mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
18 KiB
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:
- IssuerConnector — Obtains certificates from PKI systems (ACME, Vault, DigiCert)
- TargetConnector — Deploys certificates to infrastructure (NGINX, F5, IIS, Kubernetes)
- 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
- Error Handling — Return descriptive errors with context
- Timeout Management — Always use context with timeouts
- Validation — Validate configuration during Validate()
- Retry Logic — Handle transient failures gracefully
- Logging — Log all operations for debugging
- Testing — Provide unit and integration tests
- Documentation — Document configuration options and limitations
- Security — Never log sensitive data (tokens, keys, passwords)
Contributing Connectors
To contribute a connector to certctl:
- Fork the repository
- Create a feature branch:
git checkout -b feat/my-connector - Add connector implementation with tests
- Update README.md
- Add documentation to docs/
- 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
- API Reference
- Contributing Guidelines (coming soon)