mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 22:48:52 +00:00
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:
+252
-610
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user