Compare commits

...

21 Commits

Author SHA1 Message Date
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
shankar0123 648e2f7ab1 fix: use tagged switch statements to satisfy staticcheck QF1002
Convert `switch { case r.URL.Path == ... }` to `switch r.URL.Path { ... }`
in Vault and DigiCert connector tests to pass golangci-lint CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 17:25:11 -04:00
shankar0123 6375909591 feat: add Vault PKI and DigiCert CertCentral issuer connectors (M32 + M37)
Vault PKI: synchronous issuance via /v1/{mount}/sign/{role}, token auth,
revocation, CA cert retrieval, 14 tests. DigiCert CertCentral: async order
model (submit → poll → download), X-DC-DEVKEY auth, OV/EV support, PEM
bundle parsing, 16 tests. Both conditionally registered based on env vars.
Includes OpenAPI enum updates, seed data, connector docs, architecture docs,
README badges, and testing guide sign-off (Parts 38 + 39, 12 automated
smoke test assertions all passing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 17:19:46 -04:00
shankar0123 3e5ff4b9c3 chore: verify CI after badge workflow removal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:39:04 -04:00
shankar0123 76d0ce2a0f chore: remove Claude Code badge and auto-update workflow 2026-03-30 15:38:23 -04:00
shankar0123 207f2c6879 chore: update Claude Code badge [skip ci] 2026-03-30 19:30:54 +00:00
shankar0123 46a58d518a chore: trigger CI test run
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:30:22 -04:00
shankar0123 c5be6d059f fix: prevent badge workflow from triggering itself
Skip badge update when commit message contains [skip ci], preventing
the workflow's own commits from re-triggering the workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:28:45 -04:00
shankar0123 ec209c9736 chore: move mermaid diagram below intro paragraphs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:28:27 -04:00
shankar0123 d4f02c5f4b chore: update Claude Code badge [skip ci] 2026-03-30 19:24:56 +00:00
shankar0123 2409f2e464 chore: move badges under title, diagram below intro
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:24:12 -04:00
shankar0123 225c7141b8 chore: update Claude Code badge [skip ci] 2026-03-30 19:16:55 +00:00
shankar0123 8807a7303d chore: add Claude Code badge with auto-update CI workflow
Adds GitHub Stars badge and "Updated with Claude Code" badge to README.
New workflow auto-updates the Claude Code badge with commit SHA and
timestamp on each push to master/v2-dev.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:16:09 -04:00
shankar0123 a6515b4323 feat(Pre-2.1.0-E): GUI completeness — 5 new pages, clickable nav, verification badges
Wire all remaining backend features to the frontend GUI:

New pages:
- DigestPage: preview digest HTML via iframe + send with confirmation
- ObservabilityPage: health status, metrics gauges, Prometheus config + live output
- JobDetailPage: full job details, verification section, timeline, audit events
- IssuerDetailPage: redacted config, test connection, issued certificates list
- TargetDetailPage: config, agent link, deployment history with verification

Existing page updates:
- JobsPage: clickable job IDs, verification column with VerificationBadge
- IssuersPage: clickable issuer names linking to detail page
- TargetsPage: clickable target names linking to detail page
- Sidebar: Digest and Observability nav items
- 5 new routes in main.tsx

API client: getJob, getIssuer, getTarget, getJobVerification, getPrometheusMetrics
Tests: 7 new Vitest tests (203 total), testing-guide Part 37 (17 manual tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 14:10:58 -04:00
shankar0123 11173a74c6 feat(M31): agent work routing — scope jobs to assigned agents
Deployment jobs now set agent_id from target→agent relationship at
creation time. GetPendingWork() uses ListPendingByAgentID() with a
3-way UNION query (direct match, legacy NULL fallback via target JOIN,
AwaitingCSR via cert→target→agent chain) so each agent only receives
its own jobs.

- Added AgentID *string to Job domain struct
- Added agent_id to all job SQL queries (5 SELECTs, INSERT, UPDATE, scanJob)
- New ListPendingByAgentID() repository method
- Rewrote GetPendingWork() from ~25 lines to single scoped query
- 4 new Go tests (3 agent routing + 1 deployment agent_id)
- Frontend: agent_id/target_id on Job type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 14:10:42 -04:00
shankar0123 ec0e7a3560 feat: wire ARI (RFC 9702) into renewal scheduler
CheckExpiringCertificates() now queries each issuer's ARI endpoint
before creating renewal jobs. If the CA says "not yet" (suggested
window hasn't opened), renewal is deferred. ARI errors fall back
gracefully to threshold-based logic. Audit trail records
renewal_trigger=ari when ARI drives the decision.

4 new unit tests: ShouldRenewNow, NotYet, NilFallback, ErrorFallback.
3 new smoke tests in testing-guide.md Part 35.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 12:11:42 -04:00
shankar0123 a0b9285323 fix(gui): add missing Name field to certificate creation form
The New Certificate modal was missing the required "name" field,
causing all certificate creation attempts to fail with "name is
required". Added Name text input above ID field with client-side
validation matching the backend requirement.

Fixes #GH-issue (name is required on certificate creation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 07:53:14 -04:00
shankar0123 2655493ac8 fix(docs): correct migration guides — 17 issues found via repo audit
Fixes factual errors, broken links, wrong ports, inaccurate GUI
descriptions, and misleading config formats across all three migration
guides (certbot, acme.sh, cert-manager).

Key fixes:
- Correct server port from 8080/3000 to 8443 across all guides
- Fix HTTPS→HTTP for Docker Compose (not TLS-terminated)
- Fix heartbeat interval: 60 seconds, not 5 minutes
- Fix "50 servers" → "10 servers" (50 certs across 10 servers)
- Replace JSON config blocks with env var format (actual config method)
- Fix policy creation flow to match actual GUI (name/type/severity/config)
- Fix issuer wizard description to match actual 2-step flow
- Fix Vault PKI "coming in v2.1" → "planned" (ships post-2.1.0)
- Fix 5 broken links (cert-manager.md, quickstart anchors, architecture anchor)
- Remove claim of auto-generated suggestions in discovery flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 01:34:22 -04:00
shankar0123 a8fc177118 fix: resolve NULL csr_pem scan errors and QA smoke test failures
Root cause: certificate_versions.csr_pem is nullable in the schema but
Go code scanned it into a plain string. Used sql.NullString in
ListVersions and GetLatestVersion to handle NULL values correctly.

Also includes: partial update fetch-merge-update pattern to prevent FK
violations, nil directory guard in discovery service, diagnostic slog
logging in handlers, export handler 422 for unparseable PEM, OpenAPI
spec corrections, MCP tool description improvements, and test fixes.

Rewrites the Release Sign-Off section in testing-guide.md to individual
test-level granularity (320 rows) with smoke test results audited and
checked off (121 pass, 5 skip, 194 manual remaining).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 00:51:18 -04:00
shankar0123 20378ea7bb rename example READMEs to match their example names
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 18:35:21 -04:00
69 changed files with 6532 additions and 471 deletions
+1
View File
@@ -62,6 +62,7 @@ certctl-agent
certctl-cli
/server
/agent
/cli
# Private strategy docs
roadmap.md
+30 -26
View File
@@ -7,6 +7,15 @@
# certctl — Self-Hosted Certificate Lifecycle Platform
[![License](https://img.shields.io/badge/license-BSL%201.1-blue.svg)](LICENSE)
[![Go Report Card](https://goreportcard.com/badge/github.com/shankar0123/certctl)](https://goreportcard.com/report/github.com/shankar0123/certctl)
[![GitHub Release](https://img.shields.io/github/v/release/shankar0123/certctl)](https://github.com/shankar0123/certctl/releases)
[![GitHub Stars](https://img.shields.io/github/stars/shankar0123/certctl?style=flat&logo=github)](https://github.com/shankar0123/certctl/stargazers)
TLS certificate lifespans are shrinking fast. The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) unanimously in April 2025, setting a phased reduction: **200 days** by March 2026, **100 days** by March 2027, and **47 days** by March 2029. Organizations managing dozens or hundreds of certificates can no longer rely on spreadsheets, calendar reminders, or manual renewal workflows. The math doesn't work — at 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever.
certctl is a self-hosted platform that automates the entire certificate lifecycle — from issuance through renewal to deployment — with zero human intervention. It works with any certificate authority, deploys to any server, and keeps private keys on your infrastructure where they belong.
```mermaid
timeline
title TLS Certificate Maximum Lifespan (CA/Browser Forum Ballot SC-081v3)
@@ -18,14 +27,6 @@ timeline
March 2029 : 47 days
```
TLS certificate lifespans are shrinking fast. The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) unanimously in April 2025, setting a phased reduction: **200 days** by March 2026, **100 days** by March 2027, and **47 days** by March 2029. Organizations managing dozens or hundreds of certificates can no longer rely on spreadsheets, calendar reminders, or manual renewal workflows. The math doesn't work — at 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever.
certctl is a self-hosted platform that automates the entire certificate lifecycle — from issuance through renewal to deployment — with zero human intervention. It works with any certificate authority, deploys to any server, and keeps private keys on your infrastructure where they belong.
[![License](https://img.shields.io/badge/license-BSL%201.1-blue.svg)](LICENSE)
[![Go Report Card](https://goreportcard.com/badge/github.com/shankar0123/certctl)](https://goreportcard.com/report/github.com/shankar0123/certctl)
[![GitHub Release](https://img.shields.io/github/v/release/shankar0123/certctl)](https://github.com/shankar0123/certctl/releases)
## Documentation
| Guide | Description |
@@ -42,7 +43,7 @@ certctl is a self-hosted platform that automates the entire certificate lifecycl
| [Migrate from acme.sh](docs/migrate-from-acmesh.md) | Migration guide for acme.sh users with DNS-01 scripts |
| [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
@@ -58,8 +59,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:
- **Web dashboard** — 22 operational pages: certificate inventory, deployment timeline with TLS verification, bulk operations (renew/revoke/reassign), discovery triage, network scan management, approval workflows, audit trail with CSV/JSON export, agent fleet overview with OS/arch grouping, short-lived credential monitoring, digest email preview
- **REST API** — 99 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation, with sparse fields, sort, cursor pagination, and time-range filters
- **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** — 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)
- **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
@@ -84,8 +85,10 @@ For the full capability breakdown — revocation infrastructure, policy engine,
| ACME EAB (ZeroSSL, Google Trust) | Implemented (auto-fetch EAB from ZeroSSL) | `ACME` |
| step-ca | Implemented | `StepCA` |
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
| Vault PKI | Future | — |
| DigiCert | Future | — |
| Vault PKI | Beta | `VaultPKI` |
| DigiCert CertCentral | Beta | `DigiCert` |
**Vault PKI and DigiCert connectors are in beta.** If you hit any bugs or unexpected behavior, please [open a GitHub issue](https://github.com/shankar0123/certctl/issues) -- we're actively testing these and want to hear from real users.
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated today via the OpenSSL/Custom CA connector.
@@ -128,7 +131,7 @@ All connectors are pluggable — build your own by implementing the [connector i
<tr>
<td><a href="docs/screenshots/v2-policies.png"><img src="docs/screenshots/v2-policies.png" width="270" alt="Policies"></a><br><b>Policies</b><br><sub>Ownership, lifetime, renewal rules</sub></td>
<td><a href="docs/screenshots/v2-profiles.png"><img src="docs/screenshots/v2-profiles.png" width="270" alt="Profiles"></a><br><b>Profiles</b><br><sub>Key types, max TTL, crypto constraints</sub></td>
<td><a href="docs/screenshots/v2-issuers.png"><img src="docs/screenshots/v2-issuers.png" width="270" alt="Issuers"></a><br><b>Issuers</b><br><sub>Local CA, ACME, step-ca connectors</sub></td>
<td><a href="docs/screenshots/v2-issuers.png"><img src="docs/screenshots/v2-issuers.png" width="270" alt="Issuers"></a><br><b>Issuers</b><br><sub>Local CA, ACME, step-ca, Vault PKI, DigiCert</sub></td>
</tr>
<tr>
<td><a href="docs/screenshots/v2-targets.png"><img src="docs/screenshots/v2-targets.png" width="270" alt="Targets"></a><br><b>Targets</b><br><sub>NGINX, Apache, HAProxy, Traefik, Caddy deployment</sub></td>
@@ -142,7 +145,7 @@ All connectors are pluggable — build your own by implementing the [connector i
</tr>
</table>
> **22 operational GUI pages** covering the full certificate lifecycle: dashboard, certificates (list + detail with EKU badges, deployment timeline, TLS verification status), agents, fleet overview, jobs (with approval workflow), notifications, policies, profiles, issuers, targets (wizard with NGINX/Apache/HAProxy/Traefik/Caddy/F5/IIS), owners, teams, agent groups, audit trail, short-lived credentials, discovery triage, and network scan management.
> **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
@@ -163,7 +166,7 @@ docker compose -f deploy/docker-compose.yml up -d --build
Wait ~30 seconds, then open **http://localhost:8443** in your browser.
The dashboard comes pre-loaded with 35 demo certificates across 5 issuers, 8 agents, 90 days of job history, discovery scan data, and network scan targets — a realistic snapshot of a certificate inventory that looks like it's been running for months.
The dashboard comes pre-loaded with 32 demo certificates across 7 issuers, 8 agents, 180 days of job history, discovery scan data, and network scan targets — a realistic snapshot of a certificate inventory that looks like it's been running for months.
Verify the API:
```bash
@@ -171,7 +174,7 @@ curl http://localhost:8443/health
# {"status":"healthy"}
curl -s http://localhost:8443/api/v1/certificates | jq '.total'
# 35
# 32
```
### Agent Install (One-Liner)
@@ -371,7 +374,7 @@ make docker-clean # Stop + remove volumes
## 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
```
@@ -448,7 +451,7 @@ certctl-cli certs list --format json # JSON output (default: table)
## MCP Server (AI Integration)
certctl ships a standalone MCP (Model Context Protocol) server that exposes all 78 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client.
certctl ships a standalone MCP (Model Context Protocol) server that exposes all 80 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client.
```bash
# Install
@@ -484,7 +487,7 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
### 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 ✅):**
@@ -496,7 +499,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
- **EST Server** (RFC 7030) — device/WiFi certificate enrollment, PKCS#7 wire format, configurable issuer + profile binding
- **MCP Server** — 78 API operations as AI tools for Claude, Cursor, and any MCP-compatible client
- **CLI** — 12 subcommands (list/get/renew/revoke certs, agents, jobs, import, status), JSON/table output
- **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
- **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
@@ -509,16 +512,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
- **Helm Chart** — Production-ready Kubernetes with server Deployment, PostgreSQL StatefulSet with PVC, Agent DaemonSet, security contexts, resource limits, optional Ingress
**Coming in v2.1.0:**
- Vault PKI issuer connector (HashiCorp Vault /sign API)
- DigiCert CertCentral issuer connector (enterprise CA)
- Dynamic issuer and target configuration via GUI (no env var restarts)
**Also shipped:**
- 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)
- Migration guides (Certbot, acme.sh, cert-manager complement)
- One-line agent install script with cross-compiled binaries
**Coming in v2.1.0:**
- Dynamic issuer and target configuration via GUI (no env var restarts)
- First-run onboarding wizard
### V3: certctl Pro
Team access controls, identity provider integration, enterprise deployment targets, compliance and risk scoring, advanced fleet operations, event-driven architecture, advanced search, real-time operational views.
+28 -1
View File
@@ -250,6 +250,8 @@ paths:
$ref: "#/components/schemas/ManagedCertificate"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"500":
$ref: "#/components/responses/InternalError"
delete:
@@ -261,6 +263,8 @@ paths:
responses:
"204":
description: Certificate archived
"404":
$ref: "#/components/responses/NotFound"
"500":
$ref: "#/components/responses/InternalError"
@@ -306,6 +310,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/StatusResponse"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/Conflict"
"500":
$ref: "#/components/responses/InternalError"
@@ -820,6 +830,8 @@ paths:
$ref: "#/components/schemas/Agent"
"400":
$ref: "#/components/responses/BadRequest"
"409":
$ref: "#/components/responses/Conflict"
"500":
$ref: "#/components/responses/InternalError"
@@ -877,6 +889,8 @@ paths:
$ref: "#/components/schemas/StatusResponse"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"500":
$ref: "#/components/responses/InternalError"
@@ -2469,6 +2483,12 @@ components:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
Conflict:
description: Resource conflict
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
InternalError:
description: Internal server error
content:
@@ -2571,6 +2591,13 @@ components:
updated_at:
type: string
format: date-time
required:
- name
- common_name
- renewal_policy_id
- issuer_id
- owner_id
- team_id
CertificateVersion:
type: object
@@ -2616,7 +2643,7 @@ components:
# ─── Issuers ─────────────────────────────────────────────────────
IssuerType:
type: string
enum: [ACME, GenericCA, StepCA]
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert]
Issuer:
type: object
BIN
View File
Binary file not shown.
+45
View File
@@ -19,8 +19,10 @@ import (
"github.com/shankar0123/certctl/internal/domain"
acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme"
"github.com/shankar0123/certctl/internal/connector/issuer/local"
digicertissuer "github.com/shankar0123/certctl/internal/connector/issuer/digicert"
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
vaultissuer "github.com/shankar0123/certctl/internal/connector/issuer/vault"
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
@@ -133,6 +135,27 @@ func main() {
}, logger)
logger.Info("initialized OpenSSL/Custom CA issuer connector")
// Initialize Vault PKI issuer connector (for HashiCorp Vault internal PKI).
// Uses the Vault HTTP API with token authentication.
vaultConnector := vaultissuer.New(&vaultissuer.Config{
Addr: os.Getenv("CERTCTL_VAULT_ADDR"),
Token: os.Getenv("CERTCTL_VAULT_TOKEN"),
Mount: getEnvDefault("CERTCTL_VAULT_MOUNT", "pki"),
Role: os.Getenv("CERTCTL_VAULT_ROLE"),
TTL: getEnvDefault("CERTCTL_VAULT_TTL", "8760h"),
}, logger)
logger.Info("initialized Vault PKI issuer connector")
// Initialize DigiCert CertCentral issuer connector (for enterprise public CA).
// Uses the DigiCert REST API with async order model.
digicertConnector := digicertissuer.New(&digicertissuer.Config{
APIKey: os.Getenv("CERTCTL_DIGICERT_API_KEY"),
OrgID: os.Getenv("CERTCTL_DIGICERT_ORG_ID"),
ProductType: getEnvDefault("CERTCTL_DIGICERT_PRODUCT_TYPE", "ssl_basic"),
BaseURL: getEnvDefault("CERTCTL_DIGICERT_BASE_URL", "https://www.digicert.com/services/v2"),
}, logger)
logger.Info("initialized DigiCert CertCentral issuer connector")
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
// "iss-local" matches the seed data issuer ID for the Local CA.
// "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers.
@@ -145,6 +168,19 @@ func main() {
"iss-stepca": service.NewIssuerConnectorAdapter(stepcaConnector),
"iss-openssl": service.NewIssuerConnectorAdapter(opensslConnector),
}
// Conditionally register Vault PKI (only if CERTCTL_VAULT_ADDR is set)
if os.Getenv("CERTCTL_VAULT_ADDR") != "" {
issuerRegistry["iss-vault"] = service.NewIssuerConnectorAdapter(vaultConnector)
logger.Info("Vault PKI issuer registered", "id", "iss-vault")
}
// Conditionally register DigiCert (only if CERTCTL_DIGICERT_API_KEY is set)
if os.Getenv("CERTCTL_DIGICERT_API_KEY") != "" {
issuerRegistry["iss-digicert"] = service.NewIssuerConnectorAdapter(digicertConnector)
logger.Info("DigiCert CertCentral issuer registered", "id", "iss-digicert")
}
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
// Initialize revocation repository
@@ -226,6 +262,7 @@ func main() {
certificateService.SetCAOperationsSvc(caOperationsSvc)
certificateService.SetTargetRepo(targetRepo)
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
renewalService.SetTargetRepo(targetRepo)
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
@@ -543,6 +580,14 @@ func main() {
logger.Info("certctl server stopped")
}
// getEnvDefault reads an environment variable with a default fallback.
func getEnvDefault(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}
// getEnvIntDefault parses an integer from a string with a default fallback.
func getEnvIntDefault(s string, defaultVal int) int {
if s == "" {
+10 -6
View File
@@ -45,7 +45,7 @@ New to certificates? Read the [Concepts Guide](concepts.md) first.
### Design Principles
1. **Private Key Isolation** — Agents generate ECDSA P-256 keys locally and submit CSRs only. Private keys never touch the control plane. Server-side keygen available via `CERTCTL_KEYGEN_MODE=server` for demo only.
2. **Pull-Only Deployment** — The server never initiates outbound connections to agents or targets. Agents poll for work. For network appliances and agentless targets, a proxy agent in the same network zone executes deployments via the target's API. This keeps the control plane firewalled off and limits credential scope to the proxy agent's zone.
2. **Pull-Only Deployment** — The server never initiates outbound connections to agents or targets. Agents poll for work and receive only jobs assigned to their targets (routed via `agent_id` on jobs or through target→agent relationships). For network appliances and agentless targets, a proxy agent in the same network zone executes deployments via the target's API. This keeps the control plane firewalled off and limits credential scope to the proxy agent's zone.
3. **Sub-CA Capable** — The Local CA can operate as a subordinate CA under an enterprise root (e.g., ADCS). Load a pre-signed CA cert+key from disk and all issued certs chain to the enterprise trust hierarchy. Self-signed mode remains the default for development/demos.
4. **GUI as Primary Interface** — The web dashboard is the operational control plane, not a secondary viewer. Every backend feature ships with its corresponding GUI surface.
5. **Decoupled Operations** — Agents operate autonomously; the control plane coordinates but doesn't block agent function
@@ -80,13 +80,16 @@ flowchart TB
CA2["ACME\n(HTTP-01 + DNS-01 + DNS-PERSIST-01)\n(EAB, ZeroSSL auto-EAB)"]
CA3["step-ca\n(/sign API)"]
CA4["OpenSSL / Custom CA\n(script-based)"]
CA6["Vault PKI\n(planned)"]
CA6["Vault PKI\n(token auth, /sign API)"]
CA7["DigiCert CertCentral\n(async order model)"]
end
subgraph "Target Systems"
T1["NGINX\n(file write + reload)"]
T4["Apache httpd\n(file write + reload)"]
T5["HAProxy\n(combined PEM + reload)"]
T6["Traefik\n(file provider)"]
T7["Caddy\n(admin API / file)"]
T2["F5 BIG-IP\n(proxy agent + iControl REST, planned)"]
T3["IIS\n(agent-local PowerShell, planned)"]
end
@@ -96,7 +99,7 @@ flowchart TB
SVC --> REPO
REPO --> PG
SCHED --> SVC
SVC -->|"Issue/Renew"| CA1 & CA2 & CA3
SVC -->|"Issue/Renew"| CA1 & CA2 & CA3 & CA4 & CA6 & CA7
A1 & A2 & A3 -->|"CSR + Heartbeat"| API
API -->|"Cert + Chain\n(NO private key)"| A1 & A2 & A3
@@ -506,7 +509,8 @@ flowchart TB
II --> ACME["ACME v2"]
II --> SC["step-ca"]
II --> OC["OpenSSL / Custom CA"]
II --> VP["Vault PKI (planned)"]
II --> VP["Vault PKI"]
II --> DC["DigiCert CertCentral"]
end
subgraph "Target Connectors"
@@ -570,7 +574,7 @@ type Connector interface {
}
```
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required.
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts), **Vault PKI** (HashiCorp Vault's PKI secrets engine via /sign API with token auth), and **DigiCert** (commercial CA via CertCentral REST API with async order processing). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required.
**ACME Renewal Information (ARI, RFC 9702):** The ACME connector supports CA-directed renewal timing via the `GetRenewalInfo()` method. Instead of using fixed thresholds (e.g., renew 30 days before expiry), the CA tells certctl when to renew by providing a `suggestedWindow` with start and end times. This is useful for distributing renewal load during maintenance windows and coordinating mass-revocation scenarios. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. Cert ID is computed as `base64url(SHA-256(DER cert))` per RFC 9702. If the CA doesn't support ARI (404 from the ARI endpoint), certctl automatically falls back to threshold-based renewal — no operator intervention required. Errors from the CA are logged as warnings.
@@ -647,7 +651,7 @@ type ESTService interface {
}
```
**Issuer connector extension:** EST required adding `GetCACertPEM(ctx) (string, error)` to the issuer connector interface so the `/cacerts` endpoint can serve the CA chain. The Local CA connector returns its CA certificate PEM; ACME, step-ca, and OpenSSL connectors return errors (they don't expose a static CA chain — their chains are per-issuance).
**Issuer connector extension:** EST required adding `GetCACertPEM(ctx) (string, error)` to the issuer connector interface so the `/cacerts` endpoint can serve the CA chain. The Local CA connector returns its CA certificate PEM; ACME, step-ca, OpenSSL, Vault, and DigiCert connectors return errors (they don't expose a static CA chain — their chains are per-issuance).
**Audit:** Every EST enrollment is recorded in the audit trail with `protocol: "EST"`, the CN, SANs, issuer ID, serial number, and optional profile ID.
+14 -13
View File
@@ -27,7 +27,7 @@ Result:
Deploy certctl control plane once (Docker Compose, Kubernetes Helm chart, or self-hosted). Deploy agents on your VMs, bare metal, and network appliances. One dashboard shows:
- **All cert-manager certs** via discovery scanning (agents find cert-manager-issued certs copied to target machines, or scan the cluster directly)
- **All certctl-managed certs** issued by shared issuers (ACME, step-ca, Vault PKI (coming in v2.1), private CA)
- **All certctl-managed certs** issued by shared issuers (ACME, step-ca, Vault PKI (planned), private CA)
- **Unified renewal and deployment** across both worlds
- **Single pane of glass** with expiration timeline, renewal status, deployment verification, audit trail
@@ -39,8 +39,7 @@ Deploy certctl control plane once (Docker Compose, Kubernetes Helm chart, or sel
```bash
cd /opt/certctl
docker compose up -d
# Dashboard: http://localhost:3000
# API: http://localhost:8080
# Dashboard & API: http://localhost:8443
```
**Option B: Kubernetes** (recommended for prod)
@@ -60,7 +59,7 @@ chmod +x /usr/local/bin/certctl-agent
# Config
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
CERTCTL_SERVER_URL=https://certctl-control-plane:8080
CERTCTL_SERVER_URL=http://certctl-control-plane:8443
CERTCTL_API_KEY=your-api-key
CERTCTL_DISCOVERY_DIRS=/etc/nginx/certs,/etc/ssl,/etc/letsencrypt/live
CERTCTL_KEY_DIR=/var/lib/certctl/keys
@@ -83,18 +82,20 @@ Agents scan configured directories and report back all existing certs. In the da
Set up the same issuer certctl uses for non-Kubernetes certs:
- **ACME** (Let's Encrypt, for public certs)
- **step-ca** (Smallstep, for internal certs)
- **Vault PKI** (coming in v2.1) (HashiCorp Vault, for enterprise PKI)
- **Vault PKI** (planned) (HashiCorp Vault, for enterprise PKI)
- **Private CA** (your own internal root CA)
No new CA infrastructure needed. If cert-manager already uses your CA, certctl points to the same one.
### 5. Create Policies for Non-Kubernetes Certs
Go to **Policies****New Policy**:
- Issuer: shared (ACME, step-ca, Vault (coming in v2.1), private CA)
- Profile: serverAuth for NGINX/Apache/HAProxy, clientAuth for mTLS, emailProtection for S/MIME
- Renewal Threshold: 30 days (default, adjust per SLA)
- Scope: agent groups (VMs, bare metal, appliances)
Go to **Policies****+ New Policy** to create enforcement rules:
- **Name:** e.g., "VM Certificate Policy"
- **Type:** `expiration_window` or `key_algorithm` (enforce renewal thresholds or crypto requirements)
- **Severity:** `high`
- **Config:** set your enforcement parameters
Certificates are linked to issuers and profiles when created or claimed from discovery. Policies add guardrails — enforcing key algorithm requirements, expiration windows, and other compliance rules across your fleet.
### 6. View Unified Inventory
@@ -114,7 +115,7 @@ Go to **Policies** → **New Policy**:
If cert-manager and certctl both use the same CA:
- **ACME**: cert-manager uses ClusterIssuer + certctl uses ACME connector → same Let's Encrypt account, transparent coexistence
- **step-ca**: cert-manager uses external issuer CRD + certctl uses step-ca connector → same provisioner, shared certificate inventory
- **Vault PKI** (coming in v2.1): cert-manager uses external issuer CRD + certctl uses Vault connector → same mount, same audit trail
- **Vault PKI** (planned): cert-manager uses external issuer CRD + certctl uses Vault connector → same mount, same audit trail
No conflict. They just issue certs through the same CA. certctl's discovery scanning finds cert-manager-issued certs and shows them alongside certctl-managed ones.
@@ -138,6 +139,6 @@ For now: cert-manager handles Kubernetes, certctl handles everything else. They
## Next Steps
1. Review [Quick Start](./quickstart.md) for a 5-minute demo
2. Explore [Agents and Targets](./architecture.md#agents-and-targets) for deployment architecture
3. Read about [Discovery Scanning](./quickstart.md#discovery) to auto-find certs
2. Explore [Architecture](./architecture.md#agents) for deployment architecture
3. Read about [Discovery Scanning](./quickstart.md#certificate-discovery) to auto-find certs
4. Check [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment
+47 -4
View File
@@ -312,12 +312,55 @@ The `GetCACertPEM()` method returns the PEM-encoded CA certificate chain, used b
Note: EST (Enrollment over Secure Transport) is not a connector — it's a protocol handler (`internal/api/handler/est.go`) that delegates certificate issuance to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID`. See the [Architecture Guide](architecture.md#est-server-rfc-7030) for details.
### Coming in V2.1
### Built-in: Vault PKI
The following issuer connectors are planned for the v2.1.0 release:
The Vault PKI connector integrates with HashiCorp Vault's PKI secrets engine using its native `/sign` API with token-based authentication. This is ideal for organizations using Vault as their internal certificate authority — synchronous issuance without the complexity of ACME or challenge solving.
- **Vault PKI** — HashiCorp Vault's PKI secrets engine (`/v1/{mount}/sign/{role}` API) for organizations using Vault as their internal CA. Token auth, configurable mount and role.
- **DigiCert** — Commercial CA integration via DigiCert CertCentral REST API. Async order model (submit → poll for completion). OV/EV certificate support.
**Configuration:**
| Variable | Default | Description |
|----------|---------|-------------|
| `CERTCTL_VAULT_ADDR` | — | Vault server address (e.g., `https://vault.internal:8200`) |
| `CERTCTL_VAULT_TOKEN` | — | Vault auth token with permissions on the PKI mount |
| `CERTCTL_VAULT_MOUNT` | `pki` | PKI secrets engine mount path |
| `CERTCTL_VAULT_ROLE` | — | PKI role name for certificate signing |
| `CERTCTL_VAULT_TTL` | `8760h` | Certificate validity period (TTL) |
The connector is registered in the issuer registry under `iss-vault`. Vault issues certificates synchronously via the `/v1/{mount}/sign/{role}` API with `X-Vault-Token` header authentication. The issued certificate is parsed to extract serial number, validity dates, and chain information.
**Note:** CRL and OCSP are managed by Vault itself. Clients should validate certificate status against Vault's own CRL/OCSP endpoints (`GET /v1/{mount}/crl` and Vault's OCSP responder). certctl does not generate local CRL/OCSP for Vault-issued certificates. Revocation is recorded locally but Vault is the authoritative source.
Location: `internal/connector/issuer/vault/vault.go`
### Built-in: DigiCert CertCentral
The DigiCert connector integrates with DigiCert's CertCentral REST API for ordering and managing certificates from DigiCert's commercial CA. It supports both Domain Validated (DV) and Organization/Extended Validated (OV/EV) certificates, with async order processing.
**Configuration:**
| Variable | Default | Description |
|----------|---------|-------------|
| `CERTCTL_DIGICERT_API_KEY` | — | DigiCert API key (X-DC-DEVKEY header) |
| `CERTCTL_DIGICERT_ORG_ID` | — | DigiCert organization ID |
| `CERTCTL_DIGICERT_PRODUCT_TYPE` | `ssl_basic` | Certificate product (e.g., `ssl_basic`, `ssl_plus`, `ssl_ev`) |
| `CERTCTL_DIGICERT_BASE_URL` | `https://www.digicert.com/services/v2` | DigiCert API base URL |
The connector submits certificate orders to DigiCert's `/order/certificate/create` API. DV certificates may issue immediately; OV/EV certificates require validation (handled by DigiCert) and poll-based completion. The connector periodically checks order status via `/order/certificate/{order_id}` until the certificate is available.
**Authentication:** API key passed via `X-DC-DEVKEY` header, with organization ID in request body.
**Note:** CRL and OCSP are managed by DigiCert. Clients should validate certificate status against DigiCert's infrastructure. certctl records the revocation locally but does not notify DigiCert for revocation — use DigiCert's dashboard for revocation management.
Location: `internal/connector/issuer/digicert/digicert.go`
### Coming in V2.2+
The following issuer connectors are planned for future releases:
- **Entrust** — Enterprise CA via Entrust API
- **Sectigo** — Commercial CA integration via Sectigo REST API
- **Google CAS** — Google Cloud Certificate Authority Service
- **AWS ACM Private CA** — AWS-managed private CA
Note: ADCS (Active Directory Certificate Services) integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above.
+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) |
| **Certificate health scores** | ✗ | ✓ | Planned V3 |
| **Compliance scoring** | ✗ | ✓ | Planned V3 |
| **DigiCert issuer** | ✗ | ✓ | Planned V2.1 (free) |
| **Vault PKI issuer** | ✗ | ✓ | Planned V2.1 (free) |
| **DigiCert issuer** | ✗ | ✓ | Implemented (Beta) |
| **Vault PKI issuer** | ✗ | ✓ | Implemented (Beta) |
---
+30 -24
View File
@@ -99,18 +99,23 @@ Environment="CERTCTL_DISCOVERY_DIRS=/etc/acme.sh"
In the **Discovery** page:
1. Review the "Unmanaged" certificates found by the agent
2. Click **Claim** on each acme.sh certificate
3. Map to the certificate ID (certctl auto-generates suggestions)
3. Enter the managed certificate ID to link it (e.g., `mc-api-prod`)
Once claimed, the certificate appears in the main **Certificates** page with ownership, renewal history, and deployment status.
### 5. Create an ACME Issuer
In **Issuers****Configure New Issuer:**
In **Issuers****+ New Issuer:**
- **Type:** ACME v2
- **Directory URL:** `https://acme-v02.api.letsencrypt.org/directory` (production) or staging for testing
- **Email:** Same email as your acme.sh account (required for ACME ToS)
- **Challenge Type:** DNS-01 (to match acme.sh's DNS validation)
1. Select **ACME** from the issuer type grid
2. Fill in the type-specific fields: name, directory URL (`https://acme-v02.api.letsencrypt.org/directory`), and config
Or configure via environment variables:
```bash
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
export CERTCTL_ACME_EMAIL=your-email@example.com # same as your acme.sh account
export CERTCTL_ACME_CHALLENGE_TYPE=dns-01
```
### 6. Adapt Your DNS Provider Scripts
@@ -182,26 +187,28 @@ curl -X DELETE "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_record
-H "X-Auth-Key: ${CF_KEY}"
```
Configure in the ACME issuer:
Configure the ACME issuer via environment variables:
```json
{
"challenge_type": "dns-01",
"dns_present_script": "/etc/certctl/dns/cloudflare-present.sh",
"dns_cleanup_script": "/etc/certctl/dns/cloudflare-cleanup.sh",
"dns_propagation_wait": 30
}
```bash
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
export CERTCTL_ACME_EMAIL=your-email@example.com
export CERTCTL_ACME_CHALLENGE_TYPE=dns-01
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/cloudflare-present.sh
export CERTCTL_ACME_DNS_CLEANUP_SCRIPT=/etc/certctl/dns/cloudflare-cleanup.sh
```
Or create the issuer through the dashboard: **Issuers****+ New Issuer** → select **ACME** → fill in the config fields.
### 7. Create Renewal Policies
In **Policies:**
In **Policies****+ New Policy:**
- **Certificate Profile:** Select the issuer and challenge type from step 5
- **Renewal Threshold:** 30 days before expiry (or match your acme.sh cron settings)
- **Agent Group:** Select which agents should renew certificates
- **Name:** e.g., "ACME DNS-01 Policy"
- **Type:** `expiration_window` (enforces renewal thresholds)
- **Severity:** `high`
- **Config:** set your renewal window (default: 30 days before expiry)
Set one policy per domain or domain pattern.
Renewal scheduling is driven by the certificate's assigned profile and issuer. Policies add enforcement guardrails on top.
### 8. Phase Out acme.sh Cron
@@ -252,11 +259,10 @@ Benefits:
To enable:
```json
{
"challenge_type": "dns-persist-01",
"dns_persist_issuer_domain": "acme-v02.api.letsencrypt.org"
}
```bash
export CERTCTL_ACME_CHALLENGE_TYPE=dns-persist-01
export CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN=letsencrypt.org
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/cloudflare-present.sh
```
certctl automatically falls back to DNS-01 if the CA doesn't support dns-persist-01 yet.
+29 -19
View File
@@ -22,7 +22,7 @@ Option A: Docker Compose (quickest for evaluation)
```bash
cd /opt/certctl
docker compose up -d
# Dashboard & API: https://localhost:8443
# Dashboard & API: http://localhost:8443
# Default API key in logs (grep CERTCTL_API_KEY docker logs certctl-server)
```
@@ -45,7 +45,7 @@ chmod +x /usr/local/bin/certctl-agent
# Create config
sudo mkdir -p /etc/certctl /var/lib/certctl/keys
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
CERTCTL_SERVER_URL=https://certctl-control-plane.example.com:8080
CERTCTL_SERVER_URL=http://certctl-control-plane.example.com:8443
CERTCTL_API_KEY=your-api-key-here
CERTCTL_DISCOVERY_DIRS=/etc/letsencrypt/live
CERTCTL_KEY_DIR=/var/lib/certctl/keys
@@ -71,24 +71,34 @@ The control plane now knows about all 50 certs and where they live.
### 4. Configure ACME Issuer
Go to **Issuers****Add Issuer**:
- Type: ACME
- Directory URL: `https://acme-v02.api.letsencrypt.org/directory` (production)
- Email: your Let's Encrypt account email
- Challenge Type: `http-01` (if you have HTTP access) or `dns-01` (for wildcard/internal certs)
- For DNS-01, provide your DNS provider's script hook (Cloudflare, Route53, Azure DNS, etc.)
Go to **Issuers****+ New Issuer**:
1. Select **ACME** from the issuer type grid
2. Fill in the type-specific fields: name, directory URL (`https://acme-v02.api.letsencrypt.org/directory`), and any required config
Test the connection. certctl uses the same Let's Encrypt account; no new credentials needed.
Alternatively, configure via environment variables before starting the server:
```bash
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
export CERTCTL_ACME_EMAIL=your-email@example.com
export CERTCTL_ACME_CHALLENGE_TYPE=http-01 # or dns-01 for wildcard certs
```
For DNS-01, also set:
```bash
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/present.sh
export CERTCTL_ACME_DNS_CLEANUP_SCRIPT=/etc/certctl/dns/cleanup.sh
```
certctl uses the same Let's Encrypt account; no new credentials needed.
### 5. Create Renewal Policies
Go to **Policies****New Policy**:
- Profile: ACME (or create a new one with `serverAuth` EKU)
- Issuer: the ACME issuer you just created
- Renewal Threshold: 30 days before expiry (default, adjust as needed)
- Scope: select agent groups or individual agents managing your servers
Go to **Policies****+ New Policy** to create enforcement rules:
- Name: e.g., "ACME Renewal Policy"
- Type: `expiration_window` (to enforce renewal thresholds)
- Severity: `high`
- Config: set your renewal threshold (default: 30 days before expiry)
Assign this policy to your discovered certs.
Renewal scheduling is driven by the certificate's assigned profile and issuer. Policies add enforcement guardrails (key algorithm requirements, expiration windows, etc.).
### 6. Disable Certbot Cron, One Server at a Time
@@ -133,11 +143,11 @@ docker compose up -d
# Other options: CERTCTL_TEAMS_WEBHOOK_URL, CERTCTL_PAGERDUTY_ROUTING_KEY, CERTCTL_OPSGENIE_API_KEY
```
Now you get 30/14/7-day warnings before any cert expires, across all 50 servers, in one place.
Now you get 30/14/7-day warnings before any cert expires, across all 10 servers, in one place.
## What Changes
- **Renewal**: Agent polls certctl for work instead of Certbot cron triggering locally. Faster failure detection (agent heartbeat every 5 minutes vs. cron running once a day).
- **Renewal**: Agent polls certctl for work instead of Certbot cron triggering locally. Faster failure detection (agent heartbeat every 60 seconds vs. cron running once a day).
- **Deployment**: certctl verifies post-deployment by probing the live TLS endpoint and comparing SHA-256 fingerprints. Catches reload failures silently.
- **Audit Trail**: Every renewal, deployment, and alert is logged immutably. Answer "who renewed cert X when and why" within seconds.
- **Alerting**: Threshold-based alerts to Slack/email/webhook 30/14/7 days before expiry, not when cert expires.
@@ -157,5 +167,5 @@ certctl will stop renewing that cert when the policy is disabled. Certbot resume
## Next Steps
- Review the [Concepts Guide](./concepts.md) for terminology (profiles, policies, agents, jobs)
- Explore [Network Discovery](./quickstart.md#network-discovery) to find certificates you didn't know about
- Set up [Kubernetes cert-manager integration](./cert-manager.md) if you manage in-cluster certs too
- Explore [Network Discovery](./quickstart.md#network-discovery-agentless) to find certificates you didn't know about
- Set up [Kubernetes cert-manager integration](./certctl-for-cert-manager-users.md) if you manage in-cluster certs too
+1119 -37
View File
File diff suppressed because it is too large Load Diff
+187
View File
@@ -0,0 +1,187 @@
-- =============================================================================
-- Comprehensive Referential Integrity Check for seed_demo.sql
-- Run AFTER migrations and seed data are loaded
-- =============================================================================
-- 1. Verify certificate_versions.certificate_id references valid managed_certificates.id
SELECT 'FK VIOLATION: certificate_versions.certificate_id' AS issue, cv.id, cv.certificate_id
FROM certificate_versions cv
WHERE cv.certificate_id NOT IN (SELECT id FROM managed_certificates)
ORDER BY cv.id;
-- 2. Verify certificate_target_mappings references valid IDs
SELECT 'FK VIOLATION: certificate_target_mappings.certificate_id' AS issue, ctm.certificate_id
FROM certificate_target_mappings ctm
WHERE ctm.certificate_id NOT IN (SELECT id FROM managed_certificates)
ORDER BY ctm.certificate_id;
SELECT 'FK VIOLATION: certificate_target_mappings.target_id' AS issue, ctm.target_id
FROM certificate_target_mappings ctm
WHERE ctm.target_id NOT IN (SELECT id FROM deployment_targets)
ORDER BY ctm.target_id;
-- 3. Verify jobs references valid IDs
SELECT 'FK VIOLATION: jobs.certificate_id' AS issue, j.id, j.certificate_id
FROM jobs j
WHERE j.certificate_id NOT IN (SELECT id FROM managed_certificates)
ORDER BY j.id;
SELECT 'FK VIOLATION: jobs.target_id' AS issue, j.id, j.target_id
FROM jobs j
WHERE j.target_id IS NOT NULL AND j.target_id NOT IN (SELECT id FROM deployment_targets)
ORDER BY j.id;
SELECT 'FK VIOLATION: jobs.agent_id' AS issue, j.id, j.agent_id
FROM jobs j
WHERE j.agent_id NOT IN (SELECT id FROM agents)
ORDER BY j.id;
-- 4. Verify discovered_certificates references valid IDs
SELECT 'FK VIOLATION: discovered_certificates.agent_id' AS issue, dc.id, dc.agent_id
FROM discovered_certificates dc
WHERE dc.agent_id NOT IN (SELECT id FROM agents)
ORDER BY dc.id;
SELECT 'FK VIOLATION: discovered_certificates.discovery_scan_id' AS issue, dc.id, dc.discovery_scan_id
FROM discovered_certificates dc
WHERE dc.discovery_scan_id IS NOT NULL AND dc.discovery_scan_id NOT IN (SELECT id FROM discovery_scans)
ORDER BY dc.id;
-- 5. Verify notification_events references valid certificate_id
SELECT 'FK VIOLATION: notification_events.certificate_id' AS issue, ne.id, ne.certificate_id
FROM notification_events ne
WHERE ne.certificate_id IS NOT NULL AND ne.certificate_id NOT IN (SELECT id FROM managed_certificates)
ORDER BY ne.id;
-- 6. Verify policy_violations references valid certificate_id
SELECT 'FK VIOLATION: policy_violations.certificate_id' AS issue, pv.id, pv.certificate_id
FROM policy_violations pv
WHERE pv.certificate_id NOT IN (SELECT id FROM managed_certificates)
ORDER BY pv.id;
-- 7. Verify certificate_revocations references valid IDs
SELECT 'FK VIOLATION: certificate_revocations.certificate_id' AS issue, cr.id, cr.certificate_id
FROM certificate_revocations cr
WHERE cr.certificate_id NOT IN (SELECT id FROM managed_certificates)
ORDER BY cr.id;
SELECT 'FK VIOLATION: certificate_revocations.issuer_id' AS issue, cr.id, cr.issuer_id
FROM certificate_revocations cr
WHERE cr.issuer_id NOT IN (SELECT id FROM issuers)
ORDER BY cr.id;
-- 8. Verify agent_group_members references valid IDs
SELECT 'FK VIOLATION: agent_group_members.agent_group_id' AS issue, agm.agent_group_id
FROM agent_group_members agm
WHERE agm.agent_group_id NOT IN (SELECT id FROM agent_groups)
ORDER BY agm.agent_group_id;
SELECT 'FK VIOLATION: agent_group_members.agent_id' AS issue, agm.agent_id
FROM agent_group_members agm
WHERE agm.agent_id NOT IN (SELECT id FROM agents)
ORDER BY agm.agent_id;
-- 9. Verify owners.team_id references valid teams.id
SELECT 'FK VIOLATION: owners.team_id' AS issue, o.id, o.team_id
FROM owners o
WHERE o.team_id IS NOT NULL AND o.team_id NOT IN (SELECT id FROM teams)
ORDER BY o.id;
-- 10. Verify deployment_targets.agent_id references valid agents.id
SELECT 'FK VIOLATION: deployment_targets.agent_id' AS issue, dt.id, dt.agent_id
FROM deployment_targets dt
WHERE dt.agent_id NOT IN (SELECT id FROM agents)
ORDER BY dt.id;
-- 11. Verify managed_certificates FK columns
SELECT 'FK VIOLATION: managed_certificates.owner_id' AS issue, mc.id, mc.owner_id
FROM managed_certificates mc
WHERE mc.owner_id IS NOT NULL AND mc.owner_id NOT IN (SELECT id FROM owners)
ORDER BY mc.id;
SELECT 'FK VIOLATION: managed_certificates.team_id' AS issue, mc.id, mc.team_id
FROM managed_certificates mc
WHERE mc.team_id IS NOT NULL AND mc.team_id NOT IN (SELECT id FROM teams)
ORDER BY mc.id;
SELECT 'FK VIOLATION: managed_certificates.issuer_id' AS issue, mc.id, mc.issuer_id
FROM managed_certificates mc
WHERE mc.issuer_id NOT IN (SELECT id FROM issuers)
ORDER BY mc.id;
SELECT 'FK VIOLATION: managed_certificates.renewal_policy_id' AS issue, mc.id, mc.renewal_policy_id
FROM managed_certificates mc
WHERE mc.renewal_policy_id IS NOT NULL AND mc.renewal_policy_id NOT IN (SELECT id FROM renewal_policies)
ORDER BY mc.id;
-- 12. Check for duplicate primary keys
SELECT 'DUPLICATE PK: teams' AS issue, id, COUNT(*) as count
FROM teams GROUP BY id HAVING COUNT(*) > 1;
SELECT 'DUPLICATE PK: owners' AS issue, id, COUNT(*) as count
FROM owners GROUP BY id HAVING COUNT(*) > 1;
SELECT 'DUPLICATE PK: agents' AS issue, id, COUNT(*) as count
FROM agents GROUP BY id HAVING COUNT(*) > 1;
SELECT 'DUPLICATE PK: deployment_targets' AS issue, id, COUNT(*) as count
FROM deployment_targets GROUP BY id HAVING COUNT(*) > 1;
SELECT 'DUPLICATE PK: managed_certificates' AS issue, id, COUNT(*) as count
FROM managed_certificates GROUP BY id HAVING COUNT(*) > 1;
SELECT 'DUPLICATE PK: certificate_versions' AS issue, id, COUNT(*) as count
FROM certificate_versions GROUP BY id HAVING COUNT(*) > 1;
SELECT 'DUPLICATE PK: issuers' AS issue, id, COUNT(*) as count
FROM issuers GROUP BY id HAVING COUNT(*) > 1;
SELECT 'DUPLICATE PK: renewal_policies' AS issue, id, COUNT(*) as count
FROM renewal_policies GROUP BY id HAVING COUNT(*) > 1;
SELECT 'DUPLICATE PK: jobs' AS issue, id, COUNT(*) as count
FROM jobs GROUP BY id HAVING COUNT(*) > 1;
SELECT 'DUPLICATE PK: certificate_profiles' AS issue, id, COUNT(*) as count
FROM certificate_profiles GROUP BY id HAVING COUNT(*) > 1;
SELECT 'DUPLICATE PK: certificate_revocations' AS issue, id, COUNT(*) as count
FROM certificate_revocations GROUP BY id HAVING COUNT(*) > 1;
-- 13. Check fingerprint_sha256 uniqueness in certificate_versions
SELECT 'DUPLICATE FINGERPRINT: certificate_versions' AS issue, fingerprint_sha256, COUNT(*) as count
FROM certificate_versions
WHERE fingerprint_sha256 IS NOT NULL
GROUP BY fingerprint_sha256
HAVING COUNT(*) > 1;
-- 14. Check serial number uniqueness in certificate_versions
SELECT 'DUPLICATE SERIAL: certificate_versions' AS issue, serial_number, COUNT(*) as count
FROM certificate_versions
WHERE serial_number IS NOT NULL
GROUP BY serial_number
HAVING COUNT(*) > 1;
-- 15. Verify discovery_scan_id references are valid
SELECT 'FK VIOLATION: discovered_certificates.discovery_scan_id references' AS issue,
dc.id, dc.discovery_scan_id, ds.id
FROM discovered_certificates dc
LEFT JOIN discovery_scans ds ON dc.discovery_scan_id = ds.id
WHERE dc.discovery_scan_id IS NOT NULL AND ds.id IS NULL;
-- Summary: Count total records
SELECT 'SUMMARY: teams' AS table_name, COUNT(*) as count FROM teams UNION ALL
SELECT 'SUMMARY: owners', COUNT(*) FROM owners UNION ALL
SELECT 'SUMMARY: agents', COUNT(*) FROM agents UNION ALL
SELECT 'SUMMARY: deployment_targets', COUNT(*) FROM deployment_targets UNION ALL
SELECT 'SUMMARY: managed_certificates', COUNT(*) FROM managed_certificates UNION ALL
SELECT 'SUMMARY: certificate_versions', COUNT(*) FROM certificate_versions UNION ALL
SELECT 'SUMMARY: certificate_target_mappings', COUNT(*) FROM certificate_target_mappings UNION ALL
SELECT 'SUMMARY: issuers', COUNT(*) FROM issuers UNION ALL
SELECT 'SUMMARY: renewal_policies', COUNT(*) FROM renewal_policies UNION ALL
SELECT 'SUMMARY: jobs', COUNT(*) FROM jobs UNION ALL
SELECT 'SUMMARY: certificate_profiles', COUNT(*) FROM certificate_profiles UNION ALL
SELECT 'SUMMARY: certificate_revocations', COUNT(*) FROM certificate_revocations UNION ALL
SELECT 'SUMMARY: audit_events', COUNT(*) FROM audit_events UNION ALL
SELECT 'SUMMARY: discovery_scans', COUNT(*) FROM discovery_scans UNION ALL
SELECT 'SUMMARY: discovered_certificates', COUNT(*) FROM discovered_certificates;
+11
View File
@@ -3,6 +3,7 @@ package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strconv"
"strings"
@@ -134,6 +135,11 @@ func (h AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
created, err := h.svc.RegisterAgent(r.Context(), agent)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate") || strings.Contains(errMsg, "already exists") {
ErrorWithRequestID(w, http.StatusConflict, "Agent with this name already exists", requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
return
}
@@ -184,6 +190,11 @@ func (h AgentHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
}
if err := h.svc.Heartbeat(r.Context(), agentID, metadata); err != nil {
if strings.Contains(err.Error(), "not found") {
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
return
}
slog.Error("Heartbeat failed", "agent_id", agentID, "error", err.Error())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to record heartbeat", requestID)
return
}
@@ -353,11 +353,12 @@ func TestCreateCertificate_Success(t *testing.T) {
handler := NewCertificateHandler(mock)
certBody := domain.ManagedCertificate{
Name: "Production Cert",
CommonName: "example.com",
OwnerID: "o-alice",
TeamID: "t-platform",
IssuerID: "iss-local",
Name: "Production Cert",
CommonName: "example.com",
OwnerID: "o-alice",
TeamID: "t-platform",
IssuerID: "iss-local",
RenewalPolicyID: "rp-standard",
}
body, _ := json.Marshal(certBody)
@@ -410,11 +411,12 @@ func TestCreateCertificate_ServiceError(t *testing.T) {
handler := NewCertificateHandler(mock)
certBody := domain.ManagedCertificate{
Name: "Production Cert",
CommonName: "example.com",
OwnerID: "o-alice",
TeamID: "t-platform",
IssuerID: "iss-local",
Name: "Production Cert",
CommonName: "example.com",
OwnerID: "o-alice",
TeamID: "t-platform",
IssuerID: "iss-local",
RenewalPolicyID: "rp-standard",
}
body, _ := json.Marshal(certBody)
@@ -534,8 +536,8 @@ func TestArchiveCertificate_NotFound(t *testing.T) {
handler.ArchiveCertificate(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
if w.Code != http.StatusNotFound {
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
}
}
+39 -2
View File
@@ -2,6 +2,7 @@ package handler
import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"strings"
@@ -231,9 +232,18 @@ func (h CertificateHandler) CreateCertificate(w http.ResponseWriter, r *http.Req
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
if err := ValidateRequired("name", cert.Name); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
if err := ValidateRequired("renewal_policy_id", cert.RenewalPolicyID); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
created, err := h.svc.CreateCertificate(cert)
if err != nil {
slog.Error("failed to create certificate", "error", err, "request_id", requestID, "common_name", cert.CommonName, "name", cert.Name)
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create certificate", requestID)
return
}
@@ -287,6 +297,11 @@ func (h CertificateHandler) UpdateCertificate(w http.ResponseWriter, r *http.Req
updated, err := h.svc.UpdateCertificate(id, cert)
if err != nil {
if strings.Contains(err.Error(), "not found") {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
slog.Error("UpdateCertificate failed", "cert_id", id, "error", err.Error())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update certificate", requestID)
return
}
@@ -311,6 +326,10 @@ func (h CertificateHandler) ArchiveCertificate(w http.ResponseWriter, r *http.Re
}
if err := h.svc.ArchiveCertificate(id); err != nil {
if strings.Contains(err.Error(), "not found") {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to archive certificate", requestID)
return
}
@@ -353,7 +372,12 @@ func (h CertificateHandler) GetCertificateVersions(w http.ResponseWriter, r *htt
versions, total, err := h.svc.GetCertificateVersions(certID, page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
if strings.Contains(err.Error(), "not found") {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
slog.Error("GetCertificateVersions failed", "cert_id", certID, "error", err.Error())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get certificate versions", requestID)
return
}
@@ -387,6 +411,19 @@ func (h CertificateHandler) TriggerRenewal(w http.ResponseWriter, r *http.Reques
certID := parts[0]
if err := h.svc.TriggerRenewal(certID); err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "not found") {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
if strings.Contains(errMsg, "cannot renew") {
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
return
}
if strings.Contains(errMsg, "already in progress") {
ErrorWithRequestID(w, http.StatusConflict, errMsg, requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to trigger renewal", requestID)
return
}
@@ -480,7 +517,7 @@ func (h CertificateHandler) RevokeCertificate(w http.ResponseWriter, r *http.Req
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
return
}
if strings.Contains(errMsg, "not found") || strings.Contains(errMsg, "failed to fetch") {
if strings.Contains(errMsg, "not found") || strings.Contains(errMsg, "failed to fetch") || strings.Contains(errMsg, "failed to get") {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
+7
View File
@@ -3,6 +3,7 @@ package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
@@ -49,6 +50,7 @@ func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
slog.Error("ExportPEM failed", "cert_id", id, "error", err.Error())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export certificate", requestID)
return
}
@@ -96,6 +98,11 @@ func (h ExportHandler) ExportPKCS12(w http.ResponseWriter, r *http.Request) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
if strings.Contains(err.Error(), "cannot be parsed") || strings.Contains(err.Error(), "no certificates found") {
ErrorWithRequestID(w, http.StatusUnprocessableEntity, "Certificate data cannot be parsed as X.509", requestID)
return
}
slog.Error("ExportPKCS12 failed", "cert_id", id, "error", err.Error())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export PKCS#12", requestID)
return
}
+66
View File
@@ -25,6 +25,8 @@ type Config struct {
EST ESTConfig
Verification VerificationConfig
ACME ACMEConfig
Vault VaultConfig
DigiCert DigiCertConfig
Digest DigestConfig
}
@@ -141,6 +143,57 @@ type StepCAConfig struct {
ProvisionerPassword string
}
// VaultConfig contains HashiCorp Vault PKI issuer connector configuration.
type VaultConfig struct {
// Addr is the Vault server address (e.g., "https://vault.example.com:8200").
// Required for Vault PKI integration.
// Setting: CERTCTL_VAULT_ADDR environment variable.
Addr string
// Token is the Vault token for authentication.
// Required for Vault PKI integration.
// Setting: CERTCTL_VAULT_TOKEN environment variable.
Token string
// Mount is the PKI secrets engine mount path.
// Default: "pki".
// Setting: CERTCTL_VAULT_MOUNT environment variable.
Mount string
// Role is the PKI role name used for signing certificates.
// Required for Vault PKI integration.
// Setting: CERTCTL_VAULT_ROLE environment variable.
Role string
// TTL is the requested certificate time-to-live.
// Default: "8760h" (1 year).
// Setting: CERTCTL_VAULT_TTL environment variable.
TTL string
}
// DigiCertConfig contains DigiCert CertCentral issuer connector configuration.
type DigiCertConfig struct {
// APIKey is the CertCentral API key for authentication.
// Required for DigiCert integration.
// Setting: CERTCTL_DIGICERT_API_KEY environment variable.
APIKey string
// OrgID is the DigiCert organization ID for certificate orders.
// Required for DigiCert integration.
// Setting: CERTCTL_DIGICERT_ORG_ID environment variable.
OrgID string
// ProductType is the DigiCert product type for certificate orders.
// Default: "ssl_basic". Common values: "ssl_basic", "ssl_wildcard", "ssl_ev_basic".
// Setting: CERTCTL_DIGICERT_PRODUCT_TYPE environment variable.
ProductType string
// BaseURL is the DigiCert CertCentral API base URL.
// Default: "https://www.digicert.com/services/v2".
// Setting: CERTCTL_DIGICERT_BASE_URL environment variable.
BaseURL string
}
// DigestConfig controls the scheduled certificate digest email feature.
type DigestConfig struct {
// Enabled controls whether periodic digest emails are generated and sent.
@@ -429,6 +482,19 @@ func Load() (*Config, error) {
Timeout: getEnvDuration("CERTCTL_VERIFY_TIMEOUT", 10*time.Second),
Delay: getEnvDuration("CERTCTL_VERIFY_DELAY", 2*time.Second),
},
Vault: VaultConfig{
Addr: getEnv("CERTCTL_VAULT_ADDR", ""),
Token: getEnv("CERTCTL_VAULT_TOKEN", ""),
Mount: getEnv("CERTCTL_VAULT_MOUNT", "pki"),
Role: getEnv("CERTCTL_VAULT_ROLE", ""),
TTL: getEnv("CERTCTL_VAULT_TTL", "8760h"),
},
DigiCert: DigiCertConfig{
APIKey: getEnv("CERTCTL_DIGICERT_API_KEY", ""),
OrgID: getEnv("CERTCTL_DIGICERT_ORG_ID", ""),
ProductType: getEnv("CERTCTL_DIGICERT_PRODUCT_TYPE", "ssl_basic"),
BaseURL: getEnv("CERTCTL_DIGICERT_BASE_URL", "https://www.digicert.com/services/v2"),
},
ACME: ACMEConfig{
DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""),
Email: getEnv("CERTCTL_ACME_EMAIL", ""),
@@ -0,0 +1,524 @@
// Package digicert implements the issuer.Connector interface for DigiCert CertCentral.
//
// DigiCert CertCentral is an enterprise certificate authority offering DV, OV, and EV
// certificates. Unlike synchronous issuers (Vault, step-ca), DigiCert uses an
// asynchronous order model: submit an order, receive an order ID, then poll for
// completion. OV/EV certificates require organization validation which may take hours
// or days; DV certificates may be issued immediately.
//
// This connector maps to certctl's existing job state machine:
// - IssueCertificate submits the order; if status is "issued", returns cert immediately.
// If status is "pending", returns OrderID with empty CertPEM — the job system polls
// via GetOrderStatus.
// - GetOrderStatus polls the order; when status becomes "issued", downloads and
// parses the PEM bundle.
//
// Authentication: API key via X-DC-DEVKEY header.
//
// DigiCert CertCentral API used:
//
// POST /order/certificate/{product_type} - Submit certificate order
// GET /order/certificate/{order_id} - Check order status
// GET /certificate/{certificate_id}/download/format/pem_all - Download cert bundle
// PUT /certificate/{certificate_id}/revoke - Revoke certificate
// GET /user/me - Validate API credentials
package digicert
import (
"bytes"
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
// Config represents the DigiCert CertCentral issuer connector configuration.
type Config struct {
// APIKey is the CertCentral API key for authentication.
// Required. Set via CERTCTL_DIGICERT_API_KEY environment variable.
APIKey string `json:"api_key"`
// OrgID is the DigiCert organization ID for certificate orders.
// Required. Set via CERTCTL_DIGICERT_ORG_ID environment variable.
OrgID string `json:"org_id"`
// ProductType is the DigiCert product type for certificate orders.
// Default: "ssl_basic". Set via CERTCTL_DIGICERT_PRODUCT_TYPE environment variable.
// Common values: "ssl_basic", "ssl_wildcard", "ssl_ev_basic", "ssl_plus", "ssl_multi_domain".
ProductType string `json:"product_type"`
// BaseURL is the DigiCert CertCentral API base URL.
// Default: "https://www.digicert.com/services/v2".
// Set via CERTCTL_DIGICERT_BASE_URL environment variable.
BaseURL string `json:"base_url"`
}
// Connector implements the issuer.Connector interface for DigiCert CertCentral.
type Connector struct {
config *Config
logger *slog.Logger
httpClient *http.Client
}
// New creates a new DigiCert CertCentral connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
if config != nil {
if config.ProductType == "" {
config.ProductType = "ssl_basic"
}
if config.BaseURL == "" {
config.BaseURL = "https://www.digicert.com/services/v2"
}
}
return &Connector{
config: config,
logger: logger,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// orderRequest is the JSON body for DigiCert certificate order submission.
type orderRequest struct {
Certificate orderCert `json:"certificate"`
Organization orderOrg `json:"organization"`
ValidityYears int `json:"validity_years"`
}
type orderCert struct {
CommonName string `json:"common_name"`
CSR string `json:"csr"`
DNSNames []string `json:"dns_names,omitempty"`
}
type orderOrg struct {
ID json.Number `json:"id"`
}
// orderResponse is the JSON response from a certificate order submission.
type orderResponse struct {
ID int `json:"id"`
Status string `json:"status"`
CertificateID int `json:"certificate_id,omitempty"`
}
// orderStatusResponse is the JSON response from an order status check.
type orderStatusResponse struct {
ID int `json:"id"`
Status string `json:"status"`
Certificate struct {
ID int `json:"id"`
CommonName string `json:"common_name"`
} `json:"certificate"`
}
// ValidateConfig checks that the DigiCert configuration is valid and API access works.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid DigiCert config: %w", err)
}
if cfg.APIKey == "" {
return fmt.Errorf("DigiCert api_key is required")
}
if cfg.OrgID == "" {
return fmt.Errorf("DigiCert org_id is required")
}
if cfg.ProductType == "" {
cfg.ProductType = "ssl_basic"
}
if cfg.BaseURL == "" {
cfg.BaseURL = "https://www.digicert.com/services/v2"
}
// Test API access via /user/me
meURL := cfg.BaseURL + "/user/me"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil)
if err != nil {
return fmt.Errorf("failed to create API test request: %w", err)
}
req.Header.Set("X-DC-DEVKEY", cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("DigiCert API not reachable at %s: %w", cfg.BaseURL, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("DigiCert API key is invalid (status %d)", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("DigiCert API returned status %d", resp.StatusCode)
}
c.config = &cfg
c.logger.Info("DigiCert CertCentral configuration validated",
"base_url", cfg.BaseURL,
"product_type", cfg.ProductType)
return nil
}
// IssueCertificate submits a certificate order to DigiCert CertCentral.
// If the certificate is issued immediately (DV certs), returns the cert.
// If pending (OV/EV certs), returns OrderID with empty CertPEM for polling.
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing DigiCert issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs),
"product_type", c.config.ProductType)
orderReq := orderRequest{
Certificate: orderCert{
CommonName: request.CommonName,
CSR: request.CSRPEM,
DNSNames: request.SANs,
},
Organization: orderOrg{
ID: json.Number(c.config.OrgID),
},
ValidityYears: 1,
}
body, err := json.Marshal(orderReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal order request: %w", err)
}
orderURL := fmt.Sprintf("%s/order/certificate/%s", c.config.BaseURL, c.config.ProductType)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, orderURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create order request: %w", err)
}
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("DigiCert order request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read order response: %w", err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("DigiCert order returned status %d: %s", resp.StatusCode, string(respBody))
}
var orderResp orderResponse
if err := json.Unmarshal(respBody, &orderResp); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
orderID := fmt.Sprintf("%d", orderResp.ID)
c.logger.Info("DigiCert order submitted",
"order_id", orderID,
"status", orderResp.Status)
// If issued immediately (DV certs), download the certificate
if orderResp.Status == "issued" && orderResp.CertificateID > 0 {
certPEM, chainPEM, serial, notBefore, notAfter, err := c.downloadCertificate(ctx, orderResp.CertificateID)
if err != nil {
return nil, fmt.Errorf("failed to download certificate: %w", err)
}
c.logger.Info("DigiCert certificate issued immediately",
"order_id", orderID,
"serial", serial)
return &issuer.IssuanceResult{
CertPEM: certPEM,
ChainPEM: chainPEM,
Serial: serial,
NotBefore: notBefore,
NotAfter: notAfter,
OrderID: orderID,
}, nil
}
// Pending — return OrderID for polling via GetOrderStatus
c.logger.Info("DigiCert order pending validation",
"order_id", orderID,
"status", orderResp.Status)
return &issuer.IssuanceResult{
OrderID: orderID,
}, nil
}
// RenewCertificate renews a certificate by submitting a new order.
// DigiCert uses reissue for renewal, but for simplicity we submit a new order
// (reissue requires the original order ID which may not be available).
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing DigiCert renewal request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
CommonName: request.CommonName,
SANs: request.SANs,
CSRPEM: request.CSRPEM,
EKUs: request.EKUs,
})
}
// RevokeCertificate revokes a certificate at DigiCert CertCentral.
// DigiCert revocation uses certificate_id, so we extract it from the serial
// by looking up the order. For simplicity, we use the serial as the cert ID
// (the caller should provide the DigiCert certificate ID).
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
c.logger.Info("processing DigiCert revocation request", "serial", request.Serial)
reason := "unspecified"
if request.Reason != nil {
reason = *request.Reason
}
revokeBody := map[string]interface{}{
"reason": reason,
}
body, err := json.Marshal(revokeBody)
if err != nil {
return fmt.Errorf("failed to marshal revoke request: %w", err)
}
// DigiCert uses certificate_id in the URL path for revocation
revokeURL := fmt.Sprintf("%s/certificate/%s/revoke", c.config.BaseURL, request.Serial)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, revokeURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create revoke request: %w", err)
}
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("DigiCert revoke request failed: %w", err)
}
defer resp.Body.Close()
// DigiCert returns 204 No Content on successful revocation
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("DigiCert revoke returned status %d: %s", resp.StatusCode, string(respBody))
}
c.logger.Info("DigiCert certificate revoked", "serial", request.Serial, "reason", reason)
return nil
}
// GetOrderStatus checks the status of a DigiCert certificate order.
// If the order is "issued", downloads the certificate and returns it.
// If still "pending", returns pending status for continued polling.
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
c.logger.Debug("checking DigiCert order status", "order_id", orderID)
statusURL := fmt.Sprintf("%s/order/certificate/%s", c.config.BaseURL, orderID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create status request: %w", err)
}
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("DigiCert status request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read status response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("DigiCert order status returned %d: %s", resp.StatusCode, string(respBody))
}
var statusResp orderStatusResponse
if err := json.Unmarshal(respBody, &statusResp); err != nil {
return nil, fmt.Errorf("failed to parse status response: %w", err)
}
now := time.Now()
switch statusResp.Status {
case "issued":
if statusResp.Certificate.ID == 0 {
return nil, fmt.Errorf("order is issued but certificate_id is missing")
}
certPEM, chainPEM, serial, notBefore, notAfter, err := c.downloadCertificate(ctx, statusResp.Certificate.ID)
if err != nil {
return nil, fmt.Errorf("failed to download certificate: %w", err)
}
c.logger.Info("DigiCert order completed",
"order_id", orderID,
"serial", serial)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "completed",
CertPEM: &certPEM,
ChainPEM: &chainPEM,
Serial: &serial,
NotBefore: &notBefore,
NotAfter: &notAfter,
UpdatedAt: now,
}, nil
case "pending", "processing":
msg := fmt.Sprintf("order %s is %s", orderID, statusResp.Status)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "pending",
Message: &msg,
UpdatedAt: now,
}, nil
case "rejected", "denied":
msg := fmt.Sprintf("order %s was %s", orderID, statusResp.Status)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "failed",
Message: &msg,
UpdatedAt: now,
}, nil
default:
msg := fmt.Sprintf("unknown order status: %s", statusResp.Status)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "pending",
Message: &msg,
UpdatedAt: now,
}, nil
}
}
// downloadCertificate downloads the PEM bundle for a DigiCert certificate.
func (c *Connector) downloadCertificate(ctx context.Context, certificateID int) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
downloadURL := fmt.Sprintf("%s/certificate/%d/download/format/pem_all", c.config.BaseURL, certificateID)
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if reqErr != nil {
err = fmt.Errorf("failed to create download request: %w", reqErr)
return
}
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
resp, doErr := c.httpClient.Do(req)
if doErr != nil {
err = fmt.Errorf("DigiCert download request failed: %w", doErr)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
err = fmt.Errorf("DigiCert download returned status %d: %s", resp.StatusCode, string(body))
return
}
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
err = fmt.Errorf("failed to read download response: %w", readErr)
return
}
// Parse the PEM bundle: first cert is the leaf, rest are intermediates
certPEM, chainPEM, serial, notBefore, notAfter, err = parsePEMBundle(string(body))
return
}
// parsePEMBundle splits a PEM bundle into leaf cert and chain, extracting metadata.
func parsePEMBundle(bundle string) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
var certs []string
remaining := bundle
for {
var block *pem.Block
block, rest := pem.Decode([]byte(remaining))
if block == nil {
break
}
if block.Type == "CERTIFICATE" {
certs = append(certs, string(pem.EncodeToMemory(block)))
}
remaining = string(rest)
}
if len(certs) == 0 {
err = fmt.Errorf("no certificates found in PEM bundle")
return
}
certPEM = certs[0]
if len(certs) > 1 {
chainPEM = strings.Join(certs[1:], "")
}
// Parse leaf cert for metadata
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
err = fmt.Errorf("failed to decode leaf certificate PEM")
return
}
cert, parseErr := x509.ParseCertificate(block.Bytes)
if parseErr != nil {
err = fmt.Errorf("failed to parse leaf certificate: %w", parseErr)
return
}
serial = cert.SerialNumber.String()
notBefore = cert.NotBefore
notAfter = cert.NotAfter
return
}
// GenerateCRL is not supported because DigiCert manages CRL distribution.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
return nil, fmt.Errorf("DigiCert manages CRL distribution; use DigiCert's CRL endpoints")
}
// SignOCSPResponse is not supported because DigiCert manages OCSP.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
return nil, fmt.Errorf("DigiCert manages OCSP; use DigiCert's OCSP responder")
}
// GetCACertPEM is not directly supported. DigiCert intermediate certificates
// come with each certificate issuance as part of the PEM bundle.
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
return "", fmt.Errorf("DigiCert intermediate certificates are included with each issued certificate")
}
// GetRenewalInfo returns nil, nil as DigiCert does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}
// Ensure Connector implements the issuer.Connector interface.
var _ issuer.Connector = (*Connector)(nil)
@@ -0,0 +1,591 @@
package digicert_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/connector/issuer/digicert"
)
func TestDigiCertConnector(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
t.Run("ValidateConfig_Success", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/user/me" {
if r.Header.Get("X-DC-DEVKEY") == "dc-test-api-key" {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":12345,"first_name":"Test","last_name":"User"}`))
return
}
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"errors":[{"code":"invalid_api_key"}]}`))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := digicert.Config{
APIKey: "dc-test-api-key",
OrgID: "12345",
ProductType: "ssl_basic",
BaseURL: srv.URL,
}
connector := digicert.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
})
t.Run("ValidateConfig_MissingAPIKey", func(t *testing.T) {
config := digicert.Config{
OrgID: "12345",
}
connector := digicert.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing api_key")
}
if !strings.Contains(err.Error(), "api_key is required") {
t.Errorf("Expected api_key required error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingOrgID", func(t *testing.T) {
config := digicert.Config{
APIKey: "dc-test-key",
}
connector := digicert.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing org_id")
}
if !strings.Contains(err.Error(), "org_id is required") {
t.Errorf("Expected org_id required error, got: %v", err)
}
})
t.Run("ValidateConfig_InvalidKey", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/user/me" {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"errors":[{"code":"invalid_api_key"}]}`))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := digicert.Config{
APIKey: "dc-bad-key",
OrgID: "12345",
BaseURL: srv.URL,
}
connector := digicert.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for invalid API key")
}
if !strings.Contains(err.Error(), "invalid") {
t.Logf("Got error: %v", err)
}
})
t.Run("IssueCertificate_ImmediateSuccess", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
testChainPEM, _ := generateTestCert(t)
pemBundle := testCertPEM + testChainPEM
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/order/certificate/ssl_basic"):
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":99001,"status":"issued","certificate_id":88001}`))
case r.URL.Path == "/certificate/88001/download/format/pem_all":
w.WriteHeader(http.StatusOK)
w.Write([]byte(pemBundle))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &digicert.Config{
APIKey: "dc-test-key",
OrgID: "12345",
ProductType: "ssl_basic",
BaseURL: srv.URL,
}
connector := digicert.New(config, logger)
_, csrPEM := generateTestCSR(t, "app.example.com")
req := issuer.IssuanceRequest{
CommonName: "app.example.com",
SANs: []string{"app.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM should not be empty for immediate issuance")
}
if result.Serial == "" {
t.Error("Serial should not be empty for immediate issuance")
}
if result.OrderID != "99001" {
t.Errorf("Expected OrderID '99001', got '%s'", result.OrderID)
}
t.Logf("DigiCert issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
})
t.Run("IssueCertificate_Pending", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/order/certificate/ssl_ev_basic"):
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":99002,"status":"pending"}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &digicert.Config{
APIKey: "dc-test-key",
OrgID: "12345",
ProductType: "ssl_ev_basic",
BaseURL: srv.URL,
}
connector := digicert.New(config, logger)
_, csrPEM := generateTestCSR(t, "secure.example.com")
req := issuer.IssuanceRequest{
CommonName: "secure.example.com",
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.OrderID != "99002" {
t.Errorf("Expected OrderID '99002', got '%s'", result.OrderID)
}
if result.CertPEM != "" {
t.Error("CertPEM should be empty for pending order")
}
if result.Serial != "" {
t.Error("Serial should be empty for pending order")
}
})
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"errors":[{"code":"invalid_csr","message":"CSR is malformed"}]}`))
}))
defer srv.Close()
config := &digicert.Config{
APIKey: "dc-test-key",
OrgID: "12345",
ProductType: "ssl_basic",
BaseURL: srv.URL,
}
connector := digicert.New(config, logger)
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
CSRPEM: "invalid-csr",
}
_, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error for server error response")
}
})
t.Run("GetOrderStatus_Issued", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
testChainPEM, _ := generateTestCert(t)
pemBundle := testCertPEM + testChainPEM
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/order/certificate/99001":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":99001,"status":"issued","certificate":{"id":88001,"common_name":"app.example.com"}}`))
case "/certificate/88001/download/format/pem_all":
w.WriteHeader(http.StatusOK)
w.Write([]byte(pemBundle))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &digicert.Config{
APIKey: "dc-test-key",
OrgID: "12345",
BaseURL: srv.URL,
}
connector := digicert.New(config, logger)
status, err := connector.GetOrderStatus(ctx, "99001")
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "completed" {
t.Errorf("Expected status 'completed', got '%s'", status.Status)
}
if status.CertPEM == nil || *status.CertPEM == "" {
t.Error("CertPEM should not be empty for issued order")
}
if status.Serial == nil || *status.Serial == "" {
t.Error("Serial should not be empty for issued order")
}
})
t.Run("GetOrderStatus_Pending", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/order/certificate/99002" {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":99002,"status":"pending","certificate":{"id":0}}`))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &digicert.Config{
APIKey: "dc-test-key",
OrgID: "12345",
BaseURL: srv.URL,
}
connector := digicert.New(config, logger)
status, err := connector.GetOrderStatus(ctx, "99002")
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "pending" {
t.Errorf("Expected status 'pending', got '%s'", status.Status)
}
if status.CertPEM != nil {
t.Error("CertPEM should be nil for pending order")
}
})
t.Run("GetOrderStatus_Rejected", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/order/certificate/99003" {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":99003,"status":"rejected","certificate":{"id":0}}`))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &digicert.Config{
APIKey: "dc-test-key",
OrgID: "12345",
BaseURL: srv.URL,
}
connector := digicert.New(config, logger)
status, err := connector.GetOrderStatus(ctx, "99003")
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "failed" {
t.Errorf("Expected status 'failed', got '%s'", status.Status)
}
})
t.Run("RenewCertificate_NewOrder", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/order/certificate/"):
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":99010,"status":"pending"}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &digicert.Config{
APIKey: "dc-test-key",
OrgID: "12345",
ProductType: "ssl_basic",
BaseURL: srv.URL,
}
connector := digicert.New(config, logger)
_, csrPEM := generateTestCSR(t, "renew.example.com")
renewReq := issuer.RenewalRequest{
CommonName: "renew.example.com",
CSRPEM: csrPEM,
}
result, err := connector.RenewCertificate(ctx, renewReq)
if err != nil {
t.Fatalf("RenewCertificate failed: %v", err)
}
if result.OrderID == "" {
t.Error("OrderID should not be empty")
}
})
t.Run("RevokeCertificate_Success", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/revoke") && r.Method == http.MethodPut {
if r.Header.Get("X-DC-DEVKEY") == "" {
w.WriteHeader(http.StatusForbidden)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &digicert.Config{
APIKey: "dc-test-key",
OrgID: "12345",
BaseURL: srv.URL,
}
connector := digicert.New(config, logger)
reason := "keyCompromise"
revokeReq := issuer.RevocationRequest{
Serial: "88001",
Reason: &reason,
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
})
t.Run("RevokeCertificate_Error", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"errors":[{"code":"certificate_not_found"}]}`))
}))
defer srv.Close()
config := &digicert.Config{
APIKey: "dc-test-key",
OrgID: "12345",
BaseURL: srv.URL,
}
connector := digicert.New(config, logger)
revokeReq := issuer.RevocationRequest{
Serial: "00000",
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err == nil {
t.Fatal("Expected error for revocation of nonexistent cert")
}
})
t.Run("GetOrderStatus_DownloadError", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/order/certificate/99004":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":99004,"status":"issued","certificate":{"id":88004}}`))
case "/certificate/88004/download/format/pem_all":
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"errors":["internal server error"]}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &digicert.Config{
APIKey: "dc-test-key",
OrgID: "12345",
BaseURL: srv.URL,
}
connector := digicert.New(config, logger)
_, err := connector.GetOrderStatus(ctx, "99004")
if err == nil {
t.Fatal("Expected error when download fails")
}
if !strings.Contains(err.Error(), "download") {
t.Logf("Got error: %v", err)
}
})
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
config := &digicert.Config{
APIKey: "dc-test-key",
OrgID: "12345",
BaseURL: "https://api.digicert.com",
}
connector := digicert.New(config, logger)
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
if err != nil {
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
}
if result != nil {
t.Fatal("GetRenewalInfo should return nil for DigiCert")
}
})
t.Run("DefaultProductType", func(t *testing.T) {
config := &digicert.Config{
APIKey: "dc-test-key",
OrgID: "12345",
// ProductType intentionally left empty
}
connector := digicert.New(config, logger)
// Verify the connector was created (the default is set in New())
if connector == nil {
t.Fatal("Connector should not be nil")
}
// Verify via a request that uses the product type
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify the path includes the default product type
if strings.Contains(r.URL.Path, "ssl_basic") {
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":99099,"status":"pending"}`))
return
}
t.Errorf("Expected path to contain 'ssl_basic', got: %s", r.URL.Path)
w.WriteHeader(http.StatusBadRequest)
}))
defer srv.Close()
// Reconfigure with test server URL
config.BaseURL = srv.URL
connector = digicert.New(config, logger)
_, csrPEM := generateTestCSR(t, "test.example.com")
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate with default product type failed: %v", err)
}
if result.OrderID == "" {
t.Error("OrderID should not be empty")
}
})
}
// generateTestCert creates a self-signed test certificate and returns the PEM strings.
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: fmt.Sprintf("Test Certificate %s", serial.String()[:8]),
},
DNSNames: []string{"test.example.com"},
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("Failed to create certificate: %v", err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
return certPEM, keyPEM
}
// generateTestCSR creates a test CSR for the given common name.
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
csrTemplate := x509.CertificateRequest{
Subject: pkix.Name{
CommonName: commonName,
},
DNSNames: []string{commonName},
SignatureAlgorithm: x509.SHA256WithRSA,
}
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
if err != nil {
t.Fatalf("Failed to create CSR: %v", err)
}
csrPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: csrBytes,
}))
csr, err := x509.ParseCertificateRequest(csrBytes)
if err != nil {
t.Fatalf("Failed to parse CSR: %v", err)
}
return csr, csrPEM
}
+372
View File
@@ -0,0 +1,372 @@
// Package vault implements the issuer.Connector interface for HashiCorp Vault PKI
// secrets engine.
//
// Vault PKI provides a full-featured private CA with certificate signing, revocation,
// CRL, and OCSP capabilities. This connector uses the Vault HTTP API to sign CSRs
// via the /v1/{mount}/sign/{role} endpoint, authenticated with a Vault token.
//
// Vault issues certificates synchronously (like step-ca), so GetOrderStatus always
// returns "completed". CRL and OCSP are delegated to Vault's own endpoints.
//
// Authentication: Vault token via X-Vault-Token header.
//
// Vault API used:
//
// GET /v1/sys/health - Health check
// POST /v1/{mount}/sign/{role} - Sign CSR
// POST /v1/{mount}/revoke - Revoke certificate
// GET /v1/{mount}/ca/pem - Get CA certificate
package vault
import (
"bytes"
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
// Config represents the Vault PKI issuer connector configuration.
type Config struct {
// Addr is the Vault server address (e.g., "https://vault.example.com:8200").
// Required. Set via CERTCTL_VAULT_ADDR environment variable.
Addr string `json:"addr"`
// Token is the Vault token for authentication.
// Required. Set via CERTCTL_VAULT_TOKEN environment variable.
Token string `json:"token"`
// Mount is the PKI secrets engine mount path.
// Default: "pki". Set via CERTCTL_VAULT_MOUNT environment variable.
Mount string `json:"mount"`
// Role is the PKI role name used for signing certificates.
// Required. Set via CERTCTL_VAULT_ROLE environment variable.
Role string `json:"role"`
// TTL is the requested certificate TTL (e.g., "8760h" for 1 year).
// Default: "8760h". Set via CERTCTL_VAULT_TTL environment variable.
TTL string `json:"ttl"`
}
// Connector implements the issuer.Connector interface for Vault PKI.
type Connector struct {
config *Config
logger *slog.Logger
httpClient *http.Client
}
// New creates a new Vault PKI connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
if config != nil {
if config.Mount == "" {
config.Mount = "pki"
}
if config.TTL == "" {
config.TTL = "8760h"
}
}
return &Connector{
config: config,
logger: logger,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// vaultResponse is the standard Vault API response wrapper.
type vaultResponse struct {
Data json.RawMessage `json:"data"`
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// signData holds the data returned from the /sign endpoint.
type signData struct {
Certificate string `json:"certificate"`
IssuingCA string `json:"issuing_ca"`
CAChain []string `json:"ca_chain"`
SerialNumber string `json:"serial_number"`
Expiration int64 `json:"expiration"`
}
// ValidateConfig checks that the Vault configuration is valid and the server is reachable.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid Vault config: %w", err)
}
if cfg.Addr == "" {
return fmt.Errorf("Vault addr is required")
}
if cfg.Token == "" {
return fmt.Errorf("Vault token is required")
}
if cfg.Role == "" {
return fmt.Errorf("Vault role is required")
}
if cfg.Mount == "" {
cfg.Mount = "pki"
}
if cfg.TTL == "" {
cfg.TTL = "8760h"
}
// Health check
healthURL := cfg.Addr + "/v1/sys/health"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
if err != nil {
return fmt.Errorf("failed to create health check request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("Vault not reachable at %s: %w", cfg.Addr, err)
}
defer resp.Body.Close()
// Vault health returns 200 for initialized+unsealed, 429 for standby, 472 for DR secondary,
// 473 for perf standby, 501 for uninitialized, 503 for sealed
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusTooManyRequests {
return fmt.Errorf("Vault health check returned status %d", resp.StatusCode)
}
c.config = &cfg
c.logger.Info("Vault PKI configuration validated",
"addr", cfg.Addr,
"mount", cfg.Mount,
"role", cfg.Role)
return nil
}
// IssueCertificate submits a CSR to Vault PKI for signing.
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing Vault PKI issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// Build the sign request body
signBody := map[string]interface{}{
"csr": request.CSRPEM,
"common_name": request.CommonName,
"ttl": c.config.TTL,
}
if len(request.SANs) > 0 {
signBody["alt_names"] = strings.Join(request.SANs, ",")
}
body, err := json.Marshal(signBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal sign request: %w", err)
}
// POST /v1/{mount}/sign/{role}
signURL := fmt.Sprintf("%s/v1/%s/sign/%s", c.config.Addr, c.config.Mount, c.config.Role)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, signURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create sign request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Vault-Token", c.config.Token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("Vault sign request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read sign response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var vaultResp vaultResponse
if jsonErr := json.Unmarshal(respBody, &vaultResp); jsonErr == nil && len(vaultResp.Errors) > 0 {
return nil, fmt.Errorf("Vault sign returned status %d: %s", resp.StatusCode, strings.Join(vaultResp.Errors, "; "))
}
return nil, fmt.Errorf("Vault sign returned status %d: %s", resp.StatusCode, string(respBody))
}
// Parse the Vault response
var vaultResp vaultResponse
if err := json.Unmarshal(respBody, &vaultResp); err != nil {
return nil, fmt.Errorf("failed to parse Vault response: %w", err)
}
var data signData
if err := json.Unmarshal(vaultResp.Data, &data); err != nil {
return nil, fmt.Errorf("failed to parse Vault sign data: %w", err)
}
if data.Certificate == "" {
return nil, fmt.Errorf("no certificate in Vault sign response")
}
// Parse the leaf certificate to extract metadata
certPEM := data.Certificate
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return nil, fmt.Errorf("failed to decode certificate PEM from Vault")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
// Build chain PEM from ca_chain or issuing_ca
var chainPEM string
if len(data.CAChain) > 0 {
chainPEM = strings.Join(data.CAChain, "\n")
} else if data.IssuingCA != "" {
chainPEM = data.IssuingCA
}
// Normalize serial: Vault uses colon-separated hex (e.g., "aa:bb:cc"), convert to plain string
serial := normalizeSerial(data.SerialNumber)
orderID := fmt.Sprintf("vault-%s", serial)
c.logger.Info("Vault PKI certificate issued",
"common_name", request.CommonName,
"serial", serial,
"not_after", cert.NotAfter)
return &issuer.IssuanceResult{
CertPEM: certPEM,
ChainPEM: chainPEM,
Serial: serial,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
OrderID: orderID,
}, nil
}
// RenewCertificate renews a certificate by creating a new signing request.
// For Vault PKI, renewal is functionally identical to issuance (new cert signed from CSR).
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing Vault PKI renewal request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
CommonName: request.CommonName,
SANs: request.SANs,
CSRPEM: request.CSRPEM,
EKUs: request.EKUs,
})
}
// RevokeCertificate revokes a certificate at Vault PKI.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
c.logger.Info("processing Vault PKI revocation request", "serial", request.Serial)
revokeBody := map[string]interface{}{
"serial_number": request.Serial,
}
body, err := json.Marshal(revokeBody)
if err != nil {
return fmt.Errorf("failed to marshal revoke request: %w", err)
}
revokeURL := fmt.Sprintf("%s/v1/%s/revoke", c.config.Addr, c.config.Mount)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create revoke request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Vault-Token", c.config.Token)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("Vault revoke request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("Vault revoke returned status %d: %s", resp.StatusCode, string(respBody))
}
c.logger.Info("Vault PKI certificate revoked", "serial", request.Serial)
return nil
}
// GetOrderStatus returns the status of a Vault PKI order.
// Vault signs synchronously, so orders are always "completed" immediately.
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
return &issuer.OrderStatus{
OrderID: orderID,
Status: "completed",
UpdatedAt: time.Now(),
}, nil
}
// GenerateCRL is not supported because Vault serves CRL directly at /v1/{mount}/crl.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
return nil, fmt.Errorf("Vault serves CRL directly at /v1/%s/crl; use Vault's endpoint", c.config.Mount)
}
// SignOCSPResponse is not supported because Vault serves OCSP directly at /v1/{mount}/ocsp.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
return nil, fmt.Errorf("Vault serves OCSP directly at /v1/%s/ocsp; use Vault's endpoint", c.config.Mount)
}
// GetCACertPEM retrieves the CA certificate from Vault PKI.
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
caURL := fmt.Sprintf("%s/v1/%s/ca/pem", c.config.Addr, c.config.Mount)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, caURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create CA cert request: %w", err)
}
req.Header.Set("X-Vault-Token", c.config.Token)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("Vault CA cert request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Vault CA cert returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read CA cert response: %w", err)
}
return string(body), nil
}
// GetRenewalInfo returns nil, nil as Vault does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}
// normalizeSerial converts Vault's colon-separated hex serial (e.g., "aa:bb:cc:dd")
// to a plain string representation suitable for storage.
func normalizeSerial(serial string) string {
return strings.ReplaceAll(serial, ":", "-")
}
// Ensure Connector implements the issuer.Connector interface.
var _ issuer.Connector = (*Connector)(nil)
@@ -0,0 +1,527 @@
package vault_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/connector/issuer/vault"
)
func TestVaultConnector(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
t.Run("ValidateConfig_Success", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/sys/health" {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"initialized":true,"sealed":false,"standby":false}`))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := vault.Config{
Addr: srv.URL,
Token: "s.test-token-12345",
Mount: "pki",
Role: "web-certs",
TTL: "8760h",
}
connector := vault.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
})
t.Run("ValidateConfig_MissingAddr", func(t *testing.T) {
config := vault.Config{
Token: "s.test-token",
Role: "web-certs",
}
connector := vault.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing addr")
}
if !strings.Contains(err.Error(), "addr is required") {
t.Errorf("Expected addr required error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingToken", func(t *testing.T) {
config := vault.Config{
Addr: "https://vault.example.com:8200",
Role: "web-certs",
}
connector := vault.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing token")
}
if !strings.Contains(err.Error(), "token is required") {
t.Errorf("Expected token required error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingRole", func(t *testing.T) {
config := vault.Config{
Addr: "https://vault.example.com:8200",
Token: "s.test-token",
}
connector := vault.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing role")
}
if !strings.Contains(err.Error(), "role is required") {
t.Errorf("Expected role required error, got: %v", err)
}
})
t.Run("ValidateConfig_UnreachableVault", func(t *testing.T) {
config := vault.Config{
Addr: "http://localhost:19999",
Token: "s.test-token",
Role: "web-certs",
}
connector := vault.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for unreachable Vault")
}
})
t.Run("IssueCertificate_Success", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v1/sys/health":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"initialized":true,"sealed":false}`))
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
// Verify auth header
if r.Header.Get("X-Vault-Token") != "s.test-token" {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"errors":["permission denied"]}`))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
resp := fmt.Sprintf(`{
"data": {
"certificate": %q,
"issuing_ca": %q,
"ca_chain": [%q],
"serial_number": "aa:bb:cc:dd:ee:ff",
"expiration": 1893456000
}
}`, testCertPEM, testCertPEM, testCertPEM)
w.Write([]byte(resp))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &vault.Config{
Addr: srv.URL,
Token: "s.test-token",
Mount: "pki",
Role: "web-certs",
TTL: "8760h",
}
connector := vault.New(config, logger)
_, csrPEM := generateTestCSR(t, "app.example.com")
req := issuer.IssuanceRequest{
CommonName: "app.example.com",
SANs: []string{"app.example.com", "www.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM is empty")
}
if result.Serial == "" {
t.Error("Serial is empty")
}
if result.OrderID == "" {
t.Error("OrderID is empty")
}
if !strings.HasPrefix(result.OrderID, "vault-") {
t.Errorf("Expected OrderID to start with 'vault-', got '%s'", result.OrderID)
}
// Verify serial normalization (colons replaced with dashes)
if strings.Contains(result.Serial, ":") {
t.Errorf("Serial should not contain colons, got '%s'", result.Serial)
}
t.Logf("Vault issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
})
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v1/sys/health":
w.WriteHeader(http.StatusOK)
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"errors":["invalid CSR"]}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &vault.Config{
Addr: srv.URL,
Token: "s.test-token",
Mount: "pki",
Role: "web-certs",
}
connector := vault.New(config, logger)
_, csrPEM := generateTestCSR(t, "test.example.com")
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
CSRPEM: csrPEM,
}
_, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error for server error response")
}
if !strings.Contains(err.Error(), "invalid CSR") {
t.Logf("Got error: %v", err)
}
})
t.Run("IssueCertificate_Forbidden", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v1/sys/health":
w.WriteHeader(http.StatusOK)
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"errors":["permission denied"]}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &vault.Config{
Addr: srv.URL,
Token: "s.bad-token",
Mount: "pki",
Role: "web-certs",
}
connector := vault.New(config, logger)
_, csrPEM := generateTestCSR(t, "test.example.com")
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
CSRPEM: csrPEM,
}
_, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error for forbidden response")
}
if !strings.Contains(err.Error(), "permission denied") {
t.Logf("Got error: %v", err)
}
})
t.Run("RenewCertificate_Success", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v1/sys/health":
w.WriteHeader(http.StatusOK)
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
resp := fmt.Sprintf(`{
"data": {
"certificate": %q,
"issuing_ca": %q,
"serial_number": "11:22:33:44:55:66",
"expiration": 1893456000
}
}`, testCertPEM, testCertPEM)
w.Write([]byte(resp))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &vault.Config{
Addr: srv.URL,
Token: "s.test-token",
Mount: "pki",
Role: "web-certs",
}
connector := vault.New(config, logger)
_, csrPEM := generateTestCSR(t, "renew.example.com")
renewReq := issuer.RenewalRequest{
CommonName: "renew.example.com",
CSRPEM: csrPEM,
}
result, err := connector.RenewCertificate(ctx, renewReq)
if err != nil {
t.Fatalf("RenewCertificate failed: %v", err)
}
if result.Serial == "" {
t.Error("Serial is empty")
}
})
t.Run("RevokeCertificate_Success", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/sys/health":
w.WriteHeader(http.StatusOK)
case "/v1/pki/revoke":
// Verify token
if r.Header.Get("X-Vault-Token") == "" {
w.WriteHeader(http.StatusForbidden)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data":{"revocation_time":1234567890}}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &vault.Config{
Addr: srv.URL,
Token: "s.test-token",
Mount: "pki",
Role: "web-certs",
}
connector := vault.New(config, logger)
reason := "keyCompromise"
revokeReq := issuer.RevocationRequest{
Serial: "aa-bb-cc-dd-ee-ff",
Reason: &reason,
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
})
t.Run("RevokeCertificate_ServerError", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/sys/health":
w.WriteHeader(http.StatusOK)
case "/v1/pki/revoke":
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"errors":["serial not found"]}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &vault.Config{
Addr: srv.URL,
Token: "s.test-token",
Mount: "pki",
Role: "web-certs",
}
connector := vault.New(config, logger)
revokeReq := issuer.RevocationRequest{
Serial: "00-00-00-00",
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err == nil {
t.Fatal("Expected error for server error response")
}
})
t.Run("GetCACertPEM_Success", func(t *testing.T) {
expectedPEM := "-----BEGIN CERTIFICATE-----\nTESTCA\n-----END CERTIFICATE-----\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/pki/ca/pem":
w.WriteHeader(http.StatusOK)
w.Write([]byte(expectedPEM))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &vault.Config{
Addr: srv.URL,
Token: "s.test-token",
Mount: "pki",
Role: "web-certs",
}
connector := vault.New(config, logger)
caPEM, err := connector.GetCACertPEM(ctx)
if err != nil {
t.Fatalf("GetCACertPEM failed: %v", err)
}
if caPEM != expectedPEM {
t.Errorf("Expected CA PEM %q, got %q", expectedPEM, caPEM)
}
})
t.Run("GetOrderStatus_Synchronous", func(t *testing.T) {
config := &vault.Config{
Addr: "https://vault.example.com:8200",
Token: "s.test-token",
Mount: "pki",
Role: "web-certs",
}
connector := vault.New(config, logger)
status, err := connector.GetOrderStatus(ctx, "vault-aa-bb-cc")
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "completed" {
t.Errorf("Expected status 'completed', got '%s'", status.Status)
}
if status.OrderID != "vault-aa-bb-cc" {
t.Errorf("Expected OrderID 'vault-aa-bb-cc', got '%s'", status.OrderID)
}
})
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
config := &vault.Config{
Addr: "https://vault.example.com:8200",
Token: "s.test-token",
Mount: "pki",
Role: "web-certs",
}
connector := vault.New(config, logger)
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
if err != nil {
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
}
if result != nil {
t.Fatal("GetRenewalInfo should return nil for Vault")
}
})
}
// generateTestCert creates a self-signed test certificate and returns the PEM strings.
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "Test Certificate",
},
DNSNames: []string{"test.example.com"},
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("Failed to create certificate: %v", err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
return certPEM, keyPEM
}
// generateTestCSR creates a test CSR for the given common name.
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
csrTemplate := x509.CertificateRequest{
Subject: pkix.Name{
CommonName: commonName,
},
DNSNames: []string{commonName},
SignatureAlgorithm: x509.SHA256WithRSA,
}
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
if err != nil {
t.Fatalf("Failed to create CSR: %v", err)
}
csrPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: csrBytes,
}))
csr, err := x509.ParseCertificateRequest(csrBytes)
if err != nil {
t.Fatalf("Failed to parse CSR: %v", err)
}
return csr, csrPEM
}
+2
View File
@@ -69,6 +69,8 @@ const (
IssuerTypeGenericCA IssuerType = "GenericCA"
IssuerTypeStepCA IssuerType = "StepCA"
IssuerTypeOpenSSL IssuerType = "OpenSSL"
IssuerTypeVault IssuerType = "VaultPKI"
IssuerTypeDigiCert IssuerType = "DigiCert"
)
// TargetType represents the type of deployment target.
+1
View File
@@ -11,6 +11,7 @@ type Job struct {
Type JobType `json:"type"`
CertificateID string `json:"certificate_id"`
TargetID *string `json:"target_id,omitempty"`
AgentID *string `json:"agent_id,omitempty"`
Status JobStatus `json:"status"`
Attempts int `json:"attempts"`
MaxAttempts int `json:"max_attempts"`
+14
View File
@@ -662,6 +662,20 @@ func (m *mockJobRepository) GetPendingJobs(ctx context.Context, jobType domain.J
return jobs, nil
}
func (m *mockJobRepository) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
var result []*domain.Job
for _, j := range m.jobs {
if j.AgentID != nil && *j.AgentID == agentID {
if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
result = append(result, j)
} else if j.Status == domain.JobStatusAwaitingCSR {
result = append(result, j)
}
}
}
return result, nil
}
type mockAuditRepository struct {
events []*domain.AuditEvent
}
+4 -4
View File
@@ -99,7 +99,7 @@ func registerCertificateTools(s *gomcp.Server, c *Client) {
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_create_certificate",
Description: "Create a new managed certificate. Requires common_name and issuer_id at minimum.",
Description: "Create a new managed certificate. Requires name, common_name, renewal_policy_id, issuer_id, owner_id, and team_id.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateCertificateInput) (*gomcp.CallToolResult, any, error) {
data, err := c.Post("/api/v1/certificates", input)
if err != nil {
@@ -144,7 +144,7 @@ func registerCertificateTools(s *gomcp.Server, c *Client) {
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_trigger_renewal",
Description: "Trigger immediate renewal of a certificate. Creates a renewal job (async, returns 202).",
Description: "Trigger immediate renewal of a certificate. Creates a renewal job (async, returns 202). Returns 404 if certificate not found, 400 if certificate is archived/expired, 409 if renewal already in progress.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
data, err := c.Post("/api/v1/certificates/"+input.ID+"/renew", nil)
if err != nil {
@@ -385,7 +385,7 @@ func registerAgentTools(s *gomcp.Server, c *Client) {
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_register_agent",
Description: "Register a new agent. Requires name and hostname.",
Description: "Register a new agent. Requires name and hostname. Returns 409 if an agent with the same name already exists.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input RegisterAgentInput) (*gomcp.CallToolResult, any, error) {
data, err := c.Post("/api/v1/agents", input)
if err != nil {
@@ -396,7 +396,7 @@ func registerAgentTools(s *gomcp.Server, c *Client) {
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_agent_heartbeat",
Description: "Send agent heartbeat with optional metadata (OS, architecture, IP, version).",
Description: "Send agent heartbeat with optional metadata (OS, architecture, IP, version). Returns 404 if agent not found.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input struct {
ID string `json:"id" jsonschema:"Agent ID"`
Version string `json:"version,omitempty" jsonschema:"Agent version"`
+2
View File
@@ -111,6 +111,8 @@ type JobRepository interface {
UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error
// GetPendingJobs returns jobs not yet processed of a specific type.
GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error)
// ListPendingByAgentID returns pending deployment jobs and AwaitingCSR jobs for a specific agent.
ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error)
}
// RenewalPolicyRepository defines operations for managing renewal policies.
+11 -7
View File
@@ -349,7 +349,7 @@ func (r *CertificateRepository) Archive(ctx context.Context, id string) error {
func (r *CertificateRepository) ListVersions(ctx context.Context, certID string) ([]*domain.CertificateVersion, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, certificate_id, serial_number, not_before, not_after,
fingerprint_sha256, pem_chain, csr_pem, key_algorithm, key_size, created_at
fingerprint_sha256, pem_chain, csr_pem, created_at
FROM certificate_versions
WHERE certificate_id = $1
ORDER BY created_at DESC
@@ -363,10 +363,12 @@ func (r *CertificateRepository) ListVersions(ctx context.Context, certID string)
var versions []*domain.CertificateVersion
for rows.Next() {
var v domain.CertificateVersion
var csrPEM sql.NullString
if err := rows.Scan(&v.ID, &v.CertificateID, &v.SerialNumber, &v.NotBefore, &v.NotAfter,
&v.FingerprintSHA256, &v.PEMChain, &v.CSRPEM, &v.KeyAlgorithm, &v.KeySize, &v.CreatedAt); err != nil {
&v.FingerprintSHA256, &v.PEMChain, &csrPEM, &v.CreatedAt); err != nil {
return nil, fmt.Errorf("failed to scan certificate version: %w", err)
}
v.CSRPEM = csrPEM.String
versions = append(versions, &v)
}
@@ -386,11 +388,11 @@ func (r *CertificateRepository) CreateVersion(ctx context.Context, version *doma
err := r.db.QueryRowContext(ctx, `
INSERT INTO certificate_versions (
id, certificate_id, serial_number, not_before, not_after,
fingerprint_sha256, pem_chain, csr_pem, key_algorithm, key_size, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
fingerprint_sha256, pem_chain, csr_pem, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`, version.ID, version.CertificateID, version.SerialNumber, version.NotBefore, version.NotAfter,
version.FingerprintSHA256, version.PEMChain, version.CSRPEM, version.KeyAlgorithm, version.KeySize, version.CreatedAt).Scan(&version.ID)
version.FingerprintSHA256, version.PEMChain, version.CSRPEM, version.CreatedAt).Scan(&version.ID)
if err != nil {
return fmt.Errorf("failed to create certificate version: %w", err)
@@ -433,15 +435,17 @@ func (r *CertificateRepository) GetExpiringCertificates(ctx context.Context, bef
// GetLatestVersion returns the most recent certificate version for a certificate.
func (r *CertificateRepository) GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error) {
var v domain.CertificateVersion
var csrPEM sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, certificate_id, serial_number, not_before, not_after,
fingerprint_sha256, pem_chain, csr_pem, key_algorithm, key_size, created_at
fingerprint_sha256, pem_chain, csr_pem, created_at
FROM certificate_versions
WHERE certificate_id = $1
ORDER BY created_at DESC
LIMIT 1
`, certID).Scan(&v.ID, &v.CertificateID, &v.SerialNumber, &v.NotBefore, &v.NotAfter,
&v.FingerprintSHA256, &v.PEMChain, &v.CSRPEM, &v.KeyAlgorithm, &v.KeySize, &v.CreatedAt)
&v.FingerprintSHA256, &v.PEMChain, &csrPEM, &v.CreatedAt)
v.CSRPEM = csrPEM.String
if err != nil {
return nil, fmt.Errorf("failed to get latest certificate version: %w", err)
+77 -18
View File
@@ -22,7 +22,7 @@ func NewJobRepository(db *sql.DB) *JobRepository {
// List returns all jobs
func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at
FROM jobs
ORDER BY created_at DESC
@@ -52,7 +52,7 @@ func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
// Get retrieves a job by ID
func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at
FROM jobs
WHERE id = $1
@@ -77,11 +77,11 @@ func (r *JobRepository) Create(ctx context.Context, job *domain.Job) error {
err := r.db.QueryRowContext(ctx, `
INSERT INTO jobs (
id, type, certificate_id, target_id, status, attempts, max_attempts,
id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id
`, job.ID, job.Type, job.CertificateID, job.TargetID, job.Status, job.Attempts,
`, job.ID, job.Type, job.CertificateID, job.TargetID, job.AgentID, job.Status, job.Attempts,
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt, job.CompletedAt,
job.CreatedAt).Scan(&job.ID)
@@ -99,15 +99,16 @@ func (r *JobRepository) Update(ctx context.Context, job *domain.Job) error {
type = $1,
certificate_id = $2,
target_id = $3,
status = $4,
attempts = $5,
max_attempts = $6,
last_error = $7,
scheduled_at = $8,
started_at = $9,
completed_at = $10
WHERE id = $11
`, job.Type, job.CertificateID, job.TargetID, job.Status, job.Attempts,
agent_id = $4,
status = $5,
attempts = $6,
max_attempts = $7,
last_error = $8,
scheduled_at = $9,
started_at = $10,
completed_at = $11
WHERE id = $12
`, job.Type, job.CertificateID, job.TargetID, job.AgentID, job.Status, job.Attempts,
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt,
job.CompletedAt, job.ID)
@@ -150,7 +151,7 @@ func (r *JobRepository) Delete(ctx context.Context, id string) error {
// ListByStatus returns jobs with a specific status
func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at
FROM jobs
WHERE status = $1
@@ -181,7 +182,7 @@ func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatu
// ListByCertificate returns all jobs for a certificate
func (r *JobRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at
FROM jobs
WHERE certificate_id = $1
@@ -239,7 +240,7 @@ func (r *JobRepository) UpdateStatus(ctx context.Context, id string, status doma
// GetPendingJobs returns jobs not yet processed of a specific type
func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at
FROM jobs
WHERE type = $1 AND status = $2
@@ -267,13 +268,71 @@ func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobTy
return jobs, nil
}
// ListPendingByAgentID returns pending deployment jobs and AwaitingCSR jobs for a specific agent.
// Deployment jobs are matched by agent_id directly (set at creation time), with a fallback
// for legacy jobs where agent_id is NULL but target_id resolves to the agent via deployment_targets.
// AwaitingCSR jobs are matched through certificate → target mappings → agent ownership.
func (r *JobRepository) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at
FROM jobs
WHERE agent_id = $1 AND status = 'Pending' AND type = 'Deployment'
UNION ALL
SELECT j.id, j.type, j.certificate_id, j.target_id, j.agent_id, j.status, j.attempts, j.max_attempts,
j.last_error, j.scheduled_at, j.started_at, j.completed_at, j.created_at
FROM jobs j
INNER JOIN deployment_targets dt ON j.target_id = dt.id
WHERE j.agent_id IS NULL AND j.status = 'Pending' AND j.type = 'Deployment'
AND dt.agent_id = $1
UNION ALL
SELECT j.id, j.type, j.certificate_id, j.target_id, j.agent_id, j.status, j.attempts, j.max_attempts,
j.last_error, j.scheduled_at, j.started_at, j.completed_at, j.created_at
FROM jobs j
WHERE j.status = 'AwaitingCSR'
AND j.type IN ('Renewal', 'Issuance')
AND EXISTS (
SELECT 1 FROM certificate_target_mappings ctm
INNER JOIN deployment_targets dt ON ctm.target_id = dt.id
WHERE ctm.certificate_id = j.certificate_id
AND dt.agent_id = $1
)
ORDER BY created_at ASC
`, agentID)
if err != nil {
return nil, fmt.Errorf("failed to query pending jobs for agent: %w", err)
}
defer rows.Close()
var jobs []*domain.Job
for rows.Next() {
job, err := scanJob(rows)
if err != nil {
return nil, err
}
jobs = append(jobs, job)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating pending agent job rows: %w", err)
}
return jobs, nil
}
// scanJob scans a job from a row or rows
func scanJob(scanner interface {
Scan(...interface{}) error
}) (*domain.Job, error) {
var job domain.Job
err := scanner.Scan(&job.ID, &job.Type, &job.CertificateID, &job.TargetID,
&job.Status, &job.Attempts, &job.MaxAttempts, &job.LastError,
&job.AgentID, &job.Status, &job.Attempts, &job.MaxAttempts, &job.LastError,
&job.ScheduledAt, &job.StartedAt, &job.CompletedAt, &job.CreatedAt)
if err != nil {
+5 -26
View File
@@ -251,38 +251,17 @@ func (s *AgentService) GetCertificateForAgent(ctx context.Context, agentID strin
// GetPendingWork returns actionable jobs for an agent: deployment jobs (Pending) and
// renewal/issuance jobs awaiting CSR submission (AwaitingCSR).
// Jobs are scoped to the requesting agent via agent_id (set at job creation) or
// through target→agent relationships for legacy jobs and AwaitingCSR routing.
func (s *AgentService) GetPendingWork(ctx context.Context, agentID string) ([]*domain.Job, error) {
// Fetch agent to verify it exists
// Verify agent exists
_, err := s.agentRepo.Get(ctx, agentID)
if err != nil {
return nil, fmt.Errorf("failed to fetch agent: %w", err)
}
var workForAgent []*domain.Job
// Get pending deployment jobs
pendingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusPending)
if err != nil {
return nil, fmt.Errorf("failed to list pending jobs: %w", err)
}
for _, job := range pendingJobs {
if job.Type == domain.JobTypeDeployment {
workForAgent = append(workForAgent, job)
}
}
// Get AwaitingCSR jobs (agent keygen mode — agent needs to generate key + submit CSR)
awaitingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusAwaitingCSR)
if err != nil {
return nil, fmt.Errorf("failed to list awaiting CSR jobs: %w", err)
}
for _, job := range awaitingJobs {
if job.Type == domain.JobTypeRenewal || job.Type == domain.JobTypeIssuance {
workForAgent = append(workForAgent, job)
}
}
return workForAgent, nil
// Return only jobs assigned to this agent (via agent_id or target→agent relationship)
return s.jobRepo.ListPendingByAgentID(ctx, agentID)
}
// ReportJobStatus updates a job's status based on agent feedback.
+127 -4
View File
@@ -131,8 +131,9 @@ func TestHeartbeat_NotFound(t *testing.T) {
func TestGetPendingWork(t *testing.T) {
ctx := context.Background()
now := time.Now()
agentID := "agent-001"
agent := &domain.Agent{
ID: "agent-001",
ID: agentID,
Name: "prod-agent",
Hostname: "server-01",
Status: domain.AgentStatusOnline,
@@ -146,6 +147,7 @@ func TestGetPendingWork(t *testing.T) {
Type: domain.JobTypeDeployment,
CertificateID: "cert-001",
Status: domain.JobStatusPending,
AgentID: &agentID,
CreatedAt: now,
}
job2 := &domain.Job{
@@ -157,7 +159,7 @@ func TestGetPendingWork(t *testing.T) {
}
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{"agent-001": agent},
Agents: map[string]*domain.Agent{agentID: agent},
HeartbeatUpdates: make(map[string]time.Time),
}
certRepo := &mockCertRepo{
@@ -177,7 +179,7 @@ func TestGetPendingWork(t *testing.T) {
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
jobs, err := agentService.GetPendingWork(ctx, "agent-001")
jobs, err := agentService.GetPendingWork(ctx, agentID)
if err != nil {
t.Fatalf("GetPendingWork failed: %v", err)
}
@@ -185,11 +187,132 @@ func TestGetPendingWork(t *testing.T) {
if len(jobs) != 1 {
t.Errorf("expected 1 deployment job, got %d", len(jobs))
}
if jobs[0].Type != domain.JobTypeDeployment {
if len(jobs) > 0 && jobs[0].Type != domain.JobTypeDeployment {
t.Errorf("expected JobTypeDeployment, got %s", jobs[0].Type)
}
}
func TestGetPendingWork_OnlyReturnsAgentJobs(t *testing.T) {
ctx := context.Background()
now := time.Now()
agentA := "agent-A"
agentB := "agent-B"
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
agentB: {ID: agentB, Name: "agent-B", Hostname: "host-b", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashB"},
},
HeartbeatUpdates: make(map[string]time.Time),
}
jobA := &domain.Job{ID: "job-A", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentA, CreatedAt: now}
jobB := &domain.Job{ID: "job-B", Type: domain.JobTypeDeployment, CertificateID: "cert-002", Status: domain.JobStatusPending, AgentID: &agentB, CreatedAt: now}
jobRepo := &mockJobRepo{
Jobs: map[string]*domain.Job{"job-A": jobA, "job-B": jobB},
StatusUpdates: make(map[string]domain.JobStatus),
}
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
auditService := NewAuditService(&mockAuditRepo{})
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
// Agent A should only see its job
jobsA, err := agentService.GetPendingWork(ctx, agentA)
if err != nil {
t.Fatalf("GetPendingWork for agent-A failed: %v", err)
}
if len(jobsA) != 1 {
t.Fatalf("expected 1 job for agent-A, got %d", len(jobsA))
}
if jobsA[0].ID != "job-A" {
t.Errorf("expected job-A, got %s", jobsA[0].ID)
}
// Agent B should only see its job
jobsB, err := agentService.GetPendingWork(ctx, agentB)
if err != nil {
t.Fatalf("GetPendingWork for agent-B failed: %v", err)
}
if len(jobsB) != 1 {
t.Fatalf("expected 1 job for agent-B, got %d", len(jobsB))
}
if jobsB[0].ID != "job-B" {
t.Errorf("expected job-B, got %s", jobsB[0].ID)
}
}
func TestGetPendingWork_EmptyWhenNoJobsForAgent(t *testing.T) {
ctx := context.Background()
now := time.Now()
agentA := "agent-A"
agentB := "agent-B"
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
},
HeartbeatUpdates: make(map[string]time.Time),
}
// All jobs belong to agent-B
jobB := &domain.Job{ID: "job-B", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentB, CreatedAt: now}
jobRepo := &mockJobRepo{
Jobs: map[string]*domain.Job{"job-B": jobB},
StatusUpdates: make(map[string]domain.JobStatus),
}
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
auditService := NewAuditService(&mockAuditRepo{})
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
jobs, err := agentService.GetPendingWork(ctx, agentA)
if err != nil {
t.Fatalf("GetPendingWork failed: %v", err)
}
if len(jobs) != 0 {
t.Errorf("expected 0 jobs for agent-A (all jobs are for agent-B), got %d", len(jobs))
}
}
func TestGetPendingWork_DeploymentAndCSR_Scoped(t *testing.T) {
ctx := context.Background()
now := time.Now()
agentA := "agent-A"
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
},
HeartbeatUpdates: make(map[string]time.Time),
}
deployJob := &domain.Job{ID: "job-deploy", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentA, CreatedAt: now}
csrJob := &domain.Job{ID: "job-csr", Type: domain.JobTypeRenewal, CertificateID: "cert-002", Status: domain.JobStatusAwaitingCSR, AgentID: &agentA, CreatedAt: now}
jobRepo := &mockJobRepo{
Jobs: map[string]*domain.Job{"job-deploy": deployJob, "job-csr": csrJob},
StatusUpdates: make(map[string]domain.JobStatus),
}
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
auditService := NewAuditService(&mockAuditRepo{})
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
jobs, err := agentService.GetPendingWork(ctx, agentA)
if err != nil {
t.Fatalf("GetPendingWork failed: %v", err)
}
if len(jobs) != 2 {
t.Fatalf("expected 2 jobs (deployment + AwaitingCSR), got %d", len(jobs))
}
}
func TestReportJobStatus(t *testing.T) {
ctx := context.Background()
now := time.Now()
+56 -4
View File
@@ -304,6 +304,14 @@ func (s *CertificateService) CreateCertificate(cert domain.ManagedCertificate) (
if cert.UpdatedAt.IsZero() {
cert.UpdatedAt = now
}
// Default status to Pending if not set (DB column DEFAULT only applies when column is omitted from INSERT)
if cert.Status == "" {
cert.Status = domain.CertificateStatusPending
}
// Default tags to empty map if nil (avoids JSON null in JSONB column)
if cert.Tags == nil {
cert.Tags = make(map[string]string)
}
if err := s.certRepo.Create(context.Background(), &cert); err != nil {
return nil, fmt.Errorf("failed to create certificate: %w", err)
}
@@ -311,12 +319,56 @@ func (s *CertificateService) CreateCertificate(cert domain.ManagedCertificate) (
}
// UpdateCertificate modifies a certificate (handler interface method).
func (s *CertificateService) UpdateCertificate(id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
cert.ID = id
if err := s.certRepo.Update(context.Background(), &cert); err != nil {
func (s *CertificateService) UpdateCertificate(id string, patch domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
ctx := context.Background()
// Fetch existing certificate so partial updates don't zero out fields
existing, err := s.certRepo.Get(ctx, id)
if err != nil {
return nil, fmt.Errorf("certificate not found: %w", err)
}
// Merge non-zero fields from patch into existing
if patch.Name != "" {
existing.Name = patch.Name
}
if patch.CommonName != "" {
existing.CommonName = patch.CommonName
}
if len(patch.SANs) > 0 {
existing.SANs = patch.SANs
}
if patch.Environment != "" {
existing.Environment = patch.Environment
}
if patch.OwnerID != "" {
existing.OwnerID = patch.OwnerID
}
if patch.TeamID != "" {
existing.TeamID = patch.TeamID
}
if patch.IssuerID != "" {
existing.IssuerID = patch.IssuerID
}
if patch.RenewalPolicyID != "" {
existing.RenewalPolicyID = patch.RenewalPolicyID
}
if patch.CertificateProfileID != "" {
existing.CertificateProfileID = patch.CertificateProfileID
}
if patch.Status != "" {
existing.Status = patch.Status
}
if patch.Tags != nil {
existing.Tags = patch.Tags
}
existing.UpdatedAt = time.Now()
if err := s.certRepo.Update(ctx, existing); err != nil {
return nil, fmt.Errorf("failed to update certificate: %w", err)
}
return &cert, nil
return existing, nil
}
// ArchiveCertificate marks a certificate as archived (handler interface method).
+5
View File
@@ -67,6 +67,11 @@ func (s *DeploymentService) CreateDeploymentJobs(ctx context.Context, certID str
if target.ID != "" {
job.TargetID = &target.ID
}
// Route job to the target's assigned agent
if target.AgentID != "" {
agentID := target.AgentID
job.AgentID = &agentID
}
if err := s.jobRepo.Create(ctx, job); err != nil {
slog.Error("failed to create deployment job for target", "target_id", target.ID, "error", err)
+39
View File
@@ -85,6 +85,45 @@ func TestDeploymentService_CreateDeploymentJobs_Success(t *testing.T) {
if job.TargetID == nil || len(*job.TargetID) == 0 {
t.Errorf("expected job to have TargetID set")
}
// M31: Verify AgentID is set from target's agent assignment
if job.AgentID == nil {
t.Errorf("expected job to have AgentID set (M31 agent routing)")
}
}
}
// TestDeploymentService_CreateDeploymentJobs_SetsAgentID verifies AgentID is populated from target.
func TestDeploymentService_CreateDeploymentJobs_SetsAgentID(t *testing.T) {
ctx := context.Background()
svc, jobRepo, targetRepo, _, _, _, _ := newTestDeploymentService()
target := &domain.DeploymentTarget{
ID: "tgt-nginx-1",
Name: "NGINX Server 1",
Type: domain.TargetTypeNGINX,
AgentID: "agent-web-01",
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
targetRepo.AddTarget(target)
jobIDs, err := svc.CreateDeploymentJobs(ctx, "mc-cert-1")
if err != nil {
t.Fatalf("CreateDeploymentJobs failed: %v", err)
}
if len(jobIDs) != 1 {
t.Fatalf("expected 1 job, got %d", len(jobIDs))
}
job := jobRepo.Jobs[jobIDs[0]]
if job.AgentID == nil {
t.Fatal("expected AgentID to be set on deployment job")
}
if *job.AgentID != "agent-web-01" {
t.Errorf("expected AgentID 'agent-web-01', got '%s'", *job.AgentID)
}
}
+10 -5
View File
@@ -40,6 +40,11 @@ func (s *DiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *d
return nil, fmt.Errorf("report must contain at least one certificate or error")
}
// Ensure directories is never nil (PostgreSQL TEXT[] NOT NULL)
if report.Directories == nil {
report.Directories = []string{}
}
now := time.Now()
scan := &domain.DiscoveryScan{
ID: generateID("dscan"),
@@ -52,6 +57,11 @@ func (s *DiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *d
CompletedAt: &now,
}
// Store the scan record first (discovered certs reference scan via FK)
if err := s.discoveryRepo.CreateScan(ctx, scan); err != nil {
return nil, fmt.Errorf("failed to create scan record: %w", err)
}
// Upsert each discovered certificate
newCount := 0
for _, entry := range report.Certificates {
@@ -105,11 +115,6 @@ func (s *DiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *d
scan.CertificatesNew = newCount
// Store the scan record
if err := s.discoveryRepo.CreateScan(ctx, scan); err != nil {
return nil, fmt.Errorf("failed to create scan record: %w", err)
}
// Audit trail
if err := s.auditService.RecordEvent(ctx, report.AgentID, domain.ActorTypeSystem,
"discovery_scan_completed", "discovery_scan", scan.ID,
+1 -1
View File
@@ -88,7 +88,7 @@ func (s *ExportService) ExportPKCS12(ctx context.Context, certID string, passwor
// Parse PEM chain into x509.Certificate objects
certs, err := parsePEMCertificates(version.PEMChain)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate chain: %w", err)
return nil, fmt.Errorf("certificate data cannot be parsed as X.509: %w", err)
}
if len(certs) == 0 {
+55 -3
View File
@@ -26,12 +26,18 @@ type RenewalService struct {
jobRepo repository.JobRepository
renewalPolicyRepo repository.RenewalPolicyRepository
profileRepo repository.CertificateProfileRepository
targetRepo repository.TargetRepository
auditService *AuditService
notificationSvc *NotificationService
issuerRegistry map[string]IssuerConnector
keygenMode string // "agent" (default) or "server" (demo only)
}
// SetTargetRepo sets the target repository for resolving agent_id on deployment jobs.
func (s *RenewalService) SetTargetRepo(repo repository.TargetRepository) {
s.targetRepo = repo
}
// IssuerConnector defines the service-layer interface for interacting with certificate issuers.
// This is distinct from the connector-layer issuer.Connector interface to maintain dependency
// inversion. Use IssuerConnectorAdapter to bridge between the two.
@@ -163,10 +169,39 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds)
// Only create renewal job if an issuer connector is registered for this cert's issuer
if _, hasIssuer := s.issuerRegistry[cert.IssuerID]; !hasIssuer {
connector, hasIssuer := s.issuerRegistry[cert.IssuerID]
if !hasIssuer {
continue
}
// ARI check (RFC 9702): if the issuer supports ARI, let the CA direct renewal timing.
// Fetch the latest cert version to get the PEM chain for the ARI query.
ariChecked := false
if version, vErr := s.certRepo.GetLatestVersion(ctx, cert.ID); vErr == nil && version != nil && version.PEMChain != "" {
if ariResult, ariErr := connector.GetRenewalInfo(ctx, version.PEMChain); ariErr != nil {
// ARI error is non-fatal — log and fall through to threshold-based renewal
slog.Warn("ARI check failed, falling back to threshold-based renewal",
"cert_id", cert.ID, "issuer_id", cert.IssuerID, "error", ariErr)
} else if ariResult != nil {
ariChecked = true
now := time.Now()
if now.Before(ariResult.SuggestedWindowStart) {
// CA says it's too early to renew — skip this cert
slog.Debug("ARI: renewal not yet suggested by CA",
"cert_id", cert.ID,
"suggested_start", ariResult.SuggestedWindowStart,
"suggested_end", ariResult.SuggestedWindowEnd)
continue
}
slog.Info("ARI: CA suggests renewal now",
"cert_id", cert.ID,
"suggested_start", ariResult.SuggestedWindowStart,
"suggested_end", ariResult.SuggestedWindowEnd)
}
// ariResult == nil means issuer doesn't support ARI — fall through to threshold logic
}
_ = ariChecked // used for audit metadata below
// Check for existing pending/running renewal jobs to avoid duplicates
existingJobs, err := s.jobRepo.ListByCertificate(ctx, cert.ID)
if err == nil {
@@ -206,9 +241,12 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
}
// Record audit event
auditMeta := map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID}
if ariChecked {
auditMeta["renewal_trigger"] = "ari"
}
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"renewal_job_created", "certificate", cert.ID,
map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID}); auditErr != nil {
"renewal_job_created", "certificate", cert.ID, auditMeta); auditErr != nil {
slog.Error("failed to record audit event", "error", auditErr)
}
}
@@ -604,12 +642,26 @@ func (s *RenewalService) createDeploymentJobs(ctx context.Context, cert *domain.
}
for _, targetID := range cert.TargetIDs {
tid := targetID
// Resolve agent_id from target for job routing
var agentIDPtr *string
if s.targetRepo != nil {
target, err := s.targetRepo.Get(ctx, tid)
if err != nil {
slog.Warn("failed to resolve agent for deployment job", "target_id", tid, "error", err)
} else if target.AgentID != "" {
agentID := target.AgentID
agentIDPtr = &agentID
}
}
deployJob := &domain.Job{
ID: generateID("job"),
CertificateID: cert.ID,
Type: domain.JobTypeDeployment,
Status: domain.JobStatusPending,
TargetID: &tid,
AgentID: agentIDPtr,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
+279
View File
@@ -863,4 +863,283 @@ func TestProcessRenewalJob_NoCertificate(t *testing.T) {
}
}
// --- ARI (RFC 9702) Scheduler Integration Tests ---
func TestCheckExpiringCertificates_ARI_ShouldRenewNow(t *testing.T) {
t.Helper()
ctx := context.Background()
certRepo := newMockCertificateRepository()
jobRepo := newMockJobRepository()
policyRepo := newMockRenewalPolicyRepository()
auditRepo := newMockAuditRepository()
notifRepo := newMockNotificationRepository()
auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
// ARI says renew now: window started in the past
ariConnector := &mockIssuerConnector{
getRenewalInfoResult: &RenewalInfoResult{
SuggestedWindowStart: time.Now().Add(-24 * time.Hour),
SuggestedWindowEnd: time.Now().Add(48 * time.Hour),
},
}
issuerRegistry := map[string]IssuerConnector{
"iss-acme": ariConnector,
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
// Create cert expiring in 20 days with a cert version (needed for ARI lookup)
cert := &domain.ManagedCertificate{
ID: "mc-ari-renew",
Name: "ARI Cert",
CommonName: "ari.example.com",
SANs: []string{},
OwnerID: "owner-1",
TeamID: "team-1",
IssuerID: "iss-acme",
RenewalPolicyID: "rp-standard",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 0, 20),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
{ID: "cv-1", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
}
policy := &domain.RenewalPolicy{
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
AlertThresholdsDays: []int{30, 14, 7, 0},
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
policyRepo.AddPolicy(policy)
err := svc.CheckExpiringCertificates(ctx)
if err != nil {
t.Fatalf("CheckExpiringCertificates failed: %v", err)
}
// ARI says renew now, so a renewal job should be created
hasRenewalJob := false
for _, job := range jobRepo.Jobs {
if job.Type == domain.JobTypeRenewal {
hasRenewalJob = true
break
}
}
if !hasRenewalJob {
t.Errorf("expected renewal job when ARI ShouldRenewNow is true")
}
}
func TestCheckExpiringCertificates_ARI_NotYet(t *testing.T) {
t.Helper()
ctx := context.Background()
certRepo := newMockCertificateRepository()
jobRepo := newMockJobRepository()
policyRepo := newMockRenewalPolicyRepository()
auditRepo := newMockAuditRepository()
notifRepo := newMockNotificationRepository()
auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
// ARI says NOT yet: window starts in the future
ariConnector := &mockIssuerConnector{
getRenewalInfoResult: &RenewalInfoResult{
SuggestedWindowStart: time.Now().Add(72 * time.Hour),
SuggestedWindowEnd: time.Now().Add(96 * time.Hour),
},
}
issuerRegistry := map[string]IssuerConnector{
"iss-acme": ariConnector,
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
// Cert is within the 30-day threshold window (would normally trigger renewal),
// but ARI says "not yet"
cert := &domain.ManagedCertificate{
ID: "mc-ari-wait",
Name: "ARI Wait Cert",
CommonName: "ari-wait.example.com",
SANs: []string{},
OwnerID: "owner-1",
TeamID: "team-1",
IssuerID: "iss-acme",
RenewalPolicyID: "rp-standard",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 0, 10),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
{ID: "cv-2", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
}
policy := &domain.RenewalPolicy{
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
AlertThresholdsDays: []int{30, 14, 7, 0},
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
policyRepo.AddPolicy(policy)
err := svc.CheckExpiringCertificates(ctx)
if err != nil {
t.Fatalf("CheckExpiringCertificates failed: %v", err)
}
// ARI says not yet, so NO renewal job should be created
for _, job := range jobRepo.Jobs {
if job.Type == domain.JobTypeRenewal {
t.Errorf("expected no renewal job when ARI says not yet, but found one")
}
}
}
func TestCheckExpiringCertificates_ARI_NilResult_FallsThrough(t *testing.T) {
t.Helper()
ctx := context.Background()
certRepo := newMockCertificateRepository()
jobRepo := newMockJobRepository()
policyRepo := newMockRenewalPolicyRepository()
auditRepo := newMockAuditRepository()
notifRepo := newMockNotificationRepository()
auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
// ARI returns nil (issuer doesn't support ARI) — default mock behavior
issuerRegistry := map[string]IssuerConnector{
"iss-local": &mockIssuerConnector{},
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
cert := &domain.ManagedCertificate{
ID: "mc-ari-nil",
Name: "No ARI Cert",
CommonName: "no-ari.example.com",
SANs: []string{},
OwnerID: "owner-1",
TeamID: "team-1",
IssuerID: "iss-local",
RenewalPolicyID: "rp-standard",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 0, 20),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
{ID: "cv-3", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
}
policy := &domain.RenewalPolicy{
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
AlertThresholdsDays: []int{30, 14, 7, 0},
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
policyRepo.AddPolicy(policy)
err := svc.CheckExpiringCertificates(ctx)
if err != nil {
t.Fatalf("CheckExpiringCertificates failed: %v", err)
}
// ARI is nil (not supported), so threshold-based logic applies; cert is within 30-day window
hasRenewalJob := false
for _, job := range jobRepo.Jobs {
if job.Type == domain.JobTypeRenewal {
hasRenewalJob = true
break
}
}
if !hasRenewalJob {
t.Errorf("expected renewal job via threshold fallback when ARI returns nil")
}
}
func TestCheckExpiringCertificates_ARI_Error_FallsThrough(t *testing.T) {
t.Helper()
ctx := context.Background()
certRepo := newMockCertificateRepository()
jobRepo := newMockJobRepository()
policyRepo := newMockRenewalPolicyRepository()
auditRepo := newMockAuditRepository()
notifRepo := newMockNotificationRepository()
auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
// ARI returns an error — should fall through to threshold-based renewal
ariConnector := &mockIssuerConnector{
getRenewalInfoErr: fmt.Errorf("ARI endpoint unreachable"),
}
issuerRegistry := map[string]IssuerConnector{
"iss-acme": ariConnector,
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
cert := &domain.ManagedCertificate{
ID: "mc-ari-err",
Name: "ARI Error Cert",
CommonName: "ari-err.example.com",
SANs: []string{},
OwnerID: "owner-1",
TeamID: "team-1",
IssuerID: "iss-acme",
RenewalPolicyID: "rp-standard",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 0, 15),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
{ID: "cv-4", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
}
policy := &domain.RenewalPolicy{
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
AlertThresholdsDays: []int{30, 14, 7, 0},
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
policyRepo.AddPolicy(policy)
err := svc.CheckExpiringCertificates(ctx)
if err != nil {
t.Fatalf("CheckExpiringCertificates failed: %v", err)
}
// ARI failed but renewal should still happen via threshold fallback
hasRenewalJob := false
for _, job := range jobRepo.Jobs {
if job.Type == domain.JobTypeRenewal {
hasRenewalJob = true
break
}
}
if !hasRenewalJob {
t.Errorf("expected renewal job via threshold fallback when ARI errors")
}
}
// stringPtr is defined in notification_test.go
+2 -2
View File
@@ -321,8 +321,8 @@ func TestTeamService_Create_EmptyName(t *testing.T) {
t.Fatalf("expected validation error for empty name, got nil")
}
if !errors.Is(err, errors.New("team name is required")) {
t.Logf("error: %v", err)
if !strings.Contains(err.Error(), "team name is required") {
t.Errorf("expected error containing 'team name is required', got: %v", err)
}
}
+30 -9
View File
@@ -243,6 +243,25 @@ func (m *mockJobRepo) GetPendingJobs(ctx context.Context, jobType domain.JobType
return jobs, nil
}
func (m *mockJobRepo) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListErr != nil {
return nil, m.ListErr
}
var result []*domain.Job
for _, j := range m.Jobs {
if j.AgentID != nil && *j.AgentID == agentID {
if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
result = append(result, j)
} else if j.Status == domain.JobStatusAwaitingCSR {
result = append(result, j)
}
}
}
return result, nil
}
func (m *mockJobRepo) AddJob(job *domain.Job) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -660,8 +679,10 @@ func (m *mockTargetRepo) AddTarget(target *domain.DeploymentTarget) {
// mockIssuerConnector is a test implementation of IssuerConnector
type mockIssuerConnector struct {
Result *IssuanceResult
Err error
Result *IssuanceResult
Err error
getRenewalInfoResult *RenewalInfoResult
getRenewalInfoErr error
}
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) {
@@ -717,14 +738,14 @@ func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error)
}
func (m *mockIssuerConnector) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) {
if m.Err != nil {
return nil, m.Err
if m.getRenewalInfoErr != nil {
return nil, m.getRenewalInfoErr
}
now := time.Now()
return &RenewalInfoResult{
SuggestedWindowStart: now,
SuggestedWindowEnd: now.Add(7 * 24 * time.Hour),
}, nil
if m.getRenewalInfoResult != nil {
return m.getRenewalInfoResult, nil
}
// Default: return nil, nil (issuer does not support ARI)
return nil, nil
}
// Constructor functions for mocks
+4
View File
@@ -65,6 +65,10 @@ func (m *mockVerificationJobRepo) GetPendingJobs(ctx context.Context, jobType do
return nil, nil
}
func (m *mockVerificationJobRepo) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
return nil, nil
}
// newVerificationTestService creates a VerificationService wired with test doubles.
func newVerificationTestService(jobs map[string]*domain.Job, jobRepoErr error) (*VerificationService, *mockVerificationJobRepo, *mockAuditRepo) {
jobRepo := &mockVerificationJobRepo{jobs: jobs, err: jobRepoErr}
+3 -1
View File
@@ -43,7 +43,9 @@ INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) VA
('iss-acme-le', 'Let''s Encrypt Staging', 'acme', '{"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory", "email": "admin@example.com", "challenge_type": "http-01"}', true, NOW() - INTERVAL '150 days', NOW() - INTERVAL '150 days'),
('iss-stepca', 'step-ca Internal', 'stepca', '{"ca_url": "https://ca.internal:9000", "provisioner_name": "certctl", "validity_days": 90}', true, NOW() - INTERVAL '120 days', NOW() - INTERVAL '120 days'),
('iss-acme-zs', 'ZeroSSL (EAB)', 'acme', '{"directory_url": "https://acme.zerossl.com/v2/DV90", "email": "admin@example.com", "challenge_type": "http-01"}', true, NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days'),
('iss-openssl', 'Custom OpenSSL CA', 'openssl', '{"sign_script": "/opt/ca/sign.sh", "timeout_seconds": 30}', false, NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days')
('iss-openssl', 'Custom OpenSSL CA', 'openssl', '{"sign_script": "/opt/ca/sign.sh", "timeout_seconds": 30}', false, NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'),
('iss-vault', 'HashiCorp Vault PKI', 'VaultPKI', '{"addr": "https://vault.internal:8200", "mount": "pki", "role": "web-certs", "ttl": "8760h"}', true, NOW() - INTERVAL '20 days', NOW() - INTERVAL '20 days'),
('iss-digicert', 'DigiCert CertCentral', 'DigiCert', '{"base_url": "https://www.digicert.com/services/v2", "product_type": "ssl_basic"}', true, NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
+199
View File
@@ -78,6 +78,17 @@ import {
triggerNetworkScan,
previewDigest,
sendDigest,
getJob,
getJobVerification,
getIssuer,
getTarget,
getPrometheusMetrics,
getCertificateDeployments,
getCRL,
getOCSPStatus,
updateIssuer,
updateTarget,
getPolicy,
} from './client';
// Mock global fetch
@@ -627,6 +638,50 @@ describe('API Client', () => {
expect(url).toBe('/api/v1/issuers');
expect(init.method).toBe('POST');
});
it('createIssuer sends correct payload for VaultPKI type', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-vault', name: 'Vault PKI' }));
const vaultPayload = {
name: 'Vault PKI',
type: 'VaultPKI',
config: {
addr: 'https://vault.internal:8200',
token: 'hvs.test-token',
mount: 'pki',
role: 'web-certs',
ttl: '8760h',
},
};
await createIssuer(vaultPayload);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/issuers');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body);
expect(body.type).toBe('VaultPKI');
expect(body.config.addr).toBe('https://vault.internal:8200');
expect(body.config.role).toBe('web-certs');
});
it('createIssuer sends correct payload for DigiCert type', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-digicert', name: 'DigiCert' }));
const digicertPayload = {
name: 'DigiCert CertCentral',
type: 'DigiCert',
config: {
api_key: 'test-api-key',
org_id: '12345',
product_type: 'ssl_basic',
},
};
await createIssuer(digicertPayload);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/issuers');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body);
expect(body.type).toBe('DigiCert');
expect(body.config.org_id).toBe('12345');
expect(body.config.product_type).toBe('ssl_basic');
});
});
// ─── Audit ──────────────────────────────────────────
@@ -1006,4 +1061,148 @@ describe('API Client', () => {
expect(result.message).toBe('digest sent');
});
});
// ─── Job Detail ────────────────────────────
describe('Job Detail', () => {
it('getJob fetches single job by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'job-1', type: 'Deployment', status: 'Completed' }));
const result = await getJob('job-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/jobs/job-1');
expect(result.id).toBe('job-1');
expect(result.type).toBe('Deployment');
});
it('getJobVerification fetches verification result', async () => {
const verificationData = {
job_id: 'job-1',
target_id: 't-nginx1',
verified: true,
actual_fingerprint: 'abc123',
expected_fingerprint: 'abc123',
verified_at: '2026-03-28T12:00:00Z',
};
mockFetch.mockReturnValueOnce(mockJsonResponse(verificationData));
const result = await getJobVerification('job-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/jobs/job-1/verification');
expect(result.verified).toBe(true);
expect(result.actual_fingerprint).toBe('abc123');
});
});
// ─── Issuer Detail ─────────────────────────
describe('Issuer Detail', () => {
it('getIssuer fetches single issuer by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-local', name: 'Local CA', type: 'local_ca', status: 'active' }));
const result = await getIssuer('iss-local');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/issuers/iss-local');
expect(result.name).toBe('Local CA');
expect(result.type).toBe('local_ca');
});
});
// ─── Target Detail ─────────────────────────
describe('Target Detail', () => {
it('getTarget fetches single target by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-nginx1', name: 'Web Server', type: 'nginx', hostname: 'web1.example.com' }));
const result = await getTarget('t-nginx1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/targets/t-nginx1');
expect(result.name).toBe('Web Server');
expect(result.type).toBe('nginx');
});
});
// ─── Prometheus Metrics ────────────────────
describe('Prometheus Metrics', () => {
it('getPrometheusMetrics fetches text format', async () => {
const metricsText = '# HELP certctl_certificate_total Total certificates\ncertctl_certificate_total 10';
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(metricsText),
} as Response)
);
const result = await getPrometheusMetrics();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/metrics/prometheus');
expect(result).toContain('certctl_certificate_total');
});
it('getPrometheusMetrics throws on error', async () => {
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: false,
status: 500,
text: () => Promise.resolve('error'),
} as Response)
);
await expect(getPrometheusMetrics()).rejects.toThrow('Prometheus metrics failed: 500');
});
it('getPrometheusMetrics includes auth header', async () => {
setApiKey('prom-key');
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve('metrics'),
} as Response)
);
await getPrometheusMetrics();
const [, init] = mockFetch.mock.calls[0];
expect(init.headers['Authorization']).toBe('Bearer prom-key');
});
});
describe('Frontend Audit: New API Functions', () => {
it('getCertificateDeployments sends GET with cert ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0 }));
await getCertificateDeployments('mc-1');
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/certificates/mc-1/deployments');
});
it('getCRL sends GET to /crl', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ entries: [], total: 0 }));
await getCRL();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/crl');
});
it('getOCSPStatus sends GET with issuer and serial', async () => {
const buf = new ArrayBuffer(8);
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
arrayBuffer: () => Promise.resolve(buf),
} as Response)
);
await getOCSPStatus('iss-local', 'ABC123');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/ocsp/iss-local/ABC123');
});
it('updateIssuer sends PUT with data', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-1', name: 'Updated' }));
await updateIssuer('iss-1', { name: 'Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/issuers/iss-1');
expect(init.method).toBe('PUT');
});
it('updateTarget sends PUT with data', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-1', name: 'Updated' }));
await updateTarget('t-1', { name: 'Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/targets/t-1');
expect(init.method).toBe('PUT');
});
it('getPolicy sends GET with policy ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'pol-1', name: 'Test' }));
await getPolicy('pol-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/policies/pol-1');
});
});
});
+56
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
export const getAgents = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
@@ -170,6 +190,9 @@ export const createPolicy = (data: Partial<PolicyRule>) =>
export const updatePolicy = (id: string, data: Partial<PolicyRule>) =>
fetchJSON<PolicyRule>(`${BASE}/policies/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const getPolicy = (id: string) =>
fetchJSON<PolicyRule>(`${BASE}/policies/${id}`);
export const deletePolicy = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/policies/${id}`, { method: 'DELETE' });
@@ -188,6 +211,9 @@ export const createIssuer = (data: Partial<Issuer>) =>
export const testIssuerConnection = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}/test`, { method: 'POST' });
export const updateIssuer = (id: string, data: Partial<Issuer>) =>
fetchJSON<Issuer>(`${BASE}/issuers/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteIssuer = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}`, { method: 'DELETE' });
@@ -200,6 +226,9 @@ export const getTargets = (params: Record<string, string> = {}) => {
export const createTarget = (data: Partial<Target>) =>
fetchJSON<Target>(`${BASE}/targets`, { method: 'POST', body: JSON.stringify(data) });
export const updateTarget = (id: string, data: Partial<Target>) =>
fetchJSON<Target>(`${BASE}/targets/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteTarget = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
@@ -365,5 +394,32 @@ export const previewDigest = () => {
export const sendDigest = () =>
fetchJSON<{ message: string }>(`${BASE}/digest/send`, { method: 'POST' });
// Jobs (single)
export const getJob = (id: string) =>
fetchJSON<Job>(`${BASE}/jobs/${id}`);
// Job Verification
export const getJobVerification = (id: string) =>
fetchJSON<{ job_id: string; target_id: string; verified: boolean; actual_fingerprint: string; expected_fingerprint: string; verified_at: string; error?: string }>(`${BASE}/jobs/${id}/verification`);
// Issuers (single)
export const getIssuer = (id: string) =>
fetchJSON<Issuer>(`${BASE}/issuers/${id}`);
// Targets (single)
export const getTarget = (id: string) =>
fetchJSON<Target>(`${BASE}/targets/${id}`);
// Prometheus metrics (text format)
export const getPrometheusMetrics = () => {
const headers: Record<string, string> = {};
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
return fetch(`${BASE}/metrics/prometheus`, { headers })
.then(r => {
if (!r.ok) throw new Error(`Prometheus metrics failed: ${r.status}`);
return r.text();
});
};
// Health
export const getHealth = () => fetchJSON<{ status: string }>('/health');
+11
View File
@@ -18,7 +18,10 @@ export interface Certificate {
expires_at: string;
revoked_at?: string;
revocation_reason?: string;
target_ids?: string[];
tags: Record<string, string>;
last_renewal_at?: string;
last_deployment_at?: string;
created_at: string;
updated_at: string;
}
@@ -45,6 +48,8 @@ export interface CertificateVersion {
csr_pem: string;
not_before: string;
not_after: string;
key_algorithm?: string;
key_size?: number;
created_at: string;
}
@@ -70,6 +75,8 @@ export interface Job {
id: string;
certificate_id: string;
type: string;
target_id?: string;
agent_id?: string;
status: string;
attempts: number;
max_attempts: number;
@@ -133,7 +140,10 @@ export interface Issuer {
type: string;
config: Record<string, unknown>;
status: string;
/** Backend returns enabled boolean; status is derived from this */
enabled: boolean;
created_at: string;
updated_at?: string;
}
export interface Target {
@@ -145,6 +155,7 @@ export interface Target {
config: Record<string, unknown>;
status: string;
created_at: string;
updated_at?: string;
}
export interface KeyAlgorithmRule {
+3 -1
View File
@@ -19,6 +19,8 @@ const nav = [
{ to: '/discovery', label: 'Discovery', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' },
{ to: '/network-scans', label: 'Network Scans', icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 12l2 2 4-4' },
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
{ to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
{ to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
];
@@ -69,7 +71,7 @@ export default function Layout() {
</nav>
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.14</span>
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.20</span>
{authRequired && (
<button
onClick={logout}
+3
View File
@@ -23,6 +23,9 @@ const statusStyles: Record<string, string> = {
Unmanaged: 'badge-warning',
Managed: 'badge-success',
Dismissed: 'badge-neutral',
// Issuer statuses
Enabled: 'badge-success',
Disabled: 'badge-neutral',
// Notification statuses
sent: 'badge-success',
pending: 'badge-warning',
@@ -0,0 +1,56 @@
/**
* Full config viewer modal with sensitive field redaction.
* Replaces the 60-char truncation in the issuers table.
* Reusable for targets in M35 no IssuersPage-specific imports.
*/
import { isSensitiveKey } from '../../config/issuerTypes';
interface ConfigDetailModalProps {
title: string;
config: Record<string, unknown>;
onClose: () => void;
}
export default function ConfigDetailModal({ title, config, onClose }: ConfigDetailModalProps) {
const entries = Object.entries(config);
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div className="bg-surface border border-surface-border rounded-lg shadow-lg max-w-lg w-full mx-4">
<div className="border-b border-surface-border px-6 py-4 flex justify-between items-center">
<h2 className="text-lg font-semibold text-ink">{title}</h2>
<button onClick={onClose} className="text-ink-muted hover:text-ink transition-colors">
</button>
</div>
<div className="px-6 py-4 max-h-96 overflow-y-auto">
{entries.length === 0 ? (
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
) : (
<div className="space-y-0">
{entries.map(([key, val]) => {
const redacted = isSensitiveKey(key);
return (
<div key={key} className="flex justify-between py-2 border-b border-surface-border/50">
<span className="text-sm text-ink-muted">{key}</span>
<span className="text-sm text-ink font-mono text-right max-w-xs break-all">
{redacted ? '********' : String(val ?? '')}
</span>
</div>
);
})}
</div>
)}
</div>
<div className="border-t border-surface-border px-6 py-4 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 border border-surface-border rounded text-ink hover:bg-surface-hover transition-colors text-sm font-medium"
>
Close
</button>
</div>
</div>
</div>
);
}
+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
View File
@@ -25,6 +25,11 @@ import ShortLivedPage from './pages/ShortLivedPage';
import AgentFleetPage from './pages/AgentFleetPage';
import DiscoveryPage from './pages/DiscoveryPage';
import NetworkScanPage from './pages/NetworkScanPage';
import DigestPage from './pages/DigestPage';
import ObservabilityPage from './pages/ObservabilityPage';
import JobDetailPage from './pages/JobDetailPage';
import IssuerDetailPage from './pages/IssuerDetailPage';
import TargetDetailPage from './pages/TargetDetailPage';
import './index.css';
const queryClient = new QueryClient({
@@ -53,11 +58,14 @@ createRoot(document.getElementById('root')!).render(
<Route path="agents/:id" element={<AgentDetailPage />} />
<Route path="fleet" element={<AgentFleetPage />} />
<Route path="jobs" element={<JobsPage />} />
<Route path="jobs/:id" element={<JobDetailPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="policies" element={<PoliciesPage />} />
<Route path="profiles" element={<ProfilesPage />} />
<Route path="issuers" element={<IssuersPage />} />
<Route path="issuers/:id" element={<IssuerDetailPage />} />
<Route path="targets" element={<TargetsPage />} />
<Route path="targets/:id" element={<TargetDetailPage />} />
<Route path="owners" element={<OwnersPage />} />
<Route path="teams" element={<TeamsPage />} />
<Route path="agent-groups" element={<AgentGroupsPage />} />
@@ -65,6 +73,8 @@ createRoot(document.getElementById('root')!).render(
<Route path="short-lived" element={<ShortLivedPage />} />
<Route path="discovery" element={<DiscoveryPage />} />
<Route path="network-scans" element={<NetworkScanPage />} />
<Route path="digest" element={<DigestPage />} />
<Route path="observability" element={<ObservabilityPage />} />
</Route>
</Routes>
</BrowserRouter>
+10 -2
View File
@@ -13,6 +13,14 @@ const OS_COLORS: Record<string, string> = {
unknown: '#64748b',
};
const OS_DISPLAY_NAMES: Record<string, string> = {
darwin: 'macOS',
};
function displayOS(os: string): string {
return OS_DISPLAY_NAMES[os.toLowerCase()] || os;
}
const STATUS_COLORS: Record<string, string> = {
Online: '#10b981',
Offline: '#ef4444',
@@ -86,7 +94,7 @@ export default function AgentFleetPage() {
return acc;
}, {});
const osPieData = Object.entries(osDistribution).map(([name, value]) => ({
name,
name: displayOS(name),
value,
fill: OS_COLORS[name.toLowerCase()] || '#64748b',
}));
@@ -216,7 +224,7 @@ export default function AgentFleetPage() {
style={{ backgroundColor: OS_COLORS[group.os.toLowerCase()] || '#64748b' }}
/>
<h4 className="text-sm font-medium text-ink">
{group.os} / {group.arch}
{displayOS(group.os)} / {group.arch}
</h4>
<span className="text-xs text-ink-faint">
{group.agents.length} agent{group.agents.length !== 1 ? 's' : ''}
+149 -22
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners } from '../api/client';
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners, getProfiles, getIssuers } from '../api/client';
import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
@@ -13,83 +13,169 @@ import type { Certificate } from '../api/types';
function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
const [form, setForm] = useState({
name: '',
id: '',
common_name: '',
sans: '',
environment: 'production',
issuer_id: '',
certificate_profile_id: '',
owner_id: '',
team_id: '',
renewal_policy_id: '',
tags: '',
});
const [error, setError] = useState('');
const { data: profilesResp } = useQuery({
queryKey: ['profiles'],
queryFn: () => getProfiles(),
});
const { data: issuersResp } = useQuery({
queryKey: ['issuers'],
queryFn: () => getIssuers(),
});
const profiles = profilesResp?.data || [];
const issuers = issuersResp?.data || [];
const selectedProfile = profiles.find(p => p.id === form.certificate_profile_id);
const ttlLabel = selectedProfile
? selectedProfile.max_ttl_seconds < 3600
? `${Math.round(selectedProfile.max_ttl_seconds / 60)}m`
: selectedProfile.max_ttl_seconds < 86400
? `${Math.round(selectedProfile.max_ttl_seconds / 3600)}h`
: `${Math.round(selectedProfile.max_ttl_seconds / 86400)}d`
: null;
const mutation = useMutation({
mutationFn: () => createCertificate(form),
mutationFn: () => {
const payload: Record<string, unknown> = { ...form };
// Convert comma-separated SANs to array
if (form.sans.trim()) {
payload.sans = form.sans.split(',').map(s => s.trim()).filter(Boolean);
} else {
delete payload.sans;
}
// Convert comma-separated key=value tags to object
if (form.tags.trim()) {
const tags: Record<string, string> = {};
form.tags.split(',').forEach(pair => {
const [k, ...v] = pair.split('=');
if (k?.trim()) tags[k.trim()] = v.join('=').trim();
});
payload.tags = tags;
} else {
delete payload.tags;
}
return createCertificate(payload);
},
onSuccess: () => onSuccess(),
onError: (err: Error) => setError(err.message),
});
const inputClass = "w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20";
const selectClass = "w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink";
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-6 w-full max-w-lg shadow-xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">New Certificate</h2>
{error && <div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-4">{error}</div>}
<div className="space-y-3">
<div>
<label className="text-xs text-ink-muted block mb-1">Name *</label>
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className={inputClass}
placeholder="API Production Cert" />
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">ID (optional)</label>
<input value={form.id} onChange={e => setForm(f => ({ ...f, id: e.target.value }))}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
className={inputClass}
placeholder="mc-api-prod (auto-generated if empty)" />
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">Common Name *</label>
<input value={form.common_name} onChange={e => setForm(f => ({ ...f, common_name: e.target.value }))}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
className={inputClass}
placeholder="api.example.com" />
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">SANs (comma-separated)</label>
<input value={form.sans} onChange={e => setForm(f => ({ ...f, sans: e.target.value }))}
className={inputClass}
placeholder="api.example.com, api-v2.example.com" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-ink-muted block mb-1">Issuer *</label>
<select value={form.issuer_id} onChange={e => setForm(f => ({ ...f, issuer_id: e.target.value }))}
className={selectClass}>
<option value="">Select issuer...</option>
{issuers.map(i => (
<option key={i.id} value={i.id}>{i.name}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">
Profile {ttlLabel && <span className="text-brand-400 font-medium">(TTL: {ttlLabel})</span>}
</label>
<select value={form.certificate_profile_id} onChange={e => setForm(f => ({ ...f, certificate_profile_id: e.target.value }))}
className={selectClass}>
<option value="">Select profile...</option>
{profiles.map(p => (
<option key={p.id} value={p.id}>
{p.name}{p.max_ttl_seconds ? ` (${p.max_ttl_seconds < 3600 ? `${Math.round(p.max_ttl_seconds / 60)}m` : p.max_ttl_seconds < 86400 ? `${Math.round(p.max_ttl_seconds / 3600)}h` : `${Math.round(p.max_ttl_seconds / 86400)}d`})` : ''}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-ink-muted block mb-1">Environment</label>
<select value={form.environment} onChange={e => setForm(f => ({ ...f, environment: e.target.value }))}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink">
className={selectClass}>
<option value="production">Production</option>
<option value="staging">Staging</option>
<option value="development">Development</option>
</select>
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">Issuer ID *</label>
<input value={form.issuer_id} onChange={e => setForm(f => ({ ...f, issuer_id: e.target.value }))}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
placeholder="iss-local" />
<label className="text-xs text-ink-muted block mb-1">Policy</label>
<input value={form.renewal_policy_id} onChange={e => setForm(f => ({ ...f, renewal_policy_id: e.target.value }))}
className={inputClass}
placeholder="rp-standard" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-ink-muted block mb-1">Owner ID</label>
<label className="text-xs text-ink-muted block mb-1">Owner</label>
<input value={form.owner_id} onChange={e => setForm(f => ({ ...f, owner_id: e.target.value }))}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
className={inputClass}
placeholder="o-alice" />
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">Team ID</label>
<label className="text-xs text-ink-muted block mb-1">Team</label>
<input value={form.team_id} onChange={e => setForm(f => ({ ...f, team_id: e.target.value }))}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
className={inputClass}
placeholder="t-platform" />
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">Policy ID</label>
<input value={form.renewal_policy_id} onChange={e => setForm(f => ({ ...f, renewal_policy_id: e.target.value }))}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
placeholder="rp-standard" />
</div>
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">Tags</label>
<input value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
className={inputClass}
placeholder="env=prod, team=platform, app=api" />
<p className="text-xs text-ink-faint mt-0.5">Comma-separated key=value pairs</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button onClick={onClose} className="btn btn-ghost text-sm">Cancel</button>
<button
onClick={() => mutation.mutate()}
disabled={!form.common_name || !form.issuer_id || mutation.isPending}
disabled={!form.name || !form.common_name || !form.issuer_id || mutation.isPending}
className="btn btn-primary text-sm disabled:opacity-50"
>
{mutation.isPending ? 'Creating...' : 'Create Certificate'}
@@ -238,15 +324,25 @@ export default function CertificatesPage() {
const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState('');
const [envFilter, setEnvFilter] = useState('');
const [issuerFilter, setIssuerFilter] = useState('');
const [ownerFilter, setOwnerFilter] = useState('');
const [profileFilter, setProfileFilter] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBulkRevoke, setShowBulkRevoke] = useState(false);
const [showBulkReassign, setShowBulkReassign] = useState(false);
const [bulkRenewProgress, setBulkRenewProgress] = useState<{ done: number; total: number; running: boolean } | null>(null);
const { data: issuersData } = useQuery({ queryKey: ['issuers-filter'], queryFn: () => getIssuers({ per_page: '100' }) });
const { data: ownersData } = useQuery({ queryKey: ['owners-filter'], queryFn: () => getOwners({ per_page: '100' }) });
const { data: profilesData } = useQuery({ queryKey: ['profiles-filter'], queryFn: () => getProfiles({ per_page: '100' }) });
const params: Record<string, string> = {};
if (statusFilter) params.status = statusFilter;
if (envFilter) params.environment = envFilter;
if (issuerFilter) params.issuer_id = issuerFilter;
if (ownerFilter) params.owner_id = ownerFilter;
if (profileFilter) params.profile_id = profileFilter;
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['certificates', params],
@@ -295,7 +391,8 @@ export default function CertificatesPage() {
);
},
},
{ key: 'env', label: 'Environment', render: (c) => <span className="text-ink-muted">{c.environment || '—'}</span> },
{ key: 'last_renewal', label: 'Last Renewal', render: (c) => <span className="text-xs text-ink-muted">{c.last_renewal_at ? formatDate(c.last_renewal_at) : '—'}</span> },
{ key: 'last_deploy', label: 'Last Deploy', render: (c) => <span className="text-xs text-ink-muted">{c.last_deployment_at ? formatDate(c.last_deployment_at) : '—'}</span> },
{ key: 'issuer', label: 'Issuer', render: (c) => <span className="text-ink-muted text-xs">{c.issuer_id}</span> },
{ key: 'owner', label: 'Owner', render: (c) => <span className="text-ink-muted text-xs">{c.owner_id}</span> },
];
@@ -375,6 +472,36 @@ export default function CertificatesPage() {
<option value="staging">Staging</option>
<option value="development">Development</option>
</select>
<select
value={issuerFilter}
onChange={e => setIssuerFilter(e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
>
<option value="">All issuers</option>
{issuersData?.data?.map(i => (
<option key={i.id} value={i.id}>{i.name}</option>
))}
</select>
<select
value={ownerFilter}
onChange={e => setOwnerFilter(e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
>
<option value="">All owners</option>
{ownersData?.data?.map(o => (
<option key={o.id} value={o.id}>{o.name}</option>
))}
</select>
<select
value={profileFilter}
onChange={e => setProfileFilter(e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
>
<option value="">All profiles</option>
{profilesData?.data?.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div className="flex-1 overflow-y-auto">
{error ? (
+110
View File
@@ -0,0 +1,110 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { previewDigest, sendDigest } from '../api/client';
import PageHeader from '../components/PageHeader';
import ErrorState from '../components/ErrorState';
export default function DigestPage() {
const [showConfirm, setShowConfirm] = useState(false);
const { data: html, isLoading, error, refetch } = useQuery({
queryKey: ['digest-preview'],
queryFn: previewDigest,
retry: false,
});
const sendMutation = useMutation({
mutationFn: sendDigest,
onSuccess: () => setShowConfirm(false),
});
return (
<>
<PageHeader
title="Certificate Digest"
subtitle="Preview and send the scheduled certificate digest email"
action={
<button
onClick={() => setShowConfirm(true)}
disabled={!html || sendMutation.isPending}
className="btn btn-primary text-xs disabled:opacity-50"
>
Send Digest Now
</button>
}
/>
<div className="flex-1 overflow-y-auto px-6 py-4">
{sendMutation.isSuccess && (
<div className="mb-4 px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg text-sm text-emerald-700">
Digest sent successfully.
</div>
)}
{sendMutation.isError && (
<div className="mb-4 px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
Failed to send digest: {(sendMutation.error as Error).message}
</div>
)}
{isLoading && (
<div className="flex items-center justify-center py-20">
<div className="text-sm text-ink-muted">Loading digest preview...</div>
</div>
)}
{error && (
<ErrorState
error={error as Error}
onRetry={() => refetch()}
/>
)}
{html && (
<div className="bg-white border border-surface-border rounded-lg shadow-sm overflow-hidden">
<div className="px-4 py-2.5 bg-surface border-b border-surface-border flex items-center justify-between">
<span className="text-xs text-ink-muted font-medium">Email Preview</span>
<button
onClick={() => refetch()}
className="text-xs text-brand-400 hover:text-brand-500"
>
Refresh
</button>
</div>
<iframe
srcDoc={html}
title="Digest Preview"
className="w-full border-0"
style={{ minHeight: '600px' }}
sandbox="allow-same-origin"
/>
</div>
)}
</div>
{showConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowConfirm(false)}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4" onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-surface-border">
<h3 className="text-lg font-semibold text-ink">Send Digest</h3>
<p className="text-sm text-ink-muted mt-1">
This will send the certificate digest email to all configured recipients.
</p>
</div>
<div className="px-6 py-3 border-t border-surface-border flex justify-end gap-2">
<button onClick={() => setShowConfirm(false)} className="px-4 py-2 text-sm text-ink-muted hover:text-ink rounded border border-surface-border">
Cancel
</button>
<button
onClick={() => sendMutation.mutate()}
disabled={sendMutation.isPending}
className="px-4 py-2 text-sm text-white bg-brand-500 hover:bg-brand-600 rounded disabled:opacity-50"
>
{sendMutation.isPending ? 'Sending...' : 'Send'}
</button>
</div>
</div>
</div>
)}
</>
);
}
+12
View File
@@ -197,6 +197,18 @@ export default function DiscoveryPage() {
label: 'Expiry',
render: (c) => <span className="text-xs">{formatExpiry(c.not_after)}</span>,
},
{
key: 'key_info',
label: 'Key',
render: (c) => (
<div className="flex items-center gap-1">
<span className="text-xs text-ink-muted">{c.key_algorithm}{c.key_size ? ` ${c.key_size}` : ''}</span>
{c.is_ca && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 font-medium">CA</span>
)}
</div>
),
},
{
key: 'fingerprint',
label: 'Fingerprint',
+166
View File
@@ -0,0 +1,166 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import { getIssuer, testIssuerConnection, getCertificates } from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils';
import type { Certificate, Issuer } from '../api/types';
import { typeLabels, redactConfig } from '../config/issuerTypes';
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between py-2 border-b border-surface-border/50">
<span className="text-sm text-ink-muted">{label}</span>
<span className="text-sm text-ink">{value}</span>
</div>
);
}
/** Derive display status from backend enabled boolean */
function issuerStatus(issuer: Issuer): string {
if (issuer.enabled !== undefined) {
return issuer.enabled ? 'Enabled' : 'Disabled';
}
return issuer.status || 'Unknown';
}
export default function IssuerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: issuer, isLoading, error, refetch } = useQuery({
queryKey: ['issuer', id],
queryFn: () => getIssuer(id!),
enabled: !!id,
});
const { data: certsData } = useQuery({
queryKey: ['certificates', { issuer_id: id }],
queryFn: () => getCertificates({ issuer_id: id! }),
enabled: !!id,
});
const testMutation = useMutation({
mutationFn: () => testIssuerConnection(id!),
});
if (error) {
return (
<>
<PageHeader title="Issuer Details" />
<ErrorState error={error as Error} onRetry={() => refetch()} />
</>
);
}
if (isLoading || !issuer) {
return (
<>
<PageHeader title="Issuer Details" />
<div className="flex items-center justify-center py-20">
<div className="text-sm text-ink-muted">Loading issuer...</div>
</div>
</>
);
}
const safeConfig = issuer.config ? redactConfig(issuer.config) : {};
const certColumns: Column<Certificate>[] = [
{
key: 'name',
label: 'Certificate',
render: (c) => (
<div>
<div className="font-medium text-ink text-sm">{c.common_name}</div>
<div className="text-xs text-ink-faint font-mono">{c.id}</div>
</div>
),
},
{ key: 'status', label: 'Status', render: (c) => <StatusBadge status={c.status} /> },
{ key: 'expires', label: 'Expires', render: (c) => <span className="text-xs text-ink-muted">{formatDateTime(c.expires_at)}</span> },
];
return (
<>
<PageHeader
title={issuer.name}
subtitle={typeLabels[issuer.type] || issuer.type}
action={
<div className="flex gap-2">
<button
onClick={() => navigate(`/issuers?edit=${issuer.id}`)}
className="px-3 py-1.5 border border-surface-border rounded text-ink text-xs hover:bg-surface-hover transition-colors font-medium"
>
Edit
</button>
<button
onClick={() => testMutation.mutate()}
disabled={testMutation.isPending}
className="btn btn-primary text-xs disabled:opacity-50"
>
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
</button>
</div>
}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{testMutation.isSuccess && (
<div className="px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg text-sm text-emerald-700">
Connection test passed.
</div>
)}
{testMutation.isError && (
<div className="px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
Connection test failed: {(testMutation.error as Error).message}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Issuer info */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Issuer Information</h3>
<InfoRow label="ID" value={<span className="font-mono text-xs">{issuer.id}</span>} />
<InfoRow label="Name" value={issuer.name} />
<InfoRow label="Type" value={typeLabels[issuer.type] || issuer.type} />
<InfoRow label="Status" value={<StatusBadge status={issuerStatus(issuer)} />} />
<InfoRow label="Created" value={formatDateTime(issuer.created_at)} />
</div>
{/* Config (redacted) */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Configuration</h3>
{Object.keys(safeConfig).length > 0 ? (
<div className="space-y-0">
{Object.entries(safeConfig).map(([key, val]) => (
<InfoRow key={key} label={key} value={
<span className="font-mono text-xs truncate max-w-xs inline-block">{String(val)}</span>
} />
))}
</div>
) : (
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
)}
</div>
</div>
{/* Issued certificates */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">
Issued Certificates {certsData ? `(${certsData.total})` : ''}
</h3>
<DataTable
columns={certColumns}
data={certsData?.data || []}
isLoading={!certsData}
emptyMessage="No certificates issued by this issuer"
/>
</div>
</div>
</>
);
}
+206 -210
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client';
import PageHeader from '../components/PageHeader';
@@ -8,83 +9,27 @@ import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils';
import type { Issuer } from '../api/types';
import { issuerTypes, typeLabels, getIssuerCatalogStatus, type IssuerTypeConfig } from '../config/issuerTypes';
import TypeSelector from '../components/issuer/TypeSelector';
import ConfigForm from '../components/issuer/ConfigForm';
import ConfigDetailModal from '../components/issuer/ConfigDetailModal';
const typeLabels: Record<string, string> = {
local_ca: 'Local CA',
acme: 'ACME',
stepca: 'step-ca',
openssl: 'OpenSSL/Custom',
vault: 'Vault PKI',
manual: 'Manual',
};
interface IssuerConfigField {
key: string;
label: string;
placeholder?: string;
required: boolean;
type?: string;
options?: string[];
defaultValue?: string;
/** Derive display status from backend enabled boolean */
function issuerStatus(issuer: Issuer): string {
if (issuer.enabled !== undefined) {
return issuer.enabled ? 'Enabled' : 'Disabled';
}
// Fallback for legacy data that may have status string
return issuer.status || 'Unknown';
}
interface IssuerTypeConfig {
id: string;
name: string;
description: string;
configFields: IssuerConfigField[];
}
const issuerTypes: IssuerTypeConfig[] = [
{
id: 'local_ca',
name: 'Local CA',
description: 'Self-signed or subordinate CA for certificate issuance',
configFields: [
{ key: 'ca_cert_path', label: 'CA Cert Path (optional)', placeholder: '/path/to/ca.crt', required: false },
{ key: 'ca_key_path', label: 'CA Key Path (optional)', placeholder: '/path/to/ca.key', required: false },
],
},
{
id: 'acme',
name: 'ACME',
description: "Let's Encrypt or other ACME-compatible CA",
configFields: [
{ key: 'directory_url', label: 'Directory URL', placeholder: 'https://acme-v02.api.letsencrypt.org/directory', required: true },
{ key: 'email', label: 'Email', placeholder: 'admin@example.com', required: true },
{ key: 'challenge_type', label: 'Challenge Type', type: 'select', options: ['http-01', 'dns-01', 'dns-persist-01'], required: false, defaultValue: 'http-01' },
],
},
{
id: 'stepca',
name: 'step-ca',
description: 'Smallstep private CA',
configFields: [
{ key: 'ca_url', label: 'CA URL', placeholder: 'https://ca.example.com', required: true },
{ key: 'provisioner_name', label: 'Provisioner Name', placeholder: 'my-provisioner', required: true },
{ key: 'provisioner_key', label: 'Provisioner Key (JWK)', placeholder: '{...}', type: 'textarea', required: true },
],
},
{
id: 'openssl',
name: 'OpenSSL/Custom',
description: 'Script-based signing with your own CA',
configFields: [
{ key: 'sign_script', label: 'Sign Script Path', placeholder: '/path/to/sign.sh', required: true },
{ key: 'revoke_script', label: 'Revoke Script Path (optional)', placeholder: '/path/to/revoke.sh', required: false },
{ key: 'crl_script', label: 'CRL Script Path (optional)', placeholder: '/path/to/crl.sh', required: false },
{ key: 'timeout_seconds', label: 'Timeout (seconds)', placeholder: '30', type: 'number', required: false },
],
},
];
export default function IssuersPage() {
const queryClient = useQueryClient();
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; msg: string } | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [createStep, setCreateStep] = useState<'type' | 'config'>('type');
const [selectedType, setSelectedType] = useState<string | null>(null);
const [createForm, setCreateForm] = useState<Record<string, unknown>>({});
const [preselectedType, setPreselectedType] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<string>('');
const [configModal, setConfigModal] = useState<{ title: string; config: Record<string, unknown> } | null>(null);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['issuers'],
@@ -108,19 +53,31 @@ export default function IssuersPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issuers'] });
setShowCreateModal(false);
setCreateStep('type');
setSelectedType(null);
setCreateForm({});
setPreselectedType(null);
},
});
const catalogStatus = useMemo(
() => getIssuerCatalogStatus(data?.data || []),
[data?.data]
);
// Filter issuers by type
const filteredIssuers = useMemo(() => {
if (!data?.data) return [];
if (!typeFilter) return data.data;
return data.data.filter(i => i.type === typeFilter);
}, [data?.data, typeFilter]);
const columns: Column<Issuer>[] = [
{
key: 'name',
label: 'Issuer',
render: (i) => (
<div>
<div className="font-medium text-ink">{i.name}</div>
<Link to={`/issuers/${i.id}`} className="font-medium text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
{i.name}
</Link>
<div className="text-xs text-ink-faint font-mono">{i.id}</div>
</div>
),
@@ -135,7 +92,7 @@ export default function IssuersPage() {
{
key: 'status',
label: 'Status',
render: (i) => <StatusBadge status={i.status} />,
render: (i) => <StatusBadge status={issuerStatus(i)} />,
},
{
key: 'config',
@@ -143,9 +100,15 @@ export default function IssuersPage() {
render: (i) => {
if (!i.config || Object.keys(i.config).length === 0) return <span className="text-ink-faint">&mdash;</span>;
return (
<span className="text-xs text-ink-muted font-mono truncate max-w-xs block">
{JSON.stringify(i.config).slice(0, 60)}
</span>
<button
onClick={(e) => {
e.stopPropagation();
setConfigModal({ title: `${i.name} Configuration`, config: i.config });
}}
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
>
View Config
</button>
);
},
},
@@ -181,14 +144,12 @@ export default function IssuersPage() {
<>
<PageHeader
title="Issuers"
subtitle={data ? `${data.total} issuers` : undefined}
subtitle={data ? `${data.total} configured` : undefined}
action={
<button
onClick={() => {
setPreselectedType(null);
setShowCreateModal(true);
setCreateStep('type');
setSelectedType(null);
setCreateForm({});
}}
className="px-4 py-2 bg-brand-600 text-white rounded font-medium hover:bg-brand-700 transition-colors text-sm"
>
@@ -202,49 +163,83 @@ export default function IssuersPage() {
<button onClick={() => setTestResult(null)} className="ml-3 text-xs opacity-60 hover:opacity-100">dismiss</button>
</div>
)}
<div className="flex-1 overflow-y-auto">
{error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
) : (
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No issuers configured" />
<>
{/* Issuer Type Catalog Cards */}
<div className="px-6 py-4">
<h3 className="text-sm font-semibold text-ink-muted mb-3">Issuer Types</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{catalogStatus.map(({ type, status, count }) => (
<CatalogCard
key={type.id}
type={type}
status={status}
count={count}
onConfigure={() => {
setPreselectedType(type.id);
setShowCreateModal(true);
}}
onFilter={() => {
// Match both the canonical id and aliases
const filterValue = type.id === 'local' ? 'local' : type.id;
setTypeFilter(prev => prev === filterValue ? '' : filterValue);
}}
/>
))}
</div>
</div>
{/* Configured Issuers Table */}
<div className="px-6 pb-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ink-muted">Configured Issuers</h3>
<div className="flex items-center gap-2">
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="text-xs px-2 py-1.5 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500"
>
<option value="">All Types</option>
{issuerTypes.filter(t => !t.comingSoon).map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
</div>
<DataTable
columns={columns}
data={filteredIssuers}
isLoading={isLoading}
emptyMessage={typeFilter ? `No ${typeLabels[typeFilter] || typeFilter} issuers configured` : 'No issuers configured'}
/>
</div>
</>
)}
</div>
{/* Config Detail Modal */}
{configModal && (
<ConfigDetailModal
title={configModal.title}
config={configModal.config}
onClose={() => setConfigModal(null)}
/>
)}
{/* Create Issuer Modal */}
{showCreateModal && (
<CreateIssuerModal
step={createStep}
selectedType={selectedType}
form={createForm}
onTypeSelect={(type) => {
setSelectedType(type);
const typeConfig = issuerTypes.find((t) => t.id === type);
const defaultConfig: Record<string, unknown> = {};
if (typeConfig) {
typeConfig.configFields.forEach((field) => {
if (field.defaultValue) {
defaultConfig[field.key] = field.defaultValue;
}
});
}
setCreateForm({ ...defaultConfig });
setCreateStep('config');
}}
onFormChange={(field, value) => {
setCreateForm({ ...createForm, [field]: value });
}}
onBack={() => setCreateStep('type')}
onSubmit={() => {
if (!selectedType || !createForm.name) return;
const config: Record<string, unknown> = { ...createForm };
const name = config.name as string;
delete config.name;
createMutation.mutate({ name, type: selectedType, config });
preselectedType={preselectedType}
onSubmit={(name, type, config) => {
createMutation.mutate({ name, type, config });
}}
onCancel={() => {
setShowCreateModal(false);
setCreateStep('type');
setSelectedType(null);
setCreateForm({});
setPreselectedType(null);
}}
isSubmitting={createMutation.isPending}
/>
@@ -253,30 +248,94 @@ export default function IssuersPage() {
);
}
// ─── Catalog Card ───────────────────────────────────────────────
interface CatalogCardProps {
type: IssuerTypeConfig;
status: 'connected' | 'available' | 'coming_soon';
count: number;
onConfigure: () => void;
onFilter: () => void;
}
function CatalogCard({ type, status, count, onConfigure, onFilter }: CatalogCardProps) {
const statusConfig = {
connected: { label: `${count} configured`, cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
available: { label: 'Available', cls: 'bg-brand-500/10 text-brand-400 border-brand-500/30' },
coming_soon: { label: 'Coming Soon', cls: 'bg-gray-500/10 text-gray-400 border-gray-500/30' },
};
const { label, cls } = statusConfig[status];
return (
<div className={`p-4 border rounded-lg ${status === 'coming_soon' ? 'border-surface-border/50 opacity-60' : 'border-surface-border'}`}>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-lg">{type.icon}</span>
<span className="font-medium text-ink text-sm">{type.name}</span>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{label}</span>
</div>
<p className="text-xs text-ink-muted mb-3">{type.description}</p>
{status === 'connected' && (
<button
onClick={onFilter}
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
>
View issuers
</button>
)}
{status === 'available' && (
<button
onClick={onConfigure}
className="text-xs px-3 py-1 bg-brand-600 text-white rounded hover:bg-brand-700 transition-colors"
>
Configure
</button>
)}
</div>
);
}
// ─── Create Issuer Modal ────────────────────────────────────────
interface CreateIssuerModalProps {
step: 'type' | 'config';
selectedType: string | null;
form: Record<string, unknown>;
onTypeSelect: (type: string) => void;
onFormChange: (field: string, value: unknown) => void;
onBack: () => void;
onSubmit: () => void;
preselectedType: string | null;
onSubmit: (name: string, type: string, config: Record<string, unknown>) => void;
onCancel: () => void;
isSubmitting: boolean;
}
function CreateIssuerModal({
step,
selectedType,
form,
onTypeSelect,
onFormChange,
onBack,
onSubmit,
onCancel,
isSubmitting,
}: CreateIssuerModalProps) {
const selectedTypeConfig = issuerTypes.find((t) => t.id === selectedType);
function CreateIssuerModal({ preselectedType, onSubmit, onCancel, isSubmitting }: CreateIssuerModalProps) {
const [step, setStep] = useState<'type' | 'config'>(preselectedType ? 'config' : 'type');
const [selectedType, setSelectedType] = useState<string | null>(preselectedType);
const [form, setForm] = useState<Record<string, unknown>>(() => {
if (preselectedType) {
const tc = issuerTypes.find(t => t.id === preselectedType);
const defaults: Record<string, unknown> = {};
tc?.configFields.forEach(f => { if (f.defaultValue) defaults[f.key] = f.defaultValue; });
return defaults;
}
return {};
});
const selectedTypeConfig = issuerTypes.find(t => t.id === selectedType);
function handleTypeSelect(typeId: string) {
setSelectedType(typeId);
const tc = issuerTypes.find(t => t.id === typeId);
const defaults: Record<string, unknown> = {};
tc?.configFields.forEach(f => { if (f.defaultValue) defaults[f.key] = f.defaultValue; });
setForm(defaults);
setStep('config');
}
function handleSubmit() {
if (!selectedType || !form.name) return;
const config = { ...form };
const name = config.name as string;
delete config.name;
onSubmit(name, selectedType, config);
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
@@ -286,10 +345,7 @@ function CreateIssuerModal({
<h2 className="text-lg font-semibold text-ink">
{step === 'type' ? 'Create Issuer' : `Configure ${selectedTypeConfig?.name || 'Issuer'}`}
</h2>
<button
onClick={onCancel}
className="text-ink-muted hover:text-ink transition-colors"
>
<button onClick={onCancel} className="text-ink-muted hover:text-ink transition-colors">
</button>
</div>
@@ -297,79 +353,28 @@ function CreateIssuerModal({
{/* Content */}
<div className="px-6 py-6">
{step === 'type' ? (
<div className="grid grid-cols-2 gap-4">
{issuerTypes.map((type) => (
<button
key={type.id}
onClick={() => onTypeSelect(type.id)}
className="p-4 border border-surface-border rounded-lg hover:border-brand-500 hover:bg-opacity-5 transition-all text-left"
>
<div className="font-medium text-ink">{type.name}</div>
<div className="text-sm text-ink-muted mt-1">{type.description}</div>
</button>
))}
</div>
<TypeSelector onSelect={handleTypeSelect} />
) : (
<div className="space-y-5">
{/* Name field always shown */}
{/* Name field */}
<div>
<label className="block text-sm font-medium text-ink mb-2">Issuer Name *</label>
<input
type="text"
value={(form.name as string) || ''}
onChange={(e) => onFormChange('name', e.target.value)}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g., Production CA"
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
/>
</div>
{/* Type-specific fields */}
{selectedTypeConfig?.configFields.map((field) => (
<div key={field.key}>
<label className="block text-sm font-medium text-ink mb-2">
{field.label}
{field.required && <span className="text-red-600 ml-1">*</span>}
</label>
{field.type === 'select' ? (
<select
value={(form[field.key] as string) || ''}
onChange={(e) => onFormChange(field.key, e.target.value)}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
>
<option value="">Select {field.label}</option>
{field.options?.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
) : field.type === 'textarea' ? (
<textarea
value={(form[field.key] as string) || ''}
onChange={(e) => onFormChange(field.key, e.target.value)}
placeholder={field.placeholder}
rows={4}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors font-mono text-xs"
/>
) : field.type === 'number' ? (
<input
type="number"
value={(form[field.key] as number | string) || ''}
onChange={(e) => onFormChange(field.key, e.target.value ? parseInt(e.target.value, 10) : '')}
placeholder={field.placeholder}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
/>
) : (
<input
type="text"
value={(form[field.key] as string) || ''}
onChange={(e) => onFormChange(field.key, e.target.value)}
placeholder={field.placeholder}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
/>
)}
</div>
))}
{/* Type-specific fields via ConfigForm */}
{selectedTypeConfig && (
<ConfigForm
fields={selectedTypeConfig.configFields}
values={form}
onChange={(key, value) => setForm({ ...form, [key]: value })}
/>
)}
</div>
)}
</div>
@@ -378,7 +383,7 @@ function CreateIssuerModal({
<div className="border-t border-surface-border px-6 py-4 flex justify-end gap-3">
{step === 'config' && (
<button
onClick={onBack}
onClick={() => setStep('type')}
className="px-4 py-2 border border-surface-border rounded text-ink hover:bg-surface-hover transition-colors text-sm font-medium"
>
Back
@@ -392,22 +397,13 @@ function CreateIssuerModal({
</button>
{step === 'config' && (
<button
onClick={onSubmit}
onClick={handleSubmit}
disabled={isSubmitting || !form.name}
className="px-4 py-2 bg-brand-600 text-white rounded text-sm font-medium hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Creating...' : 'Create Issuer'}
</button>
)}
{step === 'type' && (
<button
onClick={() => selectedType && onTypeSelect(selectedType)}
disabled={!selectedType}
className="px-4 py-2 bg-brand-600 text-white rounded text-sm font-medium hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
)}
</div>
</div>
</div>
+183
View File
@@ -0,0 +1,183 @@
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getJob, getJobVerification, getAuditEvents } from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
import { formatDateTime, timeAgo } from '../api/utils';
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between py-2 border-b border-surface-border/50">
<span className="text-sm text-ink-muted">{label}</span>
<span className="text-sm text-ink">{value}</span>
</div>
);
}
function VerificationBadge({ status }: { status?: string }) {
if (!status) return <span className="text-xs text-ink-faint"></span>;
const styles: Record<string, string> = {
success: 'bg-emerald-100 text-emerald-700',
failed: 'bg-red-100 text-red-700',
pending: 'bg-yellow-100 text-yellow-700',
skipped: 'bg-gray-100 text-gray-600',
};
const labels: Record<string, string> = {
success: 'Verified',
failed: 'Failed',
pending: 'Pending',
skipped: 'Skipped',
};
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
{labels[status] || status}
</span>
);
}
export default function JobDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: job, isLoading, error, refetch } = useQuery({
queryKey: ['job', id],
queryFn: () => getJob(id!),
enabled: !!id,
refetchInterval: 10000,
});
const { data: verification } = useQuery({
queryKey: ['job-verification', id],
queryFn: () => getJobVerification(id!),
enabled: !!id && job?.type === 'Deployment' && job?.status === 'Completed',
retry: false,
});
const { data: auditData } = useQuery({
queryKey: ['audit', { resource_id: id }],
queryFn: () => getAuditEvents({ resource_id: id!, per_page: '10' }),
enabled: !!id,
});
if (error) {
return (
<>
<PageHeader title="Job Details" />
<ErrorState error={error as Error} onRetry={() => refetch()} />
</>
);
}
if (isLoading || !job) {
return (
<>
<PageHeader title="Job Details" />
<div className="flex items-center justify-center py-20">
<div className="text-sm text-ink-muted">Loading job...</div>
</div>
</>
);
}
return (
<>
<PageHeader
title={`Job ${job.id}`}
subtitle={`${job.type} job`}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Job details */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Job Information</h3>
<InfoRow label="ID" value={<span className="font-mono text-xs">{job.id}</span>} />
<InfoRow label="Type" value={job.type} />
<InfoRow label="Status" value={<StatusBadge status={job.status} />} />
<InfoRow label="Certificate" value={
<Link to={`/certificates/${job.certificate_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{job.certificate_id}
</Link>
} />
{job.agent_id && (
<InfoRow label="Agent" value={
<Link to={`/agents/${job.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{job.agent_id}
</Link>
} />
)}
{job.target_id && (
<InfoRow label="Target" value={
<Link to={`/targets/${job.target_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{job.target_id}
</Link>
} />
)}
<InfoRow label="Attempts" value={`${job.attempts} / ${job.max_attempts}`} />
{job.error_message && (
<InfoRow label="Error" value={
<span className="text-red-600 text-xs">{job.error_message}</span>
} />
)}
</div>
{/* Timeline */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Timeline</h3>
<InfoRow label="Created" value={formatDateTime(job.created_at)} />
<InfoRow label="Scheduled" value={formatDateTime(job.scheduled_at)} />
{job.started_at && <InfoRow label="Started" value={formatDateTime(job.started_at)} />}
{job.completed_at && <InfoRow label="Completed" value={formatDateTime(job.completed_at)} />}
{job.completed_at && job.started_at && (
<InfoRow label="Duration" value={timeAgo(job.started_at)} />
)}
</div>
</div>
{/* Verification section — only for deployment jobs */}
{job.type === 'Deployment' && (
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Post-Deployment Verification</h3>
{job.verification_status ? (
<div className="space-y-0">
<InfoRow label="Status" value={<VerificationBadge status={job.verification_status} />} />
{job.verified_at && <InfoRow label="Verified At" value={formatDateTime(job.verified_at)} />}
{job.verification_fingerprint && (
<InfoRow label="Fingerprint" value={<span className="font-mono text-xs">{job.verification_fingerprint}</span>} />
)}
{job.verification_error && (
<InfoRow label="Error" value={<span className="text-red-600 text-xs">{job.verification_error}</span>} />
)}
{verification && verification.verified && (
<InfoRow label="Expected Fingerprint" value={<span className="font-mono text-xs">{verification.expected_fingerprint}</span>} />
)}
</div>
) : (
<div className="text-sm text-ink-faint py-4 text-center">
{job.status === 'Completed' ? 'No verification data recorded' : 'Verification runs after deployment completes'}
</div>
)}
</div>
)}
{/* Audit trail */}
{auditData && auditData.data.length > 0 && (
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Related Audit Events</h3>
<div className="space-y-2">
{auditData.data.map(event => (
<div key={event.id} className="flex items-center justify-between py-2 border-b border-surface-border/50 last:border-0">
<div>
<span className="text-sm text-ink">{event.action}</span>
<span className="text-xs text-ink-faint ml-2">by {event.actor}</span>
</div>
<span className="text-xs text-ink-muted">{formatDateTime(event.timestamp)}</span>
</div>
))}
</div>
</div>
)}
</div>
</>
);
}
+50 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getJobs, cancelJob, approveRenewal, rejectRenewal } from '../api/client';
import PageHeader from '../components/PageHeader';
@@ -47,6 +48,27 @@ function RejectModal({ job, onClose, onReject }: { job: Job; onClose: () => void
);
}
function VerificationBadge({ status }: { status?: string }) {
if (!status) return <span className="text-xs text-ink-faint"></span>;
const styles: Record<string, string> = {
success: 'bg-emerald-100 text-emerald-700',
failed: 'bg-red-100 text-red-700',
pending: 'bg-yellow-100 text-yellow-700',
skipped: 'bg-gray-100 text-gray-600',
};
const labels: Record<string, string> = {
success: 'Verified',
failed: 'Failed',
pending: 'Pending',
skipped: 'Skipped',
};
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
{labels[status] || status}
</span>
);
}
export default function JobsPage() {
const [statusFilter, setStatusFilter] = useState('');
const [typeFilter, setTypeFilter] = useState('');
@@ -89,20 +111,47 @@ export default function JobsPage() {
label: 'Job',
render: (j) => (
<div>
<div className="font-mono text-xs text-ink">{j.id}</div>
<Link to={`/jobs/${j.id}`} className="font-mono text-xs text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
{j.id}
</Link>
<div className="text-xs text-ink-faint">{j.type}</div>
</div>
),
},
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
{ key: 'cert', label: 'Certificate', render: (j) => <span className="text-xs text-ink-muted font-mono">{j.certificate_id}</span> },
{
key: 'agent',
label: 'Agent',
render: (j) => j.agent_id ? (
<Link to={`/agents/${j.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono" onClick={(e) => e.stopPropagation()}>
{j.agent_id}
</Link>
) : (
<span className="text-xs text-ink-faint"></span>
),
},
{
key: 'attempts',
label: 'Attempts',
render: (j) => <span className="text-ink-muted">{j.attempts}/{j.max_attempts}</span>,
},
{
key: 'error',
label: 'Error',
render: (j) => j.status === 'Failed' && j.error_message ? (
<span className="text-xs text-red-600 truncate max-w-[200px] inline-block" title={j.error_message}>
{j.error_message.length > 80 ? j.error_message.substring(0, 80) + '...' : j.error_message}
</span>
) : <span className="text-xs text-ink-faint"></span>,
},
{ key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.scheduled_at)}</span> },
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
{
key: 'verification',
label: 'Verification',
render: (j) => j.type === 'Deployment' ? <VerificationBadge status={j.verification_status} /> : <span className="text-xs text-ink-faint"></span>,
},
{
key: 'actions',
label: '',
+149
View File
@@ -0,0 +1,149 @@
import { useQuery } from '@tanstack/react-query';
import { getMetrics, getPrometheusMetrics, getHealth } from '../api/client';
import PageHeader from '../components/PageHeader';
import ErrorState from '../components/ErrorState';
function MetricCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
return (
<div className="bg-surface border border-surface-border rounded p-4 shadow-sm">
<div className="text-xs text-ink-muted mb-1">{label}</div>
<div className="text-2xl font-bold text-ink">{value}</div>
{sub && <div className="text-xs text-ink-faint mt-1">{sub}</div>}
</div>
);
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export default function ObservabilityPage() {
const { data: metrics, isLoading, error, refetch } = useQuery({
queryKey: ['metrics'],
queryFn: getMetrics,
refetchInterval: 15000,
});
const { data: health } = useQuery({
queryKey: ['health'],
queryFn: getHealth,
refetchInterval: 15000,
});
const { data: promText } = useQuery({
queryKey: ['prometheus-metrics'],
queryFn: getPrometheusMetrics,
refetchInterval: 30000,
retry: false,
});
if (error) {
return (
<>
<PageHeader title="Observability" />
<ErrorState error={error as Error} onRetry={() => refetch()} />
</>
);
}
return (
<>
<PageHeader
title="Observability"
subtitle={health ? `Server: ${health.status}` : undefined}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{/* Health status */}
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${health?.status === 'ok' ? 'bg-emerald-500' : 'bg-red-500'}`} />
<span className="text-sm text-ink font-medium">
Server {health?.status === 'ok' ? 'Healthy' : 'Unhealthy'}
</span>
{metrics && (
<span className="text-xs text-ink-faint ml-auto">
Uptime: {formatUptime(metrics.uptime.uptime_seconds)} | Started: {new Date(metrics.uptime.server_started).toLocaleString()}
</span>
)}
</div>
{/* Gauge metrics */}
{isLoading && (
<div className="text-sm text-ink-muted py-10 text-center">Loading metrics...</div>
)}
{metrics && (
<>
<div>
<h3 className="text-sm font-semibold text-ink-muted mb-3">Certificate Gauges</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<MetricCard label="Total" value={metrics.gauge.certificate_total} />
<MetricCard label="Active" value={metrics.gauge.certificate_active} />
<MetricCard label="Expiring Soon" value={metrics.gauge.certificate_expiring_soon} />
<MetricCard label="Expired" value={metrics.gauge.certificate_expired} />
<MetricCard label="Revoked" value={metrics.gauge.certificate_revoked} />
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-ink-muted mb-3">Agent & Job Gauges</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<MetricCard label="Total Agents" value={metrics.gauge.agent_total} />
<MetricCard label="Online Agents" value={metrics.gauge.agent_online} />
<MetricCard label="Pending Jobs" value={metrics.gauge.job_pending} />
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-ink-muted mb-3">Counters</h3>
<div className="grid grid-cols-2 md:grid-cols-2 gap-3">
<MetricCard label="Jobs Completed (total)" value={metrics.counter.job_completed_total} />
<MetricCard label="Jobs Failed (total)" value={metrics.counter.job_failed_total} />
</div>
</div>
</>
)}
{/* Prometheus config */}
<div>
<h3 className="text-sm font-semibold text-ink-muted mb-3">Prometheus Integration</h3>
<div className="bg-surface border border-surface-border rounded p-4 shadow-sm">
<p className="text-sm text-ink mb-3">
Add this scrape target to your <code className="text-xs bg-surface-muted px-1 py-0.5 rounded">prometheus.yml</code>:
</p>
<pre className="bg-ink text-white rounded p-4 text-xs overflow-x-auto font-mono">
{`scrape_configs:
- job_name: 'certctl'
metrics_path: '/api/v1/metrics/prometheus'
scheme: 'https'
bearer_token: '<YOUR_API_KEY>'
static_configs:
- targets: ['<CERTCTL_HOST>:443']`}
</pre>
</div>
</div>
{/* Live Prometheus output */}
{promText && (
<div>
<h3 className="text-sm font-semibold text-ink-muted mb-3">Live Prometheus Output</h3>
<div className="bg-surface border border-surface-border rounded shadow-sm">
<div className="px-4 py-2 border-b border-surface-border flex items-center justify-between">
<span className="text-xs text-ink-faint font-mono">GET /api/v1/metrics/prometheus</span>
<span className="text-xs text-ink-faint">text/plain</span>
</div>
<pre className="p-4 text-xs text-ink-muted overflow-x-auto font-mono max-h-96 overflow-y-auto whitespace-pre">
{promText}
</pre>
</div>
</div>
)}
</div>
</>
);
}
+158 -4
View File
@@ -25,11 +25,63 @@ interface CreateProfileModalProps {
error: string | null;
}
const AVAILABLE_ALGORITHMS = ['RSA', 'ECDSA', 'Ed25519'];
const ALGORITHM_MIN_SIZES: Record<string, number[]> = {
RSA: [2048, 3072, 4096],
ECDSA: [256, 384],
Ed25519: [0],
};
const AVAILABLE_EKUS = [
{ value: 'serverAuth', label: 'Server Authentication (TLS)' },
{ value: 'clientAuth', label: 'Client Authentication' },
{ value: 'codeSigning', label: 'Code Signing' },
{ value: 'emailProtection', label: 'Email Protection (S/MIME)' },
{ value: 'timeStamping', label: 'Time Stamping' },
];
interface KeyAlgorithmEntry {
algorithm: string;
min_size: number;
}
function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: CreateProfileModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [ttl, setTtl] = useState('86400');
const [shortLived, setShortLived] = useState(false);
const [keyAlgorithms, setKeyAlgorithms] = useState<KeyAlgorithmEntry[]>([
{ algorithm: 'ECDSA', min_size: 256 },
{ algorithm: 'RSA', min_size: 2048 },
]);
const [selectedEkus, setSelectedEkus] = useState<string[]>(['serverAuth']);
const [sanPatterns, setSanPatterns] = useState('');
const [spiffePattern, setSpiffePattern] = useState('');
const addAlgorithm = () => {
const unused = AVAILABLE_ALGORITHMS.find(a => !keyAlgorithms.some(ka => ka.algorithm === a));
if (unused) {
setKeyAlgorithms([...keyAlgorithms, { algorithm: unused, min_size: ALGORITHM_MIN_SIZES[unused][0] }]);
}
};
const removeAlgorithm = (idx: number) => {
setKeyAlgorithms(keyAlgorithms.filter((_, i) => i !== idx));
};
const updateAlgorithm = (idx: number, field: 'algorithm' | 'min_size', value: string | number) => {
const updated = [...keyAlgorithms];
if (field === 'algorithm') {
updated[idx] = { algorithm: value as string, min_size: ALGORITHM_MIN_SIZES[value as string]?.[0] || 0 };
} else {
updated[idx] = { ...updated[idx], min_size: value as number };
}
setKeyAlgorithms(updated);
};
const toggleEku = (eku: string) => {
setSelectedEkus(prev => prev.includes(eku) ? prev.filter(e => e !== eku) : [...prev, eku]);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -39,20 +91,31 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
description: description.trim(),
max_ttl_seconds: parseInt(ttl) || 86400,
allow_short_lived: shortLived,
allowed_key_algorithms: keyAlgorithms,
allowed_ekus: selectedEkus,
required_san_patterns: sanPatterns.trim() ? sanPatterns.split(',').map(s => s.trim()).filter(Boolean) : [],
spiffe_uri_pattern: spiffePattern.trim() || '',
enabled: true,
});
setName('');
setDescription('');
setTtl('86400');
setShortLived(false);
setKeyAlgorithms([{ algorithm: 'ECDSA', min_size: 256 }, { algorithm: 'RSA', min_size: 2048 }]);
setSelectedEkus(['serverAuth']);
setSanPatterns('');
setSpiffePattern('');
onSuccess();
};
if (!isOpen) return null;
const inputClass = 'w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400';
const selectClass = 'bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400';
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-lg shadow-xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Create Profile</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
@@ -61,7 +124,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
className={inputClass}
placeholder="e.g., Web Server Certs"
required
/>
@@ -71,7 +134,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
className={inputClass}
placeholder="Optional description"
rows={2}
/>
@@ -82,7 +145,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
type="number"
value={ttl}
onChange={e => setTtl(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
className={inputClass}
placeholder="86400"
/>
<p className="text-xs text-ink-muted mt-1">
@@ -109,6 +172,97 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
/>
<label htmlFor="shortLived" className="text-sm text-ink">Allow short-lived certs</label>
</div>
{/* Allowed Key Algorithms */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-ink">Allowed Key Algorithms</label>
{keyAlgorithms.length < AVAILABLE_ALGORITHMS.length && (
<button type="button" onClick={addAlgorithm} className="text-xs text-brand-600 hover:text-brand-700 font-medium">
+ Add
</button>
)}
</div>
<div className="space-y-2">
{keyAlgorithms.map((ka, idx) => (
<div key={idx} className="flex items-center gap-2">
<select
value={ka.algorithm}
onChange={e => updateAlgorithm(idx, 'algorithm', e.target.value)}
className={selectClass + ' flex-1'}
>
{AVAILABLE_ALGORITHMS.map(a => (
<option key={a} value={a} disabled={a !== ka.algorithm && keyAlgorithms.some(k => k.algorithm === a)}>
{a}
</option>
))}
</select>
{ka.algorithm !== 'Ed25519' ? (
<select
value={ka.min_size}
onChange={e => updateAlgorithm(idx, 'min_size', parseInt(e.target.value))}
className={selectClass + ' w-24'}
>
{(ALGORITHM_MIN_SIZES[ka.algorithm] || []).map(s => (
<option key={s} value={s}>{s}+</option>
))}
</select>
) : (
<span className="text-xs text-ink-muted w-24 text-center">fixed</span>
)}
<button type="button" onClick={() => removeAlgorithm(idx)} className="text-xs text-red-500 hover:text-red-600">
Remove
</button>
</div>
))}
{keyAlgorithms.length === 0 && (
<p className="text-xs text-ink-faint">No algorithms configured. Click + Add to allow key types.</p>
)}
</div>
</div>
{/* Allowed EKUs */}
<div>
<label className="block text-sm font-medium text-ink mb-1">Allowed Extended Key Usages</label>
<div className="space-y-1.5">
{AVAILABLE_EKUS.map(eku => (
<label key={eku.value} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedEkus.includes(eku.value)}
onChange={() => toggleEku(eku.value)}
className="w-4 h-4"
/>
<span className="text-sm text-ink">{eku.label}</span>
</label>
))}
</div>
</div>
{/* Required SAN Patterns */}
<div>
<label className="block text-sm font-medium text-ink mb-1">Required SAN Patterns</label>
<input
value={sanPatterns}
onChange={e => setSanPatterns(e.target.value)}
className={inputClass}
placeholder="e.g., *.example.com, api.internal"
/>
<p className="text-xs text-ink-muted mt-1">Comma-separated patterns. Leave empty for no constraints.</p>
</div>
{/* SPIFFE URI Pattern */}
<div>
<label className="block text-sm font-medium text-ink mb-1">SPIFFE URI Pattern</label>
<input
value={spiffePattern}
onChange={e => setSpiffePattern(e.target.value)}
className={inputClass}
placeholder="e.g., spiffe://example.org/service/*"
/>
<p className="text-xs text-ink-muted mt-1">Optional workload identity URI SAN pattern.</p>
</div>
<div className="flex gap-2 pt-4">
<button
type="submit"
+224
View File
@@ -0,0 +1,224 @@
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getTarget, getJobs, updateTarget } from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils';
import type { Job } from '../api/types';
const typeLabels: Record<string, string> = {
nginx: 'NGINX',
apache: 'Apache',
haproxy: 'HAProxy',
traefik: 'Traefik',
caddy: 'Caddy',
f5_bigip: 'F5 BIG-IP',
iis: 'IIS',
};
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between py-2 border-b border-surface-border/50">
<span className="text-sm text-ink-muted">{label}</span>
<span className="text-sm text-ink">{value}</span>
</div>
);
}
export default function TargetDetailPage() {
const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState('');
const [editHostname, setEditHostname] = useState('');
const updateMutation = useMutation({
mutationFn: (data: Partial<{ name: string; hostname: string }>) => updateTarget(id!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['target', id] });
setIsEditing(false);
},
});
const { data: target, isLoading, error, refetch } = useQuery({
queryKey: ['target', id],
queryFn: () => getTarget(id!),
enabled: !!id,
});
// Deployment jobs for this target
const { data: jobsData } = useQuery({
queryKey: ['jobs', { target_id: id, type: 'Deployment' }],
queryFn: () => getJobs({ target_id: id! }),
enabled: !!id,
});
if (error) {
return (
<>
<PageHeader title="Target Details" />
<ErrorState error={error as Error} onRetry={() => refetch()} />
</>
);
}
if (isLoading || !target) {
return (
<>
<PageHeader title="Target Details" />
<div className="flex items-center justify-center py-20">
<div className="text-sm text-ink-muted">Loading target...</div>
</div>
</>
);
}
const jobColumns: Column<Job>[] = [
{
key: 'id',
label: 'Job',
render: (j) => (
<Link to={`/jobs/${j.id}`} className="font-mono text-xs text-accent hover:text-accent-bright">
{j.id}
</Link>
),
},
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
{ key: 'cert', label: 'Certificate', render: (j) => (
<Link to={`/certificates/${j.certificate_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{j.certificate_id}
</Link>
)},
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
{
key: 'verification',
label: 'Verification',
render: (j) => {
if (!j.verification_status) return <span className="text-xs text-ink-faint"></span>;
const styles: Record<string, string> = {
success: 'bg-emerald-100 text-emerald-700',
failed: 'bg-red-100 text-red-700',
pending: 'bg-yellow-100 text-yellow-700',
skipped: 'bg-gray-100 text-gray-600',
};
const labels: Record<string, string> = {
success: 'Verified',
failed: 'Failed',
pending: 'Pending',
skipped: 'Skipped',
};
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[j.verification_status] || 'bg-gray-100 text-gray-600'}`}>
{labels[j.verification_status] || j.verification_status}
</span>
);
},
},
];
return (
<>
<PageHeader
title={target.name}
subtitle={typeLabels[target.type] || target.type}
action={
<button
onClick={() => {
setEditName(target.name);
setEditHostname(target.hostname || '');
setIsEditing(true);
}}
className="px-3 py-1.5 border border-surface-border rounded text-ink text-xs hover:bg-surface-hover transition-colors font-medium"
>
Edit
</button>
}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Target info */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Target Information</h3>
<InfoRow label="ID" value={<span className="font-mono text-xs">{target.id}</span>} />
<InfoRow label="Name" value={target.name} />
<InfoRow label="Type" value={typeLabels[target.type] || target.type} />
<InfoRow label="Hostname" value={target.hostname || '—'} />
<InfoRow label="Status" value={<StatusBadge status={target.status} />} />
{target.agent_id && (
<InfoRow label="Agent" value={
<Link to={`/agents/${target.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{target.agent_id}
</Link>
} />
)}
<InfoRow label="Created" value={formatDateTime(target.created_at)} />
</div>
{/* Config */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Configuration</h3>
{target.config && Object.keys(target.config).length > 0 ? (
<div className="space-y-0">
{Object.entries(target.config).map(([key, val]) => (
<InfoRow key={key} label={key.replace(/_/g, ' ')} value={
<span className="font-mono text-xs truncate max-w-xs inline-block">{String(val)}</span>
} />
))}
</div>
) : (
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
)}
</div>
</div>
{/* Deployment history */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">
Deployment History {jobsData ? `(${jobsData.total})` : ''}
</h3>
<DataTable
columns={jobColumns}
data={jobsData?.data || []}
isLoading={!jobsData}
emptyMessage="No deployments to this target"
/>
</div>
</div>
{/* Edit Modal */}
{isEditing && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setIsEditing(false)}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Edit Target</h2>
{updateMutation.isError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
{(updateMutation.error as Error).message}
</div>
)}
<form onSubmit={e => { e.preventDefault(); updateMutation.mutate({ name: editName, hostname: editHostname }); }} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name</label>
<input value={editName} onChange={e => setEditName(e.target.value)} className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Hostname</label>
<input value={editHostname} onChange={e => setEditHostname(e.target.value)} className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
</div>
<div className="flex gap-2 pt-2">
<button type="submit" disabled={updateMutation.isPending} className="flex-1 btn btn-primary disabled:opacity-50">
{updateMutation.isPending ? 'Saving...' : 'Save'}
</button>
<button type="button" onClick={() => setIsEditing(false)} className="flex-1 btn btn-ghost">Cancel</button>
</div>
</form>
</div>
</div>
)}
</>
);
}
+4 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getTargets, createTarget, deleteTarget } from '../api/client';
import PageHeader from '../components/PageHeader';
@@ -266,7 +267,9 @@ export default function TargetsPage() {
label: 'Target',
render: (t) => (
<div>
<div className="font-medium text-ink">{t.name}</div>
<Link to={`/targets/${t.id}`} className="font-medium text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
{t.name}
</Link>
<div className="text-xs text-ink-faint font-mono">{t.id}</div>
</div>
),