mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:51:30 +00:00
Initial scaffold: certificate control plane v0.1.0
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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://goreportcard.com/report/github.com/shankar0123/certctl)
|
||||

|
||||
|
||||
## 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.**
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,574 @@
|
||||
# Certctl Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
Certctl is a certificate management platform with a **decoupled control-plane and agent architecture**. The control plane orchestrates certificate issuance and renewal, while stateless agents deployed across your infrastructure handle certificate generation, deployment, and renewal without exposing private keys to the control plane.
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Zero Private Key Exposure** — Private keys generated and managed only on agents
|
||||
2. **Decoupled Operations** — Agents operate autonomously; control plane is optional for agent function
|
||||
3. **Audit-First** — Complete traceability of all issuance, deployment, and rotation events
|
||||
4. **Connector Architecture** — Pluggable issuers, targets, and notifiers for extensibility
|
||||
5. **Self-Hosted** — No cloud lock-in; run on Kubernetes, Docker, or bare metal
|
||||
|
||||
---
|
||||
|
||||
## System Components
|
||||
|
||||
### Control Plane
|
||||
|
||||
The control plane is a REST API server backed by PostgreSQL. It:
|
||||
|
||||
- **Manages state**: Certificates, agents, targets, issuers, policies
|
||||
- **Orchestrates issuance**: Coordinates with ACME/PKI issuers
|
||||
- **Tracks jobs**: Certificate issuance, renewal, and deployment workflows
|
||||
- **Audits all actions**: Immutable audit trail for compliance
|
||||
- **Dispatches work**: Schedules renewal checks and deployment jobs
|
||||
|
||||
**Deployment Options**: Single binary, Docker container, Kubernetes deployment
|
||||
|
||||
### Agents
|
||||
|
||||
Lightweight agents deployed on or near your infrastructure. They:
|
||||
|
||||
- **Generate certificates**: Create private keys and certificate requests
|
||||
- **Deploy certificates**: Push certs to NGINX, F5, IIS, etc.
|
||||
- **Manage credentials**: Store and rotate API keys with control plane
|
||||
- **Report status**: Health checks and job completion status
|
||||
- **Operate independently**: Continue functioning even if control plane is unreachable
|
||||
|
||||
**Deployment Options**: Container, systemd service, Kubernetes DaemonSet, Lambda
|
||||
|
||||
### PostgreSQL Database
|
||||
|
||||
Persistent state store:
|
||||
|
||||
```
|
||||
├── Teams & Ownership
|
||||
│ ├── teams
|
||||
│ └── owners
|
||||
├── Certificate Management
|
||||
│ ├── certificates
|
||||
│ ├── certificate_versions
|
||||
│ └── renewal_policies
|
||||
├── Infrastructure
|
||||
│ ├── agents
|
||||
│ ├── targets
|
||||
│ └── target_connections
|
||||
├── Issuance
|
||||
│ ├── issuers
|
||||
│ ├── jobs
|
||||
│ └── job_steps
|
||||
├── Monitoring & Audit
|
||||
│ ├── audit_logs
|
||||
│ ├── notifications
|
||||
│ └── deployment_history
|
||||
└── Configuration
|
||||
├── agent_api_keys
|
||||
└── connector_config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow: Certificate Lifecycle
|
||||
|
||||
### 1. **Create Managed Certificate**
|
||||
|
||||
```
|
||||
User/API
|
||||
│
|
||||
├─→ POST /api/v1/certificates
|
||||
│ {
|
||||
│ "domain": "api.example.com",
|
||||
│ "issuer_id": "issuer-001",
|
||||
│ "target_ids": ["nginx-prod-01"],
|
||||
│ "renewal_days_before": 30
|
||||
│ }
|
||||
│
|
||||
└─→ Control Plane
|
||||
├─ Insert certificate record
|
||||
├─ Create initial job
|
||||
├─ Log audit event
|
||||
└─ Return cert ID + API response
|
||||
```
|
||||
|
||||
### 2. **Agent Requests Certificate (CSR → Issuance)**
|
||||
|
||||
```
|
||||
Agent Control Plane ACME Issuer
|
||||
│ │ │
|
||||
├─ POST /api/v1/csr │ │
|
||||
│ { │ │
|
||||
│ "cert_id": "cert-123", │ │
|
||||
│ "csr": "-----BEGIN CSR..." │ │
|
||||
│ } │ │
|
||||
│ ├─ Validate CSR │
|
||||
│ │ │
|
||||
│ ├─ POST /directory/new-order │
|
||||
│ ├──────────────────────────────→
|
||||
│ │ │
|
||||
│ │← Poll challenges │
|
||||
│ ├──────────────────────────────→
|
||||
│ │ │
|
||||
│ ├─ POST /acme/finalize │
|
||||
│ ├──────────────────────────────→
|
||||
│ │ │
|
||||
│← Certificate + chain │← Signed certificate │
|
||||
├─────────────────────────────────│ │
|
||||
│ │ │
|
||||
├─ Store locally: │ │
|
||||
│ /etc/certctl/api.example.com/ │ │
|
||||
│ ├─ cert.pem │ │
|
||||
│ ├─ key.pem (never sent back) │ │
|
||||
│ └─ chain.pem │ │
|
||||
│ │ │
|
||||
└─ POST /api/v1/deployments │ │
|
||||
{ "cert_id", "status": "ok" } │ │
|
||||
├─ Update cert record │
|
||||
├─ Log "issued" event │
|
||||
└─ Trigger deployment jobs │
|
||||
```
|
||||
|
||||
### 3. **Deploy Certificate to Target**
|
||||
|
||||
```
|
||||
Agent Target System
|
||||
│
|
||||
├─ Fetch target credentials from config
|
||||
│
|
||||
├─ Load certificate:
|
||||
│ - /etc/certctl/api.example.com/cert.pem
|
||||
│ - /etc/certctl/api.example.com/key.pem
|
||||
│
|
||||
├─ NGINX (SSH):
|
||||
│ ├─ scp cert.pem → /etc/nginx/ssl/
|
||||
│ ├─ scp key.pem → /etc/nginx/ssl/ (restricted perms)
|
||||
│ ├─ ssh nginx -s reload
|
||||
│ └─ Verify: curl https://api.example.com/health
|
||||
│
|
||||
├─ F5 (HTTPS API):
|
||||
│ ├─ Authenticate with credentials
|
||||
│ ├─ POST /mgmt/tm/ltm/cert {"name": "api.example.com", "cert": "..."}
|
||||
│ ├─ PUT /mgmt/tm/ltm/virtual (update virtual server)
|
||||
│ └─ Verify: F5 configuration updated
|
||||
│
|
||||
├─ IIS (WinRM):
|
||||
│ ├─ Import cert to store: Import-PfxCertificate
|
||||
│ ├─ Bind to site: Set-WebBinding
|
||||
│ └─ Verify: Get-WebBinding
|
||||
│
|
||||
└─ Report deployment status:
|
||||
POST /api/v1/deployments/{id}/status
|
||||
{ "status": "success", "deployed_at": "..." }
|
||||
```
|
||||
|
||||
### 4. **Renewal Check & Rotation**
|
||||
|
||||
```
|
||||
Scheduler (Control Plane)
|
||||
│
|
||||
├─ Every hour: SELECT certificates WHERE expiry_date < NOW() + 30 days
|
||||
│
|
||||
├─ For each certificate:
|
||||
│ │
|
||||
│ ├─ Create renewal job
|
||||
│ ├─ Notify agent(s)
|
||||
│ │
|
||||
│ └─ Agent flow:
|
||||
│ ├─ Generate new CSR
|
||||
│ ├─ Request new certificate
|
||||
│ ├─ Deploy new cert to targets
|
||||
│ ├─ Verify deployment
|
||||
│ └─ Delete old private key from agent
|
||||
│
|
||||
├─ Log completion
|
||||
└─ Notify via email/webhook
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Connector Architecture
|
||||
|
||||
Certctl uses **connector interfaces** for extensibility. Connectors are pluggable implementations of specific capabilities.
|
||||
|
||||
### Issuer Connector
|
||||
|
||||
Handles certificate issuance from external PKI systems.
|
||||
|
||||
```go
|
||||
type IssuerConnector interface {
|
||||
// GetDirectory returns the ACME directory
|
||||
GetDirectory(ctx context.Context) (*ACMEDirectory, error)
|
||||
|
||||
// NewAccount registers a new account
|
||||
NewAccount(ctx context.Context, email string) (*Account, error)
|
||||
|
||||
// NewOrder creates a new certificate order
|
||||
NewOrder(ctx context.Context, identifiers []Identifier) (*Order, error)
|
||||
|
||||
// GetAuthorization retrieves challenge info
|
||||
GetAuthorization(ctx context.Context, authURL string) (*Authorization, error)
|
||||
|
||||
// FinalizeOrder submits CSR and gets certificate
|
||||
FinalizeOrder(ctx context.Context, orderURL, csr string) ([]byte, error)
|
||||
}
|
||||
```
|
||||
|
||||
**Built-in Issuers**:
|
||||
- `acme` — ACME v2 protocol (Let's Encrypt, Sectigo, etc.)
|
||||
|
||||
**Example Usage**:
|
||||
```yaml
|
||||
issuer:
|
||||
type: acme
|
||||
config:
|
||||
directory_url: https://acme-v02.api.letsencrypt.org/directory
|
||||
email: admin@example.com
|
||||
```
|
||||
|
||||
### Target Connector
|
||||
|
||||
Deploys certificates to infrastructure systems.
|
||||
|
||||
```go
|
||||
type TargetConnector interface {
|
||||
// Validate tests connectivity and credentials
|
||||
Validate(ctx context.Context) error
|
||||
|
||||
// Deploy pushes certificate to target
|
||||
Deploy(ctx context.Context, cert *Certificate) error
|
||||
|
||||
// Remove removes/revokes certificate from target
|
||||
Remove(ctx context.Context, domain string) error
|
||||
|
||||
// GetStatus checks deployment status
|
||||
GetStatus(ctx context.Context, domain string) (string, error)
|
||||
}
|
||||
```
|
||||
|
||||
**Built-in Targets**:
|
||||
- `nginx` — NGINX via SSH
|
||||
- `f5` — F5 BIG-IP via REST API
|
||||
- `iis` — Microsoft IIS via WinRM
|
||||
|
||||
**Example Usage**:
|
||||
```yaml
|
||||
target:
|
||||
type: nginx
|
||||
config:
|
||||
host: web01.prod.internal
|
||||
ssh_user: deploy
|
||||
ssh_key: /etc/certctl/keys/deploy.pem
|
||||
cert_path: /etc/nginx/ssl
|
||||
```
|
||||
|
||||
### Notifier Connector
|
||||
|
||||
Sends notifications about certificate events.
|
||||
|
||||
```go
|
||||
type NotifierConnector interface {
|
||||
// Send delivers a notification
|
||||
Send(ctx context.Context, notif *Notification) error
|
||||
|
||||
// Validate checks configuration
|
||||
Validate(ctx context.Context) error
|
||||
}
|
||||
```
|
||||
|
||||
**Built-in Notifiers**:
|
||||
- `email` — SMTP email
|
||||
- `webhook` — HTTP webhooks
|
||||
|
||||
**Example Usage**:
|
||||
```yaml
|
||||
notifier:
|
||||
type: email
|
||||
config:
|
||||
smtp_host: smtp.example.com
|
||||
smtp_port: 587
|
||||
username: alerts@example.com
|
||||
password: "***"
|
||||
from_address: certctl@example.com
|
||||
recipients:
|
||||
- ops@example.com
|
||||
- security@example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Job Lifecycle & States
|
||||
|
||||
Jobs represent work to be done: certificate issuance, renewal, deployment, etc.
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ PENDING │ Job created, waiting to be processed
|
||||
└────┬─────┘
|
||||
│
|
||||
↓
|
||||
┌──────────┐
|
||||
│ RUNNING │ Job in progress (CSR generation, issuance, deployment)
|
||||
└────┬─────┘
|
||||
│
|
||||
├─→ SUCCESS ──→ COMPLETED (job done, no errors)
|
||||
│
|
||||
├─→ FAILURE ──→ FAILED (error occurred, may retry)
|
||||
│
|
||||
└─→ CANCEL ───→ CANCELLED (user or scheduler cancelled)
|
||||
|
||||
Additional states:
|
||||
• RETRY_WAIT — Backoff before retry
|
||||
• ABANDONED — Max retries exceeded
|
||||
```
|
||||
|
||||
### Job Steps
|
||||
|
||||
Complex jobs are broken into steps:
|
||||
|
||||
```
|
||||
Issuance Job
|
||||
│
|
||||
├─ Step 1: Notify agent of CSR request
|
||||
│ Status: COMPLETED
|
||||
│
|
||||
├─ Step 2: Wait for CSR from agent
|
||||
│ Status: RUNNING (timeout: 5 min)
|
||||
│
|
||||
├─ Step 3: Submit to ACME issuer
|
||||
│ Status: PENDING
|
||||
│
|
||||
├─ Step 4: Poll for certificate
|
||||
│ Status: PENDING
|
||||
│
|
||||
└─ Step 5: Trigger deployment jobs
|
||||
Status: PENDING
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Model
|
||||
|
||||
### Private Key Management
|
||||
|
||||
```
|
||||
Private Key Lifecycle
|
||||
│
|
||||
├─ GENERATED on Agent (never sent to control plane)
|
||||
│ └─ Location: /etc/certctl/domains/{domain}/key.pem
|
||||
│
|
||||
├─ STORED on Agent
|
||||
│ ├─ File permissions: 0600 (agent user only)
|
||||
│ └─ Encrypted at rest (optional, per deployment)
|
||||
│
|
||||
├─ USED on Agent for:
|
||||
│ ├─ Deployment to targets
|
||||
│ └─ Certificate renewal
|
||||
│
|
||||
└─ DELETED on Agent
|
||||
├─ Old key deleted after successful renewal
|
||||
└─ Manual revocation on agent removal
|
||||
```
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
**Agent-to-Server**:
|
||||
- API Key (registered at agent creation)
|
||||
- mTLS optional for high-security deployments
|
||||
- All API calls include agent ID + API key
|
||||
|
||||
**Server-to-External Systems**:
|
||||
- ACME: ACME protocol with account key
|
||||
- NGINX: SSH key authentication
|
||||
- F5: Username/password or token
|
||||
- IIS: WinRM with encrypted credentials
|
||||
|
||||
### Audit Logging
|
||||
|
||||
Every action is logged:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "audit-98765",
|
||||
"timestamp": "2024-03-14T10:30:00Z",
|
||||
"actor": {
|
||||
"type": "agent",
|
||||
"id": "agent-prod-01"
|
||||
},
|
||||
"action": "certificate_issued",
|
||||
"resource": {
|
||||
"type": "certificate",
|
||||
"id": "cert-api-example-com"
|
||||
},
|
||||
"status": "success",
|
||||
"details": {
|
||||
"issuer": "acme/letsencrypt",
|
||||
"expiry": "2024-06-12T10:30:00Z",
|
||||
"deployed_to": ["nginx-prod-01"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Query examples**:
|
||||
- All actions by agent: `GET /audit/logs?actor_type=agent&actor_id=agent-001`
|
||||
- All deployments: `GET /audit/logs?action=certificate_deployed`
|
||||
- Last 30 days: `GET /audit/logs?from=2024-02-12`
|
||||
|
||||
### Data Encryption at Rest
|
||||
|
||||
Optional encryption for sensitive fields:
|
||||
|
||||
- Passwords in connector configs
|
||||
- API keys
|
||||
- ACME account keys
|
||||
|
||||
Uses AES-256-GCM with per-row nonce.
|
||||
|
||||
---
|
||||
|
||||
## Scaling Considerations
|
||||
|
||||
### Control Plane Scaling
|
||||
|
||||
**Single Server Limits**:
|
||||
- ~1000 agents (verified in testing)
|
||||
- ~10,000 managed certificates
|
||||
- ~100,000 audit log entries per day
|
||||
|
||||
**Horizontal Scaling** (future):
|
||||
- Multiple server instances behind load balancer
|
||||
- Shared PostgreSQL backend
|
||||
- Distributed job queue (Redis/RabbitMQ)
|
||||
|
||||
### Agent Scaling
|
||||
|
||||
Agents are stateless and scale horizontally:
|
||||
|
||||
- Each agent processes certificates independently
|
||||
- Scheduler distributes renewal checks across agents
|
||||
- No inter-agent communication required
|
||||
|
||||
### Database Scaling
|
||||
|
||||
For large deployments:
|
||||
- Vertical scaling: More CPU/RAM for PostgreSQL
|
||||
- Read replicas: For audit log queries
|
||||
- Partitioning: Audit logs by date
|
||||
- Connection pooling: PgBouncer
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### External Integrations
|
||||
|
||||
```
|
||||
Certctl
|
||||
│
|
||||
├─→ ACME Servers
|
||||
│ ├─ Let's Encrypt
|
||||
│ ├─ Sectigo
|
||||
│ └─ Internal ACME (optional)
|
||||
│
|
||||
├─→ Infrastructure Targets
|
||||
│ ├─ NGINX (SSH)
|
||||
│ ├─ F5 (REST API)
|
||||
│ ├─ IIS (WinRM)
|
||||
│ └─ Kubernetes (future)
|
||||
│
|
||||
├─→ Notification Systems
|
||||
│ ├─ SMTP (email)
|
||||
│ ├─ HTTP webhooks
|
||||
│ └─ Slack (future)
|
||||
│
|
||||
└─→ External Systems
|
||||
├─ Vault (credential storage)
|
||||
├─ HashiCorp Consul (service discovery)
|
||||
└─ Prometheus (metrics)
|
||||
```
|
||||
|
||||
### Internal Component Communication
|
||||
|
||||
```
|
||||
Agent ← → Control Plane
|
||||
├─ Agent registration
|
||||
├─ CSR submission
|
||||
├─ Certificate retrieval
|
||||
├─ Deployment status
|
||||
└─ Health checks (bidirectional)
|
||||
|
||||
Scheduler → Services
|
||||
├─ Certificate renewal
|
||||
├─ Job processing
|
||||
├─ Notifications
|
||||
└─ Cleanup tasks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment Topologies
|
||||
|
||||
### Single-Node (Development)
|
||||
|
||||
```
|
||||
┌────────────────────────────┐
|
||||
│ Server + Agent │
|
||||
│ ├─ HTTP API (8443) │
|
||||
│ ├─ PostgreSQL │
|
||||
│ └─ Agent (test mode) │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
### Docker Compose (Local Dev)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Docker Network │
|
||||
│ ├─ certctl-server (8443) │
|
||||
│ ├─ postgres (5432) │
|
||||
│ ├─ certctl-agent (managed) │
|
||||
│ └─ pgadmin (5050, optional) │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Kubernetes (Production)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Kubernetes Cluster │
|
||||
│ ├─ Deployment: certctl-server (replicas=3) │
|
||||
│ ├─ DaemonSet: certctl-agent (all nodes) │
|
||||
│ ├─ StatefulSet: postgres (primary + replica) │
|
||||
│ ├─ ConfigMap: connector configurations │
|
||||
│ └─ Secret: API keys, credentials │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
| Operation | Typical Duration | Bottleneck |
|
||||
|-----------|------------------|-----------|
|
||||
| Certificate request (CSR) | 100-500ms | Agent network latency |
|
||||
| ACME challenge (DNS) | 30-60s | DNS propagation |
|
||||
| ACME finalize | 1-5s | ACME server |
|
||||
| NGINX deployment | 500ms-2s | SSH latency + nginx reload |
|
||||
| F5 deployment | 2-10s | F5 API response |
|
||||
| IIS deployment | 3-15s | WinRM latency |
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **HSM Support**: Hardware security module integration for ACME account keys
|
||||
- **Multi-Region**: Control plane federation with local agents
|
||||
- **HA Control Plane**: Active-active with etcd-backed state
|
||||
- **Policy Engine**: Advanced renewal and deployment policies
|
||||
- **Certificate Pinning**: HPKP and pin validation
|
||||
- **Metrics**: Prometheus integration for observability
|
||||
|
||||
---
|
||||
|
||||
See [README.md](../README.md) for quick start and [docs/](../) for additional guides.
|
||||
@@ -0,0 +1,726 @@
|
||||
# Certctl Connector Development Guide
|
||||
|
||||
Connectors extend certctl to integrate with external systems for certificate issuance, deployment, and notifications. This guide covers building custom connectors from scratch.
|
||||
|
||||
## Overview
|
||||
|
||||
Three types of connectors:
|
||||
|
||||
1. **IssuerConnector** — Obtains certificates from PKI systems (ACME, Vault, DigiCert)
|
||||
2. **TargetConnector** — Deploys certificates to infrastructure (NGINX, F5, IIS, Kubernetes)
|
||||
3. **NotifierConnector** — Sends notifications about certificate events (Email, Webhooks, Slack)
|
||||
|
||||
All connectors:
|
||||
- Are registered with a unique type identifier
|
||||
- Accept configuration at initialization
|
||||
- Are used by the control plane or agents
|
||||
- Are tested via validation endpoints
|
||||
|
||||
---
|
||||
|
||||
## IssuerConnector Interface
|
||||
|
||||
Issuers obtain certificates from external PKI systems.
|
||||
|
||||
### Interface Definition
|
||||
|
||||
```go
|
||||
package issuer
|
||||
|
||||
type IssuerConnector interface {
|
||||
// Validate checks the issuer configuration and connectivity
|
||||
Validate(ctx context.Context) error
|
||||
|
||||
// IssueCertificate requests a certificate for the given domains
|
||||
IssueCertificate(ctx context.Context, req *IssueRequest) (*CertificateResponse, error)
|
||||
|
||||
// RevokeCertificate revokes an issued certificate
|
||||
RevokeCertificate(ctx context.Context, certPEM []byte) error
|
||||
|
||||
// GetStatus returns the status of an issuance request
|
||||
GetStatus(ctx context.Context, requestID string) (*StatusResponse, error)
|
||||
}
|
||||
|
||||
type IssueRequest struct {
|
||||
Domains []string // Primary domain + SANs
|
||||
CSR []byte // Certificate Signing Request (PEM)
|
||||
ValidityDays int // Requested validity period
|
||||
NotBefore *time.Time // Optional: not valid before
|
||||
NotAfter *time.Time // Optional: not valid after
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type CertificateResponse struct {
|
||||
Certificate []byte // Signed certificate (PEM)
|
||||
CertificateChain []byte // CA chain (PEM)
|
||||
RequestID string // For status tracking
|
||||
ExpiresAt time.Time
|
||||
IssuedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Vault PKI Issuer
|
||||
|
||||
```go
|
||||
package vault
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"github.com/hashicorp/vault/api"
|
||||
)
|
||||
|
||||
type VaultConfig struct {
|
||||
Address string
|
||||
Token string
|
||||
PKIPath string // e.g., "pki"
|
||||
RoleName string // e.g., "example-dot-com"
|
||||
}
|
||||
|
||||
type VaultIssuer struct {
|
||||
config *VaultConfig
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
func New(cfg *VaultConfig) (*VaultIssuer, error) {
|
||||
client, err := api.NewClient(&api.Config{Address: cfg.Address})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client.SetToken(cfg.Token)
|
||||
return &VaultIssuer{config: cfg, client: client}, nil
|
||||
}
|
||||
|
||||
// Validate tests connectivity and access
|
||||
func (v *VaultIssuer) Validate(ctx context.Context) error {
|
||||
_, err := v.client.Auth().Token().LookupSelf()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Vault auth failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IssueCertificate requests a certificate from Vault
|
||||
func (v *VaultIssuer) IssueCertificate(ctx context.Context, req *issuer.IssueRequest) (
|
||||
*issuer.CertificateResponse, error) {
|
||||
|
||||
// Extract primary domain and SANs
|
||||
if len(req.Domains) == 0 {
|
||||
return nil, fmt.Errorf("no domains provided")
|
||||
}
|
||||
primaryDomain := req.Domains[0]
|
||||
altNames := req.Domains[1:]
|
||||
|
||||
// Decode CSR
|
||||
csrBlock, _ := pem.Decode(req.CSR)
|
||||
if csrBlock == nil {
|
||||
return nil, fmt.Errorf("invalid CSR format")
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(csrBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Call Vault PKI issue endpoint
|
||||
path := fmt.Sprintf("%s/issue/%s", v.config.PKIPath, v.config.RoleName)
|
||||
data := map[string]interface{}{
|
||||
"common_name": primaryDomain,
|
||||
"alt_names": altNames,
|
||||
"ttl": fmt.Sprintf("%dh", req.ValidityDays*24),
|
||||
"private_key_format": "pem",
|
||||
}
|
||||
|
||||
secret, err := v.client.Logical().Write(path, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Vault issue failed: %w", err)
|
||||
}
|
||||
|
||||
// Extract certificate and chain
|
||||
certPEM := secret.Data["certificate"].(string)
|
||||
chainPEM := secret.Data["ca_chain"].([]interface{})
|
||||
caChain := ""
|
||||
for _, ca := range chainPEM {
|
||||
caChain += ca.(string) + "\n"
|
||||
}
|
||||
|
||||
return &issuer.CertificateResponse{
|
||||
Certificate: []byte(certPEM),
|
||||
CertificateChain: []byte(caChain),
|
||||
RequestID: secret.Data["request_id"].(string),
|
||||
ExpiresAt: time.Now().AddDate(0, 0, req.ValidityDays),
|
||||
IssuedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate in Vault
|
||||
func (v *VaultIssuer) RevokeCertificate(ctx context.Context, certPEM []byte) error {
|
||||
certBlock, _ := pem.Decode(certPEM)
|
||||
if certBlock == nil {
|
||||
return fmt.Errorf("invalid certificate format")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(certBlock.Bytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/revoke", v.config.PKIPath)
|
||||
_, err = v.client.Logical().Write(path, map[string]interface{}{
|
||||
"certificate": cert.SerialNumber.String(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// GetStatus returns the status of an issuance request
|
||||
func (v *VaultIssuer) GetStatus(ctx context.Context, requestID string) (
|
||||
*issuer.StatusResponse, error) {
|
||||
// Vault PKI doesn't have a request status endpoint
|
||||
// Return immediate success (Vault issues synchronously)
|
||||
return &issuer.StatusResponse{
|
||||
Status: "success",
|
||||
Ready: true,
|
||||
IssuedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Registration
|
||||
|
||||
Register your issuer in the connector registry:
|
||||
|
||||
```go
|
||||
// internal/connector/issuer/registry.go
|
||||
|
||||
package issuer
|
||||
|
||||
var registry = map[string]Factory{
|
||||
"acme": func(cfg Config) (IssuerConnector, error) { return acme.New(&cfg) },
|
||||
"vault": func(cfg Config) (IssuerConnector, error) { return vault.New(&cfg) },
|
||||
// Add more issuers here
|
||||
}
|
||||
|
||||
func GetConnector(connectorType string, config Config) (IssuerConnector, error) {
|
||||
factory, ok := registry[connectorType]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown issuer type: %s", connectorType)
|
||||
}
|
||||
return factory(config)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TargetConnector Interface
|
||||
|
||||
Targets deploy certificates to infrastructure.
|
||||
|
||||
### Interface Definition
|
||||
|
||||
```go
|
||||
package target
|
||||
|
||||
type TargetConnector interface {
|
||||
// Validate tests connectivity and credentials
|
||||
Validate(ctx context.Context) error
|
||||
|
||||
// Deploy pushes the certificate to the target
|
||||
Deploy(ctx context.Context, req *DeployRequest) (*DeployResponse, error)
|
||||
|
||||
// Remove removes/revokes a certificate from the target
|
||||
Remove(ctx context.Context, domain string) error
|
||||
|
||||
// GetStatus checks the deployment status
|
||||
GetStatus(ctx context.Context, domain string) (*StatusResponse, error)
|
||||
}
|
||||
|
||||
type DeployRequest struct {
|
||||
Domain string // Primary domain
|
||||
Certificate []byte // Signed certificate (PEM)
|
||||
PrivateKey []byte // Private key (PEM) - optional
|
||||
CertificateChain []byte // CA chain (PEM)
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type DeployResponse struct {
|
||||
RequestID string
|
||||
Status string // "success", "pending", "error"
|
||||
Message string
|
||||
DeployedAt time.Time
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
Status string // "deployed", "pending", "failed"
|
||||
DeployedAt time.Time
|
||||
Error string
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Custom Load Balancer Target
|
||||
|
||||
```go
|
||||
package lb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type LBConfig struct {
|
||||
Host string // Load balancer hostname
|
||||
Port int // HTTPS API port
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
type LoadBalancerTarget struct {
|
||||
config *LBConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(cfg *LBConfig) *LoadBalancerTarget {
|
||||
return &LoadBalancerTarget{
|
||||
config: cfg,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tests connectivity
|
||||
func (lb *LoadBalancerTarget) Validate(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET",
|
||||
fmt.Sprintf("https://%s:%d/api/health", lb.config.Host, lb.config.Port), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth(lb.config.Username, lb.config.Password)
|
||||
|
||||
resp, err := lb.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load balancer unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("load balancer returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deploy pushes certificate to the load balancer
|
||||
func (lb *LoadBalancerTarget) Deploy(ctx context.Context, req *target.DeployRequest) (
|
||||
*target.DeployResponse, error) {
|
||||
|
||||
body := map[string]interface{}{
|
||||
"domain": req.Domain,
|
||||
"certificate": string(req.Certificate),
|
||||
"chain": string(req.CertificateChain),
|
||||
"key": string(req.PrivateKey),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST",
|
||||
fmt.Sprintf("https://%s:%d/api/certs/upload", lb.config.Host, lb.config.Port),
|
||||
bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.SetBasicAuth(lb.config.Username, lb.config.Password)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := lb.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deployment failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("deployment returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return &target.DeployResponse{
|
||||
Status: "success",
|
||||
DeployedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Remove deletes a certificate from the load balancer
|
||||
func (lb *LoadBalancerTarget) Remove(ctx context.Context, domain string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE",
|
||||
fmt.Sprintf("https://%s:%d/api/certs/%s", lb.config.Host, lb.config.Port, domain), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth(lb.config.Username, lb.config.Password)
|
||||
|
||||
resp, err := lb.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("removal returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStatus checks deployment status
|
||||
func (lb *LoadBalancerTarget) GetStatus(ctx context.Context, domain string) (
|
||||
*target.StatusResponse, error) {
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET",
|
||||
fmt.Sprintf("https://%s:%d/api/certs/%s/status", lb.config.Host, lb.config.Port, domain), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(lb.config.Username, lb.config.Password)
|
||||
|
||||
resp, err := lb.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
return &target.StatusResponse{
|
||||
Status: result["status"].(string),
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NotifierConnector Interface
|
||||
|
||||
Notifiers send alerts about certificate events.
|
||||
|
||||
### Interface Definition
|
||||
|
||||
```go
|
||||
package notifier
|
||||
|
||||
type NotifierConnector interface {
|
||||
// Validate checks configuration
|
||||
Validate(ctx context.Context) error
|
||||
|
||||
// Send delivers a notification
|
||||
Send(ctx context.Context, notification *Notification) error
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
EventType string // "certificate_issued", "renewal_failed", "deployment_success"
|
||||
Subject string
|
||||
Body string
|
||||
Severity string // "info", "warning", "error"
|
||||
Metadata map[string]string
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Slack Notifier
|
||||
|
||||
```go
|
||||
package slack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type SlackConfig struct {
|
||||
WebhookURL string // Slack incoming webhook URL
|
||||
Channel string // Optional: override channel
|
||||
Username string // Bot username
|
||||
}
|
||||
|
||||
type SlackNotifier struct {
|
||||
config *SlackConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(cfg *SlackConfig) *SlackNotifier {
|
||||
return &SlackNotifier{
|
||||
config: cfg,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks webhook connectivity
|
||||
func (s *SlackNotifier) Validate(ctx context.Context) error {
|
||||
payload := map[string]interface{}{
|
||||
"text": "Certctl test message",
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
|
||||
resp, err := s.client.Post(s.config.WebhookURL, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("slack webhook returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send posts a message to Slack
|
||||
func (s *SlackNotifier) Send(ctx context.Context, notif *notifier.Notification) error {
|
||||
color := "good"
|
||||
if notif.Severity == "error" {
|
||||
color = "danger"
|
||||
} else if notif.Severity == "warning" {
|
||||
color = "warning"
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"username": s.config.Username,
|
||||
"attachments": []map[string]interface{}{
|
||||
{
|
||||
"title": notif.Subject,
|
||||
"text": notif.Body,
|
||||
"color": color,
|
||||
"fields": formatMetadata(notif.Metadata),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(payload)
|
||||
resp, err := s.client.Post(s.config.WebhookURL, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("slack post failed: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatMetadata(m map[string]string) []map[string]interface{} {
|
||||
fields := []map[string]interface{}{}
|
||||
for k, v := range m {
|
||||
fields = append(fields, map[string]interface{}{
|
||||
"title": k,
|
||||
"value": v,
|
||||
"short": true,
|
||||
})
|
||||
}
|
||||
return fields
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Connectors
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```go
|
||||
package vault
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
cfg := &VaultConfig{
|
||||
Address: "http://localhost:8200",
|
||||
Token: "test-token",
|
||||
}
|
||||
issuer := New(cfg)
|
||||
|
||||
err := issuer.Validate(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCertificate(t *testing.T) {
|
||||
// Mock Vault responses or use Vault test harness
|
||||
cfg := &VaultConfig{/* ... */}
|
||||
issuer := New(cfg)
|
||||
|
||||
req := &issuer.IssueRequest{
|
||||
Domains: []string{"example.com"},
|
||||
CSR: testCSR,
|
||||
ValidityDays: 90,
|
||||
}
|
||||
|
||||
resp, err := issuer.IssueCertificate(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("issuance failed: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Certificate) == 0 {
|
||||
t.Fatal("no certificate returned")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```bash
|
||||
# Start dependent service
|
||||
docker run -d --name vault -p 8200:8200 vault:latest server -dev
|
||||
|
||||
# Run tests
|
||||
go test -tags=integration ./internal/connector/issuer/vault
|
||||
|
||||
# Cleanup
|
||||
docker rm -f vault
|
||||
```
|
||||
|
||||
### Validation Endpoints
|
||||
|
||||
Test connectors via the API:
|
||||
|
||||
```bash
|
||||
# Validate an issuer
|
||||
curl -X POST http://localhost:8443/api/v1/issuers/validate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "vault",
|
||||
"config": {
|
||||
"address": "http://vault.example.com:8200",
|
||||
"token": "s.xxxxxxx"
|
||||
}
|
||||
}'
|
||||
|
||||
# Validate a target
|
||||
curl -X POST http://localhost:8443/api/v1/targets/validate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "nginx",
|
||||
"config": {
|
||||
"host": "web01.example.com",
|
||||
"ssh_user": "deploy"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registering Custom Connectors
|
||||
|
||||
### 1. Create Connector Package
|
||||
|
||||
```
|
||||
internal/connector/issuer/myissuer/
|
||||
├── issuer.go # Implementation
|
||||
└── config.go # Configuration validation
|
||||
```
|
||||
|
||||
### 2. Implement Interface
|
||||
|
||||
```go
|
||||
package myissuer
|
||||
|
||||
type MyIssuer struct {
|
||||
config *Config
|
||||
}
|
||||
|
||||
func (m *MyIssuer) Validate(ctx context.Context) error {
|
||||
// Validation logic
|
||||
}
|
||||
|
||||
func (m *MyIssuer) IssueCertificate(ctx context.Context, req *issuer.IssueRequest) (*issuer.CertificateResponse, error) {
|
||||
// Issuance logic
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Register in Factory
|
||||
|
||||
```go
|
||||
// internal/connector/issuer/factory.go
|
||||
|
||||
import "github.com/shankar0123/certctl/internal/connector/issuer/myissuer"
|
||||
|
||||
var factories = map[string]ConnectorFactory{
|
||||
"myissuer": func(cfg interface{}) (IssuerConnector, error) {
|
||||
return myissuer.New(cfg.(*myissuer.Config))
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Add Configuration Schema
|
||||
|
||||
```go
|
||||
// Validate connector configuration at registration
|
||||
func ValidateConfig(connectorType string, config interface{}) error {
|
||||
switch connectorType {
|
||||
case "myissuer":
|
||||
cfg := config.(*MyConfig)
|
||||
if cfg.Host == "" {
|
||||
return fmt.Errorf("host is required")
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
return fmt.Errorf("token is required")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Use in Your Application
|
||||
|
||||
```go
|
||||
// Get connector
|
||||
connector, err := issuer.GetConnector("myissuer", config)
|
||||
|
||||
// Issue certificate
|
||||
resp, err := connector.IssueCertificate(ctx, issueReq)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Error Handling** — Return descriptive errors with context
|
||||
2. **Timeout Management** — Always use context with timeouts
|
||||
3. **Validation** — Validate configuration during Validate()
|
||||
4. **Retry Logic** — Handle transient failures gracefully
|
||||
5. **Logging** — Log all operations for debugging
|
||||
6. **Testing** — Provide unit and integration tests
|
||||
7. **Documentation** — Document configuration options and limitations
|
||||
8. **Security** — Never log sensitive data (tokens, keys, passwords)
|
||||
|
||||
---
|
||||
|
||||
## Contributing Connectors
|
||||
|
||||
To contribute a connector to certctl:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch: `git checkout -b feat/my-connector`
|
||||
3. Add connector implementation with tests
|
||||
4. Update [README.md](../README.md#supported-integrations)
|
||||
5. Add documentation to [docs/](.)
|
||||
6. Submit a pull request
|
||||
|
||||
Connectors must:
|
||||
- Implement the full interface
|
||||
- Include unit tests (>80% coverage)
|
||||
- Have integration tests (if applicable)
|
||||
- Include configuration examples
|
||||
- Document any prerequisites (API keys, credentials)
|
||||
|
||||
---
|
||||
|
||||
For more information, see:
|
||||
- [Architecture Guide](architecture.md#connector-architecture)
|
||||
- [API Reference](../README.md#api-overview)
|
||||
- [Contributing Guidelines](../CONTRIBUTING.md) (coming soon)
|
||||
@@ -0,0 +1,526 @@
|
||||
# Certctl Quick Start Guide
|
||||
|
||||
Get a working certctl deployment from zero to managing certificates in 10 minutes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker** and **Docker Compose** (recommended), or:
|
||||
- Go 1.22+
|
||||
- PostgreSQL 14+
|
||||
- psql CLI tool
|
||||
|
||||
## Option 1: Docker Compose (Fastest)
|
||||
|
||||
### 1. Clone & Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
cd certctl
|
||||
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Optional: edit .env for custom settings
|
||||
# nano .env
|
||||
```
|
||||
|
||||
### 2. Start the Stack
|
||||
|
||||
```bash
|
||||
make docker-up
|
||||
|
||||
# Wait for services to be healthy (~30 seconds)
|
||||
docker-compose -f deploy/docker-compose.yml ps
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
NAME STATUS
|
||||
certctl-postgres Up (healthy)
|
||||
certctl-server Up (healthy)
|
||||
certctl-agent Up
|
||||
```
|
||||
|
||||
### 3. Verify Health
|
||||
|
||||
```bash
|
||||
# Server health check
|
||||
curl http://localhost:8443/health
|
||||
# Expected: {"status":"healthy"}
|
||||
|
||||
# Container logs
|
||||
make docker-logs-server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Manual Build & Run
|
||||
|
||||
### 1. Clone & Dependencies
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
cd certctl
|
||||
|
||||
go mod download
|
||||
```
|
||||
|
||||
### 2. Setup PostgreSQL
|
||||
|
||||
```bash
|
||||
# Create database and user
|
||||
psql -U postgres -h localhost << EOF
|
||||
CREATE USER certctl WITH PASSWORD 'certctl';
|
||||
CREATE DATABASE certctl OWNER certctl;
|
||||
GRANT ALL PRIVILEGES ON DATABASE certctl TO certctl;
|
||||
EOF
|
||||
|
||||
# Verify connection
|
||||
psql -h localhost -U certctl -d certctl -c "SELECT 1"
|
||||
```
|
||||
|
||||
### 3. Run Migrations
|
||||
|
||||
```bash
|
||||
# Install migrate tool
|
||||
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
|
||||
|
||||
# Set database URL
|
||||
export DB_URL="postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable"
|
||||
|
||||
# Run migrations
|
||||
make migrate-up
|
||||
```
|
||||
|
||||
### 4. Start Server
|
||||
|
||||
```bash
|
||||
# Terminal 1: Server
|
||||
make run
|
||||
|
||||
# Expected output:
|
||||
# 2024-03-14T10:30:00Z server starting version=1.0.0 server_port=8443
|
||||
```
|
||||
|
||||
### 5. Start Agent (Optional)
|
||||
|
||||
```bash
|
||||
# Terminal 2: Agent
|
||||
export SERVER_URL=http://localhost:8443
|
||||
export API_KEY=default-api-key
|
||||
./bin/agent
|
||||
|
||||
# Expected output: Agent connecting to http://localhost:8443
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Walk-Through: Create Your First Certificate
|
||||
|
||||
### Step 1: Verify API Access
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:8443/health
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{"status":"healthy"}
|
||||
```
|
||||
|
||||
### Step 2: Create a Team (Optional)
|
||||
|
||||
Teams organize ownership and auditing. For this quick start, we'll use a default team.
|
||||
|
||||
```bash
|
||||
TEAM_ID="default"
|
||||
```
|
||||
|
||||
### Step 3: Register an ACME Issuer
|
||||
|
||||
Create a certificate issuer configuration (Let's Encrypt staging for this demo):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/issuers \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"team_id": "default",
|
||||
"name": "lets-encrypt-staging",
|
||||
"type": "acme",
|
||||
"config": {
|
||||
"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||
"email": "admin@example.com"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Response (save the `issuer_id`):
|
||||
```json
|
||||
{
|
||||
"id": "issuer-abc123",
|
||||
"name": "lets-encrypt-staging",
|
||||
"type": "acme",
|
||||
"created_at": "2024-03-14T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Store the issuer ID:
|
||||
```bash
|
||||
ISSUER_ID="issuer-abc123"
|
||||
```
|
||||
|
||||
### Step 4: Register an Agent
|
||||
|
||||
Agents handle certificate requests and deployment. Register one:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/agents \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"team_id": "default",
|
||||
"name": "quickstart-agent",
|
||||
"description": "Local development agent"
|
||||
}'
|
||||
```
|
||||
|
||||
Response (save the `api_key` and `id`):
|
||||
```json
|
||||
{
|
||||
"id": "agent-xyz789",
|
||||
"name": "quickstart-agent",
|
||||
"api_key": "ey...",
|
||||
"registered_at": "2024-03-14T10:30:00Z",
|
||||
"status": "registered"
|
||||
}
|
||||
```
|
||||
|
||||
Store the agent details:
|
||||
```bash
|
||||
AGENT_ID="agent-xyz789"
|
||||
AGENT_API_KEY="ey..."
|
||||
```
|
||||
|
||||
### Step 5: Create a Deployment Target
|
||||
|
||||
Targets are where certificates will be deployed (NGINX, F5, etc.). For this demo, we'll skip actual deployment:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/targets \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"team_id": "default",
|
||||
"agent_id": "'$AGENT_ID'",
|
||||
"name": "example-nginx",
|
||||
"type": "nginx",
|
||||
"config": {
|
||||
"host": "nginx.example.com",
|
||||
"ssh_user": "deploy",
|
||||
"ssh_key": "/path/to/key",
|
||||
"cert_path": "/etc/nginx/ssl"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"id": "target-def456",
|
||||
"name": "example-nginx",
|
||||
"agent_id": "agent-xyz789",
|
||||
"type": "nginx",
|
||||
"status": "pending_validation"
|
||||
}
|
||||
```
|
||||
|
||||
Store the target ID:
|
||||
```bash
|
||||
TARGET_ID="target-def456"
|
||||
```
|
||||
|
||||
### Step 6: Create a Managed Certificate
|
||||
|
||||
Now the main event—request a certificate to be issued and managed:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/certificates \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"team_id": "default",
|
||||
"domain": "api.example.com",
|
||||
"issuer_id": "'$ISSUER_ID'",
|
||||
"target_ids": ["'$TARGET_ID'"],
|
||||
"renewal_days_before": 30,
|
||||
"auto_deploy": true
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"id": "cert-ghi012",
|
||||
"domain": "api.example.com",
|
||||
"issuer_id": "issuer-abc123",
|
||||
"status": "pending",
|
||||
"created_at": "2024-03-14T10:30:00Z",
|
||||
"expires_at": null,
|
||||
"renewal_at": null
|
||||
}
|
||||
```
|
||||
|
||||
Store the certificate ID:
|
||||
```bash
|
||||
CERT_ID="cert-ghi012"
|
||||
```
|
||||
|
||||
### Step 7: Check Certificate Status
|
||||
|
||||
Poll the certificate status as issuance progresses:
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:8443/api/v1/certificates/$CERT_ID \
|
||||
-H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
Response (will change over time):
|
||||
```json
|
||||
{
|
||||
"id": "cert-ghi012",
|
||||
"domain": "api.example.com",
|
||||
"status": "issued",
|
||||
"expires_at": "2024-06-12T10:30:00Z",
|
||||
"issued_by": "issuer-abc123",
|
||||
"deployed_to": [
|
||||
{
|
||||
"target_id": "target-def456",
|
||||
"status": "success",
|
||||
"deployed_at": "2024-03-14T10:30:30Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 8: View Audit Trail
|
||||
|
||||
See all actions related to your certificate:
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8443/api/v1/audit/logs?resource_id=$CERT_ID" \
|
||||
-H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"logs": [
|
||||
{
|
||||
"id": "audit-001",
|
||||
"timestamp": "2024-03-14T10:30:00Z",
|
||||
"actor": {
|
||||
"type": "api",
|
||||
"id": "client-001"
|
||||
},
|
||||
"action": "certificate_created",
|
||||
"resource": {
|
||||
"type": "certificate",
|
||||
"id": "cert-ghi012"
|
||||
},
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"id": "audit-002",
|
||||
"timestamp": "2024-03-14T10:30:10Z",
|
||||
"actor": {
|
||||
"type": "agent",
|
||||
"id": "agent-xyz789"
|
||||
},
|
||||
"action": "certificate_issued",
|
||||
"resource": {
|
||||
"type": "certificate",
|
||||
"id": "cert-ghi012"
|
||||
},
|
||||
"status": "success",
|
||||
"details": {
|
||||
"issuer": "lets-encrypt-staging",
|
||||
"expiry": "2024-06-12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "audit-003",
|
||||
"timestamp": "2024-03-14T10:30:25Z",
|
||||
"actor": {
|
||||
"type": "system",
|
||||
"id": "scheduler"
|
||||
},
|
||||
"action": "certificate_deployed",
|
||||
"resource": {
|
||||
"type": "certificate",
|
||||
"id": "cert-ghi012"
|
||||
},
|
||||
"status": "success",
|
||||
"details": {
|
||||
"deployed_to": "example-nginx"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 9: Trigger Manual Renewal (Optional)
|
||||
|
||||
To manually trigger certificate renewal:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/renew \
|
||||
-H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
The scheduler will automatically check for renewals every hour. Certificates within 30 days of expiry are renewed automatically.
|
||||
|
||||
---
|
||||
|
||||
## Development Mode
|
||||
|
||||
For development with hot reload and database browser:
|
||||
|
||||
```bash
|
||||
# Install tools
|
||||
make install-tools
|
||||
|
||||
# Start dev stack (includes PgAdmin at localhost:5050)
|
||||
make docker-up-dev
|
||||
|
||||
# View logs
|
||||
make docker-logs-server
|
||||
make docker-logs-agent
|
||||
|
||||
# Admin credentials for PgAdmin:
|
||||
# Email: admin@example.com (default, see .env)
|
||||
# Password: admin (default, see .env)
|
||||
|
||||
# Access PgAdmin: http://localhost:5050
|
||||
# Add server: postgres, port 5432, user certctl, password certctl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing the Flow End-to-End
|
||||
|
||||
Here's a complete script to test the full flow:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
API="http://localhost:8443"
|
||||
TEAM="default"
|
||||
|
||||
echo "1. Creating ACME issuer..."
|
||||
ISSUER=$(curl -s -X POST $API/api/v1/issuers \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"team_id": "'$TEAM'",
|
||||
"name": "letsencrypt-staging",
|
||||
"type": "acme",
|
||||
"config": {
|
||||
"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||
"email": "test@example.com"
|
||||
}
|
||||
}' | jq -r '.id')
|
||||
|
||||
echo " Issuer: $ISSUER"
|
||||
|
||||
echo "2. Registering agent..."
|
||||
AGENT=$(curl -s -X POST $API/api/v1/agents \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"team_id": "'$TEAM'",
|
||||
"name": "test-agent"
|
||||
}' | jq -r '.id')
|
||||
|
||||
echo " Agent: $AGENT"
|
||||
|
||||
echo "3. Creating certificate..."
|
||||
CERT=$(curl -s -X POST $API/api/v1/certificates \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"team_id": "'$TEAM'",
|
||||
"domain": "test-'$(date +%s)'.example.com",
|
||||
"issuer_id": "'$ISSUER'",
|
||||
"renewal_days_before": 30
|
||||
}' | jq -r '.id')
|
||||
|
||||
echo " Certificate: $CERT"
|
||||
|
||||
echo "4. Checking status..."
|
||||
curl -s -X GET $API/api/v1/certificates/$CERT | jq '.status'
|
||||
|
||||
echo "5. Viewing audit trail..."
|
||||
curl -s -X GET "$API/api/v1/audit/logs?resource_id=$CERT" | jq '.logs | length'
|
||||
|
||||
echo "Done!"
|
||||
```
|
||||
|
||||
Save as `test.sh`, make executable, and run:
|
||||
```bash
|
||||
chmod +x test.sh
|
||||
./test.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Server Won't Start
|
||||
|
||||
```bash
|
||||
# Check database connection
|
||||
psql -h localhost -U certctl -d certctl -c "SELECT 1"
|
||||
|
||||
# View logs
|
||||
make docker-logs-server
|
||||
|
||||
# Check environment
|
||||
env | grep DB_
|
||||
```
|
||||
|
||||
### Agent Can't Connect
|
||||
|
||||
```bash
|
||||
# Verify server is running
|
||||
curl http://localhost:8443/health
|
||||
|
||||
# Check agent logs
|
||||
docker logs certctl-agent
|
||||
|
||||
# Verify API key is correct
|
||||
echo $AGENT_API_KEY
|
||||
```
|
||||
|
||||
### Certificate Stays "Pending"
|
||||
|
||||
```bash
|
||||
# Check if agent is registered
|
||||
curl http://localhost:8443/api/v1/agents
|
||||
|
||||
# Check agent logs for errors
|
||||
make docker-logs-agent
|
||||
|
||||
# View certificate details
|
||||
curl http://localhost:8443/api/v1/certificates/$CERT_ID
|
||||
|
||||
# Check audit trail
|
||||
curl "http://localhost:8443/api/v1/audit/logs?resource_id=$CERT_ID"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Read** [docs/architecture.md](architecture.md) to understand the design
|
||||
2. **Explore** the [API](../README.md#api-overview) for more operations
|
||||
3. **Build** a [custom connector](connectors.md) for your infrastructure
|
||||
4. **Deploy** to production using [docs/k8s-deployment.md](k8s-deployment.md) (coming soon)
|
||||
|
||||
---
|
||||
|
||||
For more help, see [README.md](../README.md#troubleshooting) or open an issue on GitHub.
|
||||
@@ -0,0 +1,5 @@
|
||||
module github.com/shankar0123/certctl
|
||||
|
||||
go 1.22.5
|
||||
|
||||
require github.com/google/uuid v1.6.0
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Executable
+155
@@ -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 ""
|
||||
Reference in New Issue
Block a user