Files
certctl/docs/connectors.md
T
2026-03-14 08:22:17 -04:00

18 KiB

Certctl Connector Development Guide

Connectors extend certctl to integrate with external systems for certificate issuance, deployment, and notifications. This guide covers building custom connectors from scratch.

Overview

Three types of connectors:

  1. IssuerConnector — Obtains certificates from PKI systems (ACME, Vault, DigiCert)
  2. TargetConnector — Deploys certificates to infrastructure (NGINX, F5, IIS, Kubernetes)
  3. NotifierConnector — Sends notifications about certificate events (Email, Webhooks, Slack)

All connectors:

  • Are registered with a unique type identifier
  • Accept configuration at initialization
  • Are used by the control plane or agents
  • Are tested via validation endpoints

IssuerConnector Interface

Issuers obtain certificates from external PKI systems.

Interface Definition

package issuer

type IssuerConnector interface {
    // Validate checks the issuer configuration and connectivity
    Validate(ctx context.Context) error

    // IssueCertificate requests a certificate for the given domains
    IssueCertificate(ctx context.Context, req *IssueRequest) (*CertificateResponse, error)

    // RevokeCertificate revokes an issued certificate
    RevokeCertificate(ctx context.Context, certPEM []byte) error

    // GetStatus returns the status of an issuance request
    GetStatus(ctx context.Context, requestID string) (*StatusResponse, error)
}

type IssueRequest struct {
    Domains          []string      // Primary domain + SANs
    CSR              []byte        // Certificate Signing Request (PEM)
    ValidityDays     int           // Requested validity period
    NotBefore        *time.Time    // Optional: not valid before
    NotAfter         *time.Time    // Optional: not valid after
    Metadata         map[string]string
}

type CertificateResponse struct {
    Certificate      []byte        // Signed certificate (PEM)
    CertificateChain []byte        // CA chain (PEM)
    RequestID        string        // For status tracking
    ExpiresAt        time.Time
    IssuedAt         time.Time
}

Example: Vault PKI Issuer

package vault

import (
    "context"
    "crypto/x509"
    "encoding/pem"
    "fmt"
    "github.com/hashicorp/vault/api"
)

type VaultConfig struct {
    Address   string
    Token     string
    PKIPath   string // e.g., "pki"
    RoleName  string // e.g., "example-dot-com"
}

type VaultIssuer struct {
    config *VaultConfig
    client *api.Client
}

func New(cfg *VaultConfig) (*VaultIssuer, error) {
    client, err := api.NewClient(&api.Config{Address: cfg.Address})
    if err != nil {
        return nil, err
    }
    client.SetToken(cfg.Token)
    return &VaultIssuer{config: cfg, client: client}, nil
}

// Validate tests connectivity and access
func (v *VaultIssuer) Validate(ctx context.Context) error {
    _, err := v.client.Auth().Token().LookupSelf()
    if err != nil {
        return fmt.Errorf("Vault auth failed: %w", err)
    }
    return nil
}

// IssueCertificate requests a certificate from Vault
func (v *VaultIssuer) IssueCertificate(ctx context.Context, req *issuer.IssueRequest) (
    *issuer.CertificateResponse, error) {

    // Extract primary domain and SANs
    if len(req.Domains) == 0 {
        return nil, fmt.Errorf("no domains provided")
    }
    primaryDomain := req.Domains[0]
    altNames := req.Domains[1:]

    // Decode CSR
    csrBlock, _ := pem.Decode(req.CSR)
    if csrBlock == nil {
        return nil, fmt.Errorf("invalid CSR format")
    }
    csr, err := x509.ParseCertificateRequest(csrBlock.Bytes)
    if err != nil {
        return nil, err
    }

    // Call Vault PKI issue endpoint
    path := fmt.Sprintf("%s/issue/%s", v.config.PKIPath, v.config.RoleName)
    data := map[string]interface{}{
        "common_name":       primaryDomain,
        "alt_names":         altNames,
        "ttl":               fmt.Sprintf("%dh", req.ValidityDays*24),
        "private_key_format": "pem",
    }

    secret, err := v.client.Logical().Write(path, data)
    if err != nil {
        return nil, fmt.Errorf("Vault issue failed: %w", err)
    }

    // Extract certificate and chain
    certPEM := secret.Data["certificate"].(string)
    chainPEM := secret.Data["ca_chain"].([]interface{})
    caChain := ""
    for _, ca := range chainPEM {
        caChain += ca.(string) + "\n"
    }

    return &issuer.CertificateResponse{
        Certificate:      []byte(certPEM),
        CertificateChain: []byte(caChain),
        RequestID:        secret.Data["request_id"].(string),
        ExpiresAt:        time.Now().AddDate(0, 0, req.ValidityDays),
        IssuedAt:         time.Now(),
    }, nil
}

// RevokeCertificate revokes a certificate in Vault
func (v *VaultIssuer) RevokeCertificate(ctx context.Context, certPEM []byte) error {
    certBlock, _ := pem.Decode(certPEM)
    if certBlock == nil {
        return fmt.Errorf("invalid certificate format")
    }
    cert, err := x509.ParseCertificate(certBlock.Bytes)
    if err != nil {
        return err
    }

    path := fmt.Sprintf("%s/revoke", v.config.PKIPath)
    _, err = v.client.Logical().Write(path, map[string]interface{}{
        "certificate": cert.SerialNumber.String(),
    })
    return err
}

// GetStatus returns the status of an issuance request
func (v *VaultIssuer) GetStatus(ctx context.Context, requestID string) (
    *issuer.StatusResponse, error) {
    // Vault PKI doesn't have a request status endpoint
    // Return immediate success (Vault issues synchronously)
    return &issuer.StatusResponse{
        Status:   "success",
        Ready:    true,
        IssuedAt: time.Now(),
    }, nil
}

Registration

Register your issuer in the connector registry:

// internal/connector/issuer/registry.go

package issuer

var registry = map[string]Factory{
    "acme":   func(cfg Config) (IssuerConnector, error) { return acme.New(&cfg) },
    "vault":  func(cfg Config) (IssuerConnector, error) { return vault.New(&cfg) },
    // Add more issuers here
}

func GetConnector(connectorType string, config Config) (IssuerConnector, error) {
    factory, ok := registry[connectorType]
    if !ok {
        return nil, fmt.Errorf("unknown issuer type: %s", connectorType)
    }
    return factory(config)
}

TargetConnector Interface

Targets deploy certificates to infrastructure.

Interface Definition

package target

type TargetConnector interface {
    // Validate tests connectivity and credentials
    Validate(ctx context.Context) error

    // Deploy pushes the certificate to the target
    Deploy(ctx context.Context, req *DeployRequest) (*DeployResponse, error)

    // Remove removes/revokes a certificate from the target
    Remove(ctx context.Context, domain string) error

    // GetStatus checks the deployment status
    GetStatus(ctx context.Context, domain string) (*StatusResponse, error)
}

type DeployRequest struct {
    Domain           string        // Primary domain
    Certificate      []byte        // Signed certificate (PEM)
    PrivateKey       []byte        // Private key (PEM) - optional
    CertificateChain []byte        // CA chain (PEM)
    Metadata         map[string]string
}

type DeployResponse struct {
    RequestID   string
    Status      string // "success", "pending", "error"
    Message     string
    DeployedAt  time.Time
}

type StatusResponse struct {
    Status     string    // "deployed", "pending", "failed"
    DeployedAt time.Time
    Error      string
}

Example: Custom Load Balancer Target

package lb

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
)

type LBConfig struct {
    Host     string // Load balancer hostname
    Port     int    // HTTPS API port
    Username string
    Password string
}

type LoadBalancerTarget struct {
    config *LBConfig
    client *http.Client
}

func New(cfg *LBConfig) *LoadBalancerTarget {
    return &LoadBalancerTarget{
        config: cfg,
        client: &http.Client{Timeout: 30 * time.Second},
    }
}

// Validate tests connectivity
func (lb *LoadBalancerTarget) Validate(ctx context.Context) error {
    req, err := http.NewRequestWithContext(ctx, "GET",
        fmt.Sprintf("https://%s:%d/api/health", lb.config.Host, lb.config.Port), nil)
    if err != nil {
        return err
    }
    req.SetBasicAuth(lb.config.Username, lb.config.Password)

    resp, err := lb.client.Do(req)
    if err != nil {
        return fmt.Errorf("load balancer unreachable: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("load balancer returned %d", resp.StatusCode)
    }
    return nil
}

// Deploy pushes certificate to the load balancer
func (lb *LoadBalancerTarget) Deploy(ctx context.Context, req *target.DeployRequest) (
    *target.DeployResponse, error) {

    body := map[string]interface{}{
        "domain":      req.Domain,
        "certificate": string(req.Certificate),
        "chain":       string(req.CertificateChain),
        "key":         string(req.PrivateKey),
    }

    payload, err := json.Marshal(body)
    if err != nil {
        return nil, err
    }

    httpReq, err := http.NewRequestWithContext(ctx, "POST",
        fmt.Sprintf("https://%s:%d/api/certs/upload", lb.config.Host, lb.config.Port),
        bytes.NewReader(payload))
    if err != nil {
        return nil, err
    }
    httpReq.SetBasicAuth(lb.config.Username, lb.config.Password)
    httpReq.Header.Set("Content-Type", "application/json")

    resp, err := lb.client.Do(httpReq)
    if err != nil {
        return nil, fmt.Errorf("deployment failed: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("deployment returned %d", resp.StatusCode)
    }

    return &target.DeployResponse{
        Status:     "success",
        DeployedAt: time.Now(),
    }, nil
}

// Remove deletes a certificate from the load balancer
func (lb *LoadBalancerTarget) Remove(ctx context.Context, domain string) error {
    req, err := http.NewRequestWithContext(ctx, "DELETE",
        fmt.Sprintf("https://%s:%d/api/certs/%s", lb.config.Host, lb.config.Port, domain), nil)
    if err != nil {
        return err
    }
    req.SetBasicAuth(lb.config.Username, lb.config.Password)

    resp, err := lb.client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("removal returned %d", resp.StatusCode)
    }
    return nil
}

// GetStatus checks deployment status
func (lb *LoadBalancerTarget) GetStatus(ctx context.Context, domain string) (
    *target.StatusResponse, error) {

    req, err := http.NewRequestWithContext(ctx, "GET",
        fmt.Sprintf("https://%s:%d/api/certs/%s/status", lb.config.Host, lb.config.Port, domain), nil)
    if err != nil {
        return nil, err
    }
    req.SetBasicAuth(lb.config.Username, lb.config.Password)

    resp, err := lb.client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    var result map[string]interface{}
    json.NewDecoder(resp.Body).Decode(&result)

    return &target.StatusResponse{
        Status: result["status"].(string),
    }, nil
}

NotifierConnector Interface

Notifiers send alerts about certificate events.

Interface Definition

package notifier

type NotifierConnector interface {
    // Validate checks configuration
    Validate(ctx context.Context) error

    // Send delivers a notification
    Send(ctx context.Context, notification *Notification) error
}

type Notification struct {
    EventType string // "certificate_issued", "renewal_failed", "deployment_success"
    Subject   string
    Body      string
    Severity  string // "info", "warning", "error"
    Metadata  map[string]string
}

Example: Slack Notifier

package slack

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
)

type SlackConfig struct {
    WebhookURL string // Slack incoming webhook URL
    Channel    string // Optional: override channel
    Username   string // Bot username
}

type SlackNotifier struct {
    config *SlackConfig
    client *http.Client
}

func New(cfg *SlackConfig) *SlackNotifier {
    return &SlackNotifier{
        config: cfg,
        client: &http.Client{Timeout: 10 * time.Second},
    }
}

// Validate checks webhook connectivity
func (s *SlackNotifier) Validate(ctx context.Context) error {
    payload := map[string]interface{}{
        "text": "Certctl test message",
    }
    data, _ := json.Marshal(payload)

    resp, err := s.client.Post(s.config.WebhookURL, "application/json", bytes.NewReader(data))
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("slack webhook returned %d", resp.StatusCode)
    }
    return nil
}

// Send posts a message to Slack
func (s *SlackNotifier) Send(ctx context.Context, notif *notifier.Notification) error {
    color := "good"
    if notif.Severity == "error" {
        color = "danger"
    } else if notif.Severity == "warning" {
        color = "warning"
    }

    payload := map[string]interface{}{
        "username": s.config.Username,
        "attachments": []map[string]interface{}{
            {
                "title":  notif.Subject,
                "text":   notif.Body,
                "color":  color,
                "fields": formatMetadata(notif.Metadata),
            },
        },
    }

    data, _ := json.Marshal(payload)
    resp, err := s.client.Post(s.config.WebhookURL, "application/json", bytes.NewReader(data))
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("slack post failed: %d", resp.StatusCode)
    }
    return nil
}

func formatMetadata(m map[string]string) []map[string]interface{} {
    fields := []map[string]interface{}{}
    for k, v := range m {
        fields = append(fields, map[string]interface{}{
            "title": k,
            "value": v,
            "short": true,
        })
    }
    return fields
}

Testing Connectors

Unit Tests

package vault

import (
    "context"
    "testing"
)

func TestValidate(t *testing.T) {
    cfg := &VaultConfig{
        Address: "http://localhost:8200",
        Token:   "test-token",
    }
    issuer := New(cfg)

    err := issuer.Validate(context.Background())
    if err == nil {
        t.Fatal("expected error for invalid token")
    }
}

func TestIssueCertificate(t *testing.T) {
    // Mock Vault responses or use Vault test harness
    cfg := &VaultConfig{/* ... */}
    issuer := New(cfg)

    req := &issuer.IssueRequest{
        Domains:      []string{"example.com"},
        CSR:          testCSR,
        ValidityDays: 90,
    }

    resp, err := issuer.IssueCertificate(context.Background(), req)
    if err != nil {
        t.Fatalf("issuance failed: %v", err)
    }

    if len(resp.Certificate) == 0 {
        t.Fatal("no certificate returned")
    }
}

Integration Tests

# Start dependent service
docker run -d --name vault -p 8200:8200 vault:latest server -dev

# Run tests
go test -tags=integration ./internal/connector/issuer/vault

# Cleanup
docker rm -f vault

Validation Endpoints

Test connectors via the API:

# Validate an issuer
curl -X POST http://localhost:8443/api/v1/issuers/validate \
  -H "Content-Type: application/json" \
  -d '{
    "type": "vault",
    "config": {
      "address": "http://vault.example.com:8200",
      "token": "s.xxxxxxx"
    }
  }'

# Validate a target
curl -X POST http://localhost:8443/api/v1/targets/validate \
  -H "Content-Type: application/json" \
  -d '{
    "type": "nginx",
    "config": {
      "host": "web01.example.com",
      "ssh_user": "deploy"
    }
  }'

Registering Custom Connectors

1. Create Connector Package

internal/connector/issuer/myissuer/
├── issuer.go       # Implementation
└── config.go       # Configuration validation

2. Implement Interface

package myissuer

type MyIssuer struct {
    config *Config
}

func (m *MyIssuer) Validate(ctx context.Context) error {
    // Validation logic
}

func (m *MyIssuer) IssueCertificate(ctx context.Context, req *issuer.IssueRequest) (*issuer.CertificateResponse, error) {
    // Issuance logic
}

3. Register in Factory

// internal/connector/issuer/factory.go

import "github.com/shankar0123/certctl/internal/connector/issuer/myissuer"

var factories = map[string]ConnectorFactory{
    "myissuer": func(cfg interface{}) (IssuerConnector, error) {
        return myissuer.New(cfg.(*myissuer.Config))
    },
}

4. Add Configuration Schema

// Validate connector configuration at registration
func ValidateConfig(connectorType string, config interface{}) error {
    switch connectorType {
    case "myissuer":
        cfg := config.(*MyConfig)
        if cfg.Host == "" {
            return fmt.Errorf("host is required")
        }
        if cfg.Token == "" {
            return fmt.Errorf("token is required")
        }
    }
    return nil
}

5. Use in Your Application

// Get connector
connector, err := issuer.GetConnector("myissuer", config)

// Issue certificate
resp, err := connector.IssueCertificate(ctx, issueReq)

Best Practices

  1. Error Handling — Return descriptive errors with context
  2. Timeout Management — Always use context with timeouts
  3. Validation — Validate configuration during Validate()
  4. Retry Logic — Handle transient failures gracefully
  5. Logging — Log all operations for debugging
  6. Testing — Provide unit and integration tests
  7. Documentation — Document configuration options and limitations
  8. Security — Never log sensitive data (tokens, keys, passwords)

Contributing Connectors

To contribute a connector to certctl:

  1. Fork the repository
  2. Create a feature branch: git checkout -b feat/my-connector
  3. Add connector implementation with tests
  4. Update README.md
  5. Add documentation to docs/
  6. Submit a pull request

Connectors must:

  • Implement the full interface
  • Include unit tests (>80% coverage)
  • Have integration tests (if applicable)
  • Include configuration examples
  • Document any prerequisites (API keys, credentials)

For more information, see: