- Wire issuer connector end-to-end with IssuerConnectorAdapter (dependency inversion)
- Renewal/issuance job processor: RSA key + CSR generation, Local CA signing, cert version storage
- Agent work API (GET /agents/{id}/work) and job status API (POST /agents/{id}/jobs/{job_id}/status)
- Agent-side deployment: WorkItem enrichment with target type/config, NGINX/F5/IIS connector invocation
- Full ACME v2 implementation: HTTP-01 challenge solving, account registration, order lifecycle
- Update all docs (README, architecture, connectors, demo-advanced, quickstart) for M1-M2
- Fix go vet warning in deployment.go (non-constant format string)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
13 KiB
Connector Development Guide
Connectors extend certctl to integrate with external systems for certificate issuance, deployment, and notifications. This guide covers the connector interfaces, built-in implementations, and how to build your own.
Overview
Three types of connectors:
- Issuer Connector — Obtains certificates from CAs (ACME, Local CA, Vault, DigiCert)
- Target Connector — Deploys certificates to infrastructure (NGINX, F5, IIS)
- Notifier Connector — Sends alerts about certificate events (Email, Webhooks, Slack)
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.
Issuer Connector
Issuer connectors obtain signed certificates from Certificate Authorities.
Interface
// internal/connector/issuer/interface.go
package issuer
type Connector interface {
// ValidateConfig checks that the issuer configuration is valid
ValidateConfig(ctx context.Context, config json.RawMessage) error
// IssueCertificate submits a CSR and returns a signed certificate
IssueCertificate(ctx context.Context, request IssuanceRequest) (*IssuanceResult, error)
// RenewCertificate renews an existing certificate
RenewCertificate(ctx context.Context, request RenewalRequest) (*IssuanceResult, error)
// RevokeCertificate revokes a previously issued certificate
RevokeCertificate(ctx context.Context, request RevocationRequest) error
// GetOrderStatus checks the status of an async issuance order
GetOrderStatus(ctx context.Context, orderID string) (*OrderStatus, error)
}
type IssuanceRequest struct {
CommonName string
SANs []string
CSRPEM string
}
type IssuanceResult struct {
CertPEM string
ChainPEM string
Serial string
NotBefore time.Time
NotAfter time.Time
OrderID string
}
type RenewalRequest struct {
CommonName string
SANs []string
CSRPEM string
OrderID string // optional, for tracking
}
type RevocationRequest struct {
Serial string
Reason string // optional
}
type OrderStatus struct {
OrderID string
Status string // "pending", "valid", "invalid", "expired"
Message string
CertPEM string
ChainPEM string
Serial string
NotBefore time.Time
NotAfter time.Time
UpdatedAt time.Time
}
Built-in: Local CA
The Local CA issuer generates self-signed certificates using Go's crypto/x509 library. It creates a CA on first use (in memory), issues certificates with proper serial numbers, validity periods, SANs, and key usage extensions.
This issuer is designed for development and demos only — certificates are self-signed and not trusted by browsers.
Configuration:
{
"ca_common_name": "CertCtl Local CA",
"validity_days": 90
}
Location: internal/connector/issuer/local/local.go
Built-in: ACME v2 (Let's Encrypt, Sectigo, ZeroSSL)
The ACME connector implements the full ACME v2 protocol using Go's golang.org/x/crypto/acme package. It supports HTTP-01 challenge solving via a built-in temporary HTTP server that starts on demand during certificate issuance.
Configuration:
{
"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory",
"email": "admin@example.com",
"http_port": 80
}
For HTTP-01 to work, the domain being validated must resolve to the machine running the connector, and the configured HTTP port must be reachable from the internet. The connector automatically registers an ACME account, creates orders, solves challenges, finalizes with the CSR, and downloads the issued certificate chain.
Environment variables for the default ACME connector:
CERTCTL_ACME_DIRECTORY_URL— ACME directory URLCERTCTL_ACME_EMAIL— Contact email for account registration
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.
Location: internal/connector/issuer/acme/acme.go
Building a Custom Issuer
Here's the structure for a HashiCorp Vault PKI issuer:
package vault
import (
"context"
"encoding/json"
"fmt"
vaultapi "github.com/hashicorp/vault/api"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
type Config struct {
Address string `json:"address"`
Token string `json:"token"`
PKIPath string `json:"pki_path"`
RoleName string `json:"role_name"`
}
type VaultIssuer struct {
config *Config
client *vaultapi.Client
}
func New(cfg *Config) (*VaultIssuer, error) {
client, err := vaultapi.NewClient(&vaultapi.Config{Address: cfg.Address})
if err != nil {
return nil, fmt.Errorf("vault client: %w", err)
}
client.SetToken(cfg.Token)
return &VaultIssuer{config: cfg, client: client}, nil
}
func (v *VaultIssuer) ValidateConfig(ctx context.Context, config json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(config, &cfg); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
if cfg.Address == "" || cfg.Token == "" {
return fmt.Errorf("address and token are required")
}
return nil
}
func (v *VaultIssuer) IssueCertificate(ctx context.Context, req issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
path := fmt.Sprintf("%s/sign/%s", v.config.PKIPath, v.config.RoleName)
secret, err := v.client.Logical().Write(path, map[string]interface{}{
"common_name": req.CommonName,
"alt_names": req.SANs,
"csr": req.CSRPEM,
})
if err != nil {
return nil, fmt.Errorf("vault sign: %w", err)
}
return &issuer.IssuanceResult{
CertPEM: secret.Data["certificate"].(string),
ChainPEM: secret.Data["ca_chain"].(string),
Serial: secret.Data["serial_number"].(string),
}, nil
}
// ... implement RenewCertificate, RevokeCertificate, GetOrderStatus
Target Connector
Target connectors deploy certificates to infrastructure systems. They run on agents, not on the control plane.
Interface
// internal/connector/target/interface.go
package target
type Connector interface {
// ValidateConfig checks target configuration
ValidateConfig(ctx context.Context, config json.RawMessage) error
// DeployCertificate pushes a certificate to the target system
DeployCertificate(ctx context.Context, request DeploymentRequest) (*DeploymentResult, error)
// ValidateDeployment verifies a certificate was deployed correctly
ValidateDeployment(ctx context.Context, request ValidationRequest) (*ValidationResult, error)
}
type DeploymentRequest struct {
CertPEM string // Signed certificate (PEM)
ChainPEM string // CA chain (PEM)
TargetConfig json.RawMessage // Target-specific config
Metadata map[string]string
// NOTE: No private key — agents handle keys locally
}
type DeploymentResult struct {
Success bool
TargetAddress string
DeploymentID string
Message string
DeployedAt time.Time
Metadata map[string]string
}
type ValidationRequest struct {
CertificateID string
Serial string
TargetConfig json.RawMessage
Metadata map[string]string
}
type ValidationResult struct {
Valid bool
Serial string
TargetAddress string
Message string
ValidatedAt time.Time
Metadata map[string]string
}
Built-in: NGINX
The NGINX connector writes certificate and chain files to disk, validates the NGINX configuration, and reloads the server.
Configuration:
{
"cert_path": "/etc/nginx/certs/cert.pem",
"chain_path": "/etc/nginx/certs/chain.pem",
"reload_command": "systemctl reload nginx",
"validate_command": "nginx -t"
}
The connector writes cert and chain files with mode 0644, runs the validation command first (so a bad config doesn't take down NGINX), and only reloads if validation passes.
Location: internal/connector/target/nginx/nginx.go
Built-in: F5 BIG-IP
Deploys certificates via the F5 REST API. Uploads the certificate and key, then updates virtual server SSL profiles.
Location: internal/connector/target/f5/f5.go
Built-in: IIS
Deploys certificates to Microsoft IIS via WinRM. Imports the certificate into the Windows certificate store and binds it to an IIS site.
Location: internal/connector/target/iis/iis.go
Notifier Connector
Notifier connectors send alerts about certificate lifecycle events (expiration warnings, renewal success/failure, deployment status, policy violations).
Interface
The service layer defines a simple notifier interface:
// internal/service/notification.go
type Notifier interface {
Send(ctx context.Context, recipient string, subject string, body string) error
Channel() string
}
The connector layer has a richer interface:
// internal/connector/notifier/interface.go
type Connector interface {
ValidateConfig(ctx context.Context, config json.RawMessage) error
SendAlert(ctx context.Context, alert Alert) error
SendEvent(ctx context.Context, event Event) error
}
Built-in notifiers: Email (SMTP) and Webhook (HTTP POST).
In demo mode, notifications are marked as "sent" even without a configured notifier — this prevents error spam in the logs while still generating notification records for the dashboard to display.
Registering a Connector
To add a new connector:
-
Create a package under the appropriate directory:
internal/connector/issuer/myissuer/internal/connector/target/mytarget/internal/connector/notifier/mynotifier/
-
Implement the interface (all methods required)
-
Register it in the service layer during server initialization in
cmd/server/main.go.
IssuerConnectorAdapter
Issuer connectors use an adapter pattern to bridge the connector-layer issuer.Connector interface with the service-layer service.IssuerConnector interface. This maintains dependency inversion — the service package never imports the connector package directly.
The adapter (internal/service/issuer_adapter.go) translates between the two interface types:
// Wrap your connector implementation with the adapter
import "github.com/shankar0123/certctl/internal/service"
myIssuer := myissuer.New(config)
adapted := service.NewIssuerConnectorAdapter(myIssuer)
Register adapted connectors keyed by the issuer ID from the database:
// In cmd/server/main.go
localCA := local.New(nil, logger)
issuerRegistry := map[string]service.IssuerConnector{
"iss-local": service.NewIssuerConnectorAdapter(localCA),
"iss-vault": service.NewIssuerConnectorAdapter(vaultIssuer), // your new issuer
}
Notifier Registration
// For notifiers
notifierRegistry := map[string]service.Notifier{
"Email": emailNotifier,
"Webhook": webhookNotifier,
"Slack": slackNotifier, // your new notifier
}
Testing Connectors
Unit Tests
func TestNginxDeploy(t *testing.T) {
cfg := &nginx.Config{
CertPath: "/tmp/test-cert.pem",
ChainPath: "/tmp/test-chain.pem",
ReloadCommand: "echo reloaded",
ValidateCommand: "echo valid",
}
connector := nginx.New(cfg, slog.Default())
result, err := connector.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: testCertPEM,
ChainPEM: testChainPEM,
})
if err != nil {
t.Fatalf("deploy failed: %v", err)
}
if !result.Success {
t.Fatal("expected success")
}
}
Integration Tests
# Start dependent service
docker run -d --name nginx -p 8080:80 nginx:latest
# Run tests
go test -tags=integration ./internal/connector/target/nginx/
# Cleanup
docker rm -f nginx
Best Practices
- Always validate config — Check all required fields in
ValidateConfigbefore any operation - Use context for timeouts — All connector methods accept
context.Context; honor cancellation and deadlines - Return descriptive errors — Wrap errors with context so failures are diagnosable from logs
- Never log secrets — Don't log API tokens, passwords, or private key material
- Support dry-run — Where possible, support a validation/dry-run mode for deployment testing
- Idempotent operations — Deploying the same certificate twice should succeed, not fail
- Report metadata — Return deployment duration, target address, and other useful data in results
What's Next
- Architecture Guide — Understanding the full system design
- Quick Start — Get certctl running locally
- Advanced Demo — See the full certificate lifecycle in action