mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 22:38:51 +00:00
Initial scaffold: certificate control plane v0.1.0
This commit is contained in:
@@ -0,0 +1,726 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user