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

727 lines
18 KiB
Markdown

# 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
```go
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
```go
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:
```go
// 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
```go
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
```go
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
```go
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
```go
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
```go
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
```bash
# 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:
```bash
# 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
```go
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
```go
// 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
```go
// 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
```go
// 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](../README.md#supported-integrations)
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:
- [Architecture Guide](architecture.md#connector-architecture)
- [API Reference](../README.md#api-overview)
- [Contributing Guidelines](../CONTRIBUTING.md) (coming soon)