mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 11:18:53 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25f33b830f | |||
| 7d6ef44e21 | |||
| dfa4dbbcbd | |||
| f92c997a50 | |||
| 697c0be9f3 | |||
| 8f146e08d6 | |||
| e6088c79a3 | |||
| e19b8c95fe | |||
| 995b72df05 | |||
| 9954fd1100 | |||
| 2a14a1da01 | |||
| 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 the full test suite 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, IIS (local PowerShell or remote WinRM), F5 BIG-IP (proxy agent), and any Linux/Unix server via SSH/SFTP — 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)
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ For a detailed comparison with CertKit, KeyTalk, and enterprise platforms, see [
|
|||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
- **Certificates renew and deploy themselves.** The scheduler monitors expiration, creates renewal jobs, issues certificates through your CA, and deploys them to target servers — all without human intervention. ACME ARI (RFC 9702) lets your CA tell certctl exactly when to renew.
|
- **Certificates renew and deploy themselves.** The scheduler monitors expiration, creates renewal jobs, issues certificates through your CA, and deploys them to target servers — all without human intervention. ACME ARI (RFC 9773) lets your CA tell certctl exactly when to renew. Ready for 45-day and 6-day certificate lifetimes (SC-081v3 and Let's Encrypt shortlived profiles).
|
||||||
|
|
||||||
- **You see everything in one place.** A 25-page operational dashboard shows every certificate across every server: status, ownership, expiration timeline, deployment history with TLS verification, discovery triage, and real-time agent fleet health. Bulk operations (renew, revoke, reassign) work across selections.
|
- **You see everything in one place.** A 25-page operational dashboard shows every certificate across every server: status, ownership, expiration timeline, deployment history with TLS verification, discovery triage, and real-time agent fleet health. Bulk operations (renew, revoke, reassign) work across selections.
|
||||||
|
|
||||||
@@ -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,8 +100,11 @@ 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 | Beta | `F5` |
|
||||||
|
| SSH (Agentless) | Beta | `SSH` |
|
||||||
|
|
||||||
### Notifiers
|
### Notifiers
|
||||||
| Notifier | Status | Type |
|
| Notifier | Status | Type |
|
||||||
@@ -209,18 +214,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 +295,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, extensively tested with CI-enforced coverage gates. 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 9773), 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, SSH, WinCertStore, JavaKeystore]
|
||||||
|
|
||||||
DeploymentTarget:
|
DeploymentTarget:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
+56
-1
@@ -30,7 +30,11 @@ 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"
|
||||||
|
sshconn "github.com/shankar0123/certctl/internal/connector/target/ssh"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
||||||
|
jks "github.com/shankar0123/certctl/internal/connector/target/javakeystore"
|
||||||
|
wcs "github.com/shankar0123/certctl/internal/connector/target/wincertstore"
|
||||||
"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"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/nginx"
|
"github.com/shankar0123/certctl/internal/connector/target/nginx"
|
||||||
@@ -584,7 +588,11 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess
|
|||||||
return nil, fmt.Errorf("invalid F5 config: %w", err)
|
return nil, fmt.Errorf("invalid F5 config: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return f5.New(&cfg, a.logger), nil
|
conn, err := f5.New(&cfg, a.logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create F5 connector: %w", err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
|
||||||
case "IIS":
|
case "IIS":
|
||||||
var cfg iis.Config
|
var cfg iis.Config
|
||||||
@@ -622,6 +630,53 @@ 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
|
||||||
|
|
||||||
|
case "SSH":
|
||||||
|
var cfg sshconn.Config
|
||||||
|
if len(configJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid SSH config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sshconn.New(&cfg, a.logger)
|
||||||
|
|
||||||
|
case "WinCertStore":
|
||||||
|
var cfg wcs.Config
|
||||||
|
if len(configJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid WinCertStore config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wcs.New(&cfg, a.logger)
|
||||||
|
|
||||||
|
case "JavaKeystore":
|
||||||
|
var cfg jks.Config
|
||||||
|
if len(configJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid JavaKeystore config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jks.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)
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-126
@@ -16,13 +16,8 @@ import (
|
|||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/api/router"
|
"github.com/shankar0123/certctl/internal/api/router"
|
||||||
"github.com/shankar0123/certctl/internal/config"
|
"github.com/shankar0123/certctl/internal/config"
|
||||||
|
"github.com/shankar0123/certctl/internal/crypto"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme"
|
|
||||||
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
|
||||||
digicertissuer "github.com/shankar0123/certctl/internal/connector/issuer/digicert"
|
|
||||||
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
|
|
||||||
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
|
||||||
vaultissuer "github.com/shankar0123/certctl/internal/connector/issuer/vault"
|
|
||||||
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
|
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
|
||||||
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
|
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
|
||||||
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
|
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
|
||||||
@@ -83,107 +78,18 @@ func main() {
|
|||||||
ownerRepo := postgres.NewOwnerRepository(db)
|
ownerRepo := postgres.NewOwnerRepository(db)
|
||||||
logger.Info("initialized all repositories")
|
logger.Info("initialized all repositories")
|
||||||
|
|
||||||
// Initialize Local CA issuer connector.
|
// Initialize dynamic issuer registry.
|
||||||
// In sub-CA mode (CERTCTL_CA_CERT_PATH + CERTCTL_CA_KEY_PATH set), loads a pre-signed
|
// Issuers are loaded from the database (with AES-GCM encrypted config).
|
||||||
// CA cert+key from disk. All issued certs chain to the upstream root (e.g., ADCS).
|
// On first boot with an empty database, env var issuers are seeded automatically.
|
||||||
// Otherwise, generates an ephemeral self-signed CA for development/demo.
|
var encryptionKey []byte
|
||||||
localCAConfig := &local.Config{}
|
if cfg.Encryption.ConfigEncryptionKey != "" {
|
||||||
if cfg.CA.CertPath != "" && cfg.CA.KeyPath != "" {
|
encryptionKey = crypto.DeriveKey(cfg.Encryption.ConfigEncryptionKey)
|
||||||
localCAConfig.CACertPath = cfg.CA.CertPath
|
logger.Info("config encryption enabled (AES-256-GCM)")
|
||||||
localCAConfig.CAKeyPath = cfg.CA.KeyPath
|
|
||||||
logger.Info("Local CA configured in sub-CA mode",
|
|
||||||
"cert_path", cfg.CA.CertPath,
|
|
||||||
"key_path", cfg.CA.KeyPath)
|
|
||||||
} else {
|
} else {
|
||||||
logger.Info("Local CA configured in self-signed mode (ephemeral)")
|
logger.Warn("CERTCTL_CONFIG_ENCRYPTION_KEY not set — issuer configs stored in plaintext (not recommended for production)")
|
||||||
}
|
|
||||||
localCA := local.New(localCAConfig, logger)
|
|
||||||
logger.Info("initialized Local CA issuer connector")
|
|
||||||
|
|
||||||
// Initialize ACME issuer connector (for Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, etc.)
|
|
||||||
// Supports HTTP-01 (default), DNS-01 (for wildcards), and DNS-PERSIST-01 (standing record) challenge types.
|
|
||||||
// EAB (External Account Binding) required by ZeroSSL, Google Trust Services, SSL.com.
|
|
||||||
acmeConnector := acmeissuer.New(&acmeissuer.Config{
|
|
||||||
DirectoryURL: os.Getenv("CERTCTL_ACME_DIRECTORY_URL"),
|
|
||||||
Email: os.Getenv("CERTCTL_ACME_EMAIL"),
|
|
||||||
EABKid: os.Getenv("CERTCTL_ACME_EAB_KID"),
|
|
||||||
EABHmac: os.Getenv("CERTCTL_ACME_EAB_HMAC"),
|
|
||||||
ChallengeType: os.Getenv("CERTCTL_ACME_CHALLENGE_TYPE"),
|
|
||||||
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"),
|
|
||||||
DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"),
|
|
||||||
DNSPersistIssuerDomain: os.Getenv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN"),
|
|
||||||
Insecure: cfg.ACME.Insecure,
|
|
||||||
}, logger)
|
|
||||||
logger.Info("initialized ACME issuer connector")
|
|
||||||
|
|
||||||
// Initialize step-ca issuer connector (for Smallstep private CA).
|
|
||||||
// Uses the native /sign API with JWK provisioner authentication.
|
|
||||||
stepcaConnector := stepcaissuer.New(&stepcaissuer.Config{
|
|
||||||
CAURL: os.Getenv("CERTCTL_STEPCA_URL"),
|
|
||||||
RootCertPath: os.Getenv("CERTCTL_STEPCA_ROOT_CERT"),
|
|
||||||
ProvisionerName: os.Getenv("CERTCTL_STEPCA_PROVISIONER"),
|
|
||||||
ProvisionerKeyPath: os.Getenv("CERTCTL_STEPCA_KEY_PATH"),
|
|
||||||
ProvisionerPassword: os.Getenv("CERTCTL_STEPCA_PASSWORD"),
|
|
||||||
}, logger)
|
|
||||||
logger.Info("initialized step-ca issuer connector")
|
|
||||||
|
|
||||||
// Initialize OpenSSL/Custom CA issuer connector (for script-based CA integrations).
|
|
||||||
// Delegates certificate signing to user-provided scripts.
|
|
||||||
opensslConnector := opensslissuer.New(&opensslissuer.Config{
|
|
||||||
SignScript: os.Getenv("CERTCTL_OPENSSL_SIGN_SCRIPT"),
|
|
||||||
RevokeScript: os.Getenv("CERTCTL_OPENSSL_REVOKE_SCRIPT"),
|
|
||||||
CRLScript: os.Getenv("CERTCTL_OPENSSL_CRL_SCRIPT"),
|
|
||||||
TimeoutSeconds: getEnvIntDefault(os.Getenv("CERTCTL_OPENSSL_TIMEOUT_SECONDS"), 30),
|
|
||||||
}, logger)
|
|
||||||
logger.Info("initialized OpenSSL/Custom CA issuer connector")
|
|
||||||
|
|
||||||
// Initialize Vault PKI issuer connector (for HashiCorp Vault internal PKI).
|
|
||||||
// Uses the Vault HTTP API with token authentication.
|
|
||||||
vaultConnector := vaultissuer.New(&vaultissuer.Config{
|
|
||||||
Addr: os.Getenv("CERTCTL_VAULT_ADDR"),
|
|
||||||
Token: os.Getenv("CERTCTL_VAULT_TOKEN"),
|
|
||||||
Mount: getEnvDefault("CERTCTL_VAULT_MOUNT", "pki"),
|
|
||||||
Role: os.Getenv("CERTCTL_VAULT_ROLE"),
|
|
||||||
TTL: getEnvDefault("CERTCTL_VAULT_TTL", "8760h"),
|
|
||||||
}, logger)
|
|
||||||
logger.Info("initialized Vault PKI issuer connector")
|
|
||||||
|
|
||||||
// Initialize DigiCert CertCentral issuer connector (for enterprise public CA).
|
|
||||||
// Uses the DigiCert REST API with async order model.
|
|
||||||
digicertConnector := digicertissuer.New(&digicertissuer.Config{
|
|
||||||
APIKey: os.Getenv("CERTCTL_DIGICERT_API_KEY"),
|
|
||||||
OrgID: os.Getenv("CERTCTL_DIGICERT_ORG_ID"),
|
|
||||||
ProductType: getEnvDefault("CERTCTL_DIGICERT_PRODUCT_TYPE", "ssl_basic"),
|
|
||||||
BaseURL: getEnvDefault("CERTCTL_DIGICERT_BASE_URL", "https://www.digicert.com/services/v2"),
|
|
||||||
}, logger)
|
|
||||||
logger.Info("initialized DigiCert CertCentral issuer connector")
|
|
||||||
|
|
||||||
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
|
|
||||||
// "iss-local" matches the seed data issuer ID for the Local CA.
|
|
||||||
// "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers.
|
|
||||||
// "iss-stepca" is the step-ca private CA connector.
|
|
||||||
// "iss-openssl" is the custom CA/OpenSSL connector.
|
|
||||||
issuerRegistry := map[string]service.IssuerConnector{
|
|
||||||
"iss-local": service.NewIssuerConnectorAdapter(localCA),
|
|
||||||
"iss-acme-staging": service.NewIssuerConnectorAdapter(acmeConnector),
|
|
||||||
"iss-acme-prod": service.NewIssuerConnectorAdapter(acmeConnector),
|
|
||||||
"iss-stepca": service.NewIssuerConnectorAdapter(stepcaConnector),
|
|
||||||
"iss-openssl": service.NewIssuerConnectorAdapter(opensslConnector),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conditionally register Vault PKI (only if CERTCTL_VAULT_ADDR is set)
|
issuerRegistry := service.NewIssuerRegistry(logger)
|
||||||
if os.Getenv("CERTCTL_VAULT_ADDR") != "" {
|
|
||||||
issuerRegistry["iss-vault"] = service.NewIssuerConnectorAdapter(vaultConnector)
|
|
||||||
logger.Info("Vault PKI issuer registered", "id", "iss-vault")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conditionally register DigiCert (only if CERTCTL_DIGICERT_API_KEY is set)
|
|
||||||
if os.Getenv("CERTCTL_DIGICERT_API_KEY") != "" {
|
|
||||||
issuerRegistry["iss-digicert"] = service.NewIssuerConnectorAdapter(digicertConnector)
|
|
||||||
logger.Info("DigiCert CertCentral issuer registered", "id", "iss-digicert")
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
|
|
||||||
|
|
||||||
// Initialize revocation repository
|
// Initialize revocation repository
|
||||||
revocationRepo := postgres.NewRevocationRepository(db)
|
revocationRepo := postgres.NewRevocationRepository(db)
|
||||||
@@ -271,8 +177,15 @@ func main() {
|
|||||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||||
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||||
agentService.SetProfileRepo(profileRepo)
|
agentService.SetProfileRepo(profileRepo)
|
||||||
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, encryptionKey, logger)
|
||||||
targetService := service.NewTargetService(targetRepo, auditService)
|
|
||||||
|
// Seed issuers from env vars on first boot (empty database only), then build registry
|
||||||
|
issuerService.SeedFromEnvVars(context.Background(), cfg)
|
||||||
|
if err := issuerService.BuildRegistry(context.Background()); err != nil {
|
||||||
|
logger.Error("failed to build issuer registry from database", "error", err)
|
||||||
|
}
|
||||||
|
logger.Info("issuer registry loaded", "issuers", issuerRegistry.Len())
|
||||||
|
targetService := service.NewTargetService(targetRepo, auditService, agentRepo, encryptionKey, logger)
|
||||||
profileService := service.NewProfileService(profileRepo, auditService)
|
profileService := service.NewProfileService(profileRepo, auditService)
|
||||||
teamService := service.NewTeamService(teamRepo, auditService)
|
teamService := service.NewTeamService(teamRepo, auditService)
|
||||||
ownerService := service.NewOwnerService(ownerRepo, auditService)
|
ownerService := service.NewOwnerService(ownerRepo, auditService)
|
||||||
@@ -409,7 +322,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
// Register EST (RFC 7030) handlers if enabled
|
// Register EST (RFC 7030) handlers if enabled
|
||||||
if cfg.EST.Enabled {
|
if cfg.EST.Enabled {
|
||||||
issuerConn, ok := issuerRegistry[cfg.EST.IssuerID]
|
issuerConn, ok := issuerRegistry.Get(cfg.EST.IssuerID)
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Error("EST issuer not found in registry", "issuer_id", cfg.EST.IssuerID)
|
logger.Error("EST issuer not found in registry", "issuer_id", cfg.EST.IssuerID)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -607,22 +520,3 @@ func main() {
|
|||||||
logger.Info("certctl server stopped")
|
logger.Info("certctl server stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// getEnvDefault reads an environment variable with a default fallback.
|
|
||||||
func getEnvDefault(key, defaultVal string) string {
|
|
||||||
if val := os.Getenv(key); val != "" {
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
|
|
||||||
// getEnvIntDefault parses an integer from a string with a default fallback.
|
|
||||||
func getEnvIntDefault(s string, defaultVal int) int {
|
|
||||||
if s == "" {
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
val, err := strconv.Atoi(s)
|
|
||||||
if err != nil {
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Demo mode: pre-populated dashboard with 15 certificates, 5 agents, issuers, etc.
|
||||||
|
# Use this to showcase certctl's dashboard with realistic data.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build
|
||||||
|
#
|
||||||
|
# To start fresh (wipe previous data):
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.demo.yml down -v
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
volumes:
|
||||||
|
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/030_seed_demo.sql
|
||||||
@@ -45,8 +45,10 @@ services:
|
|||||||
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
||||||
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
||||||
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
||||||
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/010_seed.sql
|
- ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql
|
||||||
- ../migrations/seed_test.sql:/docker-entrypoint-initdb.d/015_seed_test.sql
|
- ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql
|
||||||
|
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql
|
||||||
|
- ../migrations/seed_test.sql:/docker-entrypoint-initdb.d/025_seed_test.sql
|
||||||
# No seed_demo.sql — start with a clean database for real testing
|
# No seed_demo.sql — start with a clean database for real testing
|
||||||
networks:
|
networks:
|
||||||
certctl-test:
|
certctl-test:
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ services:
|
|||||||
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
||||||
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
||||||
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
||||||
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/010_seed.sql
|
- ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql
|
||||||
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/011_seed_demo.sql
|
- ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql
|
||||||
|
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql
|
||||||
networks:
|
networks:
|
||||||
- certctl-network
|
- certctl-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
+26
-20
@@ -90,8 +90,11 @@ 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)"]
|
||||||
T2["F5 BIG-IP\n(proxy agent + iControl REST, planned)"]
|
T8["Envoy\n(file-based SDS)"]
|
||||||
T3["IIS\n(agent-local PowerShell, planned)"]
|
T9["Postfix/Dovecot\n(file + service reload)"]
|
||||||
|
T2["F5 BIG-IP\n(proxy agent + iControl REST)"]
|
||||||
|
T3["IIS\n(WinRM + local)"]
|
||||||
|
T10["SSH\n(SFTP + reload)"]
|
||||||
end
|
end
|
||||||
|
|
||||||
DASH --> API
|
DASH --> API
|
||||||
@@ -119,7 +122,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.
|
||||||
|
|
||||||
@@ -416,7 +419,7 @@ The agent deploys certificates using target connectors. Each connector knows how
|
|||||||
- **NGINX**: Writes cert/chain/key files to disk, validates config with `nginx -t`, reloads with `nginx -s reload` or `systemctl reload nginx`
|
- **NGINX**: Writes cert/chain/key files to disk, validates config with `nginx -t`, reloads with `nginx -s reload` or `systemctl reload nginx`
|
||||||
- **Apache httpd**: Writes separate cert/chain/key files, validates with `apachectl configtest`, graceful reload
|
- **Apache httpd**: Writes separate cert/chain/key files, validates with `apachectl configtest`, graceful reload
|
||||||
- **HAProxy**: Builds a combined PEM file (cert + chain + key), optionally validates config, reloads via systemctl or signal
|
- **HAProxy**: Builds a combined PEM file (cert + chain + key), optionally validates config, reloads via systemctl or signal
|
||||||
- **F5 BIG-IP** (planned): A proxy agent in the same network zone calls the iControl REST API to upload certificate and update SSL profile bindings. The server assigns the work; the proxy agent executes it.
|
- **F5 BIG-IP**: A proxy agent in the same network zone calls the iControl REST API to upload certificate/key files, install crypto objects, and update the SSL client profile within an atomic transaction. The server assigns the work; the proxy agent executes it.
|
||||||
- **IIS** (implemented, dual-mode): (1) Agent-local (recommended) — a Windows agent on the IIS box runs PowerShell `Import-PfxCertificate` + `Set-WebBinding` directly with PFX conversion and SHA-1 thumbprint computation. (2) Proxy agent WinRM — for agentless IIS targets, a nearby Windows agent reaches the IIS box via WinRM.
|
- **IIS** (implemented, dual-mode): (1) Agent-local (recommended) — a Windows agent on the IIS box runs PowerShell `Import-PfxCertificate` + `Set-WebBinding` directly with PFX conversion and SHA-1 thumbprint computation. (2) Proxy agent WinRM — for agentless IIS targets, a nearby Windows agent reaches the IIS box via WinRM.
|
||||||
|
|
||||||
The agent handles both the certificate (public) and the private key (read from local key store at `CERTCTL_KEY_DIR`). The control plane never sees the private key and never initiates outbound connections to agents or targets (pull-only model).
|
The agent handles both the certificate (public) and the private key (read from local key store at `CERTCTL_KEY_DIR`). The control plane never sees the private key and never initiates outbound connections to agents or targets (pull-only model).
|
||||||
@@ -511,6 +514,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 +526,11 @@ flowchart TB
|
|||||||
TI --> HP["HAProxy"]
|
TI --> HP["HAProxy"]
|
||||||
TI --> TF["Traefik"]
|
TI --> TF["Traefik"]
|
||||||
TI --> CD["Caddy"]
|
TI --> CD["Caddy"]
|
||||||
TI --> F5["F5 BIG-IP (interface only)"]
|
TI --> EV["Envoy"]
|
||||||
TI --> IIS["IIS (interface only)"]
|
TI --> PO["Postfix/Dovecot"]
|
||||||
|
TI --> IIS["IIS"]
|
||||||
|
TI --> F5["F5 BIG-IP"]
|
||||||
|
TI --> SC["SSH"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Notifier Connectors"
|
subgraph "Notifier Connectors"
|
||||||
@@ -576,7 +584,7 @@ type Connector interface {
|
|||||||
|
|
||||||
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts), **Vault PKI** (HashiCorp Vault's PKI secrets engine via /sign API with token auth), and **DigiCert** (commercial CA via CertCentral REST API with async order processing). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required.
|
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts), **Vault PKI** (HashiCorp Vault's PKI secrets engine via /sign API with token auth), and **DigiCert** (commercial CA via CertCentral REST API with async order processing). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required.
|
||||||
|
|
||||||
**ACME Renewal Information (ARI, RFC 9702):** The ACME connector supports CA-directed renewal timing via the `GetRenewalInfo()` method. Instead of using fixed thresholds (e.g., renew 30 days before expiry), the CA tells certctl when to renew by providing a `suggestedWindow` with start and end times. This is useful for distributing renewal load during maintenance windows and coordinating mass-revocation scenarios. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. Cert ID is computed as `base64url(SHA-256(DER cert))` per RFC 9702. If the CA doesn't support ARI (404 from the ARI endpoint), certctl automatically falls back to threshold-based renewal — no operator intervention required. Errors from the CA are logged as warnings.
|
**ACME Renewal Information (ARI, RFC 9773):** The ACME connector supports CA-directed renewal timing via the `GetRenewalInfo()` method. Instead of using fixed thresholds (e.g., renew 30 days before expiry), the CA tells certctl when to renew by providing a `suggestedWindow` with start and end times. This is useful for distributing renewal load during maintenance windows and coordinating mass-revocation scenarios. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. Cert ID is computed as `base64url(SHA-256(DER cert))` per RFC 9773. If the CA doesn't support ARI (404 from the ARI endpoint), certctl automatically falls back to threshold-based renewal — no operator intervention required. Errors from the CA are logged as warnings.
|
||||||
|
|
||||||
The interface also includes `GetCACertPEM(ctx)` for CA chain distribution (used by the EST server's `/cacerts` endpoint).
|
The interface also includes `GetCACertPEM(ctx)` for CA chain distribution (used by the EST server's `/cacerts` endpoint).
|
||||||
|
|
||||||
@@ -598,7 +606,7 @@ Built-in targets: **NGINX** (writes cert/chain/key files, validates with `nginx
|
|||||||
|
|
||||||
After deployment, agents can perform **post-deployment TLS verification**: the agent probes the live TLS endpoint using `crypto/tls.DialWithDialer` and compares the SHA-256 fingerprint of the served certificate against what was deployed. Results are reported via `POST /api/v1/jobs/{id}/verify` and stored on the job record. Verification is best-effort — failures don't block or rollback deployments.
|
After deployment, agents can perform **post-deployment TLS verification**: the agent probes the live TLS endpoint using `crypto/tls.DialWithDialer` and compares the SHA-256 fingerprint of the served certificate against what was deployed. Results are reported via `POST /api/v1/jobs/{id}/verify` and stored on the job record. Verification is best-effort — failures don't block or rollback deployments.
|
||||||
|
|
||||||
Additional cloud, network, and Kubernetes target connectors are planned for future releases.
|
The SSH connector enables agentless deployment to any Linux/Unix server via SSH/SFTP, using the proxy agent pattern. Additional cloud, network, and Kubernetes target connectors are planned for future releases.
|
||||||
|
|
||||||
### Notifier Connector
|
### Notifier Connector
|
||||||
|
|
||||||
@@ -958,27 +966,25 @@ This data flow is pull-based and non-blocking. Agents discover at their own pace
|
|||||||
|
|
||||||
## Testing Strategy
|
## Testing Strategy
|
||||||
|
|
||||||
certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 1050+ tests across six layers (service, handler, integration, connector, frontend, and scheduler). The goal is high-confidence regression prevention at the service and handler layers, where the most complex business logic lives, combined with integration tests that exercise the full request path from HTTP to database.
|
certctl is extensively tested across eight layers with CI-enforced coverage gates that act as regression floors. The goal is high-confidence regression prevention at the service and handler layers (where the most complex business logic lives), combined with integration tests that exercise the full request path from HTTP to database.
|
||||||
|
|
||||||
**Service layer unit tests** (`internal/service/*_test.go`) — ~238 test functions across 15 files with mock repositories. These test all business logic in isolation: certificate CRUD with validation, certificate revocation (success, already-revoked, archived, invalid reason, all RFC 5280 reason codes, issuer notification, notification service integration, OCSP/CRL generation), agent lifecycle (registration, heartbeat, CSR submission with both keygen modes), job state machine (creation, processing, cancellation, retry logic), policy evaluation (all 5 rule types, violation creation), renewal and issuance flow (server-side and agent-side keygen paths), notification deduplication (threshold tag matching, channel routing), team/owner/agent group CRUD with pagination and audit recording, issuer service CRUD with connection testing, and the issuer connector adapter (type translation between connector and service layers including revocation). Mock repositories are simple structs with function fields, avoiding heavy mocking frameworks — this keeps tests readable and avoids coupling to mock library APIs.
|
**Service layer unit tests** (`internal/service/*_test.go`) — Mock-based tests across all service files covering certificate CRUD, revocation (all RFC 5280 reason codes, OCSP/CRL generation), agent lifecycle, job state machine, policy evaluation, renewal/issuance flow (both keygen modes), notification deduplication, team/owner/agent group CRUD, issuer service CRUD with connection testing, and the issuer connector adapter. Mock repositories are simple structs with function fields — no heavy mocking frameworks.
|
||||||
|
|
||||||
**Handler layer tests** (`internal/api/handler/*_test.go`) — ~257 test functions across 11 files using Go's `httptest` package. Every handler file has a corresponding test file: certificates (50 tests including revocation, DER CRL, and OCSP), agents (28 tests), jobs (21 tests including approve/reject), notifications (11 tests), policies (19 tests), profiles (18 tests), issuers (17 tests), targets (17 tests), agent groups (12 tests), teams (26 tests), and owners (21 tests). Each test file follows the same pattern: a mock service struct with function fields, `httptest.NewRecorder` for capturing responses, and a shared `contextWithRequestID()` helper. Tests cover the happy path, input validation (missing fields, invalid JSON, empty IDs, name length limits), error propagation from the service layer, method-not-allowed responses, and pagination parameters.
|
**Handler layer tests** (`internal/api/handler/*_test.go`) — Every handler file has a corresponding test file using Go's `httptest` package: certificates (including revocation, DER CRL, OCSP), agents, jobs (including approve/reject), notifications, policies, profiles, issuers, targets, agent groups, teams, owners, discovery, network scan, verification, export, EST, digest, stats, and metrics. Tests cover the happy path, input validation, error propagation, method-not-allowed, and pagination.
|
||||||
|
|
||||||
**Integration tests** (`internal/integration/`) — Two test files exercising the full stack from HTTP request through router, handler, service, and postgres repository layers. `lifecycle_test.go` has 11 subtests covering the complete certificate lifecycle: team/owner creation, certificate creation, issuer verification, renewal trigger, job verification, agent registration, CSR submission, deployment, and status reporting. `negative_test.go` has 14 subtests covering error paths, 19 M11b endpoint tests, and 8 revocation endpoint tests (M15a+M15b): nonexistent resource lookups (404s), invalid request bodies (malformed JSON, missing required fields), invalid CSR submission, heartbeat for nonexistent agents, wrong HTTP methods on list endpoints, empty list responses, renewal on nonexistent certificates, expired certificate lifecycle, team/owner/agent group CRUD validation, revocation success, already-revoked rejection, not-found revocation, JSON CRL retrieval, DER CRL retrieval, OCSP response retrieval, and short-lived cert exemption. Both use a shared `setupTestServer()` that builds a fully-wired server with real postgres repositories and the Local CA issuer connector. A third file, `e2e_test.go`, contains 8 cross-milestone test functions with 48+ subtests that exercise features across milestones end-to-end: M10 agent metadata via heartbeat, M11 profiles/teams/owners/agent-groups CRUD, M12 issuer registry verification, M13 GUI operation endpoints, M14 stats and metrics, M15 revocation and CRL, M16 notification channels, and M20 enhanced query API (sorting, cursor pagination, sparse fields, time-range filters).
|
**Integration tests** (`internal/integration/`) — Three test files exercising the full stack from HTTP request through router, handler, service, and repository layers. `lifecycle_test.go` covers the complete certificate lifecycle (team/owner creation through deployment and status reporting). `negative_test.go` covers error paths, endpoint validation, and revocation scenarios. `e2e_test.go` exercises cross-milestone features end-to-end (agent metadata, profiles, issuer registry, GUI operations, stats, revocation, notifications, enhanced query API).
|
||||||
|
|
||||||
**Frontend tests** (`web/src/api/client.test.ts`, `web/src/api/utils.test.ts`) — 86 Vitest tests covering the API client, stats/metrics endpoints, and utility functions. The API client tests mock `globalThis.fetch` and verify all endpoint functions (certificates, agents, jobs, policies, issuers, targets, notifications, audit, stats, metrics, health) send correct HTTP methods, URLs, headers, and request bodies. They also test API key management (store/retrieve/clear), auth header propagation, 401 event dispatching, and error handling (server messages, error fields, status text fallback). The stats/metrics endpoint tests verify correct query parameter handling and response shape validation. The utility tests use `vi.useFakeTimers()` for deterministic date testing and cover `formatDate`, `formatDateTime`, `timeAgo`, `daysUntil`, and `expiryColor`. The test environment uses jsdom with `@testing-library/jest-dom` matchers.
|
**Go integration tests** (`deploy/test/integration_test.go`) — Runs against the live Docker Compose test environment with real CA backends (Local CA, Pebble ACME, step-ca). Covers health checks, agent heartbeat, issuance, renewal, revocation, CRL/OCSP, EST enrollment, S/MIME, discovery, network scanning, and deployment verification using `crypto/x509` for cert parsing and `crypto/tls` for live TLS verification.
|
||||||
|
|
||||||
**CLI tests** (`internal/cli/client_test.go`) — 14 tests covering all 10 CLI subcommands with httptest mock servers, PEM parsing for bulk import, auth header verification, and JSON/table output formatting.
|
**Frontend tests** (`web/src/api/`) — Vitest tests covering the full API client (all endpoint functions with fetch mocking), stats/metrics endpoints, utility functions, and auth flows. Test environment uses jsdom with `@testing-library/jest-dom` matchers.
|
||||||
|
|
||||||
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs: Go (build, vet, race detection, static analysis, vulnerability scanning, test with coverage, coverage threshold enforcement) and Frontend (TypeScript type check, Vitest test suite, Vite production build). The Go job runs `go test -race` on service, handler, middleware, and scheduler packages to catch data races. It runs `golangci-lint` with 11 linters (errcheck, govet, staticcheck, unused, gosimple, ineffassign, typecheck, gocritic, gosec, bodyclose, noctx) configured in `.golangci.yml`. It runs `govulncheck ./...` to scan dependencies for known CVEs. Coverage thresholds are enforced per-layer: service 60%, handler 60%, domain 40%, middleware 50%. These thresholds act as regression floors — they can only go up. Connector tests are included via `./internal/connector/issuer/...` and `./internal/connector/target/...` (covers Local CA, ACME, step-ca, NGINX, Apache, HAProxy, Traefik, and Caddy packages with unit tests for certificate signing logic, DNS solver, issuer validation, and deployment flows). The Frontend job runs `npx vitest run` between the TypeScript check and production build steps.
|
**Connector tests** (`internal/connector/`) — Issuer connectors (Local CA self-signed/sub-CA modes, ACME DNS-01/DNS-PERSIST-01, step-ca, OpenSSL, Vault PKI, DigiCert, Sectigo, Google CAS — all with httptest mock servers). Target connectors (NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS with mock PowerShell executor, F5 BIG-IP with mock iControl client, Postfix/Dovecot, SSH with mock SSH client). Notifier connectors (Slack, Teams, PagerDuty, OpsGenie).
|
||||||
|
|
||||||
**Connector tests** (`internal/connector/`) — 57 test functions covering issuer, target, and notifier connectors. The Local CA connector has tests for self-signed and sub-CA modes (RSA, ECDSA, config validation, non-CA cert rejection). The ACME DNS solver has 10 tests for script-based DNS-01 and DNS-PERSIST-01 challenges (6 DNS-01 tests + 4 DNS-PERSIST-01 tests covering `PresentPersist` success, no-script error, script failure, and wildcard domain handling). The step-ca connector has tests with a mock HTTP server for issuance, renewal, revocation, and error paths. The OpenSSL/Custom CA connector has 14 tests covering config validation, issuance success/failure/timeout, renewal, revocation, and CRL generation. The NGINX target connector has 13 tests covering config validation, certificate deployment (file writing, permissions, validate/reload commands), and deployment validation. Apache httpd and HAProxy connectors each have 3 tests covering config validation, deployment, and validation flows. Traefik and Caddy connectors have tests covering file-based deployment and (for Caddy) dual-mode API/file configuration. Notifier connector tests span 20 tests across Slack (5), Teams (4), PagerDuty (6), and OpsGenie (5) — verifying channel identity, payload formatting, HTTP error handling, connection failures, auth headers, and configuration defaults.
|
**Scheduler tests** (`internal/scheduler/scheduler_test.go`) — Idempotency guards (`sync/atomic.Bool`), `WaitForCompletion` success and timeout paths, and multi-loop concurrency safety.
|
||||||
|
|
||||||
**Scheduler tests** (`internal/scheduler/scheduler_test.go`) — Tests for idempotency guards (`sync/atomic.Bool` CompareAndSwap prevents concurrent loop ticks), `WaitForCompletion` success and timeout paths, and multi-loop idempotency.
|
**Fuzz tests** (`internal/validation/`, `internal/domain/`) — Go native fuzz tests for command validation (`ValidateShellCommand`, `ValidateDomainName`, `ValidateACMEToken`) and revocation domain parsing.
|
||||||
|
|
||||||
**Fuzz tests** (`internal/validation/command_fuzz_test.go`, `internal/domain/revocation_fuzz_test.go`) — Go native fuzz tests (`testing/fuzz`) for command validation functions and revocation domain parsing. These exercise `ValidateShellCommand`, `ValidateDomainName`, and `ValidateACMEToken` with random inputs to discover edge cases.
|
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs. Go: build, vet, `go test -race`, `golangci-lint` (11 linters), `govulncheck`, test with coverage, per-layer coverage threshold enforcement (service 60%, handler 60%, domain 40%, middleware 50%). Frontend: TypeScript type check, Vitest, Vite production build.
|
||||||
|
|
||||||
**What's not tested and why:** Postgres repository implementations (`internal/repository/postgres/`) require a real database and are tested only through integration tests, not unit tests — a `testcontainers-go` scaffolding for isolated PostgreSQL instances is planned. Target connectors for F5 BIG-IP and IIS are interface stubs (implementation planned for V3). The ACME connector requires a real ACME server (tested manually against Let's Encrypt staging). These are all candidates for future expansion as the test infrastructure matures.
|
|
||||||
|
|
||||||
## What's Next
|
## What's Next
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+14
-4
@@ -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
|
||||||
|
|
||||||
@@ -183,11 +183,11 @@ Profiles are managed via the API (`/api/v1/profiles`) and the GUI, and can be as
|
|||||||
|
|
||||||
For policies with `auto_renew` disabled, renewal jobs enter an **AwaitingApproval** state instead of processing immediately. An operator must explicitly approve or reject the renewal via the API or GUI. Approved jobs transition to Pending and are picked up by the scheduler. Rejected jobs are cancelled with an optional reason. This is useful for high-value certificates where you want human oversight before renewal.
|
For policies with `auto_renew` disabled, renewal jobs enter an **AwaitingApproval** state instead of processing immediately. An operator must explicitly approve or reject the renewal via the API or GUI. Approved jobs transition to Pending and are picked up by the scheduler. Rejected jobs are cancelled with an optional reason. This is useful for high-value certificates where you want human oversight before renewal.
|
||||||
|
|
||||||
### Renewal Timing: Thresholds vs. ARI (RFC 9702)
|
### Renewal Timing: Thresholds vs. ARI (RFC 9773)
|
||||||
|
|
||||||
**Traditional approach (thresholds):** By default, certctl uses static renewal thresholds — renew a certificate at a fixed number of days before expiry (default: 30 days). This simple, predictable model works for most use cases: it avoids unnecessary renewals near expiry and gives you a predictable window to catch failures.
|
**Traditional approach (thresholds):** By default, certctl uses static renewal thresholds — renew a certificate at a fixed number of days before expiry (default: 30 days). This simple, predictable model works for most use cases: it avoids unnecessary renewals near expiry and gives you a predictable window to catch failures.
|
||||||
|
|
||||||
**Advanced approach (ACME ARI):** Some Certificate Authorities support ACME Renewal Information (RFC 9702), which allows the CA to tell certctl the optimal time to renew. Instead of guessing "renew 30 days before expiry," the CA responds with a precise `suggestedWindow` containing start and end times. This is useful when:
|
**Advanced approach (ACME ARI):** Some Certificate Authorities support ACME Renewal Information (RFC 9773), which allows the CA to tell certctl the optimal time to renew. Instead of guessing "renew 30 days before expiry," the CA responds with a precise `suggestedWindow` containing start and end times. This is useful when:
|
||||||
- The CA is performing maintenance and wants to batch renewals in a specific window
|
- The CA is performing maintenance and wants to batch renewals in a specific window
|
||||||
- The CA is coordinating a mass revocation (e.g., due to a compromise) and needs to control renewal timing
|
- The CA is coordinating a mass revocation (e.g., due to a compromise) and needs to control renewal timing
|
||||||
- You want to avoid thundering herd renewal spikes by accepting the CA's suggested timing
|
- You want to avoid thundering herd renewal spikes by accepting the CA's suggested timing
|
||||||
@@ -196,6 +196,16 @@ For policies with `auto_renew` disabled, renewal jobs enter an **AwaitingApprova
|
|||||||
|
|
||||||
**Graceful degradation:** If your CA doesn't support ARI (returns 404 from the ARI endpoint), certctl automatically falls back to the traditional threshold-based renewal. No configuration change needed — the fallback is transparent. Errors from the CA are logged as warnings and don't block the renewal process.
|
**Graceful degradation:** If your CA doesn't support ARI (returns 404 from the ARI endpoint), certctl automatically falls back to the traditional threshold-based renewal. No configuration change needed — the fallback is transparent. Errors from the CA are logged as warnings and don't block the renewal process.
|
||||||
|
|
||||||
|
### Shorter Certificate Validity (45-Day and 6-Day Certs)
|
||||||
|
|
||||||
|
The industry is moving toward shorter certificate lifetimes. The CA/Browser Forum's SC-081v3 ballot mandates a phased reduction: 200-day max (March 2026), 100-day max (March 2027), and 47-day max (March 2029). Let's Encrypt has already begun reducing default validity to 45 days, and offers 6-day "shortlived" certificates via ACME profile selection.
|
||||||
|
|
||||||
|
certctl handles shorter-lived certificates correctly out of the box:
|
||||||
|
|
||||||
|
- **45-day certs** with the default 31-day renewal window trigger renewal at day 14 — at roughly 1/3 of the cert's lifetime.
|
||||||
|
- **6-day "shortlived" certs** are always within the renewal window. ARI (RFC 9773) is the expected renewal path for these — the CA directs timing. Short-lived certs also skip CRL/OCSP since expiry is sufficient revocation (per profile TTL < 1 hour exemption).
|
||||||
|
- **ACME profile selection** lets you request specific certificate profiles from your CA. Set `CERTCTL_ACME_PROFILE=shortlived` to get 6-day certificates from Let's Encrypt, or `CERTCTL_ACME_PROFILE=tlsserver` for standard TLS certificates.
|
||||||
|
|
||||||
### Certificate Revocation
|
### Certificate Revocation
|
||||||
|
|
||||||
When a private key is compromised, a certificate is superseded, or a service is decommissioned, you need to revoke the certificate immediately — not wait for it to expire. Revocation tells clients "stop trusting this certificate right now."
|
When a private key is compromised, a certificate is superseded, or a service is decommissioned, you need to revoke the certificate immediately — not wait for it to expire. Revocation tells clients "stop trusting this certificate right now."
|
||||||
|
|||||||
+240
-15
@@ -22,9 +22,13 @@ 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)
|
||||||
|
- [SSH (Agentless Deployment)](#ssh-agentless-deployment)
|
||||||
|
- [Windows Certificate Store](#windows-certificate-store)
|
||||||
|
- [Java Keystore (JKS / PKCS#12)](#java-keystore-jks--pkcs12)
|
||||||
4. [Notifier Connector](#notifier-connector)
|
4. [Notifier Connector](#notifier-connector)
|
||||||
- [Interface](#interface-2)
|
- [Interface](#interface-2)
|
||||||
5. [Registering a Connector](#registering-a-connector)
|
5. [Registering a Connector](#registering-a-connector)
|
||||||
@@ -52,8 +56,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, F5, SSH implemented; 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.
|
||||||
@@ -172,7 +176,7 @@ The ACME connector implements the full ACME v2 protocol using Go's `golang.org/x
|
|||||||
|
|
||||||
**DNS-PERSIST-01 (standing record):** Creates a one-time persistent TXT record at `_validation-persist.<domain>` containing the CA's issuer domain and your ACME account URI. Once set, this record authorizes unlimited future certificate issuances without per-renewal DNS updates. Based on [draft-ietf-acme-dns-persist](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/) and CA/Browser Forum ballot SC-088v3. If the CA doesn't offer dns-persist-01 yet, the connector falls back to dns-01 automatically.
|
**DNS-PERSIST-01 (standing record):** Creates a one-time persistent TXT record at `_validation-persist.<domain>` containing the CA's issuer domain and your ACME account URI. Once set, this record authorizes unlimited future certificate issuances without per-renewal DNS updates. Based on [draft-ietf-acme-dns-persist](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/) and CA/Browser Forum ballot SC-088v3. If the CA doesn't offer dns-persist-01 yet, the connector falls back to dns-01 automatically.
|
||||||
|
|
||||||
**ACME Renewal Information (ARI, RFC 9702):** Instead of using fixed renewal thresholds (e.g., renew 30 days before expiry), certctl can ask the CA when it should renew. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. The ARI protocol lets the CA specify a `suggestedWindow` (start and end times) for when you should renew — useful for distributing load during maintenance windows or coordinating mass revocation scenarios. Cert ID is computed as `base64url(SHA-256(DER cert))`. If the CA doesn't support ARI (404 response), certctl automatically falls back to threshold-based renewal with no operator intervention required.
|
**ACME Renewal Information (ARI, RFC 9773):** Instead of using fixed renewal thresholds (e.g., renew 30 days before expiry), certctl can ask the CA when it should renew. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. The ARI protocol lets the CA specify a `suggestedWindow` (start and end times) for when you should renew — useful for distributing load during maintenance windows or coordinating mass revocation scenarios. Cert ID is computed as `base64url(SHA-256(DER cert))`. If the CA doesn't support ARI (404 response), certctl automatically falls back to threshold-based renewal with no operator intervention required.
|
||||||
|
|
||||||
HTTP-01 configuration:
|
HTTP-01 configuration:
|
||||||
```json
|
```json
|
||||||
@@ -242,6 +246,9 @@ Environment variables for the default ACME connector:
|
|||||||
- `CERTCTL_ACME_DNS_PRESENT_SCRIPT` — Path to DNS record creation script (dns-01 and dns-persist-01)
|
- `CERTCTL_ACME_DNS_PRESENT_SCRIPT` — Path to DNS record creation script (dns-01 and dns-persist-01)
|
||||||
- `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` — Path to DNS record cleanup script (dns-01 only, not used by dns-persist-01)
|
- `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` — Path to DNS record cleanup script (dns-01 only, not used by dns-persist-01)
|
||||||
- `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN` — CA issuer domain for persistent record (dns-persist-01 only, e.g., `letsencrypt.org`)
|
- `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN` — CA issuer domain for persistent record (dns-persist-01 only, e.g., `letsencrypt.org`)
|
||||||
|
- `CERTCTL_ACME_PROFILE` — Certificate profile for the newOrder request. Let's Encrypt supports `tlsserver` (standard TLS, default) and `shortlived` (6-day certs). Leave empty for the CA's default profile.
|
||||||
|
|
||||||
|
**Certificate Profiles:** Let's Encrypt (GA January 2026) supports ACME certificate profile selection. Set `CERTCTL_ACME_PROFILE=shortlived` to request 6-day certificates — ideal for ephemeral workloads where short validity substitutes for revocation. The `tlsserver` profile produces standard TLS certificates. When the profile field is empty (default), the CA uses its default profile, maintaining full backward compatibility.
|
||||||
|
|
||||||
The connector is registered in the issuer registry under `iss-acme-staging` and `iss-acme-prod`. Use `iss-acme-staging` for Let's Encrypt staging (rate-limit-friendly testing) and `iss-acme-prod` for production certificates.
|
The connector is registered in the issuer registry under `iss-acme-staging` and `iss-acme-prod`. Use `iss-acme-staging` for Let's Encrypt staging (rate-limit-friendly testing) and `iss-acme-prod` for production certificates.
|
||||||
|
|
||||||
@@ -354,13 +361,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,24 +667,80 @@ 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`
|
||||||
|
|
||||||
### F5 BIG-IP (Interface Only)
|
### Built-in: Postfix / Dovecot
|
||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
The planned flow is: authenticate via `POST /mgmt/shared/authn/login`, upload cert PEM via `POST /mgmt/tm/ltm/certificate`, update the SSL profile via `PATCH /mgmt/tm/ltm/profile/client-ssl/{profile}`, and validate deployment by checking profile status.
|
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.
|
||||||
|
|
||||||
Configuration (defined, not yet functional):
|
**Postfix configuration:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"host": "f5.internal.example.com",
|
"mode": "postfix",
|
||||||
"username": "admin",
|
"cert_path": "/etc/postfix/certs/cert.pem",
|
||||||
"password": "...",
|
"key_path": "/etc/postfix/certs/key.pem",
|
||||||
"partition": "Common",
|
"chain_path": "/etc/postfix/certs/chain.pem",
|
||||||
"ssl_profile": "/Common/clientssl_api"
|
"reload_command": "postfix reload",
|
||||||
|
"validate_command": "postfix check"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: F5 credentials are stored on the proxy agent, not on the control plane server. This limits the credential blast radius to the proxy agent's network zone.
|
**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 (Implemented)
|
||||||
|
|
||||||
|
The F5 BIG-IP target connector deploys certificates to F5 load balancers via the iControl REST API. F5 appliances can't run agents directly, so this connector uses the **proxy agent pattern**: a designated certctl agent in the same network zone polls for F5 deployment jobs and executes iControl REST calls on behalf of the control plane. Minimum supported BIG-IP version: 12.0+.
|
||||||
|
|
||||||
|
The deployment flow uses F5's transaction API for atomic updates: authenticate via token auth, upload cert/key/chain PEM files, install as crypto objects, update the SSL client profile within a transaction, and commit. If the transaction fails, F5 rolls back automatically and the connector cleans up uploaded crypto objects. Updating an SSL profile automatically takes effect on all bound virtual servers — no separate virtual server binding step is needed.
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `host` | string | *(required)* | F5 BIG-IP management hostname or IP |
|
||||||
|
| `port` | int | `443` | iControl REST API port |
|
||||||
|
| `username` | string | *(required)* | Administrative username |
|
||||||
|
| `password` | string | *(required)* | Administrative password |
|
||||||
|
| `partition` | string | `Common` | F5 partition for crypto objects and profiles |
|
||||||
|
| `ssl_profile` | string | *(required)* | SSL client profile name to update |
|
||||||
|
| `insecure` | bool | `true` | Skip TLS verification for management interface (self-signed certs common) |
|
||||||
|
| `timeout` | int | `30` | HTTP timeout in seconds |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"host": "f5.internal.example.com",
|
||||||
|
"port": 443,
|
||||||
|
"username": "admin",
|
||||||
|
"password": "...",
|
||||||
|
"partition": "Common",
|
||||||
|
"ssl_profile": "clientssl_api",
|
||||||
|
"insecure": true,
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
F5 credentials are stored on the proxy agent, not on the control plane server. This limits the credential blast radius to the proxy agent's network zone. Config fields are validated against regex patterns to prevent injection.
|
||||||
|
|
||||||
Location: `internal/connector/target/f5/f5.go`
|
Location: `internal/connector/target/f5/f5.go`
|
||||||
|
|
||||||
@@ -712,6 +815,128 @@ The IIS target connector supports two deployment modes — agent-local (recommen
|
|||||||
|
|
||||||
Location: `internal/connector/target/iis/iis.go`, `internal/connector/target/iis/winrm.go`
|
Location: `internal/connector/target/iis/iis.go`, `internal/connector/target/iis/winrm.go`
|
||||||
|
|
||||||
|
### SSH (Agentless Deployment)
|
||||||
|
|
||||||
|
The SSH target connector enables agentless certificate deployment to any Linux/Unix server via SSH/SFTP. Instead of installing the certctl agent binary on every target, a single "proxy agent" in the same network zone deploys certificates to remote servers over SSH. This is ideal for environments where installing agents on every server is impractical.
|
||||||
|
|
||||||
|
**Key authentication (recommended):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"host": "web-server.internal",
|
||||||
|
"port": 22,
|
||||||
|
"user": "certctl",
|
||||||
|
"auth_method": "key",
|
||||||
|
"private_key_path": "/home/certctl/.ssh/id_ed25519",
|
||||||
|
"cert_path": "/etc/ssl/certs/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/private/key.pem",
|
||||||
|
"chain_path": "/etc/ssl/certs/chain.pem",
|
||||||
|
"reload_command": "systemctl reload nginx",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Password authentication:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"host": "legacy-server.internal",
|
||||||
|
"user": "deploy",
|
||||||
|
"auth_method": "password",
|
||||||
|
"password": "s3cret",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
"reload_command": "systemctl reload apache2"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `host` | string | *(required)* | SSH hostname or IP address |
|
||||||
|
| `port` | number | 22 | SSH port |
|
||||||
|
| `user` | string | *(required)* | SSH username |
|
||||||
|
| `auth_method` | string | `"key"` | `"key"` or `"password"` |
|
||||||
|
| `private_key_path` | string | | Path to SSH private key file (key auth) |
|
||||||
|
| `private_key` | string | | Inline SSH private key PEM (alternative to path) |
|
||||||
|
| `password` | string | | SSH password (password auth) |
|
||||||
|
| `passphrase` | string | | Passphrase for encrypted private keys |
|
||||||
|
| `cert_path` | string | *(required)* | Remote path for certificate file |
|
||||||
|
| `key_path` | string | *(required)* | Remote path for private key file |
|
||||||
|
| `chain_path` | string | | Remote path for chain file (if empty, chain appended to cert) |
|
||||||
|
| `cert_mode` | string | `"0644"` | File permissions for cert (octal) |
|
||||||
|
| `key_mode` | string | `"0600"` | File permissions for private key (octal) |
|
||||||
|
| `reload_command` | string | | Command to execute after deployment |
|
||||||
|
| `timeout` | number | 30 | SSH connection timeout in seconds |
|
||||||
|
|
||||||
|
**Security:**
|
||||||
|
- Key-based authentication is recommended over password authentication
|
||||||
|
- Reload commands are validated against shell injection (same validation as Postfix/Dovecot connectors)
|
||||||
|
- Host field is regex-validated to prevent shell metacharacters
|
||||||
|
- Private keys are written with 0600 permissions by default
|
||||||
|
- Host key verification is intentionally skipped (same rationale as network scanner and F5 connector — deploying to known, operator-configured infrastructure)
|
||||||
|
- Encrypted private keys supported via passphrase
|
||||||
|
|
||||||
|
Location: `internal/connector/target/ssh/ssh.go`
|
||||||
|
|
||||||
|
### Windows Certificate Store
|
||||||
|
|
||||||
|
The Windows Certificate Store connector imports certificates into the Windows cert store via PowerShell, without managing IIS site bindings. Use this for non-IIS Windows services that read certificates from the cert store (Exchange, RDP, SQL Server, ADFS, etc.). Same injectable `PowerShellExecutor` pattern as the IIS connector, with optional WinRM proxy mode.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"store_name": "My",
|
||||||
|
"store_location": "LocalMachine",
|
||||||
|
"friendly_name": "Production API Cert",
|
||||||
|
"remove_expired": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `store_name` | string | `"My"` | Windows cert store name (My, Root, WebHosting, etc.) |
|
||||||
|
| `store_location` | string | `"LocalMachine"` | `"LocalMachine"` or `"CurrentUser"` |
|
||||||
|
| `friendly_name` | string | | Optional friendly name for the imported certificate |
|
||||||
|
| `remove_expired` | boolean | `false` | Remove expired certs with same CN after import |
|
||||||
|
| `mode` | string | `"local"` | `"local"` (agent-local) or `"winrm"` (remote) |
|
||||||
|
| `winrm_host` | string | | WinRM hostname (required for winrm mode) |
|
||||||
|
| `winrm_port` | number | 5985 | WinRM port (5985 HTTP, 5986 HTTPS) |
|
||||||
|
| `winrm_username` | string | | WinRM username (required for winrm mode) |
|
||||||
|
| `winrm_password` | string | | WinRM password (required for winrm mode) |
|
||||||
|
| `winrm_https` | boolean | `false` | Use HTTPS for WinRM |
|
||||||
|
| `winrm_insecure` | boolean | `false` | Skip TLS verification for WinRM |
|
||||||
|
|
||||||
|
Location: `internal/connector/target/wincertstore/wincertstore.go`
|
||||||
|
|
||||||
|
### Java Keystore (JKS / PKCS#12)
|
||||||
|
|
||||||
|
The Java Keystore connector deploys certificates to JKS or PKCS#12 keystores via the `keytool` CLI. This enables TLS cert deployment for Tomcat, Jetty, Kafka, Elasticsearch, and any JVM-based service. Flow: PEM to temp PKCS#12, then `keytool -importkeystore` into the target keystore.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"keystore_path": "/opt/tomcat/conf/keystore.p12",
|
||||||
|
"keystore_password": "changeit",
|
||||||
|
"keystore_type": "PKCS12",
|
||||||
|
"alias": "server",
|
||||||
|
"reload_command": "systemctl restart tomcat"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `keystore_path` | string | *(required)* | Absolute path to the keystore file |
|
||||||
|
| `keystore_password` | string | *(required)* | Keystore password |
|
||||||
|
| `keystore_type` | string | `"PKCS12"` | `"PKCS12"` or `"JKS"` |
|
||||||
|
| `alias` | string | `"server"` | Key entry alias in the keystore |
|
||||||
|
| `reload_command` | string | | Optional command to run after keystore update |
|
||||||
|
| `create_keystore` | boolean | `true` | Create keystore if it doesn't exist |
|
||||||
|
| `keytool_path` | string | `"keytool"` | Override keytool binary path |
|
||||||
|
|
||||||
|
**Security:**
|
||||||
|
- Reload commands validated against shell injection via `validation.ValidateShellCommand()`
|
||||||
|
- Alias validated against injection (alphanumeric, hyphens, underscores only)
|
||||||
|
- Path traversal prevention on keystore path
|
||||||
|
- Transient PKCS#12 temp file cleaned up after import (even on error)
|
||||||
|
|
||||||
|
Location: `internal/connector/target/javakeystore/javakeystore.go`
|
||||||
|
|
||||||
## Notifier Connector
|
## Notifier Connector
|
||||||
|
|
||||||
Notifier connectors send alerts about certificate lifecycle events (expiration warnings, renewal success/failure, deployment status, policy violations).
|
Notifier connectors send alerts about certificate lifecycle events (expiration warnings, renewal success/failure, deployment status, policy violations).
|
||||||
|
|||||||
@@ -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.
|
||||||
+10
-10
@@ -514,7 +514,7 @@ export CERTCTL_PAGERDUTY_SEVERITY="critical"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ACME Renewal Information (ARI, RFC 9702)
|
## ACME Renewal Information (ARI, RFC 9773)
|
||||||
|
|
||||||
Instead of using fixed renewal thresholds (renew 30 days before expiry), ACME ARI lets the CA tell certctl exactly when to renew. This is useful for distributing renewal load across maintenance windows and coordinating mass-revocation scenarios.
|
Instead of using fixed renewal thresholds (renew 30 days before expiry), ACME ARI lets the CA tell certctl exactly when to renew. This is useful for distributing renewal load across maintenance windows and coordinating mass-revocation scenarios.
|
||||||
|
|
||||||
@@ -530,7 +530,7 @@ export CERTCTL_ACME_ARI_ENABLED=true
|
|||||||
|
|
||||||
| Field | Details |
|
| Field | Details |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| **Protocol** | ACME Renewal Information (RFC 9702) |
|
| **Protocol** | ACME Renewal Information (RFC 9773) |
|
||||||
| **Cert ID Computation** | base64url(SHA-256(DER cert)) |
|
| **Cert ID Computation** | base64url(SHA-256(DER cert)) |
|
||||||
| **Suggested Window** | Start and end times provided by CA |
|
| **Suggested Window** | Start and end times provided by CA |
|
||||||
| **Renewal Timing** — If current time is after window start, renew immediately. Otherwise, wait until start time. |
|
| **Renewal Timing** — If current time is after window start, renew immediately. Otherwise, wait until start time. |
|
||||||
@@ -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** — Extensive coverage across service, handler, middleware, domain, and connector layers
|
||||||
- **Integration Tests** — End-to-end workflows (issuance→renewal→deployment)
|
- **Integration Tests** — End-to-end workflows (issuance→renewal→deployment) against live Docker Compose environment
|
||||||
- **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** — Vitest suite covering API client, utilities, stats/metrics, and full endpoint coverage
|
||||||
- **Total Coverage** — 900+ tests (Go + frontend combined)
|
- **CI Gates** — Per-layer coverage thresholds (service 60%, handler 60%, domain 40%, middleware 50%), race detection, static analysis, vulnerability scanning
|
||||||
|
|
||||||
### 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** | Extensively tested with CI-enforced coverage gates |
|
||||||
| **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
|
||||||
|
|||||||
+216
-8
@@ -39,7 +39,7 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp
|
|||||||
- [Part 32: Request Body Size Limits](#part-32-request-body-size-limits)
|
- [Part 32: Request Body Size Limits](#part-32-request-body-size-limits)
|
||||||
- [Part 33: Apache & HAProxy Target Connectors](#part-33-apache--haproxy-target-connectors)
|
- [Part 33: Apache & HAProxy Target Connectors](#part-33-apache--haproxy-target-connectors)
|
||||||
- [Part 34: Sub-CA Mode](#part-34-sub-ca-mode)
|
- [Part 34: Sub-CA Mode](#part-34-sub-ca-mode)
|
||||||
- [Part 35: ARI (RFC 9702) Scheduler Integration](#part-35-ari-rfc-9702-scheduler-integration)
|
- [Part 35: ARI (RFC 9773) Scheduler Integration](#part-35-ari-rfc-9773-scheduler-integration)
|
||||||
- [Part 36: Agent Work Routing (M31)](#part-36-agent-work-routing-m31)
|
- [Part 36: Agent Work Routing (M31)](#part-36-agent-work-routing-m31)
|
||||||
- [Part 37: GUI Completeness (Pre-2.1.0-E)](#part-37-gui-completeness-pre-210-e)
|
- [Part 37: GUI Completeness (Pre-2.1.0-E)](#part-37-gui-completeness-pre-210-e)
|
||||||
- [Part 38: Vault PKI Connector (M32)](#part-38-vault-pki-connector-m32)
|
- [Part 38: Vault PKI Connector (M32)](#part-38-vault-pki-connector-m32)
|
||||||
@@ -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" \
|
||||||
@@ -5077,7 +5077,7 @@ openssl crl -in /tmp/subca-crl.der -inform DER -noout -issuer
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Part 35: ARI (RFC 9702) Scheduler Integration
|
## Part 35: ARI (RFC 9773) Scheduler Integration
|
||||||
|
|
||||||
Tests that the renewal scheduler consults ARI before creating renewal jobs for ACME-issued certificates.
|
Tests that the renewal scheduler consults ARI before creating renewal jobs for ACME-issued certificates.
|
||||||
|
|
||||||
@@ -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 | |
|
||||||
@@ -6194,7 +6194,7 @@ These must be green before starting manual QA:
|
|||||||
| 34.5 | Sub-CA Key Format Support | Manual | ☐ | | |
|
| 34.5 | Sub-CA Key Format Support | Manual | ☐ | | |
|
||||||
| 34.6 | CRL Signing in Sub-CA Mode | Manual | ☐ | | |
|
| 34.6 | CRL Signing in Sub-CA Mode | Manual | ☐ | | |
|
||||||
|
|
||||||
### Part 35: ARI (RFC 9702) Scheduler Integration
|
### Part 35: ARI (RFC 9773) Scheduler Integration
|
||||||
|
|
||||||
| Test | Description | Method | Pass? | Date | Notes |
|
| Test | Description | Method | Pass? | Date | Notes |
|
||||||
|------|-------------|--------|-------|------|-------|
|
|------|-------------|--------|-------|------|-------|
|
||||||
@@ -6314,15 +6314,223 @@ 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.
|
||||||
|
|
||||||
|
### Part 45: F5 BIG-IP Target Connector (M40)
|
||||||
|
|
||||||
|
**Prerequisites:** F5 BIG-IP device (v12.0+) with iControl REST enabled, admin credentials, SSL client profile configured, proxy agent in same network zone.
|
||||||
|
|
||||||
|
#### Automated Tests
|
||||||
|
|
||||||
|
| Test | Description | Method | Pass? | Date | Notes |
|
||||||
|
|------|-------------|--------|-------|------|-------|
|
||||||
|
| 45.s1 | `TargetTypeF5` constant exists in domain | Auto | ☐ | | `grep 'TargetTypeF5' internal/domain/connector.go` |
|
||||||
|
| 45.s2 | F5 connector tests pass | Auto | ☐ | | `go test ./internal/connector/target/f5/... -v` |
|
||||||
|
| 45.s3 | F5 config fields in TargetsPage.tsx | Auto | ☐ | | `grep 'ssl_profile' web/src/pages/TargetsPage.tsx` |
|
||||||
|
| 45.s4 | F5 in OpenAPI TargetType enum | Auto | ☐ | | `grep 'F5' api/openapi.yaml` |
|
||||||
|
| 45.s5 | Agent dispatch handles F5 error return | Auto | ☐ | | `grep 'f5.New' cmd/agent/main.go` |
|
||||||
|
| 45.s6 | F5 connector docs updated (not "Interface Only") | Auto | ☐ | | `grep 'Implemented' docs/connectors.md` |
|
||||||
|
| 45.s7 | Frontend build succeeds | Auto | ☐ | | `cd web && npm run build` |
|
||||||
|
| 45.s8 | Full Go build succeeds | Auto | ☐ | | `go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/... ./cmd/mcp-server/...` |
|
||||||
|
|
||||||
|
#### Manual Tests
|
||||||
|
|
||||||
|
**45.M1: Validate F5 Connectivity**
|
||||||
|
|
||||||
|
1. Configure proxy agent with F5 target (host, username, password, partition, ssl_profile)
|
||||||
|
2. Trigger ValidateConfig — verify authentication succeeds
|
||||||
|
3. Verify log line: `F5 configuration validated`
|
||||||
|
|
||||||
|
**PASS if** auth token obtained, no errors.
|
||||||
|
|
||||||
|
**45.M2: Deploy Certificate to F5**
|
||||||
|
|
||||||
|
1. Create certificate, assign to F5 target via proxy agent
|
||||||
|
2. Trigger deployment — verify full iControl REST flow (upload → install → transaction → profile update → commit)
|
||||||
|
3. Verify SSL profile updated via F5 management GUI or `GET /mgmt/tm/ltm/profile/client-ssl/~Common~{profile}`
|
||||||
|
4. Verify virtual servers bound to the profile serve the new cert
|
||||||
|
|
||||||
|
**PASS if** certificate deployed, profile updated, virtual servers serving new cert.
|
||||||
|
|
||||||
|
**45.M3: Deploy Without Chain**
|
||||||
|
|
||||||
|
1. Issue a cert without chain (self-signed or single-issuer)
|
||||||
|
2. Deploy to F5 — verify chain upload/install steps are skipped
|
||||||
|
3. Verify profile updated with cert and key only (no chain field)
|
||||||
|
|
||||||
|
**PASS if** deployment succeeds without chain, profile has cert/key but no chain.
|
||||||
|
|
||||||
|
**45.M4: Transaction Rollback on Failure**
|
||||||
|
|
||||||
|
1. Configure an invalid SSL profile name
|
||||||
|
2. Trigger deployment — verify upload/install succeeds but profile update fails
|
||||||
|
3. Verify transaction rolled back (F5 auto-rollback)
|
||||||
|
4. Verify cleanup: uploaded crypto objects deleted from F5
|
||||||
|
|
||||||
|
**PASS if** error reported, crypto objects cleaned up.
|
||||||
|
|
||||||
|
**45.M5: Validate Deployment**
|
||||||
|
|
||||||
|
1. After successful deployment, call ValidateDeployment
|
||||||
|
2. Verify SSL profile queried and cert name returned in metadata
|
||||||
|
3. Verify `current_cert` metadata matches the deployed cert object name
|
||||||
|
|
||||||
|
**PASS if** validation returns Valid=true with correct cert reference.
|
||||||
|
|
||||||
|
**45.M6: Token Refresh on 401**
|
||||||
|
|
||||||
|
1. Deploy with valid credentials
|
||||||
|
2. Wait for token to expire (or manually invalidate)
|
||||||
|
3. Trigger another deployment — verify automatic re-authentication and retry
|
||||||
|
|
||||||
|
**PASS if** deployment succeeds after token refresh.
|
||||||
|
|
||||||
### 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) | 36 |
|
||||||
| — 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) | 259 |
|
||||||
| **Total** | **402** |
|
| **Total** | **444** |
|
||||||
|
|
||||||
**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 9773)
|
||||||
- **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.
|
||||||
|
|
||||||
|
**Extensively tested** — Go backend with race detection, static analysis (golangci-lint), and vulnerability scanning (govulncheck) on every commit. CI-enforced per-layer coverage thresholds. Frontend test suite. Every push is gated.
|
||||||
|
|
||||||
## 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 9773) 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
|
||||||
@@ -88,7 +88,7 @@ services:
|
|||||||
# Default is 30s; increase if your DNS propagates slowly
|
# Default is 30s; increase if your DNS propagates slowly
|
||||||
# Set via CERTCTL_ACME_DNS_PROPAGATION_WAIT in code, or rely on default
|
# Set via CERTCTL_ACME_DNS_PROPAGATION_WAIT in code, or rely on default
|
||||||
|
|
||||||
# Optional: Let's Encrypt Renewal Information (RFC 9702) for CA-directed renewal timing
|
# Optional: Let's Encrypt Renewal Information (RFC 9773) for CA-directed renewal timing
|
||||||
# CERTCTL_ACME_ARI_ENABLED: "true"
|
# CERTCTL_ACME_ARI_ENABLED: "true"
|
||||||
|
|
||||||
# Local CA as fallback for internal services (optional)
|
# Local CA as fallback for internal services (optional)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/crypto v0.31.0
|
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321
|
||||||
|
github.com/pkg/sftp v1.13.10
|
||||||
|
golang.org/x/crypto v0.41.0
|
||||||
software.sslmate.com/src/go-pkcs12 v0.7.0
|
software.sslmate.com/src/go-pkcs12 v0.7.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,11 +50,11 @@ require (
|
|||||||
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
|
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
|
||||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||||
github.com/klauspost/compress v1.17.4 // indirect
|
github.com/klauspost/compress v1.17.4 // indirect
|
||||||
|
github.com/kr/fs v0.1.0 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
github.com/magiconair/properties v1.8.7 // indirect
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect
|
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect
|
||||||
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321 // indirect
|
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||||
github.com/moby/sys/sequential v0.5.0 // indirect
|
github.com/moby/sys/sequential v0.5.0 // indirect
|
||||||
@@ -69,7 +71,7 @@ require (
|
|||||||
github.com/shirou/gopsutil/v3 v3.23.12 // indirect
|
github.com/shirou/gopsutil/v3 v3.23.12 // indirect
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
github.com/stretchr/testify v1.9.0 // indirect
|
github.com/stretchr/testify v1.10.0 // indirect
|
||||||
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde // indirect
|
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
@@ -79,9 +81,9 @@ require (
|
|||||||
go.opentelemetry.io/otel v1.24.0 // indirect
|
go.opentelemetry.io/otel v1.24.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
||||||
golang.org/x/net v0.23.0 // indirect
|
golang.org/x/net v0.42.0 // indirect
|
||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc
|
|||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||||
|
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
||||||
@@ -87,6 +89,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
|
|||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||||
|
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||||
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -121,6 +125,8 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ
|
|||||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||||
|
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
@@ -150,8 +156,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo=
|
github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo=
|
||||||
github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4=
|
github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4=
|
||||||
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde h1:AMNpJRc7P+GTwVbl8DkK2I9I8BBUzNiHuH/tlxrpan0=
|
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde h1:AMNpJRc7P+GTwVbl8DkK2I9I8BBUzNiHuH/tlxrpan0=
|
||||||
@@ -188,8 +194,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
|||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
@@ -202,8 +208,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
|||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
|
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -230,14 +236,14 @@ golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
|
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
|
||||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import (
|
|||||||
|
|
||||||
// MockTargetService is a mock implementation of TargetService interface.
|
// MockTargetService is a mock implementation of TargetService interface.
|
||||||
type MockTargetService struct {
|
type MockTargetService struct {
|
||||||
ListTargetsFn func(page, perPage int) ([]domain.DeploymentTarget, int64, error)
|
ListTargetsFn func(page, perPage int) ([]domain.DeploymentTarget, int64, error)
|
||||||
GetTargetFn func(id string) (*domain.DeploymentTarget, error)
|
GetTargetFn func(id string) (*domain.DeploymentTarget, error)
|
||||||
CreateTargetFn func(target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
CreateTargetFn func(target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
||||||
UpdateTargetFn func(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
UpdateTargetFn func(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
||||||
DeleteTargetFn func(id string) error
|
DeleteTargetFn func(id string) error
|
||||||
|
TestTargetConnectionFn func(id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockTargetService) ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error) {
|
func (m *MockTargetService) ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error) {
|
||||||
@@ -55,6 +56,13 @@ func (m *MockTargetService) DeleteTarget(id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockTargetService) TestTargetConnection(id string) error {
|
||||||
|
if m.TestTargetConnectionFn != nil {
|
||||||
|
return m.TestTargetConnectionFn(id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestListTargets_Success(t *testing.T) {
|
func TestListTargets_Success(t *testing.T) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
t1 := domain.DeploymentTarget{
|
t1 := domain.DeploymentTarget{
|
||||||
@@ -419,3 +427,69 @@ func TestDeleteTarget_EmptyID(t *testing.T) {
|
|||||||
t.Fatalf("expected status 400, got %d", w.Code)
|
t.Fatalf("expected status 400, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTestTargetConnection_Success(t *testing.T) {
|
||||||
|
mock := &MockTargetService{
|
||||||
|
TestTargetConnectionFn: func(id string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewTargetHandler(mock)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets/t-nginx-01/test", nil)
|
||||||
|
req = req.WithContext(contextWithRequestID())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.TestTargetConnection(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["status"] != "success" {
|
||||||
|
t.Errorf("expected status 'success', got %v", resp["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestTargetConnection_Failed(t *testing.T) {
|
||||||
|
mock := &MockTargetService{
|
||||||
|
TestTargetConnectionFn: func(id string) error {
|
||||||
|
return ErrMockServiceFailed
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewTargetHandler(mock)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets/t-nginx-01/test", nil)
|
||||||
|
req = req.WithContext(contextWithRequestID())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.TestTargetConnection(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["status"] != "failed" {
|
||||||
|
t.Errorf("expected status 'failed', got %v", resp["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestTargetConnection_MethodNotAllowed(t *testing.T) {
|
||||||
|
handler := NewTargetHandler(&MockTargetService{})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/targets/t-nginx-01/test", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.TestTargetConnection(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("expected status 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type TargetService interface {
|
|||||||
CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
||||||
UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
||||||
DeleteTarget(id string) error
|
DeleteTarget(id string) error
|
||||||
|
TestTargetConnection(id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// TargetHandler handles HTTP requests for deployment target operations.
|
// TargetHandler handles HTTP requests for deployment target operations.
|
||||||
@@ -189,3 +190,36 @@ func (h TargetHandler) DeleteTarget(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTargetConnection tests target connectivity by checking the assigned agent's heartbeat.
|
||||||
|
// POST /api/v1/targets/{id}/test
|
||||||
|
func (h TargetHandler) TestTargetConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
|
|
||||||
|
// Extract target ID from path: /api/v1/targets/{id}/test
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/targets/")
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
if len(parts) < 2 || parts[0] == "" {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "Target ID is required", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := parts[0]
|
||||||
|
|
||||||
|
if err := h.svc.TestTargetConnection(id); err != nil {
|
||||||
|
JSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "failed",
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Agent is online and reachable",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
|||||||
r.Register("GET /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.GetTarget))
|
r.Register("GET /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.GetTarget))
|
||||||
r.Register("PUT /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.UpdateTarget))
|
r.Register("PUT /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.UpdateTarget))
|
||||||
r.Register("DELETE /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.DeleteTarget))
|
r.Register("DELETE /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.DeleteTarget))
|
||||||
|
r.Register("POST /api/v1/targets/{id}/test", http.HandlerFunc(reg.Targets.TestTargetConnection))
|
||||||
|
|
||||||
// Agents routes: /api/v1/agents
|
// Agents routes: /api/v1/agents
|
||||||
r.Register("GET /api/v1/agents", http.HandlerFunc(reg.Agents.ListAgents))
|
r.Register("GET /api/v1/agents", http.HandlerFunc(reg.Agents.ListAgents))
|
||||||
|
|||||||
+102
-1
@@ -27,7 +27,17 @@ type Config struct {
|
|||||||
ACME ACMEConfig
|
ACME ACMEConfig
|
||||||
Vault VaultConfig
|
Vault VaultConfig
|
||||||
DigiCert DigiCertConfig
|
DigiCert DigiCertConfig
|
||||||
|
Sectigo SectigoConfig
|
||||||
|
GoogleCAS GoogleCASConfig
|
||||||
Digest DigestConfig
|
Digest DigestConfig
|
||||||
|
Encryption EncryptionConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptionConfig contains configuration for encrypting sensitive data at rest.
|
||||||
|
type EncryptionConfig struct {
|
||||||
|
// ConfigEncryptionKey is the passphrase used to derive AES-256-GCM keys for encrypting
|
||||||
|
// issuer config secrets in the database. If empty, configs are stored in plaintext (development only).
|
||||||
|
ConfigEncryptionKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotifierConfig contains configuration for notification connectors.
|
// NotifierConfig contains configuration for notification connectors.
|
||||||
@@ -194,6 +204,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.
|
||||||
@@ -250,7 +325,13 @@ type ACMEConfig struct {
|
|||||||
// The record value becomes: "<issuer_domain>; accounturi=<acme_account_uri>"
|
// The record value becomes: "<issuer_domain>; accounturi=<acme_account_uri>"
|
||||||
DNSPersistIssuerDomain string
|
DNSPersistIssuerDomain string
|
||||||
|
|
||||||
// ARIEnabled enables ACME Renewal Information (RFC 9702) support.
|
// Profile selects the ACME certificate profile for newOrder requests.
|
||||||
|
// Let's Encrypt supports "tlsserver" (standard TLS) and "shortlived" (6-day certs).
|
||||||
|
// Leave empty for the CA's default profile (backward-compatible).
|
||||||
|
// Setting: CERTCTL_ACME_PROFILE environment variable.
|
||||||
|
Profile string
|
||||||
|
|
||||||
|
// ARIEnabled enables ACME Renewal Information (RFC 9773) support.
|
||||||
// When enabled, the renewal scheduler queries the CA for suggested renewal windows
|
// When enabled, the renewal scheduler queries the CA for suggested renewal windows
|
||||||
// instead of relying solely on static expiration thresholds.
|
// instead of relying solely on static expiration thresholds.
|
||||||
// Default: false. Requires a CA that supports ARI (e.g., Let's Encrypt).
|
// Default: false. Requires a CA that supports ARI (e.g., Let's Encrypt).
|
||||||
@@ -500,6 +581,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", ""),
|
||||||
@@ -507,6 +604,7 @@ func Load() (*Config, error) {
|
|||||||
DNSPresentScript: getEnv("CERTCTL_ACME_DNS_PRESENT_SCRIPT", ""),
|
DNSPresentScript: getEnv("CERTCTL_ACME_DNS_PRESENT_SCRIPT", ""),
|
||||||
DNSCleanUpScript: getEnv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT", ""),
|
DNSCleanUpScript: getEnv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT", ""),
|
||||||
DNSPersistIssuerDomain: getEnv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN", ""),
|
DNSPersistIssuerDomain: getEnv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN", ""),
|
||||||
|
Profile: getEnv("CERTCTL_ACME_PROFILE", ""),
|
||||||
ARIEnabled: getEnvBool("CERTCTL_ACME_ARI_ENABLED", false),
|
ARIEnabled: getEnvBool("CERTCTL_ACME_ARI_ENABLED", false),
|
||||||
Insecure: getEnvBool("CERTCTL_ACME_INSECURE", false),
|
Insecure: getEnvBool("CERTCTL_ACME_INSECURE", false),
|
||||||
},
|
},
|
||||||
@@ -515,6 +613,9 @@ func Load() (*Config, error) {
|
|||||||
Interval: getEnvDuration("CERTCTL_DIGEST_INTERVAL", 24*time.Hour),
|
Interval: getEnvDuration("CERTCTL_DIGEST_INTERVAL", 24*time.Hour),
|
||||||
Recipients: getEnvList("CERTCTL_DIGEST_RECIPIENTS", nil),
|
Recipients: getEnvList("CERTCTL_DIGEST_RECIPIENTS", nil),
|
||||||
},
|
},
|
||||||
|
Encryption: EncryptionConfig{
|
||||||
|
ConfigEncryptionKey: getEnv("CERTCTL_CONFIG_ENCRYPTION_KEY", ""),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
|
|||||||
@@ -56,7 +56,13 @@ type Config struct {
|
|||||||
// Required when ChallengeType is "dns-persist-01". For Let's Encrypt, use "letsencrypt.org".
|
// Required when ChallengeType is "dns-persist-01". For Let's Encrypt, use "letsencrypt.org".
|
||||||
DNSPersistIssuerDomain string `json:"dns_persist_issuer_domain,omitempty"`
|
DNSPersistIssuerDomain string `json:"dns_persist_issuer_domain,omitempty"`
|
||||||
|
|
||||||
// ARIEnabled enables ACME Renewal Information (RFC 9702) support per CERTCTL_ACME_ARI_ENABLED.
|
// Profile selects the ACME certificate profile for the newOrder request.
|
||||||
|
// Let's Encrypt supports "tlsserver" (standard TLS, default) and "shortlived" (6-day certs).
|
||||||
|
// Leave empty for the CA's default profile (backward-compatible).
|
||||||
|
// See: https://letsencrypt.org/2025/01/09/acme-profiles.html
|
||||||
|
Profile string `json:"profile,omitempty"`
|
||||||
|
|
||||||
|
// ARIEnabled enables ACME Renewal Information (RFC 9773) support per CERTCTL_ACME_ARI_ENABLED.
|
||||||
// When enabled, the connector queries the CA's ARI endpoint to get CA-directed renewal timing.
|
// When enabled, the connector queries the CA's ARI endpoint to get CA-directed renewal timing.
|
||||||
ARIEnabled bool `json:"ari_enabled,omitempty"`
|
ARIEnabled bool `json:"ari_enabled,omitempty"`
|
||||||
|
|
||||||
@@ -184,6 +190,15 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
|||||||
return fmt.Errorf("invalid challenge_type: %s (must be http-01, dns-01, or dns-persist-01)", cfg.ChallengeType)
|
return fmt.Errorf("invalid challenge_type: %s (must be http-01, dns-01, or dns-persist-01)", cfg.ChallengeType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate profile if set (alphanumeric + hyphens only)
|
||||||
|
if cfg.Profile != "" {
|
||||||
|
for _, ch := range cfg.Profile {
|
||||||
|
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-') {
|
||||||
|
return fmt.Errorf("invalid profile: %q (must contain only alphanumeric characters and hyphens)", cfg.Profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DNS-01 and DNS-PERSIST-01 require a present script
|
// DNS-01 and DNS-PERSIST-01 require a present script
|
||||||
if (cfg.ChallengeType == "dns-01" || cfg.ChallengeType == "dns-persist-01") && cfg.DNSPresentScript == "" {
|
if (cfg.ChallengeType == "dns-01" || cfg.ChallengeType == "dns-persist-01") && cfg.DNSPresentScript == "" {
|
||||||
return fmt.Errorf("dns_present_script is required for %s challenge type", cfg.ChallengeType)
|
return fmt.Errorf("dns_present_script is required for %s challenge type", cfg.ChallengeType)
|
||||||
@@ -355,8 +370,8 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
|
|||||||
// Build the list of identifiers (domains)
|
// Build the list of identifiers (domains)
|
||||||
identifiers := buildIdentifiers(request.CommonName, request.SANs)
|
identifiers := buildIdentifiers(request.CommonName, request.SANs)
|
||||||
|
|
||||||
// Step 1: Create order
|
// Step 1: Create order (with optional profile for CAs that support it)
|
||||||
order, err := c.client.AuthorizeOrder(ctx, identifiers)
|
order, err := c.authorizeOrderWithProfile(ctx, identifiers, c.config.Profile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create ACME order: %w", err)
|
return nil, fmt.Errorf("failed to create ACME order: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetRenewalInfo retrieves ACME Renewal Information (ARI) per RFC 9702 for a certificate.
|
// GetRenewalInfo retrieves ACME Renewal Information (ARI) per RFC 9773 for a certificate.
|
||||||
// certPEM is the PEM-encoded certificate. Returns nil, nil if the CA does not support ARI.
|
// certPEM is the PEM-encoded certificate. Returns nil, nil if the CA does not support ARI.
|
||||||
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||||
if !c.config.ARIEnabled {
|
if !c.config.ARIEnabled {
|
||||||
@@ -102,7 +102,7 @@ func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// computeARICertID computes the ARI certificate ID as defined in RFC 9702.
|
// computeARICertID computes the ARI certificate ID as defined in RFC 9773.
|
||||||
// The cert ID is base64url(SHA256(DER encoding of the certificate)).
|
// The cert ID is base64url(SHA256(DER encoding of the certificate)).
|
||||||
func computeARICertID(certPEM string) (string, error) {
|
func computeARICertID(certPEM string) (string, error) {
|
||||||
block, _ := pem.Decode([]byte(certPEM))
|
block, _ := pem.Decode([]byte(certPEM))
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
goacme "golang.org/x/crypto/acme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// profileOrderRequest is the JSON body for a newOrder request with optional profile field.
|
||||||
|
// The profile field is an ACME extension for certificate profile selection
|
||||||
|
// (e.g., Let's Encrypt "shortlived" for 6-day certs, "tlsserver" for standard TLS).
|
||||||
|
type profileOrderRequest struct {
|
||||||
|
Identifiers []wireAuthzID `json:"identifiers"`
|
||||||
|
NotBefore string `json:"notBefore,omitempty"`
|
||||||
|
NotAfter string `json:"notAfter,omitempty"`
|
||||||
|
Profile string `json:"profile,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireAuthzID matches the ACME wire format for authorization identifiers.
|
||||||
|
type wireAuthzID struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileOrderResponse represents a parsed ACME order response.
|
||||||
|
type profileOrderResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Expires string `json:"expires,omitempty"`
|
||||||
|
Identifiers []wireAuthzID `json:"identifiers"`
|
||||||
|
AuthzURLs []string `json:"authorizations"`
|
||||||
|
FinalizeURL string `json:"finalize"`
|
||||||
|
CertURL string `json:"certificate,omitempty"`
|
||||||
|
Error *goacme.Error `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorizeOrderWithProfile creates a new ACME order with an optional certificate profile.
|
||||||
|
// This bypasses acme.Client.AuthorizeOrder() because the Go ACME library does not support
|
||||||
|
// the "profile" field in newOrder requests (as of golang.org/x/crypto v0.49.0).
|
||||||
|
//
|
||||||
|
// When profile is empty, this delegates to the standard acme.Client.AuthorizeOrder().
|
||||||
|
// When profile is set, it performs a custom JWS-signed POST to the newOrder endpoint
|
||||||
|
// with the profile field included in the request body.
|
||||||
|
func (c *Connector) authorizeOrderWithProfile(ctx context.Context, identifiers []goacme.AuthzID, profile string) (*goacme.Order, error) {
|
||||||
|
// Fast path: no profile → use the standard library path
|
||||||
|
if profile == "" {
|
||||||
|
return c.client.AuthorizeOrder(ctx, identifiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("creating ACME order with profile", "profile", profile)
|
||||||
|
|
||||||
|
// Discover the directory to get the newOrder URL
|
||||||
|
dir, err := c.client.Discover(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ACME directory discovery failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dir.OrderURL == "" {
|
||||||
|
return nil, fmt.Errorf("ACME directory has no newOrder URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the account URL (kid) for the JWS protected header
|
||||||
|
acct, err := c.client.GetReg(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get ACME account for JWS signing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the order request with profile
|
||||||
|
var wireIDs []wireAuthzID
|
||||||
|
for _, id := range identifiers {
|
||||||
|
wireIDs = append(wireIDs, wireAuthzID{Type: id.Type, Value: id.Value})
|
||||||
|
}
|
||||||
|
|
||||||
|
orderReq := profileOrderRequest{
|
||||||
|
Identifiers: wireIDs,
|
||||||
|
Profile: profile,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(orderReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal order request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch a fresh nonce
|
||||||
|
nonce, err := c.fetchNonce(ctx, dir.NonceURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the request with JWS (ES256, kid mode)
|
||||||
|
jwsBody, err := signJWS(c.accountKey, acct.URI, nonce, dir.OrderURL, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("JWS signing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST the JWS-signed request
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, dir.OrderURL, strings.NewReader(string(jwsBody)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/jose+json")
|
||||||
|
|
||||||
|
httpClient := c.httpClient()
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("newOrder request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read newOrder response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("newOrder returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response into an acme.Order-compatible struct
|
||||||
|
var orderResp profileOrderResponse
|
||||||
|
if err := json.Unmarshal(body, &orderResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse newOrder response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The order URI comes from the Location header
|
||||||
|
orderURI := resp.Header.Get("Location")
|
||||||
|
|
||||||
|
order := &goacme.Order{
|
||||||
|
URI: orderURI,
|
||||||
|
Status: orderResp.Status,
|
||||||
|
AuthzURLs: orderResp.AuthzURLs,
|
||||||
|
FinalizeURL: orderResp.FinalizeURL,
|
||||||
|
CertURL: orderResp.CertURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse identifiers back
|
||||||
|
for _, wid := range orderResp.Identifiers {
|
||||||
|
order.Identifiers = append(order.Identifiers, goacme.AuthzID{Type: wid.Type, Value: wid.Value})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("ACME order created with profile",
|
||||||
|
"profile", profile,
|
||||||
|
"order_url", orderURI,
|
||||||
|
"status", order.Status)
|
||||||
|
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchNonce retrieves a fresh anti-replay nonce from the ACME server.
|
||||||
|
func (c *Connector) fetchNonce(ctx context.Context, nonceURL string) (string, error) {
|
||||||
|
if nonceURL == "" {
|
||||||
|
return "", fmt.Errorf("no nonce URL available")
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodHead, nonceURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create nonce request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := c.httpClient()
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("nonce request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
nonce := resp.Header.Get("Replay-Nonce")
|
||||||
|
if nonce == "" {
|
||||||
|
return "", fmt.Errorf("server did not return a Replay-Nonce header")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nonce, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// signJWS creates a JWS (JSON Web Signature) in flattened JSON serialization
|
||||||
|
// using ES256 (ECDSA P-256 with SHA-256) in kid mode per RFC 8555.
|
||||||
|
//
|
||||||
|
// The JWS protected header contains:
|
||||||
|
// - alg: ES256
|
||||||
|
// - kid: account URL
|
||||||
|
// - nonce: anti-replay nonce
|
||||||
|
// - url: the target URL
|
||||||
|
func signJWS(key *ecdsa.PrivateKey, kid, nonce, targetURL string, payload []byte) ([]byte, error) {
|
||||||
|
// Build protected header
|
||||||
|
header := struct {
|
||||||
|
Alg string `json:"alg"`
|
||||||
|
Kid string `json:"kid"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}{
|
||||||
|
Alg: "ES256",
|
||||||
|
Kid: kid,
|
||||||
|
Nonce: nonce,
|
||||||
|
URL: targetURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
headerJSON, err := json.Marshal(header)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal JWS header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64url encode protected header and payload
|
||||||
|
protectedB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||||
|
payloadB64 := base64.RawURLEncoding.EncodeToString(payload)
|
||||||
|
|
||||||
|
// Create the signing input: ASCII(BASE64URL(header)) || '.' || ASCII(BASE64URL(payload))
|
||||||
|
signingInput := protectedB64 + "." + payloadB64
|
||||||
|
|
||||||
|
// Sign with ES256 (ECDSA P-256 + SHA-256)
|
||||||
|
hash := sha256.Sum256([]byte(signingInput))
|
||||||
|
r, s, err := ecdsa.Sign(rand.Reader, key, hash[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ECDSA sign: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode signature as fixed-size concatenation of r and s (32 bytes each for P-256)
|
||||||
|
curveBits := key.Curve.Params().BitSize
|
||||||
|
keyBytes := curveBits / 8
|
||||||
|
if curveBits%8 > 0 {
|
||||||
|
keyBytes++
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := make([]byte, 2*keyBytes)
|
||||||
|
rBytes := r.Bytes()
|
||||||
|
sBytes := s.Bytes()
|
||||||
|
copy(sig[keyBytes-len(rBytes):keyBytes], rBytes)
|
||||||
|
copy(sig[2*keyBytes-len(sBytes):], sBytes)
|
||||||
|
|
||||||
|
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
|
||||||
|
|
||||||
|
// Build flattened JWS JSON
|
||||||
|
jws := struct {
|
||||||
|
Protected string `json:"protected"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
}{
|
||||||
|
Protected: protectedB64,
|
||||||
|
Payload: payloadB64,
|
||||||
|
Signature: sigB64,
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(jws)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
package acme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
goacme "golang.org/x/crypto/acme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// verifyJWSSignature is a test helper that verifies a JWS signature.
|
||||||
|
func verifyJWSSignature(jwsJSON []byte, pubKey *ecdsa.PublicKey) error {
|
||||||
|
var jws struct {
|
||||||
|
Protected string `json:"protected"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(jwsJSON, &jws); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal JWS: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signingInput := jws.Protected + "." + jws.Payload
|
||||||
|
hash := sha256.Sum256([]byte(signingInput))
|
||||||
|
|
||||||
|
sigBytes, err := base64.RawURLEncoding.DecodeString(jws.Signature)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode signature: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyBytes := pubKey.Curve.Params().BitSize / 8
|
||||||
|
if len(sigBytes) != 2*keyBytes {
|
||||||
|
return fmt.Errorf("invalid signature length: %d (expected %d)", len(sigBytes), 2*keyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := new(big.Int).SetBytes(sigBytes[:keyBytes])
|
||||||
|
s := new(big.Int).SetBytes(sigBytes[keyBytes:])
|
||||||
|
|
||||||
|
if !ecdsa.Verify(pubKey, hash[:], r, s) {
|
||||||
|
return fmt.Errorf("signature verification failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_ProfileValid(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(nil, testLogger())
|
||||||
|
cfg, _ := json.Marshal(map[string]string{
|
||||||
|
"directory_url": srv.URL,
|
||||||
|
"email": "test@example.com",
|
||||||
|
"profile": "shortlived",
|
||||||
|
})
|
||||||
|
err := c.ValidateConfig(context.Background(), cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success with valid profile, got: %v", err)
|
||||||
|
}
|
||||||
|
if c.config.Profile != "shortlived" {
|
||||||
|
t.Errorf("expected profile 'shortlived', got: %s", c.config.Profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_ProfileTLSServer(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(nil, testLogger())
|
||||||
|
cfg, _ := json.Marshal(map[string]string{
|
||||||
|
"directory_url": srv.URL,
|
||||||
|
"email": "test@example.com",
|
||||||
|
"profile": "tlsserver",
|
||||||
|
})
|
||||||
|
err := c.ValidateConfig(context.Background(), cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success with valid profile, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_ProfileEmpty(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(nil, testLogger())
|
||||||
|
cfg, _ := json.Marshal(map[string]string{
|
||||||
|
"directory_url": srv.URL,
|
||||||
|
"email": "test@example.com",
|
||||||
|
"profile": "",
|
||||||
|
})
|
||||||
|
err := c.ValidateConfig(context.Background(), cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success with empty profile, got: %v", err)
|
||||||
|
}
|
||||||
|
if c.config.Profile != "" {
|
||||||
|
t.Errorf("expected empty profile, got: %s", c.config.Profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_ProfileInvalid(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(nil, testLogger())
|
||||||
|
cfg, _ := json.Marshal(map[string]string{
|
||||||
|
"directory_url": srv.URL,
|
||||||
|
"email": "test@example.com",
|
||||||
|
"profile": "short lived!",
|
||||||
|
})
|
||||||
|
err := c.ValidateConfig(context.Background(), cfg)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid profile") {
|
||||||
|
t.Fatalf("expected invalid profile error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignJWS_ES256(t *testing.T) {
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := []byte(`{"identifiers":[{"type":"dns","value":"example.com"}],"profile":"shortlived"}`)
|
||||||
|
|
||||||
|
jwsBody, err := signJWS(key, "https://acme.example.com/acct/1", "nonce-abc", "https://acme.example.com/new-order", payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("signJWS failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JWS
|
||||||
|
var jws struct {
|
||||||
|
Protected string `json:"protected"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(jwsBody, &jws); err != nil {
|
||||||
|
t.Fatalf("JWS is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify protected header
|
||||||
|
headerBytes, err := base64.RawURLEncoding.DecodeString(jws.Protected)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode protected header: %v", err)
|
||||||
|
}
|
||||||
|
var header struct {
|
||||||
|
Alg string `json:"alg"`
|
||||||
|
Kid string `json:"kid"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(headerBytes, &header); err != nil {
|
||||||
|
t.Fatalf("parse header: %v", err)
|
||||||
|
}
|
||||||
|
if header.Alg != "ES256" {
|
||||||
|
t.Errorf("expected alg ES256, got: %s", header.Alg)
|
||||||
|
}
|
||||||
|
if header.Kid != "https://acme.example.com/acct/1" {
|
||||||
|
t.Errorf("expected kid URL, got: %s", header.Kid)
|
||||||
|
}
|
||||||
|
if header.Nonce != "nonce-abc" {
|
||||||
|
t.Errorf("expected nonce, got: %s", header.Nonce)
|
||||||
|
}
|
||||||
|
if header.URL != "https://acme.example.com/new-order" {
|
||||||
|
t.Errorf("expected url, got: %s", header.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify payload
|
||||||
|
payloadBytes, err := base64.RawURLEncoding.DecodeString(jws.Payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode payload: %v", err)
|
||||||
|
}
|
||||||
|
var payloadObj struct {
|
||||||
|
Profile string `json:"profile"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payloadBytes, &payloadObj); err != nil {
|
||||||
|
t.Fatalf("parse payload: %v", err)
|
||||||
|
}
|
||||||
|
if payloadObj.Profile != "shortlived" {
|
||||||
|
t.Errorf("expected profile 'shortlived' in payload, got: %s", payloadObj.Profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
if err := verifyJWSSignature(jwsBody, &key.PublicKey); err != nil {
|
||||||
|
t.Fatalf("signature verification failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorizeOrderWithProfile_EmptyProfile_DelegatesToStandard(t *testing.T) {
|
||||||
|
// When profile is empty, authorizeOrderWithProfile should call the standard
|
||||||
|
// acme.Client.AuthorizeOrder. Since we can't mock a full ACME server for that,
|
||||||
|
// we verify it returns an error (unreachable server) rather than trying the custom path.
|
||||||
|
c := New(&Config{
|
||||||
|
DirectoryURL: "https://127.0.0.1:1/directory",
|
||||||
|
Email: "test@example.com",
|
||||||
|
ChallengeType: "http-01",
|
||||||
|
Profile: "",
|
||||||
|
}, testLogger())
|
||||||
|
|
||||||
|
// Need to initialize the client first
|
||||||
|
c.accountKey, _ = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
c.client = &goacme.Client{
|
||||||
|
Key: c.accountKey,
|
||||||
|
DirectoryURL: c.config.DirectoryURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
identifiers := []goacme.AuthzID{{Type: "dns", Value: "example.com"}}
|
||||||
|
_, err := c.authorizeOrderWithProfile(context.Background(), identifiers, "")
|
||||||
|
// Expected: network error from standard acme.Client.AuthorizeOrder
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error from unreachable server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorizeOrderWithProfile_WithProfile_SendsProfileInBody(t *testing.T) {
|
||||||
|
var receivedBody []byte
|
||||||
|
|
||||||
|
// Mock ACME server that captures the newOrder request body
|
||||||
|
mockSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/directory":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"newNonce": r.Host + "/new-nonce",
|
||||||
|
"newAccount": r.Host + "/new-account",
|
||||||
|
"newOrder": "http://" + r.Host + "/new-order",
|
||||||
|
})
|
||||||
|
case "/new-nonce":
|
||||||
|
w.Header().Set("Replay-Nonce", "test-nonce-12345")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
case "/acme/acct/1":
|
||||||
|
// Account lookup
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "valid",
|
||||||
|
})
|
||||||
|
case "/new-order":
|
||||||
|
// Capture the JWS body
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
receivedBody = body
|
||||||
|
|
||||||
|
// Return a valid order response
|
||||||
|
w.Header().Set("Location", "http://"+r.Host+"/order/123")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "pending",
|
||||||
|
"identifiers": []map[string]string{
|
||||||
|
{"type": "dns", "value": "example.com"},
|
||||||
|
},
|
||||||
|
"authorizations": []string{"http://" + r.Host + "/authz/1"},
|
||||||
|
"finalize": "http://" + r.Host + "/finalize/123",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer mockSrv.Close()
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||||
|
|
||||||
|
c := New(&Config{
|
||||||
|
DirectoryURL: mockSrv.URL + "/directory",
|
||||||
|
Email: "test@example.com",
|
||||||
|
ChallengeType: "http-01",
|
||||||
|
Profile: "shortlived",
|
||||||
|
}, logger)
|
||||||
|
|
||||||
|
// Initialize client manually (bypass full ACME registration)
|
||||||
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
c.accountKey = key
|
||||||
|
c.client = &goacme.Client{
|
||||||
|
Key: key,
|
||||||
|
DirectoryURL: c.config.DirectoryURL,
|
||||||
|
HTTPClient: c.httpClient(),
|
||||||
|
}
|
||||||
|
|
||||||
|
identifiers := []goacme.AuthzID{{Type: "dns", Value: "example.com"}}
|
||||||
|
order, err := c.authorizeOrderWithProfile(context.Background(), identifiers, "shortlived")
|
||||||
|
|
||||||
|
// The call may fail at GetReg since we're not running a real ACME server.
|
||||||
|
// That's okay — we primarily want to verify the profile flow is entered.
|
||||||
|
if err != nil {
|
||||||
|
// Expected: GetReg will fail since we don't have a real ACME account.
|
||||||
|
// But let's check if it at least tried the profile path by checking the error message.
|
||||||
|
if strings.Contains(err.Error(), "ACME account") || strings.Contains(err.Error(), "JWS signing") || strings.Contains(err.Error(), "newOrder") {
|
||||||
|
// This is expected — the profile path was entered but the mock doesn't support full ACME
|
||||||
|
t.Logf("profile path entered, expected error from mock: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got an order, verify it
|
||||||
|
if order != nil {
|
||||||
|
if order.Status != "pending" {
|
||||||
|
t.Errorf("expected status pending, got: %s", order.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the JWS body contained the profile field
|
||||||
|
if len(receivedBody) > 0 {
|
||||||
|
// Parse the JWS to extract the payload
|
||||||
|
var jws struct {
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(receivedBody, &jws); err == nil {
|
||||||
|
payloadBytes, _ := base64.RawURLEncoding.DecodeString(jws.Payload)
|
||||||
|
var payload struct {
|
||||||
|
Profile string `json:"profile"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payloadBytes, &payload); err == nil {
|
||||||
|
if payload.Profile != "shortlived" {
|
||||||
|
t.Errorf("expected profile 'shortlived' in JWS payload, got: %q", payload.Profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileOrderRequest_NoProfile_OmitsField(t *testing.T) {
|
||||||
|
req := profileOrderRequest{
|
||||||
|
Identifiers: []wireAuthzID{{Type: "dns", Value: "example.com"}},
|
||||||
|
Profile: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With omitempty, empty profile should not appear in JSON
|
||||||
|
if strings.Contains(string(data), "profile") {
|
||||||
|
t.Errorf("expected no profile field in JSON when empty, got: %s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileOrderRequest_WithProfile_IncludesField(t *testing.T) {
|
||||||
|
req := profileOrderRequest{
|
||||||
|
Identifiers: []wireAuthzID{{Type: "dns", Value: "example.com"}},
|
||||||
|
Profile: "shortlived",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(data), `"profile":"shortlived"`) {
|
||||||
|
t.Errorf("expected profile field in JSON, got: %s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigProfileUnmarshal(t *testing.T) {
|
||||||
|
// Verify that the factory (json.Unmarshal) correctly picks up the profile field
|
||||||
|
configJSON := `{"directory_url":"https://acme.example.com/dir","email":"test@example.com","profile":"shortlived","ari_enabled":true}`
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Profile != "shortlived" {
|
||||||
|
t.Errorf("expected profile 'shortlived', got: %q", cfg.Profile)
|
||||||
|
}
|
||||||
|
if cfg.DirectoryURL != "https://acme.example.com/dir" {
|
||||||
|
t.Errorf("expected directory URL, got: %q", cfg.DirectoryURL)
|
||||||
|
}
|
||||||
|
if !cfg.ARIEnabled {
|
||||||
|
t.Error("expected ARIEnabled true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigProfileUnmarshal_Empty(t *testing.T) {
|
||||||
|
// Empty profile should remain empty (backward compat)
|
||||||
|
configJSON := `{"directory_url":"https://acme.example.com/dir","email":"test@example.com"}`
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Profile != "" {
|
||||||
|
t.Errorf("expected empty profile, got: %q", cfg.Profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchNonce_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Replay-Nonce", "test-nonce-xyz")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(&Config{
|
||||||
|
DirectoryURL: srv.URL + "/directory",
|
||||||
|
}, testLogger())
|
||||||
|
|
||||||
|
nonce, err := c.fetchNonce(context.Background(), srv.URL+"/new-nonce")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetchNonce failed: %v", err)
|
||||||
|
}
|
||||||
|
if nonce != "test-nonce-xyz" {
|
||||||
|
t.Errorf("expected nonce 'test-nonce-xyz', got: %s", nonce)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchNonce_MissingHeader(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(&Config{
|
||||||
|
DirectoryURL: srv.URL + "/directory",
|
||||||
|
}, testLogger())
|
||||||
|
|
||||||
|
_, err := c.fetchNonce(context.Background(), srv.URL+"/new-nonce")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "Replay-Nonce") {
|
||||||
|
t.Fatalf("expected missing nonce error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package issuer
|
||||||
|
|
||||||
|
// Factory has been moved to internal/connector/issuerfactory to avoid import cycles.
|
||||||
|
// See issuerfactory.NewFromConfig().
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package issuer
|
||||||
|
|
||||||
|
// Factory tests have been moved to internal/connector/issuerfactory.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ type Connector interface {
|
|||||||
// Used by the EST /cacerts endpoint. Returns empty string if not available.
|
// Used by the EST /cacerts endpoint. Returns empty string if not available.
|
||||||
GetCACertPEM(ctx context.Context) (string, error)
|
GetCACertPEM(ctx context.Context) (string, error)
|
||||||
|
|
||||||
// GetRenewalInfo retrieves ACME Renewal Information (ARI) per RFC 9702 for a certificate.
|
// GetRenewalInfo retrieves ACME Renewal Information (ARI) per RFC 9773 for a certificate.
|
||||||
// certPEM is the PEM-encoded certificate. Returns nil, nil if the CA does not support ARI.
|
// certPEM is the PEM-encoded certificate. Returns nil, nil if the CA does not support ARI.
|
||||||
GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error)
|
GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,87 @@
|
|||||||
|
package issuerfactory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/acme"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/digicert"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/googlecas"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/openssl"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/sectigo"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewFromConfig instantiates an issuer connector from its type string and config JSON.
|
||||||
|
// The config JSON keys use snake_case matching the connector Config struct json tags.
|
||||||
|
// This replaces the manual wiring in cmd/server/main.go.
|
||||||
|
func NewFromConfig(issuerType string, configJSON json.RawMessage, logger *slog.Logger) (issuer.Connector, error) {
|
||||||
|
if len(configJSON) == 0 {
|
||||||
|
configJSON = []byte("{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch issuerType {
|
||||||
|
case "local", "GenericCA":
|
||||||
|
var cfg local.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Local CA config: %w", err)
|
||||||
|
}
|
||||||
|
return local.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
case "ACME":
|
||||||
|
var cfg acme.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid ACME config: %w", err)
|
||||||
|
}
|
||||||
|
return acme.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
case "StepCA":
|
||||||
|
var cfg stepca.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid step-ca config: %w", err)
|
||||||
|
}
|
||||||
|
return stepca.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
case "OpenSSL":
|
||||||
|
var cfg openssl.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid OpenSSL config: %w", err)
|
||||||
|
}
|
||||||
|
return openssl.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
case "VaultPKI":
|
||||||
|
var cfg vault.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Vault PKI config: %w", err)
|
||||||
|
}
|
||||||
|
return vault.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
case "DigiCert":
|
||||||
|
var cfg digicert.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid DigiCert config: %w", err)
|
||||||
|
}
|
||||||
|
return digicert.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
case "Sectigo":
|
||||||
|
var cfg sectigo.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Sectigo config: %w", err)
|
||||||
|
}
|
||||||
|
return sectigo.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
case "GoogleCAS":
|
||||||
|
var cfg googlecas.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Google CAS config: %w", err)
|
||||||
|
}
|
||||||
|
return googlecas.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown issuer type: %q", issuerType)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package issuerfactory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_LocalCA(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"ca_common_name":"Test CA"}`)
|
||||||
|
conn, err := NewFromConfig("local", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(local) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_GenericCA_Alias(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{}`)
|
||||||
|
conn, err := NewFromConfig("GenericCA", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(GenericCA) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_ACME(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"directory_url":"https://acme-staging-v02.api.letsencrypt.org/directory","email":"test@example.com"}`)
|
||||||
|
conn, err := NewFromConfig("ACME", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(ACME) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_StepCA(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"ca_url":"https://ca.internal:9000","provisioner_name":"test"}`)
|
||||||
|
conn, err := NewFromConfig("StepCA", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(StepCA) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_OpenSSL(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"sign_script":"/path/to/sign.sh"}`)
|
||||||
|
conn, err := NewFromConfig("OpenSSL", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(OpenSSL) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_VaultPKI(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"addr":"https://vault:8200","token":"hvs.test","mount":"pki","role":"web","ttl":"8760h"}`)
|
||||||
|
conn, err := NewFromConfig("VaultPKI", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(VaultPKI) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_DigiCert(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"api_key":"test-key","org_id":"123","product_type":"ssl_basic"}`)
|
||||||
|
conn, err := NewFromConfig("DigiCert", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(DigiCert) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_Sectigo(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"customer_uri":"test-org","login":"api-user","password":"secret","org_id":1}`)
|
||||||
|
conn, err := NewFromConfig("Sectigo", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(Sectigo) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_GoogleCAS(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"project":"my-project","location":"us-central1","ca_pool":"my-pool","credentials":"/path/to/creds.json"}`)
|
||||||
|
conn, err := NewFromConfig("GoogleCAS", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(GoogleCAS) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_UnknownType(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{}`)
|
||||||
|
_, err := NewFromConfig("UnknownCA", cfg, testLogger())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_MalformedJSON(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{invalid json}`)
|
||||||
|
_, err := NewFromConfig("ACME", cfg, testLogger())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for malformed JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_EmptyConfig(t *testing.T) {
|
||||||
|
// Empty config should work — connectors have defaults
|
||||||
|
conn, err := NewFromConfig("local", nil, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig with nil config failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Package certutil provides shared certificate utility functions for target connectors.
|
||||||
|
// These functions handle PEM/PFX conversion, key parsing, thumbprint computation,
|
||||||
|
// and random password generation. Extracted from the IIS connector (M39) to enable
|
||||||
|
// reuse by Windows Certificate Store (M46) and Java Keystore (M46) connectors.
|
||||||
|
package certutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreatePFX converts PEM-encoded cert, key, and chain into PKCS#12 (PFX) format.
|
||||||
|
// Uses go-pkcs12 Modern encoder with strong encryption.
|
||||||
|
func CreatePFX(certPEM, keyPEM, chainPEM string, password string) ([]byte, error) {
|
||||||
|
// Parse leaf certificate
|
||||||
|
certBlock, _ := pem.Decode([]byte(certPEM))
|
||||||
|
if certBlock == nil || certBlock.Type != "CERTIFICATE" {
|
||||||
|
return nil, fmt.Errorf("failed to decode certificate PEM")
|
||||||
|
}
|
||||||
|
leafCert, err := x509.ParseCertificate(certBlock.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse leaf certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse private key (supports PKCS#8, PKCS#1 RSA, and EC)
|
||||||
|
keyBlock, _ := pem.Decode([]byte(keyPEM))
|
||||||
|
if keyBlock == nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode private key PEM")
|
||||||
|
}
|
||||||
|
privateKey, err := ParsePrivateKey(keyBlock.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse CA chain certificates (optional)
|
||||||
|
var caCerts []*x509.Certificate
|
||||||
|
if chainPEM != "" {
|
||||||
|
rest := []byte(chainPEM)
|
||||||
|
for {
|
||||||
|
var block *pem.Block
|
||||||
|
block, rest = pem.Decode(rest)
|
||||||
|
if block == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if block.Type != "CERTIFICATE" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
caCert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse CA certificate: %w", err)
|
||||||
|
}
|
||||||
|
caCerts = append(caCerts, caCert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode as PKCS#12 with Modern encryption
|
||||||
|
pfxData, err := pkcs12.Modern.Encode(privateKey, leafCert, caCerts, password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode PKCS#12: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pfxData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePrivateKey attempts to parse a DER-encoded private key.
|
||||||
|
// Tries PKCS#8, PKCS#1 RSA, and EC formats in order.
|
||||||
|
func ParsePrivateKey(der []byte) (interface{}, error) {
|
||||||
|
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
if key, err := x509.ParseECPrivateKey(der); err == nil {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unsupported private key format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeThumbprint calculates the SHA-1 thumbprint of a PEM-encoded certificate.
|
||||||
|
// Windows uses SHA-1 thumbprints as the primary certificate identifier.
|
||||||
|
// Returns uppercase hex string matching Windows certutil output.
|
||||||
|
func ComputeThumbprint(certPEM string) (string, error) {
|
||||||
|
block, _ := pem.Decode([]byte(certPEM))
|
||||||
|
if block == nil || block.Type != "CERTIFICATE" {
|
||||||
|
return "", fmt.Errorf("failed to decode certificate PEM for thumbprint")
|
||||||
|
}
|
||||||
|
hash := sha1.Sum(block.Bytes)
|
||||||
|
return strings.ToUpper(hex.EncodeToString(hash[:])), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRandomPassword creates a random alphanumeric password.
|
||||||
|
// Typically used for transient PFX encryption — the password is only used
|
||||||
|
// between PFX creation and import, it never persists.
|
||||||
|
func GenerateRandomPassword(length int) (string, error) {
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
b := make([]byte, length)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read random bytes: %w", err)
|
||||||
|
}
|
||||||
|
for i := range b {
|
||||||
|
b[i] = charset[int(b[i])%len(charset)]
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCertificatePEM parses a PEM-encoded certificate and returns the x509.Certificate.
|
||||||
|
func ParseCertificatePEM(certPEM string) (*x509.Certificate, error) {
|
||||||
|
block, _ := pem.Decode([]byte(certPEM))
|
||||||
|
if block == nil || block.Type != "CERTIFICATE" {
|
||||||
|
return nil, fmt.Errorf("failed to decode certificate PEM")
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||||
|
}
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package certutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateTestCertAndKey creates a self-signed certificate and key for testing.
|
||||||
|
func generateTestCertAndKey() (string, string, error) {
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "test.example.com"},
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||||
|
|
||||||
|
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||||
|
|
||||||
|
return string(certPEM), string(keyPEM), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePFX_Success(t *testing.T) {
|
||||||
|
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate test cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pfx, err := CreatePFX(certPEM, keyPEM, "", "test-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePFX failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(pfx) == 0 {
|
||||||
|
t.Error("expected non-empty PFX data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePFX_WithChain(t *testing.T) {
|
||||||
|
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate test cert: %v", err)
|
||||||
|
}
|
||||||
|
// Use the same cert as chain for testing purposes
|
||||||
|
pfx, err := CreatePFX(certPEM, keyPEM, certPEM, "test-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePFX with chain failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(pfx) == 0 {
|
||||||
|
t.Error("expected non-empty PFX data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePFX_InvalidCert(t *testing.T) {
|
||||||
|
_, err := CreatePFX("not-a-cert", "not-a-key", "", "pw")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid cert PEM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePFX_InvalidKey(t *testing.T) {
|
||||||
|
certPEM, _, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate test cert: %v", err)
|
||||||
|
}
|
||||||
|
_, err = CreatePFX(certPEM, "not-a-key", "", "pw")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid key PEM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePrivateKey_PKCS8(t *testing.T) {
|
||||||
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
der, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||||
|
parsed, err := ParsePrivateKey(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParsePrivateKey failed: %v", err)
|
||||||
|
}
|
||||||
|
if parsed == nil {
|
||||||
|
t.Fatal("expected non-nil key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePrivateKey_EC(t *testing.T) {
|
||||||
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
der, _ := x509.MarshalECPrivateKey(key)
|
||||||
|
parsed, err := ParsePrivateKey(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParsePrivateKey failed: %v", err)
|
||||||
|
}
|
||||||
|
if parsed == nil {
|
||||||
|
t.Fatal("expected non-nil key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePrivateKey_Invalid(t *testing.T) {
|
||||||
|
_, err := ParsePrivateKey([]byte("garbage"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid key bytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeThumbprint_Success(t *testing.T) {
|
||||||
|
certPEM, _, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate test cert: %v", err)
|
||||||
|
}
|
||||||
|
thumb, err := ComputeThumbprint(certPEM)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ComputeThumbprint failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(thumb) != 40 {
|
||||||
|
t.Errorf("expected 40-char hex thumbprint, got %d chars", len(thumb))
|
||||||
|
}
|
||||||
|
// Verify uppercase hex
|
||||||
|
for _, c := range thumb {
|
||||||
|
if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F')) {
|
||||||
|
t.Errorf("thumbprint contains non-uppercase-hex char: %c", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeThumbprint_InvalidPEM(t *testing.T) {
|
||||||
|
_, err := ComputeThumbprint("not a cert")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid PEM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRandomPassword(t *testing.T) {
|
||||||
|
pw, err := GenerateRandomPassword(32)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateRandomPassword failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(pw) != 32 {
|
||||||
|
t.Errorf("expected 32-char password, got %d", len(pw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRandomPassword_Uniqueness(t *testing.T) {
|
||||||
|
pw1, _ := GenerateRandomPassword(32)
|
||||||
|
pw2, _ := GenerateRandomPassword(32)
|
||||||
|
if pw1 == pw2 {
|
||||||
|
t.Error("two generated passwords should not be identical")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCertificatePEM_Success(t *testing.T) {
|
||||||
|
certPEM, _, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate test cert: %v", err)
|
||||||
|
}
|
||||||
|
cert, err := ParseCertificatePEM(certPEM)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificatePEM failed: %v", err)
|
||||||
|
}
|
||||||
|
if cert.Subject.CommonName != "test.example.com" {
|
||||||
|
t.Errorf("expected CN test.example.com, got %s", cert.Subject.CommonName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCertificatePEM_Invalid(t *testing.T) {
|
||||||
|
_, err := ParseCertificatePEM("not a cert")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid PEM")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,108 +1,269 @@
|
|||||||
package f5
|
package f5
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/connector/target"
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the F5 BIG-IP deployment target configuration.
|
// Config represents the F5 BIG-IP deployment target configuration.
|
||||||
|
// Credentials are stored on the proxy agent, not on the control plane server,
|
||||||
|
// limiting the credential blast radius to the proxy agent's network zone.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Host string `json:"host"` // F5 BIG-IP hostname or IP
|
Host string `json:"host"` // F5 BIG-IP management hostname or IP
|
||||||
Port int `json:"port"` // F5 iControl REST API port (default 443)
|
Port int `json:"port"` // Management port (default 443)
|
||||||
Username string `json:"username"` // Administrative username
|
Username string `json:"username"` // Administrative username
|
||||||
Password string `json:"password"` // Administrative password
|
Password string `json:"password"` // Administrative password
|
||||||
Partition string `json:"partition"` // F5 partition name (e.g., "Common")
|
Partition string `json:"partition"` // F5 partition name (default "Common")
|
||||||
SSLProfile string `json:"ssl_profile"` // SSL profile name to update
|
SSLProfile string `json:"ssl_profile"` // SSL client profile name to update
|
||||||
|
Insecure bool `json:"insecure"` // Skip TLS verification for mgmt interface (default true)
|
||||||
|
Timeout int `json:"timeout"` // HTTP timeout in seconds (default 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDefaults fills in zero-value fields with sensible defaults.
|
||||||
|
func (c *Config) applyDefaults() {
|
||||||
|
if c.Port == 0 {
|
||||||
|
c.Port = 443
|
||||||
|
}
|
||||||
|
if c.Partition == "" {
|
||||||
|
c.Partition = "Common"
|
||||||
|
}
|
||||||
|
if c.Timeout == 0 {
|
||||||
|
c.Timeout = 30
|
||||||
|
}
|
||||||
|
// Insecure defaults to true because F5 management interfaces commonly use
|
||||||
|
// self-signed certificates. See TICKET-016 precedent for InsecureSkipVerify
|
||||||
|
// documentation. Operators running proper mgmt certs can set insecure=false.
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSLProfileInfo contains information about an F5 SSL client profile.
|
||||||
|
type SSLProfileInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Cert string `json:"cert"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// F5Client abstracts iControl REST API calls for testability.
|
||||||
|
// The real implementation uses net/http against the F5 management interface.
|
||||||
|
// Tests inject a mock implementation to verify call sequences without a real F5.
|
||||||
|
type F5Client interface {
|
||||||
|
// Authenticate obtains an auth token from the F5. Implementations should
|
||||||
|
// cache the token and re-authenticate on 401.
|
||||||
|
Authenticate(ctx context.Context) error
|
||||||
|
|
||||||
|
// UploadFile uploads raw bytes to the F5 file transfer endpoint.
|
||||||
|
// The Content-Range header is required even for single-chunk uploads.
|
||||||
|
UploadFile(ctx context.Context, filename string, data []byte) error
|
||||||
|
|
||||||
|
// InstallCert installs an uploaded file as a crypto cert object.
|
||||||
|
InstallCert(ctx context.Context, name, localFile string) error
|
||||||
|
|
||||||
|
// InstallKey installs an uploaded file as a crypto key object.
|
||||||
|
InstallKey(ctx context.Context, name, localFile string) error
|
||||||
|
|
||||||
|
// CreateTransaction starts an F5 transaction for atomic operations.
|
||||||
|
// Returns the transaction ID.
|
||||||
|
CreateTransaction(ctx context.Context) (string, error)
|
||||||
|
|
||||||
|
// CommitTransaction commits a transaction. If the commit fails,
|
||||||
|
// F5 rolls back all operations within the transaction automatically.
|
||||||
|
CommitTransaction(ctx context.Context, transID string) error
|
||||||
|
|
||||||
|
// UpdateSSLProfile updates an SSL client profile's cert, key, and chain
|
||||||
|
// references. If transID is non-empty, the operation is performed within
|
||||||
|
// the given transaction.
|
||||||
|
UpdateSSLProfile(ctx context.Context, partition, profile string, certName, keyName, chainName string, transID string) error
|
||||||
|
|
||||||
|
// GetSSLProfile retrieves the current configuration of an SSL client profile.
|
||||||
|
GetSSLProfile(ctx context.Context, partition, profile string) (*SSLProfileInfo, error)
|
||||||
|
|
||||||
|
// DeleteCert removes a crypto cert object from the F5.
|
||||||
|
DeleteCert(ctx context.Context, partition, name string) error
|
||||||
|
|
||||||
|
// DeleteKey removes a crypto key object from the F5.
|
||||||
|
DeleteKey(ctx context.Context, partition, name string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connector implements the target.Connector interface for F5 BIG-IP load balancers.
|
// Connector implements the target.Connector interface for F5 BIG-IP load balancers.
|
||||||
// This connector communicates with F5's iControl REST API to upload certificates and manage SSL profiles.
|
// This connector communicates with F5's iControl REST API to upload certificates,
|
||||||
|
// manage SSL profiles, and validate deployments. It uses the proxy agent pattern:
|
||||||
|
// a designated agent in the same network zone polls for F5 deployment jobs and
|
||||||
|
// executes iControl REST calls on behalf of the control plane.
|
||||||
//
|
//
|
||||||
// TODO: Implement actual F5 iControl REST API communication.
|
// Minimum supported BIG-IP version: 12.0+.
|
||||||
// The documented API endpoints and flow are:
|
|
||||||
// - Authentication: POST /mgmt/shared/authn/login
|
|
||||||
// - Upload certificate: POST /mgmt/tm/ltm/certificate
|
|
||||||
// - Update SSL profile: PATCH /mgmt/tm/ltm/profile/client-ssl/{profile_name}
|
|
||||||
// - Check SSL profile: GET /mgmt/tm/ltm/profile/client-ssl/{profile_name}
|
|
||||||
type Connector struct {
|
type Connector struct {
|
||||||
config *Config
|
config *Config
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
client *http.Client
|
client F5Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new F5 target connector with the given configuration and logger.
|
// New creates a new F5 target connector with the given configuration and logger.
|
||||||
func New(config *Config, logger *slog.Logger) *Connector {
|
// The real iControl REST HTTP client is initialized with TLS settings based on config.
|
||||||
|
func New(config *Config, logger *slog.Logger) (*Connector, error) {
|
||||||
|
if config == nil {
|
||||||
|
return nil, fmt.Errorf("F5 config is required")
|
||||||
|
}
|
||||||
|
config.applyDefaults()
|
||||||
|
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Timeout: time.Duration(config.Timeout) * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
// F5 management interfaces commonly use self-signed certificates.
|
||||||
|
// InsecureSkipVerify is controlled by the config.Insecure field
|
||||||
|
// (default true). Operators with proper management certs can set
|
||||||
|
// insecure=false. See TICKET-016 for security rationale.
|
||||||
|
InsecureSkipVerify: config.Insecure, //nolint:gosec // configurable, documented
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
realClient := &realF5Client{
|
||||||
|
baseURL: fmt.Sprintf("https://%s:%d", config.Host, config.Port),
|
||||||
|
username: config.Username,
|
||||||
|
password: config.Password,
|
||||||
|
httpClient: httpClient,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
|
||||||
return &Connector{
|
return &Connector{
|
||||||
config: config,
|
config: config,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
client: &http.Client{
|
client: realClient,
|
||||||
Timeout: 30 * time.Second,
|
}, nil
|
||||||
// TODO: Configure proper TLS verification or skip for self-signed F5 certs
|
}
|
||||||
},
|
|
||||||
|
// NewWithClient creates a new F5 target connector with an injected F5Client.
|
||||||
|
// Used in tests to mock iControl REST API calls without a real F5 device.
|
||||||
|
func NewWithClient(config *Config, logger *slog.Logger, client F5Client) *Connector {
|
||||||
|
if config != nil {
|
||||||
|
config.applyDefaults()
|
||||||
|
}
|
||||||
|
return &Connector{
|
||||||
|
config: config,
|
||||||
|
logger: logger,
|
||||||
|
client: client,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regex validators for config fields to prevent injection.
|
||||||
|
// Same pattern as IIS validIISName.
|
||||||
|
var (
|
||||||
|
// validHost matches hostnames, IPv4, and IPv6 addresses.
|
||||||
|
validHost = regexp.MustCompile(`^[a-zA-Z0-9\.\-\:\[\]]+$`)
|
||||||
|
|
||||||
|
// validPartition matches F5 partition names (alphanumeric, underscore, hyphen).
|
||||||
|
validPartition = regexp.MustCompile(`^[a-zA-Z0-9_\-]+$`)
|
||||||
|
|
||||||
|
// validProfileName matches SSL profile names (alphanumeric, underscore, hyphen, dot).
|
||||||
|
validProfileName = regexp.MustCompile(`^[a-zA-Z0-9_\-\.]+$`)
|
||||||
|
)
|
||||||
|
|
||||||
// ValidateConfig checks that the F5 BIG-IP is reachable and credentials are valid.
|
// ValidateConfig checks that the F5 BIG-IP is reachable and credentials are valid.
|
||||||
// It attempts to authenticate to the F5 iControl REST API.
|
// It validates config fields, applies defaults, and tests authentication.
|
||||||
//
|
|
||||||
// TODO: Implement actual F5 authentication validation.
|
|
||||||
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||||
var cfg Config
|
var cfg Config
|
||||||
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||||
return fmt.Errorf("invalid F5 config: %w", err)
|
return fmt.Errorf("invalid F5 config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Host == "" || cfg.Username == "" || cfg.Password == "" {
|
// Validate required fields
|
||||||
return fmt.Errorf("F5 host, username, and password are required")
|
if cfg.Host == "" {
|
||||||
|
return fmt.Errorf("host is required")
|
||||||
|
}
|
||||||
|
if cfg.Username == "" {
|
||||||
|
return fmt.Errorf("username is required")
|
||||||
|
}
|
||||||
|
if cfg.Password == "" {
|
||||||
|
return fmt.Errorf("password is required")
|
||||||
|
}
|
||||||
|
if cfg.SSLProfile == "" {
|
||||||
|
return fmt.Errorf("ssl_profile is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Port == 0 {
|
cfg.applyDefaults()
|
||||||
cfg.Port = 443 // Default HTTPS port
|
|
||||||
|
// Validate field formats (prevent injection)
|
||||||
|
if !validHost.MatchString(cfg.Host) {
|
||||||
|
return fmt.Errorf("host contains invalid characters (allowed: alphanumeric, dots, hyphens, colons, brackets)")
|
||||||
|
}
|
||||||
|
if len(cfg.Host) > 253 {
|
||||||
|
return fmt.Errorf("host exceeds maximum length (253 characters)")
|
||||||
|
}
|
||||||
|
if !validPartition.MatchString(cfg.Partition) {
|
||||||
|
return fmt.Errorf("partition contains invalid characters (allowed: alphanumeric, underscore, hyphen)")
|
||||||
|
}
|
||||||
|
if len(cfg.Partition) > 64 {
|
||||||
|
return fmt.Errorf("partition exceeds maximum length (64 characters)")
|
||||||
|
}
|
||||||
|
if !validProfileName.MatchString(cfg.SSLProfile) {
|
||||||
|
return fmt.Errorf("ssl_profile contains invalid characters (allowed: alphanumeric, underscore, hyphen, dot)")
|
||||||
|
}
|
||||||
|
if len(cfg.SSLProfile) > 256 {
|
||||||
|
return fmt.Errorf("ssl_profile exceeds maximum length (256 characters)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Partition == "" {
|
// Validate port range
|
||||||
cfg.Partition = "Common"
|
if cfg.Port < 1 || cfg.Port > 65535 {
|
||||||
|
return fmt.Errorf("port must be between 1 and 65535, got %d", cfg.Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.logger.Info("validating F5 configuration",
|
c.logger.Info("validating F5 configuration",
|
||||||
"host", cfg.Host,
|
"host", cfg.Host,
|
||||||
"port", cfg.Port,
|
"port", cfg.Port,
|
||||||
"partition", cfg.Partition)
|
"partition", cfg.Partition,
|
||||||
|
"ssl_profile", cfg.SSLProfile)
|
||||||
|
|
||||||
// TODO: Implement F5 authentication check
|
// Test authentication
|
||||||
// In production:
|
if err := c.client.Authenticate(ctx); err != nil {
|
||||||
// 1. POST to https://{host}:{port}/mgmt/shared/authn/login
|
return fmt.Errorf("F5 authentication failed: %w", err)
|
||||||
// 2. Send credentials in request body
|
}
|
||||||
// 3. Verify response contains valid authentication token
|
|
||||||
// 4. Optionally test connectivity to SSL profile endpoint
|
|
||||||
|
|
||||||
c.logger.Warn("F5 validation not yet fully implemented",
|
|
||||||
"host", cfg.Host)
|
|
||||||
|
|
||||||
c.config = &cfg
|
c.config = &cfg
|
||||||
|
c.logger.Info("F5 configuration validated",
|
||||||
|
"host", cfg.Host,
|
||||||
|
"partition", cfg.Partition,
|
||||||
|
"ssl_profile", cfg.SSLProfile)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// objectName generates a unique name for F5 crypto objects using nanosecond timestamps.
|
||||||
|
// Format: certctl-{type}-{unix_nanos}
|
||||||
|
func objectName(objType string) string {
|
||||||
|
return fmt.Sprintf("certctl-%s-%d", objType, time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
// partitionPath returns the full partition-qualified path for an F5 object reference.
|
||||||
|
// Used in JSON body values (e.g., "/Common/certctl-cert-xxx").
|
||||||
|
func partitionPath(partition, name string) string {
|
||||||
|
return fmt.Sprintf("/%s/%s", partition, name)
|
||||||
|
}
|
||||||
|
|
||||||
// DeployCertificate uploads a certificate to the F5 BIG-IP and updates the specified SSL profile.
|
// DeployCertificate uploads a certificate to the F5 BIG-IP and updates the specified SSL profile.
|
||||||
//
|
//
|
||||||
// The F5 deployment process:
|
// The deployment uses F5's transaction API for atomic profile updates:
|
||||||
// 1. Authenticate to iControl REST API using credentials
|
// 1. Authenticate to iControl REST API
|
||||||
// 2. Upload certificate PEM to /mgmt/tm/ltm/certificate
|
// 2. Upload cert/key/chain PEM files via file transfer endpoint
|
||||||
// 3. Upload chain PEM as separate certificate if needed
|
// 3. Install as crypto objects (cert, key, optionally chain)
|
||||||
// 4. Update the target SSL profile to reference the new certificate
|
// 4. Create a transaction
|
||||||
// 5. Verify the profile was updated successfully
|
// 5. Update SSL profile within the transaction
|
||||||
|
// 6. Commit the transaction (atomic — rolls back on failure)
|
||||||
//
|
//
|
||||||
// TODO: Implement actual F5 iControl REST API calls.
|
// On failure after crypto object installation, cleanup removes uploaded objects
|
||||||
// API endpoints used:
|
// to avoid accumulating orphans on the F5.
|
||||||
// - POST /mgmt/shared/authn/login (authentication)
|
|
||||||
// - POST /mgmt/tm/ltm/certificate (upload cert)
|
|
||||||
// - PATCH /mgmt/tm/ltm/profile/client-ssl/{SSLProfile} (update profile)
|
|
||||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||||
c.logger.Info("deploying certificate to F5 BIG-IP",
|
c.logger.Info("deploying certificate to F5 BIG-IP",
|
||||||
"host", c.config.Host,
|
"host", c.config.Host,
|
||||||
@@ -111,47 +272,233 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
|||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
// TODO: Implement F5 certificate deployment
|
// Validate we have a private key
|
||||||
// In production:
|
if request.KeyPEM == "" {
|
||||||
// 1. Authenticate to F5: POST /mgmt/shared/authn/login
|
errMsg := "private key (KeyPEM) is required for F5 deployment"
|
||||||
// 2. Create certificate object:
|
c.logger.Error("deployment failed", "error", errMsg)
|
||||||
// POST /mgmt/tm/ltm/certificate
|
return &target.DeploymentResult{
|
||||||
// Body: {"name": "certctl-cert-{timestamp}", "certificateText": "{CertPEM}"}
|
Success: false,
|
||||||
// 3. If chain is provided, upload as separate certificate:
|
Message: errMsg,
|
||||||
// POST /mgmt/tm/ltm/certificate
|
DeployedAt: time.Now(),
|
||||||
// Body: {"name": "certctl-chain-{timestamp}", "certificateText": "{ChainPEM}"}
|
}, fmt.Errorf("%s", errMsg)
|
||||||
// 4. Update SSL profile:
|
}
|
||||||
// PATCH /mgmt/tm/ltm/profile/client-ssl/{SSLProfile}
|
|
||||||
// Body: {"certificate": "/Common/certctl-cert-{timestamp}"}
|
// Step 1: Authenticate
|
||||||
// 5. Verify deployment by checking profile status
|
if err := c.client.Authenticate(ctx); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("F5 authentication failed: %v", err)
|
||||||
|
c.logger.Error("deployment failed", "error", err)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique object names
|
||||||
|
certName := objectName("cert")
|
||||||
|
keyName := objectName("key")
|
||||||
|
chainName := ""
|
||||||
|
hasChain := strings.TrimSpace(request.ChainPEM) != ""
|
||||||
|
if hasChain {
|
||||||
|
chainName = objectName("chain")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track installed objects for cleanup on failure
|
||||||
|
var installedCerts []string
|
||||||
|
var installedKeys []string
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
c.cleanupCryptoObjects(ctx, c.config.Partition, installedCerts, installedKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2-3: Upload cert and key PEM files
|
||||||
|
certFilename := certName + ".pem"
|
||||||
|
if err := c.client.UploadFile(ctx, certFilename, []byte(request.CertPEM)); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to upload certificate file: %v", err)
|
||||||
|
c.logger.Error("cert upload failed", "error", err)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyFilename := keyName + ".pem"
|
||||||
|
if err := c.client.UploadFile(ctx, keyFilename, []byte(request.KeyPEM)); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to upload key file: %v", err)
|
||||||
|
c.logger.Error("key upload failed", "error", err)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Upload chain if present
|
||||||
|
chainFilename := ""
|
||||||
|
if hasChain {
|
||||||
|
chainFilename = chainName + ".pem"
|
||||||
|
if err := c.client.UploadFile(ctx, chainFilename, []byte(request.ChainPEM)); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to upload chain file: %v", err)
|
||||||
|
c.logger.Error("chain upload failed", "error", err)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Install cert crypto object
|
||||||
|
certLocalFile := "/var/config/rest/downloads/" + certFilename
|
||||||
|
if err := c.client.InstallCert(ctx, certName, certLocalFile); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to install cert crypto object: %v", err)
|
||||||
|
c.logger.Error("cert install failed", "error", err)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
installedCerts = append(installedCerts, certName)
|
||||||
|
|
||||||
|
// Step 6: Install key crypto object
|
||||||
|
keyLocalFile := "/var/config/rest/downloads/" + keyFilename
|
||||||
|
if err := c.client.InstallKey(ctx, keyName, keyLocalFile); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to install key crypto object: %v", err)
|
||||||
|
c.logger.Error("key install failed", "error", err)
|
||||||
|
cleanup()
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
installedKeys = append(installedKeys, keyName)
|
||||||
|
|
||||||
|
// Step 7: Install chain crypto object (if present)
|
||||||
|
if hasChain {
|
||||||
|
chainLocalFile := "/var/config/rest/downloads/" + chainFilename
|
||||||
|
if err := c.client.InstallCert(ctx, chainName, chainLocalFile); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to install chain crypto object: %v", err)
|
||||||
|
c.logger.Error("chain install failed", "error", err)
|
||||||
|
cleanup()
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
installedCerts = append(installedCerts, chainName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8: Create transaction for atomic SSL profile update
|
||||||
|
transID, err := c.client.CreateTransaction(ctx)
|
||||||
|
if err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to create F5 transaction: %v", err)
|
||||||
|
c.logger.Error("transaction creation failed", "error", err)
|
||||||
|
cleanup()
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 9: Update SSL profile within transaction
|
||||||
|
profileChainName := chainName
|
||||||
|
if err := c.client.UpdateSSLProfile(ctx, c.config.Partition, c.config.SSLProfile, certName, keyName, profileChainName, transID); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to update SSL profile: %v", err)
|
||||||
|
c.logger.Error("profile update failed", "error", err,
|
||||||
|
"ssl_profile", c.config.SSLProfile,
|
||||||
|
"transaction_id", transID)
|
||||||
|
cleanup()
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 10: Commit transaction
|
||||||
|
if err := c.client.CommitTransaction(ctx, transID); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to commit F5 transaction: %v", err)
|
||||||
|
c.logger.Error("transaction commit failed", "error", err,
|
||||||
|
"transaction_id", transID)
|
||||||
|
cleanup()
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
deploymentDuration := time.Since(startTime)
|
deploymentDuration := time.Since(startTime)
|
||||||
|
c.logger.Info("certificate deployed to F5 BIG-IP successfully",
|
||||||
c.logger.Warn("F5 deployment not yet implemented",
|
"duration", deploymentDuration.String(),
|
||||||
"host", c.config.Host,
|
"host", c.config.Host,
|
||||||
"ssl_profile", c.config.SSLProfile)
|
"ssl_profile", c.config.SSLProfile,
|
||||||
|
"cert_object", certName)
|
||||||
|
|
||||||
return &target.DeploymentResult{
|
return &target.DeploymentResult{
|
||||||
Success: true,
|
Success: true,
|
||||||
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
DeploymentID: fmt.Sprintf("f5-%d", time.Now().Unix()),
|
DeploymentID: fmt.Sprintf("f5-%s-%d", certName, time.Now().Unix()),
|
||||||
Message: "Certificate deployment to F5 initiated (stub)",
|
Message: "Certificate uploaded and SSL profile updated via iControl REST",
|
||||||
DeployedAt: time.Now(),
|
DeployedAt: time.Now(),
|
||||||
Metadata: map[string]string{
|
Metadata: map[string]string{
|
||||||
"host": c.config.Host,
|
"host": c.config.Host,
|
||||||
"partition": c.config.Partition,
|
"partition": c.config.Partition,
|
||||||
"ssl_profile": c.config.SSLProfile,
|
"ssl_profile": c.config.SSLProfile,
|
||||||
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
"cert_object_name": certName,
|
||||||
|
"key_object_name": keyName,
|
||||||
|
"chain_object_name": chainName,
|
||||||
|
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanupCryptoObjects removes installed crypto objects from the F5 on deployment failure.
|
||||||
|
// Best-effort: logs warnings on cleanup failures but does not mask the original error.
|
||||||
|
func (c *Connector) cleanupCryptoObjects(ctx context.Context, partition string, certNames, keyNames []string) {
|
||||||
|
for _, name := range certNames {
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := c.client.DeleteCert(ctx, partition, name); err != nil {
|
||||||
|
c.logger.Warn("cleanup: failed to delete cert crypto object",
|
||||||
|
"name", name, "partition", partition, "error", err)
|
||||||
|
} else {
|
||||||
|
c.logger.Debug("cleanup: deleted cert crypto object",
|
||||||
|
"name", name, "partition", partition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range keyNames {
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := c.client.DeleteKey(ctx, partition, name); err != nil {
|
||||||
|
c.logger.Warn("cleanup: failed to delete key crypto object",
|
||||||
|
"name", name, "partition", partition, "error", err)
|
||||||
|
} else {
|
||||||
|
c.logger.Debug("cleanup: deleted key crypto object",
|
||||||
|
"name", name, "partition", partition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ValidateDeployment verifies that the certificate is properly deployed on the F5 BIG-IP.
|
// ValidateDeployment verifies that the certificate is properly deployed on the F5 BIG-IP.
|
||||||
// It checks the SSL profile configuration to ensure it references the correct certificate.
|
// It queries the SSL profile and checks that it references a certctl-managed certificate.
|
||||||
//
|
|
||||||
// TODO: Implement actual F5 validation via iControl REST API.
|
|
||||||
// API endpoint used:
|
|
||||||
// - GET /mgmt/tm/ltm/profile/client-ssl/{SSLProfile}
|
|
||||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||||
c.logger.Info("validating F5 deployment",
|
c.logger.Info("validating F5 deployment",
|
||||||
"certificate_id", request.CertificateID,
|
"certificate_id", request.CertificateID,
|
||||||
@@ -160,30 +507,385 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
|
|||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
// TODO: Implement F5 deployment validation
|
// Authenticate
|
||||||
// In production:
|
if err := c.client.Authenticate(ctx); err != nil {
|
||||||
// 1. Authenticate to F5: POST /mgmt/shared/authn/login
|
errMsg := fmt.Sprintf("F5 authentication failed: %v", err)
|
||||||
// 2. Query SSL profile:
|
c.logger.Error("validation failed", "error", err)
|
||||||
// GET /mgmt/tm/ltm/profile/client-ssl/{SSLProfile}
|
return &target.ValidationResult{
|
||||||
// 3. Verify the response includes the expected certificate name
|
Valid: false,
|
||||||
// 4. Optionally check certificate validity dates
|
Serial: request.Serial,
|
||||||
// 5. Verify the profile is in active use (no errors/warnings)
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query SSL profile
|
||||||
|
profile, err := c.client.GetSSLProfile(ctx, c.config.Partition, c.config.SSLProfile)
|
||||||
|
if err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to get SSL profile %q: %v", c.config.SSLProfile, err)
|
||||||
|
c.logger.Error("validation failed", "error", err,
|
||||||
|
"ssl_profile", c.config.SSLProfile)
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Serial: request.Serial,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify profile has a cert configured
|
||||||
|
if profile.Cert == "" {
|
||||||
|
errMsg := fmt.Sprintf("SSL profile %q has no certificate configured", c.config.SSLProfile)
|
||||||
|
c.logger.Error("validation failed", "error", errMsg)
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Serial: request.Serial,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
validationDuration := time.Since(startTime)
|
validationDuration := time.Since(startTime)
|
||||||
|
c.logger.Info("F5 deployment validated",
|
||||||
c.logger.Warn("F5 validation not yet implemented",
|
"duration", validationDuration.String(),
|
||||||
"ssl_profile", c.config.SSLProfile)
|
"ssl_profile", c.config.SSLProfile,
|
||||||
|
"current_cert", profile.Cert)
|
||||||
|
|
||||||
return &target.ValidationResult{
|
return &target.ValidationResult{
|
||||||
Valid: true,
|
Valid: true,
|
||||||
Serial: request.Serial,
|
Serial: request.Serial,
|
||||||
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
Message: "Certificate deployment validation initiated (stub)",
|
Message: fmt.Sprintf("SSL profile %q has cert %q configured", c.config.SSLProfile, profile.Cert),
|
||||||
ValidatedAt: time.Now(),
|
ValidatedAt: time.Now(),
|
||||||
Metadata: map[string]string{
|
Metadata: map[string]string{
|
||||||
"host": c.config.Host,
|
"host": c.config.Host,
|
||||||
"ssl_profile": c.config.SSLProfile,
|
"ssl_profile": c.config.SSLProfile,
|
||||||
|
"current_cert": profile.Cert,
|
||||||
|
"current_key": profile.Key,
|
||||||
|
"current_chain": profile.Chain,
|
||||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- realF5Client: production iControl REST implementation ---
|
||||||
|
|
||||||
|
// realF5Client implements F5Client using net/http against the iControl REST API.
|
||||||
|
type realF5Client struct {
|
||||||
|
baseURL string
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
httpClient *http.Client
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate obtains a token from POST /mgmt/shared/authn/login.
|
||||||
|
// The token is cached and reused. On 401 errors in other methods,
|
||||||
|
// callers should call Authenticate again to refresh.
|
||||||
|
func (c *realF5Client) Authenticate(ctx context.Context) error {
|
||||||
|
body := map[string]string{
|
||||||
|
"username": c.username,
|
||||||
|
"password": c.password,
|
||||||
|
"loginProviderName": "tmos",
|
||||||
|
}
|
||||||
|
bodyJSON, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal auth body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/mgmt/shared/authn/login", bytes.NewReader(bodyJSON))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create auth request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("F5 auth request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("F5 auth failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Token struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
} `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode auth response: %w", err)
|
||||||
|
}
|
||||||
|
if result.Token.Token == "" {
|
||||||
|
return fmt.Errorf("F5 auth response contained no token")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.token = result.Token.Token
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// doRequest executes an HTTP request with the F5 auth token.
|
||||||
|
// On 401 response, it re-authenticates once and retries.
|
||||||
|
func (c *realF5Client) doRequest(ctx context.Context, method, url string, body io.Reader, extraHeaders map[string]string) (*http.Response, error) {
|
||||||
|
return c.doRequestInternal(ctx, method, url, body, extraHeaders, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *realF5Client) doRequestInternal(ctx context.Context, method, url string, body io.Reader, extraHeaders map[string]string, retryOn401 bool) (*http.Response, error) {
|
||||||
|
// Buffer body for potential retry
|
||||||
|
var bodyBytes []byte
|
||||||
|
if body != nil {
|
||||||
|
var err error
|
||||||
|
bodyBytes, err = io.ReadAll(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read request body: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
token := c.token
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
req.Header.Set("X-F5-Auth-Token", token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
for k, v := range extraHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized && retryOn401 {
|
||||||
|
resp.Body.Close()
|
||||||
|
c.logger.Warn("F5 request returned 401, re-authenticating", "url", url)
|
||||||
|
if authErr := c.Authenticate(ctx); authErr != nil {
|
||||||
|
return nil, fmt.Errorf("F5 re-authentication failed: %w", authErr)
|
||||||
|
}
|
||||||
|
return c.doRequestInternal(ctx, method, url, bytes.NewReader(bodyBytes), extraHeaders, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadFile uploads raw bytes via POST /mgmt/shared/file-transfer/uploads/{filename}.
|
||||||
|
// The Content-Range header is required even for single-chunk uploads (F5-specific).
|
||||||
|
func (c *realF5Client) UploadFile(ctx context.Context, filename string, data []byte) error {
|
||||||
|
url := fmt.Sprintf("%s/mgmt/shared/file-transfer/uploads/%s", c.baseURL, filename)
|
||||||
|
|
||||||
|
headers := map[string]string{
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"Content-Range": fmt.Sprintf("0-%d/%d", len(data)-1, len(data)),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.doRequest(ctx, http.MethodPost, url, bytes.NewReader(data), headers)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upload file %q failed: %w", filename, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("upload file %q failed with status %d: %s", filename, resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallCert installs an uploaded file as a crypto cert object.
|
||||||
|
func (c *realF5Client) InstallCert(ctx context.Context, name, localFile string) error {
|
||||||
|
url := c.baseURL + "/mgmt/tm/sys/crypto/cert"
|
||||||
|
body := map[string]string{
|
||||||
|
"command": "install",
|
||||||
|
"name": name,
|
||||||
|
"from-local-file": localFile,
|
||||||
|
}
|
||||||
|
bodyJSON, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
resp, err := c.doRequest(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("install cert %q failed: %w", name, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("install cert %q failed with status %d: %s", name, resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallKey installs an uploaded file as a crypto key object.
|
||||||
|
func (c *realF5Client) InstallKey(ctx context.Context, name, localFile string) error {
|
||||||
|
url := c.baseURL + "/mgmt/tm/sys/crypto/key"
|
||||||
|
body := map[string]string{
|
||||||
|
"command": "install",
|
||||||
|
"name": name,
|
||||||
|
"from-local-file": localFile,
|
||||||
|
}
|
||||||
|
bodyJSON, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
resp, err := c.doRequest(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("install key %q failed: %w", name, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("install key %q failed with status %d: %s", name, resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTransaction starts an F5 transaction via POST /mgmt/tm/transaction.
|
||||||
|
func (c *realF5Client) CreateTransaction(ctx context.Context) (string, error) {
|
||||||
|
url := c.baseURL + "/mgmt/tm/transaction"
|
||||||
|
|
||||||
|
resp, err := c.doRequest(ctx, http.MethodPost, url, bytes.NewReader([]byte("{}")), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create transaction failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("create transaction failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
TransID json.Number `json:"transId"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode transaction response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transID := result.TransID.String()
|
||||||
|
if transID == "" {
|
||||||
|
return "", fmt.Errorf("F5 returned empty transaction ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
return transID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitTransaction commits a transaction via PATCH /mgmt/tm/transaction/{id}.
|
||||||
|
func (c *realF5Client) CommitTransaction(ctx context.Context, transID string) error {
|
||||||
|
url := fmt.Sprintf("%s/mgmt/tm/transaction/%s", c.baseURL, transID)
|
||||||
|
body := map[string]string{"state": "VALIDATING"}
|
||||||
|
bodyJSON, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
resp, err := c.doRequest(ctx, http.MethodPatch, url, bytes.NewReader(bodyJSON), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("commit transaction %s failed: %w", transID, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("commit transaction %s failed with status %d: %s", transID, resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSSLProfile updates an SSL client profile's cert/key/chain references.
|
||||||
|
// Uses tilde ~ as partition separator in the URL, forward slash / in JSON body values.
|
||||||
|
func (c *realF5Client) UpdateSSLProfile(ctx context.Context, partition, profile string, certName, keyName, chainName string, transID string) error {
|
||||||
|
url := fmt.Sprintf("%s/mgmt/tm/ltm/profile/client-ssl/~%s~%s", c.baseURL, partition, profile)
|
||||||
|
|
||||||
|
body := map[string]string{
|
||||||
|
"cert": partitionPath(partition, certName),
|
||||||
|
"key": partitionPath(partition, keyName),
|
||||||
|
}
|
||||||
|
if chainName != "" {
|
||||||
|
body["chain"] = partitionPath(partition, chainName)
|
||||||
|
}
|
||||||
|
bodyJSON, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
headers := map[string]string{}
|
||||||
|
if transID != "" {
|
||||||
|
headers["X-F5-REST-Overriding-Collection"] = fmt.Sprintf("/mgmt/tm/transaction/%s", transID)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.doRequest(ctx, http.MethodPatch, url, bytes.NewReader(bodyJSON), headers)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update SSL profile %q failed: %w", profile, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("update SSL profile %q failed with status %d: %s", profile, resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSSLProfile retrieves an SSL client profile's configuration.
|
||||||
|
func (c *realF5Client) GetSSLProfile(ctx context.Context, partition, profile string) (*SSLProfileInfo, error) {
|
||||||
|
url := fmt.Sprintf("%s/mgmt/tm/ltm/profile/client-ssl/~%s~%s", c.baseURL, partition, profile)
|
||||||
|
|
||||||
|
resp, err := c.doRequest(ctx, http.MethodGet, url, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get SSL profile %q failed: %w", profile, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("get SSL profile %q failed with status %d: %s", profile, resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var info SSLProfileInfo
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode SSL profile response: %w", err)
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCert removes a crypto cert object from the F5.
|
||||||
|
func (c *realF5Client) DeleteCert(ctx context.Context, partition, name string) error {
|
||||||
|
url := fmt.Sprintf("%s/mgmt/tm/sys/crypto/cert/~%s~%s", c.baseURL, partition, name)
|
||||||
|
|
||||||
|
resp, err := c.doRequest(ctx, http.MethodDelete, url, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete cert %q failed: %w", name, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("delete cert %q failed with status %d: %s", name, resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteKey removes a crypto key object from the F5.
|
||||||
|
func (c *realF5Client) DeleteKey(ctx context.Context, partition, name string) error {
|
||||||
|
url := fmt.Sprintf("%s/mgmt/tm/sys/crypto/key/~%s~%s", c.baseURL, partition, name)
|
||||||
|
|
||||||
|
resp, err := c.doRequest(ctx, http.MethodDelete, url, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete key %q failed: %w", name, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("delete key %q failed with status %d: %s", name, resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,812 @@
|
|||||||
|
package f5
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Mock F5Client ---
|
||||||
|
|
||||||
|
// mockCall records a single method call to the mock F5Client.
|
||||||
|
type mockCall struct {
|
||||||
|
Method string
|
||||||
|
Args []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockF5Client records all calls and returns configurable responses.
|
||||||
|
type mockF5Client struct {
|
||||||
|
calls []mockCall
|
||||||
|
|
||||||
|
// Configurable responses per method
|
||||||
|
authenticateErr error
|
||||||
|
authenticateCount int // tracks number of Authenticate calls
|
||||||
|
uploadFileErr error
|
||||||
|
uploadFileErrOn string // only error when filename contains this substring
|
||||||
|
installCertErr error
|
||||||
|
installCertErrOn string
|
||||||
|
installKeyErr error
|
||||||
|
createTransactionID string
|
||||||
|
createTransactionErr error
|
||||||
|
commitTransactionErr error
|
||||||
|
updateSSLProfileErr error
|
||||||
|
getSSLProfileResult *SSLProfileInfo
|
||||||
|
getSSLProfileErr error
|
||||||
|
deleteCertErr error
|
||||||
|
deleteKeyErr error
|
||||||
|
|
||||||
|
// Track cleanup calls specifically
|
||||||
|
deletedCerts []string
|
||||||
|
deletedKeys []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockF5Client() *mockF5Client {
|
||||||
|
return &mockF5Client{
|
||||||
|
createTransactionID: "12345",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockF5Client) Authenticate(ctx context.Context) error {
|
||||||
|
m.calls = append(m.calls, mockCall{Method: "Authenticate"})
|
||||||
|
m.authenticateCount++
|
||||||
|
return m.authenticateErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockF5Client) UploadFile(ctx context.Context, filename string, data []byte) error {
|
||||||
|
m.calls = append(m.calls, mockCall{Method: "UploadFile", Args: []string{filename, fmt.Sprintf("%d bytes", len(data))}})
|
||||||
|
if m.uploadFileErrOn != "" && strings.Contains(filename, m.uploadFileErrOn) {
|
||||||
|
return m.uploadFileErr
|
||||||
|
}
|
||||||
|
if m.uploadFileErrOn == "" && m.uploadFileErr != nil {
|
||||||
|
return m.uploadFileErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockF5Client) InstallCert(ctx context.Context, name, localFile string) error {
|
||||||
|
m.calls = append(m.calls, mockCall{Method: "InstallCert", Args: []string{name, localFile}})
|
||||||
|
if m.installCertErrOn != "" && strings.Contains(name, m.installCertErrOn) {
|
||||||
|
return m.installCertErr
|
||||||
|
}
|
||||||
|
if m.installCertErrOn == "" && m.installCertErr != nil {
|
||||||
|
return m.installCertErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockF5Client) InstallKey(ctx context.Context, name, localFile string) error {
|
||||||
|
m.calls = append(m.calls, mockCall{Method: "InstallKey", Args: []string{name, localFile}})
|
||||||
|
return m.installKeyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockF5Client) CreateTransaction(ctx context.Context) (string, error) {
|
||||||
|
m.calls = append(m.calls, mockCall{Method: "CreateTransaction"})
|
||||||
|
return m.createTransactionID, m.createTransactionErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockF5Client) CommitTransaction(ctx context.Context, transID string) error {
|
||||||
|
m.calls = append(m.calls, mockCall{Method: "CommitTransaction", Args: []string{transID}})
|
||||||
|
return m.commitTransactionErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockF5Client) UpdateSSLProfile(ctx context.Context, partition, profile string, certName, keyName, chainName string, transID string) error {
|
||||||
|
m.calls = append(m.calls, mockCall{Method: "UpdateSSLProfile", Args: []string{partition, profile, certName, keyName, chainName, transID}})
|
||||||
|
return m.updateSSLProfileErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockF5Client) GetSSLProfile(ctx context.Context, partition, profile string) (*SSLProfileInfo, error) {
|
||||||
|
m.calls = append(m.calls, mockCall{Method: "GetSSLProfile", Args: []string{partition, profile}})
|
||||||
|
return m.getSSLProfileResult, m.getSSLProfileErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockF5Client) DeleteCert(ctx context.Context, partition, name string) error {
|
||||||
|
m.calls = append(m.calls, mockCall{Method: "DeleteCert", Args: []string{partition, name}})
|
||||||
|
m.deletedCerts = append(m.deletedCerts, name)
|
||||||
|
return m.deleteCertErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockF5Client) DeleteKey(ctx context.Context, partition, name string) error {
|
||||||
|
m.calls = append(m.calls, mockCall{Method: "DeleteKey", Args: []string{partition, name}})
|
||||||
|
m.deletedKeys = append(m.deletedKeys, name)
|
||||||
|
return m.deleteKeyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasCalled returns true if the mock received a call to the given method.
|
||||||
|
func (m *mockF5Client) hasCalled(method string) bool {
|
||||||
|
for _, c := range m.calls {
|
||||||
|
if c.Method == method {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// callCount returns the number of times a method was called.
|
||||||
|
func (m *mockF5Client) callCount(method string) int {
|
||||||
|
count := 0
|
||||||
|
for _, c := range m.calls {
|
||||||
|
if c.Method == method {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ValidateConfig tests ---
|
||||||
|
|
||||||
|
func TestValidateConfig(t *testing.T) {
|
||||||
|
t.Run("Success", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
cfg := &Config{Host: "f5.test.com", Username: "admin", Password: "secret", SSLProfile: "myprofile"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
rawConfig, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"host": "f5.test.com",
|
||||||
|
"username": "admin",
|
||||||
|
"password": "secret",
|
||||||
|
"ssl_profile": "myprofile",
|
||||||
|
})
|
||||||
|
|
||||||
|
err := conn.ValidateConfig(context.Background(), rawConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
if !mock.hasCalled("Authenticate") {
|
||||||
|
t.Error("expected Authenticate to be called")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DefaultsApplied", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
cfg := &Config{}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
rawConfig, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"host": "f5.test.com",
|
||||||
|
"username": "admin",
|
||||||
|
"password": "secret",
|
||||||
|
"ssl_profile": "myprofile",
|
||||||
|
})
|
||||||
|
|
||||||
|
err := conn.ValidateConfig(context.Background(), rawConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check defaults were applied
|
||||||
|
if conn.config.Port != 443 {
|
||||||
|
t.Errorf("expected port 443, got %d", conn.config.Port)
|
||||||
|
}
|
||||||
|
if conn.config.Partition != "Common" {
|
||||||
|
t.Errorf("expected partition Common, got %s", conn.config.Partition)
|
||||||
|
}
|
||||||
|
if conn.config.Timeout != 30 {
|
||||||
|
t.Errorf("expected timeout 30, got %d", conn.config.Timeout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidJSON", func(t *testing.T) {
|
||||||
|
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
|
||||||
|
err := conn.ValidateConfig(context.Background(), json.RawMessage(`{invalid}`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid F5 config") {
|
||||||
|
t.Errorf("expected 'invalid F5 config' in error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MissingHost", func(t *testing.T) {
|
||||||
|
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
|
||||||
|
rawConfig, _ := json.Marshal(map[string]string{
|
||||||
|
"username": "admin", "password": "secret", "ssl_profile": "prof",
|
||||||
|
})
|
||||||
|
err := conn.ValidateConfig(context.Background(), rawConfig)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "host is required") {
|
||||||
|
t.Errorf("expected 'host is required', got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MissingUsername", func(t *testing.T) {
|
||||||
|
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
|
||||||
|
rawConfig, _ := json.Marshal(map[string]string{
|
||||||
|
"host": "f5.test.com", "password": "secret", "ssl_profile": "prof",
|
||||||
|
})
|
||||||
|
err := conn.ValidateConfig(context.Background(), rawConfig)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "username is required") {
|
||||||
|
t.Errorf("expected 'username is required', got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MissingPassword", func(t *testing.T) {
|
||||||
|
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
|
||||||
|
rawConfig, _ := json.Marshal(map[string]string{
|
||||||
|
"host": "f5.test.com", "username": "admin", "ssl_profile": "prof",
|
||||||
|
})
|
||||||
|
err := conn.ValidateConfig(context.Background(), rawConfig)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "password is required") {
|
||||||
|
t.Errorf("expected 'password is required', got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MissingSSLProfile", func(t *testing.T) {
|
||||||
|
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
|
||||||
|
rawConfig, _ := json.Marshal(map[string]string{
|
||||||
|
"host": "f5.test.com", "username": "admin", "password": "secret",
|
||||||
|
})
|
||||||
|
err := conn.ValidateConfig(context.Background(), rawConfig)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "ssl_profile is required") {
|
||||||
|
t.Errorf("expected 'ssl_profile is required', got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidPort", func(t *testing.T) {
|
||||||
|
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
|
||||||
|
rawConfig, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"host": "f5.test.com", "username": "admin", "password": "secret",
|
||||||
|
"ssl_profile": "prof", "port": 70000,
|
||||||
|
})
|
||||||
|
err := conn.ValidateConfig(context.Background(), rawConfig)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "port must be between") {
|
||||||
|
t.Errorf("expected port range error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AuthFailure", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
mock.authenticateErr = fmt.Errorf("connection refused")
|
||||||
|
conn := NewWithClient(&Config{}, testLogger(), mock)
|
||||||
|
|
||||||
|
rawConfig, _ := json.Marshal(map[string]string{
|
||||||
|
"host": "f5.test.com", "username": "admin", "password": "bad",
|
||||||
|
"ssl_profile": "prof",
|
||||||
|
})
|
||||||
|
err := conn.ValidateConfig(context.Background(), rawConfig)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "authentication failed") {
|
||||||
|
t.Errorf("expected auth failure error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidPartitionChars", func(t *testing.T) {
|
||||||
|
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
|
||||||
|
rawConfig, _ := json.Marshal(map[string]string{
|
||||||
|
"host": "f5.test.com", "username": "admin", "password": "secret",
|
||||||
|
"ssl_profile": "prof", "partition": "Common; rm -rf /",
|
||||||
|
})
|
||||||
|
err := conn.ValidateConfig(context.Background(), rawConfig)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "partition contains invalid characters") {
|
||||||
|
t.Errorf("expected partition validation error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidSSLProfileChars", func(t *testing.T) {
|
||||||
|
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
|
||||||
|
rawConfig, _ := json.Marshal(map[string]string{
|
||||||
|
"host": "f5.test.com", "username": "admin", "password": "secret",
|
||||||
|
"ssl_profile": "prof; echo pwned",
|
||||||
|
})
|
||||||
|
err := conn.ValidateConfig(context.Background(), rawConfig)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "ssl_profile contains invalid characters") {
|
||||||
|
t.Errorf("expected ssl_profile validation error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidHostChars", func(t *testing.T) {
|
||||||
|
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
|
||||||
|
rawConfig, _ := json.Marshal(map[string]string{
|
||||||
|
"host": "f5.test.com/../../etc/passwd", "username": "admin",
|
||||||
|
"password": "secret", "ssl_profile": "prof",
|
||||||
|
})
|
||||||
|
err := conn.ValidateConfig(context.Background(), rawConfig)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "host contains invalid characters") {
|
||||||
|
t.Errorf("expected host validation error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DeployCertificate tests ---
|
||||||
|
|
||||||
|
const testCertPEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBhTCCASugAwIBAgIRAJ1gCL7hBmSj6g0gYOr2FzMwCgYIKoZIzj0EAwIwEjEQ
|
||||||
|
MA4GA1UEChMHY2VydGN0bDAeFw0yNTAxMDEwMDAwMDBaFw0yNjAxMDEwMDAwMDBa
|
||||||
|
MBIxEDAOBgNVBAoTB2NlcnRjdGwwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQr
|
||||||
|
H2kMjsgP+FZuyMjJLNfewN0EDkN0s4Lz2Y1IqFqD8DlGN3zI3lPQ7hGdQbiCklPk
|
||||||
|
1YXNmfmI6L2JKxB/d9Gxo1cwVTAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYI
|
||||||
|
KwYBBQUHAwEwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBQAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAADAKBggqhkjOPQQDAgNIADBFAiEA4JIlRKL22y6c2JGwVtM60z2bGm9Lb9rq
|
||||||
|
3BSSLE8xF3UCIGSKd9bP0BBFIO20daxEP7g3/kTSSYpNMIG6yc6acdHH
|
||||||
|
-----END CERTIFICATE-----`
|
||||||
|
|
||||||
|
const testKeyPEM = `-----BEGIN EC PRIVATE KEY-----
|
||||||
|
MHQCAQEEIKj7N0fDjLaI9bGmJ/TY3PBvIxwclLOPIdOi6yWI2B5CoAcGBSuBBAAi
|
||||||
|
oWQDYgAEhLS0ynMvDJH5o0F5e6jVnXOBqRT2bHkVxQng+eqaXdY3gJoFIIxvR/q0
|
||||||
|
Vy4p3LZFQsKQfBwt3A8LLvOJY6E8bF4MNPrn0O1bQkeMjb8tSxdKfH0bARJdllD
|
||||||
|
h9oAPTR1
|
||||||
|
-----END EC PRIVATE KEY-----`
|
||||||
|
|
||||||
|
const testChainPEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBYzCCAQmgAwIBAgIRAKR1G0hS1jBOQH2VtNTzpHowCgYIKoZIzj0EAwIwEjEQ
|
||||||
|
MA4GA1UEChMHY2VydGN0bDAeFw0yNTAxMDEwMDAwMDBaFw0yNjAxMDEwMDAwMDBa
|
||||||
|
MBIxEDAOBgNVBAoTB2NlcnRjdGwwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASE
|
||||||
|
tLTKcy8MkfmjQXl7qNWdc4GpFPZseRXFCeD56ppd1jeAmgUgjG9H+rRXLinctkVC
|
||||||
|
wpB8HC3cDwsu84ljoTxso0IwQDAOBgNVHQ8BAf8EBAMCAoQwDwYDVR0TAQH/BAUw
|
||||||
|
AwEB/zAdBgNVHQ4EFgQUAAAAAAAAAAAAAAAAAAAAAAAwCgYIKoZIzj0EAwIDSAAw
|
||||||
|
RQIhAJ2K5VVTBiWBrZgdxNthZ7FEqrpNL9LiuD3bWx0xCaoAAiAh9+2p4PQmNuqN
|
||||||
|
R7kSqe/p0W0VnFx1nOJz/sDyPM+2qg==
|
||||||
|
-----END CERTIFICATE-----`
|
||||||
|
|
||||||
|
func TestDeployCertificate(t *testing.T) {
|
||||||
|
t.Run("FullSuccessWithChain", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.DeploymentRequest{
|
||||||
|
CertPEM: testCertPEM,
|
||||||
|
KeyPEM: testKeyPEM,
|
||||||
|
ChainPEM: testChainPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := conn.DeployCertificate(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeployCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Success {
|
||||||
|
t.Fatalf("expected success, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify call sequence
|
||||||
|
if !mock.hasCalled("Authenticate") {
|
||||||
|
t.Error("expected Authenticate call")
|
||||||
|
}
|
||||||
|
if mock.callCount("UploadFile") != 3 {
|
||||||
|
t.Errorf("expected 3 UploadFile calls (cert, key, chain), got %d", mock.callCount("UploadFile"))
|
||||||
|
}
|
||||||
|
if mock.callCount("InstallCert") != 2 { // cert + chain
|
||||||
|
t.Errorf("expected 2 InstallCert calls (cert + chain), got %d", mock.callCount("InstallCert"))
|
||||||
|
}
|
||||||
|
if mock.callCount("InstallKey") != 1 {
|
||||||
|
t.Errorf("expected 1 InstallKey call, got %d", mock.callCount("InstallKey"))
|
||||||
|
}
|
||||||
|
if !mock.hasCalled("CreateTransaction") {
|
||||||
|
t.Error("expected CreateTransaction call")
|
||||||
|
}
|
||||||
|
if !mock.hasCalled("UpdateSSLProfile") {
|
||||||
|
t.Error("expected UpdateSSLProfile call")
|
||||||
|
}
|
||||||
|
if !mock.hasCalled("CommitTransaction") {
|
||||||
|
t.Error("expected CommitTransaction call")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify metadata
|
||||||
|
if result.Metadata["host"] != "f5.test.com" {
|
||||||
|
t.Errorf("expected host f5.test.com in metadata, got %s", result.Metadata["host"])
|
||||||
|
}
|
||||||
|
if result.Metadata["partition"] != "Common" {
|
||||||
|
t.Errorf("expected partition Common in metadata, got %s", result.Metadata["partition"])
|
||||||
|
}
|
||||||
|
if result.Metadata["ssl_profile"] != "myprofile" {
|
||||||
|
t.Errorf("expected ssl_profile myprofile in metadata, got %s", result.Metadata["ssl_profile"])
|
||||||
|
}
|
||||||
|
if result.Metadata["cert_object_name"] == "" {
|
||||||
|
t.Error("expected cert_object_name in metadata")
|
||||||
|
}
|
||||||
|
if result.Metadata["duration_ms"] == "" {
|
||||||
|
t.Error("expected duration_ms in metadata")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SuccessWithoutChain", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.DeploymentRequest{
|
||||||
|
CertPEM: testCertPEM,
|
||||||
|
KeyPEM: testKeyPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := conn.DeployCertificate(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeployCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Success {
|
||||||
|
t.Fatalf("expected success, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only upload cert + key (no chain)
|
||||||
|
if mock.callCount("UploadFile") != 2 {
|
||||||
|
t.Errorf("expected 2 UploadFile calls, got %d", mock.callCount("UploadFile"))
|
||||||
|
}
|
||||||
|
if mock.callCount("InstallCert") != 1 { // only cert, no chain
|
||||||
|
t.Errorf("expected 1 InstallCert call (cert only), got %d", mock.callCount("InstallCert"))
|
||||||
|
}
|
||||||
|
if result.Metadata["chain_object_name"] != "" {
|
||||||
|
t.Errorf("expected empty chain_object_name, got %s", result.Metadata["chain_object_name"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MissingKeyPEM", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.DeploymentRequest{
|
||||||
|
CertPEM: testCertPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := conn.DeployCertificate(context.Background(), request)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing KeyPEM")
|
||||||
|
}
|
||||||
|
if result.Success {
|
||||||
|
t.Error("expected Success=false")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "KeyPEM") {
|
||||||
|
t.Errorf("expected KeyPEM in error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AuthFailure", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
mock.authenticateErr = fmt.Errorf("connection refused")
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "bad", Partition: "Common", SSLProfile: "myprofile"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM}
|
||||||
|
result, err := conn.DeployCertificate(context.Background(), request)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for auth failure")
|
||||||
|
}
|
||||||
|
if result.Success {
|
||||||
|
t.Error("expected Success=false")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "authentication failed") {
|
||||||
|
t.Errorf("expected auth failure in error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CertUploadFailure", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
mock.uploadFileErr = fmt.Errorf("upload timeout")
|
||||||
|
mock.uploadFileErrOn = "cert"
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM}
|
||||||
|
_, err := conn.DeployCertificate(context.Background(), request)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for cert upload failure")
|
||||||
|
}
|
||||||
|
// No cleanup needed — nothing installed yet
|
||||||
|
if len(mock.deletedCerts) > 0 || len(mock.deletedKeys) > 0 {
|
||||||
|
t.Error("expected no cleanup calls when upload fails before install")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CertInstallFailure", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
mock.installCertErr = fmt.Errorf("install failed")
|
||||||
|
// Don't set installCertErrOn — all InstallCert calls will fail
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM}
|
||||||
|
_, err := conn.DeployCertificate(context.Background(), request)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for cert install failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "cert crypto object") {
|
||||||
|
t.Errorf("expected cert install error, got: %v", err)
|
||||||
|
}
|
||||||
|
// No cleanup — cert install failed so nothing to clean up
|
||||||
|
// (the cert object wasn't successfully installed)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("KeyInstallFailure_CleansCert", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
mock.installKeyErr = fmt.Errorf("key install failed")
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM}
|
||||||
|
_, err := conn.DeployCertificate(context.Background(), request)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for key install failure")
|
||||||
|
}
|
||||||
|
// Should have cleaned up the cert that was installed
|
||||||
|
if len(mock.deletedCerts) != 1 {
|
||||||
|
t.Errorf("expected 1 cert cleanup, got %d", len(mock.deletedCerts))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TransactionCreateFailure_CleansObjects", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
mock.createTransactionErr = fmt.Errorf("transaction service unavailable")
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM}
|
||||||
|
_, err := conn.DeployCertificate(context.Background(), request)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for transaction create failure")
|
||||||
|
}
|
||||||
|
// Should clean up cert + key
|
||||||
|
if len(mock.deletedCerts) != 1 {
|
||||||
|
t.Errorf("expected 1 cert cleanup, got %d", len(mock.deletedCerts))
|
||||||
|
}
|
||||||
|
if len(mock.deletedKeys) != 1 {
|
||||||
|
t.Errorf("expected 1 key cleanup, got %d", len(mock.deletedKeys))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ProfileUpdateFailure_CleansObjects", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
mock.updateSSLProfileErr = fmt.Errorf("profile not found")
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "nonexistent"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM, ChainPEM: testChainPEM}
|
||||||
|
_, err := conn.DeployCertificate(context.Background(), request)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for profile update failure")
|
||||||
|
}
|
||||||
|
// Should clean up cert + chain + key
|
||||||
|
if len(mock.deletedCerts) != 2 { // cert + chain
|
||||||
|
t.Errorf("expected 2 cert cleanups (cert + chain), got %d", len(mock.deletedCerts))
|
||||||
|
}
|
||||||
|
if len(mock.deletedKeys) != 1 {
|
||||||
|
t.Errorf("expected 1 key cleanup, got %d", len(mock.deletedKeys))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CommitFailure_CleansObjects", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
mock.commitTransactionErr = fmt.Errorf("transaction validation failed")
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM}
|
||||||
|
_, err := conn.DeployCertificate(context.Background(), request)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for commit failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "commit") {
|
||||||
|
t.Errorf("expected commit error, got: %v", err)
|
||||||
|
}
|
||||||
|
// Should clean up installed objects
|
||||||
|
if len(mock.deletedCerts) < 1 {
|
||||||
|
t.Error("expected cert cleanup on commit failure")
|
||||||
|
}
|
||||||
|
if len(mock.deletedKeys) < 1 {
|
||||||
|
t.Error("expected key cleanup on commit failure")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MetadataVerification", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
cfg := &Config{Host: "bigip.prod.internal", Port: 8443, Username: "admin", Password: "secret", Partition: "Production", SSLProfile: "api-ssl"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM}
|
||||||
|
result, err := conn.DeployCertificate(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeployCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.Metadata["host"] != "bigip.prod.internal" {
|
||||||
|
t.Errorf("expected host bigip.prod.internal, got %s", result.Metadata["host"])
|
||||||
|
}
|
||||||
|
if result.Metadata["partition"] != "Production" {
|
||||||
|
t.Errorf("expected partition Production, got %s", result.Metadata["partition"])
|
||||||
|
}
|
||||||
|
if result.Metadata["ssl_profile"] != "api-ssl" {
|
||||||
|
t.Errorf("expected ssl_profile api-ssl, got %s", result.Metadata["ssl_profile"])
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(result.Metadata["cert_object_name"], "certctl-cert-") {
|
||||||
|
t.Errorf("expected cert_object_name to start with certctl-cert-, got %s", result.Metadata["cert_object_name"])
|
||||||
|
}
|
||||||
|
if result.TargetAddress != "bigip.prod.internal:8443" {
|
||||||
|
t.Errorf("expected target address bigip.prod.internal:8443, got %s", result.TargetAddress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ValidateDeployment tests ---
|
||||||
|
|
||||||
|
func TestValidateDeployment(t *testing.T) {
|
||||||
|
t.Run("Success", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
mock.getSSLProfileResult = &SSLProfileInfo{
|
||||||
|
Name: "myprofile",
|
||||||
|
Cert: "/Common/certctl-cert-1234567890",
|
||||||
|
Key: "/Common/certctl-key-1234567890",
|
||||||
|
Chain: "/Common/certctl-chain-1234567890",
|
||||||
|
}
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.ValidationRequest{
|
||||||
|
CertificateID: "mc-test-cert",
|
||||||
|
Serial: "abc123",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := conn.ValidateDeployment(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateDeployment failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Valid {
|
||||||
|
t.Fatalf("expected valid, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
if result.Metadata["current_cert"] != "/Common/certctl-cert-1234567890" {
|
||||||
|
t.Errorf("expected cert in metadata, got %s", result.Metadata["current_cert"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ProfileNotFound", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
mock.getSSLProfileErr = fmt.Errorf("object not found (404)")
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "nonexistent"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.ValidationRequest{CertificateID: "mc-test", Serial: "abc"}
|
||||||
|
result, err := conn.ValidateDeployment(context.Background(), request)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for profile not found")
|
||||||
|
}
|
||||||
|
if result.Valid {
|
||||||
|
t.Error("expected Valid=false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AuthFailure", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
mock.authenticateErr = fmt.Errorf("auth failed")
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "bad", Partition: "Common", SSLProfile: "myprofile"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.ValidationRequest{CertificateID: "mc-test", Serial: "abc"}
|
||||||
|
_, err := conn.ValidateDeployment(context.Background(), request)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for auth failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "authentication failed") {
|
||||||
|
t.Errorf("expected auth failure error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UnexpectedCert_StillValid", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
mock.getSSLProfileResult = &SSLProfileInfo{
|
||||||
|
Name: "myprofile",
|
||||||
|
Cert: "/Common/some-other-cert",
|
||||||
|
Key: "/Common/some-other-key",
|
||||||
|
}
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.ValidationRequest{CertificateID: "mc-test", Serial: "abc"}
|
||||||
|
result, err := conn.ValidateDeployment(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateDeployment failed: %v", err)
|
||||||
|
}
|
||||||
|
// We report what's there — it's valid (profile exists with a cert)
|
||||||
|
if !result.Valid {
|
||||||
|
t.Error("expected Valid=true (profile has a cert)")
|
||||||
|
}
|
||||||
|
if result.Metadata["current_cert"] != "/Common/some-other-cert" {
|
||||||
|
t.Errorf("expected current cert reported, got %s", result.Metadata["current_cert"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("EmptyCertField", func(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
mock.getSSLProfileResult = &SSLProfileInfo{
|
||||||
|
Name: "myprofile",
|
||||||
|
Cert: "",
|
||||||
|
Key: "",
|
||||||
|
}
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
request := target.ValidationRequest{CertificateID: "mc-test", Serial: "abc"}
|
||||||
|
result, err := conn.ValidateDeployment(context.Background(), request)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty cert field")
|
||||||
|
}
|
||||||
|
if result.Valid {
|
||||||
|
t.Error("expected Valid=false")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "no certificate configured") {
|
||||||
|
t.Errorf("expected 'no certificate configured' error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper tests ---
|
||||||
|
|
||||||
|
func TestObjectName(t *testing.T) {
|
||||||
|
name1 := objectName("cert")
|
||||||
|
name2 := objectName("cert")
|
||||||
|
|
||||||
|
if !strings.HasPrefix(name1, "certctl-cert-") {
|
||||||
|
t.Errorf("expected prefix certctl-cert-, got %s", name1)
|
||||||
|
}
|
||||||
|
// Nanosecond timestamps should produce different names
|
||||||
|
if name1 == name2 {
|
||||||
|
t.Error("expected unique names from nanosecond timestamps")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartitionPath(t *testing.T) {
|
||||||
|
path := partitionPath("Common", "certctl-cert-123")
|
||||||
|
if path != "/Common/certctl-cert-123" {
|
||||||
|
t.Errorf("expected /Common/certctl-cert-123, got %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
path = partitionPath("Production", "my-cert")
|
||||||
|
if path != "/Production/my-cert" {
|
||||||
|
t.Errorf("expected /Production/my-cert, got %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanup_MixedResults(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
mock.deleteCertErr = fmt.Errorf("cert in use") // cert delete fails
|
||||||
|
// key delete succeeds (nil error)
|
||||||
|
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Partition: "Common"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
// Should not panic and should attempt all deletions
|
||||||
|
conn.cleanupCryptoObjects(context.Background(), "Common",
|
||||||
|
[]string{"cert1", "cert2"},
|
||||||
|
[]string{"key1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Both cert deletes attempted despite errors
|
||||||
|
if len(mock.deletedCerts) != 2 {
|
||||||
|
t.Errorf("expected 2 cert delete attempts, got %d", len(mock.deletedCerts))
|
||||||
|
}
|
||||||
|
if len(mock.deletedKeys) != 1 {
|
||||||
|
t.Errorf("expected 1 key delete attempt, got %d", len(mock.deletedKeys))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanup_EmptyNames(t *testing.T) {
|
||||||
|
mock := newMockF5Client()
|
||||||
|
cfg := &Config{Host: "f5.test.com", Port: 443, Partition: "Common"}
|
||||||
|
conn := NewWithClient(cfg, testLogger(), mock)
|
||||||
|
|
||||||
|
// Empty names should be skipped
|
||||||
|
conn.cleanupCryptoObjects(context.Background(), "Common",
|
||||||
|
[]string{"", "cert1", ""},
|
||||||
|
[]string{"", ""},
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(mock.deletedCerts) != 1 {
|
||||||
|
t.Errorf("expected 1 cert delete (skipping empties), got %d", len(mock.deletedCerts))
|
||||||
|
}
|
||||||
|
if len(mock.deletedKeys) != 0 {
|
||||||
|
t.Errorf("expected 0 key deletes (all empty), got %d", len(mock.deletedKeys))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_NilConfig(t *testing.T) {
|
||||||
|
_, err := New(nil, testLogger())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil config")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "config is required") {
|
||||||
|
t.Errorf("expected 'config is required' error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,8 @@ package iis
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha1"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/pem"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -18,7 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/connector/target"
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
"github.com/shankar0123/certctl/internal/connector/target/certutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the IIS deployment target configuration.
|
// Config represents the IIS deployment target configuration.
|
||||||
@@ -256,7 +251,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Create PFX from PEM inputs
|
// Step 1: Create PFX from PEM inputs
|
||||||
pfxPassword, err := generateRandomPassword(32)
|
pfxPassword, err := certutil.GenerateRandomPassword(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := fmt.Sprintf("failed to generate PFX password: %v", err)
|
errMsg := fmt.Sprintf("failed to generate PFX password: %v", err)
|
||||||
c.logger.Error("deployment failed", "error", err)
|
c.logger.Error("deployment failed", "error", err)
|
||||||
@@ -267,7 +262,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
|||||||
}, fmt.Errorf("%s", errMsg)
|
}, fmt.Errorf("%s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
pfxData, err := createPFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword)
|
pfxData, err := certutil.CreatePFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := fmt.Sprintf("failed to create PFX: %v", err)
|
errMsg := fmt.Sprintf("failed to create PFX: %v", err)
|
||||||
c.logger.Error("PFX creation failed", "error", err)
|
c.logger.Error("PFX creation failed", "error", err)
|
||||||
@@ -281,7 +276,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
|||||||
// Step 2+3: Compute thumbprint and import PFX
|
// Step 2+3: Compute thumbprint and import PFX
|
||||||
// In local mode: write PFX to temp file, import via file path
|
// In local mode: write PFX to temp file, import via file path
|
||||||
// In WinRM mode: base64-encode PFX, decode on remote side to temp file, import, clean up
|
// In WinRM mode: base64-encode PFX, decode on remote side to temp file, import, clean up
|
||||||
thumbprint, err := computeThumbprint(request.CertPEM)
|
thumbprint, err := certutil.ComputeThumbprint(request.CertPEM)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := fmt.Sprintf("failed to compute certificate thumbprint: %v", err)
|
errMsg := fmt.Sprintf("failed to compute certificate thumbprint: %v", err)
|
||||||
c.logger.Error("deployment failed", "error", err)
|
c.logger.Error("deployment failed", "error", err)
|
||||||
@@ -564,97 +559,6 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createPFX converts PEM-encoded cert, key, and chain into PKCS#12 (PFX) format.
|
// NOTE: PFX creation, key parsing, thumbprint computation, and password generation
|
||||||
// IIS requires PFX for certificate import. Uses go-pkcs12 Modern encoder
|
// have been extracted to the shared certutil package (internal/connector/target/certutil)
|
||||||
// with strong encryption (same library used by M27 export service).
|
// for reuse by WinCertStore and JavaKeystore connectors.
|
||||||
func createPFX(certPEM, keyPEM, chainPEM string, password string) ([]byte, error) {
|
|
||||||
// Parse leaf certificate
|
|
||||||
certBlock, _ := pem.Decode([]byte(certPEM))
|
|
||||||
if certBlock == nil || certBlock.Type != "CERTIFICATE" {
|
|
||||||
return nil, fmt.Errorf("failed to decode certificate PEM")
|
|
||||||
}
|
|
||||||
leafCert, err := x509.ParseCertificate(certBlock.Bytes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse leaf certificate: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse private key (supports PKCS#8, PKCS#1 RSA, and EC)
|
|
||||||
keyBlock, _ := pem.Decode([]byte(keyPEM))
|
|
||||||
if keyBlock == nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode private key PEM")
|
|
||||||
}
|
|
||||||
privateKey, err := parsePrivateKey(keyBlock.Bytes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse CA chain certificates (optional)
|
|
||||||
var caCerts []*x509.Certificate
|
|
||||||
if chainPEM != "" {
|
|
||||||
rest := []byte(chainPEM)
|
|
||||||
for {
|
|
||||||
var block *pem.Block
|
|
||||||
block, rest = pem.Decode(rest)
|
|
||||||
if block == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if block.Type != "CERTIFICATE" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
caCert, err := x509.ParseCertificate(block.Bytes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse CA certificate: %w", err)
|
|
||||||
}
|
|
||||||
caCerts = append(caCerts, caCert)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode as PKCS#12 with Modern encryption
|
|
||||||
pfxData, err := pkcs12.Modern.Encode(privateKey, leafCert, caCerts, password)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to encode PKCS#12: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return pfxData, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parsePrivateKey attempts to parse a DER-encoded private key.
|
|
||||||
// Tries PKCS#8, PKCS#1 RSA, and EC formats in order.
|
|
||||||
func parsePrivateKey(der []byte) (interface{}, error) {
|
|
||||||
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
if key, err := x509.ParseECPrivateKey(der); err == nil {
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("unsupported private key format")
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeThumbprint calculates the SHA-1 thumbprint of a PEM-encoded certificate.
|
|
||||||
// IIS uses SHA-1 thumbprints as the primary certificate identifier.
|
|
||||||
// Returns uppercase hex string matching Windows certutil output.
|
|
||||||
func computeThumbprint(certPEM string) (string, error) {
|
|
||||||
block, _ := pem.Decode([]byte(certPEM))
|
|
||||||
if block == nil || block.Type != "CERTIFICATE" {
|
|
||||||
return "", fmt.Errorf("failed to decode certificate PEM for thumbprint")
|
|
||||||
}
|
|
||||||
hash := sha1.Sum(block.Bytes)
|
|
||||||
return strings.ToUpper(hex.EncodeToString(hash[:])), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateRandomPassword creates a random alphanumeric password for transient PFX encryption.
|
|
||||||
// The password is only used between PFX creation and import — it never persists.
|
|
||||||
func generateRandomPassword(length int) (string, error) {
|
|
||||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
||||||
b := make([]byte, length)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read random bytes: %w", err)
|
|
||||||
}
|
|
||||||
for i := range b {
|
|
||||||
b[i] = charset[int(b[i])%len(charset)]
|
|
||||||
}
|
|
||||||
return string(b), nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/connector/target"
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target/certutil"
|
||||||
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -672,7 +673,7 @@ func TestCreatePFX_Success(t *testing.T) {
|
|||||||
t.Fatalf("failed to generate test cert: %v", err)
|
t.Fatalf("failed to generate test cert: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pfxData, err := createPFX(certPEM, keyPEM, chainPEM, "testpassword")
|
pfxData, err := certutil.CreatePFX(certPEM, keyPEM, chainPEM, "testpassword")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("createPFX failed: %v", err)
|
t.Fatalf("createPFX failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -694,7 +695,7 @@ func TestCreatePFX_NoChain(t *testing.T) {
|
|||||||
t.Fatalf("failed to generate test cert: %v", err)
|
t.Fatalf("failed to generate test cert: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pfxData, err := createPFX(certPEM, keyPEM, "", "testpassword")
|
pfxData, err := certutil.CreatePFX(certPEM, keyPEM, "", "testpassword")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("createPFX with no chain failed: %v", err)
|
t.Fatalf("createPFX with no chain failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -710,7 +711,7 @@ func TestCreatePFX_InvalidCert(t *testing.T) {
|
|||||||
t.Fatalf("failed to generate test key: %v", err)
|
t.Fatalf("failed to generate test key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = createPFX("not a valid cert", keyPEM, "", "password")
|
_, err = certutil.CreatePFX("not a valid cert", keyPEM, "", "password")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for invalid cert PEM")
|
t.Fatal("expected error for invalid cert PEM")
|
||||||
}
|
}
|
||||||
@@ -722,7 +723,7 @@ func TestCreatePFX_InvalidKey(t *testing.T) {
|
|||||||
t.Fatalf("failed to generate test cert: %v", err)
|
t.Fatalf("failed to generate test cert: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = createPFX(certPEM, "not a valid key", "", "password")
|
_, err = certutil.CreatePFX(certPEM, "not a valid key", "", "password")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for invalid key PEM")
|
t.Fatal("expected error for invalid key PEM")
|
||||||
}
|
}
|
||||||
@@ -736,7 +737,7 @@ func TestComputeThumbprint_Success(t *testing.T) {
|
|||||||
t.Fatalf("failed to generate test cert: %v", err)
|
t.Fatalf("failed to generate test cert: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
thumbprint, err := computeThumbprint(certPEM)
|
thumbprint, err := certutil.ComputeThumbprint(certPEM)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("computeThumbprint failed: %v", err)
|
t.Fatalf("computeThumbprint failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -753,14 +754,14 @@ func TestComputeThumbprint_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestComputeThumbprint_InvalidPEM(t *testing.T) {
|
func TestComputeThumbprint_InvalidPEM(t *testing.T) {
|
||||||
_, err := computeThumbprint("not a valid pem")
|
_, err := certutil.ComputeThumbprint("not a valid pem")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for invalid PEM")
|
t.Fatal("expected error for invalid PEM")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestComputeThumbprint_EmptyString(t *testing.T) {
|
func TestComputeThumbprint_EmptyString(t *testing.T) {
|
||||||
_, err := computeThumbprint("")
|
_, err := certutil.ComputeThumbprint("")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for empty string")
|
t.Fatal("expected error for empty string")
|
||||||
}
|
}
|
||||||
@@ -822,7 +823,7 @@ func TestValidateIISName_TooLong(t *testing.T) {
|
|||||||
// --- Random password generation ---
|
// --- Random password generation ---
|
||||||
|
|
||||||
func TestGenerateRandomPassword(t *testing.T) {
|
func TestGenerateRandomPassword(t *testing.T) {
|
||||||
pw, err := generateRandomPassword(32)
|
pw, err := certutil.GenerateRandomPassword(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("generateRandomPassword failed: %v", err)
|
t.Fatalf("generateRandomPassword failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -838,7 +839,7 @@ func TestGenerateRandomPassword(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify two passwords are different (probabilistic but reliable)
|
// Verify two passwords are different (probabilistic but reliable)
|
||||||
pw2, _ := generateRandomPassword(32)
|
pw2, _ := certutil.GenerateRandomPassword(32)
|
||||||
if pw == pw2 {
|
if pw == pw2 {
|
||||||
t.Error("two generated passwords should be different")
|
t.Error("two generated passwords should be different")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
// Package javakeystore implements a target connector for deploying certificates
|
||||||
|
// to Java KeyStores (JKS/PKCS#12) via the keytool CLI. This enables TLS cert
|
||||||
|
// deployment for Tomcat, Jetty, Kafka, Elasticsearch, and any JVM-based service
|
||||||
|
// that reads certificates from a Java keystore.
|
||||||
|
//
|
||||||
|
// Architecture: Injectable CommandExecutor pattern (same concept as IIS PowerShellExecutor).
|
||||||
|
// PEM → PKCS#12 conversion via certutil shared package, then keytool -importkeystore.
|
||||||
|
// Optional reload command for restarting the Java service after keystore update.
|
||||||
|
package javakeystore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target/certutil"
|
||||||
|
"github.com/shankar0123/certctl/internal/validation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the Java Keystore deployment target configuration.
|
||||||
|
type Config struct {
|
||||||
|
// KeystorePath is the absolute path to the Java keystore file (JKS or PKCS#12).
|
||||||
|
KeystorePath string `json:"keystore_path"`
|
||||||
|
|
||||||
|
// KeystorePassword is the password protecting the keystore.
|
||||||
|
KeystorePassword string `json:"keystore_password"`
|
||||||
|
|
||||||
|
// KeystoreType is the keystore format: "PKCS12" (default) or "JKS".
|
||||||
|
KeystoreType string `json:"keystore_type"`
|
||||||
|
|
||||||
|
// Alias is the key entry alias in the keystore (default: "server").
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
|
||||||
|
// ReloadCommand is an optional command to run after updating the keystore
|
||||||
|
// (e.g., "systemctl restart tomcat"). Validated against shell injection.
|
||||||
|
ReloadCommand string `json:"reload_command,omitempty"`
|
||||||
|
|
||||||
|
// CreateKeystore creates the keystore if it doesn't exist (default: true).
|
||||||
|
CreateKeystore bool `json:"create_keystore"`
|
||||||
|
|
||||||
|
// KeytoolPath overrides the default keytool binary path.
|
||||||
|
// Default: "keytool" (found via PATH).
|
||||||
|
KeytoolPath string `json:"keytool_path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandExecutor abstracts command execution for testability.
|
||||||
|
type CommandExecutor interface {
|
||||||
|
Execute(ctx context.Context, name string, args ...string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// realExecutor calls commands on the local system.
|
||||||
|
type realExecutor struct{}
|
||||||
|
|
||||||
|
func (e *realExecutor) Execute(ctx context.Context, name string, args ...string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
return strings.TrimSpace(string(out)), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector implements the target.Connector interface for Java Keystore.
|
||||||
|
type Connector struct {
|
||||||
|
config *Config
|
||||||
|
logger *slog.Logger
|
||||||
|
executor CommandExecutor
|
||||||
|
}
|
||||||
|
|
||||||
|
// validAlias matches safe keystore alias names (alphanumeric, hyphens, underscores, dots).
|
||||||
|
var validAlias = regexp.MustCompile(`^[a-zA-Z0-9_\-\.]+$`)
|
||||||
|
|
||||||
|
// validKeystoreTypes defines allowed keystore type values.
|
||||||
|
var validKeystoreTypes = map[string]bool{
|
||||||
|
"PKCS12": true,
|
||||||
|
"JKS": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Java Keystore connector with the default command executor.
|
||||||
|
func New(cfg *Config, logger *slog.Logger) *Connector {
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = &Config{}
|
||||||
|
}
|
||||||
|
applyDefaults(cfg)
|
||||||
|
return &Connector{
|
||||||
|
config: cfg,
|
||||||
|
logger: logger,
|
||||||
|
executor: &realExecutor{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithExecutor creates a connector with an injected executor for testing.
|
||||||
|
func NewWithExecutor(cfg *Config, logger *slog.Logger, executor CommandExecutor) *Connector {
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = &Config{}
|
||||||
|
}
|
||||||
|
applyDefaults(cfg)
|
||||||
|
return &Connector{
|
||||||
|
config: cfg,
|
||||||
|
logger: logger,
|
||||||
|
executor: executor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDefaults(cfg *Config) {
|
||||||
|
if cfg.KeystoreType == "" {
|
||||||
|
cfg.KeystoreType = "PKCS12"
|
||||||
|
}
|
||||||
|
if cfg.Alias == "" {
|
||||||
|
cfg.Alias = "server"
|
||||||
|
}
|
||||||
|
if cfg.KeytoolPath == "" {
|
||||||
|
cfg.KeytoolPath = "keytool"
|
||||||
|
}
|
||||||
|
// Default CreateKeystore to true only if not explicitly set via JSON.
|
||||||
|
// Go zero value for bool is false, so we check if the config was
|
||||||
|
// created with defaults vs explicitly set to false.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfig validates the Java Keystore configuration.
|
||||||
|
func (c *Connector) ValidateConfig(ctx context.Context, config json.RawMessage) error {
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(config, &cfg); err != nil {
|
||||||
|
return fmt.Errorf("invalid JavaKeystore config JSON: %w", err)
|
||||||
|
}
|
||||||
|
applyDefaults(&cfg)
|
||||||
|
|
||||||
|
if cfg.KeystorePath == "" {
|
||||||
|
return fmt.Errorf("keystore_path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path traversal check — detect ".." in the raw path before Clean resolves it
|
||||||
|
if strings.Contains(cfg.KeystorePath, "..") {
|
||||||
|
return fmt.Errorf("keystore_path must not contain path traversal (..) sequences")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.KeystorePassword == "" {
|
||||||
|
return fmt.Errorf("keystore_password is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validKeystoreTypes[cfg.KeystoreType] {
|
||||||
|
return fmt.Errorf("invalid keystore_type: must be 'PKCS12' or 'JKS' (got %q)", cfg.KeystoreType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validAlias.MatchString(cfg.Alias) {
|
||||||
|
return fmt.Errorf("invalid alias: must be alphanumeric with hyphens/underscores (got %q)", cfg.Alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.ReloadCommand != "" {
|
||||||
|
if err := validation.ValidateShellCommand(cfg.ReloadCommand); err != nil {
|
||||||
|
return fmt.Errorf("invalid reload_command: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify parent directory exists for keystore path
|
||||||
|
dir := filepath.Dir(cfg.KeystorePath)
|
||||||
|
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||||
|
return fmt.Errorf("keystore directory does not exist: %s", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.config = &cfg
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployCertificate imports a certificate and key into the Java Keystore.
|
||||||
|
// Flow: PEM → PKCS#12 temp file → keytool -importkeystore → cleanup temp → optional reload
|
||||||
|
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||||
|
if request.KeyPEM == "" {
|
||||||
|
return nil, fmt.Errorf("private key is required for Java Keystore import")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("deploying certificate to Java Keystore",
|
||||||
|
"keystore", c.config.KeystorePath,
|
||||||
|
"alias", c.config.Alias,
|
||||||
|
"type", c.config.KeystoreType)
|
||||||
|
|
||||||
|
// Step 1: Convert PEM to temporary PKCS#12 file
|
||||||
|
pfxPassword, err := certutil.GenerateRandomPassword(32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate temp PFX password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pfxData, err := certutil.CreatePFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create temp PFX: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write PFX to temp file
|
||||||
|
tmpFile, err := os.CreateTemp("", "certctl-jks-*.p12")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create temp PFX file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
|
||||||
|
if _, err := tmpFile.Write(pfxData); err != nil {
|
||||||
|
tmpFile.Close()
|
||||||
|
return nil, fmt.Errorf("write temp PFX file: %w", err)
|
||||||
|
}
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
// Step 2: Delete existing alias if keystore exists (keytool -delete)
|
||||||
|
if _, err := os.Stat(c.config.KeystorePath); err == nil {
|
||||||
|
deleteArgs := []string{
|
||||||
|
"-delete",
|
||||||
|
"-alias", c.config.Alias,
|
||||||
|
"-keystore", c.config.KeystorePath,
|
||||||
|
"-storepass", c.config.KeystorePassword,
|
||||||
|
"-storetype", c.config.KeystoreType,
|
||||||
|
"-noprompt",
|
||||||
|
}
|
||||||
|
// Ignore error — alias may not exist yet
|
||||||
|
c.executor.Execute(ctx, c.config.KeytoolPath, deleteArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Import PKCS#12 into keystore (keytool -importkeystore)
|
||||||
|
importArgs := []string{
|
||||||
|
"-importkeystore",
|
||||||
|
"-srckeystore", tmpPath,
|
||||||
|
"-srcstoretype", "PKCS12",
|
||||||
|
"-srcstorepass", pfxPassword,
|
||||||
|
"-destkeystore", c.config.KeystorePath,
|
||||||
|
"-deststoretype", c.config.KeystoreType,
|
||||||
|
"-deststorepass", c.config.KeystorePassword,
|
||||||
|
"-destalias", c.config.Alias,
|
||||||
|
"-srcalias", "1", // go-pkcs12 uses alias "1" by default
|
||||||
|
"-noprompt",
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := c.executor.Execute(ctx, c.config.KeytoolPath, importArgs...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("keytool import failed: %s: %w", output, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Compute thumbprint for verification
|
||||||
|
thumbprint, err := certutil.ComputeThumbprint(request.CertPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("compute thumbprint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Optional reload command
|
||||||
|
if c.config.ReloadCommand != "" {
|
||||||
|
output, err := c.executor.Execute(ctx, "sh", "-c", c.config.ReloadCommand)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("reload command failed (non-fatal)", "error", err, "output", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("certificate imported to Java Keystore",
|
||||||
|
"keystore", c.config.KeystorePath,
|
||||||
|
"alias", c.config.Alias,
|
||||||
|
"thumbprint", thumbprint)
|
||||||
|
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: true,
|
||||||
|
TargetAddress: c.config.KeystorePath,
|
||||||
|
DeploymentID: thumbprint,
|
||||||
|
Message: fmt.Sprintf("Certificate imported to %s (alias: %s, thumbprint: %s)", c.config.KeystorePath, c.config.Alias, thumbprint),
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"thumbprint": thumbprint,
|
||||||
|
"alias": c.config.Alias,
|
||||||
|
"keystore_type": c.config.KeystoreType,
|
||||||
|
"keystore_path": c.config.KeystorePath,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDeployment verifies that a certificate exists in the Java Keystore
|
||||||
|
// by running keytool -list and checking the alias.
|
||||||
|
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||||
|
listArgs := []string{
|
||||||
|
"-list",
|
||||||
|
"-alias", c.config.Alias,
|
||||||
|
"-keystore", c.config.KeystorePath,
|
||||||
|
"-storepass", c.config.KeystorePassword,
|
||||||
|
"-storetype", c.config.KeystoreType,
|
||||||
|
"-v",
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := c.executor.Execute(ctx, c.config.KeytoolPath, listArgs...)
|
||||||
|
if err != nil {
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Serial: request.Serial,
|
||||||
|
Message: fmt.Sprintf("keytool list failed: %s", output),
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("keytool list failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the alias exists in the output
|
||||||
|
if !strings.Contains(output, c.config.Alias) {
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Serial: request.Serial,
|
||||||
|
Message: fmt.Sprintf("alias %q not found in keystore", c.config.Alias),
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("alias %q not found in keystore %s", c.config.Alias, c.config.KeystorePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract serial from keytool output for comparison
|
||||||
|
serialFound := false
|
||||||
|
if request.Serial != "" {
|
||||||
|
normalizedSerial := strings.ReplaceAll(strings.ToUpper(request.Serial), ":", "")
|
||||||
|
serialFound = strings.Contains(strings.ToUpper(output), normalizedSerial)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: true,
|
||||||
|
Serial: request.Serial,
|
||||||
|
TargetAddress: c.config.KeystorePath,
|
||||||
|
Message: fmt.Sprintf("Certificate found in keystore (alias: %s, serial_match: %v)", c.config.Alias, serialFound),
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"alias": c.config.Alias,
|
||||||
|
"serial_match": fmt.Sprintf("%v", serialFound),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Connector implements target.Connector.
|
||||||
|
var _ target.Connector = (*Connector)(nil)
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
package javakeystore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockExecutor records commands and returns configurable responses.
|
||||||
|
type mockExecutor struct {
|
||||||
|
calls []mockCall
|
||||||
|
responses []mockResponse
|
||||||
|
callIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockCall struct {
|
||||||
|
Name string
|
||||||
|
Args []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockResponse struct {
|
||||||
|
Output string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockExecutor) Execute(ctx context.Context, name string, args ...string) (string, error) {
|
||||||
|
m.calls = append(m.calls, mockCall{Name: name, Args: args})
|
||||||
|
idx := m.callIndex
|
||||||
|
m.callIndex++
|
||||||
|
if idx < len(m.responses) {
|
||||||
|
return m.responses[idx].Output, m.responses[idx].Err
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestCertAndKey creates a self-signed certificate and key for testing.
|
||||||
|
func generateTestCertAndKey() (string, string, error) {
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "test.example.com"},
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||||
|
|
||||||
|
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||||
|
|
||||||
|
return string(certPEM), string(keyPEM), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ValidateConfig Tests ---
|
||||||
|
|
||||||
|
func TestValidateConfig_Success(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg, _ := json.Marshal(Config{
|
||||||
|
KeystorePath: tmpDir + "/app.jks",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
KeystoreType: "JKS",
|
||||||
|
Alias: "server",
|
||||||
|
})
|
||||||
|
err := c.ValidateConfig(context.Background(), cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_Defaults(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg, _ := json.Marshal(Config{
|
||||||
|
KeystorePath: tmpDir + "/app.p12",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
})
|
||||||
|
err := c.ValidateConfig(context.Background(), cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success with defaults, got: %v", err)
|
||||||
|
}
|
||||||
|
if c.config.KeystoreType != "PKCS12" {
|
||||||
|
t.Errorf("expected default type PKCS12, got: %s", c.config.KeystoreType)
|
||||||
|
}
|
||||||
|
if c.config.Alias != "server" {
|
||||||
|
t.Errorf("expected default alias 'server', got: %s", c.config.Alias)
|
||||||
|
}
|
||||||
|
if c.config.KeytoolPath != "keytool" {
|
||||||
|
t.Errorf("expected default keytool path, got: %s", c.config.KeytoolPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidJSON(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
err := c.ValidateConfig(context.Background(), json.RawMessage(`{bad`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_MissingKeystorePath(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg, _ := json.Marshal(Config{KeystorePassword: "changeit"})
|
||||||
|
err := c.ValidateConfig(context.Background(), cfg)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "keystore_path is required") {
|
||||||
|
t.Fatalf("expected keystore_path error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_MissingPassword(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg, _ := json.Marshal(Config{KeystorePath: tmpDir + "/app.jks"})
|
||||||
|
err := c.ValidateConfig(context.Background(), cfg)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "keystore_password is required") {
|
||||||
|
t.Fatalf("expected password error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidKeystoreType(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg, _ := json.Marshal(Config{
|
||||||
|
KeystorePath: tmpDir + "/app.jks",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
KeystoreType: "BCFKS",
|
||||||
|
})
|
||||||
|
err := c.ValidateConfig(context.Background(), cfg)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid keystore_type") {
|
||||||
|
t.Fatalf("expected keystore_type error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidAlias(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg, _ := json.Marshal(Config{
|
||||||
|
KeystorePath: tmpDir + "/app.jks",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
Alias: "alias; rm -rf /",
|
||||||
|
})
|
||||||
|
err := c.ValidateConfig(context.Background(), cfg)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid alias") {
|
||||||
|
t.Fatalf("expected invalid alias error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_PathTraversal(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg, _ := json.Marshal(Config{
|
||||||
|
KeystorePath: "/etc/../../tmp/app.jks",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
})
|
||||||
|
err := c.ValidateConfig(context.Background(), cfg)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "path traversal") {
|
||||||
|
t.Fatalf("expected path traversal error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_DirNotExists(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg, _ := json.Marshal(Config{
|
||||||
|
KeystorePath: "/nonexistent/dir/app.jks",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
})
|
||||||
|
err := c.ValidateConfig(context.Background(), cfg)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "keystore directory does not exist") {
|
||||||
|
t.Fatalf("expected dir not exist error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_ReloadCommandInjection(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg, _ := json.Marshal(Config{
|
||||||
|
KeystorePath: tmpDir + "/app.jks",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
ReloadCommand: "systemctl restart tomcat; rm -rf /",
|
||||||
|
})
|
||||||
|
err := c.ValidateConfig(context.Background(), cfg)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid reload_command") {
|
||||||
|
t.Fatalf("expected reload_command error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_ValidReloadCommand(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg, _ := json.Marshal(Config{
|
||||||
|
KeystorePath: tmpDir + "/app.p12",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
ReloadCommand: "systemctl restart tomcat",
|
||||||
|
})
|
||||||
|
err := c.ValidateConfig(context.Background(), cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success with valid reload command, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DeployCertificate Tests ---
|
||||||
|
|
||||||
|
func TestDeployCertificate_Success(t *testing.T) {
|
||||||
|
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
mock := &mockExecutor{
|
||||||
|
responses: []mockResponse{
|
||||||
|
{Output: "", Err: nil}, // keytool -delete (alias may not exist)
|
||||||
|
{Output: "Import command completed", Err: nil}, // keytool -importkeystore
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c := NewWithExecutor(&Config{
|
||||||
|
KeystorePath: tmpDir + "/app.p12",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
KeystoreType: "PKCS12",
|
||||||
|
Alias: "server",
|
||||||
|
}, testLogger(), mock)
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
KeyPEM: keyPEM,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deploy failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Success {
|
||||||
|
t.Error("expected success=true")
|
||||||
|
}
|
||||||
|
if result.TargetAddress != tmpDir+"/app.p12" {
|
||||||
|
t.Errorf("expected keystore path as target address, got: %s", result.TargetAddress)
|
||||||
|
}
|
||||||
|
if result.Metadata["alias"] != "server" {
|
||||||
|
t.Errorf("expected alias 'server' in metadata, got: %s", result.Metadata["alias"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify keytool was called with correct args
|
||||||
|
if len(mock.calls) < 1 {
|
||||||
|
t.Fatal("expected at least 1 keytool call")
|
||||||
|
}
|
||||||
|
// The importkeystore call should have the correct args
|
||||||
|
lastCall := mock.calls[len(mock.calls)-1]
|
||||||
|
if lastCall.Name != "keytool" {
|
||||||
|
t.Errorf("expected keytool command, got: %s", lastCall.Name)
|
||||||
|
}
|
||||||
|
argsStr := strings.Join(lastCall.Args, " ")
|
||||||
|
if !strings.Contains(argsStr, "-importkeystore") {
|
||||||
|
t.Error("expected -importkeystore flag")
|
||||||
|
}
|
||||||
|
if !strings.Contains(argsStr, "-destalias server") {
|
||||||
|
t.Error("expected -destalias server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_MissingKey(t *testing.T) {
|
||||||
|
certPEM, _, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := NewWithExecutor(&Config{
|
||||||
|
KeystorePath: "/tmp/test.p12",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
}, testLogger(), &mockExecutor{})
|
||||||
|
|
||||||
|
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "private key is required") {
|
||||||
|
t.Fatalf("expected missing key error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_InvalidCert(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{
|
||||||
|
KeystorePath: "/tmp/test.p12",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
}, testLogger(), &mockExecutor{})
|
||||||
|
|
||||||
|
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||||
|
CertPEM: "not-a-cert",
|
||||||
|
KeyPEM: "not-a-key",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid cert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_ImportFailed(t *testing.T) {
|
||||||
|
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mock := &mockExecutor{
|
||||||
|
responses: []mockResponse{
|
||||||
|
// No existing keystore → delete is skipped → import is the first call
|
||||||
|
{Output: "keytool error: keystore password incorrect", Err: fmt.Errorf("exit 1")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c := NewWithExecutor(&Config{
|
||||||
|
KeystorePath: "/tmp/test.p12",
|
||||||
|
KeystorePassword: "wrongpassword",
|
||||||
|
}, testLogger(), mock)
|
||||||
|
|
||||||
|
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
KeyPEM: keyPEM,
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "keytool import failed") {
|
||||||
|
t.Fatalf("expected import failure error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_WithReload(t *testing.T) {
|
||||||
|
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mock := &mockExecutor{
|
||||||
|
responses: []mockResponse{
|
||||||
|
// No existing keystore → delete skipped → import is call 0, reload is call 1
|
||||||
|
{Output: "Imported", Err: nil}, // import
|
||||||
|
{Output: "restarted", Err: nil}, // reload
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c := NewWithExecutor(&Config{
|
||||||
|
KeystorePath: "/tmp/test.p12",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
ReloadCommand: "systemctl restart tomcat",
|
||||||
|
}, testLogger(), mock)
|
||||||
|
|
||||||
|
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
KeyPEM: keyPEM,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deploy failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify reload command was called (no existing keystore → delete skipped)
|
||||||
|
if len(mock.calls) < 2 {
|
||||||
|
t.Fatalf("expected 2 calls (import, reload), got %d", len(mock.calls))
|
||||||
|
}
|
||||||
|
reloadCall := mock.calls[1]
|
||||||
|
if reloadCall.Name != "sh" {
|
||||||
|
t.Errorf("expected sh for reload, got: %s", reloadCall.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_ReloadFailed_NonFatal(t *testing.T) {
|
||||||
|
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mock := &mockExecutor{
|
||||||
|
responses: []mockResponse{
|
||||||
|
{Output: "", Err: nil}, // delete
|
||||||
|
{Output: "Imported", Err: nil}, // import
|
||||||
|
{Output: "Failed to restart", Err: fmt.Errorf("exit 1")}, // reload fails
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c := NewWithExecutor(&Config{
|
||||||
|
KeystorePath: "/tmp/test.p12",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
ReloadCommand: "systemctl restart tomcat",
|
||||||
|
}, testLogger(), mock)
|
||||||
|
|
||||||
|
// Reload failure should NOT cause deploy to fail
|
||||||
|
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
KeyPEM: keyPEM,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deploy should succeed even when reload fails, got: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Success {
|
||||||
|
t.Error("expected success=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_JKSType(t *testing.T) {
|
||||||
|
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mock := &mockExecutor{
|
||||||
|
responses: []mockResponse{
|
||||||
|
{Output: "", Err: nil},
|
||||||
|
{Output: "Imported", Err: nil},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c := NewWithExecutor(&Config{
|
||||||
|
KeystorePath: "/tmp/test.jks",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
KeystoreType: "JKS",
|
||||||
|
Alias: "myapp",
|
||||||
|
}, testLogger(), mock)
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
KeyPEM: keyPEM,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deploy failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.Metadata["keystore_type"] != "JKS" {
|
||||||
|
t.Errorf("expected JKS type in metadata, got: %s", result.Metadata["keystore_type"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify keytool used JKS type
|
||||||
|
importCall := mock.calls[len(mock.calls)-1]
|
||||||
|
argsStr := strings.Join(importCall.Args, " ")
|
||||||
|
if !strings.Contains(argsStr, "-deststoretype JKS") {
|
||||||
|
t.Error("expected -deststoretype JKS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ValidateDeployment Tests ---
|
||||||
|
|
||||||
|
func TestValidateDeployment_Success(t *testing.T) {
|
||||||
|
mock := &mockExecutor{
|
||||||
|
responses: []mockResponse{
|
||||||
|
{Output: "Alias name: server\nCreation date: Jan 1, 2026\nEntry type: PrivateKeyEntry\nSerial number: DEADBEEF", Err: nil},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c := NewWithExecutor(&Config{
|
||||||
|
KeystorePath: "/tmp/test.p12",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
Alias: "server",
|
||||||
|
}, testLogger(), mock)
|
||||||
|
|
||||||
|
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||||
|
Serial: "DEADBEEF",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Valid {
|
||||||
|
t.Error("expected valid=true")
|
||||||
|
}
|
||||||
|
if result.Metadata["serial_match"] != "true" {
|
||||||
|
t.Error("expected serial_match=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDeployment_AliasNotFound(t *testing.T) {
|
||||||
|
mock := &mockExecutor{
|
||||||
|
responses: []mockResponse{
|
||||||
|
{Output: "keytool error: java.lang.Exception: Alias <server> does not exist", Err: fmt.Errorf("exit 1")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c := NewWithExecutor(&Config{
|
||||||
|
KeystorePath: "/tmp/test.p12",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
Alias: "server",
|
||||||
|
}, testLogger(), mock)
|
||||||
|
|
||||||
|
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||||
|
Serial: "01",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing alias")
|
||||||
|
}
|
||||||
|
if result.Valid {
|
||||||
|
t.Error("expected valid=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDeployment_SerialMismatch(t *testing.T) {
|
||||||
|
mock := &mockExecutor{
|
||||||
|
responses: []mockResponse{
|
||||||
|
{Output: "Alias name: server\nSerial number: AABBCCDD", Err: nil},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c := NewWithExecutor(&Config{
|
||||||
|
KeystorePath: "/tmp/test.p12",
|
||||||
|
KeystorePassword: "changeit",
|
||||||
|
Alias: "server",
|
||||||
|
}, testLogger(), mock)
|
||||||
|
|
||||||
|
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||||
|
Serial: "DEADBEEF",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Valid {
|
||||||
|
t.Error("expected valid=true (cert exists, just serial mismatch)")
|
||||||
|
}
|
||||||
|
if result.Metadata["serial_match"] != "false" {
|
||||||
|
t.Error("expected serial_match=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
// Package ssh implements a target.Connector for agentless certificate deployment
|
||||||
|
// via SSH/SFTP. This enables the "proxy agent" pattern — a certctl agent in the
|
||||||
|
// same network zone deploys certificates to remote servers without requiring the
|
||||||
|
// certctl agent binary on every target host.
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/sftp"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
|
"github.com/shankar0123/certctl/internal/validation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the SSH deployment target configuration.
|
||||||
|
// Supports key-based and password-based authentication for agentless
|
||||||
|
// certificate deployment to any Linux/Unix server.
|
||||||
|
type Config struct {
|
||||||
|
Host string `json:"host"` // Required. SSH hostname or IP.
|
||||||
|
Port int `json:"port"` // Default: 22.
|
||||||
|
User string `json:"user"` // Required. SSH username.
|
||||||
|
AuthMethod string `json:"auth_method"` // "key" (default) or "password".
|
||||||
|
PrivateKeyPath string `json:"private_key_path"` // Path to SSH private key file (when auth_method="key").
|
||||||
|
PrivateKey string `json:"private_key"` // Inline SSH private key PEM (alternative to path).
|
||||||
|
Password string `json:"password"` // SSH password (when auth_method="password").
|
||||||
|
Passphrase string `json:"passphrase"` // Optional passphrase for encrypted private keys.
|
||||||
|
CertPath string `json:"cert_path"` // Required. Remote path for certificate file.
|
||||||
|
KeyPath string `json:"key_path"` // Required. Remote path for private key file.
|
||||||
|
ChainPath string `json:"chain_path"` // Optional. Remote path for chain file.
|
||||||
|
CertMode string `json:"cert_mode"` // File permissions for cert (default: "0644").
|
||||||
|
KeyMode string `json:"key_mode"` // File permissions for key (default: "0600").
|
||||||
|
ReloadCommand string `json:"reload_command"` // Optional. Command to run after deployment.
|
||||||
|
Timeout int `json:"timeout"` // SSH connection timeout in seconds (default: 30).
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHClient abstracts SSH/SFTP operations for testability.
|
||||||
|
// The real implementation uses golang.org/x/crypto/ssh + github.com/pkg/sftp.
|
||||||
|
// Tests inject a mock to verify behavior without a real SSH server.
|
||||||
|
type SSHClient interface {
|
||||||
|
// Connect establishes an SSH connection to the remote host.
|
||||||
|
Connect(ctx context.Context) error
|
||||||
|
// WriteFile writes data to a remote path with the given permissions.
|
||||||
|
WriteFile(remotePath string, data []byte, mode os.FileMode) error
|
||||||
|
// Execute runs a command on the remote server and returns combined output.
|
||||||
|
Execute(ctx context.Context, command string) (string, error)
|
||||||
|
// StatFile checks if a remote file exists and returns its size.
|
||||||
|
StatFile(remotePath string) (int64, error)
|
||||||
|
// Close closes the SSH connection.
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector implements the target.Connector interface for SSH/SFTP deployment.
|
||||||
|
// This connector runs on the AGENT side and handles remote certificate deployment
|
||||||
|
// to Linux/Unix servers without requiring the certctl agent binary on each target.
|
||||||
|
type Connector struct {
|
||||||
|
config *Config
|
||||||
|
client SSHClient
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostRegex validates SSH hostnames (no shell metacharacters).
|
||||||
|
var hostRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||||
|
|
||||||
|
// permRegex validates octal permission strings like "0644" or "0600".
|
||||||
|
var permRegex = regexp.MustCompile(`^0[0-7]{3}$`)
|
||||||
|
|
||||||
|
// New creates a new SSH target connector with the given configuration and logger.
|
||||||
|
// Returns an error if the configuration is invalid.
|
||||||
|
func New(cfg *Config, logger *slog.Logger) (*Connector, error) {
|
||||||
|
applyDefaults(cfg)
|
||||||
|
client := &realSSHClient{config: cfg}
|
||||||
|
return &Connector{
|
||||||
|
config: cfg,
|
||||||
|
client: client,
|
||||||
|
logger: logger,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithClient creates a new SSH target connector with an injectable SSH client.
|
||||||
|
// Used in tests to mock SSH/SFTP operations.
|
||||||
|
func NewWithClient(cfg *Config, client SSHClient, logger *slog.Logger) *Connector {
|
||||||
|
applyDefaults(cfg)
|
||||||
|
return &Connector{
|
||||||
|
config: cfg,
|
||||||
|
client: client,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDefaults fills in default values for unset config fields.
|
||||||
|
func applyDefaults(cfg *Config) {
|
||||||
|
if cfg.Port == 0 {
|
||||||
|
cfg.Port = 22
|
||||||
|
}
|
||||||
|
if cfg.AuthMethod == "" {
|
||||||
|
cfg.AuthMethod = "key"
|
||||||
|
}
|
||||||
|
if cfg.CertMode == "" {
|
||||||
|
cfg.CertMode = "0644"
|
||||||
|
}
|
||||||
|
if cfg.KeyMode == "" {
|
||||||
|
cfg.KeyMode = "0600"
|
||||||
|
}
|
||||||
|
if cfg.Timeout == 0 {
|
||||||
|
cfg.Timeout = 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfig validates the SSH deployment target configuration.
|
||||||
|
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 SSH config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDefaults(&cfg)
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
if cfg.Host == "" {
|
||||||
|
return fmt.Errorf("SSH host is required")
|
||||||
|
}
|
||||||
|
if cfg.User == "" {
|
||||||
|
return fmt.Errorf("SSH user is required")
|
||||||
|
}
|
||||||
|
if cfg.CertPath == "" {
|
||||||
|
return fmt.Errorf("SSH cert_path is required")
|
||||||
|
}
|
||||||
|
if cfg.KeyPath == "" {
|
||||||
|
return fmt.Errorf("SSH key_path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate host (no shell metacharacters)
|
||||||
|
if !hostRegex.MatchString(cfg.Host) {
|
||||||
|
return fmt.Errorf("SSH host contains invalid characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth method validation
|
||||||
|
if cfg.AuthMethod != "key" && cfg.AuthMethod != "password" {
|
||||||
|
return fmt.Errorf("SSH auth_method must be \"key\" or \"password\", got %q", cfg.AuthMethod)
|
||||||
|
}
|
||||||
|
if cfg.AuthMethod == "key" {
|
||||||
|
if cfg.PrivateKeyPath == "" && cfg.PrivateKey == "" {
|
||||||
|
return fmt.Errorf("SSH key auth requires private_key_path or private_key")
|
||||||
|
}
|
||||||
|
// If path specified, verify file exists locally
|
||||||
|
if cfg.PrivateKeyPath != "" {
|
||||||
|
if _, err := os.Stat(cfg.PrivateKeyPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("SSH private key file not found: %s", cfg.PrivateKeyPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.AuthMethod == "password" && cfg.Password == "" {
|
||||||
|
return fmt.Errorf("SSH password auth requires password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file permissions
|
||||||
|
if !permRegex.MatchString(cfg.CertMode) {
|
||||||
|
return fmt.Errorf("SSH cert_mode must be octal (e.g., \"0644\"), got %q", cfg.CertMode)
|
||||||
|
}
|
||||||
|
if !permRegex.MatchString(cfg.KeyMode) {
|
||||||
|
return fmt.Errorf("SSH key_mode must be octal (e.g., \"0600\"), got %q", cfg.KeyMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reload command (if set) against shell injection
|
||||||
|
if cfg.ReloadCommand != "" {
|
||||||
|
if err := validation.ValidateShellCommand(cfg.ReloadCommand); err != nil {
|
||||||
|
return fmt.Errorf("SSH invalid reload_command: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.config = &cfg
|
||||||
|
c.logger.Info("SSH configuration validated",
|
||||||
|
"host", cfg.Host,
|
||||||
|
"port", cfg.Port,
|
||||||
|
"user", cfg.User,
|
||||||
|
"auth_method", cfg.AuthMethod,
|
||||||
|
"cert_path", cfg.CertPath,
|
||||||
|
"key_path", cfg.KeyPath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployCertificate deploys a certificate to the remote server via SSH/SFTP.
|
||||||
|
//
|
||||||
|
// Steps:
|
||||||
|
// 1. Connect to remote host via SSH
|
||||||
|
// 2. Write certificate (+ chain if chain_path not set) to cert_path
|
||||||
|
// 3. Write private key to key_path with restricted permissions
|
||||||
|
// 4. If chain_path is set and chain provided, write chain separately
|
||||||
|
// 5. If reload_command is set, execute it via SSH
|
||||||
|
// 6. Close connection
|
||||||
|
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||||
|
c.logger.Info("deploying certificate via SSH",
|
||||||
|
"host", c.config.Host,
|
||||||
|
"port", c.config.Port,
|
||||||
|
"cert_path", c.config.CertPath,
|
||||||
|
"key_path", c.config.KeyPath)
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Connect
|
||||||
|
if err := c.client.Connect(ctx); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("SSH connection failed: %v", err)
|
||||||
|
c.logger.Error("SSH connection failed", "error", err, "host", c.config.Host)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
defer c.client.Close()
|
||||||
|
|
||||||
|
// Parse file permissions
|
||||||
|
certMode, _ := parsePermissions(c.config.CertMode)
|
||||||
|
keyMode, _ := parsePermissions(c.config.KeyMode)
|
||||||
|
|
||||||
|
// Build cert data: if chain_path not set, append chain to cert (fullchain)
|
||||||
|
certData := request.CertPEM
|
||||||
|
if request.ChainPEM != "" && c.config.ChainPath == "" {
|
||||||
|
certData += "\n" + request.ChainPEM
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write certificate
|
||||||
|
if err := c.client.WriteFile(c.config.CertPath, []byte(certData), certMode); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to write certificate: %v", err)
|
||||||
|
c.logger.Error("certificate write failed", "error", err, "path", c.config.CertPath)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write private key (must have KeyPEM)
|
||||||
|
if request.KeyPEM == "" {
|
||||||
|
errMsg := "SSH deployment requires private key (KeyPEM)"
|
||||||
|
c.logger.Error("missing private key")
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
if err := c.client.WriteFile(c.config.KeyPath, []byte(request.KeyPEM), keyMode); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to write private key: %v", err)
|
||||||
|
c.logger.Error("key write failed", "error", err, "path", c.config.KeyPath)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write chain separately if chain_path configured
|
||||||
|
if c.config.ChainPath != "" && request.ChainPEM != "" {
|
||||||
|
if err := c.client.WriteFile(c.config.ChainPath, []byte(request.ChainPEM), certMode); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to write chain: %v", err)
|
||||||
|
c.logger.Error("chain write failed", "error", err, "path", c.config.ChainPath)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute reload command if configured
|
||||||
|
if c.config.ReloadCommand != "" {
|
||||||
|
c.logger.Debug("executing reload command", "command", c.config.ReloadCommand)
|
||||||
|
output, err := c.client.Execute(ctx, c.config.ReloadCommand)
|
||||||
|
if err != nil {
|
||||||
|
errMsg := fmt.Sprintf("reload command failed: %v (output: %s)", err, output)
|
||||||
|
c.logger.Error("reload command failed", "error", err, "output", output)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deploymentDuration := time.Since(startTime)
|
||||||
|
c.logger.Info("certificate deployed via SSH successfully",
|
||||||
|
"host", c.config.Host,
|
||||||
|
"duration", deploymentDuration.String(),
|
||||||
|
"cert_path", c.config.CertPath)
|
||||||
|
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: true,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
DeploymentID: fmt.Sprintf("ssh-%s-%d", c.config.Host, time.Now().Unix()),
|
||||||
|
Message: fmt.Sprintf("Certificate deployed via SSH to %s", c.config.Host),
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"host": c.config.Host,
|
||||||
|
"cert_path": c.config.CertPath,
|
||||||
|
"key_path": c.config.KeyPath,
|
||||||
|
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDeployment verifies that the deployed certificate files exist on the remote server.
|
||||||
|
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||||
|
c.logger.Info("validating SSH deployment",
|
||||||
|
"host", c.config.Host,
|
||||||
|
"certificate_id", request.CertificateID,
|
||||||
|
"serial", request.Serial)
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Connect
|
||||||
|
if err := c.client.Connect(ctx); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("SSH connection failed during validation: %v", err)
|
||||||
|
c.logger.Error("SSH connection failed", "error", err)
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Serial: request.Serial,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
defer c.client.Close()
|
||||||
|
|
||||||
|
// Verify cert file exists
|
||||||
|
if _, err := c.client.StatFile(c.config.CertPath); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("certificate file not found on remote: %s (%v)", c.config.CertPath, err)
|
||||||
|
c.logger.Error("validation failed", "error", err)
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Serial: request.Serial,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify key file exists
|
||||||
|
if _, err := c.client.StatFile(c.config.KeyPath); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("key file not found on remote: %s (%v)", c.config.KeyPath, err)
|
||||||
|
c.logger.Error("validation failed", "error", err)
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Serial: request.Serial,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
validationDuration := time.Since(startTime)
|
||||||
|
c.logger.Info("SSH deployment validated successfully",
|
||||||
|
"host", c.config.Host,
|
||||||
|
"duration", validationDuration.String())
|
||||||
|
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: true,
|
||||||
|
Serial: request.Serial,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: "Certificate and key files accessible on remote server",
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"host": c.config.Host,
|
||||||
|
"cert_path": c.config.CertPath,
|
||||||
|
"key_path": c.config.KeyPath,
|
||||||
|
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePermissions converts an octal permission string like "0644" to os.FileMode.
|
||||||
|
func parsePermissions(s string) (os.FileMode, error) {
|
||||||
|
var mode uint32
|
||||||
|
_, err := fmt.Sscanf(s, "%o", &mode)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid permission string %q: %w", s, err)
|
||||||
|
}
|
||||||
|
return os.FileMode(mode), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Real SSH client implementation ---
|
||||||
|
|
||||||
|
// realSSHClient implements SSHClient using golang.org/x/crypto/ssh + github.com/pkg/sftp.
|
||||||
|
type realSSHClient struct {
|
||||||
|
config *Config
|
||||||
|
sshClient *ssh.Client
|
||||||
|
sftpClient *sftp.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect establishes an SSH connection to the remote host.
|
||||||
|
func (c *realSSHClient) Connect(ctx context.Context) error {
|
||||||
|
authMethods, err := c.buildAuthMethods()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to build SSH auth: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConfig := &ssh.ClientConfig{
|
||||||
|
User: c.config.User,
|
||||||
|
Auth: authMethods,
|
||||||
|
Timeout: time.Duration(c.config.Timeout) * time.Second,
|
||||||
|
// InsecureIgnoreHostKey is used intentionally: certctl deploys to known
|
||||||
|
// infrastructure (the operator explicitly configures each target host).
|
||||||
|
// This is the same security rationale as network scanner's InsecureSkipVerify
|
||||||
|
// and F5 connector's insecure flag. Host key verification would require
|
||||||
|
// an additional known_hosts management layer that is out of scope.
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", c.config.Port))
|
||||||
|
|
||||||
|
// Use net.DialTimeout for context-aware connection (context cancellation
|
||||||
|
// is handled by the timeout on the SSH client config)
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, sshConfig.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("TCP connection to %s failed: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, sshConfig)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return fmt.Errorf("SSH handshake with %s failed: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sshClient = ssh.NewClient(sshConn, chans, reqs)
|
||||||
|
|
||||||
|
// Open SFTP session
|
||||||
|
c.sftpClient, err = sftp.NewClient(c.sshClient)
|
||||||
|
if err != nil {
|
||||||
|
c.sshClient.Close()
|
||||||
|
c.sshClient = nil
|
||||||
|
return fmt.Errorf("SFTP session failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAuthMethods constructs SSH auth methods from the config.
|
||||||
|
func (c *realSSHClient) buildAuthMethods() ([]ssh.AuthMethod, error) {
|
||||||
|
switch c.config.AuthMethod {
|
||||||
|
case "password":
|
||||||
|
return []ssh.AuthMethod{ssh.Password(c.config.Password)}, nil
|
||||||
|
|
||||||
|
case "key":
|
||||||
|
var keyData []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if c.config.PrivateKey != "" {
|
||||||
|
keyData = []byte(c.config.PrivateKey)
|
||||||
|
} else if c.config.PrivateKeyPath != "" {
|
||||||
|
keyData, err = os.ReadFile(c.config.PrivateKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read private key %s: %w", c.config.PrivateKeyPath, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("key auth requires private_key or private_key_path")
|
||||||
|
}
|
||||||
|
|
||||||
|
var signer ssh.Signer
|
||||||
|
if c.config.Passphrase != "" {
|
||||||
|
signer, err = ssh.ParsePrivateKeyWithPassphrase(keyData, []byte(c.config.Passphrase))
|
||||||
|
} else {
|
||||||
|
signer, err = ssh.ParsePrivateKey(keyData)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported auth method: %s", c.config.AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile writes data to a remote path via SFTP with the given permissions.
|
||||||
|
func (c *realSSHClient) WriteFile(remotePath string, data []byte, mode os.FileMode) error {
|
||||||
|
if c.sftpClient == nil {
|
||||||
|
return fmt.Errorf("SFTP client not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := c.sftpClient.Create(remotePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create remote file %s: %w", remotePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := f.Write(data); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return fmt.Errorf("failed to write remote file %s: %w", remotePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close remote file %s: %w", remotePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set file permissions
|
||||||
|
if err := c.sftpClient.Chmod(remotePath, mode); err != nil {
|
||||||
|
return fmt.Errorf("failed to set permissions on %s: %w", remotePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute runs a command on the remote server and returns combined output.
|
||||||
|
func (c *realSSHClient) Execute(ctx context.Context, command string) (string, error) {
|
||||||
|
if c.sshClient == nil {
|
||||||
|
return "", fmt.Errorf("SSH client not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := c.sshClient.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create SSH session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
output, err := session.CombinedOutput(command)
|
||||||
|
return string(output), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatFile checks if a remote file exists and returns its size.
|
||||||
|
func (c *realSSHClient) StatFile(remotePath string) (int64, error) {
|
||||||
|
if c.sftpClient == nil {
|
||||||
|
return 0, fmt.Errorf("SFTP client not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := c.sftpClient.Stat(remotePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to stat remote file %s: %w", remotePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the SFTP and SSH connections.
|
||||||
|
func (c *realSSHClient) Close() error {
|
||||||
|
if c.sftpClient != nil {
|
||||||
|
c.sftpClient.Close()
|
||||||
|
c.sftpClient = nil
|
||||||
|
}
|
||||||
|
if c.sshClient != nil {
|
||||||
|
c.sshClient.Close()
|
||||||
|
c.sshClient = nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,727 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testLogger returns a slog.Logger for test output.
|
||||||
|
func testLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mock SSH Client ---
|
||||||
|
|
||||||
|
// mockSSHClient records all calls and returns configurable results.
|
||||||
|
type mockSSHClient struct {
|
||||||
|
connectCalls int
|
||||||
|
connectErr error
|
||||||
|
writeFileCalls []writeFileCall
|
||||||
|
writeFileErr error
|
||||||
|
executeCalls []string
|
||||||
|
executeOutput string
|
||||||
|
executeErr error
|
||||||
|
statFileCalls []string
|
||||||
|
statFileSize int64
|
||||||
|
statFileErr error
|
||||||
|
closeCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeFileCall struct {
|
||||||
|
Path string
|
||||||
|
Data []byte
|
||||||
|
Mode os.FileMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSSHClient) Connect(ctx context.Context) error {
|
||||||
|
m.connectCalls++
|
||||||
|
return m.connectErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSSHClient) WriteFile(remotePath string, data []byte, mode os.FileMode) error {
|
||||||
|
m.writeFileCalls = append(m.writeFileCalls, writeFileCall{Path: remotePath, Data: data, Mode: mode})
|
||||||
|
return m.writeFileErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSSHClient) Execute(ctx context.Context, command string) (string, error) {
|
||||||
|
m.executeCalls = append(m.executeCalls, command)
|
||||||
|
return m.executeOutput, m.executeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSSHClient) StatFile(remotePath string) (int64, error) {
|
||||||
|
m.statFileCalls = append(m.statFileCalls, remotePath)
|
||||||
|
return m.statFileSize, m.statFileErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSSHClient) Close() error {
|
||||||
|
m.closeCalls++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ValidateConfig tests ---
|
||||||
|
|
||||||
|
func TestValidateConfig_Success_KeyAuth(t *testing.T) {
|
||||||
|
// Create a temporary key file
|
||||||
|
keyFile := createTempKeyFile(t)
|
||||||
|
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.example.com",
|
||||||
|
"user": "deploy",
|
||||||
|
"auth_method": "key",
|
||||||
|
"private_key_path": keyFile,
|
||||||
|
"cert_path": "/etc/ssl/certs/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/private/key.pem",
|
||||||
|
}
|
||||||
|
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.config.Port != 22 {
|
||||||
|
t.Errorf("expected default port 22, got %d", c.config.Port)
|
||||||
|
}
|
||||||
|
if c.config.CertMode != "0644" {
|
||||||
|
t.Errorf("expected default cert_mode 0644, got %s", c.config.CertMode)
|
||||||
|
}
|
||||||
|
if c.config.KeyMode != "0600" {
|
||||||
|
t.Errorf("expected default key_mode 0600, got %s", c.config.KeyMode)
|
||||||
|
}
|
||||||
|
if c.config.Timeout != 30 {
|
||||||
|
t.Errorf("expected default timeout 30, got %d", c.config.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_Success_InlineKey(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "10.0.0.5",
|
||||||
|
"user": "root",
|
||||||
|
"auth_method": "key",
|
||||||
|
"private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_Success_PasswordAuth(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"auth_method": "password",
|
||||||
|
"password": "s3cret",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidJSON(t *testing.T) {
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
err := c.ValidateConfig(context.Background(), json.RawMessage(`{invalid`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_MissingHost(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"user": "deploy",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_MissingUser(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_MissingCertPath(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing cert_path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_MissingKeyPath(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing key_path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_KeyAuth_MissingKey(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"auth_method": "key",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for key auth missing both private_key and private_key_path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_PasswordAuth_MissingPassword(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"auth_method": "password",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for password auth missing password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidHost(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server;rm -rf /",
|
||||||
|
"user": "deploy",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
"private_key": "fake",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for host with shell metacharacters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidPermissions(t *testing.T) {
|
||||||
|
keyFile := createTempKeyFile(t)
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"private_key_path": keyFile,
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
"cert_mode": "999",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid cert_mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_ReloadCommandInjection(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
command string
|
||||||
|
}{
|
||||||
|
{"semicolon", "systemctl reload nginx; rm -rf /"},
|
||||||
|
{"pipe", "systemctl reload nginx | cat"},
|
||||||
|
{"backtick", "systemctl reload `malicious`"},
|
||||||
|
{"command substitution", "systemctl reload $(evil)"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
keyFile := createTempKeyFile(t)
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"private_key_path": keyFile,
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
"reload_command": tc.command,
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for reload command injection: %q", tc.command)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidAuthMethod(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"auth_method": "kerberos",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid auth method")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_KeyFileNotFound(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"auth_method": "key",
|
||||||
|
"private_key_path": "/nonexistent/key.pem",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nonexistent key file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DeployCertificate tests ---
|
||||||
|
|
||||||
|
func TestDeployCertificate_Success_NoChainPath(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{statFileSize: 1024}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "-----BEGIN CERTIFICATE-----\ncert\n-----END CERTIFICATE-----",
|
||||||
|
KeyPEM: "-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----",
|
||||||
|
ChainPEM: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !result.Success {
|
||||||
|
t.Fatalf("expected success, got %s", result.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have 2 writes (cert with chain appended, key)
|
||||||
|
if len(mock.writeFileCalls) != 2 {
|
||||||
|
t.Fatalf("expected 2 write calls, got %d", len(mock.writeFileCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cert should include chain (fullchain)
|
||||||
|
certWrite := mock.writeFileCalls[0]
|
||||||
|
if certWrite.Path != "/etc/ssl/cert.pem" {
|
||||||
|
t.Errorf("expected cert path /etc/ssl/cert.pem, got %s", certWrite.Path)
|
||||||
|
}
|
||||||
|
if certWrite.Mode != 0644 {
|
||||||
|
t.Errorf("expected cert mode 0644, got %v", certWrite.Mode)
|
||||||
|
}
|
||||||
|
certContent := string(certWrite.Data)
|
||||||
|
if len(certContent) == 0 {
|
||||||
|
t.Error("cert data should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key write
|
||||||
|
keyWrite := mock.writeFileCalls[1]
|
||||||
|
if keyWrite.Path != "/etc/ssl/key.pem" {
|
||||||
|
t.Errorf("expected key path /etc/ssl/key.pem, got %s", keyWrite.Path)
|
||||||
|
}
|
||||||
|
if keyWrite.Mode != 0600 {
|
||||||
|
t.Errorf("expected key mode 0600, got %v", keyWrite.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
if result.Metadata["host"] != "server.local" {
|
||||||
|
t.Errorf("expected host metadata server.local, got %s", result.Metadata["host"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_Success_SeparateChain(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
ChainPath: "/etc/ssl/chain.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "cert-data",
|
||||||
|
KeyPEM: "key-data",
|
||||||
|
ChainPEM: "chain-data",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !result.Success {
|
||||||
|
t.Fatalf("expected success, got %s", result.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have 3 writes (cert, key, chain)
|
||||||
|
if len(mock.writeFileCalls) != 3 {
|
||||||
|
t.Fatalf("expected 3 write calls, got %d", len(mock.writeFileCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chain should be separate
|
||||||
|
chainWrite := mock.writeFileCalls[2]
|
||||||
|
if chainWrite.Path != "/etc/ssl/chain.pem" {
|
||||||
|
t.Errorf("expected chain path /etc/ssl/chain.pem, got %s", chainWrite.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_Success_WithReload(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{executeOutput: "ok"}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
ReloadCommand: "systemctl reload nginx",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "cert",
|
||||||
|
KeyPEM: "key",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !result.Success {
|
||||||
|
t.Fatalf("expected success, got %s", result.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have executed reload command
|
||||||
|
if len(mock.executeCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 execute call, got %d", len(mock.executeCalls))
|
||||||
|
}
|
||||||
|
if mock.executeCalls[0] != "systemctl reload nginx" {
|
||||||
|
t.Errorf("expected reload command, got %s", mock.executeCalls[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_MissingKeyPEM(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "cert",
|
||||||
|
KeyPEM: "", // Missing
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing KeyPEM")
|
||||||
|
}
|
||||||
|
if result.Success {
|
||||||
|
t.Fatal("expected failure result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_ConnectionFailure(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{connectErr: fmt.Errorf("connection refused")}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "unreachable.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "cert",
|
||||||
|
KeyPEM: "key",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for connection failure")
|
||||||
|
}
|
||||||
|
if result.Success {
|
||||||
|
t.Fatal("expected failure result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_WriteFailure(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{writeFileErr: fmt.Errorf("permission denied")}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "cert",
|
||||||
|
KeyPEM: "key",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for write failure")
|
||||||
|
}
|
||||||
|
if result.Success {
|
||||||
|
t.Fatal("expected failure result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_ReloadFailure(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{executeErr: fmt.Errorf("reload failed: exit status 1"), executeOutput: "error"}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
ReloadCommand: "systemctl reload nginx",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "cert",
|
||||||
|
KeyPEM: "key",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for reload failure")
|
||||||
|
}
|
||||||
|
if result.Success {
|
||||||
|
t.Fatal("expected failure result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ValidateDeployment tests ---
|
||||||
|
|
||||||
|
func TestValidateDeployment_Success(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{statFileSize: 2048}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.ValidationRequest{
|
||||||
|
CertificateID: "mc-test",
|
||||||
|
Serial: "ABC123",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.ValidateDeployment(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !result.Valid {
|
||||||
|
t.Fatalf("expected valid, got %s", result.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have stat'd both files
|
||||||
|
if len(mock.statFileCalls) != 2 {
|
||||||
|
t.Fatalf("expected 2 stat calls, got %d", len(mock.statFileCalls))
|
||||||
|
}
|
||||||
|
if mock.statFileCalls[0] != "/etc/ssl/cert.pem" {
|
||||||
|
t.Errorf("expected cert path, got %s", mock.statFileCalls[0])
|
||||||
|
}
|
||||||
|
if mock.statFileCalls[1] != "/etc/ssl/key.pem" {
|
||||||
|
t.Errorf("expected key path, got %s", mock.statFileCalls[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDeployment_CertNotFound(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{statFileErr: fmt.Errorf("file not found")}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.ValidationRequest{
|
||||||
|
CertificateID: "mc-test",
|
||||||
|
Serial: "ABC123",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.ValidateDeployment(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing cert")
|
||||||
|
}
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatal("expected invalid result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDeployment_ConnectionFailure(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{connectErr: fmt.Errorf("connection refused")}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "unreachable.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.ValidationRequest{
|
||||||
|
CertificateID: "mc-test",
|
||||||
|
Serial: "ABC123",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.ValidateDeployment(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for connection failure")
|
||||||
|
}
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatal("expected invalid result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper tests ---
|
||||||
|
|
||||||
|
func TestParsePermissions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected os.FileMode
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"0644", 0644, false},
|
||||||
|
{"0600", 0600, false},
|
||||||
|
{"0755", 0755, false},
|
||||||
|
{"invalid", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
|
mode, err := parsePermissions(tc.input)
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !tc.wantErr && mode != tc.expected {
|
||||||
|
t.Errorf("expected %v, got %v", tc.expected, mode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyDefaults(t *testing.T) {
|
||||||
|
cfg := &Config{}
|
||||||
|
applyDefaults(cfg)
|
||||||
|
|
||||||
|
if cfg.Port != 22 {
|
||||||
|
t.Errorf("expected port 22, got %d", cfg.Port)
|
||||||
|
}
|
||||||
|
if cfg.AuthMethod != "key" {
|
||||||
|
t.Errorf("expected auth_method key, got %s", cfg.AuthMethod)
|
||||||
|
}
|
||||||
|
if cfg.CertMode != "0644" {
|
||||||
|
t.Errorf("expected cert_mode 0644, got %s", cfg.CertMode)
|
||||||
|
}
|
||||||
|
if cfg.KeyMode != "0600" {
|
||||||
|
t.Errorf("expected key_mode 0600, got %s", cfg.KeyMode)
|
||||||
|
}
|
||||||
|
if cfg.Timeout != 30 {
|
||||||
|
t.Errorf("expected timeout 30, got %d", cfg.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
// createTempKeyFile creates a temporary file that simulates an SSH private key.
|
||||||
|
func createTempKeyFile(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
keyFile := dir + "/id_rsa"
|
||||||
|
if err := os.WriteFile(keyFile, []byte("fake-key-data"), 0600); err != nil {
|
||||||
|
t.Fatalf("failed to create temp key file: %v", err)
|
||||||
|
}
|
||||||
|
return keyFile
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Package wincertstore implements a target connector for deploying certificates
|
||||||
|
// to the Windows Certificate Store via PowerShell. Unlike the IIS connector,
|
||||||
|
// this connector only imports certificates into the store — it does not manage
|
||||||
|
// IIS site bindings. Use this for non-IIS Windows services that read certs
|
||||||
|
// from the Windows cert store (e.g., Exchange, RDP, SQL Server, ADFS).
|
||||||
|
//
|
||||||
|
// Architecture: Same injectable PowerShellExecutor pattern as the IIS connector.
|
||||||
|
// Supports agent-local PowerShell or WinRM proxy agent modes.
|
||||||
|
package wincertstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target/certutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the Windows Certificate Store deployment target configuration.
|
||||||
|
type Config struct {
|
||||||
|
// StoreName is the Windows certificate store name (e.g., "My", "Root", "WebHosting").
|
||||||
|
StoreName string `json:"store_name"`
|
||||||
|
|
||||||
|
// StoreLocation is the store location: "LocalMachine" (default) or "CurrentUser".
|
||||||
|
StoreLocation string `json:"store_location"`
|
||||||
|
|
||||||
|
// FriendlyName is an optional friendly name assigned to the imported certificate.
|
||||||
|
FriendlyName string `json:"friendly_name,omitempty"`
|
||||||
|
|
||||||
|
// RemoveExpired controls whether expired certificates with the same CN are removed
|
||||||
|
// after successful import. Default false.
|
||||||
|
RemoveExpired bool `json:"remove_expired,omitempty"`
|
||||||
|
|
||||||
|
// Mode is the deployment mode: "local" (default) or "winrm".
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
|
||||||
|
// WinRM settings (only used when Mode is "winrm").
|
||||||
|
WinRMHost string `json:"winrm_host,omitempty"`
|
||||||
|
WinRMPort int `json:"winrm_port,omitempty"`
|
||||||
|
WinRMUsername string `json:"winrm_username,omitempty"`
|
||||||
|
WinRMPassword string `json:"winrm_password,omitempty"`
|
||||||
|
WinRMHTTPS bool `json:"winrm_https,omitempty"`
|
||||||
|
WinRMInsecure bool `json:"winrm_insecure,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerShellExecutor abstracts PowerShell command execution for testability.
|
||||||
|
type PowerShellExecutor interface {
|
||||||
|
Execute(ctx context.Context, script string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// realExecutor calls powershell.exe on the local system.
|
||||||
|
type realExecutor struct{}
|
||||||
|
|
||||||
|
func (e *realExecutor) Execute(ctx context.Context, script string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
return strings.TrimSpace(string(out)), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector implements the target.Connector interface for Windows Certificate Store.
|
||||||
|
type Connector struct {
|
||||||
|
config *Config
|
||||||
|
logger *slog.Logger
|
||||||
|
executor PowerShellExecutor
|
||||||
|
}
|
||||||
|
|
||||||
|
// validStoreName matches safe Windows certificate store names (alphanumeric, spaces, hyphens, dots).
|
||||||
|
var validStoreName = regexp.MustCompile(`^[a-zA-Z0-9 _\-\.]+$`)
|
||||||
|
|
||||||
|
// validStoreLocation matches allowed store locations.
|
||||||
|
var validStoreLocations = map[string]bool{
|
||||||
|
"LocalMachine": true,
|
||||||
|
"CurrentUser": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Windows Certificate Store connector with the default PowerShell executor.
|
||||||
|
func New(cfg *Config, logger *slog.Logger) (*Connector, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = &Config{}
|
||||||
|
}
|
||||||
|
applyDefaults(cfg)
|
||||||
|
return &Connector{
|
||||||
|
config: cfg,
|
||||||
|
logger: logger,
|
||||||
|
executor: &realExecutor{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithExecutor creates a connector with an injected executor for testing.
|
||||||
|
func NewWithExecutor(cfg *Config, logger *slog.Logger, executor PowerShellExecutor) *Connector {
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = &Config{}
|
||||||
|
}
|
||||||
|
applyDefaults(cfg)
|
||||||
|
return &Connector{
|
||||||
|
config: cfg,
|
||||||
|
logger: logger,
|
||||||
|
executor: executor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDefaults(cfg *Config) {
|
||||||
|
if cfg.StoreName == "" {
|
||||||
|
cfg.StoreName = "My"
|
||||||
|
}
|
||||||
|
if cfg.StoreLocation == "" {
|
||||||
|
cfg.StoreLocation = "LocalMachine"
|
||||||
|
}
|
||||||
|
if cfg.Mode == "" {
|
||||||
|
cfg.Mode = "local"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfig validates the Windows Certificate Store configuration.
|
||||||
|
func (c *Connector) ValidateConfig(ctx context.Context, config json.RawMessage) error {
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(config, &cfg); err != nil {
|
||||||
|
return fmt.Errorf("invalid WinCertStore config JSON: %w", err)
|
||||||
|
}
|
||||||
|
applyDefaults(&cfg)
|
||||||
|
|
||||||
|
if !validStoreName.MatchString(cfg.StoreName) {
|
||||||
|
return fmt.Errorf("invalid store_name: must be alphanumeric (got %q)", cfg.StoreName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validStoreLocations[cfg.StoreLocation] {
|
||||||
|
return fmt.Errorf("invalid store_location: must be 'LocalMachine' or 'CurrentUser' (got %q)", cfg.StoreLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.FriendlyName != "" && !validStoreName.MatchString(cfg.FriendlyName) {
|
||||||
|
return fmt.Errorf("invalid friendly_name: must be alphanumeric (got %q)", cfg.FriendlyName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Mode != "local" && cfg.Mode != "winrm" {
|
||||||
|
return fmt.Errorf("invalid mode: must be 'local' or 'winrm' (got %q)", cfg.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Mode == "winrm" {
|
||||||
|
if cfg.WinRMHost == "" {
|
||||||
|
return fmt.Errorf("winrm_host is required when mode is 'winrm'")
|
||||||
|
}
|
||||||
|
if cfg.WinRMUsername == "" {
|
||||||
|
return fmt.Errorf("winrm_username is required when mode is 'winrm'")
|
||||||
|
}
|
||||||
|
if cfg.WinRMPassword == "" {
|
||||||
|
return fmt.Errorf("winrm_password is required when mode is 'winrm'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.config = &cfg
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployCertificate imports a certificate into the Windows Certificate Store.
|
||||||
|
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||||
|
if request.KeyPEM == "" {
|
||||||
|
return nil, fmt.Errorf("private key is required for Windows Certificate Store import")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("deploying certificate to Windows Certificate Store",
|
||||||
|
"store_name", c.config.StoreName,
|
||||||
|
"store_location", c.config.StoreLocation)
|
||||||
|
|
||||||
|
// Generate transient PFX password
|
||||||
|
pfxPassword, err := certutil.GenerateRandomPassword(32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate PFX password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert PEM to PFX
|
||||||
|
pfxData, err := certutil.CreatePFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create PFX: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute thumbprint for verification
|
||||||
|
thumbprint, err := certutil.ComputeThumbprint(request.CertPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("compute thumbprint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the PowerShell import script
|
||||||
|
pfxB64 := base64.StdEncoding.EncodeToString(pfxData)
|
||||||
|
script := c.buildImportScript(pfxB64, pfxPassword, thumbprint)
|
||||||
|
|
||||||
|
output, err := c.executor.Execute(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("PowerShell import failed: %s: %w", output, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("certificate imported to Windows Certificate Store",
|
||||||
|
"thumbprint", thumbprint,
|
||||||
|
"store", c.config.StoreName,
|
||||||
|
"location", c.config.StoreLocation)
|
||||||
|
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: true,
|
||||||
|
TargetAddress: fmt.Sprintf("cert:\\%s\\%s", c.config.StoreLocation, c.config.StoreName),
|
||||||
|
DeploymentID: thumbprint,
|
||||||
|
Message: fmt.Sprintf("Certificate imported to %s\\%s (thumbprint: %s)", c.config.StoreLocation, c.config.StoreName, thumbprint),
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"thumbprint": thumbprint,
|
||||||
|
"store_name": c.config.StoreName,
|
||||||
|
"store_location": c.config.StoreLocation,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildImportScript creates the PowerShell script to import a PFX into the cert store.
|
||||||
|
func (c *Connector) buildImportScript(pfxB64, pfxPassword, thumbprint string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Decode PFX from base64 and write to temp file
|
||||||
|
sb.WriteString(fmt.Sprintf("$pfxBytes = [System.Convert]::FromBase64String('%s')\n", pfxB64))
|
||||||
|
sb.WriteString("$pfxPath = [System.IO.Path]::GetTempFileName() + '.pfx'\n")
|
||||||
|
sb.WriteString("try {\n")
|
||||||
|
sb.WriteString(" [System.IO.File]::WriteAllBytes($pfxPath, $pfxBytes)\n")
|
||||||
|
|
||||||
|
// Import PFX to cert store
|
||||||
|
sb.WriteString(fmt.Sprintf(" $secPwd = ConvertTo-SecureString -String '%s' -Force -AsPlainText\n", pfxPassword))
|
||||||
|
sb.WriteString(fmt.Sprintf(" $cert = Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation 'Cert:\\%s\\%s' -Password $secPwd -Exportable\n",
|
||||||
|
c.config.StoreLocation, c.config.StoreName))
|
||||||
|
|
||||||
|
// Set friendly name if configured
|
||||||
|
if c.config.FriendlyName != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" $cert.FriendlyName = '%s'\n", c.config.FriendlyName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify import
|
||||||
|
sb.WriteString(fmt.Sprintf(" $imported = Get-ChildItem 'Cert:\\%s\\%s\\%s' -ErrorAction SilentlyContinue\n",
|
||||||
|
c.config.StoreLocation, c.config.StoreName, thumbprint))
|
||||||
|
sb.WriteString(" if (-not $imported) { throw 'Certificate import verification failed' }\n")
|
||||||
|
|
||||||
|
// Remove expired certs with same subject (optional)
|
||||||
|
if c.config.RemoveExpired {
|
||||||
|
sb.WriteString(" $subject = $cert.Subject\n")
|
||||||
|
sb.WriteString(fmt.Sprintf(" Get-ChildItem 'Cert:\\%s\\%s' | Where-Object { $_.Subject -eq $subject -and $_.NotAfter -lt (Get-Date) -and $_.Thumbprint -ne '%s' } | Remove-Item -Force\n",
|
||||||
|
c.config.StoreLocation, c.config.StoreName, thumbprint))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf(" Write-Output 'SUCCESS:%s'\n", thumbprint))
|
||||||
|
sb.WriteString("} finally {\n")
|
||||||
|
sb.WriteString(" if (Test-Path $pfxPath) { Remove-Item $pfxPath -Force }\n")
|
||||||
|
sb.WriteString("}\n")
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDeployment verifies that a certificate exists in the Windows Certificate Store.
|
||||||
|
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||||
|
// Get thumbprint from metadata if available, otherwise query by serial
|
||||||
|
thumbprint := ""
|
||||||
|
if request.Metadata != nil {
|
||||||
|
thumbprint = request.Metadata["thumbprint"]
|
||||||
|
}
|
||||||
|
|
||||||
|
var script string
|
||||||
|
if thumbprint != "" {
|
||||||
|
script = fmt.Sprintf("$cert = Get-ChildItem 'Cert:\\%s\\%s\\%s' -ErrorAction SilentlyContinue; if ($cert) { Write-Output ('FOUND:' + $cert.Thumbprint + ':' + $cert.NotAfter.ToString('o')) } else { Write-Output 'NOT_FOUND' }",
|
||||||
|
c.config.StoreLocation, c.config.StoreName, thumbprint)
|
||||||
|
} else {
|
||||||
|
// Fallback: search by serial number
|
||||||
|
script = fmt.Sprintf("$cert = Get-ChildItem 'Cert:\\%s\\%s' | Where-Object { $_.SerialNumber -eq '%s' } | Select-Object -First 1; if ($cert) { Write-Output ('FOUND:' + $cert.Thumbprint + ':' + $cert.NotAfter.ToString('o')) } else { Write-Output 'NOT_FOUND' }",
|
||||||
|
c.config.StoreLocation, c.config.StoreName, request.Serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := c.executor.Execute(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Serial: request.Serial,
|
||||||
|
Message: fmt.Sprintf("PowerShell query failed: %s", output),
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("validation query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(output, "FOUND:") {
|
||||||
|
parts := strings.SplitN(output, ":", 3)
|
||||||
|
foundThumb := ""
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
foundThumb = parts[1]
|
||||||
|
}
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: true,
|
||||||
|
Serial: request.Serial,
|
||||||
|
TargetAddress: fmt.Sprintf("cert:\\%s\\%s", c.config.StoreLocation, c.config.StoreName),
|
||||||
|
Message: fmt.Sprintf("Certificate found in store (thumbprint: %s)", foundThumb),
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"thumbprint": foundThumb,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Serial: request.Serial,
|
||||||
|
Message: "Certificate not found in Windows Certificate Store",
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("certificate not found in %s\\%s", c.config.StoreLocation, c.config.StoreName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Connector implements target.Connector.
|
||||||
|
var _ target.Connector = (*Connector)(nil)
|
||||||
|
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
package wincertstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockExecutor records PowerShell scripts and returns configurable responses.
|
||||||
|
type mockExecutor struct {
|
||||||
|
scripts []string
|
||||||
|
responses []string
|
||||||
|
errors []error
|
||||||
|
callIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockExecutor) Execute(ctx context.Context, script string) (string, error) {
|
||||||
|
m.scripts = append(m.scripts, script)
|
||||||
|
idx := m.callIndex
|
||||||
|
m.callIndex++
|
||||||
|
if idx < len(m.errors) && m.errors[idx] != nil {
|
||||||
|
resp := ""
|
||||||
|
if idx < len(m.responses) {
|
||||||
|
resp = m.responses[idx]
|
||||||
|
}
|
||||||
|
return resp, m.errors[idx]
|
||||||
|
}
|
||||||
|
if idx < len(m.responses) {
|
||||||
|
return m.responses[idx], nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestCertAndKey creates a self-signed certificate and key for testing.
|
||||||
|
func generateTestCertAndKey() (string, string, error) {
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "test.example.com"},
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||||
|
|
||||||
|
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||||
|
|
||||||
|
return string(certPEM), string(keyPEM), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ValidateConfig Tests ---
|
||||||
|
|
||||||
|
func TestValidateConfig_Success(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg := `{"store_name":"My","store_location":"LocalMachine"}`
|
||||||
|
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_Defaults(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg := `{}`
|
||||||
|
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success with defaults, got: %v", err)
|
||||||
|
}
|
||||||
|
if c.config.StoreName != "My" {
|
||||||
|
t.Errorf("expected default store_name 'My', got: %s", c.config.StoreName)
|
||||||
|
}
|
||||||
|
if c.config.StoreLocation != "LocalMachine" {
|
||||||
|
t.Errorf("expected default store_location 'LocalMachine', got: %s", c.config.StoreLocation)
|
||||||
|
}
|
||||||
|
if c.config.Mode != "local" {
|
||||||
|
t.Errorf("expected default mode 'local', got: %s", c.config.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidJSON(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
err := c.ValidateConfig(context.Background(), json.RawMessage(`{bad`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidStoreName(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg := `{"store_name":"My; Drop-Database"}`
|
||||||
|
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid store_name") {
|
||||||
|
t.Fatalf("expected invalid store_name error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidStoreLocation(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg := `{"store_location":"InvalidLocation"}`
|
||||||
|
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid store_location") {
|
||||||
|
t.Fatalf("expected invalid store_location error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_CurrentUser(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg := `{"store_location":"CurrentUser"}`
|
||||||
|
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success with CurrentUser, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidMode(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg := `{"mode":"ssh"}`
|
||||||
|
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid mode") {
|
||||||
|
t.Fatalf("expected invalid mode error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_WinRM_MissingHost(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg := `{"mode":"winrm","winrm_username":"admin","winrm_password":"pass"}`
|
||||||
|
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "winrm_host") {
|
||||||
|
t.Fatalf("expected winrm_host error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_WinRM_MissingUsername(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg := `{"mode":"winrm","winrm_host":"host","winrm_password":"pass"}`
|
||||||
|
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "winrm_username") {
|
||||||
|
t.Fatalf("expected winrm_username error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidFriendlyName(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg := `{"friendly_name":"cert; rm -rf /"}`
|
||||||
|
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid friendly_name") {
|
||||||
|
t.Fatalf("expected invalid friendly_name error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_WithFriendlyName(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
cfg := `{"friendly_name":"My Production Cert"}`
|
||||||
|
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success with friendly name, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DeployCertificate Tests ---
|
||||||
|
|
||||||
|
func TestDeployCertificate_Success(t *testing.T) {
|
||||||
|
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mock := &mockExecutor{
|
||||||
|
responses: []string{"SUCCESS:AABBCCDD"},
|
||||||
|
}
|
||||||
|
c := NewWithExecutor(&Config{
|
||||||
|
StoreName: "My",
|
||||||
|
StoreLocation: "LocalMachine",
|
||||||
|
}, testLogger(), mock)
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
KeyPEM: keyPEM,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deploy failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Success {
|
||||||
|
t.Error("expected success=true")
|
||||||
|
}
|
||||||
|
if result.TargetAddress != "cert:\\LocalMachine\\My" {
|
||||||
|
t.Errorf("expected target address cert:\\LocalMachine\\My, got: %s", result.TargetAddress)
|
||||||
|
}
|
||||||
|
if result.Metadata["store_name"] != "My" {
|
||||||
|
t.Errorf("expected store_name metadata 'My', got: %s", result.Metadata["store_name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the PowerShell script was called
|
||||||
|
if len(mock.scripts) != 1 {
|
||||||
|
t.Fatalf("expected 1 script call, got %d", len(mock.scripts))
|
||||||
|
}
|
||||||
|
script := mock.scripts[0]
|
||||||
|
if !strings.Contains(script, "Import-PfxCertificate") {
|
||||||
|
t.Error("expected Import-PfxCertificate in script")
|
||||||
|
}
|
||||||
|
if !strings.Contains(script, "Cert:\\LocalMachine\\My") {
|
||||||
|
t.Error("expected correct cert store path in script")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_MissingKey(t *testing.T) {
|
||||||
|
certPEM, _, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "private key is required") {
|
||||||
|
t.Fatalf("expected missing key error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_InvalidCert(t *testing.T) {
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||||
|
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||||
|
CertPEM: "not-a-cert",
|
||||||
|
KeyPEM: "not-a-key",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid cert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_ImportFailed(t *testing.T) {
|
||||||
|
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mock := &mockExecutor{
|
||||||
|
responses: []string{"Access denied"},
|
||||||
|
errors: []error{fmt.Errorf("exit code 1")},
|
||||||
|
}
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), mock)
|
||||||
|
|
||||||
|
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
KeyPEM: keyPEM,
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "PowerShell import failed") {
|
||||||
|
t.Fatalf("expected import failure error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_WithFriendlyName(t *testing.T) {
|
||||||
|
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mock := &mockExecutor{responses: []string{"SUCCESS:AABB"}}
|
||||||
|
c := NewWithExecutor(&Config{
|
||||||
|
StoreName: "My",
|
||||||
|
FriendlyName: "Production API Cert",
|
||||||
|
}, testLogger(), mock)
|
||||||
|
|
||||||
|
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
KeyPEM: keyPEM,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deploy failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(mock.scripts[0], "FriendlyName") {
|
||||||
|
t.Error("expected FriendlyName in PowerShell script")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_WithRemoveExpired(t *testing.T) {
|
||||||
|
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mock := &mockExecutor{responses: []string{"SUCCESS:AABB"}}
|
||||||
|
c := NewWithExecutor(&Config{
|
||||||
|
StoreName: "My",
|
||||||
|
RemoveExpired: true,
|
||||||
|
}, testLogger(), mock)
|
||||||
|
|
||||||
|
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
KeyPEM: keyPEM,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deploy failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(mock.scripts[0], "Remove-Item") {
|
||||||
|
t.Error("expected Remove-Item for expired cert cleanup in script")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ValidateDeployment Tests ---
|
||||||
|
|
||||||
|
func TestValidateDeployment_Success(t *testing.T) {
|
||||||
|
mock := &mockExecutor{
|
||||||
|
responses: []string{"FOUND:AABBCCDD:2027-01-01T00:00:00"},
|
||||||
|
}
|
||||||
|
c := NewWithExecutor(&Config{
|
||||||
|
StoreName: "My",
|
||||||
|
StoreLocation: "LocalMachine",
|
||||||
|
}, testLogger(), mock)
|
||||||
|
|
||||||
|
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||||
|
Serial: "01",
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"thumbprint": "AABBCCDD",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Valid {
|
||||||
|
t.Error("expected valid=true")
|
||||||
|
}
|
||||||
|
if result.Metadata["thumbprint"] != "AABBCCDD" {
|
||||||
|
t.Errorf("expected thumbprint AABBCCDD, got: %s", result.Metadata["thumbprint"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDeployment_NotFound(t *testing.T) {
|
||||||
|
mock := &mockExecutor{
|
||||||
|
responses: []string{"NOT_FOUND"},
|
||||||
|
}
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), mock)
|
||||||
|
|
||||||
|
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||||
|
Serial: "01",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for not found cert")
|
||||||
|
}
|
||||||
|
if result.Valid {
|
||||||
|
t.Error("expected valid=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDeployment_QueryFailed(t *testing.T) {
|
||||||
|
mock := &mockExecutor{
|
||||||
|
responses: []string{"error"},
|
||||||
|
errors: []error{fmt.Errorf("powershell error")},
|
||||||
|
}
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), mock)
|
||||||
|
|
||||||
|
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||||
|
Serial: "01",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for query failure")
|
||||||
|
}
|
||||||
|
if result.Valid {
|
||||||
|
t.Error("expected valid=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDeployment_BySerial(t *testing.T) {
|
||||||
|
mock := &mockExecutor{
|
||||||
|
responses: []string{"FOUND:AABB:2027-01-01T00:00:00"},
|
||||||
|
}
|
||||||
|
c := NewWithExecutor(&Config{}, testLogger(), mock)
|
||||||
|
|
||||||
|
// No thumbprint in metadata — should query by serial
|
||||||
|
_, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||||
|
Serial: "DEADBEEF",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(mock.scripts[0], "SerialNumber") {
|
||||||
|
t.Error("expected serial number query in script")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Package crypto provides AES-256-GCM encryption for sensitive configuration data.
|
||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/pbkdf2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Encrypt encrypts plaintext using AES-256-GCM with a random 12-byte nonce prepended to the output.
|
||||||
|
// The key must be exactly 32 bytes (AES-256). Returns [12-byte nonce][ciphertext+tag].
|
||||||
|
func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
|
||||||
|
if len(key) != 32 {
|
||||||
|
return nil, fmt.Errorf("encryption key must be exactly 32 bytes, got %d", len(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||||
|
return ciphertext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts ciphertext that was encrypted with Encrypt.
|
||||||
|
// Expects format: [12-byte nonce][ciphertext+tag]. Key must be exactly 32 bytes.
|
||||||
|
func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
|
||||||
|
if len(key) != 32 {
|
||||||
|
return nil, fmt.Errorf("encryption key must be exactly 32 bytes, got %d", len(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(ciphertext) < nonceSize {
|
||||||
|
return nil, fmt.Errorf("ciphertext too short: %d bytes", len(ciphertext))
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertextBody := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertextBody, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveKey derives a 32-byte AES-256 key from a passphrase using PBKDF2-SHA256.
|
||||||
|
// Uses a fixed application-specific salt and 100,000 iterations for resistance
|
||||||
|
// to brute-force attacks on weak passphrases.
|
||||||
|
func DeriveKey(passphrase string) []byte {
|
||||||
|
// Fixed salt is acceptable here because:
|
||||||
|
// 1. Each certctl instance has its own passphrase
|
||||||
|
// 2. The salt prevents generic rainbow table attacks
|
||||||
|
// 3. Per-user salts are unnecessary (single server key, not user passwords)
|
||||||
|
salt := []byte("certctl-config-encryption-v1")
|
||||||
|
return pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptIfKeySet encrypts plaintext if a key is provided, otherwise returns plaintext unchanged.
|
||||||
|
// This supports the development/demo fallback where encryption isn't configured.
|
||||||
|
func EncryptIfKeySet(plaintext []byte, key []byte) ([]byte, bool, error) {
|
||||||
|
if len(key) == 0 {
|
||||||
|
return plaintext, false, nil
|
||||||
|
}
|
||||||
|
encrypted, err := Encrypt(plaintext, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return encrypted, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptIfKeySet decrypts ciphertext if a key is provided, otherwise returns ciphertext unchanged.
|
||||||
|
func DecryptIfKeySet(ciphertext []byte, key []byte) ([]byte, error) {
|
||||||
|
if len(key) == 0 {
|
||||||
|
return ciphertext, nil
|
||||||
|
}
|
||||||
|
return Decrypt(ciphertext, key)
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||||
|
key := DeriveKey("test-passphrase")
|
||||||
|
plaintext := []byte(`{"api_key":"secret123","org_id":"456"}`)
|
||||||
|
|
||||||
|
encrypted, err := Encrypt(plaintext, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.Equal(encrypted, plaintext) {
|
||||||
|
t.Fatal("encrypted data should differ from plaintext")
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := Decrypt(encrypted, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(decrypted, plaintext) {
|
||||||
|
t.Fatalf("round-trip failed: got %q, want %q", decrypted, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptWrongKey(t *testing.T) {
|
||||||
|
key1 := DeriveKey("key-one")
|
||||||
|
key2 := DeriveKey("key-two")
|
||||||
|
plaintext := []byte("sensitive config data")
|
||||||
|
|
||||||
|
encrypted, err := Encrypt(plaintext, key1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = Decrypt(encrypted, key2)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when decrypting with wrong key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptTamperedCiphertext(t *testing.T) {
|
||||||
|
key := DeriveKey("test-key")
|
||||||
|
plaintext := []byte("important data")
|
||||||
|
|
||||||
|
encrypted, err := Encrypt(plaintext, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tamper with the ciphertext (flip a byte after the nonce)
|
||||||
|
if len(encrypted) > 13 {
|
||||||
|
encrypted[13] ^= 0xFF
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = Decrypt(encrypted, key)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when decrypting tampered ciphertext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptEmptyPlaintext(t *testing.T) {
|
||||||
|
key := DeriveKey("test-key")
|
||||||
|
plaintext := []byte{}
|
||||||
|
|
||||||
|
encrypted, err := Encrypt(plaintext, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt empty plaintext failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := Decrypt(encrypted, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt empty plaintext failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(decrypted, plaintext) {
|
||||||
|
t.Fatalf("empty plaintext round-trip failed: got %q", decrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptInvalidKeyLength(t *testing.T) {
|
||||||
|
_, err := Encrypt([]byte("data"), []byte("short-key"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid key length")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptInvalidKeyLength(t *testing.T) {
|
||||||
|
_, err := Decrypt([]byte("some-ciphertext-data"), []byte("short-key"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid key length")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptTooShortCiphertext(t *testing.T) {
|
||||||
|
key := DeriveKey("test-key")
|
||||||
|
_, err := Decrypt([]byte("short"), key)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for too-short ciphertext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveKeyDeterministic(t *testing.T) {
|
||||||
|
key1 := DeriveKey("same-passphrase")
|
||||||
|
key2 := DeriveKey("same-passphrase")
|
||||||
|
if !bytes.Equal(key1, key2) {
|
||||||
|
t.Fatal("DeriveKey should be deterministic")
|
||||||
|
}
|
||||||
|
if len(key1) != 32 {
|
||||||
|
t.Fatalf("DeriveKey should return 32 bytes, got %d", len(key1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveKeyDifferentPassphrases(t *testing.T) {
|
||||||
|
key1 := DeriveKey("passphrase-one")
|
||||||
|
key2 := DeriveKey("passphrase-two")
|
||||||
|
if bytes.Equal(key1, key2) {
|
||||||
|
t.Fatal("different passphrases should produce different keys")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptIfKeySet_WithKey(t *testing.T) {
|
||||||
|
key := DeriveKey("test-key")
|
||||||
|
plaintext := []byte("config data")
|
||||||
|
|
||||||
|
result, wasEncrypted, err := EncryptIfKeySet(plaintext, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
||||||
|
}
|
||||||
|
if !wasEncrypted {
|
||||||
|
t.Fatal("expected wasEncrypted=true when key provided")
|
||||||
|
}
|
||||||
|
if bytes.Equal(result, plaintext) {
|
||||||
|
t.Fatal("result should be encrypted")
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := DecryptIfKeySet(result, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptIfKeySet failed: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(decrypted, plaintext) {
|
||||||
|
t.Fatalf("round-trip failed: got %q", decrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptIfKeySet_NilKey(t *testing.T) {
|
||||||
|
plaintext := []byte("config data")
|
||||||
|
|
||||||
|
result, wasEncrypted, err := EncryptIfKeySet(plaintext, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptIfKeySet with nil key failed: %v", err)
|
||||||
|
}
|
||||||
|
if wasEncrypted {
|
||||||
|
t.Fatal("expected wasEncrypted=false when key is nil")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(result, plaintext) {
|
||||||
|
t.Fatal("result should be unchanged plaintext when key is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptIfKeySet_NilKey(t *testing.T) {
|
||||||
|
data := []byte("plaintext config data")
|
||||||
|
|
||||||
|
result, err := DecryptIfKeySet(data, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptIfKeySet with nil key failed: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(result, data) {
|
||||||
|
t.Fatal("result should be unchanged when key is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
|
||||||
|
key := DeriveKey("test-key")
|
||||||
|
plaintext := []byte("same data")
|
||||||
|
|
||||||
|
enc1, _ := Encrypt(plaintext, key)
|
||||||
|
enc2, _ := Encrypt(plaintext, key)
|
||||||
|
|
||||||
|
if bytes.Equal(enc1, enc2) {
|
||||||
|
t.Fatal("encrypting same plaintext twice should produce different ciphertexts (random nonce)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ package domain
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// RenewalInfo represents ACME Renewal Information (ARI) per RFC 9702.
|
// RenewalInfo represents ACME Renewal Information (ARI) per RFC 9773.
|
||||||
// It provides CA-directed renewal timing via a suggested renewal window.
|
// It provides CA-directed renewal timing via a suggested renewal window.
|
||||||
type RenewalInfo struct {
|
type RenewalInfo struct {
|
||||||
// SuggestedWindowStart is the beginning of the time window during which the CA suggests renewal.
|
// SuggestedWindowStart is the beginning of the time window during which the CA suggests renewal.
|
||||||
@@ -27,7 +27,7 @@ func (r *RenewalInfo) ShouldRenewNow() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OptimalRenewalTime returns the midpoint of the suggested renewal window,
|
// OptimalRenewalTime returns the midpoint of the suggested renewal window,
|
||||||
// which is the recommended time to initiate renewal per RFC 9702.
|
// which is the recommended time to initiate renewal per RFC 9773.
|
||||||
// This can be used for scheduling if the current time is before the window.
|
// This can be used for scheduling if the current time is before the window.
|
||||||
func (r *RenewalInfo) OptimalRenewalTime() time.Time {
|
func (r *RenewalInfo) OptimalRenewalTime() time.Time {
|
||||||
duration := r.SuggestedWindowEnd.Sub(r.SuggestedWindowStart)
|
duration := r.SuggestedWindowEnd.Sub(r.SuggestedWindowStart)
|
||||||
|
|||||||
@@ -78,3 +78,129 @@ func TestRenewalPolicy_EffectiveAlertThresholds_Nil(t *testing.T) {
|
|||||||
t.Errorf("expected %d thresholds, got %d", len(expected), len(result))
|
t.Errorf("expected %d thresholds, got %d", len(expected), len(result))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 45-Day / Short-Lived Certificate Renewal Threshold Tests ---
|
||||||
|
// These tests validate that certctl's renewal logic works correctly with shorter-lived
|
||||||
|
// certificates as the industry transitions from 90-day to 45-day validity (SC-081v3)
|
||||||
|
// and Let's Encrypt introduces 6-day "shortlived" profiles.
|
||||||
|
|
||||||
|
func TestRenewalThresholds_45DayCert(t *testing.T) {
|
||||||
|
// A 45-day cert with default thresholds [30, 14, 7, 0]:
|
||||||
|
// - 30-day alert fires when cert is 15 days old (45 - 30 = 15 days remaining)
|
||||||
|
// - 14-day alert fires when cert is 31 days old
|
||||||
|
// - 7-day alert fires when cert is 38 days old
|
||||||
|
// - 0-day alert fires at expiry
|
||||||
|
// The 30-day threshold fires at the 1/3 lifetime mark — this is correct
|
||||||
|
// (Let's Encrypt recommends renewal at 2/3 through lifetime, i.e. day 30).
|
||||||
|
thresholds := DefaultAlertThresholds()
|
||||||
|
|
||||||
|
certLifetimeDays := 45
|
||||||
|
for _, threshold := range thresholds {
|
||||||
|
daysCertAge := certLifetimeDays - threshold
|
||||||
|
if daysCertAge < 0 {
|
||||||
|
t.Errorf("threshold %d days exceeds cert lifetime %d days", threshold, certLifetimeDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the first alert (30 days) fires when 15 days remain
|
||||||
|
// This means the cert is 15 days old — at 1/3 of its lifetime
|
||||||
|
firstAlertDaysRemaining := certLifetimeDays - (certLifetimeDays - thresholds[0])
|
||||||
|
if firstAlertDaysRemaining != 30 {
|
||||||
|
t.Errorf("expected first alert at 30 days remaining, got %d", firstAlertDaysRemaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The renewal window query (31 days ahead) will find 45-day certs
|
||||||
|
// when they have 31 or fewer days remaining — at day 14 of a 45-day cert.
|
||||||
|
renewalWindowDays := 31
|
||||||
|
certAgeAtRenewalCheck := certLifetimeDays - renewalWindowDays
|
||||||
|
if certAgeAtRenewalCheck != 14 {
|
||||||
|
t.Errorf("expected renewal check to find cert at age %d, got %d", 14, certAgeAtRenewalCheck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenewalThresholds_6DayCert(t *testing.T) {
|
||||||
|
// A 6-day "shortlived" cert with default thresholds [30, 14, 7, 0]:
|
||||||
|
// - The 30-day, 14-day, and 7-day thresholds can NEVER fire (cert expires before reaching them)
|
||||||
|
// - Only the 0-day threshold fires at expiry
|
||||||
|
// For 6-day certs, ARI (RFC 9773) is the expected renewal path — the CA directs timing.
|
||||||
|
// Short-lived certs also skip CRL/OCSP (revocation via expiry, per M15b).
|
||||||
|
thresholds := DefaultAlertThresholds()
|
||||||
|
certLifetimeDays := 6
|
||||||
|
|
||||||
|
firingThresholds := 0
|
||||||
|
for _, threshold := range thresholds {
|
||||||
|
if threshold < certLifetimeDays {
|
||||||
|
firingThresholds++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the 0-day threshold can fire (0 < 6).
|
||||||
|
// The 7-day threshold means "alert when 7 days remain" — a 6-day cert
|
||||||
|
// never has 7 days remaining, so it never fires.
|
||||||
|
// For 6-day certs, ARI (RFC 9773) is the expected renewal path.
|
||||||
|
if firingThresholds != 1 {
|
||||||
|
t.Errorf("expected 1 threshold to fire for 6-day cert, got %d", firingThresholds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The renewal window query (31 days ahead) will find 6-day certs immediately
|
||||||
|
// (they're always within the 31-day window from the moment they're issued).
|
||||||
|
renewalWindowDays := 31
|
||||||
|
if certLifetimeDays < renewalWindowDays {
|
||||||
|
// This is expected — 6-day certs are always in the renewal window.
|
||||||
|
// ARI should override the threshold-based logic for these certs.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenewalThresholds_47DayCert(t *testing.T) {
|
||||||
|
// SC-081v3 mandates 47-day max validity by March 2029.
|
||||||
|
// Default thresholds [30, 14, 7, 0] should work correctly.
|
||||||
|
thresholds := DefaultAlertThresholds()
|
||||||
|
certLifetimeDays := 47
|
||||||
|
|
||||||
|
for _, threshold := range thresholds {
|
||||||
|
if threshold > certLifetimeDays {
|
||||||
|
t.Errorf("threshold %d exceeds cert lifetime %d", threshold, certLifetimeDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With RenewalWindowDays=30, renewal triggers at day 17 (47-30=17).
|
||||||
|
// That's at the 36% mark of the cert's lifetime — reasonable.
|
||||||
|
renewalWindowDays := 30
|
||||||
|
renewalDay := certLifetimeDays - renewalWindowDays
|
||||||
|
if renewalDay != 17 {
|
||||||
|
t.Errorf("expected renewal at day 17, got %d", renewalDay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenewalThresholds_200DayCert(t *testing.T) {
|
||||||
|
// SC-081v3 Phase 1: 200-day max validity (March 2026).
|
||||||
|
// All default thresholds should fire normally.
|
||||||
|
thresholds := DefaultAlertThresholds()
|
||||||
|
certLifetimeDays := 200
|
||||||
|
|
||||||
|
for _, threshold := range thresholds {
|
||||||
|
if threshold > certLifetimeDays {
|
||||||
|
t.Errorf("threshold %d exceeds cert lifetime %d", threshold, certLifetimeDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenewalThresholds_100DayCert(t *testing.T) {
|
||||||
|
// SC-081v3 Phase 2: 100-day max validity (March 2027).
|
||||||
|
thresholds := DefaultAlertThresholds()
|
||||||
|
certLifetimeDays := 100
|
||||||
|
|
||||||
|
for _, threshold := range thresholds {
|
||||||
|
if threshold > certLifetimeDays {
|
||||||
|
t.Errorf("threshold %d exceeds cert lifetime %d", threshold, certLifetimeDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With default 31-day renewal window, renewal triggers at day 69 — at 69% of lifetime.
|
||||||
|
// This is close to Let's Encrypt's recommended 2/3 mark.
|
||||||
|
renewalWindowDays := 31
|
||||||
|
renewalDay := certLifetimeDays - renewalWindowDays
|
||||||
|
if renewalDay != 69 {
|
||||||
|
t.Errorf("expected renewal at day 69, got %d", renewalDay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,25 +7,33 @@ import (
|
|||||||
|
|
||||||
// Issuer represents a certificate authority or ACME provider.
|
// Issuer represents a certificate authority or ACME provider.
|
||||||
type Issuer struct {
|
type Issuer struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type IssuerType `json:"type"`
|
Type IssuerType `json:"type"`
|
||||||
Config json.RawMessage `json:"config"`
|
Config json.RawMessage `json:"config"`
|
||||||
Enabled bool `json:"enabled"`
|
EncryptedConfig []byte `json:"-"` // AES-GCM encrypted full config (never exposed via API)
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Enabled bool `json:"enabled"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
|
||||||
|
TestStatus string `json:"test_status,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeploymentTarget represents a target system where certificates are deployed.
|
// DeploymentTarget represents a target system where certificates are deployed.
|
||||||
type DeploymentTarget struct {
|
type DeploymentTarget struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type TargetType `json:"type"`
|
Type TargetType `json:"type"`
|
||||||
AgentID string `json:"agent_id"`
|
AgentID string `json:"agent_id"`
|
||||||
Config json.RawMessage `json:"config"`
|
Config json.RawMessage `json:"config"`
|
||||||
Enabled bool `json:"enabled"`
|
EncryptedConfig []byte `json:"-"` // AES-GCM encrypted full config (never exposed via API)
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Enabled bool `json:"enabled"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
|
||||||
|
TestStatus string `json:"test_status,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent represents an agent running on a target system.
|
// Agent represents an agent running on a target system.
|
||||||
@@ -71,6 +79,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 +95,9 @@ 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"
|
||||||
|
TargetTypeSSH TargetType = "SSH"
|
||||||
|
TargetTypeWinCertStore TargetType = "WinCertStore"
|
||||||
|
TargetTypeJavaKeystore TargetType = "JavaKeystore"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,9 +43,8 @@ func TestCertificateLifecycle(t *testing.T) {
|
|||||||
localCA := local.New(nil, logger)
|
localCA := local.New(nil, logger)
|
||||||
|
|
||||||
// Build issuer registry with adapter
|
// Build issuer registry with adapter
|
||||||
issuerRegistry := map[string]service.IssuerConnector{
|
issuerRegistry := service.NewIssuerRegistry(logger)
|
||||||
"iss-local": service.NewIssuerConnectorAdapter(localCA),
|
issuerRegistry.Set("iss-local", service.NewIssuerConnectorAdapter(localCA))
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize services (following dependency graph)
|
// Initialize services (following dependency graph)
|
||||||
auditService := service.NewAuditService(auditRepo)
|
auditService := service.NewAuditService(auditRepo)
|
||||||
@@ -67,7 +66,7 @@ func TestCertificateLifecycle(t *testing.T) {
|
|||||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
|
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
|
||||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||||
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||||
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, nil, slog.Default())
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
certificateHandler := handler.NewCertificateHandler(certificateService)
|
certificateHandler := handler.NewCertificateHandler(certificateService)
|
||||||
@@ -90,7 +89,8 @@ func TestCertificateLifecycle(t *testing.T) {
|
|||||||
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
|
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
|
||||||
|
|
||||||
// EST handler — uses real Local CA issuer via ESTService
|
// EST handler — uses real Local CA issuer via ESTService
|
||||||
estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger)
|
localCAConnector, _ := issuerRegistry.Get("iss-local")
|
||||||
|
estService := service.NewESTService("iss-local", localCAConnector, auditService, logger)
|
||||||
estHandler := handler.NewESTHandler(estService)
|
estHandler := handler.NewESTHandler(estService)
|
||||||
|
|
||||||
// Create router and register handlers
|
// Create router and register handlers
|
||||||
@@ -786,6 +786,14 @@ func (m *mockTargetRepository) Create(ctx context.Context, target *domain.Deploy
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockTargetRepository) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) {
|
||||||
|
if _, exists := m.targets[target.ID]; exists {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
m.targets[target.ID] = target
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockTargetRepository) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
func (m *mockTargetRepository) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
||||||
m.targets[target.ID] = target
|
m.targets[target.ID] = target
|
||||||
return nil
|
return nil
|
||||||
@@ -954,6 +962,14 @@ func (m *mockIssuerRepository) Update(ctx context.Context, issuer *domain.Issuer
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockIssuerRepository) CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error) {
|
||||||
|
if _, exists := m.issuers[issuer.ID]; exists {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
m.issuers[issuer.ID] = issuer
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockIssuerRepository) Delete(ctx context.Context, id string) error {
|
func (m *mockIssuerRepository) Delete(ctx context.Context, id string) error {
|
||||||
delete(m.issuers, id)
|
delete(m.issuers, id)
|
||||||
return nil
|
return nil
|
||||||
@@ -1001,6 +1017,10 @@ func (m *mockTargetService) DeleteTarget(id string) error {
|
|||||||
return m.targetRepo.Delete(context.Background(), id)
|
return m.targetRepo.Delete(context.Background(), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockTargetService) TestTargetConnection(id string) error {
|
||||||
|
return nil // No-op for integration tests
|
||||||
|
}
|
||||||
|
|
||||||
type mockTeamService struct{}
|
type mockTeamService struct{}
|
||||||
|
|
||||||
func (m *mockTeamService) ListTeams(page, perPage int) ([]domain.Team, int64, error) {
|
func (m *mockTeamService) ListTeams(page, perPage int) ([]domain.Team, int64, error) {
|
||||||
|
|||||||
@@ -36,9 +36,8 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
|||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
localCA := local.New(nil, logger)
|
localCA := local.New(nil, logger)
|
||||||
|
|
||||||
issuerRegistry := map[string]service.IssuerConnector{
|
issuerRegistry := service.NewIssuerRegistry(logger)
|
||||||
"iss-local": service.NewIssuerConnectorAdapter(localCA),
|
issuerRegistry.Set("iss-local", service.NewIssuerConnectorAdapter(localCA))
|
||||||
}
|
|
||||||
|
|
||||||
revocationRepo := newMockRevocationRepository()
|
revocationRepo := newMockRevocationRepository()
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
|||||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
|
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
|
||||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||||
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||||
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, nil, logger)
|
||||||
|
|
||||||
certificateHandler := handler.NewCertificateHandler(certificateService)
|
certificateHandler := handler.NewCertificateHandler(certificateService)
|
||||||
issuerHandler := handler.NewIssuerHandler(issuerService)
|
issuerHandler := handler.NewIssuerHandler(issuerService)
|
||||||
@@ -81,7 +80,8 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
|||||||
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
|
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
|
||||||
|
|
||||||
// EST handler — uses real Local CA issuer via ESTService
|
// EST handler — uses real Local CA issuer via ESTService
|
||||||
estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger)
|
localCAConnector, _ := issuerRegistry.Get("iss-local")
|
||||||
|
estService := service.NewESTService("iss-local", localCAConnector, auditService, logger)
|
||||||
estHandler := handler.NewESTHandler(estService)
|
estHandler := handler.NewESTHandler(estService)
|
||||||
|
|
||||||
r := router.New()
|
r := router.New()
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ type IssuerRepository interface {
|
|||||||
Get(ctx context.Context, id string) (*domain.Issuer, error)
|
Get(ctx context.Context, id string) (*domain.Issuer, error)
|
||||||
// Create stores a new issuer.
|
// Create stores a new issuer.
|
||||||
Create(ctx context.Context, issuer *domain.Issuer) error
|
Create(ctx context.Context, issuer *domain.Issuer) error
|
||||||
|
// CreateIfNotExists creates an issuer only if the ID doesn't already exist (ON CONFLICT DO NOTHING).
|
||||||
|
// Returns true if created, false if already existed.
|
||||||
|
CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error)
|
||||||
// Update modifies an existing issuer.
|
// Update modifies an existing issuer.
|
||||||
Update(ctx context.Context, issuer *domain.Issuer) error
|
Update(ctx context.Context, issuer *domain.Issuer) error
|
||||||
// Delete removes an issuer.
|
// Delete removes an issuer.
|
||||||
@@ -65,6 +68,9 @@ type TargetRepository interface {
|
|||||||
Get(ctx context.Context, id string) (*domain.DeploymentTarget, error)
|
Get(ctx context.Context, id string) (*domain.DeploymentTarget, error)
|
||||||
// Create stores a new target.
|
// Create stores a new target.
|
||||||
Create(ctx context.Context, target *domain.DeploymentTarget) error
|
Create(ctx context.Context, target *domain.DeploymentTarget) error
|
||||||
|
// CreateIfNotExists creates a target only if the ID doesn't already exist (ON CONFLICT DO NOTHING).
|
||||||
|
// Returns true if created, false if already existed.
|
||||||
|
CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error)
|
||||||
// Update modifies an existing target.
|
// Update modifies an existing target.
|
||||||
Update(ctx context.Context, target *domain.DeploymentTarget) error
|
Update(ctx context.Context, target *domain.DeploymentTarget) error
|
||||||
// Delete removes a target.
|
// Delete removes a target.
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ func NewIssuerRepository(db *sql.DB) *IssuerRepository {
|
|||||||
// List returns all issuers
|
// List returns all issuers
|
||||||
func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
|
func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, name, type, config, enabled, created_at, updated_at
|
SELECT id, name, type, config, COALESCE(encrypted_config, NULL), enabled,
|
||||||
|
last_tested_at, COALESCE(test_status, 'untested'), COALESCE(source, 'database'),
|
||||||
|
created_at, updated_at
|
||||||
FROM issuers
|
FROM issuers
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`)
|
`)
|
||||||
@@ -36,7 +38,9 @@ func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var issuer domain.Issuer
|
var issuer domain.Issuer
|
||||||
if err := rows.Scan(&issuer.ID, &issuer.Name, &issuer.Type, &issuer.Config,
|
if err := rows.Scan(&issuer.ID, &issuer.Name, &issuer.Type, &issuer.Config,
|
||||||
&issuer.Enabled, &issuer.CreatedAt, &issuer.UpdatedAt); err != nil {
|
&issuer.EncryptedConfig, &issuer.Enabled,
|
||||||
|
&issuer.LastTestedAt, &issuer.TestStatus, &issuer.Source,
|
||||||
|
&issuer.CreatedAt, &issuer.UpdatedAt); err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan issuer: %w", err)
|
return nil, fmt.Errorf("failed to scan issuer: %w", err)
|
||||||
}
|
}
|
||||||
issuers = append(issuers, &issuer)
|
issuers = append(issuers, &issuer)
|
||||||
@@ -53,11 +57,15 @@ func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
|
|||||||
func (r *IssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) {
|
func (r *IssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) {
|
||||||
var issuer domain.Issuer
|
var issuer domain.Issuer
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
SELECT id, name, type, config, enabled, created_at, updated_at
|
SELECT id, name, type, config, COALESCE(encrypted_config, NULL), enabled,
|
||||||
|
last_tested_at, COALESCE(test_status, 'untested'), COALESCE(source, 'database'),
|
||||||
|
created_at, updated_at
|
||||||
FROM issuers
|
FROM issuers
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, id).Scan(&issuer.ID, &issuer.Name, &issuer.Type, &issuer.Config,
|
`, id).Scan(&issuer.ID, &issuer.Name, &issuer.Type, &issuer.Config,
|
||||||
&issuer.Enabled, &issuer.CreatedAt, &issuer.UpdatedAt)
|
&issuer.EncryptedConfig, &issuer.Enabled,
|
||||||
|
&issuer.LastTestedAt, &issuer.TestStatus, &issuer.Source,
|
||||||
|
&issuer.CreatedAt, &issuer.UpdatedAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -75,11 +83,22 @@ func (r *IssuerRepository) Create(ctx context.Context, issuer *domain.Issuer) er
|
|||||||
issuer.ID = uuid.New().String()
|
issuer.ID = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
source := issuer.Source
|
||||||
|
if source == "" {
|
||||||
|
source = "database"
|
||||||
|
}
|
||||||
|
testStatus := issuer.TestStatus
|
||||||
|
if testStatus == "" {
|
||||||
|
testStatus = "untested"
|
||||||
|
}
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at)
|
INSERT INTO issuers (id, name, type, config, encrypted_config, enabled,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
last_tested_at, test_status, source, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, issuer.ID, issuer.Name, issuer.Type, issuer.Config, issuer.Enabled,
|
`, issuer.ID, issuer.Name, issuer.Type, issuer.Config, issuer.EncryptedConfig,
|
||||||
|
issuer.Enabled, issuer.LastTestedAt, testStatus, source,
|
||||||
issuer.CreatedAt, issuer.UpdatedAt).Scan(&issuer.ID)
|
issuer.CreatedAt, issuer.UpdatedAt).Scan(&issuer.ID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -89,6 +108,40 @@ func (r *IssuerRepository) Create(ctx context.Context, issuer *domain.Issuer) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateIfNotExists creates an issuer only if the ID doesn't already exist.
|
||||||
|
// Used for env var seeding on first boot. Returns true if created, false if already existed.
|
||||||
|
func (r *IssuerRepository) CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error) {
|
||||||
|
source := issuer.Source
|
||||||
|
if source == "" {
|
||||||
|
source = "env"
|
||||||
|
}
|
||||||
|
testStatus := issuer.TestStatus
|
||||||
|
if testStatus == "" {
|
||||||
|
testStatus = "untested"
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO issuers (id, name, type, config, encrypted_config, enabled,
|
||||||
|
last_tested_at, test_status, source, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
`, issuer.ID, issuer.Name, issuer.Type, issuer.Config, issuer.EncryptedConfig,
|
||||||
|
issuer.Enabled, issuer.LastTestedAt, testStatus, source,
|
||||||
|
issuer.CreatedAt, issuer.UpdatedAt).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// ON CONFLICT DO NOTHING — row already existed
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("failed to create issuer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Update modifies an existing issuer
|
// Update modifies an existing issuer
|
||||||
func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) error {
|
func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) error {
|
||||||
result, err := r.db.ExecContext(ctx, `
|
result, err := r.db.ExecContext(ctx, `
|
||||||
@@ -96,10 +149,15 @@ func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) er
|
|||||||
name = $1,
|
name = $1,
|
||||||
type = $2,
|
type = $2,
|
||||||
config = $3,
|
config = $3,
|
||||||
enabled = $4,
|
encrypted_config = $4,
|
||||||
updated_at = $5
|
enabled = $5,
|
||||||
WHERE id = $6
|
last_tested_at = $6,
|
||||||
`, issuer.Name, issuer.Type, issuer.Config, issuer.Enabled, issuer.UpdatedAt, issuer.ID)
|
test_status = $7,
|
||||||
|
updated_at = $8
|
||||||
|
WHERE id = $9
|
||||||
|
`, issuer.Name, issuer.Type, issuer.Config, issuer.EncryptedConfig,
|
||||||
|
issuer.Enabled, issuer.LastTestedAt, issuer.TestStatus,
|
||||||
|
issuer.UpdatedAt, issuer.ID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to update issuer: %w", err)
|
return fmt.Errorf("failed to update issuer: %w", err)
|
||||||
|
|||||||
@@ -19,10 +19,40 @@ func NewTargetRepository(db *sql.DB) *TargetRepository {
|
|||||||
return &TargetRepository{db: db}
|
return &TargetRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scanTarget scans a target row including optional M35 columns (encrypted_config, last_tested_at, test_status, source).
|
||||||
|
func scanTarget(scanner interface {
|
||||||
|
Scan(dest ...interface{}) error
|
||||||
|
}, target *domain.DeploymentTarget) error {
|
||||||
|
var lastTestedAt sql.NullTime
|
||||||
|
var testStatus sql.NullString
|
||||||
|
var source sql.NullString
|
||||||
|
if err := scanner.Scan(
|
||||||
|
&target.ID, &target.Name, &target.Type, &target.AgentID,
|
||||||
|
&target.Config, &target.EncryptedConfig, &target.Enabled,
|
||||||
|
&lastTestedAt, &testStatus, &source,
|
||||||
|
&target.CreatedAt, &target.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if lastTestedAt.Valid {
|
||||||
|
target.LastTestedAt = &lastTestedAt.Time
|
||||||
|
}
|
||||||
|
if testStatus.Valid {
|
||||||
|
target.TestStatus = testStatus.String
|
||||||
|
}
|
||||||
|
if source.Valid {
|
||||||
|
target.Source = source.String
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetSelectColumns is the standard column list for target queries.
|
||||||
|
const targetSelectColumns = `id, name, type, agent_id, config, COALESCE(encrypted_config, ''::bytea), enabled, last_tested_at, COALESCE(test_status, 'untested'), COALESCE(source, 'database'), created_at, updated_at`
|
||||||
|
|
||||||
// List returns all targets
|
// List returns all targets
|
||||||
func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget, error) {
|
func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, name, type, agent_id, config, enabled, created_at, updated_at
|
SELECT `+targetSelectColumns+`
|
||||||
FROM deployment_targets
|
FROM deployment_targets
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`)
|
`)
|
||||||
@@ -35,8 +65,7 @@ func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget
|
|||||||
var targets []*domain.DeploymentTarget
|
var targets []*domain.DeploymentTarget
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var target domain.DeploymentTarget
|
var target domain.DeploymentTarget
|
||||||
if err := rows.Scan(&target.ID, &target.Name, &target.Type, &target.AgentID,
|
if err := scanTarget(rows, &target); err != nil {
|
||||||
&target.Config, &target.Enabled, &target.CreatedAt, &target.UpdatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to scan target: %w", err)
|
return nil, fmt.Errorf("failed to scan target: %w", err)
|
||||||
}
|
}
|
||||||
targets = append(targets, &target)
|
targets = append(targets, &target)
|
||||||
@@ -52,12 +81,11 @@ func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget
|
|||||||
// Get retrieves a target by ID
|
// Get retrieves a target by ID
|
||||||
func (r *TargetRepository) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
|
func (r *TargetRepository) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
|
||||||
var target domain.DeploymentTarget
|
var target domain.DeploymentTarget
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := scanTarget(r.db.QueryRowContext(ctx, `
|
||||||
SELECT id, name, type, agent_id, config, enabled, created_at, updated_at
|
SELECT `+targetSelectColumns+`
|
||||||
FROM deployment_targets
|
FROM deployment_targets
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, id).Scan(&target.ID, &target.Name, &target.Type, &target.AgentID,
|
`, id), &target)
|
||||||
&target.Config, &target.Enabled, &target.CreatedAt, &target.UpdatedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -76,10 +104,11 @@ func (r *TargetRepository) Create(ctx context.Context, target *domain.Deployment
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
INSERT INTO deployment_targets (id, name, type, agent_id, config, enabled, created_at, updated_at)
|
INSERT INTO deployment_targets (id, name, type, agent_id, config, encrypted_config, enabled, last_tested_at, test_status, source, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, target.ID, target.Name, target.Type, target.AgentID, target.Config, target.Enabled,
|
`, target.ID, target.Name, target.Type, target.AgentID, target.Config, target.EncryptedConfig,
|
||||||
|
target.Enabled, target.LastTestedAt, target.TestStatus, target.Source,
|
||||||
target.CreatedAt, target.UpdatedAt).Scan(&target.ID)
|
target.CreatedAt, target.UpdatedAt).Scan(&target.ID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -89,6 +118,33 @@ func (r *TargetRepository) Create(ctx context.Context, target *domain.Deployment
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateIfNotExists creates a target only if the ID doesn't already exist (ON CONFLICT DO NOTHING).
|
||||||
|
// Returns true if created, false if already existed.
|
||||||
|
func (r *TargetRepository) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) {
|
||||||
|
if target.ID == "" {
|
||||||
|
target.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO deployment_targets (id, name, type, agent_id, config, encrypted_config, enabled, last_tested_at, test_status, source, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`, target.ID, target.Name, target.Type, target.AgentID, target.Config, target.EncryptedConfig,
|
||||||
|
target.Enabled, target.LastTestedAt, target.TestStatus, target.Source,
|
||||||
|
target.CreatedAt, target.UpdatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to create target: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get rows affected: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Update modifies an existing target
|
// Update modifies an existing target
|
||||||
func (r *TargetRepository) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
func (r *TargetRepository) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
||||||
result, err := r.db.ExecContext(ctx, `
|
result, err := r.db.ExecContext(ctx, `
|
||||||
@@ -97,10 +153,16 @@ func (r *TargetRepository) Update(ctx context.Context, target *domain.Deployment
|
|||||||
type = $2,
|
type = $2,
|
||||||
agent_id = $3,
|
agent_id = $3,
|
||||||
config = $4,
|
config = $4,
|
||||||
enabled = $5,
|
encrypted_config = $5,
|
||||||
updated_at = $6
|
enabled = $6,
|
||||||
WHERE id = $7
|
last_tested_at = $7,
|
||||||
`, target.Name, target.Type, target.AgentID, target.Config, target.Enabled, target.UpdatedAt, target.ID)
|
test_status = $8,
|
||||||
|
source = $9,
|
||||||
|
updated_at = $10
|
||||||
|
WHERE id = $11
|
||||||
|
`, target.Name, target.Type, target.AgentID, target.Config, target.EncryptedConfig,
|
||||||
|
target.Enabled, target.LastTestedAt, target.TestStatus, target.Source,
|
||||||
|
target.UpdatedAt, target.ID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to update target: %w", err)
|
return fmt.Errorf("failed to update target: %w", err)
|
||||||
@@ -141,7 +203,7 @@ func (r *TargetRepository) Delete(ctx context.Context, id string) error {
|
|||||||
// ListByCertificate returns all targets for a given certificate
|
// ListByCertificate returns all targets for a given certificate
|
||||||
func (r *TargetRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.DeploymentTarget, error) {
|
func (r *TargetRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.DeploymentTarget, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT dt.id, dt.name, dt.type, dt.agent_id, dt.config, dt.enabled, dt.created_at, dt.updated_at
|
SELECT dt.id, dt.name, dt.type, dt.agent_id, dt.config, COALESCE(dt.encrypted_config, ''::bytea), dt.enabled, dt.last_tested_at, COALESCE(dt.test_status, 'untested'), COALESCE(dt.source, 'database'), dt.created_at, dt.updated_at
|
||||||
FROM deployment_targets dt
|
FROM deployment_targets dt
|
||||||
INNER JOIN certificate_target_mappings ctm ON dt.id = ctm.target_id
|
INNER JOIN certificate_target_mappings ctm ON dt.id = ctm.target_id
|
||||||
WHERE ctm.certificate_id = $1
|
WHERE ctm.certificate_id = $1
|
||||||
@@ -156,8 +218,7 @@ func (r *TargetRepository) ListByCertificate(ctx context.Context, certID string)
|
|||||||
var targets []*domain.DeploymentTarget
|
var targets []*domain.DeploymentTarget
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var target domain.DeploymentTarget
|
var target domain.DeploymentTarget
|
||||||
if err := rows.Scan(&target.ID, &target.Name, &target.Type, &target.AgentID,
|
if err := scanTarget(rows, &target); err != nil {
|
||||||
&target.Config, &target.Enabled, &target.CreatedAt, &target.UpdatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to scan target: %w", err)
|
return nil, fmt.Errorf("failed to scan target: %w", err)
|
||||||
}
|
}
|
||||||
targets = append(targets, &target)
|
targets = append(targets, &target)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type AgentService struct {
|
|||||||
targetRepo repository.TargetRepository
|
targetRepo repository.TargetRepository
|
||||||
profileRepo repository.CertificateProfileRepository
|
profileRepo repository.CertificateProfileRepository
|
||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
issuerRegistry map[string]IssuerConnector
|
issuerRegistry *IssuerRegistry
|
||||||
renewalService *RenewalService
|
renewalService *RenewalService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ func NewAgentService(
|
|||||||
jobRepo repository.JobRepository,
|
jobRepo repository.JobRepository,
|
||||||
targetRepo repository.TargetRepository,
|
targetRepo repository.TargetRepository,
|
||||||
auditService *AuditService,
|
auditService *AuditService,
|
||||||
issuerRegistry map[string]IssuerConnector,
|
issuerRegistry *IssuerRegistry,
|
||||||
renewalService *RenewalService,
|
renewalService *RenewalService,
|
||||||
) *AgentService {
|
) *AgentService {
|
||||||
return &AgentService{
|
return &AgentService{
|
||||||
@@ -163,7 +163,7 @@ func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: direct issuer signing (no AwaitingCSR job — ad-hoc CSR submission)
|
// Fallback: direct issuer signing (no AwaitingCSR job — ad-hoc CSR submission)
|
||||||
connector, ok := s.issuerRegistry[cert.IssuerID]
|
connector, ok := s.issuerRegistry.Get(cert.IssuerID)
|
||||||
if ok {
|
if ok {
|
||||||
// Resolve EKUs from the certificate profile if available
|
// Resolve EKUs from the certificate profile if available
|
||||||
var ekus []string
|
var ekus []string
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ func TestRegisterAgent(t *testing.T) {
|
|||||||
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ func TestHeartbeat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
auditRepo := &mockAuditRepo{}
|
auditRepo := &mockAuditRepo{}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ func TestHeartbeat_NotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
auditRepo := &mockAuditRepo{}
|
auditRepo := &mockAuditRepo{}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -175,7 +176,7 @@ func TestGetPendingWork(t *testing.T) {
|
|||||||
}
|
}
|
||||||
auditRepo := &mockAuditRepo{}
|
auditRepo := &mockAuditRepo{}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -217,7 +218,8 @@ func TestGetPendingWork_OnlyReturnsAgentJobs(t *testing.T) {
|
|||||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
auditService := NewAuditService(&mockAuditRepo{})
|
auditService := NewAuditService(&mockAuditRepo{})
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
// Agent A should only see its job
|
// Agent A should only see its job
|
||||||
jobsA, err := agentService.GetPendingWork(ctx, agentA)
|
jobsA, err := agentService.GetPendingWork(ctx, agentA)
|
||||||
@@ -268,7 +270,8 @@ func TestGetPendingWork_EmptyWhenNoJobsForAgent(t *testing.T) {
|
|||||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
auditService := NewAuditService(&mockAuditRepo{})
|
auditService := NewAuditService(&mockAuditRepo{})
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -302,7 +305,8 @@ func TestGetPendingWork_DeploymentAndCSR_Scoped(t *testing.T) {
|
|||||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
auditService := NewAuditService(&mockAuditRepo{})
|
auditService := NewAuditService(&mockAuditRepo{})
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -350,7 +354,7 @@ func TestReportJobStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -409,7 +413,7 @@ func TestMarkStaleAgentsOffline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
auditRepo := &mockAuditRepo{}
|
auditRepo := &mockAuditRepo{}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -475,7 +479,8 @@ func TestSubmitCSR(t *testing.T) {
|
|||||||
NotAfter: now.AddDate(1, 0, 0),
|
NotAfter: now.AddDate(1, 0, 0),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
issuerRegistry := map[string]IssuerConnector{"iss-local": issuerConnector}
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
issuerRegistry.Set("iss-local", issuerConnector)
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -524,7 +529,7 @@ func TestSubmitCSR_EmptyCSR(t *testing.T) {
|
|||||||
}
|
}
|
||||||
auditRepo := &mockAuditRepo{}
|
auditRepo := &mockAuditRepo{}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -572,7 +577,7 @@ func TestListAgents(t *testing.T) {
|
|||||||
}
|
}
|
||||||
auditRepo := &mockAuditRepo{}
|
auditRepo := &mockAuditRepo{}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type CAOperationsSvc struct {
|
|||||||
revocationRepo repository.RevocationRepository
|
revocationRepo repository.RevocationRepository
|
||||||
certRepo repository.CertificateRepository
|
certRepo repository.CertificateRepository
|
||||||
profileRepo repository.CertificateProfileRepository
|
profileRepo repository.CertificateProfileRepository
|
||||||
issuerRegistry map[string]IssuerConnector
|
issuerRegistry *IssuerRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCAOperationsSvc creates a new CA operations service.
|
// NewCAOperationsSvc creates a new CA operations service.
|
||||||
@@ -35,7 +35,7 @@ func NewCAOperationsSvc(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetIssuerRegistry sets the issuer registry for CRL and OCSP operations.
|
// SetIssuerRegistry sets the issuer registry for CRL and OCSP operations.
|
||||||
func (s *CAOperationsSvc) SetIssuerRegistry(registry map[string]IssuerConnector) {
|
func (s *CAOperationsSvc) SetIssuerRegistry(registry *IssuerRegistry) {
|
||||||
s.issuerRegistry = registry
|
s.issuerRegistry = registry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ func (s *CAOperationsSvc) GenerateDERCRL(issuerID string) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("issuer registry not configured")
|
return nil, fmt.Errorf("issuer registry not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
issuerConn, ok := s.issuerRegistry[issuerID]
|
issuerConn, ok := s.issuerRegistry.Get(issuerID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("issuer not found: %s", issuerID)
|
return nil, fmt.Errorf("issuer not found: %s", issuerID)
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ func (s *CAOperationsSvc) GetOCSPResponse(issuerID string, serialHex string) ([]
|
|||||||
return nil, fmt.Errorf("issuer registry not configured")
|
return nil, fmt.Errorf("issuer registry not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
issuerConn, ok := s.issuerRegistry[issuerID]
|
issuerConn, ok := s.issuerRegistry.Get(issuerID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("issuer not found: %s", issuerID)
|
return nil, fmt.Errorf("issuer not found: %s", issuerID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,9 +17,9 @@ func newCAOperationsSvcTest() (*CAOperationsSvc, *mockRevocationRepo, *mockCertR
|
|||||||
profileRepo := newMockProfileRepository()
|
profileRepo := newMockProfileRepository()
|
||||||
|
|
||||||
caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo)
|
caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo)
|
||||||
caSvc.SetIssuerRegistry(map[string]IssuerConnector{
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-local": &mockIssuerConnector{},
|
registry.Set("iss-local", &mockIssuerConnector{})
|
||||||
})
|
caSvc.SetIssuerRegistry(registry)
|
||||||
|
|
||||||
return caSvc, revocationRepo, certRepo
|
return caSvc, revocationRepo, certRepo
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -130,13 +132,14 @@ func TestConcurrentAgentHeartbeats(t *testing.T) {
|
|||||||
mockAgentRepo.AddAgent(agent)
|
mockAgentRepo.AddAgent(agent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
agentSvc := NewAgentService(
|
agentSvc := NewAgentService(
|
||||||
mockAgentRepo,
|
mockAgentRepo,
|
||||||
nil, // certRepo
|
nil, // certRepo
|
||||||
nil, // jobRepo
|
nil, // jobRepo
|
||||||
nil, // targetRepo
|
nil, // targetRepo
|
||||||
nil, // auditService
|
nil, // auditService
|
||||||
make(map[string]IssuerConnector),
|
issuerRegistry,
|
||||||
nil, // renewalService
|
nil, // renewalService
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -191,7 +194,7 @@ func TestConcurrentTargetCRUD(t *testing.T) {
|
|||||||
Targets: make(map[string]*domain.DeploymentTarget),
|
Targets: make(map[string]*domain.DeploymentTarget),
|
||||||
}
|
}
|
||||||
|
|
||||||
targetSvc := NewTargetService(mockTargetRepo, nil)
|
targetSvc := NewTargetService(mockTargetRepo, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||||
|
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
createdTargets := make([]string, 0)
|
createdTargets := make([]string, 0)
|
||||||
@@ -400,7 +403,7 @@ func TestConcurrentMixedOperations(t *testing.T) {
|
|||||||
// Setup services
|
// Setup services
|
||||||
auditSvc := &AuditService{auditRepo: mockAuditRepo}
|
auditSvc := &AuditService{auditRepo: mockAuditRepo}
|
||||||
certSvc := NewCertificateService(mockCertRepo, nil, auditSvc)
|
certSvc := NewCertificateService(mockCertRepo, nil, auditSvc)
|
||||||
targetSvc := NewTargetService(mockTargetRepo, auditSvc)
|
targetSvc := NewTargetService(mockTargetRepo, auditSvc, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
errChan := make(chan error, 30)
|
errChan := make(chan error, 30)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sensitiveKeys are config key substrings that should be redacted in API responses.
|
||||||
|
var sensitiveKeys = []string{"password", "secret", "token", "key", "hmac", "private", "credentials"}
|
||||||
|
|
||||||
|
// isSensitiveConfigKey checks if a config key contains sensitive substrings.
|
||||||
|
func isSensitiveConfigKey(key string) bool {
|
||||||
|
lower := strings.ToLower(key)
|
||||||
|
for _, s := range sensitiveKeys {
|
||||||
|
if strings.Contains(lower, s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactConfigJSON replaces sensitive values in a JSON config with "********".
|
||||||
|
func redactConfigJSON(configJSON json.RawMessage) json.RawMessage {
|
||||||
|
var m map[string]interface{}
|
||||||
|
if err := json.Unmarshal(configJSON, &m); err != nil {
|
||||||
|
return configJSON // Not a JSON object, return as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range m {
|
||||||
|
if isSensitiveConfigKey(k) {
|
||||||
|
if str, ok := v.(string); ok && str != "" {
|
||||||
|
m[k] = "********"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redacted, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return configJSON
|
||||||
|
}
|
||||||
|
return json.RawMessage(redacted)
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ func TestRenewalService_ProcessWithCancelledContext(t *testing.T) {
|
|||||||
notifierRegistry: make(map[string]Notifier),
|
notifierRegistry: make(map[string]Notifier),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
renewalSvc := NewRenewalService(
|
renewalSvc := NewRenewalService(
|
||||||
mockCertRepo,
|
mockCertRepo,
|
||||||
mockJobRepo,
|
mockJobRepo,
|
||||||
@@ -73,7 +76,7 @@ func TestRenewalService_ProcessWithCancelledContext(t *testing.T) {
|
|||||||
mockProfileRepo,
|
mockProfileRepo,
|
||||||
mockAuditSvc,
|
mockAuditSvc,
|
||||||
mockNotifSvc,
|
mockNotifSvc,
|
||||||
make(map[string]IssuerConnector),
|
issuerRegistry,
|
||||||
"agent",
|
"agent",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -139,7 +142,7 @@ func TestTargetService_ListWithCancelledContext(t *testing.T) {
|
|||||||
mockTargetRepo := &mockTargetRepo{
|
mockTargetRepo := &mockTargetRepo{
|
||||||
Targets: make(map[string]*domain.DeploymentTarget),
|
Targets: make(map[string]*domain.DeploymentTarget),
|
||||||
}
|
}
|
||||||
targetSvc := NewTargetService(mockTargetRepo, nil)
|
targetSvc := NewTargetService(mockTargetRepo, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||||
|
|
||||||
_, _, err := targetSvc.List(ctx, 1, 50)
|
_, _, err := targetSvc.List(ctx, 1, 50)
|
||||||
|
|
||||||
@@ -162,13 +165,14 @@ func TestAgentService_HeartbeatWithCancelledContext(t *testing.T) {
|
|||||||
Hostname: "localhost",
|
Hostname: "localhost",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
agentSvc := NewAgentService(
|
agentSvc := NewAgentService(
|
||||||
mockAgentRepo,
|
mockAgentRepo,
|
||||||
nil, // certRepo
|
nil, // certRepo
|
||||||
nil, // jobRepo
|
nil, // jobRepo
|
||||||
nil, // targetRepo
|
nil, // targetRepo
|
||||||
nil, // auditService
|
nil, // auditService
|
||||||
make(map[string]IssuerConnector),
|
issuerRegistry,
|
||||||
nil, // renewalService
|
nil, // renewalService
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,13 +216,14 @@ func TestAgentService_HeartbeatWithDeadlineExceeded(t *testing.T) {
|
|||||||
Hostname: "localhost",
|
Hostname: "localhost",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
agentSvc := NewAgentService(
|
agentSvc := NewAgentService(
|
||||||
mockAgentRepo,
|
mockAgentRepo,
|
||||||
nil, // certRepo
|
nil, // certRepo
|
||||||
nil, // jobRepo
|
nil, // jobRepo
|
||||||
nil, // targetRepo
|
nil, // targetRepo
|
||||||
nil, // auditService
|
nil, // auditService
|
||||||
make(map[string]IssuerConnector),
|
issuerRegistry,
|
||||||
nil, // renewalService
|
nil, // renewalService
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,9 +29,8 @@ func newTestRenewalServiceForCSR(issuerErr error) *RenewalService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
issuerConnector := &mockIssuerConnector{Err: issuerErr}
|
issuerConnector := &mockIssuerConnector{Err: issuerErr}
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-local": issuerConnector,
|
issuerRegistry.Set("iss-local", issuerConnector)
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, profileRepo, auditSvc, notifSvc, issuerRegistry, "agent")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, profileRepo, auditSvc, notifSvc, issuerRegistry, "agent")
|
||||||
return svc
|
return svc
|
||||||
|
|||||||
+550
-42
@@ -2,31 +2,50 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/config"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuerfactory"
|
||||||
|
"github.com/shankar0123/certctl/internal/crypto"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IssuerService provides business logic for certificate issuer management.
|
// IssuerService provides business logic for certificate issuer management.
|
||||||
type IssuerService struct {
|
type IssuerService struct {
|
||||||
issuerRepo repository.IssuerRepository
|
issuerRepo repository.IssuerRepository
|
||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
|
registry *IssuerRegistry
|
||||||
|
encryptionKey []byte
|
||||||
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIssuerService creates a new issuer service.
|
// NewIssuerService creates a new issuer service.
|
||||||
func NewIssuerService(
|
func NewIssuerService(
|
||||||
issuerRepo repository.IssuerRepository,
|
issuerRepo repository.IssuerRepository,
|
||||||
auditService *AuditService,
|
auditService *AuditService,
|
||||||
|
registry *IssuerRegistry,
|
||||||
|
encryptionKey []byte,
|
||||||
|
logger *slog.Logger,
|
||||||
) *IssuerService {
|
) *IssuerService {
|
||||||
return &IssuerService{
|
return &IssuerService{
|
||||||
issuerRepo: issuerRepo,
|
issuerRepo: issuerRepo,
|
||||||
auditService: auditService,
|
auditService: auditService,
|
||||||
|
registry: registry,
|
||||||
|
encryptionKey: encryptionKey,
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRegistry returns the dynamic issuer registry.
|
||||||
|
func (s *IssuerService) GetRegistry() *IssuerRegistry {
|
||||||
|
return s.registry
|
||||||
|
}
|
||||||
|
|
||||||
// List returns a paginated list of issuers.
|
// List returns a paginated list of issuers.
|
||||||
func (s *IssuerService) List(ctx context.Context, page, perPage int) ([]*domain.Issuer, int64, error) {
|
func (s *IssuerService) List(ctx context.Context, page, perPage int) ([]*domain.Issuer, int64, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
@@ -61,49 +80,112 @@ func (s *IssuerService) Get(ctx context.Context, id string) (*domain.Issuer, err
|
|||||||
return issuer, nil
|
return issuer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create validates and stores a new issuer.
|
// validIssuerTypes is the set of allowed issuer types for validation.
|
||||||
func (s *IssuerService) Create(ctx context.Context, issuer *domain.Issuer, actor string) error {
|
var validIssuerTypes = map[domain.IssuerType]bool{
|
||||||
if issuer.Name == "" {
|
domain.IssuerTypeACME: true,
|
||||||
|
domain.IssuerTypeGenericCA: true,
|
||||||
|
domain.IssuerTypeStepCA: true,
|
||||||
|
domain.IssuerTypeOpenSSL: true,
|
||||||
|
domain.IssuerTypeVault: true,
|
||||||
|
domain.IssuerTypeDigiCert: true,
|
||||||
|
domain.IssuerTypeSectigo: true,
|
||||||
|
domain.IssuerTypeGoogleCAS: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidIssuerType checks if a type string is a known issuer type.
|
||||||
|
func isValidIssuerType(t domain.IssuerType) bool {
|
||||||
|
return validIssuerTypes[t]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create validates and stores a new issuer, encrypting sensitive config.
|
||||||
|
func (s *IssuerService) Create(ctx context.Context, iss *domain.Issuer, actor string) error {
|
||||||
|
if iss.Name == "" {
|
||||||
return fmt.Errorf("issuer name is required")
|
return fmt.Errorf("issuer name is required")
|
||||||
}
|
}
|
||||||
|
if !isValidIssuerType(iss.Type) {
|
||||||
|
return fmt.Errorf("unsupported issuer type: %s", iss.Type)
|
||||||
|
}
|
||||||
|
|
||||||
if issuer.ID == "" {
|
if iss.ID == "" {
|
||||||
issuer.ID = generateID("issuer")
|
iss.ID = generateID("issuer")
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if issuer.CreatedAt.IsZero() {
|
if iss.CreatedAt.IsZero() {
|
||||||
issuer.CreatedAt = now
|
iss.CreatedAt = now
|
||||||
}
|
}
|
||||||
if issuer.UpdatedAt.IsZero() {
|
if iss.UpdatedAt.IsZero() {
|
||||||
issuer.UpdatedAt = now
|
iss.UpdatedAt = now
|
||||||
}
|
}
|
||||||
if err := s.issuerRepo.Create(ctx, issuer); err != nil {
|
if iss.TestStatus == "" {
|
||||||
|
iss.TestStatus = "untested"
|
||||||
|
}
|
||||||
|
if iss.Source == "" {
|
||||||
|
iss.Source = "database"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the full config and store redacted version in config column
|
||||||
|
if len(iss.Config) > 0 {
|
||||||
|
encrypted, _, err := crypto.EncryptIfKeySet([]byte(iss.Config), s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt config: %w", err)
|
||||||
|
}
|
||||||
|
iss.EncryptedConfig = encrypted
|
||||||
|
iss.Config = redactConfigJSON(iss.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.issuerRepo.Create(ctx, iss); err != nil {
|
||||||
return fmt.Errorf("failed to create issuer: %w", err)
|
return fmt.Errorf("failed to create issuer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add to dynamic registry
|
||||||
|
if iss.Enabled {
|
||||||
|
s.rebuildRegistryQuiet(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
if s.auditService != nil {
|
if s.auditService != nil {
|
||||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_issuer", "issuer", issuer.ID, nil); auditErr != nil {
|
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_issuer", "issuer", iss.ID, nil); auditErr != nil {
|
||||||
slog.Error("failed to record audit event", "error", auditErr)
|
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update modifies an existing issuer.
|
// Update modifies an existing issuer. Handles "********" preservation for sensitive fields.
|
||||||
func (s *IssuerService) Update(ctx context.Context, id string, issuer *domain.Issuer, actor string) error {
|
func (s *IssuerService) Update(ctx context.Context, id string, iss *domain.Issuer, actor string) error {
|
||||||
if issuer.Name == "" {
|
if iss.Name == "" {
|
||||||
return fmt.Errorf("issuer name is required")
|
return fmt.Errorf("issuer name is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
issuer.ID = id
|
iss.ID = id
|
||||||
if err := s.issuerRepo.Update(ctx, issuer); err != nil {
|
iss.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
// If config contains "********" values, merge with existing decrypted config
|
||||||
|
if len(iss.Config) > 0 {
|
||||||
|
mergedConfig, err := s.mergeRedactedConfig(ctx, id, iss.Config)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to merge config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the merged config
|
||||||
|
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
|
||||||
|
if encErr != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt config: %w", encErr)
|
||||||
|
}
|
||||||
|
iss.EncryptedConfig = encrypted
|
||||||
|
iss.Config = redactConfigJSON(json.RawMessage(mergedConfig))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.issuerRepo.Update(ctx, iss); err != nil {
|
||||||
return fmt.Errorf("failed to update issuer %s: %w", id, err)
|
return fmt.Errorf("failed to update issuer %s: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuild registry after update
|
||||||
|
s.rebuildRegistryQuiet(ctx)
|
||||||
|
|
||||||
if s.auditService != nil {
|
if s.auditService != nil {
|
||||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "update_issuer", "issuer", id, nil); auditErr != nil {
|
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "update_issuer", "issuer", id, nil); auditErr != nil {
|
||||||
slog.Error("failed to record audit event", "error", auditErr)
|
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,27 +198,48 @@ func (s *IssuerService) Delete(ctx context.Context, id string, actor string) err
|
|||||||
return fmt.Errorf("failed to delete issuer %s: %w", id, err)
|
return fmt.Errorf("failed to delete issuer %s: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove from registry
|
||||||
|
if s.registry != nil {
|
||||||
|
s.registry.Remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
if s.auditService != nil {
|
if s.auditService != nil {
|
||||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "delete_issuer", "issuer", id, nil); auditErr != nil {
|
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "delete_issuer", "issuer", id, nil); auditErr != nil {
|
||||||
slog.Error("failed to record audit event", "error", auditErr)
|
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConnectionWithContext verifies the issuer connection with context.
|
// TestConnectionWithContext tests the connection to an issuer by instantiating a throwaway
|
||||||
|
// connector and calling ValidateConfig. Records the result in the database.
|
||||||
func (s *IssuerService) TestConnectionWithContext(ctx context.Context, id string) error {
|
func (s *IssuerService) TestConnectionWithContext(ctx context.Context, id string) error {
|
||||||
issuer, err := s.issuerRepo.Get(ctx, id)
|
iss, err := s.issuerRepo.Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("issuer not found: %w", err)
|
return fmt.Errorf("issuer not found: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement actual connection test based on issuer type
|
// Get the decrypted config
|
||||||
if issuer == nil {
|
configJSON, err := s.getDecryptedConfig(iss)
|
||||||
return fmt.Errorf("issuer not found")
|
if err != nil {
|
||||||
|
s.updateTestStatus(ctx, iss, "failed")
|
||||||
|
return fmt.Errorf("failed to decrypt config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Instantiate a throwaway connector and validate
|
||||||
|
connector, err := issuerfactory.NewFromConfig(string(iss.Type), configJSON, s.logger)
|
||||||
|
if err != nil {
|
||||||
|
s.updateTestStatus(ctx, iss, "failed")
|
||||||
|
return fmt.Errorf("failed to create connector: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := connector.ValidateConfig(ctx, configJSON); err != nil {
|
||||||
|
s.updateTestStatus(ctx, iss, "failed")
|
||||||
|
return fmt.Errorf("connection test failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.updateTestStatus(ctx, iss, "success")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +248,243 @@ func (s *IssuerService) TestConnection(id string) error {
|
|||||||
return s.TestConnectionWithContext(context.Background(), id)
|
return s.TestConnectionWithContext(context.Background(), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildRegistry loads all enabled issuers from the database and rebuilds the dynamic registry.
|
||||||
|
// Called at server startup. Partial failures (individual issuers failing to load) are logged
|
||||||
|
// as warnings but don't prevent the server from starting.
|
||||||
|
func (s *IssuerService) BuildRegistry(ctx context.Context) error {
|
||||||
|
issuers, err := s.issuerRepo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load issuers from database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.registry.Rebuild(issuers, s.encryptionKey); err != nil {
|
||||||
|
// Log the error but don't fail — some issuers loaded successfully.
|
||||||
|
s.logger.Warn("issuer registry rebuilt with errors", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("issuer registry built from database", "total_issuers", len(issuers), "registry_size", s.registry.Len())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedFromEnvVars creates issuer records from environment variables if the database is empty.
|
||||||
|
// Uses ON CONFLICT DO NOTHING so GUI-created configs are never overwritten.
|
||||||
|
func (s *IssuerService) SeedFromEnvVars(ctx context.Context, cfg *config.Config) {
|
||||||
|
// Check if any issuers already exist
|
||||||
|
existing, err := s.issuerRepo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to check existing issuers for env var seeding", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(existing) > 0 {
|
||||||
|
s.logger.Info("issuers already exist in database, skipping env var seeding", "count", len(existing))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("no issuers in database, seeding from environment variables")
|
||||||
|
|
||||||
|
seeds := s.buildEnvVarSeeds(cfg)
|
||||||
|
seeded := 0
|
||||||
|
for _, seed := range seeds {
|
||||||
|
// Encrypt the config if key is set
|
||||||
|
if len(seed.Config) > 0 {
|
||||||
|
encrypted, _, encErr := crypto.EncryptIfKeySet([]byte(seed.Config), s.encryptionKey)
|
||||||
|
if encErr != nil {
|
||||||
|
s.logger.Error("failed to encrypt seed config", "id", seed.ID, "error", encErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seed.EncryptedConfig = encrypted
|
||||||
|
seed.Config = redactConfigJSON(seed.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.issuerRepo.Create(ctx, seed); err != nil {
|
||||||
|
s.logger.Warn("failed to seed issuer from env var", "id", seed.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seeded++
|
||||||
|
s.logger.Info("seeded issuer from env vars", "id", seed.ID, "type", seed.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("env var seeding complete", "seeded", seeded, "total_seeds", len(seeds))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEnvVarSeeds constructs issuer domain objects from the config's env var values.
|
||||||
|
func (s *IssuerService) buildEnvVarSeeds(cfg *config.Config) []*domain.Issuer {
|
||||||
|
now := time.Now()
|
||||||
|
var seeds []*domain.Issuer
|
||||||
|
|
||||||
|
// Local CA (always seeded)
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-local",
|
||||||
|
Name: "Local CA",
|
||||||
|
Type: domain.IssuerTypeGenericCA,
|
||||||
|
Config: mustJSON(map[string]interface{}{"ca_cert_path": cfg.CA.CertPath, "ca_key_path": cfg.CA.KeyPath}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ACME (always seeded — even with empty directory URL, for demo mode)
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-acme-staging",
|
||||||
|
Name: "ACME Staging",
|
||||||
|
Type: domain.IssuerTypeACME,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"directory_url": cfg.ACME.DirectoryURL,
|
||||||
|
"email": cfg.ACME.Email,
|
||||||
|
"challenge_type": cfg.ACME.ChallengeType,
|
||||||
|
"profile": cfg.ACME.Profile,
|
||||||
|
"insecure": cfg.ACME.Insecure,
|
||||||
|
"ari_enabled": cfg.ACME.ARIEnabled,
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ACME prod (same config, different ID for backward compat)
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-acme-prod",
|
||||||
|
Name: "ACME Production",
|
||||||
|
Type: domain.IssuerTypeACME,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"directory_url": cfg.ACME.DirectoryURL,
|
||||||
|
"email": cfg.ACME.Email,
|
||||||
|
"challenge_type": cfg.ACME.ChallengeType,
|
||||||
|
"profile": cfg.ACME.Profile,
|
||||||
|
"insecure": cfg.ACME.Insecure,
|
||||||
|
"ari_enabled": cfg.ACME.ARIEnabled,
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Conditional: step-ca — only seed if CERTCTL_STEPCA_URL is set
|
||||||
|
if stepcaURL := getEnvForSeed("CERTCTL_STEPCA_URL"); stepcaURL != "" {
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-stepca",
|
||||||
|
Name: "step-ca",
|
||||||
|
Type: domain.IssuerTypeStepCA,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"ca_url": stepcaURL,
|
||||||
|
"root_cert_path": getEnvForSeed("CERTCTL_STEPCA_ROOT_CERT"),
|
||||||
|
"provisioner_name": getEnvForSeed("CERTCTL_STEPCA_PROVISIONER"),
|
||||||
|
"provisioner_key_path": getEnvForSeed("CERTCTL_STEPCA_KEY_PATH"),
|
||||||
|
"provisioner_password": getEnvForSeed("CERTCTL_STEPCA_PASSWORD"),
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional: OpenSSL — only seed if sign script is set
|
||||||
|
if signScript := getEnvForSeed("CERTCTL_OPENSSL_SIGN_SCRIPT"); signScript != "" {
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-openssl",
|
||||||
|
Name: "OpenSSL/Custom CA",
|
||||||
|
Type: domain.IssuerTypeOpenSSL,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"sign_script": signScript,
|
||||||
|
"revoke_script": getEnvForSeed("CERTCTL_OPENSSL_REVOKE_SCRIPT"),
|
||||||
|
"crl_script": getEnvForSeed("CERTCTL_OPENSSL_CRL_SCRIPT"),
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional: Vault PKI
|
||||||
|
if cfg.Vault.Addr != "" {
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-vault",
|
||||||
|
Name: "Vault PKI",
|
||||||
|
Type: domain.IssuerTypeVault,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"addr": cfg.Vault.Addr,
|
||||||
|
"token": cfg.Vault.Token,
|
||||||
|
"mount": cfg.Vault.Mount,
|
||||||
|
"role": cfg.Vault.Role,
|
||||||
|
"ttl": cfg.Vault.TTL,
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional: DigiCert
|
||||||
|
if cfg.DigiCert.APIKey != "" {
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-digicert",
|
||||||
|
Name: "DigiCert CertCentral",
|
||||||
|
Type: domain.IssuerTypeDigiCert,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"api_key": cfg.DigiCert.APIKey,
|
||||||
|
"org_id": cfg.DigiCert.OrgID,
|
||||||
|
"product_type": cfg.DigiCert.ProductType,
|
||||||
|
"base_url": cfg.DigiCert.BaseURL,
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional: Sectigo
|
||||||
|
if cfg.Sectigo.CustomerURI != "" && cfg.Sectigo.Login != "" && cfg.Sectigo.Password != "" {
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-sectigo",
|
||||||
|
Name: "Sectigo SCM",
|
||||||
|
Type: domain.IssuerTypeSectigo,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"customer_uri": cfg.Sectigo.CustomerURI,
|
||||||
|
"login": cfg.Sectigo.Login,
|
||||||
|
"password": cfg.Sectigo.Password,
|
||||||
|
"org_id": cfg.Sectigo.OrgID,
|
||||||
|
"cert_type": cfg.Sectigo.CertType,
|
||||||
|
"term": cfg.Sectigo.Term,
|
||||||
|
"base_url": cfg.Sectigo.BaseURL,
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional: Google CAS
|
||||||
|
if cfg.GoogleCAS.Project != "" && cfg.GoogleCAS.Credentials != "" {
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-googlecas",
|
||||||
|
Name: "Google CAS",
|
||||||
|
Type: domain.IssuerTypeGoogleCAS,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"project": cfg.GoogleCAS.Project,
|
||||||
|
"location": cfg.GoogleCAS.Location,
|
||||||
|
"ca_pool": cfg.GoogleCAS.CAPool,
|
||||||
|
"credentials": cfg.GoogleCAS.Credentials,
|
||||||
|
"ttl": cfg.GoogleCAS.TTL,
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return seeds
|
||||||
|
}
|
||||||
|
|
||||||
// ListIssuers returns paginated issuers (handler interface method).
|
// ListIssuers returns paginated issuers (handler interface method).
|
||||||
func (s *IssuerService) ListIssuers(page, perPage int) ([]domain.Issuer, int64, error) {
|
func (s *IssuerService) ListIssuers(page, perPage int) ([]domain.Issuer, int64, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
@@ -176,33 +516,201 @@ func (s *IssuerService) GetIssuer(id string) (*domain.Issuer, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateIssuer creates a new issuer (handler interface method).
|
// CreateIssuer creates a new issuer (handler interface method).
|
||||||
func (s *IssuerService) CreateIssuer(issuer domain.Issuer) (*domain.Issuer, error) {
|
func (s *IssuerService) CreateIssuer(iss domain.Issuer) (*domain.Issuer, error) {
|
||||||
if issuer.ID == "" {
|
if !isValidIssuerType(iss.Type) {
|
||||||
issuer.ID = generateID("issuer")
|
return nil, fmt.Errorf("unsupported issuer type: %s", iss.Type)
|
||||||
|
}
|
||||||
|
if iss.ID == "" {
|
||||||
|
iss.ID = generateID("issuer")
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if issuer.CreatedAt.IsZero() {
|
if iss.CreatedAt.IsZero() {
|
||||||
issuer.CreatedAt = now
|
iss.CreatedAt = now
|
||||||
}
|
}
|
||||||
if issuer.UpdatedAt.IsZero() {
|
if iss.UpdatedAt.IsZero() {
|
||||||
issuer.UpdatedAt = now
|
iss.UpdatedAt = now
|
||||||
}
|
}
|
||||||
if err := s.issuerRepo.Create(context.Background(), &issuer); err != nil {
|
if iss.TestStatus == "" {
|
||||||
|
iss.TestStatus = "untested"
|
||||||
|
}
|
||||||
|
if iss.Source == "" {
|
||||||
|
iss.Source = "database"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt config
|
||||||
|
if len(iss.Config) > 0 {
|
||||||
|
encrypted, _, err := crypto.EncryptIfKeySet([]byte(iss.Config), s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt config: %w", err)
|
||||||
|
}
|
||||||
|
iss.EncryptedConfig = encrypted
|
||||||
|
iss.Config = redactConfigJSON(iss.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.issuerRepo.Create(context.Background(), &iss); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create issuer: %w", err)
|
return nil, fmt.Errorf("failed to create issuer: %w", err)
|
||||||
}
|
}
|
||||||
return &issuer, nil
|
|
||||||
|
// Rebuild registry
|
||||||
|
if iss.Enabled {
|
||||||
|
s.rebuildRegistryQuiet(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
return &iss, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateIssuer modifies an issuer (handler interface method).
|
// UpdateIssuer modifies an issuer (handler interface method).
|
||||||
func (s *IssuerService) UpdateIssuer(id string, issuer domain.Issuer) (*domain.Issuer, error) {
|
func (s *IssuerService) UpdateIssuer(id string, iss domain.Issuer) (*domain.Issuer, error) {
|
||||||
issuer.ID = id
|
iss.ID = id
|
||||||
if err := s.issuerRepo.Update(context.Background(), &issuer); err != nil {
|
iss.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
// Merge redacted fields with existing config
|
||||||
|
if len(iss.Config) > 0 {
|
||||||
|
mergedConfig, err := s.mergeRedactedConfig(context.Background(), id, iss.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to merge config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
|
||||||
|
if encErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt config: %w", encErr)
|
||||||
|
}
|
||||||
|
iss.EncryptedConfig = encrypted
|
||||||
|
iss.Config = redactConfigJSON(json.RawMessage(mergedConfig))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.issuerRepo.Update(context.Background(), &iss); err != nil {
|
||||||
return nil, fmt.Errorf("failed to update issuer: %w", err)
|
return nil, fmt.Errorf("failed to update issuer: %w", err)
|
||||||
}
|
}
|
||||||
return &issuer, nil
|
|
||||||
|
s.rebuildRegistryQuiet(context.Background())
|
||||||
|
|
||||||
|
return &iss, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteIssuer removes an issuer (handler interface method).
|
// DeleteIssuer removes an issuer (handler interface method).
|
||||||
func (s *IssuerService) DeleteIssuer(id string) error {
|
func (s *IssuerService) DeleteIssuer(id string) error {
|
||||||
return s.issuerRepo.Delete(context.Background(), id)
|
if err := s.issuerRepo.Delete(context.Background(), id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.registry != nil {
|
||||||
|
s.registry.Remove(id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal helpers ---
|
||||||
|
|
||||||
|
// rebuildRegistryQuiet rebuilds the registry, logging errors instead of returning them.
|
||||||
|
func (s *IssuerService) rebuildRegistryQuiet(ctx context.Context) {
|
||||||
|
if s.registry == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.BuildRegistry(ctx); err != nil {
|
||||||
|
s.logger.Error("failed to rebuild issuer registry after change", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDecryptedConfig returns the decrypted config JSON for an issuer.
|
||||||
|
func (s *IssuerService) getDecryptedConfig(iss *domain.Issuer) (json.RawMessage, error) {
|
||||||
|
if len(iss.EncryptedConfig) > 0 {
|
||||||
|
decrypted, err := crypto.DecryptIfKeySet(iss.EncryptedConfig, s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.RawMessage(decrypted), nil
|
||||||
|
}
|
||||||
|
if len(iss.Config) > 0 {
|
||||||
|
return iss.Config, nil
|
||||||
|
}
|
||||||
|
return json.RawMessage("{}"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeRedactedConfig merges incoming config (which may have "********" values)
|
||||||
|
// with the existing decrypted config so sensitive fields are preserved.
|
||||||
|
func (s *IssuerService) mergeRedactedConfig(ctx context.Context, id string, incoming json.RawMessage) ([]byte, error) {
|
||||||
|
// Parse incoming config
|
||||||
|
var incomingMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(incoming, &incomingMap); err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: incoming config is not a JSON object, using as-is", "issuer", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any values are "********"
|
||||||
|
hasRedacted := false
|
||||||
|
for _, v := range incomingMap {
|
||||||
|
if str, ok := v.(string); ok && str == "********" {
|
||||||
|
hasRedacted = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasRedacted {
|
||||||
|
return incoming, nil // No redacted values, use incoming as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing config to get real values
|
||||||
|
existing, err := s.issuerRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: could not load existing issuer, redacted values will be lost", "issuer", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
existingConfig, err := s.getDecryptedConfig(existing)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: could not decrypt existing config, redacted values will be lost", "issuer", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(existingConfig, &existingMap); err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: existing config is not a JSON object, redacted values will be lost", "issuer", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge: for each "********" value in incoming, use existing value
|
||||||
|
for k, v := range incomingMap {
|
||||||
|
if str, ok := v.(string); ok && str == "********" {
|
||||||
|
if existingVal, exists := existingMap[k]; exists {
|
||||||
|
incomingMap[k] = existingVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(incomingMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTestStatus updates the test_status and last_tested_at fields in the database
|
||||||
|
// and records an audit event.
|
||||||
|
func (s *IssuerService) updateTestStatus(ctx context.Context, iss *domain.Issuer, status string) {
|
||||||
|
now := time.Now()
|
||||||
|
iss.TestStatus = status
|
||||||
|
iss.LastTestedAt = &now
|
||||||
|
iss.UpdatedAt = now
|
||||||
|
if err := s.issuerRepo.Update(ctx, iss); err != nil {
|
||||||
|
s.logger.Error("failed to update test status", "issuer", iss.ID, "status", status, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record audit event for connection test
|
||||||
|
if s.auditService != nil {
|
||||||
|
action := "issuer_test_connection_" + status
|
||||||
|
details := map[string]interface{}{"issuer_type": string(iss.Type), "result": status}
|
||||||
|
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem, action, "issuer", iss.ID, details); auditErr != nil {
|
||||||
|
s.logger.Error("failed to record test connection audit event", "error", auditErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnvForSeed reads an environment variable for seed data construction.
|
||||||
|
func getEnvForSeed(key string) string {
|
||||||
|
return os.Getenv(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustJSON marshals a value to json.RawMessage, panicking on error (for seed data only).
|
||||||
|
func mustJSON(v interface{}) json.RawMessage {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("mustJSON: %v", err))
|
||||||
|
}
|
||||||
|
return json.RawMessage(b)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuerfactory"
|
||||||
|
"github.com/shankar0123/certctl/internal/crypto"
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IssuerRegistry is a thread-safe registry of issuer connectors.
|
||||||
|
// It replaces the static map[string]IssuerConnector that was built at startup.
|
||||||
|
// Consumers call Get() to look up a connector by issuer ID.
|
||||||
|
type IssuerRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
issuers map[string]IssuerConnector
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIssuerRegistry creates a new empty issuer registry.
|
||||||
|
func NewIssuerRegistry(logger *slog.Logger) *IssuerRegistry {
|
||||||
|
return &IssuerRegistry{
|
||||||
|
issuers: make(map[string]IssuerConnector),
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the issuer connector for the given ID and whether it exists.
|
||||||
|
func (r *IssuerRegistry) Get(id string) (IssuerConnector, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
conn, ok := r.issuers[id]
|
||||||
|
return conn, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set adds or replaces an issuer connector in the registry.
|
||||||
|
func (r *IssuerRegistry) Set(id string, conn IssuerConnector) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.issuers[id] = conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove removes an issuer connector from the registry.
|
||||||
|
func (r *IssuerRegistry) Remove(id string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
delete(r.issuers, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns a copy of all registered issuers.
|
||||||
|
func (r *IssuerRegistry) List() map[string]IssuerConnector {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
result := make(map[string]IssuerConnector, len(r.issuers))
|
||||||
|
for k, v := range r.issuers {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of registered issuers.
|
||||||
|
func (r *IssuerRegistry) Len() int {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return len(r.issuers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild reconstructs the registry from a list of issuer configs.
|
||||||
|
// For each enabled issuer, it decrypts the config (if encryption key is set),
|
||||||
|
// instantiates a connector via the factory, wraps it in an adapter, and
|
||||||
|
// atomically swaps the entire map.
|
||||||
|
func (r *IssuerRegistry) Rebuild(configs []*domain.Issuer, encryptionKey []byte) error {
|
||||||
|
newIssuers := make(map[string]IssuerConnector)
|
||||||
|
var errors []string
|
||||||
|
|
||||||
|
for _, cfg := range configs {
|
||||||
|
if !cfg.Enabled {
|
||||||
|
r.logger.Debug("skipping disabled issuer", "id", cfg.ID, "type", cfg.Type)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the config JSON to use for connector instantiation.
|
||||||
|
// Prefer encrypted_config (decrypted) if available; fall back to config.
|
||||||
|
var configJSON json.RawMessage
|
||||||
|
if len(cfg.EncryptedConfig) > 0 {
|
||||||
|
decrypted, err := crypto.DecryptIfKeySet(cfg.EncryptedConfig, encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("issuer %s: decrypt failed: %v", cfg.ID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
configJSON = json.RawMessage(decrypted)
|
||||||
|
} else if len(cfg.Config) > 0 {
|
||||||
|
configJSON = cfg.Config
|
||||||
|
} else {
|
||||||
|
configJSON = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
connector, err := issuerfactory.NewFromConfig(string(cfg.Type), configJSON, r.logger)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("issuer %s: factory error: %v", cfg.ID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
newIssuers[cfg.ID] = NewIssuerConnectorAdapter(connector)
|
||||||
|
r.logger.Info("issuer loaded into registry", "id", cfg.ID, "type", cfg.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic swap
|
||||||
|
r.mu.Lock()
|
||||||
|
old := r.issuers
|
||||||
|
r.issuers = newIssuers
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
// Log changes
|
||||||
|
for id := range newIssuers {
|
||||||
|
if _, existed := old[id]; !existed {
|
||||||
|
r.logger.Info("issuer added to registry", "id", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range old {
|
||||||
|
if _, exists := newIssuers[id]; !exists {
|
||||||
|
r.logger.Info("issuer removed from registry", "id", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("issuer registry rebuilt", "loaded", len(newIssuers), "failed", len(errors))
|
||||||
|
|
||||||
|
if len(errors) > 0 {
|
||||||
|
for _, e := range errors {
|
||||||
|
r.logger.Warn("issuer load failure", "detail", e)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%d issuer(s) failed to load: %s", len(errors), errors[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/crypto"
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registryTestLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_GetSet(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
mock := &mockIssuerConnector{}
|
||||||
|
reg.Set("iss-test", mock)
|
||||||
|
|
||||||
|
conn, ok := reg.Get("iss-test")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected to find iss-test in registry")
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_GetNotFound(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
_, ok := reg.Get("nonexistent")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected not to find nonexistent issuer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Remove(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
reg.Set("iss-test", &mockIssuerConnector{})
|
||||||
|
reg.Remove("iss-test")
|
||||||
|
|
||||||
|
_, ok := reg.Get("iss-test")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected issuer to be removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_List(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
reg.Set("iss-a", &mockIssuerConnector{})
|
||||||
|
reg.Set("iss-b", &mockIssuerConnector{})
|
||||||
|
|
||||||
|
list := reg.List()
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("expected 2 issuers, got %d", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify List returns a copy (modifying it doesn't affect registry)
|
||||||
|
delete(list, "iss-a")
|
||||||
|
if reg.Len() != 2 {
|
||||||
|
t.Fatal("deleting from List() copy should not affect registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Len(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
if reg.Len() != 0 {
|
||||||
|
t.Fatalf("expected empty registry, got %d", reg.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.Set("iss-a", &mockIssuerConnector{})
|
||||||
|
if reg.Len() != 1 {
|
||||||
|
t.Fatalf("expected 1 issuer, got %d", reg.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Rebuild_Enabled(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
configs := []*domain.Issuer{
|
||||||
|
{
|
||||||
|
ID: "iss-local",
|
||||||
|
Name: "Local CA",
|
||||||
|
Type: "local",
|
||||||
|
Config: json.RawMessage(`{}`),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "iss-disabled",
|
||||||
|
Name: "Disabled",
|
||||||
|
Type: "local",
|
||||||
|
Config: json.RawMessage(`{}`),
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := reg.Rebuild(configs, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rebuild failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reg.Len() != 1 {
|
||||||
|
t.Fatalf("expected 1 enabled issuer, got %d", reg.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := reg.Get("iss-local")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected iss-local in registry")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok = reg.Get("iss-disabled")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("disabled issuer should not be in registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Rebuild_WithEncryption(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
key := crypto.DeriveKey("test-key")
|
||||||
|
configJSON := []byte(`{"ca_common_name":"Encrypted CA"}`)
|
||||||
|
encrypted, err := crypto.Encrypt(configJSON, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configs := []*domain.Issuer{
|
||||||
|
{
|
||||||
|
ID: "iss-encrypted",
|
||||||
|
Name: "Encrypted Local CA",
|
||||||
|
Type: "local",
|
||||||
|
EncryptedConfig: encrypted,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = reg.Rebuild(configs, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rebuild with encryption failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := reg.Get("iss-encrypted")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected iss-encrypted in registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Rebuild_NilKeyFallback(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
configs := []*domain.Issuer{
|
||||||
|
{
|
||||||
|
ID: "iss-plain",
|
||||||
|
Name: "Plain Config",
|
||||||
|
Type: "local",
|
||||||
|
Config: json.RawMessage(`{}`),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// nil key should work — falls back to config column
|
||||||
|
err := reg.Rebuild(configs, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rebuild with nil key failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := reg.Get("iss-plain")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected iss-plain in registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Rebuild_InvalidConfig(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
configs := []*domain.Issuer{
|
||||||
|
{
|
||||||
|
ID: "iss-bad",
|
||||||
|
Name: "Bad Config",
|
||||||
|
Type: "UnknownType",
|
||||||
|
Config: json.RawMessage(`{}`),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "iss-good",
|
||||||
|
Name: "Good Config",
|
||||||
|
Type: "local",
|
||||||
|
Config: json.RawMessage(`{}`),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return an error indicating partial failure, but still load valid issuers
|
||||||
|
err := reg.Rebuild(configs, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Rebuild should return error when some issuers fail to load")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Despite the error, valid issuers should be loaded
|
||||||
|
if reg.Len() != 1 {
|
||||||
|
t.Fatalf("expected 1 valid issuer, got %d", reg.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := reg.Get("iss-good")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected iss-good in registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Rebuild_ReplacesExisting(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
// Set up initial state
|
||||||
|
reg.Set("iss-old", &mockIssuerConnector{})
|
||||||
|
|
||||||
|
configs := []*domain.Issuer{
|
||||||
|
{
|
||||||
|
ID: "iss-new",
|
||||||
|
Name: "New Issuer",
|
||||||
|
Type: "local",
|
||||||
|
Config: json.RawMessage(`{}`),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := reg.Rebuild(configs, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rebuild failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := reg.Get("iss-old")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("old issuer should have been replaced")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok = reg.Get("iss-new")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("new issuer should be present")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_ConcurrentAccess(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
wg.Add(3)
|
||||||
|
id := "iss-concurrent"
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
reg.Set(id, &mockIssuerConnector{})
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
reg.Get(id)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
reg.List()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
// No race detector panics = success
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Rebuild_Empty(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
reg.Set("iss-existing", &mockIssuerConnector{})
|
||||||
|
|
||||||
|
err := reg.Rebuild([]*domain.Issuer{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rebuild with empty configs failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reg.Len() != 0 {
|
||||||
|
t.Fatalf("expected empty registry after rebuild with no configs, got %d", reg.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ func TestIssuerService_List(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||||
|
|
||||||
issuers, total, err := service.List(ctx, 1, 2)
|
issuers, total, err := service.List(ctx, 1, 2)
|
||||||
|
|
||||||
@@ -85,7 +86,8 @@ func TestIssuerService_List_DefaultPagination(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
// Call with invalid page and perPage
|
// Call with invalid page and perPage
|
||||||
issuers, total, err := service.List(ctx, 0, 0)
|
issuers, total, err := service.List(ctx, 0, 0)
|
||||||
@@ -113,7 +115,7 @@ func TestIssuerService_List_RepositoryError(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||||
|
|
||||||
_, _, err := service.List(ctx, 1, 50)
|
_, _, err := service.List(ctx, 1, 50)
|
||||||
|
|
||||||
@@ -134,7 +136,8 @@ func TestIssuerService_List_EmptyResult(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
issuers, total, err := service.List(ctx, 1, 50)
|
issuers, total, err := service.List(ctx, 1, 50)
|
||||||
|
|
||||||
@@ -170,7 +173,7 @@ func TestIssuerService_Get(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||||
|
|
||||||
retrieved, err := service.Get(ctx, "iss-acme-prod")
|
retrieved, err := service.Get(ctx, "iss-acme-prod")
|
||||||
|
|
||||||
@@ -195,7 +198,8 @@ func TestIssuerService_Get_NotFound(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
_, err := service.Get(ctx, "nonexistent-issuer")
|
_, err := service.Get(ctx, "nonexistent-issuer")
|
||||||
|
|
||||||
@@ -212,7 +216,8 @@ func TestIssuerService_Create(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
config := map[string]interface{}{"endpoint": "https://acme.example.com/v2/new-account"}
|
config := map[string]interface{}{"endpoint": "https://acme.example.com/v2/new-account"}
|
||||||
configJSON, _ := json.Marshal(config)
|
configJSON, _ := json.Marshal(config)
|
||||||
@@ -274,7 +279,8 @@ func TestIssuerService_Create_EmptyName(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
issuer := &domain.Issuer{
|
issuer := &domain.Issuer{
|
||||||
Name: "",
|
Name: "",
|
||||||
@@ -308,7 +314,7 @@ func TestIssuerService_Create_RepositoryError(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||||
|
|
||||||
issuer := &domain.Issuer{
|
issuer := &domain.Issuer{
|
||||||
Name: "Test Issuer",
|
Name: "Test Issuer",
|
||||||
@@ -335,7 +341,8 @@ func TestIssuerService_Update(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
config := map[string]interface{}{"endpoint": "https://acme.example.com"}
|
config := map[string]interface{}{"endpoint": "https://acme.example.com"}
|
||||||
configJSON, _ := json.Marshal(config)
|
configJSON, _ := json.Marshal(config)
|
||||||
@@ -379,7 +386,8 @@ func TestIssuerService_Update_EmptyName(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
issuer := &domain.Issuer{
|
issuer := &domain.Issuer{
|
||||||
Name: "",
|
Name: "",
|
||||||
@@ -406,7 +414,8 @@ func TestIssuerService_Delete(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
err := service.Delete(ctx, "iss-to-delete", "user-frank")
|
err := service.Delete(ctx, "iss-to-delete", "user-frank")
|
||||||
|
|
||||||
@@ -438,7 +447,7 @@ func TestIssuerService_Delete_RepositoryError(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||||
|
|
||||||
err := service.Delete(ctx, "iss-bad-id", "user-grace")
|
err := service.Delete(ctx, "iss-bad-id", "user-grace")
|
||||||
|
|
||||||
@@ -455,24 +464,27 @@ func TestIssuerService_Delete_RepositoryError(t *testing.T) {
|
|||||||
func TestIssuerService_TestConnection_Success(t *testing.T) {
|
func TestIssuerService_TestConnection_Success(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
issuer := &domain.Issuer{
|
// Use GenericCA (Local CA) type because it has no required config fields,
|
||||||
|
// so ValidateConfig succeeds with empty config.
|
||||||
|
iss := &domain.Issuer{
|
||||||
ID: "iss-test-conn",
|
ID: "iss-test-conn",
|
||||||
Name: "Test Connection",
|
Name: "Test Connection",
|
||||||
Type: domain.IssuerTypeACME,
|
Type: domain.IssuerTypeGenericCA,
|
||||||
|
Config: json.RawMessage(`{"validity_days":365}`),
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
repo := newMockIssuerRepository()
|
repo := newMockIssuerRepository()
|
||||||
repo.AddIssuer(issuer)
|
repo.AddIssuer(iss)
|
||||||
|
|
||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
svc := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||||
|
|
||||||
err := service.TestConnectionWithContext(ctx, "iss-test-conn")
|
err := svc.TestConnectionWithContext(ctx, "iss-test-conn")
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("TestConnectionWithContext failed: %v", err)
|
t.Fatalf("TestConnectionWithContext failed: %v", err)
|
||||||
@@ -487,7 +499,8 @@ func TestIssuerService_TestConnection_NotFound(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
err := service.TestConnectionWithContext(ctx, "nonexistent-issuer")
|
err := service.TestConnectionWithContext(ctx, "nonexistent-issuer")
|
||||||
|
|
||||||
@@ -527,7 +540,7 @@ func TestIssuerService_ListIssuers_HandlerInterface(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||||
|
|
||||||
issuers, total, err := service.ListIssuers(1, 50)
|
issuers, total, err := service.ListIssuers(1, 50)
|
||||||
|
|
||||||
@@ -554,7 +567,8 @@ func TestIssuerService_CreateIssuer_HandlerInterface(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
config := map[string]interface{}{"url": "https://example.com"}
|
config := map[string]interface{}{"url": "https://example.com"}
|
||||||
configJSON, _ := json.Marshal(config)
|
configJSON, _ := json.Marshal(config)
|
||||||
@@ -591,7 +605,8 @@ func TestIssuerService_DeleteIssuer_HandlerInterface(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
err := service.DeleteIssuer("iss-handler-delete")
|
err := service.DeleteIssuer("iss-handler-delete")
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ func newTestJobService(jobRepo *mockJobRepo) *JobService {
|
|||||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
|
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
|
||||||
|
|
||||||
renewalService := NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notifService, make(map[string]IssuerConnector), "server")
|
issuerRegistry := NewIssuerRegistry(logger)
|
||||||
|
renewalService := NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notifService, issuerRegistry, "server")
|
||||||
deploymentService := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notifService)
|
deploymentService := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notifService)
|
||||||
|
|
||||||
return NewJobService(jobRepo, renewalService, deploymentService, logger)
|
return NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type RenewalService struct {
|
|||||||
targetRepo repository.TargetRepository
|
targetRepo repository.TargetRepository
|
||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
notificationSvc *NotificationService
|
notificationSvc *NotificationService
|
||||||
issuerRegistry map[string]IssuerConnector
|
issuerRegistry *IssuerRegistry
|
||||||
keygenMode string // "agent" (default) or "server" (demo only)
|
keygenMode string // "agent" (default) or "server" (demo only)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ type IssuerConnector interface {
|
|||||||
SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
|
SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
|
||||||
// GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer.
|
// GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer.
|
||||||
GetCACertPEM(ctx context.Context) (string, error)
|
GetCACertPEM(ctx context.Context) (string, error)
|
||||||
// GetRenewalInfo retrieves ACME Renewal Information (ARI) per RFC 9702 for a certificate.
|
// GetRenewalInfo retrieves ACME Renewal Information (ARI) per RFC 9773 for a certificate.
|
||||||
// certPEM is the PEM-encoded certificate. Returns nil, nil if the issuer does not support ARI.
|
// certPEM is the PEM-encoded certificate. Returns nil, nil if the issuer does not support ARI.
|
||||||
GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error)
|
GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error)
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ func NewRenewalService(
|
|||||||
profileRepo repository.CertificateProfileRepository,
|
profileRepo repository.CertificateProfileRepository,
|
||||||
auditService *AuditService,
|
auditService *AuditService,
|
||||||
notificationSvc *NotificationService,
|
notificationSvc *NotificationService,
|
||||||
issuerRegistry map[string]IssuerConnector,
|
issuerRegistry *IssuerRegistry,
|
||||||
keygenMode string,
|
keygenMode string,
|
||||||
) *RenewalService {
|
) *RenewalService {
|
||||||
if keygenMode == "" {
|
if keygenMode == "" {
|
||||||
@@ -169,12 +169,12 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
|||||||
s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds)
|
s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds)
|
||||||
|
|
||||||
// Only create renewal job if an issuer connector is registered for this cert's issuer
|
// Only create renewal job if an issuer connector is registered for this cert's issuer
|
||||||
connector, hasIssuer := s.issuerRegistry[cert.IssuerID]
|
connector, hasIssuer := s.issuerRegistry.Get(cert.IssuerID)
|
||||||
if !hasIssuer {
|
if !hasIssuer {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// ARI check (RFC 9702): if the issuer supports ARI, let the CA direct renewal timing.
|
// ARI check (RFC 9773): if the issuer supports ARI, let the CA direct renewal timing.
|
||||||
// Fetch the latest cert version to get the PEM chain for the ARI query.
|
// Fetch the latest cert version to get the PEM chain for the ARI query.
|
||||||
ariChecked := false
|
ariChecked := false
|
||||||
if version, vErr := s.certRepo.GetLatestVersion(ctx, cert.ID); vErr == nil && version != nil && version.PEMChain != "" {
|
if version, vErr := s.certRepo.GetLatestVersion(ctx, cert.ID); vErr == nil && version != nil && version.PEMChain != "" {
|
||||||
@@ -347,7 +347,7 @@ func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job)
|
|||||||
return fmt.Errorf("certificate has no issuer assigned")
|
return fmt.Errorf("certificate has no issuer assigned")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, ok := s.issuerRegistry[issuerID]
|
_, ok := s.issuerRegistry.Get(issuerID)
|
||||||
if !ok {
|
if !ok {
|
||||||
s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", issuerID))
|
s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", issuerID))
|
||||||
return fmt.Errorf("issuer connector not found for %s", issuerID)
|
return fmt.Errorf("issuer connector not found for %s", issuerID)
|
||||||
@@ -390,7 +390,7 @@ func (s *RenewalService) processRenewalAgentKeygen(ctx context.Context, job *dom
|
|||||||
// private key in the cert version so agents can retrieve it for deployment.
|
// private key in the cert version so agents can retrieve it for deployment.
|
||||||
// WARNING: Private keys touch the control plane. Use only for development/demo.
|
// WARNING: Private keys touch the control plane. Use only for development/demo.
|
||||||
func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate) error {
|
func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate) error {
|
||||||
connector := s.issuerRegistry[cert.IssuerID]
|
connector, _ := s.issuerRegistry.Get(cert.IssuerID)
|
||||||
|
|
||||||
// Generate server-side RSA key + CSR
|
// Generate server-side RSA key + CSR
|
||||||
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
@@ -524,7 +524,7 @@ func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *do
|
|||||||
// It signs the CSR via the issuer connector, stores the cert version (without private key),
|
// It signs the CSR via the issuer connector, stores the cert version (without private key),
|
||||||
// completes the renewal job, and creates deployment jobs.
|
// completes the renewal job, and creates deployment jobs.
|
||||||
func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate, csrPEM string) error {
|
func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate, csrPEM string) error {
|
||||||
connector, ok := s.issuerRegistry[cert.IssuerID]
|
connector, ok := s.issuerRegistry.Get(cert.IssuerID)
|
||||||
if !ok {
|
if !ok {
|
||||||
s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", cert.IssuerID))
|
s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", cert.IssuerID))
|
||||||
return fmt.Errorf("issuer connector not found for %s", cert.IssuerID)
|
return fmt.Errorf("issuer connector not found for %s", cert.IssuerID)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,9 +27,8 @@ func TestCheckExpiringCertificates_SendsThresholdAlerts(t *testing.T) {
|
|||||||
"Email": notifier,
|
"Email": notifier,
|
||||||
})
|
})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -108,9 +108,8 @@ func TestCheckExpiringCertificates_DeduplicatesAlerts(t *testing.T) {
|
|||||||
"Email": notifier,
|
"Email": notifier,
|
||||||
})
|
})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -188,9 +187,8 @@ func TestCheckExpiringCertificates_SkipsRenewalInProgress(t *testing.T) {
|
|||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -253,9 +251,8 @@ func TestCheckExpiringCertificates_UpdatesStatusToExpiring(t *testing.T) {
|
|||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -315,9 +312,8 @@ func TestCheckExpiringCertificates_UpdatesStatusToExpired(t *testing.T) {
|
|||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -377,9 +373,8 @@ func TestCheckExpiringCertificates_CreatesRenewalJob(t *testing.T) {
|
|||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -445,7 +440,7 @@ func TestCheckExpiringCertificates_SkipsWithoutIssuer(t *testing.T) {
|
|||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
// Empty issuer registry
|
// Empty issuer registry
|
||||||
issuerRegistry := map[string]IssuerConnector{}
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -505,9 +500,8 @@ func TestCheckExpiringCertificates_SkipsDuplicateJobs(t *testing.T) {
|
|||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -589,9 +583,8 @@ func TestProcessRenewalJob(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
issuerConnector := &mockIssuerConnector{}
|
issuerConnector := &mockIssuerConnector{}
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": issuerConnector,
|
issuerRegistry.Set("iss-test", issuerConnector)
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -685,9 +678,8 @@ func TestProcessRenewalJob_IssuerFailure(t *testing.T) {
|
|||||||
Err: fmt.Errorf("issuer service unavailable"),
|
Err: fmt.Errorf("issuer service unavailable"),
|
||||||
}
|
}
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": issuerConnector,
|
issuerRegistry.Set("iss-test", issuerConnector)
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -767,9 +759,8 @@ func TestRetryFailedJobs(t *testing.T) {
|
|||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -832,9 +823,8 @@ func TestProcessRenewalJob_NoCertificate(t *testing.T) {
|
|||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -863,7 +853,7 @@ func TestProcessRenewalJob_NoCertificate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ARI (RFC 9702) Scheduler Integration Tests ---
|
// --- ARI (RFC 9773) Scheduler Integration Tests ---
|
||||||
|
|
||||||
func TestCheckExpiringCertificates_ARI_ShouldRenewNow(t *testing.T) {
|
func TestCheckExpiringCertificates_ARI_ShouldRenewNow(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -885,9 +875,8 @@ func TestCheckExpiringCertificates_ARI_ShouldRenewNow(t *testing.T) {
|
|||||||
SuggestedWindowEnd: time.Now().Add(48 * time.Hour),
|
SuggestedWindowEnd: time.Now().Add(48 * time.Hour),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-acme": ariConnector,
|
issuerRegistry.Set("iss-acme", ariConnector)
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -958,9 +947,8 @@ func TestCheckExpiringCertificates_ARI_NotYet(t *testing.T) {
|
|||||||
SuggestedWindowEnd: time.Now().Add(96 * time.Hour),
|
SuggestedWindowEnd: time.Now().Add(96 * time.Hour),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-acme": ariConnector,
|
issuerRegistry.Set("iss-acme", ariConnector)
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -1021,9 +1009,8 @@ func TestCheckExpiringCertificates_ARI_NilResult_FallsThrough(t *testing.T) {
|
|||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
// ARI returns nil (issuer doesn't support ARI) — default mock behavior
|
// ARI returns nil (issuer doesn't support ARI) — default mock behavior
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-local": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-local", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -1090,9 +1077,8 @@ func TestCheckExpiringCertificates_ARI_Error_FallsThrough(t *testing.T) {
|
|||||||
ariConnector := &mockIssuerConnector{
|
ariConnector := &mockIssuerConnector{
|
||||||
getRenewalInfoErr: fmt.Errorf("ARI endpoint unreachable"),
|
getRenewalInfoErr: fmt.Errorf("ARI endpoint unreachable"),
|
||||||
}
|
}
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-acme": ariConnector,
|
issuerRegistry.Set("iss-acme", ariConnector)
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type RevocationSvc struct {
|
|||||||
revocationRepo repository.RevocationRepository
|
revocationRepo repository.RevocationRepository
|
||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
notificationSvc *NotificationService
|
notificationSvc *NotificationService
|
||||||
issuerRegistry map[string]IssuerConnector
|
issuerRegistry *IssuerRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRevocationSvc creates a new revocation service.
|
// NewRevocationSvc creates a new revocation service.
|
||||||
@@ -39,7 +39,7 @@ func (s *RevocationSvc) SetNotificationService(svc *NotificationService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetIssuerRegistry sets the issuer registry for issuer-level revocation.
|
// SetIssuerRegistry sets the issuer registry for issuer-level revocation.
|
||||||
func (s *RevocationSvc) SetIssuerRegistry(registry map[string]IssuerConnector) {
|
func (s *RevocationSvc) SetIssuerRegistry(registry *IssuerRegistry) {
|
||||||
s.issuerRegistry = registry
|
s.issuerRegistry = registry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ func (s *RevocationSvc) RevokeCertificateWithActor(ctx context.Context, certID s
|
|||||||
|
|
||||||
// 5. Notify the issuer connector (best-effort)
|
// 5. Notify the issuer connector (best-effort)
|
||||||
if s.issuerRegistry != nil {
|
if s.issuerRegistry != nil {
|
||||||
if issuerConn, ok := s.issuerRegistry[cert.IssuerID]; ok {
|
if issuerConn, ok := s.issuerRegistry.Get(cert.IssuerID); ok {
|
||||||
if err := issuerConn.RevokeCertificate(ctx, version.SerialNumber, reason); err != nil {
|
if err := issuerConn.RevokeCertificate(ctx, version.SerialNumber, reason); err != nil {
|
||||||
slog.Error("failed to notify issuer of revocation",
|
slog.Error("failed to notify issuer of revocation",
|
||||||
"error", err,
|
"error", err,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,9 +19,9 @@ func newRevocationSvcTest() (*RevocationSvc, *mockCertRepo, *mockRevocationRepo,
|
|||||||
|
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService)
|
revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService)
|
||||||
revSvc.SetIssuerRegistry(map[string]IssuerConnector{
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-local": &mockIssuerConnector{},
|
registry.Set("iss-local", &mockIssuerConnector{})
|
||||||
})
|
revSvc.SetIssuerRegistry(registry)
|
||||||
|
|
||||||
return revSvc, certRepo, revocationRepo, auditRepo
|
return revSvc, certRepo, revocationRepo, auditRepo
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,15 +22,13 @@ func newRevocationTestService() (*CertificateService, *mockCertRepo, *mockRevoca
|
|||||||
|
|
||||||
// Create RevocationSvc
|
// Create RevocationSvc
|
||||||
revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService)
|
revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService)
|
||||||
revSvc.SetIssuerRegistry(map[string]IssuerConnector{
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-local": &mockIssuerConnector{},
|
registry.Set("iss-local", &mockIssuerConnector{})
|
||||||
})
|
revSvc.SetIssuerRegistry(registry)
|
||||||
|
|
||||||
// Create CAOperationsSvc
|
// Create CAOperationsSvc
|
||||||
caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo)
|
caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo)
|
||||||
caSvc.SetIssuerRegistry(map[string]IssuerConnector{
|
caSvc.SetIssuerRegistry(registry)
|
||||||
"iss-local": &mockIssuerConnector{},
|
|
||||||
})
|
|
||||||
|
|
||||||
certService := NewCertificateService(certRepo, policyService, auditService)
|
certService := NewCertificateService(certRepo, policyService, auditService)
|
||||||
certService.SetRevocationSvc(revSvc)
|
certService.SetRevocationSvc(revSvc)
|
||||||
@@ -243,9 +242,9 @@ func TestRevokeCertificate_WithIssuerNotification(t *testing.T) {
|
|||||||
|
|
||||||
// Wire up issuer registry on RevocationSvc with mock
|
// Wire up issuer registry on RevocationSvc with mock
|
||||||
mockIssuer := &mockIssuerConnector{}
|
mockIssuer := &mockIssuerConnector{}
|
||||||
svc.revSvc.SetIssuerRegistry(map[string]IssuerConnector{
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-local": mockIssuer,
|
registry.Set("iss-local", mockIssuer)
|
||||||
})
|
svc.revSvc.SetIssuerRegistry(registry)
|
||||||
|
|
||||||
cert := &domain.ManagedCertificate{
|
cert := &domain.ManagedCertificate{
|
||||||
ID: "cert-7",
|
ID: "cert-7",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,9 +19,8 @@ func setupShortLivedTestService(
|
|||||||
) *RenewalService {
|
) *RenewalService {
|
||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(
|
svc := NewRenewalService(
|
||||||
certRepo,
|
certRepo,
|
||||||
@@ -137,9 +137,8 @@ func TestExpireShortLivedCertificates_ListError(t *testing.T) {
|
|||||||
|
|
||||||
// Create the service manually to use our custom cert repo
|
// Create the service manually to use our custom cert repo
|
||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(
|
svc := NewRenewalService(
|
||||||
customCertRepo,
|
customCertRepo,
|
||||||
@@ -385,9 +384,8 @@ func TestExpireShortLivedCertificates_NoProfileRepository(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(
|
svc := NewRenewalService(
|
||||||
certRepo,
|
certRepo,
|
||||||
|
|||||||
+251
-9
@@ -2,28 +2,61 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/crypto"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// validTargetTypes is the set of allowed target types for validation.
|
||||||
|
var validTargetTypes = map[domain.TargetType]bool{
|
||||||
|
domain.TargetTypeNGINX: true,
|
||||||
|
domain.TargetTypeApache: true,
|
||||||
|
domain.TargetTypeHAProxy: true,
|
||||||
|
domain.TargetTypeF5: true,
|
||||||
|
domain.TargetTypeIIS: true,
|
||||||
|
domain.TargetTypeTraefik: true,
|
||||||
|
domain.TargetTypeCaddy: true,
|
||||||
|
domain.TargetTypeEnvoy: true,
|
||||||
|
domain.TargetTypePostfix: true,
|
||||||
|
domain.TargetTypeDovecot: true,
|
||||||
|
domain.TargetTypeSSH: true,
|
||||||
|
domain.TargetTypeWinCertStore: true,
|
||||||
|
domain.TargetTypeJavaKeystore: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidTargetType checks if a type string is a known target type.
|
||||||
|
func isValidTargetType(t domain.TargetType) bool {
|
||||||
|
return validTargetTypes[t]
|
||||||
|
}
|
||||||
|
|
||||||
// TargetService provides business logic for deployment target management.
|
// TargetService provides business logic for deployment target management.
|
||||||
type TargetService struct {
|
type TargetService struct {
|
||||||
targetRepo repository.TargetRepository
|
targetRepo repository.TargetRepository
|
||||||
auditService *AuditService
|
agentRepo repository.AgentRepository
|
||||||
|
auditService *AuditService
|
||||||
|
encryptionKey []byte
|
||||||
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTargetService creates a new target service.
|
// NewTargetService creates a new target service.
|
||||||
func NewTargetService(
|
func NewTargetService(
|
||||||
targetRepo repository.TargetRepository,
|
targetRepo repository.TargetRepository,
|
||||||
auditService *AuditService,
|
auditService *AuditService,
|
||||||
|
agentRepo repository.AgentRepository,
|
||||||
|
encryptionKey []byte,
|
||||||
|
logger *slog.Logger,
|
||||||
) *TargetService {
|
) *TargetService {
|
||||||
return &TargetService{
|
return &TargetService{
|
||||||
targetRepo: targetRepo,
|
targetRepo: targetRepo,
|
||||||
auditService: auditService,
|
agentRepo: agentRepo,
|
||||||
|
auditService: auditService,
|
||||||
|
encryptionKey: encryptionKey,
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,11 +94,14 @@ func (s *TargetService) Get(ctx context.Context, id string) (*domain.DeploymentT
|
|||||||
return target, nil
|
return target, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create validates and stores a new deployment target.
|
// Create validates and stores a new deployment target, encrypting sensitive config.
|
||||||
func (s *TargetService) Create(ctx context.Context, target *domain.DeploymentTarget, actor string) error {
|
func (s *TargetService) Create(ctx context.Context, target *domain.DeploymentTarget, actor string) error {
|
||||||
if target.Name == "" {
|
if target.Name == "" {
|
||||||
return fmt.Errorf("target name is required")
|
return fmt.Errorf("target name is required")
|
||||||
}
|
}
|
||||||
|
if !isValidTargetType(target.Type) {
|
||||||
|
return fmt.Errorf("unsupported target type: %s", target.Type)
|
||||||
|
}
|
||||||
|
|
||||||
if target.ID == "" {
|
if target.ID == "" {
|
||||||
target.ID = generateID("target")
|
target.ID = generateID("target")
|
||||||
@@ -77,33 +113,68 @@ func (s *TargetService) Create(ctx context.Context, target *domain.DeploymentTar
|
|||||||
if target.UpdatedAt.IsZero() {
|
if target.UpdatedAt.IsZero() {
|
||||||
target.UpdatedAt = now
|
target.UpdatedAt = now
|
||||||
}
|
}
|
||||||
|
if target.TestStatus == "" {
|
||||||
|
target.TestStatus = "untested"
|
||||||
|
}
|
||||||
|
if target.Source == "" {
|
||||||
|
target.Source = "database"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the full config and store redacted version in config column
|
||||||
|
if len(target.Config) > 0 {
|
||||||
|
encrypted, _, err := crypto.EncryptIfKeySet([]byte(target.Config), s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt config: %w", err)
|
||||||
|
}
|
||||||
|
target.EncryptedConfig = encrypted
|
||||||
|
target.Config = redactConfigJSON(target.Config)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.targetRepo.Create(ctx, target); err != nil {
|
if err := s.targetRepo.Create(ctx, target); err != nil {
|
||||||
return fmt.Errorf("failed to create target: %w", err)
|
return fmt.Errorf("failed to create target: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.auditService != nil {
|
if s.auditService != nil {
|
||||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_target", "target", target.ID, nil); auditErr != nil {
|
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_target", "target", target.ID, nil); auditErr != nil {
|
||||||
slog.Error("failed to record audit event", "error", auditErr)
|
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update modifies an existing deployment target.
|
// Update modifies an existing deployment target. Handles "********" preservation for sensitive fields.
|
||||||
func (s *TargetService) Update(ctx context.Context, id string, target *domain.DeploymentTarget, actor string) error {
|
func (s *TargetService) Update(ctx context.Context, id string, target *domain.DeploymentTarget, actor string) error {
|
||||||
if target.Name == "" {
|
if target.Name == "" {
|
||||||
return fmt.Errorf("target name is required")
|
return fmt.Errorf("target name is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
target.ID = id
|
target.ID = id
|
||||||
|
target.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
// If config contains "********" values, merge with existing decrypted config
|
||||||
|
if len(target.Config) > 0 {
|
||||||
|
mergedConfig, err := s.mergeRedactedConfig(ctx, id, target.Config)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to merge config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the merged config
|
||||||
|
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
|
||||||
|
if encErr != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt config: %w", encErr)
|
||||||
|
}
|
||||||
|
target.EncryptedConfig = encrypted
|
||||||
|
target.Config = redactConfigJSON(json.RawMessage(mergedConfig))
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.targetRepo.Update(ctx, target); err != nil {
|
if err := s.targetRepo.Update(ctx, target); err != nil {
|
||||||
return fmt.Errorf("failed to update target %s: %w", id, err)
|
return fmt.Errorf("failed to update target %s: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.auditService != nil {
|
if s.auditService != nil {
|
||||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "update_target", "target", id, nil); auditErr != nil {
|
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "update_target", "target", id, nil); auditErr != nil {
|
||||||
slog.Error("failed to record audit event", "error", auditErr)
|
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,13 +189,50 @@ func (s *TargetService) Delete(ctx context.Context, id string, actor string) err
|
|||||||
|
|
||||||
if s.auditService != nil {
|
if s.auditService != nil {
|
||||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "delete_target", "target", id, nil); auditErr != nil {
|
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "delete_target", "target", id, nil); auditErr != nil {
|
||||||
slog.Error("failed to record audit event", "error", auditErr)
|
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConnection tests a target's connectivity by checking the assigned agent's heartbeat status.
|
||||||
|
// Target connectors run on agents, not on the server, so we can't instantiate a connector here.
|
||||||
|
// Instead, we verify the agent is online and reachable.
|
||||||
|
func (s *TargetService) TestConnection(ctx context.Context, id string) error {
|
||||||
|
target, err := s.targetRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("target not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if target.AgentID == "" {
|
||||||
|
s.updateTestStatus(ctx, target, "failed")
|
||||||
|
return fmt.Errorf("target has no assigned agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
agent, err := s.agentRepo.Get(ctx, target.AgentID)
|
||||||
|
if err != nil {
|
||||||
|
s.updateTestStatus(ctx, target, "failed")
|
||||||
|
return fmt.Errorf("assigned agent not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if agent.Status != domain.AgentStatusOnline {
|
||||||
|
s.updateTestStatus(ctx, target, "failed")
|
||||||
|
return fmt.Errorf("assigned agent %s is %s (expected Online)", agent.ID, agent.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check heartbeat freshness (agent must have heartbeated within the last 5 minutes)
|
||||||
|
if agent.LastHeartbeatAt != nil {
|
||||||
|
if time.Since(*agent.LastHeartbeatAt) > 5*time.Minute {
|
||||||
|
s.updateTestStatus(ctx, target, "failed")
|
||||||
|
return fmt.Errorf("assigned agent %s last heartbeat was %s ago (stale)", agent.ID, time.Since(*agent.LastHeartbeatAt).Round(time.Second))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.updateTestStatus(ctx, target, "success")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListTargets returns paginated targets (handler interface method).
|
// ListTargets returns paginated targets (handler interface method).
|
||||||
func (s *TargetService) ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error) {
|
func (s *TargetService) ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
@@ -157,6 +265,9 @@ func (s *TargetService) GetTarget(id string) (*domain.DeploymentTarget, error) {
|
|||||||
|
|
||||||
// CreateTarget creates a new target (handler interface method).
|
// CreateTarget creates a new target (handler interface method).
|
||||||
func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
|
func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
|
||||||
|
if !isValidTargetType(target.Type) {
|
||||||
|
return nil, fmt.Errorf("unsupported target type: %s", target.Type)
|
||||||
|
}
|
||||||
if target.ID == "" {
|
if target.ID == "" {
|
||||||
target.ID = generateID("target")
|
target.ID = generateID("target")
|
||||||
}
|
}
|
||||||
@@ -167,6 +278,23 @@ func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.De
|
|||||||
if target.UpdatedAt.IsZero() {
|
if target.UpdatedAt.IsZero() {
|
||||||
target.UpdatedAt = now
|
target.UpdatedAt = now
|
||||||
}
|
}
|
||||||
|
if target.TestStatus == "" {
|
||||||
|
target.TestStatus = "untested"
|
||||||
|
}
|
||||||
|
if target.Source == "" {
|
||||||
|
target.Source = "database"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt config
|
||||||
|
if len(target.Config) > 0 {
|
||||||
|
encrypted, _, err := crypto.EncryptIfKeySet([]byte(target.Config), s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt config: %w", err)
|
||||||
|
}
|
||||||
|
target.EncryptedConfig = encrypted
|
||||||
|
target.Config = redactConfigJSON(target.Config)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.targetRepo.Create(context.Background(), &target); err != nil {
|
if err := s.targetRepo.Create(context.Background(), &target); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create target: %w", err)
|
return nil, fmt.Errorf("failed to create target: %w", err)
|
||||||
}
|
}
|
||||||
@@ -176,6 +304,23 @@ func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.De
|
|||||||
// UpdateTarget modifies a target (handler interface method).
|
// UpdateTarget modifies a target (handler interface method).
|
||||||
func (s *TargetService) UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
|
func (s *TargetService) UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
|
||||||
target.ID = id
|
target.ID = id
|
||||||
|
target.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
// Merge redacted fields with existing config
|
||||||
|
if len(target.Config) > 0 {
|
||||||
|
mergedConfig, err := s.mergeRedactedConfig(context.Background(), id, target.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to merge config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
|
||||||
|
if encErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt config: %w", encErr)
|
||||||
|
}
|
||||||
|
target.EncryptedConfig = encrypted
|
||||||
|
target.Config = redactConfigJSON(json.RawMessage(mergedConfig))
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.targetRepo.Update(context.Background(), &target); err != nil {
|
if err := s.targetRepo.Update(context.Background(), &target); err != nil {
|
||||||
return nil, fmt.Errorf("failed to update target: %w", err)
|
return nil, fmt.Errorf("failed to update target: %w", err)
|
||||||
}
|
}
|
||||||
@@ -186,3 +331,100 @@ func (s *TargetService) UpdateTarget(id string, target domain.DeploymentTarget)
|
|||||||
func (s *TargetService) DeleteTarget(id string) error {
|
func (s *TargetService) DeleteTarget(id string) error {
|
||||||
return s.targetRepo.Delete(context.Background(), id)
|
return s.targetRepo.Delete(context.Background(), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTargetConnection tests target connectivity (handler interface method).
|
||||||
|
func (s *TargetService) TestTargetConnection(id string) error {
|
||||||
|
return s.TestConnection(context.Background(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal helpers ---
|
||||||
|
|
||||||
|
// getDecryptedConfig returns the decrypted config JSON for a target.
|
||||||
|
func (s *TargetService) getDecryptedConfig(target *domain.DeploymentTarget) (json.RawMessage, error) {
|
||||||
|
if len(target.EncryptedConfig) > 0 {
|
||||||
|
decrypted, err := crypto.DecryptIfKeySet(target.EncryptedConfig, s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.RawMessage(decrypted), nil
|
||||||
|
}
|
||||||
|
if len(target.Config) > 0 {
|
||||||
|
return target.Config, nil
|
||||||
|
}
|
||||||
|
return json.RawMessage("{}"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeRedactedConfig merges incoming config (which may have "********" values)
|
||||||
|
// with the existing decrypted config so sensitive fields are preserved.
|
||||||
|
func (s *TargetService) mergeRedactedConfig(ctx context.Context, id string, incoming json.RawMessage) ([]byte, error) {
|
||||||
|
// Parse incoming config
|
||||||
|
var incomingMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(incoming, &incomingMap); err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: incoming config is not a JSON object, using as-is", "target", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any values are "********"
|
||||||
|
hasRedacted := false
|
||||||
|
for _, v := range incomingMap {
|
||||||
|
if str, ok := v.(string); ok && str == "********" {
|
||||||
|
hasRedacted = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasRedacted {
|
||||||
|
return incoming, nil // No redacted values, use incoming as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing target to get real values
|
||||||
|
existing, err := s.targetRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: could not load existing target, redacted values will be lost", "target", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
existingConfig, err := s.getDecryptedConfig(existing)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: could not decrypt existing config, redacted values will be lost", "target", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(existingConfig, &existingMap); err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: existing config is not a JSON object, redacted values will be lost", "target", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge: for each "********" value in incoming, use existing value
|
||||||
|
for k, v := range incomingMap {
|
||||||
|
if str, ok := v.(string); ok && str == "********" {
|
||||||
|
if existingVal, exists := existingMap[k]; exists {
|
||||||
|
incomingMap[k] = existingVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(incomingMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTestStatus updates the test_status and last_tested_at fields in the database
|
||||||
|
// and records an audit event.
|
||||||
|
func (s *TargetService) updateTestStatus(ctx context.Context, target *domain.DeploymentTarget, status string) {
|
||||||
|
now := time.Now()
|
||||||
|
target.TestStatus = status
|
||||||
|
target.LastTestedAt = &now
|
||||||
|
target.UpdatedAt = now
|
||||||
|
if err := s.targetRepo.Update(ctx, target); err != nil {
|
||||||
|
s.logger.Error("failed to update test status", "target", target.ID, "status", status, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record audit event for connection test
|
||||||
|
if s.auditService != nil {
|
||||||
|
action := "target_test_connection_" + status
|
||||||
|
details := map[string]interface{}{"target_type": string(target.Type), "result": status, "agent_id": target.AgentID}
|
||||||
|
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem, action, "target", target.ID, details); auditErr != nil {
|
||||||
|
s.logger.Error("failed to record test connection audit event", "error", auditErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+189
-20
@@ -3,21 +3,26 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newTestTargetService creates a TargetService with mock repositories for testing.
|
// newTestTargetService creates a TargetService with mock repositories for testing.
|
||||||
func newTestTargetService() (*TargetService, *mockTargetRepo, *mockAuditRepo) {
|
func newTestTargetService() (*TargetService, *mockTargetRepo, *mockAuditRepo, *mockAgentRepo) {
|
||||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
return NewTargetService(targetRepo, auditSvc), targetRepo, auditRepo
|
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent), HeartbeatUpdates: make(map[string]time.Time)}
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||||
|
return NewTargetService(targetRepo, auditSvc, agentRepo, nil, logger), targetRepo, auditRepo, agentRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_List_Success(t *testing.T) {
|
func TestTargetService_List_Success(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Add 3 targets
|
// Add 3 targets
|
||||||
@@ -44,7 +49,7 @@ func TestTargetService_List_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_List_DefaultPagination(t *testing.T) {
|
func TestTargetService_List_DefaultPagination(t *testing.T) {
|
||||||
svc, _, _ := newTestTargetService()
|
svc, _, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Call with invalid pagination (page=0, perPage=0)
|
// Call with invalid pagination (page=0, perPage=0)
|
||||||
@@ -60,7 +65,7 @@ func TestTargetService_List_DefaultPagination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_List_EmptyPage(t *testing.T) {
|
func TestTargetService_List_EmptyPage(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Add 3 targets
|
// Add 3 targets
|
||||||
@@ -87,7 +92,7 @@ func TestTargetService_List_EmptyPage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_List_RepoError(t *testing.T) {
|
func TestTargetService_List_RepoError(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Set repo to return error
|
// Set repo to return error
|
||||||
@@ -104,7 +109,7 @@ func TestTargetService_List_RepoError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Get_Success(t *testing.T) {
|
func TestTargetService_Get_Success(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
||||||
@@ -121,7 +126,7 @@ func TestTargetService_Get_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Get_NotFound(t *testing.T) {
|
func TestTargetService_Get_NotFound(t *testing.T) {
|
||||||
svc, _, _ := newTestTargetService()
|
svc, _, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
result, err := svc.Get(ctx, "nonexistent")
|
result, err := svc.Get(ctx, "nonexistent")
|
||||||
@@ -135,7 +140,7 @@ func TestTargetService_Get_NotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Create_Success(t *testing.T) {
|
func TestTargetService_Create_Success(t *testing.T) {
|
||||||
svc, targetRepo, auditRepo := newTestTargetService()
|
svc, targetRepo, auditRepo, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
target := &domain.DeploymentTarget{
|
target := &domain.DeploymentTarget{
|
||||||
@@ -168,6 +173,14 @@ func TestTargetService_Create_Success(t *testing.T) {
|
|||||||
t.Errorf("expected timestamps to be set, CreatedAt=%v, UpdatedAt=%v", target.CreatedAt, target.UpdatedAt)
|
t.Errorf("expected timestamps to be set, CreatedAt=%v, UpdatedAt=%v", target.CreatedAt, target.UpdatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify test status and source defaults
|
||||||
|
if target.TestStatus != "untested" {
|
||||||
|
t.Errorf("expected test_status 'untested', got %s", target.TestStatus)
|
||||||
|
}
|
||||||
|
if target.Source != "database" {
|
||||||
|
t.Errorf("expected source 'database', got %s", target.Source)
|
||||||
|
}
|
||||||
|
|
||||||
// Verify audit event
|
// Verify audit event
|
||||||
if len(auditRepo.Events) == 0 {
|
if len(auditRepo.Events) == 0 {
|
||||||
t.Fatalf("expected audit event, got none")
|
t.Fatalf("expected audit event, got none")
|
||||||
@@ -184,7 +197,7 @@ func TestTargetService_Create_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Create_MissingName(t *testing.T) {
|
func TestTargetService_Create_MissingName(t *testing.T) {
|
||||||
svc, _, _ := newTestTargetService()
|
svc, _, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
target := &domain.DeploymentTarget{
|
target := &domain.DeploymentTarget{
|
||||||
@@ -197,8 +210,23 @@ func TestTargetService_Create_MissingName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTargetService_Create_InvalidType(t *testing.T) {
|
||||||
|
svc, _, _, _ := newTestTargetService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
target := &domain.DeploymentTarget{
|
||||||
|
Name: "Bad Target",
|
||||||
|
Type: domain.TargetType("InvalidType"),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.Create(ctx, target, "test-actor")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for invalid type, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTargetService_Create_RepoError(t *testing.T) {
|
func TestTargetService_Create_RepoError(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
targetRepo.CreateErr = errNotFound
|
targetRepo.CreateErr = errNotFound
|
||||||
@@ -215,7 +243,7 @@ func TestTargetService_Create_RepoError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Update_Success(t *testing.T) {
|
func TestTargetService_Update_Success(t *testing.T) {
|
||||||
svc, targetRepo, auditRepo := newTestTargetService()
|
svc, targetRepo, auditRepo, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create initial target
|
// Create initial target
|
||||||
@@ -251,7 +279,7 @@ func TestTargetService_Update_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Update_MissingName(t *testing.T) {
|
func TestTargetService_Update_MissingName(t *testing.T) {
|
||||||
svc, _, _ := newTestTargetService()
|
svc, _, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
target := &domain.DeploymentTarget{
|
target := &domain.DeploymentTarget{
|
||||||
@@ -265,7 +293,7 @@ func TestTargetService_Update_MissingName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Delete_Success(t *testing.T) {
|
func TestTargetService_Delete_Success(t *testing.T) {
|
||||||
svc, targetRepo, auditRepo := newTestTargetService()
|
svc, targetRepo, auditRepo, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create initial target
|
// Create initial target
|
||||||
@@ -295,7 +323,7 @@ func TestTargetService_Delete_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Delete_RepoError(t *testing.T) {
|
func TestTargetService_Delete_RepoError(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
targetRepo.DeleteErr = errNotFound
|
targetRepo.DeleteErr = errNotFound
|
||||||
@@ -307,7 +335,7 @@ func TestTargetService_Delete_RepoError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_ListTargets_Success(t *testing.T) {
|
func TestTargetService_ListTargets_Success(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
|
|
||||||
// Add targets
|
// Add targets
|
||||||
target1 := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
target1 := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
||||||
@@ -331,7 +359,7 @@ func TestTargetService_ListTargets_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_GetTarget_Success(t *testing.T) {
|
func TestTargetService_GetTarget_Success(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
|
|
||||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
||||||
targetRepo.AddTarget(target)
|
targetRepo.AddTarget(target)
|
||||||
@@ -347,7 +375,7 @@ func TestTargetService_GetTarget_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_CreateTarget_Success(t *testing.T) {
|
func TestTargetService_CreateTarget_Success(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
|
|
||||||
target := domain.DeploymentTarget{
|
target := domain.DeploymentTarget{
|
||||||
Name: "New Target",
|
Name: "New Target",
|
||||||
@@ -369,8 +397,22 @@ func TestTargetService_CreateTarget_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTargetService_CreateTarget_InvalidType(t *testing.T) {
|
||||||
|
svc, _, _, _ := newTestTargetService()
|
||||||
|
|
||||||
|
target := domain.DeploymentTarget{
|
||||||
|
Name: "Bad Target",
|
||||||
|
Type: domain.TargetType("Unknown"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := svc.CreateTarget(target)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for invalid type, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTargetService_UpdateTarget_Success(t *testing.T) {
|
func TestTargetService_UpdateTarget_Success(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
|
|
||||||
// Create initial target
|
// Create initial target
|
||||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Old Name", Type: domain.TargetTypeNGINX}
|
target := &domain.DeploymentTarget{ID: "t-1", Name: "Old Name", Type: domain.TargetTypeNGINX}
|
||||||
@@ -393,7 +435,7 @@ func TestTargetService_UpdateTarget_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_DeleteTarget_Success(t *testing.T) {
|
func TestTargetService_DeleteTarget_Success(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
|
|
||||||
// Create initial target
|
// Create initial target
|
||||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target To Delete", Type: domain.TargetTypeNGINX}
|
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target To Delete", Type: domain.TargetTypeNGINX}
|
||||||
@@ -410,3 +452,130 @@ func TestTargetService_DeleteTarget_Success(t *testing.T) {
|
|||||||
t.Errorf("target should be deleted from repo")
|
t.Errorf("target should be deleted from repo")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTargetService_TestConnection_AgentOnline(t *testing.T) {
|
||||||
|
svc, targetRepo, _, agentRepo := newTestTargetService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Set up agent
|
||||||
|
heartbeat := time.Now()
|
||||||
|
agent := &domain.Agent{
|
||||||
|
ID: "agent-1",
|
||||||
|
Name: "Test Agent",
|
||||||
|
Status: domain.AgentStatusOnline,
|
||||||
|
LastHeartbeatAt: &heartbeat,
|
||||||
|
}
|
||||||
|
agentRepo.Create(ctx, agent)
|
||||||
|
|
||||||
|
// Set up target assigned to agent
|
||||||
|
target := &domain.DeploymentTarget{
|
||||||
|
ID: "t-1",
|
||||||
|
Name: "Test Target",
|
||||||
|
Type: domain.TargetTypeNGINX,
|
||||||
|
AgentID: "agent-1",
|
||||||
|
}
|
||||||
|
targetRepo.AddTarget(target)
|
||||||
|
|
||||||
|
// Test connection should succeed
|
||||||
|
err := svc.TestConnection(ctx, "t-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify test status was updated
|
||||||
|
stored := targetRepo.Targets["t-1"]
|
||||||
|
if stored.TestStatus != "success" {
|
||||||
|
t.Errorf("expected test_status 'success', got %s", stored.TestStatus)
|
||||||
|
}
|
||||||
|
if stored.LastTestedAt == nil {
|
||||||
|
t.Error("expected last_tested_at to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTargetService_TestConnection_AgentOffline(t *testing.T) {
|
||||||
|
svc, targetRepo, _, agentRepo := newTestTargetService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Set up offline agent
|
||||||
|
agent := &domain.Agent{
|
||||||
|
ID: "agent-1",
|
||||||
|
Name: "Offline Agent",
|
||||||
|
Status: domain.AgentStatusOffline,
|
||||||
|
}
|
||||||
|
agentRepo.Create(ctx, agent)
|
||||||
|
|
||||||
|
// Set up target
|
||||||
|
target := &domain.DeploymentTarget{
|
||||||
|
ID: "t-1",
|
||||||
|
Name: "Test Target",
|
||||||
|
Type: domain.TargetTypeNGINX,
|
||||||
|
AgentID: "agent-1",
|
||||||
|
}
|
||||||
|
targetRepo.AddTarget(target)
|
||||||
|
|
||||||
|
err := svc.TestConnection(ctx, "t-1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for offline agent, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
stored := targetRepo.Targets["t-1"]
|
||||||
|
if stored.TestStatus != "failed" {
|
||||||
|
t.Errorf("expected test_status 'failed', got %s", stored.TestStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTargetService_TestConnection_NoAgent(t *testing.T) {
|
||||||
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
target := &domain.DeploymentTarget{
|
||||||
|
ID: "t-1",
|
||||||
|
Name: "Test Target",
|
||||||
|
Type: domain.TargetTypeNGINX,
|
||||||
|
AgentID: "",
|
||||||
|
}
|
||||||
|
targetRepo.AddTarget(target)
|
||||||
|
|
||||||
|
err := svc.TestConnection(ctx, "t-1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing agent, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTargetService_TestConnection_TargetNotFound(t *testing.T) {
|
||||||
|
svc, _, _, _ := newTestTargetService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := svc.TestConnection(ctx, "nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nonexistent target, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTargetService_TestConnection_StaleHeartbeat(t *testing.T) {
|
||||||
|
svc, targetRepo, _, agentRepo := newTestTargetService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Set up agent with stale heartbeat (10 minutes ago)
|
||||||
|
staleTime := time.Now().Add(-10 * time.Minute)
|
||||||
|
agent := &domain.Agent{
|
||||||
|
ID: "agent-1",
|
||||||
|
Name: "Stale Agent",
|
||||||
|
Status: domain.AgentStatusOnline,
|
||||||
|
LastHeartbeatAt: &staleTime,
|
||||||
|
}
|
||||||
|
agentRepo.Create(ctx, agent)
|
||||||
|
|
||||||
|
target := &domain.DeploymentTarget{
|
||||||
|
ID: "t-1",
|
||||||
|
Name: "Test Target",
|
||||||
|
Type: domain.TargetTypeNGINX,
|
||||||
|
AgentID: "agent-1",
|
||||||
|
}
|
||||||
|
targetRepo.AddTarget(target)
|
||||||
|
|
||||||
|
err := svc.TestConnection(ctx, "t-1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for stale heartbeat, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -637,6 +637,19 @@ func (m *mockTargetRepo) Create(ctx context.Context, target *domain.DeploymentTa
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockTargetRepo) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.CreateErr != nil {
|
||||||
|
return false, m.CreateErr
|
||||||
|
}
|
||||||
|
if _, exists := m.Targets[target.ID]; exists {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
m.Targets[target.ID] = target
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockTargetRepo) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
func (m *mockTargetRepo) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -856,6 +869,17 @@ func (m *mockIssuerRepository) Update(ctx context.Context, issuer *domain.Issuer
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockIssuerRepository) CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error) {
|
||||||
|
if m.CreateErr != nil {
|
||||||
|
return false, m.CreateErr
|
||||||
|
}
|
||||||
|
if _, exists := m.issuers[issuer.ID]; exists {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
m.issuers[issuer.ID] = issuer
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockIssuerRepository) Delete(ctx context.Context, id string) error {
|
func (m *mockIssuerRepository) Delete(ctx context.Context, id string) error {
|
||||||
if m.DeleteErr != nil {
|
if m.DeleteErr != nil {
|
||||||
return m.DeleteErr
|
return m.DeleteErr
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Rollback migration 000009: Remove dynamic issuer configuration columns
|
||||||
|
ALTER TABLE issuers DROP COLUMN IF EXISTS encrypted_config;
|
||||||
|
ALTER TABLE issuers DROP COLUMN IF EXISTS last_tested_at;
|
||||||
|
ALTER TABLE issuers DROP COLUMN IF EXISTS test_status;
|
||||||
|
ALTER TABLE issuers DROP COLUMN IF EXISTS source;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration 000009: Add dynamic issuer configuration columns
|
||||||
|
-- Supports M34: Dynamic Issuer Configuration (GUI)
|
||||||
|
|
||||||
|
-- encrypted_config stores AES-GCM encrypted config blob containing all fields including secrets.
|
||||||
|
-- The existing `config` JSONB column is retained for backward compatibility and holds a redacted copy.
|
||||||
|
ALTER TABLE issuers ADD COLUMN IF NOT EXISTS encrypted_config BYTEA;
|
||||||
|
|
||||||
|
-- last_tested_at tracks when the issuer connection was last successfully tested.
|
||||||
|
ALTER TABLE issuers ADD COLUMN IF NOT EXISTS last_tested_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- test_status tracks the latest connection test result.
|
||||||
|
ALTER TABLE issuers ADD COLUMN IF NOT EXISTS test_status TEXT NOT NULL DEFAULT 'untested';
|
||||||
|
|
||||||
|
-- source tracks where the issuer configuration originated from.
|
||||||
|
-- 'database' = created via GUI, 'env' = seeded from environment variables.
|
||||||
|
ALTER TABLE issuers ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'database';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Rollback migration 000010: Remove dynamic target configuration columns
|
||||||
|
ALTER TABLE deployment_targets DROP COLUMN IF EXISTS encrypted_config;
|
||||||
|
ALTER TABLE deployment_targets DROP COLUMN IF EXISTS last_tested_at;
|
||||||
|
ALTER TABLE deployment_targets DROP COLUMN IF EXISTS test_status;
|
||||||
|
ALTER TABLE deployment_targets DROP COLUMN IF EXISTS source;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration 000010: Add dynamic target configuration columns
|
||||||
|
-- Supports M35: Dynamic Target Configuration (GUI)
|
||||||
|
|
||||||
|
-- encrypted_config stores AES-GCM encrypted config blob containing all fields including secrets.
|
||||||
|
-- The existing `config` JSONB column is retained for backward compatibility and holds a redacted copy.
|
||||||
|
ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS encrypted_config BYTEA;
|
||||||
|
|
||||||
|
-- last_tested_at tracks when the target connection was last tested (agent heartbeat check).
|
||||||
|
ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS last_tested_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- test_status tracks the latest connection test result.
|
||||||
|
ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS test_status TEXT NOT NULL DEFAULT 'untested';
|
||||||
|
|
||||||
|
-- source tracks where the target configuration originated from.
|
||||||
|
-- 'database' = created via GUI, 'env' = seeded from environment variables.
|
||||||
|
ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'database';
|
||||||
+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) ----
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
getTargets,
|
getTargets,
|
||||||
createTarget,
|
createTarget,
|
||||||
deleteTarget,
|
deleteTarget,
|
||||||
|
testTargetConnection,
|
||||||
getProfiles,
|
getProfiles,
|
||||||
getProfile,
|
getProfile,
|
||||||
createProfile,
|
createProfile,
|
||||||
@@ -425,6 +426,14 @@ describe('API Client', () => {
|
|||||||
expect(url).toBe('/api/v1/targets/t-nginx');
|
expect(url).toBe('/api/v1/targets/t-nginx');
|
||||||
expect(init.method).toBe('DELETE');
|
expect(init.method).toBe('DELETE');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('testTargetConnection sends POST', async () => {
|
||||||
|
mockFetch.mockReturnValueOnce(mockJsonResponse({ status: 'success', message: 'Agent is online' }));
|
||||||
|
await testTargetConnection('t-nginx');
|
||||||
|
const [url, init] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/v1/targets/t-nginx/test');
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Approval ──────────────────────────────────────
|
// ─── Approval ──────────────────────────────────────
|
||||||
@@ -682,6 +691,28 @@ describe('API Client', () => {
|
|||||||
expect(body.config.org_id).toBe('12345');
|
expect(body.config.org_id).toBe('12345');
|
||||||
expect(body.config.product_type).toBe('ssl_basic');
|
expect(body.config.product_type).toBe('ssl_basic');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('createIssuer sends correct payload for ACME with profile', async () => {
|
||||||
|
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-acme-shortlived', name: 'ACME Shortlived' }));
|
||||||
|
const acmePayload = {
|
||||||
|
name: 'ACME Shortlived',
|
||||||
|
type: 'acme',
|
||||||
|
config: {
|
||||||
|
directory_url: 'https://acme-v02.api.letsencrypt.org/directory',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
challenge_type: 'http-01',
|
||||||
|
profile: 'shortlived',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await createIssuer(acmePayload);
|
||||||
|
const [url, init] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/v1/issuers');
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
const body = JSON.parse(init.body);
|
||||||
|
expect(body.type).toBe('acme');
|
||||||
|
expect(body.config.profile).toBe('shortlived');
|
||||||
|
expect(body.config.directory_url).toBe('https://acme-v02.api.letsencrypt.org/directory');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Audit ──────────────────────────────────────────
|
// ─── Audit ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -232,6 +232,9 @@ export const updateTarget = (id: string, data: Partial<Target>) =>
|
|||||||
export const deleteTarget = (id: string) =>
|
export const deleteTarget = (id: string) =>
|
||||||
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
|
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
|
||||||
|
|
||||||
|
export const testTargetConnection = (id: string) =>
|
||||||
|
fetchJSON<{ status: string; message: string }>(`${BASE}/targets/${id}/test`, { method: 'POST' });
|
||||||
|
|
||||||
// Profiles
|
// Profiles
|
||||||
export const getProfiles = (params: Record<string, string> = {}) => {
|
export const getProfiles = (params: Record<string, string> = {}) => {
|
||||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user