mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 18:58:56 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a53b648b1 | |||
| cb72292b83 | |||
| 3a11e447cf | |||
| bad02e6f23 | |||
| 4c3b7cbb16 | |||
| e8c64b47dd | |||
| 9feb6c796d |
@@ -36,7 +36,7 @@ gantt
|
|||||||
47 days :crit, 2020-01-01, 47d
|
47 days :crit, 2020-01-01, 47d
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Actively maintained — shipping weekly.** Found something? [Open a GitHub issue](https://github.com/shankar0123/certctl/issues) — issues get triaged same-day. CI runs 1,536+ tests with race detection, static analysis, and vulnerability scanning on every commit.
|
> **Actively maintained — shipping weekly.** Found something? [Open a GitHub issue](https://github.com/shankar0123/certctl/issues) — issues get triaged same-day. CI runs 1,554+ tests with race detection, static analysis, and vulnerability scanning on every commit.
|
||||||
|
|
||||||
## Why certctl Exists
|
## Why certctl Exists
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ Certificate lifecycle tooling today falls into two camps: expensive enterprise p
|
|||||||
|
|
||||||
certctl fills that gap. It's **CA-agnostic** — plug in any certificate authority: Let's Encrypt via ACME, Smallstep step-ca, HashiCorp Vault PKI, DigiCert CertCentral, your enterprise ADCS via sub-CA mode, or any custom CA through a shell script adapter. Run multiple issuers simultaneously for different certificate types.
|
certctl fills that gap. It's **CA-agnostic** — plug in any certificate authority: Let's Encrypt via ACME, Smallstep step-ca, HashiCorp Vault PKI, DigiCert CertCentral, your enterprise ADCS via sub-CA mode, or any custom CA through a shell script adapter. Run multiple issuers simultaneously for different certificate types.
|
||||||
|
|
||||||
It's **target-agnostic**. Agents deploy certificates to NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, and IIS (local PowerShell or remote WinRM) — all using the same pluggable connector model. The control plane never initiates outbound connections — agents poll for work, which means certctl works behind firewalls, across network zones, and in air-gapped environments.
|
It's **target-agnostic**. Agents deploy certificates to NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, and IIS (local PowerShell or remote WinRM) — all using the same pluggable connector model. The control plane never initiates outbound connections — agents poll for work, which means certctl works behind firewalls, across network zones, and in air-gapped environments.
|
||||||
|
|
||||||
For a detailed comparison with CertKit, KeyTalk, and enterprise platforms, see [Why certctl?](docs/why-certctl.md)
|
For a detailed comparison with CertKit, KeyTalk, and enterprise platforms, see [Why certctl?](docs/why-certctl.md)
|
||||||
|
|
||||||
@@ -84,8 +84,10 @@ For the full capability breakdown — revocation infrastructure (CRL + OCSP), po
|
|||||||
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
|
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
|
||||||
| Vault PKI | Beta | `VaultPKI` |
|
| Vault PKI | Beta | `VaultPKI` |
|
||||||
| DigiCert CertCentral | Beta | `DigiCert` |
|
| DigiCert CertCentral | Beta | `DigiCert` |
|
||||||
|
| Sectigo SCM | Beta | `Sectigo` |
|
||||||
|
| Google CAS | Beta | `GoogleCAS` |
|
||||||
|
|
||||||
**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.
|
**Vault PKI, DigiCert, Sectigo, and Google CAS connectors are in beta.** If you hit any bugs or unexpected behavior, please [open a GitHub issue](https://github.com/shankar0123/certctl/issues) -- we're actively testing these and want to hear from real users.
|
||||||
|
|
||||||
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated today via the OpenSSL/Custom CA connector.
|
**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.
|
||||||
|
|
||||||
@@ -98,6 +100,8 @@ For the full capability breakdown — revocation infrastructure (CRL + OCSP), po
|
|||||||
| Traefik | Implemented | `Traefik` |
|
| Traefik | Implemented | `Traefik` |
|
||||||
| Caddy | Implemented | `Caddy` |
|
| Caddy | Implemented | `Caddy` |
|
||||||
| Envoy | Implemented | `Envoy` |
|
| Envoy | Implemented | `Envoy` |
|
||||||
|
| Postfix | Implemented | `Postfix` |
|
||||||
|
| Dovecot | Implemented | `Dovecot` |
|
||||||
| Microsoft IIS | Implemented (local + WinRM) | `IIS` |
|
| Microsoft IIS | Implemented (local + WinRM) | `IIS` |
|
||||||
| F5 BIG-IP | Interface only | `F5` |
|
| F5 BIG-IP | Interface only | `F5` |
|
||||||
|
|
||||||
@@ -209,18 +213,15 @@ Each directory contains a `docker-compose.yml` and a `README.md` explaining the
|
|||||||
|
|
||||||
| Guide | Description |
|
| Guide | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| [Why certctl?](docs/why-certctl.md) | How certctl compares to open-source and enterprise certificate management platforms |
|
| [Why certctl?](docs/why-certctl.md) | How certctl compares to ACME clients, agent-based SaaS, and enterprise platforms |
|
||||||
| [Concepts](docs/concepts.md) | TLS certificates explained from scratch — for beginners who know nothing about certs |
|
| [Concepts](docs/concepts.md) | TLS certificates explained from scratch — for beginners who know nothing about certs |
|
||||||
| [Quick Start](docs/quickstart.md) | Extended quickstart — dashboard, API, CLI, discovery, stakeholder demo flow |
|
| [Quick Start](docs/quickstart.md) | 5-minute setup — dashboard, API, CLI, discovery, stakeholder demo flow |
|
||||||
|
| [Deployment Examples](docs/examples.md) | 5 turnkey scenarios (ACME+NGINX, wildcard DNS-01, private CA, step-ca, multi-issuer) with migration guides |
|
||||||
| [Advanced Demo](docs/demo-advanced.md) | Issue a certificate end-to-end with technical deep-dives |
|
| [Advanced Demo](docs/demo-advanced.md) | Issue a certificate end-to-end with technical deep-dives |
|
||||||
| [Architecture](docs/architecture.md) | System design, data flow diagrams, security model |
|
| [Architecture](docs/architecture.md) | System design, data flow diagrams, security model |
|
||||||
| [Feature Inventory](docs/features.md) | Complete reference of all V2 capabilities, API endpoints, and configuration |
|
| [Feature Inventory](docs/features.md) | Complete reference of all V2 capabilities, API endpoints, and configuration |
|
||||||
| [Configuration Reference](docs/features.md) | All 39 environment variables across server, agent, and connector config |
|
| [Connector Reference](docs/connectors.md) | Configuration for all 7 issuers, 10 targets, and 5 notifier connectors |
|
||||||
| [Connectors](docs/connectors.md) | Build custom issuer, target, and notifier connectors |
|
|
||||||
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
||||||
| [Migrate from Certbot](docs/migrate-from-certbot.md) | Step-by-step migration from Certbot/Let's Encrypt cron jobs |
|
|
||||||
| [Migrate from acme.sh](docs/migrate-from-acmesh.md) | Migration guide for acme.sh users with DNS-01 scripts |
|
|
||||||
| [certctl for cert-manager Users](docs/certctl-for-cert-manager-users.md) | Using certctl alongside cert-manager for non-Kubernetes infrastructure |
|
|
||||||
| [OpenAPI 3.1 Spec](api/openapi.yaml) | 97 operations, full request/response schemas |
|
| [OpenAPI 3.1 Spec](api/openapi.yaml) | 97 operations, full request/response schemas |
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
@@ -293,7 +294,7 @@ CI runs on every push: `go vet`, `go test -race`, `golangci-lint`, `govulncheck`
|
|||||||
Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR.
|
Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR.
|
||||||
|
|
||||||
### V2: Operational Maturity — Shipped
|
### V2: Operational Maturity — Shipped
|
||||||
30+ milestones, 1,536+ tests. Sub-CA mode, ACME DNS-01/DNS-PERSIST-01, step-ca, Vault PKI, DigiCert CertCentral, OpenSSL/Custom CA issuers. NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS targets. RFC 5280 revocation with CRL + OCSP. Certificate profiles, ownership tracking, approval workflows. Filesystem and network certificate discovery. Prometheus metrics, dashboard charts, agent fleet overview. EST server (RFC 7030), ACME ARI (RFC 9702), certificate export, S/MIME support, Helm chart, MCP server, CLI, scheduled digest emails. Slack, Teams, PagerDuty, OpsGenie, SMTP notifications. Compliance mapping (SOC 2, PCI-DSS 4.0, NIST SP 800-57). See the [Feature Inventory](docs/features.md) for details.
|
30+ milestones, 1,554+ tests. Sub-CA mode, ACME DNS-01/DNS-PERSIST-01, step-ca, Vault PKI, DigiCert CertCentral, OpenSSL/Custom CA issuers. NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS targets. RFC 5280 revocation with CRL + OCSP. Certificate profiles, ownership tracking, approval workflows. Filesystem and network certificate discovery. Prometheus metrics, dashboard charts, agent fleet overview. EST server (RFC 7030), ACME ARI (RFC 9702), certificate export, S/MIME support, Helm chart, MCP server, CLI, scheduled digest emails. Slack, Teams, PagerDuty, OpsGenie, SMTP notifications. Compliance mapping (SOC 2, PCI-DSS 4.0, NIST SP 800-57). See the [Feature Inventory](docs/features.md) for details.
|
||||||
|
|
||||||
**Coming in v2.1.0:** Dynamic issuer and target configuration via GUI (no env var restarts), first-run onboarding wizard.
|
**Coming in v2.1.0:** Dynamic issuer and target configuration via GUI (no env var restarts), first-run onboarding wizard.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -2643,7 +2643,7 @@ components:
|
|||||||
# ─── Issuers ─────────────────────────────────────────────────────
|
# ─── Issuers ─────────────────────────────────────────────────────
|
||||||
IssuerType:
|
IssuerType:
|
||||||
type: string
|
type: string
|
||||||
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert]
|
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert, Sectigo, GoogleCAS]
|
||||||
|
|
||||||
Issuer:
|
Issuer:
|
||||||
type: object
|
type: object
|
||||||
@@ -2669,7 +2669,7 @@ components:
|
|||||||
# ─── Targets ─────────────────────────────────────────────────────
|
# ─── Targets ─────────────────────────────────────────────────────
|
||||||
TargetType:
|
TargetType:
|
||||||
type: string
|
type: string
|
||||||
enum: [NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS, F5]
|
enum: [NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS, F5]
|
||||||
|
|
||||||
DeploymentTarget:
|
DeploymentTarget:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
"github.com/shankar0123/certctl/internal/connector/target/apache"
|
"github.com/shankar0123/certctl/internal/connector/target/apache"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/caddy"
|
"github.com/shankar0123/certctl/internal/connector/target/caddy"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/envoy"
|
"github.com/shankar0123/certctl/internal/connector/target/envoy"
|
||||||
|
pf "github.com/shankar0123/certctl/internal/connector/target/postfix"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/haproxy"
|
"github.com/shankar0123/certctl/internal/connector/target/haproxy"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/iis"
|
"github.com/shankar0123/certctl/internal/connector/target/iis"
|
||||||
@@ -622,6 +623,26 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess
|
|||||||
}
|
}
|
||||||
return envoy.New(&cfg, a.logger), nil
|
return envoy.New(&cfg, a.logger), nil
|
||||||
|
|
||||||
|
case "Postfix":
|
||||||
|
var cfg pf.Config
|
||||||
|
cfg.Mode = "postfix"
|
||||||
|
if len(configJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Postfix config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pf.New(&cfg, a.logger), nil
|
||||||
|
|
||||||
|
case "Dovecot":
|
||||||
|
var cfg pf.Config
|
||||||
|
cfg.Mode = "dovecot"
|
||||||
|
if len(configJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Dovecot config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pf.New(&cfg, a.logger), nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported target type: %s", targetType)
|
return nil, fmt.Errorf("unsupported target type: %s", targetType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import (
|
|||||||
digicertissuer "github.com/shankar0123/certctl/internal/connector/issuer/digicert"
|
digicertissuer "github.com/shankar0123/certctl/internal/connector/issuer/digicert"
|
||||||
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
|
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
|
||||||
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
||||||
|
googlecasissuer "github.com/shankar0123/certctl/internal/connector/issuer/googlecas"
|
||||||
|
sectigoissuer "github.com/shankar0123/certctl/internal/connector/issuer/sectigo"
|
||||||
vaultissuer "github.com/shankar0123/certctl/internal/connector/issuer/vault"
|
vaultissuer "github.com/shankar0123/certctl/internal/connector/issuer/vault"
|
||||||
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
|
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
|
||||||
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
|
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
|
||||||
@@ -158,6 +160,30 @@ func main() {
|
|||||||
}, logger)
|
}, logger)
|
||||||
logger.Info("initialized DigiCert CertCentral issuer connector")
|
logger.Info("initialized DigiCert CertCentral issuer connector")
|
||||||
|
|
||||||
|
// Initialize Sectigo SCM issuer connector (for enterprise public CA).
|
||||||
|
// Uses the Sectigo SCM REST API with async order model.
|
||||||
|
sectigoConnector := sectigoissuer.New(§igoissuer.Config{
|
||||||
|
CustomerURI: cfg.Sectigo.CustomerURI,
|
||||||
|
Login: cfg.Sectigo.Login,
|
||||||
|
Password: cfg.Sectigo.Password,
|
||||||
|
OrgID: cfg.Sectigo.OrgID,
|
||||||
|
CertType: cfg.Sectigo.CertType,
|
||||||
|
Term: cfg.Sectigo.Term,
|
||||||
|
BaseURL: cfg.Sectigo.BaseURL,
|
||||||
|
}, logger)
|
||||||
|
logger.Info("initialized Sectigo SCM issuer connector")
|
||||||
|
|
||||||
|
// Initialize Google CAS issuer connector (for GCP private CA).
|
||||||
|
// Uses the Google CAS REST API with OAuth2 service account auth.
|
||||||
|
googlecasConnector := googlecasissuer.New(&googlecasissuer.Config{
|
||||||
|
Project: cfg.GoogleCAS.Project,
|
||||||
|
Location: cfg.GoogleCAS.Location,
|
||||||
|
CAPool: cfg.GoogleCAS.CAPool,
|
||||||
|
Credentials: cfg.GoogleCAS.Credentials,
|
||||||
|
TTL: cfg.GoogleCAS.TTL,
|
||||||
|
}, logger)
|
||||||
|
logger.Info("initialized Google CAS issuer connector")
|
||||||
|
|
||||||
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
|
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
|
||||||
// "iss-local" matches the seed data issuer ID for the Local CA.
|
// "iss-local" matches the seed data issuer ID for the Local CA.
|
||||||
// "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers.
|
// "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers.
|
||||||
@@ -183,6 +209,18 @@ func main() {
|
|||||||
logger.Info("DigiCert CertCentral issuer registered", "id", "iss-digicert")
|
logger.Info("DigiCert CertCentral issuer registered", "id", "iss-digicert")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conditionally register Sectigo SCM (only if all 3 auth credentials are set)
|
||||||
|
if cfg.Sectigo.CustomerURI != "" && cfg.Sectigo.Login != "" && cfg.Sectigo.Password != "" {
|
||||||
|
issuerRegistry["iss-sectigo"] = service.NewIssuerConnectorAdapter(sectigoConnector)
|
||||||
|
logger.Info("Sectigo SCM issuer registered", "id", "iss-sectigo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditionally register Google CAS (only if project and credentials are set)
|
||||||
|
if cfg.GoogleCAS.Project != "" && cfg.GoogleCAS.Credentials != "" {
|
||||||
|
issuerRegistry["iss-googlecas"] = service.NewIssuerConnectorAdapter(googlecasConnector)
|
||||||
|
logger.Info("Google CAS issuer registered", "id", "iss-googlecas")
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
|
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
|
||||||
|
|
||||||
// Initialize revocation repository
|
// Initialize revocation repository
|
||||||
|
|||||||
@@ -90,8 +90,10 @@ flowchart TB
|
|||||||
T5["HAProxy\n(combined PEM + reload)"]
|
T5["HAProxy\n(combined PEM + reload)"]
|
||||||
T6["Traefik\n(file provider)"]
|
T6["Traefik\n(file provider)"]
|
||||||
T7["Caddy\n(admin API / file)"]
|
T7["Caddy\n(admin API / file)"]
|
||||||
|
T8["Envoy\n(file-based SDS)"]
|
||||||
|
T9["Postfix/Dovecot\n(file + service reload)"]
|
||||||
T2["F5 BIG-IP\n(proxy agent + iControl REST, planned)"]
|
T2["F5 BIG-IP\n(proxy agent + iControl REST, planned)"]
|
||||||
T3["IIS\n(agent-local PowerShell, planned)"]
|
T3["IIS\n(WinRM + local)"]
|
||||||
end
|
end
|
||||||
|
|
||||||
DASH --> API
|
DASH --> API
|
||||||
@@ -119,7 +121,7 @@ The server exposes a REST API under `/api/v1/` and optionally serves the web das
|
|||||||
|
|
||||||
### Agents
|
### Agents
|
||||||
|
|
||||||
Lightweight Go processes that run on or near your infrastructure. Agents generate ECDSA P-256 private keys locally, create CSRs, and submit them to the control plane for signing — private keys never leave agent infrastructure. Agents also handle certificate deployment to target systems (NGINX, Apache httpd, HAProxy fully implemented; F5 BIG-IP, IIS interface only with V2 implementations planned) and report job status. They communicate with the control plane via HTTP and authenticate with API keys.
|
Lightweight Go processes that run on or near your infrastructure. Agents generate ECDSA P-256 private keys locally, create CSRs, and submit them to the control plane for signing — private keys never leave agent infrastructure. Agents also handle certificate deployment to target systems (NGINX, Apache httpd, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS fully implemented; F5 BIG-IP interface stub only) and report job status. They communicate with the control plane via HTTP and authenticate with API keys.
|
||||||
|
|
||||||
The agent runs two background loops: a heartbeat (every 60 seconds) to signal it's alive, and a work poll (every 30 seconds) to check for actionable jobs via `GET /api/v1/agents/{id}/work`. Jobs may be `AwaitingCSR` (agent needs to generate key + submit CSR) or `Deployment` (agent needs to deploy a certificate). Private keys are stored in `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`) with 0600 permissions.
|
The agent runs two background loops: a heartbeat (every 60 seconds) to signal it's alive, and a work poll (every 30 seconds) to check for actionable jobs via `GET /api/v1/agents/{id}/work`. Jobs may be `AwaitingCSR` (agent needs to generate key + submit CSR) or `Deployment` (agent needs to deploy a certificate). Private keys are stored in `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`) with 0600 permissions.
|
||||||
|
|
||||||
@@ -511,6 +513,8 @@ flowchart TB
|
|||||||
II --> OC["OpenSSL / Custom CA"]
|
II --> OC["OpenSSL / Custom CA"]
|
||||||
II --> VP["Vault PKI"]
|
II --> VP["Vault PKI"]
|
||||||
II --> DC["DigiCert CertCentral"]
|
II --> DC["DigiCert CertCentral"]
|
||||||
|
II --> SG["Sectigo SCM"]
|
||||||
|
II --> GC["Google CAS"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Target Connectors"
|
subgraph "Target Connectors"
|
||||||
@@ -521,8 +525,10 @@ flowchart TB
|
|||||||
TI --> HP["HAProxy"]
|
TI --> HP["HAProxy"]
|
||||||
TI --> TF["Traefik"]
|
TI --> TF["Traefik"]
|
||||||
TI --> CD["Caddy"]
|
TI --> CD["Caddy"]
|
||||||
|
TI --> EV["Envoy"]
|
||||||
|
TI --> PO["Postfix/Dovecot"]
|
||||||
|
TI --> IIS["IIS"]
|
||||||
TI --> F5["F5 BIG-IP (interface only)"]
|
TI --> F5["F5 BIG-IP (interface only)"]
|
||||||
TI --> IIS["IIS (interface only)"]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Notifier Connectors"
|
subgraph "Notifier Connectors"
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ Agents scan configured directories and report back all existing certs. In the da
|
|||||||
Set up the same issuer certctl uses for non-Kubernetes certs:
|
Set up the same issuer certctl uses for non-Kubernetes certs:
|
||||||
- **ACME** (Let's Encrypt, for public certs)
|
- **ACME** (Let's Encrypt, for public certs)
|
||||||
- **step-ca** (Smallstep, for internal certs)
|
- **step-ca** (Smallstep, for internal certs)
|
||||||
- **Vault PKI** (planned) (HashiCorp Vault, for enterprise PKI)
|
- **Vault PKI** (HashiCorp Vault, for enterprise PKI)
|
||||||
- **Private CA** (your own internal root CA)
|
- **Private CA** (your own internal root CA)
|
||||||
|
|
||||||
No new CA infrastructure needed. If cert-manager already uses your CA, certctl points to the same one.
|
No new CA infrastructure needed. If cert-manager already uses your CA, certctl points to the same one.
|
||||||
@@ -115,7 +115,7 @@ Certificates are linked to issuers and profiles when created or claimed from dis
|
|||||||
If cert-manager and certctl both use the same CA:
|
If cert-manager and certctl both use the same CA:
|
||||||
- **ACME**: cert-manager uses ClusterIssuer + certctl uses ACME connector → same Let's Encrypt account, transparent coexistence
|
- **ACME**: cert-manager uses ClusterIssuer + certctl uses ACME connector → same Let's Encrypt account, transparent coexistence
|
||||||
- **step-ca**: cert-manager uses external issuer CRD + certctl uses step-ca connector → same provisioner, shared certificate inventory
|
- **step-ca**: cert-manager uses external issuer CRD + certctl uses step-ca connector → same provisioner, shared certificate inventory
|
||||||
- **Vault PKI** (planned): cert-manager uses external issuer CRD + certctl uses Vault connector → same mount, same audit trail
|
- **Vault PKI**: cert-manager uses external issuer CRD + certctl uses Vault connector → same mount, same audit trail
|
||||||
|
|
||||||
No conflict. They just issue certs through the same CA. certctl's discovery scanning finds cert-manager-issued certs and shows them alongside certctl-managed ones.
|
No conflict. They just issue certs through the same CA. certctl's discovery scanning finds cert-manager-issued certs and shows them alongside certctl-managed ones.
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ For now: cert-manager handles Kubernetes, certctl handles everything else. They
|
|||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
1. Review [Quick Start](./quickstart.md) for a 5-minute demo
|
1. Run through the [Quick Start](./quickstart.md) for a 5-minute demo
|
||||||
2. Explore [Architecture](./architecture.md#agents) for deployment architecture
|
2. Try the [Multi-Issuer example](../examples/multi-issuer/multi-issuer.md) — manages public and internal certs from one dashboard
|
||||||
3. Read about [Discovery Scanning](./quickstart.md#certificate-discovery) to auto-find certs
|
3. Explore [Architecture](./architecture.md#agents) for deployment patterns
|
||||||
4. Check [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment
|
4. Check the [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment
|
||||||
|
|||||||
+2
-2
@@ -125,9 +125,9 @@ Agents also report **metadata** about themselves — their operating system, CPU
|
|||||||
|
|
||||||
### Deployment Targets
|
### Deployment Targets
|
||||||
|
|
||||||
Targets are the systems where certificates actually get installed — NGINX web servers, Apache httpd servers, HAProxy load balancers, F5 BIG-IP appliances, Microsoft IIS servers. Each target type has a **connector** that knows how to deploy certificates to that specific system (e.g., writing files and reloading NGINX or Apache config, building a combined PEM for HAProxy).
|
Targets are the systems where certificates actually get installed — NGINX web servers, Apache httpd servers, HAProxy load balancers, Traefik reverse proxies, Caddy servers, Envoy gateways, Postfix/Dovecot mail servers, Microsoft IIS servers, and network appliances. Each target type has a **connector** that knows how to deploy certificates to that specific system (e.g., writing files and reloading NGINX or Apache config, building a combined PEM for HAProxy).
|
||||||
|
|
||||||
For targets where an agent runs directly on the machine (NGINX, Apache, HAProxy, IIS), the agent deploys certificates locally — no remote access needed. For network appliances where you can't install an agent (F5 BIG-IP, Palo Alto, etc.), a **proxy agent** in the same network zone picks up the deployment job and calls the appliance's API. The server never initiates outbound connections to any target.
|
For targets where an agent runs directly on the machine (NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS), the agent deploys certificates locally — no remote access needed. For network appliances where you can't install an agent (F5 BIG-IP, Palo Alto, etc.), a **proxy agent** in the same network zone picks up the deployment job and calls the appliance's API. The server never initiates outbound connections to any target.
|
||||||
|
|
||||||
## The Certificate Lifecycle
|
## The Certificate Lifecycle
|
||||||
|
|
||||||
|
|||||||
+88
-4
@@ -22,6 +22,7 @@ Connectors extend certctl to integrate with external systems for certificate iss
|
|||||||
- [Built-in: HAProxy](#built-in-haproxy)
|
- [Built-in: HAProxy](#built-in-haproxy)
|
||||||
- [Built-in: Traefik](#built-in-traefik)
|
- [Built-in: Traefik](#built-in-traefik)
|
||||||
- [Built-in: Envoy](#built-in-envoy)
|
- [Built-in: Envoy](#built-in-envoy)
|
||||||
|
- [Built-in: Postfix / Dovecot](#built-in-postfix--dovecot)
|
||||||
- [Built-in: Caddy](#built-in-caddy)
|
- [Built-in: Caddy](#built-in-caddy)
|
||||||
- [F5 BIG-IP (Interface Only)](#f5-big-ip-interface-only)
|
- [F5 BIG-IP (Interface Only)](#f5-big-ip-interface-only)
|
||||||
- [IIS (Implemented, Dual-Mode)](#iis-implemented-dual-mode)
|
- [IIS (Implemented, Dual-Mode)](#iis-implemented-dual-mode)
|
||||||
@@ -52,8 +53,8 @@ Connectors extend certctl to integrate with external systems for certificate iss
|
|||||||
|
|
||||||
Three types of connectors:
|
Three types of connectors:
|
||||||
|
|
||||||
1. **Issuer Connector** — Obtains certificates from CAs (Local CA with sub-CA support, ACME with HTTP-01 + DNS-01 + DNS-PERSIST-01, step-ca, OpenSSL/Custom CA implemented; additional CA integrations planned)
|
1. **Issuer Connector** — Obtains certificates from CAs (Local CA with sub-CA support, ACME with HTTP-01 + DNS-01 + DNS-PERSIST-01, step-ca, OpenSSL/Custom CA, Vault PKI, DigiCert implemented; additional CA integrations planned)
|
||||||
2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy, Traefik, Caddy, Envoy, IIS implemented; F5 via proxy agent planned; additional cloud and network targets planned)
|
2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS implemented; F5 via proxy agent planned; additional cloud and network targets planned)
|
||||||
3. **Notifier Connector** — Sends alerts about certificate events (Email, Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie implemented)
|
3. **Notifier Connector** — Sends alerts about certificate events (Email, Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie implemented)
|
||||||
|
|
||||||
All connectors accept JSON configuration at initialization, support config validation, and are registered in the service layer. Issuer connectors run on the control plane; target connectors run on agents. For network appliances where agents can't be installed, a **proxy agent** in the same network zone handles deployment — the server never initiates outbound connections.
|
All connectors accept JSON configuration at initialization, support config validation, and are registered in the service layer. Issuer connectors run on the control plane; target connectors run on agents. For network appliances where agents can't be installed, a **proxy agent** in the same network zone handles deployment — the server never initiates outbound connections.
|
||||||
@@ -354,13 +355,53 @@ The connector submits certificate orders to DigiCert's `/order/certificate/creat
|
|||||||
|
|
||||||
Location: `internal/connector/issuer/digicert/digicert.go`
|
Location: `internal/connector/issuer/digicert/digicert.go`
|
||||||
|
|
||||||
|
### Built-in: Sectigo SCM
|
||||||
|
|
||||||
|
The Sectigo connector integrates with Sectigo Certificate Manager's REST API for ordering and managing DV, OV, and EV certificates. Like DigiCert, it uses an async order model: submit an enrollment, receive an sslId, then poll for completion.
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_SECTIGO_CUSTOMER_URI` | — | Sectigo customer URI (organization identifier) |
|
||||||
|
| `CERTCTL_SECTIGO_LOGIN` | — | API account login |
|
||||||
|
| `CERTCTL_SECTIGO_PASSWORD` | — | API account password |
|
||||||
|
| `CERTCTL_SECTIGO_ORG_ID` | — | Organization ID (integer) |
|
||||||
|
| `CERTCTL_SECTIGO_CERT_TYPE` | — | Certificate type ID (integer, from `/ssl/v1/types`) |
|
||||||
|
| `CERTCTL_SECTIGO_TERM` | `365` | Certificate validity in days |
|
||||||
|
| `CERTCTL_SECTIGO_BASE_URL` | `https://cert-manager.com/api` | Sectigo API base URL |
|
||||||
|
|
||||||
|
The connector submits certificate enrollments to Sectigo's `/ssl/v1/enroll` API. DV certificates may issue immediately; OV/EV certificates require validation (handled by Sectigo) and poll-based completion. The connector periodically checks enrollment status via `/ssl/v1/{sslId}` and downloads the PEM bundle via `/ssl/v1/collect/{sslId}/pem` when issued.
|
||||||
|
|
||||||
|
**Authentication:** Three custom headers on every request — `customerUri`, `login`, and `password`.
|
||||||
|
|
||||||
|
**Note:** CRL and OCSP are managed by Sectigo. certctl records revocations locally and notifies Sectigo via `/ssl/v1/revoke/{sslId}`.
|
||||||
|
|
||||||
|
Location: `internal/connector/issuer/sectigo/sectigo.go`
|
||||||
|
|
||||||
|
### Built-in: Google CAS
|
||||||
|
|
||||||
|
Google Cloud Certificate Authority Service — managed private CA on GCP. Synchronous issuance via CAS REST API with OAuth2 service account auth.
|
||||||
|
|
||||||
|
| Setting | Required | Default | Description |
|
||||||
|
|---------|----------|---------|-------------|
|
||||||
|
| `CERTCTL_GOOGLE_CAS_PROJECT` | Yes | — | GCP project ID |
|
||||||
|
| `CERTCTL_GOOGLE_CAS_LOCATION` | Yes | — | GCP region (e.g., `us-central1`) |
|
||||||
|
| `CERTCTL_GOOGLE_CAS_CA_POOL` | Yes | — | CA pool name |
|
||||||
|
| `CERTCTL_GOOGLE_CAS_CREDENTIALS` | Yes | — | Path to service account JSON |
|
||||||
|
| `CERTCTL_GOOGLE_CAS_TTL` | No | `8760h` | Default certificate TTL |
|
||||||
|
|
||||||
|
**Authentication:** OAuth2 service account. The connector reads a service account JSON file, signs a JWT with the private key, and exchanges it for an access token at Google's token endpoint. Tokens are cached and refreshed automatically (5 min before expiry).
|
||||||
|
|
||||||
|
**Note:** CRL and OCSP are managed by Google CAS directly. certctl records revocations locally and notifies Google CAS via the revoke endpoint.
|
||||||
|
|
||||||
|
Location: `internal/connector/issuer/googlecas/googlecas.go`
|
||||||
|
|
||||||
### Coming in V2.2+
|
### Coming in V2.2+
|
||||||
|
|
||||||
The following issuer connectors are planned for future releases:
|
The following issuer connectors are planned for future releases:
|
||||||
|
|
||||||
- **Entrust** — Enterprise CA via Entrust API
|
- **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
|
- **AWS ACM Private CA** — AWS-managed private CA
|
||||||
|
|
||||||
Note: ADCS (Active Directory Certificate Services) integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above.
|
Note: ADCS (Active Directory Certificate Services) integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above.
|
||||||
@@ -620,6 +661,49 @@ When `sds_config` is `false` (the default), the connector simply writes cert and
|
|||||||
|
|
||||||
Location: `internal/connector/target/envoy/envoy.go`
|
Location: `internal/connector/target/envoy/envoy.go`
|
||||||
|
|
||||||
|
### Built-in: Postfix / Dovecot
|
||||||
|
|
||||||
|
The Postfix/Dovecot connector is a dual-mode mail server TLS connector. It writes certificate, key, and chain files to configured paths and reloads the mail service. The `mode` field selects between Postfix MTA and Dovecot IMAP/POP3, which determines default file paths and reload commands.
|
||||||
|
|
||||||
|
This connector pairs with certctl's S/MIME certificate support (email protection EKU, email SAN routing) for a complete email infrastructure story — TLS for transport encryption, S/MIME for end-to-end message signing and encryption.
|
||||||
|
|
||||||
|
**Postfix configuration:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "postfix",
|
||||||
|
"cert_path": "/etc/postfix/certs/cert.pem",
|
||||||
|
"key_path": "/etc/postfix/certs/key.pem",
|
||||||
|
"chain_path": "/etc/postfix/certs/chain.pem",
|
||||||
|
"reload_command": "postfix reload",
|
||||||
|
"validate_command": "postfix check"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dovecot configuration:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "dovecot",
|
||||||
|
"cert_path": "/etc/dovecot/certs/cert.pem",
|
||||||
|
"key_path": "/etc/dovecot/certs/key.pem",
|
||||||
|
"chain_path": "/etc/dovecot/certs/chain.pem",
|
||||||
|
"reload_command": "doveadm reload",
|
||||||
|
"validate_command": "doveconf -n"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Default (Postfix) | Default (Dovecot) | Description |
|
||||||
|
|-------|------|-------------------|-------------------|-------------|
|
||||||
|
| `mode` | string | `postfix` | `dovecot` | Service mode — determines defaults |
|
||||||
|
| `cert_path` | string | `/etc/postfix/certs/cert.pem` | `/etc/dovecot/certs/cert.pem` | Path for certificate file |
|
||||||
|
| `key_path` | string | `/etc/postfix/certs/key.pem` | `/etc/dovecot/certs/key.pem` | Path for private key (0600 permissions) |
|
||||||
|
| `chain_path` | string | (empty) | (empty) | If set, chain written separately; otherwise appended to cert |
|
||||||
|
| `reload_command` | string | `postfix reload` | `doveadm reload` | Command to reload the mail service |
|
||||||
|
| `validate_command` | string | `postfix check` | `doveconf -n` | Optional config validation before reload |
|
||||||
|
|
||||||
|
All commands are validated against shell injection via `validation.ValidateShellCommand()`. File permissions: cert/chain 0644, key 0600.
|
||||||
|
|
||||||
|
Location: `internal/connector/target/postfix/postfix.go`
|
||||||
|
|
||||||
### F5 BIG-IP (Interface Only)
|
### F5 BIG-IP (Interface Only)
|
||||||
|
|
||||||
The F5 BIG-IP target connector interface is defined with the iControl REST flow mapped out, but the actual API calls are not yet implemented. F5 appliances can't run agents directly, so this connector uses the **proxy agent pattern**: a designated agent in the same network zone picks up F5 deployment jobs and calls the iControl REST API. The server assigns the work; the proxy agent executes it.
|
The F5 BIG-IP target connector interface is defined with the iControl REST flow mapped out, but the actual API calls are not yet implemented. F5 appliances can't run agents directly, so this connector uses the **proxy agent pattern**: a designated agent in the same network zone picks up F5 deployment jobs and calls the iControl REST API. The server assigns the work; the proxy agent executes it.
|
||||||
|
|||||||
@@ -307,8 +307,8 @@ flowchart TD
|
|||||||
A --> F["ACME\n(Let's Encrypt)"]
|
A --> F["ACME\n(Let's Encrypt)"]
|
||||||
A --> G["step-ca\n(implemented)"]
|
A --> G["step-ca\n(implemented)"]
|
||||||
A --> H["OpenSSL / Custom CA\n(script-based)"]
|
A --> H["OpenSSL / Custom CA\n(script-based)"]
|
||||||
A --> J["DigiCert API\n(planned)"]
|
A --> J["DigiCert API\n(implemented)"]
|
||||||
A --> K["Vault PKI\n(planned)"]
|
A --> K["Vault PKI\n(implemented)"]
|
||||||
A --> L["Entrust / GlobalSign\n(planned)"]
|
A --> L["Entrust / GlobalSign\n(planned)"]
|
||||||
A --> M["Google CAS / EJBCA\n(planned)"]
|
A --> M["Google CAS / EJBCA\n(planned)"]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Deployment Examples
|
||||||
|
|
||||||
|
Five turnkey docker-compose scenarios, each runnable in under 5 minutes. Pick the one closest to your setup.
|
||||||
|
|
||||||
|
## Which Example Should I Use?
|
||||||
|
|
||||||
|
| I need to... | Example | Issuer | Target |
|
||||||
|
|--------------|---------|--------|--------|
|
||||||
|
| Get Let's Encrypt certs for NGINX on a public server | [ACME + NGINX](#acme--nginx) | ACME (HTTP-01) | NGINX |
|
||||||
|
| Issue wildcard certs without opening port 80 | [Wildcard DNS-01](#wildcard-dns-01) | ACME (DNS-01) | Any |
|
||||||
|
| Run an internal CA for services behind a firewall | [Private CA + Traefik](#private-ca--traefik) | Local CA | Traefik |
|
||||||
|
| Use Smallstep step-ca as my PKI backend | [step-ca + HAProxy](#step-ca--haproxy) | step-ca | HAProxy |
|
||||||
|
| Manage both public and internal certs from one dashboard | [Multi-Issuer](#multi-issuer) | ACME + Local CA | Mixed |
|
||||||
|
|
||||||
|
**Already using another tool?** See the migration sections below each example for Certbot, acme.sh, and cert-manager users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ACME + NGINX
|
||||||
|
|
||||||
|
**Scenario:** You have one or more public-facing domains, NGINX as the reverse proxy, and want automated Let's Encrypt certificates with HTTP-01 challenges.
|
||||||
|
|
||||||
|
**What it deploys:** certctl server + PostgreSQL + certctl agent + NGINX, all on one Docker network. The agent generates keys locally (ECDSA P-256), submits CSRs to the server, receives signed certs from Let's Encrypt, and deploys them to NGINX with automatic reload.
|
||||||
|
|
||||||
|
**Prerequisites:** A domain pointing to your server, ports 80 and 443 open, Docker Compose v20.10+.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/acme-nginx
|
||||||
|
cp .env.example .env # Edit with your domain and email
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The full walkthrough — including how HTTP-01 challenges work, adding multiple domains, switching to staging for testing, and a production checklist — is in the [example README](../examples/acme-nginx/acme-nginx.md).
|
||||||
|
|
||||||
|
**Migrating from Certbot?** certctl discovers your existing `/etc/letsencrypt/live/` certificates automatically. You keep your ACME account, disable the Certbot cron, and certctl takes over renewal with centralized visibility and deployment verification. The step-by-step process is in [Migrating from Certbot](migrate-from-certbot.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wildcard DNS-01
|
||||||
|
|
||||||
|
**Scenario:** You need wildcard certificates (`*.example.com`) or your servers aren't reachable from the internet (no port 80). DNS-01 validates ownership by creating a TXT record at your DNS provider.
|
||||||
|
|
||||||
|
**What it deploys:** certctl server + PostgreSQL + certctl agent. Includes a Cloudflare DNS hook script as a working reference — swap in your own DNS provider (Route53, Azure DNS, Google Cloud DNS, or any provider with an API).
|
||||||
|
|
||||||
|
**Prerequisites:** A domain, API credentials for your DNS provider, Docker Compose.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/acme-wildcard-dns01
|
||||||
|
cp .env.example .env # Edit with domain, email, DNS provider credentials
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The full walkthrough — including DNS-PERSIST-01 (set a TXT record once, never touch DNS again on renewals), adapting scripts for other providers, and propagation troubleshooting — is in the [example README](../examples/acme-wildcard-dns01/acme-wildcard-dns01.md).
|
||||||
|
|
||||||
|
**Migrating from acme.sh?** Your existing `dns_*` hook scripts are compatible with certctl's DNS-01 — they use the same pattern (shell scripts creating TXT records). The migration guide covers script adaptation, discovery of existing acme.sh certificates, and phasing out the acme.sh cron. See [Migrating from acme.sh](migrate-from-acmesh.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Private CA + Traefik
|
||||||
|
|
||||||
|
**Scenario:** Internal services that don't need public CA validation. You run your own certificate authority — either a self-signed root for development, or a subordinate CA chained to your enterprise root (e.g., Active Directory Certificate Services).
|
||||||
|
|
||||||
|
**What it deploys:** certctl server + PostgreSQL + certctl agent + Traefik. The Local CA issuer signs certificates directly. Traefik watches a cert directory and auto-reloads when new files appear.
|
||||||
|
|
||||||
|
**Prerequisites:** Docker Compose. For sub-CA mode, you'll need a CA certificate and key signed by your enterprise root.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/private-ca-traefik
|
||||||
|
docker compose up -d # Self-signed mode (no .env needed for demo)
|
||||||
|
```
|
||||||
|
|
||||||
|
The full walkthrough — including sub-CA setup with `CERTCTL_CA_CERT_PATH` and `CERTCTL_CA_KEY_PATH`, creating certificates via the API, monitoring deployments, and production hardening — is in the [example README](../examples/private-ca-traefik/private-ca-traefik.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## step-ca + HAProxy
|
||||||
|
|
||||||
|
**Scenario:** You use Smallstep's step-ca as your private PKI and want automated lifecycle management for certificates deployed to HAProxy load balancers.
|
||||||
|
|
||||||
|
**What it deploys:** certctl server + PostgreSQL + certctl agent + step-ca (with JWK provisioner) + HAProxy. certctl issues certs via step-ca's native `/sign` API, combines them into HAProxy's expected PEM format (cert + chain + key in one file), and reloads HAProxy.
|
||||||
|
|
||||||
|
**Prerequisites:** Docker Compose.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/step-ca-haproxy
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The full walkthrough — including step-ca provisioner configuration, integrating with an existing step-ca instance, HAProxy PEM format details, and advanced features (approval workflows, policy-based renewal, multi-instance HAProxy) — is in the [example README](../examples/step-ca-haproxy/step-ca-haproxy.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-Issuer
|
||||||
|
|
||||||
|
**Scenario:** You manage both public-facing services (needing Let's Encrypt or another public CA) and internal services (using a private CA) and want a single dashboard for everything.
|
||||||
|
|
||||||
|
**What it deploys:** certctl server + PostgreSQL + certctl agent configured with both an ACME issuer and a Local CA issuer. Demonstrates issuer assignment via profiles — public services get ACME certs, internal services get Local CA certs, all visible in one inventory.
|
||||||
|
|
||||||
|
**Prerequisites:** Docker Compose. For real ACME certs, a public domain and port 80 access.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/multi-issuer
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The full walkthrough — including profile-based issuer assignment, testing with ACME staging, Local CA enterprise sub-CA mode, and scaling beyond Docker Compose — is in the [example README](../examples/multi-issuer/multi-issuer.md).
|
||||||
|
|
||||||
|
**Using cert-manager for Kubernetes?** certctl complements cert-manager — cert-manager handles in-cluster certs, certctl handles everything outside: VMs, bare metal, network appliances, Windows servers. They can share the same CA (ACME, step-ca, Vault PKI). See [certctl for cert-manager Users](certctl-for-cert-manager-users.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beyond These Examples
|
||||||
|
|
||||||
|
These 5 scenarios cover the most common deployment patterns, but certctl supports 7 issuer backends and 10 target connectors. Once you have the basics running, you can mix and match:
|
||||||
|
|
||||||
|
**Issuers:** ACME (Let's Encrypt, ZeroSSL, Buypass, Google Trust Services), Local CA (self-signed or sub-CA), step-ca, Vault PKI, DigiCert CertCentral, OpenSSL/Custom CA script, Sectigo (coming soon).
|
||||||
|
|
||||||
|
**Targets:** NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS (local PowerShell or WinRM proxy), Postfix, Dovecot, F5 BIG-IP (coming soon).
|
||||||
|
|
||||||
|
See [Connector Reference](connectors.md) for configuration details on every issuer and target.
|
||||||
+7
-7
@@ -1286,11 +1286,11 @@ The web dashboard is the primary operational interface for certctl. Built with *
|
|||||||
- **Docker Tags** — `:latest`, `:v{version}` (`shankar0123.docker.scarf.sh/certctl-server`, `shankar0123.docker.scarf.sh/certctl-agent`)
|
- **Docker Tags** — `:latest`, `:v{version}` (`shankar0123.docker.scarf.sh/certctl-server`, `shankar0123.docker.scarf.sh/certctl-agent`)
|
||||||
|
|
||||||
### Test Suite
|
### Test Suite
|
||||||
- **Unit Tests** — 625+ test functions across service, handler, middleware, domain layers
|
- **Unit Tests** — 1,088+ test functions across service, handler, middleware, domain layers
|
||||||
- **Integration Tests** — End-to-end workflows (issuance→renewal→deployment)
|
- **Integration Tests** — End-to-end workflows (issuance→renewal→deployment)
|
||||||
- **Negative Tests** — Malformed input, nonexistent resources, error conditions
|
- **Negative Tests** — Malformed input, nonexistent resources, error conditions
|
||||||
- **Frontend Tests** — 86 Vitest tests (API client, utilities, stats/metrics, full endpoint coverage)
|
- **Frontend Tests** — 211 Vitest tests (API client, utilities, stats/metrics, full endpoint coverage)
|
||||||
- **Total Coverage** — 900+ tests (Go + frontend combined)
|
- **Total Coverage** — 1,554+ tests (Go + frontend combined)
|
||||||
|
|
||||||
### Licensing
|
### Licensing
|
||||||
- **License** — Business Source License 1.1 (BSL 1.1)
|
- **License** — Business Source License 1.1 (BSL 1.1)
|
||||||
@@ -1478,10 +1478,10 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
|||||||
|
|
||||||
| Category | Count |
|
| Category | Count |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| **API Endpoints** | 95 (under /api/v1/ + /.well-known/est/) |
|
| **API Endpoints** | 97 (under /api/v1/ + /.well-known/est/) |
|
||||||
| **Dashboard** | Full web GUI |
|
| **Dashboard** | Full web GUI |
|
||||||
| **Issuer Connectors** | 4 (Local CA, ACME, step-ca, OpenSSL) |
|
| **Issuer Connectors** | 6 (Local CA, ACME, step-ca, OpenSSL, Vault PKI, DigiCert) |
|
||||||
| **Target Connectors** | 5 (3 impl: NGINX, Apache, HAProxy; 2 stubs: F5, IIS) |
|
| **Target Connectors** | 10 (9 impl: NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS, Postfix, Dovecot; 1 stub: F5) |
|
||||||
| **Notifier Channels** | 6 (Email, Webhook, Slack, Teams, PagerDuty, OpsGenie) |
|
| **Notifier Channels** | 6 (Email, Webhook, Slack, Teams, PagerDuty, OpsGenie) |
|
||||||
| **Job Types** | 4 (Issuance, Renewal, Deployment, Validation) |
|
| **Job Types** | 4 (Issuance, Renewal, Deployment, Validation) |
|
||||||
| **Job States** | 7 (Pending, AwaitingCSR, AwaitingApproval, Running, Completed, Failed, Cancelled) |
|
| **Job States** | 7 (Pending, AwaitingCSR, AwaitingApproval, Running, Completed, Failed, Cancelled) |
|
||||||
@@ -1492,6 +1492,6 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
|||||||
| **MCP Tools** | 76 (16 resource domains) |
|
| **MCP Tools** | 76 (16 resource domains) |
|
||||||
| **CLI Subcommands** | 10 |
|
| **CLI Subcommands** | 10 |
|
||||||
| **Database Tables** | 19 |
|
| **Database Tables** | 19 |
|
||||||
| **Test Suite** | 900+ tests (Go backend + frontend) |
|
| **Test Suite** | 1,554+ tests (Go backend + frontend) |
|
||||||
| **Environment Variables** | 41+ configuration options |
|
| **Environment Variables** | 41+ configuration options |
|
||||||
|
|
||||||
|
|||||||
@@ -267,8 +267,9 @@ export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/cloudflare-present.sh
|
|||||||
|
|
||||||
certctl automatically falls back to DNS-01 if the CA doesn't support dns-persist-01 yet.
|
certctl automatically falls back to DNS-01 if the CA doesn't support dns-persist-01 yet.
|
||||||
|
|
||||||
## Support
|
## Next Steps
|
||||||
|
|
||||||
See [Connector Configuration](connectors.md) for advanced ACME options (EAB, ARI, custom timeouts).
|
- Try the [Wildcard DNS-01 example](../examples/acme-wildcard-dns01/acme-wildcard-dns01.md) — a working docker-compose with Cloudflare hooks you can adapt for your DNS provider
|
||||||
|
- See [Connector Reference](connectors.md) for advanced ACME options (EAB, ARI, custom timeouts)
|
||||||
See [Discovery Guide](concepts.md#certificate-discovery) for managing discovered certificates at scale.
|
- See [Discovery Guide](concepts.md#certificate-discovery) for managing discovered certificates at scale
|
||||||
|
- See all [Deployment Examples](./examples.md) for other scenarios (ACME+NGINX, private CA, step-ca, multi-issuer)
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ certctl will stop renewing that cert when the policy is disabled. Certbot resume
|
|||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
|
- Try the [ACME + NGINX example](../examples/acme-nginx/acme-nginx.md) — a working docker-compose you can run locally before deploying to production
|
||||||
- Review the [Concepts Guide](./concepts.md) for terminology (profiles, policies, agents, jobs)
|
- Review the [Concepts Guide](./concepts.md) for terminology (profiles, policies, agents, jobs)
|
||||||
- Explore [Network Discovery](./quickstart.md#network-discovery-agentless) to find certificates you didn't know about
|
- Explore [Network Discovery](./quickstart.md#network-discovery-agentless) to find certificates you didn't know about
|
||||||
- Set up [Kubernetes cert-manager integration](./certctl-for-cert-manager-users.md) if you manage in-cluster certs too
|
- See all [Deployment Examples](./examples.md) for other scenarios (wildcard DNS-01, private CA, step-ca, multi-issuer)
|
||||||
|
|||||||
+4
-1
@@ -461,7 +461,10 @@ The `-v` flag removes the PostgreSQL data volume for a clean slate.
|
|||||||
|
|
||||||
## What's Next
|
## What's Next
|
||||||
|
|
||||||
|
**Ready to deploy with your stack?** The [Deployment Examples](examples.md) page has 5 turnkey docker-compose scenarios — pick the one closest to your setup and have it running in minutes. It also covers migration paths from Certbot, acme.sh, and cert-manager.
|
||||||
|
|
||||||
|
- **[Deployment Examples](examples.md)** — ACME+NGINX, wildcard DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer
|
||||||
- **[Advanced Demo](demo-advanced.md)** — Issue a real certificate via the Local CA end-to-end
|
- **[Advanced Demo](demo-advanced.md)** — Issue a real certificate via the Local CA end-to-end
|
||||||
- **[Architecture](architecture.md)** — How the control plane, agents, and connectors work together
|
- **[Architecture](architecture.md)** — How the control plane, agents, and connectors work together
|
||||||
- **[Connector Guide](connectors.md)** — Build custom connectors for your infrastructure
|
- **[Connector Reference](connectors.md)** — Configuration for all 7 issuers and 10 targets
|
||||||
- **[Concepts Guide](concepts.md)** — TLS certificates, CAs, and private keys explained from scratch
|
- **[Concepts Guide](concepts.md)** — TLS certificates, CAs, and private keys explained from scratch
|
||||||
|
|||||||
+144
-5
@@ -1600,7 +1600,7 @@ curl -s -w "\nHTTP %{http_code}\n" -X POST -H "$AUTH" -H "$CT" \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Test 7.1.6 — Create IIS target (stub)**
|
**Test 7.1.6 — Create IIS target**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -w "\nHTTP %{http_code}\n" -X POST -H "$AUTH" -H "$CT" \
|
curl -s -w "\nHTTP %{http_code}\n" -X POST -H "$AUTH" -H "$CT" \
|
||||||
@@ -5833,7 +5833,7 @@ These must be green before starting manual QA:
|
|||||||
| 7.1.3 | Create Apache target | Manual | ☐ | | |
|
| 7.1.3 | Create Apache target | Manual | ☐ | | |
|
||||||
| 7.1.4 | Create HAProxy target | Manual | ☐ | | |
|
| 7.1.4 | Create HAProxy target | Manual | ☐ | | |
|
||||||
| 7.1.5 | Create F5 BIG-IP target (stub) | Auto | ☑ | 2026-03-30 | |
|
| 7.1.5 | Create F5 BIG-IP target (stub) | Auto | ☑ | 2026-03-30 | |
|
||||||
| 7.1.6 | Create IIS target (stub) | Auto | ☑ | 2026-03-30 | |
|
| 7.1.6 | Create IIS target | Auto | ☑ | 2026-03-30 | |
|
||||||
| 7.1.7 | Get target verifies type-specific config stored | Manual | ☐ | | |
|
| 7.1.7 | Get target verifies type-specific config stored | Manual | ☐ | | |
|
||||||
| 7.1.8 | Update target config | Manual | ☐ | | |
|
| 7.1.8 | Update target config | Manual | ☐ | | |
|
||||||
| 7.1.9 | Delete target returns 204 | Auto | ☑ | 2026-03-30 | |
|
| 7.1.9 | Delete target returns 204 | Auto | ☑ | 2026-03-30 | |
|
||||||
@@ -6314,15 +6314,154 @@ These must be green before starting manual QA:
|
|||||||
| 41.m8 | Discovery table — CA badge | Manual | ☐ | | |
|
| 41.m8 | Discovery table — CA badge | Manual | ☐ | | |
|
||||||
| 41.m9 | Fleet overview — macOS display | Manual | ☐ | | |
|
| 41.m9 | Fleet overview — macOS display | Manual | ☐ | | |
|
||||||
|
|
||||||
|
### Part 43: Sectigo SCM Connector (M43)
|
||||||
|
|
||||||
|
**Prerequisites:** Sectigo SCM account with API access, valid customerUri + login + password credentials, at least one cert type available in `/ssl/v1/types`.
|
||||||
|
|
||||||
|
#### Automated Tests
|
||||||
|
|
||||||
|
| Test | Description | Method | Pass? | Date | Notes |
|
||||||
|
|------|-------------|--------|-------|------|-------|
|
||||||
|
| 43.s1 | `IssuerTypeSectigo` constant exists in domain | Auto | ☐ | | `grep 'Sectigo' internal/domain/connector.go` |
|
||||||
|
| 43.s2 | `SectigoConfig` struct exists in config | Auto | ☐ | | `grep 'SectigoConfig' internal/config/config.go` |
|
||||||
|
| 43.s3 | `iss-sectigo` in seed_demo.sql | Auto | ☐ | | `grep 'iss-sectigo' migrations/seed_demo.sql` |
|
||||||
|
| 43.s4 | Sectigo in OpenAPI IssuerType enum | Auto | ☐ | | `grep 'Sectigo' api/openapi.yaml` |
|
||||||
|
| 43.s5 | Sectigo connector tests pass | Auto | ☐ | | `go test ./internal/connector/issuer/sectigo/... -v` |
|
||||||
|
| 43.s6 | Sectigo in issuerTypes.ts | Auto | ☐ | | `grep 'Sectigo' web/src/config/issuerTypes.ts` |
|
||||||
|
| 43.s7 | Frontend build succeeds | Auto | ☐ | | `cd web && npm run build` |
|
||||||
|
| 43.s8 | Full Go build succeeds | Auto | ☐ | | `go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/... ./cmd/mcp-server/...` |
|
||||||
|
|
||||||
|
#### Manual Tests
|
||||||
|
|
||||||
|
**43.M1: Validate Sectigo Credentials**
|
||||||
|
|
||||||
|
1. Configure env vars: `CERTCTL_SECTIGO_CUSTOMER_URI`, `CERTCTL_SECTIGO_LOGIN`, `CERTCTL_SECTIGO_PASSWORD`, `CERTCTL_SECTIGO_ORG_ID`
|
||||||
|
2. Start certctl server — verify log line: `Sectigo SCM issuer registered`
|
||||||
|
3. Call `GET /api/v1/issuers` — verify `iss-sectigo` appears in the list
|
||||||
|
|
||||||
|
**PASS if** `iss-sectigo` registered and visible in API.
|
||||||
|
|
||||||
|
**43.M2: Enroll DV Certificate**
|
||||||
|
|
||||||
|
1. Create a certificate with `issuer_id: iss-sectigo`
|
||||||
|
2. Trigger issuance — verify enrollment submitted (job enters Pending or AwaitingCSR)
|
||||||
|
3. If DV, check for immediate issuance or poll via GetOrderStatus
|
||||||
|
4. Verify `sslId` tracked in job's order_id field
|
||||||
|
|
||||||
|
**PASS if** enrollment submits successfully, sslId returned, job state machine progresses.
|
||||||
|
|
||||||
|
**43.M3: Async Polling — OV Certificate**
|
||||||
|
|
||||||
|
1. Submit OV certificate enrollment (requires org validation)
|
||||||
|
2. Verify job enters Pending state with sslId in order_id
|
||||||
|
3. Wait for Sectigo to process (or mock status check)
|
||||||
|
4. Verify GetOrderStatus returns "pending" → "completed" transition
|
||||||
|
5. Verify PEM bundle downloaded and parsed (leaf + chain)
|
||||||
|
|
||||||
|
**PASS if** async flow works end-to-end with correct status transitions.
|
||||||
|
|
||||||
|
**43.M4: Collect Not Ready (400/-183 Handling)**
|
||||||
|
|
||||||
|
1. If possible, catch the window where status is "Issued" but cert not yet generated
|
||||||
|
2. Verify collect endpoint returns 400 with code -183
|
||||||
|
3. Verify GetOrderStatus treats this as "pending" (not error)
|
||||||
|
4. Verify next poll succeeds when cert is generated
|
||||||
|
|
||||||
|
**PASS if** 400/-183 handled gracefully as pending, not as error.
|
||||||
|
|
||||||
|
**43.M5: Revocation**
|
||||||
|
|
||||||
|
1. Revoke an issued Sectigo certificate via `POST /api/v1/certificates/{id}/revoke`
|
||||||
|
2. Verify Sectigo revoke endpoint called (`POST /ssl/v1/revoke/{sslId}`)
|
||||||
|
3. Verify audit trail records revocation
|
||||||
|
|
||||||
|
**PASS if** revocation recorded in certctl and sent to Sectigo.
|
||||||
|
|
||||||
|
**43.M6: Auth Header Verification**
|
||||||
|
|
||||||
|
1. Inspect network requests to Sectigo API (via proxy or logs)
|
||||||
|
2. Verify all 3 headers present: `customerUri`, `login`, `password`
|
||||||
|
3. Verify no `X-DC-DEVKEY` header (DigiCert auth should not leak)
|
||||||
|
|
||||||
|
**PASS if** correct 3-header auth on all requests.
|
||||||
|
|
||||||
|
### Part 44: Google CAS Issuer Connector (M44)
|
||||||
|
|
||||||
|
**Prerequisites:** GCP project with Certificate Authority Service enabled, CA pool created, service account with `roles/privateca.certificateManager`, service account JSON key file.
|
||||||
|
|
||||||
|
#### Automated Tests
|
||||||
|
|
||||||
|
| Test | Description | Method | Pass? | Date | Notes |
|
||||||
|
|------|-------------|--------|-------|------|-------|
|
||||||
|
| 44.s1 | `IssuerTypeGoogleCAS` constant exists in domain | Auto | ☐ | | `grep 'GoogleCAS' internal/domain/connector.go` |
|
||||||
|
| 44.s2 | `GoogleCASConfig` struct exists in config | Auto | ☐ | | `grep 'GoogleCASConfig' internal/config/config.go` |
|
||||||
|
| 44.s3 | `iss-googlecas` in seed_demo.sql | Auto | ☐ | | `grep 'iss-googlecas' migrations/seed_demo.sql` |
|
||||||
|
| 44.s4 | GoogleCAS in OpenAPI IssuerType enum | Auto | ☐ | | `grep 'GoogleCAS' api/openapi.yaml` |
|
||||||
|
| 44.s5 | Google CAS connector tests pass | Auto | ☐ | | `go test ./internal/connector/issuer/googlecas/... -v` |
|
||||||
|
| 44.s6 | GoogleCAS in issuerTypes.ts | Auto | ☐ | | `grep 'GoogleCAS' web/src/config/issuerTypes.ts` |
|
||||||
|
| 44.s7 | Frontend build succeeds | Auto | ☐ | | `cd web && npm run build` |
|
||||||
|
| 44.s8 | Full Go build succeeds | Auto | ☐ | | `go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/... ./cmd/mcp-server/...` |
|
||||||
|
|
||||||
|
#### Manual Tests
|
||||||
|
|
||||||
|
**44.M1: Validate Google CAS Credentials**
|
||||||
|
|
||||||
|
1. Configure env vars: `CERTCTL_GOOGLE_CAS_PROJECT`, `CERTCTL_GOOGLE_CAS_LOCATION`, `CERTCTL_GOOGLE_CAS_CA_POOL`, `CERTCTL_GOOGLE_CAS_CREDENTIALS`
|
||||||
|
2. Start certctl server — verify log line: `Google CAS issuer registered`
|
||||||
|
3. Call `GET /api/v1/issuers` — verify `iss-googlecas` appears in the list
|
||||||
|
|
||||||
|
**PASS if** `iss-googlecas` registered and visible in API.
|
||||||
|
|
||||||
|
**44.M2: Issue Certificate via Google CAS**
|
||||||
|
|
||||||
|
1. Create a certificate with `issuer_id: iss-googlecas`
|
||||||
|
2. Trigger issuance — verify synchronous issuance (no async polling needed)
|
||||||
|
3. Verify PEM cert returned with correct CN and SANs
|
||||||
|
4. Verify certificate resource name stored in order_id field
|
||||||
|
|
||||||
|
**PASS if** certificate issued synchronously, PEM valid, resource name tracked.
|
||||||
|
|
||||||
|
**44.M3: Renewal via Google CAS**
|
||||||
|
|
||||||
|
1. Trigger renewal on a Google CAS-issued certificate
|
||||||
|
2. Verify new certificate issued (delegates to IssueCertificate)
|
||||||
|
3. Verify new serial number, updated validity dates
|
||||||
|
|
||||||
|
**PASS if** renewal produces new cert with new serial.
|
||||||
|
|
||||||
|
**44.M4: Revocation via Google CAS**
|
||||||
|
|
||||||
|
1. Revoke a Google CAS-issued certificate via `POST /api/v1/certificates/{id}/revoke`
|
||||||
|
2. Verify Google CAS revoke endpoint called (`POST {name}:revoke`)
|
||||||
|
3. Verify revocation reason mapped correctly (RFC 5280 → Google CAS enum)
|
||||||
|
4. Verify audit trail records revocation
|
||||||
|
|
||||||
|
**PASS if** revocation recorded in certctl and sent to Google CAS.
|
||||||
|
|
||||||
|
**44.M5: OAuth2 Token Caching**
|
||||||
|
|
||||||
|
1. Issue multiple certificates in quick succession
|
||||||
|
2. Verify token is cached (not re-fetched for every request)
|
||||||
|
3. Verify token refresh after expiry
|
||||||
|
|
||||||
|
**PASS if** token reuse observed, refresh works after expiry.
|
||||||
|
|
||||||
|
**44.M6: CA Certificate Retrieval**
|
||||||
|
|
||||||
|
1. Call EST cacerts endpoint with Google CAS as issuer
|
||||||
|
2. Verify CA certificate chain returned from Google CAS fetchCaCerts API
|
||||||
|
|
||||||
|
**PASS if** CA cert PEM returned successfully.
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|
||||||
| Category | Count |
|
| Category | Count |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| ☑ Auto (passed in `qa-smoke-test.sh`) | 144 |
|
| ☑ Auto (passed in `qa-smoke-test.sh`) | 144 |
|
||||||
| ☐ Auto (not yet run) | 12 |
|
| ☐ Auto (not yet run) | 28 |
|
||||||
| — Skipped (preconditions not met in demo) | 5 |
|
| — Skipped (preconditions not met in demo) | 5 |
|
||||||
| ☐ Manual (requires hands-on verification) | 241 |
|
| ☐ Manual (requires hands-on verification) | 253 |
|
||||||
| **Total** | **402** |
|
| **Total** | **430** |
|
||||||
|
|
||||||
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
||||||
|
|
||||||
|
|||||||
+75
-40
@@ -1,82 +1,117 @@
|
|||||||
# Why certctl?
|
# Why certctl?
|
||||||
|
|
||||||
Certificate management is broken at every scale between "one domain on Let's Encrypt" and "Fortune 500 budget for Venafi."
|
Certificate management is broken at every scale between "one domain on Let's Encrypt" and "Fortune 500 budget for Venafi." certctl fills that gap: a self-hosted platform that automates the entire certificate lifecycle, works with any CA, deploys to any server, and keeps private keys on your infrastructure. It's free, source-available, and you own everything.
|
||||||
|
|
||||||
If you run a personal blog, Certbot works fine. If your company spends $200K/year on Keyfactor, you're covered. But if you're an ops engineer managing 20-500 certificates across NGINX, Apache, HAProxy, and maybe a private CA — the tools available today either don't do enough or cost too much.
|
## The Math That Forces the Decision
|
||||||
|
|
||||||
certctl fills that gap.
|
The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) in April 2025, mandating a phased reduction in TLS certificate lifetimes: **200 days** as of March 2026, **100 days** by March 2027, and **47 days** by March 2029.
|
||||||
|
|
||||||
## The Problem
|
At 47-day lifespans, a team managing 100 certificates is processing **7+ renewals per week**, every week, forever. At 200 certificates, it's two per day. Manual processes, calendar reminders, and certbot cron jobs don't scale to this — a single missed renewal becomes a production outage at 3 AM. Certificate lifecycle automation is no longer optional; the only question is what tool runs it.
|
||||||
|
|
||||||
The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) in April 2025, mandating a phased reduction in TLS certificate lifetimes: 200 days as of March 2026, 100 days by March 2027, and 47 days by March 2029. That means every organization needs automated certificate renewal — not eventually, but now.
|
## The Landscape Today
|
||||||
|
|
||||||
The existing options for automation are:
|
If you're evaluating your options, here's what you'll find:
|
||||||
|
|
||||||
- **ACME clients** (Certbot, Lego, CertWarden): Handle issuance and renewal for ACME-compatible CAs, but don't manage deployment to target servers, don't provide inventory visibility, don't support non-ACME CAs, and don't offer audit trails or policy enforcement.
|
**ACME clients** (certbot, lego, acme.sh) handle issuance and renewal for Let's Encrypt and similar CAs, but they don't deploy to target servers, don't track inventory, don't support private CAs, and give you no audit trail or policy enforcement. You end up writing glue scripts and hoping they don't break.
|
||||||
- **Kubernetes-native** (cert-manager): Works well inside Kubernetes, but if your infrastructure includes bare-metal servers, VMs, or network appliances alongside Kubernetes, you need a separate solution for everything cert-manager can't reach.
|
|
||||||
- **Commercial SaaS** (CertKit, Sectigo CLM): Handle more of the lifecycle but are proprietary, cloud-dependent, and priced per certificate — costs scale linearly with your infrastructure.
|
**Kubernetes-native tools** (cert-manager) work well inside the cluster, but most organizations run mixed infrastructure — NGINX on VMs, HAProxy at the edge, IIS on Windows, maybe an F5. You need a separate solution for everything outside Kubernetes.
|
||||||
- **Enterprise platforms** (Venafi, Keyfactor, AppViewX): Comprehensive but start at $75K/year and require dedicated teams to operate.
|
|
||||||
|
**Commercial SaaS platforms** handle more of the lifecycle but are proprietary, cloud-dependent, and priced per certificate. At 100 certs and 20 agents, SaaS pricing runs $3,000-5,000/year and scales linearly. You're paying rent on your own infrastructure's security.
|
||||||
|
|
||||||
|
**Enterprise platforms** (Venafi, Keyfactor, AppViewX) are comprehensive but start at $75K/year and require dedicated teams to operate. If you have a 50-server environment, the licensing costs more than the servers.
|
||||||
|
|
||||||
## What certctl Does Differently
|
## What certctl Does Differently
|
||||||
|
|
||||||
certctl is a self-hosted certificate lifecycle platform. It handles issuance, renewal, deployment, revocation, discovery, and monitoring — with three design decisions that no other tool at any price point combines:
|
certctl handles issuance, renewal, deployment, revocation, discovery, and monitoring — with three design decisions that no other tool at any price point combines:
|
||||||
|
|
||||||
### 1. Private Keys Never Leave Your Infrastructure
|
### 1. Private Keys Never Leave Your Infrastructure
|
||||||
|
|
||||||
certctl agents generate private keys locally using ECDSA P-256. The agent creates a CSR and submits it to the control plane. The signed certificate comes back. The private key stays on the agent's filesystem with 0600 permissions.
|
certctl agents generate ECDSA P-256 private keys locally. The agent creates a CSR and submits it to the control plane. The signed certificate comes back. The private key stays on the agent's filesystem with 0600 permissions — it never crosses the network.
|
||||||
|
|
||||||
This isn't a premium feature — it's the default behavior in the free tier. Most competitors either generate keys server-side (creating a single point of compromise) or gate key isolation behind paid tiers.
|
This isn't a premium feature. It's the default behavior, free. Most alternatives either generate keys on the server (creating a single point of compromise) or gate key isolation behind paid tiers.
|
||||||
|
|
||||||
### 2. CA-Agnostic Issuer Architecture
|
### 2. CA-Agnostic Issuer Architecture
|
||||||
|
|
||||||
certctl works with any certificate authority, not just ACME providers:
|
certctl works with any certificate authority, not just ACME providers. Seven issuer connectors ship today, all free:
|
||||||
|
|
||||||
- **ACME** (Let's Encrypt, ZeroSSL, Google Trust Services, Buypass) — HTTP-01 and DNS-01 challenges, DNS-PERSIST-01 for zero-touch renewals, External Account Binding
|
- **ACME v2** (Let's Encrypt, ZeroSSL, Google Trust Services, Buypass) — HTTP-01, DNS-01, DNS-PERSIST-01 challenges, External Account Binding, ACME Renewal Information (RFC 9702)
|
||||||
- **step-ca** (Smallstep) — native /sign API with JWK provisioner authentication
|
- **HashiCorp Vault PKI** — `/v1/{mount}/sign/{role}` API, token auth
|
||||||
- **Local CA** — self-signed or sub-CA mode (chain to your enterprise root CA, e.g. ADCS)
|
- **DigiCert CertCentral** — async order model, OV/EV support
|
||||||
- **OpenSSL / Custom CA** — delegate signing to any shell script with configurable timeout
|
- **step-ca** (Smallstep) — native /sign API with JWK provisioner auth
|
||||||
- **EST enrollment** (RFC 7030) — device certificate enrollment for WiFi/802.1X, MDM, and IoT
|
- **Local CA** — self-signed or sub-CA mode (chain to ADCS or any enterprise root)
|
||||||
|
- **OpenSSL / Custom CA** — delegate signing to any shell script
|
||||||
|
- **EST enrollment** (RFC 7030) — device certs for WiFi/802.1X, MDM, IoT
|
||||||
|
|
||||||
Every issuer connector implements the same interface. Switching CAs or running multiple CAs in parallel requires zero code changes — just configuration.
|
Every connector implements the same interface. Running multiple CAs in parallel — Let's Encrypt for public certs, Vault for internal services, your enterprise CA for legacy systems — is configuration, not code.
|
||||||
|
|
||||||
### 3. Post-Deployment Verification
|
### 3. Post-Deployment Verification
|
||||||
|
|
||||||
Every other tool in this space stops at "the deployment command succeeded." certctl goes further: after deploying a certificate to a target, the agent connects back to the target's TLS endpoint and verifies the served certificate matches what was deployed, using SHA-256 fingerprint comparison.
|
Every other tool in this space stops at "the deployment command succeeded." certctl goes further: after deploying a certificate, the agent connects back to the live TLS endpoint and compares the SHA-256 fingerprint of the served certificate against what was deployed.
|
||||||
|
|
||||||
A reload command can exit 0 while the certificate doesn't take effect — wrong virtual host, stale cache, config that validates but doesn't apply. certctl catches this.
|
A reload command can exit 0 while the certificate doesn't take effect — wrong virtual host, stale cache, config that validates but doesn't apply. certctl catches this automatically.
|
||||||
|
|
||||||
|
## What Else Ships Free
|
||||||
|
|
||||||
|
The three differentiators above get the headlines, but the feature surface is wider than most paid platforms:
|
||||||
|
|
||||||
|
**10 deployment targets** — NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS (local PowerShell + remote WinRM), Postfix, and Dovecot. All use a pluggable connector model. The control plane never initiates outbound connections — agents poll for work, meaning certctl works behind firewalls, across network zones, and in air-gapped environments.
|
||||||
|
|
||||||
|
**Network certificate discovery** — active TLS scanning of CIDR ranges finds certificates you didn't know existed. Agents also scan local filesystems for PEM/DER files. Everything feeds into a triage workflow where you claim, dismiss, or import discovered certs into management.
|
||||||
|
|
||||||
|
**Immutable audit trail** — every API call recorded (method, path, actor, body hash, status, latency). Every certificate lifecycle event tracked. Append-only, no update or delete. Mapped to SOC 2, PCI-DSS 4.0, and NIST SP 800-57 compliance frameworks with published evidence guides.
|
||||||
|
|
||||||
|
**Policy engine** — 5 rule types (allowed issuers, allowed domains, required metadata, allowed environments, renewal lead time) with violation tracking and severity levels.
|
||||||
|
|
||||||
|
**PKI compliance** — DER-encoded X.509 CRL signed by issuing CA, embedded OCSP responder, RFC 5280 revocation with all reason codes, short-lived certificate exemption.
|
||||||
|
|
||||||
|
**Prometheus metrics** — `/api/v1/metrics/prometheus` in standard exposition format. Works with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics.
|
||||||
|
|
||||||
|
**MCP server** — 80 tools exposing the entire API surface for AI-assisted certificate management via Claude, Cursor, or any MCP-compatible client. No other certificate platform offers this.
|
||||||
|
|
||||||
|
**Full REST API** — 97 OpenAPI 3.1-documented operations. CLI tool with 10 subcommands. Helm chart for Kubernetes deployment. Scheduled certificate digest emails. Certificate export in PEM and PKCS#12. S/MIME support with EKU-aware issuance.
|
||||||
|
|
||||||
|
**1,554 tests** — Go backend with race detection, static analysis (golangci-lint), and vulnerability scanning (govulncheck) on every commit. Frontend test suite. CI runs on every push.
|
||||||
|
|
||||||
## How certctl Compares
|
## How certctl Compares
|
||||||
|
|
||||||
### vs. CertKit
|
### vs. ACME Clients
|
||||||
|
|
||||||
Closest competitor architecturally — agent-based, private key isolation (Keystore), multi-platform. certctl leads on issuer coverage (ACME + step-ca + Local CA + OpenSSL + EST vs. ACME-only), PKI compliance (CRL, OCSP, RFC 5280 revocation, immutable audit trail — all missing from CertKit today), policy engine (5 rule types vs. none), and network discovery (CIDR TLS scanning vs. none). certctl is source-available (BSL 1.1 → Apache 2.0) with no cert limit; CertKit is proprietary SaaS with a 3-cert free tier. Where CertKit leads: more deployment targets today (adds LiteSpeed, IIS, auto-detection), Windows support, Kubernetes, and polished SaaS onboarding.
|
ACME clients solve one slice of the problem — issuance and renewal from ACME CAs. certctl replaces the ACME client, adds 6 more CA integrations, deploys the cert to the right server, verifies it's live, tracks it in an inventory, alerts on expiry, logs everything to an audit trail, and enforces policy. If you're currently running certbot behind a cron job and a prayer, certctl replaces all of it.
|
||||||
|
|
||||||
### vs. KeyTalk
|
### vs. Agent-Based SaaS
|
||||||
|
|
||||||
Commercial (proprietary) PKI platform from a Dutch company — on-prem appliance, cloud, or managed service. Broader cert type coverage (TLS, S/MIME, device auth, VPN) and DigiCert + SCEP integrations. No public documentation on policy engine, API surface, or audit capabilities. No free tier, no public pricing. certctl trades breadth of cert types for full transparency — source-available, public API spec, free community edition with no limits.
|
The closest architectural competitors use the same agent model — local key generation, CSR submission, push-based deployment. Where certctl differs: it supports 7 issuer types (not just ACME), provides CRL/OCSP/revocation infrastructure (not just issuance), includes a policy engine and network discovery, and is source-available with no certificate limit. SaaS alternatives are typically proprietary, priced per certificate ($2+/cert/month), and cap their free tiers at 3-5 certificates. certctl is free for any number of certificates, forever.
|
||||||
|
|
||||||
### vs. Enterprise Platforms (Venafi, Keyfactor)
|
### vs. Commercial PKI Platforms
|
||||||
|
|
||||||
Comprehensive solutions with decades of features — at $75K-$250K+/yr. certctl targets organizations that need 80% of those capabilities at 1% of the cost. The trade-off: no SSO/RBAC yet (coming in certctl Pro), no F5/IIS target connectors yet, no SLA-backed support.
|
On-prem or hosted commercial platforms offer broader cert type coverage (VPN certs, device auth, SCEP) and deeper CA integrations. The trade-off: no free tier, opaque pricing (often €13K+/year for 1,500 certs), proprietary codebases, and no public API documentation. certctl trades breadth of exotic cert types for full transparency — source-available code, 97-operation OpenAPI spec, and a free community edition with no artificial limits.
|
||||||
|
|
||||||
## Getting Started
|
### vs. Enterprise Platforms
|
||||||
|
|
||||||
|
Venafi and Keyfactor offer decades of features at $75K-$250K+/year. certctl targets organizations that need 80% of those capabilities at a fraction of the cost. What certctl doesn't have yet: SSO/RBAC (coming in certctl Pro), vendor SLA-backed support. What certctl does have that enterprise platforms don't: an MCP server for AI-assisted management, ACME ARI (RFC 9702) for CA-directed renewal timing, and a deployment model that works in 5 minutes instead of 5 months.
|
||||||
|
|
||||||
|
## Who Should Look Elsewhere
|
||||||
|
|
||||||
|
certctl isn't the right tool for everyone:
|
||||||
|
|
||||||
|
- **Single-domain sites** — if you have one certificate on one server, certbot is fine. certctl is designed for managing tens to hundreds of certificates across multiple servers and CAs.
|
||||||
|
- **Pure Kubernetes environments** — if every workload runs in-cluster and you're happy with cert-manager, there's no reason to add another tool. certctl shines when your infrastructure extends beyond Kubernetes.
|
||||||
|
- **Organizations that need a vendor SLA today** — certctl is source-available software maintained by a small team. If you need contractual uptime guarantees and a support hotline, an enterprise platform is the right choice (for now).
|
||||||
|
|
||||||
|
## See It Running
|
||||||
|
|
||||||
|
The demo seeds 32 certificates across 7 issuers, 8 agents, 6 deployment targets, and 180 days of realistic history — jobs, audit events, discovery scans, approval workflows — so you can explore every feature immediately.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone and start with Docker Compose (includes demo data)
|
|
||||||
git clone https://github.com/shankar0123/certctl.git
|
git clone https://github.com/shankar0123/certctl.git
|
||||||
cd certctl/deploy
|
cd certctl/deploy && docker compose up -d
|
||||||
docker compose up -d
|
# Dashboard at http://localhost:8443
|
||||||
|
|
||||||
# Open the dashboard
|
|
||||||
open http://localhost:8443
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The demo seeds 35 certificates across 5 issuers, 8 agents, 8 deployment targets, 90 days of job history, discovery scan data, network scan targets, and pending approval jobs so you can explore every feature immediately.
|
See the [Quickstart Guide](quickstart.md) for a full walkthrough, or explore the [5 turnkey examples](../examples/) for specific scenarios (ACME+NGINX, wildcard DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer).
|
||||||
|
|
||||||
See the [Quickstart Guide](quickstart.md) for a full walkthrough.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
certctl is licensed under the [Business Source License 1.1](../LICENSE). The licensed work is free to use for any purpose other than offering a competing managed service. The license converts to Apache 2.0 on March 1, 2033.
|
certctl is source-available under the [Business Source License 1.1](../LICENSE). Free for any use except offering a competing managed service. Converts to Apache 2.0 on March 1, 2033.
|
||||||
|
|
||||||
The source is available, auditable, and self-hostable. You own your data, your keys, and your deployment.
|
You own your data, your keys, and your deployment.
|
||||||
|
|||||||
@@ -13,16 +13,18 @@ This example demonstrates certctl's core use case: **automatically manage TLS ce
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```mermaid
|
||||||
Your Domain (example.com)
|
flowchart TD
|
||||||
↓ [HTTP-01 validation, port 80]
|
A["Your Domain (example.com)"]
|
||||||
Let's Encrypt ACME
|
B["Let's Encrypt ACME"]
|
||||||
↓ [CSR submission]
|
C["certctl Server (control plane)"]
|
||||||
certctl Server (control plane)
|
D["certctl Agent (on NGINX server)"]
|
||||||
↓ [API polling]
|
E["NGINX Reverse Proxy"]
|
||||||
certctl Agent (on NGINX server)
|
|
||||||
↓ [deploy cert+key]
|
A -->|HTTP-01 validation<br/>port 80| B
|
||||||
NGINX Reverse Proxy
|
B -->|CSR submission| C
|
||||||
|
C -->|API polling| D
|
||||||
|
D -->|deploy cert+key| E
|
||||||
```
|
```
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ services:
|
|||||||
container_name: certctl-server-acme-nginx
|
container_name: certctl-server-acme-nginx
|
||||||
environment:
|
environment:
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
CERTCTL_DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
CERTCTL_SERVER_PORT: 8443
|
CERTCTL_SERVER_PORT: 8443
|
||||||
@@ -61,7 +61,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- certctl-network
|
- certctl-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ services:
|
|||||||
container_name: certctl-server-dns01
|
container_name: certctl-server-dns01
|
||||||
environment:
|
environment:
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
CERTCTL_DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
CERTCTL_SERVER_PORT: 8443
|
CERTCTL_SERVER_PORT: 8443
|
||||||
@@ -113,7 +113,7 @@ services:
|
|||||||
- certctl-network
|
- certctl-network
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ services:
|
|||||||
container_name: certctl-server-multi-issuer
|
container_name: certctl-server-multi-issuer
|
||||||
environment:
|
environment:
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
CERTCTL_DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
CERTCTL_SERVER_PORT: 8443
|
CERTCTL_SERVER_PORT: 8443
|
||||||
@@ -64,7 +64,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- certctl-network
|
- certctl-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -13,27 +13,29 @@ With certctl, both issuer types are configured and available. You assign each ce
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```mermaid
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
flowchart TD
|
||||||
│ certctl Server (Control Plane) │
|
subgraph Server ["certctl Server (Control Plane)"]
|
||||||
│ - Let's Encrypt ACME issuer (HTTP-01 challenges) │
|
A["Let's Encrypt ACME issuer<br/>(HTTP-01 challenges)"]
|
||||||
│ - Local CA issuer (self-signed or sub-CA mode) │
|
B["Local CA issuer<br/>(self-signed or sub-CA mode)"]
|
||||||
│ - PostgreSQL database (cert inventory, audit, jobs) │
|
C["PostgreSQL database<br/>(cert inventory, audit, jobs)"]
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
end
|
||||||
▲
|
|
||||||
│ API polling
|
subgraph Agent ["certctl Agent"]
|
||||||
│
|
D["Discovers existing certs<br/>(/etc/nginx/ssl, /etc/app/ssl)"]
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
E["Polls server for<br/>renewal/issuance/deployment jobs"]
|
||||||
│ certctl Agent │
|
F["Generates keys locally<br/>(agent-side crypto)"]
|
||||||
│ - Discovers existing certs in /etc/nginx/ssl and /etc/app/ssl │
|
G["Deploys certs to NGINX<br/>and app service directories"]
|
||||||
│ - Polls server for renewal/issuance/deployment jobs │
|
end
|
||||||
│ - Generates keys locally (agent-side crypto) │
|
|
||||||
│ - Deploys certs to NGINX and app service directories │
|
subgraph Targets ["Target Services"]
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
H["NGINX (public TLS)<br/>(Let's Encrypt certs)"]
|
||||||
│ │
|
I["App Services (internal TLS)<br/>(Local CA certs)"]
|
||||||
▼ ▼
|
end
|
||||||
NGINX (public TLS) App Services (internal TLS)
|
|
||||||
(Let's Encrypt certs) (Local CA certs)
|
Server -->|API polling| Agent
|
||||||
|
Agent -->|Deploy| H
|
||||||
|
Agent -->|Deploy| I
|
||||||
```
|
```
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
@@ -212,7 +214,7 @@ Each agent independently manages its local cert inventory and deployments. The s
|
|||||||
- For ACME, ensure ports 80/443 are open and your domain resolves
|
- For ACME, ensure ports 80/443 are open and your domain resolves
|
||||||
|
|
||||||
### Agent can't reach server
|
### Agent can't reach server
|
||||||
- Check network: `docker compose exec certctl-agent curl http://certctl-server:8443/api/v1/health`
|
- Check network: `docker compose exec certctl-agent curl http://certctl-server:8443/health`
|
||||||
- Verify `CERTCTL_SERVER_URL` environment variable
|
- Verify `CERTCTL_SERVER_URL` environment variable
|
||||||
|
|
||||||
### No issuers showing up
|
### No issuers showing up
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ services:
|
|||||||
container_name: certctl-server-private-ca
|
container_name: certctl-server-private-ca
|
||||||
environment:
|
environment:
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
CERTCTL_DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
CERTCTL_SERVER_PORT: 8443
|
CERTCTL_SERVER_PORT: 8443
|
||||||
@@ -77,7 +77,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- certctl-network
|
- certctl-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -17,29 +17,16 @@ This example demonstrates certctl managing certificates for **internal services
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```mermaid
|
||||||
┌──────────────────┐
|
flowchart TD
|
||||||
│ certctl-server │ (Local CA issuer)
|
A["certctl-server<br/>(control plane)<br/>(Local CA issuer)"]
|
||||||
│ (control │
|
B["certctl-agent<br/>(certificate deployer)"]
|
||||||
│ plane) │
|
C["Traefik<br/>(watches cert directory)"]
|
||||||
└────────┬─────────┘
|
D["[Internal Services]"]
|
||||||
│
|
|
||||||
│ REST API (job polling)
|
A -->|REST API<br/>job polling| B
|
||||||
│
|
B -->|Write cert/key files| C
|
||||||
┌────────▼──────────┐
|
C -->|TLS handshakes| D
|
||||||
│ certctl-agent │ (certificate deployer)
|
|
||||||
└────────┬──────────┘
|
|
||||||
│
|
|
||||||
│ Write cert/key files
|
|
||||||
│
|
|
||||||
┌────────▼──────────────────────┐
|
|
||||||
│ Traefik │
|
|
||||||
│ (watches cert directory) │
|
|
||||||
└────────────────────────────────┘
|
|
||||||
│
|
|
||||||
│ TLS handshakes
|
|
||||||
│
|
|
||||||
[Internal Services]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start (Self-Signed CA)
|
## Quick Start (Self-Signed CA)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ services:
|
|||||||
container_name: certctl-server-stepca-haproxy
|
container_name: certctl-server-stepca-haproxy
|
||||||
environment:
|
environment:
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
CERTCTL_DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
CERTCTL_SERVER_PORT: 8443
|
CERTCTL_SERVER_PORT: 8443
|
||||||
@@ -119,7 +119,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- certctl-network
|
- certctl-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ Common issues:
|
|||||||
Verify network:
|
Verify network:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec certctl-agent curl http://certctl-server:8443/api/v1/health
|
docker compose exec certctl-agent curl http://certctl-server:8443/health
|
||||||
```
|
```
|
||||||
|
|
||||||
### HAProxy config validation fails
|
### HAProxy config validation fails
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type Config struct {
|
|||||||
ACME ACMEConfig
|
ACME ACMEConfig
|
||||||
Vault VaultConfig
|
Vault VaultConfig
|
||||||
DigiCert DigiCertConfig
|
DigiCert DigiCertConfig
|
||||||
|
Sectigo SectigoConfig
|
||||||
|
GoogleCAS GoogleCASConfig
|
||||||
Digest DigestConfig
|
Digest DigestConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +196,71 @@ type DigiCertConfig struct {
|
|||||||
BaseURL string
|
BaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SectigoConfig contains Sectigo Certificate Manager issuer connector configuration.
|
||||||
|
type SectigoConfig struct {
|
||||||
|
// CustomerURI is the Sectigo customer URI (organization identifier).
|
||||||
|
// Required for Sectigo integration.
|
||||||
|
// Setting: CERTCTL_SECTIGO_CUSTOMER_URI environment variable.
|
||||||
|
CustomerURI string
|
||||||
|
|
||||||
|
// Login is the Sectigo API account login.
|
||||||
|
// Required for Sectigo integration.
|
||||||
|
// Setting: CERTCTL_SECTIGO_LOGIN environment variable.
|
||||||
|
Login string
|
||||||
|
|
||||||
|
// Password is the Sectigo API account password or API key.
|
||||||
|
// Required for Sectigo integration.
|
||||||
|
// Setting: CERTCTL_SECTIGO_PASSWORD environment variable.
|
||||||
|
Password string
|
||||||
|
|
||||||
|
// OrgID is the Sectigo organization ID for certificate enrollments.
|
||||||
|
// Required for Sectigo integration.
|
||||||
|
// Setting: CERTCTL_SECTIGO_ORG_ID environment variable.
|
||||||
|
OrgID int
|
||||||
|
|
||||||
|
// CertType is the Sectigo certificate type ID (from GET /ssl/v1/types).
|
||||||
|
// Required for enrollment. Set via CERTCTL_SECTIGO_CERT_TYPE environment variable.
|
||||||
|
CertType int
|
||||||
|
|
||||||
|
// Term is the certificate validity in days (e.g., 365, 730).
|
||||||
|
// Default: 365.
|
||||||
|
// Setting: CERTCTL_SECTIGO_TERM environment variable.
|
||||||
|
Term int
|
||||||
|
|
||||||
|
// BaseURL is the Sectigo SCM API base URL.
|
||||||
|
// Default: "https://cert-manager.com/api".
|
||||||
|
// Setting: CERTCTL_SECTIGO_BASE_URL environment variable.
|
||||||
|
BaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoogleCASConfig contains Google Cloud Certificate Authority Service configuration.
|
||||||
|
type GoogleCASConfig struct {
|
||||||
|
// Project is the GCP project ID.
|
||||||
|
// Required for Google CAS integration.
|
||||||
|
// Setting: CERTCTL_GOOGLE_CAS_PROJECT environment variable.
|
||||||
|
Project string
|
||||||
|
|
||||||
|
// Location is the GCP region (e.g., "us-central1").
|
||||||
|
// Required for Google CAS integration.
|
||||||
|
// Setting: CERTCTL_GOOGLE_CAS_LOCATION environment variable.
|
||||||
|
Location string
|
||||||
|
|
||||||
|
// CAPool is the Certificate Authority pool name.
|
||||||
|
// Required for Google CAS integration.
|
||||||
|
// Setting: CERTCTL_GOOGLE_CAS_CA_POOL environment variable.
|
||||||
|
CAPool string
|
||||||
|
|
||||||
|
// Credentials is the path to the service account JSON credentials file.
|
||||||
|
// Required for Google CAS integration.
|
||||||
|
// Setting: CERTCTL_GOOGLE_CAS_CREDENTIALS environment variable.
|
||||||
|
Credentials string
|
||||||
|
|
||||||
|
// TTL is the default certificate time-to-live.
|
||||||
|
// Default: "8760h" (1 year).
|
||||||
|
// Setting: CERTCTL_GOOGLE_CAS_TTL environment variable.
|
||||||
|
TTL string
|
||||||
|
}
|
||||||
|
|
||||||
// DigestConfig controls the scheduled certificate digest email feature.
|
// DigestConfig controls the scheduled certificate digest email feature.
|
||||||
type DigestConfig struct {
|
type DigestConfig struct {
|
||||||
// Enabled controls whether periodic digest emails are generated and sent.
|
// Enabled controls whether periodic digest emails are generated and sent.
|
||||||
@@ -500,6 +567,22 @@ func Load() (*Config, error) {
|
|||||||
ProductType: getEnv("CERTCTL_DIGICERT_PRODUCT_TYPE", "ssl_basic"),
|
ProductType: getEnv("CERTCTL_DIGICERT_PRODUCT_TYPE", "ssl_basic"),
|
||||||
BaseURL: getEnv("CERTCTL_DIGICERT_BASE_URL", "https://www.digicert.com/services/v2"),
|
BaseURL: getEnv("CERTCTL_DIGICERT_BASE_URL", "https://www.digicert.com/services/v2"),
|
||||||
},
|
},
|
||||||
|
Sectigo: SectigoConfig{
|
||||||
|
CustomerURI: getEnv("CERTCTL_SECTIGO_CUSTOMER_URI", ""),
|
||||||
|
Login: getEnv("CERTCTL_SECTIGO_LOGIN", ""),
|
||||||
|
Password: getEnv("CERTCTL_SECTIGO_PASSWORD", ""),
|
||||||
|
OrgID: getEnvInt("CERTCTL_SECTIGO_ORG_ID", 0),
|
||||||
|
CertType: getEnvInt("CERTCTL_SECTIGO_CERT_TYPE", 0),
|
||||||
|
Term: getEnvInt("CERTCTL_SECTIGO_TERM", 365),
|
||||||
|
BaseURL: getEnv("CERTCTL_SECTIGO_BASE_URL", "https://cert-manager.com/api"),
|
||||||
|
},
|
||||||
|
GoogleCAS: GoogleCASConfig{
|
||||||
|
Project: getEnv("CERTCTL_GOOGLE_CAS_PROJECT", ""),
|
||||||
|
Location: getEnv("CERTCTL_GOOGLE_CAS_LOCATION", ""),
|
||||||
|
CAPool: getEnv("CERTCTL_GOOGLE_CAS_CA_POOL", ""),
|
||||||
|
Credentials: getEnv("CERTCTL_GOOGLE_CAS_CREDENTIALS", ""),
|
||||||
|
TTL: getEnv("CERTCTL_GOOGLE_CAS_TTL", "8760h"),
|
||||||
|
},
|
||||||
ACME: ACMEConfig{
|
ACME: ACMEConfig{
|
||||||
DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""),
|
DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""),
|
||||||
Email: getEnv("CERTCTL_ACME_EMAIL", ""),
|
Email: getEnv("CERTCTL_ACME_EMAIL", ""),
|
||||||
|
|||||||
@@ -0,0 +1,619 @@
|
|||||||
|
// Package googlecas implements the issuer.Connector interface for
|
||||||
|
// Google Cloud Certificate Authority Service (CAS).
|
||||||
|
//
|
||||||
|
// Google CAS is a managed private CA service on GCP. This connector
|
||||||
|
// uses the CAS REST API (privateca.googleapis.com/v1) with OAuth2
|
||||||
|
// service account authentication. Certificates are issued synchronously.
|
||||||
|
//
|
||||||
|
// Authentication: OAuth2 service account via JWT → access token exchange.
|
||||||
|
// No Google SDK dependency — uses stdlib crypto/rsa + net/http.
|
||||||
|
//
|
||||||
|
// API endpoints used:
|
||||||
|
//
|
||||||
|
// POST /v1/{parent}/certificates - Issue certificate
|
||||||
|
// POST /v1/{name}:revoke - Revoke certificate
|
||||||
|
// POST /v1/{caPool}:fetchCaCerts - Get CA certificate chain
|
||||||
|
package googlecas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the Google CAS issuer connector configuration.
|
||||||
|
type Config struct {
|
||||||
|
// Project is the GCP project ID.
|
||||||
|
// Required. Set via CERTCTL_GOOGLE_CAS_PROJECT environment variable.
|
||||||
|
Project string `json:"project"`
|
||||||
|
|
||||||
|
// Location is the GCP region (e.g., "us-central1").
|
||||||
|
// Required. Set via CERTCTL_GOOGLE_CAS_LOCATION environment variable.
|
||||||
|
Location string `json:"location"`
|
||||||
|
|
||||||
|
// CAPool is the Certificate Authority pool name.
|
||||||
|
// Required. Set via CERTCTL_GOOGLE_CAS_CA_POOL environment variable.
|
||||||
|
CAPool string `json:"ca_pool"`
|
||||||
|
|
||||||
|
// Credentials is the path to the service account JSON credentials file.
|
||||||
|
// Required. Set via CERTCTL_GOOGLE_CAS_CREDENTIALS environment variable.
|
||||||
|
Credentials string `json:"credentials"`
|
||||||
|
|
||||||
|
// TTL is the requested certificate TTL (e.g., "8760h" for 1 year).
|
||||||
|
// Default: "8760h". Set via CERTCTL_GOOGLE_CAS_TTL environment variable.
|
||||||
|
TTL string `json:"ttl"`
|
||||||
|
|
||||||
|
// BaseURL overrides the Google CAS API base URL (for testing).
|
||||||
|
// Default: "https://privateca.googleapis.com/v1".
|
||||||
|
BaseURL string `json:"base_url,omitempty"`
|
||||||
|
|
||||||
|
// TokenURL overrides the OAuth2 token endpoint (for testing).
|
||||||
|
// Default: "https://oauth2.googleapis.com/token".
|
||||||
|
TokenURL string `json:"token_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceAccountKey represents the relevant fields from a Google service account JSON file.
|
||||||
|
type serviceAccountKey struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
PrivateKey string `json:"private_key"`
|
||||||
|
ClientEmail string `json:"client_email"`
|
||||||
|
TokenURI string `json:"token_uri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedToken holds an OAuth2 access token and its expiry.
|
||||||
|
type cachedToken struct {
|
||||||
|
token string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector implements the issuer.Connector interface for Google CAS.
|
||||||
|
type Connector struct {
|
||||||
|
config *Config
|
||||||
|
logger *slog.Logger
|
||||||
|
httpClient *http.Client
|
||||||
|
|
||||||
|
// OAuth2 token caching
|
||||||
|
mu sync.Mutex
|
||||||
|
tokenCache *cachedToken
|
||||||
|
saKey *serviceAccountKey
|
||||||
|
rsaKey *rsa.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Google CAS connector with the given configuration and logger.
|
||||||
|
func New(config *Config, logger *slog.Logger) *Connector {
|
||||||
|
if config != nil {
|
||||||
|
if config.TTL == "" {
|
||||||
|
config.TTL = "8760h"
|
||||||
|
}
|
||||||
|
if config.BaseURL == "" {
|
||||||
|
config.BaseURL = "https://privateca.googleapis.com/v1"
|
||||||
|
}
|
||||||
|
if config.TokenURL == "" {
|
||||||
|
config.TokenURL = "https://oauth2.googleapis.com/token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Connector{
|
||||||
|
config: config,
|
||||||
|
logger: logger,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parentPath returns the CAS resource parent path.
|
||||||
|
func (c *Connector) parentPath() string {
|
||||||
|
return fmt.Sprintf("projects/%s/locations/%s/caPools/%s",
|
||||||
|
c.config.Project, c.config.Location, c.config.CAPool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// certificateCreateResponse represents the Google CAS create certificate response.
|
||||||
|
type certificateCreateResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
PEMCertificate string `json:"pemCertificate"`
|
||||||
|
PEMCertificateChain []string `json:"pemCertificateChain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchCACertsResponse represents the Google CAS fetchCaCerts response.
|
||||||
|
type fetchCACertsResponse struct {
|
||||||
|
CACerts []caCertChain `json:"caCerts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type caCertChain struct {
|
||||||
|
Certificates []string `json:"certificates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// googleAPIError represents a Google API error response.
|
||||||
|
type googleAPIError struct {
|
||||||
|
Error struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfig checks that the Google CAS configuration is valid.
|
||||||
|
// Verifies required fields and that the credentials file is parseable.
|
||||||
|
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||||
|
return fmt.Errorf("invalid Google CAS config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Project == "" {
|
||||||
|
return fmt.Errorf("Google CAS project is required")
|
||||||
|
}
|
||||||
|
if cfg.Location == "" {
|
||||||
|
return fmt.Errorf("Google CAS location is required")
|
||||||
|
}
|
||||||
|
if cfg.CAPool == "" {
|
||||||
|
return fmt.Errorf("Google CAS CA pool is required")
|
||||||
|
}
|
||||||
|
if cfg.Credentials == "" {
|
||||||
|
return fmt.Errorf("Google CAS credentials path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify credentials file exists and is valid
|
||||||
|
saKey, _, err := loadServiceAccountKey(cfg.Credentials)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Google CAS credentials invalid: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if saKey.ClientEmail == "" {
|
||||||
|
return fmt.Errorf("Google CAS credentials missing client_email")
|
||||||
|
}
|
||||||
|
if saKey.PrivateKey == "" {
|
||||||
|
return fmt.Errorf("Google CAS credentials missing private_key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.TTL == "" {
|
||||||
|
cfg.TTL = "8760h"
|
||||||
|
}
|
||||||
|
if cfg.BaseURL == "" {
|
||||||
|
cfg.BaseURL = "https://privateca.googleapis.com/v1"
|
||||||
|
}
|
||||||
|
if cfg.TokenURL == "" {
|
||||||
|
cfg.TokenURL = "https://oauth2.googleapis.com/token"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.config = &cfg
|
||||||
|
c.logger.Info("Google CAS configuration validated",
|
||||||
|
"project", cfg.Project,
|
||||||
|
"location", cfg.Location,
|
||||||
|
"ca_pool", cfg.CAPool)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadServiceAccountKey reads and parses a service account JSON file.
|
||||||
|
func loadServiceAccountKey(path string) (*serviceAccountKey, *rsa.PrivateKey, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("cannot read credentials file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var saKey serviceAccountKey
|
||||||
|
if err := json.Unmarshal(data, &saKey); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("cannot parse credentials JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if saKey.PrivateKey == "" {
|
||||||
|
return &saKey, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the RSA private key
|
||||||
|
block, _ := pem.Decode([]byte(saKey.PrivateKey))
|
||||||
|
if block == nil {
|
||||||
|
return nil, nil, fmt.Errorf("cannot decode private key PEM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try PKCS#8 first, then PKCS#1
|
||||||
|
var rsaKey *rsa.PrivateKey
|
||||||
|
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||||
|
var ok bool
|
||||||
|
rsaKey, ok = key.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("private key is not RSA")
|
||||||
|
}
|
||||||
|
} else if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||||
|
rsaKey = key
|
||||||
|
} else {
|
||||||
|
return nil, nil, fmt.Errorf("cannot parse private key: not PKCS#8 or PKCS#1")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &saKey, rsaKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAccessToken returns a valid OAuth2 access token, refreshing if needed.
|
||||||
|
func (c *Connector) getAccessToken(ctx context.Context) (string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
// Return cached token if still valid (5 min buffer)
|
||||||
|
if c.tokenCache != nil && time.Now().Add(5*time.Minute).Before(c.tokenCache.expiresAt) {
|
||||||
|
return c.tokenCache.token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load credentials if not cached
|
||||||
|
if c.saKey == nil || c.rsaKey == nil {
|
||||||
|
saKey, rsaKey, err := loadServiceAccountKey(c.config.Credentials)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to load credentials: %w", err)
|
||||||
|
}
|
||||||
|
c.saKey = saKey
|
||||||
|
c.rsaKey = rsaKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build JWT
|
||||||
|
now := time.Now()
|
||||||
|
header := base64URLEncode([]byte(`{"alg":"RS256","typ":"JWT"}`))
|
||||||
|
|
||||||
|
claims, err := json.Marshal(map[string]interface{}{
|
||||||
|
"iss": c.saKey.ClientEmail,
|
||||||
|
"scope": "https://www.googleapis.com/auth/cloud-platform",
|
||||||
|
"aud": c.config.TokenURL,
|
||||||
|
"iat": now.Unix(),
|
||||||
|
"exp": now.Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal JWT claims: %w", err)
|
||||||
|
}
|
||||||
|
payload := base64URLEncode(claims)
|
||||||
|
|
||||||
|
// Sign
|
||||||
|
signingInput := header + "." + payload
|
||||||
|
hash := sha256.Sum256([]byte(signingInput))
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, c.rsaKey, crypto.SHA256, hash[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to sign JWT: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt := signingInput + "." + base64URLEncode(sig)
|
||||||
|
|
||||||
|
// Exchange JWT for access token
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"},
|
||||||
|
"assertion": {jwt},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.config.TokenURL,
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create token request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("token exchange failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("token exchange returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenResp.AccessToken == "" {
|
||||||
|
return "", fmt.Errorf("empty access token in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache token
|
||||||
|
c.tokenCache = &cachedToken{
|
||||||
|
token: tokenResp.AccessToken,
|
||||||
|
expiresAt: now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenResp.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// doAuthenticatedRequest performs an HTTP request with OAuth2 bearer token.
|
||||||
|
func (c *Connector) doAuthenticatedRequest(ctx context.Context, method, urlStr string, body interface{}) ([]byte, int, error) {
|
||||||
|
token, err := c.getAccessToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to get access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
bodyBytes, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(bodyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, urlStr, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp.StatusCode, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return respBody, resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAPIError extracts an error message from a Google API error response.
|
||||||
|
func extractAPIError(body []byte) string {
|
||||||
|
var apiErr googleAPIError
|
||||||
|
if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Error.Message != "" {
|
||||||
|
return fmt.Sprintf("%s (%s)", apiErr.Error.Message, apiErr.Error.Status)
|
||||||
|
}
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueCertificate issues a new certificate via Google CAS.
|
||||||
|
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
||||||
|
c.logger.Info("processing Google CAS issuance request",
|
||||||
|
"common_name", request.CommonName,
|
||||||
|
"san_count", len(request.SANs))
|
||||||
|
|
||||||
|
// Convert TTL to seconds string
|
||||||
|
ttlDuration, err := time.ParseDuration(c.config.TTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid TTL %q: %w", c.config.TTL, err)
|
||||||
|
}
|
||||||
|
lifetimeSeconds := fmt.Sprintf("%ds", int(ttlDuration.Seconds()))
|
||||||
|
|
||||||
|
// Generate unique certificate ID
|
||||||
|
certID := fmt.Sprintf("certctl-%d-%s", time.Now().Unix(), randomHex(4))
|
||||||
|
|
||||||
|
// Build request
|
||||||
|
createURL := fmt.Sprintf("%s/%s/certificates?certificateId=%s",
|
||||||
|
c.config.BaseURL, c.parentPath(), certID)
|
||||||
|
|
||||||
|
createBody := map[string]interface{}{
|
||||||
|
"lifetime": lifetimeSeconds,
|
||||||
|
"pemCsr": request.CSRPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, statusCode, err := c.doAuthenticatedRequest(ctx, http.MethodPost, createURL, createBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Google CAS create certificate failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("Google CAS create certificate returned status %d: %s",
|
||||||
|
statusCode, extractAPIError(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
var certResp certificateCreateResponse
|
||||||
|
if err := json.Unmarshal(respBody, &certResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse Google CAS response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if certResp.PEMCertificate == "" {
|
||||||
|
return nil, fmt.Errorf("no certificate in Google CAS response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse leaf cert to extract metadata
|
||||||
|
block, _ := pem.Decode([]byte(certResp.PEMCertificate))
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode certificate PEM from Google CAS")
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build chain PEM
|
||||||
|
chainPEM := strings.Join(certResp.PEMCertificateChain, "\n")
|
||||||
|
|
||||||
|
serial := formatSerial(cert.SerialNumber)
|
||||||
|
|
||||||
|
// Store full resource name as OrderID for revocation lookup
|
||||||
|
orderID := certResp.Name
|
||||||
|
|
||||||
|
c.logger.Info("Google CAS certificate issued",
|
||||||
|
"common_name", request.CommonName,
|
||||||
|
"serial", serial,
|
||||||
|
"name", certResp.Name,
|
||||||
|
"not_after", cert.NotAfter)
|
||||||
|
|
||||||
|
return &issuer.IssuanceResult{
|
||||||
|
CertPEM: certResp.PEMCertificate,
|
||||||
|
ChainPEM: chainPEM,
|
||||||
|
Serial: serial,
|
||||||
|
NotBefore: cert.NotBefore,
|
||||||
|
NotAfter: cert.NotAfter,
|
||||||
|
OrderID: orderID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenewCertificate renews a certificate by creating a new one.
|
||||||
|
// For Google CAS, renewal is functionally identical to issuance.
|
||||||
|
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
||||||
|
c.logger.Info("processing Google CAS renewal request",
|
||||||
|
"common_name", request.CommonName,
|
||||||
|
"san_count", len(request.SANs))
|
||||||
|
|
||||||
|
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||||
|
CommonName: request.CommonName,
|
||||||
|
SANs: request.SANs,
|
||||||
|
CSRPEM: request.CSRPEM,
|
||||||
|
EKUs: request.EKUs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeCertificate revokes a certificate at Google CAS.
|
||||||
|
// The serial field should contain the full certificate resource name (set as OrderID at issuance).
|
||||||
|
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||||
|
c.logger.Info("processing Google CAS revocation request", "serial", request.Serial)
|
||||||
|
|
||||||
|
// Determine the certificate resource name.
|
||||||
|
// If serial starts with "projects/", it's a full resource name (from OrderID).
|
||||||
|
// Otherwise, construct a best-effort path.
|
||||||
|
var certName string
|
||||||
|
if strings.HasPrefix(request.Serial, "projects/") {
|
||||||
|
certName = request.Serial
|
||||||
|
} else {
|
||||||
|
certName = fmt.Sprintf("%s/certificates/%s", c.parentPath(), request.Serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := mapRevocationReason(request.Reason)
|
||||||
|
|
||||||
|
revokeURL := fmt.Sprintf("%s/%s:revoke", c.config.BaseURL, certName)
|
||||||
|
revokeBody := map[string]interface{}{
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, statusCode, err := c.doAuthenticatedRequest(ctx, http.MethodPost, revokeURL, revokeBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Google CAS revoke failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("Google CAS revoke returned status %d: %s",
|
||||||
|
statusCode, extractAPIError(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("Google CAS certificate revoked", "name", certName, "reason", reason)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrderStatus returns the status of a Google CAS order.
|
||||||
|
// Google CAS signs synchronously, so orders are always "completed" immediately.
|
||||||
|
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
|
||||||
|
return &issuer.OrderStatus{
|
||||||
|
OrderID: orderID,
|
||||||
|
Status: "completed",
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateCRL is not supported because Google CAS manages CRL directly.
|
||||||
|
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("Google CAS manages CRL directly; not supported via certctl")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignOCSPResponse is not supported because Google CAS manages OCSP directly.
|
||||||
|
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("Google CAS manages OCSP directly; not supported via certctl")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCACertPEM retrieves the CA certificate chain from Google CAS.
|
||||||
|
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||||
|
fetchURL := fmt.Sprintf("%s/%s:fetchCaCerts", c.config.BaseURL, c.parentPath())
|
||||||
|
|
||||||
|
respBody, statusCode, err := c.doAuthenticatedRequest(ctx, http.MethodPost, fetchURL, map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Google CAS fetchCaCerts failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("Google CAS fetchCaCerts returned status %d: %s",
|
||||||
|
statusCode, extractAPIError(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp fetchCACertsResponse
|
||||||
|
if err := json.Unmarshal(respBody, &resp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse fetchCaCerts response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.CACerts) == 0 || len(resp.CACerts[0].Certificates) == 0 {
|
||||||
|
return "", fmt.Errorf("no CA certificates in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join all certificates from the first CA cert chain
|
||||||
|
return strings.Join(resp.CACerts[0].Certificates, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRenewalInfo returns nil, nil as Google CAS does not support ACME Renewal Information (ARI).
|
||||||
|
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapRevocationReason maps certctl RFC 5280 reason strings to Google CAS enum values.
|
||||||
|
func mapRevocationReason(reason *string) string {
|
||||||
|
if reason == nil {
|
||||||
|
return "REVOCATION_REASON_UNSPECIFIED"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(*reason) {
|
||||||
|
case "keycompromise":
|
||||||
|
return "KEY_COMPROMISE"
|
||||||
|
case "cacompromise":
|
||||||
|
return "CERTIFICATE_AUTHORITY_COMPROMISE"
|
||||||
|
case "affiliationchanged":
|
||||||
|
return "AFFILIATION_CHANGED"
|
||||||
|
case "superseded":
|
||||||
|
return "SUPERSEDED"
|
||||||
|
case "cessationofoperation":
|
||||||
|
return "CESSATION_OF_OPERATION"
|
||||||
|
case "certificatehold":
|
||||||
|
return "CERTIFICATE_HOLD"
|
||||||
|
case "privilegewithdrawn":
|
||||||
|
return "PRIVILEGE_WITHDRAWN"
|
||||||
|
default:
|
||||||
|
return "REVOCATION_REASON_UNSPECIFIED"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatSerial converts a *big.Int serial number to a hex string.
|
||||||
|
func formatSerial(serial *big.Int) string {
|
||||||
|
return serial.Text(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomHex generates n random bytes and returns them as a hex string.
|
||||||
|
func randomHex(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return fmt.Sprintf("%x", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64URLEncode encodes data using base64url without padding.
|
||||||
|
func base64URLEncode(data []byte) string {
|
||||||
|
return base64.RawURLEncoding.EncodeToString(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Connector implements the issuer.Connector interface.
|
||||||
|
var _ issuer.Connector = (*Connector)(nil)
|
||||||
@@ -0,0 +1,826 @@
|
|||||||
|
package googlecas_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/googlecas"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGoogleCASConnector(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_Success", func(t *testing.T) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
config := googlecas.Config{
|
||||||
|
Project: "my-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "my-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := googlecas.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingProject", func(t *testing.T) {
|
||||||
|
config := googlecas.Config{
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "my-pool",
|
||||||
|
Credentials: "/tmp/creds.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := googlecas.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing project")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "project is required") {
|
||||||
|
t.Errorf("Expected project required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingLocation", func(t *testing.T) {
|
||||||
|
config := googlecas.Config{
|
||||||
|
Project: "my-project",
|
||||||
|
CAPool: "my-pool",
|
||||||
|
Credentials: "/tmp/creds.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := googlecas.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing location")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "location is required") {
|
||||||
|
t.Errorf("Expected location required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingCAPool", func(t *testing.T) {
|
||||||
|
config := googlecas.Config{
|
||||||
|
Project: "my-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
Credentials: "/tmp/creds.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := googlecas.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing CA pool")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "CA pool is required") {
|
||||||
|
t.Errorf("Expected CA pool required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingCredentials", func(t *testing.T) {
|
||||||
|
config := googlecas.Config{
|
||||||
|
Project: "my-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "my-pool",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := googlecas.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing credentials")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "credentials path is required") {
|
||||||
|
t.Errorf("Expected credentials required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_InvalidCredentialsFile", func(t *testing.T) {
|
||||||
|
config := googlecas.Config{
|
||||||
|
Project: "my-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "my-pool",
|
||||||
|
Credentials: "/nonexistent/path/credentials.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := googlecas.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for invalid credentials file")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "credentials invalid") {
|
||||||
|
t.Errorf("Expected credentials invalid error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MalformedCredentialsJSON", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
badFile := filepath.Join(tmpDir, "bad-creds.json")
|
||||||
|
if err := os.WriteFile(badFile, []byte("not json"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := googlecas.Config{
|
||||||
|
Project: "my-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "my-pool",
|
||||||
|
Credentials: badFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := googlecas.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for malformed credentials JSON")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "credentials invalid") {
|
||||||
|
t.Errorf("Expected credentials invalid error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IssueCertificate_Success", func(t *testing.T) {
|
||||||
|
testCertPEM, _ := generateTestCert(t)
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token-12345","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
|
||||||
|
case strings.Contains(r.URL.Path, "/certificates") && r.Method == http.MethodPost &&
|
||||||
|
!strings.Contains(r.URL.Path, ":revoke") && !strings.Contains(r.URL.Path, ":fetchCaCerts"):
|
||||||
|
// Verify auth header
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth != "Bearer test-token-12345" {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"error":{"code":403,"message":"Permission denied","status":"PERMISSION_DENIED"}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Verify certificateId query param
|
||||||
|
certID := r.URL.Query().Get("certificateId")
|
||||||
|
if certID == "" {
|
||||||
|
t.Error("Missing certificateId query parameter")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
chainCert, _ := generateTestCert(t)
|
||||||
|
resp := fmt.Sprintf(`{
|
||||||
|
"name": "projects/test-project/locations/us-central1/caPools/test-pool/certificates/%s",
|
||||||
|
"pemCertificate": %q,
|
||||||
|
"pemCertificateChain": [%q]
|
||||||
|
}`, certID, testCertPEM, chainCert)
|
||||||
|
w.Write([]byte(resp))
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "app.example.com")
|
||||||
|
|
||||||
|
req := issuer.IssuanceRequest{
|
||||||
|
CommonName: "app.example.com",
|
||||||
|
SANs: []string{"app.example.com", "www.example.com"},
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.IssueCertificate(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.CertPEM == "" {
|
||||||
|
t.Error("CertPEM is empty")
|
||||||
|
}
|
||||||
|
if result.Serial == "" {
|
||||||
|
t.Error("Serial is empty")
|
||||||
|
}
|
||||||
|
if result.OrderID == "" {
|
||||||
|
t.Error("OrderID is empty")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(result.OrderID, "projects/") {
|
||||||
|
t.Errorf("Expected OrderID to be full resource name, got '%s'", result.OrderID)
|
||||||
|
}
|
||||||
|
if result.ChainPEM == "" {
|
||||||
|
t.Error("ChainPEM is empty")
|
||||||
|
}
|
||||||
|
if result.NotBefore.IsZero() {
|
||||||
|
t.Error("NotBefore is zero")
|
||||||
|
}
|
||||||
|
if result.NotAfter.IsZero() {
|
||||||
|
t.Error("NotAfter is zero")
|
||||||
|
}
|
||||||
|
t.Logf("Google CAS issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, "/certificates"):
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error":{"code":400,"message":"Invalid CSR","status":"INVALID_ARGUMENT"}}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "test.example.com")
|
||||||
|
req := issuer.IssuanceRequest{
|
||||||
|
CommonName: "test.example.com",
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := connector.IssueCertificate(ctx, req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for server error response")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Invalid CSR") {
|
||||||
|
t.Logf("Got error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IssueCertificate_InvalidResponse", func(t *testing.T) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, "/certificates"):
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`not-json`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "test.example.com")
|
||||||
|
req := issuer.IssuanceRequest{
|
||||||
|
CommonName: "test.example.com",
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := connector.IssueCertificate(ctx, req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for invalid response")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "parse") {
|
||||||
|
t.Logf("Got error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetOrderStatus_AlwaysCompleted", func(t *testing.T) {
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
TTL: "8760h",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
status, err := connector.GetOrderStatus(ctx, "projects/p/locations/l/caPools/cp/certificates/cert-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Status != "completed" {
|
||||||
|
t.Errorf("Expected status 'completed', got '%s'", status.Status)
|
||||||
|
}
|
||||||
|
if status.OrderID != "projects/p/locations/l/caPools/cp/certificates/cert-123" {
|
||||||
|
t.Errorf("Expected OrderID preserved, got '%s'", status.OrderID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RenewCertificate_NewCert", func(t *testing.T) {
|
||||||
|
testCertPEM, _ := generateTestCert(t)
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, "/certificates") && r.Method == http.MethodPost &&
|
||||||
|
!strings.Contains(r.URL.Path, ":revoke"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
resp := fmt.Sprintf(`{
|
||||||
|
"name": "projects/test-project/locations/us-central1/caPools/test-pool/certificates/certctl-renew",
|
||||||
|
"pemCertificate": %q,
|
||||||
|
"pemCertificateChain": []
|
||||||
|
}`, testCertPEM)
|
||||||
|
w.Write([]byte(resp))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "renew.example.com")
|
||||||
|
renewReq := issuer.RenewalRequest{
|
||||||
|
CommonName: "renew.example.com",
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.RenewCertificate(ctx, renewReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenewCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Serial == "" {
|
||||||
|
t.Error("Serial is empty")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RevokeCertificate_Success", func(t *testing.T) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
var receivedReason string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, ":revoke"):
|
||||||
|
var body map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
receivedReason = body["reason"].(string)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"name":"projects/p/locations/l/caPools/cp/certificates/cert-123"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
reason := "keyCompromise"
|
||||||
|
revokeReq := issuer.RevocationRequest{
|
||||||
|
Serial: "projects/test-project/locations/us-central1/caPools/test-pool/certificates/cert-123",
|
||||||
|
Reason: &reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if receivedReason != "KEY_COMPROMISE" {
|
||||||
|
t.Errorf("Expected reason 'KEY_COMPROMISE', got '%s'", receivedReason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RevokeCertificate_Error", func(t *testing.T) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, ":revoke"):
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte(`{"error":{"code":404,"message":"Certificate not found","status":"NOT_FOUND"}}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
revokeReq := issuer.RevocationRequest{
|
||||||
|
Serial: "projects/test-project/locations/us-central1/caPools/test-pool/certificates/nonexistent",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for revoke of nonexistent certificate")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Certificate not found") {
|
||||||
|
t.Logf("Got error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RevocationReasonMapping", func(t *testing.T) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reason string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"keyCompromise", "keyCompromise", "KEY_COMPROMISE"},
|
||||||
|
{"caCompromise", "caCompromise", "CERTIFICATE_AUTHORITY_COMPROMISE"},
|
||||||
|
{"affiliationChanged", "affiliationChanged", "AFFILIATION_CHANGED"},
|
||||||
|
{"superseded", "superseded", "SUPERSEDED"},
|
||||||
|
{"cessationOfOperation", "cessationOfOperation", "CESSATION_OF_OPERATION"},
|
||||||
|
{"certificateHold", "certificateHold", "CERTIFICATE_HOLD"},
|
||||||
|
{"privilegeWithdrawn", "privilegeWithdrawn", "PRIVILEGE_WITHDRAWN"},
|
||||||
|
{"unspecified", "unspecified", "REVOCATION_REASON_UNSPECIFIED"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var receivedReason string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, ":revoke"):
|
||||||
|
var body map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
receivedReason = body["reason"].(string)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
reason := tc.reason
|
||||||
|
err := connector.RevokeCertificate(ctx, issuer.RevocationRequest{
|
||||||
|
Serial: "projects/p/locations/l/caPools/cp/certificates/cert-1",
|
||||||
|
Reason: &reason,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if receivedReason != tc.expected {
|
||||||
|
t.Errorf("Expected reason '%s', got '%s'", tc.expected, receivedReason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetCACertPEM_Success", func(t *testing.T) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
caCertPEM, _ := generateTestCert(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, ":fetchCaCerts"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
resp := fmt.Sprintf(`{"caCerts":[{"certificates":[%q]}]}`, caCertPEM)
|
||||||
|
w.Write([]byte(resp))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
caPEM, err := connector.GetCACertPEM(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCACertPEM failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(caPEM, "BEGIN CERTIFICATE") {
|
||||||
|
t.Errorf("Expected CA PEM to contain certificate, got: %s", caPEM[:50])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetCACertPEM_Error", func(t *testing.T) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, ":fetchCaCerts"):
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"error":{"code":403,"message":"Permission denied","status":"PERMISSION_DENIED"}}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
_, err := connector.GetCACertPEM(ctx)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for permission denied")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
t.Fatal("GetRenewalInfo should return nil for Google CAS")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AuthHeader_BearerToken", func(t *testing.T) {
|
||||||
|
testCertPEM, _ := generateTestCert(t)
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
var authHeader string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"verified-token-abc","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, "/certificates") && r.Method == http.MethodPost:
|
||||||
|
authHeader = r.Header.Get("Authorization")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
resp := fmt.Sprintf(`{
|
||||||
|
"name": "projects/p/locations/l/caPools/cp/certificates/c1",
|
||||||
|
"pemCertificate": %q,
|
||||||
|
"pemCertificateChain": []
|
||||||
|
}`, testCertPEM)
|
||||||
|
w.Write([]byte(resp))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "auth-test.example.com")
|
||||||
|
_, err := connector.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||||
|
CommonName: "auth-test.example.com",
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if authHeader != "Bearer verified-token-abc" {
|
||||||
|
t.Errorf("Expected 'Bearer verified-token-abc', got '%s'", authHeader)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestCredentialsFile generates a temporary service account JSON file with a test RSA key.
|
||||||
|
func createTestCredentialsFile(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate RSA key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "RSA PRIVATE KEY",
|
||||||
|
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||||
|
})
|
||||||
|
|
||||||
|
creds := map[string]interface{}{
|
||||||
|
"type": "service_account",
|
||||||
|
"project_id": "test-project",
|
||||||
|
"private_key_id": "key-123",
|
||||||
|
"private_key": string(keyPEM),
|
||||||
|
"client_email": "certctl@test-project.iam.gserviceaccount.com",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(creds)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal credentials: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
credPath := filepath.Join(tmpDir, "credentials.json")
|
||||||
|
if err := os.WriteFile(credPath, data, 0600); err != nil {
|
||||||
|
t.Fatalf("Failed to write credentials file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return credPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestCert creates a self-signed test certificate and returns the PEM strings.
|
||||||
|
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: "Test Certificate",
|
||||||
|
},
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
DNSNames: []string{"test.example.com"},
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create certificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
|
||||||
|
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||||
|
|
||||||
|
return certPEM, keyPEM
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestCSR creates a test CSR for the given common name.
|
||||||
|
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csrTemplate := x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: commonName,
|
||||||
|
},
|
||||||
|
DNSNames: []string{commonName},
|
||||||
|
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||||
|
}
|
||||||
|
|
||||||
|
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create CSR: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE REQUEST",
|
||||||
|
Bytes: csrBytes,
|
||||||
|
}))
|
||||||
|
|
||||||
|
csr, err := x509.ParseCertificateRequest(csrBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse CSR: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return csr, csrPEM
|
||||||
|
}
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
// Package sectigo implements the issuer.Connector interface for Sectigo Certificate Manager (SCM).
|
||||||
|
//
|
||||||
|
// Sectigo Certificate Manager is an enterprise certificate authority offering DV, OV, and EV
|
||||||
|
// certificates. Like DigiCert, Sectigo uses an asynchronous order model: submit an enrollment,
|
||||||
|
// receive an sslId, then poll for completion. OV/EV certificates require organization validation
|
||||||
|
// which may take hours or days; DV certificates may be issued immediately.
|
||||||
|
//
|
||||||
|
// This connector maps to certctl's existing job state machine:
|
||||||
|
// - IssueCertificate submits the enrollment; if status is "Issued", returns cert immediately.
|
||||||
|
// If status is "Applied" or "Pending", returns OrderID with empty CertPEM — the job system
|
||||||
|
// polls via GetOrderStatus.
|
||||||
|
// - GetOrderStatus polls the order; when status becomes "Issued", downloads and parses the
|
||||||
|
// PEM bundle via the collect endpoint.
|
||||||
|
//
|
||||||
|
// Authentication: Three custom headers on every request — customerUri, login, password.
|
||||||
|
//
|
||||||
|
// Sectigo SCM REST API used:
|
||||||
|
//
|
||||||
|
// POST /ssl/v1/enroll - Submit certificate enrollment
|
||||||
|
// GET /ssl/v1/{sslId} - Check enrollment status
|
||||||
|
// GET /ssl/v1/collect/{sslId}/pem - Download PEM bundle when issued
|
||||||
|
// POST /ssl/v1/revoke/{sslId} - Revoke certificate
|
||||||
|
// GET /ssl/v1/types - List available cert types (used for health check)
|
||||||
|
package sectigo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the Sectigo Certificate Manager issuer connector configuration.
|
||||||
|
type Config struct {
|
||||||
|
// CustomerURI is the Sectigo customer URI (organization identifier).
|
||||||
|
// Required. Set via CERTCTL_SECTIGO_CUSTOMER_URI environment variable.
|
||||||
|
CustomerURI string `json:"customer_uri"`
|
||||||
|
|
||||||
|
// Login is the Sectigo API account login.
|
||||||
|
// Required. Set via CERTCTL_SECTIGO_LOGIN environment variable.
|
||||||
|
Login string `json:"login"`
|
||||||
|
|
||||||
|
// Password is the Sectigo API account password or API key.
|
||||||
|
// Required. Set via CERTCTL_SECTIGO_PASSWORD environment variable.
|
||||||
|
Password string `json:"password"`
|
||||||
|
|
||||||
|
// OrgID is the Sectigo organization ID for certificate enrollments.
|
||||||
|
// Required. Set via CERTCTL_SECTIGO_ORG_ID environment variable.
|
||||||
|
OrgID int `json:"org_id"`
|
||||||
|
|
||||||
|
// CertType is the Sectigo certificate type ID (from GET /ssl/v1/types).
|
||||||
|
// Required for enrollment. Set via CERTCTL_SECTIGO_CERT_TYPE environment variable.
|
||||||
|
CertType int `json:"cert_type"`
|
||||||
|
|
||||||
|
// Term is the certificate validity in days (e.g., 365, 730).
|
||||||
|
// Default: 365. Set via CERTCTL_SECTIGO_TERM environment variable.
|
||||||
|
Term int `json:"term"`
|
||||||
|
|
||||||
|
// BaseURL is the Sectigo SCM API base URL.
|
||||||
|
// Default: "https://cert-manager.com/api".
|
||||||
|
// Set via CERTCTL_SECTIGO_BASE_URL environment variable.
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector implements the issuer.Connector interface for Sectigo Certificate Manager.
|
||||||
|
type Connector struct {
|
||||||
|
config *Config
|
||||||
|
logger *slog.Logger
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Sectigo SCM connector with the given configuration and logger.
|
||||||
|
func New(config *Config, logger *slog.Logger) *Connector {
|
||||||
|
if config != nil {
|
||||||
|
if config.Term == 0 {
|
||||||
|
config.Term = 365
|
||||||
|
}
|
||||||
|
if config.BaseURL == "" {
|
||||||
|
config.BaseURL = "https://cert-manager.com/api"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Connector{
|
||||||
|
config: config,
|
||||||
|
logger: logger,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrollRequest is the JSON body for Sectigo certificate enrollment.
|
||||||
|
type enrollRequest struct {
|
||||||
|
OrgID int `json:"orgId"`
|
||||||
|
CSR string `json:"csr"`
|
||||||
|
CertType int `json:"certType"`
|
||||||
|
Term int `json:"term"`
|
||||||
|
SubjAltNames string `json:"subjAltNames,omitempty"`
|
||||||
|
Comments string `json:"comments,omitempty"`
|
||||||
|
ExternalRequester string `json:"externalRequester,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrollResponse is the JSON response from a certificate enrollment.
|
||||||
|
type enrollResponse struct {
|
||||||
|
SSLId int `json:"sslId"`
|
||||||
|
RenewId string `json:"renewId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusResponse is the JSON response from an enrollment status check.
|
||||||
|
type statusResponse struct {
|
||||||
|
SSLId int `json:"sslId"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CommonName string `json:"commonName,omitempty"`
|
||||||
|
SerialNumber string `json:"serialNumber,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// setAuthHeaders sets the three Sectigo authentication headers on a request.
|
||||||
|
func (c *Connector) setAuthHeaders(req *http.Request) {
|
||||||
|
req.Header.Set("customerUri", c.config.CustomerURI)
|
||||||
|
req.Header.Set("login", c.config.Login)
|
||||||
|
req.Header.Set("password", c.config.Password)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfig checks that the Sectigo configuration is valid and API access works.
|
||||||
|
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||||
|
return fmt.Errorf("invalid Sectigo config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.CustomerURI == "" {
|
||||||
|
return fmt.Errorf("Sectigo customer_uri is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Login == "" {
|
||||||
|
return fmt.Errorf("Sectigo login is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Password == "" {
|
||||||
|
return fmt.Errorf("Sectigo password is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.OrgID == 0 {
|
||||||
|
return fmt.Errorf("Sectigo org_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Term == 0 {
|
||||||
|
cfg.Term = 365
|
||||||
|
}
|
||||||
|
if cfg.BaseURL == "" {
|
||||||
|
cfg.BaseURL = "https://cert-manager.com/api"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test API access via GET /ssl/v1/types (health check)
|
||||||
|
typesURL := cfg.BaseURL + "/ssl/v1/types"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, typesURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create API test request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("customerUri", cfg.CustomerURI)
|
||||||
|
req.Header.Set("login", cfg.Login)
|
||||||
|
req.Header.Set("password", cfg.Password)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Sectigo API not reachable at %s: %w", cfg.BaseURL, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
return fmt.Errorf("Sectigo API credentials are invalid (status %d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("Sectigo API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.config = &cfg
|
||||||
|
c.logger.Info("Sectigo Certificate Manager configuration validated",
|
||||||
|
"base_url", cfg.BaseURL,
|
||||||
|
"org_id", cfg.OrgID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueCertificate submits a certificate enrollment to Sectigo SCM.
|
||||||
|
// If the certificate is issued immediately (DV certs), returns the cert.
|
||||||
|
// If pending (OV/EV certs), returns OrderID with empty CertPEM for polling.
|
||||||
|
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
||||||
|
c.logger.Info("processing Sectigo enrollment request",
|
||||||
|
"common_name", request.CommonName,
|
||||||
|
"san_count", len(request.SANs),
|
||||||
|
"cert_type", c.config.CertType)
|
||||||
|
|
||||||
|
enrollReq := enrollRequest{
|
||||||
|
OrgID: c.config.OrgID,
|
||||||
|
CSR: request.CSRPEM,
|
||||||
|
CertType: c.config.CertType,
|
||||||
|
Term: c.config.Term,
|
||||||
|
Comments: "Issued by certctl",
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(request.SANs) > 0 {
|
||||||
|
enrollReq.SubjAltNames = strings.Join(request.SANs, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(enrollReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal enrollment request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enrollURL := c.config.BaseURL + "/ssl/v1/enroll"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, enrollURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create enrollment request: %w", err)
|
||||||
|
}
|
||||||
|
c.setAuthHeaders(req)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Sectigo enrollment request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read enrollment response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return nil, fmt.Errorf("Sectigo enrollment returned status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var enrollResp enrollResponse
|
||||||
|
if err := json.Unmarshal(respBody, &enrollResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse enrollment response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
orderID := fmt.Sprintf("%d", enrollResp.SSLId)
|
||||||
|
|
||||||
|
c.logger.Info("Sectigo enrollment submitted", "ssl_id", orderID)
|
||||||
|
|
||||||
|
// Check status immediately to see if cert was issued right away
|
||||||
|
status, err := c.checkStatus(ctx, enrollResp.SSLId)
|
||||||
|
if err != nil {
|
||||||
|
// Status check failed but enrollment succeeded — return as pending
|
||||||
|
c.logger.Warn("Sectigo status check after enrollment failed, treating as pending",
|
||||||
|
"ssl_id", orderID, "error", err)
|
||||||
|
return &issuer.IssuanceResult{
|
||||||
|
OrderID: orderID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Status == "Issued" {
|
||||||
|
certPEM, chainPEM, serial, notBefore, notAfter, collectErr := c.collectCertificate(ctx, enrollResp.SSLId)
|
||||||
|
if collectErr != nil {
|
||||||
|
// Cert is issued but collect failed — might not be generated yet
|
||||||
|
c.logger.Warn("Sectigo certificate issued but collect failed, treating as pending",
|
||||||
|
"ssl_id", orderID, "error", collectErr)
|
||||||
|
return &issuer.IssuanceResult{
|
||||||
|
OrderID: orderID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("Sectigo certificate issued immediately",
|
||||||
|
"ssl_id", orderID,
|
||||||
|
"serial", serial)
|
||||||
|
|
||||||
|
return &issuer.IssuanceResult{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
ChainPEM: chainPEM,
|
||||||
|
Serial: serial,
|
||||||
|
NotBefore: notBefore,
|
||||||
|
NotAfter: notAfter,
|
||||||
|
OrderID: orderID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending — return OrderID for polling via GetOrderStatus
|
||||||
|
c.logger.Info("Sectigo enrollment pending validation",
|
||||||
|
"ssl_id", orderID,
|
||||||
|
"status", status.Status)
|
||||||
|
|
||||||
|
return &issuer.IssuanceResult{
|
||||||
|
OrderID: orderID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenewCertificate renews a certificate by submitting a new enrollment.
|
||||||
|
// Sectigo supports POST /ssl/renewById/{sslId} but for simplicity we submit
|
||||||
|
// a new enrollment (same pattern as DigiCert).
|
||||||
|
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
||||||
|
c.logger.Info("processing Sectigo renewal request",
|
||||||
|
"common_name", request.CommonName,
|
||||||
|
"san_count", len(request.SANs))
|
||||||
|
|
||||||
|
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||||
|
CommonName: request.CommonName,
|
||||||
|
SANs: request.SANs,
|
||||||
|
CSRPEM: request.CSRPEM,
|
||||||
|
EKUs: request.EKUs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeCertificate revokes a certificate at Sectigo SCM.
|
||||||
|
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||||
|
c.logger.Info("processing Sectigo revocation request", "serial", request.Serial)
|
||||||
|
|
||||||
|
reason := "Unspecified"
|
||||||
|
if request.Reason != nil {
|
||||||
|
reason = mapRevocationReason(*request.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeBody := map[string]interface{}{
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(revokeBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal revoke request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sectigo uses sslId in the URL path for revocation
|
||||||
|
revokeURL := fmt.Sprintf("%s/ssl/v1/revoke/%s", c.config.BaseURL, request.Serial)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create revoke request: %w", err)
|
||||||
|
}
|
||||||
|
c.setAuthHeaders(req)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Sectigo revoke request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Sectigo returns 204 No Content on successful revocation
|
||||||
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("Sectigo revoke returned status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("Sectigo certificate revoked", "serial", request.Serial, "reason", reason)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrderStatus checks the status of a Sectigo certificate enrollment.
|
||||||
|
// If the enrollment is "Issued", downloads the certificate and returns it.
|
||||||
|
// If still pending, returns pending status for continued polling.
|
||||||
|
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
|
||||||
|
c.logger.Debug("checking Sectigo enrollment status", "ssl_id", orderID)
|
||||||
|
|
||||||
|
// Parse sslId from string
|
||||||
|
var sslId int
|
||||||
|
if _, err := fmt.Sscanf(orderID, "%d", &sslId); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Sectigo ssl_id: %s", orderID)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := c.checkStatus(ctx, sslId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
switch status.Status {
|
||||||
|
case "Issued":
|
||||||
|
certPEM, chainPEM, serial, notBefore, notAfter, collectErr := c.collectCertificate(ctx, sslId)
|
||||||
|
if collectErr != nil {
|
||||||
|
// Cert approved but not yet generated — treat as pending
|
||||||
|
if isCollectNotReady(collectErr) {
|
||||||
|
msg := fmt.Sprintf("enrollment %s is issued but certificate not yet generated", orderID)
|
||||||
|
return &issuer.OrderStatus{
|
||||||
|
OrderID: orderID,
|
||||||
|
Status: "pending",
|
||||||
|
Message: &msg,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to collect certificate: %w", collectErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("Sectigo enrollment completed",
|
||||||
|
"ssl_id", orderID,
|
||||||
|
"serial", serial)
|
||||||
|
|
||||||
|
return &issuer.OrderStatus{
|
||||||
|
OrderID: orderID,
|
||||||
|
Status: "completed",
|
||||||
|
CertPEM: &certPEM,
|
||||||
|
ChainPEM: &chainPEM,
|
||||||
|
Serial: &serial,
|
||||||
|
NotBefore: ¬Before,
|
||||||
|
NotAfter: ¬After,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "Applied", "Pending":
|
||||||
|
msg := fmt.Sprintf("enrollment %s is %s", orderID, status.Status)
|
||||||
|
return &issuer.OrderStatus{
|
||||||
|
OrderID: orderID,
|
||||||
|
Status: "pending",
|
||||||
|
Message: &msg,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "Rejected":
|
||||||
|
msg := fmt.Sprintf("enrollment %s was rejected", orderID)
|
||||||
|
return &issuer.OrderStatus{
|
||||||
|
OrderID: orderID,
|
||||||
|
Status: "failed",
|
||||||
|
Message: &msg,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "Revoked", "Expired", "Not Enrolled":
|
||||||
|
msg := fmt.Sprintf("enrollment %s has status: %s", orderID, status.Status)
|
||||||
|
return &issuer.OrderStatus{
|
||||||
|
OrderID: orderID,
|
||||||
|
Status: "failed",
|
||||||
|
Message: &msg,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
msg := fmt.Sprintf("unknown enrollment status: %s", status.Status)
|
||||||
|
return &issuer.OrderStatus{
|
||||||
|
OrderID: orderID,
|
||||||
|
Status: "pending",
|
||||||
|
Message: &msg,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkStatus retrieves the enrollment status from Sectigo.
|
||||||
|
func (c *Connector) checkStatus(ctx context.Context, sslId int) (*statusResponse, error) {
|
||||||
|
statusURL := fmt.Sprintf("%s/ssl/v1/%d", c.config.BaseURL, sslId)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create status request: %w", err)
|
||||||
|
}
|
||||||
|
c.setAuthHeaders(req)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Sectigo status request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read status response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("Sectigo status returned %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusResp statusResponse
|
||||||
|
if err := json.Unmarshal(respBody, &statusResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse status response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &statusResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectCertificate downloads the PEM bundle for a Sectigo certificate.
|
||||||
|
func (c *Connector) collectCertificate(ctx context.Context, sslId int) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
|
||||||
|
collectURL := fmt.Sprintf("%s/ssl/v1/collect/%d/pem", c.config.BaseURL, sslId)
|
||||||
|
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, collectURL, nil)
|
||||||
|
if reqErr != nil {
|
||||||
|
err = fmt.Errorf("failed to create collect request: %w", reqErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.setAuthHeaders(req)
|
||||||
|
|
||||||
|
resp, doErr := c.httpClient.Do(req)
|
||||||
|
if doErr != nil {
|
||||||
|
err = fmt.Errorf("Sectigo collect request failed: %w", doErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
|
if readErr != nil {
|
||||||
|
err = fmt.Errorf("failed to read collect response: %w", readErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sectigo returns 400 with code -183 when cert is approved but not yet generated
|
||||||
|
if resp.StatusCode == http.StatusBadRequest {
|
||||||
|
err = &collectNotReadyError{statusCode: resp.StatusCode, body: string(body)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
err = fmt.Errorf("Sectigo collect returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the PEM bundle: first cert is the leaf, rest are intermediates
|
||||||
|
certPEM, chainPEM, serial, notBefore, notAfter, err = parsePEMBundle(string(body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectNotReadyError indicates the certificate is not yet generated.
|
||||||
|
type collectNotReadyError struct {
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *collectNotReadyError) Error() string {
|
||||||
|
return fmt.Sprintf("certificate not yet available (status %d): %s", e.statusCode, e.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCollectNotReady checks if an error indicates the cert is not yet generated.
|
||||||
|
func isCollectNotReady(err error) bool {
|
||||||
|
_, ok := err.(*collectNotReadyError)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePEMBundle splits a PEM bundle into leaf cert and chain, extracting metadata.
|
||||||
|
func parsePEMBundle(bundle string) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
|
||||||
|
var certs []string
|
||||||
|
remaining := bundle
|
||||||
|
|
||||||
|
for {
|
||||||
|
var block *pem.Block
|
||||||
|
block, rest := pem.Decode([]byte(remaining))
|
||||||
|
if block == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if block.Type == "CERTIFICATE" {
|
||||||
|
certs = append(certs, string(pem.EncodeToMemory(block)))
|
||||||
|
}
|
||||||
|
remaining = string(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(certs) == 0 {
|
||||||
|
err = fmt.Errorf("no certificates found in PEM bundle")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM = certs[0]
|
||||||
|
if len(certs) > 1 {
|
||||||
|
chainPEM = strings.Join(certs[1:], "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse leaf cert for metadata
|
||||||
|
block, _ := pem.Decode([]byte(certPEM))
|
||||||
|
if block == nil {
|
||||||
|
err = fmt.Errorf("failed to decode leaf certificate PEM")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, parseErr := x509.ParseCertificate(block.Bytes)
|
||||||
|
if parseErr != nil {
|
||||||
|
err = fmt.Errorf("failed to parse leaf certificate: %w", parseErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serial = cert.SerialNumber.String()
|
||||||
|
notBefore = cert.NotBefore
|
||||||
|
notAfter = cert.NotAfter
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapRevocationReason maps RFC 5280 / certctl reason strings to Sectigo reason strings.
|
||||||
|
func mapRevocationReason(reason string) string {
|
||||||
|
switch strings.ToLower(reason) {
|
||||||
|
case "keycompromise", "key_compromise":
|
||||||
|
return "Compromised"
|
||||||
|
case "cessationofoperation", "cessation_of_operation":
|
||||||
|
return "Cessation of Operation"
|
||||||
|
case "affiliationchanged", "affiliation_changed":
|
||||||
|
return "Affiliation Changed"
|
||||||
|
case "superseded":
|
||||||
|
return "Superseded"
|
||||||
|
default:
|
||||||
|
return "Unspecified"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateCRL is not supported because Sectigo manages CRL distribution.
|
||||||
|
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("Sectigo manages CRL distribution; use Sectigo's CRL endpoints")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignOCSPResponse is not supported because Sectigo manages OCSP.
|
||||||
|
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("Sectigo manages OCSP; use Sectigo's OCSP responder")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCACertPEM is not directly supported. Sectigo intermediate certificates
|
||||||
|
// come with each certificate issuance as part of the PEM bundle.
|
||||||
|
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||||
|
return "", fmt.Errorf("Sectigo intermediate certificates are included with each issued certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRenewalInfo returns nil, nil as Sectigo does not support ACME Renewal Information (ARI).
|
||||||
|
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Connector implements the issuer.Connector interface.
|
||||||
|
var _ issuer.Connector = (*Connector)(nil)
|
||||||
@@ -0,0 +1,843 @@
|
|||||||
|
package sectigo_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/sectigo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSectigoConnector(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_Success", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/ssl/v1/types" {
|
||||||
|
// Verify all 3 auth headers are present
|
||||||
|
if r.Header.Get("customerUri") != "test-org" {
|
||||||
|
t.Errorf("Expected customerUri 'test-org', got '%s'", r.Header.Get("customerUri"))
|
||||||
|
}
|
||||||
|
if r.Header.Get("login") != "api-user" {
|
||||||
|
t.Errorf("Expected login 'api-user', got '%s'", r.Header.Get("login"))
|
||||||
|
}
|
||||||
|
if r.Header.Get("password") != "api-pass" {
|
||||||
|
t.Errorf("Expected password 'api-pass', got '%s'", r.Header.Get("password"))
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`[{"id":423,"name":"Sectigo OV SSL","term":[365,730]}]`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := sectigo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
CertType: 423,
|
||||||
|
Term: 365,
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := sectigo.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingCustomerURI", func(t *testing.T) {
|
||||||
|
config := sectigo.Config{
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := sectigo.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing customer_uri")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "customer_uri is required") {
|
||||||
|
t.Errorf("Expected customer_uri required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingLogin", func(t *testing.T) {
|
||||||
|
config := sectigo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := sectigo.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing login")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "login is required") {
|
||||||
|
t.Errorf("Expected login required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingPassword", func(t *testing.T) {
|
||||||
|
config := sectigo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
OrgID: 12345,
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := sectigo.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing password")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "password is required") {
|
||||||
|
t.Errorf("Expected password required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingOrgID", func(t *testing.T) {
|
||||||
|
config := sectigo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := sectigo.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing org_id")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "org_id is required") {
|
||||||
|
t.Errorf("Expected org_id required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_InvalidCredentials", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/ssl/v1/types" {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte(`{"code":0,"description":"Invalid credentials"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := sectigo.Config{
|
||||||
|
CustomerURI: "bad-org",
|
||||||
|
Login: "bad-user",
|
||||||
|
Password: "bad-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := sectigo.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for invalid credentials")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid") {
|
||||||
|
t.Logf("Got error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IssueCertificate_ImmediateSuccess", func(t *testing.T) {
|
||||||
|
testCertPEM, _ := generateTestCert(t)
|
||||||
|
testChainPEM, _ := generateTestCert(t)
|
||||||
|
pemBundle := testCertPEM + testChainPEM
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Verify auth headers on every request
|
||||||
|
if r.Header.Get("customerUri") == "" || r.Header.Get("login") == "" || r.Header.Get("password") == "" {
|
||||||
|
t.Error("Missing auth headers on request")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/ssl/v1/enroll" && r.Method == http.MethodPost:
|
||||||
|
// Verify request body structure
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var req map[string]interface{}
|
||||||
|
json.Unmarshal(body, &req)
|
||||||
|
if req["orgId"] == nil {
|
||||||
|
t.Error("Expected orgId in enrollment request")
|
||||||
|
}
|
||||||
|
if req["certType"] == nil {
|
||||||
|
t.Error("Expected certType in enrollment request")
|
||||||
|
}
|
||||||
|
// SANs should be comma-separated string, not array
|
||||||
|
if sans, ok := req["subjAltNames"].(string); ok {
|
||||||
|
if !strings.Contains(sans, ",") && len(sans) > 0 {
|
||||||
|
// Single SAN is fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"sslId":55001,"renewId":"ren-abc"}`))
|
||||||
|
|
||||||
|
case r.URL.Path == "/ssl/v1/55001" && r.Method == http.MethodGet:
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"sslId":55001,"status":"Issued","commonName":"app.example.com"}`))
|
||||||
|
|
||||||
|
case r.URL.Path == "/ssl/v1/collect/55001/pem" && r.Method == http.MethodGet:
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(pemBundle))
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := §igo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
CertType: 423,
|
||||||
|
Term: 365,
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := sectigo.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "app.example.com")
|
||||||
|
req := issuer.IssuanceRequest{
|
||||||
|
CommonName: "app.example.com",
|
||||||
|
SANs: []string{"app.example.com", "www.example.com"},
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.IssueCertificate(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.CertPEM == "" {
|
||||||
|
t.Error("CertPEM should not be empty for immediate issuance")
|
||||||
|
}
|
||||||
|
if result.Serial == "" {
|
||||||
|
t.Error("Serial should not be empty for immediate issuance")
|
||||||
|
}
|
||||||
|
if result.OrderID != "55001" {
|
||||||
|
t.Errorf("Expected OrderID '55001', got '%s'", result.OrderID)
|
||||||
|
}
|
||||||
|
t.Logf("Sectigo issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IssueCertificate_Pending", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/ssl/v1/enroll":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"sslId":55002}`))
|
||||||
|
case "/ssl/v1/55002":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"sslId":55002,"status":"Applied","commonName":"secure.example.com"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := §igo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
CertType: 423,
|
||||||
|
Term: 365,
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := sectigo.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "secure.example.com")
|
||||||
|
req := issuer.IssuanceRequest{
|
||||||
|
CommonName: "secure.example.com",
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.IssueCertificate(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.OrderID != "55002" {
|
||||||
|
t.Errorf("Expected OrderID '55002', got '%s'", result.OrderID)
|
||||||
|
}
|
||||||
|
if result.CertPEM != "" {
|
||||||
|
t.Error("CertPEM should be empty for pending order")
|
||||||
|
}
|
||||||
|
if result.Serial != "" {
|
||||||
|
t.Error("Serial should be empty for pending order")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"code":-14,"description":"Invalid CSR"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := §igo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
CertType: 423,
|
||||||
|
Term: 365,
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := sectigo.New(config, logger)
|
||||||
|
|
||||||
|
req := issuer.IssuanceRequest{
|
||||||
|
CommonName: "test.example.com",
|
||||||
|
CSRPEM: "invalid-csr",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := connector.IssueCertificate(ctx, req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for server error response")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetOrderStatus_Issued", func(t *testing.T) {
|
||||||
|
testCertPEM, _ := generateTestCert(t)
|
||||||
|
testChainPEM, _ := generateTestCert(t)
|
||||||
|
pemBundle := testCertPEM + testChainPEM
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/ssl/v1/55001":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"sslId":55001,"status":"Issued","commonName":"app.example.com"}`))
|
||||||
|
case "/ssl/v1/collect/55001/pem":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(pemBundle))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := §igo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := sectigo.New(config, logger)
|
||||||
|
|
||||||
|
status, err := connector.GetOrderStatus(ctx, "55001")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Status != "completed" {
|
||||||
|
t.Errorf("Expected status 'completed', got '%s'", status.Status)
|
||||||
|
}
|
||||||
|
if status.CertPEM == nil || *status.CertPEM == "" {
|
||||||
|
t.Error("CertPEM should not be empty for issued order")
|
||||||
|
}
|
||||||
|
if status.Serial == nil || *status.Serial == "" {
|
||||||
|
t.Error("Serial should not be empty for issued order")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetOrderStatus_Pending", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/ssl/v1/55002" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"sslId":55002,"status":"Applied"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := §igo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := sectigo.New(config, logger)
|
||||||
|
|
||||||
|
status, err := connector.GetOrderStatus(ctx, "55002")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Status != "pending" {
|
||||||
|
t.Errorf("Expected status 'pending', got '%s'", status.Status)
|
||||||
|
}
|
||||||
|
if status.CertPEM != nil {
|
||||||
|
t.Error("CertPEM should be nil for pending order")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetOrderStatus_Rejected", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/ssl/v1/55003" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"sslId":55003,"status":"Rejected"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := §igo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := sectigo.New(config, logger)
|
||||||
|
|
||||||
|
status, err := connector.GetOrderStatus(ctx, "55003")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Status != "failed" {
|
||||||
|
t.Errorf("Expected status 'failed', got '%s'", status.Status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetOrderStatus_CollectNotReady", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/ssl/v1/55004":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"sslId":55004,"status":"Issued","commonName":"pending-collect.example.com"}`))
|
||||||
|
case "/ssl/v1/collect/55004/pem":
|
||||||
|
// Sectigo returns 400 with code -183 when cert not yet generated
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"code":-183,"description":"Certificate is not available"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := §igo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := sectigo.New(config, logger)
|
||||||
|
|
||||||
|
status, err := connector.GetOrderStatus(ctx, "55004")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be treated as pending (cert approved but not yet generated)
|
||||||
|
if status.Status != "pending" {
|
||||||
|
t.Errorf("Expected status 'pending' for collect-not-ready, got '%s'", status.Status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RenewCertificate_NewOrder", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/ssl/v1/enroll":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"sslId":55010}`))
|
||||||
|
case "/ssl/v1/55010":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"sslId":55010,"status":"Applied"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := §igo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
CertType: 423,
|
||||||
|
Term: 365,
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := sectigo.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "renew.example.com")
|
||||||
|
renewReq := issuer.RenewalRequest{
|
||||||
|
CommonName: "renew.example.com",
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.RenewCertificate(ctx, renewReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenewCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.OrderID == "" {
|
||||||
|
t.Error("OrderID should not be empty")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RevokeCertificate_Success", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/ssl/v1/revoke/") && r.Method == http.MethodPost {
|
||||||
|
// Verify auth headers
|
||||||
|
if r.Header.Get("customerUri") == "" {
|
||||||
|
t.Error("Missing customerUri header on revoke request")
|
||||||
|
}
|
||||||
|
if r.Header.Get("login") == "" {
|
||||||
|
t.Error("Missing login header on revoke request")
|
||||||
|
}
|
||||||
|
if r.Header.Get("password") == "" {
|
||||||
|
t.Error("Missing password header on revoke request")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify reason in body
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var req map[string]interface{}
|
||||||
|
json.Unmarshal(body, &req)
|
||||||
|
if req["reason"] == nil {
|
||||||
|
t.Error("Expected reason in revoke request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := §igo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := sectigo.New(config, logger)
|
||||||
|
|
||||||
|
reason := "keyCompromise"
|
||||||
|
revokeReq := issuer.RevocationRequest{
|
||||||
|
Serial: "55001",
|
||||||
|
Reason: &reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RevokeCertificate_Error", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"code":-1,"description":"Certificate not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := §igo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := sectigo.New(config, logger)
|
||||||
|
|
||||||
|
revokeReq := issuer.RevocationRequest{
|
||||||
|
Serial: "00000",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for revocation of nonexistent cert")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
|
||||||
|
config := §igo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
BaseURL: "https://cert-manager.com/api",
|
||||||
|
}
|
||||||
|
connector := sectigo.New(config, logger)
|
||||||
|
|
||||||
|
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
t.Fatal("GetRenewalInfo should return nil for Sectigo")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DefaultTerm", func(t *testing.T) {
|
||||||
|
config := §igo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
CertType: 423,
|
||||||
|
// Term intentionally left as 0
|
||||||
|
}
|
||||||
|
connector := sectigo.New(config, logger)
|
||||||
|
|
||||||
|
// Verify the connector was created (the default is set in New())
|
||||||
|
if connector == nil {
|
||||||
|
t.Fatal("Connector should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify via a request that uses the term
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/ssl/v1/enroll" {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var req map[string]interface{}
|
||||||
|
json.Unmarshal(body, &req)
|
||||||
|
// Default term should be 365
|
||||||
|
if term, ok := req["term"].(float64); ok {
|
||||||
|
if int(term) != 365 {
|
||||||
|
t.Errorf("Expected default term 365, got %d", int(term))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"sslId":55099}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/ssl/v1/55099" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"sslId":55099,"status":"Applied"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Reconfigure with test server URL
|
||||||
|
config.BaseURL = srv.URL
|
||||||
|
connector = sectigo.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "test.example.com")
|
||||||
|
req := issuer.IssuanceRequest{
|
||||||
|
CommonName: "test.example.com",
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.IssueCertificate(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueCertificate with default term failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.OrderID == "" {
|
||||||
|
t.Error("OrderID should not be empty")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AuthHeaders_PresentOnAllRequests", func(t *testing.T) {
|
||||||
|
requestCount := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestCount++
|
||||||
|
// Every single request must have all 3 auth headers
|
||||||
|
if r.Header.Get("customerUri") != "verify-org" {
|
||||||
|
t.Errorf("Request %d: expected customerUri 'verify-org', got '%s'", requestCount, r.Header.Get("customerUri"))
|
||||||
|
}
|
||||||
|
if r.Header.Get("login") != "verify-user" {
|
||||||
|
t.Errorf("Request %d: expected login 'verify-user', got '%s'", requestCount, r.Header.Get("login"))
|
||||||
|
}
|
||||||
|
if r.Header.Get("password") != "verify-pass" {
|
||||||
|
t.Errorf("Request %d: expected password 'verify-pass', got '%s'", requestCount, r.Header.Get("password"))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/ssl/v1/enroll":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"sslId":55050}`))
|
||||||
|
case "/ssl/v1/55050":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"sslId":55050,"status":"Applied"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := §igo.Config{
|
||||||
|
CustomerURI: "verify-org",
|
||||||
|
Login: "verify-user",
|
||||||
|
Password: "verify-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
CertType: 423,
|
||||||
|
Term: 365,
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := sectigo.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "auth-check.example.com")
|
||||||
|
req := issuer.IssuanceRequest{
|
||||||
|
CommonName: "auth-check.example.com",
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := connector.IssueCertificate(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestCount < 2 {
|
||||||
|
t.Errorf("Expected at least 2 requests (enroll + status), got %d", requestCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RevocationReasonMapping", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"keyCompromise", "Compromised"},
|
||||||
|
{"cessationOfOperation", "Cessation of Operation"},
|
||||||
|
{"affiliationChanged", "Affiliation Changed"},
|
||||||
|
{"superseded", "Superseded"},
|
||||||
|
{"unspecified", "Unspecified"},
|
||||||
|
{"unknown_reason", "Unspecified"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
var receivedReason string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/ssl/v1/revoke/") {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var req map[string]interface{}
|
||||||
|
json.Unmarshal(body, &req)
|
||||||
|
receivedReason = req["reason"].(string)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := §igo.Config{
|
||||||
|
CustomerURI: "test-org",
|
||||||
|
Login: "api-user",
|
||||||
|
Password: "api-pass",
|
||||||
|
OrgID: 12345,
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := sectigo.New(config, logger)
|
||||||
|
|
||||||
|
reason := tt.input
|
||||||
|
err := connector.RevokeCertificate(ctx, issuer.RevocationRequest{
|
||||||
|
Serial: "12345",
|
||||||
|
Reason: &reason,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if receivedReason != tt.expected {
|
||||||
|
t.Errorf("Expected reason '%s', got '%s'", tt.expected, receivedReason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestCert creates a self-signed test certificate and returns the PEM strings.
|
||||||
|
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: fmt.Sprintf("Test Certificate %s", serial.String()[:8]),
|
||||||
|
},
|
||||||
|
DNSNames: []string{"test.example.com"},
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create certificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
|
||||||
|
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||||
|
|
||||||
|
return certPEM, keyPEM
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestCSR creates a test CSR for the given common name.
|
||||||
|
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csrTemplate := x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: commonName,
|
||||||
|
},
|
||||||
|
DNSNames: []string{commonName},
|
||||||
|
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||||
|
}
|
||||||
|
|
||||||
|
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create CSR: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE REQUEST",
|
||||||
|
Bytes: csrBytes,
|
||||||
|
}))
|
||||||
|
|
||||||
|
csr, err := x509.ParseCertificateRequest(csrBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse CSR: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return csr, csrPEM
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package postfix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
|
"github.com/shankar0123/certctl/internal/validation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the Postfix/Dovecot deployment target configuration.
|
||||||
|
// This connector supports dual-mode operation: "postfix" for Postfix MTA
|
||||||
|
// and "dovecot" for Dovecot IMAP/POP3. The mode determines default file
|
||||||
|
// paths and reload commands. Both modes write cert/key/chain files and
|
||||||
|
// reload the mail service.
|
||||||
|
type Config struct {
|
||||||
|
Mode string `json:"mode"` // "postfix" (default) or "dovecot"
|
||||||
|
CertPath string `json:"cert_path"` // Path where cert will be written
|
||||||
|
KeyPath string `json:"key_path"` // Path where private key will be written
|
||||||
|
ChainPath string `json:"chain_path"` // Path where CA chain will be written (optional — if empty, chain appended to cert)
|
||||||
|
ReloadCommand string `json:"reload_command"` // Command to reload service
|
||||||
|
ValidateCommand string `json:"validate_command"` // Optional command to validate config before reload
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector implements the target.Connector interface for Postfix and Dovecot
|
||||||
|
// mail servers. This connector runs on the AGENT side and handles local
|
||||||
|
// certificate deployment for mail server TLS (STARTTLS, SMTPS, IMAPS, POP3S).
|
||||||
|
type Connector struct {
|
||||||
|
config *Config
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Postfix/Dovecot target connector with the given configuration and logger.
|
||||||
|
func New(config *Config, logger *slog.Logger) *Connector {
|
||||||
|
return &Connector{
|
||||||
|
config: config,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDefaults sets mode-specific default values for any unconfigured fields.
|
||||||
|
func applyDefaults(cfg *Config) {
|
||||||
|
if cfg.Mode == "" {
|
||||||
|
cfg.Mode = "postfix"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cfg.Mode {
|
||||||
|
case "dovecot":
|
||||||
|
if cfg.CertPath == "" {
|
||||||
|
cfg.CertPath = "/etc/dovecot/certs/cert.pem"
|
||||||
|
}
|
||||||
|
if cfg.KeyPath == "" {
|
||||||
|
cfg.KeyPath = "/etc/dovecot/certs/key.pem"
|
||||||
|
}
|
||||||
|
if cfg.ReloadCommand == "" {
|
||||||
|
cfg.ReloadCommand = "doveadm reload"
|
||||||
|
}
|
||||||
|
if cfg.ValidateCommand == "" {
|
||||||
|
cfg.ValidateCommand = "doveconf -n"
|
||||||
|
}
|
||||||
|
default: // "postfix"
|
||||||
|
if cfg.CertPath == "" {
|
||||||
|
cfg.CertPath = "/etc/postfix/certs/cert.pem"
|
||||||
|
}
|
||||||
|
if cfg.KeyPath == "" {
|
||||||
|
cfg.KeyPath = "/etc/postfix/certs/key.pem"
|
||||||
|
}
|
||||||
|
if cfg.ReloadCommand == "" {
|
||||||
|
cfg.ReloadCommand = "postfix reload"
|
||||||
|
}
|
||||||
|
if cfg.ValidateCommand == "" {
|
||||||
|
cfg.ValidateCommand = "postfix check"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfig checks that the configuration is valid for the selected mode.
|
||||||
|
// It applies mode-specific defaults, validates shell commands against injection,
|
||||||
|
// and verifies the certificate directory exists.
|
||||||
|
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||||
|
return fmt.Errorf("invalid mail server config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate mode
|
||||||
|
if cfg.Mode != "" && cfg.Mode != "postfix" && cfg.Mode != "dovecot" {
|
||||||
|
return fmt.Errorf("invalid mode %q: must be \"postfix\" or \"dovecot\"", cfg.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply mode-specific defaults
|
||||||
|
applyDefaults(&cfg)
|
||||||
|
|
||||||
|
// Validate commands to prevent injection attacks
|
||||||
|
if err := validation.ValidateShellCommand(cfg.ReloadCommand); err != nil {
|
||||||
|
return fmt.Errorf("invalid reload_command: %w", err)
|
||||||
|
}
|
||||||
|
if cfg.ValidateCommand != "" {
|
||||||
|
if err := validation.ValidateShellCommand(cfg.ValidateCommand); err != nil {
|
||||||
|
return fmt.Errorf("invalid validate_command: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("validating mail server configuration",
|
||||||
|
"mode", cfg.Mode,
|
||||||
|
"cert_path", cfg.CertPath,
|
||||||
|
"key_path", cfg.KeyPath,
|
||||||
|
"chain_path", cfg.ChainPath)
|
||||||
|
|
||||||
|
// Verify certificate directory exists
|
||||||
|
certDir := filepath.Dir(cfg.CertPath)
|
||||||
|
if _, err := os.Stat(certDir); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("%s cert directory does not exist: %s", cfg.Mode, certDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify validate command works (best-effort — service might not be installed yet)
|
||||||
|
if cfg.ValidateCommand != "" {
|
||||||
|
cmd := exec.CommandContext(ctx, "sh", "-c", cfg.ValidateCommand)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
c.logger.Warn("config validation command failed during config check",
|
||||||
|
"error", err,
|
||||||
|
"mode", cfg.Mode,
|
||||||
|
"validate_command", cfg.ValidateCommand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.config = &cfg
|
||||||
|
c.logger.Info("mail server configuration validated", "mode", cfg.Mode)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployCertificate writes the certificate, key, and chain to the configured paths
|
||||||
|
// and reloads the mail service to pick up the new certificates.
|
||||||
|
//
|
||||||
|
// Steps:
|
||||||
|
// 1. Write certificate to cert_path with mode 0644 (if chain_path empty, append chain)
|
||||||
|
// 2. Write private key to key_path with mode 0600
|
||||||
|
// 3. If chain_path is set, write chain separately with mode 0644
|
||||||
|
// 4. Validate configuration (if validate_command is set)
|
||||||
|
// 5. Reload service
|
||||||
|
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||||
|
c.logger.Info("deploying certificate to mail server",
|
||||||
|
"mode", c.config.Mode,
|
||||||
|
"cert_path", c.config.CertPath,
|
||||||
|
"key_path", c.config.KeyPath)
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Build certificate data: if chain_path is set, write chain separately;
|
||||||
|
// otherwise append chain to cert file (fullchain behavior)
|
||||||
|
certData := request.CertPEM
|
||||||
|
if request.ChainPEM != "" && c.config.ChainPath == "" {
|
||||||
|
certData += "\n" + request.ChainPEM
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write certificate with mode 0644 (rw-r--r--)
|
||||||
|
if err := os.WriteFile(c.config.CertPath, []byte(certData), 0644); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to write certificate: %v", err)
|
||||||
|
c.logger.Error("certificate deployment failed", "error", err)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: c.config.CertPath,
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write private key with secure permissions (0600: rw-------)
|
||||||
|
if c.config.KeyPath != "" && request.KeyPEM != "" {
|
||||||
|
if err := os.WriteFile(c.config.KeyPath, []byte(request.KeyPEM), 0600); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to write private key: %v", err)
|
||||||
|
c.logger.Error("key deployment failed", "error", err)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: c.config.KeyPath,
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
c.logger.Info("private key written", "key_path", c.config.KeyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write chain separately if chain_path is configured
|
||||||
|
if c.config.ChainPath != "" && request.ChainPEM != "" {
|
||||||
|
if err := os.WriteFile(c.config.ChainPath, []byte(request.ChainPEM), 0644); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to write chain: %v", err)
|
||||||
|
c.logger.Error("chain deployment failed", "error", err)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: c.config.ChainPath,
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate configuration before reload
|
||||||
|
if c.config.ValidateCommand != "" {
|
||||||
|
c.logger.Debug("validating configuration", "validate_command", c.config.ValidateCommand)
|
||||||
|
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
|
||||||
|
if output, err := validateCmd.CombinedOutput(); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("%s config validation failed: %v (output: %s)", c.config.Mode, err, string(output))
|
||||||
|
c.logger.Error("config validation failed", "error", err, "output", string(output))
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: c.config.CertPath,
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload service
|
||||||
|
c.logger.Debug("reloading service", "reload_command", c.config.ReloadCommand)
|
||||||
|
reloadCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ReloadCommand)
|
||||||
|
if output, err := reloadCmd.CombinedOutput(); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("%s reload failed: %v (output: %s)", c.config.Mode, err, string(output))
|
||||||
|
c.logger.Error("service reload failed", "error", err, "output", string(output))
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: c.config.CertPath,
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
deploymentDuration := time.Since(startTime)
|
||||||
|
c.logger.Info("certificate deployed to mail server successfully",
|
||||||
|
"mode", c.config.Mode,
|
||||||
|
"duration", deploymentDuration.String(),
|
||||||
|
"cert_path", c.config.CertPath)
|
||||||
|
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: true,
|
||||||
|
TargetAddress: c.config.CertPath,
|
||||||
|
DeploymentID: fmt.Sprintf("%s-%d", c.config.Mode, time.Now().Unix()),
|
||||||
|
Message: fmt.Sprintf("Certificate deployed and %s reloaded successfully", c.config.Mode),
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"cert_path": c.config.CertPath,
|
||||||
|
"key_path": c.config.KeyPath,
|
||||||
|
"mode": c.config.Mode,
|
||||||
|
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDeployment verifies that the deployed certificate is valid and accessible.
|
||||||
|
// It runs the validate command (if configured) and checks that the cert file exists.
|
||||||
|
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||||
|
c.logger.Info("validating mail server deployment",
|
||||||
|
"mode", c.config.Mode,
|
||||||
|
"certificate_id", request.CertificateID,
|
||||||
|
"serial", request.Serial)
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Validate configuration if validate command is set
|
||||||
|
if c.config.ValidateCommand != "" {
|
||||||
|
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
|
||||||
|
if output, err := validateCmd.CombinedOutput(); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("%s config validation failed: %v (output: %s)", c.config.Mode, err, string(output))
|
||||||
|
c.logger.Error("validation failed", "error", err)
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Serial: request.Serial,
|
||||||
|
TargetAddress: c.config.CertPath,
|
||||||
|
Message: errMsg,
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify certificate file exists and is readable
|
||||||
|
if _, err := os.Stat(c.config.CertPath); os.IsNotExist(err) {
|
||||||
|
errMsg := fmt.Sprintf("certificate file not found: %s", c.config.CertPath)
|
||||||
|
c.logger.Error("validation failed", "error", err)
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Serial: request.Serial,
|
||||||
|
TargetAddress: c.config.CertPath,
|
||||||
|
Message: errMsg,
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
validationDuration := time.Since(startTime)
|
||||||
|
c.logger.Info("mail server deployment validated successfully",
|
||||||
|
"mode", c.config.Mode,
|
||||||
|
"duration", validationDuration.String())
|
||||||
|
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: true,
|
||||||
|
Serial: request.Serial,
|
||||||
|
TargetAddress: c.config.CertPath,
|
||||||
|
Message: fmt.Sprintf("%s configuration valid and certificate accessible", c.config.Mode),
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"mode": c.config.Mode,
|
||||||
|
"validate_command": c.config.ValidateCommand,
|
||||||
|
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
package postfix_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target/postfix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Config Validation Tests ---
|
||||||
|
|
||||||
|
func TestPostfixConnector_ValidateConfig_Success(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := postfix.Config{
|
||||||
|
Mode: "postfix",
|
||||||
|
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||||
|
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||||
|
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||||
|
ReloadCommand: "true",
|
||||||
|
ValidateCommand: "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(&cfg, logger)
|
||||||
|
rawConfig, _ := json.Marshal(cfg)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostfixConnector_ValidateConfig_DovecotMode(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := postfix.Config{
|
||||||
|
Mode: "dovecot",
|
||||||
|
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||||
|
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||||
|
ReloadCommand: "true",
|
||||||
|
ValidateCommand: "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(&cfg, logger)
|
||||||
|
rawConfig, _ := json.Marshal(cfg)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateConfig for dovecot mode failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostfixConnector_ValidateConfig_InvalidJSON(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
connector := postfix.New(&postfix.Config{}, logger)
|
||||||
|
err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostfixConnector_ValidateConfig_InvalidMode(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cfg := postfix.Config{
|
||||||
|
Mode: "nginx",
|
||||||
|
CertPath: "/tmp/cert.pem",
|
||||||
|
ReloadCommand: "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(&cfg, logger)
|
||||||
|
rawConfig, _ := json.Marshal(cfg)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid mode")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid mode") {
|
||||||
|
t.Fatalf("expected 'invalid mode' error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostfixConnector_ValidateConfig_DirectoryNotExists(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cfg := postfix.Config{
|
||||||
|
Mode: "postfix",
|
||||||
|
CertPath: "/nonexistent/directory/cert.pem",
|
||||||
|
KeyPath: "/nonexistent/directory/key.pem",
|
||||||
|
ReloadCommand: "true",
|
||||||
|
ValidateCommand: "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(&cfg, logger)
|
||||||
|
rawConfig, _ := json.Marshal(cfg)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-existent cert directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostfixConnector_ValidateConfig_MissingCertPath(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// An empty config with mode=postfix will get defaults applied.
|
||||||
|
// The defaults point to /etc/postfix/certs/ which won't exist in test,
|
||||||
|
// so this will fail at directory check — which is fine; it validates that
|
||||||
|
// defaults are applied and path validation catches missing dirs.
|
||||||
|
cfg := postfix.Config{
|
||||||
|
Mode: "postfix",
|
||||||
|
ReloadCommand: "true",
|
||||||
|
ValidateCommand: "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(&cfg, logger)
|
||||||
|
rawConfig, _ := json.Marshal(cfg)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when default cert directory doesn't exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostfixConnector_ValidateConfig_DefaultsApplied(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a directory matching the postfix default path structure
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
certDir := filepath.Join(tmpDir, "postfix", "certs")
|
||||||
|
os.MkdirAll(certDir, 0755)
|
||||||
|
|
||||||
|
cfg := postfix.Config{
|
||||||
|
Mode: "postfix",
|
||||||
|
CertPath: filepath.Join(certDir, "cert.pem"),
|
||||||
|
KeyPath: filepath.Join(certDir, "key.pem"),
|
||||||
|
// Leave ReloadCommand and ValidateCommand empty to get defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(&cfg, logger)
|
||||||
|
rawConfig, _ := json.Marshal(cfg)
|
||||||
|
|
||||||
|
// Defaults will be applied for reload/validate commands.
|
||||||
|
// The validate command will be "postfix check" which won't exist in test env
|
||||||
|
// but ValidateConfig only warns on validate command failure (doesn't error).
|
||||||
|
// The reload command "postfix reload" will be validated by ValidateShellCommand.
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateConfig with defaults failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Deployment Tests ---
|
||||||
|
|
||||||
|
func TestPostfixConnector_DeployCertificate_Success(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &postfix.Config{
|
||||||
|
Mode: "postfix",
|
||||||
|
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||||
|
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||||
|
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||||
|
ReloadCommand: "true",
|
||||||
|
ValidateCommand: "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(cfg, logger)
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
|
||||||
|
KeyPEM: "-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----",
|
||||||
|
ChainPEM: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.DeployCertificate(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeployCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Success {
|
||||||
|
t.Fatalf("expected success, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify cert file was written (just cert, not chain — since chain_path is set)
|
||||||
|
certData, err := os.ReadFile(cfg.CertPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read cert file: %v", err)
|
||||||
|
}
|
||||||
|
if string(certData) != req.CertPEM {
|
||||||
|
t.Errorf("cert content mismatch: got %q", string(certData))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify key file was written
|
||||||
|
keyData, err := os.ReadFile(cfg.KeyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read key file: %v", err)
|
||||||
|
}
|
||||||
|
if string(keyData) != req.KeyPEM {
|
||||||
|
t.Errorf("key content mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify chain file was written
|
||||||
|
chainData, err := os.ReadFile(cfg.ChainPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read chain file: %v", err)
|
||||||
|
}
|
||||||
|
if string(chainData) != req.ChainPEM {
|
||||||
|
t.Errorf("chain content mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify cert has correct permissions (0644)
|
||||||
|
info, err := os.Stat(cfg.CertPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to stat cert file: %v", err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0644 {
|
||||||
|
t.Errorf("expected cert permissions 0644, got %v", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify key has correct permissions (0600)
|
||||||
|
info, err = os.Stat(cfg.KeyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to stat key file: %v", err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0600 {
|
||||||
|
t.Errorf("expected key permissions 0600, got %v", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify metadata
|
||||||
|
if result.Metadata == nil {
|
||||||
|
t.Fatal("expected metadata in result")
|
||||||
|
}
|
||||||
|
if result.Metadata["cert_path"] != cfg.CertPath {
|
||||||
|
t.Errorf("expected cert_path in metadata")
|
||||||
|
}
|
||||||
|
if result.Metadata["mode"] != "postfix" {
|
||||||
|
t.Errorf("expected mode=postfix in metadata, got %s", result.Metadata["mode"])
|
||||||
|
}
|
||||||
|
if _, ok := result.Metadata["duration_ms"]; !ok {
|
||||||
|
t.Errorf("expected duration_ms in metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostfixConnector_DeployCertificate_ChainAppendedToCert(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &postfix.Config{
|
||||||
|
Mode: "postfix",
|
||||||
|
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||||
|
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||||
|
ChainPath: "", // No chain_path — chain should be appended to cert
|
||||||
|
ReloadCommand: "true",
|
||||||
|
ValidateCommand: "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(cfg, logger)
|
||||||
|
|
||||||
|
certPEM := "-----BEGIN CERTIFICATE-----\ncert\n-----END CERTIFICATE-----"
|
||||||
|
chainPEM := "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----"
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
KeyPEM: "-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----",
|
||||||
|
ChainPEM: chainPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.DeployCertificate(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeployCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Success {
|
||||||
|
t.Fatalf("expected success, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify cert file contains both cert and chain (fullchain)
|
||||||
|
certData, err := os.ReadFile(cfg.CertPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read cert file: %v", err)
|
||||||
|
}
|
||||||
|
expected := certPEM + "\n" + chainPEM
|
||||||
|
if string(certData) != expected {
|
||||||
|
t.Errorf("expected fullchain content, got: %q", string(certData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostfixConnector_DeployCertificate_CertWriteFail(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cfg := &postfix.Config{
|
||||||
|
Mode: "postfix",
|
||||||
|
CertPath: "/nonexistent/directory/cert.pem",
|
||||||
|
KeyPath: "/nonexistent/directory/key.pem",
|
||||||
|
ReloadCommand: "true",
|
||||||
|
ValidateCommand: "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(cfg, logger)
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "cert",
|
||||||
|
ChainPEM: "chain",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.DeployCertificate(ctx, req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when cert write fails")
|
||||||
|
}
|
||||||
|
if result.Success {
|
||||||
|
t.Fatal("expected failure result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostfixConnector_DeployCertificate_ValidateCommandFails(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &postfix.Config{
|
||||||
|
Mode: "postfix",
|
||||||
|
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||||
|
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||||
|
ReloadCommand: "true",
|
||||||
|
ValidateCommand: "false", // Exits with code 1
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(cfg, logger)
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "cert",
|
||||||
|
ChainPEM: "chain",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.DeployCertificate(ctx, req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when validate command fails")
|
||||||
|
}
|
||||||
|
if result.Success {
|
||||||
|
t.Fatal("expected failure result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostfixConnector_DeployCertificate_ReloadCommandFails(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &postfix.Config{
|
||||||
|
Mode: "postfix",
|
||||||
|
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||||
|
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||||
|
ReloadCommand: "false", // Exits with code 1
|
||||||
|
ValidateCommand: "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(cfg, logger)
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "cert",
|
||||||
|
ChainPEM: "chain",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.DeployCertificate(ctx, req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when reload command fails")
|
||||||
|
}
|
||||||
|
if result.Success {
|
||||||
|
t.Fatal("expected failure result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validation Tests ---
|
||||||
|
|
||||||
|
func TestPostfixConnector_ValidateDeployment_Success(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
certPath := filepath.Join(tmpDir, "cert.pem")
|
||||||
|
os.WriteFile(certPath, []byte("cert"), 0644)
|
||||||
|
|
||||||
|
cfg := &postfix.Config{
|
||||||
|
Mode: "postfix",
|
||||||
|
CertPath: certPath,
|
||||||
|
ValidateCommand: "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(cfg, logger)
|
||||||
|
|
||||||
|
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
|
||||||
|
CertificateID: "mc-test",
|
||||||
|
Serial: "123",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateDeployment failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Valid {
|
||||||
|
t.Fatal("expected valid deployment")
|
||||||
|
}
|
||||||
|
if result.Metadata == nil {
|
||||||
|
t.Fatal("expected metadata in result")
|
||||||
|
}
|
||||||
|
if result.Metadata["mode"] != "postfix" {
|
||||||
|
t.Errorf("expected mode=postfix in metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostfixConnector_ValidateDeployment_CertNotFound(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cfg := &postfix.Config{
|
||||||
|
Mode: "postfix",
|
||||||
|
CertPath: "/nonexistent/cert.pem",
|
||||||
|
ValidateCommand: "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(cfg, logger)
|
||||||
|
|
||||||
|
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
|
||||||
|
CertificateID: "mc-test",
|
||||||
|
Serial: "123",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing cert file")
|
||||||
|
}
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatal("expected invalid result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Security Tests (Command Injection Prevention) ---
|
||||||
|
|
||||||
|
func TestPostfixConnector_ValidateConfig_RejectCommandInjectionSemicolon(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := postfix.Config{
|
||||||
|
Mode: "postfix",
|
||||||
|
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||||
|
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||||
|
ReloadCommand: "postfix reload; rm -rf /",
|
||||||
|
ValidateCommand: "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(&cfg, logger)
|
||||||
|
rawConfig, _ := json.Marshal(cfg)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for command injection in reload_command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostfixConnector_ValidateConfig_RejectCommandInjectionPipe(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := postfix.Config{
|
||||||
|
Mode: "postfix",
|
||||||
|
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||||
|
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||||
|
ReloadCommand: "true",
|
||||||
|
ValidateCommand: "postfix check | cat /etc/passwd",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(&cfg, logger)
|
||||||
|
rawConfig, _ := json.Marshal(cfg)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for command injection in validate_command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostfixConnector_ValidateConfig_RejectCommandSubstitution(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := postfix.Config{
|
||||||
|
Mode: "postfix",
|
||||||
|
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||||
|
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||||
|
ReloadCommand: "echo $(whoami)",
|
||||||
|
ValidateCommand: "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(&cfg, logger)
|
||||||
|
rawConfig, _ := json.Marshal(cfg)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for command substitution in reload_command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostfixConnector_ValidateConfig_RejectBackticks(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := postfix.Config{
|
||||||
|
Mode: "postfix",
|
||||||
|
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||||
|
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||||
|
ReloadCommand: "true",
|
||||||
|
ValidateCommand: "postfix check `whoami`",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := postfix.New(&cfg, logger)
|
||||||
|
rawConfig, _ := json.Marshal(cfg)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for backtick injection in validate_command")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,8 @@ const (
|
|||||||
IssuerTypeOpenSSL IssuerType = "OpenSSL"
|
IssuerTypeOpenSSL IssuerType = "OpenSSL"
|
||||||
IssuerTypeVault IssuerType = "VaultPKI"
|
IssuerTypeVault IssuerType = "VaultPKI"
|
||||||
IssuerTypeDigiCert IssuerType = "DigiCert"
|
IssuerTypeDigiCert IssuerType = "DigiCert"
|
||||||
|
IssuerTypeSectigo IssuerType = "Sectigo"
|
||||||
|
IssuerTypeGoogleCAS IssuerType = "GoogleCAS"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TargetType represents the type of deployment target.
|
// TargetType represents the type of deployment target.
|
||||||
@@ -85,4 +87,6 @@ const (
|
|||||||
TargetTypeTraefik TargetType = "Traefik"
|
TargetTypeTraefik TargetType = "Traefik"
|
||||||
TargetTypeCaddy TargetType = "Caddy"
|
TargetTypeCaddy TargetType = "Caddy"
|
||||||
TargetTypeEnvoy TargetType = "Envoy"
|
TargetTypeEnvoy TargetType = "Envoy"
|
||||||
|
TargetTypePostfix TargetType = "Postfix"
|
||||||
|
TargetTypeDovecot TargetType = "Dovecot"
|
||||||
)
|
)
|
||||||
|
|||||||
+26
-24
@@ -39,46 +39,48 @@ ON CONFLICT (id) DO NOTHING;
|
|||||||
-- 3. Issuers
|
-- 3. Issuers
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) VALUES
|
INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) VALUES
|
||||||
('iss-local', 'Local Dev CA', 'local', '{"ca_common_name": "CertCtl Demo CA", "validity_days": 90}', true, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
|
('iss-local', 'Local Dev CA', 'GenericCA', '{"ca_common_name": "CertCtl Demo CA", "validity_days": 90}', true, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
|
||||||
('iss-acme-le', 'Let''s Encrypt Staging', 'acme', '{"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory", "email": "admin@example.com", "challenge_type": "http-01"}', true, NOW() - INTERVAL '150 days', NOW() - INTERVAL '150 days'),
|
('iss-acme-le', 'Let''s Encrypt Staging', 'ACME', '{"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory", "email": "admin@example.com", "challenge_type": "http-01"}', true, NOW() - INTERVAL '150 days', NOW() - INTERVAL '150 days'),
|
||||||
('iss-stepca', 'step-ca Internal', 'stepca', '{"ca_url": "https://ca.internal:9000", "provisioner_name": "certctl", "validity_days": 90}', true, NOW() - INTERVAL '120 days', NOW() - INTERVAL '120 days'),
|
('iss-stepca', 'step-ca Internal', 'StepCA', '{"ca_url": "https://ca.internal:9000", "provisioner_name": "certctl", "validity_days": 90}', true, NOW() - INTERVAL '120 days', NOW() - INTERVAL '120 days'),
|
||||||
('iss-acme-zs', 'ZeroSSL (EAB)', 'acme', '{"directory_url": "https://acme.zerossl.com/v2/DV90", "email": "admin@example.com", "challenge_type": "http-01"}', true, NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days'),
|
('iss-acme-zs', 'ZeroSSL (EAB)', 'ACME', '{"directory_url": "https://acme.zerossl.com/v2/DV90", "email": "admin@example.com", "challenge_type": "http-01"}', true, NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days'),
|
||||||
('iss-openssl', 'Custom OpenSSL CA', 'openssl', '{"sign_script": "/opt/ca/sign.sh", "timeout_seconds": 30}', false, NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'),
|
('iss-openssl', 'Custom OpenSSL CA', 'OpenSSL', '{"sign_script": "/opt/ca/sign.sh", "timeout_seconds": 30}', false, NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'),
|
||||||
('iss-vault', 'HashiCorp Vault PKI', 'VaultPKI', '{"addr": "https://vault.internal:8200", "mount": "pki", "role": "web-certs", "ttl": "8760h"}', true, NOW() - INTERVAL '20 days', NOW() - INTERVAL '20 days'),
|
('iss-vault', 'HashiCorp Vault PKI', 'VaultPKI', '{"addr": "https://vault.internal:8200", "mount": "pki", "role": "web-certs", "ttl": "8760h"}', true, NOW() - INTERVAL '20 days', NOW() - INTERVAL '20 days'),
|
||||||
('iss-digicert', 'DigiCert CertCentral', 'DigiCert', '{"base_url": "https://www.digicert.com/services/v2", "product_type": "ssl_basic"}', true, NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days')
|
('iss-digicert', 'DigiCert CertCentral', 'DigiCert', '{"base_url": "https://www.digicert.com/services/v2", "product_type": "ssl_basic"}', true, NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days'),
|
||||||
|
('iss-sectigo', 'Sectigo SCM', 'Sectigo', '{"base_url": "https://cert-manager.com/api", "cert_type": 423, "term": 365}', true, NOW() - INTERVAL '10 days', NOW() - INTERVAL '10 days'),
|
||||||
|
('iss-googlecas','Google CAS', 'GoogleCAS', '{"project": "demo-project", "location": "us-central1", "ca_pool": "demo-pool"}', false, NOW() - INTERVAL '5 days', NOW() - INTERVAL '5 days')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 4. Agents (8 agents across multiple platforms)
|
-- 4. Agents (8 agents across multiple platforms)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash, os, architecture, ip_address, version) VALUES
|
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash, os, architecture, ip_address, version) VALUES
|
||||||
('ag-web-prod', 'web-prod-agent', 'web-prod-01.internal', 'online', NOW() - INTERVAL '30 seconds', NOW() - INTERVAL '120 days', 'demo_hash_1', 'linux', 'amd64', '10.0.1.10', '2.0.14'),
|
('ag-web-prod', 'web-prod-agent', 'web-prod-01.internal', 'Online', NOW() - INTERVAL '30 seconds', NOW() - INTERVAL '120 days', 'demo_hash_1', 'linux', 'amd64', '10.0.1.10', '2.0.14'),
|
||||||
('ag-web-staging', 'web-staging-agent', 'web-stg-01.internal', 'online', NOW() - INTERVAL '45 seconds', NOW() - INTERVAL '90 days', 'demo_hash_2', 'linux', 'amd64', '10.0.2.20', '2.0.14'),
|
('ag-web-staging', 'web-staging-agent', 'web-stg-01.internal', 'Online', NOW() - INTERVAL '45 seconds', NOW() - INTERVAL '90 days', 'demo_hash_2', 'linux', 'amd64', '10.0.2.20', '2.0.14'),
|
||||||
('ag-lb-prod', 'lb-prod-agent', 'lb-prod-01.internal', 'online', NOW() - INTERVAL '15 seconds', NOW() - INTERVAL '150 days', 'demo_hash_3', 'linux', 'amd64', '10.0.1.50', '2.0.14'),
|
('ag-lb-prod', 'lb-prod-agent', 'lb-prod-01.internal', 'Online', NOW() - INTERVAL '15 seconds', NOW() - INTERVAL '150 days', 'demo_hash_3', 'linux', 'amd64', '10.0.1.50', '2.0.14'),
|
||||||
('ag-iis-prod', 'iis-prod-agent', 'iis-prod-01.internal', 'offline', NOW() - INTERVAL '3 hours', NOW() - INTERVAL '60 days', 'demo_hash_4', 'windows', 'amd64', '10.0.3.15', '2.0.12'),
|
('ag-iis-prod', 'iis-prod-agent', 'iis-prod-01.internal', 'Offline', NOW() - INTERVAL '3 hours', NOW() - INTERVAL '60 days', 'demo_hash_4', 'windows', 'amd64', '10.0.3.15', '2.0.12'),
|
||||||
('ag-data-prod', 'data-prod-agent', 'data-prod-01.internal', 'online', NOW() - INTERVAL '20 seconds', NOW() - INTERVAL '90 days', 'demo_hash_5', 'linux', 'arm64', '10.0.4.30', '2.0.14'),
|
('ag-data-prod', 'data-prod-agent', 'data-prod-01.internal', 'Online', NOW() - INTERVAL '20 seconds', NOW() - INTERVAL '90 days', 'demo_hash_5', 'linux', 'arm64', '10.0.4.30', '2.0.14'),
|
||||||
('ag-edge-01', 'edge-eu-agent', 'edge-eu-01.internal', 'online', NOW() - INTERVAL '50 seconds', NOW() - INTERVAL '45 days', 'demo_hash_6', 'linux', 'arm64', '10.0.5.10', '2.0.14'),
|
('ag-edge-01', 'edge-eu-agent', 'edge-eu-01.internal', 'Online', NOW() - INTERVAL '50 seconds', NOW() - INTERVAL '45 days', 'demo_hash_6', 'linux', 'arm64', '10.0.5.10', '2.0.14'),
|
||||||
('ag-k8s-prod', 'k8s-prod-agent', 'k8s-node-01.internal', 'online', NOW() - INTERVAL '10 seconds', NOW() - INTERVAL '30 days', 'demo_hash_7', 'linux', 'amd64', '10.0.6.10', '2.0.14'),
|
('ag-k8s-prod', 'k8s-prod-agent', 'k8s-node-01.internal', 'Online', NOW() - INTERVAL '10 seconds', NOW() - INTERVAL '30 days', 'demo_hash_7', 'linux', 'amd64', '10.0.6.10', '2.0.14'),
|
||||||
('ag-mac-dev', 'mac-dev-agent', 'dev-mac-01.internal', 'online', NOW() - INTERVAL '60 seconds', NOW() - INTERVAL '15 days', 'demo_hash_8', 'darwin', 'arm64', '10.0.7.5', '2.0.14')
|
('ag-mac-dev', 'mac-dev-agent', 'dev-mac-01.internal', 'Online', NOW() - INTERVAL '60 seconds', NOW() - INTERVAL '15 days', 'demo_hash_8', 'darwin', 'arm64', '10.0.7.5', '2.0.14')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- Sentinel agent for network-discovered certificates
|
-- Sentinel agent for network-discovered certificates
|
||||||
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash, os, architecture, ip_address, version) VALUES
|
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash, os, architecture, ip_address, version) VALUES
|
||||||
('server-scanner', 'Network Scanner (Server-Side)', 'certctl-server', 'online', NOW(), NOW() - INTERVAL '90 days', 'sentinel_no_auth', 'linux', 'amd64', '127.0.0.1', '2.0.14')
|
('server-scanner', 'Network Scanner (Server-Side)', 'certctl-server', 'Online', NOW(), NOW() - INTERVAL '90 days', 'sentinel_no_auth', 'linux', 'amd64', '127.0.0.1', '2.0.14')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 5. Deployment Targets (8 targets across multiple connector types)
|
-- 5. Deployment Targets (8 targets across multiple connector types)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
INSERT INTO deployment_targets (id, name, type, agent_id, config, enabled, created_at, updated_at) VALUES
|
INSERT INTO deployment_targets (id, name, type, agent_id, config, enabled, created_at, updated_at) VALUES
|
||||||
('tgt-nginx-prod', 'NGINX Production', 'nginx', 'ag-web-prod', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW() - INTERVAL '120 days', NOW()),
|
('tgt-nginx-prod', 'NGINX Production', 'NGINX', 'ag-web-prod', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW() - INTERVAL '120 days', NOW()),
|
||||||
('tgt-nginx-staging', 'NGINX Staging', 'nginx', 'ag-web-staging', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW() - INTERVAL '90 days', NOW()),
|
('tgt-nginx-staging', 'NGINX Staging', 'NGINX', 'ag-web-staging', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW() - INTERVAL '90 days', NOW()),
|
||||||
('tgt-haproxy-prod', 'HAProxy Production', 'haproxy', 'ag-lb-prod', '{"combined_pem_path": "/etc/haproxy/ssl/site.pem", "reload_command": "systemctl reload haproxy"}', true, NOW() - INTERVAL '150 days', NOW()),
|
('tgt-haproxy-prod', 'HAProxy Production', 'HAProxy', 'ag-lb-prod', '{"combined_pem_path": "/etc/haproxy/ssl/site.pem", "reload_command": "systemctl reload haproxy"}', true, NOW() - INTERVAL '150 days', NOW()),
|
||||||
('tgt-apache-prod', 'Apache Production', 'apache', 'ag-web-prod', '{"cert_path": "/etc/httpd/ssl/cert.pem", "key_path": "/etc/httpd/ssl/key.pem", "chain_path": "/etc/httpd/ssl/chain.pem", "reload_command": "apachectl graceful"}', true, NOW() - INTERVAL '100 days', NOW()),
|
('tgt-apache-prod', 'Apache Production', 'Apache', 'ag-web-prod', '{"cert_path": "/etc/httpd/ssl/cert.pem", "key_path": "/etc/httpd/ssl/key.pem", "chain_path": "/etc/httpd/ssl/chain.pem", "reload_command": "apachectl graceful"}', true, NOW() - INTERVAL '100 days', NOW()),
|
||||||
('tgt-iis-prod', 'IIS Production', 'iis', 'ag-iis-prod', '{"site_name": "Default Web Site", "binding_info": "*:443:"}', true, NOW() - INTERVAL '60 days', NOW()),
|
('tgt-iis-prod', 'IIS Production', 'IIS', 'ag-iis-prod', '{"site_name": "Default Web Site", "binding_info": "*:443:"}', true, NOW() - INTERVAL '60 days', NOW()),
|
||||||
('tgt-traefik-prod', 'Traefik Production', 'traefik', 'ag-k8s-prod', '{"watch_dir": "/etc/traefik/dynamic/certs"}', true, NOW() - INTERVAL '30 days', NOW()),
|
('tgt-traefik-prod', 'Traefik Production', 'Traefik', 'ag-k8s-prod', '{"watch_dir": "/etc/traefik/dynamic/certs"}', true, NOW() - INTERVAL '30 days', NOW()),
|
||||||
('tgt-caddy-prod', 'Caddy Production', 'caddy', 'ag-edge-01', '{"mode": "api", "admin_url": "http://localhost:2019"}', true, NOW() - INTERVAL '45 days', NOW()),
|
('tgt-caddy-prod', 'Caddy Production', 'Caddy', 'ag-edge-01', '{"mode": "api", "admin_url": "http://localhost:2019"}', true, NOW() - INTERVAL '45 days', NOW()),
|
||||||
('tgt-nginx-data', 'NGINX Data Services', 'nginx', 'ag-data-prod', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW() - INTERVAL '90 days', NOW())
|
('tgt-nginx-data', 'NGINX Data Services', 'NGINX', 'ag-data-prod', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW() - INTERVAL '90 days', NOW())
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@@ -128,7 +130,7 @@ INSERT INTO certificate_profiles (id, name, description, allowed_key_algorithms,
|
|||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 7. Managed Certificates (35 certs across multiple issuers and environments)
|
-- 7. Managed Certificates (32 certs across multiple issuers and environments)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
INSERT INTO managed_certificates (id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id, status, expires_at, tags, last_renewal_at, last_deployment_at, created_at, updated_at) VALUES
|
INSERT INTO managed_certificates (id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id, status, expires_at, tags, last_renewal_at, last_deployment_at, created_at, updated_at) VALUES
|
||||||
-- ---- Active, healthy production certs (Local CA) ----
|
-- ---- Active, healthy production certs (Local CA) ----
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const typeLabels: Record<string, string> = {
|
|||||||
openssl: 'OpenSSL/Custom',
|
openssl: 'OpenSSL/Custom',
|
||||||
VaultPKI: 'Vault PKI',
|
VaultPKI: 'Vault PKI',
|
||||||
DigiCert: 'DigiCert',
|
DigiCert: 'DigiCert',
|
||||||
|
Sectigo: 'Sectigo SCM',
|
||||||
manual: 'Manual',
|
manual: 'Manual',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,12 +121,32 @@ export const issuerTypes: IssuerTypeConfig[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sectigo',
|
id: 'Sectigo',
|
||||||
name: 'Sectigo',
|
name: 'Sectigo SCM',
|
||||||
description: 'Sectigo Certificate Manager \u2014 coming soon',
|
description: 'Sectigo Certificate Manager for DV, OV, and EV certificates',
|
||||||
icon: '\uD83D\uDCE6',
|
icon: '\uD83D\uDD10',
|
||||||
configFields: [],
|
configFields: [
|
||||||
comingSoon: true,
|
{ key: 'customer_uri', label: 'Customer URI', required: true, placeholder: 'your-org-uri' },
|
||||||
|
{ key: 'login', label: 'API Login', required: true, placeholder: 'api-account-name' },
|
||||||
|
{ key: 'password', label: 'API Password', required: true, sensitive: true, type: 'password' },
|
||||||
|
{ key: 'org_id', label: 'Organization ID', required: true, placeholder: '12345', type: 'number' },
|
||||||
|
{ key: 'cert_type', label: 'Certificate Type ID', required: false, placeholder: '423', type: 'number' },
|
||||||
|
{ key: 'term', label: 'Validity (days)', required: false, placeholder: '365', type: 'number' },
|
||||||
|
{ key: 'base_url', label: 'Base URL', required: false, placeholder: 'https://cert-manager.com/api' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'GoogleCAS',
|
||||||
|
name: 'Google CAS',
|
||||||
|
description: 'Google Cloud Certificate Authority Service \u2014 managed private CA on GCP',
|
||||||
|
icon: '\u2601\uFE0F',
|
||||||
|
configFields: [
|
||||||
|
{ key: 'project', label: 'GCP Project ID', required: true, placeholder: 'my-gcp-project' },
|
||||||
|
{ key: 'location', label: 'Location', required: true, placeholder: 'us-central1' },
|
||||||
|
{ key: 'ca_pool', label: 'CA Pool', required: true, placeholder: 'my-ca-pool' },
|
||||||
|
{ key: 'credentials', label: 'Service Account JSON Path', required: true, placeholder: '/path/to/credentials.json', sensitive: true },
|
||||||
|
{ key: 'ttl', label: 'Default TTL', required: false, placeholder: '8760h' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'entrust',
|
id: 'entrust',
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const typeLabels: Record<string, string> = {
|
|||||||
traefik: 'Traefik',
|
traefik: 'Traefik',
|
||||||
caddy: 'Caddy',
|
caddy: 'Caddy',
|
||||||
envoy: 'Envoy',
|
envoy: 'Envoy',
|
||||||
|
postfix: 'Postfix',
|
||||||
|
dovecot: 'Dovecot',
|
||||||
f5_bigip: 'F5 BIG-IP',
|
f5_bigip: 'F5 BIG-IP',
|
||||||
iis: 'IIS',
|
iis: 'IIS',
|
||||||
};
|
};
|
||||||
@@ -28,6 +30,8 @@ const TARGET_TYPES = [
|
|||||||
{ value: 'traefik', label: 'Traefik', description: 'File provider deployment — writes cert/key to watched directory, auto-reload' },
|
{ value: 'traefik', label: 'Traefik', description: 'File provider deployment — writes cert/key to watched directory, auto-reload' },
|
||||||
{ value: 'caddy', label: 'Caddy', description: 'Admin API hot-reload or file-based deployment with configurable mode' },
|
{ value: 'caddy', label: 'Caddy', description: 'Admin API hot-reload or file-based deployment with configurable mode' },
|
||||||
{ value: 'envoy', label: 'Envoy', description: 'File-based deployment — writes cert/key to watched directory. Optional SDS file generation.' },
|
{ value: 'envoy', label: 'Envoy', description: 'File-based deployment — writes cert/key to watched directory. Optional SDS file generation.' },
|
||||||
|
{ value: 'postfix', label: 'Postfix', description: 'Postfix MTA — file write + postfix reload' },
|
||||||
|
{ value: 'dovecot', label: 'Dovecot', description: 'Dovecot IMAP/POP3 — file write + doveadm reload' },
|
||||||
{ value: 'f5_bigip', label: 'F5 BIG-IP', description: 'iControl REST via proxy agent (V3 implementation)' },
|
{ value: 'f5_bigip', label: 'F5 BIG-IP', description: 'iControl REST via proxy agent (V3 implementation)' },
|
||||||
{ value: 'iis', label: 'IIS', description: 'Windows IIS via agent-local PowerShell or remote WinRM proxy agent' },
|
{ value: 'iis', label: 'IIS', description: 'Windows IIS via agent-local PowerShell or remote WinRM proxy agent' },
|
||||||
];
|
];
|
||||||
@@ -69,6 +73,20 @@ const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: s
|
|||||||
{ key: 'chain_filename', label: 'Chain Filename (optional)', placeholder: 'chain.pem (leave empty to append to cert)' },
|
{ key: 'chain_filename', label: 'Chain Filename (optional)', placeholder: 'chain.pem (leave empty to append to cert)' },
|
||||||
{ key: 'sds_config', label: 'Generate SDS Config', placeholder: 'true or false' },
|
{ key: 'sds_config', label: 'Generate SDS Config', placeholder: 'true or false' },
|
||||||
],
|
],
|
||||||
|
postfix: [
|
||||||
|
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/postfix/certs/cert.pem' },
|
||||||
|
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/postfix/certs/key.pem' },
|
||||||
|
{ key: 'chain_path', label: 'Chain Path (optional)', placeholder: '/etc/postfix/certs/chain.pem' },
|
||||||
|
{ key: 'reload_command', label: 'Reload Command', placeholder: 'postfix reload' },
|
||||||
|
{ key: 'validate_command', label: 'Validate Command', placeholder: 'postfix check' },
|
||||||
|
],
|
||||||
|
dovecot: [
|
||||||
|
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/dovecot/certs/cert.pem' },
|
||||||
|
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/dovecot/certs/key.pem' },
|
||||||
|
{ key: 'chain_path', label: 'Chain Path (optional)', placeholder: '/etc/dovecot/certs/chain.pem' },
|
||||||
|
{ key: 'reload_command', label: 'Reload Command', placeholder: 'doveadm reload' },
|
||||||
|
{ key: 'validate_command', label: 'Validate Command', placeholder: 'doveconf -n' },
|
||||||
|
],
|
||||||
f5_bigip: [
|
f5_bigip: [
|
||||||
{ key: 'management_ip', label: 'Management IP', placeholder: '192.168.1.100', required: true },
|
{ key: 'management_ip', label: 'Management IP', placeholder: '192.168.1.100', required: true },
|
||||||
{ key: 'partition', label: 'Partition', placeholder: 'Common' },
|
{ key: 'partition', label: 'Partition', placeholder: 'Common' },
|
||||||
|
|||||||
Reference in New Issue
Block a user