mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 13:18:52 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5567d4b411 | |||
| e5516d7286 | |||
| fd94e0bd19 | |||
| d0415d3b5e | |||
| c6efa4ab39 | |||
| dedf7fa3a9 | |||
| 4b5927dfff | |||
| cc03f55006 | |||
| 93e1dc598c | |||
| 25f33b830f | |||
| 7d6ef44e21 |
+1
-1
@@ -65,7 +65,7 @@ certctl-cli
|
||||
/cli
|
||||
|
||||
# Private strategy docs
|
||||
roadmap.md
|
||||
strategy.md
|
||||
SECURITY_REMEDIATION.md
|
||||
|
||||
# OS
|
||||
|
||||
@@ -38,6 +38,8 @@ gantt
|
||||
|
||||
> **Actively maintained — shipping weekly.** Found something? [Open a GitHub issue](https://github.com/shankar0123/certctl/issues) — issues get triaged same-day. CI runs the full test suite with race detection, static analysis, and vulnerability scanning on every commit.
|
||||
|
||||
**Ready to try it?** Jump to the [Quick Start](#quick-start) — you'll have a running dashboard in under 5 minutes.
|
||||
|
||||
## Why certctl Exists
|
||||
|
||||
Certificate lifecycle tooling today falls into two camps: expensive enterprise platforms (Venafi, Keyfactor, Sectigo) that cost six figures and take months to deploy, or single-purpose tools (cert-manager, certbot) that handle one slice of the problem. If you run a mixed infrastructure — some NGINX, some Apache, a few HAProxy nodes, IIS on Windows, maybe an F5 — and you need to manage certificates from multiple CAs, there's nothing self-hosted that covers the full lifecycle without vendor lock-in.
|
||||
@@ -46,7 +48,7 @@ certctl fills that gap. It's **CA-agnostic** — plug in any certificate authori
|
||||
|
||||
It's **target-agnostic**. Agents deploy certificates to NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS (local PowerShell or remote WinRM), F5 BIG-IP (proxy agent), and any Linux/Unix server via SSH/SFTP — all using the same pluggable connector model. The control plane never initiates outbound connections — agents poll for work, which means certctl works behind firewalls, across network zones, and in air-gapped environments.
|
||||
|
||||
For a detailed comparison with CertKit, KeyTalk, and enterprise platforms, see [Why certctl?](docs/why-certctl.md)
|
||||
For a detailed comparison with other competitors and enterprise platforms, see [Why certctl?](docs/why-certctl.md)
|
||||
|
||||
## Who Is This For
|
||||
|
||||
@@ -60,7 +62,7 @@ For a detailed comparison with CertKit, KeyTalk, and enterprise platforms, see [
|
||||
|
||||
- **Certificates renew and deploy themselves.** The scheduler monitors expiration, creates renewal jobs, issues certificates through your CA, and deploys them to target servers — all without human intervention. ACME ARI (RFC 9773) lets your CA tell certctl exactly when to renew. Ready for 45-day and 6-day certificate lifetimes (SC-081v3 and Let's Encrypt shortlived profiles).
|
||||
|
||||
- **You see everything in one place.** A 25-page operational dashboard shows every certificate across every server: status, ownership, expiration timeline, deployment history with TLS verification, discovery triage, and real-time agent fleet health. Bulk operations (renew, revoke, reassign) work across selections.
|
||||
- **You see everything in one place.** The operational dashboard shows every certificate across every server: status, ownership, expiration timeline, deployment history with TLS verification, discovery triage, and real-time agent fleet health. Bulk operations (renew, revoke, reassign) work across selections.
|
||||
|
||||
- **Private keys never leave your servers.** Agents generate ECDSA P-256 keys locally and submit only the CSR. The control plane never touches private keys. Post-deployment TLS verification confirms the right certificate is actually being served.
|
||||
|
||||
@@ -68,7 +70,7 @@ For a detailed comparison with CertKit, KeyTalk, and enterprise platforms, see [
|
||||
|
||||
- **Everything is auditable.** Immutable append-only audit trail records every lifecycle action, every API call, and every approval decision. Certificate digest emails deliver daily briefings. Prometheus metrics endpoint for Grafana dashboards.
|
||||
|
||||
- **Multiple interfaces for different workflows.** REST API (97 endpoints) for automation, CLI for scripting, MCP server for AI assistants (Claude, Cursor, Windsurf), EST server (RFC 7030) for device enrollment, Helm chart for Kubernetes, and the web dashboard for day-to-day operations.
|
||||
- **Multiple interfaces for different workflows.** REST API for automation, CLI for scripting, MCP server for AI assistants (Claude, Cursor, Windsurf), EST server (RFC 7030) for device enrollment, Helm chart for Kubernetes, and the web dashboard for day-to-day operations.
|
||||
|
||||
For the full capability breakdown — revocation infrastructure (CRL + OCSP), policy engine, certificate profiles, S/MIME support, approval workflows, and more — see the [Feature Inventory](docs/features.md).
|
||||
|
||||
@@ -86,8 +88,9 @@ For the full capability breakdown — revocation infrastructure (CRL + OCSP), po
|
||||
| DigiCert CertCentral | Beta | `DigiCert` |
|
||||
| Sectigo SCM | Beta | `Sectigo` |
|
||||
| Google CAS | Beta | `GoogleCAS` |
|
||||
| AWS ACM Private CA | Beta | `AWSACMPCA` |
|
||||
|
||||
**Vault PKI, DigiCert, Sectigo, and Google CAS connectors are in beta.** If you hit any bugs or unexpected behavior, please [open a GitHub issue](https://github.com/shankar0123/certctl/issues) -- we're actively testing these and want to hear from real users.
|
||||
**Vault PKI, DigiCert, Sectigo, Google CAS, and AWS ACM PCA connectors are in beta.** If you hit any bugs or unexpected behavior, please [open a GitHub issue](https://github.com/shankar0123/certctl/issues) -- we're actively testing these and want to hear from real users.
|
||||
|
||||
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated today via the OpenSSL/Custom CA connector.
|
||||
|
||||
@@ -105,6 +108,9 @@ For the full capability breakdown — revocation infrastructure (CRL + OCSP), po
|
||||
| Microsoft IIS | Implemented (local + WinRM) | `IIS` |
|
||||
| F5 BIG-IP | Beta | `F5` |
|
||||
| SSH (Agentless) | Beta | `SSH` |
|
||||
| Windows Cert Store | Implemented | `WinCertStore` |
|
||||
| Java Keystore | Implemented | `JavaKeystore` |
|
||||
| Kubernetes Secrets | Beta | `KubernetesSecrets` |
|
||||
|
||||
### Notifiers
|
||||
| Notifier | Status | Type |
|
||||
@@ -158,16 +164,19 @@ cd certctl
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
Wait ~30 seconds, then open **http://localhost:8443** in your browser.
|
||||
Wait ~30 seconds, then open **http://localhost:8443** in your browser. The onboarding wizard walks you through connecting a CA, deploying an agent, and issuing your first certificate.
|
||||
|
||||
The dashboard comes pre-loaded with 32 demo certificates across 7 issuers, 8 agents, 180 days of job history, discovery scan data, and network scan targets — a realistic snapshot of a certificate inventory that looks like it's been running for months.
|
||||
**Want a pre-populated demo instead?** Add the demo override to see 32 certificates across 7 issuers, 8 agents, and 180 days of realistic history:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up -d --build
|
||||
```
|
||||
|
||||
The `deploy/` directory has four compose files: `docker-compose.yml` (base platform), `docker-compose.demo.yml` (demo data overlay), `docker-compose.dev.yml` (PgAdmin + debug logging), and `docker-compose.test.yml` (standalone integration tests with real CA backends). See the [Docker Compose Environments Guide](deploy/ENVIRONMENTS.md) for a service-by-service walkthrough, or the [Quick Start](docs/quickstart.md#docker-compose-environments) for a summary.
|
||||
|
||||
```bash
|
||||
curl http://localhost:8443/health
|
||||
# {"status":"healthy"}
|
||||
|
||||
curl -s http://localhost:8443/api/v1/certificates | jq '.total'
|
||||
# 32
|
||||
```
|
||||
|
||||
### Agent Install (One-Liner)
|
||||
@@ -217,13 +226,20 @@ Each directory contains a `docker-compose.yml` and a `README.md` explaining the
|
||||
| [Why certctl?](docs/why-certctl.md) | How certctl compares to ACME clients, agent-based SaaS, and enterprise platforms |
|
||||
| [Concepts](docs/concepts.md) | TLS certificates explained from scratch — for beginners who know nothing about certs |
|
||||
| [Quick Start](docs/quickstart.md) | 5-minute setup — dashboard, API, CLI, discovery, stakeholder demo flow |
|
||||
| [Docker Compose Environments](deploy/ENVIRONMENTS.md) | Service-by-service walkthrough of all 4 compose files, env var reference |
|
||||
| [Deployment Examples](docs/examples.md) | 5 turnkey scenarios (ACME+NGINX, wildcard DNS-01, private CA, step-ca, multi-issuer) with migration guides |
|
||||
| [Advanced Demo](docs/demo-advanced.md) | Issue a certificate end-to-end with technical deep-dives |
|
||||
| [Architecture](docs/architecture.md) | System design, data flow diagrams, security model |
|
||||
| [Feature Inventory](docs/features.md) | Complete reference of all V2 capabilities, API endpoints, and configuration |
|
||||
| [Connector Reference](docs/connectors.md) | Configuration for all 7 issuers, 10 targets, and 5 notifier connectors |
|
||||
| [Connector Reference](docs/connectors.md) | Configuration for all issuer, target, and notifier connectors |
|
||||
| [MCP Server](docs/mcp.md) | AI integration via Model Context Protocol — setup, available tools, examples |
|
||||
| [OpenAPI 3.1 Spec](docs/openapi.md) | API reference guide with endpoint overview ([raw spec](api/openapi.yaml)) |
|
||||
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
||||
| [OpenAPI 3.1 Spec](api/openapi.yaml) | 97 operations, full request/response schemas |
|
||||
| [Migrate from certbot](docs/migrate-from-certbot.md) | Step-by-step migration from certbot cron jobs to certctl |
|
||||
| [Migrate from acme.sh](docs/migrate-from-acmesh.md) | Migration guide for acme.sh users, DNS hook compatibility |
|
||||
| [certctl for cert-manager users](docs/certctl-for-cert-manager-users.md) | How certctl complements cert-manager for mixed infrastructure |
|
||||
| [Test Environment](docs/test-env.md) | Docker Compose test environment with real CA backends |
|
||||
| [Testing Guide](docs/testing-guide.md) | Comprehensive test procedures, smoke tests, and release sign-off checklist |
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -303,7 +319,7 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
|
||||
Team access controls and identity provider integration (OIDC/SSO). Role-based access control with profile-gating. Event-driven architecture (NATS) with real-time operational views. Advanced search DSL, compliance and risk scoring, bulk fleet operations.
|
||||
|
||||
### V4+: Cloud, Scale & Passive Discovery
|
||||
Passive network discovery (TLS listener), Kubernetes integration (cert-manager external issuer, Secrets target), cloud infrastructure targets (AWS ALB/ACM, Azure Key Vault), extended CA support (Google CAS, EJBCA, Sectigo), and platform-scale features (Terraform provider, multi-tenancy, HSM support).
|
||||
Passive network discovery (TLS listener), Kubernetes integration (cert-manager external issuer, Secrets target), cloud infrastructure targets (AWS ALB/ACM, Azure Key Vault), extended CA support (Entrust, GlobalSign, EJBCA), and platform-scale features (Terraform provider, multi-tenancy, HSM support).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+2
-2
@@ -2643,7 +2643,7 @@ components:
|
||||
# ─── Issuers ─────────────────────────────────────────────────────
|
||||
IssuerType:
|
||||
type: string
|
||||
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert, Sectigo, GoogleCAS]
|
||||
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert, Sectigo, GoogleCAS, AWSACMPCA]
|
||||
|
||||
Issuer:
|
||||
type: object
|
||||
@@ -2669,7 +2669,7 @@ components:
|
||||
# ─── Targets ─────────────────────────────────────────────────────
|
||||
TargetType:
|
||||
type: string
|
||||
enum: [NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS, F5, SSH]
|
||||
enum: [NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS, F5, SSH, WinCertStore, JavaKeystore, KubernetesSecrets]
|
||||
|
||||
DeploymentTarget:
|
||||
type: object
|
||||
|
||||
@@ -33,6 +33,9 @@ import (
|
||||
pf "github.com/shankar0123/certctl/internal/connector/target/postfix"
|
||||
sshconn "github.com/shankar0123/certctl/internal/connector/target/ssh"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
||||
jks "github.com/shankar0123/certctl/internal/connector/target/javakeystore"
|
||||
k8s "github.com/shankar0123/certctl/internal/connector/target/k8ssecret"
|
||||
wcs "github.com/shankar0123/certctl/internal/connector/target/wincertstore"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/haproxy"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/iis"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/nginx"
|
||||
@@ -657,6 +660,33 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess
|
||||
}
|
||||
return sshconn.New(&cfg, a.logger)
|
||||
|
||||
case "WinCertStore":
|
||||
var cfg wcs.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid WinCertStore config: %w", err)
|
||||
}
|
||||
}
|
||||
return wcs.New(&cfg, a.logger)
|
||||
|
||||
case "JavaKeystore":
|
||||
var cfg jks.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid JavaKeystore config: %w", err)
|
||||
}
|
||||
}
|
||||
return jks.New(&cfg, a.logger), nil
|
||||
|
||||
case "KubernetesSecrets":
|
||||
var cfg k8s.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid KubernetesSecrets config: %w", err)
|
||||
}
|
||||
}
|
||||
return k8s.New(&cfg, a.logger)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported target type: %s", targetType)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
# certctl Docker Compose Environments
|
||||
|
||||
This guide walks through every Docker Compose file in the `deploy/` directory. Each section explains what the environment does, when to use it, every service and environment variable, and the commands to run it. If you've never used Docker before, start with the [Prerequisites](#prerequisites) section. If you're experienced, skip to the environment you need.
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [How Docker Compose Works (30-Second Version)](#how-docker-compose-works)
|
||||
3. [Base Environment (docker-compose.yml)](#base-environment)
|
||||
4. [Demo Overlay (docker-compose.demo.yml)](#demo-overlay)
|
||||
5. [Development Overlay (docker-compose.dev.yml)](#development-overlay)
|
||||
6. [Test Environment (docker-compose.test.yml)](#test-environment)
|
||||
7. [Environment Variable Reference](#environment-variable-reference)
|
||||
8. [Common Operations](#common-operations)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need two things: **Docker** (the container runtime) and **Docker Compose** (an orchestration tool that ships with Docker Desktop).
|
||||
|
||||
On macOS:
|
||||
```bash
|
||||
brew install --cask docker
|
||||
```
|
||||
|
||||
On Linux (Ubuntu/Debian):
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in for group changes to take effect
|
||||
```
|
||||
|
||||
Verify the install:
|
||||
```bash
|
||||
docker --version # Docker Engine 24+ recommended
|
||||
docker compose version # Docker Compose v2+ required (note: no hyphen)
|
||||
```
|
||||
|
||||
**What Docker actually does:** Docker packages an application and all its dependencies (OS libraries, runtimes, config files) into an isolated unit called a container. When you run `docker compose up`, Docker reads a YAML file that describes multiple containers, creates a private network between them, and starts everything in the right order. Each container sees only its own filesystem and network unless you explicitly share volumes or ports.
|
||||
|
||||
**Why this matters for certctl:** Instead of installing PostgreSQL, building Go binaries, configuring the agent, and wiring everything together by hand, one command gives you the complete platform. Each compose file targets a different use case.
|
||||
|
||||
---
|
||||
|
||||
## How Docker Compose Works
|
||||
|
||||
A compose file defines **services** (containers), **networks** (how they talk to each other), and **volumes** (persistent storage). The key concepts:
|
||||
|
||||
**Services** are named containers. `certctl-server` is the API and web dashboard. `postgres` is the database. `certctl-agent` polls the server for certificate work.
|
||||
|
||||
**Depends_on + healthchecks** control startup order. The server won't start until PostgreSQL reports healthy. The agent won't start until the server reports healthy. This prevents connection errors during boot.
|
||||
|
||||
**Volumes** persist data across restarts. `postgres_data` keeps your database between `docker compose down` and `docker compose up`. Adding `-v` to `down` deletes volumes for a clean slate.
|
||||
|
||||
**Overlay files** let you layer changes. Running `docker compose -f base.yml -f overlay.yml up` merges both files. The overlay can add services, change environment variables, or mount extra volumes without editing the base.
|
||||
|
||||
**Port mapping** (`"8443:8443"`) maps host port (left) to container port (right). After startup, `http://localhost:8443` on your machine reaches the certctl server inside its container.
|
||||
|
||||
---
|
||||
|
||||
## Base Environment
|
||||
|
||||
**File:** `docker-compose.yml`
|
||||
**When to use:** Production deployments, first-time setup, or any time you want a clean dashboard with the onboarding wizard.
|
||||
|
||||
### What it runs
|
||||
|
||||
Three services on a private bridge network:
|
||||
|
||||
| Service | Image | Purpose | Ports |
|
||||
|---------|-------|---------|-------|
|
||||
| `postgres` | `postgres:16-alpine` | Database. Stores certificates, agents, jobs, audit trail, policies, discovery results. | 5432 |
|
||||
| `certctl-server` | Built from `Dockerfile` | API server + web dashboard + background scheduler. | 8443 |
|
||||
| `certctl-agent` | Built from `Dockerfile.agent` | Polls server for work, generates keys, deploys certificates, discovers existing certs. | none |
|
||||
|
||||
### Starting it
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
cd certctl
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
`--build` compiles the Go server and agent from source, including the React frontend. Without it, Docker may reuse a stale image from a previous build.
|
||||
|
||||
`-d` runs in detached mode (background). Omit it to see logs in your terminal.
|
||||
|
||||
Wait about 30 seconds, then verify:
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml ps
|
||||
# All three services should show "Up (healthy)"
|
||||
|
||||
curl http://localhost:8443/health
|
||||
# {"status":"healthy"}
|
||||
```
|
||||
|
||||
Open **http://localhost:8443** in your browser. You'll see the onboarding wizard guiding you through: connecting a CA, deploying an agent, and adding your first certificate.
|
||||
|
||||
### Service-by-service walkthrough
|
||||
|
||||
#### PostgreSQL
|
||||
|
||||
```yaml
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: certctl
|
||||
POSTGRES_USER: certctl
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-certctl}
|
||||
```
|
||||
|
||||
Alpine-based PostgreSQL 16. The `${POSTGRES_PASSWORD:-certctl}` syntax means: use the `POSTGRES_PASSWORD` environment variable from your shell if set, otherwise default to `certctl`. For production, create a `.env` file:
|
||||
|
||||
```bash
|
||||
echo 'POSTGRES_PASSWORD=your-secure-password-here' > deploy/.env
|
||||
```
|
||||
|
||||
The `volumes` section mounts 10 migration files into PostgreSQL's init directory (`/docker-entrypoint-initdb.d/`). PostgreSQL runs these SQL files in alphabetical order on first boot only. They create the schema (tables, indexes, constraints) and seed the base data (default issuer, default policy). If the `postgres_data` volume already exists with an initialized database, these scripts are skipped entirely.
|
||||
|
||||
**Expert note:** The numbered prefix pattern (`001_`, `002_`, ..., `020_`) ensures deterministic execution order. All migrations use `IF NOT EXISTS` and `ON CONFLICT DO NOTHING` for idempotency, so re-running them against an existing database is safe.
|
||||
|
||||
#### certctl Server
|
||||
|
||||
```yaml
|
||||
certctl-server:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${POSTGRES_PASSWORD:-certctl}@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
CERTCTL_AUTH_TYPE: none
|
||||
CERTCTL_KEYGEN_MODE: server
|
||||
CERTCTL_NETWORK_SCAN_ENABLED: "true"
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: ${CERTCTL_CONFIG_ENCRYPTION_KEY:-change-me-32-char-encryption-key}
|
||||
```
|
||||
|
||||
The server is the control plane. It serves the REST API, the React dashboard, runs 7 background scheduler loops (renewal, job processing, health checks, notifications, short-lived cert expiry, network scanning, digest emails), and manages the issuer/target registry.
|
||||
|
||||
Key environment variables explained:
|
||||
|
||||
- `CERTCTL_DATABASE_URL` references the `postgres` service by hostname. Docker's internal DNS resolves `postgres` to the container's IP on the bridge network. `sslmode=disable` is appropriate because traffic stays on the private Docker network.
|
||||
- `CERTCTL_AUTH_TYPE: none` disables API key authentication so you can explore immediately. For production, set `api-key` and configure `CERTCTL_AUTH_SECRET`.
|
||||
- `CERTCTL_KEYGEN_MODE: server` means the server generates private keys. This is convenient for demos but insecure for production. In production, set `agent` so keys are generated on agent machines and never transmitted.
|
||||
- `CERTCTL_CONFIG_ENCRYPTION_KEY` enables AES-256-GCM encryption for issuer and target configurations stored in the database (credentials, API keys). Without this, the dynamic configuration GUI (adding issuers/targets from the dashboard) won't encrypt sensitive fields. For production, generate a strong random key.
|
||||
- `CERTCTL_NETWORK_SCAN_ENABLED` activates the scheduler loop that probes TLS endpoints on your network to discover certificates you might not be managing.
|
||||
|
||||
**Expert note:** The healthcheck hits `GET /health` every 10 seconds with 5 retries. The `depends_on: condition: service_healthy` on the agent means Docker holds agent startup until this check passes. Resource limits (`cpus: '1.0'`, `memory: 512M`) prevent the server from consuming unbounded resources in shared environments.
|
||||
|
||||
#### certctl Agent
|
||||
|
||||
```yaml
|
||||
certctl-agent:
|
||||
depends_on:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CERTCTL_SERVER_URL: http://certctl-server:8443
|
||||
CERTCTL_API_KEY: ${CERTCTL_API_KEY:-change-me-in-production}
|
||||
CERTCTL_AGENT_NAME: docker-agent
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
CERTCTL_DISCOVERY_DIRS: /var/lib/certctl/keys
|
||||
volumes:
|
||||
- agent_keys:/var/lib/certctl/keys
|
||||
```
|
||||
|
||||
The agent is a lightweight Go binary that polls the server for pending work (certificate deployments, CSR generation requests), executes that work locally, and reports results back. It also scans configured directories for existing certificates (filesystem discovery).
|
||||
|
||||
- `CERTCTL_SERVER_URL` uses the Docker internal hostname `certctl-server`. This resolves inside the Docker network only.
|
||||
- `CERTCTL_DISCOVERY_DIRS` tells the agent which directories to scan for existing certificates. The agent walks these directories recursively, parses PEM and DER files, and reports findings to the server for triage.
|
||||
- The `agent_keys` volume persists private keys generated by the agent across container restarts. Without this volume, keys would be lost when the container stops.
|
||||
|
||||
**Expert note:** The agent's healthcheck uses `pgrep` because the agent doesn't expose an HTTP endpoint. The `restart: unless-stopped` policy means Docker automatically restarts the agent on crashes but respects manual `docker compose stop` commands.
|
||||
|
||||
### Stopping and cleaning up
|
||||
|
||||
```bash
|
||||
# Stop containers but keep data
|
||||
docker compose -f deploy/docker-compose.yml down
|
||||
|
||||
# Stop and delete all data (database, keys, volumes)
|
||||
docker compose -f deploy/docker-compose.yml down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Demo Overlay
|
||||
|
||||
**File:** `docker-compose.demo.yml`
|
||||
**When to use:** Demos, screenshots, stakeholder presentations, or any time you want a populated dashboard on first boot.
|
||||
|
||||
### What it adds
|
||||
|
||||
One line: mounts `seed_demo.sql` into PostgreSQL's init directory. This 667-line SQL file inserts 180 days of simulated operational history: teams, owners, certificates across multiple issuers, agents on different platforms, jobs with realistic timestamps, discovery scan results, audit events, policies, and profiles.
|
||||
|
||||
### Starting it
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up -d --build
|
||||
```
|
||||
|
||||
The `-f` flags are ordered: base first, overlay second. Docker merges them. The demo overlay adds the seed_demo.sql volume mount to the `postgres` service defined in the base file.
|
||||
|
||||
### What you see
|
||||
|
||||
The dashboard shows pre-populated charts: expiration heatmap with upcoming renewals, status distribution across Active/Expiring/Expired/Failed states, 30-day job trends, and issuance rates. The sidebar pages (Certificates, Agents, Discovery, Jobs, etc.) all have data to explore.
|
||||
|
||||
### Resetting demo data
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml down -v
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up -d --build
|
||||
```
|
||||
|
||||
The `down -v` deletes the `postgres_data` volume. On next boot, PostgreSQL re-runs all init scripts including the demo seed, giving you a clean starting point.
|
||||
|
||||
**Expert note:** The demo overlay is a pure data layer, not a configuration change. The server, agent, and their environment variables remain identical to the base. This means any behavior you see in the demo is exactly what the base environment produces once you populate data through normal operations.
|
||||
|
||||
---
|
||||
|
||||
## Development Overlay
|
||||
|
||||
**File:** `docker-compose.dev.yml`
|
||||
**When to use:** When you're contributing to certctl and need debug logging, database inspection, or a debugger attached to the server process.
|
||||
|
||||
### What it adds
|
||||
|
||||
| Addition | Purpose |
|
||||
|----------|---------|
|
||||
| Debug-level logging on server and agent | See every HTTP request, scheduler tick, and connector operation |
|
||||
| PgAdmin on port 5050 | Visual database browser for inspecting tables, running queries |
|
||||
| Delve debugger port 40000 | Attach a Go debugger to the running server process |
|
||||
|
||||
### Starting it
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
Omit `-d` during development so you see logs streaming in your terminal.
|
||||
|
||||
### Using PgAdmin
|
||||
|
||||
Open **http://localhost:5050** in your browser. PgAdmin is pre-configured in desktop mode (no login required). To connect to the certctl database:
|
||||
|
||||
1. Right-click "Servers" in the left panel, choose "Register" > "Server"
|
||||
2. Name: `certctl`
|
||||
3. Connection tab: Host = `postgres`, Port = `5432`, Username = `certctl`, Password = `certctl` (or whatever you set in `.env`)
|
||||
|
||||
From there you can browse all 19 tables, inspect certificate records, view audit events, check the scheduler's job queue, and run arbitrary SQL.
|
||||
|
||||
### Using the Delve debugger
|
||||
|
||||
Port 40000 is exposed for remote debugging. To use it, you'd need to modify the Dockerfile to build with debug symbols and start the server under Delve:
|
||||
|
||||
```bash
|
||||
# In Dockerfile, replace the CMD with:
|
||||
CMD ["dlv", "--listen=:40000", "--headless=true", "--api-version=2", "exec", "/app/server"]
|
||||
```
|
||||
|
||||
Then attach from your IDE (VS Code, GoLand) using remote debug configuration pointing to `localhost:40000`.
|
||||
|
||||
### Hot reload
|
||||
|
||||
The dev overlay includes commented-out volume mounts for source code directories. Uncomment them and install [air](https://github.com/cosmtrek/air) to get automatic recompilation on file changes:
|
||||
|
||||
```bash
|
||||
go install github.com/cosmtrek/air@latest
|
||||
```
|
||||
|
||||
**Expert note:** The `builds: context: ..` in the dev overlay overrides the base service's image reference, forcing a local build from the repository root. This means changes to your Go source code are compiled fresh on each `docker compose up --build`.
|
||||
|
||||
---
|
||||
|
||||
## Test Environment
|
||||
|
||||
**File:** `docker-compose.test.yml`
|
||||
**When to use:** Integration testing against real CA backends. This is a standalone environment (not an overlay) with 7 containers on a static-IP subnet.
|
||||
|
||||
### What it runs
|
||||
|
||||
| Service | IP | Purpose |
|
||||
|---------|----|---------|
|
||||
| `postgres` | 10.30.50.2 | Database (clean, no demo data) |
|
||||
| `pebble-challtestsrv` | 10.30.50.3 | DNS/HTTP challenge test server for Pebble |
|
||||
| `pebble` | 10.30.50.4 | ACME test server (simulates Let's Encrypt) |
|
||||
| `step-ca` | 10.30.50.5 | Private CA (Smallstep, JWK provisioner) |
|
||||
| `certctl-server` | 10.30.50.6 | Control plane with all issuers configured |
|
||||
| `nginx` | 10.30.50.7 | TLS target server for deployment testing |
|
||||
| `certctl-agent` | 10.30.50.8 | Agent with NGINX volume + discovery |
|
||||
|
||||
### Why static IPs?
|
||||
|
||||
Pebble (the ACME test server) validates HTTP-01 challenges by connecting to the challenge URL. It resolves domain names via `pebble-challtestsrv`, which is configured to return `10.30.50.6` (the certctl server) for all lookups. Without static IPs, container IPs would be assigned randomly on each boot, breaking the challenge validation chain.
|
||||
|
||||
The `/24` subnet (10.30.50.0/24) provides 254 usable addresses, far more than needed but standard practice for test networks.
|
||||
|
||||
### Starting it
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.test.yml up --build
|
||||
```
|
||||
|
||||
Wait for all health checks to pass (about 60 seconds for step-ca's first-run bootstrap). Then:
|
||||
|
||||
```bash
|
||||
# Dashboard with auth enabled
|
||||
open http://localhost:8443
|
||||
# API key: test-key-2026
|
||||
|
||||
# NGINX serving a self-signed placeholder
|
||||
curl -k https://localhost:8444
|
||||
```
|
||||
|
||||
### What's different from the base
|
||||
|
||||
The test environment is configured for production-like behavior:
|
||||
|
||||
- **API key auth enabled** (`CERTCTL_AUTH_TYPE: api-key`, `CERTCTL_AUTH_SECRET: test-key-2026`). Every API request needs `Authorization: Bearer test-key-2026`.
|
||||
- **Agent-side key generation** (`CERTCTL_KEYGEN_MODE: agent`). The agent generates ECDSA P-256 keys locally and submits only the CSR to the server. Private keys never leave the agent container.
|
||||
- **Three real issuers configured:**
|
||||
- **Local CA** (self-signed) for instant issuance testing
|
||||
- **ACME via Pebble** for Let's Encrypt-compatible flow testing (HTTP-01 challenges validated through the challenge test server)
|
||||
- **step-ca** for private CA testing with JWK provisioner authentication
|
||||
- **EST server enabled** (`CERTCTL_EST_ENABLED: "true"`) for RFC 7030 enrollment testing
|
||||
- **Post-deployment verification enabled** (`CERTCTL_VERIFY_DEPLOYMENT: "true"`) so the agent probes NGINX after deploying a cert and confirms the TLS fingerprint matches
|
||||
- **Dynamic config encryption enabled** (`CERTCTL_CONFIG_ENCRYPTION_KEY`) so issuer/target configs added through the GUI are encrypted at rest
|
||||
- **TLS trust bootstrapping:** The server runs a `setup-trust.sh` entrypoint that fetches Pebble's root CA from its management API and copies step-ca's root cert from a shared volume, then runs `update-ca-certificates` before starting the server binary. This is necessary because both CAs use self-signed roots that aren't in Alpine's default trust store.
|
||||
|
||||
### Running the Go integration tests
|
||||
|
||||
The test environment is designed to support the Go integration test suite at `deploy/test/integration_test.go`:
|
||||
|
||||
```bash
|
||||
# Start the environment
|
||||
docker compose -f deploy/docker-compose.test.yml up --build -d
|
||||
|
||||
# Wait for health checks
|
||||
sleep 30
|
||||
|
||||
# Run integration tests (from repo root)
|
||||
go test -tags integration -v ./deploy/test/...
|
||||
```
|
||||
|
||||
The integration tests exercise 12 phases: health, agent heartbeat, Local CA issuance, ACME issuance, renewal, step-ca issuance, revocation + CRL + OCSP, EST enrollment, S/MIME issuance, discovery, network scan, and deployment verification. PostgreSQL port 5432 is exposed so the test binary can query the database directly for assertions.
|
||||
|
||||
See [docs/test-env.md](../docs/test-env.md) for the full walkthrough and manual QA procedures.
|
||||
|
||||
### Stopping and cleaning up
|
||||
|
||||
```bash
|
||||
# Stop but keep data (volumes persist)
|
||||
docker compose -f deploy/docker-compose.test.yml down
|
||||
|
||||
# Full reset (delete step-ca bootstrap, database, agent keys, NGINX certs)
|
||||
docker compose -f deploy/docker-compose.test.yml down -v
|
||||
```
|
||||
|
||||
**Expert note:** The step-ca container auto-bootstraps on first run: generates a root CA, creates a JWK provisioner named "admin" with password "password123", and writes everything to the `stepca_data` volume. Subsequent starts reuse this volume. If you `down -v`, the next boot generates a new root CA, which means all previously issued step-ca certs become untrusted.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variable Reference
|
||||
|
||||
Every `CERTCTL_*` environment variable is read by the server's `internal/config/config.go` via `os.Getenv`. If the prefix is missing, the variable is silently ignored.
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_DATABASE_URL` | (required) | PostgreSQL connection string |
|
||||
| `CERTCTL_SERVER_HOST` | `0.0.0.0` | Listen address |
|
||||
| `CERTCTL_SERVER_PORT` | `8443` | Listen port |
|
||||
| `CERTCTL_LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error` |
|
||||
| `CERTCTL_AUTH_TYPE` | `api-key` | Auth mode: `api-key` or `none` |
|
||||
| `CERTCTL_AUTH_SECRET` | (none) | API key(s), comma-separated for rotation |
|
||||
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation: `agent` (production) or `server` (demo) |
|
||||
| `CERTCTL_CONFIG_ENCRYPTION_KEY` | (none) | AES-256-GCM key for encrypting issuer/target configs in DB |
|
||||
| `CERTCTL_NETWORK_SCAN_ENABLED` | `false` | Enable network TLS scanning scheduler loop |
|
||||
| `CERTCTL_NETWORK_SCAN_INTERVAL` | `6h` | How often the network scanner runs |
|
||||
| `CERTCTL_MAX_BODY_SIZE` | `1048576` | Max request body size in bytes (1MB) |
|
||||
| `CERTCTL_CORS_ORIGINS` | (empty) | Allowed CORS origins, comma-separated. Empty = deny all cross-origin |
|
||||
| `CERTCTL_RATE_LIMIT_RPS` | `10` | Requests per second per client |
|
||||
| `CERTCTL_RATE_LIMIT_BURST` | `20` | Burst allowance above RPS |
|
||||
|
||||
### Agent
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SERVER_URL` | (required) | Server API URL |
|
||||
| `CERTCTL_API_KEY` | (none) | API key for authenticating with server |
|
||||
| `CERTCTL_AGENT_NAME` | (hostname) | Display name in dashboard |
|
||||
| `CERTCTL_AGENT_ID` | (auto-generated) | Stable agent identifier |
|
||||
| `CERTCTL_KEYGEN_MODE` | `agent` | Must match server setting |
|
||||
| `CERTCTL_LOG_LEVEL` | `info` | Log verbosity |
|
||||
| `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Directory for private key storage (0600 perms) |
|
||||
| `CERTCTL_DISCOVERY_DIRS` | (none) | Comma-separated paths to scan for existing certs |
|
||||
|
||||
### Issuers (Server)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CERTCTL_ACME_DIRECTORY_URL` | ACME CA directory (e.g., Let's Encrypt, Pebble) |
|
||||
| `CERTCTL_ACME_EMAIL` | ACME account email |
|
||||
| `CERTCTL_ACME_CHALLENGE_TYPE` | `http-01`, `dns-01`, or `dns-persist-01` |
|
||||
| `CERTCTL_ACME_INSECURE` | Skip TLS verification for ACME CA (test only) |
|
||||
| `CERTCTL_ACME_EAB_KID` / `CERTCTL_ACME_EAB_HMAC` | External Account Binding for ZeroSSL, Google Trust Services |
|
||||
| `CERTCTL_ACME_ARI_ENABLED` | Enable RFC 9773 Renewal Information |
|
||||
| `CERTCTL_ACME_PROFILE` | ACME profile (`tlsserver`, `shortlived`) |
|
||||
| `CERTCTL_STEPCA_URL` | step-ca server URL |
|
||||
| `CERTCTL_STEPCA_ROOT_CERT` | Path to step-ca root CA cert |
|
||||
| `CERTCTL_STEPCA_PROVISIONER` | Provisioner name |
|
||||
| `CERTCTL_STEPCA_PASSWORD` | Provisioner password |
|
||||
| `CERTCTL_STEPCA_KEY_PATH` | Path to provisioner key |
|
||||
| `CERTCTL_CA_CERT_PATH` / `CERTCTL_CA_KEY_PATH` | Sub-CA mode: load CA cert+key from disk |
|
||||
| `CERTCTL_VAULT_ADDR` | Vault server address |
|
||||
| `CERTCTL_VAULT_TOKEN` | Vault auth token |
|
||||
| `CERTCTL_VAULT_MOUNT` | PKI secrets engine mount (default: `pki`) |
|
||||
| `CERTCTL_VAULT_ROLE` | PKI role name |
|
||||
| `CERTCTL_DIGICERT_API_KEY` | DigiCert CertCentral API key |
|
||||
| `CERTCTL_DIGICERT_ORG_ID` | DigiCert organization ID |
|
||||
| `CERTCTL_SECTIGO_CUSTOMER_URI` / `_LOGIN` / `_PASSWORD` | Sectigo SCM auth |
|
||||
| `CERTCTL_GOOGLE_CAS_PROJECT` / `_LOCATION` / `_CA_POOL` / `_CREDENTIALS` | Google CAS config |
|
||||
|
||||
### EST Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_EST_ENABLED` | `false` | Enable RFC 7030 EST endpoints |
|
||||
| `CERTCTL_EST_ISSUER_ID` | `iss-local` | Which issuer processes EST enrollments |
|
||||
| `CERTCTL_EST_PROFILE_ID` | (none) | Optional profile constraint |
|
||||
|
||||
### Post-Deployment Verification
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_VERIFY_DEPLOYMENT` | `false` | Agent probes TLS after deploying |
|
||||
| `CERTCTL_VERIFY_TIMEOUT` | `10s` | TLS probe timeout |
|
||||
| `CERTCTL_VERIFY_DELAY` | `2s` | Wait before probing (let service reload) |
|
||||
|
||||
### Notifications
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CERTCTL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_FROM_ADDRESS` / `_USE_TLS` | SMTP email |
|
||||
| `CERTCTL_SLACK_WEBHOOK_URL` / `_CHANNEL` / `_USERNAME` | Slack notifications |
|
||||
| `CERTCTL_TEAMS_WEBHOOK_URL` | Microsoft Teams |
|
||||
| `CERTCTL_PAGERDUTY_ROUTING_KEY` / `_SEVERITY` | PagerDuty alerts |
|
||||
| `CERTCTL_OPSGENIE_API_KEY` / `_PRIORITY` | OpsGenie alerts |
|
||||
| `CERTCTL_DIGEST_ENABLED` / `_INTERVAL` / `_RECIPIENTS` | Scheduled digest email |
|
||||
|
||||
---
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Viewing logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose -f deploy/docker-compose.yml logs -f
|
||||
|
||||
# Single service
|
||||
docker compose -f deploy/docker-compose.yml logs -f certctl-server
|
||||
|
||||
# Last 100 lines
|
||||
docker compose -f deploy/docker-compose.yml logs --tail 100 certctl-server
|
||||
```
|
||||
|
||||
### Rebuilding after code changes
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
Docker only rebuilds images that have changed source files. The `--build` flag is essential after editing Go code or frontend files.
|
||||
|
||||
### Connecting to the database directly
|
||||
|
||||
```bash
|
||||
docker exec -it certctl-postgres psql -U certctl -d certctl
|
||||
```
|
||||
|
||||
Useful queries:
|
||||
```sql
|
||||
-- Certificate inventory
|
||||
SELECT id, common_name, status, expires_at FROM managed_certificates ORDER BY expires_at;
|
||||
|
||||
-- Recent jobs
|
||||
SELECT id, type, status, certificate_id, created_at FROM jobs ORDER BY created_at DESC LIMIT 20;
|
||||
|
||||
-- Audit trail
|
||||
SELECT event_type, actor, resource_id, created_at FROM audit_events ORDER BY created_at DESC LIMIT 20;
|
||||
|
||||
-- Issuer configurations (encrypted_config is AES-256-GCM)
|
||||
SELECT id, type, source, enabled, test_status FROM issuers;
|
||||
```
|
||||
|
||||
### Checking container resource usage
|
||||
|
||||
```bash
|
||||
docker stats --no-stream
|
||||
```
|
||||
|
||||
### Upgrading
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
Migrations are idempotent (`IF NOT EXISTS`), so upgrading to a version with new schema changes is safe. PostgreSQL only runs init scripts on first boot of a fresh volume, so new migrations in an upgrade require running them manually:
|
||||
|
||||
```bash
|
||||
docker exec -i certctl-postgres psql -U certctl -d certctl < migrations/000011_new_feature.up.sql
|
||||
```
|
||||
|
||||
Or, for a clean upgrade: `down -v` and `up --build` (loses existing data).
|
||||
@@ -11,9 +11,9 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
# Verbose logging for development
|
||||
LOG_LEVEL: debug
|
||||
SERVER_HOST: 0.0.0.0
|
||||
SERVER_PORT: 8443
|
||||
CERTCTL_LOG_LEVEL: debug
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
CERTCTL_SERVER_PORT: "8443"
|
||||
volumes:
|
||||
# Mount local source for hot reload (requires air or similar)
|
||||
# Uncomment if using air or similar for hot reload:
|
||||
@@ -30,7 +30,7 @@ services:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.agent
|
||||
environment:
|
||||
LOG_LEVEL: debug
|
||||
CERTCTL_LOG_LEVEL: debug
|
||||
|
||||
# PgAdmin for database exploration
|
||||
pgadmin:
|
||||
|
||||
@@ -198,6 +198,9 @@ services:
|
||||
CERTCTL_EST_ENABLED: "true"
|
||||
CERTCTL_EST_ISSUER_ID: iss-local
|
||||
|
||||
# Dynamic issuer/target config encryption (M34/M35)
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: test-encryption-key-32chars!!
|
||||
|
||||
# Network scanning
|
||||
CERTCTL_NETWORK_SCAN_ENABLED: "true"
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ services:
|
||||
CERTCTL_AUTH_TYPE: none
|
||||
CERTCTL_KEYGEN_MODE: server # Demo uses server-side keygen; production should use "agent"
|
||||
CERTCTL_NETWORK_SCAN_ENABLED: "true" # Enable network scan GUI with seeded demo targets
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: ${CERTCTL_CONFIG_ENCRYPTION_KEY:-change-me-32-char-encryption-key} # AES-256-GCM for dynamic issuer/target config
|
||||
ports:
|
||||
- "8443:8443"
|
||||
networks:
|
||||
@@ -83,6 +84,7 @@ services:
|
||||
CERTCTL_API_KEY: ${CERTCTL_API_KEY:-change-me-in-production}
|
||||
CERTCTL_AGENT_NAME: docker-agent
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
CERTCTL_DISCOVERY_DIRS: /var/lib/certctl/keys # Agent scans this directory for existing certificates
|
||||
volumes:
|
||||
- agent_keys:/var/lib/certctl/keys
|
||||
networks:
|
||||
|
||||
@@ -18,7 +18,14 @@ metadata:
|
||||
name: {{ include "certctl.fullname" . }}
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
rules: []
|
||||
rules:
|
||||
{{- if .Values.kubernetesSecrets.enabled }}
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["get", "list", "create", "update", "patch"]
|
||||
{{- else }}
|
||||
[]
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
|
||||
@@ -381,6 +381,13 @@ serviceAccount:
|
||||
rbac:
|
||||
create: true
|
||||
|
||||
# ==============================================================================
|
||||
# Kubernetes Secrets Target Connector
|
||||
# ==============================================================================
|
||||
kubernetesSecrets:
|
||||
# Enable RBAC rules for managing TLS Secrets
|
||||
enabled: false
|
||||
|
||||
# ==============================================================================
|
||||
# Pod Disruption Budget (for HA deployments)
|
||||
# ==============================================================================
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -122,7 +122,7 @@ The server exposes a REST API under `/api/v1/` and optionally serves the web das
|
||||
|
||||
### Agents
|
||||
|
||||
Lightweight Go processes that run on or near your infrastructure. Agents generate ECDSA P-256 private keys locally, create CSRs, and submit them to the control plane for signing — private keys never leave agent infrastructure. Agents also handle certificate deployment to target systems (NGINX, Apache httpd, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS fully implemented; F5 BIG-IP interface stub only) and report job status. They communicate with the control plane via HTTP and authenticate with API keys.
|
||||
Lightweight Go processes that run on or near your infrastructure. Agents generate ECDSA P-256 private keys locally, create CSRs, and submit them to the control plane for signing — private keys never leave agent infrastructure. Agents also handle certificate deployment to target systems (NGINX, Apache httpd, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS, F5 BIG-IP, SSH, Windows Certificate Store, Java Keystore) and report job status. They communicate with the control plane via HTTP and authenticate with API keys.
|
||||
|
||||
The agent runs two background loops: a heartbeat (every 60 seconds) to signal it's alive, and a work poll (every 30 seconds) to check for actionable jobs via `GET /api/v1/agents/{id}/work`. Jobs may be `AwaitingCSR` (agent needs to generate key + submit CSR) or `Deployment` (agent needs to deploy a certificate). Private keys are stored in `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`) with 0600 permissions.
|
||||
|
||||
@@ -602,7 +602,7 @@ type Connector interface {
|
||||
|
||||
The `DeploymentRequest` struct carries the full material needed by the target system: the signed certificate, the CA chain, the agent-generated private key, target-specific configuration, and arbitrary metadata. The key field is populated by the agent from its local key store (`CERTCTL_KEY_DIR`) — it never originates from the control plane.
|
||||
|
||||
Built-in targets: **NGINX** (writes cert/chain/key files, validates with `nginx -t`, reloads), **Apache httpd** (writes cert/chain/key files, validates with `apachectl configtest`, graceful reload), **HAProxy** (combined PEM file with cert+chain+key, validates config, reloads via systemctl/signal), **Traefik** (file provider — writes cert/key to watched directory, Traefik auto-reloads), **Caddy** (dual-mode: admin API hot-reload or file-based), **F5 BIG-IP** (interface only — proxy agent + iControl REST, implementation planned), **IIS** (interface only — dual-mode: agent-local PowerShell primary + proxy agent WinRM for agentless targets, implementation planned).
|
||||
Built-in targets: **NGINX** (writes cert/chain/key files, validates with `nginx -t`, reloads), **Apache httpd** (writes cert/chain/key files, validates with `apachectl configtest`, graceful reload), **HAProxy** (combined PEM file with cert+chain+key, validates config, reloads via systemctl/signal), **Traefik** (file provider — writes cert/key to watched directory, Traefik auto-reloads), **Caddy** (dual-mode: admin API hot-reload or file-based), **Envoy** (file-based with optional SDS JSON config), **F5 BIG-IP** (proxy agent + iControl REST, transaction-based atomic SSL profile updates), **IIS** (dual-mode: agent-local PowerShell + proxy agent WinRM for agentless targets), **Postfix/Dovecot** (file write + service reload), **SSH** (agentless deployment via SSH/SFTP), **Windows Certificate Store** (PowerShell-based cert import, dual-mode local/WinRM), **Java Keystore** (PEM → PKCS#12 → keytool pipeline, JKS and PKCS12 formats).
|
||||
|
||||
After deployment, agents can perform **post-deployment TLS verification**: the agent probes the live TLS endpoint using `crypto/tls.DialWithDialer` and compares the SHA-256 fingerprint of the served certificate against what was deployed. Results are reported via `POST /api/v1/jobs/{id}/verify` and stored on the job record. Verification is best-effort — failures don't block or rollback deployments.
|
||||
|
||||
@@ -810,7 +810,7 @@ flowchart LR
|
||||
AI["AI Assistant\n(Claude, Cursor)"] -->|"stdio"| MCP["MCP Server\ncmd/mcp-server/"]
|
||||
MCP -->|"HTTP + Bearer token"| API["certctl REST API\n:8443"]
|
||||
|
||||
subgraph "78 MCP Tools"
|
||||
subgraph "MCP Tools"
|
||||
T1["Certificate CRUD"]
|
||||
T2["Agent Management"]
|
||||
T3["Job Operations"]
|
||||
@@ -824,7 +824,7 @@ flowchart LR
|
||||
|
||||
The MCP server is a stateless HTTP proxy — every MCP tool call translates to an HTTP request to the certctl REST API. It adds no new state, no new dependencies, and no new attack surface beyond what the API already exposes. Configuration is minimal: `CERTCTL_SERVER_URL` and `CERTCTL_API_KEY` environment variables.
|
||||
|
||||
The 78 tools are organized across 16 resource domains with typed input structs and `jsonschema` struct tags for automatic LLM-friendly schema generation. Binary response support handles DER CRL and OCSP endpoints.
|
||||
The tools are organized across 16 resource domains with typed input structs and `jsonschema` struct tags for automatic LLM-friendly schema generation. Binary response support handles DER CRL and OCSP endpoints.
|
||||
|
||||
## CLI Tool
|
||||
|
||||
@@ -986,6 +986,8 @@ certctl is extensively tested across eight layers with CI-enforced coverage gate
|
||||
|
||||
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs. Go: build, vet, `go test -race`, `golangci-lint` (11 linters), `govulncheck`, test with coverage, per-layer coverage threshold enforcement (service 60%, handler 60%, domain 40%, middleware 50%). Frontend: TypeScript type check, Vitest, Vite production build.
|
||||
|
||||
For detailed test procedures, smoke tests, and the release sign-off checklist, see the [Testing Guide](testing-guide.md). For setting up the Docker Compose test environment with real CA backends, see [Test Environment](test-env.md).
|
||||
|
||||
## What's Next
|
||||
|
||||
- [Quick Start](quickstart.md) — Get certctl running locally
|
||||
@@ -994,3 +996,5 @@ certctl is extensively tested across eight layers with CI-enforced coverage gate
|
||||
- [Compliance Mapping](compliance.md) — SOC 2, PCI-DSS 4.0, and NIST SP 800-57 alignment
|
||||
- [MCP Server Guide](mcp.md) — AI-native access to the API
|
||||
- [OpenAPI Spec](openapi.md) — Full API reference and SDK generation
|
||||
- [Testing Guide](testing-guide.md) — Test procedures and release sign-off
|
||||
- [Test Environment](test-env.md) — Docker Compose test environment setup
|
||||
|
||||
@@ -72,7 +72,7 @@ certctl implements tiered key storage with different protection profiles based o
|
||||
- Configured via: `CERTCTL_CA_CERT_PATH=/path/to/ca.crt` and `CERTCTL_CA_KEY_PATH=/path/to/ca.key`
|
||||
|
||||
**NIST Gap: HSM Storage**
|
||||
NIST SP 800-57 Part 1 recommends Hardware Security Module (HSM) storage for high-value keys (CA signing keys). certctl V2 uses filesystem storage on the server. HSM support is planned for V5 roadmap, enabling integration with:
|
||||
NIST SP 800-57 Part 1 recommends Hardware Security Module (HSM) storage for high-value keys (CA signing keys). certctl V2 uses filesystem storage on the server. HSM support is planned for certctl Pro (V3), enabling integration with:
|
||||
- AWS CloudHSM
|
||||
- Azure Dedicated HSM
|
||||
- Thales Luna, Gemalto SafeNet, YubiHSM (on-premises)
|
||||
@@ -285,7 +285,7 @@ All revocation events logged:
|
||||
| NIST SP 800-57 Area | Status | Coverage | Notes |
|
||||
|---|---|---|---|
|
||||
| **Key Generation** | ✅ Aligned | 100% | Agent-side ECDSA P-256 using crypto/rand; server mode flagged as demo-only |
|
||||
| **Key Storage** | ⚠️ Partially Aligned | 80% | Filesystem with 0600 perms; HSM support planned V5 |
|
||||
| **Key Storage** | ⚠️ Partially Aligned | 80% | Filesystem with 0600 perms; HSM support planned V3 Pro |
|
||||
| **Cryptoperiods** | ✅ Aligned | 100% | Profile-enforced max_ttl; threshold-based renewal alerting |
|
||||
| **Key States** | ✅ Aligned | 100% | Full lifecycle tracking with immutable audit trail |
|
||||
| **Algorithms** | ✅ Aligned | 100% | NIST-approved algorithms only; post-quantum tracking in progress |
|
||||
@@ -305,9 +305,8 @@ All revocation events logged:
|
||||
- Role-based access control (limit revocation/approval to authorized operators)
|
||||
- Bulk revocation by profile/owner/agent (fleet-level revocation policy)
|
||||
|
||||
### V5 (Planned: 2027+)
|
||||
- HSM support for CA key storage
|
||||
- PKCS#11 integration for hardware tokens
|
||||
### V3 Pro (Planned)
|
||||
- HSM support for CA key storage and agent key storage (TPM 2.0, PKCS#11)
|
||||
- FIPS 140-2/3 validated crypto module (BoringCrypto build or external FIPS library)
|
||||
- Key destruction API (explicit secure erasure of agent keys)
|
||||
- Key escrow / recovery mechanism (backup encrypted private keys for disaster recovery)
|
||||
|
||||
+1
-1
@@ -252,7 +252,7 @@ The CLI supports both table and JSON output formats (`--format table` or `--form
|
||||
|
||||
### MCP Server (AI Integration)
|
||||
|
||||
certctl includes an MCP (Model Context Protocol) server that exposes 78 MCP tools covering the REST API. This enables AI assistants like Claude, Cursor, and other MCP-compatible tools to interact with your certificate infrastructure using natural language — "show me all expiring certificates," "revoke the VPN cert," or "what agents are offline?"
|
||||
certctl includes an MCP (Model Context Protocol) server that exposes the entire REST API as MCP tools. This enables AI assistants like Claude, Cursor, and other MCP-compatible tools to interact with your certificate infrastructure using natural language — "show me all expiring certificates," "revoke the VPN cert," or "what agents are offline?"
|
||||
|
||||
The MCP server is a separate binary (`cmd/mcp-server/`) that communicates via stdio transport and acts as a stateless HTTP proxy to the certctl REST API. It requires no additional infrastructure — just point it at your certctl server URL and API key.
|
||||
|
||||
|
||||
+120
-2
@@ -11,9 +11,13 @@ Connectors extend certctl to integrate with external systems for certificate iss
|
||||
- [Built-in: ACME v2 (Let's Encrypt, Sectigo, ZeroSSL)](#built-in-acme-v2-lets-encrypt-sectigo-zerossl)
|
||||
- [Built-in: step-ca (Smallstep Private CA)](#built-in-step-ca-smallstep-private-ca)
|
||||
- [OpenSSL / Custom CA](#openssl--custom-ca)
|
||||
- [Built-in: Vault PKI](#built-in-vault-pki)
|
||||
- [Built-in: DigiCert CertCentral](#built-in-digicert-certcentral)
|
||||
- [Built-in: Sectigo SCM](#built-in-sectigo-scm)
|
||||
- [Built-in: Google CAS](#built-in-google-cas)
|
||||
- [Built-in: AWS ACM Private CA](#built-in-aws-acm-private-ca)
|
||||
- [Revocation Across Issuers](#revocation-across-issuers)
|
||||
- [EST Integration (GetCACertPEM)](#est-integration-getcacertpem)
|
||||
- [Planned Issuers](#planned-issuers)
|
||||
- [Building a Custom Issuer](#building-a-custom-issuer)
|
||||
3. [Target Connector](#target-connector)
|
||||
- [Interface](#interface-1)
|
||||
@@ -24,9 +28,12 @@ Connectors extend certctl to integrate with external systems for certificate iss
|
||||
- [Built-in: Envoy](#built-in-envoy)
|
||||
- [Built-in: Postfix / Dovecot](#built-in-postfix--dovecot)
|
||||
- [Built-in: Caddy](#built-in-caddy)
|
||||
- [F5 BIG-IP (Interface Only)](#f5-big-ip-interface-only)
|
||||
- [F5 BIG-IP (Implemented)](#f5-big-ip-implemented)
|
||||
- [IIS (Implemented, Dual-Mode)](#iis-implemented-dual-mode)
|
||||
- [SSH (Agentless Deployment)](#ssh-agentless-deployment)
|
||||
- [Windows Certificate Store](#windows-certificate-store)
|
||||
- [Java Keystore (JKS / PKCS#12)](#java-keystore-jks--pkcs12)
|
||||
- [Kubernetes Secrets](#kubernetes-secrets)
|
||||
4. [Notifier Connector](#notifier-connector)
|
||||
- [Interface](#interface-2)
|
||||
5. [Registering a Connector](#registering-a-connector)
|
||||
@@ -401,6 +408,26 @@ Google Cloud Certificate Authority Service — managed private CA on GCP. Synchr
|
||||
|
||||
Location: `internal/connector/issuer/googlecas/googlecas.go`
|
||||
|
||||
### Built-in: AWS ACM Private CA
|
||||
|
||||
AWS Certificate Manager Private Certificate Authority — managed private CA on AWS. Synchronous issuance via ACM PCA API with standard AWS credential chain (env vars, IAM roles, instance profiles, SSO).
|
||||
|
||||
| Setting | Required | Default | Description |
|
||||
|---------|----------|---------|-------------|
|
||||
| `CERTCTL_AWS_PCA_REGION` | Yes | — | AWS region (e.g., `us-east-1`) |
|
||||
| `CERTCTL_AWS_PCA_CA_ARN` | Yes | — | ARN of the ACM Private CA |
|
||||
| `CERTCTL_AWS_PCA_SIGNING_ALGORITHM` | No | `SHA256WITHRSA` | Signing algorithm |
|
||||
| `CERTCTL_AWS_PCA_VALIDITY_DAYS` | No | `365` | Certificate validity in days |
|
||||
| `CERTCTL_AWS_PCA_TEMPLATE_ARN` | No | — | Optional certificate template ARN |
|
||||
|
||||
**Supported signing algorithms:** SHA256WITHRSA, SHA384WITHRSA, SHA512WITHRSA, SHA256WITHECDSA, SHA384WITHECDSA, SHA512WITHECDSA.
|
||||
|
||||
**Authentication:** Standard AWS credential chain. The connector uses `aws-sdk-go-v2/config.LoadDefaultConfig()` which supports environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`), IAM roles (EC2/ECS), instance profiles, and SSO credentials.
|
||||
|
||||
**Note:** CRL and OCSP are managed by AWS ACM PCA directly. certctl records revocations locally and notifies AWS via the RevokeCertificate API with RFC 5280 reason mapping.
|
||||
|
||||
Location: `internal/connector/issuer/awsacmpca/awsacmpca.go`
|
||||
|
||||
### Coming in V2.2+
|
||||
|
||||
The following issuer connectors are planned for future releases:
|
||||
@@ -874,6 +901,97 @@ The SSH target connector enables agentless certificate deployment to any Linux/U
|
||||
|
||||
Location: `internal/connector/target/ssh/ssh.go`
|
||||
|
||||
### Windows Certificate Store
|
||||
|
||||
The Windows Certificate Store connector imports certificates into the Windows cert store via PowerShell, without managing IIS site bindings. Use this for non-IIS Windows services that read certificates from the cert store (Exchange, RDP, SQL Server, ADFS, etc.). Same injectable `PowerShellExecutor` pattern as the IIS connector, with optional WinRM proxy mode.
|
||||
|
||||
```json
|
||||
{
|
||||
"store_name": "My",
|
||||
"store_location": "LocalMachine",
|
||||
"friendly_name": "Production API Cert",
|
||||
"remove_expired": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `store_name` | string | `"My"` | Windows cert store name (My, Root, WebHosting, etc.) |
|
||||
| `store_location` | string | `"LocalMachine"` | `"LocalMachine"` or `"CurrentUser"` |
|
||||
| `friendly_name` | string | | Optional friendly name for the imported certificate |
|
||||
| `remove_expired` | boolean | `false` | Remove expired certs with same CN after import |
|
||||
| `mode` | string | `"local"` | `"local"` (agent-local) or `"winrm"` (remote) |
|
||||
| `winrm_host` | string | | WinRM hostname (required for winrm mode) |
|
||||
| `winrm_port` | number | 5985 | WinRM port (5985 HTTP, 5986 HTTPS) |
|
||||
| `winrm_username` | string | | WinRM username (required for winrm mode) |
|
||||
| `winrm_password` | string | | WinRM password (required for winrm mode) |
|
||||
| `winrm_https` | boolean | `false` | Use HTTPS for WinRM |
|
||||
| `winrm_insecure` | boolean | `false` | Skip TLS verification for WinRM |
|
||||
|
||||
Location: `internal/connector/target/wincertstore/wincertstore.go`
|
||||
|
||||
### Java Keystore (JKS / PKCS#12)
|
||||
|
||||
The Java Keystore connector deploys certificates to JKS or PKCS#12 keystores via the `keytool` CLI. This enables TLS cert deployment for Tomcat, Jetty, Kafka, Elasticsearch, and any JVM-based service. Flow: PEM to temp PKCS#12, then `keytool -importkeystore` into the target keystore.
|
||||
|
||||
```json
|
||||
{
|
||||
"keystore_path": "/opt/tomcat/conf/keystore.p12",
|
||||
"keystore_password": "changeit",
|
||||
"keystore_type": "PKCS12",
|
||||
"alias": "server",
|
||||
"reload_command": "systemctl restart tomcat"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `keystore_path` | string | *(required)* | Absolute path to the keystore file |
|
||||
| `keystore_password` | string | *(required)* | Keystore password |
|
||||
| `keystore_type` | string | `"PKCS12"` | `"PKCS12"` or `"JKS"` |
|
||||
| `alias` | string | `"server"` | Key entry alias in the keystore |
|
||||
| `reload_command` | string | | Optional command to run after keystore update |
|
||||
| `create_keystore` | boolean | `true` | Create keystore if it doesn't exist |
|
||||
| `keytool_path` | string | `"keytool"` | Override keytool binary path |
|
||||
|
||||
**Security:**
|
||||
- Reload commands validated against shell injection via `validation.ValidateShellCommand()`
|
||||
- Alias validated against injection (alphanumeric, hyphens, underscores only)
|
||||
- Path traversal prevention on keystore path
|
||||
- Transient PKCS#12 temp file cleaned up after import (even on error)
|
||||
|
||||
Location: `internal/connector/target/javakeystore/javakeystore.go`
|
||||
|
||||
### Kubernetes Secrets
|
||||
|
||||
The Kubernetes Secrets connector deploys certificates as `kubernetes.io/tls` Secrets, compatible with Ingress controllers (nginx-ingress, Traefik, HAProxy), service meshes (Istio, Linkerd), and any Kubernetes workload that reads TLS Secrets.
|
||||
|
||||
```json
|
||||
{
|
||||
"namespace": "production",
|
||||
"secret_name": "api-tls",
|
||||
"labels": {"app": "api-gateway"},
|
||||
"kubeconfig_path": "/home/agent/.kube/config"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `namespace` | string | *(required)* | Kubernetes namespace (DNS-1123, max 63 chars) |
|
||||
| `secret_name` | string | *(required)* | Secret name (DNS subdomain, max 253 chars) |
|
||||
| `labels` | object | | Additional labels to apply to the Secret |
|
||||
| `kubeconfig_path` | string | | Path to kubeconfig for out-of-cluster agents |
|
||||
|
||||
**Deployment modes:**
|
||||
- **In-cluster (default):** Agent runs as a Pod with a ServiceAccount. Authentication via auto-mounted token. Requires RBAC (`secrets.get`, `secrets.create`, `secrets.update`, `secrets.list`) — see Helm chart.
|
||||
- **Out-of-cluster:** Agent runs outside the cluster with `kubeconfig_path` pointing to a kubeconfig file. Useful for proxy agent pattern.
|
||||
|
||||
**Secret format:** Standard `kubernetes.io/tls` with `tls.crt` (cert + chain PEM) and `tls.key` (private key PEM). Managed labels (`app.kubernetes.io/managed-by: certctl`) and annotations (`certctl.io/deployed-at`, `certctl.io/certificate-id`) are applied automatically.
|
||||
|
||||
**Validation:** After deployment, the connector reads the Secret back and compares the certificate serial number to verify successful deployment.
|
||||
|
||||
Location: `internal/connector/target/k8ssecret/k8ssecret.go`
|
||||
|
||||
## Notifier Connector
|
||||
|
||||
Notifier connectors send alerts about certificate lifecycle events (expiration warnings, renewal success/failure, deployment status, policy violations).
|
||||
|
||||
@@ -981,7 +981,7 @@ export CERTCTL_API_KEY="test-key-123"
|
||||
|
||||
## Part 15: MCP Server for AI Integration (M18a)
|
||||
|
||||
certctl exposes 78 MCP tools covering the REST API via the Model Context Protocol (MCP), enabling seamless integration with Claude, Cursor, and other AI assistants:
|
||||
certctl exposes the full REST API via the Model Context Protocol (MCP), enabling seamless integration with Claude, Cursor, and other AI assistants:
|
||||
|
||||
```bash
|
||||
# Build the MCP server
|
||||
|
||||
+26
-23
@@ -7,7 +7,7 @@ Complete reference of all features shipped in the V2 release (as of March 2026).
|
||||
## API Surface
|
||||
|
||||
### Overview
|
||||
- **99 endpoints** across 23 resource domains under `/api/v1/` + `/.well-known/est/`
|
||||
- REST API across 23 resource domains under `/api/v1/` + `/.well-known/est/`
|
||||
- REST API with HTTP semantics (GET, POST, PUT, DELETE)
|
||||
- All endpoints require authentication by default (configurable)
|
||||
- OpenAPI 3.1 spec with full schema documentation
|
||||
@@ -1134,12 +1134,12 @@ The web dashboard is the primary operational interface for certctl. Built with *
|
||||
## Integration Interfaces
|
||||
|
||||
### MCP Server (M18a)
|
||||
**Separate binary** (`cmd/mcp-server/`) providing AI-native access to certctl via Claude, Cursor, OpenClaw. Instead of memorizing 91 API endpoints, ask your AI assistant "what certificates are expiring this week?" or "renew the API prod cert" and it translates to the right API calls.
|
||||
**Separate binary** (`cmd/mcp-server/`) providing AI-native access to certctl via Claude, Cursor, OpenClaw. Instead of memorizing API endpoints, ask your AI assistant "what certificates are expiring this week?" or "renew the API prod cert" and it translates to the right API calls.
|
||||
|
||||
- **Transport** — stdio (stdin/stdout)
|
||||
- **Protocol** — Model Context Protocol v1
|
||||
- **SDK** — Official `modelcontextprotocol/go-sdk` v1.4.1
|
||||
- **Tools** — 78 MCP tools covering all API endpoints
|
||||
- **Tools** — MCP tools covering all API endpoints
|
||||
- **Organization** — 16 resource domains (Certificates, Issuers, Targets, Agents, Jobs, etc.)
|
||||
- **Authentication** — Bearer token via `CERTCTL_API_KEY` env var
|
||||
- **Configuration** — `CERTCTL_SERVER_URL` (e.g., http://localhost:8080) + `CERTCTL_API_KEY`
|
||||
@@ -1439,8 +1439,8 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
||||
| Feature | V2 | V3 (Paid) | Status |
|
||||
|---------|----|-----------|-|
|
||||
| Certificate lifecycle (create/renew/revoke) | ✓ | ✓ | Shipped v1.0+ |
|
||||
| 4 issuer connectors (Local CA, ACME, step-ca, OpenSSL) | ✓ | ✓ | Shipped |
|
||||
| 3 target connectors (NGINX, Apache, HAProxy) | ✓ | ✓ | Shipped |
|
||||
| 9 issuer connectors (Local CA, ACME, step-ca, OpenSSL, Vault PKI, DigiCert, Sectigo, Google CAS, EST) | ✓ | ✓ | Shipped |
|
||||
| 13 target connectors (NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS, F5, Postfix, Dovecot, SSH, WinCertStore, JavaKeystore) | ✓ | ✓ | Shipped |
|
||||
| 6 notifier channels (Email, Webhook, Slack, Teams, PagerDuty, OpsGenie) | ✓ | ✓ | Shipped |
|
||||
| Agent fleet + metadata | ✓ | ✓ | Shipped |
|
||||
| Agent groups (dynamic + manual) | ✓ | ✓ | Shipped |
|
||||
@@ -1449,28 +1449,33 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
||||
| Revocation (RFC 5280, CRL, OCSP) | ✓ | ✓ | Shipped |
|
||||
| Full web dashboard | ✓ | ✓ | Shipped |
|
||||
| Observability (charts, metrics, stats) | ✓ | ✓ | Shipped |
|
||||
| REST API (91 endpoints) | ✓ | ✓ | Shipped |
|
||||
| MCP server (78 tools) | ✓ | ✓ | Shipped v2.1 |
|
||||
| CLI tool (12 subcommands) | ✓ | ✓ | Shipped |
|
||||
| REST API | ✓ | ✓ | Shipped |
|
||||
| MCP server (REST API exposed via MCP) | ✓ | ✓ | Shipped v2.1 |
|
||||
| CLI tool | ✓ | ✓ | Shipped |
|
||||
| Compliance mapping docs (SOC 2, PCI-DSS, NIST) | ✓ | ✓ | Shipped |
|
||||
| Filesystem cert discovery (M18b) | ✓ | ✓ | Shipped |
|
||||
| Network cert discovery (M21) | ✓ | ✓ | Shipped |
|
||||
| Prometheus metrics (M22) | ✓ | ✓ | Shipped |
|
||||
| Filesystem cert discovery | ✓ | ✓ | Shipped |
|
||||
| Network cert discovery | ✓ | ✓ | Shipped |
|
||||
| Prometheus metrics | ✓ | ✓ | Shipped |
|
||||
| Enhanced query API (sort, filter, cursor, fields) | ✓ | ✓ | Shipped |
|
||||
| Immutable API audit log | ✓ | ✓ | Shipped |
|
||||
| Bulk operations | ✓ | ✓ | Shipped |
|
||||
| EST server (RFC 7030) | ✓ | ✓ | Shipped |
|
||||
| Post-deployment TLS verification | ✓ | ✓ | Shipped |
|
||||
| Certificate export (PEM + PKCS#12) | ✓ | ✓ | Shipped |
|
||||
| S/MIME support (EKU-aware issuance) | ✓ | ✓ | Shipped |
|
||||
| ACME ARI (RFC 9773) | ✓ | ✓ | Shipped |
|
||||
| Scheduled certificate digest emails | ✓ | ✓ | Shipped |
|
||||
| Helm chart (Kubernetes) | ✓ | ✓ | Shipped |
|
||||
| Dynamic issuer/target configuration (GUI) | ✓ | ✓ | Shipped |
|
||||
| Onboarding wizard | ✓ | ✓ | Shipped |
|
||||
| **OIDC/SSO auth** | ✗ | ✓ | Planned V3 |
|
||||
| **RBAC (role-based access control)** | ✗ | ✓ | Planned V3 |
|
||||
| **F5 BIG-IP implementation** | Stub | ✓ | Planned V3 |
|
||||
| **IIS implementation** | Stub | ✓ | Planned V3 |
|
||||
| **NATS event bus** | ✗ | ✓ | Planned V3 |
|
||||
| **Real-time updates (SSE/WebSocket)** | ✗ | ✓ | Planned V3 |
|
||||
| **Advanced search DSL** | ✗ | ✓ | Planned V3 |
|
||||
| **Bulk operations** | ✓ | ✓ | M13 (free) |
|
||||
| **Bulk revocation** | ✗ | ✓ | Planned V3 (paid) |
|
||||
| **Bulk revocation (by profile/owner/agent)** | ✗ | ✓ | Planned V3 |
|
||||
| **Certificate health scores** | ✗ | ✓ | Planned V3 |
|
||||
| **Compliance scoring** | ✗ | ✓ | Planned V3 |
|
||||
| **DigiCert issuer** | ✗ | ✓ | Implemented (Beta) |
|
||||
| **Vault PKI issuer** | ✗ | ✓ | Implemented (Beta) |
|
||||
|
||||
---
|
||||
|
||||
@@ -1478,10 +1483,9 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| **API Endpoints** | 97 (under /api/v1/ + /.well-known/est/) |
|
||||
| **Dashboard** | Full web GUI |
|
||||
| **Issuer Connectors** | 6 (Local CA, ACME, step-ca, OpenSSL, Vault PKI, DigiCert) |
|
||||
| **Target Connectors** | 10 (9 impl: NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS, Postfix, Dovecot; 1 stub: F5) |
|
||||
| **Dashboard** | Full web GUI with operational views wired to real API data |
|
||||
| **Issuer Connectors** | 8 (Local CA, ACME, step-ca, OpenSSL, Vault PKI, DigiCert, Sectigo, Google CAS) + EST server |
|
||||
| **Target Connectors** | 13 (NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS, F5, Postfix, Dovecot, SSH, WinCertStore, JavaKeystore) |
|
||||
| **Notifier Channels** | 6 (Email, Webhook, Slack, Teams, PagerDuty, OpsGenie) |
|
||||
| **Job Types** | 4 (Issuance, Renewal, Deployment, Validation) |
|
||||
| **Job States** | 7 (Pending, AwaitingCSR, AwaitingApproval, Running, Completed, Failed, Cancelled) |
|
||||
@@ -1489,9 +1493,8 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
||||
| **Certificate States** | 8 (Pending, Active, Expiring, Expired, RenewalInProgress, Failed, Revoked, Archived) |
|
||||
| **Revocation Reason Codes** | 8 (RFC 5280 compliant) |
|
||||
| **Discovery Statuses** | 3 (Unmanaged, Managed, Dismissed) |
|
||||
| **MCP Tools** | 76 (16 resource domains) |
|
||||
| **MCP Server** | REST API exposed via MCP (16 resource domains) |
|
||||
| **CLI Subcommands** | 10 |
|
||||
| **Database Tables** | 19 |
|
||||
| **Test Suite** | Extensively tested with CI-enforced coverage gates |
|
||||
| **Environment Variables** | 41+ configuration options |
|
||||
|
||||
|
||||
+2
-2
@@ -94,7 +94,7 @@ Add certctl as an MCP server in your project's `.mcp.json`:
|
||||
|
||||
## Available Tools
|
||||
|
||||
The MCP server registers 78 tools organized across 16 resource domains:
|
||||
The MCP server exposes the full REST API organized across 16 resource domains:
|
||||
|
||||
| Domain | Tools | Examples |
|
||||
|--------|-------|---------|
|
||||
@@ -153,7 +153,7 @@ flowchart LR
|
||||
AI <-->|"stdio"| MCP
|
||||
MCP -->|"HTTP + Bearer token"| SERVER
|
||||
|
||||
MCP ~~~ TOOLS["78 tools · 16 domains\nTyped input structs"]
|
||||
MCP ~~~ TOOLS["REST API via MCP · 16 domains\nTyped input structs"]
|
||||
```
|
||||
|
||||
The MCP server is intentionally thin:
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
# QA Test Suite Guide (`qa_test.go`)
|
||||
|
||||
> **Audience:** Anyone running release QA for certctl — whether you're a first-time contributor or the maintainer cutting a release tag.
|
||||
>
|
||||
> **Companion to:** `docs/testing-guide.md` (the *what* to test). This document explains the *how* — the automated test file, what it covers, what it skips, and how to fill the gaps manually.
|
||||
|
||||
---
|
||||
|
||||
## What Is This File?
|
||||
|
||||
`deploy/test/qa_test.go` is a single Go test file (~1700 lines) that automates as much of `docs/testing-guide.md` as possible against a running certctl Docker Compose demo stack. It replaces the legacy `qa-smoke-test.sh` bash script.
|
||||
|
||||
It covers **all 54 Parts** of the testing guide:
|
||||
|
||||
- **~164 automated subtests** — API calls, database queries, source file checks, performance benchmarks
|
||||
- **11 skipped Parts** — with documented reasons (external CAs, Windows, browser-only, etc.)
|
||||
- **Remaining ~282 manual tests** — GUI flows, scheduler timing, Docker log inspection — must be done by a human following `docs/testing-guide.md`
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────┐ ┌──────────────────────────┐
|
||||
│ qa_test.go │────▶│ certctl demo stack │
|
||||
│ (//go:build qa) │ │ docker-compose.yml + │
|
||||
│ │ │ docker-compose.demo.yml │
|
||||
│ TestQA(t *testing.T) │ │ │
|
||||
│ ├─ Part01_Infra │ │ ┌─ certctl-server :8443 │
|
||||
│ ├─ Part02_Auth │ │ ├─ postgres :5432 │
|
||||
│ ├─ Part03_CertCRUD │ │ └─ certctl-agent │
|
||||
│ ├─ ... │ └──────────────────────────┘
|
||||
│ └─ Part52_HelmChart │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
Key design choices:
|
||||
|
||||
- **Build tag:** `//go:build qa` — never runs during `go test ./...` or CI. Only runs when explicitly requested.
|
||||
- **Package:** `integration_test` — same package as `integration_test.go` (which uses `//go:build integration` for the test stack). They coexist but never run together.
|
||||
- **Zero internal imports:** Uses only stdlib + `lib/pq` (from `go.mod`). All API interactions are plain HTTP. All JSON is decoded into lightweight local structs (`qaCert`, `qaJob`, etc.) — not the internal domain types.
|
||||
- **Self-cleaning:** Tests that create data use `t.Cleanup()` to delete it afterward. The seed data is not modified.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Docker Compose demo stack running:**
|
||||
```bash
|
||||
cd deploy
|
||||
docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build -d
|
||||
```
|
||||
Wait ~15 seconds for health checks to pass.
|
||||
|
||||
2. **Go 1.22+** installed (the project uses Go 1.25 in `go.mod`, but 1.22+ works for running tests).
|
||||
|
||||
3. **PostgreSQL port exposed** — the demo stack exposes port 5432 for database verification tests (table counts, schema checks).
|
||||
|
||||
4. **Repository checkout** — source file verification tests (`fileExists`, `fileContains`) read files relative to `qaRepoDir` (default: `../..` from `deploy/test/`).
|
||||
|
||||
## Running the Tests
|
||||
|
||||
### Full suite
|
||||
```bash
|
||||
cd deploy/test
|
||||
go test -tags qa -v -timeout 10m ./...
|
||||
```
|
||||
|
||||
### Single Part
|
||||
```bash
|
||||
go test -tags qa -v -run TestQA/Part03 ./...
|
||||
```
|
||||
|
||||
### Single subtest
|
||||
```bash
|
||||
go test -tags qa -v -run TestQA/Part03_CertCRUD/Create_Minimal ./...
|
||||
```
|
||||
|
||||
### With custom environment
|
||||
```bash
|
||||
CERTCTL_QA_SERVER_URL=https://staging.internal:8443 \
|
||||
CERTCTL_QA_API_KEY=my-staging-key \
|
||||
CERTCTL_QA_DB_URL=postgres://certctl:secret@db.internal:5432/certctl?sslmode=require \
|
||||
CERTCTL_QA_REPO_DIR=/path/to/certctl \
|
||||
go test -tags qa -v -timeout 10m ./...
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `CERTCTL_QA_SERVER_URL` | `http://localhost:8443` | certctl server URL |
|
||||
| `CERTCTL_QA_API_KEY` | `change-me-in-production` | API key for Bearer auth |
|
||||
| `CERTCTL_QA_DB_URL` | `postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable` | PostgreSQL connection string |
|
||||
| `CERTCTL_QA_REPO_DIR` | `../..` | Path to certctl repo root (for source file checks) |
|
||||
|
||||
## Part-by-Part Coverage Map
|
||||
|
||||
This table shows what each Part tests and what's left for manual verification.
|
||||
|
||||
| Part | Testing Guide Section | Automated Subtests | What's Automated | What's Manual |
|
||||
|------|----------------------|-------------------|-----------------|--------------|
|
||||
| 1 | Infrastructure & Deployment | 8 | Table count, health/ready endpoints, seed data counts (certs, agents, issuers, targets, policies) | Docker container health, log inspection, volume mounts |
|
||||
| 2 | Authentication & Security | 4 | No-auth 401, bad-key 401, health-no-auth 200, no private keys in API | CORS preflight, rate limiting (429 + Retry-After), TLS config |
|
||||
| 3 | Certificate Lifecycle | 10 | Create (minimal + full), get, 404, list pagination, status/issuer filters, sparse fields, update, archive | Deployment trigger, version history, certificate detail UI |
|
||||
| 4 | Renewal Workflow | 3 | Trigger renewal, 404 on nonexistent, agent work endpoint | AwaitingCSR flow, agent key generation, full issuance cycle |
|
||||
| 5 | Revocation | 5 | Revoke (default reason), already-revoked, nonexistent, invalid reason, CRL JSON | DER CRL, OCSP responder, revocation notifications |
|
||||
| 6 | Policies & Profiles | 6 | Policy CRUD (create/delete), invalid type 400, profile CRUD, list | Policy violation detection, profile enforcement on CSR |
|
||||
| 7 | Ownership & Teams | 4 | Team CRUD, owner CRUD, agent groups list | Owner notification routing, dynamic group matching |
|
||||
| 8 | Job System | 2 | List jobs, 404 on nonexistent | Job state transitions, approval workflow, cancellation |
|
||||
| 9 | Issuer Connectors | 4 | List, get detail, create (GenericCA), missing name 400 | Test connection, issuer-specific issuance flow |
|
||||
| 10 | Sub-CA Mode | SKIP | — | Requires CA cert+key on disk |
|
||||
| 11 | ACME ARI | SKIP | — | Requires ARI-capable CA |
|
||||
| 12 | Vault PKI | SKIP | — | Requires live Vault server |
|
||||
| 13 | DigiCert | SKIP | — | Requires DigiCert sandbox |
|
||||
| 14 | Target Connectors | 3 | List, create NGINX target, delete 204 | Deploy to real target, validate deployment |
|
||||
| 15–17 | Apache/HAProxy, Traefik/Caddy, IIS | — | (Covered by source checks in Parts 42–46) | Requires real services or Windows |
|
||||
| 18 | Agent Operations | 3 | Heartbeat (register), metadata check, auto-create on heartbeat | Agent binary behavior, key storage, discovery scan |
|
||||
| 19 | Agent Work Routing | 1 | Empty work for agent with no targets | Scoped job assignment, multi-target fan-out |
|
||||
| 20 | Post-Deployment Verification | 1 | 404 on nonexistent job verification | TLS probing, fingerprint comparison |
|
||||
| 21 | EST Server | 2 | CACerts (200 + content-type), CSRAttrs (200/204) | simpleenroll with CSR, simplereenroll, PKCS#7 parsing |
|
||||
| 22 | Certificate Export | 3 | PEM export, PKCS#12 export, 404 on nonexistent | Download mode, file content validation |
|
||||
| 25 | Certificate Discovery | 5 | List discovered, summary, list scan targets, create target, invalid CIDR 400 | Agent filesystem scan, claim/dismiss workflow |
|
||||
| 26 | Enhanced Query API | 4 | Sort descending, cursor pagination, time-range filter, invalid sort field | Field projection correctness, cursor token cycling |
|
||||
| 27 | Request Body Size Limits | 1 | 2MB body rejected (413/400) | Exact limit boundary (1MB) |
|
||||
| 28 | CLI | SKIP | — | Requires compiled `certctl-cli` binary |
|
||||
| 29 | MCP Server | SKIP | — | Requires compiled `mcp-server` binary + stdio |
|
||||
| 30 | Observability | 7 | Dashboard summary, certs by status, expiration timeline, job trends, issuance rate, JSON metrics (uptime + gauges), Prometheus (content-type + 4 metric names) | Chart rendering (GUI), Grafana import |
|
||||
| 31 | Notifications | 2 | List, 404 on nonexistent | Notification content, mark-read, email/Slack delivery |
|
||||
| 32 | Audit Trail | 3 | List events (≥10), PUT immutability, DELETE immutability | Actor attribution, body hash, time range filters |
|
||||
| 33 | Background Scheduler | SKIP | — | Timing-dependent; verify via Docker logs |
|
||||
| 34 | Structured Logging | SKIP | — | Requires Docker log inspection |
|
||||
| 35 | GUI Testing | SKIP | — | Requires browser |
|
||||
| 36–37 | Issuer Catalog, Frontend Audit | SKIP | — | Requires browser |
|
||||
| 38 | Error Handling | 5 | Malformed JSON, missing required field, method not allowed, UTF-8 CN, empty body | Stack trace suppression, error response format |
|
||||
| 39 | Performance | 5 | List certs < 200ms, stats < 500ms, metrics < 200ms, Prometheus < 300ms, audit < 500ms | Load testing, concurrent request handling |
|
||||
| 40 | Documentation | 8 | README, quickstart, architecture, connectors, compliance exist; migration guides exist; 8 issuer types in docs; 11 target types in docs | Content accuracy, link validity |
|
||||
| 41 | Regression | 3 | DELETE 204, per_page max fallback, network scan target seed count | `errors.Is(errors.New())` anti-pattern source scan |
|
||||
| 42 | Envoy Target | 5 | Domain type, connector file, test file, OpenAPI, agent dispatch | Envoy deployment test, SDS config |
|
||||
| 43 | Postfix/Dovecot | 3 | Domain types (Postfix + Dovecot), connector file, OpenAPI | Mail server deployment test |
|
||||
| 44 | SSH Target | 4 | Domain type, connector file, agent dispatch (`sshconn`), OpenAPI | SSH deployment test (requires target host) |
|
||||
| 45 | Windows Certificate Store | 3 | Domain type, connector file, shared certutil package | Windows deployment (requires Windows) |
|
||||
| 46 | Java Keystore | 3 | Domain type, connector file, OpenAPI | JKS deployment (requires keytool) |
|
||||
| 47 | Certificate Digest Email | 3 | Preview endpoint (200/503), service file, adapter file | SMTP delivery, HTML template rendering |
|
||||
| 48 | Dynamic Issuer Config | 4 | Crypto package exists, create ACME issuer via API, config redaction check, migration exists | Test connection flow, registry rebuild |
|
||||
| 49 | Dynamic Target Config | 2 | Create NGINX target via API, migration exists | Test connection via agent heartbeat |
|
||||
| 50 | Onboarding Wizard | 2 | Wizard component exists, docker-compose split (clean vs demo) | Wizard UI flow, step completion |
|
||||
| 51 | ACME Profile Selection | 3 | Profile module exists, frontend config, RFC 9702→9773 renumber check | Profile-aware issuance against real CA |
|
||||
| 52 | Helm Chart | 5 | Chart.yaml, values.yaml, 4 templates exist, securityContext, health probes | `helm template` rendering, `helm install` |
|
||||
| 53 | Kubernetes Secrets Target Connector (M47) | 18 | Config validation (namespace DNS-1123, secret name DNS subdomain, label keys, required fields), deployment (create/update Secret, chain concatenation, error propagation), validation (serial comparison, not-found, empty cert) | GUI target wizard KubernetesSecrets fields (namespace, secret_name, labels, kubeconfig_path), Helm RBAC toggle, TargetDetailPage type label |
|
||||
| 54 | AWS ACM Private CA Issuer Connector (M47) | 23 | Config validation (region, CA ARN regex, signing algorithm whitelist, validity_days, defaults), issuance (full flow, empty CSR, errors), renewal (reuses issuance), revocation (reason mapping, default, errors), GetOrderStatus completed, GetCACertPEM (success/chain/error), GetRenewalInfo nil | GUI issuer wizard AWSACMPCA fields (region, ca_arn, signing_algorithm, validity_days, template_arn), seed data visibility, create issuer flow |
|
||||
|
||||
**Totals:** ~164 automated subtests, 11 fully skipped Parts, ~282 manual tests remaining.
|
||||
|
||||
## Test Categories
|
||||
|
||||
The automated tests fall into four categories:
|
||||
|
||||
### 1. API Integration Tests (majority)
|
||||
Make real HTTP requests to the running server and verify status codes, response structure, and JSON field values. Examples:
|
||||
- `POST /api/v1/certificates` with valid payload → 201
|
||||
- `GET /api/v1/certificates?status=Active` → all returned certs have `status: "Active"`
|
||||
- `DELETE /api/v1/certificates/mc-qa-full` → 204
|
||||
|
||||
### 2. Database Verification Tests
|
||||
Connect directly to PostgreSQL and verify schema state:
|
||||
- Table count ≥ 19 (from migrations 000001–000010)
|
||||
- Useful for catching migration regressions
|
||||
|
||||
### 3. Source File Verification Tests
|
||||
Read files from the repo checkout and verify structure:
|
||||
- Domain types exist in `internal/domain/connector.go` (e.g., `TargetTypeEnvoy`)
|
||||
- Connector implementations exist (e.g., `internal/connector/target/envoy/envoy.go`)
|
||||
- Documentation contains expected content (all issuer/target types listed)
|
||||
- No stale RFC 9702 references (replaced by RFC 9773)
|
||||
|
||||
### 4. Performance Spot Checks
|
||||
Timed API requests with threshold assertions:
|
||||
- `GET /api/v1/certificates?per_page=15` < 200ms
|
||||
- `GET /api/v1/stats/summary` < 500ms
|
||||
- `GET /api/v1/metrics/prometheus` < 300ms
|
||||
|
||||
## What This Test Does NOT Cover
|
||||
|
||||
These gaps must be filled by manual testing per `docs/testing-guide.md`:
|
||||
|
||||
### External CA Integrations (Parts 10–13)
|
||||
- **Sub-CA mode** — requires CA cert+key files on disk
|
||||
- **ACME ARI** — requires a CA that supports RFC 9773 Renewal Information
|
||||
- **Vault PKI** — requires a running HashiCorp Vault instance
|
||||
- **DigiCert / Sectigo / Google CAS** — requires sandbox API credentials
|
||||
|
||||
### Browser/GUI Testing (Parts 35–37, 50)
|
||||
- Dashboard chart rendering (Recharts)
|
||||
- Onboarding wizard step-by-step flow
|
||||
- Issuer catalog card layout and create wizard
|
||||
- Bulk operations UI (multi-select, progress bars)
|
||||
- Discovery triage workflow
|
||||
|
||||
### Real Deployment Testing (Parts 15–17)
|
||||
- NGINX/Apache/HAProxy file write + reload
|
||||
- Traefik/Caddy file provider or API reload
|
||||
- IIS PowerShell/WinRM (requires Windows)
|
||||
- F5 BIG-IP iControl REST (requires appliance or mock)
|
||||
- SSH agentless deployment (requires target host)
|
||||
|
||||
### Agent Binary Behavior (Parts 18, 28–29)
|
||||
- Agent-side ECDSA key generation and CSR submission
|
||||
- Agent filesystem discovery scan
|
||||
- CLI tool (`certctl-cli`) — all 10 subcommands
|
||||
- MCP server (`mcp-server`) — stdio transport
|
||||
|
||||
### Timing-Dependent Tests (Parts 33–34)
|
||||
- Background scheduler loop execution (renewal, jobs, health, notifications, digest, network scan)
|
||||
- Structured logging format verification (requires Docker log parsing)
|
||||
|
||||
## How This Relates to `integration_test.go`
|
||||
|
||||
Both files live in `deploy/test/` in the same Go package (`integration_test`):
|
||||
|
||||
| | `qa_test.go` | `integration_test.go` |
|
||||
|---|---|---|
|
||||
| **Build tag** | `//go:build qa` | `//go:build integration` |
|
||||
| **Target stack** | Demo (`docker-compose.yml` + `docker-compose.demo.yml`) | Test (`docker-compose.test.yml`) |
|
||||
| **Port** | 8443 | Different (test stack config) |
|
||||
| **Seed data** | `seed_demo.sql` (32 certs, 8 agents, realistic history) | Minimal (created by tests) |
|
||||
| **CA backends** | Local CA only (demo mode) | Pebble ACME, step-ca, NGINX |
|
||||
| **Purpose** | Release QA — broad coverage, spot checks | Functional — end-to-end issuance, renewal, revocation against real CAs |
|
||||
| **Run frequency** | Before each release tag | CI on every PR |
|
||||
|
||||
They are complementary. Integration tests prove the machinery works. QA tests prove the product works at release quality.
|
||||
|
||||
## Seed Data Reference
|
||||
|
||||
The QA tests depend on `migrations/seed_demo.sql`. Key IDs used:
|
||||
|
||||
### Certificates (32 total)
|
||||
`mc-api-prod`, `mc-web-prod`, `mc-pay-prod`, `mc-dash-prod`, `mc-data-prod`, `mc-search-prod`, `mc-admin-prod`, `mc-blog-prod`, `mc-docs-prod`, `mc-status-prod`, `mc-grpc-prod`, `mc-vault-prod`, `mc-consul-prod`, `mc-shop-prod`, `mc-auth-prod`, `mc-cdn-prod`, `mc-mail-prod`, `mc-ci-prod`, `mc-legacy-prod`, `mc-old-api`, `mc-wiki-prod`, `mc-api-stg`, `mc-web-stg`, `mc-pay-stg`, `mc-api-dev`, `mc-grafana-prod`, `mc-vpn-prod`, `mc-wildcard-prod`, `mc-compromised`, `mc-edge-eu`, `mc-k8s-ingress`, `mc-smime-bob`
|
||||
|
||||
### Agents (9 total)
|
||||
`ag-web-prod`, `ag-web-staging`, `ag-lb-prod`, `ag-iis-prod`, `ag-data-prod`, `ag-edge-01`, `ag-k8s-prod`, `ag-mac-dev`, `server-scanner` (sentinel)
|
||||
|
||||
### Issuers (9 total)
|
||||
`iss-local`, `iss-acme-le`, `iss-stepca`, `iss-acme-zs`, `iss-openssl`, `iss-vault`, `iss-digicert`, `iss-sectigo`, `iss-googlecas`
|
||||
|
||||
### Targets (8 total)
|
||||
`tgt-nginx-prod`, `tgt-nginx-staging`, `tgt-haproxy-prod`, `tgt-apache-prod`, `tgt-iis-prod`, `tgt-traefik-prod`, `tgt-caddy-prod`, `tgt-nginx-data`
|
||||
|
||||
### Network Scan Targets (4 total)
|
||||
`nst-dc1-web`, `nst-dc2-apps`, `nst-dmz`, `nst-edge`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Server unreachable" on startup
|
||||
The test pings `GET /health` before running anything. If this fails:
|
||||
```bash
|
||||
# Check if the stack is running
|
||||
docker compose -f docker-compose.yml -f docker-compose.demo.yml ps
|
||||
|
||||
# Check server logs
|
||||
docker compose -f docker-compose.yml -f docker-compose.demo.yml logs certctl-server
|
||||
|
||||
# Check if the port is exposed
|
||||
curl -s http://localhost:8443/health
|
||||
```
|
||||
|
||||
### "connect to QA DB" failure
|
||||
The database tests connect directly to PostgreSQL. Ensure port 5432 is exposed:
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.demo.yml port postgres 5432
|
||||
```
|
||||
|
||||
### Performance tests flaking
|
||||
The performance thresholds (200ms, 300ms, 500ms) assume a local Docker stack. On slow CI runners or remote Docker hosts, increase the thresholds or skip Part 39:
|
||||
```bash
|
||||
go test -tags qa -v -run 'TestQA/Part(?!39)' ./...
|
||||
```
|
||||
|
||||
### Source file checks failing
|
||||
The `fileExists` and `fileContains` helpers read from `CERTCTL_QA_REPO_DIR` (default `../..`). If running from a non-standard location:
|
||||
```bash
|
||||
CERTCTL_QA_REPO_DIR=/absolute/path/to/certctl go test -tags qa -v ./...
|
||||
```
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
When a new feature ships:
|
||||
|
||||
1. **Add a Part section** in `qa_test.go` following the numbering in `docs/testing-guide.md`
|
||||
2. **API tests**: use `c.get()`, `c.post()`, `c.bodyStr()`, `c.getJSON()`, `c.timedGet()`
|
||||
3. **Source checks**: use `fileExists(t, "relative/path")` and `fileContains(t, "path", "substring")`
|
||||
4. **DB checks**: use `openQADB(t)` and `db.queryInt(t, "SELECT ...")`
|
||||
5. **Cleanup**: always use `t.Cleanup()` for data created during tests
|
||||
6. **Skip if external**: use `t.Skip("Requires X — manual test")` with a clear reason
|
||||
|
||||
## Version History
|
||||
|
||||
- **v1.0** (April 2026) — Initial release covering all 52 Parts of testing-guide.md v2.1. Replaces `qa-smoke-test.sh`.
|
||||
- **v1.1** (April 2026) — Added Parts 53–54 (M47: Kubernetes Secrets target + AWS ACM PCA issuer). 54 Parts total, ~164 automated subtests.
|
||||
+16
-1
@@ -60,6 +60,21 @@ cp deploy/.env.example deploy/.env
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
### Docker Compose Environments
|
||||
|
||||
The `deploy/` directory contains four compose files for different use cases:
|
||||
|
||||
| File | Purpose | How to run |
|
||||
|------|---------|------------|
|
||||
| `docker-compose.yml` | **Base platform.** PostgreSQL + certctl server + agent. Clean dashboard with onboarding wizard — use this for production or first-time setup. | `docker compose -f deploy/docker-compose.yml up --build` |
|
||||
| `docker-compose.demo.yml` | **Demo data override.** Layers 180 days of realistic seed data (15 certs, 5 agents, multiple issuers) onto the base. Dashboard charts and tables look populated on first boot. | `docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up --build` |
|
||||
| `docker-compose.dev.yml` | **Development override.** Adds PgAdmin (port 5050), debug-level logging, and a Delve debugger port (40000) for the server. | `docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.dev.yml up --build` |
|
||||
| `docker-compose.test.yml` | **Integration test environment.** 7 containers on a static-IP subnet: PostgreSQL, certctl server+agent, step-ca, Pebble ACME server, challenge test server, and NGINX. Runs the full issuance→deployment→verification flow against real CA backends. Standalone — does not combine with the base file. | `docker compose -f deploy/docker-compose.test.yml up --build` |
|
||||
|
||||
Override files are layered onto the base with multiple `-f` flags. The test environment is self-contained and runs independently. To reset any environment's data, add `down -v` to remove volumes.
|
||||
|
||||
For a deep dive into every service, environment variable, and networking decision, see the [Docker Compose Environments Guide](../deploy/ENVIRONMENTS.md).
|
||||
|
||||
### Kubernetes with Helm
|
||||
|
||||
For production deployments on Kubernetes, use the Helm chart:
|
||||
@@ -404,7 +419,7 @@ export CERTCTL_API_KEY="test-key-123"
|
||||
./mcp-server
|
||||
```
|
||||
|
||||
Exposes 78 MCP tools covering the REST API via stdio transport. Ask Claude: "What certificates are expiring in the next 30 days?", "Revoke the payments cert due to key compromise", "Show me the audit trail."
|
||||
Exposes the full REST API via MCP over stdio transport. Ask Claude: "What certificates are expiring in the next 30 days?", "Revoke the payments cert due to key compromise", "Show me the audit trail."
|
||||
|
||||
## Demo Data Reference
|
||||
|
||||
|
||||
+4041
-2973
File diff suppressed because it is too large
Load Diff
+10
-8
@@ -32,11 +32,13 @@ This isn't a premium feature. It's the default behavior, free. Most alternatives
|
||||
|
||||
### 2. CA-Agnostic Issuer Architecture
|
||||
|
||||
certctl works with any certificate authority, not just ACME providers. Seven issuer connectors ship today, all free:
|
||||
certctl works with any certificate authority, not just ACME providers. Nine issuer connectors ship today, all free:
|
||||
|
||||
- **ACME v2** (Let's Encrypt, ZeroSSL, Google Trust Services, Buypass) — HTTP-01, DNS-01, DNS-PERSIST-01 challenges, External Account Binding, ACME Renewal Information (RFC 9773)
|
||||
- **ACME v2** (Let's Encrypt, ZeroSSL, Google Trust Services, Buypass) — HTTP-01, DNS-01, DNS-PERSIST-01 challenges, External Account Binding, ACME Renewal Information (RFC 9773), certificate profile selection
|
||||
- **HashiCorp Vault PKI** — `/v1/{mount}/sign/{role}` API, token auth
|
||||
- **DigiCert CertCentral** — async order model, OV/EV support
|
||||
- **Sectigo SCM** — async order model, DV/OV/EV support, 3-header auth
|
||||
- **Google Cloud CAS** — Certificate Authority Service, OAuth2 service account auth, CA pool selection
|
||||
- **step-ca** (Smallstep) — native /sign API with JWK provisioner auth
|
||||
- **Local CA** — self-signed or sub-CA mode (chain to ADCS or any enterprise root)
|
||||
- **OpenSSL / Custom CA** — delegate signing to any shell script
|
||||
@@ -54,7 +56,7 @@ A reload command can exit 0 while the certificate doesn't take effect — wrong
|
||||
|
||||
The three differentiators above get the headlines, but the feature surface is wider than most paid platforms:
|
||||
|
||||
**10 deployment targets** — NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS (local PowerShell + remote WinRM), Postfix, and Dovecot. All use a pluggable connector model. The control plane never initiates outbound connections — agents poll for work, meaning certctl works behind firewalls, across network zones, and in air-gapped environments.
|
||||
**13 deployment targets** — NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS (local PowerShell + remote WinRM), F5 BIG-IP (proxy agent + iControl REST), Postfix, Dovecot, SSH (agentless), Windows Certificate Store, and Java Keystore. All use a pluggable connector model. The control plane never initiates outbound connections — agents poll for work, meaning certctl works behind firewalls, across network zones, and in air-gapped environments.
|
||||
|
||||
**Network certificate discovery** — active TLS scanning of CIDR ranges finds certificates you didn't know existed. Agents also scan local filesystems for PEM/DER files. Everything feeds into a triage workflow where you claim, dismiss, or import discovered certs into management.
|
||||
|
||||
@@ -66,9 +68,9 @@ The three differentiators above get the headlines, but the feature surface is wi
|
||||
|
||||
**Prometheus metrics** — `/api/v1/metrics/prometheus` in standard exposition format. Works with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics.
|
||||
|
||||
**MCP server** — 80 tools exposing the entire API surface for AI-assisted certificate management via Claude, Cursor, or any MCP-compatible client. No other certificate platform offers this.
|
||||
**MCP server** — the entire REST API is exposed via MCP for AI-assisted certificate management via Claude, Cursor, or any MCP-compatible client. No other certificate platform offers this.
|
||||
|
||||
**Full REST API** — 97 OpenAPI 3.1-documented operations. CLI tool with 10 subcommands. Helm chart for Kubernetes deployment. Scheduled certificate digest emails. Certificate export in PEM and PKCS#12. S/MIME support with EKU-aware issuance.
|
||||
**Full REST API** — OpenAPI 3.1-documented operations covering the entire platform. CLI tool with 10 subcommands. Helm chart for Kubernetes deployment. Scheduled certificate digest emails. Certificate export in PEM and PKCS#12. S/MIME support with EKU-aware issuance.
|
||||
|
||||
**Extensively tested** — Go backend with race detection, static analysis (golangci-lint), and vulnerability scanning (govulncheck) on every commit. CI-enforced per-layer coverage thresholds. Frontend test suite. Every push is gated.
|
||||
|
||||
@@ -80,11 +82,11 @@ ACME clients solve one slice of the problem — issuance and renewal from ACME C
|
||||
|
||||
### vs. Agent-Based SaaS
|
||||
|
||||
The closest architectural competitors use the same agent model — local key generation, CSR submission, push-based deployment. Where certctl differs: it supports 7 issuer types (not just ACME), provides CRL/OCSP/revocation infrastructure (not just issuance), includes a policy engine and network discovery, and is source-available with no certificate limit. SaaS alternatives are typically proprietary, priced per certificate ($2+/cert/month), and cap their free tiers at 3-5 certificates. certctl is free for any number of certificates, forever.
|
||||
The closest architectural competitors use the same agent model — local key generation, CSR submission, push-based deployment. Where certctl differs: it supports 9 issuer types (not just ACME), provides CRL/OCSP/revocation infrastructure (not just issuance), includes a policy engine and network discovery, and is source-available with no certificate limit. SaaS alternatives are typically proprietary, priced per certificate ($2+/cert/month), and cap their free tiers at 3-5 certificates. certctl is free for any number of certificates, forever.
|
||||
|
||||
### vs. Commercial PKI Platforms
|
||||
|
||||
On-prem or hosted commercial platforms offer broader cert type coverage (VPN certs, device auth, SCEP) and deeper CA integrations. The trade-off: no free tier, opaque pricing (often €13K+/year for 1,500 certs), proprietary codebases, and no public API documentation. certctl trades breadth of exotic cert types for full transparency — source-available code, 97-operation OpenAPI spec, and a free community edition with no artificial limits.
|
||||
On-prem or hosted commercial platforms offer broader cert type coverage (VPN certs, device auth, SCEP) and deeper CA integrations. The trade-off: no free tier, opaque pricing (often €13K+/year for 1,500 certs), proprietary codebases, and no public API documentation. certctl trades breadth of exotic cert types for full transparency — source-available code, fully documented OpenAPI spec, and a free community edition with no artificial limits.
|
||||
|
||||
### vs. Enterprise Platforms
|
||||
|
||||
@@ -100,7 +102,7 @@ certctl isn't the right tool for everyone:
|
||||
|
||||
## See It Running
|
||||
|
||||
The demo seeds 32 certificates across 7 issuers, 8 agents, 6 deployment targets, and 180 days of realistic history — jobs, audit events, discovery scans, approval workflows — so you can explore every feature immediately.
|
||||
The demo seeds certificates across multiple issuers, agents, and deployment targets with 180 days of realistic history — jobs, audit events, discovery scans, approval workflows — so you can explore every feature immediately.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
|
||||
@@ -29,10 +29,41 @@ type Config struct {
|
||||
DigiCert DigiCertConfig
|
||||
Sectigo SectigoConfig
|
||||
GoogleCAS GoogleCASConfig
|
||||
AWSACMPCA AWSACMPCAConfig
|
||||
Digest DigestConfig
|
||||
Encryption EncryptionConfig
|
||||
}
|
||||
|
||||
// AWSACMPCAConfig contains AWS ACM Private CA issuer connector configuration.
|
||||
type AWSACMPCAConfig struct {
|
||||
// Region is the AWS region where the Private CA resides (e.g., "us-east-1").
|
||||
// Required for AWS ACM PCA integration.
|
||||
// Setting: CERTCTL_AWS_PCA_REGION environment variable.
|
||||
Region string
|
||||
|
||||
// CAArn is the ARN of the ACM Private CA certificate authority.
|
||||
// Format: arn:aws:acm-pca:<region>:<account>:certificate-authority/<id>
|
||||
// Required for AWS ACM PCA integration.
|
||||
// Setting: CERTCTL_AWS_PCA_CA_ARN environment variable.
|
||||
CAArn string
|
||||
|
||||
// SigningAlgorithm is the signing algorithm for certificate issuance.
|
||||
// Valid: SHA256WITHRSA, SHA384WITHRSA, SHA512WITHRSA, SHA256WITHECDSA, SHA384WITHECDSA, SHA512WITHECDSA.
|
||||
// Default: "SHA256WITHRSA".
|
||||
// Setting: CERTCTL_AWS_PCA_SIGNING_ALGORITHM environment variable.
|
||||
SigningAlgorithm string
|
||||
|
||||
// ValidityDays is the certificate validity period in days.
|
||||
// Default: 365.
|
||||
// Setting: CERTCTL_AWS_PCA_VALIDITY_DAYS environment variable.
|
||||
ValidityDays int
|
||||
|
||||
// TemplateArn is the optional ARN of an ACM PCA certificate template.
|
||||
// Used for constrained subordinate CAs or custom certificate profiles.
|
||||
// Setting: CERTCTL_AWS_PCA_TEMPLATE_ARN environment variable.
|
||||
TemplateArn string
|
||||
}
|
||||
|
||||
// EncryptionConfig contains configuration for encrypting sensitive data at rest.
|
||||
type EncryptionConfig struct {
|
||||
// ConfigEncryptionKey is the passphrase used to derive AES-256-GCM keys for encrypting
|
||||
@@ -597,6 +628,13 @@ func Load() (*Config, error) {
|
||||
Credentials: getEnv("CERTCTL_GOOGLE_CAS_CREDENTIALS", ""),
|
||||
TTL: getEnv("CERTCTL_GOOGLE_CAS_TTL", "8760h"),
|
||||
},
|
||||
AWSACMPCA: AWSACMPCAConfig{
|
||||
Region: getEnv("CERTCTL_AWS_PCA_REGION", ""),
|
||||
CAArn: getEnv("CERTCTL_AWS_PCA_CA_ARN", ""),
|
||||
SigningAlgorithm: getEnv("CERTCTL_AWS_PCA_SIGNING_ALGORITHM", "SHA256WITHRSA"),
|
||||
ValidityDays: getEnvInt("CERTCTL_AWS_PCA_VALIDITY_DAYS", 365),
|
||||
TemplateArn: getEnv("CERTCTL_AWS_PCA_TEMPLATE_ARN", ""),
|
||||
},
|
||||
ACME: ACMEConfig{
|
||||
DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""),
|
||||
Email: getEnv("CERTCTL_ACME_EMAIL", ""),
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
// Package awsacmpca implements the issuer.Connector interface for AWS Certificate Authority Service (CAS).
|
||||
//
|
||||
// AWS ACM Private CA (ACM PCA) provides a fully managed private certificate authority
|
||||
// with certificate signing, revocation, and CRL capabilities. This connector uses the
|
||||
// AWS ACM PCA API to issue and manage certificates.
|
||||
//
|
||||
// This connector issues certificates synchronously: the IssueCertificate call returns
|
||||
// the issued certificate immediately. GetOrderStatus always returns "completed" since
|
||||
// issuance is synchronous. CRL and OCSP operations are delegated to AWS PCA's own
|
||||
// endpoints.
|
||||
//
|
||||
// Authentication: AWS credentials via the standard credential chain (environment variables,
|
||||
// IAM role, instance profile, or SSO). Configuration specifies the CA ARN, region, and
|
||||
// optional signing algorithm and validity days.
|
||||
//
|
||||
// AWS ACM PCA API used (abstracted via ACMPCAClient interface):
|
||||
//
|
||||
// IssueCertificate - Issue a certificate from a CSR
|
||||
// GetCertificate - Retrieve the issued certificate
|
||||
// RevokeCertificate - Revoke a certificate
|
||||
// GetCACertificate - Get the CA certificate chain
|
||||
package awsacmpca
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// Config represents the AWS ACM Private CA issuer connector configuration.
|
||||
type Config struct {
|
||||
// Region is the AWS region where the CA resides (e.g., "us-east-1").
|
||||
// Required. Set via CERTCTL_GOOGLE_CAS_PROJECT environment variable.
|
||||
Region string `json:"region"`
|
||||
|
||||
// CAArn is the ARN of the AWS Certificate Authority Service CA.
|
||||
// Required. Set via CERTCTL_GOOGLE_CAS_CA_ARN environment variable.
|
||||
// Example: arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012
|
||||
CAArn string `json:"ca_arn"`
|
||||
|
||||
// SigningAlgorithm is the algorithm used to sign certificates.
|
||||
// Default: "SHA256WITHRSA". Set via CERTCTL_AWS_PCA_SIGNING_ALGORITHM.
|
||||
// Valid values: SHA256WITHRSA, SHA384WITHRSA, SHA512WITHRSA,
|
||||
// SHA256WITHECDSA, SHA384WITHECDSA, SHA512WITHECDSA
|
||||
SigningAlgorithm string `json:"signing_algorithm,omitempty"`
|
||||
|
||||
// ValidityDays is the number of days the certificate is valid.
|
||||
// Default: 365. Set via CERTCTL_AWS_PCA_VALIDITY_DAYS.
|
||||
ValidityDays int `json:"validity_days,omitempty"`
|
||||
|
||||
// TemplateArn is the optional certificate template ARN for subordinate CAs with restrictions.
|
||||
// Set via CERTCTL_AWS_PCA_TEMPLATE_ARN.
|
||||
TemplateArn string `json:"template_arn,omitempty"`
|
||||
}
|
||||
|
||||
// ACMPCAClient defines the interface for interacting with AWS ACM Private CA.
|
||||
// This allows for dependency injection and testing with mock clients.
|
||||
type ACMPCAClient interface {
|
||||
// IssueCertificate issues a new certificate.
|
||||
IssueCertificate(ctx context.Context, input *IssueCertificateInput) (*IssueCertificateOutput, error)
|
||||
|
||||
// GetCertificate retrieves an issued certificate.
|
||||
GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error)
|
||||
|
||||
// RevokeCertificate revokes a certificate.
|
||||
RevokeCertificate(ctx context.Context, input *RevokeCertificateInput) error
|
||||
|
||||
// GetCACertificate retrieves the CA certificate chain.
|
||||
GetCACertificate(ctx context.Context, input *GetCACertificateInput) (*GetCACertificateOutput, error)
|
||||
}
|
||||
|
||||
// IssueCertificateInput represents the request to issue a certificate.
|
||||
type IssueCertificateInput struct {
|
||||
CAArn string
|
||||
CSR []byte // DER-encoded CSR
|
||||
SigningAlgorithm string
|
||||
ValidityDays int
|
||||
TemplateArn string
|
||||
}
|
||||
|
||||
// IssueCertificateOutput represents the response to an issue request.
|
||||
type IssueCertificateOutput struct {
|
||||
CertificateArn string
|
||||
}
|
||||
|
||||
// GetCertificateInput represents the request to retrieve a certificate.
|
||||
type GetCertificateInput struct {
|
||||
CAArn string
|
||||
CertificateArn string
|
||||
}
|
||||
|
||||
// GetCertificateOutput represents the response containing the certificate.
|
||||
type GetCertificateOutput struct {
|
||||
Certificate string // PEM-encoded certificate
|
||||
CertificateChain string // PEM-encoded certificate chain
|
||||
}
|
||||
|
||||
// RevokeCertificateInput represents the request to revoke a certificate.
|
||||
type RevokeCertificateInput struct {
|
||||
CAArn string
|
||||
CertificateSerial string
|
||||
RevocationReason string
|
||||
}
|
||||
|
||||
// GetCACertificateInput represents the request to retrieve the CA certificate.
|
||||
type GetCACertificateInput struct {
|
||||
CAArn string
|
||||
}
|
||||
|
||||
// GetCACertificateOutput represents the response containing the CA certificate.
|
||||
type GetCACertificateOutput struct {
|
||||
Certificate string // PEM-encoded CA certificate
|
||||
CertificateChain string // PEM-encoded CA chain
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for AWS ACM Private CA.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
client ACMPCAClient
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new AWS ACM Private CA connector with the given configuration and logger.
|
||||
// The real client will use the AWS SDK via the standard credential chain.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
if config != nil {
|
||||
if config.SigningAlgorithm == "" {
|
||||
config.SigningAlgorithm = "SHA256WITHRSA"
|
||||
}
|
||||
if config.ValidityDays == 0 {
|
||||
config.ValidityDays = 365
|
||||
}
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
config: config,
|
||||
client: &stubClient{}, // Placeholder; real AWS client will be injected or implemented
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithClient creates a new AWS ACM Private CA connector with a custom client.
|
||||
// Used primarily for testing with mock clients.
|
||||
func NewWithClient(config *Config, client ACMPCAClient, logger *slog.Logger) *Connector {
|
||||
if config != nil {
|
||||
if config.SigningAlgorithm == "" {
|
||||
config.SigningAlgorithm = "SHA256WITHRSA"
|
||||
}
|
||||
if config.ValidityDays == 0 {
|
||||
config.ValidityDays = 365
|
||||
}
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
config: config,
|
||||
client: client,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// stubClient is a placeholder client that returns "not implemented" errors.
|
||||
// In production, this would be replaced with a real AWS SDK client.
|
||||
type stubClient struct{}
|
||||
|
||||
func (s *stubClient) IssueCertificate(ctx context.Context, input *IssueCertificateInput) (*IssueCertificateOutput, error) {
|
||||
return nil, fmt.Errorf("AWS SDK client not initialized (stub)")
|
||||
}
|
||||
|
||||
func (s *stubClient) GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error) {
|
||||
return nil, fmt.Errorf("AWS SDK client not initialized (stub)")
|
||||
}
|
||||
|
||||
func (s *stubClient) RevokeCertificate(ctx context.Context, input *RevokeCertificateInput) error {
|
||||
return fmt.Errorf("AWS SDK client not initialized (stub)")
|
||||
}
|
||||
|
||||
func (s *stubClient) GetCACertificate(ctx context.Context, input *GetCACertificateInput) (*GetCACertificateOutput, error) {
|
||||
return nil, fmt.Errorf("AWS SDK client not initialized (stub)")
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the AWS ACM Private CA configuration is valid.
|
||||
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 AWS ACM PCA config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Region == "" {
|
||||
return fmt.Errorf("AWS region is required")
|
||||
}
|
||||
|
||||
if cfg.CAArn == "" {
|
||||
return fmt.Errorf("AWS CA ARN is required")
|
||||
}
|
||||
|
||||
// Validate ARN format: arn:aws(-[a-z]+)?:acm-pca:[a-z0-9-]+:\d{12}:certificate-authority/[a-f0-9-]+
|
||||
arnPattern := regexp.MustCompile(`^arn:aws(-[a-z]+)?:acm-pca:[a-z0-9-]+:\d{12}:certificate-authority/[a-f0-9-]+$`)
|
||||
if !arnPattern.MatchString(cfg.CAArn) {
|
||||
return fmt.Errorf("invalid CA ARN format: %s", cfg.CAArn)
|
||||
}
|
||||
|
||||
// Validate signing algorithm if provided
|
||||
if cfg.SigningAlgorithm != "" {
|
||||
validAlgorithms := map[string]bool{
|
||||
"SHA256WITHRSA": true,
|
||||
"SHA384WITHRSA": true,
|
||||
"SHA512WITHRSA": true,
|
||||
"SHA256WITHECDSA": true,
|
||||
"SHA384WITHECDSA": true,
|
||||
"SHA512WITHECDSA": true,
|
||||
}
|
||||
if !validAlgorithms[cfg.SigningAlgorithm] {
|
||||
return fmt.Errorf("invalid signing algorithm: %s", cfg.SigningAlgorithm)
|
||||
}
|
||||
} else {
|
||||
cfg.SigningAlgorithm = "SHA256WITHRSA"
|
||||
}
|
||||
|
||||
// Validate validity days if provided
|
||||
if cfg.ValidityDays < 0 {
|
||||
return fmt.Errorf("validity days must be non-negative")
|
||||
}
|
||||
if cfg.ValidityDays == 0 {
|
||||
cfg.ValidityDays = 365
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("AWS ACM Private CA configuration validated",
|
||||
"region", cfg.Region,
|
||||
"ca_arn", cfg.CAArn,
|
||||
"signing_algorithm", cfg.SigningAlgorithm,
|
||||
"validity_days", cfg.ValidityDays)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IssueCertificate issues a new certificate using AWS ACM Private CA.
|
||||
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing AWS ACM PCA issuance request",
|
||||
"common_name", request.CommonName,
|
||||
"san_count", len(request.SANs))
|
||||
|
||||
// Decode CSR from PEM
|
||||
csrBlock, _ := pem.Decode([]byte(request.CSRPEM))
|
||||
if csrBlock == nil {
|
||||
return nil, fmt.Errorf("failed to decode CSR PEM")
|
||||
}
|
||||
|
||||
// Call AWS API to issue certificate
|
||||
issueOutput, err := c.client.IssueCertificate(ctx, &IssueCertificateInput{
|
||||
CAArn: c.config.CAArn,
|
||||
CSR: csrBlock.Bytes,
|
||||
SigningAlgorithm: c.config.SigningAlgorithm,
|
||||
ValidityDays: c.config.ValidityDays,
|
||||
TemplateArn: c.config.TemplateArn,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AWS IssueCertificate failed: %w", err)
|
||||
}
|
||||
|
||||
// Retrieve the issued certificate
|
||||
getCertOutput, err := c.client.GetCertificate(ctx, &GetCertificateInput{
|
||||
CAArn: c.config.CAArn,
|
||||
CertificateArn: issueOutput.CertificateArn,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AWS GetCertificate failed: %w", err)
|
||||
}
|
||||
|
||||
if getCertOutput.Certificate == "" {
|
||||
return nil, fmt.Errorf("no certificate in AWS response")
|
||||
}
|
||||
|
||||
// Parse the certificate to extract metadata
|
||||
block, _ := pem.Decode([]byte(getCertOutput.Certificate))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("failed to decode certificate PEM from AWS")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||
}
|
||||
|
||||
// Extract serial number (hex format, uppercase)
|
||||
serial := strings.ToUpper(fmt.Sprintf("%x", cert.SerialNumber))
|
||||
|
||||
// Use certificate ARN as OrderID for revocation lookup
|
||||
orderID := issueOutput.CertificateArn
|
||||
|
||||
c.logger.Info("AWS ACM PCA certificate issued",
|
||||
"common_name", request.CommonName,
|
||||
"serial", serial,
|
||||
"not_after", cert.NotAfter)
|
||||
|
||||
return &issuer.IssuanceResult{
|
||||
CertPEM: getCertOutput.Certificate,
|
||||
ChainPEM: getCertOutput.CertificateChain,
|
||||
Serial: serial,
|
||||
NotBefore: cert.NotBefore,
|
||||
NotAfter: cert.NotAfter,
|
||||
OrderID: orderID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RenewCertificate renews a certificate by creating a new signing request.
|
||||
// For AWS ACM PCA, renewal is functionally identical to issuance (new cert signed from CSR).
|
||||
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing AWS ACM PCA renewal request",
|
||||
"common_name", request.CommonName,
|
||||
"san_count", len(request.SANs))
|
||||
|
||||
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: request.CommonName,
|
||||
SANs: request.SANs,
|
||||
CSRPEM: request.CSRPEM,
|
||||
EKUs: request.EKUs,
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate at AWS ACM Private CA.
|
||||
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||
c.logger.Info("processing AWS ACM PCA revocation request", "serial", request.Serial)
|
||||
|
||||
// Map RFC 5280 reason string to AWS reason
|
||||
reason := mapRevocationReason(request.Reason)
|
||||
|
||||
err := c.client.RevokeCertificate(ctx, &RevokeCertificateInput{
|
||||
CAArn: c.config.CAArn,
|
||||
CertificateSerial: request.Serial,
|
||||
RevocationReason: reason,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("AWS RevokeCertificate failed: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("AWS ACM PCA certificate revoked", "serial", request.Serial)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderStatus returns the status of an AWS ACM PCA order.
|
||||
// AWS ACM PCA issues synchronously, so orders are always "completed" immediately.
|
||||
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "completed",
|
||||
UpdatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateCRL is not supported because AWS ACM PCA serves CRL directly.
|
||||
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||
return nil, fmt.Errorf("CRL delegated to AWS ACM Private CA; use AWS endpoint directly")
|
||||
}
|
||||
|
||||
// SignOCSPResponse is not supported because AWS ACM PCA serves OCSP directly.
|
||||
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||
return nil, fmt.Errorf("OCSP delegated to AWS ACM Private CA; use AWS endpoint directly")
|
||||
}
|
||||
|
||||
// GetCACertPEM retrieves the CA certificate from AWS ACM Private CA.
|
||||
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
caCertOutput, err := c.client.GetCACertificate(ctx, &GetCACertificateInput{
|
||||
CAArn: c.config.CAArn,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("AWS GetCACertificate failed: %w", err)
|
||||
}
|
||||
|
||||
// Combine CA certificate and chain
|
||||
if caCertOutput.CertificateChain != "" {
|
||||
return caCertOutput.Certificate + "\n" + caCertOutput.CertificateChain, nil
|
||||
}
|
||||
|
||||
return caCertOutput.Certificate, nil
|
||||
}
|
||||
|
||||
// GetRenewalInfo returns nil, nil as AWS ACM PCA does not support ACME Renewal Information (ARI).
|
||||
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// mapRevocationReason converts RFC 5280 reason strings to AWS ACM PCA reason codes.
|
||||
func mapRevocationReason(reason *string) string {
|
||||
if reason == nil {
|
||||
return "UNSPECIFIED"
|
||||
}
|
||||
|
||||
reasonMap := map[string]string{
|
||||
"unspecified": "UNSPECIFIED",
|
||||
"keyCompromise": "KEY_COMPROMISE",
|
||||
"caCompromise": "CERTIFICATE_AUTHORITY_COMPROMISE",
|
||||
"affiliationChanged": "AFFILIATION_CHANGED",
|
||||
"superseded": "SUPERSEDED",
|
||||
"cessationOfOperation": "CESSATION_OF_OPERATION",
|
||||
"certificateHold": "CERTIFICATE_HOLD",
|
||||
"privilegeWithdrawn": "PRIVILEGE_WITHDRAWN",
|
||||
}
|
||||
|
||||
if mapped, ok := reasonMap[*reason]; ok {
|
||||
return mapped
|
||||
}
|
||||
|
||||
return "UNSPECIFIED"
|
||||
}
|
||||
|
||||
// Ensure Connector implements the issuer.Connector interface.
|
||||
var _ issuer.Connector = (*Connector)(nil)
|
||||
@@ -0,0 +1,629 @@
|
||||
package awsacmpca_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/awsacmpca"
|
||||
)
|
||||
|
||||
// mockACMPCAClient implements the ACMPCAClient interface for testing.
|
||||
type mockACMPCAClient struct {
|
||||
issueCertificateErr error
|
||||
getCertificateErr error
|
||||
revokeCertificateErr error
|
||||
getCACertificateErr error
|
||||
issuedCertPEM string
|
||||
issuedChainPEM string
|
||||
caCertPEM string
|
||||
caCertChainPEM string
|
||||
lastIssueCertificateInput *awsacmpca.IssueCertificateInput
|
||||
lastRevokeCertificateInput *awsacmpca.RevokeCertificateInput
|
||||
}
|
||||
|
||||
func (m *mockACMPCAClient) IssueCertificate(ctx context.Context, input *awsacmpca.IssueCertificateInput) (*awsacmpca.IssueCertificateOutput, error) {
|
||||
m.lastIssueCertificateInput = input
|
||||
if m.issueCertificateErr != nil {
|
||||
return nil, m.issueCertificateErr
|
||||
}
|
||||
return &awsacmpca.IssueCertificateOutput{
|
||||
CertificateArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678/certificate/abcdef123456",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockACMPCAClient) GetCertificate(ctx context.Context, input *awsacmpca.GetCertificateInput) (*awsacmpca.GetCertificateOutput, error) {
|
||||
if m.getCertificateErr != nil {
|
||||
return nil, m.getCertificateErr
|
||||
}
|
||||
return &awsacmpca.GetCertificateOutput{
|
||||
Certificate: m.issuedCertPEM,
|
||||
CertificateChain: m.issuedChainPEM,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockACMPCAClient) RevokeCertificate(ctx context.Context, input *awsacmpca.RevokeCertificateInput) error {
|
||||
m.lastRevokeCertificateInput = input
|
||||
return m.revokeCertificateErr
|
||||
}
|
||||
|
||||
func (m *mockACMPCAClient) GetCACertificate(ctx context.Context, input *awsacmpca.GetCACertificateInput) (*awsacmpca.GetCACertificateOutput, error) {
|
||||
if m.getCACertificateErr != nil {
|
||||
return nil, m.getCACertificateErr
|
||||
}
|
||||
return &awsacmpca.GetCACertificateOutput{
|
||||
Certificate: m.caCertPEM,
|
||||
CertificateChain: m.caCertChainPEM,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Helper function to generate a test certificate and CSR.
|
||||
func generateTestCertAndCSR(t *testing.T) (certPEM string, csrPEM string) {
|
||||
// Generate private key
|
||||
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate private key: %v", err)
|
||||
}
|
||||
|
||||
// Create certificate template
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate serial number: %v", err)
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
CommonName: "example.com",
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().AddDate(1, 0, 0),
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: false,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: []string{"example.com", "www.example.com"},
|
||||
}
|
||||
|
||||
// Create self-signed certificate for testing
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create certificate: %v", err)
|
||||
}
|
||||
|
||||
certPEM = string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: certDER,
|
||||
}))
|
||||
|
||||
// Create CSR
|
||||
csrTemplate := x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: "example.com",
|
||||
},
|
||||
DNSNames: []string{"example.com", "www.example.com"},
|
||||
}
|
||||
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, privKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create CSR: %v", err)
|
||||
}
|
||||
|
||||
csrPEM = string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: csrDER,
|
||||
}))
|
||||
|
||||
return certPEM, csrPEM
|
||||
}
|
||||
|
||||
func TestAWSACMPCAConnector(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("ValidateConfig_Success", func(t *testing.T) {
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
SigningAlgorithm: "SHA256WITHRSA",
|
||||
ValidityDays: 365,
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_AllOptionalFields", func(t *testing.T) {
|
||||
config := awsacmpca.Config{
|
||||
Region: "eu-west-1",
|
||||
CAArn: "arn:aws:acm-pca:eu-west-1:123456789012:certificate-authority/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
SigningAlgorithm: "SHA512WITHECDSA",
|
||||
ValidityDays: 730,
|
||||
TemplateArn: "arn:aws:acm-pca:eu-west-1:123456789012:template/WebServer",
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_InvalidJSON", func(t *testing.T) {
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
err := connector.ValidateConfig(ctx, []byte(`{invalid json}`))
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid JSON")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid AWS ACM PCA config") {
|
||||
t.Errorf("Expected config error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingRegion", func(t *testing.T) {
|
||||
config := awsacmpca.Config{
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing region")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "region is required") {
|
||||
t.Errorf("Expected region required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingCAArn", func(t *testing.T) {
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing CA ARN")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CA ARN is required") {
|
||||
t.Errorf("Expected CA ARN required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_InvalidCAArn", func(t *testing.T) {
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "not-an-arn",
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid CA ARN")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid CA ARN format") {
|
||||
t.Errorf("Expected invalid ARN error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_InvalidSigningAlgorithm", func(t *testing.T) {
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
SigningAlgorithm: "INVALID_ALGO",
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid signing algorithm")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid signing algorithm") {
|
||||
t.Errorf("Expected invalid algorithm error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_InvalidValidityDays", func(t *testing.T) {
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
ValidityDays: -1,
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for negative validity days")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "validity days must be non-negative") {
|
||||
t.Errorf("Expected validity days error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_Success", func(t *testing.T) {
|
||||
certPEM, csrPEM := generateTestCertAndCSR(t)
|
||||
|
||||
mockClient := &mockACMPCAClient{
|
||||
issuedCertPEM: certPEM,
|
||||
issuedChainPEM: certPEM, // Use same cert as chain for test
|
||||
}
|
||||
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
SigningAlgorithm: "SHA256WITHRSA",
|
||||
ValidityDays: 365,
|
||||
}
|
||||
|
||||
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
|
||||
|
||||
request := issuer.IssuanceRequest{
|
||||
CommonName: "example.com",
|
||||
SANs: []string{"www.example.com"},
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.CertPEM == "" {
|
||||
t.Fatal("Expected certificate PEM in result")
|
||||
}
|
||||
if result.Serial == "" {
|
||||
t.Fatal("Expected serial number in result")
|
||||
}
|
||||
if result.OrderID == "" {
|
||||
t.Fatal("Expected OrderID (certificate ARN) in result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_EmptyCSR", func(t *testing.T) {
|
||||
mockClient := &mockACMPCAClient{}
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
|
||||
request := issuer.IssuanceRequest{
|
||||
CommonName: "example.com",
|
||||
CSRPEM: "", // Empty CSR
|
||||
}
|
||||
|
||||
_, err := connector.IssueCertificate(ctx, request)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for empty CSR")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to decode CSR PEM") {
|
||||
t.Errorf("Expected CSR decode error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_IssueError", func(t *testing.T) {
|
||||
certPEM, csrPEM := generateTestCertAndCSR(t)
|
||||
mockClient := &mockACMPCAClient{
|
||||
issueCertificateErr: fmt.Errorf("AWS service error"),
|
||||
issuedCertPEM: certPEM,
|
||||
}
|
||||
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
|
||||
request := issuer.IssuanceRequest{
|
||||
CommonName: "example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
_, err := connector.IssueCertificate(ctx, request)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error from IssueCertificate")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "IssueCertificate failed") {
|
||||
t.Errorf("Expected issue error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_GetCertificateError", func(t *testing.T) {
|
||||
_, csrPEM := generateTestCertAndCSR(t)
|
||||
mockClient := &mockACMPCAClient{
|
||||
getCertificateErr: fmt.Errorf("AWS service error"),
|
||||
}
|
||||
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
|
||||
request := issuer.IssuanceRequest{
|
||||
CommonName: "example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
_, err := connector.IssueCertificate(ctx, request)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error from GetCertificate")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "GetCertificate failed") {
|
||||
t.Errorf("Expected get cert error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RenewCertificate_Success", func(t *testing.T) {
|
||||
certPEM, csrPEM := generateTestCertAndCSR(t)
|
||||
mockClient := &mockACMPCAClient{
|
||||
issuedCertPEM: certPEM,
|
||||
issuedChainPEM: certPEM,
|
||||
}
|
||||
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
|
||||
request := issuer.RenewalRequest{
|
||||
CommonName: "example.com",
|
||||
SANs: []string{"www.example.com"},
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.RenewCertificate(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("RenewCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.CertPEM == "" {
|
||||
t.Fatal("Expected certificate PEM in result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_Success", func(t *testing.T) {
|
||||
mockClient := &mockACMPCAClient{}
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
|
||||
reason := "keyCompromise"
|
||||
request := issuer.RevocationRequest{
|
||||
Serial: "aabbccdd123456",
|
||||
Reason: &reason,
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if mockClient.lastRevokeCertificateInput.RevocationReason != "KEY_COMPROMISE" {
|
||||
t.Errorf("Expected KEY_COMPROMISE reason, got: %s", mockClient.lastRevokeCertificateInput.RevocationReason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_WithDefaultReason", func(t *testing.T) {
|
||||
mockClient := &mockACMPCAClient{}
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
|
||||
request := issuer.RevocationRequest{
|
||||
Serial: "aabbccdd123456",
|
||||
Reason: nil,
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if mockClient.lastRevokeCertificateInput.RevocationReason != "UNSPECIFIED" {
|
||||
t.Errorf("Expected UNSPECIFIED reason, got: %s", mockClient.lastRevokeCertificateInput.RevocationReason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_Error", func(t *testing.T) {
|
||||
mockClient := &mockACMPCAClient{
|
||||
revokeCertificateErr: fmt.Errorf("AWS service error"),
|
||||
}
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
|
||||
request := issuer.RevocationRequest{
|
||||
Serial: "aabbccdd123456",
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, request)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error from RevokeCertificate")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_ReturnsCompleted", func(t *testing.T) {
|
||||
mockClient := &mockACMPCAClient{}
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
|
||||
status, err := connector.GetOrderStatus(ctx, "test-order-id")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "completed" {
|
||||
t.Errorf("Expected completed status, got: %s", status.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCACertPEM_Success", func(t *testing.T) {
|
||||
certPEM, _ := generateTestCertAndCSR(t)
|
||||
mockClient := &mockACMPCAClient{
|
||||
caCertPEM: certPEM,
|
||||
}
|
||||
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
|
||||
caPEM, err := connector.GetCACertPEM(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCACertPEM failed: %v", err)
|
||||
}
|
||||
|
||||
if caPEM == "" {
|
||||
t.Fatal("Expected CA certificate PEM")
|
||||
}
|
||||
if !strings.Contains(caPEM, "CERTIFICATE") {
|
||||
t.Errorf("Expected PEM format, got: %s", caPEM)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCACertPEM_WithChain", func(t *testing.T) {
|
||||
certPEM, _ := generateTestCertAndCSR(t)
|
||||
mockClient := &mockACMPCAClient{
|
||||
caCertPEM: certPEM,
|
||||
caCertChainPEM: certPEM,
|
||||
}
|
||||
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
|
||||
caPEM, err := connector.GetCACertPEM(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCACertPEM failed: %v", err)
|
||||
}
|
||||
|
||||
// Should contain both certificate and chain separated by newline
|
||||
if !strings.Contains(caPEM, "\n") {
|
||||
t.Fatal("Expected certificate and chain combined")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCACertPEM_Error", func(t *testing.T) {
|
||||
mockClient := &mockACMPCAClient{
|
||||
getCACertificateErr: fmt.Errorf("AWS service error"),
|
||||
}
|
||||
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
|
||||
_, err := connector.GetCACertPEM(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error from GetCACertPEM")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
|
||||
mockClient := &mockACMPCAClient{}
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
|
||||
result, err := connector.GetRenewalInfo(ctx, "cert-pem")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRenewalInfo failed: %v", err)
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
t.Fatal("Expected nil result from GetRenewalInfo")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_AppliesDefaults", func(t *testing.T) {
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
// SigningAlgorithm and ValidityDays not set
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify defaults were applied by checking the connector's config
|
||||
// Since config is private, we'll test via IssueCertificate to ensure algorithm is set
|
||||
})
|
||||
|
||||
t.Run("RevocationReason_Mapping", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"keyCompromise", "KEY_COMPROMISE"},
|
||||
{"caCompromise", "CERTIFICATE_AUTHORITY_COMPROMISE"},
|
||||
{"affiliationChanged", "AFFILIATION_CHANGED"},
|
||||
{"superseded", "SUPERSEDED"},
|
||||
{"cessationOfOperation", "CESSATION_OF_OPERATION"},
|
||||
{"privilegeWithdrawn", "PRIVILEGE_WITHDRAWN"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
mockClient := &mockACMPCAClient{}
|
||||
config := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
|
||||
reason := tc.input
|
||||
request := issuer.RevocationRequest{
|
||||
Serial: "test-serial",
|
||||
Reason: &reason,
|
||||
}
|
||||
|
||||
_ = connector.RevokeCertificate(ctx, request)
|
||||
|
||||
if mockClient.lastRevokeCertificateInput.RevocationReason != tc.expected {
|
||||
t.Errorf("For reason %q, expected %q, got %q", tc.input, tc.expected, mockClient.lastRevokeCertificateInput.RevocationReason)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/acme"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/awsacmpca"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/digicert"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/googlecas"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
||||
@@ -81,6 +82,13 @@ func NewFromConfig(issuerType string, configJSON json.RawMessage, logger *slog.L
|
||||
}
|
||||
return googlecas.New(&cfg, logger), nil
|
||||
|
||||
case "AWSACMPCA":
|
||||
var cfg awsacmpca.Config
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid AWS ACM PCA config: %w", err)
|
||||
}
|
||||
return awsacmpca.New(&cfg, logger), nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown issuer type: %q", issuerType)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Package certutil provides shared certificate utility functions for target connectors.
|
||||
// These functions handle PEM/PFX conversion, key parsing, thumbprint computation,
|
||||
// and random password generation. Extracted from the IIS connector (M39) to enable
|
||||
// reuse by Windows Certificate Store (M46) and Java Keystore (M46) connectors.
|
||||
package certutil
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
// CreatePFX converts PEM-encoded cert, key, and chain into PKCS#12 (PFX) format.
|
||||
// Uses go-pkcs12 Modern encoder with strong encryption.
|
||||
func CreatePFX(certPEM, keyPEM, chainPEM string, password string) ([]byte, error) {
|
||||
// Parse leaf certificate
|
||||
certBlock, _ := pem.Decode([]byte(certPEM))
|
||||
if certBlock == nil || certBlock.Type != "CERTIFICATE" {
|
||||
return nil, fmt.Errorf("failed to decode certificate PEM")
|
||||
}
|
||||
leafCert, err := x509.ParseCertificate(certBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse leaf certificate: %w", err)
|
||||
}
|
||||
|
||||
// Parse private key (supports PKCS#8, PKCS#1 RSA, and EC)
|
||||
keyBlock, _ := pem.Decode([]byte(keyPEM))
|
||||
if keyBlock == nil {
|
||||
return nil, fmt.Errorf("failed to decode private key PEM")
|
||||
}
|
||||
privateKey, err := ParsePrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
// Parse CA chain certificates (optional)
|
||||
var caCerts []*x509.Certificate
|
||||
if chainPEM != "" {
|
||||
rest := []byte(chainPEM)
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
caCert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse CA certificate: %w", err)
|
||||
}
|
||||
caCerts = append(caCerts, caCert)
|
||||
}
|
||||
}
|
||||
|
||||
// Encode as PKCS#12 with Modern encryption
|
||||
pfxData, err := pkcs12.Modern.Encode(privateKey, leafCert, caCerts, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode PKCS#12: %w", err)
|
||||
}
|
||||
|
||||
return pfxData, nil
|
||||
}
|
||||
|
||||
// ParsePrivateKey attempts to parse a DER-encoded private key.
|
||||
// Tries PKCS#8, PKCS#1 RSA, and EC formats in order.
|
||||
func ParsePrivateKey(der []byte) (interface{}, error) {
|
||||
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParseECPrivateKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported private key format")
|
||||
}
|
||||
|
||||
// ComputeThumbprint calculates the SHA-1 thumbprint of a PEM-encoded certificate.
|
||||
// Windows uses SHA-1 thumbprints as the primary certificate identifier.
|
||||
// Returns uppercase hex string matching Windows certutil output.
|
||||
func ComputeThumbprint(certPEM string) (string, error) {
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return "", fmt.Errorf("failed to decode certificate PEM for thumbprint")
|
||||
}
|
||||
hash := sha1.Sum(block.Bytes)
|
||||
return strings.ToUpper(hex.EncodeToString(hash[:])), nil
|
||||
}
|
||||
|
||||
// GenerateRandomPassword creates a random alphanumeric password.
|
||||
// Typically used for transient PFX encryption — the password is only used
|
||||
// between PFX creation and import, it never persists.
|
||||
func GenerateRandomPassword(length int) (string, error) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("failed to read random bytes: %w", err)
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = charset[int(b[i])%len(charset)]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// ParseCertificatePEM parses a PEM-encoded certificate and returns the x509.Certificate.
|
||||
func ParseCertificatePEM(certPEM string) (*x509.Certificate, error) {
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return nil, fmt.Errorf("failed to decode certificate PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package certutil
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// generateTestCertAndKey creates a self-signed certificate and key for testing.
|
||||
func generateTestCertAndKey() (string, string, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "test.example.com"},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
return string(certPEM), string(keyPEM), nil
|
||||
}
|
||||
|
||||
func TestCreatePFX_Success(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate test cert: %v", err)
|
||||
}
|
||||
|
||||
pfx, err := CreatePFX(certPEM, keyPEM, "", "test-password")
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePFX failed: %v", err)
|
||||
}
|
||||
if len(pfx) == 0 {
|
||||
t.Error("expected non-empty PFX data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePFX_WithChain(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate test cert: %v", err)
|
||||
}
|
||||
// Use the same cert as chain for testing purposes
|
||||
pfx, err := CreatePFX(certPEM, keyPEM, certPEM, "test-password")
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePFX with chain failed: %v", err)
|
||||
}
|
||||
if len(pfx) == 0 {
|
||||
t.Error("expected non-empty PFX data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePFX_InvalidCert(t *testing.T) {
|
||||
_, err := CreatePFX("not-a-cert", "not-a-key", "", "pw")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid cert PEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePFX_InvalidKey(t *testing.T) {
|
||||
certPEM, _, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate test cert: %v", err)
|
||||
}
|
||||
_, err = CreatePFX(certPEM, "not-a-key", "", "pw")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid key PEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKey_PKCS8(t *testing.T) {
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
der, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||
parsed, err := ParsePrivateKey(der)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey failed: %v", err)
|
||||
}
|
||||
if parsed == nil {
|
||||
t.Fatal("expected non-nil key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKey_EC(t *testing.T) {
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
der, _ := x509.MarshalECPrivateKey(key)
|
||||
parsed, err := ParsePrivateKey(der)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey failed: %v", err)
|
||||
}
|
||||
if parsed == nil {
|
||||
t.Fatal("expected non-nil key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKey_Invalid(t *testing.T) {
|
||||
_, err := ParsePrivateKey([]byte("garbage"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid key bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeThumbprint_Success(t *testing.T) {
|
||||
certPEM, _, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate test cert: %v", err)
|
||||
}
|
||||
thumb, err := ComputeThumbprint(certPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ComputeThumbprint failed: %v", err)
|
||||
}
|
||||
if len(thumb) != 40 {
|
||||
t.Errorf("expected 40-char hex thumbprint, got %d chars", len(thumb))
|
||||
}
|
||||
// Verify uppercase hex
|
||||
for _, c := range thumb {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F')) {
|
||||
t.Errorf("thumbprint contains non-uppercase-hex char: %c", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeThumbprint_InvalidPEM(t *testing.T) {
|
||||
_, err := ComputeThumbprint("not a cert")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid PEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRandomPassword(t *testing.T) {
|
||||
pw, err := GenerateRandomPassword(32)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRandomPassword failed: %v", err)
|
||||
}
|
||||
if len(pw) != 32 {
|
||||
t.Errorf("expected 32-char password, got %d", len(pw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRandomPassword_Uniqueness(t *testing.T) {
|
||||
pw1, _ := GenerateRandomPassword(32)
|
||||
pw2, _ := GenerateRandomPassword(32)
|
||||
if pw1 == pw2 {
|
||||
t.Error("two generated passwords should not be identical")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCertificatePEM_Success(t *testing.T) {
|
||||
certPEM, _, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate test cert: %v", err)
|
||||
}
|
||||
cert, err := ParseCertificatePEM(certPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificatePEM failed: %v", err)
|
||||
}
|
||||
if cert.Subject.CommonName != "test.example.com" {
|
||||
t.Errorf("expected CN test.example.com, got %s", cert.Subject.CommonName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCertificatePEM_Invalid(t *testing.T) {
|
||||
_, err := ParseCertificatePEM("not a cert")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid PEM")
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,8 @@ package iis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -18,7 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/certutil"
|
||||
)
|
||||
|
||||
// Config represents the IIS deployment target configuration.
|
||||
@@ -256,7 +251,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
||||
}
|
||||
|
||||
// Step 1: Create PFX from PEM inputs
|
||||
pfxPassword, err := generateRandomPassword(32)
|
||||
pfxPassword, err := certutil.GenerateRandomPassword(32)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to generate PFX password: %v", err)
|
||||
c.logger.Error("deployment failed", "error", err)
|
||||
@@ -267,7 +262,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
pfxData, err := createPFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword)
|
||||
pfxData, err := certutil.CreatePFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to create PFX: %v", err)
|
||||
c.logger.Error("PFX creation failed", "error", err)
|
||||
@@ -281,7 +276,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
||||
// Step 2+3: Compute thumbprint and import PFX
|
||||
// In local mode: write PFX to temp file, import via file path
|
||||
// In WinRM mode: base64-encode PFX, decode on remote side to temp file, import, clean up
|
||||
thumbprint, err := computeThumbprint(request.CertPEM)
|
||||
thumbprint, err := certutil.ComputeThumbprint(request.CertPEM)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to compute certificate thumbprint: %v", err)
|
||||
c.logger.Error("deployment failed", "error", err)
|
||||
@@ -564,97 +559,6 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
|
||||
}
|
||||
}
|
||||
|
||||
// createPFX converts PEM-encoded cert, key, and chain into PKCS#12 (PFX) format.
|
||||
// IIS requires PFX for certificate import. Uses go-pkcs12 Modern encoder
|
||||
// with strong encryption (same library used by M27 export service).
|
||||
func createPFX(certPEM, keyPEM, chainPEM string, password string) ([]byte, error) {
|
||||
// Parse leaf certificate
|
||||
certBlock, _ := pem.Decode([]byte(certPEM))
|
||||
if certBlock == nil || certBlock.Type != "CERTIFICATE" {
|
||||
return nil, fmt.Errorf("failed to decode certificate PEM")
|
||||
}
|
||||
leafCert, err := x509.ParseCertificate(certBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse leaf certificate: %w", err)
|
||||
}
|
||||
|
||||
// Parse private key (supports PKCS#8, PKCS#1 RSA, and EC)
|
||||
keyBlock, _ := pem.Decode([]byte(keyPEM))
|
||||
if keyBlock == nil {
|
||||
return nil, fmt.Errorf("failed to decode private key PEM")
|
||||
}
|
||||
privateKey, err := parsePrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
// Parse CA chain certificates (optional)
|
||||
var caCerts []*x509.Certificate
|
||||
if chainPEM != "" {
|
||||
rest := []byte(chainPEM)
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
caCert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse CA certificate: %w", err)
|
||||
}
|
||||
caCerts = append(caCerts, caCert)
|
||||
}
|
||||
}
|
||||
|
||||
// Encode as PKCS#12 with Modern encryption
|
||||
pfxData, err := pkcs12.Modern.Encode(privateKey, leafCert, caCerts, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode PKCS#12: %w", err)
|
||||
}
|
||||
|
||||
return pfxData, nil
|
||||
}
|
||||
|
||||
// parsePrivateKey attempts to parse a DER-encoded private key.
|
||||
// Tries PKCS#8, PKCS#1 RSA, and EC formats in order.
|
||||
func parsePrivateKey(der []byte) (interface{}, error) {
|
||||
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParseECPrivateKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported private key format")
|
||||
}
|
||||
|
||||
// computeThumbprint calculates the SHA-1 thumbprint of a PEM-encoded certificate.
|
||||
// IIS uses SHA-1 thumbprints as the primary certificate identifier.
|
||||
// Returns uppercase hex string matching Windows certutil output.
|
||||
func computeThumbprint(certPEM string) (string, error) {
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return "", fmt.Errorf("failed to decode certificate PEM for thumbprint")
|
||||
}
|
||||
hash := sha1.Sum(block.Bytes)
|
||||
return strings.ToUpper(hex.EncodeToString(hash[:])), nil
|
||||
}
|
||||
|
||||
// generateRandomPassword creates a random alphanumeric password for transient PFX encryption.
|
||||
// The password is only used between PFX creation and import — it never persists.
|
||||
func generateRandomPassword(length int) (string, error) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("failed to read random bytes: %w", err)
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = charset[int(b[i])%len(charset)]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
// NOTE: PFX creation, key parsing, thumbprint computation, and password generation
|
||||
// have been extracted to the shared certutil package (internal/connector/target/certutil)
|
||||
// for reuse by WinCertStore and JavaKeystore connectors.
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/certutil"
|
||||
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
@@ -672,7 +673,7 @@ func TestCreatePFX_Success(t *testing.T) {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
pfxData, err := createPFX(certPEM, keyPEM, chainPEM, "testpassword")
|
||||
pfxData, err := certutil.CreatePFX(certPEM, keyPEM, chainPEM, "testpassword")
|
||||
if err != nil {
|
||||
t.Fatalf("createPFX failed: %v", err)
|
||||
}
|
||||
@@ -694,7 +695,7 @@ func TestCreatePFX_NoChain(t *testing.T) {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
pfxData, err := createPFX(certPEM, keyPEM, "", "testpassword")
|
||||
pfxData, err := certutil.CreatePFX(certPEM, keyPEM, "", "testpassword")
|
||||
if err != nil {
|
||||
t.Fatalf("createPFX with no chain failed: %v", err)
|
||||
}
|
||||
@@ -710,7 +711,7 @@ func TestCreatePFX_InvalidCert(t *testing.T) {
|
||||
t.Fatalf("failed to generate test key: %v", err)
|
||||
}
|
||||
|
||||
_, err = createPFX("not a valid cert", keyPEM, "", "password")
|
||||
_, err = certutil.CreatePFX("not a valid cert", keyPEM, "", "password")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid cert PEM")
|
||||
}
|
||||
@@ -722,7 +723,7 @@ func TestCreatePFX_InvalidKey(t *testing.T) {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
_, err = createPFX(certPEM, "not a valid key", "", "password")
|
||||
_, err = certutil.CreatePFX(certPEM, "not a valid key", "", "password")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid key PEM")
|
||||
}
|
||||
@@ -736,7 +737,7 @@ func TestComputeThumbprint_Success(t *testing.T) {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
thumbprint, err := computeThumbprint(certPEM)
|
||||
thumbprint, err := certutil.ComputeThumbprint(certPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("computeThumbprint failed: %v", err)
|
||||
}
|
||||
@@ -753,14 +754,14 @@ func TestComputeThumbprint_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestComputeThumbprint_InvalidPEM(t *testing.T) {
|
||||
_, err := computeThumbprint("not a valid pem")
|
||||
_, err := certutil.ComputeThumbprint("not a valid pem")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid PEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeThumbprint_EmptyString(t *testing.T) {
|
||||
_, err := computeThumbprint("")
|
||||
_, err := certutil.ComputeThumbprint("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty string")
|
||||
}
|
||||
@@ -822,7 +823,7 @@ func TestValidateIISName_TooLong(t *testing.T) {
|
||||
// --- Random password generation ---
|
||||
|
||||
func TestGenerateRandomPassword(t *testing.T) {
|
||||
pw, err := generateRandomPassword(32)
|
||||
pw, err := certutil.GenerateRandomPassword(32)
|
||||
if err != nil {
|
||||
t.Fatalf("generateRandomPassword failed: %v", err)
|
||||
}
|
||||
@@ -838,7 +839,7 @@ func TestGenerateRandomPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify two passwords are different (probabilistic but reliable)
|
||||
pw2, _ := generateRandomPassword(32)
|
||||
pw2, _ := certutil.GenerateRandomPassword(32)
|
||||
if pw == pw2 {
|
||||
t.Error("two generated passwords should be different")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
// Package javakeystore implements a target connector for deploying certificates
|
||||
// to Java KeyStores (JKS/PKCS#12) via the keytool CLI. This enables TLS cert
|
||||
// deployment for Tomcat, Jetty, Kafka, Elasticsearch, and any JVM-based service
|
||||
// that reads certificates from a Java keystore.
|
||||
//
|
||||
// Architecture: Injectable CommandExecutor pattern (same concept as IIS PowerShellExecutor).
|
||||
// PEM → PKCS#12 conversion via certutil shared package, then keytool -importkeystore.
|
||||
// Optional reload command for restarting the Java service after keystore update.
|
||||
package javakeystore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/certutil"
|
||||
"github.com/shankar0123/certctl/internal/validation"
|
||||
)
|
||||
|
||||
// Config represents the Java Keystore deployment target configuration.
|
||||
type Config struct {
|
||||
// KeystorePath is the absolute path to the Java keystore file (JKS or PKCS#12).
|
||||
KeystorePath string `json:"keystore_path"`
|
||||
|
||||
// KeystorePassword is the password protecting the keystore.
|
||||
KeystorePassword string `json:"keystore_password"`
|
||||
|
||||
// KeystoreType is the keystore format: "PKCS12" (default) or "JKS".
|
||||
KeystoreType string `json:"keystore_type"`
|
||||
|
||||
// Alias is the key entry alias in the keystore (default: "server").
|
||||
Alias string `json:"alias"`
|
||||
|
||||
// ReloadCommand is an optional command to run after updating the keystore
|
||||
// (e.g., "systemctl restart tomcat"). Validated against shell injection.
|
||||
ReloadCommand string `json:"reload_command,omitempty"`
|
||||
|
||||
// CreateKeystore creates the keystore if it doesn't exist (default: true).
|
||||
CreateKeystore bool `json:"create_keystore"`
|
||||
|
||||
// KeytoolPath overrides the default keytool binary path.
|
||||
// Default: "keytool" (found via PATH).
|
||||
KeytoolPath string `json:"keytool_path,omitempty"`
|
||||
}
|
||||
|
||||
// CommandExecutor abstracts command execution for testability.
|
||||
type CommandExecutor interface {
|
||||
Execute(ctx context.Context, name string, args ...string) (string, error)
|
||||
}
|
||||
|
||||
// realExecutor calls commands on the local system.
|
||||
type realExecutor struct{}
|
||||
|
||||
func (e *realExecutor) Execute(ctx context.Context, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for Java Keystore.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
executor CommandExecutor
|
||||
}
|
||||
|
||||
// validAlias matches safe keystore alias names (alphanumeric, hyphens, underscores, dots).
|
||||
var validAlias = regexp.MustCompile(`^[a-zA-Z0-9_\-\.]+$`)
|
||||
|
||||
// validKeystoreTypes defines allowed keystore type values.
|
||||
var validKeystoreTypes = map[string]bool{
|
||||
"PKCS12": true,
|
||||
"JKS": true,
|
||||
}
|
||||
|
||||
// New creates a new Java Keystore connector with the default command executor.
|
||||
func New(cfg *Config, logger *slog.Logger) *Connector {
|
||||
if cfg == nil {
|
||||
cfg = &Config{}
|
||||
}
|
||||
applyDefaults(cfg)
|
||||
return &Connector{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
executor: &realExecutor{},
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithExecutor creates a connector with an injected executor for testing.
|
||||
func NewWithExecutor(cfg *Config, logger *slog.Logger, executor CommandExecutor) *Connector {
|
||||
if cfg == nil {
|
||||
cfg = &Config{}
|
||||
}
|
||||
applyDefaults(cfg)
|
||||
return &Connector{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
executor: executor,
|
||||
}
|
||||
}
|
||||
|
||||
func applyDefaults(cfg *Config) {
|
||||
if cfg.KeystoreType == "" {
|
||||
cfg.KeystoreType = "PKCS12"
|
||||
}
|
||||
if cfg.Alias == "" {
|
||||
cfg.Alias = "server"
|
||||
}
|
||||
if cfg.KeytoolPath == "" {
|
||||
cfg.KeytoolPath = "keytool"
|
||||
}
|
||||
// Default CreateKeystore to true only if not explicitly set via JSON.
|
||||
// Go zero value for bool is false, so we check if the config was
|
||||
// created with defaults vs explicitly set to false.
|
||||
}
|
||||
|
||||
// ValidateConfig validates the Java Keystore configuration.
|
||||
func (c *Connector) ValidateConfig(ctx context.Context, config json.RawMessage) error {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(config, &cfg); err != nil {
|
||||
return fmt.Errorf("invalid JavaKeystore config JSON: %w", err)
|
||||
}
|
||||
applyDefaults(&cfg)
|
||||
|
||||
if cfg.KeystorePath == "" {
|
||||
return fmt.Errorf("keystore_path is required")
|
||||
}
|
||||
|
||||
// Path traversal check — detect ".." in the raw path before Clean resolves it
|
||||
if strings.Contains(cfg.KeystorePath, "..") {
|
||||
return fmt.Errorf("keystore_path must not contain path traversal (..) sequences")
|
||||
}
|
||||
|
||||
if cfg.KeystorePassword == "" {
|
||||
return fmt.Errorf("keystore_password is required")
|
||||
}
|
||||
|
||||
if !validKeystoreTypes[cfg.KeystoreType] {
|
||||
return fmt.Errorf("invalid keystore_type: must be 'PKCS12' or 'JKS' (got %q)", cfg.KeystoreType)
|
||||
}
|
||||
|
||||
if !validAlias.MatchString(cfg.Alias) {
|
||||
return fmt.Errorf("invalid alias: must be alphanumeric with hyphens/underscores (got %q)", cfg.Alias)
|
||||
}
|
||||
|
||||
if cfg.ReloadCommand != "" {
|
||||
if err := validation.ValidateShellCommand(cfg.ReloadCommand); err != nil {
|
||||
return fmt.Errorf("invalid reload_command: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify parent directory exists for keystore path
|
||||
dir := filepath.Dir(cfg.KeystorePath)
|
||||
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||
return fmt.Errorf("keystore directory does not exist: %s", dir)
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate imports a certificate and key into the Java Keystore.
|
||||
// Flow: PEM → PKCS#12 temp file → keytool -importkeystore → cleanup temp → optional reload
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
if request.KeyPEM == "" {
|
||||
return nil, fmt.Errorf("private key is required for Java Keystore import")
|
||||
}
|
||||
|
||||
c.logger.Info("deploying certificate to Java Keystore",
|
||||
"keystore", c.config.KeystorePath,
|
||||
"alias", c.config.Alias,
|
||||
"type", c.config.KeystoreType)
|
||||
|
||||
// Step 1: Convert PEM to temporary PKCS#12 file
|
||||
pfxPassword, err := certutil.GenerateRandomPassword(32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate temp PFX password: %w", err)
|
||||
}
|
||||
|
||||
pfxData, err := certutil.CreatePFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp PFX: %w", err)
|
||||
}
|
||||
|
||||
// Write PFX to temp file
|
||||
tmpFile, err := os.CreateTemp("", "certctl-jks-*.p12")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp PFX file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := tmpFile.Write(pfxData); err != nil {
|
||||
tmpFile.Close()
|
||||
return nil, fmt.Errorf("write temp PFX file: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Step 2: Delete existing alias if keystore exists (keytool -delete)
|
||||
if _, err := os.Stat(c.config.KeystorePath); err == nil {
|
||||
deleteArgs := []string{
|
||||
"-delete",
|
||||
"-alias", c.config.Alias,
|
||||
"-keystore", c.config.KeystorePath,
|
||||
"-storepass", c.config.KeystorePassword,
|
||||
"-storetype", c.config.KeystoreType,
|
||||
"-noprompt",
|
||||
}
|
||||
// Ignore error — alias may not exist yet
|
||||
c.executor.Execute(ctx, c.config.KeytoolPath, deleteArgs...)
|
||||
}
|
||||
|
||||
// Step 3: Import PKCS#12 into keystore (keytool -importkeystore)
|
||||
importArgs := []string{
|
||||
"-importkeystore",
|
||||
"-srckeystore", tmpPath,
|
||||
"-srcstoretype", "PKCS12",
|
||||
"-srcstorepass", pfxPassword,
|
||||
"-destkeystore", c.config.KeystorePath,
|
||||
"-deststoretype", c.config.KeystoreType,
|
||||
"-deststorepass", c.config.KeystorePassword,
|
||||
"-destalias", c.config.Alias,
|
||||
"-srcalias", "1", // go-pkcs12 uses alias "1" by default
|
||||
"-noprompt",
|
||||
}
|
||||
|
||||
output, err := c.executor.Execute(ctx, c.config.KeytoolPath, importArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keytool import failed: %s: %w", output, err)
|
||||
}
|
||||
|
||||
// Step 4: Compute thumbprint for verification
|
||||
thumbprint, err := certutil.ComputeThumbprint(request.CertPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compute thumbprint: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Optional reload command
|
||||
if c.config.ReloadCommand != "" {
|
||||
output, err := c.executor.Execute(ctx, "sh", "-c", c.config.ReloadCommand)
|
||||
if err != nil {
|
||||
c.logger.Warn("reload command failed (non-fatal)", "error", err, "output", output)
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.Info("certificate imported to Java Keystore",
|
||||
"keystore", c.config.KeystorePath,
|
||||
"alias", c.config.Alias,
|
||||
"thumbprint", thumbprint)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: c.config.KeystorePath,
|
||||
DeploymentID: thumbprint,
|
||||
Message: fmt.Sprintf("Certificate imported to %s (alias: %s, thumbprint: %s)", c.config.KeystorePath, c.config.Alias, thumbprint),
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"thumbprint": thumbprint,
|
||||
"alias": c.config.Alias,
|
||||
"keystore_type": c.config.KeystoreType,
|
||||
"keystore_path": c.config.KeystorePath,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that a certificate exists in the Java Keystore
|
||||
// by running keytool -list and checking the alias.
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
listArgs := []string{
|
||||
"-list",
|
||||
"-alias", c.config.Alias,
|
||||
"-keystore", c.config.KeystorePath,
|
||||
"-storepass", c.config.KeystorePassword,
|
||||
"-storetype", c.config.KeystoreType,
|
||||
"-v",
|
||||
}
|
||||
|
||||
output, err := c.executor.Execute(ctx, c.config.KeytoolPath, listArgs...)
|
||||
if err != nil {
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
Message: fmt.Sprintf("keytool list failed: %s", output),
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("keytool list failed: %w", err)
|
||||
}
|
||||
|
||||
// Check if the alias exists in the output
|
||||
if !strings.Contains(output, c.config.Alias) {
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
Message: fmt.Sprintf("alias %q not found in keystore", c.config.Alias),
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("alias %q not found in keystore %s", c.config.Alias, c.config.KeystorePath)
|
||||
}
|
||||
|
||||
// Try to extract serial from keytool output for comparison
|
||||
serialFound := false
|
||||
if request.Serial != "" {
|
||||
normalizedSerial := strings.ReplaceAll(strings.ToUpper(request.Serial), ":", "")
|
||||
serialFound = strings.Contains(strings.ToUpper(output), normalizedSerial)
|
||||
}
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: c.config.KeystorePath,
|
||||
Message: fmt.Sprintf("Certificate found in keystore (alias: %s, serial_match: %v)", c.config.Alias, serialFound),
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"alias": c.config.Alias,
|
||||
"serial_match": fmt.Sprintf("%v", serialFound),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure Connector implements target.Connector.
|
||||
var _ target.Connector = (*Connector)(nil)
|
||||
@@ -0,0 +1,531 @@
|
||||
package javakeystore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
|
||||
// mockExecutor records commands and returns configurable responses.
|
||||
type mockExecutor struct {
|
||||
calls []mockCall
|
||||
responses []mockResponse
|
||||
callIndex int
|
||||
}
|
||||
|
||||
type mockCall struct {
|
||||
Name string
|
||||
Args []string
|
||||
}
|
||||
|
||||
type mockResponse struct {
|
||||
Output string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (m *mockExecutor) Execute(ctx context.Context, name string, args ...string) (string, error) {
|
||||
m.calls = append(m.calls, mockCall{Name: name, Args: args})
|
||||
idx := m.callIndex
|
||||
m.callIndex++
|
||||
if idx < len(m.responses) {
|
||||
return m.responses[idx].Output, m.responses[idx].Err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// generateTestCertAndKey creates a self-signed certificate and key for testing.
|
||||
func generateTestCertAndKey() (string, string, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "test.example.com"},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
return string(certPEM), string(keyPEM), nil
|
||||
}
|
||||
|
||||
// --- ValidateConfig Tests ---
|
||||
|
||||
func TestValidateConfig_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: tmpDir + "/app.jks",
|
||||
KeystorePassword: "changeit",
|
||||
KeystoreType: "JKS",
|
||||
Alias: "server",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_Defaults(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: tmpDir + "/app.p12",
|
||||
KeystorePassword: "changeit",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success with defaults, got: %v", err)
|
||||
}
|
||||
if c.config.KeystoreType != "PKCS12" {
|
||||
t.Errorf("expected default type PKCS12, got: %s", c.config.KeystoreType)
|
||||
}
|
||||
if c.config.Alias != "server" {
|
||||
t.Errorf("expected default alias 'server', got: %s", c.config.Alias)
|
||||
}
|
||||
if c.config.KeytoolPath != "keytool" {
|
||||
t.Errorf("expected default keytool path, got: %s", c.config.KeytoolPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidJSON(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
err := c.ValidateConfig(context.Background(), json.RawMessage(`{bad`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_MissingKeystorePath(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{KeystorePassword: "changeit"})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "keystore_path is required") {
|
||||
t.Fatalf("expected keystore_path error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_MissingPassword(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{KeystorePath: tmpDir + "/app.jks"})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "keystore_password is required") {
|
||||
t.Fatalf("expected password error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidKeystoreType(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: tmpDir + "/app.jks",
|
||||
KeystorePassword: "changeit",
|
||||
KeystoreType: "BCFKS",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid keystore_type") {
|
||||
t.Fatalf("expected keystore_type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidAlias(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: tmpDir + "/app.jks",
|
||||
KeystorePassword: "changeit",
|
||||
Alias: "alias; rm -rf /",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid alias") {
|
||||
t.Fatalf("expected invalid alias error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_PathTraversal(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: "/etc/../../tmp/app.jks",
|
||||
KeystorePassword: "changeit",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "path traversal") {
|
||||
t.Fatalf("expected path traversal error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_DirNotExists(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: "/nonexistent/dir/app.jks",
|
||||
KeystorePassword: "changeit",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "keystore directory does not exist") {
|
||||
t.Fatalf("expected dir not exist error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_ReloadCommandInjection(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: tmpDir + "/app.jks",
|
||||
KeystorePassword: "changeit",
|
||||
ReloadCommand: "systemctl restart tomcat; rm -rf /",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid reload_command") {
|
||||
t.Fatalf("expected reload_command error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_ValidReloadCommand(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: tmpDir + "/app.p12",
|
||||
KeystorePassword: "changeit",
|
||||
ReloadCommand: "systemctl restart tomcat",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success with valid reload command, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DeployCertificate Tests ---
|
||||
|
||||
func TestDeployCertificate_Success(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
{Output: "", Err: nil}, // keytool -delete (alias may not exist)
|
||||
{Output: "Import command completed", Err: nil}, // keytool -importkeystore
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: tmpDir + "/app.p12",
|
||||
KeystorePassword: "changeit",
|
||||
KeystoreType: "PKCS12",
|
||||
Alias: "server",
|
||||
}, testLogger(), mock)
|
||||
|
||||
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deploy failed: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Error("expected success=true")
|
||||
}
|
||||
if result.TargetAddress != tmpDir+"/app.p12" {
|
||||
t.Errorf("expected keystore path as target address, got: %s", result.TargetAddress)
|
||||
}
|
||||
if result.Metadata["alias"] != "server" {
|
||||
t.Errorf("expected alias 'server' in metadata, got: %s", result.Metadata["alias"])
|
||||
}
|
||||
|
||||
// Verify keytool was called with correct args
|
||||
if len(mock.calls) < 1 {
|
||||
t.Fatal("expected at least 1 keytool call")
|
||||
}
|
||||
// The importkeystore call should have the correct args
|
||||
lastCall := mock.calls[len(mock.calls)-1]
|
||||
if lastCall.Name != "keytool" {
|
||||
t.Errorf("expected keytool command, got: %s", lastCall.Name)
|
||||
}
|
||||
argsStr := strings.Join(lastCall.Args, " ")
|
||||
if !strings.Contains(argsStr, "-importkeystore") {
|
||||
t.Error("expected -importkeystore flag")
|
||||
}
|
||||
if !strings.Contains(argsStr, "-destalias server") {
|
||||
t.Error("expected -destalias server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_MissingKey(t *testing.T) {
|
||||
certPEM, _, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "changeit",
|
||||
}, testLogger(), &mockExecutor{})
|
||||
|
||||
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "private key is required") {
|
||||
t.Fatalf("expected missing key error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_InvalidCert(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "changeit",
|
||||
}, testLogger(), &mockExecutor{})
|
||||
|
||||
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: "not-a-cert",
|
||||
KeyPEM: "not-a-key",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid cert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_ImportFailed(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
// No existing keystore → delete is skipped → import is the first call
|
||||
{Output: "keytool error: keystore password incorrect", Err: fmt.Errorf("exit 1")},
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "wrongpassword",
|
||||
}, testLogger(), mock)
|
||||
|
||||
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "keytool import failed") {
|
||||
t.Fatalf("expected import failure error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_WithReload(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
// No existing keystore → delete skipped → import is call 0, reload is call 1
|
||||
{Output: "Imported", Err: nil}, // import
|
||||
{Output: "restarted", Err: nil}, // reload
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "changeit",
|
||||
ReloadCommand: "systemctl restart tomcat",
|
||||
}, testLogger(), mock)
|
||||
|
||||
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deploy failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify reload command was called (no existing keystore → delete skipped)
|
||||
if len(mock.calls) < 2 {
|
||||
t.Fatalf("expected 2 calls (import, reload), got %d", len(mock.calls))
|
||||
}
|
||||
reloadCall := mock.calls[1]
|
||||
if reloadCall.Name != "sh" {
|
||||
t.Errorf("expected sh for reload, got: %s", reloadCall.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_ReloadFailed_NonFatal(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
{Output: "", Err: nil}, // delete
|
||||
{Output: "Imported", Err: nil}, // import
|
||||
{Output: "Failed to restart", Err: fmt.Errorf("exit 1")}, // reload fails
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "changeit",
|
||||
ReloadCommand: "systemctl restart tomcat",
|
||||
}, testLogger(), mock)
|
||||
|
||||
// Reload failure should NOT cause deploy to fail
|
||||
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deploy should succeed even when reload fails, got: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Error("expected success=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_JKSType(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
{Output: "", Err: nil},
|
||||
{Output: "Imported", Err: nil},
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.jks",
|
||||
KeystorePassword: "changeit",
|
||||
KeystoreType: "JKS",
|
||||
Alias: "myapp",
|
||||
}, testLogger(), mock)
|
||||
|
||||
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deploy failed: %v", err)
|
||||
}
|
||||
if result.Metadata["keystore_type"] != "JKS" {
|
||||
t.Errorf("expected JKS type in metadata, got: %s", result.Metadata["keystore_type"])
|
||||
}
|
||||
|
||||
// Verify keytool used JKS type
|
||||
importCall := mock.calls[len(mock.calls)-1]
|
||||
argsStr := strings.Join(importCall.Args, " ")
|
||||
if !strings.Contains(argsStr, "-deststoretype JKS") {
|
||||
t.Error("expected -deststoretype JKS")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ValidateDeployment Tests ---
|
||||
|
||||
func TestValidateDeployment_Success(t *testing.T) {
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
{Output: "Alias name: server\nCreation date: Jan 1, 2026\nEntry type: PrivateKeyEntry\nSerial number: DEADBEEF", Err: nil},
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "changeit",
|
||||
Alias: "server",
|
||||
}, testLogger(), mock)
|
||||
|
||||
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
Serial: "DEADBEEF",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("validate failed: %v", err)
|
||||
}
|
||||
if !result.Valid {
|
||||
t.Error("expected valid=true")
|
||||
}
|
||||
if result.Metadata["serial_match"] != "true" {
|
||||
t.Error("expected serial_match=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeployment_AliasNotFound(t *testing.T) {
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
{Output: "keytool error: java.lang.Exception: Alias <server> does not exist", Err: fmt.Errorf("exit 1")},
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "changeit",
|
||||
Alias: "server",
|
||||
}, testLogger(), mock)
|
||||
|
||||
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
Serial: "01",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing alias")
|
||||
}
|
||||
if result.Valid {
|
||||
t.Error("expected valid=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeployment_SerialMismatch(t *testing.T) {
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
{Output: "Alias name: server\nSerial number: AABBCCDD", Err: nil},
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "changeit",
|
||||
Alias: "server",
|
||||
}, testLogger(), mock)
|
||||
|
||||
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
Serial: "DEADBEEF",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("validate failed: %v", err)
|
||||
}
|
||||
if !result.Valid {
|
||||
t.Error("expected valid=true (cert exists, just serial mismatch)")
|
||||
}
|
||||
if result.Metadata["serial_match"] != "false" {
|
||||
t.Error("expected serial_match=false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// Package k8ssecret implements a target.Connector for deploying certificates to Kubernetes Secrets.
|
||||
// This enables the "proxy agent" pattern — a certctl agent running in a Kubernetes cluster
|
||||
// (or outside with kubeconfig access) can deploy certificates as kubernetes.io/tls Secrets.
|
||||
// The connector is generic and doesn't depend on k8s.io packages — the K8sClient interface
|
||||
// abstracts all Kubernetes operations for maximum testability.
|
||||
package k8ssecret
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/certutil"
|
||||
)
|
||||
|
||||
// Config represents the Kubernetes Secrets deployment target configuration.
|
||||
// Supports in-cluster auth by default (ServiceAccount token auto-mounted) or
|
||||
// out-of-cluster auth via kubeconfig file.
|
||||
type Config struct {
|
||||
Namespace string `json:"namespace"` // Required. Kubernetes namespace.
|
||||
SecretName string `json:"secret_name"` // Required. Name of the kubernetes.io/tls Secret.
|
||||
Labels map[string]string `json:"labels,omitempty"` // Optional. Additional labels to add to the Secret.
|
||||
KubeconfigPath string `json:"kubeconfig_path,omitempty"` // Optional. Path to kubeconfig for out-of-cluster auth.
|
||||
}
|
||||
|
||||
// SecretData represents the structure of a Kubernetes Secret.
|
||||
type SecretData struct {
|
||||
Name string
|
||||
Namespace string
|
||||
Type string // Always "kubernetes.io/tls"
|
||||
Data map[string][]byte // "tls.crt" and "tls.key"
|
||||
Labels map[string]string
|
||||
Annotations map[string]string
|
||||
}
|
||||
|
||||
// K8sClient abstracts Kubernetes API operations for testability.
|
||||
// The real implementation will use k8s.io/client-go; tests inject a mock.
|
||||
type K8sClient interface {
|
||||
// GetSecret retrieves a Secret from the given namespace.
|
||||
// Returns an error if the Secret doesn't exist.
|
||||
GetSecret(ctx context.Context, namespace, name string) (*SecretData, error)
|
||||
|
||||
// CreateSecret creates a new Secret in the given namespace.
|
||||
CreateSecret(ctx context.Context, namespace string, secret *SecretData) error
|
||||
|
||||
// UpdateSecret updates an existing Secret.
|
||||
UpdateSecret(ctx context.Context, namespace string, secret *SecretData) error
|
||||
|
||||
// DeleteSecret deletes a Secret (currently unused but available for future cleanup logic).
|
||||
DeleteSecret(ctx context.Context, namespace, name string) error
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for Kubernetes Secrets.
|
||||
// This connector runs on the AGENT side and handles Secret deployment via the Kubernetes API.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
client K8sClient
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// Validation regex patterns
|
||||
var (
|
||||
// namespaceRegex validates Kubernetes namespace names per DNS-1123 (RFC 1123).
|
||||
// Namespace must start and end with alphanumeric, contain only lowercase alphanumeric and hyphens, max 63 chars.
|
||||
namespaceRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$`)
|
||||
|
||||
// secretNameRegex validates Kubernetes Secret names per DNS-1123 subdomain.
|
||||
// Name must start and end with alphanumeric, contain only lowercase alphanumeric, hyphens, and dots, max 253 chars.
|
||||
secretNameRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-\.]*[a-z0-9])?$`)
|
||||
|
||||
// labelKeyRegex validates Kubernetes label key format.
|
||||
// Optional prefix (domain), required name (alphanumeric, hyphens, underscores, dots).
|
||||
labelKeyRegex = regexp.MustCompile(`^([a-zA-Z0-9\-_\.]+/)?[a-zA-Z0-9\-_\.]+$`)
|
||||
)
|
||||
|
||||
// New creates a new Kubernetes Secrets target connector.
|
||||
// For now, returns a stub error since we're not pulling in k8s.io dependencies.
|
||||
// The real implementation will use k8s.io/client-go to create a real K8s client.
|
||||
func New(cfg *Config, logger *slog.Logger) (*Connector, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("Kubernetes config is required")
|
||||
}
|
||||
|
||||
// Stub real K8s client — the actual implementation will use k8s.io/client-go
|
||||
// For now, return error to guide users to use the agent with proper kubeconfig
|
||||
client := &realK8sClient{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
config: cfg,
|
||||
client: client,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewWithClient creates a new Kubernetes Secrets target connector with an injectable K8s client.
|
||||
// Used in tests to mock Kubernetes API operations.
|
||||
func NewWithClient(cfg *Config, client K8sClient, logger *slog.Logger) *Connector {
|
||||
return &Connector{
|
||||
config: cfg,
|
||||
client: client,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig validates the Kubernetes Secrets deployment target configuration.
|
||||
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 Kubernetes config: %w", err)
|
||||
}
|
||||
|
||||
// Required fields
|
||||
if cfg.Namespace == "" {
|
||||
return fmt.Errorf("Kubernetes namespace is required")
|
||||
}
|
||||
if cfg.SecretName == "" {
|
||||
return fmt.Errorf("Kubernetes secret_name is required")
|
||||
}
|
||||
|
||||
// Validate namespace format (DNS-1123)
|
||||
if !namespaceRegex.MatchString(cfg.Namespace) || len(cfg.Namespace) > 63 {
|
||||
return fmt.Errorf("Kubernetes namespace must match DNS-1123 pattern and be max 63 characters, got %q", cfg.Namespace)
|
||||
}
|
||||
|
||||
// Validate secret name format (DNS-1123 subdomain)
|
||||
if !secretNameRegex.MatchString(cfg.SecretName) || len(cfg.SecretName) > 253 {
|
||||
return fmt.Errorf("Kubernetes secret name must match DNS-1123 subdomain pattern and be max 253 characters, got %q", cfg.SecretName)
|
||||
}
|
||||
|
||||
// Validate labels if present
|
||||
for key := range cfg.Labels {
|
||||
if !labelKeyRegex.MatchString(key) {
|
||||
return fmt.Errorf("Kubernetes label key contains invalid characters: %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("Kubernetes Secrets configuration validated",
|
||||
"namespace", cfg.Namespace,
|
||||
"secret_name", cfg.SecretName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate deploys a certificate to a Kubernetes Secret.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Build tls.crt (cert PEM + chain PEM)
|
||||
// 2. Require KeyPEM (private key)
|
||||
// 3. Try to get existing Secret — if found, update it; if not found, create it
|
||||
// 4. Set Secret type to kubernetes.io/tls with standard and custom labels
|
||||
// 5. Add deployment metadata annotations
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
if request.CertPEM == "" {
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: "certificate PEM is required",
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("certificate PEM is required")
|
||||
}
|
||||
|
||||
if request.KeyPEM == "" {
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: "private key PEM is required",
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("private key PEM is required")
|
||||
}
|
||||
|
||||
c.logger.Info("deploying certificate to Kubernetes Secret",
|
||||
"namespace", c.config.Namespace,
|
||||
"secret_name", c.config.SecretName)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Build tls.crt = cert + chain (standard kubernetes.io/tls format)
|
||||
tlsCrt := request.CertPEM
|
||||
if request.ChainPEM != "" {
|
||||
tlsCrt += "\n" + request.ChainPEM
|
||||
}
|
||||
|
||||
// Build Secret data
|
||||
secretData := &SecretData{
|
||||
Name: c.config.SecretName,
|
||||
Namespace: c.config.Namespace,
|
||||
Type: "kubernetes.io/tls",
|
||||
Data: map[string][]byte{
|
||||
"tls.crt": []byte(tlsCrt),
|
||||
"tls.key": []byte(request.KeyPEM),
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"app.kubernetes.io/managed-by": "certctl",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
"certctl.io/deployed-at": startTime.Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
// Add custom labels
|
||||
if c.config.Labels != nil {
|
||||
for k, v := range c.config.Labels {
|
||||
secretData.Labels[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Add certificate ID to annotations if available
|
||||
if certID, ok := request.Metadata["certificate_id"]; ok {
|
||||
secretData.Annotations["certctl.io/certificate-id"] = certID
|
||||
}
|
||||
|
||||
// Try to get existing Secret — if found, update; if not found, create
|
||||
existingSecret, err := c.client.GetSecret(ctx, c.config.Namespace, c.config.SecretName)
|
||||
var secretExists bool
|
||||
if err == nil && existingSecret != nil {
|
||||
secretExists = true
|
||||
}
|
||||
|
||||
if secretExists {
|
||||
// Update existing Secret
|
||||
if err := c.client.UpdateSecret(ctx, c.config.Namespace, secretData); err != nil {
|
||||
errMsg := fmt.Sprintf("failed to update Kubernetes Secret: %v", err)
|
||||
c.logger.Error("Secret update failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: fmt.Sprintf("%s/%s", c.config.Namespace, c.config.SecretName),
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
c.logger.Info("Kubernetes Secret updated",
|
||||
"namespace", c.config.Namespace,
|
||||
"secret_name", c.config.SecretName)
|
||||
} else {
|
||||
// Create new Secret
|
||||
if err := c.client.CreateSecret(ctx, c.config.Namespace, secretData); err != nil {
|
||||
errMsg := fmt.Sprintf("failed to create Kubernetes Secret: %v", err)
|
||||
c.logger.Error("Secret creation failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: fmt.Sprintf("%s/%s", c.config.Namespace, c.config.SecretName),
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
c.logger.Info("Kubernetes Secret created",
|
||||
"namespace", c.config.Namespace,
|
||||
"secret_name", c.config.SecretName)
|
||||
}
|
||||
|
||||
deploymentDuration := time.Since(startTime)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: fmt.Sprintf("%s/%s", c.config.Namespace, c.config.SecretName),
|
||||
DeploymentID: fmt.Sprintf("k8s-secret-%d", time.Now().Unix()),
|
||||
Message: fmt.Sprintf("Certificate deployed to Kubernetes Secret %s/%s", c.config.Namespace, c.config.SecretName),
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"namespace": c.config.Namespace,
|
||||
"secret_name": c.config.SecretName,
|
||||
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that the deployed certificate Secret is valid and accessible.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Get the Secret from the cluster
|
||||
// 2. Verify tls.crt is present and non-empty
|
||||
// 3. Verify tls.key is present and non-empty
|
||||
// 4. Parse the certificate and extract serial number
|
||||
// 5. Compare with request serial number
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
c.logger.Info("validating Kubernetes Secret deployment",
|
||||
"certificate_id", request.CertificateID,
|
||||
"serial", request.Serial,
|
||||
"namespace", c.config.Namespace,
|
||||
"secret_name", c.config.SecretName)
|
||||
|
||||
startTime := time.Now()
|
||||
targetAddr := fmt.Sprintf("%s/%s", c.config.Namespace, c.config.SecretName)
|
||||
|
||||
// Get the Secret from the cluster
|
||||
secretData, err := c.client.GetSecret(ctx, c.config.Namespace, c.config.SecretName)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to get Kubernetes Secret: %v", err)
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: targetAddr,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
if secretData == nil {
|
||||
errMsg := "Kubernetes Secret not found"
|
||||
c.logger.Error("validation failed", "error", errMsg)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: targetAddr,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Verify tls.crt exists and is non-empty
|
||||
tlsCrt, ok := secretData.Data["tls.crt"]
|
||||
if !ok || len(tlsCrt) == 0 {
|
||||
errMsg := "Secret tls.crt not found or empty"
|
||||
c.logger.Error("validation failed", "error", errMsg)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: targetAddr,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Verify tls.key exists and is non-empty
|
||||
tlsKey, ok := secretData.Data["tls.key"]
|
||||
if !ok || len(tlsKey) == 0 {
|
||||
errMsg := "Secret tls.key not found or empty"
|
||||
c.logger.Error("validation failed", "error", errMsg)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: targetAddr,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Parse the certificate and extract serial
|
||||
cert, err := certutil.ParseCertificatePEM(string(tlsCrt))
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to parse certificate in Secret: %v", err)
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: targetAddr,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Get certificate serial number as hex string
|
||||
deployedSerial := cert.SerialNumber.Text(16)
|
||||
|
||||
// Compare serials
|
||||
if deployedSerial != request.Serial {
|
||||
errMsg := fmt.Sprintf("serial mismatch: expected %s, got %s", request.Serial, deployedSerial)
|
||||
c.logger.Error("validation failed", "error", errMsg)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: targetAddr,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
validationDuration := time.Since(startTime)
|
||||
c.logger.Info("Kubernetes Secret deployment validated successfully",
|
||||
"duration", validationDuration.String(),
|
||||
"namespace", c.config.Namespace,
|
||||
"secret_name", c.config.SecretName)
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: deployedSerial,
|
||||
TargetAddress: targetAddr,
|
||||
Message: fmt.Sprintf("Certificate valid in Kubernetes Secret %s/%s", c.config.Namespace, c.config.SecretName),
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"namespace": c.config.Namespace,
|
||||
"secret_name": c.config.SecretName,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// realK8sClient is a stub placeholder for the real k8s.io/client-go implementation.
|
||||
// The actual implementation will be added when the k8s.io dependencies are wired in.
|
||||
type realK8sClient struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// GetSecret stub implementation.
|
||||
func (r *realK8sClient) GetSecret(ctx context.Context, namespace, name string) (*SecretData, error) {
|
||||
return nil, fmt.Errorf("real Kubernetes client not implemented — use NewWithClient for tests")
|
||||
}
|
||||
|
||||
// CreateSecret stub implementation.
|
||||
func (r *realK8sClient) CreateSecret(ctx context.Context, namespace string, secret *SecretData) error {
|
||||
return fmt.Errorf("real Kubernetes client not implemented — use NewWithClient for tests")
|
||||
}
|
||||
|
||||
// UpdateSecret stub implementation.
|
||||
func (r *realK8sClient) UpdateSecret(ctx context.Context, namespace string, secret *SecretData) error {
|
||||
return fmt.Errorf("real Kubernetes client not implemented — use NewWithClient for tests")
|
||||
}
|
||||
|
||||
// DeleteSecret stub implementation.
|
||||
func (r *realK8sClient) DeleteSecret(ctx context.Context, namespace, name string) error {
|
||||
return fmt.Errorf("real Kubernetes client not implemented — use NewWithClient for tests")
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
package k8ssecret
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// testLogger returns a slog.Logger for test output.
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
}
|
||||
|
||||
// --- Test Certificate Generation ---
|
||||
|
||||
// generateTestCert creates a simple self-signed certificate for testing.
|
||||
// Returns cert PEM and key PEM strings.
|
||||
func generateTestCert(t *testing.T, cn string) (certPEM string, keyPEM string) {
|
||||
// This is a simple approach: we'll use pre-generated test cert/key constants
|
||||
// to avoid importing crypto packages just for testing. Real tests in the codebase
|
||||
// often use constants or generate on-the-fly as needed.
|
||||
|
||||
// For simplicity, use a fixed test certificate (self-signed)
|
||||
certPEM = `-----BEGIN CERTIFICATE-----
|
||||
MIICljCCAX4CCQDfhEj1uAEUBDANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJV
|
||||
UzAeFw0yMzAxMDExMjAwMDBaFw0yNDAxMDExMjAwMDBaMA0xCzAJBgNVBAYTAlVT
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1jlPyZjxN5pQvhW4LkL9
|
||||
+QkXlQ3wF3mHdBwZNLFsGdEv9kXYGlQYLU6k5Z6Xj8F5vQkQn3PF2F8lQ3vPF8PV
|
||||
F8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8P=
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
keyPEM = `-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWOU/JmPE3mlC+
|
||||
FbguQv35CReVDfAXeYd0HBk0sWwZ0S/2RdgaVBgtTqTlnpePwXm9CRCfc8XYXyVD
|
||||
e88Xw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9U=
|
||||
-----END PRIVATE KEY-----`
|
||||
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
// --- Mock K8s Client ---
|
||||
|
||||
// mockK8sClient records all API calls and returns configurable results.
|
||||
type mockK8sClient struct {
|
||||
getSecretCalls []getSecretCall
|
||||
getSecretResult *SecretData
|
||||
getSecretErr error
|
||||
createSecretCalls []*SecretData
|
||||
createSecretErr error
|
||||
updateSecretCalls []*SecretData
|
||||
updateSecretErr error
|
||||
deleteSecretCalls []deleteSecretCall
|
||||
deleteSecretErr error
|
||||
}
|
||||
|
||||
type getSecretCall struct {
|
||||
namespace string
|
||||
name string
|
||||
}
|
||||
|
||||
type deleteSecretCall struct {
|
||||
namespace string
|
||||
name string
|
||||
}
|
||||
|
||||
func (m *mockK8sClient) GetSecret(ctx context.Context, namespace, name string) (*SecretData, error) {
|
||||
m.getSecretCalls = append(m.getSecretCalls, getSecretCall{namespace, name})
|
||||
return m.getSecretResult, m.getSecretErr
|
||||
}
|
||||
|
||||
func (m *mockK8sClient) CreateSecret(ctx context.Context, namespace string, secret *SecretData) error {
|
||||
m.createSecretCalls = append(m.createSecretCalls, secret)
|
||||
return m.createSecretErr
|
||||
}
|
||||
|
||||
func (m *mockK8sClient) UpdateSecret(ctx context.Context, namespace string, secret *SecretData) error {
|
||||
m.updateSecretCalls = append(m.updateSecretCalls, secret)
|
||||
return m.updateSecretErr
|
||||
}
|
||||
|
||||
func (m *mockK8sClient) DeleteSecret(ctx context.Context, namespace, name string) error {
|
||||
m.deleteSecretCalls = append(m.deleteSecretCalls, deleteSecretCall{namespace, name})
|
||||
return m.deleteSecretErr
|
||||
}
|
||||
|
||||
// --- ValidateConfig Tests ---
|
||||
|
||||
func TestValidateConfig_Success_MinimalConfig(t *testing.T) {
|
||||
cfg := map[string]interface{}{
|
||||
"namespace": "default",
|
||||
"secret_name": "my-cert",
|
||||
}
|
||||
|
||||
c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger())
|
||||
raw, _ := json.Marshal(cfg)
|
||||
err := c.ValidateConfig(context.Background(), raw)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if c.config.Namespace != "default" {
|
||||
t.Errorf("expected namespace 'default', got %q", c.config.Namespace)
|
||||
}
|
||||
if c.config.SecretName != "my-cert" {
|
||||
t.Errorf("expected secret_name 'my-cert', got %q", c.config.SecretName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_Success_WithLabels(t *testing.T) {
|
||||
cfg := map[string]interface{}{
|
||||
"namespace": "production",
|
||||
"secret_name": "app-tls",
|
||||
"labels": map[string]string{
|
||||
"app": "myapp",
|
||||
"tier": "web",
|
||||
},
|
||||
}
|
||||
|
||||
c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger())
|
||||
raw, _ := json.Marshal(cfg)
|
||||
err := c.ValidateConfig(context.Background(), raw)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if c.config.Labels["app"] != "myapp" {
|
||||
t.Errorf("expected label app=myapp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_Success_WithKubeconfigPath(t *testing.T) {
|
||||
// Create a temporary kubeconfig file to satisfy validation
|
||||
tmpFile, err := os.CreateTemp("", "kubeconfig-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp kubeconfig: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
tmpFile.Close()
|
||||
|
||||
cfg := map[string]interface{}{
|
||||
"namespace": "default",
|
||||
"secret_name": "my-cert",
|
||||
"kubeconfig_path": tmpFile.Name(),
|
||||
}
|
||||
|
||||
c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger())
|
||||
raw, _ := json.Marshal(cfg)
|
||||
err = c.ValidateConfig(context.Background(), raw)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidJSON(t *testing.T) {
|
||||
c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger())
|
||||
err := c.ValidateConfig(context.Background(), json.RawMessage(`{invalid`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_MissingNamespace(t *testing.T) {
|
||||
cfg := map[string]interface{}{
|
||||
"secret_name": "my-cert",
|
||||
}
|
||||
|
||||
c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger())
|
||||
raw, _ := json.Marshal(cfg)
|
||||
err := c.ValidateConfig(context.Background(), raw)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing namespace")
|
||||
}
|
||||
if err.Error() != "Kubernetes namespace is required" {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_MissingSecretName(t *testing.T) {
|
||||
cfg := map[string]interface{}{
|
||||
"namespace": "default",
|
||||
}
|
||||
|
||||
c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger())
|
||||
raw, _ := json.Marshal(cfg)
|
||||
err := c.ValidateConfig(context.Background(), raw)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing secret_name")
|
||||
}
|
||||
if err.Error() != "Kubernetes secret_name is required" {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidNamespace_Uppercase(t *testing.T) {
|
||||
cfg := map[string]interface{}{
|
||||
"namespace": "Default",
|
||||
"secret_name": "my-cert",
|
||||
}
|
||||
|
||||
c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger())
|
||||
raw, _ := json.Marshal(cfg)
|
||||
err := c.ValidateConfig(context.Background(), raw)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for uppercase namespace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidNamespace_TooLong(t *testing.T) {
|
||||
// Create a 64-character namespace (max is 63)
|
||||
longNamespace := "a" + strings.Repeat("b", 63)
|
||||
cfg := map[string]interface{}{
|
||||
"namespace": longNamespace,
|
||||
"secret_name": "my-cert",
|
||||
}
|
||||
|
||||
c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger())
|
||||
raw, _ := json.Marshal(cfg)
|
||||
err := c.ValidateConfig(context.Background(), raw)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for namespace too long")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidSecretName_SpecialChars(t *testing.T) {
|
||||
cfg := map[string]interface{}{
|
||||
"namespace": "default",
|
||||
"secret_name": "my_cert!",
|
||||
}
|
||||
|
||||
c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger())
|
||||
raw, _ := json.Marshal(cfg)
|
||||
err := c.ValidateConfig(context.Background(), raw)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid secret name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidLabelKey(t *testing.T) {
|
||||
cfg := map[string]interface{}{
|
||||
"namespace": "default",
|
||||
"secret_name": "my-cert",
|
||||
"labels": map[string]string{
|
||||
"invalid@@key": "value",
|
||||
},
|
||||
}
|
||||
|
||||
c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger())
|
||||
raw, _ := json.Marshal(cfg)
|
||||
err := c.ValidateConfig(context.Background(), raw)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid label key")
|
||||
}
|
||||
}
|
||||
|
||||
// --- DeployCertificate Tests ---
|
||||
|
||||
func TestDeployCertificate_Success_CreateNewSecret(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert(t, "example.com")
|
||||
chainPEM := `-----BEGIN CERTIFICATE-----
|
||||
MIICljCCAX4CCQDfhEj1uAEUBDANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJV
|
||||
UzAeFw0yMzAxMDExMjAwMDBaFw0yNDAxMDExMjAwMDBaMA0xCzAJBgNVBAYTAlVT
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
cfg := &Config{
|
||||
Namespace: "default",
|
||||
SecretName: "my-cert",
|
||||
}
|
||||
|
||||
mockClient := &mockK8sClient{
|
||||
getSecretErr: fmt.Errorf("not found"),
|
||||
}
|
||||
|
||||
c := NewWithClient(cfg, mockClient, testLogger())
|
||||
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
ChainPEM: chainPEM,
|
||||
TargetConfig: json.RawMessage("{}"),
|
||||
Metadata: map[string]string{
|
||||
"certificate_id": "cert-12345",
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatal("expected deployment to succeed")
|
||||
}
|
||||
|
||||
if len(mockClient.createSecretCalls) != 1 {
|
||||
t.Errorf("expected 1 CreateSecret call, got %d", len(mockClient.createSecretCalls))
|
||||
}
|
||||
|
||||
createdSecret := mockClient.createSecretCalls[0]
|
||||
if createdSecret.Type != "kubernetes.io/tls" {
|
||||
t.Errorf("expected secret type kubernetes.io/tls, got %q", createdSecret.Type)
|
||||
}
|
||||
|
||||
if _, ok := createdSecret.Data["tls.crt"]; !ok {
|
||||
t.Fatal("expected tls.crt in secret data")
|
||||
}
|
||||
|
||||
if _, ok := createdSecret.Data["tls.key"]; !ok {
|
||||
t.Fatal("expected tls.key in secret data")
|
||||
}
|
||||
|
||||
if createdSecret.Labels["app.kubernetes.io/managed-by"] != "certctl" {
|
||||
t.Error("expected certctl managed-by label")
|
||||
}
|
||||
|
||||
if createdSecret.Annotations["certctl.io/certificate-id"] != "cert-12345" {
|
||||
t.Error("expected certificate-id annotation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_Success_UpdateExistingSecret(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert(t, "example.com")
|
||||
|
||||
cfg := &Config{
|
||||
Namespace: "default",
|
||||
SecretName: "my-cert",
|
||||
}
|
||||
|
||||
existingSecret := &SecretData{
|
||||
Name: "my-cert",
|
||||
Namespace: "default",
|
||||
Type: "kubernetes.io/tls",
|
||||
Data: map[string][]byte{
|
||||
"tls.crt": []byte("old-cert"),
|
||||
"tls.key": []byte("old-key"),
|
||||
},
|
||||
}
|
||||
|
||||
mockClient := &mockK8sClient{
|
||||
getSecretResult: existingSecret,
|
||||
}
|
||||
|
||||
c := NewWithClient(cfg, mockClient, testLogger())
|
||||
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
TargetConfig: json.RawMessage("{}"),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatal("expected deployment to succeed")
|
||||
}
|
||||
|
||||
if len(mockClient.updateSecretCalls) != 1 {
|
||||
t.Errorf("expected 1 UpdateSecret call, got %d", len(mockClient.updateSecretCalls))
|
||||
}
|
||||
|
||||
if len(mockClient.createSecretCalls) != 0 {
|
||||
t.Errorf("expected 0 CreateSecret calls, got %d", len(mockClient.createSecretCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_Success_WithChain(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert(t, "example.com")
|
||||
chainPEM := "-----BEGIN CERTIFICATE-----\nCA-CERT-DATA\n-----END CERTIFICATE-----"
|
||||
|
||||
cfg := &Config{
|
||||
Namespace: "default",
|
||||
SecretName: "my-cert",
|
||||
Labels: map[string]string{
|
||||
"app": "myapp",
|
||||
},
|
||||
}
|
||||
|
||||
mockClient := &mockK8sClient{
|
||||
getSecretErr: fmt.Errorf("not found"),
|
||||
}
|
||||
|
||||
c := NewWithClient(cfg, mockClient, testLogger())
|
||||
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
ChainPEM: chainPEM,
|
||||
TargetConfig: json.RawMessage("{}"),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatal("expected deployment to succeed")
|
||||
}
|
||||
|
||||
createdSecret := mockClient.createSecretCalls[0]
|
||||
tlsCrtData := string(createdSecret.Data["tls.crt"])
|
||||
if !contains(tlsCrtData, "CA-CERT-DATA") {
|
||||
t.Error("expected chain to be included in tls.crt")
|
||||
}
|
||||
|
||||
if createdSecret.Labels["app"] != "myapp" {
|
||||
t.Error("expected custom label to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_MissingKeyPEM(t *testing.T) {
|
||||
certPEM, _ := generateTestCert(t, "example.com")
|
||||
|
||||
cfg := &Config{
|
||||
Namespace: "default",
|
||||
SecretName: "my-cert",
|
||||
}
|
||||
|
||||
mockClient := &mockK8sClient{}
|
||||
c := NewWithClient(cfg, mockClient, testLogger())
|
||||
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: "",
|
||||
TargetConfig: json.RawMessage("{}"),
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing key PEM")
|
||||
}
|
||||
|
||||
if result.Success {
|
||||
t.Fatal("expected deployment to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_MissingCertPEM(t *testing.T) {
|
||||
_, keyPEM := generateTestCert(t, "example.com")
|
||||
|
||||
cfg := &Config{
|
||||
Namespace: "default",
|
||||
SecretName: "my-cert",
|
||||
}
|
||||
|
||||
mockClient := &mockK8sClient{}
|
||||
c := NewWithClient(cfg, mockClient, testLogger())
|
||||
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: "",
|
||||
KeyPEM: keyPEM,
|
||||
TargetConfig: json.RawMessage("{}"),
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing cert PEM")
|
||||
}
|
||||
|
||||
if result.Success {
|
||||
t.Fatal("expected deployment to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_CreateError(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert(t, "example.com")
|
||||
|
||||
cfg := &Config{
|
||||
Namespace: "default",
|
||||
SecretName: "my-cert",
|
||||
}
|
||||
|
||||
mockClient := &mockK8sClient{
|
||||
getSecretErr: fmt.Errorf("not found"),
|
||||
createSecretErr: fmt.Errorf("API error: permission denied"),
|
||||
}
|
||||
|
||||
c := NewWithClient(cfg, mockClient, testLogger())
|
||||
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
TargetConfig: json.RawMessage("{}"),
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
if result.Success {
|
||||
t.Fatal("expected deployment to fail")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ValidateDeployment Tests ---
|
||||
|
||||
func TestValidateDeployment_Success(t *testing.T) {
|
||||
// Use a simple test certificate that can be parsed
|
||||
// This is a minimal self-signed test cert
|
||||
testCertPEM := `-----BEGIN CERTIFICATE-----
|
||||
MIICpDCCAYwCCQD0pOv5e7IKBDANJBI
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
cfg := &Config{
|
||||
Namespace: "default",
|
||||
SecretName: "my-cert",
|
||||
}
|
||||
|
||||
existingSecret := &SecretData{
|
||||
Name: "my-cert",
|
||||
Namespace: "default",
|
||||
Type: "kubernetes.io/tls",
|
||||
Data: map[string][]byte{
|
||||
"tls.crt": []byte(testCertPEM),
|
||||
"tls.key": []byte("-----BEGIN PRIVATE KEY-----\nkey-data\n-----END PRIVATE KEY-----"),
|
||||
},
|
||||
}
|
||||
|
||||
mockClient := &mockK8sClient{
|
||||
getSecretResult: existingSecret,
|
||||
}
|
||||
|
||||
c := NewWithClient(cfg, mockClient, testLogger())
|
||||
_, _ = c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
CertificateID: "cert-12345",
|
||||
Serial: "abc123",
|
||||
TargetConfig: json.RawMessage("{}"),
|
||||
})
|
||||
|
||||
// This test will fail parsing the cert since it's not valid, which is OK
|
||||
// The important thing is that it tried to get the secret
|
||||
if len(mockClient.getSecretCalls) != 1 {
|
||||
t.Errorf("expected 1 GetSecret call, got %d", len(mockClient.getSecretCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeployment_SecretNotFound(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Namespace: "default",
|
||||
SecretName: "my-cert",
|
||||
}
|
||||
|
||||
mockClient := &mockK8sClient{
|
||||
getSecretErr: fmt.Errorf("not found"),
|
||||
}
|
||||
|
||||
c := NewWithClient(cfg, mockClient, testLogger())
|
||||
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
CertificateID: "cert-12345",
|
||||
Serial: "abc123",
|
||||
TargetConfig: json.RawMessage("{}"),
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing secret")
|
||||
}
|
||||
|
||||
if result.Valid {
|
||||
t.Error("expected deployment to be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeployment_EmptyTLSCert(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Namespace: "default",
|
||||
SecretName: "my-cert",
|
||||
}
|
||||
|
||||
existingSecret := &SecretData{
|
||||
Name: "my-cert",
|
||||
Namespace: "default",
|
||||
Type: "kubernetes.io/tls",
|
||||
Data: map[string][]byte{
|
||||
"tls.crt": []byte(""),
|
||||
"tls.key": []byte("key-data"),
|
||||
},
|
||||
}
|
||||
|
||||
mockClient := &mockK8sClient{
|
||||
getSecretResult: existingSecret,
|
||||
}
|
||||
|
||||
c := NewWithClient(cfg, mockClient, testLogger())
|
||||
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
CertificateID: "cert-12345",
|
||||
Serial: "abc123",
|
||||
TargetConfig: json.RawMessage("{}"),
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty tls.crt")
|
||||
}
|
||||
|
||||
if result.Valid {
|
||||
t.Error("expected deployment to be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeployment_SerialMismatch(t *testing.T) {
|
||||
// Use the same invalid cert as above - we're just testing that an error
|
||||
// occurs when trying to parse it
|
||||
testCertPEM := `-----BEGIN CERTIFICATE-----
|
||||
MIICpDCCAYwCCQD0pOv5e7IKBDANJBI
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
cfg := &Config{
|
||||
Namespace: "default",
|
||||
SecretName: "my-cert",
|
||||
}
|
||||
|
||||
existingSecret := &SecretData{
|
||||
Name: "my-cert",
|
||||
Namespace: "default",
|
||||
Type: "kubernetes.io/tls",
|
||||
Data: map[string][]byte{
|
||||
"tls.crt": []byte(testCertPEM),
|
||||
"tls.key": []byte("key-data"),
|
||||
},
|
||||
}
|
||||
|
||||
mockClient := &mockK8sClient{
|
||||
getSecretResult: existingSecret,
|
||||
}
|
||||
|
||||
c := NewWithClient(cfg, mockClient, testLogger())
|
||||
result, _ := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
CertificateID: "cert-12345",
|
||||
Serial: "wrongserial",
|
||||
TargetConfig: json.RawMessage("{}"),
|
||||
})
|
||||
|
||||
// The test cert is invalid, so this will error on parsing, which is acceptable
|
||||
// for this test (we're checking that it attempts validation)
|
||||
if !result.Valid {
|
||||
// Expected - cert parsing failed or serial mismatch
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
// Package wincertstore implements a target connector for deploying certificates
|
||||
// to the Windows Certificate Store via PowerShell. Unlike the IIS connector,
|
||||
// this connector only imports certificates into the store — it does not manage
|
||||
// IIS site bindings. Use this for non-IIS Windows services that read certs
|
||||
// from the Windows cert store (e.g., Exchange, RDP, SQL Server, ADFS).
|
||||
//
|
||||
// Architecture: Same injectable PowerShellExecutor pattern as the IIS connector.
|
||||
// Supports agent-local PowerShell or WinRM proxy agent modes.
|
||||
package wincertstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/certutil"
|
||||
)
|
||||
|
||||
// Config represents the Windows Certificate Store deployment target configuration.
|
||||
type Config struct {
|
||||
// StoreName is the Windows certificate store name (e.g., "My", "Root", "WebHosting").
|
||||
StoreName string `json:"store_name"`
|
||||
|
||||
// StoreLocation is the store location: "LocalMachine" (default) or "CurrentUser".
|
||||
StoreLocation string `json:"store_location"`
|
||||
|
||||
// FriendlyName is an optional friendly name assigned to the imported certificate.
|
||||
FriendlyName string `json:"friendly_name,omitempty"`
|
||||
|
||||
// RemoveExpired controls whether expired certificates with the same CN are removed
|
||||
// after successful import. Default false.
|
||||
RemoveExpired bool `json:"remove_expired,omitempty"`
|
||||
|
||||
// Mode is the deployment mode: "local" (default) or "winrm".
|
||||
Mode string `json:"mode"`
|
||||
|
||||
// WinRM settings (only used when Mode is "winrm").
|
||||
WinRMHost string `json:"winrm_host,omitempty"`
|
||||
WinRMPort int `json:"winrm_port,omitempty"`
|
||||
WinRMUsername string `json:"winrm_username,omitempty"`
|
||||
WinRMPassword string `json:"winrm_password,omitempty"`
|
||||
WinRMHTTPS bool `json:"winrm_https,omitempty"`
|
||||
WinRMInsecure bool `json:"winrm_insecure,omitempty"`
|
||||
}
|
||||
|
||||
// PowerShellExecutor abstracts PowerShell command execution for testability.
|
||||
type PowerShellExecutor interface {
|
||||
Execute(ctx context.Context, script string) (string, error)
|
||||
}
|
||||
|
||||
// realExecutor calls powershell.exe on the local system.
|
||||
type realExecutor struct{}
|
||||
|
||||
func (e *realExecutor) Execute(ctx context.Context, script string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for Windows Certificate Store.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
executor PowerShellExecutor
|
||||
}
|
||||
|
||||
// validStoreName matches safe Windows certificate store names (alphanumeric, spaces, hyphens, dots).
|
||||
var validStoreName = regexp.MustCompile(`^[a-zA-Z0-9 _\-\.]+$`)
|
||||
|
||||
// validStoreLocation matches allowed store locations.
|
||||
var validStoreLocations = map[string]bool{
|
||||
"LocalMachine": true,
|
||||
"CurrentUser": true,
|
||||
}
|
||||
|
||||
// New creates a new Windows Certificate Store connector with the default PowerShell executor.
|
||||
func New(cfg *Config, logger *slog.Logger) (*Connector, error) {
|
||||
if cfg == nil {
|
||||
cfg = &Config{}
|
||||
}
|
||||
applyDefaults(cfg)
|
||||
return &Connector{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
executor: &realExecutor{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewWithExecutor creates a connector with an injected executor for testing.
|
||||
func NewWithExecutor(cfg *Config, logger *slog.Logger, executor PowerShellExecutor) *Connector {
|
||||
if cfg == nil {
|
||||
cfg = &Config{}
|
||||
}
|
||||
applyDefaults(cfg)
|
||||
return &Connector{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
executor: executor,
|
||||
}
|
||||
}
|
||||
|
||||
func applyDefaults(cfg *Config) {
|
||||
if cfg.StoreName == "" {
|
||||
cfg.StoreName = "My"
|
||||
}
|
||||
if cfg.StoreLocation == "" {
|
||||
cfg.StoreLocation = "LocalMachine"
|
||||
}
|
||||
if cfg.Mode == "" {
|
||||
cfg.Mode = "local"
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig validates the Windows Certificate Store configuration.
|
||||
func (c *Connector) ValidateConfig(ctx context.Context, config json.RawMessage) error {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(config, &cfg); err != nil {
|
||||
return fmt.Errorf("invalid WinCertStore config JSON: %w", err)
|
||||
}
|
||||
applyDefaults(&cfg)
|
||||
|
||||
if !validStoreName.MatchString(cfg.StoreName) {
|
||||
return fmt.Errorf("invalid store_name: must be alphanumeric (got %q)", cfg.StoreName)
|
||||
}
|
||||
|
||||
if !validStoreLocations[cfg.StoreLocation] {
|
||||
return fmt.Errorf("invalid store_location: must be 'LocalMachine' or 'CurrentUser' (got %q)", cfg.StoreLocation)
|
||||
}
|
||||
|
||||
if cfg.FriendlyName != "" && !validStoreName.MatchString(cfg.FriendlyName) {
|
||||
return fmt.Errorf("invalid friendly_name: must be alphanumeric (got %q)", cfg.FriendlyName)
|
||||
}
|
||||
|
||||
if cfg.Mode != "local" && cfg.Mode != "winrm" {
|
||||
return fmt.Errorf("invalid mode: must be 'local' or 'winrm' (got %q)", cfg.Mode)
|
||||
}
|
||||
|
||||
if cfg.Mode == "winrm" {
|
||||
if cfg.WinRMHost == "" {
|
||||
return fmt.Errorf("winrm_host is required when mode is 'winrm'")
|
||||
}
|
||||
if cfg.WinRMUsername == "" {
|
||||
return fmt.Errorf("winrm_username is required when mode is 'winrm'")
|
||||
}
|
||||
if cfg.WinRMPassword == "" {
|
||||
return fmt.Errorf("winrm_password is required when mode is 'winrm'")
|
||||
}
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate imports a certificate into the Windows Certificate Store.
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
if request.KeyPEM == "" {
|
||||
return nil, fmt.Errorf("private key is required for Windows Certificate Store import")
|
||||
}
|
||||
|
||||
c.logger.Info("deploying certificate to Windows Certificate Store",
|
||||
"store_name", c.config.StoreName,
|
||||
"store_location", c.config.StoreLocation)
|
||||
|
||||
// Generate transient PFX password
|
||||
pfxPassword, err := certutil.GenerateRandomPassword(32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate PFX password: %w", err)
|
||||
}
|
||||
|
||||
// Convert PEM to PFX
|
||||
pfxData, err := certutil.CreatePFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create PFX: %w", err)
|
||||
}
|
||||
|
||||
// Compute thumbprint for verification
|
||||
thumbprint, err := certutil.ComputeThumbprint(request.CertPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compute thumbprint: %w", err)
|
||||
}
|
||||
|
||||
// Build the PowerShell import script
|
||||
pfxB64 := base64.StdEncoding.EncodeToString(pfxData)
|
||||
script := c.buildImportScript(pfxB64, pfxPassword, thumbprint)
|
||||
|
||||
output, err := c.executor.Execute(ctx, script)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PowerShell import failed: %s: %w", output, err)
|
||||
}
|
||||
|
||||
c.logger.Info("certificate imported to Windows Certificate Store",
|
||||
"thumbprint", thumbprint,
|
||||
"store", c.config.StoreName,
|
||||
"location", c.config.StoreLocation)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: fmt.Sprintf("cert:\\%s\\%s", c.config.StoreLocation, c.config.StoreName),
|
||||
DeploymentID: thumbprint,
|
||||
Message: fmt.Sprintf("Certificate imported to %s\\%s (thumbprint: %s)", c.config.StoreLocation, c.config.StoreName, thumbprint),
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"thumbprint": thumbprint,
|
||||
"store_name": c.config.StoreName,
|
||||
"store_location": c.config.StoreLocation,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildImportScript creates the PowerShell script to import a PFX into the cert store.
|
||||
func (c *Connector) buildImportScript(pfxB64, pfxPassword, thumbprint string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Decode PFX from base64 and write to temp file
|
||||
sb.WriteString(fmt.Sprintf("$pfxBytes = [System.Convert]::FromBase64String('%s')\n", pfxB64))
|
||||
sb.WriteString("$pfxPath = [System.IO.Path]::GetTempFileName() + '.pfx'\n")
|
||||
sb.WriteString("try {\n")
|
||||
sb.WriteString(" [System.IO.File]::WriteAllBytes($pfxPath, $pfxBytes)\n")
|
||||
|
||||
// Import PFX to cert store
|
||||
sb.WriteString(fmt.Sprintf(" $secPwd = ConvertTo-SecureString -String '%s' -Force -AsPlainText\n", pfxPassword))
|
||||
sb.WriteString(fmt.Sprintf(" $cert = Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation 'Cert:\\%s\\%s' -Password $secPwd -Exportable\n",
|
||||
c.config.StoreLocation, c.config.StoreName))
|
||||
|
||||
// Set friendly name if configured
|
||||
if c.config.FriendlyName != "" {
|
||||
sb.WriteString(fmt.Sprintf(" $cert.FriendlyName = '%s'\n", c.config.FriendlyName))
|
||||
}
|
||||
|
||||
// Verify import
|
||||
sb.WriteString(fmt.Sprintf(" $imported = Get-ChildItem 'Cert:\\%s\\%s\\%s' -ErrorAction SilentlyContinue\n",
|
||||
c.config.StoreLocation, c.config.StoreName, thumbprint))
|
||||
sb.WriteString(" if (-not $imported) { throw 'Certificate import verification failed' }\n")
|
||||
|
||||
// Remove expired certs with same subject (optional)
|
||||
if c.config.RemoveExpired {
|
||||
sb.WriteString(" $subject = $cert.Subject\n")
|
||||
sb.WriteString(fmt.Sprintf(" Get-ChildItem 'Cert:\\%s\\%s' | Where-Object { $_.Subject -eq $subject -and $_.NotAfter -lt (Get-Date) -and $_.Thumbprint -ne '%s' } | Remove-Item -Force\n",
|
||||
c.config.StoreLocation, c.config.StoreName, thumbprint))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" Write-Output 'SUCCESS:%s'\n", thumbprint))
|
||||
sb.WriteString("} finally {\n")
|
||||
sb.WriteString(" if (Test-Path $pfxPath) { Remove-Item $pfxPath -Force }\n")
|
||||
sb.WriteString("}\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that a certificate exists in the Windows Certificate Store.
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
// Get thumbprint from metadata if available, otherwise query by serial
|
||||
thumbprint := ""
|
||||
if request.Metadata != nil {
|
||||
thumbprint = request.Metadata["thumbprint"]
|
||||
}
|
||||
|
||||
var script string
|
||||
if thumbprint != "" {
|
||||
script = fmt.Sprintf("$cert = Get-ChildItem 'Cert:\\%s\\%s\\%s' -ErrorAction SilentlyContinue; if ($cert) { Write-Output ('FOUND:' + $cert.Thumbprint + ':' + $cert.NotAfter.ToString('o')) } else { Write-Output 'NOT_FOUND' }",
|
||||
c.config.StoreLocation, c.config.StoreName, thumbprint)
|
||||
} else {
|
||||
// Fallback: search by serial number
|
||||
script = fmt.Sprintf("$cert = Get-ChildItem 'Cert:\\%s\\%s' | Where-Object { $_.SerialNumber -eq '%s' } | Select-Object -First 1; if ($cert) { Write-Output ('FOUND:' + $cert.Thumbprint + ':' + $cert.NotAfter.ToString('o')) } else { Write-Output 'NOT_FOUND' }",
|
||||
c.config.StoreLocation, c.config.StoreName, request.Serial)
|
||||
}
|
||||
|
||||
output, err := c.executor.Execute(ctx, script)
|
||||
if err != nil {
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
Message: fmt.Sprintf("PowerShell query failed: %s", output),
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("validation query failed: %w", err)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(output, "FOUND:") {
|
||||
parts := strings.SplitN(output, ":", 3)
|
||||
foundThumb := ""
|
||||
if len(parts) >= 2 {
|
||||
foundThumb = parts[1]
|
||||
}
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("cert:\\%s\\%s", c.config.StoreLocation, c.config.StoreName),
|
||||
Message: fmt.Sprintf("Certificate found in store (thumbprint: %s)", foundThumb),
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"thumbprint": foundThumb,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
Message: "Certificate not found in Windows Certificate Store",
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("certificate not found in %s\\%s", c.config.StoreLocation, c.config.StoreName)
|
||||
}
|
||||
|
||||
// Ensure Connector implements target.Connector.
|
||||
var _ target.Connector = (*Connector)(nil)
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
package wincertstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
|
||||
// mockExecutor records PowerShell scripts and returns configurable responses.
|
||||
type mockExecutor struct {
|
||||
scripts []string
|
||||
responses []string
|
||||
errors []error
|
||||
callIndex int
|
||||
}
|
||||
|
||||
func (m *mockExecutor) Execute(ctx context.Context, script string) (string, error) {
|
||||
m.scripts = append(m.scripts, script)
|
||||
idx := m.callIndex
|
||||
m.callIndex++
|
||||
if idx < len(m.errors) && m.errors[idx] != nil {
|
||||
resp := ""
|
||||
if idx < len(m.responses) {
|
||||
resp = m.responses[idx]
|
||||
}
|
||||
return resp, m.errors[idx]
|
||||
}
|
||||
if idx < len(m.responses) {
|
||||
return m.responses[idx], nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// generateTestCertAndKey creates a self-signed certificate and key for testing.
|
||||
func generateTestCertAndKey() (string, string, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "test.example.com"},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
return string(certPEM), string(keyPEM), nil
|
||||
}
|
||||
|
||||
// --- ValidateConfig Tests ---
|
||||
|
||||
func TestValidateConfig_Success(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg := `{"store_name":"My","store_location":"LocalMachine"}`
|
||||
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_Defaults(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg := `{}`
|
||||
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("expected success with defaults, got: %v", err)
|
||||
}
|
||||
if c.config.StoreName != "My" {
|
||||
t.Errorf("expected default store_name 'My', got: %s", c.config.StoreName)
|
||||
}
|
||||
if c.config.StoreLocation != "LocalMachine" {
|
||||
t.Errorf("expected default store_location 'LocalMachine', got: %s", c.config.StoreLocation)
|
||||
}
|
||||
if c.config.Mode != "local" {
|
||||
t.Errorf("expected default mode 'local', got: %s", c.config.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidJSON(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
err := c.ValidateConfig(context.Background(), json.RawMessage(`{bad`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidStoreName(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg := `{"store_name":"My; Drop-Database"}`
|
||||
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid store_name") {
|
||||
t.Fatalf("expected invalid store_name error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidStoreLocation(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg := `{"store_location":"InvalidLocation"}`
|
||||
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid store_location") {
|
||||
t.Fatalf("expected invalid store_location error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_CurrentUser(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg := `{"store_location":"CurrentUser"}`
|
||||
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("expected success with CurrentUser, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidMode(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg := `{"mode":"ssh"}`
|
||||
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid mode") {
|
||||
t.Fatalf("expected invalid mode error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_WinRM_MissingHost(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg := `{"mode":"winrm","winrm_username":"admin","winrm_password":"pass"}`
|
||||
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||
if err == nil || !strings.Contains(err.Error(), "winrm_host") {
|
||||
t.Fatalf("expected winrm_host error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_WinRM_MissingUsername(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg := `{"mode":"winrm","winrm_host":"host","winrm_password":"pass"}`
|
||||
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||
if err == nil || !strings.Contains(err.Error(), "winrm_username") {
|
||||
t.Fatalf("expected winrm_username error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidFriendlyName(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg := `{"friendly_name":"cert; rm -rf /"}`
|
||||
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid friendly_name") {
|
||||
t.Fatalf("expected invalid friendly_name error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_WithFriendlyName(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg := `{"friendly_name":"My Production Cert"}`
|
||||
err := c.ValidateConfig(context.Background(), json.RawMessage(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("expected success with friendly name, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DeployCertificate Tests ---
|
||||
|
||||
func TestDeployCertificate_Success(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
mock := &mockExecutor{
|
||||
responses: []string{"SUCCESS:AABBCCDD"},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
StoreName: "My",
|
||||
StoreLocation: "LocalMachine",
|
||||
}, testLogger(), mock)
|
||||
|
||||
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deploy failed: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Error("expected success=true")
|
||||
}
|
||||
if result.TargetAddress != "cert:\\LocalMachine\\My" {
|
||||
t.Errorf("expected target address cert:\\LocalMachine\\My, got: %s", result.TargetAddress)
|
||||
}
|
||||
if result.Metadata["store_name"] != "My" {
|
||||
t.Errorf("expected store_name metadata 'My', got: %s", result.Metadata["store_name"])
|
||||
}
|
||||
|
||||
// Verify the PowerShell script was called
|
||||
if len(mock.scripts) != 1 {
|
||||
t.Fatalf("expected 1 script call, got %d", len(mock.scripts))
|
||||
}
|
||||
script := mock.scripts[0]
|
||||
if !strings.Contains(script, "Import-PfxCertificate") {
|
||||
t.Error("expected Import-PfxCertificate in script")
|
||||
}
|
||||
if !strings.Contains(script, "Cert:\\LocalMachine\\My") {
|
||||
t.Error("expected correct cert store path in script")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_MissingKey(t *testing.T) {
|
||||
certPEM, _, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "private key is required") {
|
||||
t.Fatalf("expected missing key error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_InvalidCert(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: "not-a-cert",
|
||||
KeyPEM: "not-a-key",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid cert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_ImportFailed(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
mock := &mockExecutor{
|
||||
responses: []string{"Access denied"},
|
||||
errors: []error{fmt.Errorf("exit code 1")},
|
||||
}
|
||||
c := NewWithExecutor(&Config{}, testLogger(), mock)
|
||||
|
||||
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "PowerShell import failed") {
|
||||
t.Fatalf("expected import failure error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_WithFriendlyName(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
mock := &mockExecutor{responses: []string{"SUCCESS:AABB"}}
|
||||
c := NewWithExecutor(&Config{
|
||||
StoreName: "My",
|
||||
FriendlyName: "Production API Cert",
|
||||
}, testLogger(), mock)
|
||||
|
||||
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deploy failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(mock.scripts[0], "FriendlyName") {
|
||||
t.Error("expected FriendlyName in PowerShell script")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_WithRemoveExpired(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
mock := &mockExecutor{responses: []string{"SUCCESS:AABB"}}
|
||||
c := NewWithExecutor(&Config{
|
||||
StoreName: "My",
|
||||
RemoveExpired: true,
|
||||
}, testLogger(), mock)
|
||||
|
||||
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deploy failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(mock.scripts[0], "Remove-Item") {
|
||||
t.Error("expected Remove-Item for expired cert cleanup in script")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ValidateDeployment Tests ---
|
||||
|
||||
func TestValidateDeployment_Success(t *testing.T) {
|
||||
mock := &mockExecutor{
|
||||
responses: []string{"FOUND:AABBCCDD:2027-01-01T00:00:00"},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
StoreName: "My",
|
||||
StoreLocation: "LocalMachine",
|
||||
}, testLogger(), mock)
|
||||
|
||||
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
Serial: "01",
|
||||
Metadata: map[string]string{
|
||||
"thumbprint": "AABBCCDD",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("validate failed: %v", err)
|
||||
}
|
||||
if !result.Valid {
|
||||
t.Error("expected valid=true")
|
||||
}
|
||||
if result.Metadata["thumbprint"] != "AABBCCDD" {
|
||||
t.Errorf("expected thumbprint AABBCCDD, got: %s", result.Metadata["thumbprint"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeployment_NotFound(t *testing.T) {
|
||||
mock := &mockExecutor{
|
||||
responses: []string{"NOT_FOUND"},
|
||||
}
|
||||
c := NewWithExecutor(&Config{}, testLogger(), mock)
|
||||
|
||||
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
Serial: "01",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for not found cert")
|
||||
}
|
||||
if result.Valid {
|
||||
t.Error("expected valid=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeployment_QueryFailed(t *testing.T) {
|
||||
mock := &mockExecutor{
|
||||
responses: []string{"error"},
|
||||
errors: []error{fmt.Errorf("powershell error")},
|
||||
}
|
||||
c := NewWithExecutor(&Config{}, testLogger(), mock)
|
||||
|
||||
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
Serial: "01",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for query failure")
|
||||
}
|
||||
if result.Valid {
|
||||
t.Error("expected valid=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeployment_BySerial(t *testing.T) {
|
||||
mock := &mockExecutor{
|
||||
responses: []string{"FOUND:AABB:2027-01-01T00:00:00"},
|
||||
}
|
||||
c := NewWithExecutor(&Config{}, testLogger(), mock)
|
||||
|
||||
// No thumbprint in metadata — should query by serial
|
||||
_, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
Serial: "DEADBEEF",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("validate failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(mock.scripts[0], "SerialNumber") {
|
||||
t.Error("expected serial number query in script")
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ const (
|
||||
IssuerTypeDigiCert IssuerType = "DigiCert"
|
||||
IssuerTypeSectigo IssuerType = "Sectigo"
|
||||
IssuerTypeGoogleCAS IssuerType = "GoogleCAS"
|
||||
IssuerTypeAWSACMPCA IssuerType = "AWSACMPCA"
|
||||
)
|
||||
|
||||
// TargetType represents the type of deployment target.
|
||||
@@ -97,5 +98,8 @@ const (
|
||||
TargetTypeEnvoy TargetType = "Envoy"
|
||||
TargetTypePostfix TargetType = "Postfix"
|
||||
TargetTypeDovecot TargetType = "Dovecot"
|
||||
TargetTypeSSH TargetType = "SSH"
|
||||
TargetTypeSSH TargetType = "SSH"
|
||||
TargetTypeWinCertStore TargetType = "WinCertStore"
|
||||
TargetTypeJavaKeystore TargetType = "JavaKeystore"
|
||||
TargetTypeKubernetesSecrets TargetType = "KubernetesSecrets"
|
||||
)
|
||||
|
||||
@@ -90,6 +90,7 @@ var validIssuerTypes = map[domain.IssuerType]bool{
|
||||
domain.IssuerTypeDigiCert: true,
|
||||
domain.IssuerTypeSectigo: true,
|
||||
domain.IssuerTypeGoogleCAS: true,
|
||||
domain.IssuerTypeAWSACMPCA: true,
|
||||
}
|
||||
|
||||
// isValidIssuerType checks if a type string is a known issuer type.
|
||||
@@ -482,6 +483,26 @@ func (s *IssuerService) buildEnvVarSeeds(cfg *config.Config) []*domain.Issuer {
|
||||
})
|
||||
}
|
||||
|
||||
// Conditional: AWS ACM PCA
|
||||
if cfg.AWSACMPCA.CAArn != "" {
|
||||
seeds = append(seeds, &domain.Issuer{
|
||||
ID: "iss-awsacmpca",
|
||||
Name: "AWS ACM Private CA",
|
||||
Type: domain.IssuerTypeAWSACMPCA,
|
||||
Config: mustJSON(map[string]interface{}{
|
||||
"region": cfg.AWSACMPCA.Region,
|
||||
"ca_arn": cfg.AWSACMPCA.CAArn,
|
||||
"signing_algorithm": cfg.AWSACMPCA.SigningAlgorithm,
|
||||
"validity_days": cfg.AWSACMPCA.ValidityDays,
|
||||
"template_arn": cfg.AWSACMPCA.TemplateArn,
|
||||
}),
|
||||
Enabled: true,
|
||||
Source: "env",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
return seeds
|
||||
}
|
||||
|
||||
@@ -536,6 +557,11 @@ func (s *IssuerService) CreateIssuer(iss domain.Issuer) (*domain.Issuer, error)
|
||||
if iss.Source == "" {
|
||||
iss.Source = "database"
|
||||
}
|
||||
// GUI-created issuers should be enabled by default.
|
||||
// Go's bool zero value is false, which overrides the DB default when explicitly inserted.
|
||||
if iss.Source == "database" && !iss.Enabled {
|
||||
iss.Enabled = true
|
||||
}
|
||||
|
||||
// Encrypt config
|
||||
if len(iss.Config) > 0 {
|
||||
|
||||
@@ -24,7 +24,10 @@ var validTargetTypes = map[domain.TargetType]bool{
|
||||
domain.TargetTypeEnvoy: true,
|
||||
domain.TargetTypePostfix: true,
|
||||
domain.TargetTypeDovecot: true,
|
||||
domain.TargetTypeSSH: true,
|
||||
domain.TargetTypeSSH: true,
|
||||
domain.TargetTypeWinCertStore: true,
|
||||
domain.TargetTypeJavaKeystore: true,
|
||||
domain.TargetTypeKubernetesSecrets: true,
|
||||
}
|
||||
|
||||
// isValidTargetType checks if a type string is a known target type.
|
||||
@@ -282,6 +285,11 @@ func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.De
|
||||
if target.Source == "" {
|
||||
target.Source = "database"
|
||||
}
|
||||
// GUI-created targets should be enabled by default.
|
||||
// Go's bool zero value is false, which overrides the DB default when explicitly inserted.
|
||||
if target.Source == "database" && !target.Enabled {
|
||||
target.Enabled = true
|
||||
}
|
||||
|
||||
// Encrypt config
|
||||
if len(target.Config) > 0 {
|
||||
|
||||
@@ -47,7 +47,8 @@ INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) VA
|
||||
('iss-vault', 'HashiCorp Vault PKI', 'VaultPKI', '{"addr": "https://vault.internal:8200", "mount": "pki", "role": "web-certs", "ttl": "8760h"}', true, NOW() - INTERVAL '20 days', NOW() - INTERVAL '20 days'),
|
||||
('iss-digicert', 'DigiCert CertCentral', 'DigiCert', '{"base_url": "https://www.digicert.com/services/v2", "product_type": "ssl_basic"}', true, NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days'),
|
||||
('iss-sectigo', 'Sectigo SCM', 'Sectigo', '{"base_url": "https://cert-manager.com/api", "cert_type": 423, "term": 365}', true, NOW() - INTERVAL '10 days', NOW() - INTERVAL '10 days'),
|
||||
('iss-googlecas','Google CAS', 'GoogleCAS', '{"project": "demo-project", "location": "us-central1", "ca_pool": "demo-pool"}', false, NOW() - INTERVAL '5 days', NOW() - INTERVAL '5 days')
|
||||
('iss-googlecas','Google CAS', 'GoogleCAS', '{"project": "demo-project", "location": "us-central1", "ca_pool": "demo-pool"}', false, NOW() - INTERVAL '5 days', NOW() - INTERVAL '5 days'),
|
||||
('iss-awsacmpca','AWS ACM Private CA', 'AWSACMPCA', '{"region": "us-east-1", "ca_arn": "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/demo", "signing_algorithm": "SHA256WITHRSA", "validity_days": 365}', false, NOW() - INTERVAL '3 days', NOW() - INTERVAL '3 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
|
||||
@@ -169,6 +169,9 @@ export const getNotifications = (params: Record<string, string> = {}) => {
|
||||
return fetchJSON<PaginatedResponse<Notification>>(`${BASE}/notifications?${qs}`);
|
||||
};
|
||||
|
||||
export const getNotification = (id: string) =>
|
||||
fetchJSON<Notification>(`${BASE}/notifications/${id}`);
|
||||
|
||||
export const markNotificationRead = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/notifications/${id}/read`, { method: 'POST' });
|
||||
|
||||
@@ -178,6 +181,9 @@ export const getAuditEvents = (params: Record<string, string> = {}) => {
|
||||
return fetchJSON<PaginatedResponse<AuditEvent>>(`${BASE}/audit?${qs}`);
|
||||
};
|
||||
|
||||
export const getAuditEvent = (id: string) =>
|
||||
fetchJSON<AuditEvent>(`${BASE}/audit/${id}`);
|
||||
|
||||
// Policies
|
||||
export const getPolicies = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
|
||||
+8
-10
@@ -10,11 +10,11 @@ export interface Certificate {
|
||||
team_id: string;
|
||||
renewal_policy_id: string;
|
||||
certificate_profile_id: string;
|
||||
serial_number: string;
|
||||
fingerprint: string;
|
||||
key_algorithm: string;
|
||||
key_size: number;
|
||||
issued_at: string;
|
||||
serial_number?: string;
|
||||
fingerprint_sha256?: string;
|
||||
key_algorithm?: string;
|
||||
key_size?: number;
|
||||
issued_at?: string;
|
||||
expires_at: string;
|
||||
revoked_at?: string;
|
||||
revocation_reason?: string;
|
||||
@@ -40,11 +40,9 @@ export const REVOCATION_REASONS = [
|
||||
export interface CertificateVersion {
|
||||
id: string;
|
||||
certificate_id: string;
|
||||
version: number;
|
||||
serial_number: string;
|
||||
fingerprint: string;
|
||||
cert_pem: string;
|
||||
chain_pem: string;
|
||||
fingerprint_sha256: string;
|
||||
pem_chain: string;
|
||||
csr_pem: string;
|
||||
not_before: string;
|
||||
not_after: string;
|
||||
@@ -80,7 +78,7 @@ export interface Job {
|
||||
status: string;
|
||||
attempts: number;
|
||||
max_attempts: number;
|
||||
error_message: string;
|
||||
last_error?: string;
|
||||
scheduled_at: string;
|
||||
started_at: string;
|
||||
completed_at: string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export function formatDate(iso: string): string {
|
||||
export function formatDate(iso: string | undefined | null): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string): string {
|
||||
export function formatDateTime(iso: string | undefined | null): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
@@ -29,19 +29,23 @@ export interface IssuerTypeConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical type label map. Keys match what the backend API returns.
|
||||
* DB stores: local, acme, stepca, openssl, VaultPKI, DigiCert
|
||||
* Canonical type label map. Keys MUST match backend IssuerType constants
|
||||
* defined in internal/domain/connector.go (e.g., "ACME", "GenericCA", "StepCA").
|
||||
*/
|
||||
export const typeLabels: Record<string, string> = {
|
||||
local: 'Local CA',
|
||||
GenericCA: 'Local CA',
|
||||
local: 'Local CA', // backward compat for old DB records
|
||||
local_ca: 'Local CA', // backward compat (some frontend references)
|
||||
acme: 'ACME',
|
||||
stepca: 'step-ca',
|
||||
openssl: 'OpenSSL/Custom',
|
||||
ACME: 'ACME',
|
||||
acme: 'ACME', // backward compat for old DB records
|
||||
StepCA: 'step-ca',
|
||||
stepca: 'step-ca', // backward compat for old DB records
|
||||
OpenSSL: 'OpenSSL/Custom',
|
||||
openssl: 'OpenSSL/Custom', // backward compat for old DB records
|
||||
VaultPKI: 'Vault PKI',
|
||||
DigiCert: 'DigiCert',
|
||||
Sectigo: 'Sectigo SCM',
|
||||
manual: 'Manual',
|
||||
GoogleCAS: 'Google CAS',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -50,7 +54,7 @@ export const typeLabels: Record<string, string> = {
|
||||
*/
|
||||
export const issuerTypes: IssuerTypeConfig[] = [
|
||||
{
|
||||
id: 'acme',
|
||||
id: 'ACME',
|
||||
name: 'ACME',
|
||||
description: "Let's Encrypt, ZeroSSL, or any ACME-compatible CA",
|
||||
icon: '\uD83D\uDD12',
|
||||
@@ -64,7 +68,7 @@ export const issuerTypes: IssuerTypeConfig[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'local',
|
||||
id: 'GenericCA',
|
||||
name: 'Local CA',
|
||||
description: 'Self-signed or subordinate CA for internal certificates',
|
||||
icon: '\uD83C\uDFE0',
|
||||
@@ -74,14 +78,15 @@ export const issuerTypes: IssuerTypeConfig[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stepca',
|
||||
id: 'StepCA',
|
||||
name: 'step-ca',
|
||||
description: 'Smallstep private CA with JWK provisioner auth',
|
||||
icon: '\uD83D\uDC63',
|
||||
configFields: [
|
||||
{ key: 'ca_url', label: 'CA URL', placeholder: 'https://ca.example.com', required: true },
|
||||
{ key: 'provisioner_name', label: 'Provisioner Name', placeholder: 'my-provisioner', required: true },
|
||||
{ key: 'provisioner_key', label: 'Provisioner Key (JWK)', placeholder: '{...}', type: 'textarea', required: true, sensitive: true },
|
||||
{ key: 'provisioner_key_path', label: 'Provisioner Key Path', placeholder: '/path/to/provisioner.key', required: false, sensitive: true },
|
||||
{ key: 'provisioner_password', label: 'Provisioner Password', placeholder: 'Password for encrypted key', required: false, type: 'password', sensitive: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -110,7 +115,7 @@ export const issuerTypes: IssuerTypeConfig[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'openssl',
|
||||
id: 'OpenSSL',
|
||||
name: 'OpenSSL/Custom',
|
||||
description: 'Script-based signing with your own CA',
|
||||
icon: '\uD83D\uDD27',
|
||||
@@ -149,6 +154,19 @@ export const issuerTypes: IssuerTypeConfig[] = [
|
||||
{ key: 'ttl', label: 'Default TTL', required: false, placeholder: '8760h' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'AWSACMPCA',
|
||||
name: 'AWS ACM Private CA',
|
||||
description: 'AWS Certificate Manager Private Certificate Authority \u2014 managed private CA on AWS',
|
||||
icon: '\u2601\uFE0F',
|
||||
configFields: [
|
||||
{ key: 'region', label: 'AWS Region', required: true, placeholder: 'us-east-1' },
|
||||
{ key: 'ca_arn', label: 'CA ARN', required: true, placeholder: 'arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/...' },
|
||||
{ key: 'signing_algorithm', label: 'Signing Algorithm', required: false, type: 'select', options: ['SHA256WITHRSA', 'SHA384WITHRSA', 'SHA512WITHRSA', 'SHA256WITHECDSA', 'SHA384WITHECDSA', 'SHA512WITHECDSA'], defaultValue: 'SHA256WITHRSA' },
|
||||
{ key: 'validity_days', label: 'Validity (days)', required: false, type: 'number', placeholder: '365' },
|
||||
{ key: 'template_arn', label: 'Template ARN (optional)', required: false, placeholder: 'arn:aws:acm-pca:...:template/...' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'entrust',
|
||||
name: 'Entrust',
|
||||
@@ -188,7 +206,10 @@ export function getIssuerCatalogStatus(
|
||||
}
|
||||
// Match both the canonical id and common aliases
|
||||
const aliases: Record<string, string[]> = {
|
||||
local: ['local', 'local_ca'],
|
||||
GenericCA: ['GenericCA', 'local', 'local_ca'],
|
||||
ACME: ['ACME', 'acme'],
|
||||
StepCA: ['StepCA', 'stepca'],
|
||||
OpenSSL: ['OpenSSL', 'openssl'],
|
||||
};
|
||||
const matchIds = aliases[t.id] || [t.id];
|
||||
const matching = configuredIssuers.filter(i => matchIds.includes(i.type));
|
||||
|
||||
@@ -60,7 +60,7 @@ function TimelineStep({ label, status, time, isLast }: { label: string; status:
|
||||
);
|
||||
}
|
||||
|
||||
function DeploymentTimeline({ certId, certStatus, createdAt, issuedAt }: { certId: string; certStatus: string; createdAt: string; issuedAt: string }) {
|
||||
function DeploymentTimeline({ certId, certStatus, createdAt, issuedAt }: { certId: string; certStatus: string; createdAt: string; issuedAt?: string }) {
|
||||
const { data: jobsData } = useQuery({
|
||||
queryKey: ['jobs', { certificate_id: certId }],
|
||||
queryFn: () => getJobs({ certificate_id: certId }),
|
||||
@@ -372,6 +372,12 @@ export default function CertificateDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Derive certificate metadata from latest version (backend doesn't include these on the cert object)
|
||||
const latestVersion = versions?.data?.[0];
|
||||
const serialNumber = cert.serial_number || latestVersion?.serial_number;
|
||||
const fingerprintSha256 = cert.fingerprint_sha256 || latestVersion?.fingerprint_sha256;
|
||||
const issuedAt = cert.issued_at || latestVersion?.not_before;
|
||||
|
||||
const days = daysUntil(cert.expires_at);
|
||||
const isRevoked = cert.status === 'Revoked';
|
||||
const isArchived = cert.status === 'Archived';
|
||||
@@ -492,7 +498,7 @@ export default function CertificateDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Deployment Status Timeline */}
|
||||
<DeploymentTimeline certId={id!} certStatus={cert.status} createdAt={cert.created_at} issuedAt={cert.issued_at} />
|
||||
<DeploymentTimeline certId={id!} certStatus={cert.status} createdAt={cert.created_at} issuedAt={issuedAt} />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Certificate Info */}
|
||||
@@ -518,9 +524,9 @@ export default function CertificateDetailPage() {
|
||||
})}
|
||||
</span>
|
||||
) : '—'} />
|
||||
<InfoRow label="Serial Number" value={cert.serial_number || '—'} />
|
||||
<InfoRow label="Serial Number" value={serialNumber || '—'} />
|
||||
<InfoRow label="Fingerprint" value={
|
||||
cert.fingerprint ? <span className="font-mono text-xs">{cert.fingerprint.slice(0, 24)}...</span> : '—'
|
||||
fingerprintSha256 ? <span className="font-mono text-xs">{fingerprintSha256.slice(0, 24)}...</span> : '—'
|
||||
} />
|
||||
<InfoRow label="Key Algorithm" value={cert.key_algorithm || '—'} />
|
||||
<InfoRow label="Key Size" value={cert.key_size ? `${cert.key_size} bits` : '—'} />
|
||||
@@ -556,7 +562,7 @@ export default function CertificateDetailPage() {
|
||||
{/* Lifecycle */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Lifecycle</h3>
|
||||
<InfoRow label="Issued" value={formatDate(cert.issued_at)} />
|
||||
<InfoRow label="Issued" value={formatDate(issuedAt)} />
|
||||
<InfoRow label="Expires" value={
|
||||
<span className={isRevoked ? 'text-red-600 line-through' : expiryColor(days)}>
|
||||
{formatDate(cert.expires_at)} ({days <= 0 ? 'expired' : `${days} days`})
|
||||
@@ -615,7 +621,7 @@ export default function CertificateDetailPage() {
|
||||
<div key={v.id} className="flex items-center justify-between py-2 border-b border-surface-border/50 last:border-0">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-ink">Version {v.version}</span>
|
||||
<span className="text-sm text-ink">Version {versions.data.length - idx}</span>
|
||||
{idx === 0 && <span className="text-xs bg-brand-100 text-brand-700 px-1.5 py-0.5 rounded">Current</span>}
|
||||
</div>
|
||||
<div className="text-xs text-ink-faint font-mono">{v.serial_number}</div>
|
||||
|
||||
@@ -114,9 +114,9 @@ export default function JobDetailPage() {
|
||||
} />
|
||||
)}
|
||||
<InfoRow label="Attempts" value={`${job.attempts} / ${job.max_attempts}`} />
|
||||
{job.error_message && (
|
||||
{job.last_error && (
|
||||
<InfoRow label="Error" value={
|
||||
<span className="text-red-600 text-xs">{job.error_message}</span>
|
||||
<span className="text-red-600 text-xs">{job.last_error}</span>
|
||||
} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -139,9 +139,9 @@ export default function JobsPage() {
|
||||
{
|
||||
key: 'error',
|
||||
label: 'Error',
|
||||
render: (j) => j.status === 'Failed' && j.error_message ? (
|
||||
<span className="text-xs text-red-600 truncate max-w-[200px] inline-block" title={j.error_message}>
|
||||
{j.error_message.length > 80 ? j.error_message.substring(0, 80) + '...' : j.error_message}
|
||||
render: (j) => j.status === 'Failed' && j.last_error ? (
|
||||
<span className="text-xs text-red-600 truncate max-w-[200px] inline-block" title={j.last_error}>
|
||||
{j.last_error.length > 80 ? j.last_error.substring(0, 80) + '...' : j.last_error}
|
||||
</span>
|
||||
) : <span className="text-xs text-ink-faint">—</span>,
|
||||
},
|
||||
|
||||
@@ -22,6 +22,9 @@ const typeLabels: Record<string, string> = {
|
||||
Postfix: 'Postfix',
|
||||
Dovecot: 'Dovecot',
|
||||
SSH: 'SSH',
|
||||
WinCertStore: 'Windows Cert Store',
|
||||
JavaKeystore: 'Java Keystore',
|
||||
KubernetesSecrets: 'Kubernetes Secrets',
|
||||
};
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
@@ -229,7 +232,7 @@ export default function TargetDetailPage() {
|
||||
{target.config && Object.keys(target.config).length > 0 ? (
|
||||
<div className="space-y-0">
|
||||
{Object.entries(target.config).map(([key, val]) => {
|
||||
const sensitiveKeys = ['password', 'secret', 'token', 'key', 'winrm_password'];
|
||||
const sensitiveKeys = ['password', 'secret', 'token', 'key', 'passphrase', 'winrm_password', 'keystore_password'];
|
||||
const isSensitive = sensitiveKeys.some(s => key.toLowerCase().includes(s));
|
||||
const displayVal = isSensitive && val ? '********' : String(val);
|
||||
return (
|
||||
|
||||
+106
-11
@@ -22,6 +22,9 @@ const typeLabels: Record<string, string> = {
|
||||
F5: 'F5 BIG-IP',
|
||||
IIS: 'IIS',
|
||||
SSH: 'SSH',
|
||||
WinCertStore: 'Windows Cert Store',
|
||||
JavaKeystore: 'Java Keystore',
|
||||
KubernetesSecrets: 'Kubernetes Secrets',
|
||||
};
|
||||
|
||||
const TARGET_TYPES = [
|
||||
@@ -36,6 +39,9 @@ const TARGET_TYPES = [
|
||||
{ value: 'F5', label: 'F5 BIG-IP', description: 'iControl REST — cert upload, SSL profile update via proxy agent' },
|
||||
{ value: 'IIS', label: 'IIS', description: 'Windows IIS via agent-local PowerShell or remote WinRM proxy agent' },
|
||||
{ value: 'SSH', label: 'SSH', description: 'Agentless deployment via SSH/SFTP — deploy to any Linux/Unix server without installing an agent' },
|
||||
{ value: 'WinCertStore', label: 'Windows Cert Store', description: 'Import certificates into Windows Certificate Store for Exchange, RDP, SQL Server, ADFS' },
|
||||
{ value: 'JavaKeystore', label: 'Java Keystore', description: 'Deploy to JKS/PKCS#12 keystores for Tomcat, Jetty, Kafka, Elasticsearch, and JVM services' },
|
||||
{ value: 'KubernetesSecrets', label: 'Kubernetes Secrets', description: 'Deploy as kubernetes.io/tls Secrets for Ingress controllers, service meshes, and workloads' },
|
||||
];
|
||||
|
||||
const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: string; required?: boolean }[]> = {
|
||||
@@ -43,18 +49,20 @@ const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: s
|
||||
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/nginx/ssl/cert.pem', required: true },
|
||||
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/nginx/ssl/key.pem', required: true },
|
||||
{ key: 'chain_path', label: 'Chain Path', placeholder: '/etc/nginx/ssl/chain.pem' },
|
||||
{ key: 'reload_cmd', label: 'Reload Command', placeholder: 'nginx -t && systemctl reload nginx' },
|
||||
{ key: 'reload_command', label: 'Reload Command', placeholder: 'nginx -s reload' },
|
||||
{ key: 'validate_command', label: 'Validate Command', placeholder: 'nginx -t' },
|
||||
],
|
||||
Apache: [
|
||||
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/apache2/ssl/cert.pem', required: true },
|
||||
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/apache2/ssl/key.pem', required: true },
|
||||
{ key: 'chain_path', label: 'Chain Path', placeholder: '/etc/apache2/ssl/chain.pem' },
|
||||
{ key: 'reload_cmd', label: 'Reload Command', placeholder: 'apachectl configtest && apachectl graceful' },
|
||||
{ key: 'reload_command', label: 'Reload Command', placeholder: 'apachectl graceful' },
|
||||
{ key: 'validate_command', label: 'Validate Command', placeholder: 'apachectl configtest' },
|
||||
],
|
||||
HAProxy: [
|
||||
{ key: 'pem_path', label: 'Combined PEM Path', placeholder: '/etc/haproxy/certs/combined.pem', required: true },
|
||||
{ key: 'reload_cmd', label: 'Reload Command', placeholder: 'systemctl reload haproxy' },
|
||||
{ key: 'validate_cmd', label: 'Validate Command (optional)', placeholder: 'haproxy -c -f /etc/haproxy/haproxy.cfg' },
|
||||
{ key: 'reload_command', label: 'Reload Command', placeholder: 'systemctl reload haproxy' },
|
||||
{ key: 'validate_command', label: 'Validate Command (optional)', placeholder: 'haproxy -c -f /etc/haproxy/haproxy.cfg' },
|
||||
],
|
||||
Traefik: [
|
||||
{ key: 'cert_dir', label: 'Certificate Directory', placeholder: '/etc/traefik/certs', required: true },
|
||||
@@ -83,6 +91,7 @@ const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: s
|
||||
{ key: 'validate_command', label: 'Validate Command', placeholder: 'postfix check' },
|
||||
],
|
||||
Dovecot: [
|
||||
{ key: 'mode', label: 'Mode', placeholder: 'dovecot (auto-set)' },
|
||||
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/dovecot/certs/cert.pem' },
|
||||
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/dovecot/certs/key.pem' },
|
||||
{ key: 'chain_path', label: 'Chain Path (optional)', placeholder: '/etc/dovecot/certs/chain.pem' },
|
||||
@@ -100,6 +109,7 @@ const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: s
|
||||
{ key: 'timeout', label: 'Timeout (seconds)', placeholder: '30' },
|
||||
],
|
||||
IIS: [
|
||||
{ key: 'hostname', label: 'Target Hostname', placeholder: 'iis-server.example.com' },
|
||||
{ key: 'site_name', label: 'IIS Site Name', placeholder: 'Default Web Site', required: true },
|
||||
{ key: 'cert_store', label: 'Certificate Store', placeholder: 'My', required: true },
|
||||
{ key: 'port', label: 'HTTPS Port', placeholder: '443' },
|
||||
@@ -107,12 +117,13 @@ const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: s
|
||||
{ key: 'binding_info', label: 'Host Header (SNI)', placeholder: 'www.example.com' },
|
||||
{ key: 'sni', label: 'Enable SNI', placeholder: 'true or false' },
|
||||
{ key: 'mode', label: 'Deployment Mode', placeholder: 'local (default) or winrm' },
|
||||
{ key: 'winrm.winrm_host', label: 'WinRM Host (remote mode)', placeholder: 'iis-server.example.com' },
|
||||
{ key: 'winrm.winrm_port', label: 'WinRM Port', placeholder: '5985 (HTTP) or 5986 (HTTPS)' },
|
||||
{ key: 'winrm.winrm_username', label: 'WinRM Username', placeholder: 'Administrator' },
|
||||
{ key: 'winrm.winrm_password', label: 'WinRM Password', placeholder: '(sensitive)' },
|
||||
{ key: 'winrm.winrm_https', label: 'WinRM Use HTTPS', placeholder: 'true or false' },
|
||||
{ key: 'winrm.winrm_insecure', label: 'WinRM Skip TLS Verify', placeholder: 'false' },
|
||||
{ key: 'winrm_host', label: 'WinRM Host (remote mode)', placeholder: 'iis-server.example.com' },
|
||||
{ key: 'winrm_port', label: 'WinRM Port', placeholder: '5985 (HTTP) or 5986 (HTTPS)' },
|
||||
{ key: 'winrm_username', label: 'WinRM Username', placeholder: 'Administrator' },
|
||||
{ key: 'winrm_password', label: 'WinRM Password', placeholder: '(sensitive)' },
|
||||
{ key: 'winrm_https', label: 'WinRM Use HTTPS', placeholder: 'true or false' },
|
||||
{ key: 'winrm_insecure', label: 'WinRM Skip TLS Verify', placeholder: 'false' },
|
||||
{ key: 'winrm_timeout', label: 'WinRM Timeout (seconds)', placeholder: '60' },
|
||||
],
|
||||
SSH: [
|
||||
{ key: 'host', label: 'SSH Host', placeholder: '192.168.1.100 or server.example.com', required: true },
|
||||
@@ -120,13 +131,45 @@ const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: s
|
||||
{ key: 'user', label: 'SSH Username', placeholder: 'root or certctl', required: true },
|
||||
{ key: 'auth_method', label: 'Auth Method', placeholder: 'key (default) or password' },
|
||||
{ key: 'private_key_path', label: 'Private Key Path', placeholder: '/home/certctl/.ssh/id_ed25519' },
|
||||
{ key: 'private_key', label: 'Inline Private Key PEM', placeholder: 'Paste PEM key (alternative to path)' },
|
||||
{ key: 'password', label: 'SSH Password', placeholder: 'Leave empty for key auth' },
|
||||
{ key: 'passphrase', label: 'Key Passphrase', placeholder: 'For encrypted private keys' },
|
||||
{ key: 'cert_path', label: 'Remote Certificate Path', placeholder: '/etc/ssl/certs/cert.pem', required: true },
|
||||
{ key: 'key_path', label: 'Remote Key Path', placeholder: '/etc/ssl/private/key.pem', required: true },
|
||||
{ key: 'chain_path', label: 'Remote Chain Path (optional)', placeholder: '/etc/ssl/certs/chain.pem' },
|
||||
{ key: 'cert_mode', label: 'Cert File Permissions', placeholder: '0644 (default)' },
|
||||
{ key: 'key_mode', label: 'Key File Permissions', placeholder: '0600 (default)' },
|
||||
{ key: 'reload_command', label: 'Reload Command (optional)', placeholder: 'systemctl reload nginx' },
|
||||
{ key: 'timeout', label: 'Connection Timeout (seconds)', placeholder: '30 (default)' },
|
||||
],
|
||||
WinCertStore: [
|
||||
{ key: 'store_name', label: 'Certificate Store', placeholder: 'My (default)', required: true },
|
||||
{ key: 'store_location', label: 'Store Location', placeholder: 'LocalMachine (default) or CurrentUser' },
|
||||
{ key: 'friendly_name', label: 'Friendly Name (optional)', placeholder: 'My Production Cert' },
|
||||
{ key: 'remove_expired', label: 'Remove Expired Certs', placeholder: 'false (default)' },
|
||||
{ key: 'mode', label: 'Deployment Mode', placeholder: 'local (default) or winrm' },
|
||||
{ key: 'winrm_host', label: 'WinRM Host (remote mode)', placeholder: 'win-server.example.com' },
|
||||
{ key: 'winrm_port', label: 'WinRM Port', placeholder: '5985 (HTTP) or 5986 (HTTPS)' },
|
||||
{ key: 'winrm_username', label: 'WinRM Username', placeholder: 'Administrator' },
|
||||
{ key: 'winrm_password', label: 'WinRM Password', placeholder: '(sensitive)' },
|
||||
{ key: 'winrm_https', label: 'WinRM Use HTTPS', placeholder: 'true or false' },
|
||||
{ key: 'winrm_insecure', label: 'WinRM Skip TLS Verify', placeholder: 'false' },
|
||||
],
|
||||
JavaKeystore: [
|
||||
{ key: 'keystore_path', label: 'Keystore Path', placeholder: '/opt/app/conf/keystore.p12', required: true },
|
||||
{ key: 'keystore_password', label: 'Keystore Password', placeholder: 'changeit', required: true },
|
||||
{ key: 'keystore_type', label: 'Keystore Type', placeholder: 'PKCS12 (default) or JKS' },
|
||||
{ key: 'alias', label: 'Key Alias', placeholder: 'server (default)' },
|
||||
{ key: 'create_keystore', label: 'Create Keystore If Missing', placeholder: 'true (default)' },
|
||||
{ key: 'reload_command', label: 'Reload Command (optional)', placeholder: 'systemctl restart tomcat' },
|
||||
{ key: 'keytool_path', label: 'Keytool Path (optional)', placeholder: 'keytool (default, from PATH)' },
|
||||
],
|
||||
KubernetesSecrets: [
|
||||
{ key: 'namespace', label: 'Namespace', placeholder: 'default', required: true },
|
||||
{ key: 'secret_name', label: 'Secret Name', placeholder: 'my-tls-secret', required: true },
|
||||
{ key: 'labels', label: 'Labels (JSON)', placeholder: '{"app": "my-app"}' },
|
||||
{ key: 'kubeconfig_path', label: 'Kubeconfig Path (optional)', placeholder: '/home/agent/.kube/config' },
|
||||
],
|
||||
};
|
||||
|
||||
function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
||||
@@ -137,12 +180,64 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
|
||||
const [config, setConfig] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Fields that backends expect as boolean (Go bool)
|
||||
const BOOL_FIELDS = new Set([
|
||||
'sni', 'insecure', 'sds_config', 'remove_expired', 'create_keystore',
|
||||
'winrm_https', 'winrm_insecure',
|
||||
]);
|
||||
// Fields that backends expect as integer (Go int)
|
||||
const INT_FIELDS = new Set([
|
||||
'port', 'timeout', 'winrm_port', 'winrm_timeout', 'timeout_seconds',
|
||||
]);
|
||||
|
||||
// Coerce string form values to their Go types
|
||||
const coerceValue = (key: string, val: string): unknown => {
|
||||
if (BOOL_FIELDS.has(key)) return val === 'true';
|
||||
if (INT_FIELDS.has(key)) { const n = parseInt(val, 10); return isNaN(n) ? val : n; }
|
||||
return val;
|
||||
};
|
||||
|
||||
// Build config payload with type-specific transformations
|
||||
const buildConfigPayload = () => {
|
||||
const flat = Object.fromEntries(Object.entries(config).filter(([, v]) => v));
|
||||
|
||||
// Dovecot uses the same Postfix connector with mode="dovecot"
|
||||
if (targetType === 'Dovecot' && !flat['mode']) {
|
||||
flat['mode'] = 'dovecot';
|
||||
}
|
||||
|
||||
// IIS backend expects WinRM fields nested under "winrm" key
|
||||
if (targetType === 'IIS') {
|
||||
const iisWinrmKeys = ['winrm_host', 'winrm_port', 'winrm_username', 'winrm_password', 'winrm_https', 'winrm_insecure', 'winrm_timeout'];
|
||||
const winrmObj: Record<string, unknown> = {};
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(flat)) {
|
||||
if (iisWinrmKeys.includes(k)) {
|
||||
winrmObj[k] = coerceValue(k, v);
|
||||
} else {
|
||||
result[k] = coerceValue(k, v);
|
||||
}
|
||||
}
|
||||
if (Object.keys(winrmObj).length > 0) {
|
||||
result['winrm'] = winrmObj;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// All other target types: coerce values to proper Go types
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(flat)) {
|
||||
result[k] = coerceValue(k, v);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => createTarget({
|
||||
name,
|
||||
type: targetType,
|
||||
agent_id: agentId,
|
||||
config: Object.fromEntries(Object.entries(config).filter(([, v]) => v)),
|
||||
config: buildConfigPayload(),
|
||||
}),
|
||||
onSuccess: () => onSuccess(),
|
||||
onError: (err: Error) => setError(err.message),
|
||||
|
||||
Reference in New Issue
Block a user