mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:31:34 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 648e2f7ab1 | |||
| 6375909591 | |||
| 3e5ff4b9c3 | |||
| 76d0ce2a0f | |||
| 207f2c6879 | |||
| 46a58d518a | |||
| c5be6d059f | |||
| ec209c9736 | |||
| d4f02c5f4b | |||
| 2409f2e464 | |||
| 225c7141b8 | |||
| 8807a7303d | |||
| a6515b4323 | |||
| 11173a74c6 | |||
| ec0e7a3560 | |||
| a0b9285323 | |||
| 2655493ac8 | |||
| a8fc177118 | |||
| 20378ea7bb |
@@ -7,6 +7,15 @@
|
||||
|
||||
# certctl — Self-Hosted Certificate Lifecycle Platform
|
||||
|
||||
[](LICENSE)
|
||||
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
||||
[](https://github.com/shankar0123/certctl/releases)
|
||||
[](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://goreportcard.com/report/github.com/shankar0123/certctl)
|
||||
[](https://github.com/shankar0123/certctl/releases)
|
||||
|
||||
## Documentation
|
||||
|
||||
| Guide | Description |
|
||||
@@ -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.
|
||||
|
||||
@@ -510,8 +513,6 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
|
||||
- **Helm Chart** — Production-ready Kubernetes with server Deployment, PostgreSQL StatefulSet with PVC, Agent DaemonSet, security contexts, resource limits, optional Ingress
|
||||
|
||||
**Coming in v2.1.0:**
|
||||
- Vault PKI issuer connector (HashiCorp Vault /sign API)
|
||||
- DigiCert CertCentral issuer connector (enterprise CA)
|
||||
- Dynamic issuer and target configuration via GUI (no env var restarts)
|
||||
- Issuer catalog page (see all supported CAs, configure from dashboard)
|
||||
- First-run onboarding wizard
|
||||
|
||||
+28
-1
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
@@ -570,7 +570,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 +647,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.
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
+30
-24
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+888
-37
@@ -39,6 +39,11 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp
|
||||
- [Part 32: Request Body Size Limits](#part-32-request-body-size-limits)
|
||||
- [Part 33: Apache & HAProxy Target Connectors](#part-33-apache--haproxy-target-connectors)
|
||||
- [Part 34: Sub-CA Mode](#part-34-sub-ca-mode)
|
||||
- [Part 35: ARI (RFC 9702) Scheduler Integration](#part-35-ari-rfc-9702-scheduler-integration)
|
||||
- [Part 36: Agent Work Routing (M31)](#part-36-agent-work-routing-m31)
|
||||
- [Part 37: GUI Completeness (Pre-2.1.0-E)](#part-37-gui-completeness-pre-210-e)
|
||||
- [Part 38: Vault PKI Connector (M32)](#part-38-vault-pki-connector-m32)
|
||||
- [Part 39: DigiCert Connector (M37)](#part-39-digicert-connector-m37)
|
||||
- [Release Sign-Off](#release-sign-off)
|
||||
|
||||
---
|
||||
@@ -5069,46 +5074,892 @@ openssl crl -in /tmp/subca-crl.der -inform DER -noout -issuer
|
||||
|
||||
---
|
||||
|
||||
## Part 35: ARI (RFC 9702) Scheduler Integration
|
||||
|
||||
Tests that the renewal scheduler consults ARI before creating renewal jobs for ACME-issued certificates.
|
||||
|
||||
### 35.1 ARI Defers Renewal When CA Says "Not Yet"
|
||||
|
||||
**Prerequisite:** ACME issuer configured with `CERTCTL_ACME_ARI_ENABLED=true`, connected to a CA that supports ARI (e.g., Let's Encrypt staging). Certificate within the 30-day expiry window but the CA's `suggestedWindow.start` is in the future.
|
||||
|
||||
```bash
|
||||
# Check scheduler logs for ARI deferral
|
||||
docker logs certctl-server 2>&1 | grep "ARI: renewal not yet suggested"
|
||||
```
|
||||
|
||||
**Expected:** Log line showing `ARI: renewal not yet suggested by CA` with `cert_id`, `suggested_start`, `suggested_end`. No renewal job created for that cert.
|
||||
**PASS if** the scheduler skips renewal job creation when ARI says the window hasn't opened.
|
||||
|
||||
### 35.2 ARI Triggers Renewal When CA Says "Now"
|
||||
|
||||
**Prerequisite:** Same setup as 35.1, but the certificate's ARI `suggestedWindow.start` is in the past (CA is actively suggesting renewal).
|
||||
|
||||
```bash
|
||||
# Check scheduler logs for ARI-triggered renewal
|
||||
docker logs certctl-server 2>&1 | grep "ARI: CA suggests renewal now"
|
||||
|
||||
# Verify renewal job was created
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"http://localhost:8443/api/v1/jobs?type=renewal" | jq '.data[] | select(.certificate_id == "<cert-id>")'
|
||||
```
|
||||
|
||||
**Expected:** Log line showing `ARI: CA suggests renewal now`. Renewal job created with `renewal_trigger: ari` in the audit trail.
|
||||
**PASS if** a renewal job is created when ARI indicates the renewal window is open.
|
||||
|
||||
### 35.3 ARI Fallback on Error
|
||||
|
||||
**Prerequisite:** ACME issuer with `CERTCTL_ACME_ARI_ENABLED=true`, but the ARI endpoint is unreachable or returns an error (e.g., network issue, 500 from CA).
|
||||
|
||||
```bash
|
||||
# Check scheduler logs for ARI fallback
|
||||
docker logs certctl-server 2>&1 | grep "ARI check failed, falling back"
|
||||
```
|
||||
|
||||
**Expected:** Warning log `ARI check failed, falling back to threshold-based renewal`. Renewal proceeds normally using the configured expiration thresholds.
|
||||
**PASS if** renewal still works when ARI is unavailable, using threshold-based logic as fallback.
|
||||
|
||||
---
|
||||
|
||||
## Part 36: Agent Work Routing (M31)
|
||||
|
||||
Tests that `GetPendingWork()` returns only jobs scoped to the requesting agent, and that deployment jobs have `agent_id` populated at creation time.
|
||||
|
||||
### 36.1 Multi-Agent Routing
|
||||
|
||||
**Prerequisite:** Two agents registered (`agent-web-01`, `agent-lb-01`), two targets (one per agent), one certificate mapped to both targets. Trigger renewal to create deployment jobs.
|
||||
|
||||
```bash
|
||||
# Poll as agent-web-01 — should only see its deployment job
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"http://localhost:8443/api/v1/agents/agent-web-01/work" | jq '.[] | .target_id'
|
||||
|
||||
# Poll as agent-lb-01 — should only see its deployment job
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"http://localhost:8443/api/v1/agents/agent-lb-01/work" | jq '.[] | .target_id'
|
||||
```
|
||||
|
||||
**Expected:** Each agent receives only the deployment job for its assigned target. Agent-web-01 does NOT see agent-lb-01's job and vice versa.
|
||||
**PASS if** each agent's work response contains only jobs for targets it owns.
|
||||
|
||||
### 36.2 Agent With No Targets Gets Empty Work
|
||||
|
||||
**Prerequisite:** Register a new agent with no target assignments.
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"http://localhost:8443/api/v1/agents/agent-no-targets/work" | jq 'length'
|
||||
```
|
||||
|
||||
**Expected:** Empty array (0 jobs).
|
||||
**PASS if** the response is an empty list.
|
||||
|
||||
### 36.3 Deployment Jobs Have agent_id Populated
|
||||
|
||||
**Prerequisite:** Deployment jobs created via renewal or manual trigger.
|
||||
|
||||
```bash
|
||||
# Check that deployment jobs in the system have agent_id set
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"http://localhost:8443/api/v1/jobs" | jq '[.data[] | select(.type == "Deployment") | .agent_id] | map(select(. != null)) | length'
|
||||
```
|
||||
|
||||
**Expected:** All deployment jobs for targets with agent assignments have `agent_id` populated.
|
||||
**PASS if** deployment jobs have non-null `agent_id` values.
|
||||
|
||||
---
|
||||
|
||||
## Part 38: Vault PKI Connector (M32)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Vault server running with PKI secrets engine enabled at `pki` mount
|
||||
- PKI role created with appropriate certificate generation policy
|
||||
- Vault token with read/sign permissions on the PKI path
|
||||
- Environment variables configured:
|
||||
```bash
|
||||
export CERTCTL_VAULT_ADDR="https://vault.internal:8200"
|
||||
export CERTCTL_VAULT_TOKEN="s.xxxxxxxxxxxxxxxx"
|
||||
export CERTCTL_VAULT_MOUNT="pki"
|
||||
export CERTCTL_VAULT_ROLE="certctl-role"
|
||||
export CERTCTL_VAULT_TTL="8760h"
|
||||
```
|
||||
|
||||
### 38.1 Register Vault PKI Issuer
|
||||
|
||||
**Test:** Register a Vault PKI issuer via the API.
|
||||
|
||||
```bash
|
||||
curl -X POST -H "$AUTH" -H "$CT" \
|
||||
"$SERVER/api/v1/issuers" \
|
||||
-d '{
|
||||
"id": "iss-vault-prod",
|
||||
"name": "Vault PKI Production",
|
||||
"type": "VaultPKI",
|
||||
"config": {
|
||||
"vault_addr": "'"$CERTCTL_VAULT_ADDR"'",
|
||||
"vault_token": "'"$CERTCTL_VAULT_TOKEN"'",
|
||||
"vault_mount": "'"$CERTCTL_VAULT_MOUNT"'",
|
||||
"vault_role": "'"$CERTCTL_VAULT_ROLE"'",
|
||||
"vault_ttl": "'"$CERTCTL_VAULT_TTL"'"
|
||||
}
|
||||
}' | jq '.id'
|
||||
```
|
||||
|
||||
**Expected:** Returns issuer ID `iss-vault-prod`.
|
||||
**PASS if** issuer is registered and appears in `GET /api/v1/issuers`.
|
||||
|
||||
### 38.2 Issue Certificate via Vault PKI
|
||||
|
||||
**Test:** Create a certificate and issue it through Vault PKI.
|
||||
|
||||
```bash
|
||||
CERT_ID=$(curl -s -X POST -H "$AUTH" -H "$CT" \
|
||||
"$SERVER/api/v1/certificates" \
|
||||
-d '{
|
||||
"common_name": "vault-test.example.com",
|
||||
"issuer_id": "iss-vault-prod",
|
||||
"key_algorithm": "RSA-2048"
|
||||
}' | jq -r '.id')
|
||||
|
||||
curl -s -X POST -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID/renew" | jq '.job_id'
|
||||
```
|
||||
|
||||
**Expected:** Renewal job created and eventually moves to Completed status.
|
||||
**PASS if** certificate is issued by Vault with valid serial number and chain.
|
||||
|
||||
### 38.3 Verify Certificate Serial and Subject
|
||||
|
||||
**Test:** Check that the issued certificate has correct Vault metadata.
|
||||
|
||||
```bash
|
||||
curl -s -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID" | jq '.versions[0] | {serial, subject_dn, not_before, not_after}'
|
||||
```
|
||||
|
||||
**Expected:** Serial, DN, and validity dates from Vault PKI.
|
||||
**PASS if** certificate metadata is populated from Vault's response.
|
||||
|
||||
### 38.4 Revocation Records Locally
|
||||
|
||||
**Test:** Revoke the certificate and verify local recording.
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID/revoke" \
|
||||
-d '{"reason": "superseded"}' | jq '.revoked_at'
|
||||
```
|
||||
|
||||
**Expected:** Returns `revoked_at` timestamp.
|
||||
**PASS if** revocation is recorded locally in the audit trail but not propagated to Vault (Vault is authoritative for its own revocation).
|
||||
|
||||
---
|
||||
|
||||
## Part 39: DigiCert Connector (M37)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- DigiCert CertCentral account with API access
|
||||
- API key and organization ID from DigiCert
|
||||
- Environment variables configured:
|
||||
```bash
|
||||
export CERTCTL_DIGICERT_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
export CERTCTL_DIGICERT_ORG_ID="123456"
|
||||
export CERTCTL_DIGICERT_PRODUCT_TYPE="ssl_basic"
|
||||
export CERTCTL_DIGICERT_BASE_URL="https://www.digicert.com/services/v2"
|
||||
```
|
||||
|
||||
### 39.1 Register DigiCert Issuer
|
||||
|
||||
**Test:** Register a DigiCert CertCentral issuer via the API.
|
||||
|
||||
```bash
|
||||
curl -X POST -H "$AUTH" -H "$CT" \
|
||||
"$SERVER/api/v1/issuers" \
|
||||
-d '{
|
||||
"id": "iss-digicert-prod",
|
||||
"name": "DigiCert CertCentral",
|
||||
"type": "DigiCert",
|
||||
"config": {
|
||||
"api_key": "'"$CERTCTL_DIGICERT_API_KEY"'",
|
||||
"org_id": "'"$CERTCTL_DIGICERT_ORG_ID"'",
|
||||
"product_type": "'"$CERTCTL_DIGICERT_PRODUCT_TYPE"'",
|
||||
"base_url": "'"$CERTCTL_DIGICERT_BASE_URL"'"
|
||||
}
|
||||
}' | jq '.id'
|
||||
```
|
||||
|
||||
**Expected:** Returns issuer ID `iss-digicert-prod`.
|
||||
**PASS if** issuer is registered and appears in `GET /api/v1/issuers`.
|
||||
|
||||
### 39.2 Issue DV Certificate via DigiCert
|
||||
|
||||
**Test:** Create a DV certificate order and track it to completion.
|
||||
|
||||
```bash
|
||||
CERT_ID=$(curl -s -X POST -H "$AUTH" -H "$CT" \
|
||||
"$SERVER/api/v1/certificates" \
|
||||
-d '{
|
||||
"common_name": "dv-test.example.com",
|
||||
"issuer_id": "iss-digicert-prod",
|
||||
"key_algorithm": "RSA-2048"
|
||||
}' | jq -r '.id')
|
||||
|
||||
JOB_ID=$(curl -s -X POST -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID/renew" | jq -r '.job_id')
|
||||
|
||||
# Poll for job completion (DV certs may issue immediately)
|
||||
for i in {1..30}; do
|
||||
STATUS=$(curl -s -H "$AUTH" \
|
||||
"$SERVER/api/v1/jobs/$JOB_ID" | jq -r '.status')
|
||||
echo "Job status: $STATUS"
|
||||
[ "$STATUS" = "Completed" ] && break
|
||||
sleep 2
|
||||
done
|
||||
```
|
||||
|
||||
**Expected:** Job eventually reaches Completed status with certificate issued.
|
||||
**PASS if** certificate has DigiCert serial number and chain.
|
||||
|
||||
### 39.3 Verify Order ID Tracking
|
||||
|
||||
**Test:** Check that the job record includes the DigiCert order ID for auditing.
|
||||
|
||||
```bash
|
||||
curl -s -H "$AUTH" \
|
||||
"$SERVER/api/v1/jobs/$JOB_ID" | jq '.metadata'
|
||||
```
|
||||
|
||||
**Expected:** Metadata includes `order_id` from DigiCert for order tracking.
|
||||
**PASS if** audit trail shows the DigiCert order lifecycle.
|
||||
|
||||
### 39.4 Async Poll Behavior
|
||||
|
||||
**Test:** Verify the connector polls for certificate completion (OV certs take longer).
|
||||
|
||||
```bash
|
||||
# Submit OV certificate order (requires validation)
|
||||
CERT_ID=$(curl -s -X POST -H "$AUTH" -H "$CT" \
|
||||
"$SERVER/api/v1/certificates" \
|
||||
-d '{
|
||||
"common_name": "ov-test.example.com",
|
||||
"issuer_id": "iss-digicert-prod",
|
||||
"key_algorithm": "RSA-2048"
|
||||
}' | jq -r '.id')
|
||||
|
||||
JOB_ID=$(curl -s -X POST -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID/renew" | jq -r '.job_id')
|
||||
|
||||
# Check job status transitions
|
||||
curl -s -H "$AUTH" "$SERVER/api/v1/jobs/$JOB_ID" | jq '.status'
|
||||
```
|
||||
|
||||
**Expected:** Job status transitions through pending states as DigiCert validates.
|
||||
**PASS if** polling mechanism works and job reaches completion once DigiCert issues the certificate.
|
||||
|
||||
### 39.5 Revocation Records Locally
|
||||
|
||||
**Test:** Revoke a DigiCert-issued certificate.
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID/revoke" \
|
||||
-d '{"reason": "cessationOfOperation"}' | jq '.revoked_at'
|
||||
```
|
||||
|
||||
**Expected:** Returns `revoked_at` timestamp.
|
||||
**PASS if** revocation is recorded locally; operator manages revocation in DigiCert CertCentral dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Release Sign-Off
|
||||
|
||||
All 34 parts must pass before tagging v2.1.0.
|
||||
All tests below must pass before tagging v2.1.0. Each row is one individual test from the guide above. The **Method** column indicates whether `qa-smoke-test.sh` covers the test automatically (**Auto**) or requires hands-on verification (**Manual**).
|
||||
|
||||
| Section | Pass? | Tester | Date | Notes |
|
||||
|---------|-------|--------|------|-------|
|
||||
| Part 1: Infrastructure & Deployment | ☐ | | | |
|
||||
| Part 2: Authentication & Security | ☐ | | | |
|
||||
| Part 3: Certificate Lifecycle (CRUD) | ☐ | | | |
|
||||
| Part 4: Renewal Workflow | ☐ | | | |
|
||||
| Part 5: Revocation | ☐ | | | |
|
||||
| Part 6: Issuer Connectors | ☐ | | | |
|
||||
| Part 7: Target Connectors & Deployment | ☐ | | | |
|
||||
| Part 8: Agent Operations | ☐ | | | |
|
||||
| Part 9: Job System | ☐ | | | |
|
||||
| Part 10: Policies & Profiles | ☐ | | | |
|
||||
| Part 11: Ownership, Teams & Agent Groups | ☐ | | | |
|
||||
| Part 12: Notifications | ☐ | | | |
|
||||
| Part 13: Observability (JSON + Prometheus) | ☐ | | | |
|
||||
| Part 14: Audit Trail | ☐ | | | |
|
||||
| Part 15: Certificate Discovery (Filesystem + Network) | ☐ | | | |
|
||||
| Part 16: Enhanced Query API | ☐ | | | |
|
||||
| Part 17: CLI Tool | ☐ | | | |
|
||||
| Part 18: MCP Server | ☐ | | | |
|
||||
| Part 19: GUI Testing | ☐ | | | |
|
||||
| Part 20: Background Scheduler | ☐ | | | |
|
||||
| Part 21: Error Handling | ☐ | | | |
|
||||
| Part 22: Performance Spot Checks | ☐ | | | |
|
||||
| Part 23: Structured Logging | ☐ | | | |
|
||||
| Part 24: Documentation Verification | ☐ | | | |
|
||||
| Part 25: Regression Tests | ☐ | | | |
|
||||
| Part 26: EST Server (RFC 7030) | ☐ | | | |
|
||||
| Part 27: Post-Deployment TLS Verification | ☐ | | | |
|
||||
| Part 28: Traefik & Caddy Target Connectors | ☐ | | | |
|
||||
| Part 29: Certificate Export (PEM & PKCS#12) | ☐ | | | |
|
||||
| Part 30: S/MIME & EKU Support | ☐ | | | |
|
||||
| Part 31: OCSP Responder & DER CRL | ☐ | | | |
|
||||
| Part 32: Request Body Size Limits | ☐ | | | |
|
||||
| Part 33: Apache & HAProxy Target Connectors | ☐ | | | |
|
||||
| Part 34: Sub-CA Mode | ☐ | | | |
|
||||
### Automated Prerequisites
|
||||
|
||||
These must be green before starting manual QA:
|
||||
|
||||
| Gate | Pass? | Date | Notes |
|
||||
|------|-------|------|-------|
|
||||
| CI pipeline green (Go build + vet + race + lint + vuln + tests) | ☐ | | |
|
||||
| CI pipeline green (Frontend tsc + vitest + vite build) | ☐ | | |
|
||||
| Coverage thresholds met (service 60%, handler 60%, domain 40%, middleware 50%) | ☐ | | |
|
||||
| `qa-smoke-test.sh` — 0 failures | ☑ | 2026-03-30 | 124 pass, 0 fail, 5 skip |
|
||||
|
||||
### Part 1: Infrastructure & Deployment
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 1.1.1 | PostgreSQL is accepting connections | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.1.2 | Database schema applied (21 tables) | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.1.3 | Server liveness probe | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.1.4 | Server readiness probe | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.1.5 | Agent container is running | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.1.6 | Demo seed data loaded (all 9 resource types) | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.2.1 | Server shuts down cleanly on SIGTERM | Manual | ☐ | | |
|
||||
| 1.2.2 | Data persists across full restart | Manual | ☐ | | |
|
||||
| 1.3.1 | Custom port binding | Manual | ☐ | | |
|
||||
| 1.3.2 | Debug logging | Manual | ☐ | | |
|
||||
| 1.3.3 | Auth disabled with explicit none | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.3.4 | Auth none produces warning log | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 2: Authentication & Security
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 2.1.1 | Request without auth header returns 401 | Manual | ☐ | | |
|
||||
| 2.1.2 | Request with wrong API key returns 401 | Manual | ☐ | | |
|
||||
| 2.1.3 | Request with valid API key returns 200 | Manual | ☐ | | |
|
||||
| 2.1.4 | /health accessible without auth (always) | Manual | ☐ | | |
|
||||
| 2.1.5 | /ready accessible without auth (always) | Manual | ☐ | | |
|
||||
| 2.1.6 | /api/v1/auth/info accessible without auth (GUI bootstrap) | Manual | ☐ | | |
|
||||
| 2.1.7 | /api/v1/auth/check with valid key returns 200 | Manual | ☐ | | |
|
||||
| 2.1.8 | /api/v1/auth/check without key returns 401 | Manual | ☐ | | |
|
||||
| 2.2.1 | Burst exceeds limit, returns 429 with Retry-After | Manual | ☐ | | |
|
||||
| 2.2.2 | 429 response includes Retry-After header | Manual | ☐ | | |
|
||||
| 2.2.3 | Rate limit bucket refills after waiting | Manual | ☐ | | |
|
||||
| 2.3.1 | Preflight OPTIONS with allowed origin returns CORS headers | Manual | ☐ | | |
|
||||
| 2.3.2 | Request from disallowed origin has no CORS headers | Manual | ☐ | | |
|
||||
| 2.3.3 | Wildcard CORS mode | Manual | ☐ | | |
|
||||
| 2.4.1 | Private keys never in API responses (certificate detail) | Auto | ☑ | 2026-03-30 | |
|
||||
| 2.4.2 | Private keys never in API responses (certificate versions) | Auto | ☑ | 2026-03-30 | |
|
||||
| 2.4.3 | Private keys never in API responses (agent work) | Auto | ☑ | 2026-03-30 | |
|
||||
| 2.4.4 | Private keys never in server logs | Auto | ☑ | 2026-03-30 | |
|
||||
| 2.4.5 | API key stored as SHA-256 hash (not plaintext) | Manual | ☐ | | |
|
||||
|
||||
### Part 3: Certificate Lifecycle (CRUD)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 3.1.1 | Create certificate with minimal fields | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.1.2 | Create certificate with all fields | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.1.3 | Create certificate with duplicate common_name | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.1 | List certificates with pagination metadata | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.2 | Filter by status | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.3 | Filter by owner | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.4 | Filter by issuer | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.5 | Filter by environment | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.6 | Pagination: page 2 | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.7 | Sort descending by notAfter | Manual | ☐ | | |
|
||||
| 3.2.8 | Sort ascending by commonName | Manual | ☐ | | |
|
||||
| 3.2.9 | Sparse fields | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.10 | Cursor pagination: first page | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.11 | Cursor pagination: second page | Manual | ☐ | | |
|
||||
| 3.2.12 | Time-range filter: expires_before | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.3.1 | Get single certificate by ID | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.3.2 | Get nonexistent certificate returns 404 | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.3.3 | Update certificate fields | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.3.4 | Archive (soft delete) certificate | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.3.5 | Get archived certificate behavior | Manual | ☐ | | |
|
||||
| 3.4.1 | Get certificate versions | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.4.2 | Get certificate deployments | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.4.3 | Trigger deployment creates a job | Manual | ☐ | | |
|
||||
|
||||
### Part 4: Renewal Workflow
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 4.1.1 | Trigger renewal creates job | Auto | ☑ | 2026-03-30 | |
|
||||
| 4.1.2 | Renewal job appears in jobs list | Auto | ☑ | 2026-03-30 | |
|
||||
| 4.1.3 | Renewal on nonexistent certificate returns 404 | Auto | ☑ | 2026-03-30 | |
|
||||
| 4.2.1 | Server keygen mode: job completes automatically | Manual | ☐ | | |
|
||||
| 4.3.1 | Approve a job | Manual | ☐ | | |
|
||||
| 4.3.2 | Reject a job with reason | Manual | ☐ | | |
|
||||
| 4.4.1 | Agent work endpoint returns pending jobs | Auto | ☑ | 2026-03-30 | |
|
||||
| 4.4.2 | Agent reports job status | Manual | ☐ | | |
|
||||
|
||||
### Part 5: Revocation
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 5.1.1 | Revoke with default reason | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.1.2 | Revoke with reason: keyCompromise | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.1.3 | Revoke with reason: caCompromise | Manual | ☐ | | |
|
||||
| 5.1.4 | Revoke with reason: affiliationChanged | Manual | ☐ | | |
|
||||
| 5.1.5 | Revoke with reason: superseded | Manual | ☐ | | |
|
||||
| 5.1.6 | Revoke with reason: cessationOfOperation | Manual | ☐ | | |
|
||||
| 5.1.7 | Revoke with reason: certificateHold | Manual | ☐ | | |
|
||||
| 5.1.8 | Revoke with reason: privilegeWithdrawn | Manual | ☐ | | |
|
||||
| 5.2.1 | Revoke already-revoked certificate | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.2.2 | Revoke nonexistent certificate | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.2.3 | Revoke with invalid reason | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.2.4 | Revocation appears in audit trail | Manual | ☐ | | |
|
||||
| 5.3.1 | JSON CRL endpoint | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.3.2 | DER CRL endpoint | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.3.3 | OCSP: good response for non-revoked cert | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.3.4 | OCSP: revoked response for revoked cert | Manual | ☐ | | |
|
||||
| 5.3.5 | OCSP: unknown serial | Manual | ☐ | | |
|
||||
|
||||
### Part 6: Issuer Connectors
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 6.1.1 | List issuers shows seed data | Auto | ☑ | 2026-03-30 | |
|
||||
| 6.1.2 | Get issuer detail | Auto | ☑ | 2026-03-30 | |
|
||||
| 6.1.3 | Create issuer | Auto | ☑ | 2026-03-30 | |
|
||||
| 6.1.4 | Update issuer | Manual | ☐ | | |
|
||||
| 6.1.5 | Delete issuer | Auto | ☑ | 2026-03-30 | |
|
||||
| 6.1.6 | Test issuer connection | Manual | ☐ | | |
|
||||
| 6.1.7 | Create issuer with missing name returns validation error | Auto | ☑ | 2026-03-30 | |
|
||||
| 6.1.8 | Create issuer with invalid type | Manual | ☐ | | |
|
||||
| 6.2.1 | List ACME issuer with DNS-01 configuration | Manual | ☐ | | |
|
||||
| 6.2.2 | Create ACME issuer with DNS-PERSIST-01 | Manual | ☐ | | |
|
||||
| 6.2.3 | Configure ACME with External Account Binding (ZeroSSL) | Manual | ☐ | | |
|
||||
|
||||
### Part 7: Target Connectors & Deployment
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 7.1.1 | List targets shows seed data | Auto | ☑ | 2026-03-30 | |
|
||||
| 7.1.2 | Create NGINX target | Auto | ☑ | 2026-03-30 | |
|
||||
| 7.1.3 | Create Apache target | Manual | ☐ | | |
|
||||
| 7.1.4 | Create HAProxy target | Manual | ☐ | | |
|
||||
| 7.1.5 | Create F5 BIG-IP target (stub) | Auto | ☑ | 2026-03-30 | |
|
||||
| 7.1.6 | Create IIS target (stub) | Auto | ☑ | 2026-03-30 | |
|
||||
| 7.1.7 | Get target verifies type-specific config stored | Manual | ☐ | | |
|
||||
| 7.1.8 | Update target config | Manual | ☐ | | |
|
||||
| 7.1.9 | Delete target returns 204 | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 8: Agent Operations
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 8.1.1 | Register new agent | Auto | ☑ | 2026-03-30 | |
|
||||
| 8.1.2 | List agents includes new agent | Manual | ☐ | | |
|
||||
| 8.1.3 | Get agent detail with metadata | Manual | ☐ | | |
|
||||
| 8.2.1 | Agent heartbeat updates last_heartbeat_at | Auto | ☑ | 2026-03-30 | |
|
||||
| 8.2.2 | Heartbeat metadata stored | Auto | ☑ | 2026-03-30 | |
|
||||
| 8.2.3 | Heartbeat for nonexistent agent | Auto | ☑ | 2026-03-30 | |
|
||||
| 8.3.1 | Agent work polling returns jobs | Manual | ☐ | | |
|
||||
| 8.3.2 | Agent work polling with no pending work | Manual | ☐ | | |
|
||||
| 8.3.3 | Agent certificate pickup | Manual | ☐ | | |
|
||||
| 8.3.4 | Delete agent for cleanup | Auto | — | 2026-03-30 | Skipped — DELETE not implemented |
|
||||
|
||||
### Part 9: Job System
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 9.1.1 | List jobs with pagination | Auto | ☑ | 2026-03-30 | |
|
||||
| 9.1.2 | Filter jobs by status | Manual | ☐ | | |
|
||||
| 9.1.3 | Filter jobs by type | Manual | ☐ | | |
|
||||
| 9.1.4 | Get job detail | Manual | ☐ | | |
|
||||
| 9.1.5 | Get nonexistent job | Auto | ☑ | 2026-03-30 | |
|
||||
| 9.2.1 | Cancel pending job | Manual | ☐ | | |
|
||||
| 9.2.2 | Cancel already-completed job | Manual | ☐ | | |
|
||||
|
||||
### Part 10: Policies & Profiles
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 10.1.1 | List policies | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.1.2 | Create policy | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.1.3 | Get policy | Manual | ☐ | | |
|
||||
| 10.1.4 | Update policy | Manual | ☐ | | |
|
||||
| 10.1.5 | Delete policy | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.1.6 | Policy violations endpoint | Manual | ☐ | | |
|
||||
| 10.1.7 | Invalid policy type returns 400 | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.2.1 | List profiles | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.2.2 | Create profile with crypto constraints | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.2.3 | Get profile | Manual | ☐ | | |
|
||||
| 10.2.4 | Update profile | Manual | ☐ | | |
|
||||
| 10.2.5 | Delete profile | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.2.6 | Short-lived profile exists (TTL < 1 hour) | Manual | ☐ | | |
|
||||
|
||||
### Part 11: Ownership, Teams & Agent Groups
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 11.1.1 | List teams | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.1.2 | Team CRUD cycle | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.2.1 | Owner CRUD with team assignment | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.2.2 | Get, update, delete owner | Manual | ☐ | | |
|
||||
| 11.3.1 | List agent groups | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.3.2 | Create agent group with dynamic criteria | Manual | ☐ | | |
|
||||
| 11.3.3 | Agent group membership endpoint | Manual | ☐ | | |
|
||||
| 11.3.4 | Delete agent group returns 204 | Manual | ☐ | | |
|
||||
| 11.4.1 | Delete owner with assigned certificates (expect 409) | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.4.2 | Delete issuer with assigned certificates (expect 409) | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.4.3 | Delete team cascades successfully | Manual | ☐ | | |
|
||||
|
||||
### Part 12: Notifications
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 12.1.1 | List notifications with pagination | Auto | ☑ | 2026-03-30 | |
|
||||
| 12.1.2 | Get single notification | Manual | ☐ | | |
|
||||
| 12.1.3 | Mark notification as read | Auto | ☑ | 2026-03-30 | |
|
||||
| 12.1.4 | Mark already-read notification (idempotent) | Manual | ☐ | | |
|
||||
| 12.1.5 | Get nonexistent notification | Auto | ☑ | 2026-03-30 | |
|
||||
| 12.1.6 | Verify notification created from revocation | Manual | ☐ | | |
|
||||
|
||||
### Part 13: Observability
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 13.1.1 | Dashboard summary | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.1.2 | Certificates by status | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.1.3 | Expiration timeline | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.1.4 | Job trends | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.1.5 | Issuance rate | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.1.6 | Stats with invalid days parameter | Manual | ☐ | | |
|
||||
| 13.2.1 | JSON metrics endpoint | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.2.2 | Metric values are non-negative | Manual | ☐ | | |
|
||||
| 13.2.3 | Uptime is positive | Manual | ☐ | | |
|
||||
| 13.3.1 | Prometheus content type | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.3.2 | Prometheus output contains HELP lines | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.3.3 | Prometheus output contains TYPE lines | Manual | ☐ | | |
|
||||
| 13.3.4 | All documented Prometheus metrics present | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.3.5 | Prometheus metric values are parseable numbers | Manual | ☐ | | |
|
||||
| 13.3.6 | Method not allowed on metrics (POST) | Manual | ☐ | | |
|
||||
|
||||
### Part 14: Audit Trail
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 14.1.1 | List audit events | Auto | ☑ | 2026-03-30 | |
|
||||
| 14.1.2 | Get single audit event | Manual | ☐ | | |
|
||||
| 14.1.3 | Filter audit by time range | Manual | ☐ | | |
|
||||
| 14.1.4 | Filter audit by actor | Manual | ☐ | | |
|
||||
| 14.1.5 | Filter audit by resource type | Auto | ☑ | 2026-03-30 | |
|
||||
| 14.1.6 | Filter audit by action | Manual | ☐ | | |
|
||||
| 14.1.7 | API calls create audit entries | Manual | ☐ | | |
|
||||
| 14.1.8 | Audit immutability (no PUT/DELETE) | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 15: Certificate Discovery
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 15.1.1 | Submit discovery report | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.1.2 | Submit report with multiple certificates | Manual | ☐ | | |
|
||||
| 15.1.3 | Duplicate fingerprint deduplication | Manual | ☐ | | |
|
||||
| 15.1.4 | List discovered certificates | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.1.5 | Filter by status: Unmanaged | Manual | ☐ | | |
|
||||
| 15.1.6 | Filter by agent_id | Manual | ☐ | | |
|
||||
| 15.1.7 | Get discovered certificate detail | Manual | ☐ | | |
|
||||
| 15.1.8 | Claim discovered certificate | Manual | ☐ | | |
|
||||
| 15.1.9 | Dismiss discovered certificate | Manual | ☐ | | |
|
||||
| 15.1.10 | List discovery scans | Manual | ☐ | | |
|
||||
| 15.1.11 | Discovery summary | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.2.1 | List network scan targets (seed data) | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.2.2 | Create network scan target | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.2.3 | Get scan target detail | Manual | ☐ | | |
|
||||
| 15.2.4 | Update scan target | Manual | ☐ | | |
|
||||
| 15.2.5 | Delete scan target | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.2.6 | Trigger manual scan | Manual | ☐ | | |
|
||||
| 15.2.7 | Invalid CIDR validation | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 16: Enhanced Query API
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 16.1.1 | Sparse fields: only requested fields returned | Manual | ☐ | | |
|
||||
| 16.1.2 | Sort ascending: commonName | Manual | ☐ | | |
|
||||
| 16.1.3 | Sort descending: notAfter | Manual | ☐ | | |
|
||||
| 16.1.4 | Sort by invalid field | Auto | ☑ | 2026-03-30 | |
|
||||
| 16.1.5 | Cursor pagination first page | Manual | ☐ | | |
|
||||
| 16.1.6 | Cursor pagination second page | Manual | ☐ | | |
|
||||
| 16.1.7 | Time-range: expires_before | Auto | ☑ | 2026-03-30 | |
|
||||
| 16.1.8 | Time-range: created_after | Auto | ☑ | 2026-03-30 | |
|
||||
| 16.1.9 | Combined filters | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 17: CLI Tool
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 17.2.1 | List certificates (table format) | Manual | ☐ | | |
|
||||
| 17.2.2 | List certificates (JSON format) | Manual | ☐ | | |
|
||||
| 17.2.3 | Get specific certificate | Manual | ☐ | | |
|
||||
| 17.2.4 | Get nonexistent certificate | Manual | ☐ | | |
|
||||
| 17.2.5 | Renew certificate | Manual | ☐ | | |
|
||||
| 17.2.6 | Revoke certificate with reason | Manual | ☐ | | |
|
||||
| 17.3.1 | List agents | Manual | ☐ | | |
|
||||
| 17.3.2 | List jobs | Manual | ☐ | | |
|
||||
| 17.4.1 | Server status/health | Manual | ☐ | | |
|
||||
| 17.4.2 | CLI version | Manual | ☐ | | |
|
||||
| 17.5.1 | Import single PEM file | Manual | ☐ | | |
|
||||
| 17.6.1 | --server flag overrides env var | Manual | ☐ | | |
|
||||
| 17.6.2 | --api-key flag overrides env var | Manual | ☐ | | |
|
||||
| 17.6.3 | Missing server URL produces error | Manual | ☐ | | |
|
||||
|
||||
### Part 18: MCP Server
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 18.1.1 | Binary builds successfully | Manual | ☐ | | |
|
||||
| 18.1.2 | Startup with valid env vars | Manual | ☐ | | |
|
||||
| 18.1.3 | Missing CERTCTL_SERVER_URL behavior | Manual | ☐ | | |
|
||||
| 18.2.1 | Tool count verification (78 tools) | Manual | ☐ | | |
|
||||
| 18.2.2 | All 16 resource domains present | Manual | ☐ | | |
|
||||
| 18.3.1 | List certificates via MCP | Manual | ☐ | | |
|
||||
| 18.3.2 | Get specific certificate via MCP | Manual | ☐ | | |
|
||||
|
||||
### Part 19: GUI Testing
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 19.1 | Authentication Flow | Manual | ☐ | | |
|
||||
| 19.2 | Dashboard Page | Manual | ☐ | | |
|
||||
| 19.3 | Certificates Page | Manual | ☐ | | |
|
||||
| 19.4 | Certificate Detail Page | Manual | ☐ | | |
|
||||
| 19.5 | Jobs Page — Approval Workflow | Manual | ☐ | | |
|
||||
| 19.6 | Discovery Triage Page | Manual | ☐ | | |
|
||||
| 19.7 | Network Scan Management Page | Manual | ☐ | | |
|
||||
| 19.8 | Other Pages (agents, policies, audit, etc.) | Manual | ☐ | | |
|
||||
| 19.9 | Cross-Cutting (responsive, error states, dark theme) | Manual | ☐ | | |
|
||||
|
||||
### Part 20: Background Scheduler
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 20.1.1 | Scheduler startup: all 7 loops registered | Manual | ☐ | | |
|
||||
| 20.1.2 | Job processor loop fires (30s interval) | Manual | ☐ | | |
|
||||
| 20.1.3 | Agent health check marks offline (2m interval) | Manual | ☐ | | |
|
||||
| 20.1.4 | Notification processor fires (1m interval) | Manual | ☐ | | |
|
||||
| 20.1.5 | Short-lived expiry check (30s interval) | Manual | ☐ | | |
|
||||
| 20.1.6 | Network scanner loop (conditional on env var) | Manual | ☐ | | |
|
||||
| 20.1.7 | Renewal check loop (1h interval — log verification) | Manual | ☐ | | |
|
||||
| 20.1.8 | Scheduler graceful stop | Manual | ☐ | | |
|
||||
|
||||
### Part 21: Error Handling
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 21.1.1 | Malformed JSON body | Auto | ☑ | 2026-03-30 | |
|
||||
| 21.1.2 | Missing required field | Auto | ☑ | 2026-03-30 | |
|
||||
| 21.1.3 | Method not allowed | Auto | ☑ | 2026-03-30 | |
|
||||
| 21.1.4 | Invalid query parameter | Manual | ☐ | | |
|
||||
| 21.1.5 | UTF-8 in common name | Auto | ☑ | 2026-03-30 | |
|
||||
| 21.1.6 | Concurrent requests (parallel curl) | Manual | ☐ | | |
|
||||
| 21.1.7 | Server survives internal error | Auto | ☑ | 2026-03-30 | |
|
||||
| 21.1.8 | Empty request body on POST | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 22: Performance Spot Checks
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 22.1.1 | List certificates < 200ms | Auto | ☑ | 2026-03-30 | |
|
||||
| 22.1.2 | Stats summary < 500ms | Auto | ☑ | 2026-03-30 | |
|
||||
| 22.1.3 | Metrics < 200ms | Auto | ☑ | 2026-03-30 | |
|
||||
| 22.1.4 | 50 health checks < 5 seconds total | Manual | ☐ | | |
|
||||
|
||||
### Part 23: Structured Logging
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 23.1.1 | Server logs are valid JSON | Manual | ☐ | | |
|
||||
| 23.1.2 | Log lines contain level field | Manual | ☐ | | |
|
||||
| 23.1.3 | Request ID propagation | Manual | ☐ | | |
|
||||
| 23.1.4 | Error logs at ERROR level | Manual | ☐ | | |
|
||||
| 23.1.5 | No unstructured output in log stream | Manual | ☐ | | |
|
||||
|
||||
### Part 24: Documentation Verification
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 24.1 | OpenAPI spec matches router, README accuracy | Manual | ☐ | | |
|
||||
|
||||
### Part 25: Regression Tests
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 25.1.1 | DELETE endpoints return 204, not 200 | Auto | ☑ | 2026-03-30 | |
|
||||
| 25.1.2 | per_page exceeding max falls back to default | Auto | ☑ | 2026-03-30 | |
|
||||
| 25.1.3 | Seed demo network scan targets present | Auto | ☑ | 2026-03-30 | |
|
||||
| 25.1.4 | GUI delete on FK-restricted entities shows error, not silent f... | Auto | ☑ | 2026-03-30 | |
|
||||
| 25.1.5 | OpenAPI spec operations match router | Manual | ☐ | | |
|
||||
| 25.1.6 | Go service tests use strings.Contains, not errors.Is | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 26: EST Server (RFC 7030)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 26.1 | GET /.well-known/est/cacerts returns PKCS#7 CA chain | Auto | — | 2026-03-30 | Skipped — EST not enabled in demo |
|
||||
| 26.2 | GET /cacerts method enforcement | Auto | — | 2026-03-30 | Skipped — EST not enabled in demo |
|
||||
| 26.3 | POST /.well-known/est/simpleenroll with PEM CSR | Manual | ☐ | | |
|
||||
| 26.4 | POST /simpleenroll with base64-encoded DER CSR | Manual | ☐ | | |
|
||||
| 26.5 | POST /simpleenroll with empty body | Auto | — | 2026-03-30 | Skipped — EST not enabled in demo |
|
||||
| 26.6 | POST /simpleenroll with invalid CSR | Manual | ☐ | | |
|
||||
| 26.7 | POST /simpleenroll with CSR missing Common Name | Manual | ☐ | | |
|
||||
| 26.8 | POST /simpleenroll method enforcement (GET not allowed) | Manual | ☐ | | |
|
||||
| 26.9 | POST /.well-known/est/simplereenroll (re-enrollment) | Manual | ☐ | | |
|
||||
| 26.10 | GET /simplereenroll method enforcement | Manual | ☐ | | |
|
||||
| 26.11 | GET /.well-known/est/csrattrs returns 204 (no required attrs) | Auto | — | 2026-03-30 | Skipped — EST not enabled in demo |
|
||||
| 26.12 | POST /csrattrs method enforcement | Manual | ☐ | | |
|
||||
| 26.13 | EST enrollment creates audit event | Manual | ☐ | | |
|
||||
| 26.14 | EST disabled returns 404 | Manual | ☐ | | |
|
||||
| 26.15 | EST with profile binding | Manual | ☐ | | |
|
||||
|
||||
### Part 27: Post-Deployment TLS Verification
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 27.1 | Submit Verification Result (Success) | Manual | ☐ | | |
|
||||
| 27.2 | Submit Verification Result (Failure — Fingerprint Mismatch) | Manual | ☐ | | |
|
||||
| 27.3 | Get Verification Status | Manual | ☐ | | |
|
||||
| 27.4 | Missing Required Fields | Manual | ☐ | | |
|
||||
| 27.5 | Audit Trail | Manual | ☐ | | |
|
||||
| 27.6 | Database Schema Verification | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 28: Traefik & Caddy Target Connectors
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 28.1 | Traefik File Provider Deployment | Manual | ☐ | | |
|
||||
| 28.2 | Caddy API Mode Deployment | Manual | ☐ | | |
|
||||
| 28.3 | Caddy File Mode Deployment | Manual | ☐ | | |
|
||||
| 28.4 | Agent Connector Dispatch | Manual | ☐ | | |
|
||||
| 28.5 | Connector Unit Tests | Manual | ☐ | | |
|
||||
|
||||
### Part 29: Certificate Export
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 29.1 | Export PEM (JSON Response) | Auto | ☑ | 2026-03-30 | |
|
||||
| 29.2 | Export PEM (File Download) | Manual | ☐ | | |
|
||||
| 29.3 | Export PEM — Not Found | Auto | ☑ | 2026-03-30 | |
|
||||
| 29.4 | Export PKCS#12 | Auto | ☑ | 2026-03-30 | |
|
||||
| 29.5 | Export PKCS#12 — Empty Password | Manual | ☐ | | |
|
||||
| 29.6 | Export Audit Trail | Manual | ☐ | | |
|
||||
| 29.7 | Export Unit Tests | Manual | ☐ | | |
|
||||
| 29.8 | GUI Export Buttons | Manual | ☐ | | |
|
||||
|
||||
### Part 30: S/MIME & EKU Support
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 30.1 | S/MIME Profile Exists in Seed Data | Auto | ☑ | 2026-03-30 | |
|
||||
| 30.2 | All Five Profiles Present | Auto | ☑ | 2026-03-30 | |
|
||||
| 30.3 | EKU Strings in Profile API | Manual | ☐ | | |
|
||||
| 30.4 | Agent CSR SAN Splitting (Email vs DNS) | Manual | ☐ | | |
|
||||
| 30.5 | EKU Service-Layer Tests | Manual | ☐ | | |
|
||||
|
||||
### Part 31: OCSP Responder & DER CRL
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 31.1 | DER-Encoded CRL | Auto | ☑ | 2026-03-30 | |
|
||||
| 31.2 | DER CRL — Nonexistent Issuer | Auto | ☑ | 2026-03-30 | |
|
||||
| 31.3 | OCSP Responder — Good Status | Manual | ☐ | | |
|
||||
| 31.4 | OCSP Responder — Revoked Status | Manual | ☐ | | |
|
||||
| 31.5 | OCSP — Unknown Certificate | Manual | ☐ | | |
|
||||
| 31.6 | Short-Lived Certificate CRL Exemption | Manual | ☐ | | |
|
||||
| 31.7 | OCSP / CRL Unit Tests | Manual | ☐ | | |
|
||||
|
||||
### Part 32: Request Body Size Limits
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 32.1 | Default 1MB Limit | Manual | ☐ | | |
|
||||
| 32.2 | Normal-Sized Requests Work | Auto | ☑ | 2026-03-30 | |
|
||||
| 32.3 | Custom Body Size via Environment Variable | Manual | ☐ | | |
|
||||
| 32.4 | Requests Without Bodies Are Unaffected | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 33: Apache & HAProxy Target Connectors
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 33.1 | Create Apache Target | Manual | ☐ | | |
|
||||
| 33.2 | Apache Config — Separate Files | Manual | ☐ | | |
|
||||
| 33.3 | Create HAProxy Target | Manual | ☐ | | |
|
||||
| 33.4 | HAProxy Combined PEM Requirement | Manual | ☐ | | |
|
||||
| 33.5 | Shell Command Injection Prevention | Manual | ☐ | | |
|
||||
| 33.6 | Connector Unit Tests | Manual | ☐ | | |
|
||||
|
||||
### Part 34: Sub-CA Mode
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 34.1 | Self-Signed Mode (Default) | Manual | ☐ | | |
|
||||
| 34.2 | Sub-CA Mode — Configuration | Manual | ☐ | | |
|
||||
| 34.3 | Sub-CA Chain Construction | Manual | ☐ | | |
|
||||
| 34.4 | Sub-CA Validation — Non-CA Cert Rejected | Manual | ☐ | | |
|
||||
| 34.5 | Sub-CA Key Format Support | Manual | ☐ | | |
|
||||
| 34.6 | CRL Signing in Sub-CA Mode | Manual | ☐ | | |
|
||||
|
||||
### Part 35: ARI (RFC 9702) Scheduler Integration
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 35.a1 | ARI nil fallback — renewal jobs still created | Auto | ☑ | 2026-03-30 | |
|
||||
| 35.a2 | No ARI errors with Local CA issuer | Auto | ☑ | 2026-03-30 | |
|
||||
| 35.a3 | Server healthy after ARI wiring (metrics) | Auto | ☑ | 2026-03-30 | |
|
||||
| 35.1 | ARI defers renewal when CA says "not yet" (requires ACME+ARI) | Manual | ☐ | | |
|
||||
| 35.2 | ARI triggers renewal when CA says "now" (requires ACME+ARI) | Manual | ☐ | | |
|
||||
| 35.3 | ARI fallback on error — threshold-based (requires ACME+ARI) | Manual | ☐ | | |
|
||||
|
||||
### Part 36: Agent Work Routing (M31)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 36.a1 | Agent receives only its deployment jobs | Auto | ☐ | | |
|
||||
| 36.a2 | Agent with no targets gets empty work list | Auto | ☐ | | |
|
||||
| 36.a3 | Deployment jobs have agent_id populated | Auto | ☐ | | |
|
||||
| 36.1 | Multi-agent routing with 2 agents, 2 targets | Manual | ☐ | | |
|
||||
| 36.2 | Agent with no assigned targets gets empty work | Manual | ☐ | | |
|
||||
| 36.3 | Database agent_id populated on deployment jobs | Manual | ☐ | | |
|
||||
|
||||
### Part 37: GUI Completeness (Pre-2.1.0-E)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 37.1 | DigestPage renders preview iframe | Manual | ☐ | | |
|
||||
| 37.2 | DigestPage send button with confirmation modal | Manual | ☐ | | |
|
||||
| 37.3 | ObservabilityPage shows metrics gauges | Manual | ☐ | | |
|
||||
| 37.4 | ObservabilityPage Prometheus config block | Manual | ☐ | | |
|
||||
| 37.5 | ObservabilityPage live Prometheus output | Manual | ☐ | | |
|
||||
| 37.6 | JobDetailPage displays job info and timeline | Manual | ☐ | | |
|
||||
| 37.7 | JobDetailPage verification section for deployment jobs | Manual | ☐ | | |
|
||||
| 37.8 | IssuerDetailPage shows redacted config | Manual | ☐ | | |
|
||||
| 37.9 | IssuerDetailPage test connection button | Manual | ☐ | | |
|
||||
| 37.10 | IssuerDetailPage issued certificates list | Manual | ☐ | | |
|
||||
| 37.11 | TargetDetailPage shows config and agent link | Manual | ☐ | | |
|
||||
| 37.12 | TargetDetailPage deployment history table | Manual | ☐ | | |
|
||||
| 37.13 | JobsPage — job IDs clickable to /jobs/:id | Manual | ☐ | | |
|
||||
| 37.14 | JobsPage — verification column for deployment jobs | Manual | ☐ | | |
|
||||
| 37.15 | IssuersPage — issuer names clickable to /issuers/:id | Manual | ☐ | | |
|
||||
| 37.16 | TargetsPage — target names clickable to /targets/:id | Manual | ☐ | | |
|
||||
| 37.17 | Sidebar — Digest and Observability nav items | Manual | ☐ | | |
|
||||
|
||||
### Part 38: Vault PKI Connector (M32)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 38.s1 | Vault PKI issuer exists in seed data | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.1 |
|
||||
| 38.s2 | Vault issuer type is VaultPKI | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.2 |
|
||||
| 38.s3 | Vault issuer is enabled | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.3 |
|
||||
| 38.s4 | Vault connector passes go vet | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.4 |
|
||||
| 38.s5 | Vault connector tests pass | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.5 |
|
||||
| 38.s6 | OpenAPI spec includes VaultPKI type | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.6 |
|
||||
| 38.1 | Register Vault PKI issuer | Manual | ☐ | | Requires live Vault server |
|
||||
| 38.2 | Issue certificate via Vault PKI | Manual | ☐ | | Requires live Vault server |
|
||||
| 38.3 | Verify certificate serial and subject | Manual | ☐ | | Requires live Vault server |
|
||||
| 38.4 | Revocation records locally | Manual | ☐ | | Requires live Vault server |
|
||||
|
||||
### Part 39: DigiCert Connector (M37)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 39.s1 | DigiCert issuer exists in seed data | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.1 |
|
||||
| 39.s2 | DigiCert issuer type is DigiCert | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.2 |
|
||||
| 39.s3 | DigiCert issuer is enabled | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.3 |
|
||||
| 39.s4 | DigiCert connector passes go vet | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.4 |
|
||||
| 39.s5 | DigiCert connector tests pass | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.5 |
|
||||
| 39.s6 | OpenAPI spec includes DigiCert type | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.6 |
|
||||
| 39.1 | Register DigiCert issuer | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
| 39.2 | Issue DV certificate via DigiCert | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
| 39.3 | Verify order ID tracking | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
| 39.4 | Async poll behavior | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
| 39.5 | Revocation records locally | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
|
||||
### Summary
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| ☑ Auto (passed in `qa-smoke-test.sh`) | 136 |
|
||||
| — Skipped (preconditions not met in demo) | 5 |
|
||||
| ☐ Manual (requires hands-on verification) | 226 |
|
||||
| **Total** | **367** |
|
||||
|
||||
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -231,6 +232,14 @@ 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 {
|
||||
@@ -287,6 +296,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 +325,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 +371,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 +410,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 +516,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: ¬Before,
|
||||
NotAfter: ¬After,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
|
||||
case "pending", "processing":
|
||||
msg := fmt.Sprintf("order %s is %s", orderID, statusResp.Status)
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "pending",
|
||||
Message: &msg,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
|
||||
case "rejected", "denied":
|
||||
msg := fmt.Sprintf("order %s was %s", orderID, statusResp.Status)
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "failed",
|
||||
Message: &msg,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
msg := fmt.Sprintf("unknown order status: %s", statusResp.Status)
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "pending",
|
||||
Message: &msg,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// downloadCertificate downloads the PEM bundle for a DigiCert certificate.
|
||||
func (c *Connector) downloadCertificate(ctx context.Context, certificateID int) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
|
||||
downloadURL := fmt.Sprintf("%s/certificate/%d/download/format/pem_all", c.config.BaseURL, certificateID)
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if reqErr != nil {
|
||||
err = fmt.Errorf("failed to create download request: %w", reqErr)
|
||||
return
|
||||
}
|
||||
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
|
||||
|
||||
resp, doErr := c.httpClient.Do(req)
|
||||
if doErr != nil {
|
||||
err = fmt.Errorf("DigiCert download request failed: %w", doErr)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err = fmt.Errorf("DigiCert download returned status %d: %s", resp.StatusCode, string(body))
|
||||
return
|
||||
}
|
||||
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
err = fmt.Errorf("failed to read download response: %w", readErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the PEM bundle: first cert is the leaf, rest are intermediates
|
||||
certPEM, chainPEM, serial, notBefore, notAfter, err = parsePEMBundle(string(body))
|
||||
return
|
||||
}
|
||||
|
||||
// parsePEMBundle splits a PEM bundle into leaf cert and chain, extracting metadata.
|
||||
func parsePEMBundle(bundle string) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
|
||||
var certs []string
|
||||
remaining := bundle
|
||||
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest := pem.Decode([]byte(remaining))
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type == "CERTIFICATE" {
|
||||
certs = append(certs, string(pem.EncodeToMemory(block)))
|
||||
}
|
||||
remaining = string(rest)
|
||||
}
|
||||
|
||||
if len(certs) == 0 {
|
||||
err = fmt.Errorf("no certificates found in PEM bundle")
|
||||
return
|
||||
}
|
||||
|
||||
certPEM = certs[0]
|
||||
if len(certs) > 1 {
|
||||
chainPEM = strings.Join(certs[1:], "")
|
||||
}
|
||||
|
||||
// Parse leaf cert for metadata
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
err = fmt.Errorf("failed to decode leaf certificate PEM")
|
||||
return
|
||||
}
|
||||
|
||||
cert, parseErr := x509.ParseCertificate(block.Bytes)
|
||||
if parseErr != nil {
|
||||
err = fmt.Errorf("failed to parse leaf certificate: %w", parseErr)
|
||||
return
|
||||
}
|
||||
|
||||
serial = cert.SerialNumber.String()
|
||||
notBefore = cert.NotBefore
|
||||
notAfter = cert.NotAfter
|
||||
return
|
||||
}
|
||||
|
||||
// GenerateCRL is not supported because DigiCert manages CRL distribution.
|
||||
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||
return nil, fmt.Errorf("DigiCert manages CRL distribution; use DigiCert's CRL endpoints")
|
||||
}
|
||||
|
||||
// SignOCSPResponse is not supported because DigiCert manages OCSP.
|
||||
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||
return nil, fmt.Errorf("DigiCert manages OCSP; use DigiCert's OCSP responder")
|
||||
}
|
||||
|
||||
// GetCACertPEM is not directly supported. DigiCert intermediate certificates
|
||||
// come with each certificate issuance as part of the PEM bundle.
|
||||
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
return "", fmt.Errorf("DigiCert intermediate certificates are included with each issued certificate")
|
||||
}
|
||||
|
||||
// GetRenewalInfo returns nil, nil as DigiCert does not support ACME Renewal Information (ARI).
|
||||
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Ensure Connector implements the issuer.Connector interface.
|
||||
var _ issuer.Connector = (*Connector)(nil)
|
||||
@@ -0,0 +1,591 @@
|
||||
package digicert_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/digicert"
|
||||
)
|
||||
|
||||
func TestDigiCertConnector(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("ValidateConfig_Success", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/user/me" {
|
||||
if r.Header.Get("X-DC-DEVKEY") == "dc-test-api-key" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"id":12345,"first_name":"Test","last_name":"User"}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"errors":[{"code":"invalid_api_key"}]}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := digicert.Config{
|
||||
APIKey: "dc-test-api-key",
|
||||
OrgID: "12345",
|
||||
ProductType: "ssl_basic",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
|
||||
connector := digicert.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingAPIKey", func(t *testing.T) {
|
||||
config := digicert.Config{
|
||||
OrgID: "12345",
|
||||
}
|
||||
|
||||
connector := digicert.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing api_key")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "api_key is required") {
|
||||
t.Errorf("Expected api_key required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingOrgID", func(t *testing.T) {
|
||||
config := digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
}
|
||||
|
||||
connector := digicert.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing org_id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "org_id is required") {
|
||||
t.Errorf("Expected org_id required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_InvalidKey", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/user/me" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"errors":[{"code":"invalid_api_key"}]}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := digicert.Config{
|
||||
APIKey: "dc-bad-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
|
||||
connector := digicert.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid API key")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid") {
|
||||
t.Logf("Got error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_ImmediateSuccess", func(t *testing.T) {
|
||||
testCertPEM, _ := generateTestCert(t)
|
||||
testChainPEM, _ := generateTestCert(t)
|
||||
pemBundle := testCertPEM + testChainPEM
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/order/certificate/ssl_basic"):
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{"id":99001,"status":"issued","certificate_id":88001}`))
|
||||
case r.URL.Path == "/certificate/88001/download/format/pem_all":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(pemBundle))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
ProductType: "ssl_basic",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "app.example.com")
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "app.example.com",
|
||||
SANs: []string{"app.example.com"},
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.CertPEM == "" {
|
||||
t.Error("CertPEM should not be empty for immediate issuance")
|
||||
}
|
||||
if result.Serial == "" {
|
||||
t.Error("Serial should not be empty for immediate issuance")
|
||||
}
|
||||
if result.OrderID != "99001" {
|
||||
t.Errorf("Expected OrderID '99001', got '%s'", result.OrderID)
|
||||
}
|
||||
t.Logf("DigiCert issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_Pending", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/order/certificate/ssl_ev_basic"):
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{"id":99002,"status":"pending"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
ProductType: "ssl_ev_basic",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "secure.example.com")
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "secure.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.OrderID != "99002" {
|
||||
t.Errorf("Expected OrderID '99002', got '%s'", result.OrderID)
|
||||
}
|
||||
if result.CertPEM != "" {
|
||||
t.Error("CertPEM should be empty for pending order")
|
||||
}
|
||||
if result.Serial != "" {
|
||||
t.Error("Serial should be empty for pending order")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"errors":[{"code":"invalid_csr","message":"CSR is malformed"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
ProductType: "ssl_basic",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "test.example.com",
|
||||
CSRPEM: "invalid-csr",
|
||||
}
|
||||
|
||||
_, err := connector.IssueCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for server error response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_Issued", func(t *testing.T) {
|
||||
testCertPEM, _ := generateTestCert(t)
|
||||
testChainPEM, _ := generateTestCert(t)
|
||||
pemBundle := testCertPEM + testChainPEM
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/order/certificate/99001":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"id":99001,"status":"issued","certificate":{"id":88001,"common_name":"app.example.com"}}`))
|
||||
case "/certificate/88001/download/format/pem_all":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(pemBundle))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
status, err := connector.GetOrderStatus(ctx, "99001")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "completed" {
|
||||
t.Errorf("Expected status 'completed', got '%s'", status.Status)
|
||||
}
|
||||
if status.CertPEM == nil || *status.CertPEM == "" {
|
||||
t.Error("CertPEM should not be empty for issued order")
|
||||
}
|
||||
if status.Serial == nil || *status.Serial == "" {
|
||||
t.Error("Serial should not be empty for issued order")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_Pending", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/order/certificate/99002" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"id":99002,"status":"pending","certificate":{"id":0}}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
status, err := connector.GetOrderStatus(ctx, "99002")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "pending" {
|
||||
t.Errorf("Expected status 'pending', got '%s'", status.Status)
|
||||
}
|
||||
if status.CertPEM != nil {
|
||||
t.Error("CertPEM should be nil for pending order")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_Rejected", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/order/certificate/99003" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"id":99003,"status":"rejected","certificate":{"id":0}}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
status, err := connector.GetOrderStatus(ctx, "99003")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "failed" {
|
||||
t.Errorf("Expected status 'failed', got '%s'", status.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RenewCertificate_NewOrder", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/order/certificate/"):
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{"id":99010,"status":"pending"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
ProductType: "ssl_basic",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "renew.example.com")
|
||||
renewReq := issuer.RenewalRequest{
|
||||
CommonName: "renew.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.RenewCertificate(ctx, renewReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RenewCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.OrderID == "" {
|
||||
t.Error("OrderID should not be empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_Success", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/revoke") && r.Method == http.MethodPut {
|
||||
if r.Header.Get("X-DC-DEVKEY") == "" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
reason := "keyCompromise"
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "88001",
|
||||
Reason: &reason,
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_Error", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"errors":[{"code":"certificate_not_found"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "00000",
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for revocation of nonexistent cert")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_DownloadError", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/order/certificate/99004":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"id":99004,"status":"issued","certificate":{"id":88004}}`))
|
||||
case "/certificate/88004/download/format/pem_all":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"errors":["internal server error"]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
_, err := connector.GetOrderStatus(ctx, "99004")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when download fails")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "download") {
|
||||
t.Logf("Got error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: "https://api.digicert.com",
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatal("GetRenewalInfo should return nil for DigiCert")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DefaultProductType", func(t *testing.T) {
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
// ProductType intentionally left empty
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
// Verify the connector was created (the default is set in New())
|
||||
if connector == nil {
|
||||
t.Fatal("Connector should not be nil")
|
||||
}
|
||||
|
||||
// Verify via a request that uses the product type
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify the path includes the default product type
|
||||
if strings.Contains(r.URL.Path, "ssl_basic") {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{"id":99099,"status":"pending"}`))
|
||||
return
|
||||
}
|
||||
t.Errorf("Expected path to contain 'ssl_basic', got: %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Reconfigure with test server URL
|
||||
config.BaseURL = srv.URL
|
||||
connector = digicert.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "test.example.com")
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "test.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate with default product type failed: %v", err)
|
||||
}
|
||||
if result.OrderID == "" {
|
||||
t.Error("OrderID should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// generateTestCert creates a self-signed test certificate and returns the PEM strings.
|
||||
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate key: %v", err)
|
||||
}
|
||||
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: fmt.Sprintf("Test Certificate %s", serial.String()[:8]),
|
||||
},
|
||||
DNSNames: []string{"test.example.com"},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create certificate: %v", err)
|
||||
}
|
||||
|
||||
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
|
||||
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
// generateTestCSR creates a test CSR for the given common name.
|
||||
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate key: %v", err)
|
||||
}
|
||||
|
||||
csrTemplate := x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: commonName,
|
||||
},
|
||||
DNSNames: []string{commonName},
|
||||
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||
}
|
||||
|
||||
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create CSR: %v", err)
|
||||
}
|
||||
|
||||
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: csrBytes,
|
||||
}))
|
||||
|
||||
csr, err := x509.ParseCertificateRequest(csrBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse CSR: %v", err)
|
||||
}
|
||||
|
||||
return csr, csrPEM
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -11,6 +11,7 @@ type Job struct {
|
||||
Type JobType `json:"type"`
|
||||
CertificateID string `json:"certificate_id"`
|
||||
TargetID *string `json:"target_id,omitempty"`
|
||||
AgentID *string `json:"agent_id,omitempty"`
|
||||
Status JobStatus `json:"status"`
|
||||
Attempts int `json:"attempts"`
|
||||
MaxAttempts int `json:"max_attempts"`
|
||||
|
||||
@@ -662,6 +662,20 @@ func (m *mockJobRepository) GetPendingJobs(ctx context.Context, jobType domain.J
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
func (m *mockJobRepository) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||
var result []*domain.Job
|
||||
for _, j := range m.jobs {
|
||||
if j.AgentID != nil && *j.AgentID == agentID {
|
||||
if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
|
||||
result = append(result, j)
|
||||
} else if j.Status == domain.JobStatusAwaitingCSR {
|
||||
result = append(result, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type mockAuditRepository struct {
|
||||
events []*domain.AuditEvent
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -311,12 +311,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).
|
||||
|
||||
@@ -67,6 +67,11 @@ func (s *DeploymentService) CreateDeploymentJobs(ctx context.Context, certID str
|
||||
if target.ID != "" {
|
||||
job.TargetID = &target.ID
|
||||
}
|
||||
// Route job to the target's assigned agent
|
||||
if target.AgentID != "" {
|
||||
agentID := target.AgentID
|
||||
job.AgentID = &agentID
|
||||
}
|
||||
|
||||
if err := s.jobRepo.Create(ctx, job); err != nil {
|
||||
slog.Error("failed to create deployment job for target", "target_id", target.ID, "error", err)
|
||||
|
||||
@@ -85,6 +85,45 @@ func TestDeploymentService_CreateDeploymentJobs_Success(t *testing.T) {
|
||||
if job.TargetID == nil || len(*job.TargetID) == 0 {
|
||||
t.Errorf("expected job to have TargetID set")
|
||||
}
|
||||
|
||||
// M31: Verify AgentID is set from target's agent assignment
|
||||
if job.AgentID == nil {
|
||||
t.Errorf("expected job to have AgentID set (M31 agent routing)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploymentService_CreateDeploymentJobs_SetsAgentID verifies AgentID is populated from target.
|
||||
func TestDeploymentService_CreateDeploymentJobs_SetsAgentID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, jobRepo, targetRepo, _, _, _, _ := newTestDeploymentService()
|
||||
|
||||
target := &domain.DeploymentTarget{
|
||||
ID: "tgt-nginx-1",
|
||||
Name: "NGINX Server 1",
|
||||
Type: domain.TargetTypeNGINX,
|
||||
AgentID: "agent-web-01",
|
||||
Enabled: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
targetRepo.AddTarget(target)
|
||||
|
||||
jobIDs, err := svc.CreateDeploymentJobs(ctx, "mc-cert-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDeploymentJobs failed: %v", err)
|
||||
}
|
||||
|
||||
if len(jobIDs) != 1 {
|
||||
t.Fatalf("expected 1 job, got %d", len(jobIDs))
|
||||
}
|
||||
|
||||
job := jobRepo.Jobs[jobIDs[0]]
|
||||
if job.AgentID == nil {
|
||||
t.Fatal("expected AgentID to be set on deployment job")
|
||||
}
|
||||
if *job.AgentID != "agent-web-01" {
|
||||
t.Errorf("expected AgentID 'agent-web-01', got '%s'", *job.AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +243,25 @@ func (m *mockJobRepo) GetPendingJobs(ctx context.Context, jobType domain.JobType
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
func (m *mockJobRepo) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.ListErr != nil {
|
||||
return nil, m.ListErr
|
||||
}
|
||||
var result []*domain.Job
|
||||
for _, j := range m.Jobs {
|
||||
if j.AgentID != nil && *j.AgentID == agentID {
|
||||
if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
|
||||
result = append(result, j)
|
||||
} else if j.Status == domain.JobStatusAwaitingCSR {
|
||||
result = append(result, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockJobRepo) AddJob(job *domain.Job) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -660,8 +679,10 @@ func (m *mockTargetRepo) AddTarget(target *domain.DeploymentTarget) {
|
||||
|
||||
// mockIssuerConnector is a test implementation of IssuerConnector
|
||||
type mockIssuerConnector struct {
|
||||
Result *IssuanceResult
|
||||
Err error
|
||||
Result *IssuanceResult
|
||||
Err error
|
||||
getRenewalInfoResult *RenewalInfoResult
|
||||
getRenewalInfoErr error
|
||||
}
|
||||
|
||||
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) {
|
||||
@@ -717,14 +738,14 @@ func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
func (m *mockIssuerConnector) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) {
|
||||
if m.Err != nil {
|
||||
return nil, m.Err
|
||||
if m.getRenewalInfoErr != nil {
|
||||
return nil, m.getRenewalInfoErr
|
||||
}
|
||||
now := time.Now()
|
||||
return &RenewalInfoResult{
|
||||
SuggestedWindowStart: now,
|
||||
SuggestedWindowEnd: now.Add(7 * 24 * time.Hour),
|
||||
}, nil
|
||||
if m.getRenewalInfoResult != nil {
|
||||
return m.getRenewalInfoResult, nil
|
||||
}
|
||||
// Default: return nil, nil (issuer does not support ARI)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Constructor functions for mocks
|
||||
|
||||
@@ -65,6 +65,10 @@ func (m *mockVerificationJobRepo) GetPendingJobs(ctx context.Context, jobType do
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockVerificationJobRepo) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// newVerificationTestService creates a VerificationService wired with test doubles.
|
||||
func newVerificationTestService(jobs map[string]*domain.Job, jobRepoErr error) (*VerificationService, *mockVerificationJobRepo, *mockAuditRepo) {
|
||||
jobRepo := &mockVerificationJobRepo{jobs: jobs, err: jobRepoErr}
|
||||
|
||||
@@ -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;
|
||||
|
||||
-- ============================================================
|
||||
|
||||
@@ -78,6 +78,11 @@ import {
|
||||
triggerNetworkScan,
|
||||
previewDigest,
|
||||
sendDigest,
|
||||
getJob,
|
||||
getJobVerification,
|
||||
getIssuer,
|
||||
getTarget,
|
||||
getPrometheusMetrics,
|
||||
} from './client';
|
||||
|
||||
// Mock global fetch
|
||||
@@ -1006,4 +1011,99 @@ 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -365,5 +365,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');
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface Job {
|
||||
id: string;
|
||||
certificate_id: string;
|
||||
type: string;
|
||||
target_id?: string;
|
||||
agent_id?: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
max_attempts: number;
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ import ShortLivedPage from './pages/ShortLivedPage';
|
||||
import AgentFleetPage from './pages/AgentFleetPage';
|
||||
import DiscoveryPage from './pages/DiscoveryPage';
|
||||
import NetworkScanPage from './pages/NetworkScanPage';
|
||||
import DigestPage from './pages/DigestPage';
|
||||
import ObservabilityPage from './pages/ObservabilityPage';
|
||||
import JobDetailPage from './pages/JobDetailPage';
|
||||
import IssuerDetailPage from './pages/IssuerDetailPage';
|
||||
import TargetDetailPage from './pages/TargetDetailPage';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -53,11 +58,14 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="agents/:id" element={<AgentDetailPage />} />
|
||||
<Route path="fleet" element={<AgentFleetPage />} />
|
||||
<Route path="jobs" element={<JobsPage />} />
|
||||
<Route path="jobs/:id" element={<JobDetailPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="policies" element={<PoliciesPage />} />
|
||||
<Route path="profiles" element={<ProfilesPage />} />
|
||||
<Route path="issuers" element={<IssuersPage />} />
|
||||
<Route path="issuers/:id" element={<IssuerDetailPage />} />
|
||||
<Route path="targets" element={<TargetsPage />} />
|
||||
<Route path="targets/:id" element={<TargetDetailPage />} />
|
||||
<Route path="owners" element={<OwnersPage />} />
|
||||
<Route path="teams" element={<TeamsPage />} />
|
||||
<Route path="agent-groups" element={<AgentGroupsPage />} />
|
||||
@@ -65,6 +73,8 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="short-lived" element={<ShortLivedPage />} />
|
||||
<Route path="discovery" element={<DiscoveryPage />} />
|
||||
<Route path="network-scans" element={<NetworkScanPage />} />
|
||||
<Route path="digest" element={<DigestPage />} />
|
||||
<Route path="observability" element={<ObservabilityPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { Certificate } from '../api/types';
|
||||
|
||||
function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
id: '',
|
||||
common_name: '',
|
||||
environment: 'production',
|
||||
@@ -35,6 +36,12 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
|
||||
<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="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="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 }))}
|
||||
@@ -89,7 +96,7 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
|
||||
<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'}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useParams } 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 } from '../api/types';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
local_ca: 'Local CA',
|
||||
acme: 'ACME (Let\'s Encrypt)',
|
||||
step_ca: 'step-ca',
|
||||
openssl: 'OpenSSL / Custom',
|
||||
vault: 'Vault PKI',
|
||||
};
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
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 IssuerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Redact sensitive config fields
|
||||
const safeConfig = issuer.config ? Object.fromEntries(
|
||||
Object.entries(issuer.config).map(([k, v]) => {
|
||||
const sensitive = ['password', 'secret', 'token', 'key', 'hmac', 'private'].some(s => k.toLowerCase().includes(s));
|
||||
return [k, sensitive ? '********' : v];
|
||||
})
|
||||
) : {};
|
||||
|
||||
const certColumns: Column<Certificate>[] = [
|
||||
{
|
||||
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={
|
||||
<button
|
||||
onClick={() => testMutation.mutate()}
|
||||
disabled={testMutation.isPending}
|
||||
className="btn btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<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={issuer.status} />} />
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } 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';
|
||||
@@ -120,7 +121,9 @@ export default function IssuersPage() {
|
||||
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>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getJob, getJobVerification, getAuditEvents } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime, timeAgo } from '../api/utils';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between py-2 border-b border-surface-border/50">
|
||||
<span className="text-sm text-ink-muted">{label}</span>
|
||||
<span className="text-sm text-ink">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerificationBadge({ status }: { status?: string }) {
|
||||
if (!status) return <span className="text-xs text-ink-faint">—</span>;
|
||||
const styles: Record<string, string> = {
|
||||
success: 'bg-emerald-100 text-emerald-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
skipped: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
success: 'Verified',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JobDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const { data: job, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['job', id],
|
||||
queryFn: () => getJob(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const { data: verification } = useQuery({
|
||||
queryKey: ['job-verification', id],
|
||||
queryFn: () => getJobVerification(id!),
|
||||
enabled: !!id && job?.type === 'Deployment' && job?.status === 'Completed',
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: auditData } = useQuery({
|
||||
queryKey: ['audit', { resource_id: id }],
|
||||
queryFn: () => getAuditEvents({ resource_id: id!, per_page: '10' }),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Job Details" />
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !job) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Job Details" />
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-sm text-ink-muted">Loading job...</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={`Job ${job.id}`}
|
||||
subtitle={`${job.type} job`}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Job details */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Job Information</h3>
|
||||
<InfoRow label="ID" value={<span className="font-mono text-xs">{job.id}</span>} />
|
||||
<InfoRow label="Type" value={job.type} />
|
||||
<InfoRow label="Status" value={<StatusBadge status={job.status} />} />
|
||||
<InfoRow label="Certificate" value={
|
||||
<Link to={`/certificates/${job.certificate_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||
{job.certificate_id}
|
||||
</Link>
|
||||
} />
|
||||
{job.agent_id && (
|
||||
<InfoRow label="Agent" value={
|
||||
<Link to={`/agents/${job.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||
{job.agent_id}
|
||||
</Link>
|
||||
} />
|
||||
)}
|
||||
{job.target_id && (
|
||||
<InfoRow label="Target" value={
|
||||
<Link to={`/targets/${job.target_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||
{job.target_id}
|
||||
</Link>
|
||||
} />
|
||||
)}
|
||||
<InfoRow label="Attempts" value={`${job.attempts} / ${job.max_attempts}`} />
|
||||
{job.error_message && (
|
||||
<InfoRow label="Error" value={
|
||||
<span className="text-red-600 text-xs">{job.error_message}</span>
|
||||
} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Timeline</h3>
|
||||
<InfoRow label="Created" value={formatDateTime(job.created_at)} />
|
||||
<InfoRow label="Scheduled" value={formatDateTime(job.scheduled_at)} />
|
||||
{job.started_at && <InfoRow label="Started" value={formatDateTime(job.started_at)} />}
|
||||
{job.completed_at && <InfoRow label="Completed" value={formatDateTime(job.completed_at)} />}
|
||||
{job.completed_at && job.started_at && (
|
||||
<InfoRow label="Duration" value={timeAgo(job.started_at)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification section — only for deployment jobs */}
|
||||
{job.type === 'Deployment' && (
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Post-Deployment Verification</h3>
|
||||
{job.verification_status ? (
|
||||
<div className="space-y-0">
|
||||
<InfoRow label="Status" value={<VerificationBadge status={job.verification_status} />} />
|
||||
{job.verified_at && <InfoRow label="Verified At" value={formatDateTime(job.verified_at)} />}
|
||||
{job.verification_fingerprint && (
|
||||
<InfoRow label="Fingerprint" value={<span className="font-mono text-xs">{job.verification_fingerprint}</span>} />
|
||||
)}
|
||||
{job.verification_error && (
|
||||
<InfoRow label="Error" value={<span className="text-red-600 text-xs">{job.verification_error}</span>} />
|
||||
)}
|
||||
{verification && verification.verified && (
|
||||
<InfoRow label="Expected Fingerprint" value={<span className="font-mono text-xs">{verification.expected_fingerprint}</span>} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-ink-faint py-4 text-center">
|
||||
{job.status === 'Completed' ? 'No verification data recorded' : 'Verification runs after deployment completes'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audit trail */}
|
||||
{auditData && auditData.data.length > 0 && (
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Related Audit Events</h3>
|
||||
<div className="space-y-2">
|
||||
{auditData.data.map(event => (
|
||||
<div key={event.id} className="flex items-center justify-between py-2 border-b border-surface-border/50 last:border-0">
|
||||
<div>
|
||||
<span className="text-sm text-ink">{event.action}</span>
|
||||
<span className="text-xs text-ink-faint ml-2">by {event.actor}</span>
|
||||
</div>
|
||||
<span className="text-xs text-ink-muted">{formatDateTime(event.timestamp)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getJobs, cancelJob, approveRenewal, rejectRenewal } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
@@ -47,6 +48,27 @@ function RejectModal({ job, onClose, onReject }: { job: Job; onClose: () => void
|
||||
);
|
||||
}
|
||||
|
||||
function VerificationBadge({ status }: { status?: string }) {
|
||||
if (!status) return <span className="text-xs text-ink-faint">—</span>;
|
||||
const styles: Record<string, string> = {
|
||||
success: 'bg-emerald-100 text-emerald-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
skipped: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
success: 'Verified',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JobsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
@@ -89,13 +111,26 @@ 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',
|
||||
@@ -103,6 +138,11 @@ export default function JobsPage() {
|
||||
},
|
||||
{ key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.scheduled_at)}</span> },
|
||||
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
|
||||
{
|
||||
key: 'verification',
|
||||
label: 'Verification',
|
||||
render: (j) => j.type === 'Deployment' ? <VerificationBadge status={j.verification_status} /> : <span className="text-xs text-ink-faint">—</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getMetrics, getPrometheusMetrics, getHealth } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
|
||||
function MetricCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
|
||||
return (
|
||||
<div className="bg-surface border border-surface-border rounded p-4 shadow-sm">
|
||||
<div className="text-xs text-ink-muted mb-1">{label}</div>
|
||||
<div className="text-2xl font-bold text-ink">{value}</div>
|
||||
{sub && <div className="text-xs text-ink-faint mt-1">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
export default function ObservabilityPage() {
|
||||
const { data: metrics, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['metrics'],
|
||||
queryFn: getMetrics,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: getHealth,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const { data: promText } = useQuery({
|
||||
queryKey: ['prometheus-metrics'],
|
||||
queryFn: getPrometheusMetrics,
|
||||
refetchInterval: 30000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Observability" />
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Observability"
|
||||
subtitle={health ? `Server: ${health.status}` : undefined}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
{/* Health status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${health?.status === 'ok' ? 'bg-emerald-500' : 'bg-red-500'}`} />
|
||||
<span className="text-sm text-ink font-medium">
|
||||
Server {health?.status === 'ok' ? 'Healthy' : 'Unhealthy'}
|
||||
</span>
|
||||
{metrics && (
|
||||
<span className="text-xs text-ink-faint ml-auto">
|
||||
Uptime: {formatUptime(metrics.uptime.uptime_seconds)} | Started: {new Date(metrics.uptime.server_started).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gauge metrics */}
|
||||
{isLoading && (
|
||||
<div className="text-sm text-ink-muted py-10 text-center">Loading metrics...</div>
|
||||
)}
|
||||
|
||||
{metrics && (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Certificate Gauges</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<MetricCard label="Total" value={metrics.gauge.certificate_total} />
|
||||
<MetricCard label="Active" value={metrics.gauge.certificate_active} />
|
||||
<MetricCard label="Expiring Soon" value={metrics.gauge.certificate_expiring_soon} />
|
||||
<MetricCard label="Expired" value={metrics.gauge.certificate_expired} />
|
||||
<MetricCard label="Revoked" value={metrics.gauge.certificate_revoked} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Agent & Job Gauges</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<MetricCard label="Total Agents" value={metrics.gauge.agent_total} />
|
||||
<MetricCard label="Online Agents" value={metrics.gauge.agent_online} />
|
||||
<MetricCard label="Pending Jobs" value={metrics.gauge.job_pending} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Counters</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-2 gap-3">
|
||||
<MetricCard label="Jobs Completed (total)" value={metrics.counter.job_completed_total} />
|
||||
<MetricCard label="Jobs Failed (total)" value={metrics.counter.job_failed_total} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Prometheus config */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Prometheus Integration</h3>
|
||||
<div className="bg-surface border border-surface-border rounded p-4 shadow-sm">
|
||||
<p className="text-sm text-ink mb-3">
|
||||
Add this scrape target to your <code className="text-xs bg-surface-muted px-1 py-0.5 rounded">prometheus.yml</code>:
|
||||
</p>
|
||||
<pre className="bg-ink text-white rounded p-4 text-xs overflow-x-auto font-mono">
|
||||
{`scrape_configs:
|
||||
- job_name: 'certctl'
|
||||
metrics_path: '/api/v1/metrics/prometheus'
|
||||
scheme: 'https'
|
||||
bearer_token: '<YOUR_API_KEY>'
|
||||
static_configs:
|
||||
- targets: ['<CERTCTL_HOST>:443']`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Prometheus output */}
|
||||
{promText && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Live Prometheus Output</h3>
|
||||
<div className="bg-surface border border-surface-border rounded shadow-sm">
|
||||
<div className="px-4 py-2 border-b border-surface-border flex items-center justify-between">
|
||||
<span className="text-xs text-ink-faint font-mono">GET /api/v1/metrics/prometheus</span>
|
||||
<span className="text-xs text-ink-faint">text/plain</span>
|
||||
</div>
|
||||
<pre className="p-4 text-xs text-ink-muted overflow-x-auto font-mono max-h-96 overflow-y-auto whitespace-pre">
|
||||
{promText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTarget, getJobs } 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 { 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}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user