Files
certctl/docs/connectors.md
T
shankar0123 9a12ee18b2 docs: codify proxy agent pattern, sub-CA capability, IIS dual-mode design
Three architectural decisions from user feedback:

1. Pull-only deployment model — server never initiates outbound
   connections. Network appliances (F5, Palo Alto, FortiGate, Citrix)
   use a proxy agent in the same network zone. Added as design principle
   #2 across all docs.

2. IIS dual-mode — agent-local PowerShell (primary/recommended) + proxy
   agent WinRM (for agentless targets). Replaces the previous WinRM-only
   design. Updated connectors.md, architecture.md, demo-advanced.md.

3. Sub-CA to ADCS — Local CA can load a pre-signed CA cert+key from
   disk, so all issued certs chain to the enterprise root. Replaces the
   planned standalone ADCS issuer connector. Updated concepts.md,
   connectors.md, demo-advanced.md issuer diagram.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:45:18 -04:00

20 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:

  1. Issuer Connector — Obtains certificates from CAs (Local CA with sub-CA support, ACME implemented; step-ca, OpenSSL planned V2; DigiCert, Entrust, GlobalSign, EJBCA, Vault PKI, Google CAS planned V3)
  2. Target Connector — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy implemented; F5 via proxy agent, IIS dual-mode interface only; AWS ALB, Azure Key Vault, Palo Alto, FortiGate, Citrix ADC, Kubernetes Secrets planned V3)
  3. Notifier Connector — Sends alerts about certificate events (Email, Webhooks; Slack, Teams, PagerDuty, OpsGenie planned V2)

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.

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 signs certificates using Go's crypto/x509 library. It supports two modes:

Self-signed mode (default): Creates a CA on first use (in memory), issues certificates with proper serial numbers, validity periods, SANs, and key usage extensions. Designed for development and demos — certificates are self-signed and not trusted by browsers.

Sub-CA mode (planned M12): Loads a CA certificate and private key from disk (CERTCTL_CA_CERT_PATH + CERTCTL_CA_KEY_PATH). The CA cert is signed by an upstream CA (e.g., ADCS), so all issued certificates chain to the enterprise root trust hierarchy. Clients that already trust the enterprise root automatically trust certctl-issued certs. If the paths are not set, falls back to self-signed mode.

Configuration:

{
  "ca_common_name": "CertCtl Local CA",
  "validity_days": 90,
  "ca_cert_path": "/etc/certctl/ca/ca.pem",
  "ca_key_path": "/etc/certctl/ca/ca-key.pem"
}

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.

Limitation: v1 supports HTTP-01 challenges only. DNS-01 challenge support (required for wildcard certificates and hosts that can't serve HTTP on port 80) is planned for V2, including provider-specific DNS adapters (Cloudflare, Route53, etc.) and custom validation script hooks.

Environment variables for the default ACME connector:

  • CERTCTL_ACME_DIRECTORY_URL — ACME directory URL
  • CERTCTL_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

Planned Issuers (V2)

The following issuer connectors are planned for V2:

  • step-ca — Smallstep's private CA and ACME server. Would allow certctl to issue certificates from a self-hosted step-ca instance via its ACME or provisioner APIs.
  • OpenSSL / Custom CA — Support for external CAs that use OpenSSL-based signing workflows, including custom script hooks for organizations with existing CA tooling.
  • Vault PKI — HashiCorp Vault's PKI secrets engine for organizations using Vault as their internal CA.
  • DigiCert — Commercial CA integration via DigiCert's REST API.

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.

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), from control plane
    ChainPEM     string            // CA chain (PEM), from control plane
    KeyPEM       string            // Private key (PEM), from agent's local key store
    TargetConfig json.RawMessage   // Target-specific config (NGINX paths, F5 API, IIS site)
    Metadata     map[string]string // Arbitrary context (cert ID, environment, etc.)
    // NOTE: KeyPEM is populated by the agent from its local key store
    // (CERTCTL_KEY_DIR). It is NEVER sent from the control plane.
    // The control plane only provides CertPEM and ChainPEM (public material).
    // The agent combines the locally-generated private key with the signed
    // certificate to create the full deployment payload.
}

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, chain, and key files to disk, validates the NGINX configuration, and reloads the server. This is a common deployment pattern for teams running NGINX as a reverse proxy or TLS termination point.

Configuration:

{
  "cert_path": "/etc/nginx/certs/cert.pem",
  "chain_path": "/etc/nginx/certs/chain.pem",
  "key_path": "/etc/nginx/certs/key.pem",
  "reload_command": "systemctl reload nginx",
  "validate_command": "nginx -t"
}

The deployment flow is designed to be safe and atomic where possible: the connector writes cert and chain files with mode 0644 and the key file with mode 0600 (read-only by owner), runs the validation command first (so a bad config doesn't take down NGINX), and only reloads if validation passes. If the validation command fails, the connector rolls back the file writes and returns an error with the validation output — this prevents a partial deployment from breaking a running NGINX instance.

The reload_command defaults to systemctl reload nginx but can be overridden for custom setups (e.g., nginx -s reload for non-systemd environments, or docker exec nginx nginx -s reload for containerized NGINX).

Location: internal/connector/target/nginx/nginx.go

Built-in: Apache httpd

The Apache httpd connector follows the same pattern as NGINX: it writes separate certificate, chain, and key files to disk, validates the Apache configuration with apachectl configtest, and performs a graceful reload. The key difference is that private keys are written with 0600 permissions (owner-only read) for security, while cert and chain files use 0644.

Configuration:

{
  "cert_path": "/etc/apache2/ssl/cert.pem",
  "chain_path": "/etc/apache2/ssl/chain.pem",
  "key_path": "/etc/apache2/ssl/key.pem",
  "reload_command": "apachectl graceful",
  "validate_command": "apachectl configtest"
}

The reload_command can be customized for different environments (e.g., systemctl reload apache2 for systemd, httpd -k graceful for RHEL/CentOS). Validation output is captured and included in error messages for debugging.

Location: internal/connector/target/apache/apache.go

Built-in: HAProxy

The HAProxy connector differs from NGINX and Apache because HAProxy expects all TLS material in a single combined PEM file (certificate + chain + private key concatenated). The connector builds this combined file, writes it with 0600 permissions (since it contains the private key), optionally validates the HAProxy configuration, and reloads.

Configuration:

{
  "pem_path": "/etc/haproxy/certs/site.pem",
  "reload_command": "systemctl reload haproxy",
  "validate_command": "haproxy -c -f /etc/haproxy/haproxy.cfg"
}

The combined PEM is built in this order: server certificate, intermediate/chain certificates, private key. The validate_command is optional — if omitted, the connector skips config validation and goes straight to reload.

Location: internal/connector/target/haproxy/haproxy.go

Planned: F5 BIG-IP (V2, Interface Only)

The F5 BIG-IP target connector interface is built 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 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. Implementation is planned for V2.

Configuration (defined, not yet functional):

{
  "host": "f5.internal.example.com",
  "username": "admin",
  "password": "...",
  "partition": "Common",
  "ssl_profile": "/Common/clientssl_api"
}

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.

Location: internal/connector/target/f5/f5.go

Planned: IIS (V2, Interface Only, Dual-Mode)

The IIS target connector supports two deployment modes:

Agent-local (recommended): A Windows agent runs directly on the IIS server and deploys certificates using PowerShell — Import-PfxCertificate to install into the certificate store and Set-WebBinding to bind to the IIS site. This is the preferred approach: no remote access needed, no credential management, same pull-based model as NGINX/Apache/HAProxy.

Proxy agent WinRM (for agentless targets): For Windows servers where you don't want to install an agent, a nearby Windows agent acts as a proxy and reaches the IIS box via WinRM. The proxy agent picks up the deployment job, transfers the PFX bundle over WinRM, and runs the PowerShell commands remotely. WinRM credentials are stored on the proxy agent, not on the control plane.

Configuration (defined, not yet functional):

{
  "mode": "local",
  "site_name": "Default Web Site",
  "cert_store": "WebHosting",
  "winrm_host": "",
  "winrm_username": "",
  "winrm_password": "",
  "winrm_use_https": true
}

When mode is "local", the winrm_* fields are ignored. When mode is "proxy", the agent connects to the remote IIS server via WinRM using the provided credentials.

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:

  1. Create a package under the appropriate directory:

    • internal/connector/issuer/myissuer/
    • internal/connector/target/mytarget/
    • internal/connector/notifier/mynotifier/
  2. Implement the interface (all methods required)

  3. 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,
        KeyPEM:   testKeyPEM,
    })
    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

  1. Always validate config — Check all required fields in ValidateConfig before any operation
  2. Use context for timeouts — All connector methods accept context.Context; honor cancellation and deadlines
  3. Return descriptive errors — Wrap errors with context so failures are diagnosable from logs
  4. Never log secrets — Don't log API tokens, passwords, or private key material
  5. Support dry-run — Where possible, support a validation/dry-run mode for deployment testing
  6. Idempotent operations — Deploying the same certificate twice should succeed, not fail
  7. Report metadata — Return deployment duration, target address, and other useful data in results

What's Next