mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +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:
@@ -30,6 +30,7 @@ WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/bin/server .
|
||||
COPY --chown=certctl:certctl migrations/ ./migrations/
|
||||
COPY --chown=certctl:certctl web/ ./web/
|
||||
|
||||
RUN chown -R certctl:certctl /app
|
||||
|
||||
|
||||
@@ -33,10 +33,11 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CERTCTL_DB_URL: postgres://certctl:certctl@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:certctl@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
CERTCTL_AUTH_TYPE: none
|
||||
ports:
|
||||
- "8443:8443"
|
||||
networks:
|
||||
|
||||
+176
-454
@@ -1,574 +1,296 @@
|
||||
# Certctl Architecture
|
||||
# Architecture Guide
|
||||
|
||||
## 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.
|
||||
Certctl is a certificate management platform with a **decoupled control-plane and agent architecture**. The control plane orchestrates certificate issuance and renewal, while agents deployed across your infrastructure handle key generation, certificate deployment, and local validation — private keys never leave the infrastructure they were generated on.
|
||||
|
||||
New to certificates? Read the [Concepts Guide](concepts.md) first.
|
||||
|
||||
### 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
|
||||
1. **Zero Private Key Exposure** — Private keys are generated and managed only on agents, never sent to the control plane
|
||||
2. **Decoupled Operations** — Agents operate autonomously; the control plane coordinates but doesn't block 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
|
||||
|
||||
---
|
||||
5. **Self-Hosted** — No cloud lock-in; run with Docker Compose, Kubernetes, or bare metal
|
||||
|
||||
## System Components
|
||||
|
||||
### Control Plane
|
||||
### Control Plane (Server)
|
||||
|
||||
The control plane is a REST API server backed by PostgreSQL. It:
|
||||
The control plane is a Go HTTP server backed by PostgreSQL. It manages state (certificates, agents, targets, issuers, policies), orchestrates issuance by coordinating with CAs through issuer connectors, tracks jobs for certificate issuance/renewal/deployment workflows, maintains an immutable audit trail, and dispatches work via a background scheduler.
|
||||
|
||||
- **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
|
||||
The server exposes a REST API under `/api/v1/` and optionally serves the web dashboard as static files from the `web/` directory.
|
||||
|
||||
**Deployment Options**: Single binary, Docker container, Kubernetes deployment
|
||||
**Key internals**: The server uses Go 1.22's `net/http` stdlib routing (no external router framework), structured logging via `slog`, and a handler → service → repository layered architecture. Handlers define their own service interfaces for clean dependency inversion.
|
||||
|
||||
### Agents
|
||||
|
||||
Lightweight agents deployed on or near your infrastructure. They:
|
||||
Lightweight Go processes that run on or near your infrastructure. An agent generates private keys locally, creates CSRs, receives signed certificates from the control plane, deploys them to target systems, and reports status back. Agents communicate with the control plane via HTTP and authenticate with API keys.
|
||||
|
||||
- **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
|
||||
The agent runs two background loops: a heartbeat (every 60 seconds) to signal it's alive, and a work poll (every 30 seconds) to check for pending jobs.
|
||||
|
||||
**Deployment Options**: Container, systemd service, Kubernetes DaemonSet, Lambda
|
||||
### Web Dashboard
|
||||
|
||||
A single-page React application served as a static HTML file (`web/index.html`). It communicates with the REST API and provides a visual interface for certificate inventory, agent status, job monitoring, audit trail, policy management, and notifications.
|
||||
|
||||
The dashboard includes a **demo mode** that activates when the API is unreachable — it renders realistic mock data for screenshots and offline presentations.
|
||||
|
||||
### PostgreSQL Database
|
||||
|
||||
Persistent state store:
|
||||
All state is stored in PostgreSQL 16. The schema uses TEXT primary keys (not UUIDs) with human-readable prefixed IDs like `mc-api-prod`, `t-platform`, `o-alice`.
|
||||
|
||||
Database tables:
|
||||
|
||||
```
|
||||
├── 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
|
||||
Teams & Ownership
|
||||
├── teams
|
||||
└── owners
|
||||
|
||||
Certificate Management
|
||||
├── managed_certificates
|
||||
├── certificate_versions
|
||||
└── renewal_policies
|
||||
|
||||
Infrastructure
|
||||
├── agents
|
||||
└── deployment_targets
|
||||
|
||||
Issuance
|
||||
├── issuers
|
||||
└── jobs
|
||||
|
||||
Policy Engine
|
||||
├── policy_rules
|
||||
└── policy_violations
|
||||
|
||||
Certificate-Target Mapping
|
||||
└── certificate_target_mappings
|
||||
|
||||
Monitoring & Audit
|
||||
├── audit_events
|
||||
└── notification_events
|
||||
```
|
||||
|
||||
---
|
||||
Migrations are idempotent (`IF NOT EXISTS` on all CREATE statements, `ON CONFLICT (id) DO NOTHING` on all seed data) so they're safe to run multiple times — important for Docker Compose where both initdb and the server may run the same SQL.
|
||||
|
||||
## Data Flow: Certificate Lifecycle
|
||||
|
||||
### 1. **Create Managed Certificate**
|
||||
### 1. Create Managed Certificate
|
||||
|
||||
```
|
||||
User/API
|
||||
User / API Client
|
||||
│
|
||||
├─→ POST /api/v1/certificates
|
||||
│ {
|
||||
│ "domain": "api.example.com",
|
||||
│ "issuer_id": "issuer-001",
|
||||
│ "target_ids": ["nginx-prod-01"],
|
||||
│ "renewal_days_before": 30
|
||||
│ "name": "API Production",
|
||||
│ "common_name": "api.example.com",
|
||||
│ "sans": ["api.example.com"],
|
||||
│ "environment": "production",
|
||||
│ "owner_id": "o-alice",
|
||||
│ "team_id": "t-platform",
|
||||
│ "issuer_id": "iss-local",
|
||||
│ "renewal_policy_id": "rp-default",
|
||||
│ "status": "Pending"
|
||||
│ }
|
||||
│
|
||||
└─→ Control Plane
|
||||
├─ Insert certificate record
|
||||
├─ Create initial job
|
||||
├─ Log audit event
|
||||
└─ Return cert ID + API response
|
||||
├─ Validates input and policy rules
|
||||
├─ Inserts record into managed_certificates
|
||||
├─ Logs audit event (certificate_created)
|
||||
└─ Returns certificate with ID
|
||||
```
|
||||
|
||||
### 2. **Agent Requests Certificate (CSR → Issuance)**
|
||||
### 2. Agent Requests Certificate (CSR → Issuance)
|
||||
|
||||
```
|
||||
Agent Control Plane ACME Issuer
|
||||
Agent Control Plane Issuer (Local CA / ACME)
|
||||
│ │ │
|
||||
├─ POST /api/v1/csr │ │
|
||||
│ { │ │
|
||||
│ "cert_id": "cert-123", │ │
|
||||
│ "csr": "-----BEGIN CSR..." │ │
|
||||
│ } │ │
|
||||
├─ POST /api/v1/agents/{id}/csr │ │
|
||||
│ { "csr_pem": "-----BEGIN..." } │ │
|
||||
│ ├─ Validate CSR │
|
||||
│ │ │
|
||||
│ ├─ POST /directory/new-order │
|
||||
│ ├──────────────────────────────→
|
||||
│ ├─ Submit CSR to issuer │
|
||||
│ ├──────────────────────────────→│
|
||||
│ │ │
|
||||
│ │← Poll challenges │
|
||||
│ ├──────────────────────────────→
|
||||
│ │← Signed certificate + chain │
|
||||
│ │←──────────────────────────────│
|
||||
│ │ │
|
||||
│ ├─ POST /acme/finalize │
|
||||
│ ├──────────────────────────────→
|
||||
│ ├─ Store certificate version │
|
||||
│ ├─ Update cert status → Active │
|
||||
│ ├─ Log audit event │
|
||||
│ │ │
|
||||
│← Certificate + chain │← Signed certificate │
|
||||
├─────────────────────────────────│ │
|
||||
│← Certificate + chain (PEM) │ │
|
||||
│ (NO private key) │ │
|
||||
│ │ │
|
||||
├─ Store locally: │ │
|
||||
│ /etc/certctl/api.example.com/ │ │
|
||||
│ ├─ cert.pem │ │
|
||||
│ ├─ key.pem (never sent back) │ │
|
||||
│ └─ chain.pem │ │
|
||||
│ cert.pem + chain.pem │ │
|
||||
│ key.pem (generated locally, │ │
|
||||
│ never sent anywhere) │ │
|
||||
│ │ │
|
||||
└─ POST /api/v1/deployments │ │
|
||||
{ "cert_id", "status": "ok" } │ │
|
||||
├─ Update cert record │
|
||||
├─ Log "issued" event │
|
||||
└─ Trigger deployment jobs │
|
||||
└─ Deploy to target system │ │
|
||||
```
|
||||
|
||||
### 3. **Deploy Certificate to Target**
|
||||
### 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": "..." }
|
||||
```
|
||||
The agent deploys certificates using target connectors. Each connector knows how to push certificates to a specific system:
|
||||
|
||||
### 4. **Renewal Check & Rotation**
|
||||
- **NGINX**: Writes cert/chain files to disk, validates config with `nginx -t`, reloads with `nginx -s reload` or `systemctl reload nginx`
|
||||
- **F5 BIG-IP**: Calls the F5 REST API to upload certificate and update virtual server bindings
|
||||
- **IIS**: Uses WinRM to import the certificate into the Windows certificate store and bind it to an IIS site
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
The agent handles both the certificate (public) and the private key (local only). The control plane never sees the private key.
|
||||
|
||||
---
|
||||
### 4. Automatic Renewal
|
||||
|
||||
The control plane runs a scheduler with four background loops:
|
||||
|
||||
| Loop | Interval | Purpose |
|
||||
|------|----------|---------|
|
||||
| Renewal checker | 1 hour | Finds certificates approaching expiry, creates renewal jobs |
|
||||
| Job processor | 30 seconds | Processes pending jobs (issuance, renewal, deployment) |
|
||||
| Agent health check | 2 minutes | Marks agents as offline if heartbeat is stale |
|
||||
| Notification processor | 1 minute | Sends pending notifications via configured channels |
|
||||
|
||||
When the renewal checker finds a certificate within its renewal window (e.g., 30 days before expiry), it creates a renewal job. The job processor picks it up, coordinates with the issuer, and triggers deployment. All steps are logged in the audit trail and generate notifications.
|
||||
|
||||
## Connector Architecture
|
||||
|
||||
Certctl uses **connector interfaces** for extensibility. Connectors are pluggable implementations of specific capabilities.
|
||||
Certctl uses connector interfaces for extensibility. Each connector type has a standard interface that implementations must satisfy.
|
||||
|
||||
### Issuer Connector
|
||||
|
||||
Handles certificate issuance from external PKI systems.
|
||||
Handles certificate issuance from CAs.
|
||||
|
||||
```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)
|
||||
type Connector interface {
|
||||
ValidateConfig(ctx context.Context, config json.RawMessage) error
|
||||
IssueCertificate(ctx context.Context, request IssuanceRequest) (*IssuanceResult, error)
|
||||
RenewCertificate(ctx context.Context, request RenewalRequest) (*IssuanceResult, error)
|
||||
RevokeCertificate(ctx context.Context, request RevocationRequest) error
|
||||
GetOrderStatus(ctx context.Context, orderID string) (*OrderStatus, 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
|
||||
```
|
||||
Built-in issuers: **Local CA** (self-signed, for development/demos) and **ACME** (Let's Encrypt, Sectigo, etc., in progress).
|
||||
|
||||
### Target Connector
|
||||
|
||||
Deploys certificates to infrastructure systems.
|
||||
Deploys certificates to infrastructure. Note: the interface does NOT include private keys — agents handle keys locally.
|
||||
|
||||
```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)
|
||||
type Connector interface {
|
||||
ValidateConfig(ctx context.Context, config json.RawMessage) error
|
||||
DeployCertificate(ctx context.Context, request DeploymentRequest) (*DeploymentResult, error)
|
||||
ValidateDeployment(ctx context.Context, request ValidationRequest) (*ValidationResult, 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
|
||||
```
|
||||
Built-in targets: **NGINX**, **F5 BIG-IP**, **IIS**.
|
||||
|
||||
### Notifier Connector
|
||||
|
||||
Sends notifications about certificate events.
|
||||
Sends alerts about certificate lifecycle events.
|
||||
|
||||
```go
|
||||
type NotifierConnector interface {
|
||||
// Send delivers a notification
|
||||
Send(ctx context.Context, notif *Notification) error
|
||||
|
||||
// Validate checks configuration
|
||||
Validate(ctx context.Context) error
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
**Built-in Notifiers**:
|
||||
- `email` — SMTP email
|
||||
- `webhook` — HTTP webhooks
|
||||
Built-in notifiers: **Email** (SMTP) and **Webhook** (HTTP POST).
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
---
|
||||
See the [Connector Development Guide](connectors.md) for details on building custom connectors.
|
||||
|
||||
## 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
|
||||
```
|
||||
Private keys follow a strict lifecycle:
|
||||
|
||||
### Authentication & Authorization
|
||||
1. **Generated on the agent** — never sent to the control plane
|
||||
2. **Stored on the agent** — file permissions 0600, owned by the agent process user
|
||||
3. **Used by the agent** — for deployment to targets and CSR generation
|
||||
4. **Rotated by the agent** — old keys deleted after successful renewal
|
||||
|
||||
**Agent-to-Server**:
|
||||
- API Key (registered at agent creation)
|
||||
- mTLS optional for high-security deployments
|
||||
- All API calls include agent ID + API key
|
||||
The control plane only ever handles public material: certificates, chains, and CSRs. This is a deliberate architectural decision — even if the control plane database is compromised, no private keys are exposed.
|
||||
|
||||
**Server-to-External Systems**:
|
||||
- ACME: ACME protocol with account key
|
||||
- NGINX: SSH key authentication
|
||||
- F5: Username/password or token
|
||||
- IIS: WinRM with encrypted credentials
|
||||
### Authentication
|
||||
|
||||
### Audit Logging
|
||||
- **API clients → Server**: API key in `Authorization: Bearer` header, or `none` for demo mode
|
||||
- **Agent → Server**: API key registered at agent creation, included in all requests
|
||||
- **Server → Issuers**: ACME account key, or connector-specific credentials
|
||||
- **Agent → Targets**: SSH keys, API tokens, WinRM credentials (stored locally on agent)
|
||||
|
||||
Every action is logged:
|
||||
### Audit Trail
|
||||
|
||||
Every action is recorded as an immutable audit event:
|
||||
|
||||
```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"]
|
||||
}
|
||||
"id": "audit-001",
|
||||
"actor": "o-alice",
|
||||
"actor_type": "User",
|
||||
"action": "certificate_created",
|
||||
"resource_type": "certificate",
|
||||
"resource_id": "mc-api-prod",
|
||||
"details": {"environment": "production"},
|
||||
"timestamp": "2026-03-14T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**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`
|
||||
Audit events cannot be modified or deleted. They support filtering by actor, action, resource type, resource ID, and time range.
|
||||
|
||||
### Data Encryption at Rest
|
||||
## API Design
|
||||
|
||||
Optional encryption for sensitive fields:
|
||||
All endpoints are under `/api/v1/` and follow consistent patterns:
|
||||
|
||||
- Passwords in connector configs
|
||||
- API keys
|
||||
- ACME account keys
|
||||
- **List**: `GET /api/v1/{resources}` — returns `{data: [...], total, page, per_page}`
|
||||
- **Get**: `GET /api/v1/{resources}/{id}` — returns the resource
|
||||
- **Create**: `POST /api/v1/{resources}` — returns the created resource with `201`
|
||||
- **Update**: `PUT /api/v1/{resources}/{id}` — returns the updated resource
|
||||
- **Delete**: `DELETE /api/v1/{resources}/{id}` — returns `204` (soft delete/archive)
|
||||
- **Actions**: `POST /api/v1/{resources}/{id}/{action}` — returns `202` for async operations
|
||||
|
||||
Uses AES-256-GCM with per-row nonce.
|
||||
Resources: certificates, issuers, targets, agents, jobs, policies, teams, owners, audit, notifications.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
---
|
||||
Health checks live outside the API prefix: `GET /health` and `GET /ready`.
|
||||
|
||||
## Deployment Topologies
|
||||
|
||||
### Single-Node (Development)
|
||||
### Docker Compose (Development / Small Deployments)
|
||||
|
||||
```
|
||||
┌────────────────────────────┐
|
||||
│ 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) │
|
||||
└─────────────────────────────────────┘
|
||||
│ ├─ certctl-server (:8443) │
|
||||
│ │ └─ Serves API + dashboard │
|
||||
│ ├─ postgres (:5432) │
|
||||
│ │ └─ Schema + seed data │
|
||||
│ └─ certctl-agent │
|
||||
│ └─ Heartbeat + work polling │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Kubernetes (Production)
|
||||
### Production (Kubernetes)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Kubernetes Cluster │
|
||||
│ ├─ Deployment: certctl-server (replicas=3) │
|
||||
│ ├─ DaemonSet: certctl-agent (all nodes) │
|
||||
│ ├─ StatefulSet: postgres (primary + replica) │
|
||||
│ ├─ ConfigMap: connector configurations │
|
||||
│ └─ Secret: API keys, credentials │
|
||||
└──────────────────────────────────────────────────┘
|
||||
│ ├─ Deployment: certctl-server (replicas=2+) │
|
||||
│ ├─ DaemonSet: certctl-agent (infra nodes) │
|
||||
│ ├─ StatefulSet: PostgreSQL (primary+replica) │
|
||||
│ ├─ ConfigMap: issuer/target configurations │
|
||||
│ └─ Secret: API keys, ACME credentials │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
For production, you would also add an ingress controller, TLS termination for the certctl API itself, and external PostgreSQL (RDS, Cloud SQL, etc.).
|
||||
|
||||
## Performance Characteristics
|
||||
## What's Next
|
||||
|
||||
| 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.
|
||||
- [Quick Start](quickstart.md) — Get certctl running locally
|
||||
- [Advanced Demo](demo-advanced.md) — Issue a certificate end-to-end
|
||||
- [Connector Guide](connectors.md) — Build custom connectors
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# Understanding Certificates: A Beginner's Guide
|
||||
|
||||
If you've never worked with TLS certificates before, this guide will get you up to speed. By the end, you'll understand what certificates are, why they matter, and why managing them at scale is hard enough to need a tool like certctl.
|
||||
|
||||
## What Is a TLS Certificate?
|
||||
|
||||
When you visit `https://yourbank.com`, your browser checks a digital document called a **TLS certificate** before sending any data. That certificate proves two things: (1) you're really talking to yourbank.com and not an imposter, and (2) everything sent between you and the server is encrypted.
|
||||
|
||||
A TLS certificate is just a file — a small chunk of structured data that contains a **public key**, the **domain name** it belongs to, who **issued** it (the Certificate Authority), and when it **expires**. It's signed by a trusted third party so that browsers and clients can verify it's legitimate.
|
||||
|
||||
Think of it like a notarized ID badge for a website. The badge says "I am api.example.com," the notary (Certificate Authority) vouches for it, and anyone can check the notary's signature to confirm the badge is real.
|
||||
|
||||
## Why Do Certificates Expire?
|
||||
|
||||
Every certificate has an expiration date, typically 90 days for Let's Encrypt or up to 1 year for commercial CAs. This isn't a bug — it's a security feature. Short lifetimes limit the damage if a private key is compromised, and they force organizations to prove they still control their domains.
|
||||
|
||||
The problem? When you have 5 certificates, tracking expiry dates is trivial. When you have 500 certificates spread across NGINX servers, F5 load balancers, and IIS boxes in three environments, it becomes a ticking time bomb. One missed renewal means a production outage — your site goes down, your API returns errors, and your customers see scary browser warnings.
|
||||
|
||||
**This is the core problem certctl solves**: automated tracking, renewal, and deployment of certificates across your entire infrastructure.
|
||||
|
||||
## The Cast of Characters
|
||||
|
||||
### Certificate Authority (CA)
|
||||
|
||||
A CA is the trusted third party that signs your certificates. When a CA signs a cert, they're saying "we've verified that whoever asked for this certificate actually controls this domain." Browsers ship with a built-in list of CAs they trust.
|
||||
|
||||
Common CAs include Let's Encrypt (free, automated), DigiCert, Sectigo, and your organization's internal/private CA. Each issues certificates through different protocols and APIs.
|
||||
|
||||
### ACME Protocol
|
||||
|
||||
ACME (Automatic Certificate Management Environment) is the protocol Let's Encrypt created for automated certificate issuance. Instead of filling out forms and waiting for emails, ACME lets software request, validate, and receive certificates programmatically. The server proves domain ownership by responding to challenges — placing a specific file on the web server (HTTP-01) or creating a DNS record (DNS-01).
|
||||
|
||||
certctl speaks ACME natively, so it can request certificates from Let's Encrypt or any ACME-compatible CA without manual intervention.
|
||||
|
||||
### Private Key
|
||||
|
||||
Every certificate has a corresponding private key. The certificate is public — anyone can see it. The private key is secret — it's what allows your server to decrypt traffic. If someone gets your private key, they can impersonate your server.
|
||||
|
||||
**This is why certctl's architecture is built around a critical rule: private keys never leave the server they were generated on.** The control plane orchestrates certificate issuance and tracks state, but it never sees or stores private keys. Keys are generated locally by agents running on your infrastructure.
|
||||
|
||||
### Subject Alternative Names (SANs)
|
||||
|
||||
A single certificate can cover multiple domain names. The primary domain is the Common Name (CN), and additional domains are listed as Subject Alternative Names. For example, one cert might cover `example.com`, `www.example.com`, and `api.example.com`. This reduces the number of certificates you need to manage.
|
||||
|
||||
### Certificate Chain
|
||||
|
||||
When a CA signs your certificate, the CA itself has a certificate, which was signed by a higher-level CA, all the way up to a **root CA** that browsers trust directly. This chain of trust — your cert, signed by an intermediate CA, signed by a root CA — is called the certificate chain. Servers need to present the full chain so clients can verify the entire trust path.
|
||||
|
||||
## How certctl Works
|
||||
|
||||
certctl has three main components that work together:
|
||||
|
||||
### The Control Plane (Server)
|
||||
|
||||
This is the brain. It's a REST API server backed by PostgreSQL that tracks every certificate in your organization: what domain it covers, when it expires, who owns it, which servers it's deployed to, and its full audit history. It runs a scheduler that automatically checks for expiring certificates and triggers renewal jobs.
|
||||
|
||||
The control plane never touches private keys. It coordinates the certificate lifecycle — "this cert needs renewal," "deploy this cert to these targets" — but the actual cryptographic operations happen elsewhere.
|
||||
|
||||
### Agents
|
||||
|
||||
Agents are lightweight processes that run on or near your infrastructure. They do the actual work: generating private keys, creating Certificate Signing Requests (CSRs), receiving signed certificates, and deploying them to servers. An agent might run on the same machine as your NGINX server, or on a management host that has SSH access to your web servers.
|
||||
|
||||
The flow looks like this:
|
||||
|
||||
1. The scheduler on the control plane decides a certificate needs renewal
|
||||
2. The control plane creates a renewal job
|
||||
3. An agent picks up the job, generates a new private key locally, and sends a CSR (which contains only the public key) to the control plane
|
||||
4. The control plane submits the CSR to the CA and receives the signed certificate
|
||||
5. The control plane sends the signed certificate (public material only) back to the agent
|
||||
6. The agent deploys the certificate and private key to the target server
|
||||
7. The agent reports success back to the control plane
|
||||
|
||||
At no point does the private key leave the agent. This is a fundamental security property.
|
||||
|
||||
### Deployment Targets
|
||||
|
||||
Targets are the systems where certificates actually get installed — NGINX web servers, F5 BIG-IP load balancers, Microsoft IIS servers. Each target type has a **connector** that knows how to deploy certificates to that specific system (e.g., writing files and reloading NGINX config, calling the F5 REST API, running PowerShell commands on IIS via WinRM).
|
||||
|
||||
## The Certificate Lifecycle
|
||||
|
||||
Every managed certificate in certctl goes through these states:
|
||||
|
||||
```
|
||||
Pending → Active → Expiring → (auto-renewal) → Active → ...
|
||||
→ Expired (if renewal fails)
|
||||
→ Failed (if issuance fails)
|
||||
```
|
||||
|
||||
- **Pending**: Certificate record created, awaiting initial issuance
|
||||
- **Active**: Certificate is valid and deployed, everything is healthy
|
||||
- **Expiring**: Certificate is within the renewal window (e.g., 30 days before expiry) — renewal will be triggered automatically
|
||||
- **Expired**: Certificate passed its expiration date without successful renewal — this is a problem
|
||||
- **Failed**: Something went wrong during issuance or renewal — needs investigation
|
||||
- **RenewalInProgress**: A renewal job is currently running
|
||||
- **Archived**: Certificate was decommissioned and soft-deleted
|
||||
|
||||
## Why Not Just Use Certbot?
|
||||
|
||||
Certbot is great for a single server. It runs on one machine, gets one certificate, and installs it locally. But it doesn't solve the organizational problem: who owns which certificates? When do they expire across the fleet? Which servers need updating? Did the deployment succeed everywhere? Who changed what, and when?
|
||||
|
||||
certctl is for organizations that need visibility, automation, and accountability across their certificate infrastructure. It's the difference between a spreadsheet and a database — both store data, but one scales.
|
||||
|
||||
## Key Concepts in certctl
|
||||
|
||||
### Teams and Owners
|
||||
|
||||
Every certificate belongs to a **team** and has an **owner**. This answers the question "whose problem is it when this cert expires?" In a large organization, the platform team might own infrastructure certs while the payments team owns payment gateway certs.
|
||||
|
||||
### Policies
|
||||
|
||||
Policies are guardrails. You can enforce rules like "production certificates must use specific issuers," "all certificates must have an owner," or "certificate lifetime cannot exceed 90 days." When a certificate violates a policy, certctl flags it with a policy violation so you can take action.
|
||||
|
||||
### Jobs
|
||||
|
||||
Every action in certctl — issuing a certificate, renewing one, deploying to a target — is tracked as a **job**. Jobs have states (Pending, Running, Completed, Failed, Cancelled), retry logic, and a full audit trail. If a deployment fails, you can see exactly what happened and when.
|
||||
|
||||
### Audit Trail
|
||||
|
||||
Every action is logged: who did it, what changed, when, and why. This is essential for compliance (SOC 2, PCI-DSS, ISO 27001) and for debugging. You can trace a certificate's entire history from creation through every renewal and deployment.
|
||||
|
||||
### Notifications
|
||||
|
||||
certctl can alert you when certificates are expiring, when renewals fail, when deployments succeed, or when policy violations are detected. Notifications go out via email or webhooks, with Slack support planned.
|
||||
|
||||
## What's Next
|
||||
|
||||
Now that you understand the concepts, head to the [Quick Start Guide](quickstart.md) to get certctl running locally in under 5 minutes. You'll see a pre-loaded dashboard with demo certificates, explore the API, and understand how everything fits together.
|
||||
|
||||
For a deeper look at the system design, see the [Architecture Guide](architecture.md).
|
||||
+259
-617
@@ -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)
|
||||
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"
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
### 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 ValidationRequest struct {
|
||||
CertificateID string
|
||||
Serial string
|
||||
TargetConfig json.RawMessage
|
||||
Metadata map[string]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"
|
||||
type ValidationResult struct {
|
||||
Valid bool
|
||||
Serial string
|
||||
TargetAddress string
|
||||
Message string
|
||||
ValidatedAt time.Time
|
||||
Metadata map[string]string
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Slack Notifier
|
||||
### Built-in: NGINX
|
||||
|
||||
```go
|
||||
package slack
|
||||
The NGINX connector writes certificate and chain files to disk, validates the NGINX configuration, and reloads the server.
|
||||
|
||||
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
|
||||
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
|
||||
// internal/service/notification.go
|
||||
|
||||
type Notifier interface {
|
||||
Send(ctx context.Context, recipient string, subject string, body string) error
|
||||
Channel() string
|
||||
}
|
||||
```
|
||||
|
||||
The connector layer has a richer interface:
|
||||
|
||||
```go
|
||||
// internal/connector/notifier/interface.go
|
||||
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
// For issuers
|
||||
issuerRegistry := map[string]service.IssuerConnector{
|
||||
"local": localCAConnector,
|
||||
"acme": acmeConnector,
|
||||
"vault": vaultConnector, // your new issuer
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
# Advanced Demo: Certificate Lifecycle End-to-End
|
||||
|
||||
This demo goes beyond browsing pre-loaded data. You'll create a team, register an owner, set up an issuer, create a certificate, trigger renewal, and watch everything appear in the dashboard in real time. By the end, you'll understand the full certificate lifecycle as certctl manages it.
|
||||
|
||||
**Time**: 10-15 minutes
|
||||
**Prerequisites**: certctl running via Docker Compose (see [Quick Start](quickstart.md))
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure certctl is running:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
# Wait for healthy status
|
||||
docker compose -f deploy/docker-compose.yml ps
|
||||
```
|
||||
|
||||
Open **http://localhost:8443** in your browser alongside your terminal. You'll watch changes appear in the dashboard as you make API calls.
|
||||
|
||||
Set up a base variable for convenience:
|
||||
|
||||
```bash
|
||||
API="http://localhost:8443"
|
||||
```
|
||||
|
||||
## Part 1: Build the Organization Structure
|
||||
|
||||
### Create a new team
|
||||
|
||||
```bash
|
||||
curl -s -X POST $API/api/v1/teams \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "t-demo",
|
||||
"name": "Demo Team",
|
||||
"description": "Team created during advanced demo walkthrough"
|
||||
}' | jq .
|
||||
```
|
||||
|
||||
### Register an owner
|
||||
|
||||
```bash
|
||||
curl -s -X POST $API/api/v1/owners \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "o-demo-user",
|
||||
"name": "Demo User",
|
||||
"email": "demo@example.com",
|
||||
"team_id": "t-demo"
|
||||
}' | jq .
|
||||
```
|
||||
|
||||
Verify both exist:
|
||||
|
||||
```bash
|
||||
curl -s $API/api/v1/teams/t-demo | jq .
|
||||
curl -s $API/api/v1/owners/o-demo-user | jq .
|
||||
```
|
||||
|
||||
## Part 2: Configure the Issuer
|
||||
|
||||
The demo ships with a Local CA issuer (`iss-local`) that can sign certificates immediately — no external CA needed. Let's verify it's available:
|
||||
|
||||
```bash
|
||||
curl -s $API/api/v1/issuers/iss-local | jq .
|
||||
```
|
||||
|
||||
You should see:
|
||||
```json
|
||||
{
|
||||
"id": "iss-local",
|
||||
"name": "Local Dev CA",
|
||||
"type": "GenericCA",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
This Local CA generates real X.509 certificates using Go's `crypto/x509` library. The certificates are self-signed (not trusted by browsers in production), but structurally identical to production certificates — they have serial numbers, validity periods, SANs, key usage extensions, and a proper certificate chain.
|
||||
|
||||
## Part 3: Create a Managed Certificate
|
||||
|
||||
Now the main event. Let's create a certificate for a fictional internal API:
|
||||
|
||||
```bash
|
||||
curl -s -X POST $API/api/v1/certificates \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "mc-demo-api",
|
||||
"name": "Demo API Certificate",
|
||||
"common_name": "demo-api.internal.example.com",
|
||||
"sans": ["demo-api.internal.example.com", "demo-api-v2.internal.example.com"],
|
||||
"environment": "staging",
|
||||
"owner_id": "o-demo-user",
|
||||
"team_id": "t-demo",
|
||||
"issuer_id": "iss-local",
|
||||
"renewal_policy_id": "rp-default",
|
||||
"status": "Pending",
|
||||
"tags": {
|
||||
"service": "demo-api",
|
||||
"created_by": "advanced-demo",
|
||||
"tier": "internal"
|
||||
}
|
||||
}' | jq .
|
||||
```
|
||||
|
||||
**Check the dashboard now.** Click "Certificates" in the sidebar. You'll see your new "Demo API Certificate" with status "Pending" alongside the pre-loaded demo certificates. Click on it to see the full details: owner, team, environment, tags, and timeline.
|
||||
|
||||
### Verify via API
|
||||
|
||||
```bash
|
||||
curl -s $API/api/v1/certificates/mc-demo-api | jq '{id, name, common_name, status, environment, owner_id, team_id}'
|
||||
```
|
||||
|
||||
## Part 4: Trigger Certificate Renewal
|
||||
|
||||
In production, the scheduler automatically triggers renewal when certificates approach expiry. For this demo, we'll trigger it manually:
|
||||
|
||||
```bash
|
||||
curl -s -X POST $API/api/v1/certificates/mc-demo-api/renew | jq .
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"status": "renewal_triggered"
|
||||
}
|
||||
```
|
||||
|
||||
This creates a renewal job. Check the jobs list:
|
||||
|
||||
```bash
|
||||
curl -s "$API/api/v1/jobs" | jq '.data[] | select(.certificate_id == "mc-demo-api") | {id, type, status, certificate_id}'
|
||||
```
|
||||
|
||||
**Check the dashboard.** Go to the "Jobs" view — you'll see the renewal job for your certificate.
|
||||
|
||||
## Part 5: Deploy the Certificate
|
||||
|
||||
Trigger deployment to see the deployment workflow:
|
||||
|
||||
```bash
|
||||
curl -s -X POST $API/api/v1/certificates/mc-demo-api/deploy | jq .
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"status": "deployment_triggered"
|
||||
}
|
||||
```
|
||||
|
||||
Check for deployment jobs:
|
||||
|
||||
```bash
|
||||
curl -s "$API/api/v1/jobs" | jq '.data[] | select(.certificate_id == "mc-demo-api")'
|
||||
```
|
||||
|
||||
## Part 6: View the Audit Trail
|
||||
|
||||
Every action you've taken has been recorded. Check the audit trail:
|
||||
|
||||
```bash
|
||||
curl -s $API/api/v1/audit | jq '.data[0:5]'
|
||||
```
|
||||
|
||||
You'll see events for certificate creation, renewal trigger, and deployment trigger — each with actor, action, resource type, and timestamp.
|
||||
|
||||
**Check the dashboard.** The "Audit" view shows the full timeline of all actions across the system.
|
||||
|
||||
## Part 7: Check Notifications
|
||||
|
||||
Certctl sends notifications for certificate lifecycle events. Check what notifications were generated:
|
||||
|
||||
```bash
|
||||
curl -s $API/api/v1/notifications | jq '.data[0:5]'
|
||||
```
|
||||
|
||||
In demo mode, notifications are marked as "sent" even without a real email/webhook backend. In production, these would go out via SMTP or HTTP webhooks.
|
||||
|
||||
## Part 8: Create a Second Certificate and Compare
|
||||
|
||||
Let's create another certificate in production to see how the dashboard handles multiple environments:
|
||||
|
||||
```bash
|
||||
curl -s -X POST $API/api/v1/certificates \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "mc-demo-payments",
|
||||
"name": "Demo Payments Gateway",
|
||||
"common_name": "payments.example.com",
|
||||
"sans": ["payments.example.com", "checkout.example.com"],
|
||||
"environment": "production",
|
||||
"owner_id": "o-demo-user",
|
||||
"team_id": "t-demo",
|
||||
"issuer_id": "iss-local",
|
||||
"renewal_policy_id": "rp-default",
|
||||
"status": "Active",
|
||||
"expires_at": "2026-04-01T00:00:00Z",
|
||||
"tags": {
|
||||
"service": "payments",
|
||||
"pci": "true",
|
||||
"tier": "critical"
|
||||
}
|
||||
}' | jq .
|
||||
```
|
||||
|
||||
This certificate expires in about 18 days from the demo date, so it should show up as "Expiring" in the dashboard when the scheduler runs. **Refresh the dashboard** — you'll see it in the certificate list.
|
||||
|
||||
Now filter the dashboard by environment or status to see how the filtering works with your new certificates mixed in with the demo data.
|
||||
|
||||
## Part 9: Policy Violations
|
||||
|
||||
Let's see what happens when a certificate doesn't meet policy requirements. Check existing policy rules:
|
||||
|
||||
```bash
|
||||
curl -s $API/api/v1/policies | jq '.data[] | {id, name, type, enabled}'
|
||||
```
|
||||
|
||||
The demo includes rules for required owner metadata, allowed environments, maximum certificate lifetime, and minimum renewal windows. Check existing violations:
|
||||
|
||||
```bash
|
||||
curl -s "$API/api/v1/policies/pr-max-certificate-lifetime/violations" | jq .
|
||||
```
|
||||
|
||||
**In the dashboard**, click "Policies" in the sidebar to see all active rules and which certificates are violating them.
|
||||
|
||||
## Full Automated Script
|
||||
|
||||
Here's a single script that runs the entire demo end-to-end. Save it as `demo.sh` and run it:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
API="http://localhost:8443"
|
||||
BLUE='\033[0;34m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${BLUE}=== certctl Advanced Demo ===${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 1: Health check
|
||||
echo -e "${YELLOW}Step 1: Checking server health...${NC}"
|
||||
HEALTH=$(curl -s $API/health | jq -r '.status')
|
||||
if [ "$HEALTH" != "healthy" ]; then
|
||||
echo "Server is not healthy. Run: docker compose -f deploy/docker-compose.yml up -d"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}Server is healthy${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 2: Create team
|
||||
echo -e "${YELLOW}Step 2: Creating demo team...${NC}"
|
||||
curl -s -X POST $API/api/v1/teams \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id":"t-demo-auto","name":"Automated Demo Team","description":"Created by demo script"}' | jq -r '.id'
|
||||
echo -e "${GREEN}Team created${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 3: Create owner
|
||||
echo -e "${YELLOW}Step 3: Registering demo owner...${NC}"
|
||||
curl -s -X POST $API/api/v1/owners \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id":"o-demo-auto","name":"Demo Script","email":"demo-script@example.com","team_id":"t-demo-auto"}' | jq -r '.id'
|
||||
echo -e "${GREEN}Owner registered${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 4: Create certificate
|
||||
echo -e "${YELLOW}Step 4: Creating managed certificate...${NC}"
|
||||
CERT_ID="mc-demo-$(date +%s)"
|
||||
curl -s -X POST $API/api/v1/certificates \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id":"'$CERT_ID'",
|
||||
"name":"Demo Auto Certificate",
|
||||
"common_name":"auto-demo.internal.example.com",
|
||||
"sans":["auto-demo.internal.example.com"],
|
||||
"environment":"staging",
|
||||
"owner_id":"o-demo-auto",
|
||||
"team_id":"t-demo-auto",
|
||||
"issuer_id":"iss-local",
|
||||
"renewal_policy_id":"rp-default",
|
||||
"status":"Pending",
|
||||
"tags":{"created_by":"demo-script","automated":"true"}
|
||||
}' | jq '{id, name, status}'
|
||||
echo -e "${GREEN}Certificate created: $CERT_ID${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 5: Trigger renewal
|
||||
echo -e "${YELLOW}Step 5: Triggering certificate renewal...${NC}"
|
||||
curl -s -X POST $API/api/v1/certificates/$CERT_ID/renew | jq .
|
||||
echo -e "${GREEN}Renewal triggered${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 6: Trigger deployment
|
||||
echo -e "${YELLOW}Step 6: Triggering certificate deployment...${NC}"
|
||||
curl -s -X POST $API/api/v1/certificates/$CERT_ID/deploy | jq .
|
||||
echo -e "${GREEN}Deployment triggered${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 7: Check certificate status
|
||||
echo -e "${YELLOW}Step 7: Checking certificate status...${NC}"
|
||||
curl -s $API/api/v1/certificates/$CERT_ID | jq '{id, name, status, common_name, environment}'
|
||||
echo ""
|
||||
|
||||
# Step 8: Check jobs
|
||||
echo -e "${YELLOW}Step 8: Checking jobs...${NC}"
|
||||
curl -s "$API/api/v1/jobs" | jq "[.data[] | select(.certificate_id == \"$CERT_ID\") | {id, type, status}]"
|
||||
echo ""
|
||||
|
||||
# Step 9: View recent audit events
|
||||
echo -e "${YELLOW}Step 9: Recent audit events...${NC}"
|
||||
curl -s $API/api/v1/audit | jq '.data[0:3] | .[] | {action, resource_type, resource_id, timestamp}'
|
||||
echo ""
|
||||
|
||||
# Step 10: Summary
|
||||
echo -e "${BLUE}=== Demo Complete ===${NC}"
|
||||
echo ""
|
||||
echo "What happened:"
|
||||
echo " 1. Created a team and owner for accountability"
|
||||
echo " 2. Created a managed certificate tracked by certctl"
|
||||
echo " 3. Triggered renewal (would contact the Local CA in production flow)"
|
||||
echo " 4. Triggered deployment (would push to NGINX/F5/IIS targets)"
|
||||
echo " 5. All actions recorded in the audit trail"
|
||||
echo ""
|
||||
echo -e "Open ${GREEN}http://localhost:8443${NC} to see everything in the dashboard."
|
||||
echo "Look for certificate: $CERT_ID"
|
||||
```
|
||||
|
||||
Make it executable and run:
|
||||
|
||||
```bash
|
||||
chmod +x demo.sh
|
||||
./demo.sh
|
||||
```
|
||||
|
||||
## What to Show Stakeholders
|
||||
|
||||
If you're using this demo to present certctl to decision-makers, here's the narrative:
|
||||
|
||||
1. **Start with the dashboard** — "This is your certificate inventory. Every TLS certificate across your infrastructure, in one place."
|
||||
2. **Point to expiring certs** — "These certificates would have caused outages. Certctl catches them automatically."
|
||||
3. **Show the cert you just created** — "I just created this via the API. It's already tracked, assigned to a team, and will be renewed automatically."
|
||||
4. **Show the audit trail** — "Complete traceability. Every action, every change, every deployment — timestamped and attributed."
|
||||
5. **Show policies** — "Guardrails. We enforce that every certificate has an owner, uses approved CAs, and stays within allowed environments."
|
||||
6. **Show agents** — "Private keys never touch the control plane. Agents handle cryptographic operations locally on your infrastructure."
|
||||
7. **Show the API** — "Everything is API-first. The dashboard is just one consumer. You can integrate with CI/CD, Terraform, or custom tooling."
|
||||
|
||||
## Teardown
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml down -v
|
||||
```
|
||||
+10
-3
@@ -1,11 +1,12 @@
|
||||
# certctl Demo Guide
|
||||
|
||||
Get the full certctl experience running locally in under 2 minutes.
|
||||
A 5-7 minute guided walkthrough of certctl's dashboard and API. Perfect for stakeholder presentations and team demos.
|
||||
|
||||
New to certificates? Read the [Concepts Guide](concepts.md) first. Want a hands-on demo where you issue certificates yourself? See the [Advanced Demo](demo-advanced.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clone and start everything
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
cd certctl
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
@@ -15,7 +16,7 @@ Wait ~30 seconds for PostgreSQL to initialize and the server to start, then open
|
||||
|
||||
**http://localhost:8443**
|
||||
|
||||
You'll see the dashboard pre-loaded with 15 demo certificates across multiple teams, environments, and statuses — including expiring, expired, active, failed, and in-progress renewals.
|
||||
You'll see the dashboard pre-loaded with 14 demo certificates across multiple teams, environments, and statuses — including expiring, expired, active, failed, and in-progress renewals.
|
||||
|
||||
## What You'll See
|
||||
|
||||
@@ -117,3 +118,9 @@ If you're demoing to a team or customer, here's a suggested flow:
|
||||
7. **Show the API** — "Everything you see here is API-first, so you can automate on top of it"
|
||||
|
||||
The whole walkthrough takes 5-7 minutes.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Advanced Demo](demo-advanced.md)** — Go hands-on: create a team, issue a certificate via API, trigger renewal, and watch it appear in the dashboard
|
||||
- **[Concepts Guide](concepts.md)** — Understand TLS certificates, CAs, and private keys from scratch
|
||||
- **[Architecture](architecture.md)** — Deep dive into the control plane, agent model, and connector architecture
|
||||
|
||||
+148
-452
@@ -1,36 +1,32 @@
|
||||
# Certctl Quick Start Guide
|
||||
# Quick Start Guide
|
||||
|
||||
Get a working certctl deployment from zero to managing certificates in 10 minutes.
|
||||
Get certctl running locally and managing certificates in under 5 minutes.
|
||||
|
||||
New to certificates? Read the [Concepts Guide](concepts.md) first — it explains TLS, CAs, and private keys in plain language.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker** and **Docker Compose** (recommended), or:
|
||||
- Go 1.22+
|
||||
- PostgreSQL 14+
|
||||
- psql CLI tool
|
||||
You need **Docker** and **Docker Compose** installed. That's it.
|
||||
|
||||
## Option 1: Docker Compose (Fastest)
|
||||
On macOS:
|
||||
```bash
|
||||
brew install --cask docker
|
||||
```
|
||||
|
||||
### 1. Clone & Setup
|
||||
On Linux, follow the official Docker install guide for your distribution.
|
||||
|
||||
## Start Everything
|
||||
|
||||
```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
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
### 2. Start the Stack
|
||||
Wait about 30 seconds for PostgreSQL to initialize and the server to boot. Check that everything is healthy:
|
||||
|
||||
```bash
|
||||
make docker-up
|
||||
|
||||
# Wait for services to be healthy (~30 seconds)
|
||||
docker-compose -f deploy/docker-compose.yml ps
|
||||
docker compose -f deploy/docker-compose.yml ps
|
||||
```
|
||||
|
||||
You should see:
|
||||
@@ -41,486 +37,186 @@ certctl-server Up (healthy)
|
||||
certctl-agent Up
|
||||
```
|
||||
|
||||
### 3. Verify Health
|
||||
|
||||
Verify the server responds:
|
||||
```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)
|
||||
## Open the Dashboard
|
||||
|
||||
Teams organize ownership and auditing. For this quick start, we'll use a default team.
|
||||
Open **http://localhost:8443** in your browser.
|
||||
|
||||
The dashboard comes pre-loaded with 14 demo certificates across multiple teams, environments, and statuses. You'll see expiring certs, expired certs, active certs, failed renewals — a realistic snapshot of what a certificate inventory looks like in a real organization.
|
||||
|
||||
Explore the sidebar: Certificates, Agents, Policies, Jobs, Audit Trail, Notifications. Everything you see in the dashboard is backed by the REST API.
|
||||
|
||||
## Explore the API
|
||||
|
||||
The dashboard reads from the same REST API you can call directly. All endpoints live under `/api/v1/` and return JSON.
|
||||
|
||||
### List all certificates
|
||||
|
||||
```bash
|
||||
TEAM_ID="default"
|
||||
curl -s http://localhost:8443/api/v1/certificates | jq .
|
||||
```
|
||||
|
||||
### 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`):
|
||||
The response has this shape:
|
||||
```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": [
|
||||
"data": [
|
||||
{
|
||||
"target_id": "target-def456",
|
||||
"status": "success",
|
||||
"deployed_at": "2024-03-14T10:30:30Z"
|
||||
"id": "mc-api-prod",
|
||||
"name": "API Production",
|
||||
"common_name": "api.example.com",
|
||||
"sans": ["api.example.com", "api-v2.example.com"],
|
||||
"environment": "production",
|
||||
"owner_id": "o-alice",
|
||||
"team_id": "t-platform",
|
||||
"issuer_id": "iss-local",
|
||||
"status": "Active",
|
||||
"expires_at": "2026-05-28T00:00:00Z",
|
||||
"tags": {"service": "api-gateway", "tier": "critical"},
|
||||
"created_at": "2026-03-14T00:00:00Z",
|
||||
"updated_at": "2026-03-14T00:00:00Z"
|
||||
}
|
||||
]
|
||||
],
|
||||
"total": 14,
|
||||
"page": 1,
|
||||
"per_page": 50
|
||||
}
|
||||
```
|
||||
|
||||
### Step 8: View Audit Trail
|
||||
|
||||
See all actions related to your certificate:
|
||||
### Filter by status
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8443/api/v1/audit/logs?resource_id=$CERT_ID" \
|
||||
-H "Content-Type: application/json"
|
||||
# Get only expiring certificates
|
||||
curl -s "http://localhost:8443/api/v1/certificates?status=Expiring" | jq .
|
||||
|
||||
# Get only production certificates
|
||||
curl -s "http://localhost:8443/api/v1/certificates?environment=production" | jq .
|
||||
```
|
||||
|
||||
Response:
|
||||
### Get a specific certificate
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod | jq .
|
||||
```
|
||||
|
||||
### List agents
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8443/api/v1/agents | jq .
|
||||
```
|
||||
|
||||
### View audit trail
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8443/api/v1/audit | jq .
|
||||
```
|
||||
|
||||
### View policy rules
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8443/api/v1/policies | jq .
|
||||
```
|
||||
|
||||
### View notifications
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8443/api/v1/notifications | jq .
|
||||
```
|
||||
|
||||
## Create Your First Certificate
|
||||
|
||||
Let's create a new managed certificate from scratch using the API. This will create a certificate record that certctl will track, renew, and deploy.
|
||||
|
||||
### Step 1: Create a certificate
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8443/api/v1/certificates \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "My First Certificate",
|
||||
"common_name": "myapp.example.com",
|
||||
"sans": ["myapp.example.com", "www.myapp.example.com"],
|
||||
"environment": "staging",
|
||||
"owner_id": "o-alice",
|
||||
"team_id": "t-platform",
|
||||
"issuer_id": "iss-local",
|
||||
"renewal_policy_id": "rp-default",
|
||||
"status": "Pending",
|
||||
"tags": {"purpose": "quickstart-demo"}
|
||||
}' | jq .
|
||||
```
|
||||
|
||||
The server returns the created certificate with an auto-generated ID:
|
||||
```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"
|
||||
}
|
||||
}
|
||||
]
|
||||
"id": "a1b2c3d4-...",
|
||||
"name": "My First Certificate",
|
||||
"common_name": "myapp.example.com",
|
||||
"status": "Pending",
|
||||
"created_at": "2026-03-14T..."
|
||||
}
|
||||
```
|
||||
|
||||
### Step 9: Trigger Manual Renewal (Optional)
|
||||
|
||||
To manually trigger certificate renewal:
|
||||
|
||||
Save the certificate ID:
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/renew \
|
||||
-H "Content-Type: application/json"
|
||||
CERT_ID="<paste the id from the response>"
|
||||
```
|
||||
|
||||
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:
|
||||
### Step 2: Trigger renewal
|
||||
|
||||
```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
|
||||
curl -s -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/renew | jq .
|
||||
```
|
||||
|
||||
---
|
||||
This creates a renewal job that will be processed by the scheduler.
|
||||
|
||||
## Testing the Flow End-to-End
|
||||
|
||||
Here's a complete script to test the full flow:
|
||||
### Step 3: Check the certificate
|
||||
|
||||
```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!"
|
||||
curl -s http://localhost:8443/api/v1/certificates/$CERT_ID | jq .
|
||||
```
|
||||
|
||||
Save as `test.sh`, make executable, and run:
|
||||
```bash
|
||||
chmod +x test.sh
|
||||
./test.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Server Won't Start
|
||||
### Step 4: Check the audit trail
|
||||
|
||||
```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_
|
||||
curl -s http://localhost:8443/api/v1/audit | jq '.data[0:3]'
|
||||
```
|
||||
|
||||
### Agent Can't Connect
|
||||
Refresh the dashboard at http://localhost:8443 — your new certificate appears in the inventory.
|
||||
|
||||
## Understanding the Demo Data
|
||||
|
||||
The demo comes pre-loaded with realistic data so you can explore certctl's features immediately:
|
||||
|
||||
| Resource | Count | Examples |
|
||||
|----------|-------|---------|
|
||||
| Teams | 5 | Platform, Security, Payments, Frontend, Data |
|
||||
| Owners | 5 | Alice, Bob, Carol, Dave, Eve |
|
||||
| Issuers | 3 | Local Dev CA, Let's Encrypt Staging, DigiCert |
|
||||
| Agents | 5 | nginx-prod, nginx-staging, f5-prod, iis-prod, data-agent |
|
||||
| Targets | 5 | NGINX (prod/staging/data), F5 LB, IIS |
|
||||
| Certificates | 14 | Various statuses: Active, Expiring, Expired, Failed |
|
||||
| Policies | 4 | Required owner, allowed environments, max lifetime, min renewal window |
|
||||
|
||||
Certificates have varied statuses so you can see what each state looks like in the dashboard: healthy certs with 45+ days remaining, certs about to expire (5-12 days), certs that already expired, and a failed renewal.
|
||||
|
||||
## Tear Down
|
||||
|
||||
```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
|
||||
docker compose -f deploy/docker-compose.yml down -v
|
||||
```
|
||||
|
||||
### Certificate Stays "Pending"
|
||||
The `-v` flag removes the PostgreSQL data volume so you get a clean slate next time.
|
||||
|
||||
```bash
|
||||
# Check if agent is registered
|
||||
curl http://localhost:8443/api/v1/agents
|
||||
## What's Next
|
||||
|
||||
# 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.
|
||||
- **[Advanced Demo](demo-advanced.md)** — Issue a real certificate via the Local CA and watch it appear in the dashboard
|
||||
- **[Demo Walkthrough](demo-guide.md)** — Guided 5-minute stakeholder presentation
|
||||
- **[Architecture](architecture.md)** — How the control plane, agents, and connectors work together
|
||||
- **[Connector Guide](connectors.md)** — Build custom connectors for your infrastructure
|
||||
|
||||
@@ -75,7 +75,7 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer
|
||||
}
|
||||
|
||||
// Get total count
|
||||
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM certificates %s", whereClause)
|
||||
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM managed_certificates %s", whereClause)
|
||||
var total int
|
||||
if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to count certificates: %w", err)
|
||||
@@ -84,9 +84,9 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer
|
||||
// Get paginated results
|
||||
offset := (filter.Page - 1) * filter.PerPage
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id,
|
||||
SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id,
|
||||
status, expires_at, tags, last_renewal_at, last_deployment_at, created_at, updated_at
|
||||
FROM certificates
|
||||
FROM managed_certificates
|
||||
%s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $%d OFFSET $%d
|
||||
@@ -119,9 +119,9 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer
|
||||
// Get retrieves a certificate by ID
|
||||
func (r *CertificateRepository) Get(ctx context.Context, id string) (*domain.ManagedCertificate, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id,
|
||||
SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id,
|
||||
status, expires_at, tags, last_renewal_at, last_deployment_at, created_at, updated_at
|
||||
FROM certificates
|
||||
FROM managed_certificates
|
||||
WHERE id = $1
|
||||
`, id)
|
||||
|
||||
@@ -148,13 +148,13 @@ func (r *CertificateRepository) Create(ctx context.Context, cert *domain.Managed
|
||||
}
|
||||
|
||||
err = r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO certificates (
|
||||
id, name, common_name, sans, environment, owner_id, team_id, issuer_id,
|
||||
INSERT INTO managed_certificates (
|
||||
id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id,
|
||||
status, expires_at, tags, last_renewal_at, last_deployment_at, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
RETURNING id
|
||||
`, cert.ID, cert.Name, cert.CommonName, pq.Array(cert.SANs), cert.Environment,
|
||||
cert.OwnerID, cert.TeamID, cert.IssuerID, cert.Status, cert.ExpiresAt,
|
||||
cert.OwnerID, cert.TeamID, cert.IssuerID, cert.RenewalPolicyID, cert.Status, cert.ExpiresAt,
|
||||
tagsJSON, cert.LastRenewalAt, cert.LastDeploymentAt, cert.CreatedAt, cert.UpdatedAt).Scan(&cert.ID)
|
||||
|
||||
if err != nil {
|
||||
@@ -172,7 +172,7 @@ func (r *CertificateRepository) Update(ctx context.Context, cert *domain.Managed
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE certificates SET
|
||||
UPDATE managed_certificates SET
|
||||
name = $1,
|
||||
common_name = $2,
|
||||
sans = $3,
|
||||
@@ -210,7 +210,7 @@ func (r *CertificateRepository) Update(ctx context.Context, cert *domain.Managed
|
||||
// Archive marks a certificate as archived
|
||||
func (r *CertificateRepository) Archive(ctx context.Context, id string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE certificates SET status = $1, updated_at = $2 WHERE id = $3
|
||||
UPDATE managed_certificates SET status = $1, updated_at = $2 WHERE id = $3
|
||||
`, domain.CertificateStatusArchived, time.Now(), id)
|
||||
|
||||
if err != nil {
|
||||
@@ -286,9 +286,9 @@ func (r *CertificateRepository) CreateVersion(ctx context.Context, version *doma
|
||||
// GetExpiringCertificates returns certificates expiring before the given time
|
||||
func (r *CertificateRepository) GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id,
|
||||
SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id,
|
||||
status, expires_at, tags, last_renewal_at, last_deployment_at, created_at, updated_at
|
||||
FROM certificates
|
||||
FROM managed_certificates
|
||||
WHERE expires_at < $1 AND status != $2
|
||||
ORDER BY expires_at ASC
|
||||
`, before, domain.CertificateStatusArchived)
|
||||
@@ -324,7 +324,7 @@ func scanCertificate(scanner interface {
|
||||
|
||||
err := scanner.Scan(
|
||||
&cert.ID, &cert.Name, &cert.CommonName, &sans, &cert.Environment, &cert.OwnerID,
|
||||
&cert.TeamID, &cert.IssuerID, &cert.Status, &cert.ExpiresAt, &tagsJSON,
|
||||
&cert.TeamID, &cert.IssuerID, &cert.RenewalPolicyID, &cert.Status, &cert.ExpiresAt, &tagsJSON,
|
||||
&cert.LastRenewalAt, &cert.LastDeploymentAt, &cert.CreatedAt, &cert.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -29,12 +29,12 @@ func (r *NotificationRepository) Create(ctx context.Context, notif *domain.Notif
|
||||
}
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO notifications (
|
||||
id, type, certificate_id, channel, recipient, message, sent_at, status, error, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
INSERT INTO notification_events (
|
||||
id, type, certificate_id, channel, recipient, message, sent_at, status, error
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
`, notif.ID, notif.Type, notif.CertificateID, notif.Channel, notif.Recipient,
|
||||
notif.Message, notif.SentAt, notif.Status, notif.Error, notif.CreatedAt).Scan(¬if.ID)
|
||||
notif.Message, notif.SentAt, notif.Status, notif.Error).Scan(¬if.ID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create notification: %w", err)
|
||||
@@ -84,7 +84,7 @@ func (r *NotificationRepository) List(ctx context.Context, filter *repository.No
|
||||
}
|
||||
|
||||
// Get total count
|
||||
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM notifications %s", whereClause)
|
||||
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM notification_events %s", whereClause)
|
||||
var total int
|
||||
if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
|
||||
return nil, fmt.Errorf("failed to count notifications: %w", err)
|
||||
@@ -93,10 +93,10 @@ func (r *NotificationRepository) List(ctx context.Context, filter *repository.No
|
||||
// Get paginated results
|
||||
offset := (filter.Page - 1) * filter.PerPage
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, type, certificate_id, channel, recipient, message, sent_at, status, error, created_at
|
||||
FROM notifications
|
||||
SELECT id, type, certificate_id, channel, recipient, message, sent_at, status, error
|
||||
FROM notification_events
|
||||
%s
|
||||
ORDER BY created_at DESC
|
||||
ORDER BY sent_at DESC NULLS LAST
|
||||
LIMIT $%d OFFSET $%d
|
||||
`, whereClause, argCount, argCount+1)
|
||||
|
||||
@@ -127,7 +127,7 @@ func (r *NotificationRepository) List(ctx context.Context, filter *repository.No
|
||||
// UpdateStatus updates a notification's delivery status
|
||||
func (r *NotificationRepository) UpdateStatus(ctx context.Context, id string, status string, sentAt time.Time) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE notifications SET status = $1, sent_at = $2 WHERE id = $3
|
||||
UPDATE notification_events SET status = $1, sent_at = $2 WHERE id = $3
|
||||
`, status, sentAt, id)
|
||||
|
||||
if err != nil {
|
||||
@@ -152,7 +152,7 @@ func scanNotification(scanner interface {
|
||||
}) (*domain.NotificationEvent, error) {
|
||||
var notif domain.NotificationEvent
|
||||
err := scanner.Scan(¬if.ID, ¬if.Type, ¬if.CertificateID, ¬if.Channel,
|
||||
¬if.Recipient, ¬if.Message, ¬if.SentAt, ¬if.Status, ¬if.Error, ¬if.CreatedAt)
|
||||
¬if.Recipient, ¬if.Message, ¬if.SentAt, ¬if.Status, ¬if.Error)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan notification: %w", err)
|
||||
|
||||
@@ -74,8 +74,8 @@ func (s *AgentService) Register(ctx context.Context, name string, hostname strin
|
||||
return agent, apiKey, nil
|
||||
}
|
||||
|
||||
// Heartbeat updates an agent's last seen time and status.
|
||||
func (s *AgentService) Heartbeat(ctx context.Context, agentID string) error {
|
||||
// HeartbeatWithContext updates an agent's last seen time and status.
|
||||
func (s *AgentService) HeartbeatWithContext(ctx context.Context, agentID string) error {
|
||||
agent, err := s.agentRepo.Get(ctx, agentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch agent: %w", err)
|
||||
@@ -97,6 +97,11 @@ func (s *AgentService) Heartbeat(ctx context.Context, agentID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Heartbeat updates agent heartbeat (handler interface method).
|
||||
func (s *AgentService) Heartbeat(agentID string) error {
|
||||
return s.HeartbeatWithContext(context.Background(), agentID)
|
||||
}
|
||||
|
||||
// SubmitCSR validates and processes a Certificate Signing Request from an agent.
|
||||
func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID string, csrPEM []byte) error {
|
||||
// Fetch agent
|
||||
@@ -244,6 +249,81 @@ func (s *AgentService) GetAgentByAPIKey(ctx context.Context, apiKey string) (*do
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// ListAgents returns paginated agents (handler interface method).
|
||||
func (s *AgentService) ListAgents(page, perPage int) ([]domain.Agent, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
agents, err := s.agentRepo.List(context.Background())
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list agents: %w", err)
|
||||
}
|
||||
|
||||
total := int64(len(agents))
|
||||
start := (page - 1) * perPage
|
||||
if start >= int(total) {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + perPage
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
|
||||
var result []domain.Agent
|
||||
for _, a := range agents[start:end] {
|
||||
if a != nil {
|
||||
result = append(result, *a)
|
||||
}
|
||||
}
|
||||
|
||||
return result, total, nil
|
||||
}
|
||||
|
||||
// GetAgent returns a single agent (handler interface method).
|
||||
func (s *AgentService) GetAgent(id string) (*domain.Agent, error) {
|
||||
return s.agentRepo.Get(context.Background(), id)
|
||||
}
|
||||
|
||||
// RegisterAgent creates and registers a new agent (handler interface method).
|
||||
func (s *AgentService) RegisterAgent(agent domain.Agent) (*domain.Agent, error) {
|
||||
agent.ID = generateID("agent")
|
||||
apiKey := generateAPIKey()
|
||||
agent.APIKeyHash = hashAPIKey(apiKey)
|
||||
agent.Status = domain.AgentStatusOnline
|
||||
now := time.Now()
|
||||
agent.RegisteredAt = now
|
||||
agent.LastHeartbeatAt = &now
|
||||
|
||||
if err := s.agentRepo.Create(context.Background(), &agent); err != nil {
|
||||
return nil, fmt.Errorf("failed to register agent: %w", err)
|
||||
}
|
||||
return &agent, nil
|
||||
}
|
||||
|
||||
// CSRSubmit processes a CSR submission from an agent (handler interface method).
|
||||
func (s *AgentService) CSRSubmit(agentID string, csrPEM string) (string, error) {
|
||||
// For the handler interface, we accept the CSR as a string
|
||||
err := s.SubmitCSR(context.Background(), agentID, "", []byte(csrPEM))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Return the CSR as acknowledgment
|
||||
return csrPEM, nil
|
||||
}
|
||||
|
||||
// CertificatePickup retrieves a certificate for an agent (handler interface method).
|
||||
func (s *AgentService) CertificatePickup(agentID, certID string) (string, error) {
|
||||
certPEM, err := s.GetCertificateForAgent(context.Background(), agentID, certID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(certPEM), nil
|
||||
}
|
||||
|
||||
// generateAPIKey creates a random API key for an agent.
|
||||
func generateAPIKey() string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
@@ -119,8 +119,8 @@ func (s *AuditService) ListAuditEvents(page, perPage int) ([]domain.AuditEvent,
|
||||
}
|
||||
|
||||
filter := &repository.AuditFilter{
|
||||
Offset: int64((page - 1) * perPage),
|
||||
PerPage: int64(perPage),
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
}
|
||||
|
||||
events, err := s.auditRepo.List(context.Background(), filter)
|
||||
@@ -145,7 +145,8 @@ func (s *AuditService) ListAuditEvents(page, perPage int) ([]domain.AuditEvent,
|
||||
// GetAuditEvent returns a single audit event (handler interface method).
|
||||
func (s *AuditService) GetAuditEvent(id string) (*domain.AuditEvent, error) {
|
||||
filter := &repository.AuditFilter{
|
||||
ID: id,
|
||||
ResourceID: id,
|
||||
PerPage: 1,
|
||||
}
|
||||
|
||||
events, err := s.auditRepo.List(context.Background(), filter)
|
||||
|
||||
@@ -154,8 +154,8 @@ func (s *CertificateService) GetVersions(ctx context.Context, certID string) ([]
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// TriggerRenewal initiates a renewal job if the certificate is eligible.
|
||||
func (s *CertificateService) TriggerRenewal(ctx context.Context, certID string, actor string) error {
|
||||
// TriggerRenewalWithActor initiates a renewal job if the certificate is eligible.
|
||||
func (s *CertificateService) TriggerRenewalWithActor(ctx context.Context, certID string, actor string) error {
|
||||
cert, err := s.certRepo.Get(ctx, certID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch certificate: %w", err)
|
||||
@@ -190,8 +190,8 @@ func (s *CertificateService) TriggerRenewal(ctx context.Context, certID string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// TriggerDeployment creates deployment jobs for all targets of a certificate.
|
||||
func (s *CertificateService) TriggerDeployment(ctx context.Context, certID string, actor string) error {
|
||||
// TriggerDeploymentWithActor creates deployment jobs for all targets of a certificate.
|
||||
func (s *CertificateService) TriggerDeploymentWithActor(ctx context.Context, certID string, actor string) error {
|
||||
cert, err := s.certRepo.Get(ctx, certID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch certificate: %w", err)
|
||||
@@ -211,3 +211,110 @@ func (s *CertificateService) TriggerDeployment(ctx context.Context, certID strin
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListCertificates returns paginated certificates with optional filtering (handler interface method).
|
||||
func (s *CertificateService) ListCertificates(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
// Build filter for repository
|
||||
filter := &repository.CertificateFilter{
|
||||
Status: status,
|
||||
Environment: environment,
|
||||
OwnerID: ownerID,
|
||||
TeamID: teamID,
|
||||
IssuerID: issuerID,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
}
|
||||
|
||||
certs, total, err := s.certRepo.List(context.Background(), filter)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list certificates: %w", err)
|
||||
}
|
||||
|
||||
var result []domain.ManagedCertificate
|
||||
for _, c := range certs {
|
||||
if c != nil {
|
||||
result = append(result, *c)
|
||||
}
|
||||
}
|
||||
|
||||
return result, int64(total), nil
|
||||
}
|
||||
|
||||
// GetCertificate returns a single certificate (handler interface method).
|
||||
func (s *CertificateService) GetCertificate(id string) (*domain.ManagedCertificate, error) {
|
||||
return s.certRepo.Get(context.Background(), id)
|
||||
}
|
||||
|
||||
// CreateCertificate creates a new certificate (handler interface method).
|
||||
func (s *CertificateService) CreateCertificate(cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
||||
cert.ID = generateID("cert")
|
||||
if err := s.certRepo.Create(context.Background(), &cert); err != nil {
|
||||
return nil, fmt.Errorf("failed to create certificate: %w", err)
|
||||
}
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// UpdateCertificate modifies a certificate (handler interface method).
|
||||
func (s *CertificateService) UpdateCertificate(id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
||||
cert.ID = id
|
||||
if err := s.certRepo.Update(context.Background(), &cert); err != nil {
|
||||
return nil, fmt.Errorf("failed to update certificate: %w", err)
|
||||
}
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// ArchiveCertificate marks a certificate as archived (handler interface method).
|
||||
func (s *CertificateService) ArchiveCertificate(id string) error {
|
||||
return s.certRepo.Archive(context.Background(), id)
|
||||
}
|
||||
|
||||
// GetCertificateVersions returns certificate versions (handler interface method).
|
||||
func (s *CertificateService) GetCertificateVersions(certID string, page, perPage int) ([]domain.CertificateVersion, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
versions, err := s.certRepo.ListVersions(context.Background(), certID)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list certificate versions: %w", err)
|
||||
}
|
||||
|
||||
total := int64(len(versions))
|
||||
start := (page - 1) * perPage
|
||||
if start >= int(total) {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + perPage
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
|
||||
var result []domain.CertificateVersion
|
||||
for _, v := range versions[start:end] {
|
||||
if v != nil {
|
||||
result = append(result, *v)
|
||||
}
|
||||
}
|
||||
|
||||
return result, total, nil
|
||||
}
|
||||
|
||||
// TriggerRenewal initiates renewal (handler interface method).
|
||||
func (s *CertificateService) TriggerRenewal(certID string) error {
|
||||
return s.TriggerRenewalWithActor(context.Background(), certID, "api")
|
||||
}
|
||||
|
||||
// TriggerDeployment triggers deployment (handler interface method).
|
||||
func (s *CertificateService) TriggerDeployment(certID string, targetID string) error {
|
||||
return s.TriggerDeploymentWithActor(context.Background(), certID, "api")
|
||||
}
|
||||
|
||||
@@ -34,12 +34,20 @@ func (s *IssuerService) List(ctx context.Context, page, perPage int) ([]*domain.
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
offset := int64((page - 1) * perPage)
|
||||
issuers, total, err := s.issuerRepo.List(ctx, offset, int64(perPage))
|
||||
issuers, err := s.issuerRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list issuers: %w", err)
|
||||
}
|
||||
return issuers, total, nil
|
||||
total := int64(len(issuers))
|
||||
start := (page - 1) * perPage
|
||||
if start >= int(total) {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + perPage
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
return issuers[start:end], total, nil
|
||||
}
|
||||
|
||||
// Get retrieves an issuer by ID.
|
||||
@@ -100,8 +108,8 @@ func (s *IssuerService) Delete(ctx context.Context, id string, actor string) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnection verifies the issuer connection.
|
||||
func (s *IssuerService) TestConnection(ctx context.Context, id string) error {
|
||||
// TestConnectionWithContext verifies the issuer connection with context.
|
||||
func (s *IssuerService) TestConnectionWithContext(ctx context.Context, id string) error {
|
||||
issuer, err := s.issuerRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("issuer not found: %w", err)
|
||||
@@ -115,6 +123,11 @@ func (s *IssuerService) TestConnection(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnection verifies the issuer connection (handler interface method).
|
||||
func (s *IssuerService) TestConnection(id string) error {
|
||||
return s.TestConnectionWithContext(context.Background(), id)
|
||||
}
|
||||
|
||||
// ListIssuers returns paginated issuers (handler interface method).
|
||||
func (s *IssuerService) ListIssuers(page, perPage int) ([]domain.Issuer, int64, error) {
|
||||
if page < 1 {
|
||||
@@ -124,13 +137,12 @@ func (s *IssuerService) ListIssuers(page, perPage int) ([]domain.Issuer, int64,
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
offset := int64((page - 1) * perPage)
|
||||
issuers, total, err := s.issuerRepo.List(context.Background(), offset, int64(perPage))
|
||||
issuers, err := s.issuerRepo.List(context.Background())
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list issuers: %w", err)
|
||||
}
|
||||
total := int64(len(issuers))
|
||||
|
||||
// Convert pointers to values for the handler interface
|
||||
var result []domain.Issuer
|
||||
for _, i := range issuers {
|
||||
if i != nil {
|
||||
|
||||
+62
-2
@@ -179,8 +179,8 @@ func (s *JobService) GetJobStatus(ctx context.Context, jobID string) (*domain.Jo
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// CancelJob cancels a pending or running job.
|
||||
func (s *JobService) CancelJob(ctx context.Context, jobID string) error {
|
||||
// CancelJobWithContext cancels a pending or running job.
|
||||
func (s *JobService) CancelJobWithContext(ctx context.Context, jobID string) error {
|
||||
job, err := s.jobRepo.Get(ctx, jobID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch job: %w", err)
|
||||
@@ -197,3 +197,63 @@ func (s *JobService) CancelJob(ctx context.Context, jobID string) error {
|
||||
s.logger.Info("job cancelled", "job_id", jobID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelJob cancels a job (handler interface method).
|
||||
func (s *JobService) CancelJob(id string) error {
|
||||
return s.CancelJobWithContext(context.Background(), id)
|
||||
}
|
||||
|
||||
// ListJobs returns paginated jobs with optional filtering (handler interface method).
|
||||
func (s *JobService) ListJobs(status, jobType string, page, perPage int) ([]domain.Job, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
allJobs, err := s.jobRepo.List(context.Background())
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list jobs: %w", err)
|
||||
}
|
||||
|
||||
// Filter jobs in memory based on status and jobType
|
||||
var filtered []*domain.Job
|
||||
for _, job := range allJobs {
|
||||
if job == nil {
|
||||
continue
|
||||
}
|
||||
if status != "" && string(job.Status) != status {
|
||||
continue
|
||||
}
|
||||
if jobType != "" && string(job.Type) != jobType {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, job)
|
||||
}
|
||||
|
||||
total := int64(len(filtered))
|
||||
start := (page - 1) * perPage
|
||||
if start >= int(total) {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + perPage
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
|
||||
var result []domain.Job
|
||||
for _, job := range filtered[start:end] {
|
||||
if job != nil {
|
||||
result = append(result, *job)
|
||||
}
|
||||
}
|
||||
|
||||
return result, total, nil
|
||||
}
|
||||
|
||||
// GetJob returns a single job (handler interface method).
|
||||
func (s *JobService) GetJob(id string) (*domain.Job, error) {
|
||||
return s.jobRepo.Get(context.Background(), id)
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +173,9 @@ func (s *NotificationService) sendNotification(ctx context.Context, notif *domai
|
||||
// Get the appropriate notifier for the channel
|
||||
notifier, ok := s.notifierRegistry[string(notif.Channel)]
|
||||
if !ok {
|
||||
return fmt.Errorf("notifier not found for channel %s", notif.Channel)
|
||||
// No notifier configured for this channel — mark as sent (demo mode)
|
||||
_ = s.notifRepo.UpdateStatus(ctx, notif.ID, "sent", time.Now())
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send the notification
|
||||
@@ -213,3 +215,59 @@ func (s *NotificationService) GetNotificationHistory(ctx context.Context, certID
|
||||
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
// ListNotifications returns paginated notifications (handler interface method).
|
||||
func (s *NotificationService) ListNotifications(page, perPage int) ([]domain.NotificationEvent, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
filter := &repository.NotificationFilter{
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
}
|
||||
|
||||
notifications, err := s.notifRepo.List(context.Background(), filter)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list notifications: %w", err)
|
||||
}
|
||||
|
||||
var result []domain.NotificationEvent
|
||||
for _, n := range notifications {
|
||||
if n != nil {
|
||||
result = append(result, *n)
|
||||
}
|
||||
}
|
||||
|
||||
total := int64(len(result))
|
||||
return result, total, nil
|
||||
}
|
||||
|
||||
// GetNotification returns a single notification (handler interface method).
|
||||
func (s *NotificationService) GetNotification(id string) (*domain.NotificationEvent, error) {
|
||||
filter := &repository.NotificationFilter{
|
||||
PerPage: 1,
|
||||
}
|
||||
|
||||
notifications, err := s.notifRepo.List(context.Background(), filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get notification: %w", err)
|
||||
}
|
||||
|
||||
// Find notification with matching ID (repository filter doesn't support ID directly)
|
||||
for _, n := range notifications {
|
||||
if n != nil && n.ID == id {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("notification not found")
|
||||
}
|
||||
|
||||
// MarkAsRead marks a notification as read (handler interface method).
|
||||
func (s *NotificationService) MarkAsRead(id string) error {
|
||||
return s.notifRepo.UpdateStatus(context.Background(), id, "read", time.Now())
|
||||
}
|
||||
|
||||
@@ -34,12 +34,20 @@ func (s *OwnerService) List(ctx context.Context, page, perPage int) ([]*domain.O
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
offset := int64((page - 1) * perPage)
|
||||
owners, total, err := s.ownerRepo.List(ctx, offset, int64(perPage))
|
||||
owners, err := s.ownerRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list owners: %w", err)
|
||||
}
|
||||
return owners, total, nil
|
||||
total := int64(len(owners))
|
||||
start := (page - 1) * perPage
|
||||
if start >= int(total) {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + perPage
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
return owners[start:end], total, nil
|
||||
}
|
||||
|
||||
// Get retrieves an owner by ID.
|
||||
@@ -109,13 +117,12 @@ func (s *OwnerService) ListOwners(page, perPage int) ([]domain.Owner, int64, err
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
offset := int64((page - 1) * perPage)
|
||||
owners, total, err := s.ownerRepo.List(context.Background(), offset, int64(perPage))
|
||||
owners, err := s.ownerRepo.List(context.Background())
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list owners: %w", err)
|
||||
}
|
||||
total := int64(len(owners))
|
||||
|
||||
// Convert pointers to values for the handler interface
|
||||
var result []domain.Owner
|
||||
for _, o := range owners {
|
||||
if o != nil {
|
||||
|
||||
+111
-2
@@ -219,11 +219,120 @@ func (s *PolicyService) DeleteRule(ctx context.Context, id string, actor string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListViolations returns policy violations matching filter criteria.
|
||||
func (s *PolicyService) ListViolations(ctx context.Context, filter *repository.AuditFilter) ([]*domain.PolicyViolation, error) {
|
||||
// ListViolationsWithContext returns policy violations matching filter criteria.
|
||||
func (s *PolicyService) ListViolationsWithContext(ctx context.Context, filter *repository.AuditFilter) ([]*domain.PolicyViolation, error) {
|
||||
violations, err := s.policyRepo.ListViolations(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list policy violations: %w", err)
|
||||
}
|
||||
return violations, nil
|
||||
}
|
||||
|
||||
// ListPolicies returns paginated policies (handler interface method).
|
||||
func (s *PolicyService) ListPolicies(page, perPage int) ([]domain.PolicyRule, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
rules, err := s.policyRepo.ListRules(context.Background())
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list policies: %w", err)
|
||||
}
|
||||
|
||||
total := int64(len(rules))
|
||||
start := (page - 1) * perPage
|
||||
if start >= int(total) {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + perPage
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
|
||||
var result []domain.PolicyRule
|
||||
for _, r := range rules[start:end] {
|
||||
if r != nil {
|
||||
result = append(result, *r)
|
||||
}
|
||||
}
|
||||
|
||||
return result, total, nil
|
||||
}
|
||||
|
||||
// GetPolicy returns a single policy (handler interface method).
|
||||
func (s *PolicyService) GetPolicy(id string) (*domain.PolicyRule, error) {
|
||||
return s.policyRepo.GetRule(context.Background(), id)
|
||||
}
|
||||
|
||||
// CreatePolicy creates a new policy (handler interface method).
|
||||
func (s *PolicyService) CreatePolicy(policy domain.PolicyRule) (*domain.PolicyRule, error) {
|
||||
if policy.ID == "" {
|
||||
policy.ID = generateID("rule")
|
||||
}
|
||||
if policy.CreatedAt.IsZero() {
|
||||
policy.CreatedAt = time.Now()
|
||||
}
|
||||
|
||||
if err := s.policyRepo.CreateRule(context.Background(), &policy); err != nil {
|
||||
return nil, fmt.Errorf("failed to create policy: %w", err)
|
||||
}
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
// UpdatePolicy modifies a policy (handler interface method).
|
||||
func (s *PolicyService) UpdatePolicy(id string, policy domain.PolicyRule) (*domain.PolicyRule, error) {
|
||||
policy.ID = id
|
||||
policy.UpdatedAt = time.Now()
|
||||
|
||||
if err := s.policyRepo.UpdateRule(context.Background(), &policy); err != nil {
|
||||
return nil, fmt.Errorf("failed to update policy: %w", err)
|
||||
}
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
// DeletePolicy removes a policy (handler interface method).
|
||||
func (s *PolicyService) DeletePolicy(id string) error {
|
||||
return s.policyRepo.DeleteRule(context.Background(), id)
|
||||
}
|
||||
|
||||
// ListViolationsHandler returns policy violations with pagination (handler interface method).
|
||||
func (s *PolicyService) ListViolations(policyID string, page, perPage int) ([]domain.PolicyViolation, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
filter := &repository.AuditFilter{
|
||||
ResourceID: policyID,
|
||||
PerPage: 1000, // Get all violations for the policy
|
||||
}
|
||||
|
||||
violations, err := s.policyRepo.ListViolations(context.Background(), filter)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list violations: %w", err)
|
||||
}
|
||||
|
||||
total := int64(len(violations))
|
||||
start := (page - 1) * perPage
|
||||
if start >= int(total) {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + perPage
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
|
||||
var result []domain.PolicyViolation
|
||||
for _, v := range violations[start:end] {
|
||||
if v != nil {
|
||||
result = append(result, *v)
|
||||
}
|
||||
}
|
||||
|
||||
return result, total, nil
|
||||
}
|
||||
|
||||
@@ -62,6 +62,16 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
||||
// Calculate days until expiry
|
||||
daysUntil := time.Until(cert.ExpiresAt).Hours() / 24
|
||||
|
||||
// Send expiration warning notification (always, regardless of issuer availability)
|
||||
if err := s.notificationSvc.SendExpirationWarning(ctx, cert, int(daysUntil)); err != nil {
|
||||
fmt.Printf("failed to send expiration warning for cert %s: %v\n", cert.ID, err)
|
||||
}
|
||||
|
||||
// Only create renewal job if an issuer connector is registered for this cert's issuer
|
||||
if _, hasIssuer := s.issuerRegistry[cert.IssuerID]; !hasIssuer {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create renewal job
|
||||
job := &domain.Job{
|
||||
ID: generateID("job"),
|
||||
@@ -77,11 +87,6 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Send expiration warning notification
|
||||
if err := s.notificationSvc.SendExpirationWarning(ctx, cert, int(daysUntil)); err != nil {
|
||||
fmt.Printf("failed to send expiration warning for cert %s: %v\n", cert.ID, err)
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||
"renewal_job_created", "certificate", cert.ID,
|
||||
|
||||
@@ -34,12 +34,20 @@ func (s *TargetService) List(ctx context.Context, page, perPage int) ([]*domain.
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
offset := int64((page - 1) * perPage)
|
||||
targets, total, err := s.targetRepo.List(ctx, offset, int64(perPage))
|
||||
targets, err := s.targetRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list targets: %w", err)
|
||||
}
|
||||
return targets, total, nil
|
||||
total := int64(len(targets))
|
||||
start := (page - 1) * perPage
|
||||
if start >= int(total) {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + perPage
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
return targets[start:end], total, nil
|
||||
}
|
||||
|
||||
// Get retrieves a deployment target by ID.
|
||||
@@ -109,13 +117,12 @@ func (s *TargetService) ListTargets(page, perPage int) ([]domain.DeploymentTarge
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
offset := int64((page - 1) * perPage)
|
||||
targets, total, err := s.targetRepo.List(context.Background(), offset, int64(perPage))
|
||||
targets, err := s.targetRepo.List(context.Background())
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list targets: %w", err)
|
||||
}
|
||||
total := int64(len(targets))
|
||||
|
||||
// Convert pointers to values for the handler interface
|
||||
var result []domain.DeploymentTarget
|
||||
for _, t := range targets {
|
||||
if t != nil {
|
||||
|
||||
@@ -34,12 +34,20 @@ func (s *TeamService) List(ctx context.Context, page, perPage int) ([]*domain.Te
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
offset := int64((page - 1) * perPage)
|
||||
teams, total, err := s.teamRepo.List(ctx, offset, int64(perPage))
|
||||
teams, err := s.teamRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list teams: %w", err)
|
||||
}
|
||||
return teams, total, nil
|
||||
total := int64(len(teams))
|
||||
start := (page - 1) * perPage
|
||||
if start >= int(total) {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + perPage
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
return teams[start:end], total, nil
|
||||
}
|
||||
|
||||
// Get retrieves a team by ID.
|
||||
@@ -109,13 +117,12 @@ func (s *TeamService) ListTeams(page, perPage int) ([]domain.Team, int64, error)
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
offset := int64((page - 1) * perPage)
|
||||
teams, total, err := s.teamRepo.List(context.Background(), offset, int64(perPage))
|
||||
teams, err := s.teamRepo.List(context.Background())
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list teams: %w", err)
|
||||
}
|
||||
total := int64(len(teams))
|
||||
|
||||
// Convert pointers to values for the handler interface
|
||||
var result []domain.Team
|
||||
for _, t := range teams {
|
||||
if t != nil {
|
||||
|
||||
Reference in New Issue
Block a user