Initial scaffold: certificate control plane v0.1.0

This commit is contained in:
shankar0123
2026-03-14 08:22:17 -04:00
commit d395776a95
57 changed files with 9548 additions and 0 deletions
+574
View File
@@ -0,0 +1,574 @@
# Certctl Architecture
## Overview
Certctl is a certificate management platform with a **decoupled control-plane and agent architecture**. The control plane orchestrates certificate issuance and renewal, while stateless agents deployed across your infrastructure handle certificate generation, deployment, and renewal without exposing private keys to the control plane.
### Design Principles
1. **Zero Private Key Exposure** — Private keys generated and managed only on agents
2. **Decoupled Operations** — Agents operate autonomously; control plane is optional for agent function
3. **Audit-First** — Complete traceability of all issuance, deployment, and rotation events
4. **Connector Architecture** — Pluggable issuers, targets, and notifiers for extensibility
5. **Self-Hosted** — No cloud lock-in; run on Kubernetes, Docker, or bare metal
---
## System Components
### Control Plane
The control plane is a REST API server backed by PostgreSQL. It:
- **Manages state**: Certificates, agents, targets, issuers, policies
- **Orchestrates issuance**: Coordinates with ACME/PKI issuers
- **Tracks jobs**: Certificate issuance, renewal, and deployment workflows
- **Audits all actions**: Immutable audit trail for compliance
- **Dispatches work**: Schedules renewal checks and deployment jobs
**Deployment Options**: Single binary, Docker container, Kubernetes deployment
### Agents
Lightweight agents deployed on or near your infrastructure. They:
- **Generate certificates**: Create private keys and certificate requests
- **Deploy certificates**: Push certs to NGINX, F5, IIS, etc.
- **Manage credentials**: Store and rotate API keys with control plane
- **Report status**: Health checks and job completion status
- **Operate independently**: Continue functioning even if control plane is unreachable
**Deployment Options**: Container, systemd service, Kubernetes DaemonSet, Lambda
### PostgreSQL Database
Persistent state store:
```
├── Teams & Ownership
│ ├── teams
│ └── owners
├── Certificate Management
│ ├── certificates
│ ├── certificate_versions
│ └── renewal_policies
├── Infrastructure
│ ├── agents
│ ├── targets
│ └── target_connections
├── Issuance
│ ├── issuers
│ ├── jobs
│ └── job_steps
├── Monitoring & Audit
│ ├── audit_logs
│ ├── notifications
│ └── deployment_history
└── Configuration
├── agent_api_keys
└── connector_config
```
---
## Data Flow: Certificate Lifecycle
### 1. **Create Managed Certificate**
```
User/API
├─→ POST /api/v1/certificates
│ {
│ "domain": "api.example.com",
│ "issuer_id": "issuer-001",
│ "target_ids": ["nginx-prod-01"],
│ "renewal_days_before": 30
│ }
└─→ Control Plane
├─ Insert certificate record
├─ Create initial job
├─ Log audit event
└─ Return cert ID + API response
```
### 2. **Agent Requests Certificate (CSR → Issuance)**
```
Agent Control Plane ACME Issuer
│ │ │
├─ POST /api/v1/csr │ │
│ { │ │
│ "cert_id": "cert-123", │ │
│ "csr": "-----BEGIN CSR..." │ │
│ } │ │
│ ├─ Validate CSR │
│ │ │
│ ├─ POST /directory/new-order │
│ ├──────────────────────────────→
│ │ │
│ │← Poll challenges │
│ ├──────────────────────────────→
│ │ │
│ ├─ POST /acme/finalize │
│ ├──────────────────────────────→
│ │ │
│← Certificate + chain │← Signed certificate │
├─────────────────────────────────│ │
│ │ │
├─ Store locally: │ │
│ /etc/certctl/api.example.com/ │ │
│ ├─ cert.pem │ │
│ ├─ key.pem (never sent back) │ │
│ └─ chain.pem │ │
│ │ │
└─ POST /api/v1/deployments │ │
{ "cert_id", "status": "ok" } │ │
├─ Update cert record │
├─ Log "issued" event │
└─ Trigger deployment jobs │
```
### 3. **Deploy Certificate to Target**
```
Agent Target System
├─ Fetch target credentials from config
├─ Load certificate:
│ - /etc/certctl/api.example.com/cert.pem
│ - /etc/certctl/api.example.com/key.pem
├─ NGINX (SSH):
│ ├─ scp cert.pem → /etc/nginx/ssl/
│ ├─ scp key.pem → /etc/nginx/ssl/ (restricted perms)
│ ├─ ssh nginx -s reload
│ └─ Verify: curl https://api.example.com/health
├─ F5 (HTTPS API):
│ ├─ Authenticate with credentials
│ ├─ POST /mgmt/tm/ltm/cert {"name": "api.example.com", "cert": "..."}
│ ├─ PUT /mgmt/tm/ltm/virtual (update virtual server)
│ └─ Verify: F5 configuration updated
├─ IIS (WinRM):
│ ├─ Import cert to store: Import-PfxCertificate
│ ├─ Bind to site: Set-WebBinding
│ └─ Verify: Get-WebBinding
└─ Report deployment status:
POST /api/v1/deployments/{id}/status
{ "status": "success", "deployed_at": "..." }
```
### 4. **Renewal Check & Rotation**
```
Scheduler (Control Plane)
├─ Every hour: SELECT certificates WHERE expiry_date < NOW() + 30 days
├─ For each certificate:
│ │
│ ├─ Create renewal job
│ ├─ Notify agent(s)
│ │
│ └─ Agent flow:
│ ├─ Generate new CSR
│ ├─ Request new certificate
│ ├─ Deploy new cert to targets
│ ├─ Verify deployment
│ └─ Delete old private key from agent
├─ Log completion
└─ Notify via email/webhook
```
---
## Connector Architecture
Certctl uses **connector interfaces** for extensibility. Connectors are pluggable implementations of specific capabilities.
### Issuer Connector
Handles certificate issuance from external PKI systems.
```go
type IssuerConnector interface {
// GetDirectory returns the ACME directory
GetDirectory(ctx context.Context) (*ACMEDirectory, error)
// NewAccount registers a new account
NewAccount(ctx context.Context, email string) (*Account, error)
// NewOrder creates a new certificate order
NewOrder(ctx context.Context, identifiers []Identifier) (*Order, error)
// GetAuthorization retrieves challenge info
GetAuthorization(ctx context.Context, authURL string) (*Authorization, error)
// FinalizeOrder submits CSR and gets certificate
FinalizeOrder(ctx context.Context, orderURL, csr string) ([]byte, error)
}
```
**Built-in Issuers**:
- `acme` — ACME v2 protocol (Let's Encrypt, Sectigo, etc.)
**Example Usage**:
```yaml
issuer:
type: acme
config:
directory_url: https://acme-v02.api.letsencrypt.org/directory
email: admin@example.com
```
### Target Connector
Deploys certificates to infrastructure systems.
```go
type TargetConnector interface {
// Validate tests connectivity and credentials
Validate(ctx context.Context) error
// Deploy pushes certificate to target
Deploy(ctx context.Context, cert *Certificate) error
// Remove removes/revokes certificate from target
Remove(ctx context.Context, domain string) error
// GetStatus checks deployment status
GetStatus(ctx context.Context, domain string) (string, error)
}
```
**Built-in Targets**:
- `nginx` — NGINX via SSH
- `f5` — F5 BIG-IP via REST API
- `iis` — Microsoft IIS via WinRM
**Example Usage**:
```yaml
target:
type: nginx
config:
host: web01.prod.internal
ssh_user: deploy
ssh_key: /etc/certctl/keys/deploy.pem
cert_path: /etc/nginx/ssl
```
### Notifier Connector
Sends notifications about certificate events.
```go
type NotifierConnector interface {
// Send delivers a notification
Send(ctx context.Context, notif *Notification) error
// Validate checks configuration
Validate(ctx context.Context) error
}
```
**Built-in Notifiers**:
- `email` — SMTP email
- `webhook` — HTTP webhooks
**Example Usage**:
```yaml
notifier:
type: email
config:
smtp_host: smtp.example.com
smtp_port: 587
username: alerts@example.com
password: "***"
from_address: certctl@example.com
recipients:
- ops@example.com
- security@example.com
```
---
## Job Lifecycle & States
Jobs represent work to be done: certificate issuance, renewal, deployment, etc.
```
┌──────────┐
│ PENDING │ Job created, waiting to be processed
└────┬─────┘
┌──────────┐
│ RUNNING │ Job in progress (CSR generation, issuance, deployment)
└────┬─────┘
├─→ SUCCESS ──→ COMPLETED (job done, no errors)
├─→ FAILURE ──→ FAILED (error occurred, may retry)
└─→ CANCEL ───→ CANCELLED (user or scheduler cancelled)
Additional states:
• RETRY_WAIT — Backoff before retry
• ABANDONED — Max retries exceeded
```
### Job Steps
Complex jobs are broken into steps:
```
Issuance Job
├─ Step 1: Notify agent of CSR request
│ Status: COMPLETED
├─ Step 2: Wait for CSR from agent
│ Status: RUNNING (timeout: 5 min)
├─ Step 3: Submit to ACME issuer
│ Status: PENDING
├─ Step 4: Poll for certificate
│ Status: PENDING
└─ Step 5: Trigger deployment jobs
Status: PENDING
```
---
## Security Model
### Private Key Management
```
Private Key Lifecycle
├─ GENERATED on Agent (never sent to control plane)
│ └─ Location: /etc/certctl/domains/{domain}/key.pem
├─ STORED on Agent
│ ├─ File permissions: 0600 (agent user only)
│ └─ Encrypted at rest (optional, per deployment)
├─ USED on Agent for:
│ ├─ Deployment to targets
│ └─ Certificate renewal
└─ DELETED on Agent
├─ Old key deleted after successful renewal
└─ Manual revocation on agent removal
```
### Authentication & Authorization
**Agent-to-Server**:
- API Key (registered at agent creation)
- mTLS optional for high-security deployments
- All API calls include agent ID + API key
**Server-to-External Systems**:
- ACME: ACME protocol with account key
- NGINX: SSH key authentication
- F5: Username/password or token
- IIS: WinRM with encrypted credentials
### Audit Logging
Every action is logged:
```json
{
"id": "audit-98765",
"timestamp": "2024-03-14T10:30:00Z",
"actor": {
"type": "agent",
"id": "agent-prod-01"
},
"action": "certificate_issued",
"resource": {
"type": "certificate",
"id": "cert-api-example-com"
},
"status": "success",
"details": {
"issuer": "acme/letsencrypt",
"expiry": "2024-06-12T10:30:00Z",
"deployed_to": ["nginx-prod-01"]
}
}
```
**Query examples**:
- All actions by agent: `GET /audit/logs?actor_type=agent&actor_id=agent-001`
- All deployments: `GET /audit/logs?action=certificate_deployed`
- Last 30 days: `GET /audit/logs?from=2024-02-12`
### Data Encryption at Rest
Optional encryption for sensitive fields:
- Passwords in connector configs
- API keys
- ACME account keys
Uses AES-256-GCM with per-row nonce.
---
## Scaling Considerations
### Control Plane Scaling
**Single Server Limits**:
- ~1000 agents (verified in testing)
- ~10,000 managed certificates
- ~100,000 audit log entries per day
**Horizontal Scaling** (future):
- Multiple server instances behind load balancer
- Shared PostgreSQL backend
- Distributed job queue (Redis/RabbitMQ)
### Agent Scaling
Agents are stateless and scale horizontally:
- Each agent processes certificates independently
- Scheduler distributes renewal checks across agents
- No inter-agent communication required
### Database Scaling
For large deployments:
- Vertical scaling: More CPU/RAM for PostgreSQL
- Read replicas: For audit log queries
- Partitioning: Audit logs by date
- Connection pooling: PgBouncer
---
## Integration Points
### External Integrations
```
Certctl
├─→ ACME Servers
│ ├─ Let's Encrypt
│ ├─ Sectigo
│ └─ Internal ACME (optional)
├─→ Infrastructure Targets
│ ├─ NGINX (SSH)
│ ├─ F5 (REST API)
│ ├─ IIS (WinRM)
│ └─ Kubernetes (future)
├─→ Notification Systems
│ ├─ SMTP (email)
│ ├─ HTTP webhooks
│ └─ Slack (future)
└─→ External Systems
├─ Vault (credential storage)
├─ HashiCorp Consul (service discovery)
└─ Prometheus (metrics)
```
### Internal Component Communication
```
Agent ← → Control Plane
├─ Agent registration
├─ CSR submission
├─ Certificate retrieval
├─ Deployment status
└─ Health checks (bidirectional)
Scheduler → Services
├─ Certificate renewal
├─ Job processing
├─ Notifications
└─ Cleanup tasks
```
---
## Deployment Topologies
### Single-Node (Development)
```
┌────────────────────────────┐
│ Server + Agent │
│ ├─ HTTP API (8443) │
│ ├─ PostgreSQL │
│ └─ Agent (test mode) │
└────────────────────────────┘
```
### Docker Compose (Local Dev)
```
┌─────────────────────────────────────┐
│ Docker Network │
│ ├─ certctl-server (8443) │
│ ├─ postgres (5432) │
│ ├─ certctl-agent (managed) │
│ └─ pgadmin (5050, optional) │
└─────────────────────────────────────┘
```
### Kubernetes (Production)
```
┌──────────────────────────────────────────────────┐
│ Kubernetes Cluster │
│ ├─ Deployment: certctl-server (replicas=3) │
│ ├─ DaemonSet: certctl-agent (all nodes) │
│ ├─ StatefulSet: postgres (primary + replica) │
│ ├─ ConfigMap: connector configurations │
│ └─ Secret: API keys, credentials │
└──────────────────────────────────────────────────┘
```
---
## Performance Characteristics
| Operation | Typical Duration | Bottleneck |
|-----------|------------------|-----------|
| Certificate request (CSR) | 100-500ms | Agent network latency |
| ACME challenge (DNS) | 30-60s | DNS propagation |
| ACME finalize | 1-5s | ACME server |
| NGINX deployment | 500ms-2s | SSH latency + nginx reload |
| F5 deployment | 2-10s | F5 API response |
| IIS deployment | 3-15s | WinRM latency |
---
## Future Enhancements
- **HSM Support**: Hardware security module integration for ACME account keys
- **Multi-Region**: Control plane federation with local agents
- **HA Control Plane**: Active-active with etcd-backed state
- **Policy Engine**: Advanced renewal and deployment policies
- **Certificate Pinning**: HPKP and pin validation
- **Metrics**: Prometheus integration for observability
---
See [README.md](../README.md) for quick start and [docs/](../) for additional guides.
+726
View File
@@ -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)
+526
View File
@@ -0,0 +1,526 @@
# Certctl Quick Start Guide
Get a working certctl deployment from zero to managing certificates in 10 minutes.
## Prerequisites
- **Docker** and **Docker Compose** (recommended), or:
- Go 1.22+
- PostgreSQL 14+
- psql CLI tool
## Option 1: Docker Compose (Fastest)
### 1. Clone & Setup
```bash
git clone https://github.com/shankar0123/certctl.git
cd certctl
# Copy environment template
cp .env.example .env
# Optional: edit .env for custom settings
# nano .env
```
### 2. Start the Stack
```bash
make docker-up
# Wait for services to be healthy (~30 seconds)
docker-compose -f deploy/docker-compose.yml ps
```
You should see:
```
NAME STATUS
certctl-postgres Up (healthy)
certctl-server Up (healthy)
certctl-agent Up
```
### 3. Verify Health
```bash
# Server health check
curl http://localhost:8443/health
# Expected: {"status":"healthy"}
# Container logs
make docker-logs-server
```
---
## Option 2: Manual Build & Run
### 1. Clone & Dependencies
```bash
git clone https://github.com/shankar0123/certctl.git
cd certctl
go mod download
```
### 2. Setup PostgreSQL
```bash
# Create database and user
psql -U postgres -h localhost << EOF
CREATE USER certctl WITH PASSWORD 'certctl';
CREATE DATABASE certctl OWNER certctl;
GRANT ALL PRIVILEGES ON DATABASE certctl TO certctl;
EOF
# Verify connection
psql -h localhost -U certctl -d certctl -c "SELECT 1"
```
### 3. Run Migrations
```bash
# Install migrate tool
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
# Set database URL
export DB_URL="postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable"
# Run migrations
make migrate-up
```
### 4. Start Server
```bash
# Terminal 1: Server
make run
# Expected output:
# 2024-03-14T10:30:00Z server starting version=1.0.0 server_port=8443
```
### 5. Start Agent (Optional)
```bash
# Terminal 2: Agent
export SERVER_URL=http://localhost:8443
export API_KEY=default-api-key
./bin/agent
# Expected output: Agent connecting to http://localhost:8443
```
---
## Walk-Through: Create Your First Certificate
### Step 1: Verify API Access
```bash
curl -X GET http://localhost:8443/health
```
Response:
```json
{"status":"healthy"}
```
### Step 2: Create a Team (Optional)
Teams organize ownership and auditing. For this quick start, we'll use a default team.
```bash
TEAM_ID="default"
```
### Step 3: Register an ACME Issuer
Create a certificate issuer configuration (Let's Encrypt staging for this demo):
```bash
curl -X POST http://localhost:8443/api/v1/issuers \
-H "Content-Type: application/json" \
-d '{
"team_id": "default",
"name": "lets-encrypt-staging",
"type": "acme",
"config": {
"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory",
"email": "admin@example.com"
}
}'
```
Response (save the `issuer_id`):
```json
{
"id": "issuer-abc123",
"name": "lets-encrypt-staging",
"type": "acme",
"created_at": "2024-03-14T10:30:00Z"
}
```
Store the issuer ID:
```bash
ISSUER_ID="issuer-abc123"
```
### Step 4: Register an Agent
Agents handle certificate requests and deployment. Register one:
```bash
curl -X POST http://localhost:8443/api/v1/agents \
-H "Content-Type: application/json" \
-d '{
"team_id": "default",
"name": "quickstart-agent",
"description": "Local development agent"
}'
```
Response (save the `api_key` and `id`):
```json
{
"id": "agent-xyz789",
"name": "quickstart-agent",
"api_key": "ey...",
"registered_at": "2024-03-14T10:30:00Z",
"status": "registered"
}
```
Store the agent details:
```bash
AGENT_ID="agent-xyz789"
AGENT_API_KEY="ey..."
```
### Step 5: Create a Deployment Target
Targets are where certificates will be deployed (NGINX, F5, etc.). For this demo, we'll skip actual deployment:
```bash
curl -X POST http://localhost:8443/api/v1/targets \
-H "Content-Type: application/json" \
-d '{
"team_id": "default",
"agent_id": "'$AGENT_ID'",
"name": "example-nginx",
"type": "nginx",
"config": {
"host": "nginx.example.com",
"ssh_user": "deploy",
"ssh_key": "/path/to/key",
"cert_path": "/etc/nginx/ssl"
}
}'
```
Response:
```json
{
"id": "target-def456",
"name": "example-nginx",
"agent_id": "agent-xyz789",
"type": "nginx",
"status": "pending_validation"
}
```
Store the target ID:
```bash
TARGET_ID="target-def456"
```
### Step 6: Create a Managed Certificate
Now the main event—request a certificate to be issued and managed:
```bash
curl -X POST http://localhost:8443/api/v1/certificates \
-H "Content-Type: application/json" \
-d '{
"team_id": "default",
"domain": "api.example.com",
"issuer_id": "'$ISSUER_ID'",
"target_ids": ["'$TARGET_ID'"],
"renewal_days_before": 30,
"auto_deploy": true
}'
```
Response:
```json
{
"id": "cert-ghi012",
"domain": "api.example.com",
"issuer_id": "issuer-abc123",
"status": "pending",
"created_at": "2024-03-14T10:30:00Z",
"expires_at": null,
"renewal_at": null
}
```
Store the certificate ID:
```bash
CERT_ID="cert-ghi012"
```
### Step 7: Check Certificate Status
Poll the certificate status as issuance progresses:
```bash
curl -X GET http://localhost:8443/api/v1/certificates/$CERT_ID \
-H "Content-Type: application/json"
```
Response (will change over time):
```json
{
"id": "cert-ghi012",
"domain": "api.example.com",
"status": "issued",
"expires_at": "2024-06-12T10:30:00Z",
"issued_by": "issuer-abc123",
"deployed_to": [
{
"target_id": "target-def456",
"status": "success",
"deployed_at": "2024-03-14T10:30:30Z"
}
]
}
```
### Step 8: View Audit Trail
See all actions related to your certificate:
```bash
curl -X GET "http://localhost:8443/api/v1/audit/logs?resource_id=$CERT_ID" \
-H "Content-Type: application/json"
```
Response:
```json
{
"logs": [
{
"id": "audit-001",
"timestamp": "2024-03-14T10:30:00Z",
"actor": {
"type": "api",
"id": "client-001"
},
"action": "certificate_created",
"resource": {
"type": "certificate",
"id": "cert-ghi012"
},
"status": "success"
},
{
"id": "audit-002",
"timestamp": "2024-03-14T10:30:10Z",
"actor": {
"type": "agent",
"id": "agent-xyz789"
},
"action": "certificate_issued",
"resource": {
"type": "certificate",
"id": "cert-ghi012"
},
"status": "success",
"details": {
"issuer": "lets-encrypt-staging",
"expiry": "2024-06-12"
}
},
{
"id": "audit-003",
"timestamp": "2024-03-14T10:30:25Z",
"actor": {
"type": "system",
"id": "scheduler"
},
"action": "certificate_deployed",
"resource": {
"type": "certificate",
"id": "cert-ghi012"
},
"status": "success",
"details": {
"deployed_to": "example-nginx"
}
}
]
}
```
### Step 9: Trigger Manual Renewal (Optional)
To manually trigger certificate renewal:
```bash
curl -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/renew \
-H "Content-Type: application/json"
```
The scheduler will automatically check for renewals every hour. Certificates within 30 days of expiry are renewed automatically.
---
## Development Mode
For development with hot reload and database browser:
```bash
# Install tools
make install-tools
# Start dev stack (includes PgAdmin at localhost:5050)
make docker-up-dev
# View logs
make docker-logs-server
make docker-logs-agent
# Admin credentials for PgAdmin:
# Email: admin@example.com (default, see .env)
# Password: admin (default, see .env)
# Access PgAdmin: http://localhost:5050
# Add server: postgres, port 5432, user certctl, password certctl
```
---
## Testing the Flow End-to-End
Here's a complete script to test the full flow:
```bash
#!/bin/bash
set -e
API="http://localhost:8443"
TEAM="default"
echo "1. Creating ACME issuer..."
ISSUER=$(curl -s -X POST $API/api/v1/issuers \
-H "Content-Type: application/json" \
-d '{
"team_id": "'$TEAM'",
"name": "letsencrypt-staging",
"type": "acme",
"config": {
"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory",
"email": "test@example.com"
}
}' | jq -r '.id')
echo " Issuer: $ISSUER"
echo "2. Registering agent..."
AGENT=$(curl -s -X POST $API/api/v1/agents \
-H "Content-Type: application/json" \
-d '{
"team_id": "'$TEAM'",
"name": "test-agent"
}' | jq -r '.id')
echo " Agent: $AGENT"
echo "3. Creating certificate..."
CERT=$(curl -s -X POST $API/api/v1/certificates \
-H "Content-Type: application/json" \
-d '{
"team_id": "'$TEAM'",
"domain": "test-'$(date +%s)'.example.com",
"issuer_id": "'$ISSUER'",
"renewal_days_before": 30
}' | jq -r '.id')
echo " Certificate: $CERT"
echo "4. Checking status..."
curl -s -X GET $API/api/v1/certificates/$CERT | jq '.status'
echo "5. Viewing audit trail..."
curl -s -X GET "$API/api/v1/audit/logs?resource_id=$CERT" | jq '.logs | length'
echo "Done!"
```
Save as `test.sh`, make executable, and run:
```bash
chmod +x test.sh
./test.sh
```
---
## Common Issues
### Server Won't Start
```bash
# Check database connection
psql -h localhost -U certctl -d certctl -c "SELECT 1"
# View logs
make docker-logs-server
# Check environment
env | grep DB_
```
### Agent Can't Connect
```bash
# Verify server is running
curl http://localhost:8443/health
# Check agent logs
docker logs certctl-agent
# Verify API key is correct
echo $AGENT_API_KEY
```
### Certificate Stays "Pending"
```bash
# Check if agent is registered
curl http://localhost:8443/api/v1/agents
# Check agent logs for errors
make docker-logs-agent
# View certificate details
curl http://localhost:8443/api/v1/certificates/$CERT_ID
# Check audit trail
curl "http://localhost:8443/api/v1/audit/logs?resource_id=$CERT_ID"
```
---
## Next Steps
1. **Read** [docs/architecture.md](architecture.md) to understand the design
2. **Explore** the [API](../README.md#api-overview) for more operations
3. **Build** a [custom connector](connectors.md) for your infrastructure
4. **Deploy** to production using [docs/k8s-deployment.md](k8s-deployment.md) (coming soon)
---
For more help, see [README.md](../README.md#troubleshooting) or open an issue on GitHub.