Initial scaffold: certificate control plane v0.1.0

This commit is contained in:
shankar0123
2026-03-14 08:22:17 -04:00
commit d395776a95
57 changed files with 9548 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
# Certctl Configuration Example
# Copy this file to .env and configure for your environment
# DO NOT commit .env with real secrets to version control
# ==============================================================================
# PostgreSQL Database Configuration
# ==============================================================================
POSTGRES_DB=certctl
POSTGRES_USER=certctl
POSTGRES_PASSWORD=change-me-in-production
POSTGRES_PORT=5432
# ==============================================================================
# Certctl Server Configuration
# ==============================================================================
SERVER_HOST=0.0.0.0
SERVER_PORT=8443
LOG_LEVEL=info
# Database connection string (alternative to individual vars)
# DB_URL=postgres://certctl:password@localhost:5432/certctl?sslmode=disable
# ==============================================================================
# ACME Configuration
# ==============================================================================
# For Let's Encrypt production, use: https://acme-v02.api.letsencrypt.org/directory
# For Let's Encrypt staging, use: https://acme-staging-v02.api.letsencrypt.org/directory
ACME_DIRECTORY_URL=https://acme-staging-v02.api.letsencrypt.org/directory
ACME_EMAIL=admin@example.com
# ==============================================================================
# SMTP Configuration (for email notifications)
# ==============================================================================
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=your-smtp-user
SMTP_PASSWORD=your-smtp-password
SMTP_FROM_ADDRESS=certctl@example.com
# ==============================================================================
# Webhook Configuration (optional)
# ==============================================================================
WEBHOOK_URL=https://your-webhook-endpoint.example.com/notifications
WEBHOOK_SECRET=your-webhook-secret
# ==============================================================================
# Agent Configuration
# ==============================================================================
SERVER_URL=http://localhost:8443
AGENT_API_KEY=change-me-in-production
AGENT_NAME=local-agent
CHECK_INTERVAL=60s
# ==============================================================================
# PgAdmin Configuration (development only)
# ==============================================================================
PGADMIN_EMAIL=admin@example.com
PGADMIN_PASSWORD=admin
PGADMIN_PORT=5050
# ==============================================================================
# Security Settings
# ==============================================================================
# JWT secret for API authentication (generate with: openssl rand -base64 32)
JWT_SECRET=change-me-in-production
# Encryption key for sensitive data (generate with: openssl rand -base64 32)
ENCRYPTION_KEY=change-me-in-production
# ==============================================================================
# Feature Flags (optional)
# ==============================================================================
ENABLE_AUDIT_LOG=true
ENABLE_METRICS=true
ENABLE_TRACING=false
+57
View File
@@ -0,0 +1,57 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.so.*
*.dylib
bin/
dist/
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
coverage.out
coverage.html
# Go workspace file
go.work
# Dependency directories
vendor/
# Environment variables
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
*.iml
# Temporary files
tmp/
temp/
*.log
# Database
*.db
*.sqlite3
*.sql
# Build artifacts
certctl-server
certctl-agent
server
agent
# OS
.DS_Store
Thumbs.db
+70
View File
@@ -0,0 +1,70 @@
# Multi-stage build for certctl server and agent binaries
# Stage 1: Build
FROM golang:1.22-alpine AS builder
# Install build dependencies
RUN apk add --no-cache git ca-certificates tzdata
# Set working directory
WORKDIR /app
# Copy go mod and sum files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build server binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-w -s" \
-o bin/server \
./cmd/server
# Build agent binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-w -s" \
-o bin/agent \
./cmd/agent
# Stage 2: Runtime
FROM alpine:3.19
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata curl
# Create non-root user
RUN addgroup -g 1000 certctl && \
adduser -D -u 1000 -G certctl certctl
# Set working directory
WORKDIR /app
# Copy binaries from builder
COPY --from=builder /app/bin/server .
COPY --from=builder /app/bin/agent .
# Copy migration files if needed
COPY --chown=certctl:certctl migrations/ ./migrations/
# Change ownership
RUN chown -R certctl:certctl /app
# Switch to non-root user
USER certctl
# Expose port for server
EXPOSE 8443
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8443/health || exit 1
# Default entrypoint is the server
ENTRYPOINT ["/app/server"]
# Notes:
# - To run the server: docker run -p 8443:8443 -e DB_HOST=postgres certctl:latest
# - To run the agent: docker run -e SERVER_URL=http://server:8443 -e API_KEY=<key> certctl:latest /app/agent
+53
View File
@@ -0,0 +1,53 @@
# Multi-stage build for certctl agent binary
# Stage 1: Build
FROM golang:1.22-alpine AS builder
# Install build dependencies
RUN apk add --no-cache git ca-certificates
# Set working directory
WORKDIR /app
# Copy go mod and sum files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build agent binary only
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-w -s" \
-o bin/agent \
./cmd/agent
# Stage 2: Runtime
FROM alpine:3.19
# Install runtime dependencies (minimal)
RUN apk add --no-cache ca-certificates curl
# Create non-root user
RUN addgroup -g 1000 certctl && \
adduser -D -u 1000 -G certctl certctl
# Set working directory
WORKDIR /app
# Copy binary from builder
COPY --from=builder /app/bin/agent .
# Change ownership
RUN chown -R certctl:certctl /app
# Switch to non-root user
USER certctl
# Health check (optional, depends on agent implementation)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:9000/health || exit 1 || true
# Default entrypoint is the agent
ENTRYPOINT ["/app/agent"]
+188
View File
@@ -0,0 +1,188 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined in Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising
permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object
form, made available under the License, including but not limited to
source code, object code, and documentation.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship.
"Contribution" shall mean any work of authorship, including
the original Work and any Derivative Works thereof, that is
intentionally submitted to, or received by, Licensor for inclusion
in the Work by the copyright owner or by an individual or Legal
Entity authorized to submit on behalf of the copyright owner.
"Contributor" shall mean Licensor and any Legal Entity on behalf
of whom a Contribution has been received by Licensor and subsequently
incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file, then any
Derivative Works that You distribute must include a readable
copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE from the Work, provided that
such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions of this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to Licensor shall be under the terms and conditions of
this License, without limitation of any additional terms or
conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contribution.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the inability to use the Work
(including but not limited to damages for loss of goodwill, work
stoppage, computer failure or malfunction, or any and all other
commercial damages or losses), even if such Contributor has been
advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Enclose the text in
the appropriate comment syntax for your file type.) The text should
be enclosed in such a way that it will make it clear to others
that the file is subject to the Apache License:
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+166
View File
@@ -0,0 +1,166 @@
.PHONY: help build run test lint clean docker-up docker-down migrate-up migrate-down generate
# Default target - show help
help:
@echo "Certctl Development Commands"
@echo "============================="
@echo ""
@echo "Build & Run:"
@echo " make build Build server and agent binaries"
@echo " make run Run server locally (requires DB)"
@echo " make run-agent Run agent locally"
@echo ""
@echo "Testing & Quality:"
@echo " make test Run all tests"
@echo " make test-verbose Run tests with verbose output"
@echo " make lint Run linter (golangci-lint)"
@echo " make fmt Format code with gofmt"
@echo ""
@echo "Database:"
@echo " make migrate-up Run migrations (requires DB_URL)"
@echo " make migrate-down Rollback last migration"
@echo " make db-seed Seed database with test data"
@echo ""
@echo "Docker:"
@echo " make docker-build Build Docker images"
@echo " make docker-up Start Docker Compose stack"
@echo " make docker-down Stop Docker Compose stack"
@echo " make docker-logs View Docker logs"
@echo " make docker-clean Remove Docker resources"
@echo ""
@echo "Code Generation:"
@echo " make generate Run go generate"
@echo " make clean Clean build artifacts"
@echo ""
# Build targets
build:
@echo "Building server and agent..."
mkdir -p bin
CGO_ENABLED=0 go build -o bin/server ./cmd/server
CGO_ENABLED=0 go build -o bin/agent ./cmd/agent
@echo "Build complete: bin/server, bin/agent"
build-server:
@echo "Building server..."
mkdir -p bin
CGO_ENABLED=0 go build -o bin/server ./cmd/server
@echo "Server build complete"
build-agent:
@echo "Building agent..."
mkdir -p bin
CGO_ENABLED=0 go build -o bin/agent ./cmd/agent
@echo "Agent build complete"
# Run targets
run: build-server
@echo "Starting server (requires DATABASE_URL or DB_* env vars)..."
./bin/server
run-agent: build-agent
@echo "Starting agent (requires SERVER_URL and API_KEY env vars)..."
./bin/agent
# Testing targets
test:
@echo "Running tests..."
go test ./...
test-verbose:
@echo "Running tests with verbose output..."
go test -v ./...
test-coverage:
@echo "Running tests with coverage..."
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report: coverage.html"
# Linting targets
lint:
@echo "Running golangci-lint..."
@which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
golangci-lint run ./...
fmt:
@echo "Formatting code..."
go fmt ./...
@echo "Code formatted"
vet:
@echo "Running go vet..."
go vet ./...
# Database targets (requires migrate tool)
migrate-up:
@echo "Running migrations..."
@which migrate > /dev/null || (echo "Installing migrate CLI..." && go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest)
migrate -path migrations -database "${DB_URL:-postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable}" up
migrate-down:
@echo "Rolling back last migration..."
@which migrate > /dev/null || (echo "Installing migrate CLI..." && go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest)
migrate -path migrations -database "${DB_URL:-postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable}" down 1
migrate-status:
@echo "Checking migration status..."
@which migrate > /dev/null || (echo "Installing migrate CLI..." && go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest)
migrate -path migrations -database "${DB_URL:-postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable}" version
db-seed:
@echo "Seeding database with test data..."
go run ./scripts/seed/main.go
# Docker targets
docker-build:
@echo "Building Docker images..."
docker-compose -f deploy/docker-compose.yml build
docker-up:
@echo "Starting Docker Compose stack..."
docker-compose -f deploy/docker-compose.yml up -d
@echo "Stack running. Access server at http://localhost:8443"
docker-up-dev:
@echo "Starting Docker Compose stack (dev mode)..."
docker-compose -f deploy/docker-compose.yml -f deploy/docker-compose.dev.yml up -d
@echo "Stack running. PgAdmin at http://localhost:5050"
docker-down:
@echo "Stopping Docker Compose stack..."
docker-compose -f deploy/docker-compose.yml down
docker-logs:
docker-compose -f deploy/docker-compose.yml logs -f
docker-logs-server:
docker-compose -f deploy/docker-compose.yml logs -f certctl-server
docker-logs-agent:
docker-compose -f deploy/docker-compose.yml logs -f certctl-agent
docker-clean:
@echo "Removing Docker resources..."
docker-compose -f deploy/docker-compose.yml down -v
@echo "Cleaned up"
# Code generation
generate:
@echo "Running go generate..."
go generate ./...
@echo "Code generation complete"
# Cleanup
clean:
@echo "Cleaning build artifacts..."
rm -rf bin/ dist/ coverage.out coverage.html
go clean -testcache
@echo "Cleanup complete"
install-tools:
@echo "Installing development tools..."
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
go install github.com/cosmtrek/air@latest
@echo "Tools installed"
+393
View File
@@ -0,0 +1,393 @@
# Certctl — Open-Source Certificate Control Plane
A self-hosted, cloud-agnostic certificate management platform for teams. Manage issuance, deployment, and renewal of TLS certificates at scale with zero private key exposure in the control plane.
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
[![Go Report Card](https://goreportcard.com/badge/github.com/shankar0123/certctl)](https://goreportcard.com/report/github.com/shankar0123/certctl)
![Status: Active Development](https://img.shields.io/badge/status-active%20development-green)
## Overview
Certctl decouples certificate management into a control plane and lightweight agents deployed across your infrastructure. The control plane orchestrates issuance and renewal via multiple ACME issuers, while agents securely request, deploy, and renew certificates on target systems—all without exposing private keys outside the edge.
### Why Certctl?
- **Decoupled architecture**: Control plane + edge agents, no SSH or privileged access required
- **Multi-issuer support**: ACME (Let's Encrypt, Sectigo, etc.), with extensible connector framework
- **Zero private key exposure**: Keys generated and managed on agents, never sent to control plane
- **Audit-first design**: Every action logged with full traceability
- **Connector ecosystem**: Extensible issuer, target, and notifier connectors
- **Self-hosted**: Run on Kubernetes, Docker Compose, or bare metal—no cloud lock-in
- **Production-ready**: Graceful error handling, observability, database-backed state
## Quick Start
### With Docker Compose (Recommended)
```bash
# Clone the repo
git clone https://github.com/shankar0123/certctl.git
cd certctl
# Copy example environment variables
cp .env.example .env
# Start the stack
make docker-up
# Check health
curl http://localhost:8443/health
```
The stack includes PostgreSQL, certctl server, and a sample agent. Logs available via:
```bash
make docker-logs-server
make docker-logs-agent
```
### Manual Build & Run
#### Prerequisites
- Go 1.22+
- PostgreSQL 14+
- (Optional) Docker & Docker Compose
#### Build from Source
```bash
# Install dependencies
go mod download
# Build binaries
make build
# Run migrations
export DB_URL="postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable"
make migrate-up
# Start server (in one terminal)
make run
# Start agent (in another terminal, with API key from server logs)
API_KEY="<key-from-server>" SERVER_URL=http://localhost:8443 ./bin/agent
```
## Architecture
### System Components
```
┌─────────────────────────────────────────────────────────┐
│ CONTROL PLANE │
│ ┌──────────────────────────────────────────────────┐ │
│ │ API Server (8443) │ │
│ │ • Certificate management │ │
│ │ • Issuance orchestration │ │
│ │ • Audit logging │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ PostgreSQL Database │ │
│ │ • Certificates, agents, targets, policies │ │
│ │ • Complete audit trail │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│ │
│ (mTLS + API Key) │
│ │
┌────┴────┐ ┌────────┴────┐
│ │ │ │
┌───┴──┐ ┌──┴───┐ ┌─────┴──┐ ┌──────┴────┐
│Agent │ │Agent │ │ Agent │ │ Agent │
│ #1 │ │ #2 │ │ #3 │ │ #N │
└──────┘ └──────┘ └────────┘ └───────────┘
│ │ │ │
├────┬────┼────┬───┬──┴─────┬─────┴──┬───┐
│ │ │ │ │ │ │ │
┌──┴─┐┌─┴──┐┌───┴──┐│┌───────┐│┌──────┐│ │
│ACME││K8s ││F5 ││Vault│ │Webhook│
│ ││LB ││LB ││ │ │
└────┘└────┘└────┘└────┘└──────┘
```
### Data Flow: Certificate Issuance
1. **Create Certificate** → Control plane stores managed certificate record
2. **Generate CSR** → Agent creates private key (stays local) and CSR
3. **Request Certificate** → Agent sends CSR to control plane
4. **Issue via ACME** → Control plane submits to issuer (Let's Encrypt, etc.)
5. **Return Certificate** → Agent receives signed cert, stores locally
6. **Deploy** → Agent pushes certificate to targets (NGINX, F5, IIS, etc.)
7. **Notify** → Webhook or email notification sent on completion
### Database Schema Overview
| Entity | Purpose |
|--------|---------|
| `certificates` | Managed certificate records with metadata |
| `agents` | Registered agents in the fleet |
| `targets` | Deployment targets (NGINX, F5, IIS, etc.) |
| `issuers` | ACME issuer configurations |
| `jobs` | Issuance and deployment jobs |
| `audit_logs` | Complete action trail |
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `SERVER_HOST` | `0.0.0.0` | Server bind address |
| `SERVER_PORT` | `8443` | Server listen port |
| `DB_HOST` | `localhost` | PostgreSQL host |
| `DB_PORT` | `5432` | PostgreSQL port |
| `DB_USER` | `certctl` | Database user |
| `DB_PASSWORD` | — | Database password |
| `DB_NAME` | `certctl` | Database name |
| `LOG_LEVEL` | `info` | Log level (debug, info, warn, error) |
| `ACME_DIRECTORY_URL` | staging | ACME directory URL |
| `ACME_EMAIL` | — | ACME registration email |
| `SMTP_HOST` | — | SMTP server for email notifications |
| `SMTP_PORT` | `587` | SMTP port |
| `SMTP_USERNAME` | — | SMTP username |
| `SMTP_PASSWORD` | — | SMTP password |
| `SMTP_FROM_ADDRESS` | — | Email from address |
See `.env.example` for complete reference.
## API Overview
### Key Endpoints
#### Certificates
- `POST /api/v1/certificates` — Create managed certificate
- `GET /api/v1/certificates` — List certificates
- `GET /api/v1/certificates/:id` — Get certificate details
- `PUT /api/v1/certificates/:id` — Update certificate
- `DELETE /api/v1/certificates/:id` — Archive certificate
#### Agents
- `POST /api/v1/agents` — Register new agent
- `GET /api/v1/agents` — List agents
- `GET /api/v1/agents/:id` — Get agent details
- `PUT /api/v1/agents/:id` — Update agent
#### Targets
- `POST /api/v1/targets` — Add deployment target
- `GET /api/v1/targets` — List targets
- `PUT /api/v1/targets/:id` — Update target
- `DELETE /api/v1/targets/:id` — Remove target
#### Issuers
- `POST /api/v1/issuers` — Register ACME issuer
- `GET /api/v1/issuers` — List issuers
- `PUT /api/v1/issuers/:id` — Update issuer
#### Audit
- `GET /api/v1/audit/logs` — Query audit trail
- `GET /api/v1/audit/logs/:id` — Get specific log entry
#### System
- `GET /health` — Health check
Full API docs: [docs/api.md](docs/api.md) (coming soon)
## Agent Setup Guide
### Installation
Agents can be deployed as:
- **Docker container**: `docker pull certctl:agent`
- **Systemd service**: `systemctl start certctl-agent`
- **Kubernetes DaemonSet**: See [docs/k8s-deployment.md](docs/k8s-deployment.md)
### Configuration
Agents require:
1. **Server URL**: Control plane address (e.g., `https://certctl.example.com:8443`)
2. **API Key**: Issued by control plane on agent registration
3. **Agent Name**: Unique identifier in fleet
Example systemd unit:
```ini
[Unit]
Description=Certctl Agent
After=network.target
[Service]
Type=simple
ExecStart=/opt/certctl-agent/agent
Environment="SERVER_URL=https://certctl.example.com:8443"
Environment="API_KEY=ey..."
Environment="AGENT_NAME=prod-web-01"
Restart=on-failure
RestartSec=10s
[Install]
WantedBy=multi-user.target
```
## Supported Integrations
### Certificate Issuers
| Issuer | Status | Connector |
|--------|--------|-----------|
| Let's Encrypt (ACME v2) | ✓ Production | `acme` |
| Sectigo ACME | ✓ Tested | `acme` |
| Vault PKI | ◐ Planned | `vault` |
| DigiCert | ◐ Planned | `digicert` |
### Deployment Targets
| Target | Status | Connector |
|--------|--------|-----------|
| NGINX | ✓ Production | `nginx` |
| F5 BIG-IP | ✓ Tested | `f5` |
| Microsoft IIS | ✓ Tested | `iis` |
| Kubernetes Secrets | ◐ Planned | `k8s` |
| AWS CloudFront | ◐ Planned | `aws` |
### Notifiers
| Notifier | Status | Connector |
|----------|--------|-----------|
| Email (SMTP) | ✓ Production | `email` |
| Webhooks | ✓ Production | `webhook` |
| Slack | ◐ Planned | `slack` |
| PagerDuty | ◐ Planned | `pagerduty` |
## Development
### Local Setup
```bash
make install-tools
cp .env.example .env
make docker-up-dev
# Access PgAdmin at http://localhost:5050
# Server logs: make docker-logs-server
```
### Testing
```bash
# Run all tests
make test
# Run with coverage
make test-coverage
# Run specific package
go test -v ./internal/service/...
```
### Linting & Format
```bash
make lint
make fmt
make vet
```
### Building Connectors
See [docs/connectors.md](docs/connectors.md) for a step-by-step guide to building issuers, targets, and notifier connectors.
## Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/my-feature`
3. Commit changes: `git commit -am 'Add feature'`
4. Push to branch: `git push origin feature/my-feature`
5. Open a pull request
### Code Standards
- Go 1.22+ with `go fmt`, `go vet`, `golangci-lint`
- Tests required for new features (>80% coverage)
- Clear commit messages
- Update relevant documentation
## Security
### Private Key Management
- Private keys **never** sent to control plane
- Keys generated and managed exclusively on agents
- Encrypted at rest on agent systems
- Cleared from memory after use
### Authentication
- Agent-to-server: mTLS + API key
- API key rotation supported
- Audit logging of all authenticated actions
### Audit Trail
- Complete action history in PostgreSQL
- Immutable audit logs
- Queryable by resource, user, timestamp, action
For security issues, email security@example.com (do not open public issues).
## Performance & Scaling
- **Agents**: Stateless, horizontal scaling via fleet management
- **Control Plane**: Single server handles 1000+ agents
- **Database**: PostgreSQL; vertical scaling recommended
- **Jobs**: Asynchronous processing; tunable concurrency
See [docs/scaling.md](docs/scaling.md) for deployment guidance.
## Troubleshooting
### Server Won't Start
```bash
# Check database connection
psql -h localhost -U certctl -d certctl
# Check logs
make docker-logs-server
# Verify environment variables
env | grep -E "DB_|SERVER_|ACME_"
```
### Agent Can't Connect
```bash
# Check server health
curl -v https://certctl.example.com:8443/health
# Verify API key
echo $API_KEY
# Check agent logs
make docker-logs-agent
```
### Certificate Not Deploying
1. Check agent is registered: `curl http://localhost:8443/api/v1/agents`
2. Check target is reachable: `curl http://target-server:22` (SSH test)
3. Review audit log: `curl http://localhost:8443/api/v1/audit/logs`
## Roadmap
- [ ] Kubernetes CRD for certificate management
- [ ] Terraform provider
- [ ] Multi-region deployment
- [ ] HA control plane with etcd backend
- [ ] Advanced scheduling policies
- [ ] Certificate pinning validation
- [ ] Hardware security module (HSM) support
## License
Certctl is licensed under the [Apache License 2.0](LICENSE). See LICENSE file for details.
## Support & Community
- **Issues**: [GitHub Issues](https://github.com/shankar0123/certctl/issues)
- **Discussions**: [GitHub Discussions](https://github.com/shankar0123/certctl/discussions)
- **Documentation**: [Full Docs](docs/)
- **Security**: security@example.com
---
**Built with ❤️ for infrastructure teams managing certificates at scale.**
+58
View File
@@ -0,0 +1,58 @@
version: '3.8'
# Development overrides for docker-compose.yml
# Usage: docker-compose -f deploy/docker-compose.yml -f deploy/docker-compose.dev.yml up
services:
# Override server configuration for development
certctl-server:
build:
context: ..
dockerfile: Dockerfile
environment:
# Verbose logging for development
LOG_LEVEL: debug
SERVER_HOST: 0.0.0.0
SERVER_PORT: 8443
volumes:
# Mount local source for hot reload (requires air or similar)
# Uncomment if using air or similar for hot reload:
# - ../cmd:/app/cmd
# - ../internal:/app/internal
# - ../api:/app/api
ports:
- "8443:8443"
- "40000:40000" # Delve debugger port (if debugging)
# Override agent configuration for development
certctl-agent:
build:
context: ..
dockerfile: Dockerfile.agent
environment:
LOG_LEVEL: debug
# PgAdmin for database exploration
pgadmin:
image: dpage/pgadmin4:latest
container_name: certctl-pgadmin
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@example.com}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin}
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
ports:
- "${PGADMIN_PORT:-5050}:80"
networks:
- certctl-network
depends_on:
- postgres
restart: unless-stopped
# Notes for development:
# 1. Enable hot reload by installing air: go install github.com/cosmtrek/air@latest
# Then in cmd/server and cmd/agent, create .air.toml for watch configuration
# 2. Debug the server by attaching Delve to port 40000
# 3. Access PgAdmin at http://localhost:5050 to browse the database
# 4. View server logs: docker-compose logs -f certctl-server
# 5. View agent logs: docker-compose logs -f certctl-agent
+112
View File
@@ -0,0 +1,112 @@
version: '3.8'
services:
# PostgreSQL database
postgres:
image: postgres:16-alpine
container_name: certctl-postgres
environment:
POSTGRES_DB: ${POSTGRES_DB:-certctl}
POSTGRES_USER: ${POSTGRES_USER:-certctl}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-certctl}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- certctl-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-certctl} -d ${POSTGRES_DB:-certctl}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# Certctl Server
certctl-server:
build:
context: ..
dockerfile: Dockerfile
container_name: certctl-server
depends_on:
postgres:
condition: service_healthy
environment:
# Database configuration
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ${POSTGRES_USER:-certctl}
DB_PASSWORD: ${POSTGRES_PASSWORD:-certctl}
DB_NAME: ${POSTGRES_DB:-certctl}
DB_SSL_MODE: disable
# Server configuration
SERVER_HOST: 0.0.0.0
SERVER_PORT: 8443
LOG_LEVEL: info
# ACME Configuration (example: Let's Encrypt staging)
ACME_DIRECTORY_URL: https://acme-staging-v02.api.letsencrypt.org/directory
ACME_EMAIL: ${ACME_EMAIL:-admin@example.com}
# SMTP Configuration (for email notifications)
SMTP_HOST: ${SMTP_HOST:-smtp.example.com}
SMTP_PORT: 587
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_ADDRESS: ${SMTP_FROM_ADDRESS:-certctl@example.com}
# Webhook Configuration (optional)
WEBHOOK_URL: ${WEBHOOK_URL:-}
WEBHOOK_SECRET: ${WEBHOOK_SECRET:-}
ports:
- "${SERVER_PORT:-8443}:8443"
networks:
- certctl-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8443/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
restart: unless-stopped
logs:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# Certctl Agent
certctl-agent:
build:
context: ..
dockerfile: Dockerfile.agent
container_name: certctl-agent
depends_on:
certctl-server:
condition: service_healthy
environment:
# Server configuration
SERVER_URL: http://certctl-server:8443
API_KEY: ${AGENT_API_KEY:-change-me-in-production}
AGENT_NAME: ${AGENT_NAME:-docker-agent}
# Agent configuration
LOG_LEVEL: info
CHECK_INTERVAL: 60s
networks:
- certctl-network
restart: unless-stopped
logs:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
certctl-network:
driver: bridge
volumes:
postgres_data:
driver: local
+574
View File
@@ -0,0 +1,574 @@
# Certctl Architecture
## Overview
Certctl is a certificate management platform with a **decoupled control-plane and agent architecture**. The control plane orchestrates certificate issuance and renewal, while stateless agents deployed across your infrastructure handle certificate generation, deployment, and renewal without exposing private keys to the control plane.
### Design Principles
1. **Zero Private Key Exposure** — Private keys generated and managed only on agents
2. **Decoupled Operations** — Agents operate autonomously; control plane is optional for agent function
3. **Audit-First** — Complete traceability of all issuance, deployment, and rotation events
4. **Connector Architecture** — Pluggable issuers, targets, and notifiers for extensibility
5. **Self-Hosted** — No cloud lock-in; run on Kubernetes, Docker, or bare metal
---
## System Components
### Control Plane
The control plane is a REST API server backed by PostgreSQL. It:
- **Manages state**: Certificates, agents, targets, issuers, policies
- **Orchestrates issuance**: Coordinates with ACME/PKI issuers
- **Tracks jobs**: Certificate issuance, renewal, and deployment workflows
- **Audits all actions**: Immutable audit trail for compliance
- **Dispatches work**: Schedules renewal checks and deployment jobs
**Deployment Options**: Single binary, Docker container, Kubernetes deployment
### Agents
Lightweight agents deployed on or near your infrastructure. They:
- **Generate certificates**: Create private keys and certificate requests
- **Deploy certificates**: Push certs to NGINX, F5, IIS, etc.
- **Manage credentials**: Store and rotate API keys with control plane
- **Report status**: Health checks and job completion status
- **Operate independently**: Continue functioning even if control plane is unreachable
**Deployment Options**: Container, systemd service, Kubernetes DaemonSet, Lambda
### PostgreSQL Database
Persistent state store:
```
├── Teams & Ownership
│ ├── teams
│ └── owners
├── Certificate Management
│ ├── certificates
│ ├── certificate_versions
│ └── renewal_policies
├── Infrastructure
│ ├── agents
│ ├── targets
│ └── target_connections
├── Issuance
│ ├── issuers
│ ├── jobs
│ └── job_steps
├── Monitoring & Audit
│ ├── audit_logs
│ ├── notifications
│ └── deployment_history
└── Configuration
├── agent_api_keys
└── connector_config
```
---
## Data Flow: Certificate Lifecycle
### 1. **Create Managed Certificate**
```
User/API
├─→ POST /api/v1/certificates
│ {
│ "domain": "api.example.com",
│ "issuer_id": "issuer-001",
│ "target_ids": ["nginx-prod-01"],
│ "renewal_days_before": 30
│ }
└─→ Control Plane
├─ Insert certificate record
├─ Create initial job
├─ Log audit event
└─ Return cert ID + API response
```
### 2. **Agent Requests Certificate (CSR → Issuance)**
```
Agent Control Plane ACME Issuer
│ │ │
├─ POST /api/v1/csr │ │
│ { │ │
│ "cert_id": "cert-123", │ │
│ "csr": "-----BEGIN CSR..." │ │
│ } │ │
│ ├─ Validate CSR │
│ │ │
│ ├─ POST /directory/new-order │
│ ├──────────────────────────────→
│ │ │
│ │← Poll challenges │
│ ├──────────────────────────────→
│ │ │
│ ├─ POST /acme/finalize │
│ ├──────────────────────────────→
│ │ │
│← Certificate + chain │← Signed certificate │
├─────────────────────────────────│ │
│ │ │
├─ Store locally: │ │
│ /etc/certctl/api.example.com/ │ │
│ ├─ cert.pem │ │
│ ├─ key.pem (never sent back) │ │
│ └─ chain.pem │ │
│ │ │
└─ POST /api/v1/deployments │ │
{ "cert_id", "status": "ok" } │ │
├─ Update cert record │
├─ Log "issued" event │
└─ Trigger deployment jobs │
```
### 3. **Deploy Certificate to Target**
```
Agent Target System
├─ Fetch target credentials from config
├─ Load certificate:
│ - /etc/certctl/api.example.com/cert.pem
│ - /etc/certctl/api.example.com/key.pem
├─ NGINX (SSH):
│ ├─ scp cert.pem → /etc/nginx/ssl/
│ ├─ scp key.pem → /etc/nginx/ssl/ (restricted perms)
│ ├─ ssh nginx -s reload
│ └─ Verify: curl https://api.example.com/health
├─ F5 (HTTPS API):
│ ├─ Authenticate with credentials
│ ├─ POST /mgmt/tm/ltm/cert {"name": "api.example.com", "cert": "..."}
│ ├─ PUT /mgmt/tm/ltm/virtual (update virtual server)
│ └─ Verify: F5 configuration updated
├─ IIS (WinRM):
│ ├─ Import cert to store: Import-PfxCertificate
│ ├─ Bind to site: Set-WebBinding
│ └─ Verify: Get-WebBinding
└─ Report deployment status:
POST /api/v1/deployments/{id}/status
{ "status": "success", "deployed_at": "..." }
```
### 4. **Renewal Check & Rotation**
```
Scheduler (Control Plane)
├─ Every hour: SELECT certificates WHERE expiry_date < NOW() + 30 days
├─ For each certificate:
│ │
│ ├─ Create renewal job
│ ├─ Notify agent(s)
│ │
│ └─ Agent flow:
│ ├─ Generate new CSR
│ ├─ Request new certificate
│ ├─ Deploy new cert to targets
│ ├─ Verify deployment
│ └─ Delete old private key from agent
├─ Log completion
└─ Notify via email/webhook
```
---
## Connector Architecture
Certctl uses **connector interfaces** for extensibility. Connectors are pluggable implementations of specific capabilities.
### Issuer Connector
Handles certificate issuance from external PKI systems.
```go
type IssuerConnector interface {
// GetDirectory returns the ACME directory
GetDirectory(ctx context.Context) (*ACMEDirectory, error)
// NewAccount registers a new account
NewAccount(ctx context.Context, email string) (*Account, error)
// NewOrder creates a new certificate order
NewOrder(ctx context.Context, identifiers []Identifier) (*Order, error)
// GetAuthorization retrieves challenge info
GetAuthorization(ctx context.Context, authURL string) (*Authorization, error)
// FinalizeOrder submits CSR and gets certificate
FinalizeOrder(ctx context.Context, orderURL, csr string) ([]byte, error)
}
```
**Built-in Issuers**:
- `acme` — ACME v2 protocol (Let's Encrypt, Sectigo, etc.)
**Example Usage**:
```yaml
issuer:
type: acme
config:
directory_url: https://acme-v02.api.letsencrypt.org/directory
email: admin@example.com
```
### Target Connector
Deploys certificates to infrastructure systems.
```go
type TargetConnector interface {
// Validate tests connectivity and credentials
Validate(ctx context.Context) error
// Deploy pushes certificate to target
Deploy(ctx context.Context, cert *Certificate) error
// Remove removes/revokes certificate from target
Remove(ctx context.Context, domain string) error
// GetStatus checks deployment status
GetStatus(ctx context.Context, domain string) (string, error)
}
```
**Built-in Targets**:
- `nginx` — NGINX via SSH
- `f5` — F5 BIG-IP via REST API
- `iis` — Microsoft IIS via WinRM
**Example Usage**:
```yaml
target:
type: nginx
config:
host: web01.prod.internal
ssh_user: deploy
ssh_key: /etc/certctl/keys/deploy.pem
cert_path: /etc/nginx/ssl
```
### Notifier Connector
Sends notifications about certificate events.
```go
type NotifierConnector interface {
// Send delivers a notification
Send(ctx context.Context, notif *Notification) error
// Validate checks configuration
Validate(ctx context.Context) error
}
```
**Built-in Notifiers**:
- `email` — SMTP email
- `webhook` — HTTP webhooks
**Example Usage**:
```yaml
notifier:
type: email
config:
smtp_host: smtp.example.com
smtp_port: 587
username: alerts@example.com
password: "***"
from_address: certctl@example.com
recipients:
- ops@example.com
- security@example.com
```
---
## Job Lifecycle & States
Jobs represent work to be done: certificate issuance, renewal, deployment, etc.
```
┌──────────┐
│ PENDING │ Job created, waiting to be processed
└────┬─────┘
┌──────────┐
│ RUNNING │ Job in progress (CSR generation, issuance, deployment)
└────┬─────┘
├─→ SUCCESS ──→ COMPLETED (job done, no errors)
├─→ FAILURE ──→ FAILED (error occurred, may retry)
└─→ CANCEL ───→ CANCELLED (user or scheduler cancelled)
Additional states:
• RETRY_WAIT — Backoff before retry
• ABANDONED — Max retries exceeded
```
### Job Steps
Complex jobs are broken into steps:
```
Issuance Job
├─ Step 1: Notify agent of CSR request
│ Status: COMPLETED
├─ Step 2: Wait for CSR from agent
│ Status: RUNNING (timeout: 5 min)
├─ Step 3: Submit to ACME issuer
│ Status: PENDING
├─ Step 4: Poll for certificate
│ Status: PENDING
└─ Step 5: Trigger deployment jobs
Status: PENDING
```
---
## Security Model
### Private Key Management
```
Private Key Lifecycle
├─ GENERATED on Agent (never sent to control plane)
│ └─ Location: /etc/certctl/domains/{domain}/key.pem
├─ STORED on Agent
│ ├─ File permissions: 0600 (agent user only)
│ └─ Encrypted at rest (optional, per deployment)
├─ USED on Agent for:
│ ├─ Deployment to targets
│ └─ Certificate renewal
└─ DELETED on Agent
├─ Old key deleted after successful renewal
└─ Manual revocation on agent removal
```
### Authentication & Authorization
**Agent-to-Server**:
- API Key (registered at agent creation)
- mTLS optional for high-security deployments
- All API calls include agent ID + API key
**Server-to-External Systems**:
- ACME: ACME protocol with account key
- NGINX: SSH key authentication
- F5: Username/password or token
- IIS: WinRM with encrypted credentials
### Audit Logging
Every action is logged:
```json
{
"id": "audit-98765",
"timestamp": "2024-03-14T10:30:00Z",
"actor": {
"type": "agent",
"id": "agent-prod-01"
},
"action": "certificate_issued",
"resource": {
"type": "certificate",
"id": "cert-api-example-com"
},
"status": "success",
"details": {
"issuer": "acme/letsencrypt",
"expiry": "2024-06-12T10:30:00Z",
"deployed_to": ["nginx-prod-01"]
}
}
```
**Query examples**:
- All actions by agent: `GET /audit/logs?actor_type=agent&actor_id=agent-001`
- All deployments: `GET /audit/logs?action=certificate_deployed`
- Last 30 days: `GET /audit/logs?from=2024-02-12`
### Data Encryption at Rest
Optional encryption for sensitive fields:
- Passwords in connector configs
- API keys
- ACME account keys
Uses AES-256-GCM with per-row nonce.
---
## Scaling Considerations
### Control Plane Scaling
**Single Server Limits**:
- ~1000 agents (verified in testing)
- ~10,000 managed certificates
- ~100,000 audit log entries per day
**Horizontal Scaling** (future):
- Multiple server instances behind load balancer
- Shared PostgreSQL backend
- Distributed job queue (Redis/RabbitMQ)
### Agent Scaling
Agents are stateless and scale horizontally:
- Each agent processes certificates independently
- Scheduler distributes renewal checks across agents
- No inter-agent communication required
### Database Scaling
For large deployments:
- Vertical scaling: More CPU/RAM for PostgreSQL
- Read replicas: For audit log queries
- Partitioning: Audit logs by date
- Connection pooling: PgBouncer
---
## Integration Points
### External Integrations
```
Certctl
├─→ ACME Servers
│ ├─ Let's Encrypt
│ ├─ Sectigo
│ └─ Internal ACME (optional)
├─→ Infrastructure Targets
│ ├─ NGINX (SSH)
│ ├─ F5 (REST API)
│ ├─ IIS (WinRM)
│ └─ Kubernetes (future)
├─→ Notification Systems
│ ├─ SMTP (email)
│ ├─ HTTP webhooks
│ └─ Slack (future)
└─→ External Systems
├─ Vault (credential storage)
├─ HashiCorp Consul (service discovery)
└─ Prometheus (metrics)
```
### Internal Component Communication
```
Agent ← → Control Plane
├─ Agent registration
├─ CSR submission
├─ Certificate retrieval
├─ Deployment status
└─ Health checks (bidirectional)
Scheduler → Services
├─ Certificate renewal
├─ Job processing
├─ Notifications
└─ Cleanup tasks
```
---
## Deployment Topologies
### Single-Node (Development)
```
┌────────────────────────────┐
│ Server + Agent │
│ ├─ HTTP API (8443) │
│ ├─ PostgreSQL │
│ └─ Agent (test mode) │
└────────────────────────────┘
```
### Docker Compose (Local Dev)
```
┌─────────────────────────────────────┐
│ Docker Network │
│ ├─ certctl-server (8443) │
│ ├─ postgres (5432) │
│ ├─ certctl-agent (managed) │
│ └─ pgadmin (5050, optional) │
└─────────────────────────────────────┘
```
### Kubernetes (Production)
```
┌──────────────────────────────────────────────────┐
│ Kubernetes Cluster │
│ ├─ Deployment: certctl-server (replicas=3) │
│ ├─ DaemonSet: certctl-agent (all nodes) │
│ ├─ StatefulSet: postgres (primary + replica) │
│ ├─ ConfigMap: connector configurations │
│ └─ Secret: API keys, credentials │
└──────────────────────────────────────────────────┘
```
---
## Performance Characteristics
| Operation | Typical Duration | Bottleneck |
|-----------|------------------|-----------|
| Certificate request (CSR) | 100-500ms | Agent network latency |
| ACME challenge (DNS) | 30-60s | DNS propagation |
| ACME finalize | 1-5s | ACME server |
| NGINX deployment | 500ms-2s | SSH latency + nginx reload |
| F5 deployment | 2-10s | F5 API response |
| IIS deployment | 3-15s | WinRM latency |
---
## Future Enhancements
- **HSM Support**: Hardware security module integration for ACME account keys
- **Multi-Region**: Control plane federation with local agents
- **HA Control Plane**: Active-active with etcd-backed state
- **Policy Engine**: Advanced renewal and deployment policies
- **Certificate Pinning**: HPKP and pin validation
- **Metrics**: Prometheus integration for observability
---
See [README.md](../README.md) for quick start and [docs/](../) for additional guides.
+726
View File
@@ -0,0 +1,726 @@
# Certctl Connector Development Guide
Connectors extend certctl to integrate with external systems for certificate issuance, deployment, and notifications. This guide covers building custom connectors from scratch.
## Overview
Three types of connectors:
1. **IssuerConnector** — Obtains certificates from PKI systems (ACME, Vault, DigiCert)
2. **TargetConnector** — Deploys certificates to infrastructure (NGINX, F5, IIS, Kubernetes)
3. **NotifierConnector** — Sends notifications about certificate events (Email, Webhooks, Slack)
All connectors:
- Are registered with a unique type identifier
- Accept configuration at initialization
- Are used by the control plane or agents
- Are tested via validation endpoints
---
## IssuerConnector Interface
Issuers obtain certificates from external PKI systems.
### Interface Definition
```go
package issuer
type IssuerConnector interface {
// Validate checks the issuer configuration and connectivity
Validate(ctx context.Context) error
// IssueCertificate requests a certificate for the given domains
IssueCertificate(ctx context.Context, req *IssueRequest) (*CertificateResponse, error)
// RevokeCertificate revokes an issued certificate
RevokeCertificate(ctx context.Context, certPEM []byte) error
// GetStatus returns the status of an issuance request
GetStatus(ctx context.Context, requestID string) (*StatusResponse, error)
}
type IssueRequest struct {
Domains []string // Primary domain + SANs
CSR []byte // Certificate Signing Request (PEM)
ValidityDays int // Requested validity period
NotBefore *time.Time // Optional: not valid before
NotAfter *time.Time // Optional: not valid after
Metadata map[string]string
}
type CertificateResponse struct {
Certificate []byte // Signed certificate (PEM)
CertificateChain []byte // CA chain (PEM)
RequestID string // For status tracking
ExpiresAt time.Time
IssuedAt time.Time
}
```
### Example: Vault PKI Issuer
```go
package vault
import (
"context"
"crypto/x509"
"encoding/pem"
"fmt"
"github.com/hashicorp/vault/api"
)
type VaultConfig struct {
Address string
Token string
PKIPath string // e.g., "pki"
RoleName string // e.g., "example-dot-com"
}
type VaultIssuer struct {
config *VaultConfig
client *api.Client
}
func New(cfg *VaultConfig) (*VaultIssuer, error) {
client, err := api.NewClient(&api.Config{Address: cfg.Address})
if err != nil {
return nil, err
}
client.SetToken(cfg.Token)
return &VaultIssuer{config: cfg, client: client}, nil
}
// Validate tests connectivity and access
func (v *VaultIssuer) Validate(ctx context.Context) error {
_, err := v.client.Auth().Token().LookupSelf()
if err != nil {
return fmt.Errorf("Vault auth failed: %w", err)
}
return nil
}
// IssueCertificate requests a certificate from Vault
func (v *VaultIssuer) IssueCertificate(ctx context.Context, req *issuer.IssueRequest) (
*issuer.CertificateResponse, error) {
// Extract primary domain and SANs
if len(req.Domains) == 0 {
return nil, fmt.Errorf("no domains provided")
}
primaryDomain := req.Domains[0]
altNames := req.Domains[1:]
// Decode CSR
csrBlock, _ := pem.Decode(req.CSR)
if csrBlock == nil {
return nil, fmt.Errorf("invalid CSR format")
}
csr, err := x509.ParseCertificateRequest(csrBlock.Bytes)
if err != nil {
return nil, err
}
// Call Vault PKI issue endpoint
path := fmt.Sprintf("%s/issue/%s", v.config.PKIPath, v.config.RoleName)
data := map[string]interface{}{
"common_name": primaryDomain,
"alt_names": altNames,
"ttl": fmt.Sprintf("%dh", req.ValidityDays*24),
"private_key_format": "pem",
}
secret, err := v.client.Logical().Write(path, data)
if err != nil {
return nil, fmt.Errorf("Vault issue failed: %w", err)
}
// Extract certificate and chain
certPEM := secret.Data["certificate"].(string)
chainPEM := secret.Data["ca_chain"].([]interface{})
caChain := ""
for _, ca := range chainPEM {
caChain += ca.(string) + "\n"
}
return &issuer.CertificateResponse{
Certificate: []byte(certPEM),
CertificateChain: []byte(caChain),
RequestID: secret.Data["request_id"].(string),
ExpiresAt: time.Now().AddDate(0, 0, req.ValidityDays),
IssuedAt: time.Now(),
}, nil
}
// RevokeCertificate revokes a certificate in Vault
func (v *VaultIssuer) RevokeCertificate(ctx context.Context, certPEM []byte) error {
certBlock, _ := pem.Decode(certPEM)
if certBlock == nil {
return fmt.Errorf("invalid certificate format")
}
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return err
}
path := fmt.Sprintf("%s/revoke", v.config.PKIPath)
_, err = v.client.Logical().Write(path, map[string]interface{}{
"certificate": cert.SerialNumber.String(),
})
return err
}
// GetStatus returns the status of an issuance request
func (v *VaultIssuer) GetStatus(ctx context.Context, requestID string) (
*issuer.StatusResponse, error) {
// Vault PKI doesn't have a request status endpoint
// Return immediate success (Vault issues synchronously)
return &issuer.StatusResponse{
Status: "success",
Ready: true,
IssuedAt: time.Now(),
}, nil
}
```
### Registration
Register your issuer in the connector registry:
```go
// internal/connector/issuer/registry.go
package issuer
var registry = map[string]Factory{
"acme": func(cfg Config) (IssuerConnector, error) { return acme.New(&cfg) },
"vault": func(cfg Config) (IssuerConnector, error) { return vault.New(&cfg) },
// Add more issuers here
}
func GetConnector(connectorType string, config Config) (IssuerConnector, error) {
factory, ok := registry[connectorType]
if !ok {
return nil, fmt.Errorf("unknown issuer type: %s", connectorType)
}
return factory(config)
}
```
---
## TargetConnector Interface
Targets deploy certificates to infrastructure.
### Interface Definition
```go
package target
type TargetConnector interface {
// Validate tests connectivity and credentials
Validate(ctx context.Context) error
// Deploy pushes the certificate to the target
Deploy(ctx context.Context, req *DeployRequest) (*DeployResponse, error)
// Remove removes/revokes a certificate from the target
Remove(ctx context.Context, domain string) error
// GetStatus checks the deployment status
GetStatus(ctx context.Context, domain string) (*StatusResponse, error)
}
type DeployRequest struct {
Domain string // Primary domain
Certificate []byte // Signed certificate (PEM)
PrivateKey []byte // Private key (PEM) - optional
CertificateChain []byte // CA chain (PEM)
Metadata map[string]string
}
type DeployResponse struct {
RequestID string
Status string // "success", "pending", "error"
Message string
DeployedAt time.Time
}
type StatusResponse struct {
Status string // "deployed", "pending", "failed"
DeployedAt time.Time
Error string
}
```
### Example: Custom Load Balancer Target
```go
package lb
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
type LBConfig struct {
Host string // Load balancer hostname
Port int // HTTPS API port
Username string
Password string
}
type LoadBalancerTarget struct {
config *LBConfig
client *http.Client
}
func New(cfg *LBConfig) *LoadBalancerTarget {
return &LoadBalancerTarget{
config: cfg,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// Validate tests connectivity
func (lb *LoadBalancerTarget) Validate(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("https://%s:%d/api/health", lb.config.Host, lb.config.Port), nil)
if err != nil {
return err
}
req.SetBasicAuth(lb.config.Username, lb.config.Password)
resp, err := lb.client.Do(req)
if err != nil {
return fmt.Errorf("load balancer unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("load balancer returned %d", resp.StatusCode)
}
return nil
}
// Deploy pushes certificate to the load balancer
func (lb *LoadBalancerTarget) Deploy(ctx context.Context, req *target.DeployRequest) (
*target.DeployResponse, error) {
body := map[string]interface{}{
"domain": req.Domain,
"certificate": string(req.Certificate),
"chain": string(req.CertificateChain),
"key": string(req.PrivateKey),
}
payload, err := json.Marshal(body)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(ctx, "POST",
fmt.Sprintf("https://%s:%d/api/certs/upload", lb.config.Host, lb.config.Port),
bytes.NewReader(payload))
if err != nil {
return nil, err
}
httpReq.SetBasicAuth(lb.config.Username, lb.config.Password)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := lb.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("deployment failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("deployment returned %d", resp.StatusCode)
}
return &target.DeployResponse{
Status: "success",
DeployedAt: time.Now(),
}, nil
}
// Remove deletes a certificate from the load balancer
func (lb *LoadBalancerTarget) Remove(ctx context.Context, domain string) error {
req, err := http.NewRequestWithContext(ctx, "DELETE",
fmt.Sprintf("https://%s:%d/api/certs/%s", lb.config.Host, lb.config.Port, domain), nil)
if err != nil {
return err
}
req.SetBasicAuth(lb.config.Username, lb.config.Password)
resp, err := lb.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("removal returned %d", resp.StatusCode)
}
return nil
}
// GetStatus checks deployment status
func (lb *LoadBalancerTarget) GetStatus(ctx context.Context, domain string) (
*target.StatusResponse, error) {
req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("https://%s:%d/api/certs/%s/status", lb.config.Host, lb.config.Port, domain), nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(lb.config.Username, lb.config.Password)
resp, err := lb.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
return &target.StatusResponse{
Status: result["status"].(string),
}, nil
}
```
---
## NotifierConnector Interface
Notifiers send alerts about certificate events.
### Interface Definition
```go
package notifier
type NotifierConnector interface {
// Validate checks configuration
Validate(ctx context.Context) error
// Send delivers a notification
Send(ctx context.Context, notification *Notification) error
}
type Notification struct {
EventType string // "certificate_issued", "renewal_failed", "deployment_success"
Subject string
Body string
Severity string // "info", "warning", "error"
Metadata map[string]string
}
```
### Example: Slack Notifier
```go
package slack
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
type SlackConfig struct {
WebhookURL string // Slack incoming webhook URL
Channel string // Optional: override channel
Username string // Bot username
}
type SlackNotifier struct {
config *SlackConfig
client *http.Client
}
func New(cfg *SlackConfig) *SlackNotifier {
return &SlackNotifier{
config: cfg,
client: &http.Client{Timeout: 10 * time.Second},
}
}
// Validate checks webhook connectivity
func (s *SlackNotifier) Validate(ctx context.Context) error {
payload := map[string]interface{}{
"text": "Certctl test message",
}
data, _ := json.Marshal(payload)
resp, err := s.client.Post(s.config.WebhookURL, "application/json", bytes.NewReader(data))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("slack webhook returned %d", resp.StatusCode)
}
return nil
}
// Send posts a message to Slack
func (s *SlackNotifier) Send(ctx context.Context, notif *notifier.Notification) error {
color := "good"
if notif.Severity == "error" {
color = "danger"
} else if notif.Severity == "warning" {
color = "warning"
}
payload := map[string]interface{}{
"username": s.config.Username,
"attachments": []map[string]interface{}{
{
"title": notif.Subject,
"text": notif.Body,
"color": color,
"fields": formatMetadata(notif.Metadata),
},
},
}
data, _ := json.Marshal(payload)
resp, err := s.client.Post(s.config.WebhookURL, "application/json", bytes.NewReader(data))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("slack post failed: %d", resp.StatusCode)
}
return nil
}
func formatMetadata(m map[string]string) []map[string]interface{} {
fields := []map[string]interface{}{}
for k, v := range m {
fields = append(fields, map[string]interface{}{
"title": k,
"value": v,
"short": true,
})
}
return fields
}
```
---
## Testing Connectors
### Unit Tests
```go
package vault
import (
"context"
"testing"
)
func TestValidate(t *testing.T) {
cfg := &VaultConfig{
Address: "http://localhost:8200",
Token: "test-token",
}
issuer := New(cfg)
err := issuer.Validate(context.Background())
if err == nil {
t.Fatal("expected error for invalid token")
}
}
func TestIssueCertificate(t *testing.T) {
// Mock Vault responses or use Vault test harness
cfg := &VaultConfig{/* ... */}
issuer := New(cfg)
req := &issuer.IssueRequest{
Domains: []string{"example.com"},
CSR: testCSR,
ValidityDays: 90,
}
resp, err := issuer.IssueCertificate(context.Background(), req)
if err != nil {
t.Fatalf("issuance failed: %v", err)
}
if len(resp.Certificate) == 0 {
t.Fatal("no certificate returned")
}
}
```
### Integration Tests
```bash
# Start dependent service
docker run -d --name vault -p 8200:8200 vault:latest server -dev
# Run tests
go test -tags=integration ./internal/connector/issuer/vault
# Cleanup
docker rm -f vault
```
### Validation Endpoints
Test connectors via the API:
```bash
# Validate an issuer
curl -X POST http://localhost:8443/api/v1/issuers/validate \
-H "Content-Type: application/json" \
-d '{
"type": "vault",
"config": {
"address": "http://vault.example.com:8200",
"token": "s.xxxxxxx"
}
}'
# Validate a target
curl -X POST http://localhost:8443/api/v1/targets/validate \
-H "Content-Type: application/json" \
-d '{
"type": "nginx",
"config": {
"host": "web01.example.com",
"ssh_user": "deploy"
}
}'
```
---
## Registering Custom Connectors
### 1. Create Connector Package
```
internal/connector/issuer/myissuer/
├── issuer.go # Implementation
└── config.go # Configuration validation
```
### 2. Implement Interface
```go
package myissuer
type MyIssuer struct {
config *Config
}
func (m *MyIssuer) Validate(ctx context.Context) error {
// Validation logic
}
func (m *MyIssuer) IssueCertificate(ctx context.Context, req *issuer.IssueRequest) (*issuer.CertificateResponse, error) {
// Issuance logic
}
```
### 3. Register in Factory
```go
// internal/connector/issuer/factory.go
import "github.com/shankar0123/certctl/internal/connector/issuer/myissuer"
var factories = map[string]ConnectorFactory{
"myissuer": func(cfg interface{}) (IssuerConnector, error) {
return myissuer.New(cfg.(*myissuer.Config))
},
}
```
### 4. Add Configuration Schema
```go
// Validate connector configuration at registration
func ValidateConfig(connectorType string, config interface{}) error {
switch connectorType {
case "myissuer":
cfg := config.(*MyConfig)
if cfg.Host == "" {
return fmt.Errorf("host is required")
}
if cfg.Token == "" {
return fmt.Errorf("token is required")
}
}
return nil
}
```
### 5. Use in Your Application
```go
// Get connector
connector, err := issuer.GetConnector("myissuer", config)
// Issue certificate
resp, err := connector.IssueCertificate(ctx, issueReq)
```
---
## Best Practices
1. **Error Handling** — Return descriptive errors with context
2. **Timeout Management** — Always use context with timeouts
3. **Validation** — Validate configuration during Validate()
4. **Retry Logic** — Handle transient failures gracefully
5. **Logging** — Log all operations for debugging
6. **Testing** — Provide unit and integration tests
7. **Documentation** — Document configuration options and limitations
8. **Security** — Never log sensitive data (tokens, keys, passwords)
---
## Contributing Connectors
To contribute a connector to certctl:
1. Fork the repository
2. Create a feature branch: `git checkout -b feat/my-connector`
3. Add connector implementation with tests
4. Update [README.md](../README.md#supported-integrations)
5. Add documentation to [docs/](.)
6. Submit a pull request
Connectors must:
- Implement the full interface
- Include unit tests (>80% coverage)
- Have integration tests (if applicable)
- Include configuration examples
- Document any prerequisites (API keys, credentials)
---
For more information, see:
- [Architecture Guide](architecture.md#connector-architecture)
- [API Reference](../README.md#api-overview)
- [Contributing Guidelines](../CONTRIBUTING.md) (coming soon)
+526
View File
@@ -0,0 +1,526 @@
# Certctl Quick Start Guide
Get a working certctl deployment from zero to managing certificates in 10 minutes.
## Prerequisites
- **Docker** and **Docker Compose** (recommended), or:
- Go 1.22+
- PostgreSQL 14+
- psql CLI tool
## Option 1: Docker Compose (Fastest)
### 1. Clone & Setup
```bash
git clone https://github.com/shankar0123/certctl.git
cd certctl
# Copy environment template
cp .env.example .env
# Optional: edit .env for custom settings
# nano .env
```
### 2. Start the Stack
```bash
make docker-up
# Wait for services to be healthy (~30 seconds)
docker-compose -f deploy/docker-compose.yml ps
```
You should see:
```
NAME STATUS
certctl-postgres Up (healthy)
certctl-server Up (healthy)
certctl-agent Up
```
### 3. Verify Health
```bash
# Server health check
curl http://localhost:8443/health
# Expected: {"status":"healthy"}
# Container logs
make docker-logs-server
```
---
## Option 2: Manual Build & Run
### 1. Clone & Dependencies
```bash
git clone https://github.com/shankar0123/certctl.git
cd certctl
go mod download
```
### 2. Setup PostgreSQL
```bash
# Create database and user
psql -U postgres -h localhost << EOF
CREATE USER certctl WITH PASSWORD 'certctl';
CREATE DATABASE certctl OWNER certctl;
GRANT ALL PRIVILEGES ON DATABASE certctl TO certctl;
EOF
# Verify connection
psql -h localhost -U certctl -d certctl -c "SELECT 1"
```
### 3. Run Migrations
```bash
# Install migrate tool
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
# Set database URL
export DB_URL="postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable"
# Run migrations
make migrate-up
```
### 4. Start Server
```bash
# Terminal 1: Server
make run
# Expected output:
# 2024-03-14T10:30:00Z server starting version=1.0.0 server_port=8443
```
### 5. Start Agent (Optional)
```bash
# Terminal 2: Agent
export SERVER_URL=http://localhost:8443
export API_KEY=default-api-key
./bin/agent
# Expected output: Agent connecting to http://localhost:8443
```
---
## Walk-Through: Create Your First Certificate
### Step 1: Verify API Access
```bash
curl -X GET http://localhost:8443/health
```
Response:
```json
{"status":"healthy"}
```
### Step 2: Create a Team (Optional)
Teams organize ownership and auditing. For this quick start, we'll use a default team.
```bash
TEAM_ID="default"
```
### Step 3: Register an ACME Issuer
Create a certificate issuer configuration (Let's Encrypt staging for this demo):
```bash
curl -X POST http://localhost:8443/api/v1/issuers \
-H "Content-Type: application/json" \
-d '{
"team_id": "default",
"name": "lets-encrypt-staging",
"type": "acme",
"config": {
"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory",
"email": "admin@example.com"
}
}'
```
Response (save the `issuer_id`):
```json
{
"id": "issuer-abc123",
"name": "lets-encrypt-staging",
"type": "acme",
"created_at": "2024-03-14T10:30:00Z"
}
```
Store the issuer ID:
```bash
ISSUER_ID="issuer-abc123"
```
### Step 4: Register an Agent
Agents handle certificate requests and deployment. Register one:
```bash
curl -X POST http://localhost:8443/api/v1/agents \
-H "Content-Type: application/json" \
-d '{
"team_id": "default",
"name": "quickstart-agent",
"description": "Local development agent"
}'
```
Response (save the `api_key` and `id`):
```json
{
"id": "agent-xyz789",
"name": "quickstart-agent",
"api_key": "ey...",
"registered_at": "2024-03-14T10:30:00Z",
"status": "registered"
}
```
Store the agent details:
```bash
AGENT_ID="agent-xyz789"
AGENT_API_KEY="ey..."
```
### Step 5: Create a Deployment Target
Targets are where certificates will be deployed (NGINX, F5, etc.). For this demo, we'll skip actual deployment:
```bash
curl -X POST http://localhost:8443/api/v1/targets \
-H "Content-Type: application/json" \
-d '{
"team_id": "default",
"agent_id": "'$AGENT_ID'",
"name": "example-nginx",
"type": "nginx",
"config": {
"host": "nginx.example.com",
"ssh_user": "deploy",
"ssh_key": "/path/to/key",
"cert_path": "/etc/nginx/ssl"
}
}'
```
Response:
```json
{
"id": "target-def456",
"name": "example-nginx",
"agent_id": "agent-xyz789",
"type": "nginx",
"status": "pending_validation"
}
```
Store the target ID:
```bash
TARGET_ID="target-def456"
```
### Step 6: Create a Managed Certificate
Now the main event—request a certificate to be issued and managed:
```bash
curl -X POST http://localhost:8443/api/v1/certificates \
-H "Content-Type: application/json" \
-d '{
"team_id": "default",
"domain": "api.example.com",
"issuer_id": "'$ISSUER_ID'",
"target_ids": ["'$TARGET_ID'"],
"renewal_days_before": 30,
"auto_deploy": true
}'
```
Response:
```json
{
"id": "cert-ghi012",
"domain": "api.example.com",
"issuer_id": "issuer-abc123",
"status": "pending",
"created_at": "2024-03-14T10:30:00Z",
"expires_at": null,
"renewal_at": null
}
```
Store the certificate ID:
```bash
CERT_ID="cert-ghi012"
```
### Step 7: Check Certificate Status
Poll the certificate status as issuance progresses:
```bash
curl -X GET http://localhost:8443/api/v1/certificates/$CERT_ID \
-H "Content-Type: application/json"
```
Response (will change over time):
```json
{
"id": "cert-ghi012",
"domain": "api.example.com",
"status": "issued",
"expires_at": "2024-06-12T10:30:00Z",
"issued_by": "issuer-abc123",
"deployed_to": [
{
"target_id": "target-def456",
"status": "success",
"deployed_at": "2024-03-14T10:30:30Z"
}
]
}
```
### Step 8: View Audit Trail
See all actions related to your certificate:
```bash
curl -X GET "http://localhost:8443/api/v1/audit/logs?resource_id=$CERT_ID" \
-H "Content-Type: application/json"
```
Response:
```json
{
"logs": [
{
"id": "audit-001",
"timestamp": "2024-03-14T10:30:00Z",
"actor": {
"type": "api",
"id": "client-001"
},
"action": "certificate_created",
"resource": {
"type": "certificate",
"id": "cert-ghi012"
},
"status": "success"
},
{
"id": "audit-002",
"timestamp": "2024-03-14T10:30:10Z",
"actor": {
"type": "agent",
"id": "agent-xyz789"
},
"action": "certificate_issued",
"resource": {
"type": "certificate",
"id": "cert-ghi012"
},
"status": "success",
"details": {
"issuer": "lets-encrypt-staging",
"expiry": "2024-06-12"
}
},
{
"id": "audit-003",
"timestamp": "2024-03-14T10:30:25Z",
"actor": {
"type": "system",
"id": "scheduler"
},
"action": "certificate_deployed",
"resource": {
"type": "certificate",
"id": "cert-ghi012"
},
"status": "success",
"details": {
"deployed_to": "example-nginx"
}
}
]
}
```
### Step 9: Trigger Manual Renewal (Optional)
To manually trigger certificate renewal:
```bash
curl -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/renew \
-H "Content-Type: application/json"
```
The scheduler will automatically check for renewals every hour. Certificates within 30 days of expiry are renewed automatically.
---
## Development Mode
For development with hot reload and database browser:
```bash
# Install tools
make install-tools
# Start dev stack (includes PgAdmin at localhost:5050)
make docker-up-dev
# View logs
make docker-logs-server
make docker-logs-agent
# Admin credentials for PgAdmin:
# Email: admin@example.com (default, see .env)
# Password: admin (default, see .env)
# Access PgAdmin: http://localhost:5050
# Add server: postgres, port 5432, user certctl, password certctl
```
---
## Testing the Flow End-to-End
Here's a complete script to test the full flow:
```bash
#!/bin/bash
set -e
API="http://localhost:8443"
TEAM="default"
echo "1. Creating ACME issuer..."
ISSUER=$(curl -s -X POST $API/api/v1/issuers \
-H "Content-Type: application/json" \
-d '{
"team_id": "'$TEAM'",
"name": "letsencrypt-staging",
"type": "acme",
"config": {
"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory",
"email": "test@example.com"
}
}' | jq -r '.id')
echo " Issuer: $ISSUER"
echo "2. Registering agent..."
AGENT=$(curl -s -X POST $API/api/v1/agents \
-H "Content-Type: application/json" \
-d '{
"team_id": "'$TEAM'",
"name": "test-agent"
}' | jq -r '.id')
echo " Agent: $AGENT"
echo "3. Creating certificate..."
CERT=$(curl -s -X POST $API/api/v1/certificates \
-H "Content-Type: application/json" \
-d '{
"team_id": "'$TEAM'",
"domain": "test-'$(date +%s)'.example.com",
"issuer_id": "'$ISSUER'",
"renewal_days_before": 30
}' | jq -r '.id')
echo " Certificate: $CERT"
echo "4. Checking status..."
curl -s -X GET $API/api/v1/certificates/$CERT | jq '.status'
echo "5. Viewing audit trail..."
curl -s -X GET "$API/api/v1/audit/logs?resource_id=$CERT" | jq '.logs | length'
echo "Done!"
```
Save as `test.sh`, make executable, and run:
```bash
chmod +x test.sh
./test.sh
```
---
## Common Issues
### Server Won't Start
```bash
# Check database connection
psql -h localhost -U certctl -d certctl -c "SELECT 1"
# View logs
make docker-logs-server
# Check environment
env | grep DB_
```
### Agent Can't Connect
```bash
# Verify server is running
curl http://localhost:8443/health
# Check agent logs
docker logs certctl-agent
# Verify API key is correct
echo $AGENT_API_KEY
```
### Certificate Stays "Pending"
```bash
# Check if agent is registered
curl http://localhost:8443/api/v1/agents
# Check agent logs for errors
make docker-logs-agent
# View certificate details
curl http://localhost:8443/api/v1/certificates/$CERT_ID
# Check audit trail
curl "http://localhost:8443/api/v1/audit/logs?resource_id=$CERT_ID"
```
---
## Next Steps
1. **Read** [docs/architecture.md](architecture.md) to understand the design
2. **Explore** the [API](../README.md#api-overview) for more operations
3. **Build** a [custom connector](connectors.md) for your infrastructure
4. **Deploy** to production using [docs/k8s-deployment.md](k8s-deployment.md) (coming soon)
---
For more help, see [README.md](../README.md#troubleshooting) or open an issue on GitHub.
+5
View File
@@ -0,0 +1,5 @@
module github.com/shankar0123/certctl
go 1.22.5
require github.com/google/uuid v1.6.0
+2
View File
@@ -0,0 +1,2 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+233
View File
@@ -0,0 +1,233 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// AgentService defines the service interface for agent operations.
type AgentService interface {
ListAgents(page, perPage int) ([]domain.Agent, int64, error)
GetAgent(id string) (*domain.Agent, error)
RegisterAgent(agent domain.Agent) (*domain.Agent, error)
Heartbeat(agentID string) error
CSRSubmit(agentID string, csrPEM string) (string, error)
CertificatePickup(agentID, certID string) (string, error)
}
// AgentHandler handles HTTP requests for agent operations.
type AgentHandler struct {
svc AgentService
}
// NewAgentHandler creates a new AgentHandler with a service dependency.
func NewAgentHandler(svc AgentService) AgentHandler {
return AgentHandler{svc: svc}
}
// ListAgents lists all registered agents.
// GET /api/v1/agents?page=1&per_page=50
func (h AgentHandler) ListAgents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
agents, total, err := h.svc.ListAgents(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list agents", requestID)
return
}
response := PagedResponse{
Data: agents,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetAgent retrieves a single agent by ID.
// GET /api/v1/agents/{id}
func (h AgentHandler) GetAgent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Agent ID is required", requestID)
return
}
id = parts[0]
agent, err := h.svc.GetAgent(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
return
}
JSON(w, http.StatusOK, agent)
}
// RegisterAgent registers a new agent.
// POST /api/v1/agents
func (h AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var agent domain.Agent
if err := json.NewDecoder(r.Body).Decode(&agent); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
created, err := h.svc.RegisterAgent(agent)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// Heartbeat records a heartbeat from an agent.
// POST /api/v1/agents/{id}/heartbeat
func (h AgentHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
// Extract agent ID from path /api/v1/agents/{id}/heartbeat
path := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Agent ID is required", requestID)
return
}
agentID := parts[0]
if err := h.svc.Heartbeat(agentID); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to record heartbeat", requestID)
return
}
response := map[string]string{
"status": "heartbeat_recorded",
}
JSON(w, http.StatusOK, response)
}
// AgentCSRSubmit receives a Certificate Signing Request from an agent.
// POST /api/v1/agents/{id}/csr
func (h AgentHandler) AgentCSRSubmit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
// Extract agent ID from path /api/v1/agents/{id}/csr
path := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Agent ID is required", requestID)
return
}
agentID := parts[0]
var req struct {
CSRPEM string `json:"csr_pem"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
if req.CSRPEM == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "CSR PEM is required", requestID)
return
}
jobID, err := h.svc.CSRSubmit(agentID, req.CSRPEM)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to submit CSR", requestID)
return
}
response := map[string]string{
"job_id": jobID,
"status": "csr_received",
}
JSON(w, http.StatusAccepted, response)
}
// AgentCertificatePickup allows an agent to retrieve an issued certificate.
// GET /api/v1/agents/{id}/certificates/{cert_id}
func (h AgentHandler) AgentCertificatePickup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
// Extract agent ID and certificate ID from path /api/v1/agents/{id}/certificates/{cert_id}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/")
parts := strings.Split(path, "/")
if len(parts) < 4 || parts[0] == "" || parts[2] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Agent ID and Certificate ID are required", requestID)
return
}
agentID := parts[0]
certID := parts[2]
certPEM, err := h.svc.CertificatePickup(agentID, certID)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found or not ready", requestID)
return
}
response := map[string]string{
"certificate_pem": certPEM,
}
JSON(w, http.StatusOK, response)
}
+93
View File
@@ -0,0 +1,93 @@
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// AuditService defines the service interface for audit event operations.
type AuditService interface {
ListAuditEvents(page, perPage int) ([]domain.AuditEvent, int64, error)
GetAuditEvent(id string) (*domain.AuditEvent, error)
}
// AuditHandler handles HTTP requests for audit event operations.
type AuditHandler struct {
svc AuditService
}
// NewAuditHandler creates a new AuditHandler with a service dependency.
func NewAuditHandler(svc AuditService) AuditHandler {
return AuditHandler{svc: svc}
}
// ListAuditEvents lists audit events.
// GET /api/v1/audit?page=1&per_page=50
func (h AuditHandler) ListAuditEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
events, total, err := h.svc.ListAuditEvents(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list audit events", requestID)
return
}
response := PagedResponse{
Data: events,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetAuditEvent retrieves a single audit event by ID.
// GET /api/v1/audit/{id}
func (h AuditHandler) GetAuditEvent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/audit/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Audit event ID is required", requestID)
return
}
id = parts[0]
event, err := h.svc.GetAuditEvent(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Audit event not found", requestID)
return
}
JSON(w, http.StatusOK, event)
}
+306
View File
@@ -0,0 +1,306 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// CertificateService defines the service interface for certificate operations.
type CertificateService interface {
ListCertificates(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error)
GetCertificate(id string) (*domain.ManagedCertificate, error)
CreateCertificate(cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
UpdateCertificate(id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
ArchiveCertificate(id string) error
GetCertificateVersions(certID string, page, perPage int) ([]domain.CertificateVersion, int64, error)
TriggerRenewal(certID string) error
TriggerDeployment(certID string, targetID string) error
}
// CertificateHandler handles HTTP requests for certificate operations.
type CertificateHandler struct {
svc CertificateService
}
// NewCertificateHandler creates a new CertificateHandler with a service dependency.
func NewCertificateHandler(svc CertificateService) CertificateHandler {
return CertificateHandler{svc: svc}
}
// ListCertificates lists certificates with optional filtering.
// GET /api/v1/certificates?status=Active&environment=prod&owner_id=...&team_id=...&issuer_id=...&page=1&per_page=50
func (h CertificateHandler) ListCertificates(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
// Parse query parameters
query := r.URL.Query()
status := query.Get("status")
environment := query.Get("environment")
ownerID := query.Get("owner_id")
teamID := query.Get("team_id")
issuerID := query.Get("issuer_id")
page := 1
perPage := 50
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
certs, total, err := h.svc.ListCertificates(status, environment, ownerID, teamID, issuerID, page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list certificates", requestID)
return
}
response := PagedResponse{
Data: certs,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetCertificate retrieves a single certificate by ID.
// GET /api/v1/certificates/{id}
func (h CertificateHandler) GetCertificate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/certificates/")
if id == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
cert, err := h.svc.GetCertificate(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
JSON(w, http.StatusOK, cert)
}
// CreateCertificate creates a new certificate.
// POST /api/v1/certificates
func (h CertificateHandler) CreateCertificate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var cert domain.ManagedCertificate
if err := json.NewDecoder(r.Body).Decode(&cert); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
created, err := h.svc.CreateCertificate(cert)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create certificate", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// UpdateCertificate updates an existing certificate.
// PUT /api/v1/certificates/{id}
func (h CertificateHandler) UpdateCertificate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/certificates/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
id = parts[0]
var cert domain.ManagedCertificate
if err := json.NewDecoder(r.Body).Decode(&cert); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
updated, err := h.svc.UpdateCertificate(id, cert)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update certificate", requestID)
return
}
JSON(w, http.StatusOK, updated)
}
// ArchiveCertificate archives a certificate (soft delete).
// DELETE /api/v1/certificates/{id}
func (h CertificateHandler) ArchiveCertificate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/certificates/")
if id == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
if err := h.svc.ArchiveCertificate(id); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to archive certificate", requestID)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetCertificateVersions retrieves version history for a certificate.
// GET /api/v1/certificates/{id}/versions
func (h CertificateHandler) GetCertificateVersions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
// Extract certificate ID from path /api/v1/certificates/{id}/versions
path := strings.TrimPrefix(r.URL.Path, "/api/v1/certificates/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
certID := parts[0]
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
versions, total, err := h.svc.GetCertificateVersions(certID, page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
response := PagedResponse{
Data: versions,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// TriggerRenewal triggers manual renewal for a certificate.
// POST /api/v1/certificates/{id}/renew
func (h CertificateHandler) TriggerRenewal(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
// Extract certificate ID from path /api/v1/certificates/{id}/renew
path := strings.TrimPrefix(r.URL.Path, "/api/v1/certificates/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
certID := parts[0]
if err := h.svc.TriggerRenewal(certID); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to trigger renewal", requestID)
return
}
response := map[string]string{
"status": "renewal_triggered",
}
JSON(w, http.StatusAccepted, response)
}
// TriggerDeployment triggers deployment of a certificate to targets.
// POST /api/v1/certificates/{id}/deploy
func (h CertificateHandler) TriggerDeployment(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
// Extract certificate ID from path /api/v1/certificates/{id}/deploy
path := strings.TrimPrefix(r.URL.Path, "/api/v1/certificates/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
certID := parts[0]
// Optional: parse request body for specific target ID
var req struct {
TargetID string `json:"target_id,omitempty"`
}
if r.Header.Get("Content-Type") == "application/json" {
json.NewDecoder(r.Body).Decode(&req)
}
if err := h.svc.TriggerDeployment(certID, req.TargetID); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to trigger deployment", requestID)
return
}
response := map[string]string{
"status": "deployment_triggered",
}
JSON(w, http.StatusAccepted, response)
}
+43
View File
@@ -0,0 +1,43 @@
package handler
import (
"net/http"
)
// HealthHandler handles health and readiness check endpoints.
type HealthHandler struct{}
// NewHealthHandler creates a new HealthHandler.
func NewHealthHandler() HealthHandler {
return HealthHandler{}
}
// Health responds with a simple health check indicating the service is alive.
// GET /health
func (h HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
response := map[string]string{
"status": "healthy",
}
JSON(w, http.StatusOK, response)
}
// Ready responds with readiness status, indicating whether the service is ready to handle requests.
// GET /ready
func (h HealthHandler) Ready(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
response := map[string]string{
"status": "ready",
}
JSON(w, http.StatusOK, response)
}
+209
View File
@@ -0,0 +1,209 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// IssuerService defines the service interface for issuer operations.
type IssuerService interface {
ListIssuers(page, perPage int) ([]domain.Issuer, int64, error)
GetIssuer(id string) (*domain.Issuer, error)
CreateIssuer(issuer domain.Issuer) (*domain.Issuer, error)
UpdateIssuer(id string, issuer domain.Issuer) (*domain.Issuer, error)
DeleteIssuer(id string) error
TestConnection(id string) error
}
// IssuerHandler handles HTTP requests for issuer operations.
type IssuerHandler struct {
svc IssuerService
}
// NewIssuerHandler creates a new IssuerHandler with a service dependency.
func NewIssuerHandler(svc IssuerService) IssuerHandler {
return IssuerHandler{svc: svc}
}
// ListIssuers lists all configured issuers.
// GET /api/v1/issuers?page=1&per_page=50
func (h IssuerHandler) ListIssuers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
issuers, total, err := h.svc.ListIssuers(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list issuers", requestID)
return
}
response := PagedResponse{
Data: issuers,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetIssuer retrieves a single issuer by ID.
// GET /api/v1/issuers/{id}
func (h IssuerHandler) GetIssuer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/issuers/")
if id == "" || strings.Contains(id, "/") {
ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID is required", requestID)
return
}
issuer, err := h.svc.GetIssuer(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Issuer not found", requestID)
return
}
JSON(w, http.StatusOK, issuer)
}
// CreateIssuer creates a new issuer configuration.
// POST /api/v1/issuers
func (h IssuerHandler) CreateIssuer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var issuer domain.Issuer
if err := json.NewDecoder(r.Body).Decode(&issuer); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
created, err := h.svc.CreateIssuer(issuer)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create issuer", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// UpdateIssuer updates an existing issuer configuration.
// PUT /api/v1/issuers/{id}
func (h IssuerHandler) UpdateIssuer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/issuers/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID is required", requestID)
return
}
id = parts[0]
var issuer domain.Issuer
if err := json.NewDecoder(r.Body).Decode(&issuer); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
updated, err := h.svc.UpdateIssuer(id, issuer)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update issuer", requestID)
return
}
JSON(w, http.StatusOK, updated)
}
// DeleteIssuer deletes an issuer configuration.
// DELETE /api/v1/issuers/{id}
func (h IssuerHandler) DeleteIssuer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/issuers/")
if id == "" || strings.Contains(id, "/") {
ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID is required", requestID)
return
}
if err := h.svc.DeleteIssuer(id); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete issuer", requestID)
return
}
w.WriteHeader(http.StatusNoContent)
}
// TestConnection tests the connection to an issuer.
// POST /api/v1/issuers/{id}/test
func (h IssuerHandler) TestConnection(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
// Extract issuer ID from path /api/v1/issuers/{id}/test
path := strings.TrimPrefix(r.URL.Path, "/api/v1/issuers/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID is required", requestID)
return
}
issuerID := parts[0]
if err := h.svc.TestConnection(issuerID); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Connection test failed", requestID)
return
}
response := map[string]string{
"status": "connection_successful",
}
JSON(w, http.StatusOK, response)
}
+128
View File
@@ -0,0 +1,128 @@
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// JobService defines the service interface for job operations.
type JobService interface {
ListJobs(status, jobType string, page, perPage int) ([]domain.Job, int64, error)
GetJob(id string) (*domain.Job, error)
CancelJob(id string) error
}
// JobHandler handles HTTP requests for job operations.
type JobHandler struct {
svc JobService
}
// NewJobHandler creates a new JobHandler with a service dependency.
func NewJobHandler(svc JobService) JobHandler {
return JobHandler{svc: svc}
}
// ListJobs lists jobs with optional filtering by status and type.
// GET /api/v1/jobs?status=Pending&type=Renewal&page=1&per_page=50
func (h JobHandler) ListJobs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
query := r.URL.Query()
status := query.Get("status")
jobType := query.Get("type")
page := 1
perPage := 50
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
jobs, total, err := h.svc.ListJobs(status, jobType, page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list jobs", requestID)
return
}
response := PagedResponse{
Data: jobs,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetJob retrieves a single job by ID.
// GET /api/v1/jobs/{id}
func (h JobHandler) GetJob(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/jobs/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Job ID is required", requestID)
return
}
id = parts[0]
job, err := h.svc.GetJob(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
return
}
JSON(w, http.StatusOK, job)
}
// CancelJob cancels a job.
// POST /api/v1/jobs/{id}/cancel
func (h JobHandler) CancelJob(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
// Extract job ID from path /api/v1/jobs/{id}/cancel
path := strings.TrimPrefix(r.URL.Path, "/api/v1/jobs/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Job ID is required", requestID)
return
}
jobID := parts[0]
if err := h.svc.CancelJob(jobID); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to cancel job", requestID)
return
}
response := map[string]string{
"status": "job_cancelled",
}
JSON(w, http.StatusOK, response)
}
+125
View File
@@ -0,0 +1,125 @@
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// NotificationService defines the service interface for notification operations.
type NotificationService interface {
ListNotifications(page, perPage int) ([]domain.NotificationEvent, int64, error)
GetNotification(id string) (*domain.NotificationEvent, error)
MarkAsRead(id string) error
}
// NotificationHandler handles HTTP requests for notification operations.
type NotificationHandler struct {
svc NotificationService
}
// NewNotificationHandler creates a new NotificationHandler with a service dependency.
func NewNotificationHandler(svc NotificationService) NotificationHandler {
return NotificationHandler{svc: svc}
}
// ListNotifications lists notifications.
// GET /api/v1/notifications?page=1&per_page=50
func (h NotificationHandler) ListNotifications(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
notifications, total, err := h.svc.ListNotifications(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list notifications", requestID)
return
}
response := PagedResponse{
Data: notifications,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetNotification retrieves a single notification by ID.
// GET /api/v1/notifications/{id}
func (h NotificationHandler) GetNotification(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/notifications/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Notification ID is required", requestID)
return
}
id = parts[0]
notification, err := h.svc.GetNotification(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Notification not found", requestID)
return
}
JSON(w, http.StatusOK, notification)
}
// MarkAsRead marks a notification as read.
// POST /api/v1/notifications/{id}/read
func (h NotificationHandler) MarkAsRead(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
// Extract notification ID from path /api/v1/notifications/{id}/read
path := strings.TrimPrefix(r.URL.Path, "/api/v1/notifications/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Notification ID is required", requestID)
return
}
notificationID := parts[0]
if err := h.svc.MarkAsRead(notificationID); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to mark notification as read", requestID)
return
}
response := map[string]string{
"status": "marked_as_read",
}
JSON(w, http.StatusOK, response)
}
+181
View File
@@ -0,0 +1,181 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// OwnerService defines the service interface for owner operations.
type OwnerService interface {
ListOwners(page, perPage int) ([]domain.Owner, int64, error)
GetOwner(id string) (*domain.Owner, error)
CreateOwner(owner domain.Owner) (*domain.Owner, error)
UpdateOwner(id string, owner domain.Owner) (*domain.Owner, error)
DeleteOwner(id string) error
}
// OwnerHandler handles HTTP requests for owner operations.
type OwnerHandler struct {
svc OwnerService
}
// NewOwnerHandler creates a new OwnerHandler with a service dependency.
func NewOwnerHandler(svc OwnerService) OwnerHandler {
return OwnerHandler{svc: svc}
}
// ListOwners lists all owners.
// GET /api/v1/owners?page=1&per_page=50
func (h OwnerHandler) ListOwners(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
owners, total, err := h.svc.ListOwners(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list owners", requestID)
return
}
response := PagedResponse{
Data: owners,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetOwner retrieves a single owner by ID.
// GET /api/v1/owners/{id}
func (h OwnerHandler) GetOwner(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/owners/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Owner ID is required", requestID)
return
}
id = parts[0]
owner, err := h.svc.GetOwner(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Owner not found", requestID)
return
}
JSON(w, http.StatusOK, owner)
}
// CreateOwner creates a new owner.
// POST /api/v1/owners
func (h OwnerHandler) CreateOwner(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var owner domain.Owner
if err := json.NewDecoder(r.Body).Decode(&owner); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
created, err := h.svc.CreateOwner(owner)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create owner", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// UpdateOwner updates an existing owner.
// PUT /api/v1/owners/{id}
func (h OwnerHandler) UpdateOwner(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/owners/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Owner ID is required", requestID)
return
}
id = parts[0]
var owner domain.Owner
if err := json.NewDecoder(r.Body).Decode(&owner); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
updated, err := h.svc.UpdateOwner(id, owner)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update owner", requestID)
return
}
JSON(w, http.StatusOK, updated)
}
// DeleteOwner deletes an owner.
// DELETE /api/v1/owners/{id}
func (h OwnerHandler) DeleteOwner(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/owners/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Owner ID is required", requestID)
return
}
id = parts[0]
if err := h.svc.DeleteOwner(id); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete owner", requestID)
return
}
w.WriteHeader(http.StatusNoContent)
}
+231
View File
@@ -0,0 +1,231 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// PolicyService defines the service interface for policy rule operations.
type PolicyService interface {
ListPolicies(page, perPage int) ([]domain.PolicyRule, int64, error)
GetPolicy(id string) (*domain.PolicyRule, error)
CreatePolicy(policy domain.PolicyRule) (*domain.PolicyRule, error)
UpdatePolicy(id string, policy domain.PolicyRule) (*domain.PolicyRule, error)
DeletePolicy(id string) error
ListViolations(policyID string, page, perPage int) ([]domain.PolicyViolation, int64, error)
}
// PolicyHandler handles HTTP requests for policy rule operations.
type PolicyHandler struct {
svc PolicyService
}
// NewPolicyHandler creates a new PolicyHandler with a service dependency.
func NewPolicyHandler(svc PolicyService) PolicyHandler {
return PolicyHandler{svc: svc}
}
// ListPolicies lists all policy rules.
// GET /api/v1/policies?page=1&per_page=50
func (h PolicyHandler) ListPolicies(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
policies, total, err := h.svc.ListPolicies(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list policies", requestID)
return
}
response := PagedResponse{
Data: policies,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetPolicy retrieves a single policy rule by ID.
// GET /api/v1/policies/{id}
func (h PolicyHandler) GetPolicy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/policies/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Policy ID is required", requestID)
return
}
id = parts[0]
policy, err := h.svc.GetPolicy(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Policy not found", requestID)
return
}
JSON(w, http.StatusOK, policy)
}
// CreatePolicy creates a new policy rule.
// POST /api/v1/policies
func (h PolicyHandler) CreatePolicy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var policy domain.PolicyRule
if err := json.NewDecoder(r.Body).Decode(&policy); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
created, err := h.svc.CreatePolicy(policy)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create policy", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// UpdatePolicy updates an existing policy rule.
// PUT /api/v1/policies/{id}
func (h PolicyHandler) UpdatePolicy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/policies/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Policy ID is required", requestID)
return
}
id = parts[0]
var policy domain.PolicyRule
if err := json.NewDecoder(r.Body).Decode(&policy); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
updated, err := h.svc.UpdatePolicy(id, policy)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update policy", requestID)
return
}
JSON(w, http.StatusOK, updated)
}
// DeletePolicy deletes a policy rule.
// DELETE /api/v1/policies/{id}
func (h PolicyHandler) DeletePolicy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/policies/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Policy ID is required", requestID)
return
}
id = parts[0]
if err := h.svc.DeletePolicy(id); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete policy", requestID)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ListViolations lists policy violations for a specific policy rule.
// GET /api/v1/policies/{id}/violations?page=1&per_page=50
func (h PolicyHandler) ListViolations(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
// Extract policy ID from path /api/v1/policies/{id}/violations
path := strings.TrimPrefix(r.URL.Path, "/api/v1/policies/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Policy ID is required", requestID)
return
}
policyID := parts[0]
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
violations, total, err := h.svc.ListViolations(policyID, page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list violations", requestID)
return
}
response := PagedResponse{
Data: violations,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
+51
View File
@@ -0,0 +1,51 @@
package handler
import (
"encoding/json"
"net/http"
)
// PagedResponse represents a paginated API response.
type PagedResponse struct {
Data interface{} `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}
// ErrorResponse represents a standard error response.
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
RequestID string `json:"request_id,omitempty"`
}
// JSON writes a JSON response with the given status code and data.
func JSON(w http.ResponseWriter, status int, data interface{}) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
return json.NewEncoder(w).Encode(data)
}
// Error writes a JSON error response with the given status code and message.
func Error(w http.ResponseWriter, status int, message string) error {
errResp := ErrorResponse{
Error: http.StatusText(status),
Message: message,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
return json.NewEncoder(w).Encode(errResp)
}
// ErrorWithRequestID writes a JSON error response including a request ID.
func ErrorWithRequestID(w http.ResponseWriter, status int, message, requestID string) error {
errResp := ErrorResponse{
Error: http.StatusText(status),
Message: message,
RequestID: requestID,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
return json.NewEncoder(w).Encode(errResp)
}
+177
View File
@@ -0,0 +1,177 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// TargetService defines the service interface for deployment target operations.
type TargetService interface {
ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error)
GetTarget(id string) (*domain.DeploymentTarget, error)
CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
DeleteTarget(id string) error
}
// TargetHandler handles HTTP requests for deployment target operations.
type TargetHandler struct {
svc TargetService
}
// NewTargetHandler creates a new TargetHandler with a service dependency.
func NewTargetHandler(svc TargetService) TargetHandler {
return TargetHandler{svc: svc}
}
// ListTargets lists all deployment targets.
// GET /api/v1/targets?page=1&per_page=50
func (h TargetHandler) ListTargets(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
targets, total, err := h.svc.ListTargets(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list targets", requestID)
return
}
response := PagedResponse{
Data: targets,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetTarget retrieves a single deployment target by ID.
// GET /api/v1/targets/{id}
func (h TargetHandler) GetTarget(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/targets/")
if id == "" || strings.Contains(id, "/") {
ErrorWithRequestID(w, http.StatusBadRequest, "Target ID is required", requestID)
return
}
target, err := h.svc.GetTarget(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Target not found", requestID)
return
}
JSON(w, http.StatusOK, target)
}
// CreateTarget creates a new deployment target.
// POST /api/v1/targets
func (h TargetHandler) CreateTarget(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var target domain.DeploymentTarget
if err := json.NewDecoder(r.Body).Decode(&target); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
created, err := h.svc.CreateTarget(target)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create target", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// UpdateTarget updates an existing deployment target.
// PUT /api/v1/targets/{id}
func (h TargetHandler) UpdateTarget(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/targets/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Target ID is required", requestID)
return
}
id = parts[0]
var target domain.DeploymentTarget
if err := json.NewDecoder(r.Body).Decode(&target); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
updated, err := h.svc.UpdateTarget(id, target)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update target", requestID)
return
}
JSON(w, http.StatusOK, updated)
}
// DeleteTarget deletes a deployment target.
// DELETE /api/v1/targets/{id}
func (h TargetHandler) DeleteTarget(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/targets/")
if id == "" || strings.Contains(id, "/") {
ErrorWithRequestID(w, http.StatusBadRequest, "Target ID is required", requestID)
return
}
if err := h.svc.DeleteTarget(id); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete target", requestID)
return
}
w.WriteHeader(http.StatusNoContent)
}
+181
View File
@@ -0,0 +1,181 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// TeamService defines the service interface for team operations.
type TeamService interface {
ListTeams(page, perPage int) ([]domain.Team, int64, error)
GetTeam(id string) (*domain.Team, error)
CreateTeam(team domain.Team) (*domain.Team, error)
UpdateTeam(id string, team domain.Team) (*domain.Team, error)
DeleteTeam(id string) error
}
// TeamHandler handles HTTP requests for team operations.
type TeamHandler struct {
svc TeamService
}
// NewTeamHandler creates a new TeamHandler with a service dependency.
func NewTeamHandler(svc TeamService) TeamHandler {
return TeamHandler{svc: svc}
}
// ListTeams lists all teams.
// GET /api/v1/teams?page=1&per_page=50
func (h TeamHandler) ListTeams(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
teams, total, err := h.svc.ListTeams(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list teams", requestID)
return
}
response := PagedResponse{
Data: teams,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetTeam retrieves a single team by ID.
// GET /api/v1/teams/{id}
func (h TeamHandler) GetTeam(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/teams/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Team ID is required", requestID)
return
}
id = parts[0]
team, err := h.svc.GetTeam(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Team not found", requestID)
return
}
JSON(w, http.StatusOK, team)
}
// CreateTeam creates a new team.
// POST /api/v1/teams
func (h TeamHandler) CreateTeam(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var team domain.Team
if err := json.NewDecoder(r.Body).Decode(&team); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
created, err := h.svc.CreateTeam(team)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create team", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// UpdateTeam updates an existing team.
// PUT /api/v1/teams/{id}
func (h TeamHandler) UpdateTeam(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/teams/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Team ID is required", requestID)
return
}
id = parts[0]
var team domain.Team
if err := json.NewDecoder(r.Body).Decode(&team); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
updated, err := h.svc.UpdateTeam(id, team)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update team", requestID)
return
}
JSON(w, http.StatusOK, updated)
}
// DeleteTeam deletes a team.
// DELETE /api/v1/teams/{id}
func (h TeamHandler) DeleteTeam(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/teams/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Team ID is required", requestID)
return
}
id = parts[0]
if err := h.svc.DeleteTeam(id); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete team", requestID)
return
}
w.WriteHeader(http.StatusNoContent)
}
+144
View File
@@ -0,0 +1,144 @@
package middleware
import (
"context"
"log"
"net/http"
"time"
"github.com/google/uuid"
)
// RequestIDKey is the context key for storing request IDs.
type RequestIDKey struct{}
// UserKey is the context key for storing authenticated user information.
type UserKey struct{}
// RequestID middleware generates a unique request ID and adds it to the request context and response headers.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := uuid.New().String()
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), RequestIDKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Logging middleware logs request details including method, path, status, and duration.
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap response writer to capture status code
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
requestID := getRequestID(r.Context())
log.Printf("[%s] %s %s %d %v", requestID, r.Method, r.URL.Path, wrapped.statusCode, duration)
})
}
// Recovery middleware recovers from panics and returns a 500 error.
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
requestID := getRequestID(r.Context())
log.Printf("[%s] PANIC: %v", requestID, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// Auth middleware is a placeholder that checks the Authorization header and extracts user information.
// In production, this would validate tokens, verify signatures, etc.
func Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
// For now, allow requests without auth (placeholder)
// In production, enforce auth on protected routes
next.ServeHTTP(w, r)
return
}
// Simple stub: just extract user ID from Bearer token (format: "Bearer <user_id>")
// This is NOT secure and for development only
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
userID := authHeader[7:]
ctx := context.WithValue(r.Context(), UserKey{}, userID)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
http.Error(w, "Invalid Authorization header", http.StatusUnauthorized)
})
}
// ContentType middleware sets the Content-Type header to application/json.
func ContentType(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
next.ServeHTTP(w, r)
})
}
// CORS middleware adds CORS headers to allow cross-origin requests.
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// GetRequestID extracts the request ID from context.
func GetRequestID(ctx context.Context) string {
return getRequestID(ctx)
}
// getRequestID is an internal helper to extract request ID from context.
func getRequestID(ctx context.Context) string {
id, ok := ctx.Value(RequestIDKey{}).(string)
if !ok {
return "unknown"
}
return id
}
// GetUser extracts the authenticated user from context.
func GetUser(ctx context.Context) (string, bool) {
user, ok := ctx.Value(UserKey{}).(string)
return user, ok
}
// responseWriter wraps http.ResponseWriter to capture the status code.
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Chain chains multiple middleware functions.
func Chain(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
for i := len(middleware) - 1; i >= 0; i-- {
h = middleware[i](h)
}
return h
}
+145
View File
@@ -0,0 +1,145 @@
package router
import (
"net/http"
"github.com/shankar0123/certctl/internal/api/handler"
"github.com/shankar0123/certctl/internal/api/middleware"
)
// Router wraps http.ServeMux and manages route registration with middleware.
type Router struct {
mux *http.ServeMux
middleware []func(http.Handler) http.Handler
}
// New creates a new Router instance.
func New() *Router {
return &Router{
mux: http.NewServeMux(),
middleware: []func(http.Handler) http.Handler{},
}
}
// NewWithMiddleware creates a Router with initial middleware stack.
func NewWithMiddleware(middlewares ...func(http.Handler) http.Handler) *Router {
r := New()
r.middleware = middlewares
return r
}
// ServeHTTP implements http.Handler interface.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mux.ServeHTTP(w, req)
}
// Register registers a handler for a given path with the middleware chain applied.
func (r *Router) Register(pattern string, handler http.Handler) {
r.mux.Handle(pattern, middleware.Chain(handler, r.middleware...))
}
// RegisterFunc registers a handler function for a given path.
func (r *Router) RegisterFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) {
r.Register(pattern, http.HandlerFunc(handler))
}
// RegisterHandlers sets up all API routes with their handlers.
func (r *Router) RegisterHandlers(
certificates handler.CertificateHandler,
issuers handler.IssuerHandler,
targets handler.TargetHandler,
agents handler.AgentHandler,
jobs handler.JobHandler,
policies handler.PolicyHandler,
teams handler.TeamHandler,
owners handler.OwnerHandler,
audit handler.AuditHandler,
notifications handler.NotificationHandler,
health handler.HealthHandler,
) {
// Health endpoints (no middleware)
r.mux.Handle("GET /health", middleware.Chain(
http.HandlerFunc(health.Health),
middleware.CORS,
middleware.ContentType,
))
r.mux.Handle("GET /ready", middleware.Chain(
http.HandlerFunc(health.Ready),
middleware.CORS,
middleware.ContentType,
))
// Certificates routes: /api/v1/certificates
r.Register("GET /api/v1/certificates", http.HandlerFunc(certificates.ListCertificates))
r.Register("POST /api/v1/certificates", http.HandlerFunc(certificates.CreateCertificate))
r.Register("GET /api/v1/certificates/{id}", http.HandlerFunc(certificates.GetCertificate))
r.Register("PUT /api/v1/certificates/{id}", http.HandlerFunc(certificates.UpdateCertificate))
r.Register("DELETE /api/v1/certificates/{id}", http.HandlerFunc(certificates.ArchiveCertificate))
r.Register("GET /api/v1/certificates/{id}/versions", http.HandlerFunc(certificates.GetCertificateVersions))
r.Register("POST /api/v1/certificates/{id}/renew", http.HandlerFunc(certificates.TriggerRenewal))
r.Register("POST /api/v1/certificates/{id}/deploy", http.HandlerFunc(certificates.TriggerDeployment))
// Issuers routes: /api/v1/issuers
r.Register("GET /api/v1/issuers", http.HandlerFunc(issuers.ListIssuers))
r.Register("POST /api/v1/issuers", http.HandlerFunc(issuers.CreateIssuer))
r.Register("GET /api/v1/issuers/{id}", http.HandlerFunc(issuers.GetIssuer))
r.Register("PUT /api/v1/issuers/{id}", http.HandlerFunc(issuers.UpdateIssuer))
r.Register("DELETE /api/v1/issuers/{id}", http.HandlerFunc(issuers.DeleteIssuer))
r.Register("POST /api/v1/issuers/{id}/test", http.HandlerFunc(issuers.TestConnection))
// Targets routes: /api/v1/targets
r.Register("GET /api/v1/targets", http.HandlerFunc(targets.ListTargets))
r.Register("POST /api/v1/targets", http.HandlerFunc(targets.CreateTarget))
r.Register("GET /api/v1/targets/{id}", http.HandlerFunc(targets.GetTarget))
r.Register("PUT /api/v1/targets/{id}", http.HandlerFunc(targets.UpdateTarget))
r.Register("DELETE /api/v1/targets/{id}", http.HandlerFunc(targets.DeleteTarget))
// Agents routes: /api/v1/agents
r.Register("GET /api/v1/agents", http.HandlerFunc(agents.ListAgents))
r.Register("POST /api/v1/agents", http.HandlerFunc(agents.RegisterAgent))
r.Register("GET /api/v1/agents/{id}", http.HandlerFunc(agents.GetAgent))
r.Register("POST /api/v1/agents/{id}/heartbeat", http.HandlerFunc(agents.Heartbeat))
r.Register("POST /api/v1/agents/{id}/csr", http.HandlerFunc(agents.AgentCSRSubmit))
r.Register("GET /api/v1/agents/{id}/certificates/{cert_id}", http.HandlerFunc(agents.AgentCertificatePickup))
// Jobs routes: /api/v1/jobs
r.Register("GET /api/v1/jobs", http.HandlerFunc(jobs.ListJobs))
r.Register("GET /api/v1/jobs/{id}", http.HandlerFunc(jobs.GetJob))
r.Register("POST /api/v1/jobs/{id}/cancel", http.HandlerFunc(jobs.CancelJob))
// Policies routes: /api/v1/policies
r.Register("GET /api/v1/policies", http.HandlerFunc(policies.ListPolicies))
r.Register("POST /api/v1/policies", http.HandlerFunc(policies.CreatePolicy))
r.Register("GET /api/v1/policies/{id}", http.HandlerFunc(policies.GetPolicy))
r.Register("PUT /api/v1/policies/{id}", http.HandlerFunc(policies.UpdatePolicy))
r.Register("DELETE /api/v1/policies/{id}", http.HandlerFunc(policies.DeletePolicy))
r.Register("GET /api/v1/policies/{id}/violations", http.HandlerFunc(policies.ListViolations))
// Teams routes: /api/v1/teams
r.Register("GET /api/v1/teams", http.HandlerFunc(teams.ListTeams))
r.Register("POST /api/v1/teams", http.HandlerFunc(teams.CreateTeam))
r.Register("GET /api/v1/teams/{id}", http.HandlerFunc(teams.GetTeam))
r.Register("PUT /api/v1/teams/{id}", http.HandlerFunc(teams.UpdateTeam))
r.Register("DELETE /api/v1/teams/{id}", http.HandlerFunc(teams.DeleteTeam))
// Owners routes: /api/v1/owners
r.Register("GET /api/v1/owners", http.HandlerFunc(owners.ListOwners))
r.Register("POST /api/v1/owners", http.HandlerFunc(owners.CreateOwner))
r.Register("GET /api/v1/owners/{id}", http.HandlerFunc(owners.GetOwner))
r.Register("PUT /api/v1/owners/{id}", http.HandlerFunc(owners.UpdateOwner))
r.Register("DELETE /api/v1/owners/{id}", http.HandlerFunc(owners.DeleteOwner))
// Audit routes: /api/v1/audit
r.Register("GET /api/v1/audit", http.HandlerFunc(audit.ListAuditEvents))
r.Register("GET /api/v1/audit/{id}", http.HandlerFunc(audit.GetAuditEvent))
// Notifications routes: /api/v1/notifications
r.Register("GET /api/v1/notifications", http.HandlerFunc(notifications.ListNotifications))
r.Register("GET /api/v1/notifications/{id}", http.HandlerFunc(notifications.GetNotification))
r.Register("POST /api/v1/notifications/{id}/read", http.HandlerFunc(notifications.MarkAsRead))
}
// GetMux returns the underlying http.ServeMux for direct access if needed.
func (r *Router) GetMux() *http.ServeMux {
return r.mux
}
+209
View File
@@ -0,0 +1,209 @@
package config
import (
"fmt"
"log/slog"
"os"
"strconv"
"time"
)
// Config represents the complete application configuration.
// All configuration values are read from environment variables with CERTCTL_ prefix.
type Config struct {
Server ServerConfig
Database DatabaseConfig
Scheduler SchedulerConfig
Log LogConfig
Auth AuthConfig
}
// ServerConfig contains HTTP server configuration.
type ServerConfig struct {
Host string
Port int
}
// DatabaseConfig contains database connection configuration.
type DatabaseConfig struct {
URL string
MaxConnections int
MigrationsPath string
}
// SchedulerConfig contains scheduler timing configuration.
type SchedulerConfig struct {
RenewalCheckInterval time.Duration
JobProcessorInterval time.Duration
AgentHealthCheckInterval time.Duration
NotificationProcessInterval time.Duration
}
// LogConfig contains logging configuration.
type LogConfig struct {
Level string // "debug", "info", "warn", "error"
Format string // "json" or "text"
}
// AuthConfig contains authentication configuration.
type AuthConfig struct {
Type string // "api-key", "jwt", "none"
Secret string // Secret key for signing (if applicable)
}
// Load reads configuration from environment variables and returns a Config.
// Environment variables must have the CERTCTL_ prefix.
// Example: CERTCTL_SERVER_HOST, CERTCTL_DATABASE_URL, etc.
func Load() (*Config, error) {
cfg := &Config{
Server: ServerConfig{
Host: getEnv("CERTCTL_SERVER_HOST", "127.0.0.1"),
Port: getEnvInt("CERTCTL_SERVER_PORT", 8080),
},
Database: DatabaseConfig{
URL: getEnv("CERTCTL_DATABASE_URL", "postgres://localhost/certctl"),
MaxConnections: getEnvInt("CERTCTL_DATABASE_MAX_CONNS", 25),
MigrationsPath: getEnv("CERTCTL_DATABASE_MIGRATIONS_PATH", "./migrations"),
},
Scheduler: SchedulerConfig{
RenewalCheckInterval: getEnvDuration("CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL", 1*time.Hour),
JobProcessorInterval: getEnvDuration("CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL", 30*time.Second),
AgentHealthCheckInterval: getEnvDuration("CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL", 2*time.Minute),
NotificationProcessInterval: getEnvDuration("CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL", 1*time.Minute),
},
Log: LogConfig{
Level: getEnv("CERTCTL_LOG_LEVEL", "info"),
Format: getEnv("CERTCTL_LOG_FORMAT", "json"),
},
Auth: AuthConfig{
Type: getEnv("CERTCTL_AUTH_TYPE", "api-key"),
Secret: getEnv("CERTCTL_AUTH_SECRET", ""),
},
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
// Validate checks that the configuration is valid.
func (c *Config) Validate() error {
// Validate server configuration
if c.Server.Port < 1 || c.Server.Port > 65535 {
return fmt.Errorf("invalid server port: %d", c.Server.Port)
}
// Validate database configuration
if c.Database.URL == "" {
return fmt.Errorf("database URL is required")
}
if c.Database.MaxConnections < 1 {
return fmt.Errorf("database max_connections must be at least 1")
}
// Validate log level
validLogLevels := map[string]bool{
"debug": true,
"info": true,
"warn": true,
"error": true,
}
if !validLogLevels[c.Log.Level] {
return fmt.Errorf("invalid log level: %s", c.Log.Level)
}
// Validate log format
validFormats := map[string]bool{
"json": true,
"text": true,
}
if !validFormats[c.Log.Format] {
return fmt.Errorf("invalid log format: %s", c.Log.Format)
}
// Validate auth type
validAuthTypes := map[string]bool{
"api-key": true,
"jwt": true,
"none": true,
}
if !validAuthTypes[c.Auth.Type] {
return fmt.Errorf("invalid auth type: %s", c.Auth.Type)
}
// If using JWT or API-key, secret is required
if (c.Auth.Type == "jwt" || c.Auth.Type == "api-key") && c.Auth.Secret == "" {
return fmt.Errorf("auth secret is required for auth type %s", c.Auth.Type)
}
// Validate scheduler intervals
if c.Scheduler.RenewalCheckInterval < 1*time.Minute {
return fmt.Errorf("renewal check interval must be at least 1 minute")
}
if c.Scheduler.JobProcessorInterval < 1*time.Second {
return fmt.Errorf("job processor interval must be at least 1 second")
}
if c.Scheduler.AgentHealthCheckInterval < 1*time.Second {
return fmt.Errorf("agent health check interval must be at least 1 second")
}
if c.Scheduler.NotificationProcessInterval < 1*time.Second {
return fmt.Errorf("notification process interval must be at least 1 second")
}
return nil
}
// getEnv reads a string environment variable with the given key and default value.
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getEnvInt reads an integer environment variable with the given key and default value.
func getEnvInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
intVal, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
return intVal
}
return defaultValue
}
// getEnvDuration reads a time.Duration environment variable.
// The value should be a valid Go duration string (e.g., "1h", "30s", "5m").
func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
if value := os.Getenv(key); value != "" {
duration, err := time.ParseDuration(value)
if err != nil {
return defaultValue
}
return duration
}
return defaultValue
}
// GetLogLevel returns the appropriate slog.Level from the configured log level.
func (c *Config) GetLogLevel() slog.Level {
switch c.Log.Level {
case "debug":
return slog.LevelDebug
case "info":
return slog.LevelInfo
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
+186
View File
@@ -0,0 +1,186 @@
package acme
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
// Config represents the ACME issuer connector configuration.
type Config struct {
DirectoryURL string `json:"directory_url"`
Email string `json:"email"`
EABKid string `json:"eab_kid,omitempty"`
EABHmac string `json:"eab_hmac,omitempty"`
}
// Connector implements the issuer.Connector interface for ACME-compatible CAs.
// This is a stub implementation that demonstrates the structure; actual ACME protocol
// implementation will use a proper ACME library (e.g., golang.org/x/crypto/acme).
type Connector struct {
config *Config
logger *slog.Logger
client *http.Client
}
// New creates a new ACME connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// ValidateConfig checks that the ACME directory URL is reachable and valid.
// It performs a HEAD request to the directory URL to verify connectivity.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid ACME config: %w", err)
}
if cfg.DirectoryURL == "" {
return fmt.Errorf("ACME directory_url is required")
}
if cfg.Email == "" {
return fmt.Errorf("ACME email is required")
}
c.logger.Info("validating ACME configuration", "directory_url", cfg.DirectoryURL)
// Verify that the directory URL is reachable
req, err := http.NewRequestWithContext(ctx, http.MethodHead, cfg.DirectoryURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("failed to reach ACME directory: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("ACME directory returned status %d", resp.StatusCode)
}
c.config = &cfg
c.logger.Info("ACME configuration validated")
return nil
}
// IssueCertificate submits a certificate issuance request to the ACME CA.
//
// The flow for ACME is:
// 1. Create a new order with the CA, specifying the identifiers (SANs + CN)
// 2. The CA returns authorization challenges (DNS, HTTP, etc.)
// 3. Solve the challenges (stub: in production, the agent or external solver handles this)
// 4. Finalize the order by submitting the CSR
// 5. Download the issued certificate and chain
//
// TODO: Implement actual ACME protocol using golang.org/x/crypto/acme.
// This stub documents the expected flow but doesn't execute it.
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing ACME issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// TODO: Implement ACME order creation.
// For now, return a stub response to demonstrate the interface.
// In production:
// 1. Connect to the ACME directory
// 2. Create a new order with identifiers from CommonName and SANs
// 3. Get authorization challenges
// 4. Wait for challenge completion (agent/solver will handle)
// 5. Submit CSR to finalize order
// 6. Retrieve issued certificate and chain
c.logger.Warn("ACME issuance not yet implemented", "common_name", request.CommonName)
// Stub: Return a placeholder result
return &issuer.IssuanceResult{
CertPEM: "-----BEGIN CERTIFICATE-----\n(stub)\n-----END CERTIFICATE-----\n",
ChainPEM: "-----BEGIN CERTIFICATE-----\n(stub chain)\n-----END CERTIFICATE-----\n",
Serial: "stub-serial-123456",
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(0, 0, 90),
OrderID: "stub-order-id",
}, nil
}
// RenewCertificate renews an existing certificate by submitting a new ACME order.
// The process is identical to IssueCertificate but uses the existing CSR from the previous certificate.
//
// TODO: Implement actual ACME protocol using golang.org/x/crypto/acme.
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing ACME renewal request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// TODO: Implement ACME renewal.
// In production:
// 1. Create a new order with the same identifiers
// 2. Obtain and solve authorization challenges
// 3. Submit the CSR (from request.CSRPEM)
// 4. Retrieve the issued certificate and chain
c.logger.Warn("ACME renewal not yet implemented", "common_name", request.CommonName)
// Stub: Return a placeholder result
return &issuer.IssuanceResult{
CertPEM: "-----BEGIN CERTIFICATE-----\n(stub renewed)\n-----END CERTIFICATE-----\n",
ChainPEM: "-----BEGIN CERTIFICATE-----\n(stub chain)\n-----END CERTIFICATE-----\n",
Serial: "stub-serial-renewal-123456",
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(0, 0, 90),
OrderID: "stub-order-renewal-id",
}, nil
}
// RevokeCertificate revokes a certificate at the ACME CA.
// The CA will no longer consider the certificate valid.
//
// TODO: Implement revocation via ACME protocol.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
c.logger.Info("processing ACME revocation request", "serial", request.Serial)
// TODO: Implement ACME revocation.
// In production:
// 1. Retrieve the certificate PEM
// 2. Post revocation request to CA's revocation endpoint
// 3. Provide reason if given
c.logger.Warn("ACME revocation not yet implemented", "serial", request.Serial)
return nil
}
// GetOrderStatus retrieves the current status of an ACME order.
// This is useful for polling the status of pending issuance or renewal orders.
//
// TODO: Implement order status polling.
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
c.logger.Info("fetching ACME order status", "order_id", orderID)
// TODO: Implement ACME order status polling.
// In production:
// 1. Connect to the ACME directory
// 2. Fetch order status by orderID
// 3. Return current status, message, and any issued certificate material
c.logger.Warn("ACME order status polling not yet implemented", "order_id", orderID)
// Stub: Return a placeholder status
return &issuer.OrderStatus{
OrderID: orderID,
Status: "processing",
Message: nil,
UpdatedAt: time.Now(),
}, nil
}
+69
View File
@@ -0,0 +1,69 @@
package issuer
import (
"context"
"encoding/json"
"time"
)
// Connector defines the interface for certificate issuance operations.
type Connector interface {
// ValidateConfig validates the issuer configuration.
ValidateConfig(ctx context.Context, config json.RawMessage) error
// IssueCertificate issues a new certificate.
IssueCertificate(ctx context.Context, request IssuanceRequest) (*IssuanceResult, error)
// RenewCertificate renews an existing certificate.
RenewCertificate(ctx context.Context, request RenewalRequest) (*IssuanceResult, error)
// RevokeCertificate revokes a certificate.
RevokeCertificate(ctx context.Context, request RevocationRequest) error
// GetOrderStatus retrieves the status of an issuance or renewal order.
GetOrderStatus(ctx context.Context, orderID string) (*OrderStatus, error)
}
// IssuanceRequest contains the parameters for issuing a new certificate.
type IssuanceRequest struct {
CommonName string `json:"common_name"`
SANs []string `json:"sans"`
CSRPEM string `json:"csr_pem"`
}
// IssuanceResult contains the result of a successful certificate issuance.
type IssuanceResult struct {
CertPEM string `json:"cert_pem"`
ChainPEM string `json:"chain_pem"`
Serial string `json:"serial"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
OrderID string `json:"order_id"`
}
// RenewalRequest contains the parameters for renewing a certificate.
type RenewalRequest struct {
CommonName string `json:"common_name"`
SANs []string `json:"sans"`
CSRPEM string `json:"csr_pem"`
OrderID *string `json:"order_id,omitempty"`
}
// RevocationRequest contains the parameters for revoking a certificate.
type RevocationRequest struct {
Serial string `json:"serial"`
Reason *string `json:"reason,omitempty"`
}
// OrderStatus contains the status of a pending issuance or renewal order.
type OrderStatus struct {
OrderID string `json:"order_id"`
Status string `json:"status"`
Message *string `json:"message,omitempty"`
CertPEM *string `json:"cert_pem,omitempty"`
ChainPEM *string `json:"chain_pem,omitempty"`
Serial *string `json:"serial,omitempty"`
NotBefore *time.Time `json:"not_before,omitempty"`
NotAfter *time.Time `json:"not_after,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
+270
View File
@@ -0,0 +1,270 @@
package email
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/smtp"
"strings"
"time"
"github.com/shankar0123/certctl/internal/connector/notifier"
)
// Config represents the email notifier configuration.
type Config struct {
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
Username string `json:"username"`
Password string `json:"password"`
FromAddress string `json:"from_address"`
UseTLS bool `json:"tls"`
}
// Connector implements the notifier.Connector interface for email notifications.
// It sends alert and event notifications via SMTP.
type Connector struct {
config *Config
logger *slog.Logger
}
// New creates a new email notifier with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
}
}
// ValidateConfig checks that the SMTP server is reachable and credentials are valid.
// It attempts to connect to the SMTP server to verify connectivity.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid email config: %w", err)
}
if cfg.SMTPHost == "" || cfg.SMTPPort == 0 || cfg.FromAddress == "" {
return fmt.Errorf("email smtp_host, smtp_port, and from_address are required")
}
c.logger.Info("validating email configuration",
"smtp_host", cfg.SMTPHost,
"smtp_port", cfg.SMTPPort)
// Test SMTP connectivity with timeout
addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort)
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return fmt.Errorf("failed to reach SMTP server %s: %w", addr, err)
}
defer conn.Close()
c.config = &cfg
c.logger.Info("email configuration validated")
return nil
}
// SendAlert sends an alert notification via SMTP.
// It formats the alert as an email message and sends it to the recipient.
func (c *Connector) SendAlert(ctx context.Context, alert notifier.Alert) error {
c.logger.Info("sending email alert",
"alert_id", alert.ID,
"severity", alert.Severity,
"recipient", alert.Recipient)
// Format email subject and body
subject := fmt.Sprintf("[%s] %s", strings.ToUpper(alert.Severity), alert.Subject)
body := c.formatAlertBody(alert)
// Send email
if err := c.sendEmail(ctx, alert.Recipient, subject, body); err != nil {
c.logger.Error("failed to send alert email",
"alert_id", alert.ID,
"error", err)
return fmt.Errorf("failed to send alert email: %w", err)
}
c.logger.Info("alert email sent successfully",
"alert_id", alert.ID,
"recipient", alert.Recipient)
return nil
}
// SendEvent sends an event notification via SMTP.
// It formats the event as an email message and sends it to the recipient.
func (c *Connector) SendEvent(ctx context.Context, event notifier.Event) error {
c.logger.Info("sending email event",
"event_id", event.ID,
"event_type", event.Type,
"recipient", event.Recipient)
// Format email subject and body
subject := fmt.Sprintf("[Event] %s", event.Subject)
body := c.formatEventBody(event)
// Send email
if err := c.sendEmail(ctx, event.Recipient, subject, body); err != nil {
c.logger.Error("failed to send event email",
"event_id", event.ID,
"error", err)
return fmt.Errorf("failed to send event email: %w", err)
}
c.logger.Info("event email sent successfully",
"event_id", event.ID,
"recipient", event.Recipient)
return nil
}
// sendEmail sends an email message using the configured SMTP server.
// It handles both TLS and plain authentication modes.
func (c *Connector) sendEmail(ctx context.Context, to, subject, body string) error {
addr := fmt.Sprintf("%s:%d", c.config.SMTPHost, c.config.SMTPPort)
// Connect to SMTP server
var auth smtp.Auth
if c.config.Username != "" && c.config.Password != "" {
auth = smtp.PlainAuth("", c.config.Username, c.config.Password, c.config.SMTPHost)
}
var conn net.Conn
var err error
if c.config.UseTLS {
// Connect with TLS
tlsConfig := &tls.Config{
ServerName: c.config.SMTPHost,
}
conn, err = tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("failed to connect via TLS: %w", err)
}
} else {
// Connect without TLS
conn, err = net.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
}
defer conn.Close()
// Create SMTP client
client, err := smtp.NewClient(conn, c.config.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer client.Close()
// Authenticate if credentials provided
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
}
// Send email
if err := client.Mail(c.config.FromAddress); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
wc, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
defer wc.Close()
// Format and write email headers and body
message := c.formatEmailMessage(c.config.FromAddress, to, subject, body)
if _, err := wc.Write(message); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err := client.Quit(); err != nil {
return fmt.Errorf("failed to quit SMTP: %w", err)
}
return nil
}
// formatEmailMessage formats an email message with standard headers.
func (c *Connector) formatEmailMessage(from, to, subject, body string) []byte {
message := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s",
from,
to,
subject,
time.Now().Format(time.RFC1123Z),
body,
)
return []byte(message)
}
// formatAlertBody formats an alert notification as email body text.
func (c *Connector) formatAlertBody(alert notifier.Alert) string {
body := fmt.Sprintf(`
Certificate Alert Notification
================================
Alert ID: %s
Type: %s
Severity: %s
Created: %s
Subject: %s
Message:
%s
%s
`, alert.ID, alert.Type, alert.Severity, alert.CreatedAt.Format(time.RFC3339), alert.Subject, alert.Message, c.formatMetadata(alert.Metadata))
return body
}
// formatEventBody formats an event notification as email body text.
func (c *Connector) formatEventBody(event notifier.Event) string {
certInfo := ""
if event.CertificateID != nil {
certInfo = fmt.Sprintf("Certificate ID: %s\n", *event.CertificateID)
}
body := fmt.Sprintf(`
Certificate Event Notification
================================
Event ID: %s
Type: %s
Created: %s
%sSubject: %s
Body:
%s
%s
`, event.ID, event.Type, event.CreatedAt.Format(time.RFC3339), certInfo, event.Subject, event.Body, c.formatMetadata(event.Metadata))
return body
}
// formatMetadata formats metadata as a readable string.
func (c *Connector) formatMetadata(metadata map[string]string) string {
if len(metadata) == 0 {
return ""
}
metadataStr := "\nMetadata:\n"
for key, value := range metadata {
metadataStr += fmt.Sprintf(" %s: %s\n", key, value)
}
return metadataStr
}
+43
View File
@@ -0,0 +1,43 @@
package notifier
import (
"context"
"encoding/json"
"time"
)
// Connector defines the interface for sending notifications about certificate events.
type Connector interface {
// ValidateConfig validates the notifier configuration.
ValidateConfig(ctx context.Context, config json.RawMessage) error
// SendAlert sends an alert notification.
SendAlert(ctx context.Context, alert Alert) error
// SendEvent sends an event notification.
SendEvent(ctx context.Context, event Event) error
}
// Alert represents a notification alert with urgency.
type Alert struct {
ID string `json:"id"`
Type string `json:"type"`
Severity string `json:"severity"`
Subject string `json:"subject"`
Message string `json:"message"`
Recipient string `json:"recipient"`
Metadata map[string]string `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Event represents a notification event with contextual information.
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
CertificateID *string `json:"certificate_id,omitempty"`
Recipient string `json:"recipient"`
Subject string `json:"subject"`
Body string `json:"body"`
Metadata map[string]string `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -0,0 +1,211 @@
package webhook
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"github.com/shankar0123/certctl/internal/connector/notifier"
)
// Config represents the webhook notifier configuration.
type Config struct {
URL string `json:"url"`
Secret string `json:"secret,omitempty"` // Secret for HMAC-SHA256 signature
Headers map[string]string `json:"headers,omitempty"` // Custom headers to include
}
// Connector implements the notifier.Connector interface for webhook notifications.
// It sends alert and event notifications via HTTP POST with optional HMAC signing.
type Connector struct {
config *Config
logger *slog.Logger
client *http.Client
}
// New creates a new webhook notifier with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// ValidateConfig checks that the webhook URL is valid and reachable.
// It performs a test request to verify the endpoint is accessible.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid webhook config: %w", err)
}
if cfg.URL == "" {
return fmt.Errorf("webhook url is required")
}
c.logger.Info("validating webhook configuration", "url", cfg.URL)
// Test webhook connectivity with a HEAD request
req, err := http.NewRequestWithContext(ctx, http.MethodHead, cfg.URL, nil)
if err != nil {
return fmt.Errorf("invalid webhook URL: %w", err)
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("failed to reach webhook endpoint: %w", err)
}
defer resp.Body.Close()
// Accept any 2xx or 3xx status code as valid
if resp.StatusCode >= 400 {
c.logger.Warn("webhook validation: endpoint returned error status",
"status_code", resp.StatusCode)
// Still allow configuration; the endpoint might be designed to accept POST
}
c.config = &cfg
c.logger.Info("webhook configuration validated")
return nil
}
// SendAlert sends an alert notification via webhook.
// It POSTs the alert as JSON to the configured webhook URL with optional HMAC signature.
func (c *Connector) SendAlert(ctx context.Context, alert notifier.Alert) error {
c.logger.Info("sending webhook alert",
"alert_id", alert.ID,
"severity", alert.Severity)
// Format payload
payload := map[string]interface{}{
"type": "alert",
"alert_id": alert.ID,
"severity": alert.Severity,
"subject": alert.Subject,
"message": alert.Message,
"recipient": alert.Recipient,
"created_at": alert.CreatedAt,
"metadata": alert.Metadata,
}
if err := c.postWebhook(ctx, payload); err != nil {
c.logger.Error("failed to send alert via webhook",
"alert_id", alert.ID,
"error", err)
return fmt.Errorf("failed to send alert via webhook: %w", err)
}
c.logger.Info("alert sent via webhook", "alert_id", alert.ID)
return nil
}
// SendEvent sends an event notification via webhook.
// It POSTs the event as JSON to the configured webhook URL with optional HMAC signature.
func (c *Connector) SendEvent(ctx context.Context, event notifier.Event) error {
c.logger.Info("sending webhook event",
"event_id", event.ID,
"event_type", event.Type)
// Format payload
payload := map[string]interface{}{
"type": "event",
"event_id": event.ID,
"event_type": event.Type,
"subject": event.Subject,
"body": event.Body,
"recipient": event.Recipient,
"created_at": event.CreatedAt,
}
if event.CertificateID != nil {
payload["certificate_id"] = *event.CertificateID
}
if event.Metadata != nil {
payload["metadata"] = event.Metadata
}
if err := c.postWebhook(ctx, payload); err != nil {
c.logger.Error("failed to send event via webhook",
"event_id", event.ID,
"error", err)
return fmt.Errorf("failed to send event via webhook: %w", err)
}
c.logger.Info("event sent via webhook", "event_id", event.ID)
return nil
}
// postWebhook sends a payload to the webhook URL with proper headers and signing.
// If a secret is configured, it signs the payload using HMAC-SHA256 and includes
// the signature in the X-Signature header.
func (c *Connector) postWebhook(ctx context.Context, payload interface{}) error {
// Marshal payload to JSON
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
// Create request
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.config.URL, bytes.NewReader(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Set standard headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "certctl-notifier/1.0")
// Add custom headers from configuration
for key, value := range c.config.Headers {
req.Header.Set(key, value)
}
// Sign payload if secret is configured
if c.config.Secret != "" {
signature := c.signPayload(jsonData)
req.Header.Set("X-Signature", signature)
req.Header.Set("X-Signature-Algorithm", "sha256")
}
// Send request
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("failed to send webhook request: %w", err)
}
defer resp.Body.Close()
// Read response body for error logging
respBody, _ := io.ReadAll(resp.Body)
// Accept 2xx status codes as success
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("webhook returned status %d: %s", resp.StatusCode, string(respBody))
}
c.logger.Debug("webhook request successful",
"status_code", resp.StatusCode,
"url", c.config.URL)
return nil
}
// signPayload computes an HMAC-SHA256 signature of the payload using the configured secret.
// The signature is returned as a hex-encoded string in the format "sha256=<hex>".
func (c *Connector) signPayload(data []byte) string {
h := hmac.New(sha256.New, []byte(c.config.Secret))
h.Write(data)
signature := hex.EncodeToString(h.Sum(nil))
return fmt.Sprintf("sha256=%s", signature)
}
+189
View File
@@ -0,0 +1,189 @@
package f5
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
)
// Config represents the F5 BIG-IP deployment target configuration.
type Config struct {
Host string `json:"host"` // F5 BIG-IP hostname or IP
Port int `json:"port"` // F5 iControl REST API port (default 443)
Username string `json:"username"` // Administrative username
Password string `json:"password"` // Administrative password
Partition string `json:"partition"` // F5 partition name (e.g., "Common")
SSLProfile string `json:"ssl_profile"` // SSL profile name to update
}
// Connector implements the target.Connector interface for F5 BIG-IP load balancers.
// This connector communicates with F5's iControl REST API to upload certificates and manage SSL profiles.
//
// TODO: Implement actual F5 iControl REST API communication.
// The documented API endpoints and flow are:
// - Authentication: POST /mgmt/shared/authn/login
// - Upload certificate: POST /mgmt/tm/ltm/certificate
// - Update SSL profile: PATCH /mgmt/tm/ltm/profile/client-ssl/{profile_name}
// - Check SSL profile: GET /mgmt/tm/ltm/profile/client-ssl/{profile_name}
type Connector struct {
config *Config
logger *slog.Logger
client *http.Client
}
// New creates a new F5 target connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
client: &http.Client{
Timeout: 30 * time.Second,
// TODO: Configure proper TLS verification or skip for self-signed F5 certs
},
}
}
// ValidateConfig checks that the F5 BIG-IP is reachable and credentials are valid.
// It attempts to authenticate to the F5 iControl REST API.
//
// TODO: Implement actual F5 authentication validation.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid F5 config: %w", err)
}
if cfg.Host == "" || cfg.Username == "" || cfg.Password == "" {
return fmt.Errorf("F5 host, username, and password are required")
}
if cfg.Port == 0 {
cfg.Port = 443 // Default HTTPS port
}
if cfg.Partition == "" {
cfg.Partition = "Common"
}
c.logger.Info("validating F5 configuration",
"host", cfg.Host,
"port", cfg.Port,
"partition", cfg.Partition)
// TODO: Implement F5 authentication check
// In production:
// 1. POST to https://{host}:{port}/mgmt/shared/authn/login
// 2. Send credentials in request body
// 3. Verify response contains valid authentication token
// 4. Optionally test connectivity to SSL profile endpoint
c.logger.Warn("F5 validation not yet fully implemented",
"host", cfg.Host)
c.config = &cfg
return nil
}
// DeployCertificate uploads a certificate to the F5 BIG-IP and updates the specified SSL profile.
//
// The F5 deployment process:
// 1. Authenticate to iControl REST API using credentials
// 2. Upload certificate PEM to /mgmt/tm/ltm/certificate
// 3. Upload chain PEM as separate certificate if needed
// 4. Update the target SSL profile to reference the new certificate
// 5. Verify the profile was updated successfully
//
// TODO: Implement actual F5 iControl REST API calls.
// API endpoints used:
// - POST /mgmt/shared/authn/login (authentication)
// - POST /mgmt/tm/ltm/certificate (upload cert)
// - PATCH /mgmt/tm/ltm/profile/client-ssl/{SSLProfile} (update profile)
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
c.logger.Info("deploying certificate to F5 BIG-IP",
"host", c.config.Host,
"partition", c.config.Partition,
"ssl_profile", c.config.SSLProfile)
startTime := time.Now()
// TODO: Implement F5 certificate deployment
// In production:
// 1. Authenticate to F5: POST /mgmt/shared/authn/login
// 2. Create certificate object:
// POST /mgmt/tm/ltm/certificate
// Body: {"name": "certctl-cert-{timestamp}", "certificateText": "{CertPEM}"}
// 3. If chain is provided, upload as separate certificate:
// POST /mgmt/tm/ltm/certificate
// Body: {"name": "certctl-chain-{timestamp}", "certificateText": "{ChainPEM}"}
// 4. Update SSL profile:
// PATCH /mgmt/tm/ltm/profile/client-ssl/{SSLProfile}
// Body: {"certificate": "/Common/certctl-cert-{timestamp}"}
// 5. Verify deployment by checking profile status
deploymentDuration := time.Since(startTime)
c.logger.Warn("F5 deployment not yet implemented",
"host", c.config.Host,
"ssl_profile", c.config.SSLProfile)
return &target.DeploymentResult{
Success: true,
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
DeploymentID: fmt.Sprintf("f5-%d", time.Now().Unix()),
Message: "Certificate deployment to F5 initiated (stub)",
DeployedAt: time.Now(),
Metadata: map[string]string{
"host": c.config.Host,
"partition": c.config.Partition,
"ssl_profile": c.config.SSLProfile,
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
},
}, nil
}
// ValidateDeployment verifies that the certificate is properly deployed on the F5 BIG-IP.
// It checks the SSL profile configuration to ensure it references the correct certificate.
//
// TODO: Implement actual F5 validation via iControl REST API.
// API endpoint used:
// - GET /mgmt/tm/ltm/profile/client-ssl/{SSLProfile}
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
c.logger.Info("validating F5 deployment",
"certificate_id", request.CertificateID,
"serial", request.Serial,
"ssl_profile", c.config.SSLProfile)
startTime := time.Now()
// TODO: Implement F5 deployment validation
// In production:
// 1. Authenticate to F5: POST /mgmt/shared/authn/login
// 2. Query SSL profile:
// GET /mgmt/tm/ltm/profile/client-ssl/{SSLProfile}
// 3. Verify the response includes the expected certificate name
// 4. Optionally check certificate validity dates
// 5. Verify the profile is in active use (no errors/warnings)
validationDuration := time.Since(startTime)
c.logger.Warn("F5 validation not yet implemented",
"ssl_profile", c.config.SSLProfile)
return &target.ValidationResult{
Valid: true,
Serial: request.Serial,
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
Message: "Certificate deployment validation initiated (stub)",
ValidatedAt: time.Now(),
Metadata: map[string]string{
"host": c.config.Host,
"ssl_profile": c.config.SSLProfile,
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
},
}, nil
}
+196
View File
@@ -0,0 +1,196 @@
package iis
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"runtime"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
)
// Config represents the IIS deployment target configuration.
// This configuration is for Windows agents that manage IIS servers.
type Config struct {
Hostname string `json:"hostname"` // Target hostname or IP
SiteName string `json:"site_name"` // IIS site name (e.g., "Default Web Site")
CertStore string `json:"cert_store"` // Windows cert store (e.g., "My", "WebHosting")
BindingInfo string `json:"binding_info"` // Binding info (e.g., "*.example.com")
}
// Connector implements the target.Connector interface for IIS (Internet Information Services).
// This connector runs on Windows agents and manages certificate deployment via IIS.
//
// IIS certificate management requires:
// - Windows Server with IIS installed
// - PowerShell execution available
// - Administrative privileges
//
// TODO: Implement actual PowerShell command execution for:
// - Certificate import: Import-PfxCertificate
// - IIS binding update: New-WebBinding, Set-WebBinding
// - Validation: Get-WebBinding
type Connector struct {
config *Config
logger *slog.Logger
}
// New creates a new IIS target connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
}
}
// ValidateConfig checks that the IIS configuration is valid and accessible.
// It verifies that we're on Windows and that the IIS site exists.
//
// TODO: Implement actual PowerShell checks.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid IIS config: %w", err)
}
if cfg.SiteName == "" || cfg.CertStore == "" {
return fmt.Errorf("IIS site_name and cert_store are required")
}
// Verify we're on Windows
if runtime.GOOS != "windows" {
return fmt.Errorf("IIS connector only runs on Windows, got %s", runtime.GOOS)
}
c.logger.Info("validating IIS configuration",
"site_name", cfg.SiteName,
"cert_store", cfg.CertStore,
"hostname", cfg.Hostname)
// TODO: Implement PowerShell check
// In production:
// 1. Run PowerShell command: Get-IISSite -Name {SiteName}
// 2. Verify site exists and is running
// 3. Check cert store: Get-Item -Path "Cert:\LocalMachine\{CertStore}"
c.logger.Warn("IIS validation not yet fully implemented",
"site_name", cfg.SiteName)
c.config = &cfg
return nil
}
// DeployCertificate imports a certificate to the Windows certificate store and updates
// the IIS binding to use the new certificate.
//
// The IIS deployment process (via PowerShell):
// 1. Create a temporary PFX file from the certificate and existing private key
// (Note: The private key is managed by the agent, not provided by the control plane)
// 2. Import the PFX to the Windows certificate store (My store by default)
// 3. Get the certificate thumbprint
// 4. Update the IIS binding to use the new certificate by thumbprint
// 5. Verify the binding is active
//
// TODO: Implement actual PowerShell commands:
// - Import-PfxCertificate -FilePath {pfxPath} -CertStoreLocation "Cert:\LocalMachine\My"
// - Get-ChildItem -Path "Cert:\LocalMachine\My" | Where {$_.Subject -eq "CN=..."}
// - Set-WebBinding -Name {SiteName} -BindingInformation "{BindingInfo}" -Protocol https -SslFlags 1 -CertificateThumbprint {thumbprint}
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
c.logger.Info("deploying certificate to IIS",
"site_name", c.config.SiteName,
"cert_store", c.config.CertStore)
startTime := time.Now()
// TODO: Implement IIS certificate deployment
// In production:
// 1. Create temporary PFX from CertPEM and ChainPEM
// (Private key should already exist on the agent)
// 2. Import certificate:
// PowerShell: Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation "Cert:\LocalMachine\{CertStore}" -Password $password
// 3. Get certificate thumbprint:
// PowerShell: (Get-ChildItem -Path "Cert:\LocalMachine\{CertStore}" | Where {$_.Subject -like "*CN=*"}).Thumbprint
// 4. Update IIS binding:
// PowerShell: Set-WebBinding -Name "{SiteName}" -BindingInformation "{BindingInfo}:443:*.example.com" -Protocol https -CertificateThumbprint $thumbprint
// 5. Remove temporary PFX file
deploymentDuration := time.Since(startTime)
c.logger.Warn("IIS deployment not yet implemented",
"site_name", c.config.SiteName)
return &target.DeploymentResult{
Success: true,
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
DeploymentID: fmt.Sprintf("iis-%d", time.Now().Unix()),
Message: "Certificate deployment to IIS initiated (stub)",
DeployedAt: time.Now(),
Metadata: map[string]string{
"hostname": c.config.Hostname,
"site_name": c.config.SiteName,
"cert_store": c.config.CertStore,
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
},
}, nil
}
// ValidateDeployment verifies that the certificate is properly deployed in IIS.
// It checks the IIS binding configuration to ensure it's active with the correct certificate.
//
// TODO: Implement actual PowerShell validation.
// PowerShell command:
// - Get-IISSiteBinding -Name {SiteName} | Where {$_.protocol -eq "https"}
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
c.logger.Info("validating IIS deployment",
"certificate_id", request.CertificateID,
"serial", request.Serial,
"site_name", c.config.SiteName)
startTime := time.Now()
// TODO: Implement IIS deployment validation
// In production:
// 1. Query IIS binding status:
// PowerShell: Get-WebBinding -Name "{SiteName}" -Protocol "https"
// 2. Verify binding exists and is active
// 3. Extract certificate thumbprint from binding
// 4. Query certificate store to verify thumbprint matches expected certificate
// 5. Check certificate validity dates and key match
validationDuration := time.Since(startTime)
c.logger.Warn("IIS validation not yet implemented",
"site_name", c.config.SiteName)
return &target.ValidationResult{
Valid: true,
Serial: request.Serial,
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
Message: "Certificate deployment validation initiated (stub)",
ValidatedAt: time.Now(),
Metadata: map[string]string{
"hostname": c.config.Hostname,
"site_name": c.config.SiteName,
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
},
}, nil
}
// executePowerShellCommand is a helper to run PowerShell commands on Windows.
// It's a stub implementation that documents the pattern for actual PS execution.
func (c *Connector) executePowerShellCommand(ctx context.Context, psCommand string) (string, error) {
if runtime.GOOS != "windows" {
return "", fmt.Errorf("PowerShell commands only work on Windows")
}
// TODO: Implement actual PowerShell execution
// In production:
// cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", psCommand)
// output, err := cmd.CombinedOutput()
// return string(output), err
c.logger.Debug("executing PowerShell command", "command", psCommand)
return "", nil
}
+57
View File
@@ -0,0 +1,57 @@
package target
import (
"context"
"encoding/json"
"time"
)
// Connector defines the interface for certificate deployment operations.
type Connector interface {
// ValidateConfig validates the deployment target configuration.
ValidateConfig(ctx context.Context, config json.RawMessage) error
// DeployCertificate deploys a certificate to the target.
// The request contains the certificate and chain in PEM format, but never a private key.
DeployCertificate(ctx context.Context, request DeploymentRequest) (*DeploymentResult, error)
// ValidateDeployment verifies that a deployed certificate is valid and accessible.
ValidateDeployment(ctx context.Context, request ValidationRequest) (*ValidationResult, error)
}
// DeploymentRequest contains the parameters for deploying a certificate to a target.
// Note: This request NEVER contains a private key. The agent generates keys locally.
type DeploymentRequest struct {
CertPEM string `json:"cert_pem"`
ChainPEM string `json:"chain_pem"`
TargetConfig json.RawMessage `json:"target_config"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// DeploymentResult contains the result of a successful certificate deployment.
type DeploymentResult struct {
Success bool `json:"success"`
TargetAddress string `json:"target_address"`
DeploymentID string `json:"deployment_id"`
Message string `json:"message"`
DeployedAt time.Time `json:"deployed_at"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// ValidationRequest contains the parameters for validating a deployed certificate.
type ValidationRequest struct {
CertificateID string `json:"certificate_id"`
Serial string `json:"serial"`
TargetConfig json.RawMessage `json:"target_config"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// ValidationResult contains the result of a certificate validation check.
type ValidationResult struct {
Valid bool `json:"valid"`
Serial string `json:"serial"`
TargetAddress string `json:"target_address"`
Message string `json:"message"`
ValidatedAt time.Time `json:"validated_at"`
Metadata map[string]string `json:"metadata,omitempty"`
}
+222
View File
@@ -0,0 +1,222 @@
package nginx
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
)
// Config represents the NGINX deployment target configuration.
// This configuration is used on the agent side to deploy certificates to NGINX.
type Config struct {
CertPath string `json:"cert_path"` // Path where cert will be written (typically /etc/nginx/certs/cert.pem)
KeyPath string `json:"key_path"` // Path where private key will be written (NOT provided by control plane)
ChainPath string `json:"chain_path"` // Path where chain will be written (typically /etc/nginx/certs/chain.pem)
ReloadCommand string `json:"reload_command"` // Command to reload NGINX (e.g., "nginx -s reload" or "systemctl reload nginx")
ValidateCommand string `json:"validate_command"` // Command to validate NGINX config (e.g., "nginx -t")
}
// Connector implements the target.Connector interface for NGINX servers.
// This connector runs on the AGENT side and handles local certificate deployment.
type Connector struct {
config *Config
logger *slog.Logger
}
// New creates a new NGINX target connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
}
}
// ValidateConfig checks that all required configuration paths and commands are valid.
// It verifies that the certificate and key paths are writable and commands are executable.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid NGINX config: %w", err)
}
if cfg.CertPath == "" || cfg.ChainPath == "" {
return fmt.Errorf("NGINX cert_path and chain_path are required")
}
if cfg.ReloadCommand == "" || cfg.ValidateCommand == "" {
return fmt.Errorf("NGINX reload_command and validate_command are required")
}
c.logger.Info("validating NGINX configuration",
"cert_path", cfg.CertPath,
"chain_path", cfg.ChainPath)
// Verify directory exists and is writable
certDir := cfg.CertPath[:len(cfg.CertPath)-len("/cert.pem")] // Simple path extraction
if _, err := os.Stat(certDir); os.IsNotExist(err) {
return fmt.Errorf("NGINX cert directory does not exist: %s", certDir)
}
// Verify validate command works
cmd := exec.CommandContext(ctx, "sh", "-c", cfg.ValidateCommand)
if err := cmd.Run(); err != nil {
c.logger.Warn("NGINX config validation failed during config check",
"error", err,
"validate_command", cfg.ValidateCommand)
// Don't fail validation; NGINX might not be installed yet
}
c.config = &cfg
c.logger.Info("NGINX configuration validated")
return nil
}
// DeployCertificate writes the certificate and chain to the configured paths
// and reloads NGINX to pick up the new certificates.
// The agent (not the control plane) manages the private key.
//
// Steps:
// 1. Write certificate to cert_path with mode 0644 (readable by all)
// 2. Write chain to chain_path with mode 0644
// 3. Validate NGINX configuration
// 4. Execute reload command
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
c.logger.Info("deploying certificate to NGINX",
"cert_path", c.config.CertPath,
"chain_path", c.config.ChainPath)
startTime := time.Now()
// Write certificate with secure permissions (0644: rw-r--r--)
if err := os.WriteFile(c.config.CertPath, []byte(request.CertPEM), 0644); err != nil {
errMsg := fmt.Sprintf("failed to write certificate: %v", err)
c.logger.Error("certificate deployment failed", "error", err)
return &target.DeploymentResult{
Success: false,
TargetAddress: c.config.CertPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf(errMsg)
}
// Write chain with same permissions
if err := os.WriteFile(c.config.ChainPath, []byte(request.ChainPEM), 0644); err != nil {
errMsg := fmt.Sprintf("failed to write chain: %v", err)
c.logger.Error("chain deployment failed", "error", err)
return &target.DeploymentResult{
Success: false,
TargetAddress: c.config.ChainPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf(errMsg)
}
// Validate NGINX configuration before reload
c.logger.Debug("validating NGINX configuration", "validate_command", c.config.ValidateCommand)
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
if err := validateCmd.Run(); err != nil {
errMsg := fmt.Sprintf("NGINX config validation failed: %v", err)
c.logger.Error("NGINX validation failed", "error", err)
return &target.DeploymentResult{
Success: false,
TargetAddress: c.config.CertPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf(errMsg)
}
// Reload NGINX
c.logger.Debug("reloading NGINX", "reload_command", c.config.ReloadCommand)
reloadCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ReloadCommand)
if err := reloadCmd.Run(); err != nil {
errMsg := fmt.Sprintf("NGINX reload failed: %v", err)
c.logger.Error("NGINX reload failed", "error", err)
return &target.DeploymentResult{
Success: false,
TargetAddress: c.config.CertPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf(errMsg)
}
deploymentDuration := time.Since(startTime)
c.logger.Info("certificate deployed to NGINX successfully",
"duration", deploymentDuration.String(),
"cert_path", c.config.CertPath)
return &target.DeploymentResult{
Success: true,
TargetAddress: c.config.CertPath,
DeploymentID: fmt.Sprintf("nginx-%d", time.Now().Unix()),
Message: "Certificate deployed and NGINX reloaded successfully",
DeployedAt: time.Now(),
Metadata: map[string]string{
"cert_path": c.config.CertPath,
"chain_path": c.config.ChainPath,
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
},
}, nil
}
// ValidateDeployment verifies that the deployed certificate is valid and accessible.
// It validates the NGINX configuration to ensure the certificate can be read.
//
// Steps:
// 1. Run validate command to check config syntax
// 2. Verify certificate file is readable
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
c.logger.Info("validating NGINX deployment",
"certificate_id", request.CertificateID,
"serial", request.Serial)
startTime := time.Now()
// Validate NGINX configuration
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
if err := validateCmd.Run(); err != nil {
errMsg := fmt.Sprintf("NGINX config validation failed: %v", err)
c.logger.Error("validation failed", "error", err)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: c.config.CertPath,
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf(errMsg)
}
// Verify certificate file exists and is readable
if _, err := os.Stat(c.config.CertPath); os.IsNotExist(err) {
errMsg := fmt.Sprintf("certificate file not found: %s", c.config.CertPath)
c.logger.Error("validation failed", "error", err)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: c.config.CertPath,
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf(errMsg)
}
validationDuration := time.Since(startTime)
c.logger.Info("NGINX deployment validated successfully",
"duration", validationDuration.String())
return &target.ValidationResult{
Valid: true,
Serial: request.Serial,
TargetAddress: c.config.CertPath,
Message: "NGINX configuration valid and certificate accessible",
ValidatedAt: time.Now(),
Metadata: map[string]string{
"validate_command": c.config.ValidateCommand,
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
},
}, nil
}
+27
View File
@@ -0,0 +1,27 @@
package domain
import (
"encoding/json"
"time"
)
// AuditEvent records an action taken in the control plane.
type AuditEvent struct {
ID string `json:"id"`
Actor string `json:"actor"`
ActorType ActorType `json:"actor_type"`
Action string `json:"action"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
Details json.RawMessage `json:"details"`
Timestamp time.Time `json:"timestamp"`
}
// ActorType represents the entity performing an action.
type ActorType string
const (
ActorTypeUser ActorType = "User"
ActorTypeSystem ActorType = "System"
ActorTypeAgent ActorType = "Agent"
)
+65
View File
@@ -0,0 +1,65 @@
package domain
import (
"time"
)
// ManagedCertificate represents a certificate managed by the control plane.
type ManagedCertificate struct {
ID string `json:"id"`
Name string `json:"name"`
CommonName string `json:"common_name"`
SANs []string `json:"sans"`
Environment string `json:"environment"`
OwnerID string `json:"owner_id"`
TeamID string `json:"team_id"`
IssuerID string `json:"issuer_id"`
TargetIDs []string `json:"target_ids"`
RenewalPolicyID string `json:"renewal_policy_id"`
Status CertificateStatus `json:"status"`
ExpiresAt time.Time `json:"expires_at"`
Tags map[string]string `json:"tags"`
LastRenewalAt *time.Time `json:"last_renewal_at,omitempty"`
LastDeploymentAt *time.Time `json:"last_deployment_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CertificateVersion represents a specific version of a certificate.
type CertificateVersion struct {
ID string `json:"id"`
CertificateID string `json:"certificate_id"`
SerialNumber string `json:"serial_number"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
FingerprintSHA256 string `json:"fingerprint_sha256"`
PEMChain string `json:"pem_chain"`
CSRPEM string `json:"csr_pem"`
CreatedAt time.Time `json:"created_at"`
}
// CertificateStatus represents the lifecycle status of a managed certificate.
type CertificateStatus string
const (
CertificateStatusPending CertificateStatus = "Pending"
CertificateStatusActive CertificateStatus = "Active"
CertificateStatusExpiring CertificateStatus = "Expiring"
CertificateStatusExpired CertificateStatus = "Expired"
CertificateStatusRenewalInProgress CertificateStatus = "RenewalInProgress"
CertificateStatusFailed CertificateStatus = "Failed"
CertificateStatusRevoked CertificateStatus = "Revoked"
CertificateStatusArchived CertificateStatus = "Archived"
)
// RenewalPolicy defines renewal parameters for a managed certificate.
type RenewalPolicy struct {
ID string `json:"id"`
Name string `json:"name"`
RenewalWindowDays int `json:"renewal_window_days"`
AutoRenew bool `json:"auto_renew"`
MaxRetries int `json:"max_retries"`
RetryInterval int `json:"retry_interval_seconds"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+66
View File
@@ -0,0 +1,66 @@
package domain
import (
"encoding/json"
"time"
)
// Issuer represents a certificate authority or ACME provider.
type Issuer struct {
ID string `json:"id"`
Name string `json:"name"`
Type IssuerType `json:"type"`
Config json.RawMessage `json:"config"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// DeploymentTarget represents a target system where certificates are deployed.
type DeploymentTarget struct {
ID string `json:"id"`
Name string `json:"name"`
Type TargetType `json:"type"`
AgentID string `json:"agent_id"`
Config json.RawMessage `json:"config"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Agent represents an agent running on a target system.
type Agent struct {
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Status AgentStatus `json:"status"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
RegisteredAt time.Time `json:"registered_at"`
APIKeyHash string `json:"api_key_hash"`
}
// AgentStatus represents the operational status of an agent.
type AgentStatus string
const (
AgentStatusOnline AgentStatus = "Online"
AgentStatusOffline AgentStatus = "Offline"
AgentStatusDegraded AgentStatus = "Degraded"
)
// IssuerType represents the type of certificate authority.
type IssuerType string
const (
IssuerTypeACME IssuerType = "ACME"
IssuerTypeGenericCA IssuerType = "GenericCA"
)
// TargetType represents the type of deployment target.
type TargetType string
const (
TargetTypeNGINX TargetType = "NGINX"
TargetTypeF5 TargetType = "F5"
TargetTypeIIS TargetType = "IIS"
)
+50
View File
@@ -0,0 +1,50 @@
package domain
import (
"encoding/json"
"time"
)
// Job represents a unit of work in the certificate control plane.
type Job struct {
ID string `json:"id"`
Type JobType `json:"type"`
CertificateID string `json:"certificate_id"`
TargetID *string `json:"target_id,omitempty"`
Status JobStatus `json:"status"`
Attempts int `json:"attempts"`
MaxAttempts int `json:"max_attempts"`
LastError *string `json:"last_error,omitempty"`
ScheduledAt time.Time `json:"scheduled_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// JobType represents the classification of work to be performed.
type JobType string
const (
JobTypeIssuance JobType = "Issuance"
JobTypeRenewal JobType = "Renewal"
JobTypeDeployment JobType = "Deployment"
JobTypeValidation JobType = "Validation"
)
// JobStatus represents the execution state of a job.
type JobStatus string
const (
JobStatusPending JobStatus = "Pending"
JobStatusRunning JobStatus = "Running"
JobStatusCompleted JobStatus = "Completed"
JobStatusFailed JobStatus = "Failed"
JobStatusCancelled JobStatus = "Cancelled"
)
// DeploymentJob represents a job that deploys a certificate to a target via an agent.
type DeploymentJob struct {
Job `json:"job"`
AgentID string `json:"agent_id"`
DeploymentResult json.RawMessage `json:"deployment_result,omitempty"`
}
+40
View File
@@ -0,0 +1,40 @@
package domain
import (
"time"
)
// NotificationEvent records a notification sent to users about certificate events.
type NotificationEvent struct {
ID string `json:"id"`
Type NotificationType `json:"type"`
CertificateID *string `json:"certificate_id,omitempty"`
Channel NotificationChannel `json:"channel"`
Recipient string `json:"recipient"`
Message string `json:"message"`
SentAt *time.Time `json:"sent_at,omitempty"`
Status string `json:"status"`
Error *string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// NotificationType represents the event that triggered a notification.
type NotificationType string
const (
NotificationTypeExpirationWarning NotificationType = "ExpirationWarning"
NotificationTypeRenewalSuccess NotificationType = "RenewalSuccess"
NotificationTypeRenewalFailure NotificationType = "RenewalFailure"
NotificationTypeDeploymentSuccess NotificationType = "DeploymentSuccess"
NotificationTypeDeploymentFailure NotificationType = "DeploymentFailure"
NotificationTypePolicyViolation NotificationType = "PolicyViolation"
)
// NotificationChannel represents the communication medium for a notification.
type NotificationChannel string
const (
NotificationChannelEmail NotificationChannel = "Email"
NotificationChannelWebhook NotificationChannel = "Webhook"
NotificationChannelSlack NotificationChannel = "Slack"
)
+47
View File
@@ -0,0 +1,47 @@
package domain
import (
"encoding/json"
"time"
)
// PolicyRule defines enforcement rules for certificate management.
type PolicyRule struct {
ID string `json:"id"`
Name string `json:"name"`
Type PolicyType `json:"type"`
Config json.RawMessage `json:"config"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PolicyType represents the category of policy enforcement.
type PolicyType string
const (
PolicyTypeAllowedIssuers PolicyType = "AllowedIssuers"
PolicyTypeAllowedDomains PolicyType = "AllowedDomains"
PolicyTypeRequiredMetadata PolicyType = "RequiredMetadata"
PolicyTypeAllowedEnvironments PolicyType = "AllowedEnvironments"
PolicyTypeRenewalLeadTime PolicyType = "RenewalLeadTime"
)
// PolicyViolation records an instance of a certificate violating a policy rule.
type PolicyViolation struct {
ID string `json:"id"`
CertificateID string `json:"certificate_id"`
RuleID string `json:"rule_id"`
Message string `json:"message"`
Severity PolicySeverity `json:"severity"`
CreatedAt time.Time `json:"created_at"`
}
// PolicySeverity indicates the impact level of a policy violation.
type PolicySeverity string
const (
PolicySeverityWarning PolicySeverity = "Warning"
PolicySeverityError PolicySeverity = "Error"
PolicySeverityCritical PolicySeverity = "Critical"
)
+24
View File
@@ -0,0 +1,24 @@
package domain
import (
"time"
)
// Team represents an organizational unit managing certificates.
type Team struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Owner represents a user who owns certificates within a team.
type Owner struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
TeamID string `json:"team_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+44
View File
@@ -0,0 +1,44 @@
package repository
import "time"
// CertificateFilter defines filtering criteria for certificate queries.
type CertificateFilter struct {
Status string // e.g., "active", "expiring", "expired", "archived"
Environment string // e.g., "production", "staging", "development"
OwnerID string
TeamID string
IssuerID string
Page int // 1-indexed; default 1
PerPage int // default 50, max 500
}
// JobFilter defines filtering criteria for job queries.
type JobFilter struct {
Status string // e.g., "pending", "in-progress", "completed", "failed"
Type string // e.g., "renewal", "deployment"
CertificateID string
Page int
PerPage int
}
// AuditFilter defines filtering criteria for audit event queries.
type AuditFilter struct {
Actor string // username or service ID
ActorType string // "user", "agent", "system"
ResourceType string // e.g., "certificate", "policy", "agent"
ResourceID string
From time.Time
To time.Time
Page int
PerPage int
}
// NotificationFilter defines filtering criteria for notification queries.
type NotificationFilter struct {
CertificateID string // optional: filter by certificate
Status string // e.g., "pending", "sent", "failed"
Channel string // e.g., "email", "slack", "webhook"
Page int
PerPage int
}
+162
View File
@@ -0,0 +1,162 @@
package repository
import (
"context"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// CertificateRepository defines operations for managing certificates.
type CertificateRepository interface {
// List returns a paginated list of certificates matching the filter criteria.
List(ctx context.Context, filter *CertificateFilter) ([]*domain.ManagedCertificate, int, error)
// Get retrieves a certificate by ID.
Get(ctx context.Context, id string) (*domain.ManagedCertificate, error)
// Create stores a new certificate.
Create(ctx context.Context, cert *domain.ManagedCertificate) error
// Update modifies an existing certificate.
Update(ctx context.Context, cert *domain.ManagedCertificate) error
// Archive marks a certificate as archived.
Archive(ctx context.Context, id string) error
// ListVersions returns all versions of a certificate.
ListVersions(ctx context.Context, certID string) ([]*domain.CertificateVersion, error)
// CreateVersion stores a new certificate version.
CreateVersion(ctx context.Context, version *domain.CertificateVersion) error
// GetExpiringCertificates returns certificates expiring before the given time.
GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error)
}
// IssuerRepository defines operations for managing certificate issuers.
type IssuerRepository interface {
// List returns all issuers, optionally filtered.
List(ctx context.Context) ([]*domain.Issuer, error)
// Get retrieves an issuer by ID.
Get(ctx context.Context, id string) (*domain.Issuer, error)
// Create stores a new issuer.
Create(ctx context.Context, issuer *domain.Issuer) error
// Update modifies an existing issuer.
Update(ctx context.Context, issuer *domain.Issuer) error
// Delete removes an issuer.
Delete(ctx context.Context, id string) error
}
// TargetRepository defines operations for managing deployment targets.
type TargetRepository interface {
// List returns all targets, optionally filtered.
List(ctx context.Context) ([]*domain.DeploymentTarget, error)
// Get retrieves a target by ID.
Get(ctx context.Context, id string) (*domain.DeploymentTarget, error)
// Create stores a new target.
Create(ctx context.Context, target *domain.DeploymentTarget) error
// Update modifies an existing target.
Update(ctx context.Context, target *domain.DeploymentTarget) error
// Delete removes a target.
Delete(ctx context.Context, id string) error
// ListByCertificate returns all targets for a given certificate.
ListByCertificate(ctx context.Context, certID string) ([]*domain.DeploymentTarget, error)
}
// AgentRepository defines operations for managing control plane agents.
type AgentRepository interface {
// List returns all agents.
List(ctx context.Context) ([]*domain.Agent, error)
// Get retrieves an agent by ID.
Get(ctx context.Context, id string) (*domain.Agent, error)
// Create stores a new agent.
Create(ctx context.Context, agent *domain.Agent) error
// Update modifies an existing agent.
Update(ctx context.Context, agent *domain.Agent) error
// Delete removes an agent.
Delete(ctx context.Context, id string) error
// UpdateHeartbeat updates the agent's last heartbeat timestamp.
UpdateHeartbeat(ctx context.Context, id string) error
// GetByAPIKey retrieves an agent by hashed API key.
GetByAPIKey(ctx context.Context, keyHash string) (*domain.Agent, error)
}
// JobRepository defines operations for managing renewal and deployment jobs.
type JobRepository interface {
// List returns all jobs.
List(ctx context.Context) ([]*domain.Job, error)
// Get retrieves a job by ID.
Get(ctx context.Context, id string) (*domain.Job, error)
// Create stores a new job.
Create(ctx context.Context, job *domain.Job) error
// Update modifies an existing job.
Update(ctx context.Context, job *domain.Job) error
// Delete removes a job.
Delete(ctx context.Context, id string) error
// ListByStatus returns jobs with a specific status.
ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error)
// ListByCertificate returns all jobs for a certificate.
ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error)
// UpdateStatus updates a job's status and optional error message.
UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error
// GetPendingJobs returns jobs not yet processed of a specific type.
GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error)
}
// PolicyRepository defines operations for managing compliance policies and violations.
type PolicyRepository interface {
// ListRules returns all policy rules.
ListRules(ctx context.Context) ([]*domain.PolicyRule, error)
// GetRule retrieves a policy rule by ID.
GetRule(ctx context.Context, id string) (*domain.PolicyRule, error)
// CreateRule stores a new policy rule.
CreateRule(ctx context.Context, rule *domain.PolicyRule) error
// UpdateRule modifies an existing policy rule.
UpdateRule(ctx context.Context, rule *domain.PolicyRule) error
// DeleteRule removes a policy rule.
DeleteRule(ctx context.Context, id string) error
// CreateViolation records a policy violation.
CreateViolation(ctx context.Context, violation *domain.PolicyViolation) error
// ListViolations returns policy violations, optionally filtered.
ListViolations(ctx context.Context, filter *AuditFilter) ([]*domain.PolicyViolation, error)
}
// AuditRepository defines operations for recording and retrieving audit logs.
type AuditRepository interface {
// Create stores a new audit event.
Create(ctx context.Context, event *domain.AuditEvent) error
// List returns audit events matching the filter criteria.
List(ctx context.Context, filter *AuditFilter) ([]*domain.AuditEvent, error)
}
// NotificationRepository defines operations for managing notifications.
type NotificationRepository interface {
// Create stores a new notification.
Create(ctx context.Context, notif *domain.NotificationEvent) error
// List returns notifications matching the filter criteria.
List(ctx context.Context, filter *NotificationFilter) ([]*domain.NotificationEvent, error)
// UpdateStatus updates a notification's delivery status.
UpdateStatus(ctx context.Context, id string, status string, sentAt time.Time) error
}
// TeamRepository defines operations for managing teams.
type TeamRepository interface {
// List returns all teams.
List(ctx context.Context) ([]*domain.Team, error)
// Get retrieves a team by ID.
Get(ctx context.Context, id string) (*domain.Team, error)
// Create stores a new team.
Create(ctx context.Context, team *domain.Team) error
// Update modifies an existing team.
Update(ctx context.Context, team *domain.Team) error
// Delete removes a team.
Delete(ctx context.Context, id string) error
}
// OwnerRepository defines operations for managing certificate owners.
type OwnerRepository interface {
// List returns all owners.
List(ctx context.Context) ([]*domain.Owner, error)
// Get retrieves an owner by ID.
Get(ctx context.Context, id string) (*domain.Owner, error)
// Create stores a new owner.
Create(ctx context.Context, owner *domain.Owner) error
// Update modifies an existing owner.
Update(ctx context.Context, owner *domain.Owner) error
// Delete removes an owner.
Delete(ctx context.Context, id string) error
}
+219
View File
@@ -0,0 +1,219 @@
package scheduler
import (
"context"
"log/slog"
"time"
"github.com/shankar0123/certctl/internal/service"
)
// Scheduler manages background jobs and periodic tasks for the certificate control plane.
// It runs multiple concurrent loops for renewal checks, job processing, agent health checks,
// and notification processing.
type Scheduler struct {
renewalService *service.RenewalService
jobService *service.JobService
agentService *service.AgentService
notificationService *service.NotificationService
logger *slog.Logger
// Configurable tick intervals
renewalCheckInterval time.Duration
jobProcessorInterval time.Duration
agentHealthCheckInterval time.Duration
notificationProcessInterval time.Duration
}
// NewScheduler creates a new scheduler with configurable intervals.
func NewScheduler(
renewalService *service.RenewalService,
jobService *service.JobService,
agentService *service.AgentService,
notificationService *service.NotificationService,
logger *slog.Logger,
) *Scheduler {
return &Scheduler{
renewalService: renewalService,
jobService: jobService,
agentService: agentService,
notificationService: notificationService,
logger: logger,
// Default intervals
renewalCheckInterval: 1 * time.Hour,
jobProcessorInterval: 30 * time.Second,
agentHealthCheckInterval: 2 * time.Minute,
notificationProcessInterval: 1 * time.Minute,
}
}
// SetRenewalCheckInterval configures the interval for renewal checks.
func (s *Scheduler) SetRenewalCheckInterval(d time.Duration) {
s.renewalCheckInterval = d
}
// SetJobProcessorInterval configures the interval for job processing.
func (s *Scheduler) SetJobProcessorInterval(d time.Duration) {
s.jobProcessorInterval = d
}
// SetAgentHealthCheckInterval configures the interval for agent health checks.
func (s *Scheduler) SetAgentHealthCheckInterval(d time.Duration) {
s.agentHealthCheckInterval = d
}
// SetNotificationProcessInterval configures the interval for notification processing.
func (s *Scheduler) SetNotificationProcessInterval(d time.Duration) {
s.notificationProcessInterval = d
}
// Start initiates all background scheduler loops. It returns a channel that signals
// when the scheduler has started all loops. The scheduler runs until the context is cancelled.
func (s *Scheduler) Start(ctx context.Context) <-chan struct{} {
startedChan := make(chan struct{})
go func() {
s.logger.Info("scheduler starting")
// Signal that the scheduler has started all loops
go func() {
<-time.After(100 * time.Millisecond)
close(startedChan)
}()
// Start all scheduler loops concurrently
go s.renewalCheckLoop(ctx)
go s.jobProcessorLoop(ctx)
go s.agentHealthCheckLoop(ctx)
go s.notificationProcessLoop(ctx)
// Wait for context cancellation
<-ctx.Done()
s.logger.Info("scheduler shutting down", "reason", ctx.Err())
}()
return startedChan
}
// renewalCheckLoop runs every renewalCheckInterval and checks for expiring certificates.
// If an error occurs, it logs the error but continues running.
func (s *Scheduler) renewalCheckLoop(ctx context.Context) {
ticker := time.NewTicker(s.renewalCheckInterval)
defer ticker.Stop()
// Run immediately on start
s.runRenewalCheck(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.runRenewalCheck(ctx)
}
}
}
// runRenewalCheck executes a single renewal check with error recovery.
func (s *Scheduler) runRenewalCheck(ctx context.Context) {
if err := s.renewalService.CheckExpiringCertificates(ctx); err != nil {
s.logger.Error("renewal check failed",
"error", err,
"interval", s.renewalCheckInterval.String())
} else {
s.logger.Debug("renewal check completed")
}
}
// jobProcessorLoop runs every jobProcessorInterval and processes pending jobs.
// It picks up pending jobs, executes them, and handles the results.
// If an error occurs, it logs the error but continues running.
func (s *Scheduler) jobProcessorLoop(ctx context.Context) {
ticker := time.NewTicker(s.jobProcessorInterval)
defer ticker.Stop()
// Run immediately on start
s.runJobProcessor(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.runJobProcessor(ctx)
}
}
}
// runJobProcessor executes a single job processing cycle with error recovery.
func (s *Scheduler) runJobProcessor(ctx context.Context) {
if err := s.jobService.ProcessPendingJobs(ctx); err != nil {
s.logger.Error("job processor failed",
"error", err,
"interval", s.jobProcessorInterval.String())
} else {
s.logger.Debug("job processor completed")
}
}
// agentHealthCheckLoop runs every agentHealthCheckInterval and marks stale agents as offline.
// An agent is considered stale if it hasn't sent a heartbeat within the health check interval.
// If an error occurs, it logs the error but continues running.
func (s *Scheduler) agentHealthCheckLoop(ctx context.Context) {
ticker := time.NewTicker(s.agentHealthCheckInterval)
defer ticker.Stop()
// Run immediately on start
s.runAgentHealthCheck(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.runAgentHealthCheck(ctx)
}
}
}
// runAgentHealthCheck executes a single agent health check with error recovery.
func (s *Scheduler) runAgentHealthCheck(ctx context.Context) {
if err := s.agentService.MarkStaleAgentsOffline(ctx, s.agentHealthCheckInterval); err != nil {
s.logger.Error("agent health check failed",
"error", err,
"interval", s.agentHealthCheckInterval.String())
} else {
s.logger.Debug("agent health check completed")
}
}
// notificationProcessLoop runs every notificationProcessInterval and processes pending notifications.
// If an error occurs, it logs the error but continues running.
func (s *Scheduler) notificationProcessLoop(ctx context.Context) {
ticker := time.NewTicker(s.notificationProcessInterval)
defer ticker.Stop()
// Run immediately on start
s.runNotificationProcess(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.runNotificationProcess(ctx)
}
}
}
// runNotificationProcess executes a single notification processing cycle with error recovery.
func (s *Scheduler) runNotificationProcess(ctx context.Context) {
if err := s.notificationService.ProcessPendingNotifications(ctx); err != nil {
s.logger.Error("notification processor failed",
"error", err,
"interval", s.notificationProcessInterval.String())
} else {
s.logger.Debug("notification processor completed")
}
}
+261
View File
@@ -0,0 +1,261 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/rand"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// AgentService provides business logic for managing and coordinating with agents.
type AgentService struct {
agentRepo repository.AgentRepository
certRepo repository.CertificateRepository
jobRepo repository.JobRepository
auditService *AuditService
issuerRegistry map[string]IssuerConnector
}
// NewAgentService creates a new agent service.
func NewAgentService(
agentRepo repository.AgentRepository,
certRepo repository.CertificateRepository,
jobRepo repository.JobRepository,
auditService *AuditService,
issuerRegistry map[string]IssuerConnector,
) *AgentService {
return &AgentService{
agentRepo: agentRepo,
certRepo: certRepo,
jobRepo: jobRepo,
auditService: auditService,
issuerRegistry: issuerRegistry,
}
}
// Register creates a new agent and returns its API key (only once).
func (s *AgentService) Register(ctx context.Context, name string, hostname string) (*domain.Agent, string, error) {
if name == "" || hostname == "" {
return nil, "", fmt.Errorf("agent name and hostname are required")
}
// Generate API key
apiKey := generateAPIKey()
apiKeyHash := hashAPIKey(apiKey)
now := time.Now()
agent := &domain.Agent{
ID: generateID("agent"),
Name: name,
Hostname: hostname,
APIKeyHash: apiKeyHash,
Status: domain.AgentStatusOnline,
RegisteredAt: now,
LastHeartbeatAt: &now,
}
if err := s.agentRepo.Create(ctx, agent); err != nil {
return nil, "", fmt.Errorf("failed to create agent: %w", err)
}
// Record audit event
if err := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"agent_registered", "agent", agent.ID,
map[string]interface{}{"name": name, "hostname": hostname}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
// Return the API key only once; the agent must save it securely
return agent, apiKey, nil
}
// Heartbeat updates an agent's last seen time and status.
func (s *AgentService) Heartbeat(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)
}
// Update heartbeat
if err := s.agentRepo.UpdateHeartbeat(ctx, agentID); err != nil {
return fmt.Errorf("failed to update heartbeat: %w", err)
}
// Update status if previously offline
if agent.Status != domain.AgentStatusOnline {
agent.Status = domain.AgentStatusOnline
if err := s.agentRepo.Update(ctx, agent); err != nil {
fmt.Printf("failed to update agent status: %v\n", err)
}
}
return nil
}
// 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
agent, err := s.agentRepo.Get(ctx, agentID)
if err != nil {
return fmt.Errorf("failed to fetch agent: %w", err)
}
// Validate CSR format (basic check)
if len(csrPEM) == 0 {
return fmt.Errorf("invalid CSR: empty")
}
// In production, parse and validate the CSR signature and CN here
// For now, accept and proceed
// In a production system, we'd store the CSR in a certificate version or metadata
// For now, we just validate and accept it
// Record audit event
if err := s.auditService.RecordEvent(ctx, agent.ID, domain.ActorTypeAgent,
"csr_submitted", "certificate", certID,
map[string]interface{}{"agent_hostname": agent.Hostname}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// GetCertificateForAgent returns the latest public certificate material for an agent.
func (s *AgentService) GetCertificateForAgent(ctx context.Context, agentID string, certID string) ([]byte, error) {
// Fetch agent
_, err := s.agentRepo.Get(ctx, agentID)
if err != nil {
return nil, fmt.Errorf("failed to fetch agent: %w", err)
}
// Get latest version
versions, err := s.certRepo.ListVersions(ctx, certID)
if err != nil {
return nil, fmt.Errorf("failed to fetch certificate versions: %w", err)
}
if len(versions) == 0 {
return nil, fmt.Errorf("no certificate versions found")
}
// Return the most recent version (latest CreatedAt timestamp)
latestVersion := versions[0]
for _, v := range versions {
if v.CreatedAt.After(latestVersion.CreatedAt) {
latestVersion = v
}
}
// Record audit event
if err := s.auditService.RecordEvent(ctx, agentID, domain.ActorTypeAgent,
"certificate_retrieved", "certificate", certID,
map[string]interface{}{"version": latestVersion.SerialNumber}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return []byte(latestVersion.PEMChain), nil
}
// GetPendingWork returns deployment jobs assigned to an agent.
func (s *AgentService) GetPendingWork(ctx context.Context, agentID string) ([]*domain.Job, error) {
// Fetch agent to verify it exists
_, err := s.agentRepo.Get(ctx, agentID)
if err != nil {
return nil, fmt.Errorf("failed to fetch agent: %w", err)
}
// Get all deployment jobs
jobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusPending)
if err != nil {
return nil, fmt.Errorf("failed to list pending jobs: %w", err)
}
var workForAgent []*domain.Job
// Filter to only jobs assigned to this agent
// Note: In this implementation, agents don't filter jobs by assignment
// All deployment jobs are returned for the agent to process
for _, job := range jobs {
if job.Type == domain.JobTypeDeployment {
workForAgent = append(workForAgent, job)
}
}
return workForAgent, nil
}
// ReportJobStatus updates a job's status based on agent feedback.
func (s *AgentService) ReportJobStatus(ctx context.Context, agentID string, jobID string, status domain.JobStatus, errMsg string) error {
// Fetch job to verify it exists
_, err := s.jobRepo.Get(ctx, jobID)
if err != nil {
return fmt.Errorf("failed to fetch job: %w", err)
}
// Update job status
if err := s.jobRepo.UpdateStatus(ctx, jobID, status, errMsg); err != nil {
return fmt.Errorf("failed to update job status: %w", err)
}
// Record audit event
if err := s.auditService.RecordEvent(ctx, agentID, domain.ActorTypeAgent,
"job_status_reported", "job", jobID,
map[string]interface{}{"status": status, "error": errMsg}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// MarkStaleAgentsOffline marks agents as offline if they haven't sent a heartbeat
// within the given threshold duration.
func (s *AgentService) MarkStaleAgentsOffline(ctx context.Context, threshold time.Duration) error {
agents, err := s.agentRepo.List(ctx)
if err != nil {
return fmt.Errorf("failed to list agents: %w", err)
}
cutoff := time.Now().Add(-threshold)
for _, agent := range agents {
if agent.Status == domain.AgentStatusOnline && agent.LastHeartbeatAt != nil && agent.LastHeartbeatAt.Before(cutoff) {
agent.Status = domain.AgentStatusOffline
if err := s.agentRepo.Update(ctx, agent); err != nil {
fmt.Printf("failed to mark agent %s offline: %v\n", agent.ID, err)
continue
}
}
}
return nil
}
// GetAgentByAPIKey retrieves an agent by hashed API key.
func (s *AgentService) GetAgentByAPIKey(ctx context.Context, apiKey string) (*domain.Agent, error) {
apiKeyHash := hashAPIKey(apiKey)
agent, err := s.agentRepo.GetByAPIKey(ctx, apiKeyHash)
if err != nil {
return nil, fmt.Errorf("invalid API key: %w", err)
}
return agent, nil
}
// generateAPIKey creates a random API key for an agent.
func generateAPIKey() string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, 32)
for i := range b {
b[i] = charset[rand.Intn(len(charset))]
}
return string(b)
}
// hashAPIKey hashes an API key using SHA256.
func hashAPIKey(apiKey string) string {
hash := sha256.Sum256([]byte(apiKey))
return hex.EncodeToString(hash[:])
}
+110
View File
@@ -0,0 +1,110 @@
package service
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// AuditService provides business logic for recording and retrieving audit events.
type AuditService struct {
auditRepo repository.AuditRepository
}
// NewAuditService creates a new audit service.
func NewAuditService(auditRepo repository.AuditRepository) *AuditService {
return &AuditService{
auditRepo: auditRepo,
}
}
// RecordEvent records an audit event with actor, action, and resource information.
func (s *AuditService) RecordEvent(ctx context.Context, actor string, actorType domain.ActorType, action string, resourceType string, resourceID string, details map[string]interface{}) error {
detailsJSON, err := json.Marshal(details)
if err != nil {
detailsJSON = []byte("{}")
}
event := &domain.AuditEvent{
ID: generateID("audit"),
Timestamp: time.Now(),
Actor: actor,
ActorType: actorType,
Action: action,
ResourceType: resourceType,
ResourceID: resourceID,
Details: json.RawMessage(detailsJSON),
}
if err := s.auditRepo.Create(ctx, event); err != nil {
return fmt.Errorf("failed to record audit event: %w", err)
}
return nil
}
// List returns audit events matching filter criteria.
func (s *AuditService) List(ctx context.Context, filter *repository.AuditFilter) ([]*domain.AuditEvent, error) {
events, err := s.auditRepo.List(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to list audit events: %w", err)
}
return events, nil
}
// ListByResource returns all audit events for a specific resource.
func (s *AuditService) ListByResource(ctx context.Context, resourceType string, resourceID string) ([]*domain.AuditEvent, error) {
filter := &repository.AuditFilter{
ResourceType: resourceType,
ResourceID: resourceID,
PerPage: 1000, // reasonable default for single resource
}
events, err := s.auditRepo.List(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to list audit events: %w", err)
}
return events, nil
}
// ListByActor returns all audit events for a specific actor.
func (s *AuditService) ListByActor(ctx context.Context, actor string) ([]*domain.AuditEvent, error) {
filter := &repository.AuditFilter{
Actor: actor,
PerPage: 1000,
}
events, err := s.auditRepo.List(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to list audit events: %w", err)
}
return events, nil
}
// ListByAction returns all audit events for a specific action type.
func (s *AuditService) ListByAction(ctx context.Context, action string, from, to time.Time) ([]*domain.AuditEvent, error) {
filter := &repository.AuditFilter{
From: from,
To: to,
PerPage: 1000,
}
events, err := s.auditRepo.List(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to list audit events: %w", err)
}
// Filter by action on client side (repository may not filter by action directly)
var filtered []*domain.AuditEvent
for _, e := range events {
if e.Action == action {
filtered = append(filtered, e)
}
}
return filtered, nil
}
+213
View File
@@ -0,0 +1,213 @@
package service
import (
"context"
"fmt"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// CertificateService provides business logic for certificate management.
type CertificateService struct {
certRepo repository.CertificateRepository
policyService *PolicyService
auditService *AuditService
}
// NewCertificateService creates a new certificate service.
func NewCertificateService(
certRepo repository.CertificateRepository,
policyService *PolicyService,
auditService *AuditService,
) *CertificateService {
return &CertificateService{
certRepo: certRepo,
policyService: policyService,
auditService: auditService,
}
}
// List returns a paginated list of certificates matching the filter.
func (s *CertificateService) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
certs, total, err := s.certRepo.List(ctx, filter)
if err != nil {
return nil, 0, fmt.Errorf("failed to list certificates: %w", err)
}
return certs, total, nil
}
// Get retrieves a certificate by ID.
func (s *CertificateService) Get(ctx context.Context, id string) (*domain.ManagedCertificate, error) {
cert, err := s.certRepo.Get(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get certificate %s: %w", id, err)
}
return cert, nil
}
// Create validates and stores a new certificate.
func (s *CertificateService) Create(ctx context.Context, cert *domain.ManagedCertificate, actor string) error {
// Validate certificate structure
if cert.ID == "" || cert.CommonName == "" || cert.IssuerID == "" {
return fmt.Errorf("invalid certificate: missing required fields")
}
// Run policy validation
violations, err := s.policyService.ValidateCertificate(ctx, cert)
if err != nil {
return fmt.Errorf("policy validation failed: %w", err)
}
if len(violations) > 0 {
// Record violations but do not block creation
for _, v := range violations {
_ = s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"policy_violation_detected", "certificate", cert.ID,
map[string]interface{}{"rule_id": v.RuleID, "message": v.Message})
}
}
// Store certificate
if err := s.certRepo.Create(ctx, cert); err != nil {
return fmt.Errorf("failed to create certificate: %w", err)
}
// Record audit event
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"certificate_created", "certificate", cert.ID,
map[string]interface{}{"common_name": cert.CommonName}); err != nil {
// Log but don't fail the operation
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// Update modifies an existing certificate.
func (s *CertificateService) Update(ctx context.Context, cert *domain.ManagedCertificate, actor string) error {
existing, err := s.certRepo.Get(ctx, cert.ID)
if err != nil {
return fmt.Errorf("failed to fetch existing certificate: %w", err)
}
// Run policy validation on updated cert
violations, err := s.policyService.ValidateCertificate(ctx, cert)
if err != nil {
return fmt.Errorf("policy validation failed: %w", err)
}
if len(violations) > 0 {
for _, v := range violations {
_ = s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"policy_violation_detected", "certificate", cert.ID,
map[string]interface{}{"rule_id": v.RuleID, "message": v.Message})
}
}
// Store updated certificate
if err := s.certRepo.Update(ctx, cert); err != nil {
return fmt.Errorf("failed to update certificate: %w", err)
}
// Record audit event with diff info
changes := map[string]interface{}{}
if existing.Status != cert.Status {
changes["status"] = fmt.Sprintf("%s -> %s", existing.Status, cert.Status)
}
if existing.ExpiresAt != cert.ExpiresAt {
changes["expiry"] = fmt.Sprintf("%s -> %s", existing.ExpiresAt, cert.ExpiresAt)
}
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"certificate_updated", "certificate", cert.ID, changes); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// Archive marks a certificate as archived.
func (s *CertificateService) Archive(ctx context.Context, id string, actor string) error {
cert, err := s.certRepo.Get(ctx, id)
if err != nil {
return fmt.Errorf("failed to fetch certificate: %w", err)
}
if err := s.certRepo.Archive(ctx, id); err != nil {
return fmt.Errorf("failed to archive certificate: %w", err)
}
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"certificate_archived", "certificate", id,
map[string]interface{}{"common_name": cert.CommonName}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// GetVersions returns all versions of a certificate.
func (s *CertificateService) GetVersions(ctx context.Context, certID string) ([]*domain.CertificateVersion, error) {
versions, err := s.certRepo.ListVersions(ctx, certID)
if err != nil {
return nil, fmt.Errorf("failed to list certificate versions: %w", err)
}
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 {
cert, err := s.certRepo.Get(ctx, certID)
if err != nil {
return fmt.Errorf("failed to fetch certificate: %w", err)
}
// Validate eligibility
if cert.Status == domain.CertificateStatusArchived {
return fmt.Errorf("cannot renew archived certificate")
}
if cert.Status == domain.CertificateStatusExpired {
return fmt.Errorf("cannot renew expired certificate; reissue instead")
}
// Check if already renewing
if cert.Status == domain.CertificateStatusRenewalInProgress {
return fmt.Errorf("certificate renewal already in progress")
}
// Update status
cert.Status = domain.CertificateStatusRenewalInProgress
if err := s.certRepo.Update(ctx, cert); err != nil {
return fmt.Errorf("failed to update certificate status: %w", err)
}
// Record audit event
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"renewal_triggered", "certificate", certID,
map[string]interface{}{"common_name": cert.CommonName}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// TriggerDeployment creates deployment jobs for all targets of a certificate.
func (s *CertificateService) TriggerDeployment(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)
}
if cert.Status == domain.CertificateStatusArchived {
return fmt.Errorf("cannot deploy archived certificate")
}
// Note: In practice, the DeploymentService would be called to create jobs.
// This is a placeholder for the coordination logic.
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"deployment_triggered", "certificate", certID,
map[string]interface{}{"common_name": cert.CommonName}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
+293
View File
@@ -0,0 +1,293 @@
package service
import (
"context"
"fmt"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// DeploymentService manages certificate deployment to targets via agents.
type DeploymentService struct {
jobRepo repository.JobRepository
targetRepo repository.TargetRepository
agentRepo repository.AgentRepository
certRepo repository.CertificateRepository
auditService *AuditService
notificationSvc *NotificationService
}
// NewDeploymentService creates a new deployment service.
func NewDeploymentService(
jobRepo repository.JobRepository,
targetRepo repository.TargetRepository,
agentRepo repository.AgentRepository,
certRepo repository.CertificateRepository,
auditService *AuditService,
notificationSvc *NotificationService,
) *DeploymentService {
return &DeploymentService{
jobRepo: jobRepo,
targetRepo: targetRepo,
agentRepo: agentRepo,
certRepo: certRepo,
auditService: auditService,
notificationSvc: notificationSvc,
}
}
// CreateDeploymentJobs creates a job for each target of a certificate.
func (s *DeploymentService) CreateDeploymentJobs(ctx context.Context, certID string) ([]string, error) {
// Fetch all targets for this certificate
targets, err := s.targetRepo.ListByCertificate(ctx, certID)
if err != nil {
return nil, fmt.Errorf("failed to list targets: %w", err)
}
if len(targets) == 0 {
return nil, fmt.Errorf("no targets found for certificate %s", certID)
}
var jobIDs []string
// Create a deployment job for each target
for _, target := range targets {
job := &domain.Job{
ID: generateID("job"),
CertificateID: certID,
Type: domain.JobTypeDeployment,
Status: domain.JobStatusPending,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
// Store target info in TargetID field
if target.ID != "" {
job.TargetID = &target.ID
}
if err := s.jobRepo.Create(ctx, job); err != nil {
fmt.Printf("failed to create deployment job for target %s: %v\n", target.ID, err)
continue
}
jobIDs = append(jobIDs, job.ID)
}
if len(jobIDs) == 0 {
return nil, fmt.Errorf("failed to create any deployment jobs")
}
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"deployment_jobs_created", "certificate", certID,
map[string]interface{}{"target_count": len(targets), "job_count": len(jobIDs)})
return jobIDs, nil
}
// ProcessDeploymentJob handles a deployment job by coordinating with an agent.
func (s *DeploymentService) ProcessDeploymentJob(ctx context.Context, job *domain.Job) error {
// Update job status to in-progress
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusRunning, ""); err != nil {
return fmt.Errorf("failed to update job status: %w", err)
}
// Fetch certificate
cert, err := s.certRepo.Get(ctx, job.CertificateID)
if err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("certificate fetch failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("failed to fetch certificate: %w", err)
}
// Fetch target
var targetID string
if job.TargetID != nil {
targetID = *job.TargetID
}
if targetID == "" {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, "target_id not found in job")
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("target_id not found in job")
}
target, err := s.targetRepo.Get(ctx, targetID)
if err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("target fetch failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("failed to fetch target: %w", err)
}
// Verify agent is available
agentID := target.AgentID
agent, err := s.agentRepo.Get(ctx, agentID)
if err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("agent fetch failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("failed to fetch agent: %w", err)
}
// Check agent heartbeat (must be within last 5 minutes)
if agent.LastHeartbeatAt != nil && time.Since(*agent.LastHeartbeatAt) > 5*time.Minute {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, "agent is offline")
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
_ = s.notificationSvc.SendDeploymentNotification(ctx, cert, target, false, fmt.Errorf("agent offline"))
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"deployment_job_failed", "certificate", job.CertificateID,
map[string]interface{}{"job_id": job.ID, "reason": "agent offline", "target_id": targetID})
return fmt.Errorf("agent %s is offline", agentID)
}
// In a real implementation, the agent would poll GetPendingWork() to fetch this job.
// The control plane would wait for the agent to complete the work asynchronously.
// For now, we mark it as pending and rely on agent polling.
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"deployment_job_dispatched", "certificate", job.CertificateID,
map[string]interface{}{"job_id": job.ID, "target_id": targetID, "agent_id": agentID})
return nil
}
// ValidateDeployment checks the deployment status of a certificate on a target.
func (s *DeploymentService) ValidateDeployment(ctx context.Context, certID string, targetID string) (bool, error) {
// List deployment jobs for this certificate and target
jobs, err := s.jobRepo.ListByCertificate(ctx, certID)
if err != nil {
return false, fmt.Errorf("failed to list jobs: %w", err)
}
for _, job := range jobs {
if job.Type != domain.JobTypeDeployment {
continue
}
// Check if this job is for the target
if job.TargetID == nil || *job.TargetID != targetID {
continue
}
// Check if the most recent job for this target succeeded
if job.Status == domain.JobStatusCompleted {
return true, nil
}
if job.Status == domain.JobStatusFailed {
if job.LastError != nil {
return false, fmt.Errorf("deployment failed: %s", *job.LastError)
}
return false, fmt.Errorf("deployment failed")
}
// Still in progress
return false, fmt.Errorf("deployment in progress")
}
// No deployment job found
return false, fmt.Errorf("no deployment job found for target %s", targetID)
}
// MarkDeploymentComplete marks a deployment job as successfully completed.
// This is called by agents after they finish deploying a certificate.
func (s *DeploymentService) MarkDeploymentComplete(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)
}
if err := s.jobRepo.UpdateStatus(ctx, jobID, domain.JobStatusCompleted, ""); err != nil {
return fmt.Errorf("failed to update job status: %w", err)
}
// Fetch certificate and target for notification
cert, err := s.certRepo.Get(ctx, job.CertificateID)
if err != nil {
fmt.Printf("failed to fetch certificate for notification: %v\n", err)
return nil
}
var targetID string
if job.TargetID != nil {
targetID = *job.TargetID
}
if targetID != "" {
target, err := s.targetRepo.Get(ctx, targetID)
if err != nil {
fmt.Printf("failed to fetch target for notification: %v\n", err)
return nil
}
// Send deployment success notification
if err := s.notificationSvc.SendDeploymentNotification(ctx, cert, target, true, nil); err != nil {
fmt.Printf("failed to send deployment notification: %v\n", err)
}
}
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"deployment_job_completed", "certificate", job.CertificateID,
map[string]interface{}{"job_id": jobID, "target_id": targetID})
return nil
}
// MarkDeploymentFailed marks a deployment job as failed.
// Called by agents when deployment fails.
func (s *DeploymentService) MarkDeploymentFailed(ctx context.Context, jobID string, errMsg string) error {
job, err := s.jobRepo.Get(ctx, jobID)
if err != nil {
return fmt.Errorf("failed to fetch job: %w", err)
}
if err := s.jobRepo.UpdateStatus(ctx, jobID, domain.JobStatusFailed, errMsg); err != nil {
return fmt.Errorf("failed to update job status: %w", err)
}
// Fetch certificate and target for notification
cert, err := s.certRepo.Get(ctx, job.CertificateID)
if err != nil {
fmt.Printf("failed to fetch certificate for notification: %v\n", err)
return nil
}
var targetID string
if job.TargetID != nil {
targetID = *job.TargetID
}
if targetID != "" {
target, err := s.targetRepo.Get(ctx, targetID)
if err != nil {
fmt.Printf("failed to fetch target for notification: %v\n", err)
return nil
}
// Send deployment failure notification
if err := s.notificationSvc.SendDeploymentNotification(ctx, cert, target, false, fmt.Errorf(errMsg)); err != nil {
fmt.Printf("failed to send deployment notification: %v\n", err)
}
}
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"deployment_job_failed", "certificate", job.CertificateID,
map[string]interface{}{"job_id": jobID, "target_id": targetID, "error": errMsg})
return nil
}
+199
View File
@@ -0,0 +1,199 @@
package service
import (
"context"
"fmt"
"log/slog"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// JobService manages job processing and status tracking.
// It coordinates between the scheduler and various job-specific services.
type JobService struct {
jobRepo repository.JobRepository
renewalService *RenewalService
deploymentService *DeploymentService
logger *slog.Logger
}
// NewJobService creates a new job service.
func NewJobService(
jobRepo repository.JobRepository,
renewalService *RenewalService,
deploymentService *DeploymentService,
logger *slog.Logger,
) *JobService {
return &JobService{
jobRepo: jobRepo,
renewalService: renewalService,
deploymentService: deploymentService,
logger: logger,
}
}
// ProcessPendingJobs fetches and processes all pending jobs.
// It routes jobs to the appropriate service based on job type and handles errors gracefully.
func (s *JobService) ProcessPendingJobs(ctx context.Context) error {
// Fetch pending jobs
pendingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusPending)
if err != nil {
return fmt.Errorf("failed to list pending jobs: %w", err)
}
if len(pendingJobs) == 0 {
s.logger.Debug("no pending jobs to process")
return nil
}
s.logger.Info("processing pending jobs", "count", len(pendingJobs))
var processedCount int
var failedCount int
// Process each job
for _, job := range pendingJobs {
if err := s.processJob(ctx, job); err != nil {
s.logger.Error("failed to process job",
"job_id", job.ID,
"job_type", job.Type,
"error", err)
failedCount++
continue
}
processedCount++
}
s.logger.Info("job processing completed",
"processed", processedCount,
"failed", failedCount,
"total", len(pendingJobs))
return nil
}
// processJob routes a single job to the appropriate service based on type.
func (s *JobService) processJob(ctx context.Context, job *domain.Job) error {
s.logger.Debug("processing job",
"job_id", job.ID,
"job_type", job.Type,
"certificate_id", job.CertificateID)
switch job.Type {
case domain.JobTypeRenewal:
return s.renewalService.ProcessRenewalJob(ctx, job)
case domain.JobTypeDeployment:
return s.deploymentService.ProcessDeploymentJob(ctx, job)
case domain.JobTypeIssuance:
return s.processIssuanceJob(ctx, job)
case domain.JobTypeValidation:
return s.processValidationJob(ctx, job)
default:
return fmt.Errorf("unknown job type: %s", job.Type)
}
}
// processIssuanceJob handles a certificate issuance job.
// This is a placeholder that documents the flow.
// TODO: Implement actual issuance job processing if needed.
func (s *JobService) processIssuanceJob(ctx context.Context, job *domain.Job) error {
s.logger.Debug("processing issuance job", "job_id", job.ID)
// TODO: Implement issuance job processing
// In production:
// 1. Fetch the certificate
// 2. Fetch the issuer
// 3. Generate or retrieve CSR
// 4. Call issuer to issue new certificate
// 5. Create certificate version
// 6. Update certificate status
// 7. Mark job as completed
return fmt.Errorf("issuance job processing not yet implemented")
}
// processValidationJob handles a certificate validation job.
// This is a placeholder that documents the flow.
// TODO: Implement actual validation job processing if needed.
func (s *JobService) processValidationJob(ctx context.Context, job *domain.Job) error {
s.logger.Debug("processing validation job", "job_id", job.ID)
// TODO: Implement validation job processing
// In production:
// 1. Fetch the certificate
// 2. For each target, call target connector ValidateDeployment
// 3. Aggregate results
// 4. Update job status based on results
// 5. Send notification if any validation fails
return fmt.Errorf("validation job processing not yet implemented")
}
// RetryFailedJobs finds failed jobs and resets them for retry.
// It only retries jobs that haven't exceeded max attempts.
func (s *JobService) RetryFailedJobs(ctx context.Context, maxRetries int) error {
s.logger.Debug("retrying failed jobs", "max_retries", maxRetries)
failedJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusFailed)
if err != nil {
return fmt.Errorf("failed to fetch failed jobs: %w", err)
}
var retriedCount int
for _, job := range failedJobs {
// Check if we can retry (Attempts < MaxAttempts)
if job.Attempts >= job.MaxAttempts {
s.logger.Debug("job exceeded max retries",
"job_id", job.ID,
"attempts", job.Attempts,
"max_attempts", job.MaxAttempts)
continue
}
// Reset status to pending for retry
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusPending, ""); err != nil {
s.logger.Error("failed to reset job status for retry",
"job_id", job.ID,
"error", err)
continue
}
retriedCount++
}
s.logger.Info("failed jobs retry completed",
"retried", retriedCount,
"total_failed", len(failedJobs))
return nil
}
// GetJobStatus returns the current status of a job.
func (s *JobService) GetJobStatus(ctx context.Context, jobID string) (*domain.Job, error) {
job, err := s.jobRepo.Get(ctx, jobID)
if err != nil {
return nil, fmt.Errorf("failed to fetch job: %w", err)
}
return job, nil
}
// CancelJob cancels a pending or running job.
func (s *JobService) CancelJob(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)
}
if job.Status != domain.JobStatusPending && job.Status != domain.JobStatusRunning {
return fmt.Errorf("cannot cancel job with status %s", job.Status)
}
if err := s.jobRepo.UpdateStatus(ctx, jobID, domain.JobStatusCancelled, "cancelled by user"); err != nil {
return fmt.Errorf("failed to cancel job: %w", err)
}
s.logger.Info("job cancelled", "job_id", jobID)
return nil
}
+215
View File
@@ -0,0 +1,215 @@
package service
import (
"context"
"fmt"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// NotificationService provides business logic for managing notifications.
type NotificationService struct {
notifRepo repository.NotificationRepository
notifierRegistry map[string]Notifier
}
// Notifier defines the interface for notification channels (email, Slack, webhooks, etc.).
type Notifier interface {
// Send delivers a notification and returns error if unsuccessful.
Send(ctx context.Context, recipient string, subject string, body string) error
// Channel returns the channel identifier (e.g., "email", "slack").
Channel() string
}
// NewNotificationService creates a new notification service.
func NewNotificationService(
notifRepo repository.NotificationRepository,
notifierRegistry map[string]Notifier,
) *NotificationService {
return &NotificationService{
notifRepo: notifRepo,
notifierRegistry: notifierRegistry,
}
}
// SendExpirationWarning sends a certificate expiration warning.
func (s *NotificationService) SendExpirationWarning(ctx context.Context, cert *domain.ManagedCertificate, daysUntilExpiry int) error {
body := fmt.Sprintf(
"The certificate for %s will expire in %d days (%s).\n\nPlease schedule renewal.",
cert.CommonName, daysUntilExpiry, cert.ExpiresAt.Format("2006-01-02"),
)
// Create notification record
notif := &domain.NotificationEvent{
ID: generateID("notif"),
CertificateID: &cert.ID,
Type: domain.NotificationTypeExpirationWarning,
Channel: domain.NotificationChannelEmail,
Recipient: cert.OwnerID,
Message: body,
Status: "pending",
CreatedAt: time.Now(),
}
if err := s.notifRepo.Create(ctx, notif); err != nil {
return fmt.Errorf("failed to create notification: %w", err)
}
// Attempt immediate send
return s.sendNotification(ctx, notif)
}
// SendRenewalNotification sends a renewal success or failure notification.
func (s *NotificationService) SendRenewalNotification(ctx context.Context, cert *domain.ManagedCertificate, success bool, err error) error {
var body string
if success {
body = fmt.Sprintf(
"The certificate for %s has been successfully renewed.\n\nNew expiry: %s",
cert.CommonName, cert.ExpiresAt.Format("2006-01-02"),
)
} else {
body = fmt.Sprintf(
"The certificate for %s failed to renew.\n\nError: %v\n\nPlease investigate.",
cert.CommonName, err,
)
}
var notifType domain.NotificationType
if success {
notifType = domain.NotificationTypeRenewalSuccess
} else {
notifType = domain.NotificationTypeRenewalFailure
}
notif := &domain.NotificationEvent{
ID: generateID("notif"),
CertificateID: &cert.ID,
Type: notifType,
Channel: domain.NotificationChannelEmail,
Recipient: cert.OwnerID,
Message: body,
Status: "pending",
CreatedAt: time.Now(),
}
if err := s.notifRepo.Create(ctx, notif); err != nil {
return fmt.Errorf("failed to create notification: %w", err)
}
return s.sendNotification(ctx, notif)
}
// SendDeploymentNotification sends a deployment success or failure notification.
func (s *NotificationService) SendDeploymentNotification(ctx context.Context, cert *domain.ManagedCertificate, target *domain.DeploymentTarget, success bool, err error) error {
var body string
if success {
body = fmt.Sprintf(
"The certificate for %s has been successfully deployed to %s.",
cert.CommonName, target.Name,
)
} else {
body = fmt.Sprintf(
"The certificate for %s failed to deploy to %s.\n\nError: %v\n\nPlease investigate.",
cert.CommonName, target.Name, err,
)
}
notifType := domain.NotificationTypeDeploymentSuccess
if !success {
notifType = domain.NotificationTypeDeploymentFailure
}
notif := &domain.NotificationEvent{
ID: generateID("notif"),
CertificateID: &cert.ID,
Type: notifType,
Channel: domain.NotificationChannelEmail,
Recipient: cert.OwnerID,
Message: body,
Status: "pending",
CreatedAt: time.Now(),
}
if err := s.notifRepo.Create(ctx, notif); err != nil {
return fmt.Errorf("failed to create notification: %w", err)
}
return s.sendNotification(ctx, notif)
}
// ProcessPendingNotifications sends all pending notifications in batch.
func (s *NotificationService) ProcessPendingNotifications(ctx context.Context) error {
filter := &repository.NotificationFilter{
Status: "pending",
PerPage: 1000,
}
pending, err := s.notifRepo.List(ctx, filter)
if err != nil {
return fmt.Errorf("failed to list pending notifications: %w", err)
}
var failedCount int
for _, notif := range pending {
if err := s.sendNotification(ctx, notif); err != nil {
fmt.Printf("failed to send notification %s: %v\n", notif.ID, err)
failedCount++
}
}
if failedCount > 0 {
return fmt.Errorf("failed to send %d out of %d notifications", failedCount, len(pending))
}
return nil
}
// sendNotification delivers a single notification via the appropriate channel.
func (s *NotificationService) sendNotification(ctx context.Context, notif *domain.NotificationEvent) error {
// 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)
}
// Send the notification
if err := notifier.Send(ctx, notif.Recipient, string(notif.Type), notif.Message); err != nil {
// Update status to failed
_ = s.notifRepo.UpdateStatus(ctx, notif.ID, "failed", time.Time{})
return fmt.Errorf("failed to send via %s: %w", notif.Channel, err)
}
// Update status to sent
if err := s.notifRepo.UpdateStatus(ctx, notif.ID, "sent", time.Now()); err != nil {
fmt.Printf("failed to update notification status: %v\n", err)
}
return nil
}
// RegisterNotifier registers a new notification channel handler.
func (s *NotificationService) RegisterNotifier(channel string, notifier Notifier) {
if s.notifierRegistry == nil {
s.notifierRegistry = make(map[string]Notifier)
}
s.notifierRegistry[channel] = notifier
}
// GetNotificationHistory returns all notifications for a certificate.
func (s *NotificationService) GetNotificationHistory(ctx context.Context, certID string) ([]*domain.NotificationEvent, error) {
filter := &repository.NotificationFilter{
CertificateID: certID,
PerPage: 1000,
}
notifications, err := s.notifRepo.List(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to list notifications: %w", err)
}
return notifications, nil
}
+229
View File
@@ -0,0 +1,229 @@
package service
import (
"context"
"fmt"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// PolicyService provides business logic for compliance policy management.
type PolicyService struct {
policyRepo repository.PolicyRepository
auditService *AuditService
}
// NewPolicyService creates a new policy service.
func NewPolicyService(
policyRepo repository.PolicyRepository,
auditService *AuditService,
) *PolicyService {
return &PolicyService{
policyRepo: policyRepo,
auditService: auditService,
}
}
// ValidateCertificate runs all enabled policy rules against a certificate.
func (s *PolicyService) ValidateCertificate(ctx context.Context, cert *domain.ManagedCertificate) ([]*domain.PolicyViolation, error) {
rules, err := s.policyRepo.ListRules(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list policy rules: %w", err)
}
var violations []*domain.PolicyViolation
for _, rule := range rules {
// Skip disabled rules
if !rule.Enabled {
continue
}
// Evaluate rule against certificate
v, err := s.evaluateRule(rule, cert)
if err != nil {
fmt.Printf("failed to evaluate rule %s: %v\n", rule.ID, err)
continue
}
if v != nil {
violations = append(violations, v)
}
}
return violations, nil
}
// evaluateRule checks if a certificate violates a single policy rule.
func (s *PolicyService) evaluateRule(rule *domain.PolicyRule, cert *domain.ManagedCertificate) (*domain.PolicyViolation, error) {
switch rule.Type {
case domain.PolicyTypeAllowedIssuers:
// Restrict to specific issuers
// Note: In a production implementation, we would parse rule.Config to extract parameters
if cert.IssuerID == "" {
return &domain.PolicyViolation{
ID: generateID("violation"),
RuleID: rule.ID,
CertificateID: cert.ID,
Severity: domain.PolicySeverityWarning,
Message: "certificate has no issuer assigned",
CreatedAt: time.Now(),
}, nil
}
case domain.PolicyTypeAllowedDomains:
// Ensure certificate domains are in allowed list
if len(cert.SANs) == 0 {
return &domain.PolicyViolation{
ID: generateID("violation"),
RuleID: rule.ID,
CertificateID: cert.ID,
Severity: domain.PolicySeverityWarning,
Message: "certificate has no subject alternative names",
CreatedAt: time.Now(),
}, nil
}
case domain.PolicyTypeRequiredMetadata:
// Ensure certificate has required metadata/tags
if len(cert.Tags) == 0 {
return &domain.PolicyViolation{
ID: generateID("violation"),
RuleID: rule.ID,
CertificateID: cert.ID,
Severity: domain.PolicySeverityWarning,
Message: "certificate has no tags or metadata",
CreatedAt: time.Now(),
}, nil
}
case domain.PolicyTypeAllowedEnvironments:
// Restrict to specific environments
if cert.Environment == "" {
return &domain.PolicyViolation{
ID: generateID("violation"),
RuleID: rule.ID,
CertificateID: cert.ID,
Severity: domain.PolicySeverityWarning,
Message: "certificate has no environment assigned",
CreatedAt: time.Now(),
}, nil
}
case domain.PolicyTypeRenewalLeadTime:
// Ensure renewal begins before certificate expires
daysUntilExpiry := time.Until(cert.ExpiresAt).Hours() / 24
if daysUntilExpiry < 30 && daysUntilExpiry > 0 {
return &domain.PolicyViolation{
ID: generateID("violation"),
RuleID: rule.ID,
CertificateID: cert.ID,
Severity: domain.PolicySeverityWarning,
Message: fmt.Sprintf("certificate expires in %.1f days, plan renewal soon", daysUntilExpiry),
CreatedAt: time.Now(),
}, nil
}
default:
return nil, fmt.Errorf("unknown policy rule type: %s", rule.Type)
}
return nil, nil
}
// CreateRule stores a new policy rule.
func (s *PolicyService) CreateRule(ctx context.Context, rule *domain.PolicyRule, actor string) error {
if rule.ID == "" {
rule.ID = generateID("rule")
}
if rule.CreatedAt.IsZero() {
rule.CreatedAt = time.Now()
}
if err := s.policyRepo.CreateRule(ctx, rule); err != nil {
return fmt.Errorf("failed to create policy rule: %w", err)
}
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"policy_rule_created", "policy", rule.ID,
map[string]interface{}{"rule_type": rule.Type}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// UpdateRule modifies an existing policy rule.
func (s *PolicyService) UpdateRule(ctx context.Context, rule *domain.PolicyRule, actor string) error {
existing, err := s.policyRepo.GetRule(ctx, rule.ID)
if err != nil {
return fmt.Errorf("failed to fetch existing rule: %w", err)
}
rule.UpdatedAt = time.Now()
if err := s.policyRepo.UpdateRule(ctx, rule); err != nil {
return fmt.Errorf("failed to update policy rule: %w", err)
}
changes := map[string]interface{}{}
if existing.Enabled != rule.Enabled {
changes["enabled"] = rule.Enabled
}
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"policy_rule_updated", "policy", rule.ID, changes); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// GetRule retrieves a policy rule by ID.
func (s *PolicyService) GetRule(ctx context.Context, id string) (*domain.PolicyRule, error) {
rule, err := s.policyRepo.GetRule(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to fetch policy rule: %w", err)
}
return rule, nil
}
// ListRules returns all policy rules.
func (s *PolicyService) ListRules(ctx context.Context) ([]*domain.PolicyRule, error) {
rules, err := s.policyRepo.ListRules(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list policy rules: %w", err)
}
return rules, nil
}
// DeleteRule removes a policy rule.
func (s *PolicyService) DeleteRule(ctx context.Context, id string, actor string) error {
rule, err := s.policyRepo.GetRule(ctx, id)
if err != nil {
return fmt.Errorf("failed to fetch rule: %w", err)
}
if err := s.policyRepo.DeleteRule(ctx, id); err != nil {
return fmt.Errorf("failed to delete policy rule: %w", err)
}
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"policy_rule_deleted", "policy", id,
map[string]interface{}{"rule_type": rule.Type}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// ListViolations returns policy violations matching filter criteria.
func (s *PolicyService) ListViolations(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
}
+225
View File
@@ -0,0 +1,225 @@
package service
import (
"context"
"fmt"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// RenewalService manages certificate renewal workflows.
type RenewalService struct {
certRepo repository.CertificateRepository
jobRepo repository.JobRepository
auditService *AuditService
notificationSvc *NotificationService
issuerRegistry map[string]IssuerConnector
}
// IssuerConnector defines the interface for interacting with certificate issuers.
type IssuerConnector interface {
// RenewCertificate renews a certificate and returns the new certificate PEM.
RenewCertificate(ctx context.Context, csr []byte) ([]byte, error)
// GetCertificateChain returns the issuer's certificate chain.
GetCertificateChain(ctx context.Context) ([]byte, error)
}
// NewRenewalService creates a new renewal service.
func NewRenewalService(
certRepo repository.CertificateRepository,
jobRepo repository.JobRepository,
auditService *AuditService,
notificationSvc *NotificationService,
issuerRegistry map[string]IssuerConnector,
) *RenewalService {
return &RenewalService{
certRepo: certRepo,
jobRepo: jobRepo,
auditService: auditService,
notificationSvc: notificationSvc,
issuerRegistry: issuerRegistry,
}
}
// CheckExpiringCertificates identifies certificates needing renewal based on policy windows.
func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
// Default renewal window: 30 days before expiry
renewalWindow := time.Now().AddDate(0, 0, 30)
expiring, err := s.certRepo.GetExpiringCertificates(ctx, renewalWindow)
if err != nil {
return fmt.Errorf("failed to fetch expiring certificates: %w", err)
}
for _, cert := range expiring {
// Skip if already renewing or archived
if cert.Status == domain.CertificateStatusRenewalInProgress || cert.Status == domain.CertificateStatusArchived {
continue
}
// Calculate days until expiry
daysUntil := time.Until(cert.ExpiresAt).Hours() / 24
// Create renewal job
job := &domain.Job{
ID: generateID("job"),
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusPending,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
if err := s.jobRepo.Create(ctx, job); err != nil {
fmt.Printf("failed to create renewal job for cert %s: %v\n", cert.ID, err)
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,
map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID})
}
return nil
}
// ProcessRenewalJob executes a renewal job: call issuer, store new version, update cert status.
func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job) error {
// Update job status to in-progress
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusRunning, ""); err != nil {
return fmt.Errorf("failed to update job status: %w", err)
}
// Fetch certificate
cert, err := s.certRepo.Get(ctx, job.CertificateID)
if err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("certificate fetch failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("failed to fetch certificate: %w", err)
}
// Get issuer connector
issuerID := cert.IssuerID
if issuerID == "" {
return fmt.Errorf("certificate has no issuer assigned")
}
connector, ok := s.issuerRegistry[issuerID]
if !ok {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed,
fmt.Sprintf("issuer connector not found for %s", issuerID))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("issuer connector not found for %s", issuerID)
}
// TODO: In production, fetch CSR from agent or generate new CSR
// For now, we'd use cert.CSR or generate a new one from the private key
csr := []byte{} // placeholder
// Call issuer to renew
certPEM, err := connector.RenewCertificate(ctx, csr)
if err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("issuer renewal failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
// Send failure notification
_ = s.notificationSvc.SendRenewalNotification(ctx, cert, false, err)
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"renewal_job_failed", "certificate", job.CertificateID,
map[string]interface{}{"job_id": job.ID, "error": err.Error()})
return fmt.Errorf("issuer renewal failed: %w", err)
}
// Create new certificate version
version := &domain.CertificateVersion{
ID: generateID("certver"),
CertificateID: job.CertificateID,
SerialNumber: fmt.Sprintf("renewed-%d", time.Now().Unix()),
PEMChain: string(certPEM),
CreatedAt: time.Now(),
}
if err := s.certRepo.CreateVersion(ctx, version); err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("version creation failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("failed to create certificate version: %w", err)
}
// Update certificate status
cert.Status = domain.CertificateStatusActive
if err := s.certRepo.Update(ctx, cert); err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("cert update failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("failed to update certificate: %w", err)
}
// Mark job as completed
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusCompleted, ""); err != nil {
return fmt.Errorf("failed to update job status: %w", err)
}
// Send success notification
if err := s.notificationSvc.SendRenewalNotification(ctx, cert, true, nil); err != nil {
fmt.Printf("failed to send renewal notification: %v\n", err)
}
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"renewal_job_completed", "certificate", job.CertificateID,
map[string]interface{}{"job_id": job.ID, "serial": version.SerialNumber})
return nil
}
// Retry attempts to reprocess failed renewal jobs with exponential backoff.
func (s *RenewalService) RetryFailedJobs(ctx context.Context, maxRetries int) error {
failedJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusFailed)
if err != nil {
return fmt.Errorf("failed to fetch failed jobs: %w", err)
}
for _, job := range failedJobs {
if job.Type != domain.JobTypeRenewal {
continue
}
// Check if we've exceeded max attempts
if job.Attempts >= job.MaxAttempts {
continue
}
// Reset status to pending for retry
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusPending, ""); err != nil {
fmt.Printf("failed to reset job status for retry: %v\n", err)
continue
}
}
return nil
}
// generateID is a helper to generate unique IDs. In production, use a proper ID generator.
func generateID(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
}
+155
View File
@@ -0,0 +1,155 @@
#!/bin/bash
set -e
# Certctl development setup script
# Installs prerequisites and initializes a local development environment
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}=== Certctl Development Setup ===${NC}\n"
# Check Go installation
echo "Checking Go installation..."
if ! command -v go &> /dev/null; then
echo -e "${RED}✗ Go 1.22+ not found${NC}"
echo " Install from: https://golang.org/dl/"
exit 1
fi
GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//')
echo -e "${GREEN}✓ Go $GO_VERSION found${NC}"
# Check Docker installation
echo "Checking Docker installation..."
if ! command -v docker &> /dev/null; then
echo -e "${RED}✗ Docker not found${NC}"
echo " Install from: https://docs.docker.com/get-docker/"
exit 1
fi
DOCKER_VERSION=$(docker version --format '{{.Server.Version}}')
echo -e "${GREEN}✓ Docker $DOCKER_VERSION found${NC}"
# Check Docker Compose installation
echo "Checking Docker Compose installation..."
if ! command -v docker-compose &> /dev/null; then
echo -e "${RED}✗ Docker Compose not found${NC}"
echo " Install from: https://docs.docker.com/compose/install/"
exit 1
fi
DC_VERSION=$(docker-compose version --short)
echo -e "${GREEN}✓ Docker Compose $DC_VERSION found${NC}\n"
# Setup environment
echo "Setting up environment..."
if [ ! -f "$PROJECT_ROOT/.env" ]; then
echo "Creating .env from template..."
cp "$PROJECT_ROOT/.env.example" "$PROJECT_ROOT/.env"
echo -e "${GREEN}✓ .env created${NC}"
echo " Edit with your configuration: nano .env"
else
echo -e "${YELLOW}⚠ .env already exists, skipping${NC}"
fi
# Download Go modules
echo -e "\nDownloading Go modules..."
cd "$PROJECT_ROOT"
go mod download
echo -e "${GREEN}✓ Go modules downloaded${NC}"
# Install development tools
echo -e "\nInstalling development tools..."
echo " Installing golangci-lint..."
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
echo " Installing migrate..."
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
echo " Installing air (hot reload)..."
go install github.com/cosmtrek/air@latest
echo -e "${GREEN}✓ Development tools installed${NC}"
# Start Docker Compose
echo -e "\nStarting Docker Compose stack..."
cd "$PROJECT_ROOT"
docker-compose -f deploy/docker-compose.yml up -d
echo -e "${GREEN}✓ Stack started${NC}"
# Wait for PostgreSQL to be ready
echo "Waiting for PostgreSQL to be ready..."
max_attempts=30
attempt=0
while [ $attempt -lt $max_attempts ]; do
if docker-compose -f deploy/docker-compose.yml exec postgres \
pg_isready -U certctl -d certctl > /dev/null 2>&1; then
echo -e "${GREEN}✓ PostgreSQL is ready${NC}"
break
fi
attempt=$((attempt + 1))
if [ $attempt -eq $max_attempts ]; then
echo -e "${RED}✗ PostgreSQL failed to start${NC}"
exit 1
fi
echo " Attempt $attempt/$max_attempts..."
sleep 2
done
# Run migrations
echo -e "\nRunning database migrations..."
export DB_URL="postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable"
migrate -path "$PROJECT_ROOT/migrations" -database "$DB_URL" up 2>/dev/null || {
echo -e "${YELLOW}⚠ Migrations might need initialization${NC}"
echo " Run manually: make migrate-up"
}
echo -e "${GREEN}✓ Database initialized${NC}"
# Build project
echo -e "\nBuilding project..."
make -C "$PROJECT_ROOT" build > /dev/null 2>&1
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ Build successful${NC}"
else
echo -e "${YELLOW}⚠ Build had issues, check Makefile${NC}"
fi
# Print summary
echo -e "\n${GREEN}=== Setup Complete ===${NC}\n"
echo "Your development environment is ready!"
echo ""
echo "Services running:"
echo " • Server: http://localhost:8443"
echo " • Database: postgres://certctl:certctl@localhost:5432/certctl"
echo " • Agent: Connected to server"
echo ""
echo "Next steps:"
echo " 1. Review configuration:"
echo " cat .env"
echo ""
echo " 2. View logs:"
echo " make docker-logs-server"
echo " make docker-logs-agent"
echo ""
echo " 3. Test the API:"
echo " curl http://localhost:8443/health"
echo ""
echo " 4. Try the quick start guide:"
echo " cat docs/quickstart.md"
echo ""
echo " 5. Access PgAdmin (optional):"
echo " make docker-up-dev"
echo " # Then visit http://localhost:5050"
echo ""
echo "Useful commands:"
echo " make help - Show all available commands"
echo " make test - Run tests"
echo " make lint - Run linter"
echo " make docker-down - Stop services"
echo " make docker-logs - View service logs"
echo ""
echo "For more information, see:"
echo " • README.md"
echo " • docs/architecture.md"
echo " • docs/quickstart.md"
echo ""