mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:41:41 +00:00
727 lines
18 KiB
Markdown
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)
|