Fix runtime bugs, implement service layer, and overhaul documentation

Runtime fixes:
- Fix env var mismatch (CERTCTL_DB_URL → CERTCTL_DATABASE_URL)
- Fix table name mismatches (certificates → managed_certificates, notifications → notification_events)
- Add renewal_policy_id to certificate queries
- Remove non-existent created_at from notification queries
- Add env var fallback for agent CLI flags
- Graceful degradation for missing notifiers/issuers in demo mode
- Copy web/ directory in Dockerfile for dashboard serving

Service layer:
- Implement handler-service interface pattern across all services
- Wire up certificate, agent, job, policy, team, owner, audit, notification services

Documentation:
- Add concepts.md: beginner-friendly guide to TLS, CAs, private keys
- Rewrite quickstart.md with accurate API examples matching actual handlers
- Add demo-advanced.md: interactive demo with cert issuance and automated script
- Update architecture.md with correct table names and connector interfaces
- Update connectors.md to match actual Go interface signatures
- Update demo-guide.md with cross-references to new docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-14 21:38:11 -04:00
parent 3a9fe8ba37
commit 9b4122b159
21 changed files with 1597 additions and 1591 deletions
+252 -610
View File
@@ -1,572 +1,339 @@
# Certctl Connector Development Guide
# 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.
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. **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)
1. **Issuer Connector** — Obtains certificates from CAs (ACME, Local CA, Vault, DigiCert)
2. **Target Connector** — Deploys certificates to infrastructure (NGINX, F5, IIS)
3. **Notifier Connector** — Sends alerts 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
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.
---
## Issuer Connector
## IssuerConnector Interface
Issuer connectors obtain signed certificates from Certificate Authorities.
Issuers obtain certificates from external PKI systems.
### Interface Definition
### Interface
```go
// internal/connector/issuer/interface.go
package issuer
type IssuerConnector interface {
// Validate checks the issuer configuration and connectivity
Validate(ctx context.Context) error
type Connector interface {
// ValidateConfig checks that the issuer configuration is valid
ValidateConfig(ctx context.Context, config json.RawMessage) error
// IssueCertificate requests a certificate for the given domains
IssueCertificate(ctx context.Context, req *IssueRequest) (*CertificateResponse, error)
// IssueCertificate submits a CSR and returns a signed certificate
IssueCertificate(ctx context.Context, request IssuanceRequest) (*IssuanceResult, error)
// RevokeCertificate revokes an issued certificate
RevokeCertificate(ctx context.Context, certPEM []byte) error
// RenewCertificate renews an existing certificate
RenewCertificate(ctx context.Context, request RenewalRequest) (*IssuanceResult, error)
// GetStatus returns the status of an issuance request
GetStatus(ctx context.Context, requestID string) (*StatusResponse, 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 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 IssuanceRequest struct {
CommonName string
SANs []string
CSRPEM 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
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
}
```
### Example: Vault PKI Issuer
### Built-in: Local CA
The Local CA issuer generates self-signed certificates using Go's `crypto/x509` library. It creates a CA on first use (in memory), issues certificates with proper serial numbers, validity periods, SANs, and key usage extensions.
This issuer is designed for development and demos only — certificates are self-signed and not trusted by browsers.
Configuration:
```json
{
"ca_common_name": "CertCtl Local CA",
"validity_days": 90
}
```
Location: `internal/connector/issuer/local/local.go`
### Building a Custom Issuer
Here's the structure for a HashiCorp Vault PKI issuer:
```go
package vault
import (
"context"
"crypto/x509"
"encoding/pem"
"encoding/json"
"fmt"
"github.com/hashicorp/vault/api"
vaultapi "github.com/hashicorp/vault/api"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
type VaultConfig struct {
Address string
Token string
PKIPath string // e.g., "pki"
RoleName string // e.g., "example-dot-com"
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 *VaultConfig
client *api.Client
config *Config
client *vaultapi.Client
}
func New(cfg *VaultConfig) (*VaultIssuer, error) {
client, err := api.NewClient(&api.Config{Address: cfg.Address})
func New(cfg *Config) (*VaultIssuer, error) {
client, err := vaultapi.NewClient(&vaultapi.Config{Address: cfg.Address})
if err != nil {
return nil, err
return nil, fmt.Errorf("vault client: %w", 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)
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
}
// 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(),
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,
})
return err
}
if err != nil {
return nil, fmt.Errorf("vault sign: %w", 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(),
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
```
### Registration
## Target Connector
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
Target connectors deploy certificates to infrastructure systems. They run on agents, not on the control plane.
### Interface
```go
// internal/connector/target/interface.go
package target
type TargetConnector interface {
// Validate tests connectivity and credentials
Validate(ctx context.Context) error
type Connector interface {
// ValidateConfig checks target configuration
ValidateConfig(ctx context.Context, config json.RawMessage) error
// Deploy pushes the certificate to the target
Deploy(ctx context.Context, req *DeployRequest) (*DeployResponse, error)
// DeployCertificate pushes a certificate to the target system
DeployCertificate(ctx context.Context, request DeploymentRequest) (*DeploymentResult, 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)
// ValidateDeployment verifies a certificate was deployed correctly
ValidateDeployment(ctx context.Context, request ValidationRequest) (*ValidationResult, 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 DeploymentRequest struct {
CertPEM string // Signed certificate (PEM)
ChainPEM string // CA chain (PEM)
TargetConfig json.RawMessage // Target-specific config
Metadata map[string]string
// NOTE: No private key — agents handle keys locally
}
type DeployResponse struct {
RequestID string
Status string // "success", "pending", "error"
Message string
DeployedAt time.Time
type DeploymentResult struct {
Success bool
TargetAddress string
DeploymentID string
Message string
DeployedAt time.Time
Metadata map[string]string
}
type StatusResponse struct {
Status string // "deployed", "pending", "failed"
DeployedAt time.Time
Error 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
}
```
### Example: Custom Load Balancer Target
### Built-in: NGINX
The NGINX connector writes certificate and chain files to disk, validates the NGINX configuration, and reloads the server.
Configuration:
```json
{
"cert_path": "/etc/nginx/certs/cert.pem",
"chain_path": "/etc/nginx/certs/chain.pem",
"reload_command": "systemctl reload nginx",
"validate_command": "nginx -t"
}
```
The connector writes cert and chain files with mode 0644, runs the validation command first (so a bad config doesn't take down NGINX), and only reloads if validation passes.
Location: `internal/connector/target/nginx/nginx.go`
### Built-in: F5 BIG-IP
Deploys certificates via the F5 REST API. Uploads the certificate and key, then updates virtual server SSL profiles.
Location: `internal/connector/target/f5/f5.go`
### Built-in: IIS
Deploys certificates to Microsoft IIS via WinRM. Imports the certificate into the Windows certificate store and binds it to an IIS site.
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:
```go
package lb
// internal/service/notification.go
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
type Notifier interface {
Send(ctx context.Context, recipient string, subject string, body string) error
Channel() string
}
```
---
## NotifierConnector Interface
Notifiers send alerts about certificate events.
### Interface Definition
The connector layer has a richer interface:
```go
package notifier
// internal/connector/notifier/interface.go
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
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
}
```
### Example: Slack Notifier
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`:
```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
// For issuers
issuerRegistry := map[string]service.IssuerConnector{
"local": localCAConnector,
"acme": acmeConnector,
"vault": vaultConnector, // your new issuer
}
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
// For notifiers
notifierRegistry := map[string]service.Notifier{
"Email": emailNotifier,
"Webhook": webhookNotifier,
"Slack": slackNotifier, // your new notifier
}
```
---
## Testing Connectors
### Unit Tests
```go
package vault
import (
"context"
"testing"
)
func TestValidate(t *testing.T) {
cfg := &VaultConfig{
Address: "http://localhost:8200",
Token: "test-token",
func TestNginxDeploy(t *testing.T) {
cfg := &nginx.Config{
CertPath: "/tmp/test-cert.pem",
ChainPath: "/tmp/test-chain.pem",
ReloadCommand: "echo reloaded",
ValidateCommand: "echo valid",
}
issuer := New(cfg)
connector := nginx.New(cfg, slog.Default())
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)
result, err := connector.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: testCertPEM,
ChainPEM: testChainPEM,
})
if err != nil {
t.Fatalf("issuance failed: %v", err)
t.Fatalf("deploy failed: %v", err)
}
if len(resp.Certificate) == 0 {
t.Fatal("no certificate returned")
if !result.Success {
t.Fatal("expected success")
}
}
```
@@ -575,152 +342,27 @@ func TestIssueCertificate(t *testing.T) {
```bash
# Start dependent service
docker run -d --name vault -p 8200:8200 vault:latest server -dev
docker run -d --name nginx -p 8080:80 nginx:latest
# Run tests
go test -tags=integration ./internal/connector/issuer/vault
go test -tags=integration ./internal/connector/target/nginx/
# Cleanup
docker rm -f vault
docker rm -f nginx
```
### 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)
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
## 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)
- [Architecture Guide](architecture.md) — Understanding the full system design
- [Quick Start](quickstart.md) — Get certctl running locally
- [Advanced Demo](demo-advanced.md) — See the full certificate lifecycle in action