Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f92d148881 | |||
| 50c520e1ff | |||
| 8380cb7946 | |||
| 6d8ab54f46 | |||
| e19c240a79 | |||
| 5c38bc3bfe | |||
| b5687aece8 | |||
| cdb6ebdb6a | |||
| bb85f1a56e | |||
| 44c4d89011 | |||
| eaccbcdcf1 | |||
| 4e3cff0729 | |||
| 09c819d424 | |||
| 29b55bfd01 | |||
| 4092bdfb1a | |||
| 743dca2fb3 | |||
| 92bba64772 | |||
| 7d14635a72 | |||
| 58aa217428 | |||
| a05dba49f7 | |||
| 3efe86e29e | |||
| c0320c35f0 | |||
| 0f4a1b268b | |||
| 3eb4749b4d | |||
| 983ab56662 | |||
| 90bdb8c329 | |||
| d185e317df | |||
| 72cda5877a |
@@ -65,8 +65,8 @@ jobs:
|
|||||||
## Docker Images
|
## Docker Images
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/shankar0123/certctl-server:${{ steps.version.outputs.VERSION }}
|
docker pull shankar0123.docker.scarf.sh/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||||
docker pull ghcr.io/shankar0123/certctl-agent:${{ steps.version.outputs.VERSION }}
|
docker pull shankar0123.docker.scarf.sh/certctl-agent:${{ steps.version.outputs.VERSION }}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|||||||
@@ -1,53 +1,127 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/logo/certctl-logo.png" alt="certctl logo" width="450">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=89db181e-76e0-45cc-b9c0-790c3dfdfc73" />
|
||||||
|
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=b9379aff-9e5c-4d01-8f2d-9e4ffa09d126" />
|
||||||
|
|
||||||
# certctl — Self-Hosted Certificate Lifecycle Platform
|
# certctl — Self-Hosted Certificate Lifecycle Platform
|
||||||
|
|
||||||
TLS certificate lifespans are shrinking. The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) unanimously in April 2025, setting a phased reduction: **200 days** by March 2026, **100 days** by March 2027, and **47 days** by March 2029. Manual certificate management is no longer viable at any scale.
|
```mermaid
|
||||||
|
timeline
|
||||||
|
title TLS Certificate Maximum Lifespan (CA/Browser Forum Ballot SC-081v3)
|
||||||
|
2015 : 5 years
|
||||||
|
2018 : 825 days
|
||||||
|
2020 : 398 days
|
||||||
|
March 2026 : 200 days
|
||||||
|
March 2027 : 100 days
|
||||||
|
March 2029 : 47 days
|
||||||
|
```
|
||||||
|
|
||||||
certctl is a self-hosted platform for **end-to-end certificate lifecycle automation** — from issuance through renewal to deployment — with zero human intervention. Track every certificate in your organization, automatically renew them before they expire, and deploy them to your servers without touching a terminal. Private keys never leave your infrastructure.
|
TLS certificate lifespans are shrinking fast. The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) unanimously in April 2025, setting a phased reduction: **200 days** by March 2026, **100 days** by March 2027, and **47 days** by March 2029. Organizations managing dozens or hundreds of certificates can no longer rely on spreadsheets, calendar reminders, or manual renewal workflows. The math doesn't work — at 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever.
|
||||||
|
|
||||||
|
certctl is a self-hosted platform that automates the entire certificate lifecycle — from issuance through renewal to deployment — with zero human intervention. It works with any certificate authority, deploys to any server, and keeps private keys on your infrastructure where they belong.
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
||||||

|

|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Guide | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| [Concepts](docs/concepts.md) | TLS certificates explained from scratch — for beginners who know nothing about certs |
|
||||||
|
| [Quick Start](docs/quickstart.md) | Get running in 5 minutes — dashboard, API, CLI, discovery, stakeholder demo flow |
|
||||||
|
| [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 |
|
||||||
|
| [Connectors](docs/connectors.md) | Build custom issuer, target, and notifier connectors |
|
||||||
|
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
||||||
|
| [Manual Testing Guide](docs/testing-guide.md) | Extensively tested — full V2 QA runbook with exact commands and pass/fail criteria |
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Why certctl Exists](#why-certctl-exists)
|
||||||
|
- [What It Does](#what-it-does)
|
||||||
|
- [Screenshots](#screenshots)
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [MCP Server (AI Integration)](#mcp-server-ai-integration)
|
||||||
|
- [CLI](#cli)
|
||||||
|
- [API Overview](#api-overview)
|
||||||
|
- [Supported Integrations](#supported-integrations)
|
||||||
|
- [Development](#development)
|
||||||
|
- [Security](#security)
|
||||||
|
- [Roadmap](#roadmap)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
|
## 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, 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.
|
||||||
|
|
||||||
|
certctl fills that gap. It's **CA-agnostic** — the issuer connector interface means you can plug in any certificate authority: a self-signed local CA for dev, Let's Encrypt via ACME for public certs, Smallstep step-ca for your private PKI, your enterprise ADCS via sub-CA mode, or any custom CA through a shell script adapter. You're never locked to a single CA vendor, and you can run multiple issuers simultaneously for different certificate types.
|
||||||
|
|
||||||
|
It's also **target-agnostic**. Agents deploy certificates to NGINX, Apache, and HAProxy today, with Traefik and Caddy support coming next — all using the same pluggable connector model for any server that accepts cert files. 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.
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
certctl gives you a single pane of glass for every TLS certificate in your organization. The **web dashboard** shows your full certificate inventory — what's healthy, what's expiring, what's already expired, and who owns each one. The **REST API** (91 endpoints under `/api/v1/`) lets you automate everything. **Agents** deployed on your infrastructure generate private keys locally, discover existing certificates on disk, and submit CSRs — private keys never leave your servers. The **network scanner** discovers certificates on TLS endpoints across your infrastructure without requiring agents. The background scheduler watches expiration dates and triggers renewals automatically — when certificate lifespans drop to 47 days, certctl handles the constant rotation without human involvement.
|
certctl gives you a single pane of glass for every TLS certificate in your organization. The **web dashboard** shows your full certificate inventory — what's healthy, what's expiring, what's already expired, and who owns each one. The **REST API** (95 endpoints under `/api/v1/` + `/.well-known/est/`) lets you automate everything. **Agents** deployed on your infrastructure generate private keys locally, discover existing certificates on disk, and submit CSRs — private keys never leave your servers. The **network scanner** discovers certificates on TLS endpoints across your infrastructure without requiring agents. The **EST server** (RFC 7030) enables device and WiFi certificate enrollment via industry-standard Enrollment over Secure Transport. The background scheduler watches expiration dates and triggers renewals automatically — when certificate lifespans drop to 47 days, certctl handles the constant rotation without human involvement.
|
||||||
|
|
||||||
```mermaid
|
**Core capabilities:**
|
||||||
flowchart LR
|
|
||||||
subgraph "Control Plane"
|
|
||||||
API["REST API + Dashboard\n:8443"]
|
|
||||||
PG[("PostgreSQL")]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Your Infrastructure"
|
- **Full lifecycle automation** — issuance, renewal, deployment, and revocation with zero human intervention. Configurable renewal policies trigger jobs automatically based on expiration thresholds.
|
||||||
A1["Agent"] --> T1["NGINX"]
|
- **CA-agnostic issuer connectors** — Local CA (self-signed + sub-CA for enterprise root chains), ACME v2 with HTTP-01, DNS-01, and DNS-PERSIST-01 challenges (Let's Encrypt, Sectigo, any ACME-compatible CA), Smallstep step-ca (native /sign API), and OpenSSL/Custom CA (delegate to any shell script). Pluggable interface — add your own CA in one file.
|
||||||
A2["Agent"] --> T2["Apache / HAProxy"]
|
- **Agent-side key generation** — agents generate ECDSA P-256 keys locally, store them with 0600 permissions, and submit only the CSR. Private keys never touch the control plane. This is the default mode, not an opt-in feature.
|
||||||
A3["Agent"] --> T3["F5 · IIS"]
|
- **Certificate discovery** — agents scan filesystems for existing PEM/DER certificates and report findings for triage. The network scanner probes TLS endpoints across CIDR ranges to find certificates you didn't know existed.
|
||||||
end
|
- **Revocation infrastructure** — RFC 5280 revocation with all standard reason codes, DER-encoded X.509 CRL per issuer, embedded OCSP responder, and short-lived certificate exemption (certs under 1 hour skip CRL/OCSP).
|
||||||
|
- **Policy engine** — 5 rule types with violation tracking and severity levels. Certificate profiles enforce allowed key types, maximum TTL, and crypto constraints at enrollment time.
|
||||||
API --> PG
|
- **Immutable audit trail** — every action recorded to an append-only log. Every API call recorded with method, path, actor, SHA-256 body hash, response status, and latency. No update or delete on audit records.
|
||||||
A1 & A2 & A3 -->|"CSR + status\n(no private keys)"| API
|
- **Operational dashboard** — Full React GUI with certificate inventory, bulk operations (multi-select renew/revoke/reassign), deployment timeline visualization, inline policy editing, agent fleet overview, expiration heatmaps, and real-time short-lived credential tracking.
|
||||||
API -->|"Signed certs"| A1 & A2 & A3
|
- **Observability** — JSON and Prometheus metrics endpoints, 5 stats API endpoints for dashboards, structured slog logging with request ID propagation. Compatible with Prometheus, Grafana Agent, Datadog Agent, and Victoria Metrics.
|
||||||
API -->|"Issue/Renew"| CA["Certificate Authorities\nLocal CA · ACME · step-ca · OpenSSL"]
|
- **Notifications** — threshold-based alerting with deduplication. Routes to email, webhooks, Slack, Microsoft Teams, PagerDuty, and OpsGenie.
|
||||||
```
|
- **EST enrollment (RFC 7030)** — built-in Enrollment over Secure Transport server for device certificate enrollment. Supports WiFi/802.1X, MDM, and IoT use cases. PKCS#7 certs-only wire format, accepts PEM or base64-encoded DER CSRs, configurable issuer and profile binding.
|
||||||
|
- **Multi-purpose certificates** — certificate profiles support arbitrary EKU (Extended Key Usage) constraints. TLS (serverAuth/clientAuth) today, with S/MIME (emailProtection) and code signing support coming in v2.0.2.
|
||||||
|
- **AI and CLI access** — MCP server exposes all 78 API operations as tools for Claude, Cursor, and any MCP-compatible client. CLI tool with 12 subcommands for terminal workflows and scripting.
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
| | |
|
<table>
|
||||||
|---|---|
|
<tr>
|
||||||
|  |  |
|
<td><a href="docs/screenshots/v2-dashboard.png"><img src="docs/screenshots/v2-dashboard.png" width="270" alt="Dashboard"></a><br><b>Dashboard</b><br><sub>Stats, expiration heatmap, renewal trends</sub></td>
|
||||||
| **Dashboard** — certificate stats, expiry timeline, recent jobs | **Certificates** — full inventory with status, environment, owner filters |
|
<td><a href="docs/screenshots/v2-certificates.png"><img src="docs/screenshots/v2-certificates.png" width="270" alt="Certificates"></a><br><b>Certificates</b><br><sub>Inventory with status, owner, team filters</sub></td>
|
||||||
|  |  |
|
<td><a href="docs/screenshots/v2-agents.png"><img src="docs/screenshots/v2-agents.png" width="270" alt="Agents"></a><br><b>Agents</b><br><sub>Fleet health, OS/arch, IP, version</sub></td>
|
||||||
| **Agents** — fleet health, hostname, heartbeat tracking | **Jobs** — issuance, renewal, deployment job queue |
|
</tr>
|
||||||
|  |  |
|
<tr>
|
||||||
| **Notifications** — threshold alerts grouped by certificate | **Policies** — enforcement rules with enable/disable and delete |
|
<td><a href="docs/screenshots/v2-fleet.png"><img src="docs/screenshots/v2-fleet.png" width="270" alt="Fleet Overview"></a><br><b>Fleet Overview</b><br><sub>OS distribution, status breakdown</sub></td>
|
||||||
|  |  |
|
<td><a href="docs/screenshots/v2-jobs.png"><img src="docs/screenshots/v2-jobs.png" width="270" alt="Jobs"></a><br><b>Jobs</b><br><sub>Issuance, renewal, deployment queue</sub></td>
|
||||||
| **Issuers** — CA connectors with test connectivity | **Targets** — deployment targets (NGINX, Apache, HAProxy, F5, IIS) |
|
<td><a href="docs/screenshots/v2-notifications.png"><img src="docs/screenshots/v2-notifications.png" width="270" alt="Notifications"></a><br><b>Notifications</b><br><sub>Expiration warnings, renewal results</sub></td>
|
||||||
|  | |
|
</tr>
|
||||||
| **Audit Trail** — immutable log of every action | |
|
<tr>
|
||||||
|
<td><a href="docs/screenshots/v2-policies.png"><img src="docs/screenshots/v2-policies.png" width="270" alt="Policies"></a><br><b>Policies</b><br><sub>Ownership, lifetime, renewal rules</sub></td>
|
||||||
|
<td><a href="docs/screenshots/v2-profiles.png"><img src="docs/screenshots/v2-profiles.png" width="270" alt="Profiles"></a><br><b>Profiles</b><br><sub>Key types, max TTL, crypto constraints</sub></td>
|
||||||
|
<td><a href="docs/screenshots/v2-issuers.png"><img src="docs/screenshots/v2-issuers.png" width="270" alt="Issuers"></a><br><b>Issuers</b><br><sub>Local CA, ACME, step-ca connectors</sub></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="docs/screenshots/v2-targets.png"><img src="docs/screenshots/v2-targets.png" width="270" alt="Targets"></a><br><b>Targets</b><br><sub>NGINX, Apache, HAProxy deployment</sub></td>
|
||||||
|
<td><a href="docs/screenshots/v2-owners.png"><img src="docs/screenshots/v2-owners.png" width="270" alt="Owners"></a><br><b>Owners</b><br><sub>Cert ownership with team assignment</sub></td>
|
||||||
|
<td><a href="docs/screenshots/v2-teams.png"><img src="docs/screenshots/v2-teams.png" width="270" alt="Teams"></a><br><b>Teams</b><br><sub>Org grouping for notification routing</sub></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="docs/screenshots/v2-agent-groups.png"><img src="docs/screenshots/v2-agent-groups.png" width="270" alt="Agent Groups"></a><br><b>Agent Groups</b><br><sub>Dynamic grouping by OS, arch, CIDR</sub></td>
|
||||||
|
<td><a href="docs/screenshots/v2-audit-trail.png"><img src="docs/screenshots/v2-audit-trail.png" width="270" alt="Audit Trail"></a><br><b>Audit Trail</b><br><sub>Immutable log, CSV/JSON export</sub></td>
|
||||||
|
<td><a href="docs/screenshots/v2-short-lived.png"><img src="docs/screenshots/v2-short-lived.png" width="270" alt="Short-Lived"></a><br><b>Short-Lived Creds</b><br><sub>Ephemeral certs with live TTL countdown</sub></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
### Docker Pull
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull shankar0123.docker.scarf.sh/certctl-server
|
||||||
|
docker pull shankar0123.docker.scarf.sh/certctl-agent
|
||||||
|
```
|
||||||
|
|
||||||
### Docker Compose (Recommended)
|
### Docker Compose (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -72,7 +146,7 @@ curl -s http://localhost:8443/api/v1/certificates | jq '.total'
|
|||||||
### Manual Build
|
### Manual Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Prerequisites: Go 1.22+, PostgreSQL 16+
|
# Prerequisites: Go 1.25+, PostgreSQL 16+
|
||||||
go mod download
|
go mod download
|
||||||
make build
|
make build
|
||||||
|
|
||||||
@@ -92,45 +166,9 @@ export CERTCTL_AGENT_ID=agent-local-01
|
|||||||
./bin/agent --agent-id=agent-local-01
|
./bin/agent --agent-id=agent-local-01
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
| Guide | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| [Concepts](docs/concepts.md) | TLS certificates explained from scratch — for beginners who know nothing about certs |
|
|
||||||
| [Quick Start](docs/quickstart.md) | Get running in 5 minutes with accurate API examples |
|
|
||||||
| [Demo Walkthrough](docs/demo-guide.md) | 5-7 minute guided stakeholder presentation |
|
|
||||||
| [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 |
|
|
||||||
| [Connectors](docs/connectors.md) | Build custom issuer, target, and notifier connectors |
|
|
||||||
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
|
||||||
| [Manual Testing Guide](docs/testing-guide.md) | 284 tests across 25 areas — full V2 QA runbook with exact commands and pass/fail criteria |
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```mermaid
|
**Control plane** (Go 1.25 net/http) → **PostgreSQL 16** (21 tables, TEXT primary keys) → **Agents** (key generation, CSR submission, cert deployment). Background scheduler runs 6 loops: renewal checks (1h), job processing (30s), agent health (2m), notifications (1m), short-lived cert expiry (30s), network scanning (6h). See [Architecture Guide](docs/architecture.md) for full system diagrams and data flow.
|
||||||
flowchart TB
|
|
||||||
subgraph "Control Plane (certctl-server)"
|
|
||||||
DASH["Web Dashboard\nReact SPA"]
|
|
||||||
API["REST API\nGo 1.22 net/http"]
|
|
||||||
SVC["Service Layer"]
|
|
||||||
REPO["Repository Layer\ndatabase/sql + lib/pq"]
|
|
||||||
SCHED["Scheduler\nRenewal · Jobs · Health · Notifications · Short-Lived Expiry · Network Scan"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Data Store"
|
|
||||||
PG[("PostgreSQL 16\n21 tables\nTEXT primary keys")]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Agents"
|
|
||||||
AG["certctl-agent\nKey generation · CSR · Deployment"]
|
|
||||||
end
|
|
||||||
|
|
||||||
DASH --> API
|
|
||||||
API --> SVC --> REPO --> PG
|
|
||||||
SCHED --> SVC
|
|
||||||
AG -->|"Heartbeat + CSR"| API
|
|
||||||
API -->|"Cert + Chain"| AG
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Design Decisions
|
### Key Design Decisions
|
||||||
|
|
||||||
@@ -182,7 +220,7 @@ All server environment variables use the `CERTCTL_` prefix:
|
|||||||
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation mode: `agent` (production) or `server` (demo only) |
|
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation mode: `agent` (production) or `server` (demo only) |
|
||||||
| `CERTCTL_ACME_DIRECTORY_URL` | — | ACME directory URL (e.g., Let's Encrypt staging) |
|
| `CERTCTL_ACME_DIRECTORY_URL` | — | ACME directory URL (e.g., Let's Encrypt staging) |
|
||||||
| `CERTCTL_ACME_EMAIL` | — | Contact email for ACME account registration |
|
| `CERTCTL_ACME_EMAIL` | — | Contact email for ACME account registration |
|
||||||
| `CERTCTL_ACME_CHALLENGE_TYPE` | — | ACME challenge type: `http-01` (default) or `dns-01` |
|
| `CERTCTL_ACME_CHALLENGE_TYPE` | — | ACME challenge type: `http-01` (default), `dns-01`, or `dns-persist-01` |
|
||||||
| `CERTCTL_CA_CERT_PATH` | — | Path to CA certificate for sub-CA mode |
|
| `CERTCTL_CA_CERT_PATH` | — | Path to CA certificate for sub-CA mode |
|
||||||
| `CERTCTL_CA_KEY_PATH` | — | Path to CA private key for sub-CA mode |
|
| `CERTCTL_CA_KEY_PATH` | — | Path to CA private key for sub-CA mode |
|
||||||
| `CERTCTL_CORS_ORIGINS` | — | Comma-separated allowed CORS origins (empty = same-origin, `*` = all) |
|
| `CERTCTL_CORS_ORIGINS` | — | Comma-separated allowed CORS origins (empty = same-origin, `*` = all) |
|
||||||
@@ -194,8 +232,9 @@ All server environment variables use the `CERTCTL_` prefix:
|
|||||||
| `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | `30s` | How often the scheduler processes pending jobs |
|
| `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | `30s` | How often the scheduler processes pending jobs |
|
||||||
| `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | `2m` | How often the scheduler checks agent health |
|
| `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | `2m` | How often the scheduler checks agent health |
|
||||||
| `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | `1m` | How often the scheduler processes pending notifications |
|
| `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | `1m` | How often the scheduler processes pending notifications |
|
||||||
| `CERTCTL_ACME_DNS_PRESENT_SCRIPT` | — | Script to create DNS-01 `_acme-challenge` TXT record |
|
| `CERTCTL_ACME_DNS_PRESENT_SCRIPT` | — | Script to create DNS TXT record (`_acme-challenge` for dns-01, `_validation-persist` for dns-persist-01) |
|
||||||
| `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` | — | Script to remove DNS-01 `_acme-challenge` TXT record |
|
| `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` | — | Script to remove DNS-01 `_acme-challenge` TXT record (not used by dns-persist-01) |
|
||||||
|
| `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN` | — | CA issuer domain for dns-persist-01 (e.g., `letsencrypt.org`) |
|
||||||
| `CERTCTL_STEPCA_URL` | — | step-ca server URL |
|
| `CERTCTL_STEPCA_URL` | — | step-ca server URL |
|
||||||
| `CERTCTL_STEPCA_PROVISIONER` | — | step-ca JWK provisioner name |
|
| `CERTCTL_STEPCA_PROVISIONER` | — | step-ca JWK provisioner name |
|
||||||
| `CERTCTL_STEPCA_KEY_PATH` | — | Path to step-ca provisioner private key (JWK JSON) |
|
| `CERTCTL_STEPCA_KEY_PATH` | — | Path to step-ca provisioner private key (JWK JSON) |
|
||||||
@@ -206,6 +245,9 @@ All server environment variables use the `CERTCTL_` prefix:
|
|||||||
| `CERTCTL_OPENSSL_TIMEOUT_SECONDS` | `30` | Timeout for OpenSSL script execution |
|
| `CERTCTL_OPENSSL_TIMEOUT_SECONDS` | `30` | Timeout for OpenSSL script execution |
|
||||||
| `CERTCTL_NETWORK_SCAN_ENABLED` | `false` | Enable server-side network certificate discovery (TLS scanning) |
|
| `CERTCTL_NETWORK_SCAN_ENABLED` | `false` | Enable server-side network certificate discovery (TLS scanning) |
|
||||||
| `CERTCTL_NETWORK_SCAN_INTERVAL` | `6h` | How often the scheduler runs network scans |
|
| `CERTCTL_NETWORK_SCAN_INTERVAL` | `6h` | How often the scheduler runs network scans |
|
||||||
|
| `CERTCTL_EST_ENABLED` | `false` | Enable EST (RFC 7030) enrollment endpoints under /.well-known/est/ |
|
||||||
|
| `CERTCTL_EST_ISSUER_ID` | `iss-local` | Issuer connector ID used for EST certificate enrollment |
|
||||||
|
| `CERTCTL_EST_PROFILE_ID` | — | Optional certificate profile ID to constrain EST enrollments |
|
||||||
| `CERTCTL_SLACK_WEBHOOK_URL` | — | Slack incoming webhook URL for notifications |
|
| `CERTCTL_SLACK_WEBHOOK_URL` | — | Slack incoming webhook URL for notifications |
|
||||||
| `CERTCTL_TEAMS_WEBHOOK_URL` | — | Microsoft Teams incoming webhook URL |
|
| `CERTCTL_TEAMS_WEBHOOK_URL` | — | Microsoft Teams incoming webhook URL |
|
||||||
| `CERTCTL_PAGERDUTY_ROUTING_KEY` | — | PagerDuty Events API v2 routing key |
|
| `CERTCTL_PAGERDUTY_ROUTING_KEY` | — | PagerDuty Events API v2 routing key |
|
||||||
@@ -269,19 +311,26 @@ go install github.com/shankar0123/certctl/cmd/cli@latest
|
|||||||
export CERTCTL_SERVER_URL=http://localhost:8443
|
export CERTCTL_SERVER_URL=http://localhost:8443
|
||||||
export CERTCTL_API_KEY=your-api-key
|
export CERTCTL_API_KEY=your-api-key
|
||||||
|
|
||||||
# Commands
|
# Certificate commands
|
||||||
certctl-cli list-certs # List all certificates
|
certctl-cli certs list # List all certificates
|
||||||
certctl-cli get-cert --id mc-api-prod # Get certificate details
|
certctl-cli certs get mc-api-prod # Get certificate details
|
||||||
certctl-cli renew-cert --id mc-api-prod # Trigger renewal
|
certctl-cli certs renew mc-api-prod # Trigger renewal
|
||||||
certctl-cli revoke-cert --id mc-api-prod --reason keyCompromise
|
certctl-cli certs revoke mc-api-prod --reason keyCompromise
|
||||||
certctl-cli list-agents # List registered agents
|
|
||||||
certctl-cli list-jobs # List jobs
|
# Agent and job commands
|
||||||
certctl-cli health # Server health check
|
certctl-cli agents list # List registered agents
|
||||||
certctl-cli metrics # Server metrics
|
certctl-cli agents get ag-web-prod # Get agent details
|
||||||
certctl-cli import --file certs.pem # Bulk import from PEM file
|
certctl-cli jobs list # List jobs
|
||||||
|
certctl-cli jobs get job-123 # Get job details
|
||||||
|
certctl-cli jobs cancel job-123 # Cancel a pending job
|
||||||
|
|
||||||
|
# Operations
|
||||||
|
certctl-cli status # Server health + summary stats
|
||||||
|
certctl-cli import certs.pem # Bulk import from PEM file
|
||||||
|
certctl-cli version # Show CLI version
|
||||||
|
|
||||||
# Output formats
|
# Output formats
|
||||||
certctl-cli list-certs --format json # JSON output (default: table)
|
certctl-cli certs list --format json # JSON output (default: table)
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Overview
|
## API Overview
|
||||||
@@ -420,6 +469,14 @@ GET /api/v1/auth/info Auth mode info (no auth required)
|
|||||||
GET /api/v1/auth/check Validate credentials
|
GET /api/v1/auth/check Validate credentials
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### EST Enrollment (RFC 7030)
|
||||||
|
```
|
||||||
|
GET /.well-known/est/cacerts CA certificate chain (PKCS#7 certs-only)
|
||||||
|
POST /.well-known/est/simpleenroll Simple enrollment (PEM or base64-DER CSR)
|
||||||
|
POST /.well-known/est/simplereenroll Simple re-enrollment (certificate renewal)
|
||||||
|
GET /.well-known/est/csrattrs CSR attributes request
|
||||||
|
```
|
||||||
|
|
||||||
### Health
|
### Health
|
||||||
```
|
```
|
||||||
GET /health Server health check
|
GET /health Server health check
|
||||||
@@ -432,13 +489,13 @@ GET /ready Readiness check
|
|||||||
| Issuer | Status | Type |
|
| Issuer | Status | Type |
|
||||||
|--------|--------|------|
|
|--------|--------|------|
|
||||||
| Local CA (self-signed + sub-CA) | Implemented | `GenericCA` |
|
| Local CA (self-signed + sub-CA) | Implemented | `GenericCA` |
|
||||||
| ACME v2 (Let's Encrypt, Sectigo) | Implemented (HTTP-01 + DNS-01) | `ACME` |
|
| ACME v2 (Let's Encrypt, Sectigo) | Implemented (HTTP-01 + DNS-01 + DNS-PERSIST-01) | `ACME` |
|
||||||
| step-ca | Implemented | `StepCA` |
|
| step-ca | Implemented | `StepCA` |
|
||||||
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
|
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
|
||||||
| Vault PKI | Planned | — |
|
| Vault PKI | Future | — |
|
||||||
| DigiCert | Planned | — |
|
| DigiCert | Future | — |
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
### Deployment Targets
|
### Deployment Targets
|
||||||
| Target | Status | Type |
|
| Target | Status | Type |
|
||||||
@@ -446,9 +503,10 @@ GET /ready Readiness check
|
|||||||
| NGINX | Implemented | `NGINX` |
|
| NGINX | Implemented | `NGINX` |
|
||||||
| Apache httpd | Implemented | `Apache` |
|
| Apache httpd | Implemented | `Apache` |
|
||||||
| HAProxy | Implemented | `HAProxy` |
|
| HAProxy | Implemented | `HAProxy` |
|
||||||
|
| Traefik | Planned (v2.1.x) | `Traefik` |
|
||||||
|
| Caddy | Planned (v2.1.x) | `Caddy` |
|
||||||
| F5 BIG-IP | Interface only | `F5` |
|
| F5 BIG-IP | Interface only | `F5` |
|
||||||
| Microsoft IIS | Interface only | `IIS` |
|
| Microsoft IIS | Interface only | `IIS` |
|
||||||
| Kubernetes Secrets | Planned | — |
|
|
||||||
|
|
||||||
### Notifiers
|
### Notifiers
|
||||||
| Notifier | Status | Type |
|
| Notifier | Status | Type |
|
||||||
@@ -509,12 +567,12 @@ make docker-clean # Stop + remove volumes
|
|||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### V1 (v1.0.0 released)
|
### V1 (v1.0.0 released)
|
||||||
All nine development milestones (M1–M9) are complete. The backend covers the full certificate lifecycle: Local CA and ACME v2 issuers, NGINX/Apache/HAProxy/F5/IIS target connectors, threshold-based expiration alerting, agent-side ECDSA P-256 key generation, API auth with rate limiting, and a React dashboard with 19 pages wired to the real API. The CI pipeline runs build, vet, test with coverage gates (service layer 30%+, handler layer 50%+), frontend type checking, Vitest test suite, and Vite production build on every push. Docker images are published to GitHub Container Registry on every version tag via the release workflow.
|
All nine development milestones (M1–M9) are complete. The backend covers the full certificate lifecycle: Local CA and ACME v2 issuers, NGINX/Apache/HAProxy/F5/IIS target connectors, threshold-based expiration alerting, agent-side ECDSA P-256 key generation, API auth with rate limiting, and a full React dashboard wired to the real API. The CI pipeline runs build, vet, test with coverage gates (service layer 30%+, handler layer 50%+), frontend type checking, Vitest test suite, and Vite production build on every push. Docker images are published to GitHub Container Registry on every version tag via the release workflow.
|
||||||
|
|
||||||
### V2: Operational Maturity
|
### V2: Operational Maturity
|
||||||
- **M10: Agent Metadata + Targets** ✅ — agents report OS, architecture, IP, hostname, version via heartbeat; Apache httpd and HAProxy target connectors
|
- **M10: Agent Metadata + Targets** ✅ — agents report OS, architecture, IP, hostname, version via heartbeat; Apache httpd and HAProxy target connectors
|
||||||
- **M11: Crypto Policy + Profiles + Ownership** ✅ — certificate profiles (named enrollment profiles with allowed key types, max TTL, crypto constraints), certificate ownership tracking (owners + teams + notification routing), dynamic agent groups (OS/arch/IP CIDR/version matching), interactive renewal approval (AwaitingApproval state)
|
- **M11: Crypto Policy + Profiles + Ownership** ✅ — certificate profiles (named enrollment profiles with allowed key types, max TTL, crypto constraints), certificate ownership tracking (owners + teams + notification routing), dynamic agent groups (OS/arch/IP CIDR/version matching), interactive renewal approval (AwaitingApproval state)
|
||||||
- **M12: Sub-CA + DNS-01 + step-ca** ✅ — Local CA sub-CA mode (enterprise root chain with RSA/ECDSA/PKCS#8), ACME DNS-01 challenges (script-based DNS hooks for any provider, wildcard cert support), step-ca issuer connector (native /sign API with JWK provisioner auth)
|
- **M12: Sub-CA + DNS-01 + step-ca** ✅ — Local CA sub-CA mode (enterprise root chain with RSA/ECDSA/PKCS#8), ACME DNS-01 challenges (script-based DNS hooks for any provider, wildcard cert support), ACME DNS-PERSIST-01 challenges (standing TXT record, no per-renewal DNS updates, auto-fallback to dns-01), step-ca issuer connector (native /sign API with JWK provisioner auth)
|
||||||
- **M15a: Core Revocation** ✅ — revocation API with all RFC 5280 reason codes, JSON CRL endpoint, webhook + email revocation notifications, best-effort issuer notification, `certificate_revocations` table with idempotent recording, 48 new tests
|
- **M15a: Core Revocation** ✅ — revocation API with all RFC 5280 reason codes, JSON CRL endpoint, webhook + email revocation notifications, best-effort issuer notification, `certificate_revocations` table with idempotent recording, 48 new tests
|
||||||
- **M15b: OCSP + Revocation GUI** ✅ — embedded OCSP responder (GET /api/v1/ocsp/{issuer_id}/{serial}), DER-encoded X.509 CRL (GET /api/v1/crl/{issuer_id}), short-lived cert exemption (TTL < 1h skip CRL/OCSP), revocation GUI with reason modal, ~31 new tests
|
- **M15b: OCSP + Revocation GUI** ✅ — embedded OCSP responder (GET /api/v1/ocsp/{issuer_id}/{serial}), DER-encoded X.509 CRL (GET /api/v1/crl/{issuer_id}), short-lived cert exemption (TTL < 1h skip CRL/OCSP), revocation GUI with reason modal, ~31 new tests
|
||||||
- **M13: GUI Operations** ✅ — bulk cert operations (multi-select → renew, revoke, reassign owner), deployment status timeline, inline policy/profile editor, target connector configuration wizard, audit trail export (CSV/JSON), short-lived credentials dashboard view
|
- **M13: GUI Operations** ✅ — bulk cert operations (multi-select → renew, revoke, reassign owner), deployment status timeline, inline policy/profile editor, target connector configuration wizard, audit trail export (CSV/JSON), short-lived credentials dashboard view
|
||||||
@@ -523,18 +581,23 @@ All nine development milestones (M1–M9) are complete. The backend covers the f
|
|||||||
- **M19: Immutable API Audit Log** ✅ — every API call recorded to immutable audit trail (method, path, actor, SHA-256 body hash, status, latency), async recording via goroutine, configurable path exclusions
|
- **M19: Immutable API Audit Log** ✅ — every API call recorded to immutable audit trail (method, path, actor, SHA-256 body hash, status, latency), async recording via goroutine, configurable path exclusions
|
||||||
- **M16a: Notifier Connectors** ✅ — Slack (incoming webhook), Microsoft Teams (MessageCard), PagerDuty (Events API v2), OpsGenie (Alert API v2) — config-driven enablement via env vars
|
- **M16a: Notifier Connectors** ✅ — Slack (incoming webhook), Microsoft Teams (MessageCard), PagerDuty (Events API v2), OpsGenie (Alert API v2) — config-driven enablement via env vars
|
||||||
- **M17: Additional Connectors** ✅ — OpenSSL/Custom CA issuer connector (script-based signing with configurable timeout)
|
- **M17: Additional Connectors** ✅ — OpenSSL/Custom CA issuer connector (script-based signing with configurable timeout)
|
||||||
- **M16b: CLI + Bulk Import** ✅ — `certctl-cli` with 10 subcommands (list/get/renew/revoke certs, list agents/jobs, health, metrics, PEM bulk import), stdlib-only, JSON/table output
|
- **M16b: CLI + Bulk Import** ✅ — `certctl-cli` with 12 subcommands (certs list/get/renew/revoke, agents list/get, jobs list/get/cancel, import, status, version), stdlib-only, JSON/table output
|
||||||
- **M20: Enhanced Query API** ✅ — sparse field selection (`?fields=`), sort with direction (`?sort=-notAfter`), time-range filters (`expires_before`, `created_after`, etc.), cursor-based pagination (`?cursor=&page_size=`), `GET /certificates/{id}/deployments`, additional filters (`agent_id`, `profile_id`)
|
- **M20: Enhanced Query API** ✅ — sparse field selection (`?fields=`), sort with direction (`?sort=-notAfter`), time-range filters (`expires_before`, `created_after`, etc.), cursor-based pagination (`?cursor=&page_size=`), `GET /certificates/{id}/deployments`, additional filters (`agent_id`, `profile_id`)
|
||||||
- **M18b: Filesystem Cert Discovery** ✅ — agents scan configured directories (PEM/DER), report findings to control plane, deduplication by SHA-256 fingerprint, claim/dismiss/triage workflow via API
|
- **M18b: Filesystem Cert Discovery** ✅ — agents scan configured directories (PEM/DER), report findings to control plane, deduplication by SHA-256 fingerprint, claim/dismiss/triage workflow via API
|
||||||
- **M21: Network Cert Discovery** ✅ — server-side active TLS scanning of CIDR ranges and ports, concurrent probing (50 goroutines), CIDR expansion with /20 safety cap, sentinel agent pattern for discovery pipeline reuse, CRUD API for scan targets, scheduler integration (6h default)
|
- **M21: Network Cert Discovery** ✅ — server-side active TLS scanning of CIDR ranges and ports, concurrent probing (50 goroutines), CIDR expansion with /20 safety cap, sentinel agent pattern for discovery pipeline reuse, CRUD API for scan targets, scheduler integration (6h default)
|
||||||
- **M22: Prometheus Metrics** ✅ — `GET /api/v1/metrics/prometheus` returns Prometheus exposition format (`text/plain; version=0.0.4`), 11 metrics with `certctl_` prefix, compatible with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics
|
- **M22: Prometheus Metrics** ✅ — `GET /api/v1/metrics/prometheus` returns Prometheus exposition format (`text/plain; version=0.0.4`), 11 metrics with `certctl_` prefix, compatible with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics
|
||||||
|
- **M23: EST Server (RFC 7030)** ✅ — Enrollment over Secure Transport for device/WiFi certificate enrollment, 4 endpoints under /.well-known/est/, PKCS#7 certs-only wire format, base64-encoded DER CSR input, configurable issuer + profile binding, audit trail, 28 new tests
|
||||||
- **Compliance Mapping** ✅ — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 capability mapping documentation
|
- **Compliance Mapping** ✅ — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 capability mapping documentation
|
||||||
|
- **M24: S/MIME Certificate Support** (Planned — v2.0.2) — wire profile EKU constraints through the issuance pipeline so certctl can issue S/MIME (emailProtection), code signing, and custom EKU certificates, not just TLS
|
||||||
|
- **M25: Traefik + Caddy Targets** (Planned — v2.1.x) — Traefik (file provider, auto-reload on filesystem change) and Caddy (Admin API, hot-reload) deployment target connectors
|
||||||
|
- **M26: Certificate Export** (Planned — v2.1.x) — single-certificate download in PFX/PKCS12, DER, and PEM formats with optional chain inclusion, GUI download button on certificate detail page
|
||||||
|
|
||||||
|
### V3: certctl Pro
|
||||||
|
|
||||||
### V3: Team & Enterprise
|
|
||||||
Team access controls, identity provider integration, enterprise deployment targets, compliance and risk scoring, advanced fleet operations, event-driven architecture, advanced search, real-time operational views, and premium CA integrations.
|
Team access controls, identity provider integration, enterprise deployment targets, compliance and risk scoring, advanced fleet operations, event-driven architecture, advanced search, real-time operational views, and premium CA integrations.
|
||||||
|
|
||||||
### V4+: Cloud, Scale & Passive Discovery
|
### V4+: Cloud, Scale & Passive Discovery
|
||||||
Passive network discovery (TLS listener), Kubernetes integration, cloud infrastructure targets (AWS ALB/ACM, Azure Key Vault), extended CA support, and platform-scale features.
|
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 (Vault PKI, Google CAS, EJBCA), and platform-scale features (Terraform provider, multi-tenancy, HSM support).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -98,13 +98,14 @@ func main() {
|
|||||||
logger.Info("initialized Local CA issuer connector")
|
logger.Info("initialized Local CA issuer connector")
|
||||||
|
|
||||||
// Initialize ACME issuer connector (for Let's Encrypt, Sectigo, etc.)
|
// Initialize ACME issuer connector (for Let's Encrypt, Sectigo, etc.)
|
||||||
// Supports HTTP-01 (default) and DNS-01 (for wildcards) challenge types.
|
// Supports HTTP-01 (default), DNS-01 (for wildcards), and DNS-PERSIST-01 (standing record) challenge types.
|
||||||
acmeConnector := acmeissuer.New(&acmeissuer.Config{
|
acmeConnector := acmeissuer.New(&acmeissuer.Config{
|
||||||
DirectoryURL: os.Getenv("CERTCTL_ACME_DIRECTORY_URL"),
|
DirectoryURL: os.Getenv("CERTCTL_ACME_DIRECTORY_URL"),
|
||||||
Email: os.Getenv("CERTCTL_ACME_EMAIL"),
|
Email: os.Getenv("CERTCTL_ACME_EMAIL"),
|
||||||
ChallengeType: os.Getenv("CERTCTL_ACME_CHALLENGE_TYPE"),
|
ChallengeType: os.Getenv("CERTCTL_ACME_CHALLENGE_TYPE"),
|
||||||
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"),
|
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"),
|
||||||
DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"),
|
DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"),
|
||||||
|
DNSPersistIssuerDomain: os.Getenv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN"),
|
||||||
}, logger)
|
}, logger)
|
||||||
logger.Info("initialized ACME issuer connector")
|
logger.Info("initialized ACME issuer connector")
|
||||||
|
|
||||||
@@ -302,6 +303,25 @@ func main() {
|
|||||||
discoveryHandler,
|
discoveryHandler,
|
||||||
networkScanHandler,
|
networkScanHandler,
|
||||||
)
|
)
|
||||||
|
// Register EST (RFC 7030) handlers if enabled
|
||||||
|
if cfg.EST.Enabled {
|
||||||
|
issuerConn, ok := issuerRegistry[cfg.EST.IssuerID]
|
||||||
|
if !ok {
|
||||||
|
logger.Error("EST issuer not found in registry", "issuer_id", cfg.EST.IssuerID)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
estService := service.NewESTService(cfg.EST.IssuerID, issuerConn, auditService, logger)
|
||||||
|
if cfg.EST.ProfileID != "" {
|
||||||
|
estService.SetProfileID(cfg.EST.ProfileID)
|
||||||
|
}
|
||||||
|
estHandler := handler.NewESTHandler(estService)
|
||||||
|
apiRouter.RegisterESTHandlers(estHandler)
|
||||||
|
logger.Info("EST server enabled",
|
||||||
|
"issuer_id", cfg.EST.IssuerID,
|
||||||
|
"profile_id", cfg.EST.ProfileID,
|
||||||
|
"endpoints", "/.well-known/est/{cacerts,simpleenroll,simplereenroll,csrattrs}")
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info("registered all API handlers")
|
logger.Info("registered all API handlers")
|
||||||
|
|
||||||
// Build middleware stack
|
// Build middleware stack
|
||||||
@@ -380,9 +400,10 @@ func main() {
|
|||||||
fileServer := http.FileServer(http.Dir(webDir))
|
fileServer := http.FileServer(http.Dir(webDir))
|
||||||
finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
path := r.URL.Path
|
path := r.URL.Path
|
||||||
// API and health routes go to the API handler
|
// API, health, and EST routes go to the API handler
|
||||||
if path == "/health" || path == "/ready" ||
|
if path == "/health" || path == "/ready" ||
|
||||||
(len(path) >= 8 && path[:8] == "/api/v1/") {
|
(len(path) >= 8 && path[:8] == "/api/v1/") ||
|
||||||
|
(len(path) >= 16 && path[:16] == "/.well-known/est") {
|
||||||
apiHandler.ServeHTTP(w, r)
|
apiHandler.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ flowchart TB
|
|||||||
|
|
||||||
subgraph "Issuer Backends"
|
subgraph "Issuer Backends"
|
||||||
CA1["Local CA\n(crypto/x509, sub-CA)"]
|
CA1["Local CA\n(crypto/x509, sub-CA)"]
|
||||||
CA2["ACME\n(HTTP-01 + DNS-01)"]
|
CA2["ACME\n(HTTP-01 + DNS-01 + DNS-PERSIST-01)"]
|
||||||
CA3["step-ca\n(/sign API)"]
|
CA3["step-ca\n(/sign API)"]
|
||||||
CA4["OpenSSL / Custom CA\n(script-based)"]
|
CA4["OpenSSL / Custom CA\n(script-based)"]
|
||||||
CA6["Vault PKI\n(planned)"]
|
CA6["Vault PKI\n(planned)"]
|
||||||
@@ -76,7 +76,7 @@ The control plane is a Go HTTP server backed by PostgreSQL. It manages state (ce
|
|||||||
|
|
||||||
The server exposes a REST API under `/api/v1/` and optionally serves the web dashboard as static files from the `web/` directory.
|
The server exposes a REST API under `/api/v1/` and optionally serves the web dashboard as static files from the `web/` directory.
|
||||||
|
|
||||||
**Key internals**: The server uses Go 1.22's `net/http` stdlib routing (no external router framework), structured logging via `slog`, and a handler → service → repository layered architecture. Handlers define their own service interfaces for clean dependency inversion.
|
**Key internals**: The server uses Go 1.25's `net/http` stdlib routing (no external router framework), structured logging via `slog`, and a handler → service → repository layered architecture. Handlers define their own service interfaces for clean dependency inversion.
|
||||||
|
|
||||||
### Agents
|
### Agents
|
||||||
|
|
||||||
@@ -92,14 +92,14 @@ The agent runs two background loops: a heartbeat (every 60 seconds) to signal it
|
|||||||
|
|
||||||
The web dashboard is the primary operational interface for certctl. It is built with Vite + React + TypeScript and uses TanStack Query for server state management (caching, background refetching, optimistic updates).
|
The web dashboard is the primary operational interface for certctl. It is built with Vite + React + TypeScript and uses TanStack Query for server state management (caching, background refetching, optimistic updates).
|
||||||
|
|
||||||
**Current views (19 pages)**: certificate inventory (list with multi-select bulk operations + "New Certificate" creation modal + detail with deployment status timeline, inline policy/profile editor, version history, deploy, revoke, archive, and trigger renewal actions), agent fleet (list + detail with system info + OS/architecture grouping with charts), job queue (status, retry, cancel, approve/reject), notification inbox (threshold alert grouping, mark-as-read), audit trail (time range, actor, action filters + CSV/JSON export), policy management (rules with enable/disable toggle + delete + violations), issuers (list with test connection + delete), targets (list with 3-step configuration wizard + delete), owners (list with team resolution + delete), teams (list with delete), agent groups (list with dynamic match criteria badges + enable/disable + delete), certificate profiles (list with crypto constraints), short-lived credentials dashboard (TTL countdown, profile filtering, auto-refresh), summary dashboard with charts (expiration heatmap, renewal success rate, status distribution, issuance rate), and login page.
|
**Current views**: certificate inventory (list with multi-select bulk operations + "New Certificate" creation modal + detail with deployment status timeline, inline policy/profile editor, version history, deploy, revoke, archive, and trigger renewal actions), agent fleet (list + detail with system info + OS/architecture grouping with charts), job queue (status, retry, cancel, approve/reject), notification inbox (threshold alert grouping, mark-as-read), audit trail (time range, actor, action filters + CSV/JSON export), policy management (rules with enable/disable toggle + delete + violations), issuers (list with test connection + delete), targets (list with 3-step configuration wizard + delete), owners (list with team resolution + delete), teams (list with delete), agent groups (list with dynamic match criteria badges + enable/disable + delete), certificate profiles (list with crypto constraints), short-lived credentials dashboard (TTL countdown, profile filtering, auto-refresh), summary dashboard with charts (expiration heatmap, renewal success rate, status distribution, issuance rate), and login page.
|
||||||
|
|
||||||
The dashboard includes an **ErrorBoundary component** for graceful error recovery — if a view crashes, the boundary catches the error and displays a user-friendly message instead of breaking the entire dashboard. It also includes a **demo mode** that activates when the API is unreachable — it renders realistic mock data for screenshots and offline presentations.
|
The dashboard includes an **ErrorBoundary component** for graceful error recovery — if a view crashes, the boundary catches the error and displays a user-friendly message instead of breaking the entire dashboard. It also includes a **demo mode** that activates when the API is unreachable — it renders realistic mock data for screenshots and offline presentations.
|
||||||
|
|
||||||
**Tech decisions**:
|
**Tech decisions**:
|
||||||
- Vite for fast builds and HMR during development
|
- Vite for fast builds and HMR during development
|
||||||
- TanStack Query over manual fetch/useEffect for automatic cache invalidation and refetching
|
- TanStack Query over manual fetch/useEffect for automatic cache invalidation and refetching
|
||||||
- Dark theme default (ops teams live in dark mode)
|
- Light content area with branded dark teal sidebar, Inter + JetBrains Mono typography
|
||||||
- SSE/WebSocket planned for real-time job status updates
|
- SSE/WebSocket planned for real-time job status updates
|
||||||
|
|
||||||
### PostgreSQL Database
|
### PostgreSQL Database
|
||||||
@@ -122,8 +122,11 @@ erDiagram
|
|||||||
managed_certificates ||--o{ policy_violations : "violates"
|
managed_certificates ||--o{ policy_violations : "violates"
|
||||||
managed_certificates ||--o{ audit_events : "logged in"
|
managed_certificates ||--o{ audit_events : "logged in"
|
||||||
managed_certificates ||--o{ notification_events : "generates"
|
managed_certificates ||--o{ notification_events : "generates"
|
||||||
|
managed_certificates ||--o{ certificate_revocations : "revoked via"
|
||||||
agent_groups ||--o{ agent_group_members : "has members"
|
agent_groups ||--o{ agent_group_members : "has members"
|
||||||
agents ||--o{ agent_group_members : "belongs to"
|
agents ||--o{ agent_group_members : "belongs to"
|
||||||
|
agents ||--o{ discovered_certificates : "discovers"
|
||||||
|
agents ||--o{ discovery_scans : "performs"
|
||||||
|
|
||||||
teams {
|
teams {
|
||||||
text id PK
|
text id PK
|
||||||
@@ -242,6 +245,43 @@ erDiagram
|
|||||||
text agent_id FK
|
text agent_id FK
|
||||||
text membership_type
|
text membership_type
|
||||||
}
|
}
|
||||||
|
renewal_policies {
|
||||||
|
text id PK
|
||||||
|
text certificate_id FK
|
||||||
|
int renewal_days_before
|
||||||
|
jsonb alert_thresholds_days
|
||||||
|
boolean auto_renew
|
||||||
|
text agent_group_id FK
|
||||||
|
}
|
||||||
|
certificate_revocations {
|
||||||
|
text id PK
|
||||||
|
text certificate_id FK
|
||||||
|
text serial_number
|
||||||
|
text reason
|
||||||
|
timestamp revoked_at
|
||||||
|
boolean issuer_notified
|
||||||
|
}
|
||||||
|
discovered_certificates {
|
||||||
|
text id PK
|
||||||
|
text agent_id FK
|
||||||
|
text fingerprint_sha256
|
||||||
|
text common_name
|
||||||
|
text source_path
|
||||||
|
text status
|
||||||
|
}
|
||||||
|
discovery_scans {
|
||||||
|
text id PK
|
||||||
|
text agent_id FK
|
||||||
|
int certs_found
|
||||||
|
timestamp scanned_at
|
||||||
|
}
|
||||||
|
network_scan_targets {
|
||||||
|
text id PK
|
||||||
|
text name
|
||||||
|
text[] cidrs
|
||||||
|
int[] ports
|
||||||
|
boolean enabled
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Migrations are idempotent (`IF NOT EXISTS` on all CREATE statements, `ON CONFLICT (id) DO NOTHING` on all seed data) so they're safe to run multiple times — important for Docker Compose where both initdb and the server may run the same SQL.
|
Migrations are idempotent (`IF NOT EXISTS` on all CREATE statements, `ON CONFLICT (id) DO NOTHING` on all seed data) so they're safe to run multiple times — important for Docker Compose where both initdb and the server may run the same SQL.
|
||||||
@@ -481,10 +521,13 @@ type Connector interface {
|
|||||||
RenewCertificate(ctx context.Context, request RenewalRequest) (*IssuanceResult, error)
|
RenewCertificate(ctx context.Context, request RenewalRequest) (*IssuanceResult, error)
|
||||||
RevokeCertificate(ctx context.Context, request RevocationRequest) error
|
RevokeCertificate(ctx context.Context, request RevocationRequest) error
|
||||||
GetOrderStatus(ctx context.Context, orderID string) (*OrderStatus, error)
|
GetOrderStatus(ctx context.Context, orderID string) (*OrderStatus, error)
|
||||||
|
GenerateCRL(ctx context.Context, revokedCerts []RevokedCertEntry) ([]byte, error)
|
||||||
|
SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
|
||||||
|
GetCACertPEM(ctx context.Context) (string, error)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01 and DNS-01 challenges, compatible with Let's Encrypt, Sectigo, and any ACME-compliant CA), and **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance, order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks), order finalization, and DER-to-PEM chain conversion.
|
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, Sectigo, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance, order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. The interface also includes `GetCACertPEM(ctx)` for CA chain distribution (used by the EST server's `/cacerts` endpoint).
|
||||||
|
|
||||||
### Target Connector
|
### Target Connector
|
||||||
|
|
||||||
@@ -520,6 +563,45 @@ Built-in notifiers: **Email** (SMTP), **Webhook** (HTTP POST), **Slack** (incomi
|
|||||||
|
|
||||||
See the [Connector Development Guide](connectors.md) for details on building custom connectors.
|
See the [Connector Development Guide](connectors.md) for details on building custom connectors.
|
||||||
|
|
||||||
|
### EST Server (RFC 7030)
|
||||||
|
|
||||||
|
The EST (Enrollment over Secure Transport) server provides an industry-standard enrollment interface for devices that need certificates without using the REST API. It runs under `/.well-known/est/` per RFC 7030 and supports four operations: CA certificate distribution (`/cacerts`), initial enrollment (`/simpleenroll`), re-enrollment (`/simplereenroll`), and CSR attributes (`/csrattrs`).
|
||||||
|
|
||||||
|
**Architecture:** EST is a handler-level protocol that delegates certificate issuance to an existing `IssuerConnector`. This means EST is not a new issuer — it's a new *interface* to the existing issuance infrastructure. The `ESTService` bridges the `ESTHandler` to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID`.
|
||||||
|
|
||||||
|
```
|
||||||
|
Client (WiFi AP, MDM, IoT)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ESTHandler (handler layer)
|
||||||
|
│ CSR parsing, PKCS#7 response encoding
|
||||||
|
▼
|
||||||
|
ESTService (service layer)
|
||||||
|
│ CSR validation, CN/SAN extraction, audit recording
|
||||||
|
▼
|
||||||
|
IssuerConnector (connector layer via IssuerConnectorAdapter)
|
||||||
|
│ Certificate signing (Local CA, step-ca, etc.)
|
||||||
|
▼
|
||||||
|
Signed certificate returned as PKCS#7 certs-only
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wire format:** EST uses PKCS#7 (RFC 2315) certs-only degenerate SignedData for certificate responses and base64-encoded DER for CSR requests. The handler includes a hand-rolled ASN.1 PKCS#7 builder — no external PKCS#7 dependency. The CSR reader accepts both base64-encoded DER (standard EST wire format) and PEM-encoded PKCS#10 (convenience for debugging).
|
||||||
|
|
||||||
|
**Interface:** The `ESTHandler` defines an `ESTService` interface (dependency inversion, same pattern as all other handlers):
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ESTService interface {
|
||||||
|
GetCACerts(ctx context.Context) (string, error)
|
||||||
|
SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error)
|
||||||
|
SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error)
|
||||||
|
GetCSRAttrs(ctx context.Context) ([]byte, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issuer connector extension:** EST required adding `GetCACertPEM(ctx) (string, error)` to the issuer connector interface so the `/cacerts` endpoint can serve the CA chain. The Local CA connector returns its CA certificate PEM; ACME, step-ca, and OpenSSL connectors return errors (they don't expose a static CA chain — their chains are per-issuance).
|
||||||
|
|
||||||
|
**Audit:** Every EST enrollment is recorded in the audit trail with `protocol: "EST"`, the CN, SANs, issuer ID, serial number, and optional profile ID.
|
||||||
|
|
||||||
## Security Model
|
## Security Model
|
||||||
|
|
||||||
### Private Key Management
|
### Private Key Management
|
||||||
@@ -606,9 +688,9 @@ All endpoints are under `/api/v1/` and follow consistent patterns:
|
|||||||
- **Delete**: `DELETE /api/v1/{resources}/{id}` — returns `204` (soft delete/archive)
|
- **Delete**: `DELETE /api/v1/{resources}/{id}` — returns `204` (soft delete/archive)
|
||||||
- **Actions**: `POST /api/v1/{resources}/{id}/{action}` — returns `202` for async operations
|
- **Actions**: `POST /api/v1/{resources}/{id}/{action}` — returns `202` for async operations
|
||||||
|
|
||||||
Resources: certificates, issuers, targets, agents, jobs, policies, profiles, teams, owners, agent-groups, audit, notifications.
|
Resources: certificates, issuers, targets, agents, jobs, policies, profiles, teams, owners, agent-groups, audit, notifications, discovered-certificates, discovery-scans, network-scan-targets, stats, metrics.
|
||||||
|
|
||||||
The full API is documented in an OpenAPI 3.1 specification at `api/openapi.yaml` with 91 endpoints across 19 resource domains (including health, readiness, auth, 7 discovery endpoints from M18b, 6 network scan endpoints from M21, and Prometheus metrics from M22), all request/response schemas, and pagination conventions. See the [OpenAPI Guide](openapi.md) for usage with Swagger UI and SDK generation.
|
The full API is documented in an OpenAPI 3.1 specification at `api/openapi.yaml` with 97 endpoints across 20 resource domains (95 under `/api/v1/` + `/.well-known/est/` plus `/health` and `/ready`; includes auth, 7 discovery endpoints from M18b, 6 network scan endpoints from M21, Prometheus metrics from M22, and 4 EST enrollment endpoints from M23), all request/response schemas, and pagination conventions. See the [OpenAPI Guide](openapi.md) for usage with Swagger UI and SDK generation.
|
||||||
|
|
||||||
Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST /api/v1/jobs/{id}/approve`, `POST /api/v1/jobs/{id}/reject`.
|
Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST /api/v1/jobs/{id}/approve`, `POST /api/v1/jobs/{id}/reject`.
|
||||||
|
|
||||||
@@ -654,7 +736,7 @@ The 78 tools are organized across 16 resource domains with typed input structs a
|
|||||||
|
|
||||||
certctl ships with a command-line tool (`certctl-cli`, built from `cmd/cli/main.go`) that wraps the REST API for terminal workflows. The CLI uses Go's standard library only (`flag` + `text/tabwriter`) — no Cobra or other framework dependencies.
|
certctl ships with a command-line tool (`certctl-cli`, built from `cmd/cli/main.go`) that wraps the REST API for terminal workflows. The CLI uses Go's standard library only (`flag` + `text/tabwriter`) — no Cobra or other framework dependencies.
|
||||||
|
|
||||||
10 subcommands: `list-certs`, `get-cert`, `renew-cert`, `revoke-cert`, `list-agents`, `list-jobs`, `health`, `metrics`, and `import` (bulk PEM import). Output is available in table (default) or JSON format via `--format`. Connection is configured via `CERTCTL_SERVER_URL` and `CERTCTL_API_KEY` environment variables or CLI flags.
|
12 subcommands organized by resource: `certs list`, `certs get`, `certs renew`, `certs revoke`, `agents list`, `agents get`, `jobs list`, `jobs get`, `jobs cancel`, `import` (bulk PEM import), `status` (health + summary stats), and `version`. Output is available in table (default) or JSON format via `--format`. Connection is configured via `CERTCTL_SERVER_URL` and `CERTCTL_API_KEY` environment variables or CLI flags.
|
||||||
|
|
||||||
The bulk import command (`certctl-cli import <file.pem>`) parses multi-certificate PEM files and creates certificate records via the API — useful for bootstrapping certctl with existing certificate inventory.
|
The bulk import command (`certctl-cli import <file.pem>`) parses multi-certificate PEM files and creates certificate records via the API — useful for bootstrapping certctl with existing certificate inventory.
|
||||||
|
|
||||||
@@ -787,7 +869,7 @@ certctl uses a layered testing approach aligned with the handler → service →
|
|||||||
|
|
||||||
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs: Go (build, vet, test with coverage, coverage threshold enforcement) and Frontend (TypeScript type check, Vitest test suite, Vite production build). The Go job runs all tests with `-coverprofile`, then enforces coverage thresholds: service layer must be at least 30% (current: ~35%) and handler layer must be at least 50% (current: ~63%). These thresholds act as regression floors — they can only go up. The service layer threshold is deliberately lower because much of the service code depends on postgres repositories and external connectors that require real infrastructure to test meaningfully. Connector tests are included via `./internal/connector/issuer/...` and `./internal/connector/target/...` (covers Local CA, ACME, step-ca, NGINX, Apache, and HAProxy packages with unit tests for certificate signing logic, DNS solver, issuer validation, and deployment flows). The Frontend job runs `npx vitest run` between the TypeScript check and production build steps.
|
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs: Go (build, vet, test with coverage, coverage threshold enforcement) and Frontend (TypeScript type check, Vitest test suite, Vite production build). The Go job runs all tests with `-coverprofile`, then enforces coverage thresholds: service layer must be at least 30% (current: ~35%) and handler layer must be at least 50% (current: ~63%). These thresholds act as regression floors — they can only go up. The service layer threshold is deliberately lower because much of the service code depends on postgres repositories and external connectors that require real infrastructure to test meaningfully. Connector tests are included via `./internal/connector/issuer/...` and `./internal/connector/target/...` (covers Local CA, ACME, step-ca, NGINX, Apache, and HAProxy packages with unit tests for certificate signing logic, DNS solver, issuer validation, and deployment flows). The Frontend job runs `npx vitest run` between the TypeScript check and production build steps.
|
||||||
|
|
||||||
**Connector tests** (`internal/connector/`) — 57 test functions covering issuer, target, and notifier connectors. The Local CA connector has tests for self-signed and sub-CA modes (RSA, ECDSA, config validation, non-CA cert rejection). The ACME DNS solver has 6 tests for script-based DNS-01 challenges. The step-ca connector has tests with a mock HTTP server for issuance, renewal, revocation, and error paths. The OpenSSL/Custom CA connector has 14 tests covering config validation, issuance success/failure/timeout, renewal, revocation, and CRL generation. The NGINX target connector has 13 tests covering config validation, certificate deployment (file writing, permissions, validate/reload commands), and deployment validation. Apache httpd and HAProxy connectors each have 3 tests covering config validation, deployment, and validation flows. Notifier connector tests span 20 tests across Slack (5), Teams (4), PagerDuty (6), and OpsGenie (5) — verifying channel identity, payload formatting, HTTP error handling, connection failures, auth headers, and configuration defaults.
|
**Connector tests** (`internal/connector/`) — 57 test functions covering issuer, target, and notifier connectors. The Local CA connector has tests for self-signed and sub-CA modes (RSA, ECDSA, config validation, non-CA cert rejection). The ACME DNS solver has 10 tests for script-based DNS-01 and DNS-PERSIST-01 challenges (6 DNS-01 tests + 4 DNS-PERSIST-01 tests covering `PresentPersist` success, no-script error, script failure, and wildcard domain handling). The step-ca connector has tests with a mock HTTP server for issuance, renewal, revocation, and error paths. The OpenSSL/Custom CA connector has 14 tests covering config validation, issuance success/failure/timeout, renewal, revocation, and CRL generation. The NGINX target connector has 13 tests covering config validation, certificate deployment (file writing, permissions, validate/reload commands), and deployment validation. Apache httpd and HAProxy connectors each have 3 tests covering config validation, deployment, and validation flows. Notifier connector tests span 20 tests across Slack (5), Teams (4), PagerDuty (6), and OpsGenie (5) — verifying channel identity, payload formatting, HTTP error handling, connection failures, auth headers, and configuration defaults.
|
||||||
|
|
||||||
**What's not tested and why:** Postgres repository implementations (`internal/repository/postgres/`) require a real database and are tested only through integration tests, not unit tests. Target connectors for F5 BIG-IP and IIS are interface stubs (implementation planned for a future release). Scheduler loops are time-dependent and tested manually during development. The ACME connector requires a real ACME server (tested manually against Let's Encrypt staging). These are all candidates for future expansion as the test infrastructure matures.
|
**What's not tested and why:** Postgres repository implementations (`internal/repository/postgres/`) require a real database and are tested only through integration tests, not unit tests. Target connectors for F5 BIG-IP and IIS are interface stubs (implementation planned for a future release). Scheduler loops are time-dependent and tested manually during development. The ACME connector requires a real ACME server (tested manually against Let's Encrypt staging). These are all candidates for future expansion as the test infrastructure matures.
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ certctl generates certificate keys on agent infrastructure using Go's `crypto/ra
|
|||||||
|
|
||||||
**Server-Side Key Generation (Demo Only)**
|
**Server-Side Key Generation (Demo Only)**
|
||||||
- Available for development and testing via `CERTCTL_KEYGEN_MODE=server`
|
- Available for development and testing via `CERTCTL_KEYGEN_MODE=server`
|
||||||
- Explicitly logged as a warning at startup: "server-side keygen enabled (production deployments must use agent mode)"
|
- Explicitly logged as a warning at startup: "server-side key generation enabled (CERTCTL_KEYGEN_MODE=server) — private keys touch control plane, demo only"
|
||||||
- Docker Compose demo uses server mode for backward compatibility
|
- Docker Compose demo uses server mode for backward compatibility
|
||||||
- Not recommended for production; agent mode is the secure default
|
- Not recommended for production; agent mode is the secure default
|
||||||
|
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
|||||||
- **Server-Side Fallback** (demo/development only) — `CERTCTL_KEYGEN_MODE=server`:
|
- **Server-Side Fallback** (demo/development only) — `CERTCTL_KEYGEN_MODE=server`:
|
||||||
- Control plane generates RSA 2048-bit or ECDSA P-256 keys using `crypto/rand` + `crypto/rsa`.
|
- Control plane generates RSA 2048-bit or ECDSA P-256 keys using `crypto/rand` + `crypto/rsa`.
|
||||||
- Server signs CSR and stores the private key in the certificate version record for agent deployment. **Security note:** In server keygen mode, the control plane holds private keys — this is why agent keygen mode is the recommended default for production.
|
- Server signs CSR and stores the private key in the certificate version record for agent deployment. **Security note:** In server keygen mode, the control plane holds private keys — this is why agent keygen mode is the recommended default for production.
|
||||||
- **Must not be used in production.** Explicit warning logged: `Key generation mode is server; this should only be used for testing.`
|
- **Must not be used in production.** Explicit warning logged: `server-side key generation enabled (CERTCTL_KEYGEN_MODE=server) — private keys touch control plane, demo only`
|
||||||
|
|
||||||
- **Issuer-Specific Key Negotiation**:
|
- **Issuer-Specific Key Negotiation**:
|
||||||
- **ACME (Let's Encrypt, ZeroSSL)**: Let's Encrypt controls key types; certctl requests ECDSA P-256 by default.
|
- **ACME (Let's Encrypt, ZeroSSL)**: Let's Encrypt controls key types; certctl requests ECDSA P-256 by default.
|
||||||
@@ -178,7 +178,7 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
|||||||
|
|
||||||
**Evidence You Can Provide**:
|
**Evidence You Can Provide**:
|
||||||
- Deployment configuration: `CERTCTL_KEYGEN_MODE=agent` in production (verify in `docker-compose.yml`, Kubernetes manifests, or systemd units).
|
- Deployment configuration: `CERTCTL_KEYGEN_MODE=agent` in production (verify in `docker-compose.yml`, Kubernetes manifests, or systemd units).
|
||||||
- Agent log excerpt showing key generation: `openssl genrsa...` or agent process logs with CSR submission timestamp.
|
- Agent log excerpt showing key generation: Go `crypto/ecdsa.GenerateKey(elliptic.P256())` via agent process logs with CSR submission timestamp.
|
||||||
- Certificate CSR audit: `GET /api/v1/audit?type=certificate_issued` showing CSR fingerprint (SHA-256 hash of CSR PEM).
|
- Certificate CSR audit: `GET /api/v1/audit?type=certificate_issued` showing CSR fingerprint (SHA-256 hash of CSR PEM).
|
||||||
- Renewal job logs showing agent-submitted CSR, not server-generated key.
|
- Renewal job logs showing agent-submitted CSR, not server-generated key.
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
|||||||
- **Control Plane Key Storage** — Sensitive credentials managed via environment variables or `.env` files:
|
- **Control Plane Key Storage** — Sensitive credentials managed via environment variables or `.env` files:
|
||||||
- CA private key path: `CERTCTL_CA_CERT_PATH` + `CERTCTL_CA_KEY_PATH` (for Local CA sub-CA mode).
|
- CA private key path: `CERTCTL_CA_CERT_PATH` + `CERTCTL_CA_KEY_PATH` (for Local CA sub-CA mode).
|
||||||
- ACME account key: embedded in ACME issuer config (not stored separately; ACME library handles in memory).
|
- ACME account key: embedded in ACME issuer config (not stored separately; ACME library handles in memory).
|
||||||
- step-ca provisioner key: `CERTCTL_STEPCA_PROVISIONER_KEY` env var (JWK, in memory during runtime).
|
- step-ca provisioner key: `CERTCTL_STEPCA_KEY_PATH` env var (path to JWK private key file, loaded into memory during runtime).
|
||||||
- API keys: `CERTCTL_API_KEY` (SHA-256 hashed in database, plaintext never stored).
|
- API keys: `CERTCTL_API_KEY` (SHA-256 hashed in database, plaintext never stored).
|
||||||
- Database credentials: `CERTCTL_DATABASE_URL` in `.env` file, not in source code.
|
- Database credentials: `CERTCTL_DATABASE_URL` in `.env` file, not in source code.
|
||||||
|
|
||||||
@@ -785,7 +785,7 @@ Certctl v3 (Pro) adds paid features that strengthen PCI-DSS compliance posture:
|
|||||||
For additional guidance on certctl features and PCI-DSS mapping:
|
For additional guidance on certctl features and PCI-DSS mapping:
|
||||||
- Review the [Architecture Guide](architecture.md) for system design.
|
- Review the [Architecture Guide](architecture.md) for system design.
|
||||||
- Check [Connectors Documentation](connectors.md) for issuer/target/notifier capabilities.
|
- Check [Connectors Documentation](connectors.md) for issuer/target/notifier capabilities.
|
||||||
- Run the [Demo Guide](demo-guide.md) to see features in action.
|
- Run the [Quick Start Guide](quickstart.md) to see features in action.
|
||||||
- Consult your QSA for final compliance determination.
|
- Consult your QSA for final compliance determination.
|
||||||
|
|
||||||
**Last Updated**: March 24, 2026 (certctl v1.0 with M18b discovery and M19 audit logging)
|
**Last Updated**: March 24, 2026 (certctl v1.0 with M18b discovery and M19 audit logging)
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ Each section includes:
|
|||||||
|
|
||||||
- **API Key Policy** — All API access requires an API key or explicit opt-out. Opt-out (`CERTCTL_AUTH_TYPE=none`) logs a warning: "WARNING: Auth disabled (CERTCTL_AUTH_TYPE=none) — this is insecure and only for development". Configuration choice is logged at startup.
|
- **API Key Policy** — All API access requires an API key or explicit opt-out. Opt-out (`CERTCTL_AUTH_TYPE=none`) logs a warning: "WARNING: Auth disabled (CERTCTL_AUTH_TYPE=none) — this is insecure and only for development". Configuration choice is logged at startup.
|
||||||
- **Agent Authentication** — Agents authenticate to the server via API keys (same mechanism as users). Agent credentials are separate from user API keys.
|
- **Agent Authentication** — Agents authenticate to the server via API keys (same mechanism as users). Agent credentials are separate from user API keys.
|
||||||
- **Private Key Policy** — Agent-side key generation is the default (`CERTCTL_KEYGEN_MODE=agent`). Server-side keygen (`CERTCTL_KEYGEN_MODE=server`) requires explicit configuration and logs a warning: "Server-side keygen enabled — private keys will be stored in PostgreSQL (development only)".
|
- **Private Key Policy** — Agent-side key generation is the default (`CERTCTL_KEYGEN_MODE=agent`). Server-side keygen (`CERTCTL_KEYGEN_MODE=server`) requires explicit configuration and logs a warning: "server-side key generation enabled (CERTCTL_KEYGEN_MODE=server) — private keys touch control plane, demo only".
|
||||||
- **Password Policy** — Not applicable; certctl uses API keys exclusively. Password management is delegated to your organization's IAM system if you integrate OIDC/SSO (V3).
|
- **Password Policy** — Not applicable; certctl uses API keys exclusively. Password management is delegated to your organization's IAM system if you integrate OIDC/SSO (V3).
|
||||||
|
|
||||||
**Evidence Locations**:
|
**Evidence Locations**:
|
||||||
@@ -119,7 +119,7 @@ Each section includes:
|
|||||||
|
|
||||||
**certctl Implementation** (V2):
|
**certctl Implementation** (V2):
|
||||||
|
|
||||||
- **TLS for Control Plane** — All API communication occurs over HTTPS (TLS 1.2+). Server uses `tls.Dial()` for outbound connections to issuers and targets. Configuration: `CERTCTL_SERVER_ADDR` (default `:8443`).
|
- **TLS for Control Plane** — All API communication occurs over HTTPS (TLS 1.2+). Server uses `tls.Dial()` for outbound connections to issuers and targets. Configuration: `CERTCTL_SERVER_HOST` (default `127.0.0.1`) + `CERTCTL_SERVER_PORT` (default `8080`; Docker Compose maps to `8443`).
|
||||||
- **Agent-to-Server Communication** — Agents submit CSRs and heartbeats over HTTPS to the server using the same TLS stack.
|
- **Agent-to-Server Communication** — Agents submit CSRs and heartbeats over HTTPS to the server using the same TLS stack.
|
||||||
- **Private Key Isolation** — Agents generate ECDSA P-256 private keys locally (`crypto/ecdsa` + `crypto/elliptic`). Private keys are never transmitted to the server — agents submit CSRs only. Private keys are stored on agent filesystem (`CERTCTL_KEY_DIR`, default `/var/lib/certctl/keys`) with 0600 (owner read/write only) permissions. Server-side keygen mode logs a development warning; production must use agent-side keygen.
|
- **Private Key Isolation** — Agents generate ECDSA P-256 private keys locally (`crypto/ecdsa` + `crypto/elliptic`). Private keys are never transmitted to the server — agents submit CSRs only. Private keys are stored on agent filesystem (`CERTCTL_KEY_DIR`, default `/var/lib/certctl/keys`) with 0600 (owner read/write only) permissions. Server-side keygen mode logs a development warning; production must use agent-side keygen.
|
||||||
- **Certificate Storage** — Signed certificates are stored in PostgreSQL as PEM text (along with metadata). Certificates are not secrets and may be transmitted plaintext. Private keys are never stored on the control plane in production (agent-side keygen mode).
|
- **Certificate Storage** — Signed certificates are stored in PostgreSQL as PEM text (along with metadata). Certificates are not secrets and may be transmitted plaintext. Private keys are never stored on the control plane in production (agent-side keygen mode).
|
||||||
|
|||||||
@@ -34,9 +34,17 @@ certctl includes a built-in **Local CA** that can operate in two modes: self-sig
|
|||||||
|
|
||||||
### ACME Protocol
|
### ACME Protocol
|
||||||
|
|
||||||
ACME (Automatic Certificate Management Environment) is the protocol Let's Encrypt created for automated certificate issuance. Instead of filling out forms and waiting for emails, ACME lets software request, validate, and receive certificates programmatically. The server proves domain ownership by responding to challenges — placing a specific file on the web server (HTTP-01) or creating a DNS record (DNS-01).
|
ACME (Automatic Certificate Management Environment) is the protocol Let's Encrypt created for automated certificate issuance. Instead of filling out forms and waiting for emails, ACME lets software request, validate, and receive certificates programmatically. The server proves domain ownership by responding to challenges — placing a specific file on the web server (HTTP-01), creating a DNS record (DNS-01), or maintaining a standing DNS record that persists across renewals (DNS-PERSIST-01).
|
||||||
|
|
||||||
certctl speaks ACME natively with both HTTP-01 and DNS-01 challenges, so it can request certificates — including wildcard certificates — from Let's Encrypt or any ACME-compatible CA without manual intervention. HTTP-01 uses a built-in temporary HTTP server for domain validation; DNS-01 uses pluggable script-based hooks to create TXT records with any DNS provider (Cloudflare, Route53, Azure DNS, etc.).
|
certctl speaks ACME natively with HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, so it can request certificates — including wildcard certificates — from Let's Encrypt or any ACME-compatible CA without manual intervention. HTTP-01 uses a built-in temporary HTTP server for domain validation; DNS-01 uses pluggable script-based hooks to create TXT records with any DNS provider (Cloudflare, Route53, Azure DNS, etc.); DNS-PERSIST-01 creates a standing `_validation-persist` TXT record once (containing the CA domain and account URI) that the CA revalidates on every renewal — no per-renewal DNS updates needed. If the CA doesn't yet support DNS-PERSIST-01, certctl automatically falls back to DNS-01.
|
||||||
|
|
||||||
|
### EST Protocol (Enrollment over Secure Transport)
|
||||||
|
|
||||||
|
EST (RFC 7030) is a standard protocol for devices to request certificates from a CA. While ACME was designed for web servers proving domain ownership, EST was designed for devices that need certificates without domain validation — think WiFi access points, corporate laptops connecting to 802.1X networks, IoT devices, and mobile devices managed by MDM platforms.
|
||||||
|
|
||||||
|
The workflow is straightforward: a device generates a key pair and a Certificate Signing Request (CSR), sends the CSR to the EST server, and gets back a signed certificate. The EST server also distributes its CA certificate chain so devices can build a complete trust path.
|
||||||
|
|
||||||
|
certctl includes a built-in EST server at `/.well-known/est/` with four operations: distributing the CA certificate chain (`/cacerts`), enrolling new devices (`/simpleenroll`), renewing existing certificates (`/simplereenroll`), and advertising CSR requirements (`/csrattrs`). EST enrollment uses the same issuer connectors as the REST API — so a certificate issued via EST and a certificate issued via the dashboard go through the same CA, appear in the same inventory, and follow the same policies.
|
||||||
|
|
||||||
### Private Key
|
### Private Key
|
||||||
|
|
||||||
@@ -180,16 +188,22 @@ certctl can alert you when certificates are expiring, when renewals fail, when d
|
|||||||
|
|
||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
certctl ships with a command-line tool (`certctl-cli`) for operators who prefer terminal workflows or need to integrate certctl into shell scripts and CI/CD pipelines. The CLI wraps the REST API with 10 subcommands: `list-certs`, `get-cert`, `renew-cert`, `revoke-cert`, `list-agents`, `list-jobs`, `health`, `metrics`, and `import` (for bulk PEM import).
|
certctl ships with a command-line tool (`certctl-cli`) for operators who prefer terminal workflows or need to integrate certctl into shell scripts and CI/CD pipelines. The CLI wraps the REST API with 12 subcommands organized by resource: `certs list`, `certs get`, `certs renew`, `certs revoke`, `agents list`, `agents get`, `jobs list`, `jobs get`, `jobs cancel`, `import` (bulk PEM import), `status` (health + summary stats), and `version`.
|
||||||
|
|
||||||
The CLI supports both table and JSON output formats (`--format table` or `--format json`), connects to the server via `CERTCTL_SERVER_URL` and authenticates with `CERTCTL_API_KEY`. It's built with Go's standard library only — no external dependencies.
|
The CLI supports both table and JSON output formats (`--format table` or `--format json`), connects to the server via `CERTCTL_SERVER_URL` and authenticates with `CERTCTL_API_KEY`. It's built with Go's standard library only — no external dependencies.
|
||||||
|
|
||||||
### MCP Server (AI Integration)
|
### MCP Server (AI Integration)
|
||||||
|
|
||||||
certctl includes an MCP (Model Context Protocol) server that exposes all 78 API endpoints 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?"
|
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?"
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
### EST Enrollment (Device Certificates)
|
||||||
|
|
||||||
|
certctl's EST server enables device certificate enrollment for use cases that don't fit the traditional "ops team requests a cert via API" model. When a RADIUS server is configured to use certctl for 802.1X WiFi authentication, or an MDM platform enrolls corporate devices, they use the EST protocol at `/.well-known/est/`. The EST server validates the CSR, issues a certificate via the configured issuer connector, and returns it in PKCS#7 format — the standard wire format that every EST client understands. Each enrollment is recorded in the audit trail with the protocol, common name, SANs, issuer, and serial number.
|
||||||
|
|
||||||
|
Enable it with `CERTCTL_EST_ENABLED=true`. Optionally bind enrollments to a specific issuer (`CERTCTL_EST_ISSUER_ID`) or certificate profile (`CERTCTL_EST_PROFILE_ID`) to constrain what EST clients can request.
|
||||||
|
|
||||||
### Certificate Discovery
|
### Certificate Discovery
|
||||||
|
|
||||||
Certificate discovery is the process of automatically finding existing certificates in your infrastructure — certificates you didn't issue through certctl, possibly issued by other CAs or tools. This is essential for building a complete inventory before you can manage everything.
|
Certificate discovery is the process of automatically finding existing certificates in your infrastructure — certificates you didn't issue through certctl, possibly issued by other CAs or tools. This is essential for building a complete inventory before you can manage everything.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Connectors extend certctl to integrate with external systems for certificate iss
|
|||||||
|
|
||||||
Three types of connectors:
|
Three types of connectors:
|
||||||
|
|
||||||
1. **Issuer Connector** — Obtains certificates from CAs (Local CA with sub-CA support, ACME with HTTP-01 + DNS-01, step-ca, OpenSSL/Custom CA implemented; additional CA integrations planned)
|
1. **Issuer Connector** — Obtains certificates from CAs (Local CA with sub-CA support, ACME with HTTP-01 + DNS-01 + DNS-PERSIST-01, step-ca, OpenSSL/Custom CA implemented; additional CA integrations planned)
|
||||||
2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy implemented; F5 via proxy agent, IIS dual-mode interface only; additional cloud and network targets planned)
|
2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy implemented; F5 via proxy agent, IIS dual-mode interface only; additional cloud and network targets planned)
|
||||||
3. **Notifier Connector** — Sends alerts about certificate events (Email, Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie implemented)
|
3. **Notifier Connector** — Sends alerts about certificate events (Email, Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie implemented)
|
||||||
|
|
||||||
@@ -37,6 +37,19 @@ type Connector interface {
|
|||||||
|
|
||||||
// GetOrderStatus checks the status of an async issuance order
|
// GetOrderStatus checks the status of an async issuance order
|
||||||
GetOrderStatus(ctx context.Context, orderID string) (*OrderStatus, error)
|
GetOrderStatus(ctx context.Context, orderID string) (*OrderStatus, error)
|
||||||
|
|
||||||
|
// GenerateCRL generates a DER-encoded X.509 CRL signed by this issuer.
|
||||||
|
// Returns nil if the issuer does not support CRL generation (e.g., ACME).
|
||||||
|
GenerateCRL(ctx context.Context, revokedCerts []RevokedCertEntry) ([]byte, error)
|
||||||
|
|
||||||
|
// SignOCSPResponse signs an OCSP response for the given certificate serial.
|
||||||
|
// Returns nil if the issuer does not support OCSP (e.g., ACME).
|
||||||
|
SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
|
||||||
|
|
||||||
|
// GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer.
|
||||||
|
// Used by the EST server's /cacerts endpoint (RFC 7030).
|
||||||
|
// Returns error if the issuer doesn't provide a static CA chain (e.g., ACME, step-ca).
|
||||||
|
GetCACertPEM(ctx context.Context) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type IssuanceRequest struct {
|
type IssuanceRequest struct {
|
||||||
@@ -103,12 +116,14 @@ Location: `internal/connector/issuer/local/local.go`
|
|||||||
|
|
||||||
### Built-in: ACME v2 (Let's Encrypt, Sectigo, ZeroSSL)
|
### Built-in: ACME v2 (Let's Encrypt, Sectigo, ZeroSSL)
|
||||||
|
|
||||||
The ACME connector implements the full ACME v2 protocol using Go's `golang.org/x/crypto/acme` package. It supports two challenge methods:
|
The ACME connector implements the full ACME v2 protocol using Go's `golang.org/x/crypto/acme` package. It supports three challenge methods:
|
||||||
|
|
||||||
**HTTP-01 (default):** A built-in temporary HTTP server starts on demand during certificate issuance. The domain being validated must resolve to the machine running the connector, and the configured HTTP port must be reachable from the internet.
|
**HTTP-01 (default):** A built-in temporary HTTP server starts on demand during certificate issuance. The domain being validated must resolve to the machine running the connector, and the configured HTTP port must be reachable from the internet.
|
||||||
|
|
||||||
**DNS-01 (for wildcards):** Creates DNS TXT records via user-provided scripts. Required for wildcard certificates (`*.example.com`) and hosts that can't serve HTTP on port 80. The connector invokes external scripts to create and clean up `_acme-challenge` TXT records, making it compatible with any DNS provider (Cloudflare, Route53, Azure DNS, etc.).
|
**DNS-01 (for wildcards):** Creates DNS TXT records via user-provided scripts. Required for wildcard certificates (`*.example.com`) and hosts that can't serve HTTP on port 80. The connector invokes external scripts to create and clean up `_acme-challenge` TXT records, making it compatible with any DNS provider (Cloudflare, Route53, Azure DNS, etc.).
|
||||||
|
|
||||||
|
**DNS-PERSIST-01 (standing record):** Creates a one-time persistent TXT record at `_validation-persist.<domain>` containing the CA's issuer domain and your ACME account URI. Once set, this record authorizes unlimited future certificate issuances without per-renewal DNS updates. Based on [draft-ietf-acme-dns-persist](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/) and CA/Browser Forum ballot SC-088v3. If the CA doesn't offer dns-persist-01 yet, the connector falls back to dns-01 automatically.
|
||||||
|
|
||||||
HTTP-01 configuration:
|
HTTP-01 configuration:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -130,14 +145,29 @@ DNS-01 configuration:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
DNS hook scripts receive these environment variables: `CERTCTL_DNS_DOMAIN` (domain being validated), `CERTCTL_DNS_FQDN` (full record name, e.g., `_acme-challenge.example.com`), `CERTCTL_DNS_VALUE` (TXT record value), `CERTCTL_DNS_TOKEN` (ACME challenge token). The present script must create the TXT record and exit 0; the cleanup script removes it.
|
DNS-PERSIST-01 configuration:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"directory_url": "https://acme-v02.api.letsencrypt.org/directory",
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"challenge_type": "dns-persist-01",
|
||||||
|
"dns_present_script": "/etc/certctl/dns/create-record.sh",
|
||||||
|
"dns_persist_issuer_domain": "letsencrypt.org",
|
||||||
|
"dns_propagation_wait": 30
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The present script creates a TXT record at `_validation-persist.<domain>` with the value `letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/<your-id>`. This record is permanent — no cleanup script is needed.
|
||||||
|
|
||||||
|
DNS hook scripts receive these environment variables: `CERTCTL_DNS_DOMAIN` (domain being validated), `CERTCTL_DNS_FQDN` (full record name — `_acme-challenge.<domain>` for dns-01, `_validation-persist.<domain>` for dns-persist-01), `CERTCTL_DNS_VALUE` (TXT record value), `CERTCTL_DNS_TOKEN` (ACME challenge token). The present script must create the TXT record and exit 0; the cleanup script removes it (dns-01 only).
|
||||||
|
|
||||||
Environment variables for the default ACME connector:
|
Environment variables for the default ACME connector:
|
||||||
- `CERTCTL_ACME_DIRECTORY_URL` — ACME directory URL
|
- `CERTCTL_ACME_DIRECTORY_URL` — ACME directory URL
|
||||||
- `CERTCTL_ACME_EMAIL` — Contact email for account registration
|
- `CERTCTL_ACME_EMAIL` — Contact email for account registration
|
||||||
- `CERTCTL_ACME_CHALLENGE_TYPE` — `http-01` (default) or `dns-01`
|
- `CERTCTL_ACME_CHALLENGE_TYPE` — `http-01` (default), `dns-01`, or `dns-persist-01`
|
||||||
- `CERTCTL_ACME_DNS_PRESENT_SCRIPT` — Path to DNS record creation script (dns-01 only)
|
- `CERTCTL_ACME_DNS_PRESENT_SCRIPT` — Path to DNS record creation script (dns-01 and dns-persist-01)
|
||||||
- `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` — Path to DNS record cleanup script (dns-01 only)
|
- `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` — Path to DNS record cleanup script (dns-01 only, not used by dns-persist-01)
|
||||||
|
- `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN` — CA issuer domain for persistent record (dns-persist-01 only, e.g., `letsencrypt.org`)
|
||||||
|
|
||||||
The connector is registered in the issuer registry under `iss-acme-staging` and `iss-acme-prod`. Use `iss-acme-staging` for Let's Encrypt staging (rate-limit-friendly testing) and `iss-acme-prod` for production certificates.
|
The connector is registered in the issuer registry under `iss-acme-staging` and `iss-acme-prod`. Use `iss-acme-staging` for Let's Encrypt staging (rate-limit-friendly testing) and `iss-acme-prod` for production certificates.
|
||||||
|
|
||||||
@@ -198,12 +228,23 @@ Each issuer handles revocation differently:
|
|||||||
- **step-ca**: Calls step-ca's `/revoke` API endpoint. Clients should check step-ca's own CRL/OCSP for authoritative status.
|
- **step-ca**: Calls step-ca's `/revoke` API endpoint. Clients should check step-ca's own CRL/OCSP for authoritative status.
|
||||||
- **OpenSSL/Custom CA**: Invokes the configured revoke script (`CERTCTL_OPENSSL_REVOKE_SCRIPT`) with the serial number as an argument.
|
- **OpenSSL/Custom CA**: Invokes the configured revoke script (`CERTCTL_OPENSSL_REVOKE_SCRIPT`) with the serial number as an argument.
|
||||||
|
|
||||||
|
### EST Integration (GetCACertPEM)
|
||||||
|
|
||||||
|
The `GetCACertPEM()` method returns the PEM-encoded CA certificate chain, used by the EST server's `/.well-known/est/cacerts` endpoint (RFC 7030) to distribute the CA chain to enrolling devices. Each issuer handles this differently:
|
||||||
|
|
||||||
|
- **Local CA**: Returns the CA certificate PEM (self-signed or sub-CA cert). This is the primary EST issuer.
|
||||||
|
- **ACME**: Returns error — ACME CAs provide chains per-issuance, not statically.
|
||||||
|
- **step-ca**: Returns error — step-ca serves its own `/root` endpoint for CA distribution.
|
||||||
|
- **OpenSSL/Custom CA**: Returns error — custom script-based CAs have no CA cert access through certctl.
|
||||||
|
|
||||||
|
Note: EST (Enrollment over Secure Transport) is not a connector — it's a protocol handler (`internal/api/handler/est.go`) that delegates certificate issuance to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID`. See the [Architecture Guide](architecture.md#est-server-rfc-7030) for details.
|
||||||
|
|
||||||
### Planned Issuers
|
### Planned Issuers
|
||||||
|
|
||||||
The following issuer connectors are planned for future milestones:
|
The following issuer connectors are planned for future milestones:
|
||||||
|
|
||||||
- **Vault PKI** — HashiCorp Vault's PKI secrets engine for organizations using Vault as their internal CA (planned for V4.0+).
|
- **Vault PKI** — HashiCorp Vault's PKI secrets engine for organizations using Vault as their internal CA (planned for V4.0+).
|
||||||
- **DigiCert** — Commercial CA integration via DigiCert's REST API (planned for V3 paid release).
|
- **DigiCert** — Commercial CA integration via DigiCert's REST API (planned).
|
||||||
|
|
||||||
Note: ADCS (Active Directory Certificate Services) integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above.
|
Note: ADCS (Active Directory Certificate Services) integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above.
|
||||||
|
|
||||||
@@ -393,11 +434,11 @@ The combined PEM is built in this order: server certificate, intermediate/chain
|
|||||||
|
|
||||||
Location: `internal/connector/target/haproxy/haproxy.go`
|
Location: `internal/connector/target/haproxy/haproxy.go`
|
||||||
|
|
||||||
### V3 (Paid): F5 BIG-IP (Interface Only)
|
### F5 BIG-IP (Interface Only)
|
||||||
|
|
||||||
The F5 BIG-IP target connector interface is built with the iControl REST flow mapped out, but the actual API calls are not yet implemented. F5 appliances can't run agents directly, so this connector uses the **proxy agent pattern**: a designated agent in the same network zone picks up F5 deployment jobs and calls the iControl REST API. The server assigns the work; the proxy agent executes it. Implementation is planned for the paid V3 release.
|
The F5 BIG-IP target connector interface is defined with the iControl REST flow mapped out, but the actual API calls are not yet implemented. F5 appliances can't run agents directly, so this connector uses the **proxy agent pattern**: a designated agent in the same network zone picks up F5 deployment jobs and calls the iControl REST API. The server assigns the work; the proxy agent executes it.
|
||||||
|
|
||||||
The planned flow is: authenticate via `POST /mgmt/shared/authn/login`, upload cert PEM via `POST /mgmt/tm/ltm/certificate`, update the SSL profile via `PATCH /mgmt/tm/ltm/profile/client-ssl/{profile}`, and validate deployment by checking profile status. Implementation is planned for a future release.
|
The planned flow is: authenticate via `POST /mgmt/shared/authn/login`, upload cert PEM via `POST /mgmt/tm/ltm/certificate`, update the SSL profile via `PATCH /mgmt/tm/ltm/profile/client-ssl/{profile}`, and validate deployment by checking profile status.
|
||||||
|
|
||||||
Configuration (defined, not yet functional):
|
Configuration (defined, not yet functional):
|
||||||
```json
|
```json
|
||||||
@@ -414,9 +455,9 @@ Note: F5 credentials are stored on the proxy agent, not on the control plane ser
|
|||||||
|
|
||||||
Location: `internal/connector/target/f5/f5.go`
|
Location: `internal/connector/target/f5/f5.go`
|
||||||
|
|
||||||
### V3 (Paid): IIS (Interface Only, Dual-Mode)
|
### IIS (Interface Only, Dual-Mode)
|
||||||
|
|
||||||
The IIS target connector supports two deployment modes planned for the paid V3 release:
|
The IIS target connector supports two planned deployment modes:
|
||||||
|
|
||||||
**Agent-local (recommended):** A Windows agent runs directly on the IIS server and deploys certificates using PowerShell — `Import-PfxCertificate` to install into the certificate store and `Set-WebBinding` to bind to the IIS site. This is the preferred approach: no remote access needed, no credential management, same pull-based model as NGINX/Apache/HAProxy.
|
**Agent-local (recommended):** A Windows agent runs directly on the IIS server and deploys certificates using PowerShell — `Import-PfxCertificate` to install into the certificate store and `Set-WebBinding` to bind to the IIS site. This is the preferred approach: no remote access needed, no credential management, same pull-based model as NGINX/Apache/HAProxy.
|
||||||
|
|
||||||
@@ -474,6 +515,8 @@ Each notifier is enabled by its configuration env var:
|
|||||||
|
|
||||||
| Notifier | Env Var | Description |
|
| Notifier | Env Var | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
|
| Email | `CERTCTL_EMAIL_SMTP_HOST`, `CERTCTL_EMAIL_SMTP_PORT`, `CERTCTL_EMAIL_FROM` | SMTP email delivery. Optional: `CERTCTL_EMAIL_SMTP_USERNAME`, `CERTCTL_EMAIL_SMTP_PASSWORD` |
|
||||||
|
| Webhook | `CERTCTL_WEBHOOK_URL` | HTTP POST to any endpoint. Optional: `CERTCTL_WEBHOOK_SECRET` for HMAC signing |
|
||||||
| Slack | `CERTCTL_SLACK_WEBHOOK_URL` | Incoming webhook URL. Optional: `CERTCTL_SLACK_CHANNEL`, `CERTCTL_SLACK_USERNAME` |
|
| Slack | `CERTCTL_SLACK_WEBHOOK_URL` | Incoming webhook URL. Optional: `CERTCTL_SLACK_CHANNEL`, `CERTCTL_SLACK_USERNAME` |
|
||||||
| Teams | `CERTCTL_TEAMS_WEBHOOK_URL` | Incoming webhook URL (MessageCard format) |
|
| Teams | `CERTCTL_TEAMS_WEBHOOK_URL` | Incoming webhook URL (MessageCard format) |
|
||||||
| PagerDuty | `CERTCTL_PAGERDUTY_ROUTING_KEY` | Events API v2 routing key. Optional: `CERTCTL_PAGERDUTY_SEVERITY` (default: "warning") |
|
| PagerDuty | `CERTCTL_PAGERDUTY_ROUTING_KEY` | Events API v2 routing key. Optional: `CERTCTL_PAGERDUTY_SEVERITY` (default: "warning") |
|
||||||
|
|||||||
@@ -97,6 +97,21 @@ curl -s -X POST $API/api/v1/certificates \
|
|||||||
}' | jq .
|
}' | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ACME with DNS-PERSIST-01 (Zero-Touch Renewals)
|
||||||
|
|
||||||
|
DNS-PERSIST-01 uses a standing `_validation-persist` TXT record that you set once. The CA revalidates it on every renewal — no per-renewal DNS updates, no cleanup scripts, no propagation waits. If the CA doesn't support DNS-PERSIST-01 yet, certctl falls back to DNS-01 automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Configure ACME DNS-PERSIST-01
|
||||||
|
export CERTCTL_ACME_CHALLENGE_TYPE="dns-persist-01"
|
||||||
|
export CERTCTL_ACME_DNS_PRESENT_SCRIPT="/usr/local/bin/dns-present.sh"
|
||||||
|
export CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN="letsencrypt.org"
|
||||||
|
|
||||||
|
# The present script creates a _validation-persist.<domain> TXT record with value:
|
||||||
|
# "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/12345"
|
||||||
|
# This record is set once and never touched again.
|
||||||
|
```
|
||||||
|
|
||||||
### step-ca (Smallstep Private CA)
|
### step-ca (Smallstep Private CA)
|
||||||
|
|
||||||
For organizations running step-ca as their private CA:
|
For organizations running step-ca as their private CA:
|
||||||
@@ -221,7 +236,7 @@ You should see:
|
|||||||
|
|
||||||
The result is a structurally valid X.509 certificate — browsers won't trust it (no root CA in their trust store), but it exercises the exact same code paths that a production ACME or Vault issuer would.
|
The result is a structurally valid X.509 certificate — browsers won't trust it (no root CA in their trust store), but it exercises the exact same code paths that a production ACME or Vault issuer would.
|
||||||
|
|
||||||
**Why pluggable issuers:** Different organizations use different CAs. Some use Let's Encrypt (ACME protocol), some use step-ca or internal PKI (Vault), some use commercial CAs (DigiCert, Entrust, GlobalSign), and some have custom OpenSSL-based workflows. For enterprises with ADCS, certctl can operate as a sub-CA — all issued certs chain to the enterprise root. The connector interface means certctl doesn't care — it calls `IssueCertificate()` and gets back a signed cert regardless of the backend. V1 ships with Local CA (self-signed or sub-CA), ACME (HTTP-01 + DNS-01 for wildcards), and step-ca (Smallstep private CA via native /sign API). OpenSSL/Custom CA is planned for V2; DigiCert, Vault PKI, Entrust, GlobalSign, Google CAS, and EJBCA are planned for V3.
|
**Why pluggable issuers:** Different organizations use different CAs. Some use Let's Encrypt (ACME protocol), some use step-ca or internal PKI (Vault), some use commercial CAs (DigiCert, Entrust, GlobalSign), and some have custom OpenSSL-based workflows. For enterprises with ADCS, certctl can operate as a sub-CA — all issued certs chain to the enterprise root. The connector interface means certctl doesn't care — it calls `IssueCertificate()` and gets back a signed cert regardless of the backend. V1 ships with Local CA (self-signed or sub-CA), ACME (HTTP-01 + DNS-01 + DNS-PERSIST-01 for wildcards), and step-ca (Smallstep private CA via native /sign API). V2 adds the OpenSSL/Custom CA connector (script-based signing). DigiCert, Vault PKI, Entrust, GlobalSign, Google CAS, and EJBCA are planned for V3+.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
@@ -472,14 +487,14 @@ In production, agents poll for work and report results. You can simulate this ma
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Poll for pending deployment work (as an agent)
|
# Poll for pending deployment work (as an agent)
|
||||||
curl -s "$API/api/v1/agents/agent-nginx-prod/work" | jq .
|
curl -s "$API/api/v1/agents/ag-web-prod/work" | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
This returns pending deployment jobs assigned to the agent. The agent would then fetch the certificate, deploy it, and report back:
|
This returns pending deployment jobs assigned to the agent. The agent would then fetch the certificate, deploy it, and report back:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Report job completion (replace JOB_ID with an actual job ID from the work response)
|
# Report job completion (replace JOB_ID with an actual job ID from the work response)
|
||||||
curl -s -X POST "$API/api/v1/agents/agent-nginx-prod/jobs/JOB_ID/status" \
|
curl -s -X POST "$API/api/v1/agents/ag-web-prod/jobs/JOB_ID/status" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"status": "Completed",
|
"status": "Completed",
|
||||||
@@ -875,28 +890,28 @@ export CERTCTL_SERVER_URL="http://localhost:8443"
|
|||||||
export CERTCTL_API_KEY="test-key-123"
|
export CERTCTL_API_KEY="test-key-123"
|
||||||
|
|
||||||
# List certificates (JSON or table format)
|
# List certificates (JSON or table format)
|
||||||
./certctl-cli list-certs --format table
|
./certctl-cli certs list
|
||||||
|
|
||||||
# Get certificate details
|
# Get certificate details
|
||||||
./certctl-cli get-cert mc-demo-api
|
./certctl-cli certs get mc-demo-api
|
||||||
|
|
||||||
# Trigger renewal
|
# Trigger renewal
|
||||||
./certctl-cli renew-cert mc-demo-api
|
./certctl-cli certs renew mc-demo-api
|
||||||
|
|
||||||
# Revoke a certificate with RFC 5280 reason
|
# Revoke a certificate with RFC 5280 reason
|
||||||
./certctl-cli revoke-cert mc-demo-payments --reason keyCompromise
|
./certctl-cli certs revoke mc-demo-payments --reason keyCompromise
|
||||||
|
|
||||||
# List agents
|
# List agents
|
||||||
./certctl-cli list-agents
|
./certctl-cli agents list
|
||||||
|
|
||||||
# List pending jobs
|
# List pending jobs
|
||||||
./certctl-cli list-jobs
|
./certctl-cli jobs list
|
||||||
|
|
||||||
# Check system health
|
# Check system health and stats
|
||||||
./certctl-cli health
|
./certctl-cli status
|
||||||
|
|
||||||
# Export metrics
|
# JSON output format
|
||||||
./certctl-cli metrics --format json
|
./certctl-cli --format json status
|
||||||
|
|
||||||
# Bulk import certificates from a PEM file
|
# Bulk import certificates from a PEM file
|
||||||
./certctl-cli import /path/to/certificates.pem
|
./certctl-cli import /path/to/certificates.pem
|
||||||
@@ -908,7 +923,7 @@ export CERTCTL_API_KEY="test-key-123"
|
|||||||
|
|
||||||
## Part 15: MCP Server for AI Integration (M18a)
|
## Part 15: MCP Server for AI Integration (M18a)
|
||||||
|
|
||||||
certctl exposes all 78 API endpoints as tools via the Model Context Protocol (MCP), enabling seamless integration with Claude, Cursor, and other AI assistants:
|
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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the MCP server
|
# Build the MCP server
|
||||||
@@ -922,7 +937,7 @@ export CERTCTL_API_KEY="test-key-123"
|
|||||||
./mcp-server
|
./mcp-server
|
||||||
```
|
```
|
||||||
|
|
||||||
**How it works:** The MCP server uses the official Model Context Protocol Go SDK to expose stateless HTTP proxies to all 78 API endpoints. Each MCP tool corresponds to one or more REST endpoints and includes:
|
**How it works:** The MCP server uses the official Model Context Protocol Go SDK to expose 78 stateless HTTP proxy tools covering the REST API. Each MCP tool corresponds to one or more REST endpoints and includes:
|
||||||
|
|
||||||
- **Input schema** — typed arguments with JSON schema hints for LLM-friendly introspection
|
- **Input schema** — typed arguments with JSON schema hints for LLM-friendly introspection
|
||||||
- **Binary support** — handles DER-encoded CRL and OCSP responses without mangling
|
- **Binary support** — handles DER-encoded CRL and OCSP responses without mangling
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
# certctl Demo Guide
|
|
||||||
|
|
||||||
A 5-10 minute guided walkthrough of certctl's dashboard and API. Perfect for stakeholder presentations and team demos.
|
|
||||||
|
|
||||||
New to certificates? Read the [Concepts Guide](concepts.md) first. Want a hands-on demo where you issue certificates yourself? See the [Advanced Demo](demo-advanced.md).
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/shankar0123/certctl.git
|
|
||||||
cd certctl
|
|
||||||
docker compose -f deploy/docker-compose.yml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Wait ~30 seconds for PostgreSQL to initialize and the server to start, then open:
|
|
||||||
|
|
||||||
**http://localhost:8443**
|
|
||||||
|
|
||||||
You'll see the dashboard pre-loaded with 15 demo certificates across multiple teams, environments, and statuses — including expiring, expired, active, failed, wildcard, and in-progress renewals.
|
|
||||||
|
|
||||||
## What You'll See
|
|
||||||
|
|
||||||
### Dashboard Overview
|
|
||||||
The main dashboard shows at a glance:
|
|
||||||
- **Total certificates** managed across your infrastructure
|
|
||||||
- **Expiring soon** — certificates within 30 days of expiration (yellow/red)
|
|
||||||
- **Expired** — certificates past their expiration date
|
|
||||||
- **Active** — healthy certificates with time remaining
|
|
||||||
- **Renewal success rate** — percentage of automated renewals that succeeded
|
|
||||||
|
|
||||||
Below the stats, interactive charts provide deeper visibility: an **expiration heatmap** (90-day weekly buckets), **renewal success rate trends** (30-day line chart), **certificate status distribution** (donut chart), and **issuance rate** (30-day bar chart).
|
|
||||||
|
|
||||||
### Certificates View
|
|
||||||
Click "Certificates" in the sidebar to see the full inventory:
|
|
||||||
- Search by name or domain
|
|
||||||
- Filter by status (Active, Expiring, Expired, Failed) or environment (Production, Staging)
|
|
||||||
- Sort by any column
|
|
||||||
- Click any row to see full details: metadata, version history, deployment targets, and audit trail
|
|
||||||
|
|
||||||
### Demo Scenarios to Walk Through
|
|
||||||
|
|
||||||
**1. "We're about to have an outage"**
|
|
||||||
Filter by status → Expiring. You'll see `auth-production` (12 days), `cdn-production` (8 days), and `mail-production` (5 days). These are real alerts the platform would catch automatically.
|
|
||||||
|
|
||||||
**2. "A renewal failed"**
|
|
||||||
Look at `vpn-production` — status: Failed. Click it to see the audit trail showing the ACME challenge failure after 3 retry attempts. The system sent a webhook notification to the ops channel.
|
|
||||||
|
|
||||||
**3. "Who owns this cert?"**
|
|
||||||
Click any certificate to see the owner, team, environment, and tags. Every cert has clear accountability.
|
|
||||||
|
|
||||||
**4. "What happened to the legacy app?"**
|
|
||||||
Filter by status → Expired. `legacy-app` expired 3 days ago, `old-api-v1` expired 15 days ago. Both have policy violations flagged.
|
|
||||||
|
|
||||||
**5. "Show me the agent fleet"**
|
|
||||||
Click "Agents" in the sidebar. Four agents are online, one (`iis-prod-agent`) went offline 3 hours ago — you'd want to investigate that.
|
|
||||||
|
|
||||||
**6. "What policies are enforced?"**
|
|
||||||
Click "Policies" to see the active rules: required owner metadata, allowed environments, max certificate lifetime, minimum renewal window. Check the violations list to see which certs are non-compliant.
|
|
||||||
|
|
||||||
**7. "Can I revoke a compromised cert?"**
|
|
||||||
Click any active certificate, then click the "Revoke" button. A modal appears with RFC 5280 reason codes (Key Compromise, Superseded, Cessation of Operation, etc.). After revocation, the cert shows a revocation banner with the reason and timestamp.
|
|
||||||
|
|
||||||
**8. "Show me short-lived credentials"**
|
|
||||||
Click "Short-Lived" in the sidebar. This view shows certificates with TTL under 1 hour — live countdown timers, auto-refresh every 10 seconds, and profile-based filtering. These are for service-to-service auth where rapid expiry replaces revocation.
|
|
||||||
|
|
||||||
**9. "What about bulk operations?"**
|
|
||||||
On the Certificates page, select multiple certificates using the checkboxes. A bulk action bar appears with options to trigger renewal, revoke (with reason codes), or reassign ownership — all with progress tracking.
|
|
||||||
|
|
||||||
**10. "How do I see the deployment history?"**
|
|
||||||
Click any certificate, then scroll to the deployment timeline. A visual 4-step timeline shows the lifecycle: Requested → Issued → Deploying → Active. Previous versions show a rollback button.
|
|
||||||
|
|
||||||
**11. "What about certificates already running in production?"**
|
|
||||||
Enable discovery on agents by setting `CERTCTL_DISCOVERY_DIRS` to directories containing certificates (e.g., `/etc/nginx/certs`). Agents scan on startup and every 6 hours, report findings to the control plane. For network-based discovery without agents, enable `CERTCTL_NETWORK_SCAN_ENABLED=true` and configure scan targets via the API — the server probes TLS endpoints on configured CIDR ranges and ports. Click "Discovered Certificates" to see what agents and network scans found — claim unmanaged certs to bring them under certctl's management, or dismiss them.
|
|
||||||
|
|
||||||
## REST API Walkthrough
|
|
||||||
|
|
||||||
The dashboard is backed by a real REST API (91 endpoints). Try these while the demo is running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List all certificates
|
|
||||||
curl -s http://localhost:8443/api/v1/certificates | jq .
|
|
||||||
|
|
||||||
# Get expiring certs
|
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?status=expiring" | jq .
|
|
||||||
|
|
||||||
# Advanced query: sort by expiration, sparse fields, cursor pagination
|
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?sort=-expires_at&fields=id,common_name,expires_at" | jq .
|
|
||||||
|
|
||||||
# Time-range filter: certs expiring before June 2026
|
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?expires_before=2026-06-01T00:00:00Z" | jq .
|
|
||||||
|
|
||||||
# Get a specific certificate
|
|
||||||
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod | jq .
|
|
||||||
|
|
||||||
# Get deployment targets for a certificate
|
|
||||||
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod/deployments | jq .
|
|
||||||
|
|
||||||
# List agents
|
|
||||||
curl -s http://localhost:8443/api/v1/agents | jq .
|
|
||||||
|
|
||||||
# View audit trail (immutable API audit log of all actions)
|
|
||||||
curl -s http://localhost:8443/api/v1/audit | jq .
|
|
||||||
|
|
||||||
# View policy violations (replace POLICY_ID with a real policy ID, e.g. pr-require-owner)
|
|
||||||
curl -s http://localhost:8443/api/v1/policies/pr-require-owner/violations | jq .
|
|
||||||
|
|
||||||
# Check system health
|
|
||||||
curl -s http://localhost:8443/health | jq .
|
|
||||||
|
|
||||||
# Dashboard stats and metrics
|
|
||||||
curl -s http://localhost:8443/api/v1/stats/summary | jq .
|
|
||||||
curl -s http://localhost:8443/api/v1/stats/certificates-by-status | jq .
|
|
||||||
curl -s http://localhost:8443/api/v1/stats/expiration-timeline | jq .
|
|
||||||
curl -s http://localhost:8443/api/v1/stats/job-trends | jq .
|
|
||||||
curl -s http://localhost:8443/api/v1/stats/issuance-rate | jq .
|
|
||||||
curl -s http://localhost:8443/api/v1/metrics | jq .
|
|
||||||
curl -s http://localhost:8443/api/v1/metrics/prometheus # Prometheus format
|
|
||||||
|
|
||||||
# Certificate profiles
|
|
||||||
curl -s http://localhost:8443/api/v1/profiles | jq .
|
|
||||||
|
|
||||||
# Agent groups
|
|
||||||
curl -s http://localhost:8443/api/v1/agent-groups | jq .
|
|
||||||
|
|
||||||
# Revoke a certificate
|
|
||||||
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-api-prod/revoke \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"reason": "superseded"}' | jq .
|
|
||||||
|
|
||||||
# CRL and OCSP endpoints
|
|
||||||
curl -s http://localhost:8443/api/v1/crl | jq .
|
|
||||||
curl -s http://localhost:8443/api/v1/crl/iss-local -o /tmp/crl.der
|
|
||||||
|
|
||||||
# List discovered certificates
|
|
||||||
curl -s http://localhost:8443/api/v1/discovered-certificates | jq .
|
|
||||||
|
|
||||||
# Discovery summary (counts by status)
|
|
||||||
curl -s http://localhost:8443/api/v1/discovery-summary | jq .
|
|
||||||
|
|
||||||
# Network scan targets (active TLS scanning)
|
|
||||||
curl -s http://localhost:8443/api/v1/network-scan-targets | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
## CLI Tool
|
|
||||||
|
|
||||||
certctl ships with a command-line tool (`certctl-cli`) for terminal users:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the CLI
|
|
||||||
cd cmd/cli && go build -o certctl-cli .
|
|
||||||
|
|
||||||
# Set credentials
|
|
||||||
export CERTCTL_SERVER_URL="http://localhost:8443"
|
|
||||||
export CERTCTL_API_KEY="test-key-123"
|
|
||||||
|
|
||||||
# List certificates (JSON or table format)
|
|
||||||
./certctl-cli list-certs --format json
|
|
||||||
./certctl-cli list-certs --format table
|
|
||||||
|
|
||||||
# Get certificate details
|
|
||||||
./certctl-cli get-cert mc-api-prod
|
|
||||||
|
|
||||||
# Trigger renewal
|
|
||||||
./certctl-cli renew-cert mc-api-prod
|
|
||||||
|
|
||||||
# Revoke a certificate (with RFC 5280 reason)
|
|
||||||
./certctl-cli revoke-cert mc-api-prod --reason keyCompromise
|
|
||||||
|
|
||||||
# List agents
|
|
||||||
./certctl-cli list-agents
|
|
||||||
|
|
||||||
# List pending jobs
|
|
||||||
./certctl-cli list-jobs
|
|
||||||
|
|
||||||
# Bulk import certificates from PEM files
|
|
||||||
./certctl-cli import /path/to/certs.pem
|
|
||||||
|
|
||||||
# Check health and metrics
|
|
||||||
./certctl-cli health
|
|
||||||
./certctl-cli metrics
|
|
||||||
```
|
|
||||||
|
|
||||||
## MCP Server for AI Integration
|
|
||||||
|
|
||||||
certctl exposes its 78 API endpoints as tools via the Model Context Protocol (MCP), enabling integration with Claude, Cursor, and other AI assistants:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build and run the MCP server
|
|
||||||
cd cmd/mcp-server && go build -o mcp-server .
|
|
||||||
|
|
||||||
export CERTCTL_SERVER_URL="http://localhost:8443"
|
|
||||||
export CERTCTL_API_KEY="test-key-123"
|
|
||||||
|
|
||||||
./mcp-server
|
|
||||||
```
|
|
||||||
|
|
||||||
The MCP server:
|
|
||||||
- Exposes all 78 API endpoints as MCP tools with typed schemas
|
|
||||||
- Handles binary responses (DER CRL, OCSP responses)
|
|
||||||
- Uses stdio transport for Claude/Cursor/OpenClaw integration
|
|
||||||
- Zero external dependencies — pure Go with official MCP SDK
|
|
||||||
|
|
||||||
You can then ask Claude questions like:
|
|
||||||
- "What certificates are expiring in the next 30 days?"
|
|
||||||
- "Revoke the payments certificate due to key compromise"
|
|
||||||
- "Show me the audit trail for the last 10 actions"
|
|
||||||
- "List all certificates with PCI compliance tags"
|
|
||||||
|
|
||||||
## Dashboard Demo Mode
|
|
||||||
|
|
||||||
The dashboard includes a **Demo Mode** that works without any backend. Build and serve the frontend with Vite:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
# Dashboard available at http://localhost:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
When the API is unreachable, the dashboard automatically loads realistic mock data and shows a subtle "Demo Mode" badge. This is perfect for screenshots, presentations, or quick demos without any infrastructure.
|
|
||||||
|
|
||||||
## Teardown
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f deploy/docker-compose.yml down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
The `-v` flag removes the PostgreSQL data volume so you get a clean slate next time.
|
|
||||||
|
|
||||||
## Presenting to Stakeholders
|
|
||||||
|
|
||||||
If you're demoing to a team or customer, here's a suggested flow:
|
|
||||||
|
|
||||||
1. **Start with the dashboard** — "This is your certificate inventory at a glance, with real-time charts showing expiration trends and renewal health"
|
|
||||||
2. **Show the expiring certs** — "These three would have caused outages without this platform"
|
|
||||||
3. **Click into auth-production** — "Here's the full lifecycle: who owns it, where it's deployed, deployment timeline, when it was last renewed"
|
|
||||||
4. **Show revocation** — "If a key is compromised, one click revokes the cert with an RFC 5280 reason code. CRL and OCSP are served automatically"
|
|
||||||
5. **Show the failed VPN cert** — "The system tried 3 times, then alerted the team via Slack, Teams, PagerDuty, or OpsGenie"
|
|
||||||
6. **Show agents and fleet overview** — "Agents run on your infrastructure, handle key generation locally (ECDSA P-256). Fleet view shows OS, architecture, and version distribution"
|
|
||||||
7. **Show profiles** — "Certificate profiles enforce crypto constraints — key types, max TTL, compliance requirements"
|
|
||||||
8. **Show policies** — "Guardrails prevent teams from going outside approved scope"
|
|
||||||
9. **Show bulk operations** — "Select multiple certs, trigger renewal or revoke in bulk with progress tracking"
|
|
||||||
10. **Show certificate discovery** — "We discover certificates two ways: agents scan local filesystems, and the server actively probes TLS endpoints on your network. We deduplicate by fingerprint, show you what we found, and let you claim them or dismiss them"
|
|
||||||
11. **Show the immutable audit trail** — "Every action in the system is recorded: who did it, what they did, when, what changed. Export to CSV/JSON for compliance"
|
|
||||||
12. **Show advanced query features** — "Sort by any field, filter by date range, paginate efficiently with cursor-based pagination, select just the fields you need"
|
|
||||||
13. **Show the CLI and MCP server** — "Terminal users get `certctl-cli` with 10 subcommands. AI assistants get MCP integration with 78 tools. Everything is API-first"
|
|
||||||
|
|
||||||
The whole walkthrough takes 5-10 minutes.
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
- **[Advanced Demo](demo-advanced.md)** — Go hands-on: create a team, issue a certificate via API, trigger renewal, and watch it appear in the dashboard
|
|
||||||
- **[Concepts Guide](concepts.md)** — Understand TLS certificates, CAs, and private keys from scratch
|
|
||||||
- **[Architecture](architecture.md)** — Deep dive into the control plane, agent model, and connector architecture
|
|
||||||
@@ -7,7 +7,7 @@ Complete reference of all features shipped in the V2 release (as of March 2026).
|
|||||||
## API Surface
|
## API Surface
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
- **91 endpoints** across 19 resource domains under `/api/v1/`
|
- **95 endpoints** across 20 resource domains under `/api/v1/` + `/.well-known/est/`
|
||||||
- REST API with HTTP semantics (GET, POST, PUT, DELETE)
|
- REST API with HTTP semantics (GET, POST, PUT, DELETE)
|
||||||
- All endpoints require authentication by default (configurable)
|
- All endpoints require authentication by default (configurable)
|
||||||
- OpenAPI 3.1 spec with full schema documentation
|
- OpenAPI 3.1 spec with full schema documentation
|
||||||
@@ -94,6 +94,7 @@ curl -H "$AUTH" "$SERVER/api/v1/certificates?expires_before=2026-04-24T00:00:00Z
|
|||||||
| **Notifications** | 3 | List, get, mark as read |
|
| **Notifications** | 3 | List, get, mark as read |
|
||||||
| **Stats** | 5 | Dashboard summary, certificates by status, expiration timeline, job trends, issuance rate |
|
| **Stats** | 5 | Dashboard summary, certificates by status, expiration timeline, job trends, issuance rate |
|
||||||
| **Metrics** | 2 | JSON metrics (gauges, counters, uptime), Prometheus exposition format |
|
| **Metrics** | 2 | JSON metrics (gauges, counters, uptime), Prometheus exposition format |
|
||||||
|
| **EST (RFC 7030)** | 4 | CA certs (PKCS#7), simple enrollment, re-enrollment, CSR attributes |
|
||||||
| **Health** | 4 | Health check, readiness check, auth info, auth check |
|
| **Health** | 4 | Health check, readiness check, auth info, auth check |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -287,16 +288,17 @@ curl -H "$AUTH" "$SERVER/api/v1/policies/rp-standard/violations"
|
|||||||
- **Use Case** — Internal PKI, enterprise trust chains
|
- **Use Case** — Internal PKI, enterprise trust chains
|
||||||
|
|
||||||
### ACME v2
|
### ACME v2
|
||||||
- **Challenge Types** — HTTP-01 (default) and DNS-01 (wildcard support)
|
- **Challenge Types** — HTTP-01 (default), DNS-01 (wildcard support), and DNS-PERSIST-01 (standing record, no per-renewal DNS updates)
|
||||||
- **DNS-01 Script Hooks** — Pluggable DNS solver for any provider (Cloudflare, Route53, Azure DNS, etc.)
|
- **DNS-01 Script Hooks** — Pluggable DNS solver for any provider (Cloudflare, Route53, Azure DNS, etc.)
|
||||||
- **Configuration** — `CERTCTL_ACME_DIRECTORY_URL`, `CERTCTL_ACME_EMAIL`, `CERTCTL_ACME_CHALLENGE_TYPE=dns-01`, `CERTCTL_ACME_DNS_PRESENT_SCRIPT`, `CERTCTL_ACME_DNS_CLEANUP_SCRIPT`
|
- **DNS-PERSIST-01** — Standing `_validation-persist` TXT record set once, reused forever. Auto-fallback to DNS-01 if CA doesn't support it yet.
|
||||||
|
- **Configuration** — `CERTCTL_ACME_DIRECTORY_URL`, `CERTCTL_ACME_EMAIL`, `CERTCTL_ACME_CHALLENGE_TYPE`, `CERTCTL_ACME_DNS_PRESENT_SCRIPT`, `CERTCTL_ACME_DNS_CLEANUP_SCRIPT`, `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN`
|
||||||
- **DNS Propagation Wait** — Configurable timeout before validation
|
- **DNS Propagation Wait** — Configurable timeout before validation
|
||||||
- **Use Case** — Public CAs (LetsEncrypt), wildcard certs
|
- **Use Case** — Public CAs (LetsEncrypt), wildcard certs
|
||||||
|
|
||||||
### step-ca
|
### step-ca
|
||||||
- **Protocol** — Native `/sign` and `/revoke` API (not ACME)
|
- **Protocol** — Native `/sign` and `/revoke` API (not ACME)
|
||||||
- **Authentication** — JWK provisioner with key file + password
|
- **Authentication** — JWK provisioner with key file + password
|
||||||
- **Configuration** — `CERTCTL_STEPCA_URL`, `CERTCTL_STEPCA_PROVISIONER_NAME`, `CERTCTL_STEPCA_PROVISIONER_KEY_PATH`, `CERTCTL_STEPCA_PROVISIONER_PASSWORD`
|
- **Configuration** — `CERTCTL_STEPCA_URL`, `CERTCTL_STEPCA_PROVISIONER`, `CERTCTL_STEPCA_KEY_PATH`, `CERTCTL_STEPCA_PASSWORD`
|
||||||
- **Operations** — Issue, renew, revoke
|
- **Operations** — Issue, renew, revoke
|
||||||
- **Use Case** — Smallstep private CA, internal PKI with strong auth
|
- **Use Case** — Smallstep private CA, internal PKI with strong auth
|
||||||
|
|
||||||
@@ -818,7 +820,7 @@ All loops have configurable intervals via environment variables (`CERTCTL_SCHEDU
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Web Dashboard (19 Pages)
|
## Web Dashboard
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
The web dashboard is the primary operational interface for certctl. Built with **Vite + React 18 + TypeScript + TanStack Query v5 + Tailwind CSS 3 + Recharts**.
|
The web dashboard is the primary operational interface for certctl. Built with **Vite + React 18 + TypeScript + TanStack Query v5 + Tailwind CSS 3 + Recharts**.
|
||||||
@@ -903,16 +905,18 @@ The web dashboard is the primary operational interface for certctl. Built with *
|
|||||||
|
|
||||||
| Subcommand | Usage | Output Format |
|
| Subcommand | Usage | Output Format |
|
||||||
|------------|-------|----------------|
|
|------------|-------|----------------|
|
||||||
| **list-certs** | `certctl-cli list-certs [--filter]` | Table or JSON (--format=json) |
|
| **certs list** | `certctl-cli certs list` | Table or JSON (--format=json) |
|
||||||
| **get-cert** | `certctl-cli get-cert <id>` | JSON cert details |
|
| **certs get** | `certctl-cli certs get <id>` | JSON cert details |
|
||||||
| **renew-cert** | `certctl-cli renew-cert <id>` | Job ID confirmation |
|
| **certs renew** | `certctl-cli certs renew <id>` | Job ID confirmation |
|
||||||
| **revoke-cert** | `certctl-cli revoke-cert <id> [--reason]` | Revocation confirmation |
|
| **certs revoke** | `certctl-cli certs revoke <id> [--reason]` | Revocation confirmation |
|
||||||
| **list-agents** | `certctl-cli list-agents` | Table or JSON |
|
| **agents list** | `certctl-cli agents list` | Table or JSON |
|
||||||
| **list-jobs** | `certctl-cli list-jobs [--filter]` | Table or JSON |
|
| **agents get** | `certctl-cli agents get <id>` | Agent details |
|
||||||
| **health** | `certctl-cli health` | Server status |
|
| **jobs list** | `certctl-cli jobs list` | Table or JSON |
|
||||||
| **metrics** | `certctl-cli metrics` | JSON metrics |
|
| **jobs get** | `certctl-cli jobs get <id>` | Job details |
|
||||||
|
| **jobs cancel** | `certctl-cli jobs cancel <id>` | Cancellation confirmation |
|
||||||
|
| **status** | `certctl-cli status` | Health + summary stats |
|
||||||
| **import** | `certctl-cli import <pem-file>` | Bulk import cert count |
|
| **import** | `certctl-cli import <pem-file>` | Bulk import cert count |
|
||||||
| **help** | `certctl-cli help [command]` | Command documentation |
|
| **version** | `certctl-cli version` | Version string |
|
||||||
|
|
||||||
**Implementation Details:**
|
**Implementation Details:**
|
||||||
- Stdlib-only (flag + text/tabwriter); no Cobra dependency
|
- Stdlib-only (flag + text/tabwriter); no Cobra dependency
|
||||||
@@ -922,9 +926,39 @@ The web dashboard is the primary operational interface for certctl. Built with *
|
|||||||
- CLI flags: `--server`, `--api-key`, `--format` (json/table)
|
- CLI flags: `--server`, `--api-key`, `--format` (json/table)
|
||||||
- Tested with httptest mock server; all commands covered
|
- Tested with httptest mock server; all commands covered
|
||||||
|
|
||||||
|
### EST Server (RFC 7030, M23)
|
||||||
|
**Enrollment over Secure Transport** — industry-standard protocol for device certificate enrollment. Enables WiFi/802.1X, MDM, IoT, and BYOD use cases where devices need certificates without direct API access.
|
||||||
|
|
||||||
|
**Endpoints** (under `/.well-known/est/` per RFC 7030):
|
||||||
|
|
||||||
|
| Endpoint | Method | Description | Wire Format |
|
||||||
|
|----------|--------|-------------|-------------|
|
||||||
|
| `/cacerts` | GET | CA certificate chain distribution | Base64 PKCS#7 certs-only (application/pkcs7-mime) |
|
||||||
|
| `/simpleenroll` | POST | Initial certificate enrollment | Request: PEM or base64-DER PKCS#10; Response: PKCS#7 |
|
||||||
|
| `/simplereenroll` | POST | Certificate re-enrollment (renewal) | Same as simpleenroll |
|
||||||
|
| `/csrattrs` | GET | CSR attributes the server requires | ASN.1 DER (application/csrattrs) |
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- **ESTService** bridges handler to existing `IssuerConnector` — no new issuance logic, reuses existing CA connectors
|
||||||
|
- **CSR input handling** — accepts both base64-encoded DER (EST wire standard) and PEM-encoded PKCS#10 (convenience)
|
||||||
|
- **PKCS#7 output** — hand-rolled ASN.1 degenerate SignedData builder (no external PKCS#7 dependency)
|
||||||
|
- **CSR validation** — signature verification, Common Name extraction, SAN extraction (DNS, IP, email, URI)
|
||||||
|
- **Configurable issuer binding** — `CERTCTL_EST_ISSUER_ID` selects which issuer connector processes enrollment
|
||||||
|
- **Optional profile binding** — `CERTCTL_EST_PROFILE_ID` constrains enrollments to a specific certificate profile
|
||||||
|
- **Audit trail** — all EST enrollments recorded with protocol=EST, CN, SANs, issuer ID, serial, profile ID
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_EST_ENABLED` | `false` | Enable EST enrollment endpoints |
|
||||||
|
| `CERTCTL_EST_ISSUER_ID` | `iss-local` | Issuer connector for EST enrollments |
|
||||||
|
| `CERTCTL_EST_PROFILE_ID` | — | Optional profile ID to constrain enrollments |
|
||||||
|
|
||||||
|
**Note:** EST endpoints currently use the same middleware stack as the REST API (API key auth). TLS client certificate authentication for EST is planned for V3.
|
||||||
|
|
||||||
### OpenAPI 3.1 Specification
|
### OpenAPI 3.1 Specification
|
||||||
- **File** — `api/openapi.yaml`
|
- **File** — `api/openapi.yaml`
|
||||||
- **Scope** — 93 operations (91 API + /health + /ready), all request/response schemas, enums, pagination
|
- **Scope** — 97 operations (95 API + /health + /ready), all request/response schemas, enums, pagination
|
||||||
- **Schemas** — Complete domain models with examples
|
- **Schemas** — Complete domain models with examples
|
||||||
- **Enums** — Job types, states, policy rule types, notification types
|
- **Enums** — Job types, states, policy rule types, notification types
|
||||||
- **Pagination** — Standard envelope (data, total, page, per_page)
|
- **Pagination** — Standard envelope (data, total, page, per_page)
|
||||||
@@ -1002,8 +1036,8 @@ The web dashboard is the primary operational interface for certctl. Built with *
|
|||||||
- **GitHub Actions** — `.github/workflows/ci.yml`
|
- **GitHub Actions** — `.github/workflows/ci.yml`
|
||||||
- **Parallel Jobs** — Go (build, vet, test+coverage, gates) and Frontend (tsc, vitest, vite build)
|
- **Parallel Jobs** — Go (build, vet, test+coverage, gates) and Frontend (tsc, vitest, vite build)
|
||||||
- **Coverage Gates** — Service layer ≥30%, handler layer ≥50%
|
- **Coverage Gates** — Service layer ≥30%, handler layer ≥50%
|
||||||
- **Release Workflow** — Tag push → build → publish Docker images to `ghcr.io`
|
- **Release Workflow** — Tag push → build → publish Docker images to GitHub Container Registry
|
||||||
- **Docker Tags** — `:latest`, `:v{version}` (ghcr.io/shankar0123/certctl)
|
- **Docker Tags** — `:latest`, `:v{version}` (`shankar0123.docker.scarf.sh/certctl-server`, `shankar0123.docker.scarf.sh/certctl-agent`)
|
||||||
|
|
||||||
### Test Suite
|
### Test Suite
|
||||||
- **Unit Tests** — 625+ test functions across service, handler, middleware, domain layers
|
- **Unit Tests** — 625+ test functions across service, handler, middleware, domain layers
|
||||||
@@ -1084,17 +1118,18 @@ The web dashboard is the primary operational interface for certctl. Built with *
|
|||||||
|----------|------|---------|---------|
|
|----------|------|---------|---------|
|
||||||
| `CERTCTL_ACME_DIRECTORY_URL` | string | (empty) | ACME server directory URL |
|
| `CERTCTL_ACME_DIRECTORY_URL` | string | (empty) | ACME server directory URL |
|
||||||
| `CERTCTL_ACME_EMAIL` | string | (empty) | Account email for ACME registration |
|
| `CERTCTL_ACME_EMAIL` | string | (empty) | Account email for ACME registration |
|
||||||
| `CERTCTL_ACME_CHALLENGE_TYPE` | string | http-01 | http-01 or dns-01 |
|
| `CERTCTL_ACME_CHALLENGE_TYPE` | string | http-01 | http-01, dns-01, or dns-persist-01 |
|
||||||
| `CERTCTL_ACME_DNS_PRESENT_SCRIPT` | string | (empty) | Script path for DNS-01 present hook |
|
| `CERTCTL_ACME_DNS_PRESENT_SCRIPT` | string | (empty) | Script path for DNS present hook (dns-01 and dns-persist-01) |
|
||||||
| `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` | string | (empty) | Script path for DNS-01 cleanup hook |
|
| `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` | string | (empty) | Script path for DNS cleanup hook (dns-01 only) |
|
||||||
|
| `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN` | string | (empty) | CA issuer domain for dns-persist-01 (e.g., letsencrypt.org) |
|
||||||
|
|
||||||
#### step-ca Issuer
|
#### step-ca Issuer
|
||||||
| Variable | Type | Default | Purpose |
|
| Variable | Type | Default | Purpose |
|
||||||
|----------|------|---------|---------|
|
|----------|------|---------|---------|
|
||||||
| `CERTCTL_STEPCA_URL` | string | (empty) | step-ca server URL |
|
| `CERTCTL_STEPCA_URL` | string | (empty) | step-ca server URL |
|
||||||
| `CERTCTL_STEPCA_PROVISIONER_NAME` | string | (empty) | JWK provisioner name |
|
| `CERTCTL_STEPCA_PROVISIONER` | string | (empty) | JWK provisioner name |
|
||||||
| `CERTCTL_STEPCA_PROVISIONER_KEY_PATH` | string | (empty) | Path to provisioner JWK private key |
|
| `CERTCTL_STEPCA_KEY_PATH` | string | (empty) | Path to provisioner JWK private key |
|
||||||
| `CERTCTL_STEPCA_PROVISIONER_PASSWORD` | string | (empty) | Provisioner key password (if encrypted) |
|
| `CERTCTL_STEPCA_PASSWORD` | string | (empty) | Provisioner key password (if encrypted) |
|
||||||
|
|
||||||
#### OpenSSL/Custom CA Issuer
|
#### OpenSSL/Custom CA Issuer
|
||||||
| Variable | Type | Default | Purpose |
|
| Variable | Type | Default | Purpose |
|
||||||
@@ -1166,11 +1201,11 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
|||||||
| Policies + violations | ✓ | ✓ | Shipped |
|
| Policies + violations | ✓ | ✓ | Shipped |
|
||||||
| Profiles + crypto constraints | ✓ | ✓ | Shipped |
|
| Profiles + crypto constraints | ✓ | ✓ | Shipped |
|
||||||
| Revocation (RFC 5280, CRL, OCSP) | ✓ | ✓ | Shipped |
|
| Revocation (RFC 5280, CRL, OCSP) | ✓ | ✓ | Shipped |
|
||||||
| Dashboard + 19 pages | ✓ | ✓ | Shipped |
|
| Full web dashboard | ✓ | ✓ | Shipped |
|
||||||
| Observability (charts, metrics, stats) | ✓ | ✓ | Shipped |
|
| Observability (charts, metrics, stats) | ✓ | ✓ | Shipped |
|
||||||
| REST API (91 endpoints) | ✓ | ✓ | Shipped |
|
| REST API (91 endpoints) | ✓ | ✓ | Shipped |
|
||||||
| MCP server (78 tools) | ✓ | ✓ | Shipped v2.1 |
|
| MCP server (78 tools) | ✓ | ✓ | Shipped v2.1 |
|
||||||
| CLI tool (10 subcommands) | ✓ | ✓ | Shipped |
|
| CLI tool (12 subcommands) | ✓ | ✓ | Shipped |
|
||||||
| Compliance mapping docs (SOC 2, PCI-DSS, NIST) | ✓ | ✓ | Shipped |
|
| Compliance mapping docs (SOC 2, PCI-DSS, NIST) | ✓ | ✓ | Shipped |
|
||||||
| Filesystem cert discovery (M18b) | ✓ | ✓ | Shipped |
|
| Filesystem cert discovery (M18b) | ✓ | ✓ | Shipped |
|
||||||
| Network cert discovery (M21) | ✓ | ✓ | Shipped |
|
| Network cert discovery (M21) | ✓ | ✓ | Shipped |
|
||||||
@@ -1197,8 +1232,8 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
|||||||
|
|
||||||
| Category | Count |
|
| Category | Count |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| **API Endpoints** | 91 (under /api/v1/) |
|
| **API Endpoints** | 95 (under /api/v1/ + /.well-known/est/) |
|
||||||
| **Dashboard Pages** | 19 |
|
| **Dashboard** | Full web GUI |
|
||||||
| **Issuer Connectors** | 4 (Local CA, ACME, step-ca, OpenSSL) |
|
| **Issuer Connectors** | 4 (Local CA, ACME, step-ca, OpenSSL) |
|
||||||
| **Target Connectors** | 5 (3 impl: NGINX, Apache, HAProxy; 2 stubs: F5, IIS) |
|
| **Target Connectors** | 5 (3 impl: NGINX, Apache, HAProxy; 2 stubs: F5, IIS) |
|
||||||
| **Notifier Channels** | 6 (Email, Webhook, Slack, Teams, PagerDuty, OpsGenie) |
|
| **Notifier Channels** | 6 (Email, Webhook, Slack, Teams, PagerDuty, OpsGenie) |
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Quick Start Guide
|
# Quick Start Guide
|
||||||
|
|
||||||
Get certctl running locally and managing certificates in under 5 minutes. With TLS certificate lifespans dropping to 47 days by 2029, automated lifecycle management isn't optional — it's infrastructure. This guide gets you hands-on with certctl's automation loop: tracking, renewing, and deploying certificates without manual intervention.
|
Certificate lifespans are dropping to **47 days by 2029**. At that cadence, a team managing 100 certificates is processing 7+ renewals per week — every week, forever. Manual processes break. certctl automates the entire lifecycle: issuance, renewal, deployment, revocation, and audit — with zero human intervention.
|
||||||
|
|
||||||
|
This guide gets you running in 5 minutes and walks you through everything certctl does.
|
||||||
|
|
||||||
New to certificates? Read the [Concepts Guide](concepts.md) first — it explains TLS, CAs, and private keys in plain language.
|
New to certificates? Read the [Concepts Guide](concepts.md) first — it explains TLS, CAs, and private keys in plain language.
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ cd certctl
|
|||||||
docker compose -f deploy/docker-compose.yml up -d --build
|
docker compose -f deploy/docker-compose.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The `--build` flag is important — it builds the server image including the React frontend. Without it, Docker may use a stale cached image that doesn't include the dashboard.
|
The `--build` flag builds the server image including the React frontend. Without it, Docker may use a stale cached image.
|
||||||
|
|
||||||
**For production deployments**, copy `deploy/.env.example` to `deploy/.env` and customize the credentials:
|
**For production deployments**, copy `deploy/.env.example` to `deploy/.env` and customize the credentials:
|
||||||
```bash
|
```bash
|
||||||
@@ -32,7 +34,7 @@ cp deploy/.env.example deploy/.env
|
|||||||
docker compose -f deploy/docker-compose.yml up -d --build
|
docker compose -f deploy/docker-compose.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Wait about 30 seconds for PostgreSQL to initialize and the server to boot. Check that everything is healthy:
|
Wait about 30 seconds for PostgreSQL to initialize, then verify:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f deploy/docker-compose.yml ps
|
docker compose -f deploy/docker-compose.yml ps
|
||||||
@@ -46,7 +48,6 @@ certctl-server Up (healthy)
|
|||||||
certctl-agent Up
|
certctl-agent Up
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify the server responds:
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8443/health
|
curl http://localhost:8443/health
|
||||||
```
|
```
|
||||||
@@ -58,98 +59,123 @@ curl http://localhost:8443/health
|
|||||||
|
|
||||||
Open **http://localhost:8443** in your browser.
|
Open **http://localhost:8443** in your browser.
|
||||||
|
|
||||||
The dashboard comes pre-loaded with 15 demo certificates across multiple teams, environments, and statuses. You'll see expiring certs, expired certs, active certs, failed renewals — a realistic snapshot of what a certificate inventory looks like in a real organization.
|
The dashboard comes pre-loaded with 15 demo certificates across multiple teams, environments, and statuses — expiring certs, expired certs, active certs, failed renewals. A realistic snapshot of what certificate management looks like in a real organization.
|
||||||
|
|
||||||
Explore the sidebar: Certificates, Agents, Policies, Jobs, Audit Trail, Notifications. Everything you see in the dashboard is backed by the REST API.
|
### What you're looking at
|
||||||
|
|
||||||
|
The main dashboard shows total certificates, how many are expiring soon, how many have expired, the renewal success rate, and four charts: an **expiration heatmap** (90-day weekly buckets), **renewal success rate trends** (30-day line chart), **certificate status distribution** (donut chart), and **issuance rate** (30-day bar chart).
|
||||||
|
|
||||||
|
Explore the sidebar: Certificates, Agents, Policies, Jobs, Audit Trail, Notifications, Profiles, Teams, Owners, Agent Groups, Fleet Overview, Short-Lived Credentials, Discovery.
|
||||||
|
|
||||||
|
### Scenarios to walk through
|
||||||
|
|
||||||
|
**"We're about to have an outage"** — Filter certificates by status → Expiring. You'll see `auth-production` (12 days), `cdn-production` (8 days), and `mail-production` (5 days). At 47-day lifespans, this is every other week. certctl catches these automatically and triggers renewal before they expire.
|
||||||
|
|
||||||
|
**"A renewal failed"** — Look at `vpn-production` — status: Failed. Click it to see the audit trail showing the ACME challenge failure after 3 retry attempts. The system sent a webhook notification to the ops channel. No one had to notice manually.
|
||||||
|
|
||||||
|
**"Who owns this cert?"** — Click any certificate. Owner, team, environment, tags. Clear accountability. Notifications route to the owner's email automatically.
|
||||||
|
|
||||||
|
**"Can I revoke a compromised cert?"** — Click any active certificate, then "Revoke." A modal with RFC 5280 reason codes (Key Compromise, Superseded, Cessation of Operation). After revocation, CRL and OCSP are served automatically — clients stop trusting the cert immediately.
|
||||||
|
|
||||||
|
**"What about certificates already in production?"** — Click "Discovered Certificates." Agents scan local filesystems for existing certs. The server probes TLS endpoints on configured CIDR ranges. Both feed into a triage workflow: claim unmanaged certs to bring them under automation, or dismiss them.
|
||||||
|
|
||||||
|
**"Show me the agent fleet"** — Click "Agents." Four agents online, one offline. Click "Fleet Overview" for OS/architecture grouping, version distribution, and per-platform listing. Agents generate ECDSA P-256 keys locally — private keys never leave your infrastructure.
|
||||||
|
|
||||||
|
**"What about bulk operations?"** — On the Certificates page, select multiple certificates with checkboxes. A bulk action bar appears: trigger renewal, revoke with reason codes, or reassign ownership — all with progress tracking. At 47-day lifespans with hundreds of certs, bulk operations aren't optional.
|
||||||
|
|
||||||
|
**"Short-lived credentials?"** — Click "Short-Lived" in the sidebar. Live countdown timers for certificates with TTL under 1 hour. Auto-refresh every 10 seconds. These are for service-to-service auth where rapid expiry replaces revocation.
|
||||||
|
|
||||||
## Explore the API
|
## Explore the API
|
||||||
|
|
||||||
The dashboard reads from the same REST API you can call directly. All endpoints live under `/api/v1/` and return JSON.
|
Everything you see in the dashboard is backed by the REST API. All endpoints live under `/api/v1/` and return JSON.
|
||||||
|
|
||||||
### List all certificates
|
### Core operations
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# List all certificates
|
||||||
curl -s http://localhost:8443/api/v1/certificates | jq .
|
curl -s http://localhost:8443/api/v1/certificates | jq .
|
||||||
```
|
|
||||||
|
|
||||||
The response has this shape:
|
# Filter by status
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"id": "mc-api-prod",
|
|
||||||
"name": "API Production",
|
|
||||||
"common_name": "api.example.com",
|
|
||||||
"sans": ["api.example.com", "api-v2.example.com"],
|
|
||||||
"environment": "production",
|
|
||||||
"owner_id": "o-alice",
|
|
||||||
"team_id": "t-platform",
|
|
||||||
"issuer_id": "iss-local",
|
|
||||||
"status": "Active",
|
|
||||||
"expires_at": "2026-05-28T00:00:00Z",
|
|
||||||
"tags": {"service": "api-gateway", "tier": "critical"},
|
|
||||||
"created_at": "2026-03-14T00:00:00Z",
|
|
||||||
"updated_at": "2026-03-14T00:00:00Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 15,
|
|
||||||
"page": 1,
|
|
||||||
"per_page": 50
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Filter by status
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Get only expiring certificates
|
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?status=Expiring" | jq .
|
curl -s "http://localhost:8443/api/v1/certificates?status=Expiring" | jq .
|
||||||
|
|
||||||
# Get only production certificates
|
# Filter by environment
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?environment=production" | jq .
|
curl -s "http://localhost:8443/api/v1/certificates?environment=production" | jq .
|
||||||
```
|
|
||||||
|
|
||||||
### Get a specific certificate
|
# Get a specific certificate
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod | jq .
|
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod | jq .
|
||||||
```
|
|
||||||
|
|
||||||
### List agents
|
# Get deployment targets for a certificate
|
||||||
|
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod/deployments | jq .
|
||||||
|
|
||||||
```bash
|
# List agents
|
||||||
curl -s http://localhost:8443/api/v1/agents | jq .
|
curl -s http://localhost:8443/api/v1/agents | jq .
|
||||||
```
|
|
||||||
|
|
||||||
### Check agent pending work
|
# Check agent pending work
|
||||||
|
curl -s http://localhost:8443/api/v1/agents/ag-web-prod/work | jq .
|
||||||
|
|
||||||
```bash
|
# View audit trail
|
||||||
# Replace with an actual agent ID from the list above
|
|
||||||
curl -s http://localhost:8443/api/v1/agents/agent-nginx-prod/work | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
### View audit trail
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s http://localhost:8443/api/v1/audit | jq .
|
curl -s http://localhost:8443/api/v1/audit | jq .
|
||||||
```
|
|
||||||
|
|
||||||
### View policy rules
|
# View policies and violations
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s http://localhost:8443/api/v1/policies | jq .
|
curl -s http://localhost:8443/api/v1/policies | jq .
|
||||||
|
curl -s http://localhost:8443/api/v1/policies/pr-require-owner/violations | jq .
|
||||||
|
|
||||||
|
# Notifications
|
||||||
|
curl -s http://localhost:8443/api/v1/notifications | jq .
|
||||||
|
|
||||||
|
# Profiles and agent groups
|
||||||
|
curl -s http://localhost:8443/api/v1/profiles | jq .
|
||||||
|
curl -s http://localhost:8443/api/v1/agent-groups | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
### View notifications
|
### Sorting, filtering, and pagination
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s http://localhost:8443/api/v1/notifications | jq .
|
# Sort by expiration date (ascending)
|
||||||
|
curl -s "http://localhost:8443/api/v1/certificates?sort=notAfter" | jq .
|
||||||
|
|
||||||
|
# Sort descending (prefix with -)
|
||||||
|
curl -s "http://localhost:8443/api/v1/certificates?sort=-createdAt" | jq .
|
||||||
|
|
||||||
|
# Time-range filters (RFC3339)
|
||||||
|
curl -s "http://localhost:8443/api/v1/certificates?expires_before=2026-05-01T00:00:00Z" | jq .
|
||||||
|
curl -s "http://localhost:8443/api/v1/certificates?created_after=2026-03-01T00:00:00Z" | jq .
|
||||||
|
|
||||||
|
# Sparse fields — request only what you need
|
||||||
|
curl -s "http://localhost:8443/api/v1/certificates?fields=id,common_name,status,expires_at" | jq .
|
||||||
|
|
||||||
|
# Cursor pagination — efficient for large inventories
|
||||||
|
curl -s "http://localhost:8443/api/v1/certificates?page_size=5" | jq '{next_cursor: .next_cursor, count: (.data | length)}'
|
||||||
|
curl -s "http://localhost:8443/api/v1/certificates?cursor=<next_cursor_value>&page_size=5" | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported sort fields: `notAfter`, `expiresAt`, `createdAt`, `updatedAt`, `commonName`, `name`, `status`, `environment`.
|
||||||
|
|
||||||
|
### Stats and metrics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dashboard summary
|
||||||
|
curl -s http://localhost:8443/api/v1/stats/summary | jq .
|
||||||
|
|
||||||
|
# Certificates by status
|
||||||
|
curl -s http://localhost:8443/api/v1/stats/certificates-by-status | jq .
|
||||||
|
|
||||||
|
# Expiration timeline (next 90 days)
|
||||||
|
curl -s "http://localhost:8443/api/v1/stats/expiration-timeline?days=90" | jq .
|
||||||
|
|
||||||
|
# Job trends (last 30 days)
|
||||||
|
curl -s "http://localhost:8443/api/v1/stats/job-trends?days=30" | jq .
|
||||||
|
|
||||||
|
# JSON metrics
|
||||||
|
curl -s http://localhost:8443/api/v1/metrics | jq .
|
||||||
|
|
||||||
|
# Prometheus format (for Prometheus, Grafana Agent, Datadog)
|
||||||
|
curl -s http://localhost:8443/api/v1/metrics/prometheus
|
||||||
```
|
```
|
||||||
|
|
||||||
## Create Your First Certificate
|
## Create Your First Certificate
|
||||||
|
|
||||||
Let's create a new managed certificate from scratch using the API. This will create a certificate record that certctl will track, renew, and deploy.
|
Create a certificate record that certctl will track, renew, and deploy automatically.
|
||||||
|
|
||||||
### Step 1: Create a certificate
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://localhost:8443/api/v1/certificates \
|
curl -s -X POST http://localhost:8443/api/v1/certificates \
|
||||||
@@ -168,47 +194,26 @@ curl -s -X POST http://localhost:8443/api/v1/certificates \
|
|||||||
}' | jq .
|
}' | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
The server returns the created certificate. Since we didn't include an `id` field, the server auto-generates one using the name and a timestamp:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "My First Certificate-1710403200000000000",
|
|
||||||
"name": "My First Certificate",
|
|
||||||
"common_name": "myapp.example.com",
|
|
||||||
"status": "Pending",
|
|
||||||
"created_at": "2026-03-14T..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Save the certificate ID (or provide your own `id` in the request body, e.g. `"id": "mc-my-first"`):
|
Save the certificate ID (or provide your own `id` in the request body, e.g. `"id": "mc-my-first"`):
|
||||||
```bash
|
```bash
|
||||||
CERT_ID="<paste the id from the response>"
|
CERT_ID="<paste the id from the response>"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Trigger renewal
|
Trigger renewal:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/renew | jq .
|
curl -s -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/renew | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates a renewal job that will be processed by the scheduler.
|
Check the result:
|
||||||
|
|
||||||
### Step 3: Check the certificate
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s http://localhost:8443/api/v1/certificates/$CERT_ID | jq .
|
curl -s http://localhost:8443/api/v1/certificates/$CERT_ID | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 4: Check the audit trail
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s http://localhost:8443/api/v1/audit | jq '.data[0:3]'
|
|
||||||
```
|
|
||||||
|
|
||||||
Refresh the dashboard at http://localhost:8443 — your new certificate appears in the inventory.
|
Refresh the dashboard at http://localhost:8443 — your new certificate appears in the inventory.
|
||||||
|
|
||||||
### Step 5: Revoke a certificate
|
### Revoke a certificate
|
||||||
|
|
||||||
If a certificate's private key is compromised or the service is decommissioned, revoke it:
|
When a private key is compromised or a service is decommissioned:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/revoke \
|
curl -s -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/revoke \
|
||||||
@@ -216,111 +221,17 @@ curl -s -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/revoke \
|
|||||||
-d '{"reason": "superseded"}' | jq .
|
-d '{"reason": "superseded"}' | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported RFC 5280 reason codes: `unspecified`, `keyCompromise`, `caCompromise`, `affiliationChanged`, `superseded`, `cessationOfOperation`, `certificateHold`, `privilegeWithdrawn`. If you omit the reason, it defaults to `unspecified`.
|
Supported RFC 5280 reason codes: `unspecified`, `keyCompromise`, `caCompromise`, `affiliationChanged`, `superseded`, `cessationOfOperation`, `certificateHold`, `privilegeWithdrawn`.
|
||||||
|
|
||||||
Check the CRL to confirm:
|
|
||||||
|
|
||||||
|
Confirm via CRL:
|
||||||
```bash
|
```bash
|
||||||
curl -s http://localhost:8443/api/v1/crl | jq .
|
curl -s http://localhost:8443/api/v1/crl | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Understanding the Demo Data
|
|
||||||
|
|
||||||
The demo comes pre-loaded with realistic data so you can explore certctl's features immediately:
|
|
||||||
|
|
||||||
| Resource | Count | Examples |
|
|
||||||
|----------|-------|---------|
|
|
||||||
| Teams | 5 | Platform, Security, Payments, Frontend, Data |
|
|
||||||
| Owners | 5 | Alice, Bob, Carol, Dave, Eve |
|
|
||||||
| Issuers | 4 | Local Dev CA, Let's Encrypt Staging, step-ca Internal, DigiCert (disabled) |
|
|
||||||
| Agents | 5 | nginx-prod, nginx-staging, f5-prod, iis-prod, data-agent |
|
|
||||||
| Targets | 5 | NGINX (prod/staging/data), F5 LB, IIS |
|
|
||||||
| Certificates | 15 | Various statuses: Active, Expiring, Expired, Failed, Wildcard |
|
|
||||||
| Policies | 4 | Required owner, allowed environments, max lifetime, min renewal window |
|
|
||||||
| Profiles | 3 | Default TLS, Short-Lived, High-Security |
|
|
||||||
| Agent Groups | 5 | Linux agents, ARM agents, Production subnet, etc. |
|
|
||||||
|
|
||||||
Certificates have varied statuses so you can see what each state looks like in the dashboard: healthy certs with 45+ days remaining, certs about to expire (5-12 days), certs that already expired, and a failed renewal.
|
|
||||||
|
|
||||||
## Advanced API Features
|
|
||||||
|
|
||||||
### Sorting and filtering
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Sort certificates by expiration date (ascending)
|
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?sort=notAfter" | jq .
|
|
||||||
|
|
||||||
# Sort descending (prefix with -)
|
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?sort=-createdAt" | jq .
|
|
||||||
|
|
||||||
# Time-range filters (RFC3339 format)
|
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?expires_before=2026-05-01T00:00:00Z" | jq .
|
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?created_after=2026-03-01T00:00:00Z" | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported sort fields: `notAfter`, `expiresAt`, `createdAt`, `updatedAt`, `commonName`, `name`, `status`, `environment`.
|
|
||||||
|
|
||||||
### Sparse field selection
|
|
||||||
|
|
||||||
Request only the fields you need to reduce response size:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?fields=id,common_name,status,expires_at" | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cursor-based pagination
|
|
||||||
|
|
||||||
For large datasets, cursor pagination is more efficient than page-based:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# First page
|
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?page_size=5" | jq '{next_cursor: .next_cursor, count: (.data | length)}'
|
|
||||||
|
|
||||||
# Next page (use the next_cursor from the previous response)
|
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?cursor=<next_cursor_value>&page_size=5" | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stats and metrics
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Dashboard summary
|
|
||||||
curl -s http://localhost:8443/api/v1/stats/summary | jq .
|
|
||||||
|
|
||||||
# Certificates by status
|
|
||||||
curl -s http://localhost:8443/api/v1/stats/certificates-by-status | jq .
|
|
||||||
|
|
||||||
# Expiration timeline (next 90 days)
|
|
||||||
curl -s "http://localhost:8443/api/v1/stats/expiration-timeline?days=90" | jq .
|
|
||||||
|
|
||||||
# Job trends (last 30 days)
|
|
||||||
curl -s "http://localhost:8443/api/v1/stats/job-trends?days=30" | jq .
|
|
||||||
|
|
||||||
# System metrics (JSON)
|
|
||||||
curl -s http://localhost:8443/api/v1/metrics | jq .
|
|
||||||
|
|
||||||
# System metrics (Prometheus format — for scraping by Prometheus, Grafana Agent, Datadog)
|
|
||||||
curl -s http://localhost:8443/api/v1/metrics/prometheus
|
|
||||||
```
|
|
||||||
|
|
||||||
### Certificate profiles
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List all profiles
|
|
||||||
curl -s http://localhost:8443/api/v1/profiles | jq .
|
|
||||||
|
|
||||||
# Get a specific profile
|
|
||||||
curl -s http://localhost:8443/api/v1/profiles/prof-default | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Certificate deployments
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# View deployment targets for a certificate
|
|
||||||
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod/deployments | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Interactive approval workflow
|
### Interactive approval workflow
|
||||||
|
|
||||||
|
For high-value certificates where you want human oversight:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Approve a pending job
|
# Approve a pending job
|
||||||
curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/approve \
|
curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/approve \
|
||||||
@@ -333,49 +244,25 @@ curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/reject \
|
|||||||
-d '{"reason": "Key type does not meet compliance requirements"}' | jq .
|
-d '{"reason": "Key type does not meet compliance requirements"}' | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tear Down
|
## Certificate Discovery
|
||||||
|
|
||||||
```bash
|
Find certificates already running in your infrastructure — ones you didn't issue through certctl.
|
||||||
docker compose -f deploy/docker-compose.yml down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
The `-v` flag removes the PostgreSQL data volume so you get a clean slate next time.
|
### Filesystem discovery (agent-based)
|
||||||
|
|
||||||
### Certificate Discovery
|
|
||||||
|
|
||||||
Agents can scan your infrastructure for existing certificates you're not yet managing:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Configure agent to scan directories
|
# Configure agent to scan directories
|
||||||
export CERTCTL_DISCOVERY_DIRS="/etc/nginx/certs,/etc/ssl/certs,/var/lib/certs"
|
export CERTCTL_DISCOVERY_DIRS="/etc/nginx/certs,/etc/ssl/certs,/var/lib/certs"
|
||||||
|
# Agent scans on startup + every 6 hours
|
||||||
# Agent scans on startup + every 6 hours, reports findings to control plane
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Query discovered certificates:
|
### Network discovery (agentless)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List all discovered certs from a specific agent
|
# Enable network scanning
|
||||||
curl -s "http://localhost:8443/api/v1/discovered-certificates?agent_id=agent-nginx-prod" | jq .
|
|
||||||
|
|
||||||
# Get discovery summary (counts by status)
|
|
||||||
curl -s http://localhost:8443/api/v1/discovery-summary | jq .
|
|
||||||
|
|
||||||
# Claim a discovered cert (link to managed cert)
|
|
||||||
curl -s -X POST "http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/claim" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"managed_certificate_id": "mc-api-prod"}' | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Network Certificate Discovery
|
|
||||||
|
|
||||||
The server can also discover certificates by scanning TLS endpoints directly — no agent required:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable network scanning (set in environment or docker-compose)
|
|
||||||
export CERTCTL_NETWORK_SCAN_ENABLED=true
|
export CERTCTL_NETWORK_SCAN_ENABLED=true
|
||||||
|
|
||||||
# Create a scan target (e.g., scan your internal network on port 443)
|
# Create a scan target
|
||||||
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
@@ -389,17 +276,105 @@ curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
|||||||
|
|
||||||
# Trigger an immediate scan
|
# Trigger an immediate scan
|
||||||
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets/nst-internal-network/scan | jq .
|
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets/nst-internal-network/scan | jq .
|
||||||
|
|
||||||
# List scan targets with results
|
|
||||||
curl -s http://localhost:8443/api/v1/network-scan-targets | jq .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Discovered network certificates appear in the same `GET /api/v1/discovered-certificates` list as filesystem-discovered certs, with `agent_id=server-scanner` and `source_format=network`.
|
### Triage discovered certificates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List discovered certs
|
||||||
|
curl -s "http://localhost:8443/api/v1/discovered-certificates?agent_id=agent-nginx-prod" | jq .
|
||||||
|
|
||||||
|
# Summary counts
|
||||||
|
curl -s http://localhost:8443/api/v1/discovery-summary | jq .
|
||||||
|
|
||||||
|
# Claim a discovered cert (bring under management)
|
||||||
|
curl -s -X POST "http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/claim" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"managed_certificate_id": "mc-api-prod"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Tool
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cmd/cli && go build -o certctl-cli .
|
||||||
|
|
||||||
|
export CERTCTL_SERVER_URL="http://localhost:8443"
|
||||||
|
export CERTCTL_API_KEY="test-key-123"
|
||||||
|
|
||||||
|
./certctl-cli certs list # List certificates
|
||||||
|
./certctl-cli certs get mc-api-prod # Certificate details
|
||||||
|
./certctl-cli certs renew mc-api-prod # Trigger renewal
|
||||||
|
./certctl-cli certs revoke mc-api-prod --reason keyCompromise
|
||||||
|
./certctl-cli agents list # List agents
|
||||||
|
./certctl-cli jobs list # List jobs
|
||||||
|
./certctl-cli import /path/to/certs.pem # Bulk import
|
||||||
|
./certctl-cli status # Health + stats
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP Server (AI Integration)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cmd/mcp-server && go build -o mcp-server .
|
||||||
|
|
||||||
|
export CERTCTL_SERVER_URL="http://localhost:8443"
|
||||||
|
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."
|
||||||
|
|
||||||
|
## Demo Data Reference
|
||||||
|
|
||||||
|
| Resource | Count | Examples |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| Teams | 5 | Platform, Security, Payments, Frontend, Data |
|
||||||
|
| Owners | 5 | Alice, Bob, Carol, Dave, Eve |
|
||||||
|
| Issuers | 4 | Local Dev CA, Let's Encrypt Staging, step-ca Internal, DigiCert (disabled) |
|
||||||
|
| Agents | 5 | ag-web-prod, ag-web-staging, ag-lb-prod, ag-iis-prod, ag-data-prod |
|
||||||
|
| Targets | 5 | NGINX (prod/staging/data), F5 LB, IIS |
|
||||||
|
| Certificates | 15 | Various statuses: Active, Expiring, Expired, Failed, Wildcard |
|
||||||
|
| Policies | 4 | Required owner, allowed environments, max lifetime, min renewal window |
|
||||||
|
| Profiles | 3 | Default TLS, Short-Lived, High-Security |
|
||||||
|
| Agent Groups | 5 | Linux agents, ARM agents, Production subnet, etc. |
|
||||||
|
|
||||||
|
## Dashboard Demo Mode
|
||||||
|
|
||||||
|
The dashboard works without a backend for screenshots and presentations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && npm install && npm run dev
|
||||||
|
# Dashboard at http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
When the API is unreachable, the dashboard loads realistic mock data with a "Demo Mode" badge.
|
||||||
|
|
||||||
|
## Presenting to Stakeholders
|
||||||
|
|
||||||
|
A suggested 5-minute flow:
|
||||||
|
|
||||||
|
1. **Dashboard** — "Certificate inventory at a glance. Real-time charts show expiration trends and renewal health."
|
||||||
|
2. **Expiring certs** — "These three would have caused outages. At 47-day lifespans, this happens every other week."
|
||||||
|
3. **Certificate detail** — "Full lifecycle: who owns it, where it's deployed, deployment timeline, version history with rollback."
|
||||||
|
4. **Revocation** — "One click revokes with an RFC 5280 reason code. CRL and OCSP served automatically."
|
||||||
|
5. **Failed renewal** — "System tried 3 times, then alerted the team via Slack, Teams, PagerDuty, or OpsGenie."
|
||||||
|
6. **Agent fleet** — "Agents handle key generation locally (ECDSA P-256). Private keys never leave your infrastructure."
|
||||||
|
7. **Discovery** — "Agents scan filesystems, server probes TLS endpoints. We find what you're not managing yet."
|
||||||
|
8. **Bulk operations** — "Select multiple certs, renew or revoke in bulk. At 47-day lifespans with hundreds of certs, this is essential."
|
||||||
|
9. **Audit trail** — "Every action recorded. Export to CSV/JSON for compliance."
|
||||||
|
10. **CLI + MCP** — "Terminal users get `certctl-cli`. AI assistants get MCP integration. Everything is API-first."
|
||||||
|
|
||||||
|
## Tear Down
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/docker-compose.yml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
The `-v` flag removes the PostgreSQL data volume for a clean slate.
|
||||||
|
|
||||||
## What's Next
|
## What's Next
|
||||||
|
|
||||||
- **[Advanced Demo](demo-advanced.md)** — Issue a real certificate via the Local CA and watch it appear in the dashboard
|
- **[Advanced Demo](demo-advanced.md)** — Issue a real certificate via the Local CA end-to-end
|
||||||
- **[Demo Walkthrough](demo-guide.md)** — Guided 5-minute stakeholder presentation
|
|
||||||
- **[Architecture](architecture.md)** — How the control plane, agents, and connectors work together
|
- **[Architecture](architecture.md)** — How the control plane, agents, and connectors work together
|
||||||
- **[Connector Guide](connectors.md)** — Build custom connectors for your infrastructure
|
- **[Connector Guide](connectors.md)** — Build custom connectors for your infrastructure
|
||||||
- **[CLI Reference](cli.md)** — Manage certificates from your terminal
|
- **[Concepts Guide](concepts.md)** — TLS certificates, CAs, and private keys explained from scratch
|
||||||
|
|||||||
|
After Width: | Height: | Size: 755 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 438 KiB |
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 700 KiB |
|
After Width: | Height: | Size: 680 KiB |
|
After Width: | Height: | Size: 500 KiB |
|
After Width: | Height: | Size: 432 KiB |
|
After Width: | Height: | Size: 399 KiB |
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 615 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 414 KiB |
|
After Width: | Height: | Size: 485 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 391 KiB |
@@ -1,12 +1,12 @@
|
|||||||
# certctl V2.0 Release QA Guide
|
# certctl V2.0 Release QA Guide
|
||||||
|
|
||||||
Comprehensive manual testing playbook. Every test has a concrete command, an explanation of what it validates and why it matters, exact expected output, and an unambiguous pass/fail criterion. Run every test before tagging v2.0.0.
|
Comprehensive manual testing playbook. Every test has a concrete command, an explanation of what it validates and why it matters, exact expected output, and an unambiguous pass/fail criterion.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
### Why manual QA on top of 900+ automated tests?
|
### Why manual QA on top of automated tests?
|
||||||
|
|
||||||
Automated tests mock dependencies and run in isolation. Manual QA validates the full integrated stack: real PostgreSQL, real HTTP, real agent binary, real file I/O, real scheduler timing. It catches issues that unit tests can't: migration ordering, Docker networking, env var parsing, browser rendering, and timing-dependent scheduler behavior.
|
Automated tests mock dependencies and run in isolation. Manual QA validates the full integrated stack: real PostgreSQL, real HTTP, real agent binary, real file I/O, real scheduler timing. It catches issues that unit tests can't: migration ordering, Docker networking, env var parsing, browser rendering, and timing-dependent scheduler behavior.
|
||||||
|
|
||||||
@@ -1423,6 +1423,42 @@ curl -s -w "\nHTTP %{http_code}\n" -X POST -H "$AUTH" -H "$CT" \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 6.2 ACME DNS Challenge Configuration
|
||||||
|
|
||||||
|
**Test 6.2.1 — List ACME issuer with DNS-01 configuration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "$AUTH" "$SERVER/api/v1/issuers/iss-acme-le" | jq '{id, type, config}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Retrieves the ACME Let's Encrypt issuer and verifies its configuration.
|
||||||
|
**Why:** ACME issuers configured for DNS-01 challenges need their solver scripts accessible for wildcard certificate support.
|
||||||
|
**Expected:** HTTP 200. `type` = "acme". `config` may include challenge type and DNS script paths.
|
||||||
|
**PASS if** HTTP 200 and type matches. **FAIL** otherwise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 6.2.2 — Create ACME issuer with DNS-PERSIST-01**
|
||||||
|
|
||||||
|
Edit `deploy/docker-compose.yml` to set environment variables for ACME DNS-PERSIST-01:
|
||||||
|
- `CERTCTL_ACME_CHALLENGE_TYPE: dns-persist-01`
|
||||||
|
- `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN: le.example.com`
|
||||||
|
- `CERTCTL_ACME_DNS_PRESENT_SCRIPT: /usr/local/bin/dns-present.sh`
|
||||||
|
- `CERTCTL_ACME_DNS_CLEANUP_SCRIPT: /usr/local/bin/dns-cleanup.sh`
|
||||||
|
|
||||||
|
Restart and verify the issuer accepts the config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "$AUTH" "$SERVER/api/v1/issuers/iss-acme-le" | jq '{id, type}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Verifies that ACME issuers read DNS-PERSIST-01 configuration from environment variables.
|
||||||
|
**Why:** DNS-PERSIST-01 requires a standing TXT record per IETF draft. The issuer must know the issuer domain and support this challenge type.
|
||||||
|
**Expected:** HTTP 200. ACME issuer still functional.
|
||||||
|
**PASS if** HTTP 200 and issuer still works. **FAIL** if 500 or issuer broken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Part 7: Target Connectors & Deployment
|
## Part 7: Target Connectors & Deployment
|
||||||
|
|
||||||
**What this validates:** CRUD for deployment targets, including type-specific configuration for all 5 target types.
|
**What this validates:** CRUD for deployment targets, including type-specific configuration for all 5 target types.
|
||||||
@@ -2094,6 +2130,49 @@ curl -s -w "\nHTTP %{http_code}\n" -X DELETE -H "$AUTH" "$SERVER/api/v1/agent-gr
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 11.4 Foreign Key Constraint Behavior
|
||||||
|
|
||||||
|
**What this validates:** Delete operations correctly fail with 409 when referenced entities still exist.
|
||||||
|
|
||||||
|
**Why it matters:** Owners and issuers use `ON DELETE RESTRICT` — you can't delete them while certificates reference them. Teams use `ON DELETE CASCADE`, so team deletes succeed and cascade. If the server returns a silent 500 instead of 409, the GUI swallows the error and the user thinks nothing happened.
|
||||||
|
|
||||||
|
**Test 11.4.1 — Delete owner with assigned certificates (expect 409)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Try to delete Alice Chen (o-alice) — she owns certificates in the demo data
|
||||||
|
curl -s -w "\nHTTP %{http_code}\n" -X DELETE -H "$AUTH" "$SERVER/api/v1/owners/o-alice" | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 409 with message "Cannot delete owner: certificates are still assigned to this owner".
|
||||||
|
**PASS if** 409 Conflict. **FAIL** if 204 (data integrity violation) or 500 (unhelpful error).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 11.4.2 — Delete issuer with assigned certificates (expect 409)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Try to delete the Local Dev CA (iss-local) — certificates reference it
|
||||||
|
curl -s -w "\nHTTP %{http_code}\n" -X DELETE -H "$AUTH" "$SERVER/api/v1/issuers/iss-local" | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 409 with message "Cannot delete issuer: certificates are still using this issuer".
|
||||||
|
**PASS if** 409 Conflict. **FAIL** if 204 or 500.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 11.4.3 — Delete team cascades successfully**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a test team, then delete it — teams use ON DELETE CASCADE
|
||||||
|
curl -s -X POST -H "$AUTH" -H "$CT" -d '{"id": "t-fk-test", "name": "FK Test Team"}' $SERVER/api/v1/teams > /dev/null
|
||||||
|
curl -s -w "\nHTTP %{http_code}\n" -X DELETE -H "$AUTH" "$SERVER/api/v1/teams/t-fk-test"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 204 (cascade allows deletion).
|
||||||
|
**PASS if** 204. **FAIL** if 409 or 500.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Part 12: Notifications
|
## Part 12: Notifications
|
||||||
|
|
||||||
**What this validates:** Notification creation, listing, and read status management.
|
**What this validates:** Notification creation, listing, and read status management.
|
||||||
@@ -2325,8 +2404,8 @@ curl -s -H "$AUTH" "$SERVER/api/v1/metrics/prometheus" | grep -c "^# HELP"
|
|||||||
|
|
||||||
**What:** Counts `# HELP` comment lines (metric descriptions).
|
**What:** Counts `# HELP` comment lines (metric descriptions).
|
||||||
**Why:** HELP lines are required by the Prometheus exposition format. Missing = non-compliant.
|
**Why:** HELP lines are required by the Prometheus exposition format. Missing = non-compliant.
|
||||||
**Expected:** Count ≥ 11 (one per metric).
|
**Expected:** Count > 0 (one per metric).
|
||||||
**PASS if** count ≥ 11. **FAIL** if 0.
|
**PASS if** count > 0. **FAIL** if 0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2337,12 +2416,12 @@ curl -s -H "$AUTH" "$SERVER/api/v1/metrics/prometheus" | grep -c "^# TYPE"
|
|||||||
```
|
```
|
||||||
|
|
||||||
**What:** Counts `# TYPE` annotations (gauge/counter declarations).
|
**What:** Counts `# TYPE` annotations (gauge/counter declarations).
|
||||||
**Expected:** Count ≥ 11.
|
**Expected:** Count > 0.
|
||||||
**PASS if** count ≥ 11. **FAIL** if 0.
|
**PASS if** count > 0. **FAIL** if 0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Test 13.3.4 — All 11 Prometheus metrics present**
|
**Test 13.3.4 — All documented Prometheus metrics present**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
METRICS=$(curl -s -H "$AUTH" "$SERVER/api/v1/metrics/prometheus")
|
METRICS=$(curl -s -H "$AUTH" "$SERVER/api/v1/metrics/prometheus")
|
||||||
@@ -2352,10 +2431,10 @@ for m in certctl_certificate_total certctl_certificate_active certctl_certificat
|
|||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
**What:** Verifies all 11 documented Prometheus metrics are present in the output.
|
**What:** Verifies all documented Prometheus metrics are present in the output.
|
||||||
**Why:** Missing metrics mean missing dashboard panels in Grafana. Each metric was chosen for operational value.
|
**Why:** Missing metrics mean missing dashboard panels in Grafana. Each metric was chosen for operational value.
|
||||||
**Expected:** Each metric reports count = 1 (present).
|
**Expected:** Each metric reports count = 1 (present).
|
||||||
**PASS if** all 11 metrics show count = 1. **FAIL** if any shows 0.
|
**PASS if** all metrics show count = 1. **FAIL** if any shows 0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -3192,7 +3271,7 @@ echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_certificate",
|
|||||||
|
|
||||||
## Part 19: GUI Testing
|
## Part 19: GUI Testing
|
||||||
|
|
||||||
**What this validates:** The web dashboard — 19 pages of operational UI.
|
**What this validates:** The full web dashboard — all pages of operational UI.
|
||||||
|
|
||||||
**Why it matters:** Operators spend 80% of their time in the GUI. If it's broken, the product is broken, regardless of how good the API is.
|
**Why it matters:** Operators spend 80% of their time in the GUI. If it's broken, the product is broken, regardless of how good the API is.
|
||||||
|
|
||||||
@@ -3255,7 +3334,7 @@ Open `http://localhost:8443` in a browser.
|
|||||||
| 19.6.1 | Sidebar nav | Click all sidebar links | All pages load without errors | PASS if no broken routes |
|
| 19.6.1 | Sidebar nav | Click all sidebar links | All pages load without errors | PASS if no broken routes |
|
||||||
| 19.6.2 | Logout | Click logout | Returns to login screen | PASS if login page shown |
|
| 19.6.2 | Logout | Click logout | Returns to login screen | PASS if login page shown |
|
||||||
| 19.6.3 | 401 redirect | Expire/remove auth token | Auto-redirect to login | PASS if login page shown |
|
| 19.6.3 | 401 redirect | Expire/remove auth token | Auto-redirect to login | PASS if login page shown |
|
||||||
| 19.6.4 | Dark theme | Check page styling | Dark background, readable text | PASS if theme consistent |
|
| 19.6.4 | Theme consistency | Check page styling | Light content area, teal sidebar, branded colors, readable text | PASS if theme consistent across all pages |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -3655,36 +3734,38 @@ docker compose logs certctl-server 2>&1 | grep -v "^certctl-server" | grep -cv "
|
|||||||
|
|
||||||
**What this validates:** Documentation accuracy against the running system. Claims in docs must match reality.
|
**What this validates:** Documentation accuracy against the running system. Claims in docs must match reality.
|
||||||
|
|
||||||
**Why it matters:** Inaccurate documentation destroys trust. If the README says "21 tables" but there are 19, or "78 MCP tools" but there are 76, evaluators question everything else too.
|
**Why it matters:** Inaccurate documentation destroys trust. Claims in docs must match the running system. If the README says "X features" but the code doesn't have them, evaluators question everything else too.
|
||||||
|
|
||||||
| Test ID | Document | Verification | Pass/Fail Criteria |
|
| Test ID | Document | Verification | Pass/Fail Criteria |
|
||||||
|---------|----------|-------------|-------------------|
|
|---------|----------|-------------|-------------------|
|
||||||
| 24.1.1 | `README.md` | Feature list matches actual capabilities. Screenshot paths resolve. Mermaid diagram says "21 tables". | PASS if all claims verified |
|
| 24.1.1 | `README.md` | Feature list matches actual capabilities. Screenshot paths resolve. Mermaid diagram shows database schema tables. | PASS if all claims verified |
|
||||||
| 24.1.2 | `docs/quickstart.md` | Every command in the quickstart works on a clean clone. | PASS if all commands succeed |
|
| 24.1.2 | `docs/quickstart.md` | Every command in the quickstart works on a clean clone. | PASS if all commands succeed |
|
||||||
| 24.1.3 | `docs/concepts.md` | Terminology matches API field names and UI labels. | PASS if terminology consistent |
|
| 24.1.3 | `docs/concepts.md` | Terminology matches API field names and UI labels. | PASS if terminology consistent |
|
||||||
| 24.1.4 | `docs/architecture.md` | Component diagram matches `docker compose ps`. Says "21 tables", "78 MCP Tools", "900+ tests". | PASS if numbers match |
|
| 24.1.4 | `docs/architecture.md` | Component diagram matches `docker compose ps`. Key components and tables documented. | PASS if accurate |
|
||||||
| 24.1.5 | `docs/connectors.md` | All 5 issuer types and 5 target types documented. F5/IIS marked as stubs. | PASS if all documented |
|
| 24.1.5 | `docs/connectors.md` | All issuer types and target types documented. F5/IIS marked as stubs. | PASS if all documented |
|
||||||
| 24.1.6 | `docs/features.md` | Endpoint count (93), MCP tools (78), table count (21), test count (900+) all accurate. | PASS if numbers match |
|
| 24.1.6 | `docs/features.md` | Feature list complete and accurate. | PASS if accurate |
|
||||||
| 24.1.7 | `docs/demo-guide.md` | Demo walkthrough works against fresh `docker compose up`. | PASS if all steps work |
|
| 24.1.7 | `docs/quickstart.md` | Quick start + demo walkthrough works against fresh `docker compose up`. | PASS if all steps work |
|
||||||
| 24.1.8 | `docs/demo-advanced.md` | All parts executable against running stack. Network discovery section present. | PASS if all executable |
|
| 24.1.8 | `docs/demo-advanced.md` | All parts executable against running stack. Network discovery section present. | PASS if all executable |
|
||||||
| 24.1.9 | `docs/compliance.md` | Framework links resolve, mapping references real features. | PASS if links work |
|
| 24.1.9 | `docs/compliance.md` | Framework links resolve, mapping references real features. | PASS if links work |
|
||||||
| 24.1.10 | `docs/compliance-soc2.md` | API endpoints cited actually exist in the router. | PASS if endpoints exist |
|
| 24.1.10 | `docs/compliance-soc2.md` | API endpoints cited actually exist in the router. | PASS if endpoints exist |
|
||||||
| 24.1.11 | `docs/compliance-pci-dss.md` | Claims match implementation (audit trail, revocation, key management). | PASS if claims verified |
|
| 24.1.11 | `docs/compliance-pci-dss.md` | Claims match implementation (audit trail, revocation, key management). | PASS if claims verified |
|
||||||
| 24.1.12 | `docs/compliance-nist.md` | Key management claims match agent keygen behavior. | PASS if claims verified |
|
| 24.1.12 | `docs/compliance-nist.md` | Key management claims match agent keygen behavior. | PASS if claims verified |
|
||||||
| 24.1.13 | `docs/mcp.md` | Tool count = 78, domain count = 16, setup instructions work. | PASS if numbers match |
|
| 24.1.13 | `docs/mcp.md` | Tool coverage documented, setup instructions work. | PASS if accurate |
|
||||||
| 24.1.14 | `api/openapi.yaml` | Operation count = 93, matches all routes in router.go. | PASS if count matches |
|
| 24.1.14 | `api/openapi.yaml` | OpenAPI spec matches all routes in router.go (check operation count). | PASS if count matches |
|
||||||
|
|
||||||
**Verification command for OpenAPI parity:**
|
**Verification command for OpenAPI parity:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Count OpenAPI operations
|
# Count OpenAPI operations
|
||||||
grep -c "operationId:" api/openapi.yaml
|
OPENAPI_OPS=$(grep -c "operationId:" api/openapi.yaml)
|
||||||
# Count router registrations
|
# Count router registrations
|
||||||
grep -c "r.Register\|r.mux.Handle" internal/api/router/router.go
|
ROUTER_REGS=$(grep -c "r.Register\|r.mux.Handle" internal/api/router/router.go)
|
||||||
|
echo "OpenAPI operations: $OPENAPI_OPS"
|
||||||
|
echo "Router registrations: $ROUTER_REGS"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Expected:** Both return 93.
|
**Expected:** Both counts match.
|
||||||
**PASS if** both counts = 93. **FAIL** if mismatch.
|
**PASS if** both counts are equal. **FAIL** if mismatch (indicates spec/code drift).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -3741,21 +3822,42 @@ curl -s -H "$AUTH" "$SERVER/api/v1/network-scan-targets" | jq '{total, ids: [.it
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Test 25.1.4 — OpenAPI spec operations match router**
|
**Test 25.1.4 — GUI delete on FK-restricted entities shows error, not silent failure**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Try deleting owner o-alice via API — she owns demo certificates
|
||||||
|
CODE=$(curl -s -o /tmp/delete-resp.json -w "%{http_code}" -X DELETE -H "$AUTH" "$SERVER/api/v1/owners/o-alice")
|
||||||
|
echo "DELETE owner with certs: HTTP $CODE"
|
||||||
|
cat /tmp/delete-resp.json | jq .
|
||||||
|
|
||||||
|
# Try deleting issuer iss-local — certificates reference it
|
||||||
|
CODE=$(curl -s -o /tmp/delete-resp.json -w "%{http_code}" -X DELETE -H "$AUTH" "$SERVER/api/v1/issuers/iss-local")
|
||||||
|
echo "DELETE issuer with certs: HTTP $CODE"
|
||||||
|
cat /tmp/delete-resp.json | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Verifies that deleting owners/issuers with assigned certificates returns 409 Conflict with a descriptive message.
|
||||||
|
**Why:** This was a real bug — the backend returned 500 (generic "Failed to delete"), `fetchJSON` threw on the error, and TanStack Query's `onError` wasn't wired up. The user clicked OK on the confirm dialog and nothing visibly happened. Fixed by: (1) backend returns 409 with descriptive message for FK constraint violations, (2) `fetchJSON` handles 204 No Content for successful deletes, (3) frontend mutation `onError` surfaces the error.
|
||||||
|
**Expected:** Both return HTTP 409 with descriptive conflict messages.
|
||||||
|
**PASS if** both 409 with messages. **FAIL** if 500 (unhelpful error) or 204 (data integrity violation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 25.1.5 — OpenAPI spec operations match router**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo "OpenAPI operations: $(grep -c 'operationId:' api/openapi.yaml)"
|
echo "OpenAPI operations: $(grep -c 'operationId:' api/openapi.yaml)"
|
||||||
echo "Router registrations: $(grep -c 'r.Register\|r.mux.Handle' internal/api/router/router.go)"
|
echo "Router registrations: $(grep -c 'r.Register\|r.mux.Handle' internal/api/router/router.go)"
|
||||||
```
|
```
|
||||||
|
|
||||||
**What:** Counts operations in the OpenAPI spec and route registrations in the router.
|
**What:** Counts operations in the OpenAPI spec and route registrations in the router, verifying they match.
|
||||||
**Why:** The audit found the OpenAPI spec had 78 operations while the router had 93. This was fixed by adding 15 missing operations.
|
**Why:** OpenAPI spec drift happens as endpoints are added or removed. Mismatches indicate the spec is out of date.
|
||||||
**Expected:** Both = 93.
|
**Expected:** Both counts equal.
|
||||||
**PASS if** both equal 93. **FAIL** if mismatch.
|
**PASS if** both counts match. **FAIL** if mismatch (indicates spec/code drift).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Test 25.1.5 — Go service tests use strings.Contains, not errors.Is**
|
**Test 25.1.6 — Go service tests use strings.Contains, not errors.Is**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep -rn "errors.Is.*errors.New\|errors.Is(.*err.*errors.New" internal/service/*_test.go | wc -l
|
grep -rn "errors.Is.*errors.New\|errors.Is(.*err.*errors.New" internal/service/*_test.go | wc -l
|
||||||
@@ -3768,9 +3870,248 @@ grep -rn "errors.Is.*errors.New\|errors.Is(.*err.*errors.New" internal/service/*
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Part 26: EST Server (RFC 7030)
|
||||||
|
|
||||||
|
**Scope:** Enrollment over Secure Transport — 4 endpoints under `/.well-known/est/` for device certificate enrollment. Tests cover CA cert distribution, certificate enrollment (PEM and base64-DER CSR formats), re-enrollment, CSR attributes, wire format compliance, and error handling.
|
||||||
|
|
||||||
|
**Prerequisites:** Server running with `CERTCTL_EST_ENABLED=true`, `CERTCTL_EST_ISSUER_ID=iss-local` (or a valid issuer). An ECDSA P-256 key pair and CSR for enrollment tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 26.1 — GET /.well-known/est/cacerts returns PKCS#7 CA chain**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $API_KEY" \
|
||||||
|
http://localhost:8443/.well-known/est/cacerts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 200, `Content-Type: application/pkcs7-mime`, `Content-Transfer-Encoding: base64`. Body is base64-encoded degenerate PKCS#7 SignedData containing the CA certificate chain.
|
||||||
|
**PASS if** status = 200, correct content type, non-empty body.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 26.2 — GET /cacerts method enforcement**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||||
|
-H "Authorization: Bearer $API_KEY" \
|
||||||
|
http://localhost:8443/.well-known/est/cacerts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 405 Method Not Allowed.
|
||||||
|
**PASS if** status = 405.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 26.3 — POST /.well-known/est/simpleenroll with PEM CSR**
|
||||||
|
|
||||||
|
Generate a test CSR and submit as PEM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate ECDSA P-256 key and CSR
|
||||||
|
openssl ecparam -name prime256v1 -genkey -noout -out /tmp/est-test.key
|
||||||
|
openssl req -new -key /tmp/est-test.key -out /tmp/est-test.csr \
|
||||||
|
-subj "/CN=est-test.example.com" \
|
||||||
|
-addext "subjectAltName=DNS:est-test.example.com"
|
||||||
|
|
||||||
|
# Submit PEM CSR
|
||||||
|
curl -s -w "\n%{http_code}" \
|
||||||
|
-H "Authorization: Bearer $API_KEY" \
|
||||||
|
-H "Content-Type: application/pkcs10" \
|
||||||
|
--data-binary @/tmp/est-test.csr \
|
||||||
|
http://localhost:8443/.well-known/est/simpleenroll
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 200, `Content-Type: application/pkcs7-mime`, `Content-Transfer-Encoding: base64`. Body contains base64-encoded PKCS#7 with the signed certificate.
|
||||||
|
**PASS if** status = 200, response decodes to valid PKCS#7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 26.4 — POST /simpleenroll with base64-encoded DER CSR**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Convert PEM CSR to base64-encoded DER (EST wire format)
|
||||||
|
openssl req -in /tmp/est-test.csr -outform DER | base64 > /tmp/est-test-b64der.csr
|
||||||
|
|
||||||
|
curl -s -w "\n%{http_code}" \
|
||||||
|
-H "Authorization: Bearer $API_KEY" \
|
||||||
|
-H "Content-Type: application/pkcs10" \
|
||||||
|
--data-binary @/tmp/est-test-b64der.csr \
|
||||||
|
http://localhost:8443/.well-known/est/simpleenroll
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 200. Server auto-detects base64-encoded DER and converts to PEM internally.
|
||||||
|
**PASS if** status = 200.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 26.5 — POST /simpleenroll with empty body**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-H "Authorization: Bearer $API_KEY" \
|
||||||
|
-H "Content-Type: application/pkcs10" \
|
||||||
|
-X POST -d "" \
|
||||||
|
http://localhost:8443/.well-known/est/simpleenroll
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 400 Bad Request.
|
||||||
|
**PASS if** status = 400.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 26.6 — POST /simpleenroll with invalid CSR**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-H "Authorization: Bearer $API_KEY" \
|
||||||
|
-H "Content-Type: application/pkcs10" \
|
||||||
|
-X POST -d "not-a-valid-csr-at-all" \
|
||||||
|
http://localhost:8443/.well-known/est/simpleenroll
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 400 Bad Request.
|
||||||
|
**PASS if** status = 400.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 26.7 — POST /simpleenroll with CSR missing Common Name**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl ecparam -name prime256v1 -genkey -noout -out /tmp/est-nocn.key
|
||||||
|
openssl req -new -key /tmp/est-nocn.key -out /tmp/est-nocn.csr -subj "/"
|
||||||
|
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-H "Authorization: Bearer $API_KEY" \
|
||||||
|
-H "Content-Type: application/pkcs10" \
|
||||||
|
--data-binary @/tmp/est-nocn.csr \
|
||||||
|
http://localhost:8443/.well-known/est/simpleenroll
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 500 (service returns error for missing CN). Error message should reference "Common Name".
|
||||||
|
**PASS if** status != 200.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 26.8 — POST /simpleenroll method enforcement (GET not allowed)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-H "Authorization: Bearer $API_KEY" \
|
||||||
|
http://localhost:8443/.well-known/est/simpleenroll
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 405 Method Not Allowed.
|
||||||
|
**PASS if** status = 405.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 26.9 — POST /.well-known/est/simplereenroll (re-enrollment)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl ecparam -name prime256v1 -genkey -noout -out /tmp/est-renew.key
|
||||||
|
openssl req -new -key /tmp/est-renew.key -out /tmp/est-renew.csr \
|
||||||
|
-subj "/CN=renew-est.example.com" \
|
||||||
|
-addext "subjectAltName=DNS:renew-est.example.com"
|
||||||
|
|
||||||
|
curl -s -w "\n%{http_code}" \
|
||||||
|
-H "Authorization: Bearer $API_KEY" \
|
||||||
|
-H "Content-Type: application/pkcs10" \
|
||||||
|
--data-binary @/tmp/est-renew.csr \
|
||||||
|
http://localhost:8443/.well-known/est/simplereenroll
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 200. Functionally identical to simpleenroll per RFC 7030 Section 4.2.2.
|
||||||
|
**PASS if** status = 200, valid PKCS#7 response.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 26.10 — GET /simplereenroll method enforcement**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-H "Authorization: Bearer $API_KEY" \
|
||||||
|
http://localhost:8443/.well-known/est/simplereenroll
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 405 Method Not Allowed.
|
||||||
|
**PASS if** status = 405.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 26.11 — GET /.well-known/est/csrattrs returns 204 (no required attrs)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-H "Authorization: Bearer $API_KEY" \
|
||||||
|
http://localhost:8443/.well-known/est/csrattrs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 204 No Content (default implementation requires no specific CSR attributes).
|
||||||
|
**PASS if** status = 204.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 26.12 — POST /csrattrs method enforcement**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-H "Authorization: Bearer $API_KEY" \
|
||||||
|
-X POST http://localhost:8443/.well-known/est/csrattrs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 405 Method Not Allowed.
|
||||||
|
**PASS if** status = 405.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 26.13 — EST enrollment creates audit event**
|
||||||
|
|
||||||
|
After a successful simpleenroll request (Test 26.3), query the audit trail:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||||
|
"http://localhost:8443/api/v1/audit?page=1&per_page=10" | \
|
||||||
|
jq '.data[] | select(.action == "est_simple_enroll")'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** At least one audit event with `action: "est_simple_enroll"`, `protocol: "EST"` in details, and the enrolled CN in the details.
|
||||||
|
**PASS if** audit event found with correct action and details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 26.14 — EST disabled returns 404**
|
||||||
|
|
||||||
|
With `CERTCTL_EST_ENABLED=false` (default), EST endpoints should not be registered:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:8443/.well-known/est/cacerts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** HTTP 404 Not Found (endpoints not registered when EST is disabled).
|
||||||
|
**PASS if** status = 404.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Test 26.15 — EST with profile binding**
|
||||||
|
|
||||||
|
With `CERTCTL_EST_PROFILE_ID=profile-wifi-client`, verify that audit events include the profile_id in their details:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# After enrollment with profile binding, check audit
|
||||||
|
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||||
|
"http://localhost:8443/api/v1/audit?page=1&per_page=5" | \
|
||||||
|
jq '.data[0].details.profile_id'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** Profile ID appears in audit event details when configured.
|
||||||
|
**PASS if** `profile_id` present in audit details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Release Sign-Off
|
## Release Sign-Off
|
||||||
|
|
||||||
All 25 parts must pass before tagging v2.0.0.
|
All 26 parts must pass before tagging v2.0.1.
|
||||||
|
|
||||||
| Section | Pass? | Tester | Date | Notes |
|
| Section | Pass? | Tester | Date | Notes |
|
||||||
|---------|-------|--------|------|-------|
|
|---------|-------|--------|------|-------|
|
||||||
@@ -3799,6 +4140,7 @@ All 25 parts must pass before tagging v2.0.0.
|
|||||||
| Part 23: Structured Logging | ☐ | | | |
|
| Part 23: Structured Logging | ☐ | | | |
|
||||||
| Part 24: Documentation Verification | ☐ | | | |
|
| Part 24: Documentation Verification | ☐ | | | |
|
||||||
| Part 25: Regression Tests | ☐ | | | |
|
| Part 25: Regression Tests | ☐ | | | |
|
||||||
|
| Part 26: EST Server (RFC 7030) | ☐ | | | |
|
||||||
|
|
||||||
**Automated tests (900+) must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ESTService defines the service interface for EST enrollment operations.
|
||||||
|
// EST (RFC 7030) is a protocol for certificate enrollment over HTTPS.
|
||||||
|
type ESTService interface {
|
||||||
|
// GetCACerts returns the PEM-encoded CA certificate chain for the EST issuer.
|
||||||
|
GetCACerts(ctx context.Context) (string, error)
|
||||||
|
|
||||||
|
// SimpleEnroll processes a PKCS#10 CSR and returns a signed certificate.
|
||||||
|
SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error)
|
||||||
|
|
||||||
|
// SimpleReEnroll processes a re-enrollment CSR (same as enroll for our purposes).
|
||||||
|
SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error)
|
||||||
|
|
||||||
|
// GetCSRAttrs returns the CSR attributes the server wants clients to include.
|
||||||
|
GetCSRAttrs(ctx context.Context) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESTHandler handles HTTP requests for the EST protocol (RFC 7030).
|
||||||
|
//
|
||||||
|
// EST endpoints are served under /.well-known/est/ per the RFC.
|
||||||
|
// Wire format: base64-encoded DER (PKCS#7 for certs, PKCS#10 for CSRs).
|
||||||
|
//
|
||||||
|
// Supported operations:
|
||||||
|
// - GET /.well-known/est/cacerts — CA certificate distribution
|
||||||
|
// - POST /.well-known/est/simpleenroll — initial enrollment
|
||||||
|
// - POST /.well-known/est/simplereenroll — re-enrollment
|
||||||
|
// - GET /.well-known/est/csrattrs — CSR attributes
|
||||||
|
type ESTHandler struct {
|
||||||
|
svc ESTService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewESTHandler creates a new ESTHandler.
|
||||||
|
func NewESTHandler(svc ESTService) ESTHandler {
|
||||||
|
return ESTHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CACerts handles GET /.well-known/est/cacerts
|
||||||
|
// Returns the CA certificate chain as base64-encoded PKCS#7 (certs-only).
|
||||||
|
// Per RFC 7030 Section 4.1, this is a "certs-only" CMC Simple PKI Response.
|
||||||
|
// For simplicity and broad client compatibility, we return base64-encoded DER certificates.
|
||||||
|
func (h ESTHandler) CACerts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
caCertPEM, err := h.svc.GetCACerts(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CA certificates: %v", err), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse PEM to DER for PKCS#7 encoding
|
||||||
|
derCerts, err := pemToDERChain(caCertPEM)
|
||||||
|
if err != nil {
|
||||||
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to encode CA certificates", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a simple PKCS#7 SignedData (certs-only, degenerate) structure
|
||||||
|
pkcs7Data, err := buildCertsOnlyPKCS7(derCerts)
|
||||||
|
if err != nil {
|
||||||
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to build PKCS#7 response", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 7030 Section 4.1.3: response is base64-encoded application/pkcs7-mime
|
||||||
|
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
|
||||||
|
w.Header().Set("Content-Transfer-Encoding", "base64")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(pkcs7Data)
|
||||||
|
// Write base64 with line breaks at 76 chars per RFC 2045
|
||||||
|
for i := 0; i < len(encoded); i += 76 {
|
||||||
|
end := i + 76
|
||||||
|
if end > len(encoded) {
|
||||||
|
end = len(encoded)
|
||||||
|
}
|
||||||
|
w.Write([]byte(encoded[i:end]))
|
||||||
|
w.Write([]byte("\r\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimpleEnroll handles POST /.well-known/est/simpleenroll
|
||||||
|
// Accepts a base64-encoded PKCS#10 CSR and returns a base64-encoded PKCS#7 certificate.
|
||||||
|
func (h ESTHandler) SimpleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
|
|
||||||
|
csrPEM, err := h.readCSRFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.svc.SimpleEnroll(r.Context(), csrPEM)
|
||||||
|
if err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Enrollment failed: %v", err), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeCertResponse(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimpleReEnroll handles POST /.well-known/est/simplereenroll
|
||||||
|
// Same as SimpleEnroll but for re-enrollment (certificate renewal).
|
||||||
|
func (h ESTHandler) SimpleReEnroll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
|
|
||||||
|
csrPEM, err := h.readCSRFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.svc.SimpleReEnroll(r.Context(), csrPEM)
|
||||||
|
if err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Re-enrollment failed: %v", err), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeCertResponse(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRAttrs handles GET /.well-known/est/csrattrs
|
||||||
|
// Returns the CSR attributes the server wants the client to include in enrollment requests.
|
||||||
|
func (h ESTHandler) CSRAttrs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs, err := h.svc.GetCSRAttrs(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CSR attributes: %v", err), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(attrs) == 0 {
|
||||||
|
// No specific attributes required — return 204
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/csrattrs")
|
||||||
|
w.Header().Set("Content-Transfer-Encoding", "base64")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(base64.StdEncoding.EncodeToString(attrs)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCSRFromRequest reads and decodes the CSR from an EST enrollment request.
|
||||||
|
// EST sends CSRs as base64-encoded PKCS#10 DER with Content-Type application/pkcs10.
|
||||||
|
func (h ESTHandler) readCSRFromRequest(r *http.Request) (string, error) {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read request body: %w", err)
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
if len(body) == 0 {
|
||||||
|
return "", fmt.Errorf("empty request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's already PEM-encoded (some clients send PEM directly)
|
||||||
|
bodyStr := strings.TrimSpace(string(body))
|
||||||
|
if strings.HasPrefix(bodyStr, "-----BEGIN CERTIFICATE REQUEST-----") {
|
||||||
|
// Validate it parses
|
||||||
|
block, _ := pem.Decode([]byte(bodyStr))
|
||||||
|
if block == nil {
|
||||||
|
return "", fmt.Errorf("invalid PEM-encoded CSR")
|
||||||
|
}
|
||||||
|
if _, err := x509.ParseCertificateRequest(block.Bytes); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid CSR: %w", err)
|
||||||
|
}
|
||||||
|
return bodyStr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EST standard: base64-encoded DER PKCS#10
|
||||||
|
derBytes, err := base64.StdEncoding.DecodeString(bodyStr)
|
||||||
|
if err != nil {
|
||||||
|
// Try with padding/whitespace stripped
|
||||||
|
cleaned := strings.Map(func(r rune) rune {
|
||||||
|
if r == '\r' || r == '\n' || r == ' ' || r == '\t' {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, bodyStr)
|
||||||
|
derBytes, err = base64.StdEncoding.DecodeString(cleaned)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode base64 CSR: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate it's a valid PKCS#10 CSR
|
||||||
|
if _, err := x509.ParseCertificateRequest(derBytes); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid PKCS#10 CSR: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert DER to PEM for internal use (certctl services expect PEM)
|
||||||
|
csrPEM := pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE REQUEST",
|
||||||
|
Bytes: derBytes,
|
||||||
|
})
|
||||||
|
return string(csrPEM), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCertResponse writes an EST enrollment response as base64-encoded PKCS#7.
|
||||||
|
func (h ESTHandler) writeCertResponse(w http.ResponseWriter, result *domain.ESTEnrollResult) {
|
||||||
|
// Parse cert and chain PEM to DER
|
||||||
|
var derCerts [][]byte
|
||||||
|
|
||||||
|
// Add the issued certificate
|
||||||
|
certDER, err := pemToDERChain(result.CertPEM)
|
||||||
|
if err != nil || len(certDER) == 0 {
|
||||||
|
http.Error(w, "Failed to encode certificate", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
derCerts = append(derCerts, certDER...)
|
||||||
|
|
||||||
|
// Add the CA chain if present
|
||||||
|
if result.ChainPEM != "" {
|
||||||
|
chainDER, err := pemToDERChain(result.ChainPEM)
|
||||||
|
if err == nil {
|
||||||
|
derCerts = append(derCerts, chainDER...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build PKCS#7 certs-only
|
||||||
|
pkcs7Data, err := buildCertsOnlyPKCS7(derCerts)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to build PKCS#7 response", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
|
||||||
|
w.Header().Set("Content-Transfer-Encoding", "base64")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(pkcs7Data)
|
||||||
|
for i := 0; i < len(encoded); i += 76 {
|
||||||
|
end := i + 76
|
||||||
|
if end > len(encoded) {
|
||||||
|
end = len(encoded)
|
||||||
|
}
|
||||||
|
w.Write([]byte(encoded[i:end]))
|
||||||
|
w.Write([]byte("\r\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pemToDERChain converts PEM-encoded certificates to a slice of DER-encoded certificates.
|
||||||
|
func pemToDERChain(pemData string) ([][]byte, error) {
|
||||||
|
var derCerts [][]byte
|
||||||
|
rest := []byte(pemData)
|
||||||
|
for {
|
||||||
|
var block *pem.Block
|
||||||
|
block, rest = pem.Decode(rest)
|
||||||
|
if block == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if block.Type == "CERTIFICATE" {
|
||||||
|
derCerts = append(derCerts, block.Bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(derCerts) == 0 {
|
||||||
|
return nil, fmt.Errorf("no certificates found in PEM data")
|
||||||
|
}
|
||||||
|
return derCerts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCertsOnlyPKCS7 creates a degenerate PKCS#7 SignedData structure containing only certificates.
|
||||||
|
// This is the "certs-only" format specified in RFC 7030 Section 4.1.3 for /cacerts responses
|
||||||
|
// and enrollment responses.
|
||||||
|
//
|
||||||
|
// ASN.1 structure (simplified):
|
||||||
|
//
|
||||||
|
// ContentInfo {
|
||||||
|
// contentType: signedData (1.2.840.113549.1.7.2)
|
||||||
|
// content: SignedData {
|
||||||
|
// version: 1
|
||||||
|
// digestAlgorithms: {} (empty)
|
||||||
|
// encapContentInfo: { contentType: data (1.2.840.113549.1.7.1) }
|
||||||
|
// certificates: [cert1, cert2, ...]
|
||||||
|
// signerInfos: {} (empty)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
func buildCertsOnlyPKCS7(derCerts [][]byte) ([]byte, error) {
|
||||||
|
// We build the ASN.1 manually to avoid pulling in a PKCS#7 library.
|
||||||
|
// This is a well-defined, static structure — no signing needed.
|
||||||
|
|
||||||
|
// OID for signedData: 1.2.840.113549.1.7.2
|
||||||
|
oidSignedData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
|
||||||
|
// OID for data: 1.2.840.113549.1.7.1
|
||||||
|
oidData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
|
||||||
|
|
||||||
|
// Build certificates [0] IMPLICIT SET OF Certificate
|
||||||
|
var certsContent []byte
|
||||||
|
for _, cert := range derCerts {
|
||||||
|
certsContent = append(certsContent, cert...)
|
||||||
|
}
|
||||||
|
certsField := asn1WrapImplicit(0, certsContent)
|
||||||
|
|
||||||
|
// Build encapContentInfo: SEQUENCE { OID data }
|
||||||
|
encapContentInfo := asn1WrapSequence(oidData)
|
||||||
|
|
||||||
|
// Build digestAlgorithms: SET {} (empty)
|
||||||
|
digestAlgorithms := asn1WrapSet(nil)
|
||||||
|
|
||||||
|
// Build signerInfos: SET {} (empty)
|
||||||
|
signerInfos := asn1WrapSet(nil)
|
||||||
|
|
||||||
|
// Version: INTEGER 1
|
||||||
|
version := []byte{0x02, 0x01, 0x01}
|
||||||
|
|
||||||
|
// Build SignedData SEQUENCE
|
||||||
|
var signedDataContent []byte
|
||||||
|
signedDataContent = append(signedDataContent, version...)
|
||||||
|
signedDataContent = append(signedDataContent, digestAlgorithms...)
|
||||||
|
signedDataContent = append(signedDataContent, encapContentInfo...)
|
||||||
|
signedDataContent = append(signedDataContent, certsField...)
|
||||||
|
signedDataContent = append(signedDataContent, signerInfos...)
|
||||||
|
signedData := asn1WrapSequence(signedDataContent)
|
||||||
|
|
||||||
|
// Wrap in [0] EXPLICIT for ContentInfo.content
|
||||||
|
contentField := asn1WrapExplicit(0, signedData)
|
||||||
|
|
||||||
|
// Build ContentInfo SEQUENCE
|
||||||
|
var contentInfoContent []byte
|
||||||
|
contentInfoContent = append(contentInfoContent, oidSignedData...)
|
||||||
|
contentInfoContent = append(contentInfoContent, contentField...)
|
||||||
|
contentInfo := asn1WrapSequence(contentInfoContent)
|
||||||
|
|
||||||
|
return contentInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// asn1WrapSequence wraps content in an ASN.1 SEQUENCE tag (0x30).
|
||||||
|
func asn1WrapSequence(content []byte) []byte {
|
||||||
|
return asn1Wrap(0x30, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// asn1WrapSet wraps content in an ASN.1 SET tag (0x31).
|
||||||
|
func asn1WrapSet(content []byte) []byte {
|
||||||
|
return asn1Wrap(0x31, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// asn1WrapExplicit wraps content in an ASN.1 context-specific EXPLICIT tag.
|
||||||
|
func asn1WrapExplicit(tag int, content []byte) []byte {
|
||||||
|
return asn1Wrap(byte(0xa0|tag), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// asn1WrapImplicit wraps content in an ASN.1 context-specific IMPLICIT CONSTRUCTED tag.
|
||||||
|
func asn1WrapImplicit(tag int, content []byte) []byte {
|
||||||
|
return asn1Wrap(byte(0xa0|tag), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// asn1Wrap wraps content with an ASN.1 tag and length.
|
||||||
|
func asn1Wrap(tag byte, content []byte) []byte {
|
||||||
|
length := len(content)
|
||||||
|
var result []byte
|
||||||
|
result = append(result, tag)
|
||||||
|
result = append(result, asn1EncodeLength(length)...)
|
||||||
|
result = append(result, content...)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// asn1EncodeLength encodes a length in ASN.1 DER format.
|
||||||
|
func asn1EncodeLength(length int) []byte {
|
||||||
|
if length < 0x80 {
|
||||||
|
return []byte{byte(length)}
|
||||||
|
}
|
||||||
|
// Long form
|
||||||
|
var lengthBytes []byte
|
||||||
|
l := length
|
||||||
|
for l > 0 {
|
||||||
|
lengthBytes = append([]byte{byte(l & 0xff)}, lengthBytes...)
|
||||||
|
l >>= 8
|
||||||
|
}
|
||||||
|
return append([]byte{byte(0x80 | len(lengthBytes))}, lengthBytes...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockESTService implements ESTService for testing.
|
||||||
|
type mockESTService struct {
|
||||||
|
CACertPEM string
|
||||||
|
CACertErr error
|
||||||
|
EnrollResult *domain.ESTEnrollResult
|
||||||
|
EnrollErr error
|
||||||
|
CSRAttrs []byte
|
||||||
|
CSRAttrsErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockESTService) GetCACerts(ctx context.Context) (string, error) {
|
||||||
|
return m.CACertPEM, m.CACertErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockESTService) SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
||||||
|
return m.EnrollResult, m.EnrollErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockESTService) SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
||||||
|
return m.EnrollResult, m.EnrollErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockESTService) GetCSRAttrs(ctx context.Context) ([]byte, error) {
|
||||||
|
return m.CSRAttrs, m.CSRAttrsErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestCSRPEM creates a valid ECDSA P-256 CSR for testing.
|
||||||
|
func generateTestCSRPEM(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate key: %v", err)
|
||||||
|
}
|
||||||
|
template := &x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{CommonName: "test.example.com"},
|
||||||
|
DNSNames: []string{"test.example.com", "www.example.com"},
|
||||||
|
}
|
||||||
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create CSR: %v", err)
|
||||||
|
}
|
||||||
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestCSRBase64DER creates a valid base64-encoded DER CSR for EST wire format.
|
||||||
|
func generateTestCSRBase64DER(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate key: %v", err)
|
||||||
|
}
|
||||||
|
template := &x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{CommonName: "test.example.com"},
|
||||||
|
DNSNames: []string{"test.example.com"},
|
||||||
|
}
|
||||||
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create CSR: %v", err)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(csrDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestCertPEM creates a real self-signed certificate PEM for testing.
|
||||||
|
func generateTestCertPEM(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate key: %v", err)
|
||||||
|
}
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "Test CA"},
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||||
|
IsCA: true,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create certificate: %v", err)
|
||||||
|
}
|
||||||
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTCACerts_Success(t *testing.T) {
|
||||||
|
certPEM := generateTestCertPEM(t)
|
||||||
|
svc := &mockESTService{
|
||||||
|
CACertPEM: certPEM,
|
||||||
|
}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/cacerts", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.CACerts(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
ct := w.Header().Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "application/pkcs7-mime") {
|
||||||
|
t.Errorf("expected application/pkcs7-mime content type, got %s", ct)
|
||||||
|
}
|
||||||
|
cte := w.Header().Get("Content-Transfer-Encoding")
|
||||||
|
if cte != "base64" {
|
||||||
|
t.Errorf("expected base64 content-transfer-encoding, got %s", cte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTCACerts_MethodNotAllowed(t *testing.T) {
|
||||||
|
svc := &mockESTService{}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/cacerts", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.CACerts(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTCACerts_ServiceError(t *testing.T) {
|
||||||
|
svc := &mockESTService{
|
||||||
|
CACertErr: errors.New("issuer unavailable"),
|
||||||
|
}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/cacerts", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.CACerts(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTSimpleEnroll_Success_PEM(t *testing.T) {
|
||||||
|
csrPEM := generateTestCSRPEM(t)
|
||||||
|
certPEM := generateTestCertPEM(t)
|
||||||
|
svc := &mockESTService{
|
||||||
|
EnrollResult: &domain.ESTEnrollResult{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
ChainPEM: certPEM,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(csrPEM))
|
||||||
|
req.Header.Set("Content-Type", "application/pkcs10")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.SimpleEnroll(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
ct := w.Header().Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "application/pkcs7-mime") {
|
||||||
|
t.Errorf("expected application/pkcs7-mime, got %s", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTSimpleEnroll_Success_Base64DER(t *testing.T) {
|
||||||
|
csrB64 := generateTestCSRBase64DER(t)
|
||||||
|
certPEM := generateTestCertPEM(t)
|
||||||
|
svc := &mockESTService{
|
||||||
|
EnrollResult: &domain.ESTEnrollResult{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
ChainPEM: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(csrB64))
|
||||||
|
req.Header.Set("Content-Type", "application/pkcs10")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.SimpleEnroll(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTSimpleEnroll_MethodNotAllowed(t *testing.T) {
|
||||||
|
svc := &mockESTService{}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/simpleenroll", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.SimpleEnroll(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTSimpleEnroll_EmptyBody(t *testing.T) {
|
||||||
|
svc := &mockESTService{}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(""))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.SimpleEnroll(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTSimpleEnroll_InvalidCSR(t *testing.T) {
|
||||||
|
svc := &mockESTService{}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader("not-a-valid-csr"))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.SimpleEnroll(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTSimpleEnroll_ServiceError(t *testing.T) {
|
||||||
|
csrPEM := generateTestCSRPEM(t)
|
||||||
|
svc := &mockESTService{
|
||||||
|
EnrollErr: errors.New("issuance failed"),
|
||||||
|
}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(csrPEM))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.SimpleEnroll(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTSimpleReEnroll_Success(t *testing.T) {
|
||||||
|
csrPEM := generateTestCSRPEM(t)
|
||||||
|
certPEM := generateTestCertPEM(t)
|
||||||
|
svc := &mockESTService{
|
||||||
|
EnrollResult: &domain.ESTEnrollResult{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
ChainPEM: certPEM,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simplereenroll", strings.NewReader(csrPEM))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.SimpleReEnroll(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTSimpleReEnroll_MethodNotAllowed(t *testing.T) {
|
||||||
|
svc := &mockESTService{}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/simplereenroll", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.SimpleReEnroll(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTCSRAttrs_NoContent(t *testing.T) {
|
||||||
|
svc := &mockESTService{
|
||||||
|
CSRAttrs: nil,
|
||||||
|
}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/csrattrs", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.CSRAttrs(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("expected 204, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTCSRAttrs_WithData(t *testing.T) {
|
||||||
|
svc := &mockESTService{
|
||||||
|
CSRAttrs: []byte{0x30, 0x00}, // empty SEQUENCE
|
||||||
|
}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/csrattrs", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.CSRAttrs(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
ct := w.Header().Get("Content-Type")
|
||||||
|
if ct != "application/csrattrs" {
|
||||||
|
t.Errorf("expected application/csrattrs, got %s", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTCSRAttrs_MethodNotAllowed(t *testing.T) {
|
||||||
|
svc := &mockESTService{}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/csrattrs", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.CSRAttrs(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCertsOnlyPKCS7(t *testing.T) {
|
||||||
|
// Test with a dummy DER certificate
|
||||||
|
dummyCert := []byte{0x30, 0x82, 0x01, 0x00} // minimal ASN.1 SEQUENCE
|
||||||
|
result, err := buildCertsOnlyPKCS7([][]byte{dummyCert})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildCertsOnlyPKCS7 failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
t.Error("expected non-empty PKCS#7 output")
|
||||||
|
}
|
||||||
|
// Verify it starts with SEQUENCE tag
|
||||||
|
if result[0] != 0x30 {
|
||||||
|
t.Errorf("expected PKCS#7 to start with SEQUENCE tag (0x30), got 0x%02x", result[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPemToDERChain(t *testing.T) {
|
||||||
|
pemData := generateTestCertPEM(t)
|
||||||
|
certs, err := pemToDERChain(pemData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pemToDERChain failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(certs) != 1 {
|
||||||
|
t.Errorf("expected 1 cert, got %d", len(certs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPemToDERChain_NoCerts(t *testing.T) {
|
||||||
|
_, err := pemToDERChain("not a PEM")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid PEM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASN1EncodeLength(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
length int
|
||||||
|
expected []byte
|
||||||
|
}{
|
||||||
|
{0, []byte{0x00}},
|
||||||
|
{1, []byte{0x01}},
|
||||||
|
{127, []byte{0x7f}},
|
||||||
|
{128, []byte{0x81, 0x80}},
|
||||||
|
{256, []byte{0x82, 0x01, 0x00}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := asn1EncodeLength(tt.length)
|
||||||
|
if len(result) != len(tt.expected) {
|
||||||
|
t.Errorf("asn1EncodeLength(%d): expected %d bytes, got %d", tt.length, len(tt.expected), len(result))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range result {
|
||||||
|
if result[i] != tt.expected[i] {
|
||||||
|
t.Errorf("asn1EncodeLength(%d): byte %d: expected 0x%02x, got 0x%02x", tt.length, i, tt.expected[i], result[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,7 +184,13 @@ func (h IssuerHandler) DeleteIssuer(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.DeleteIssuer(id); err != nil {
|
if err := h.svc.DeleteIssuer(id); err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete issuer", requestID)
|
if strings.Contains(err.Error(), "violates foreign key") || strings.Contains(err.Error(), "RESTRICT") {
|
||||||
|
ErrorWithRequestID(w, http.StatusConflict, "Cannot delete issuer: certificates are still using this issuer", requestID)
|
||||||
|
} else if strings.Contains(err.Error(), "not found") {
|
||||||
|
ErrorWithRequestID(w, http.StatusNotFound, "Issuer not found", requestID)
|
||||||
|
} else {
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete issuer", requestID)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,13 @@ func (h OwnerHandler) DeleteOwner(w http.ResponseWriter, r *http.Request) {
|
|||||||
id = parts[0]
|
id = parts[0]
|
||||||
|
|
||||||
if err := h.svc.DeleteOwner(id); err != nil {
|
if err := h.svc.DeleteOwner(id); err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete owner", requestID)
|
if strings.Contains(err.Error(), "violates foreign key") || strings.Contains(err.Error(), "RESTRICT") {
|
||||||
|
ErrorWithRequestID(w, http.StatusConflict, "Cannot delete owner: certificates are still assigned to this owner", requestID)
|
||||||
|
} else if strings.Contains(err.Error(), "not found") {
|
||||||
|
ErrorWithRequestID(w, http.StatusNotFound, "Owner not found", requestID)
|
||||||
|
} else {
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete owner", requestID)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,16 @@ func (r *Router) RegisterHandlers(
|
|||||||
r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(networkScan.TriggerNetworkScan))
|
r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(networkScan.TriggerNetworkScan))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterESTHandlers sets up EST (RFC 7030) routes under /.well-known/est/.
|
||||||
|
// EST endpoints use a separate middleware chain (no API key auth — EST uses TLS client certs).
|
||||||
|
func (r *Router) RegisterESTHandlers(est handler.ESTHandler) {
|
||||||
|
// EST endpoints per RFC 7030 Section 3.2.2
|
||||||
|
r.Register("GET /.well-known/est/cacerts", http.HandlerFunc(est.CACerts))
|
||||||
|
r.Register("POST /.well-known/est/simpleenroll", http.HandlerFunc(est.SimpleEnroll))
|
||||||
|
r.Register("POST /.well-known/est/simplereenroll", http.HandlerFunc(est.SimpleReEnroll))
|
||||||
|
r.Register("GET /.well-known/est/csrattrs", http.HandlerFunc(est.CSRAttrs))
|
||||||
|
}
|
||||||
|
|
||||||
// GetMux returns the underlying http.ServeMux for direct access if needed.
|
// GetMux returns the underlying http.ServeMux for direct access if needed.
|
||||||
func (r *Router) GetMux() *http.ServeMux {
|
func (r *Router) GetMux() *http.ServeMux {
|
||||||
return r.mux
|
return r.mux
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Config struct {
|
|||||||
CA CAConfig
|
CA CAConfig
|
||||||
Notifiers NotifierConfig
|
Notifiers NotifierConfig
|
||||||
NetworkScan NetworkScanConfig
|
NetworkScan NetworkScanConfig
|
||||||
|
EST ESTConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotifierConfig contains configuration for notification connectors.
|
// NotifierConfig contains configuration for notification connectors.
|
||||||
@@ -66,11 +67,12 @@ type StepCAConfig struct {
|
|||||||
|
|
||||||
// ACMEConfig contains ACME issuer connector configuration.
|
// ACMEConfig contains ACME issuer connector configuration.
|
||||||
type ACMEConfig struct {
|
type ACMEConfig struct {
|
||||||
DirectoryURL string
|
DirectoryURL string
|
||||||
Email string
|
Email string
|
||||||
ChallengeType string // "http-01" (default) or "dns-01"
|
ChallengeType string // "http-01" (default), "dns-01", or "dns-persist-01"
|
||||||
DNSPresentScript string
|
DNSPresentScript string
|
||||||
DNSCleanUpScript string
|
DNSCleanUpScript string
|
||||||
|
DNSPersistIssuerDomain string // Required for dns-persist-01 (e.g., "letsencrypt.org")
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenSSLConfig contains OpenSSL/Custom CA issuer connector configuration.
|
// OpenSSLConfig contains OpenSSL/Custom CA issuer connector configuration.
|
||||||
@@ -81,6 +83,14 @@ type OpenSSLConfig struct {
|
|||||||
TimeoutSeconds int
|
TimeoutSeconds int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ESTConfig controls the RFC 7030 Enrollment over Secure Transport server.
|
||||||
|
type ESTConfig struct {
|
||||||
|
Enabled bool // Enable EST endpoints (default false)
|
||||||
|
IssuerID string // Which issuer connector to use for EST enrollment (e.g., "iss-local")
|
||||||
|
// ProfileID optionally constrains EST enrollments to a specific certificate profile.
|
||||||
|
ProfileID string
|
||||||
|
}
|
||||||
|
|
||||||
// NetworkScanConfig controls the server-side active TLS scanner.
|
// NetworkScanConfig controls the server-side active TLS scanner.
|
||||||
type NetworkScanConfig struct {
|
type NetworkScanConfig struct {
|
||||||
Enabled bool // Enable network scanning (default false)
|
Enabled bool // Enable network scanning (default false)
|
||||||
@@ -189,6 +199,11 @@ func Load() (*Config, error) {
|
|||||||
Enabled: getEnvBool("CERTCTL_NETWORK_SCAN_ENABLED", false),
|
Enabled: getEnvBool("CERTCTL_NETWORK_SCAN_ENABLED", false),
|
||||||
ScanInterval: getEnvDuration("CERTCTL_NETWORK_SCAN_INTERVAL", 6*time.Hour),
|
ScanInterval: getEnvDuration("CERTCTL_NETWORK_SCAN_INTERVAL", 6*time.Hour),
|
||||||
},
|
},
|
||||||
|
EST: ESTConfig{
|
||||||
|
Enabled: getEnvBool("CERTCTL_EST_ENABLED", false),
|
||||||
|
IssuerID: getEnv("CERTCTL_EST_ISSUER_ID", "iss-local"),
|
||||||
|
ProfileID: getEnv("CERTCTL_EST_PROFILE_ID", ""),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
|
|||||||
@@ -28,21 +28,28 @@ type Config struct {
|
|||||||
EABHmac string `json:"eab_hmac,omitempty"` // External Account Binding HMAC Key
|
EABHmac string `json:"eab_hmac,omitempty"` // External Account Binding HMAC Key
|
||||||
HTTPPort int `json:"http_port,omitempty"` // Port for HTTP-01 challenge server (default: 80)
|
HTTPPort int `json:"http_port,omitempty"` // Port for HTTP-01 challenge server (default: 80)
|
||||||
|
|
||||||
// ChallengeType selects the ACME challenge method: "http-01" (default) or "dns-01".
|
// ChallengeType selects the ACME challenge method: "http-01" (default), "dns-01", or "dns-persist-01".
|
||||||
// DNS-01 is required for wildcard certificates (*.example.com).
|
// DNS-01 is required for wildcard certificates (*.example.com).
|
||||||
|
// DNS-PERSIST-01 uses a standing TXT record (set once, reused forever) — no per-renewal DNS updates.
|
||||||
ChallengeType string `json:"challenge_type,omitempty"`
|
ChallengeType string `json:"challenge_type,omitempty"`
|
||||||
|
|
||||||
// DNSPresentScript is the path to a script that creates DNS TXT records (dns-01 only).
|
// DNSPresentScript is the path to a script that creates DNS TXT records (dns-01 and dns-persist-01).
|
||||||
// The script receives CERTCTL_DNS_DOMAIN, CERTCTL_DNS_FQDN, CERTCTL_DNS_VALUE, CERTCTL_DNS_TOKEN.
|
// The script receives CERTCTL_DNS_DOMAIN, CERTCTL_DNS_FQDN, CERTCTL_DNS_VALUE, CERTCTL_DNS_TOKEN.
|
||||||
DNSPresentScript string `json:"dns_present_script,omitempty"`
|
DNSPresentScript string `json:"dns_present_script,omitempty"`
|
||||||
|
|
||||||
// DNSCleanUpScript is the path to a script that removes DNS TXT records (dns-01 only).
|
// DNSCleanUpScript is the path to a script that removes DNS TXT records (dns-01 only).
|
||||||
// Optional — if not set, records are not cleaned up automatically.
|
// Optional — if not set, records are not cleaned up automatically.
|
||||||
|
// Not used by dns-persist-01 (records are permanent).
|
||||||
DNSCleanUpScript string `json:"dns_cleanup_script,omitempty"`
|
DNSCleanUpScript string `json:"dns_cleanup_script,omitempty"`
|
||||||
|
|
||||||
// DNSPropagationWait is how long to wait (in seconds) after creating the TXT record
|
// DNSPropagationWait is how long to wait (in seconds) after creating the TXT record
|
||||||
// before telling the CA to validate. Defaults to 30 seconds.
|
// before telling the CA to validate. Defaults to 30 seconds.
|
||||||
DNSPropagationWait int `json:"dns_propagation_wait,omitempty"`
|
DNSPropagationWait int `json:"dns_propagation_wait,omitempty"`
|
||||||
|
|
||||||
|
// DNSPersistIssuerDomain is the CA's issuer domain name for dns-persist-01 records.
|
||||||
|
// Used to construct the TXT record value: "<issuer-domain>; accounturi=<account-uri>".
|
||||||
|
// Required when ChallengeType is "dns-persist-01". For Let's Encrypt, use "letsencrypt.org".
|
||||||
|
DNSPersistIssuerDomain string `json:"dns_persist_issuer_domain,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connector implements the issuer.Connector interface for ACME-compatible CAs
|
// Connector implements the issuer.Connector interface for ACME-compatible CAs
|
||||||
@@ -87,10 +94,11 @@ func New(config *Config, logger *slog.Logger) *Connector {
|
|||||||
challengeTokens: make(map[string]string),
|
challengeTokens: make(map[string]string),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize DNS solver if dns-01 challenge type is configured
|
// Initialize DNS solver if dns-01 or dns-persist-01 challenge type is configured
|
||||||
if config != nil && config.ChallengeType == "dns-01" && config.DNSPresentScript != "" {
|
if config != nil && (config.ChallengeType == "dns-01" || config.ChallengeType == "dns-persist-01") && config.DNSPresentScript != "" {
|
||||||
c.dnsSolver = NewScriptDNSSolver(config.DNSPresentScript, config.DNSCleanUpScript, logger)
|
c.dnsSolver = NewScriptDNSSolver(config.DNSPresentScript, config.DNSCleanUpScript, logger)
|
||||||
logger.Info("DNS-01 challenge solver configured",
|
logger.Info("DNS challenge solver configured",
|
||||||
|
"challenge_type", config.ChallengeType,
|
||||||
"present_script", config.DNSPresentScript,
|
"present_script", config.DNSPresentScript,
|
||||||
"cleanup_script", config.DNSCleanUpScript)
|
"cleanup_script", config.DNSCleanUpScript)
|
||||||
}
|
}
|
||||||
@@ -141,13 +149,18 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate challenge type
|
// Validate challenge type
|
||||||
if cfg.ChallengeType != "http-01" && cfg.ChallengeType != "dns-01" {
|
if cfg.ChallengeType != "http-01" && cfg.ChallengeType != "dns-01" && cfg.ChallengeType != "dns-persist-01" {
|
||||||
return fmt.Errorf("invalid challenge_type: %s (must be http-01 or dns-01)", cfg.ChallengeType)
|
return fmt.Errorf("invalid challenge_type: %s (must be http-01, dns-01, or dns-persist-01)", cfg.ChallengeType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNS-01 requires a present script
|
// DNS-01 and DNS-PERSIST-01 require a present script
|
||||||
if cfg.ChallengeType == "dns-01" && cfg.DNSPresentScript == "" {
|
if (cfg.ChallengeType == "dns-01" || cfg.ChallengeType == "dns-persist-01") && cfg.DNSPresentScript == "" {
|
||||||
return fmt.Errorf("dns_present_script is required for dns-01 challenge type")
|
return fmt.Errorf("dns_present_script is required for %s challenge type", cfg.ChallengeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS-PERSIST-01 requires an issuer domain
|
||||||
|
if cfg.ChallengeType == "dns-persist-01" && cfg.DNSPersistIssuerDomain == "" {
|
||||||
|
return fmt.Errorf("dns_persist_issuer_domain is required for dns-persist-01 challenge type (e.g., \"letsencrypt.org\")")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.DNSPropagationWait == 0 {
|
if cfg.DNSPropagationWait == 0 {
|
||||||
@@ -156,8 +169,8 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
|||||||
|
|
||||||
c.config = &cfg
|
c.config = &cfg
|
||||||
|
|
||||||
// Re-initialize DNS solver if switching to dns-01
|
// Re-initialize DNS solver if switching to dns-01 or dns-persist-01
|
||||||
if cfg.ChallengeType == "dns-01" && cfg.DNSPresentScript != "" {
|
if (cfg.ChallengeType == "dns-01" || cfg.ChallengeType == "dns-persist-01") && cfg.DNSPresentScript != "" {
|
||||||
c.dnsSolver = NewScriptDNSSolver(cfg.DNSPresentScript, cfg.DNSCleanUpScript, c.logger)
|
c.dnsSolver = NewScriptDNSSolver(cfg.DNSPresentScript, cfg.DNSCleanUpScript, c.logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,12 +348,16 @@ func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer
|
|||||||
}
|
}
|
||||||
|
|
||||||
// solveAuthorizations processes all authorization URLs and solves their challenges.
|
// solveAuthorizations processes all authorization URLs and solves their challenges.
|
||||||
// Supports both HTTP-01 and DNS-01 challenge types based on configuration.
|
// Supports HTTP-01, DNS-01, and DNS-PERSIST-01 challenge types based on configuration.
|
||||||
func (c *Connector) solveAuthorizations(ctx context.Context, authzURLs []string) error {
|
func (c *Connector) solveAuthorizations(ctx context.Context, authzURLs []string) error {
|
||||||
if c.config.ChallengeType == "dns-01" {
|
switch c.config.ChallengeType {
|
||||||
|
case "dns-01":
|
||||||
return c.solveAuthorizationsDNS01(ctx, authzURLs)
|
return c.solveAuthorizationsDNS01(ctx, authzURLs)
|
||||||
|
case "dns-persist-01":
|
||||||
|
return c.solveAuthorizationsDNSPersist01(ctx, authzURLs)
|
||||||
|
default:
|
||||||
|
return c.solveAuthorizationsHTTP01(ctx, authzURLs)
|
||||||
}
|
}
|
||||||
return c.solveAuthorizationsHTTP01(ctx, authzURLs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// solveAuthorizationsHTTP01 solves challenges using the HTTP-01 method.
|
// solveAuthorizationsHTTP01 solves challenges using the HTTP-01 method.
|
||||||
@@ -497,6 +514,126 @@ func (c *Connector) solveAuthorizationsDNS01(ctx context.Context, authzURLs []st
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// solveAuthorizationsDNSPersist01 solves challenges using the DNS-PERSIST-01 method.
|
||||||
|
// DNS-PERSIST-01 uses a standing TXT record at _validation-persist.<domain> that persists
|
||||||
|
// across renewals. The record contains the CA's issuer domain and the ACME account URI,
|
||||||
|
// authorizing unlimited future issuances without per-renewal DNS updates.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. For each authorization, check if it's already valid (standing record exists)
|
||||||
|
// 2. If pending, find the dns-persist-01 challenge
|
||||||
|
// 3. Build the TXT record value: "<issuer-domain>; accounturi=<account-uri>"
|
||||||
|
// 4. Create the _validation-persist TXT record via the present script (one-time)
|
||||||
|
// 5. Wait for propagation, then accept the challenge
|
||||||
|
// 6. No cleanup — the record is permanent by design
|
||||||
|
//
|
||||||
|
// See: draft-ietf-acme-dns-persist (IETF), CA/Browser Forum ballot SC-088v3
|
||||||
|
func (c *Connector) solveAuthorizationsDNSPersist01(ctx context.Context, authzURLs []string) error {
|
||||||
|
if c.dnsSolver == nil {
|
||||||
|
return fmt.Errorf("dns-persist-01 challenge type configured but no DNS solver available")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the account URI for the TXT record value
|
||||||
|
if err := c.ensureClient(ctx); err != nil {
|
||||||
|
return fmt.Errorf("ACME client init for dns-persist-01: %w", err)
|
||||||
|
}
|
||||||
|
acct, err := c.client.GetReg(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get ACME account URI for dns-persist-01: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, authzURL := range authzURLs {
|
||||||
|
authz, err := c.client.GetAuthorization(ctx, authzURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get authorization %s: %w", authzURL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already valid (standing record recognized), skip
|
||||||
|
if authz.Status == acme.StatusValid {
|
||||||
|
c.logger.Info("dns-persist-01 authorization already valid (standing record recognized)",
|
||||||
|
"domain", authz.Identifier.Value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the dns-persist-01 challenge
|
||||||
|
var persistChallenge *acme.Challenge
|
||||||
|
for _, ch := range authz.Challenges {
|
||||||
|
if ch.Type == "dns-persist-01" {
|
||||||
|
persistChallenge = ch
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if the CA doesn't offer dns-persist-01 yet, try dns-01
|
||||||
|
if persistChallenge == nil {
|
||||||
|
c.logger.Warn("dns-persist-01 challenge not offered by CA, falling back to dns-01",
|
||||||
|
"domain", authz.Identifier.Value)
|
||||||
|
return c.solveAuthorizationsDNS01(ctx, authzURLs)
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := authz.Identifier.Value
|
||||||
|
|
||||||
|
// Build the persistent TXT record value per draft-ietf-acme-dns-persist:
|
||||||
|
// "<issuer-domain>; accounturi=<account-uri>"
|
||||||
|
recordValue := fmt.Sprintf("%s; accounturi=%s", c.config.DNSPersistIssuerDomain, acct.URI)
|
||||||
|
|
||||||
|
c.logger.Info("creating persistent DNS validation record",
|
||||||
|
"domain", domain,
|
||||||
|
"fqdn", "_validation-persist."+domain,
|
||||||
|
"issuer_domain", c.config.DNSPersistIssuerDomain,
|
||||||
|
"account_uri", acct.URI)
|
||||||
|
|
||||||
|
// Create the standing TXT record via the present script.
|
||||||
|
// The script receives CERTCTL_DNS_FQDN="_validation-persist.<domain>"
|
||||||
|
// and CERTCTL_DNS_VALUE="<issuer-domain>; accounturi=<account-uri>".
|
||||||
|
if err := c.presentPersistRecord(ctx, domain, persistChallenge.Token, recordValue); err != nil {
|
||||||
|
return fmt.Errorf("failed to create persistent DNS record for %s: %w", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for DNS propagation
|
||||||
|
propagationWait := time.Duration(c.config.DNSPropagationWait) * time.Second
|
||||||
|
c.logger.Info("waiting for DNS propagation",
|
||||||
|
"domain", domain,
|
||||||
|
"wait_seconds", c.config.DNSPropagationWait)
|
||||||
|
time.Sleep(propagationWait)
|
||||||
|
|
||||||
|
// Tell the CA we're ready
|
||||||
|
if _, err := c.client.Accept(ctx, persistChallenge); err != nil {
|
||||||
|
return fmt.Errorf("failed to accept dns-persist-01 challenge: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for authorization to be valid
|
||||||
|
if _, err := c.client.WaitAuthorization(ctx, authzURL); err != nil {
|
||||||
|
return fmt.Errorf("dns-persist-01 authorization failed for %s: %w", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("dns-persist-01 authorization validated (record is now permanent)",
|
||||||
|
"domain", domain)
|
||||||
|
|
||||||
|
// No cleanup — the record is permanent by design.
|
||||||
|
// Future renewals will skip challenge solving entirely (authz.Status == StatusValid).
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// presentPersistRecord creates a _validation-persist TXT record using the DNS solver.
|
||||||
|
// Unlike dns-01 which uses _acme-challenge, dns-persist-01 uses _validation-persist.
|
||||||
|
func (c *Connector) presentPersistRecord(ctx context.Context, domain, token, recordValue string) error {
|
||||||
|
if c.dnsSolver == nil {
|
||||||
|
return fmt.Errorf("DNS solver not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use PresentPersist if available (ScriptDNSSolver) — targets _validation-persist prefix.
|
||||||
|
if solver, ok := c.dnsSolver.(*ScriptDNSSolver); ok {
|
||||||
|
return solver.PresentPersist(ctx, domain, token, recordValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other DNSSolver implementations, fall back to Present.
|
||||||
|
// Custom implementations should read CERTCTL_DNS_FQDN to determine the record name.
|
||||||
|
return c.dnsSolver.Present(ctx, domain, token, recordValue)
|
||||||
|
}
|
||||||
|
|
||||||
// startChallengeServer starts an HTTP server that responds to ACME HTTP-01 challenges.
|
// startChallengeServer starts an HTTP server that responds to ACME HTTP-01 challenges.
|
||||||
// It listens on the configured HTTP port and serves challenge tokens at
|
// It listens on the configured HTTP port and serves challenge tokens at
|
||||||
// /.well-known/acme-challenge/{token}.
|
// /.well-known/acme-challenge/{token}.
|
||||||
@@ -619,3 +756,8 @@ func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.Revok
|
|||||||
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||||
return nil, fmt.Errorf("ACME issuers do not support OCSP response signing")
|
return nil, fmt.Errorf("ACME issuers do not support OCSP response signing")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCACertPEM is not supported by ACME issuers (the CA chain is returned per-issuance).
|
||||||
|
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||||
|
return "", fmt.Errorf("ACME issuers do not provide a static CA certificate; chain is returned per-issuance")
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,6 +82,24 @@ func (s *ScriptDNSSolver) CleanUp(ctx context.Context, domain, token, keyAuth st
|
|||||||
return s.runScript(ctx, s.CleanUpScript, domain, fqdn, token, keyAuth)
|
return s.runScript(ctx, s.CleanUpScript, domain, fqdn, token, keyAuth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PresentPersist creates a persistent DNS TXT record at _validation-persist.<domain>.
|
||||||
|
// Used by dns-persist-01 (draft-ietf-acme-dns-persist). Unlike Present (which targets
|
||||||
|
// _acme-challenge), this targets _validation-persist and the record is intended to be permanent.
|
||||||
|
func (s *ScriptDNSSolver) PresentPersist(ctx context.Context, domain, token, recordValue string) error {
|
||||||
|
if s.PresentScript == "" {
|
||||||
|
return fmt.Errorf("DNS present script not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
fqdn := "_validation-persist." + domain
|
||||||
|
|
||||||
|
s.Logger.Info("creating persistent DNS TXT record via script",
|
||||||
|
"domain", domain,
|
||||||
|
"fqdn", fqdn,
|
||||||
|
"script", s.PresentScript)
|
||||||
|
|
||||||
|
return s.runScript(ctx, s.PresentScript, domain, fqdn, token, recordValue)
|
||||||
|
}
|
||||||
|
|
||||||
// runScript executes a DNS hook script with the appropriate environment variables.
|
// runScript executes a DNS hook script with the appropriate environment variables.
|
||||||
func (s *ScriptDNSSolver) runScript(ctx context.Context, script, domain, fqdn, token, keyAuth string) error {
|
func (s *ScriptDNSSolver) runScript(ctx context.Context, script, domain, fqdn, token, keyAuth string) error {
|
||||||
timeout := s.Timeout
|
timeout := s.Timeout
|
||||||
|
|||||||
@@ -110,3 +110,86 @@ echo "cleaned $CERTCTL_DNS_FQDN" > ` + outputFile + `
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScriptDNSSolver_PresentPersist(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("PresentPersist_Success", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
outputFile := filepath.Join(tmpDir, "persist-record.txt")
|
||||||
|
|
||||||
|
scriptPath := filepath.Join(tmpDir, "present.sh")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "DOMAIN=$CERTCTL_DNS_DOMAIN FQDN=$CERTCTL_DNS_FQDN VALUE=$CERTCTL_DNS_VALUE TOKEN=$CERTCTL_DNS_TOKEN" > ` + outputFile + `
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create script: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
solver := acmeissuer.NewScriptDNSSolver(scriptPath, "", logger)
|
||||||
|
err := solver.PresentPersist(ctx, "example.com", "test-token", "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PresentPersist failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := os.ReadFile(outputFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read output file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify _validation-persist prefix (not _acme-challenge)
|
||||||
|
expected := "DOMAIN=example.com FQDN=_validation-persist.example.com VALUE=letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/123 TOKEN=test-token\n"
|
||||||
|
if string(output) != expected {
|
||||||
|
t.Errorf("Script output mismatch:\ngot: %q\nwant: %q", string(output), expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PresentPersist_NoScript", func(t *testing.T) {
|
||||||
|
solver := acmeissuer.NewScriptDNSSolver("", "", logger)
|
||||||
|
err := solver.PresentPersist(ctx, "example.com", "token", "letsencrypt.org; accounturi=https://example.com/acct/1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error when no script is configured")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PresentPersist_ScriptFailure", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
scriptPath := filepath.Join(tmpDir, "fail.sh")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "error: DNS API failure" >&2
|
||||||
|
exit 1
|
||||||
|
`
|
||||||
|
os.WriteFile(scriptPath, []byte(script), 0755)
|
||||||
|
|
||||||
|
solver := acmeissuer.NewScriptDNSSolver(scriptPath, "", logger)
|
||||||
|
err := solver.PresentPersist(ctx, "example.com", "token", "letsencrypt.org; accounturi=https://example.com/acct/1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error from failing script")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PresentPersist_WildcardDomain", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
outputFile := filepath.Join(tmpDir, "persist-wildcard.txt")
|
||||||
|
|
||||||
|
scriptPath := filepath.Join(tmpDir, "present.sh")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "FQDN=$CERTCTL_DNS_FQDN" > ` + outputFile + `
|
||||||
|
`
|
||||||
|
os.WriteFile(scriptPath, []byte(script), 0755)
|
||||||
|
|
||||||
|
solver := acmeissuer.NewScriptDNSSolver(scriptPath, "", logger)
|
||||||
|
// For *.example.com, the persist record should be at _validation-persist.example.com
|
||||||
|
err := solver.PresentPersist(ctx, "example.com", "token", "letsencrypt.org; accounturi=https://example.com/acct/1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PresentPersist failed for wildcard base domain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := os.ReadFile(outputFile)
|
||||||
|
expected := "FQDN=_validation-persist.example.com\n"
|
||||||
|
if string(output) != expected {
|
||||||
|
t.Errorf("FQDN mismatch: got %q, want %q", string(output), expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ type Connector interface {
|
|||||||
// SignOCSPResponse signs an OCSP response for the given certificate serial.
|
// SignOCSPResponse signs an OCSP response for the given certificate serial.
|
||||||
// Returns nil if the issuer does not support OCSP (e.g., ACME).
|
// Returns nil if the issuer does not support OCSP (e.g., ACME).
|
||||||
SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
|
SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
|
||||||
|
|
||||||
|
// GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer.
|
||||||
|
// Used by the EST /cacerts endpoint. Returns empty string if not available.
|
||||||
|
GetCACertPEM(ctx context.Context) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssuanceRequest contains the parameters for issuing a new certificate.
|
// IssuanceRequest contains the parameters for issuing a new certificate.
|
||||||
|
|||||||
@@ -664,3 +664,12 @@ func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignReq
|
|||||||
|
|
||||||
return respBytes, nil
|
return respBytes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCACertPEM returns the PEM-encoded CA certificate for this issuer.
|
||||||
|
// Used by the EST /cacerts endpoint to distribute the CA trust chain.
|
||||||
|
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||||
|
if err := c.ensureCA(ctx); err != nil {
|
||||||
|
return "", fmt.Errorf("CA initialization failed: %w", err)
|
||||||
|
}
|
||||||
|
return c.caCertPEM, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -358,6 +358,11 @@ func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignReq
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCACertPEM is not supported by the custom CA connector (no CA cert access).
|
||||||
|
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||||
|
return "", fmt.Errorf("custom CA connector does not provide CA certificate access")
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helper Methods ---
|
// --- Helper Methods ---
|
||||||
|
|
||||||
// writeTempFile writes data to a temporary file and returns its path.
|
// writeTempFile writes data to a temporary file and returns its path.
|
||||||
|
|||||||
@@ -467,5 +467,10 @@ func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignReq
|
|||||||
return nil, fmt.Errorf("step-ca provides its own OCSP responder; use step-ca's /ocsp directly")
|
return nil, fmt.Errorf("step-ca provides its own OCSP responder; use step-ca's /ocsp directly")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCACertPEM is not directly supported; step-ca serves its own /root endpoint.
|
||||||
|
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||||
|
return "", fmt.Errorf("step-ca serves its own CA certificate at /root; use step-ca's endpoint directly")
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure Connector implements the issuer.Connector interface.
|
// Ensure Connector implements the issuer.Connector interface.
|
||||||
var _ issuer.Connector = (*Connector)(nil)
|
var _ issuer.Connector = (*Connector)(nil)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func TestDiscoveredCertificate_IsExpired(t *testing.T) {
|
|||||||
{"expired certificate", &pastTime, true},
|
{"expired certificate", &pastTime, true},
|
||||||
{"valid certificate", &futureTime, false},
|
{"valid certificate", &futureTime, false},
|
||||||
{"nil NotAfter", nil, false},
|
{"nil NotAfter", nil, false},
|
||||||
{"expires at current time (edge case)", &now, false}, // Before() = false when at same time
|
{"expires at current time (edge case)", func() *time.Time { t := now.Add(1 * time.Second); return &t }(), false}, // 1s in future — Before() returns false
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
// ESTEnrollResult holds the result of an EST (RFC 7030) enrollment operation.
|
||||||
|
type ESTEnrollResult struct {
|
||||||
|
CertPEM string `json:"cert_pem"` // PEM-encoded signed certificate
|
||||||
|
ChainPEM string `json:"chain_pem"` // PEM-encoded CA chain
|
||||||
|
}
|
||||||
@@ -2,9 +2,17 @@ package integration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -892,3 +900,269 @@ func TestM20EnhancedQueryAPI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateE2ECSRPEM creates a valid ECDSA P-256 CSR PEM for integration testing.
|
||||||
|
func generateE2ECSRPEM(t *testing.T, cn string, sans []string) string {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
template := &x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
DNSNames: sans,
|
||||||
|
}
|
||||||
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create CSR: %v", err)
|
||||||
|
}
|
||||||
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateE2ECSRBase64DER creates a valid base64-encoded DER CSR for EST wire format testing.
|
||||||
|
func generateE2ECSRBase64DER(t *testing.T, cn string, sans []string) string {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
template := &x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
DNSNames: sans,
|
||||||
|
}
|
||||||
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create CSR: %v", err)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(csrDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPrometheusMetrics exercises the Prometheus metrics endpoint (M22).
|
||||||
|
func TestPrometheusMetrics(t *testing.T) {
|
||||||
|
server, _, _, _ := setupTestServer(t)
|
||||||
|
|
||||||
|
t.Run("GetPrometheusMetrics_Success", func(t *testing.T) {
|
||||||
|
resp, err := http.Get(server.URL + "/api/v1/metrics/prometheus")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Content-Type contains text/plain
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if !strings.Contains(contentType, "text/plain") {
|
||||||
|
t.Errorf("expected Content-Type containing 'text/plain', got %s", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and verify Prometheus format
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
bodyStr := string(body)
|
||||||
|
|
||||||
|
// Should contain HELP and TYPE lines for metrics
|
||||||
|
if !strings.Contains(bodyStr, "# HELP") {
|
||||||
|
t.Error("expected HELP line in Prometheus response")
|
||||||
|
}
|
||||||
|
if !strings.Contains(bodyStr, "# TYPE") {
|
||||||
|
t.Error("expected TYPE line in Prometheus response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should contain metric lines (gauge, counter, uptime)
|
||||||
|
if !strings.Contains(bodyStr, "certctl_") {
|
||||||
|
t.Error("expected certctl_ prefixed metrics in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Prometheus metrics endpoint working, body size: %d bytes", len(bodyStr))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetPrometheusMetrics_MethodNotAllowed", func(t *testing.T) {
|
||||||
|
resp, err := http.Post(server.URL+"/api/v1/metrics/prometheus", "application/json", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestESTEndpoints exercises the EST (RFC 7030) enrollment endpoints end-to-end (M23).
|
||||||
|
func TestESTEndpoints(t *testing.T) {
|
||||||
|
server, _, _, _ := setupTestServer(t)
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// GET /cacerts — CA certificate chain
|
||||||
|
// ===========================
|
||||||
|
t.Run("GetCACerts_Success", func(t *testing.T) {
|
||||||
|
resp, err := http.Get(server.URL + "/.well-known/est/cacerts")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
ct := resp.Header.Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "application/pkcs7-mime") {
|
||||||
|
t.Errorf("expected application/pkcs7-mime content type, got %s", ct)
|
||||||
|
}
|
||||||
|
cte := resp.Header.Get("Content-Transfer-Encoding")
|
||||||
|
if cte != "base64" {
|
||||||
|
t.Errorf("expected base64 content-transfer-encoding, got %s", cte)
|
||||||
|
}
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
if len(bodyBytes) == 0 {
|
||||||
|
t.Error("expected non-empty PKCS#7 response body")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetCACerts_MethodNotAllowed", func(t *testing.T) {
|
||||||
|
resp, err := http.Post(server.URL+"/.well-known/est/cacerts", "application/json", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// POST /simpleenroll — certificate enrollment
|
||||||
|
// ===========================
|
||||||
|
t.Run("SimpleEnroll_PEM_Success", func(t *testing.T) {
|
||||||
|
csrPEM := generateE2ECSRPEM(t, "est-test.example.com", []string{"est-test.example.com"})
|
||||||
|
resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader(csrPEM))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
ct := resp.Header.Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "application/pkcs7-mime") {
|
||||||
|
t.Errorf("expected application/pkcs7-mime, got %s", ct)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SimpleEnroll_Base64DER_Success", func(t *testing.T) {
|
||||||
|
csrB64 := generateE2ECSRBase64DER(t, "est-der.example.com", []string{"est-der.example.com"})
|
||||||
|
resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader(csrB64))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SimpleEnroll_EmptyBody", func(t *testing.T) {
|
||||||
|
resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader(""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for empty body, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SimpleEnroll_InvalidCSR", func(t *testing.T) {
|
||||||
|
resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader("not-a-valid-csr"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for invalid CSR, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SimpleEnroll_MissingCN", func(t *testing.T) {
|
||||||
|
csrPEM := generateE2ECSRPEM(t, "", []string{"no-cn.example.com"})
|
||||||
|
resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader(csrPEM))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
// Should fail because EST requires a Common Name
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
t.Error("expected error for CSR without Common Name")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SimpleEnroll_MethodNotAllowed", func(t *testing.T) {
|
||||||
|
resp, err := http.Get(server.URL + "/.well-known/est/simpleenroll")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// POST /simplereenroll — certificate re-enrollment
|
||||||
|
// ===========================
|
||||||
|
t.Run("SimpleReEnroll_Success", func(t *testing.T) {
|
||||||
|
csrPEM := generateE2ECSRPEM(t, "renew-est.example.com", []string{"renew-est.example.com"})
|
||||||
|
resp, err := http.Post(server.URL+"/.well-known/est/simplereenroll", "application/pkcs10", strings.NewReader(csrPEM))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SimpleReEnroll_MethodNotAllowed", func(t *testing.T) {
|
||||||
|
resp, err := http.Get(server.URL + "/.well-known/est/simplereenroll")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// GET /csrattrs — CSR attributes
|
||||||
|
// ===========================
|
||||||
|
t.Run("GetCSRAttrs_NoContent", func(t *testing.T) {
|
||||||
|
resp, err := http.Get(server.URL + "/.well-known/est/csrattrs")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
// Default implementation returns nil attrs → 204 No Content
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("expected 204, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetCSRAttrs_MethodNotAllowed", func(t *testing.T) {
|
||||||
|
resp, err := http.Post(server.URL+"/.well-known/est/csrattrs", "application/json", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ func TestCertificateLifecycle(t *testing.T) {
|
|||||||
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
|
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
|
||||||
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
|
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
|
||||||
|
|
||||||
|
// EST handler — uses real Local CA issuer via ESTService
|
||||||
|
estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger)
|
||||||
|
estHandler := handler.NewESTHandler(estService)
|
||||||
|
|
||||||
// Create router and register handlers
|
// Create router and register handlers
|
||||||
r := router.New()
|
r := router.New()
|
||||||
r.RegisterHandlers(
|
r.RegisterHandlers(
|
||||||
@@ -103,6 +107,7 @@ func TestCertificateLifecycle(t *testing.T) {
|
|||||||
discoveryHandler,
|
discoveryHandler,
|
||||||
networkScanHandler,
|
networkScanHandler,
|
||||||
)
|
)
|
||||||
|
r.RegisterESTHandlers(estHandler)
|
||||||
|
|
||||||
// Create test server
|
// Create test server
|
||||||
server := httptest.NewServer(r)
|
server := httptest.NewServer(r)
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
|||||||
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
|
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
|
||||||
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
|
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
|
||||||
|
|
||||||
|
// EST handler — uses real Local CA issuer via ESTService
|
||||||
|
estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger)
|
||||||
|
estHandler := handler.NewESTHandler(estService)
|
||||||
|
|
||||||
r := router.New()
|
r := router.New()
|
||||||
r.RegisterHandlers(
|
r.RegisterHandlers(
|
||||||
certificateHandler,
|
certificateHandler,
|
||||||
@@ -95,6 +99,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
|||||||
discoveryHandler,
|
discoveryHandler,
|
||||||
networkScanHandler,
|
networkScanHandler,
|
||||||
)
|
)
|
||||||
|
r.RegisterESTHandlers(estHandler)
|
||||||
|
|
||||||
server := httptest.NewServer(r)
|
server := httptest.NewServer(r)
|
||||||
t.Cleanup(func() { server.Close() })
|
t.Cleanup(func() { server.Close() })
|
||||||
@@ -799,4 +804,3 @@ func TestRevocationEndpoints(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// mockNetworkScanService is defined in lifecycle_test.go (same package)
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ESTService implements the EST (RFC 7030) enrollment protocol.
|
||||||
|
// It delegates certificate operations to an existing IssuerConnector and records
|
||||||
|
// enrollment events in the audit trail.
|
||||||
|
type ESTService struct {
|
||||||
|
issuer IssuerConnector
|
||||||
|
issuerID string
|
||||||
|
auditService *AuditService
|
||||||
|
logger *slog.Logger
|
||||||
|
profileID string // optional: constrain enrollments to a specific profile
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewESTService creates a new ESTService for the given issuer connector.
|
||||||
|
func NewESTService(issuerID string, issuer IssuerConnector, auditService *AuditService, logger *slog.Logger) *ESTService {
|
||||||
|
return &ESTService{
|
||||||
|
issuer: issuer,
|
||||||
|
issuerID: issuerID,
|
||||||
|
auditService: auditService,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProfileID constrains EST enrollments to a specific certificate profile.
|
||||||
|
func (s *ESTService) SetProfileID(profileID string) {
|
||||||
|
s.profileID = profileID
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCACerts returns the PEM-encoded CA certificate chain for this EST server.
|
||||||
|
// RFC 7030 Section 4.1: /cacerts distributes the current CA certificates.
|
||||||
|
func (s *ESTService) GetCACerts(ctx context.Context) (string, error) {
|
||||||
|
caPEM, err := s.issuer.GetCACertPEM(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get CA certificates from issuer %s: %w", s.issuerID, err)
|
||||||
|
}
|
||||||
|
if caPEM == "" {
|
||||||
|
return "", fmt.Errorf("issuer %s does not provide CA certificates for EST", s.issuerID)
|
||||||
|
}
|
||||||
|
return caPEM, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimpleEnroll processes an initial enrollment request.
|
||||||
|
// RFC 7030 Section 4.2: /simpleenroll accepts a PKCS#10 CSR and returns a signed cert.
|
||||||
|
func (s *ESTService) SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
||||||
|
return s.processEnrollment(ctx, csrPEM, "est_simple_enroll")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimpleReEnroll processes a re-enrollment request.
|
||||||
|
// RFC 7030 Section 4.2.2: /simplereenroll is functionally identical to /simpleenroll
|
||||||
|
// but is used when renewing an existing certificate.
|
||||||
|
func (s *ESTService) SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
||||||
|
return s.processEnrollment(ctx, csrPEM, "est_simple_reenroll")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCSRAttrs returns the CSR attributes the server wants clients to include.
|
||||||
|
// RFC 7030 Section 4.5: /csrattrs tells clients what to put in their CSR.
|
||||||
|
// Returns nil if no specific attributes are required.
|
||||||
|
func (s *ESTService) GetCSRAttrs(ctx context.Context) ([]byte, error) {
|
||||||
|
// For now, we don't require specific CSR attributes.
|
||||||
|
// In the future, this could return key type constraints from the profile.
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processEnrollment handles the common enrollment logic for both simpleenroll and simplereenroll.
|
||||||
|
func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, auditAction string) (*domain.ESTEnrollResult, error) {
|
||||||
|
// Parse the CSR to extract CN and SANs
|
||||||
|
block, _ := pem.Decode([]byte(csrPEM))
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("invalid CSR PEM")
|
||||||
|
}
|
||||||
|
|
||||||
|
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse CSR: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := csr.CheckSignature(); err != nil {
|
||||||
|
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
commonName := csr.Subject.CommonName
|
||||||
|
if commonName == "" {
|
||||||
|
return nil, fmt.Errorf("CSR must include a Common Name")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect SANs
|
||||||
|
var sans []string
|
||||||
|
for _, dns := range csr.DNSNames {
|
||||||
|
sans = append(sans, dns)
|
||||||
|
}
|
||||||
|
for _, ip := range csr.IPAddresses {
|
||||||
|
sans = append(sans, ip.String())
|
||||||
|
}
|
||||||
|
for _, email := range csr.EmailAddresses {
|
||||||
|
sans = append(sans, email)
|
||||||
|
}
|
||||||
|
for _, uri := range csr.URIs {
|
||||||
|
sans = append(sans, uri.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("EST enrollment request",
|
||||||
|
"action", auditAction,
|
||||||
|
"common_name", commonName,
|
||||||
|
"sans", strings.Join(sans, ","),
|
||||||
|
"issuer", s.issuerID)
|
||||||
|
|
||||||
|
// Issue the certificate via the configured issuer connector
|
||||||
|
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("EST enrollment failed",
|
||||||
|
"action", auditAction,
|
||||||
|
"common_name", commonName,
|
||||||
|
"error", err)
|
||||||
|
return nil, fmt.Errorf("certificate issuance failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit the enrollment
|
||||||
|
if s.auditService != nil {
|
||||||
|
details := map[string]interface{}{
|
||||||
|
"common_name": commonName,
|
||||||
|
"sans": sans,
|
||||||
|
"issuer_id": s.issuerID,
|
||||||
|
"serial": result.Serial,
|
||||||
|
"protocol": "EST",
|
||||||
|
}
|
||||||
|
if s.profileID != "" {
|
||||||
|
details["profile_id"] = s.profileID
|
||||||
|
}
|
||||||
|
_ = s.auditService.RecordEvent(ctx, "est-client", "system", auditAction, "certificate", result.Serial, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("EST enrollment successful",
|
||||||
|
"action", auditAction,
|
||||||
|
"common_name", commonName,
|
||||||
|
"serial", result.Serial,
|
||||||
|
"not_after", result.NotAfter)
|
||||||
|
|
||||||
|
return &domain.ESTEnrollResult{
|
||||||
|
CertPEM: result.CertPEM,
|
||||||
|
ChainPEM: result.ChainPEM,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateCSRPEM creates a valid ECDSA P-256 CSR for testing.
|
||||||
|
func generateCSRPEM(t *testing.T, cn string, sans []string) string {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
template := &x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
DNSNames: sans,
|
||||||
|
}
|
||||||
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create CSR: %v", err)
|
||||||
|
}
|
||||||
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTService_GetCACerts_Success(t *testing.T) {
|
||||||
|
mockIssuer := &mockIssuerConnector{}
|
||||||
|
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||||
|
|
||||||
|
caPEM, err := svc.GetCACerts(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if caPEM == "" {
|
||||||
|
t.Error("expected non-empty CA PEM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTService_GetCACerts_IssuerError(t *testing.T) {
|
||||||
|
mockIssuer := &mockIssuerConnector{Err: errors.New("CA unavailable")}
|
||||||
|
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||||
|
|
||||||
|
_, err := svc.GetCACerts(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "CA unavailable") {
|
||||||
|
t.Errorf("expected error to contain 'CA unavailable', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTService_SimpleEnroll_Success(t *testing.T) {
|
||||||
|
mockIssuer := &mockIssuerConnector{}
|
||||||
|
auditRepo := newMockAuditRepository()
|
||||||
|
auditSvc := NewAuditService(auditRepo)
|
||||||
|
svc := NewESTService("iss-local", mockIssuer, auditSvc, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||||
|
|
||||||
|
csrPEM := generateCSRPEM(t, "test.example.com", []string{"test.example.com"})
|
||||||
|
|
||||||
|
result, err := svc.SimpleEnroll(context.Background(), csrPEM)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected non-nil result")
|
||||||
|
}
|
||||||
|
if result.CertPEM == "" {
|
||||||
|
t.Error("expected non-empty CertPEM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify audit event was recorded
|
||||||
|
if len(auditRepo.Events) == 0 {
|
||||||
|
t.Error("expected audit event to be recorded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTService_SimpleEnroll_InvalidCSR(t *testing.T) {
|
||||||
|
mockIssuer := &mockIssuerConnector{}
|
||||||
|
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||||
|
|
||||||
|
_, err := svc.SimpleEnroll(context.Background(), "not-valid-pem")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid CSR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTService_SimpleEnroll_MissingCN(t *testing.T) {
|
||||||
|
mockIssuer := &mockIssuerConnector{}
|
||||||
|
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||||
|
|
||||||
|
csrPEM := generateCSRPEM(t, "", []string{"test.example.com"})
|
||||||
|
|
||||||
|
_, err := svc.SimpleEnroll(context.Background(), csrPEM)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing CN")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Common Name") {
|
||||||
|
t.Errorf("expected 'Common Name' in error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTService_SimpleEnroll_IssuerError(t *testing.T) {
|
||||||
|
mockIssuer := &mockIssuerConnector{Err: errors.New("issuance failed")}
|
||||||
|
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||||
|
|
||||||
|
csrPEM := generateCSRPEM(t, "test.example.com", nil)
|
||||||
|
|
||||||
|
_, err := svc.SimpleEnroll(context.Background(), csrPEM)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "issuance failed") {
|
||||||
|
t.Errorf("expected 'issuance failed', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTService_SimpleReEnroll_Success(t *testing.T) {
|
||||||
|
mockIssuer := &mockIssuerConnector{}
|
||||||
|
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||||
|
|
||||||
|
csrPEM := generateCSRPEM(t, "renew.example.com", []string{"renew.example.com"})
|
||||||
|
|
||||||
|
result, err := svc.SimpleReEnroll(context.Background(), csrPEM)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected non-nil result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTService_GetCSRAttrs_Empty(t *testing.T) {
|
||||||
|
mockIssuer := &mockIssuerConnector{}
|
||||||
|
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||||
|
|
||||||
|
attrs, err := svc.GetCSRAttrs(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if attrs != nil {
|
||||||
|
t.Errorf("expected nil attrs, got %v", attrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTService_SimpleEnroll_WithProfile(t *testing.T) {
|
||||||
|
mockIssuer := &mockIssuerConnector{}
|
||||||
|
auditRepo := newMockAuditRepository()
|
||||||
|
auditSvc := NewAuditService(auditRepo)
|
||||||
|
svc := NewESTService("iss-local", mockIssuer, auditSvc, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||||
|
svc.SetProfileID("profile-wifi-client")
|
||||||
|
|
||||||
|
csrPEM := generateCSRPEM(t, "device.example.com", nil)
|
||||||
|
|
||||||
|
result, err := svc.SimpleEnroll(context.Background(), csrPEM)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected non-nil result")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify audit event includes profile_id
|
||||||
|
if len(auditRepo.Events) == 0 {
|
||||||
|
t.Fatal("expected audit event")
|
||||||
|
}
|
||||||
|
lastEvent := auditRepo.Events[len(auditRepo.Events)-1]
|
||||||
|
if lastEvent.Details == nil {
|
||||||
|
t.Fatal("expected audit details")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,3 +95,8 @@ func (a *IssuerConnectorAdapter) SignOCSPResponse(ctx context.Context, req OCSPS
|
|||||||
NextUpdate: req.NextUpdate,
|
NextUpdate: req.NextUpdate,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCACertPEM delegates to the underlying connector.
|
||||||
|
func (a *IssuerConnectorAdapter) GetCACertPEM(ctx context.Context) (string, error) {
|
||||||
|
return a.connector.GetCACertPEM(ctx)
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ func (m *mockConnectorLayerIssuer) SignOCSPResponse(ctx context.Context, req iss
|
|||||||
return []byte("mock-ocsp-response"), nil
|
return []byte("mock-ocsp-response"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockConnectorLayerIssuer) GetCACertPEM(ctx context.Context) (string, error) {
|
||||||
|
return "-----BEGIN CERTIFICATE-----\nmock-ca-cert\n-----END CERTIFICATE-----", nil
|
||||||
|
}
|
||||||
|
|
||||||
// Tests for IssueCertificate
|
// Tests for IssueCertificate
|
||||||
|
|
||||||
func TestIssuerConnectorAdapter_IssueCertificate_Success(t *testing.T) {
|
func TestIssuerConnectorAdapter_IssueCertificate_Success(t *testing.T) {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ type IssuerConnector interface {
|
|||||||
GenerateCRL(ctx context.Context, revokedCerts []CRLEntry) ([]byte, error)
|
GenerateCRL(ctx context.Context, revokedCerts []CRLEntry) ([]byte, error)
|
||||||
// SignOCSPResponse signs an OCSP response for the given certificate serial.
|
// SignOCSPResponse signs an OCSP response for the given certificate serial.
|
||||||
SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
|
SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
|
||||||
|
// GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer.
|
||||||
|
GetCACertPEM(ctx context.Context) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssuanceResult holds the result of a certificate issuance or renewal operation.
|
// IssuanceResult holds the result of a certificate issuance or renewal operation.
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ func TestGetJobStats_Empty(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetJobStats_WithData(t *testing.T) {
|
func TestGetJobStats_WithData(t *testing.T) {
|
||||||
svc, _, jobRepo, _ := newTestStatsService()
|
svc, _, jobRepo, _ := newTestStatsService()
|
||||||
completedAt := time.Now().Add(-1 * time.Hour)
|
completedAt := time.Now()
|
||||||
jobRepo.AddJob(&domain.Job{ID: "j-1", Status: domain.JobStatusCompleted, CompletedAt: &completedAt})
|
jobRepo.AddJob(&domain.Job{ID: "j-1", Status: domain.JobStatusCompleted, CompletedAt: &completedAt})
|
||||||
jobRepo.AddJob(&domain.Job{ID: "j-2", Status: domain.JobStatusFailed, CompletedAt: &completedAt})
|
jobRepo.AddJob(&domain.Job{ID: "j-2", Status: domain.JobStatusFailed, CompletedAt: &completedAt})
|
||||||
|
|
||||||
|
|||||||
@@ -634,6 +634,13 @@ func (m *mockIssuerConnector) SignOCSPResponse(ctx context.Context, req OCSPSign
|
|||||||
return []byte("mock-ocsp-response"), nil
|
return []byte("mock-ocsp-response"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||||
|
if m.Err != nil {
|
||||||
|
return "", m.Err
|
||||||
|
}
|
||||||
|
return "-----BEGIN CERTIFICATE-----\nmock-ca-cert\n-----END CERTIFICATE-----", nil
|
||||||
|
}
|
||||||
|
|
||||||
// Constructor functions for mocks
|
// Constructor functions for mocks
|
||||||
|
|
||||||
func newMockCertificateRepository() *mockCertRepo {
|
func newMockCertificateRepository() *mockCertRepo {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
throw new Error(errorMsg || `HTTP ${res.status}`);
|
throw new Error(errorMsg || `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
|
if (res.status === 204) return {} as T;
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 755 KiB |
@@ -7,10 +7,10 @@ export default function AuthGate({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
|
<div className="min-h-screen bg-page flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-2xl font-bold text-blue-400 mb-2">certctl</h1>
|
<h1 className="text-2xl font-bold text-brand-500 mb-2">certctl</h1>
|
||||||
<p className="text-sm text-slate-400">Connecting...</p>
|
<p className="text-sm text-ink-muted">Connecting...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ interface DataTableProps<T> {
|
|||||||
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange }: DataTableProps<T>) {
|
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange }: DataTableProps<T>) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16 text-slate-400">
|
<div className="flex items-center justify-center py-16 text-ink-muted">
|
||||||
<svg className="animate-spin h-5 w-5 mr-3" viewBox="0 0 24 24">
|
<svg className="animate-spin h-5 w-5 mr-3" viewBox="0 0 24 24">
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
@@ -32,7 +32,7 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
|
|||||||
|
|
||||||
if (!data.length) {
|
if (!data.length) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16 text-slate-500">
|
<div className="flex items-center justify-center py-16 text-ink-faint">
|
||||||
{emptyMessage || 'No data found'}
|
{emptyMessage || 'No data found'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -62,19 +62,19 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b-2 border-slate-700">
|
<tr className="border-b-2 border-surface-border bg-surface-muted">
|
||||||
{selectable && (
|
{selectable && (
|
||||||
<th className="px-3 py-3 w-10">
|
<th className="px-3 py-3 w-10">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={allSelected || false}
|
checked={allSelected || false}
|
||||||
onChange={toggleAll}
|
onChange={toggleAll}
|
||||||
className="rounded border-slate-600 bg-slate-900 text-blue-600 focus:ring-blue-500 focus:ring-offset-0 cursor-pointer"
|
className="rounded border-surface-border bg-white text-brand-500 focus:ring-brand-500 focus:ring-offset-0 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
{columns.map(col => (
|
{columns.map(col => (
|
||||||
<th key={col.key} className={`px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider ${col.className || ''}`}>
|
<th key={col.key} className={`px-4 py-3 text-left text-xs font-semibold text-ink-muted uppercase tracking-wider ${col.className || ''}`}>
|
||||||
{col.label}
|
{col.label}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -88,7 +88,7 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
|
|||||||
<tr
|
<tr
|
||||||
key={rowKey}
|
key={rowKey}
|
||||||
onClick={() => onRowClick?.(item)}
|
onClick={() => onRowClick?.(item)}
|
||||||
className={`border-b border-slate-700/50 transition-colors hover:bg-blue-500/5 ${onRowClick ? 'cursor-pointer' : ''} ${isSelected ? 'bg-blue-500/10' : ''}`}
|
className={`border-b border-surface-border/50 transition-colors hover:bg-surface-muted ${onRowClick ? 'cursor-pointer' : ''} ${isSelected ? 'bg-brand-50' : ''}`}
|
||||||
>
|
>
|
||||||
{selectable && (
|
{selectable && (
|
||||||
<td className="px-3 py-3 w-10">
|
<td className="px-3 py-3 w-10">
|
||||||
@@ -97,12 +97,12 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
|
|||||||
checked={isSelected || false}
|
checked={isSelected || false}
|
||||||
onChange={(e) => { e.stopPropagation(); toggleOne(rowKey); }}
|
onChange={(e) => { e.stopPropagation(); toggleOne(rowKey); }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="rounded border-slate-600 bg-slate-900 text-blue-600 focus:ring-blue-500 focus:ring-offset-0 cursor-pointer"
|
className="rounded border-surface-border bg-white text-brand-500 focus:ring-brand-500 focus:ring-offset-0 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{columns.map(col => (
|
{columns.map(col => (
|
||||||
<td key={col.key} className={`px-4 py-3 ${col.className || ''}`}>
|
<td key={col.key} className={`px-4 py-3 text-ink ${col.className || ''}`}>
|
||||||
{col.render(item)}
|
{col.render(item)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-slate-900">
|
<div className="flex items-center justify-center min-h-screen bg-page">
|
||||||
<div className="text-center p-8">
|
<div className="text-center p-8">
|
||||||
<h1 className="text-xl font-semibold text-red-400 mb-2">Something went wrong</h1>
|
<h1 className="text-xl font-semibold text-red-700 mb-2">Something went wrong</h1>
|
||||||
<p className="text-sm text-slate-400 mb-4">
|
<p className="text-sm text-ink-muted mb-4">
|
||||||
{this.state.error?.message || 'An unexpected error occurred'}
|
{this.state.error?.message || 'An unexpected error occurred'}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -37,7 +37,7 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
this.setState({ hasError: false, error: null });
|
this.setState({ hasError: false, error: null });
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-500"
|
className="px-4 py-2 bg-brand-500 text-white rounded text-sm hover:bg-brand-600"
|
||||||
>
|
>
|
||||||
Reload Page
|
Reload Page
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ interface ErrorStateProps {
|
|||||||
|
|
||||||
export default function ErrorState({ error, onRetry }: ErrorStateProps) {
|
export default function ErrorState({ error, onRetry }: ErrorStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
|
<div className="flex flex-col items-center justify-center py-16 text-ink-muted">
|
||||||
<svg className="w-12 h-12 text-red-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="w-12 h-12 text-red-700 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-sm mb-2">Failed to load data</p>
|
<p className="text-sm mb-2 text-ink">Failed to load data</p>
|
||||||
<p className="text-xs text-slate-500 mb-4">{error.message}</p>
|
<p className="text-xs text-ink-faint mb-4">{error.message}</p>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<button onClick={onRetry} className="btn btn-primary text-xs">
|
<button onClick={onRetry} className="btn btn-primary text-xs">
|
||||||
Retry
|
Retry
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
import { useAuth } from './AuthProvider';
|
import { useAuth } from './AuthProvider';
|
||||||
|
import logo from '../assets/certctl-logo.png';
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ to: '/', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4' },
|
{ to: '/', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4' },
|
||||||
@@ -21,7 +22,7 @@ const nav = [
|
|||||||
|
|
||||||
function Icon({ d }: { d: string }) {
|
function Icon({ d }: { d: string }) {
|
||||||
return (
|
return (
|
||||||
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="w-[18px] h-[18px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d={d} />
|
<path strokeLinecap="round" strokeLinejoin="round" d={d} />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -32,23 +33,30 @@ export default function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="flex h-screen overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* Sidebar — deep teal from logo */}
|
||||||
<aside className="w-64 bg-slate-800 border-r border-slate-700 flex flex-col">
|
<aside className="w-60 bg-sidebar flex flex-col shadow-xl">
|
||||||
<div className="p-6 border-b border-slate-700">
|
{/* Logo — large and prominent */}
|
||||||
<h1 className="text-xl font-bold text-blue-400">certctl</h1>
|
<div className="px-4 pt-5 pb-4 flex flex-col items-center gap-2">
|
||||||
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">Certificate Control Plane</p>
|
<div className="bg-white rounded-xl p-2 shadow-lg">
|
||||||
|
<img src={logo} alt="certctl" className="h-16 w-16" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-lg font-bold text-white tracking-tight">certctl</h1>
|
||||||
|
<p className="text-[10px] text-brand-300 uppercase tracking-[0.2em]">Control Plane</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
|
||||||
|
<nav className="flex-1 py-2 px-3 space-y-0.5 overflow-y-auto">
|
||||||
{nav.map(item => (
|
{nav.map(item => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
end={item.to === '/'}
|
end={item.to === '/'}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors ${
|
`flex items-center gap-3 px-3 py-2 text-[13px] rounded transition-all duration-150 ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-white/15 text-white font-semibold shadow-sm'
|
||||||
: 'text-slate-400 hover:bg-slate-700 hover:text-slate-200'
|
: 'text-sidebar-text hover:text-white hover:bg-white/10'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -57,12 +65,13 @@ export default function Layout() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="p-4 border-t border-slate-700 flex items-center justify-between">
|
|
||||||
<span className="text-xs text-slate-500">certctl v1.0-dev</span>
|
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.3</span>
|
||||||
{authRequired && (
|
{authRequired && (
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
className="text-xs text-sidebar-text hover:text-white transition-colors"
|
||||||
title="Sign out"
|
title="Sign out"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
@@ -73,8 +82,8 @@ export default function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content — light background */}
|
||||||
<main className="flex-1 flex flex-col overflow-hidden">
|
<main className="flex-1 flex flex-col overflow-hidden bg-page">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ interface PageHeaderProps {
|
|||||||
|
|
||||||
export default function PageHeader({ title, subtitle, action }: PageHeaderProps) {
|
export default function PageHeader({ title, subtitle, action }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-700 bg-slate-800">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-surface-border bg-surface">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">{title}</h2>
|
<h2 className="text-lg font-semibold text-ink">{title}</h2>
|
||||||
{subtitle && <p className="text-sm text-slate-400 mt-0.5">{subtitle}</p>}
|
{subtitle && <p className="text-sm text-ink-muted mt-0.5">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const statusStyles: Record<string, string> = {
|
const statusStyles: Record<string, string> = {
|
||||||
|
// Certificate statuses
|
||||||
Active: 'badge-success',
|
Active: 'badge-success',
|
||||||
Expiring: 'badge-warning',
|
Expiring: 'badge-warning',
|
||||||
Expired: 'badge-danger',
|
Expired: 'badge-danger',
|
||||||
@@ -8,6 +9,8 @@ const statusStyles: Record<string, string> = {
|
|||||||
Revoked: 'badge-danger',
|
Revoked: 'badge-danger',
|
||||||
// Job statuses
|
// Job statuses
|
||||||
Pending: 'badge-info',
|
Pending: 'badge-info',
|
||||||
|
AwaitingCSR: 'badge-info',
|
||||||
|
AwaitingApproval: 'badge-info',
|
||||||
Running: 'badge-warning',
|
Running: 'badge-warning',
|
||||||
Completed: 'badge-success',
|
Completed: 'badge-success',
|
||||||
Failed: 'badge-danger',
|
Failed: 'badge-danger',
|
||||||
|
|||||||
@@ -1,30 +1,53 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
@apply bg-slate-900 text-slate-100 antialiased;
|
@apply bg-page text-ink antialiased;
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
/* Badges */
|
||||||
.badge {
|
.badge {
|
||||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold tracking-wide;
|
||||||
}
|
}
|
||||||
.badge-success { @apply bg-emerald-500/10 text-emerald-400 border border-emerald-500/20; }
|
.badge-success { @apply bg-emerald-100 text-emerald-700; }
|
||||||
.badge-warning { @apply bg-amber-500/10 text-amber-400 border border-amber-500/20; }
|
.badge-warning { @apply bg-amber-100 text-amber-700; }
|
||||||
.badge-danger { @apply bg-red-500/10 text-red-400 border border-red-500/20; }
|
.badge-danger { @apply bg-red-100 text-red-700; }
|
||||||
.badge-info { @apply bg-blue-500/10 text-blue-400 border border-blue-500/20; }
|
.badge-info { @apply bg-brand-100 text-brand-700; }
|
||||||
.badge-neutral { @apply bg-slate-500/10 text-slate-400 border border-slate-500/20; }
|
.badge-neutral { @apply bg-slate-100 text-slate-600; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
.card {
|
.card {
|
||||||
@apply bg-slate-800 border border-slate-700 rounded-lg;
|
@apply bg-surface border border-surface-border rounded-md shadow-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors;
|
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded text-sm font-semibold transition-all duration-150;
|
||||||
|
}
|
||||||
|
.btn-primary { @apply bg-brand-500 hover:bg-brand-600 text-white shadow-sm; }
|
||||||
|
.btn-danger { @apply bg-red-500 hover:bg-red-600 text-white shadow-sm; }
|
||||||
|
.btn-ghost { @apply text-ink-muted hover:text-ink hover:bg-surface-muted; }
|
||||||
|
.btn-outline { @apply border border-surface-border text-ink-muted hover:text-ink hover:bg-surface-muted; }
|
||||||
|
|
||||||
|
/* Form inputs */
|
||||||
|
.input {
|
||||||
|
@apply bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink placeholder:text-ink-faint focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 outline-none transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monospace data values */
|
||||||
|
.mono {
|
||||||
|
@apply font-mono text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat cards with colored top borders */
|
||||||
|
.stat-card {
|
||||||
|
@apply bg-surface border border-surface-border rounded-md shadow-sm p-5 border-t-4;
|
||||||
}
|
}
|
||||||
.btn-primary { @apply bg-blue-600 hover:bg-blue-500 text-white; }
|
|
||||||
.btn-ghost { @apply hover:bg-slate-700 text-slate-300; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { formatDateTime, timeAgo } from '../api/utils';
|
|||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between py-2 border-b border-slate-700/50">
|
<div className="flex justify-between py-2 border-b border-surface-border/50">
|
||||||
<span className="text-sm text-slate-400">{label}</span>
|
<span className="text-sm text-ink-muted">{label}</span>
|
||||||
<span className="text-sm text-slate-200">{value}</span>
|
<span className="text-sm text-ink">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ export default function AgentDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Agent" />
|
<PageHeader title="Agent" />
|
||||||
<div className="flex items-center justify-center flex-1 text-slate-400">Loading...</div>
|
<div className="flex items-center justify-center flex-1 text-ink-muted">Loading...</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,8 +75,8 @@ export default function AgentDetailPage() {
|
|||||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Agent Info */}
|
{/* Agent Info */}
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Agent Details</h3>
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Agent Details</h3>
|
||||||
<InfoRow label="Health" value={<StatusBadge status={health} />} />
|
<InfoRow label="Health" value={<StatusBadge status={health} />} />
|
||||||
<InfoRow label="Hostname" value={<span className="font-mono text-xs">{agent.hostname || '—'}</span>} />
|
<InfoRow label="Hostname" value={<span className="font-mono text-xs">{agent.hostname || '—'}</span>} />
|
||||||
<InfoRow label="IP Address" value={<span className="font-mono text-xs">{agent.ip_address || '—'}</span>} />
|
<InfoRow label="IP Address" value={<span className="font-mono text-xs">{agent.ip_address || '—'}</span>} />
|
||||||
@@ -85,7 +85,7 @@ export default function AgentDetailPage() {
|
|||||||
agent.last_heartbeat ? (
|
agent.last_heartbeat ? (
|
||||||
<span>
|
<span>
|
||||||
{timeAgo(agent.last_heartbeat)}
|
{timeAgo(agent.last_heartbeat)}
|
||||||
<span className="text-slate-500 ml-2 text-xs">{formatDateTime(agent.last_heartbeat)}</span>
|
<span className="text-ink-faint ml-2 text-xs">{formatDateTime(agent.last_heartbeat)}</span>
|
||||||
</span>
|
</span>
|
||||||
) : '—'
|
) : '—'
|
||||||
} />
|
} />
|
||||||
@@ -94,15 +94,15 @@ export default function AgentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* System Info */}
|
{/* System Info */}
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">System Information</h3>
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">System Information</h3>
|
||||||
<InfoRow label="Operating System" value={agent.os || '—'} />
|
<InfoRow label="Operating System" value={agent.os || '—'} />
|
||||||
<InfoRow label="Architecture" value={agent.architecture || '—'} />
|
<InfoRow label="Architecture" value={agent.architecture || '—'} />
|
||||||
<InfoRow label="IP Address" value={<span className="font-mono text-xs">{agent.ip_address || '—'}</span>} />
|
<InfoRow label="IP Address" value={<span className="font-mono text-xs">{agent.ip_address || '—'}</span>} />
|
||||||
<InfoRow label="Agent Version" value={agent.version || '—'} />
|
<InfoRow label="Agent Version" value={agent.version || '—'} />
|
||||||
{agent.capabilities?.length ? (
|
{agent.capabilities?.length ? (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<p className="text-xs text-slate-400 mb-2">Capabilities</p>
|
<p className="text-xs text-ink-muted mb-2">Capabilities</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{agent.capabilities.map((c) => (
|
{agent.capabilities.map((c) => (
|
||||||
<span key={c} className="badge badge-info">{c}</span>
|
<span key={c} className="badge badge-info">{c}</span>
|
||||||
@@ -112,7 +112,7 @@ export default function AgentDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{agent.tags && Object.keys(agent.tags).length > 0 ? (
|
{agent.tags && Object.keys(agent.tags).length > 0 ? (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<p className="text-xs text-slate-400 mb-2">Tags</p>
|
<p className="text-xs text-ink-muted mb-2">Tags</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{Object.entries(agent.tags).map(([k, v]) => (
|
{Object.entries(agent.tags).map(([k, v]) => (
|
||||||
<span key={k} className="badge badge-neutral">{k}: {v}</span>
|
<span key={k} className="badge badge-neutral">{k}: {v}</span>
|
||||||
@@ -124,20 +124,20 @@ export default function AgentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Jobs */}
|
{/* Recent Jobs */}
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Recent Jobs</h3>
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Recent Jobs</h3>
|
||||||
{!agentJobs.length ? (
|
{!agentJobs.length ? (
|
||||||
<p className="text-sm text-slate-500">No recent jobs</p>
|
<p className="text-sm text-ink-faint">No recent jobs</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{agentJobs.map(j => (
|
{agentJobs.map(j => (
|
||||||
<div key={j.id} className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-700/50 transition-colors">
|
<div key={j.id} className="flex items-center justify-between py-2 px-3 rounded hover:bg-surface-muted transition-colors">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-slate-200">{j.type}</div>
|
<div className="text-sm text-ink">{j.type}</div>
|
||||||
<div className="text-xs text-slate-500 font-mono">{j.id}</div>
|
<div className="text-xs text-ink-faint font-mono">{j.id}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-slate-400 font-mono">{j.certificate_id}</span>
|
<span className="text-xs text-ink-muted font-mono">{j.certificate_id}</span>
|
||||||
<StatusBadge status={j.status} />
|
<StatusBadge status={j.status} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,16 +147,16 @@ export default function AgentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Heartbeat Timeline */}
|
{/* Heartbeat Timeline */}
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Heartbeat Status</h3>
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Heartbeat Status</h3>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className={`w-3 h-3 rounded-full ${
|
<div className={`w-3 h-3 rounded-full ${
|
||||||
health === 'Online' ? 'bg-emerald-400 animate-pulse' :
|
health === 'Online' ? 'bg-emerald-500 animate-pulse' :
|
||||||
health === 'Stale' ? 'bg-amber-400' : 'bg-red-400'
|
health === 'Stale' ? 'bg-amber-500' : 'bg-red-500'
|
||||||
}`} />
|
}`} />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-200">{health}</p>
|
<p className="text-sm text-ink">{health}</p>
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-ink-muted">
|
||||||
{health === 'Online' && 'Agent is responding to heartbeat checks'}
|
{health === 'Online' && 'Agent is responding to heartbeat checks'}
|
||||||
{health === 'Stale' && 'Agent has not sent a heartbeat recently'}
|
{health === 'Stale' && 'Agent has not sent a heartbeat recently'}
|
||||||
{health === 'Offline' && 'Agent is not responding'}
|
{health === 'Offline' && 'Agent is not responding'}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Agent } from '../api/types';
|
|||||||
|
|
||||||
const OS_COLORS: Record<string, string> = {
|
const OS_COLORS: Record<string, string> = {
|
||||||
linux: '#f97316',
|
linux: '#f97316',
|
||||||
darwin: '#3b82f6',
|
darwin: '#2ea88f',
|
||||||
windows: '#8b5cf6',
|
windows: '#8b5cf6',
|
||||||
unknown: '#64748b',
|
unknown: '#64748b',
|
||||||
};
|
};
|
||||||
@@ -53,9 +53,9 @@ function groupAgents(agents: Agent[]): GroupedAgents[] {
|
|||||||
const CustomTooltip = ({ active, payload }: any) => {
|
const CustomTooltip = ({ active, payload }: any) => {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-xs shadow-lg">
|
<div className="bg-surface border border-surface-border rounded px-3 py-2 text-xs shadow-lg">
|
||||||
{payload.map((entry: any, i: number) => (
|
{payload.map((entry: any, i: number) => (
|
||||||
<p key={i} style={{ color: entry.payload?.fill || entry.color }}>
|
<p key={i} style={{ color: entry.payload?.fill || entry.color }} className="font-medium">
|
||||||
{entry.name}: {entry.value}
|
{entry.name}: {entry.value}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
@@ -113,25 +113,25 @@ export default function AgentFleetPage() {
|
|||||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<div className="card p-5 text-center">
|
<div className="bg-surface border border-surface-border border-t-4 border-t-brand-400 rounded p-5 text-center shadow-sm">
|
||||||
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Total Agents</p>
|
<p className="text-xs font-semibold text-ink-muted uppercase tracking-wider">Total Agents</p>
|
||||||
<p className="text-3xl font-bold mt-2 text-blue-400">{totalAgents}</p>
|
<p className="text-3xl font-bold mt-2 text-brand-500">{totalAgents}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="card p-5 text-center">
|
<div className="bg-surface border border-surface-border border-t-4 border-t-emerald-500 rounded p-5 text-center shadow-sm">
|
||||||
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Online</p>
|
<p className="text-xs font-semibold text-ink-muted uppercase tracking-wider">Online</p>
|
||||||
<p className="text-3xl font-bold mt-2 text-emerald-400">{onlineAgents}</p>
|
<p className="text-3xl font-bold mt-2 text-emerald-600">{onlineAgents}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="card p-5 text-center">
|
<div className="bg-surface border border-surface-border border-t-4 border-t-red-500 rounded p-5 text-center shadow-sm">
|
||||||
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Offline</p>
|
<p className="text-xs font-semibold text-ink-muted uppercase tracking-wider">Offline</p>
|
||||||
<p className="text-3xl font-bold mt-2 text-red-400">{offlineAgents}</p>
|
<p className="text-3xl font-bold mt-2 text-red-600">{offlineAgents}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts */}
|
{/* Charts */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* OS Distribution */}
|
{/* OS Distribution */}
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">OS Distribution</h3>
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">OS Distribution</h3>
|
||||||
<div className="h-48">
|
<div className="h-48">
|
||||||
{osPieData.length > 0 ? (
|
{osPieData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
@@ -145,14 +145,14 @@ export default function AgentFleetPage() {
|
|||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center text-sm text-slate-500">No data</div>
|
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No data</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Distribution */}
|
{/* Status Distribution */}
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Status Distribution</h3>
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Status Distribution</h3>
|
||||||
<div className="h-48">
|
<div className="h-48">
|
||||||
{statusPieData.length > 0 ? (
|
{statusPieData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
@@ -166,33 +166,33 @@ export default function AgentFleetPage() {
|
|||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center text-sm text-slate-500">No data</div>
|
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No data</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Version Breakdown */}
|
{/* Version Breakdown */}
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Agent Versions</h3>
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Agent Versions</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{Object.entries(versionCounts)
|
{Object.entries(versionCounts)
|
||||||
.sort(([, a], [, b]) => b - a)
|
.sort(([, a], [, b]) => b - a)
|
||||||
.map(([version, count]) => (
|
.map(([version, count]) => (
|
||||||
<div key={version} className="flex items-center justify-between">
|
<div key={version} className="flex items-center justify-between">
|
||||||
<span className="text-sm text-slate-300 font-mono">{version}</span>
|
<span className="text-sm text-ink font-mono">{version}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-24 bg-slate-700 rounded-full h-2">
|
<div className="w-24 bg-surface-border rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className="bg-blue-500 h-2 rounded-full"
|
className="bg-brand-400 h-2 rounded-full"
|
||||||
style={{ width: `${(count / totalAgents) * 100}%` }}
|
style={{ width: `${(count / totalAgents) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-slate-400 w-8 text-right">{count}</span>
|
<span className="text-xs text-ink-muted w-8 text-right">{count}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{Object.keys(versionCounts).length === 0 && (
|
{Object.keys(versionCounts).length === 0 && (
|
||||||
<p className="text-sm text-slate-500">No version data</p>
|
<p className="text-sm text-ink-faint">No version data</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,50 +200,50 @@ export default function AgentFleetPage() {
|
|||||||
|
|
||||||
{/* Environment Groups */}
|
{/* Environment Groups */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Fleet by Platform</h3>
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Fleet by Platform</h3>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-sm text-slate-500">Loading fleet data...</p>
|
<p className="text-sm text-ink-faint">Loading fleet data...</p>
|
||||||
) : groups.length === 0 ? (
|
) : groups.length === 0 ? (
|
||||||
<p className="text-sm text-slate-500">No agents registered</p>
|
<p className="text-sm text-ink-faint">No agents registered</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{groups.map(group => (
|
{groups.map(group => (
|
||||||
<div key={`${group.os}/${group.arch}`} className="card">
|
<div key={`${group.os}/${group.arch}`} className="bg-surface border border-surface-border rounded overflow-hidden shadow-sm">
|
||||||
<div className="px-5 py-4 border-b border-slate-700 flex items-center justify-between">
|
<div className="px-5 py-4 border-b border-surface-border flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full"
|
||||||
style={{ backgroundColor: OS_COLORS[group.os.toLowerCase()] || '#64748b' }}
|
style={{ backgroundColor: OS_COLORS[group.os.toLowerCase()] || '#64748b' }}
|
||||||
/>
|
/>
|
||||||
<h4 className="text-sm font-medium text-slate-200">
|
<h4 className="text-sm font-medium text-ink">
|
||||||
{group.os} / {group.arch}
|
{group.os} / {group.arch}
|
||||||
</h4>
|
</h4>
|
||||||
<span className="text-xs text-slate-500">
|
<span className="text-xs text-ink-faint">
|
||||||
{group.agents.length} agent{group.agents.length !== 1 ? 's' : ''}
|
{group.agents.length} agent{group.agents.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-xs">
|
<div className="flex items-center gap-3 text-xs">
|
||||||
<span className="text-emerald-400">{group.online} online</span>
|
<span className="text-emerald-600">{group.online} online</span>
|
||||||
{group.offline > 0 && <span className="text-red-400">{group.offline} offline</span>}
|
{group.offline > 0 && <span className="text-red-600">{group.offline} offline</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-slate-700/50">
|
<div className="divide-y divide-surface-border/50">
|
||||||
{group.agents.map(agent => (
|
{group.agents.map(agent => (
|
||||||
<div
|
<div
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
onClick={() => navigate(`/agents/${agent.id}`)}
|
onClick={() => navigate(`/agents/${agent.id}`)}
|
||||||
className="px-5 py-3 flex items-center justify-between hover:bg-slate-700/30 cursor-pointer transition-colors"
|
className="px-5 py-3 flex items-center justify-between hover:bg-surface-muted cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-2 h-2 rounded-full ${agent.status === 'Online' ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
<div className={`w-2 h-2 rounded-full ${agent.status === 'Online' ? 'bg-emerald-500' : 'bg-red-500'}`} />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-slate-200">{agent.name || agent.hostname}</div>
|
<div className="text-sm text-ink">{agent.name || agent.hostname}</div>
|
||||||
<div className="text-xs text-slate-500">{agent.ip_address || agent.id}</div>
|
<div className="text-xs text-ink-faint">{agent.ip_address || agent.id}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{agent.version && (
|
{agent.version && (
|
||||||
<span className="text-xs text-slate-500 font-mono">{agent.version}</span>
|
<span className="text-xs text-ink-muted font-mono">{agent.version}</span>
|
||||||
)}
|
)}
|
||||||
<StatusBadge status={agent.status} />
|
<StatusBadge status={agent.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ export default function AgentGroupsPage() {
|
|||||||
label: 'Group',
|
label: 'Group',
|
||||||
render: (g) => (
|
render: (g) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-slate-200">{g.name}</div>
|
<div className="font-medium text-ink">{g.name}</div>
|
||||||
<div className="text-xs text-slate-500 font-mono">{g.id}</div>
|
<div className="text-xs text-ink-faint font-mono">{g.id}</div>
|
||||||
{g.description && (
|
{g.description && (
|
||||||
<div className="text-xs text-slate-400 mt-0.5 max-w-xs truncate">{g.description}</div>
|
<div className="text-xs text-ink-muted mt-0.5 max-w-xs truncate">{g.description}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -51,7 +51,7 @@ export default function AgentGroupsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-slate-500 text-xs">Manual only</span>
|
<span className="text-ink-faint text-xs">Manual only</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -63,7 +63,7 @@ export default function AgentGroupsPage() {
|
|||||||
{
|
{
|
||||||
key: 'created',
|
key: 'created',
|
||||||
label: 'Created',
|
label: 'Created',
|
||||||
render: (g) => <span className="text-xs text-slate-400">{formatDateTime(g.created_at)}</span>,
|
render: (g) => <span className="text-xs text-ink-muted">{formatDateTime(g.created_at)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@@ -71,7 +71,7 @@ export default function AgentGroupsPage() {
|
|||||||
render: (g) => (
|
render: (g) => (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete group ${g.name}?`)) deleteMutation.mutate(g.id); }}
|
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete group ${g.name}?`)) deleteMutation.mutate(g.id); }}
|
||||||
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export default function AgentsPage() {
|
|||||||
label: 'Agent',
|
label: 'Agent',
|
||||||
render: (a) => (
|
render: (a) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-slate-200">{a.name}</div>
|
<div className="font-medium text-ink">{a.name}</div>
|
||||||
<div className="text-xs text-slate-500">{a.id}</div>
|
<div className="text-xs text-ink-faint">{a.id}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -41,14 +41,14 @@ export default function AgentsPage() {
|
|||||||
label: 'Health',
|
label: 'Health',
|
||||||
render: (a) => <StatusBadge status={a.status || heartbeatStatus(a.last_heartbeat)} />,
|
render: (a) => <StatusBadge status={a.status || heartbeatStatus(a.last_heartbeat)} />,
|
||||||
},
|
},
|
||||||
{ key: 'hostname', label: 'Hostname', render: (a) => <span className="text-slate-300 font-mono text-xs">{a.hostname || '—'}</span> },
|
{ key: 'hostname', label: 'Hostname', render: (a) => <span className="text-ink-muted font-mono text-xs">{a.hostname || '—'}</span> },
|
||||||
{ key: 'os', label: 'OS / Arch', render: (a) => <span className="text-slate-400 text-xs">{a.os && a.architecture ? `${a.os}/${a.architecture}` : a.os || '—'}</span> },
|
{ key: 'os', label: 'OS / Arch', render: (a) => <span className="text-ink-muted text-xs">{a.os && a.architecture ? `${a.os}/${a.architecture}` : a.os || '—'}</span> },
|
||||||
{ key: 'ip', label: 'IP Address', render: (a) => <span className="text-slate-400 font-mono text-xs">{a.ip_address || '—'}</span> },
|
{ key: 'ip', label: 'IP Address', render: (a) => <span className="text-ink-muted font-mono text-xs">{a.ip_address || '—'}</span> },
|
||||||
{ key: 'version', label: 'Version', render: (a) => <span className="text-slate-400 text-xs">{a.version || '—'}</span> },
|
{ key: 'version', label: 'Version', render: (a) => <span className="text-ink-muted text-xs">{a.version || '—'}</span> },
|
||||||
{
|
{
|
||||||
key: 'heartbeat',
|
key: 'heartbeat',
|
||||||
label: 'Last Heartbeat',
|
label: 'Last Heartbeat',
|
||||||
render: (a) => <span className="text-slate-400 text-xs">{timeAgo(a.last_heartbeat)}</span>,
|
render: (a) => <span className="text-ink-muted text-xs">{timeAgo(a.last_heartbeat)}</span>,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ import { formatDateTime } from '../api/utils';
|
|||||||
import type { AuditEvent } from '../api/types';
|
import type { AuditEvent } from '../api/types';
|
||||||
|
|
||||||
const actionColors: Record<string, string> = {
|
const actionColors: Record<string, string> = {
|
||||||
certificate_created: 'text-emerald-400',
|
certificate_created: 'text-emerald-600',
|
||||||
renewal_triggered: 'text-blue-400',
|
renewal_triggered: 'text-brand-500',
|
||||||
renewal_job_created: 'text-blue-400',
|
renewal_job_created: 'text-brand-500',
|
||||||
renewal_completed: 'text-emerald-400',
|
renewal_completed: 'text-emerald-600',
|
||||||
deployment_completed: 'text-emerald-400',
|
deployment_completed: 'text-emerald-600',
|
||||||
deployment_failed: 'text-red-400',
|
deployment_failed: 'text-red-600',
|
||||||
expiration_alert_sent: 'text-amber-400',
|
expiration_alert_sent: 'text-amber-600',
|
||||||
agent_registered: 'text-blue-400',
|
agent_registered: 'text-brand-500',
|
||||||
policy_violated: 'text-red-400',
|
policy_violated: 'text-red-600',
|
||||||
certificate_revoked: 'text-red-400',
|
certificate_revoked: 'text-red-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESOURCE_TYPES = ['', 'certificate', 'agent', 'job', 'notification', 'policy', 'target', 'issuer'];
|
const RESOURCE_TYPES = ['', 'certificate', 'agent', 'job', 'notification', 'policy', 'target', 'issuer'];
|
||||||
@@ -94,7 +94,7 @@ export default function AuditPage() {
|
|||||||
key: 'action',
|
key: 'action',
|
||||||
label: 'Action',
|
label: 'Action',
|
||||||
render: (e) => (
|
render: (e) => (
|
||||||
<span className={`text-sm font-medium ${actionColors[e.action] || 'text-slate-300'}`}>
|
<span className={`text-sm font-medium ${actionColors[e.action] || 'text-ink'}`}>
|
||||||
{e.action.replace(/_/g, ' ')}
|
{e.action.replace(/_/g, ' ')}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@@ -104,8 +104,8 @@ export default function AuditPage() {
|
|||||||
label: 'Actor',
|
label: 'Actor',
|
||||||
render: (e) => (
|
render: (e) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-slate-200">{e.actor}</div>
|
<div className="text-sm text-ink">{e.actor}</div>
|
||||||
<div className="text-xs text-slate-500">{e.actor_type}</div>
|
<div className="text-xs text-ink-faint">{e.actor_type}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -114,8 +114,8 @@ export default function AuditPage() {
|
|||||||
label: 'Resource',
|
label: 'Resource',
|
||||||
render: (e) => (
|
render: (e) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-slate-300">{e.resource_type}</div>
|
<div className="text-sm text-ink">{e.resource_type}</div>
|
||||||
<div className="text-xs text-slate-500 font-mono">{e.resource_id}</div>
|
<div className="text-xs text-ink-faint font-mono">{e.resource_id}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -123,15 +123,15 @@ export default function AuditPage() {
|
|||||||
key: 'details',
|
key: 'details',
|
||||||
label: 'Details',
|
label: 'Details',
|
||||||
render: (e) => {
|
render: (e) => {
|
||||||
if (!e.details || Object.keys(e.details).length === 0) return <span className="text-slate-500">—</span>;
|
if (!e.details || Object.keys(e.details).length === 0) return <span className="text-ink-faint">—</span>;
|
||||||
return (
|
return (
|
||||||
<span className="text-xs text-slate-400 font-mono truncate max-w-xs block">
|
<span className="text-xs text-ink-muted font-mono truncate max-w-xs block">
|
||||||
{JSON.stringify(e.details).slice(0, 60)}
|
{JSON.stringify(e.details).slice(0, 60)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ key: 'time', label: 'Time', render: (e) => <span className="text-xs text-slate-400">{formatDateTime(e.timestamp)}</span> },
|
{ key: 'time', label: 'Time', render: (e) => <span className="text-xs text-ink-muted">{formatDateTime(e.timestamp)}</span> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasFilters = resourceType || actorFilter || timeRange || actionFilter;
|
const hasFilters = resourceType || actorFilter || timeRange || actionFilter;
|
||||||
@@ -144,21 +144,21 @@ export default function AuditPage() {
|
|||||||
action={
|
action={
|
||||||
filtered.length > 0 ? (
|
filtered.length > 0 ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => exportCSV(filtered)} className="btn btn-ghost text-xs border border-slate-600">
|
<button onClick={() => exportCSV(filtered)} className="btn btn-ghost text-xs border border-surface-border">
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => exportJSON(filtered)} className="btn btn-ghost text-xs border border-slate-600">
|
<button onClick={() => exportJSON(filtered)} className="btn btn-ghost text-xs border border-surface-border">
|
||||||
Export JSON
|
Export JSON
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="px-4 py-3 flex flex-wrap gap-3 border-b border-slate-700/50">
|
<div className="px-4 py-3 flex flex-wrap gap-3 border-b border-surface-border/50">
|
||||||
<select
|
<select
|
||||||
value={resourceType}
|
value={resourceType}
|
||||||
onChange={(e) => setResourceType(e.target.value)}
|
onChange={(e) => setResourceType(e.target.value)}
|
||||||
className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-blue-500"
|
className="bg-surface border border-surface-border rounded px-3 py-1.5 text-xs text-ink focus:outline-none focus:border-brand-400"
|
||||||
>
|
>
|
||||||
<option value="">All resources</option>
|
<option value="">All resources</option>
|
||||||
{RESOURCE_TYPES.filter(Boolean).map((t) => (
|
{RESOURCE_TYPES.filter(Boolean).map((t) => (
|
||||||
@@ -170,19 +170,19 @@ export default function AuditPage() {
|
|||||||
placeholder="Filter by actor..."
|
placeholder="Filter by actor..."
|
||||||
value={actorFilter}
|
value={actorFilter}
|
||||||
onChange={(e) => setActorFilter(e.target.value)}
|
onChange={(e) => setActorFilter(e.target.value)}
|
||||||
className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 placeholder-slate-500 focus:outline-none focus:border-blue-500 w-40"
|
className="bg-surface border border-surface-border rounded px-3 py-1.5 text-xs text-ink placeholder-ink-faint focus:outline-none focus:border-brand-400 w-40"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Filter by action..."
|
placeholder="Filter by action..."
|
||||||
value={actionFilter}
|
value={actionFilter}
|
||||||
onChange={(e) => setActionFilter(e.target.value)}
|
onChange={(e) => setActionFilter(e.target.value)}
|
||||||
className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 placeholder-slate-500 focus:outline-none focus:border-blue-500 w-40"
|
className="bg-surface border border-surface-border rounded px-3 py-1.5 text-xs text-ink placeholder-ink-faint focus:outline-none focus:border-brand-400 w-40"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={timeRange}
|
value={timeRange}
|
||||||
onChange={(e) => setTimeRange(e.target.value)}
|
onChange={(e) => setTimeRange(e.target.value)}
|
||||||
className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-blue-500"
|
className="bg-surface border border-surface-border rounded px-3 py-1.5 text-xs text-ink focus:outline-none focus:border-brand-400"
|
||||||
>
|
>
|
||||||
{TIME_RANGES.map((r) => (
|
{TIME_RANGES.map((r) => (
|
||||||
<option key={r.value} value={r.value}>{r.label}</option>
|
<option key={r.value} value={r.value}>{r.label}</option>
|
||||||
@@ -191,7 +191,7 @@ export default function AuditPage() {
|
|||||||
{hasFilters && (
|
{hasFilters && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setResourceType(''); setActorFilter(''); setTimeRange(''); setActionFilter(''); }}
|
onClick={() => { setResourceType(''); setActorFilter(''); setTimeRange(''); setActionFilter(''); }}
|
||||||
className="text-xs text-slate-400 hover:text-slate-200 transition-colors"
|
className="text-xs text-ink-muted hover:text-ink transition-colors"
|
||||||
>
|
>
|
||||||
Clear filters
|
Clear filters
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import type { Job } from '../api/types';
|
|||||||
|
|
||||||
function InfoRow({ label, value, editable, onEdit }: { label: string; value: React.ReactNode; editable?: boolean; onEdit?: () => void }) {
|
function InfoRow({ label, value, editable, onEdit }: { label: string; value: React.ReactNode; editable?: boolean; onEdit?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between py-2 border-b border-slate-700/50 group">
|
<div className="flex justify-between py-2 border-b border-surface-border/50 group">
|
||||||
<span className="text-sm text-slate-400">{label}</span>
|
<span className="text-sm text-ink-muted">{label}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-slate-200">{value}</span>
|
<span className="text-sm text-ink">{value}</span>
|
||||||
{editable && onEdit && (
|
{editable && onEdit && (
|
||||||
<button onClick={onEdit} className="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-blue-400 hover:text-blue-300">
|
<button onClick={onEdit} className="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-brand-400 hover:text-brand-500">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -28,22 +28,22 @@ function InfoRow({ label, value, editable, onEdit }: { label: string; value: Rea
|
|||||||
// Timeline step component for deployment status
|
// Timeline step component for deployment status
|
||||||
function TimelineStep({ label, status, time, isLast }: { label: string; status: 'completed' | 'active' | 'pending' | 'failed'; time?: string; isLast?: boolean }) {
|
function TimelineStep({ label, status, time, isLast }: { label: string; status: 'completed' | 'active' | 'pending' | 'failed'; time?: string; isLast?: boolean }) {
|
||||||
const dotStyles = {
|
const dotStyles = {
|
||||||
completed: 'bg-emerald-500 ring-emerald-500/30',
|
completed: 'bg-emerald-500 ring-emerald-200',
|
||||||
active: 'bg-blue-500 ring-blue-500/30 animate-pulse',
|
active: 'bg-brand-400 ring-brand-200 animate-pulse',
|
||||||
pending: 'bg-slate-600 ring-slate-600/30',
|
pending: 'bg-surface-muted ring-surface-border',
|
||||||
failed: 'bg-red-500 ring-red-500/30',
|
failed: 'bg-red-500 ring-red-200',
|
||||||
};
|
};
|
||||||
const lineStyles = {
|
const lineStyles = {
|
||||||
completed: 'bg-emerald-500/50',
|
completed: 'bg-emerald-300',
|
||||||
active: 'bg-blue-500/30',
|
active: 'bg-brand-200',
|
||||||
pending: 'bg-slate-700',
|
pending: 'bg-surface-border',
|
||||||
failed: 'bg-red-500/30',
|
failed: 'bg-red-300',
|
||||||
};
|
};
|
||||||
const textStyles = {
|
const textStyles = {
|
||||||
completed: 'text-emerald-400',
|
completed: 'text-emerald-600',
|
||||||
active: 'text-blue-400',
|
active: 'text-brand-400',
|
||||||
pending: 'text-slate-500',
|
pending: 'text-ink-faint',
|
||||||
failed: 'text-red-400',
|
failed: 'text-red-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,7 +54,7 @@ function TimelineStep({ label, status, time, isLast }: { label: string; status:
|
|||||||
</div>
|
</div>
|
||||||
<div className="pb-6">
|
<div className="pb-6">
|
||||||
<div className={`text-sm font-medium ${textStyles[status]}`}>{label}</div>
|
<div className={`text-sm font-medium ${textStyles[status]}`}>{label}</div>
|
||||||
{time && <div className="text-xs text-slate-500 mt-0.5">{time}</div>}
|
{time && <div className="text-xs text-ink-faint mt-0.5">{time}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -117,8 +117,8 @@ function DeploymentTimeline({ certId, certStatus, createdAt, issuedAt }: { certI
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Lifecycle Timeline</h3>
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Lifecycle Timeline</h3>
|
||||||
<div className="pl-1">
|
<div className="pl-1">
|
||||||
<TimelineStep label="Requested" status={getRequestedStatus()} time={getRequestedTime()} />
|
<TimelineStep label="Requested" status={getRequestedStatus()} time={getRequestedTime()} />
|
||||||
<TimelineStep label="Issued" status={getIssuedStatus()} time={getIssuedTime()} />
|
<TimelineStep label="Issued" status={getIssuedStatus()} time={getIssuedTime()} />
|
||||||
@@ -161,10 +161,10 @@ function InlinePolicyEditor({ certId, currentPolicyId, currentProfileId }: { cer
|
|||||||
|
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
return (
|
return (
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-semibold text-slate-300">Policy & Profile</h3>
|
<h3 className="text-sm font-semibold text-ink-muted">Policy & Profile</h3>
|
||||||
<button onClick={() => setEditing(true)} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">
|
<button onClick={() => setEditing(true)} className="text-xs text-brand-400 hover:text-brand-500 transition-colors">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,28 +175,28 @@ function InlinePolicyEditor({ certId, currentPolicyId, currentProfileId }: { cer
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card p-5 border-blue-500/30">
|
<div className="bg-surface border border-surface-border border-brand-400 rounded p-5 shadow-sm">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-semibold text-blue-400">Edit Policy & Profile</h3>
|
<h3 className="text-sm font-semibold text-brand-500">Edit Policy & Profile</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => { setEditing(false); setPolicyId(currentPolicyId); setProfileId(currentProfileId); }}
|
<button onClick={() => { setEditing(false); setPolicyId(currentPolicyId); setProfileId(currentProfileId); }}
|
||||||
className="text-xs text-slate-400 hover:text-slate-300">Cancel</button>
|
className="text-xs text-ink-muted hover:text-ink">Cancel</button>
|
||||||
<button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}
|
<button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}
|
||||||
className="text-xs text-blue-400 hover:text-blue-300 font-medium disabled:opacity-50">
|
className="text-xs text-brand-400 hover:text-brand-500 font-medium disabled:opacity-50">
|
||||||
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{saveMutation.isError && (
|
{saveMutation.isError && (
|
||||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-3">
|
<div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-3">
|
||||||
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save'}
|
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-slate-400 block mb-1">Renewal Policy</label>
|
<label className="text-xs text-ink-muted block mb-1">Renewal Policy</label>
|
||||||
<select value={policyId} onChange={e => setPolicyId(e.target.value)}
|
<select value={policyId} onChange={e => setPolicyId(e.target.value)}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200">
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink">
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{policies?.data?.map(p => (
|
{policies?.data?.map(p => (
|
||||||
<option key={p.id} value={p.id}>{p.name} ({p.type})</option>
|
<option key={p.id} value={p.id}>{p.name} ({p.type})</option>
|
||||||
@@ -204,9 +204,9 @@ function InlinePolicyEditor({ certId, currentPolicyId, currentProfileId }: { cer
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-slate-400 block mb-1">Certificate Profile</label>
|
<label className="text-xs text-ink-muted block mb-1">Certificate Profile</label>
|
||||||
<select value={profileId} onChange={e => setProfileId(e.target.value)}
|
<select value={profileId} onChange={e => setProfileId(e.target.value)}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200">
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink">
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{profiles?.data?.map(p => (
|
{profiles?.data?.map(p => (
|
||||||
<option key={p.id} value={p.id}>{p.name} — max TTL {p.max_ttl_seconds ? `${Math.round(p.max_ttl_seconds / 86400)}d` : '∞'}</option>
|
<option key={p.id} value={p.id}>{p.name} — max TTL {p.max_ttl_seconds ? `${Math.round(p.max_ttl_seconds / 86400)}d` : '∞'}</option>
|
||||||
@@ -316,7 +316,7 @@ export default function CertificateDetailPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowDeploy(true)}
|
onClick={() => setShowDeploy(true)}
|
||||||
disabled={isArchived || isRevoked}
|
disabled={isArchived || isRevoked}
|
||||||
className="btn btn-ghost text-xs border border-slate-600 disabled:opacity-50"
|
className="btn btn-ghost text-xs border border-surface-border disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Deploy
|
Deploy
|
||||||
</button>
|
</button>
|
||||||
@@ -349,53 +349,53 @@ export default function CertificateDetailPage() {
|
|||||||
/>
|
/>
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
{renewMutation.isSuccess && (
|
{renewMutation.isSuccess && (
|
||||||
<div className="bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-lg px-4 py-3 text-sm">
|
<div className="bg-emerald-50 border border-emerald-200 text-emerald-700 rounded px-4 py-3 text-sm">
|
||||||
Renewal triggered successfully. A renewal job has been created.
|
Renewal triggered successfully. A renewal job has been created.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renewMutation.isError && (
|
{renewMutation.isError && (
|
||||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-4 py-3 text-sm">
|
<div className="bg-red-50 border border-red-200 text-red-700 rounded px-4 py-3 text-sm">
|
||||||
Failed to trigger renewal: {renewMutation.error instanceof Error ? renewMutation.error.message : 'Unknown error'}
|
Failed to trigger renewal: {renewMutation.error instanceof Error ? renewMutation.error.message : 'Unknown error'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{deployMutation.isSuccess && (
|
{deployMutation.isSuccess && (
|
||||||
<div className="bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-lg px-4 py-3 text-sm">
|
<div className="bg-emerald-50 border border-emerald-200 text-emerald-700 rounded px-4 py-3 text-sm">
|
||||||
Deployment triggered. A deployment job has been created.
|
Deployment triggered. A deployment job has been created.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{deployMutation.isError && (
|
{deployMutation.isError && (
|
||||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-4 py-3 text-sm">
|
<div className="bg-red-50 border border-red-200 text-red-700 rounded px-4 py-3 text-sm">
|
||||||
Failed to deploy: {deployMutation.error instanceof Error ? deployMutation.error.message : 'Unknown error'}
|
Failed to deploy: {deployMutation.error instanceof Error ? deployMutation.error.message : 'Unknown error'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{archiveMutation.isError && (
|
{archiveMutation.isError && (
|
||||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-4 py-3 text-sm">
|
<div className="bg-red-50 border border-red-200 text-red-700 rounded px-4 py-3 text-sm">
|
||||||
Failed to archive: {archiveMutation.error instanceof Error ? archiveMutation.error.message : 'Unknown error'}
|
Failed to archive: {archiveMutation.error instanceof Error ? archiveMutation.error.message : 'Unknown error'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{revokeMutation.isSuccess && (
|
{revokeMutation.isSuccess && (
|
||||||
<div className="bg-amber-500/10 border border-amber-500/20 text-amber-400 rounded-lg px-4 py-3 text-sm">
|
<div className="bg-amber-50 border border-amber-200 text-amber-700 rounded px-4 py-3 text-sm">
|
||||||
Certificate revoked successfully. It has been added to the CRL.
|
Certificate revoked successfully. It has been added to the CRL.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{revokeMutation.isError && (
|
{revokeMutation.isError && (
|
||||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-4 py-3 text-sm">
|
<div className="bg-red-50 border border-red-200 text-red-700 rounded px-4 py-3 text-sm">
|
||||||
Failed to revoke: {revokeMutation.error instanceof Error ? revokeMutation.error.message : 'Unknown error'}
|
Failed to revoke: {revokeMutation.error instanceof Error ? revokeMutation.error.message : 'Unknown error'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Revocation Banner */}
|
{/* Revocation Banner */}
|
||||||
{isRevoked && (
|
{isRevoked && (
|
||||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3">
|
<div className="bg-red-50 border border-red-200 rounded px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
<div className="w-8 h-8 rounded bg-red-100 flex items-center justify-center flex-shrink-0">
|
||||||
<svg className="w-4 h-4 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-4 h-4 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-red-400">Certificate Revoked</div>
|
<div className="text-sm font-medium text-red-700">Certificate Revoked</div>
|
||||||
<div className="text-xs text-slate-400 mt-0.5">
|
<div className="text-xs text-red-600 mt-0.5">
|
||||||
Reason: {REVOCATION_REASONS.find(r => r.value === cert.revocation_reason)?.label || cert.revocation_reason || 'Unspecified'}
|
Reason: {REVOCATION_REASONS.find(r => r.value === cert.revocation_reason)?.label || cert.revocation_reason || 'Unspecified'}
|
||||||
{cert.revoked_at && <> · Revoked {formatDateTime(cert.revoked_at)}</>}
|
{cert.revoked_at && <> · Revoked {formatDateTime(cert.revoked_at)}</>}
|
||||||
</div>
|
</div>
|
||||||
@@ -409,8 +409,8 @@ export default function CertificateDetailPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Certificate Info */}
|
{/* Certificate Info */}
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Certificate Details</h3>
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Certificate Details</h3>
|
||||||
<InfoRow label="Status" value={<StatusBadge status={cert.status} />} />
|
<InfoRow label="Status" value={<StatusBadge status={cert.status} />} />
|
||||||
<InfoRow label="Common Name" value={cert.common_name} />
|
<InfoRow label="Common Name" value={cert.common_name} />
|
||||||
<InfoRow label="SANs" value={cert.sans?.length ? cert.sans.join(', ') : '—'} />
|
<InfoRow label="SANs" value={cert.sans?.length ? cert.sans.join(', ') : '—'} />
|
||||||
@@ -423,11 +423,11 @@ export default function CertificateDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lifecycle */}
|
{/* Lifecycle */}
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Lifecycle</h3>
|
<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(cert.issued_at)} />
|
||||||
<InfoRow label="Expires" value={
|
<InfoRow label="Expires" value={
|
||||||
<span className={isRevoked ? 'text-red-400 line-through' : expiryColor(days)}>
|
<span className={isRevoked ? 'text-red-600 line-through' : expiryColor(days)}>
|
||||||
{formatDate(cert.expires_at)} ({days <= 0 ? 'expired' : `${days} days`})
|
{formatDate(cert.expires_at)} ({days <= 0 ? 'expired' : `${days} days`})
|
||||||
</span>
|
</span>
|
||||||
} />
|
} />
|
||||||
@@ -438,10 +438,10 @@ export default function CertificateDetailPage() {
|
|||||||
{isRevoked && (
|
{isRevoked && (
|
||||||
<>
|
<>
|
||||||
<InfoRow label="Revoked At" value={
|
<InfoRow label="Revoked At" value={
|
||||||
<span className="text-red-400">{cert.revoked_at ? formatDateTime(cert.revoked_at) : '—'}</span>
|
<span className="text-red-600">{cert.revoked_at ? formatDateTime(cert.revoked_at) : '—'}</span>
|
||||||
} />
|
} />
|
||||||
<InfoRow label="Revocation Reason" value={
|
<InfoRow label="Revocation Reason" value={
|
||||||
<span className="text-red-400">
|
<span className="text-red-600">
|
||||||
{REVOCATION_REASONS.find(r => r.value === cert.revocation_reason)?.label || cert.revocation_reason || '—'}
|
{REVOCATION_REASONS.find(r => r.value === cert.revocation_reason)?.label || cert.revocation_reason || '—'}
|
||||||
</span>
|
</span>
|
||||||
} />
|
} />
|
||||||
@@ -461,8 +461,8 @@ export default function CertificateDetailPage() {
|
|||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{cert.tags && Object.keys(cert.tags).length > 0 && (
|
{cert.tags && Object.keys(cert.tags).length > 0 && (
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Tags</h3>
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Tags</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{Object.entries(cert.tags).map(([k, v]) => (
|
{Object.entries(cert.tags).map(([k, v]) => (
|
||||||
<span key={k} className="badge badge-neutral">{k}: {v}</span>
|
<span key={k} className="badge badge-neutral">{k}: {v}</span>
|
||||||
@@ -472,32 +472,32 @@ export default function CertificateDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Version History */}
|
{/* Version History */}
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">
|
||||||
Version History {versions?.data?.length ? `(${versions.data.length})` : ''}
|
Version History {versions?.data?.length ? `(${versions.data.length})` : ''}
|
||||||
</h3>
|
</h3>
|
||||||
{!versions?.data?.length ? (
|
{!versions?.data?.length ? (
|
||||||
<p className="text-sm text-slate-500">No versions yet</p>
|
<p className="text-sm text-ink-faint">No versions yet</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{versions.data.map((v, idx) => (
|
{versions.data.map((v, idx) => (
|
||||||
<div key={v.id} className="flex items-center justify-between py-2 border-b border-slate-700/50 last:border-0">
|
<div key={v.id} className="flex items-center justify-between py-2 border-b border-surface-border/50 last:border-0">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-slate-200">Version {v.version}</span>
|
<span className="text-sm text-ink">Version {v.version}</span>
|
||||||
{idx === 0 && <span className="text-xs bg-blue-500/20 text-blue-400 px-1.5 py-0.5 rounded">Current</span>}
|
{idx === 0 && <span className="text-xs bg-brand-100 text-brand-700 px-1.5 py-0.5 rounded">Current</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500 font-mono">{v.serial_number}</div>
|
<div className="text-xs text-ink-faint font-mono">{v.serial_number}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-sm text-slate-300">{formatDate(v.not_before)} — {formatDate(v.not_after)}</div>
|
<div className="text-sm text-ink-muted">{formatDate(v.not_before)} — {formatDate(v.not_after)}</div>
|
||||||
<div className="text-xs text-slate-500">{formatDateTime(v.created_at)}</div>
|
<div className="text-xs text-ink-faint">{formatDateTime(v.created_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
{idx > 0 && cert?.status !== 'Archived' && cert?.status !== 'Revoked' && (
|
{idx > 0 && cert?.status !== 'Archived' && cert?.status !== 'Revoked' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeploy(true)}
|
onClick={() => setShowDeploy(true)}
|
||||||
className="text-xs text-amber-400 hover:text-amber-300 border border-amber-500/30 px-2 py-1 rounded hover:bg-amber-500/10 transition-colors"
|
className="text-xs text-amber-600 hover:text-amber-700 border border-amber-300 px-2 py-1 rounded hover:bg-amber-50 transition-colors"
|
||||||
title="Redeploy this version to targets"
|
title="Redeploy this version to targets"
|
||||||
>
|
>
|
||||||
Rollback
|
Rollback
|
||||||
@@ -513,19 +513,19 @@ export default function CertificateDetailPage() {
|
|||||||
|
|
||||||
{/* Deploy Modal */}
|
{/* Deploy Modal */}
|
||||||
{showDeploy && (
|
{showDeploy && (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowDeploy(false)}>
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setShowDeploy(false)}>
|
||||||
<div className="bg-slate-800 border border-slate-600 rounded-xl p-6 w-full max-w-md shadow-2xl" onClick={e => e.stopPropagation()}>
|
<div className="bg-surface border border-surface-border rounded p-6 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
<h2 className="text-lg font-semibold text-slate-200 mb-4">Deploy Certificate</h2>
|
<h2 className="text-lg font-semibold text-ink mb-4">Deploy Certificate</h2>
|
||||||
{deployMutation.isError && (
|
{deployMutation.isError && (
|
||||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-3">
|
<div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-3">
|
||||||
{deployMutation.error instanceof Error ? deployMutation.error.message : 'Unknown error'}
|
{deployMutation.error instanceof Error ? deployMutation.error.message : 'Unknown error'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<label className="text-xs text-slate-400 block mb-2">Select Target</label>
|
<label className="text-xs text-ink-muted block mb-2">Select Target</label>
|
||||||
<select
|
<select
|
||||||
value={deployTargetId}
|
value={deployTargetId}
|
||||||
onChange={e => setDeployTargetId(e.target.value)}
|
onChange={e => setDeployTargetId(e.target.value)}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 mb-4"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink mb-4"
|
||||||
>
|
>
|
||||||
<option value="">Choose a target...</option>
|
<option value="">Choose a target...</option>
|
||||||
{targets?.data?.map(t => (
|
{targets?.data?.map(t => (
|
||||||
@@ -548,22 +548,22 @@ export default function CertificateDetailPage() {
|
|||||||
|
|
||||||
{/* Revoke Modal */}
|
{/* Revoke Modal */}
|
||||||
{showRevoke && (
|
{showRevoke && (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowRevoke(false)}>
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setShowRevoke(false)}>
|
||||||
<div className="bg-slate-800 border border-slate-600 rounded-xl p-6 w-full max-w-md shadow-2xl" onClick={e => e.stopPropagation()}>
|
<div className="bg-surface border border-surface-border rounded p-6 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
<h2 className="text-lg font-semibold text-red-400 mb-2">Revoke Certificate</h2>
|
<h2 className="text-lg font-semibold text-red-700 mb-2">Revoke Certificate</h2>
|
||||||
<p className="text-sm text-slate-400 mb-4">
|
<p className="text-sm text-ink-muted mb-4">
|
||||||
This action cannot be undone. The certificate will be added to the CRL and marked as revoked.
|
This action cannot be undone. The certificate will be added to the CRL and marked as revoked.
|
||||||
</p>
|
</p>
|
||||||
{revokeMutation.isError && (
|
{revokeMutation.isError && (
|
||||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-3">
|
<div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-3">
|
||||||
{revokeMutation.error instanceof Error ? revokeMutation.error.message : 'Unknown error'}
|
{revokeMutation.error instanceof Error ? revokeMutation.error.message : 'Unknown error'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<label className="text-xs text-slate-400 block mb-2">Revocation Reason (RFC 5280)</label>
|
<label className="text-xs text-ink-muted block mb-2">Revocation Reason (RFC 5280)</label>
|
||||||
<select
|
<select
|
||||||
value={revokeReason}
|
value={revokeReason}
|
||||||
onChange={e => setRevokeReason(e.target.value)}
|
onChange={e => setRevokeReason(e.target.value)}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 mb-4"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink mb-4"
|
||||||
>
|
>
|
||||||
{REVOCATION_REASONS.map(r => (
|
{REVOCATION_REASONS.map(r => (
|
||||||
<option key={r.value} value={r.value}>{r.label}</option>
|
<option key={r.value} value={r.value}>{r.label}</option>
|
||||||
|
|||||||
@@ -30,57 +30,57 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
<div className="bg-slate-800 border border-slate-600 rounded-xl p-6 w-full max-w-lg shadow-2xl" onClick={e => e.stopPropagation()}>
|
<div className="bg-surface border border-surface-border rounded p-6 w-full max-w-lg shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
<h2 className="text-lg font-semibold text-slate-200 mb-4">New Certificate</h2>
|
<h2 className="text-lg font-semibold text-ink mb-4">New Certificate</h2>
|
||||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-4">{error}</div>}
|
{error && <div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-4">{error}</div>}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-slate-400 block mb-1">ID (optional)</label>
|
<label className="text-xs text-ink-muted block mb-1">ID (optional)</label>
|
||||||
<input value={form.id} onChange={e => setForm(f => ({ ...f, id: e.target.value }))}
|
<input value={form.id} onChange={e => setForm(f => ({ ...f, id: e.target.value }))}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
|
||||||
placeholder="mc-api-prod (auto-generated if empty)" />
|
placeholder="mc-api-prod (auto-generated if empty)" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-slate-400 block mb-1">Common Name *</label>
|
<label className="text-xs text-ink-muted block mb-1">Common Name *</label>
|
||||||
<input value={form.common_name} onChange={e => setForm(f => ({ ...f, common_name: e.target.value }))}
|
<input value={form.common_name} onChange={e => setForm(f => ({ ...f, common_name: e.target.value }))}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
|
||||||
placeholder="api.example.com" />
|
placeholder="api.example.com" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-slate-400 block mb-1">Environment</label>
|
<label className="text-xs text-ink-muted block mb-1">Environment</label>
|
||||||
<select value={form.environment} onChange={e => setForm(f => ({ ...f, environment: e.target.value }))}
|
<select value={form.environment} onChange={e => setForm(f => ({ ...f, environment: e.target.value }))}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200">
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink">
|
||||||
<option value="production">Production</option>
|
<option value="production">Production</option>
|
||||||
<option value="staging">Staging</option>
|
<option value="staging">Staging</option>
|
||||||
<option value="development">Development</option>
|
<option value="development">Development</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-slate-400 block mb-1">Issuer ID *</label>
|
<label className="text-xs text-ink-muted block mb-1">Issuer ID *</label>
|
||||||
<input value={form.issuer_id} onChange={e => setForm(f => ({ ...f, issuer_id: e.target.value }))}
|
<input value={form.issuer_id} onChange={e => setForm(f => ({ ...f, issuer_id: e.target.value }))}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
|
||||||
placeholder="iss-local" />
|
placeholder="iss-local" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-slate-400 block mb-1">Owner ID</label>
|
<label className="text-xs text-ink-muted block mb-1">Owner ID</label>
|
||||||
<input value={form.owner_id} onChange={e => setForm(f => ({ ...f, owner_id: e.target.value }))}
|
<input value={form.owner_id} onChange={e => setForm(f => ({ ...f, owner_id: e.target.value }))}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
|
||||||
placeholder="o-alice" />
|
placeholder="o-alice" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-slate-400 block mb-1">Team ID</label>
|
<label className="text-xs text-ink-muted block mb-1">Team ID</label>
|
||||||
<input value={form.team_id} onChange={e => setForm(f => ({ ...f, team_id: e.target.value }))}
|
<input value={form.team_id} onChange={e => setForm(f => ({ ...f, team_id: e.target.value }))}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
|
||||||
placeholder="t-platform" />
|
placeholder="t-platform" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-slate-400 block mb-1">Policy ID</label>
|
<label className="text-xs text-ink-muted block mb-1">Policy ID</label>
|
||||||
<input value={form.renewal_policy_id} onChange={e => setForm(f => ({ ...f, renewal_policy_id: e.target.value }))}
|
<input value={form.renewal_policy_id} onChange={e => setForm(f => ({ ...f, renewal_policy_id: e.target.value }))}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
|
||||||
placeholder="rp-standard" />
|
placeholder="rp-standard" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,27 +124,27 @@ function BulkRevokeModal({ ids, onClose, onSuccess }: { ids: string[]; onClose:
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
<div className="bg-slate-800 border border-slate-600 rounded-xl p-6 w-full max-w-md shadow-2xl" onClick={e => e.stopPropagation()}>
|
<div className="bg-surface border border-surface-border rounded p-6 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
<h2 className="text-lg font-semibold text-red-400 mb-2">Bulk Revoke</h2>
|
<h2 className="text-lg font-semibold text-red-700 mb-2">Bulk Revoke</h2>
|
||||||
<p className="text-sm text-slate-400 mb-4">
|
<p className="text-sm text-ink-muted mb-4">
|
||||||
Revoke {ids.length} certificate{ids.length > 1 ? 's' : ''}. This cannot be undone.
|
Revoke {ids.length} certificate{ids.length > 1 ? 's' : ''}. This cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-3">{error}</div>}
|
{error && <div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-3">{error}</div>}
|
||||||
{running && (
|
{running && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="flex justify-between text-xs text-slate-400 mb-1">
|
<div className="flex justify-between text-xs text-ink-muted mb-1">
|
||||||
<span>Progress</span>
|
<span>Progress</span>
|
||||||
<span>{progress}/{ids.length}</span>
|
<span>{progress}/{ids.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-slate-700 rounded-full h-2">
|
<div className="w-full bg-surface-border rounded-full h-2">
|
||||||
<div className="bg-red-500 h-2 rounded-full transition-all" style={{ width: `${(progress / ids.length) * 100}%` }} />
|
<div className="bg-red-500 h-2 rounded-full transition-all" style={{ width: `${(progress / ids.length) * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<label className="text-xs text-slate-400 block mb-2">Revocation Reason (RFC 5280)</label>
|
<label className="text-xs text-ink-muted block mb-2">Revocation Reason (RFC 5280)</label>
|
||||||
<select value={reason} onChange={e => setReason(e.target.value)}
|
<select value={reason} onChange={e => setReason(e.target.value)}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 mb-4"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink mb-4"
|
||||||
disabled={running}
|
disabled={running}
|
||||||
>
|
>
|
||||||
{REVOCATION_REASONS.map(r => (
|
{REVOCATION_REASONS.map(r => (
|
||||||
@@ -193,27 +193,27 @@ function BulkReassignModal({ ids, onClose, onSuccess }: { ids: string[]; onClose
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
<div className="bg-slate-800 border border-slate-600 rounded-xl p-6 w-full max-w-md shadow-2xl" onClick={e => e.stopPropagation()}>
|
<div className="bg-surface border border-surface-border rounded p-6 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
<h2 className="text-lg font-semibold text-slate-200 mb-2">Reassign Owner</h2>
|
<h2 className="text-lg font-semibold text-ink mb-2">Reassign Owner</h2>
|
||||||
<p className="text-sm text-slate-400 mb-4">
|
<p className="text-sm text-ink-muted mb-4">
|
||||||
Reassign {ids.length} certificate{ids.length > 1 ? 's' : ''} to a new owner.
|
Reassign {ids.length} certificate{ids.length > 1 ? 's' : ''} to a new owner.
|
||||||
</p>
|
</p>
|
||||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-3">{error}</div>}
|
{error && <div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-3">{error}</div>}
|
||||||
{running && (
|
{running && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="flex justify-between text-xs text-slate-400 mb-1">
|
<div className="flex justify-between text-xs text-ink-muted mb-1">
|
||||||
<span>Progress</span>
|
<span>Progress</span>
|
||||||
<span>{progress}/{ids.length}</span>
|
<span>{progress}/{ids.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-slate-700 rounded-full h-2">
|
<div className="w-full bg-surface-border rounded-full h-2">
|
||||||
<div className="bg-blue-500 h-2 rounded-full transition-all" style={{ width: `${(progress / ids.length) * 100}%` }} />
|
<div className="bg-brand-400 h-2 rounded-full transition-all" style={{ width: `${(progress / ids.length) * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<label className="text-xs text-slate-400 block mb-2">New Owner</label>
|
<label className="text-xs text-ink-muted block mb-2">New Owner</label>
|
||||||
<select value={ownerId} onChange={e => setOwnerId(e.target.value)}
|
<select value={ownerId} onChange={e => setOwnerId(e.target.value)}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 mb-4"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink mb-4"
|
||||||
disabled={running}
|
disabled={running}
|
||||||
>
|
>
|
||||||
<option value="">Select owner...</option>
|
<option value="">Select owner...</option>
|
||||||
@@ -276,8 +276,8 @@ export default function CertificatesPage() {
|
|||||||
label: 'Certificate',
|
label: 'Certificate',
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-slate-200">{c.common_name}</div>
|
<div className="font-medium text-ink">{c.common_name}</div>
|
||||||
<div className="text-xs text-slate-500 mt-0.5">{c.id}</div>
|
<div className="text-xs text-ink-faint mt-0.5">{c.id}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -290,14 +290,14 @@ export default function CertificatesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={expiryColor(days)}>{formatDate(c.expires_at)}</div>
|
<div className={expiryColor(days)}>{formatDate(c.expires_at)}</div>
|
||||||
<div className="text-xs text-slate-500">{days <= 0 ? 'Expired' : `${days} days`}</div>
|
<div className="text-xs text-ink-faint">{days <= 0 ? 'Expired' : `${days} days`}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ key: 'env', label: 'Environment', render: (c) => <span className="text-slate-300">{c.environment || '—'}</span> },
|
{ key: 'env', label: 'Environment', render: (c) => <span className="text-ink-muted">{c.environment || '—'}</span> },
|
||||||
{ key: 'issuer', label: 'Issuer', render: (c) => <span className="text-slate-400 text-xs">{c.issuer_id}</span> },
|
{ key: 'issuer', label: 'Issuer', render: (c) => <span className="text-ink-muted text-xs">{c.issuer_id}</span> },
|
||||||
{ key: 'owner', label: 'Owner', render: (c) => <span className="text-slate-400 text-xs">{c.owner_id}</span> },
|
{ key: 'owner', label: 'Owner', render: (c) => <span className="text-ink-muted text-xs">{c.owner_id}</span> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const selectedArray = Array.from(selectedIds);
|
const selectedArray = Array.from(selectedIds);
|
||||||
@@ -317,8 +317,8 @@ export default function CertificatesPage() {
|
|||||||
|
|
||||||
{/* Bulk Action Bar */}
|
{/* Bulk Action Bar */}
|
||||||
{hasSelection && (
|
{hasSelection && (
|
||||||
<div className="px-6 py-3 bg-blue-500/10 border-b border-blue-500/20 flex items-center justify-between">
|
<div className="px-6 py-3 bg-brand-50 border-b border-brand-200 flex items-center justify-between">
|
||||||
<span className="text-sm text-blue-400 font-medium">{selectedArray.length} selected</span>
|
<span className="text-sm text-brand-600 font-medium">{selectedArray.length} selected</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={handleBulkRenewal} disabled={bulkRenewProgress?.running}
|
<button onClick={handleBulkRenewal} disabled={bulkRenewProgress?.running}
|
||||||
className="btn btn-primary text-xs disabled:opacity-50">
|
className="btn btn-primary text-xs disabled:opacity-50">
|
||||||
@@ -331,11 +331,11 @@ export default function CertificatesPage() {
|
|||||||
Revoke
|
Revoke
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setShowBulkReassign(true)}
|
<button onClick={() => setShowBulkReassign(true)}
|
||||||
className="btn btn-ghost text-xs text-blue-400 hover:text-blue-300 border border-blue-600/50">
|
className="btn btn-ghost text-xs text-brand-400 hover:text-brand-300 border border-brand-600/50">
|
||||||
Reassign Owner
|
Reassign Owner
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setSelectedIds(new Set())}
|
<button onClick={() => setSelectedIds(new Set())}
|
||||||
className="btn btn-ghost text-xs text-slate-400">
|
className="btn btn-ghost text-xs text-ink-muted">
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -344,18 +344,18 @@ export default function CertificatesPage() {
|
|||||||
|
|
||||||
{/* Bulk Renewal Success */}
|
{/* Bulk Renewal Success */}
|
||||||
{bulkRenewProgress && !bulkRenewProgress.running && (
|
{bulkRenewProgress && !bulkRenewProgress.running && (
|
||||||
<div className="px-6 py-2 bg-emerald-500/10 border-b border-emerald-500/20">
|
<div className="px-6 py-2 bg-emerald-50 border-b border-emerald-200">
|
||||||
<span className="text-sm text-emerald-400">
|
<span className="text-sm text-emerald-700">
|
||||||
Triggered renewal for {bulkRenewProgress.done} certificate{bulkRenewProgress.done > 1 ? 's' : ''}.
|
Triggered renewal for {bulkRenewProgress.done} certificate{bulkRenewProgress.done > 1 ? 's' : ''}.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="px-6 py-3 flex gap-3 border-b border-slate-700/50">
|
<div className="px-6 py-3 flex gap-3 border-b border-surface-border/50">
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={e => setStatusFilter(e.target.value)}
|
onChange={e => setStatusFilter(e.target.value)}
|
||||||
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-300"
|
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
|
||||||
>
|
>
|
||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
<option value="Active">Active</option>
|
<option value="Active">Active</option>
|
||||||
@@ -368,7 +368,7 @@ export default function CertificatesPage() {
|
|||||||
<select
|
<select
|
||||||
value={envFilter}
|
value={envFilter}
|
||||||
onChange={e => setEnvFilter(e.target.value)}
|
onChange={e => setEnvFilter(e.target.value)}
|
||||||
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-300"
|
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
|
||||||
>
|
>
|
||||||
<option value="">All environments</option>
|
<option value="">All environments</option>
|
||||||
<option value="production">Production</option>
|
<option value="production">Production</option>
|
||||||
|
|||||||
@@ -19,28 +19,29 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
Expired: '#ef4444',
|
Expired: '#ef4444',
|
||||||
Revoked: '#8b5cf6',
|
Revoked: '#8b5cf6',
|
||||||
Pending: '#6366f1',
|
Pending: '#6366f1',
|
||||||
RenewalInProgress: '#3b82f6',
|
RenewalInProgress: '#2ea88f',
|
||||||
Failed: '#f43f5e',
|
Failed: '#f43f5e',
|
||||||
Archived: '#64748b',
|
Archived: '#64748b',
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatCard({ label, value, icon, color }: { label: string; value: string | number; icon: string; color: string }) {
|
function StatCard({ label, value, icon, color }: { label: string; value: string | number; icon: string; color: string }) {
|
||||||
const colorMap: Record<string, string> = {
|
const colorMap: Record<string, { bg: string; border: string; text: string }> = {
|
||||||
success: 'bg-emerald-500/10 text-emerald-400',
|
success: { bg: 'bg-emerald-50', border: 'border-t-emerald-500', text: 'text-emerald-700' },
|
||||||
warning: 'bg-amber-500/10 text-amber-400',
|
warning: { bg: 'bg-amber-50', border: 'border-t-amber-500', text: 'text-amber-700' },
|
||||||
danger: 'bg-red-500/10 text-red-400',
|
danger: { bg: 'bg-red-50', border: 'border-t-red-500', text: 'text-red-700' },
|
||||||
info: 'bg-blue-500/10 text-blue-400',
|
info: { bg: 'bg-blue-50', border: 'border-t-brand-400', text: 'text-brand-500' },
|
||||||
};
|
};
|
||||||
|
const config = colorMap[color] || colorMap.info;
|
||||||
return (
|
return (
|
||||||
<div className="card p-5 flex items-start gap-4 hover:border-blue-500/30 transition-colors">
|
<div className={`bg-surface border border-surface-border border-t-4 ${config.border} rounded p-5 flex items-start gap-4 hover:bg-surface-muted transition-colors shadow-sm`}>
|
||||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${colorMap[color] || colorMap.info}`}>
|
<div className={`w-10 h-10 rounded flex items-center justify-center shrink-0 ${config.bg} ${config.text}`}>
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d={icon} />
|
<path strokeLinecap="round" strokeLinejoin="round" d={icon} />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">{label}</p>
|
<p className="text-xs font-semibold text-ink-muted uppercase tracking-wider">{label}</p>
|
||||||
<p className="text-2xl font-bold mt-1">{value}</p>
|
<p className="text-2xl font-bold mt-1 text-ink">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -48,8 +49,8 @@ function StatCard({ label, value, icon, color }: { label: string; value: string
|
|||||||
|
|
||||||
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
|
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">{title}</h3>
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">{title}</h3>
|
||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -60,8 +61,8 @@ function ChartCard({ title, children }: { title: string; children: React.ReactNo
|
|||||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-xs shadow-lg">
|
<div className="bg-surface border border-surface-border rounded px-3 py-2 text-xs shadow-lg">
|
||||||
<p className="text-slate-300 mb-1">{label}</p>
|
<p className="text-ink mb-1">{label}</p>
|
||||||
{payload.map((entry: any, i: number) => (
|
{payload.map((entry: any, i: number) => (
|
||||||
<p key={i} style={{ color: entry.color }}>
|
<p key={i} style={{ color: entry.color }}>
|
||||||
{entry.name}: {typeof entry.value === 'number' && entry.name?.includes('rate') ? `${entry.value.toFixed(1)}%` : entry.value}
|
{entry.name}: {typeof entry.value === 'number' && entry.name?.includes('rate') ? `${entry.value.toFixed(1)}%` : entry.value}
|
||||||
@@ -159,12 +160,12 @@ export default function DashboardPage() {
|
|||||||
<Legend
|
<Legend
|
||||||
verticalAlign="bottom"
|
verticalAlign="bottom"
|
||||||
height={36}
|
height={36}
|
||||||
formatter={(value: string) => <span className="text-xs text-slate-400">{value}</span>}
|
formatter={(value: string) => <span className="text-xs text-ink-muted">{value}</span>}
|
||||||
/>
|
/>
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center text-sm text-slate-500">No certificate data</div>
|
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No certificate data</div>
|
||||||
)}
|
)}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
@@ -173,15 +174,15 @@ export default function DashboardPage() {
|
|||||||
{weeklyExpiration.length > 0 ? (
|
{weeklyExpiration.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={weeklyExpiration}>
|
<BarChart data={weeklyExpiration}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||||
<XAxis dataKey="week" tick={{ fill: '#94a3b8', fontSize: 11 }} tickFormatter={formatShortDate} />
|
<XAxis dataKey="week" tick={{ fill: '#64748b', fontSize: 11 }} tickFormatter={formatShortDate} />
|
||||||
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} allowDecimals={false} />
|
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} allowDecimals={false} />
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<Bar dataKey="count" name="Expiring certs" fill="#f59e0b" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="count" name="Expiring certs" fill="#f59e0b" radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center text-sm text-slate-500">No expiration data</div>
|
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No expiration data</div>
|
||||||
)}
|
)}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,17 +194,17 @@ export default function DashboardPage() {
|
|||||||
{(jobTrends || []).length > 0 ? (
|
{(jobTrends || []).length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={jobTrends}>
|
<LineChart data={jobTrends}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||||
<XAxis dataKey="date" tick={{ fill: '#94a3b8', fontSize: 11 }} tickFormatter={formatShortDate} />
|
<XAxis dataKey="date" tick={{ fill: '#64748b', fontSize: 11 }} tickFormatter={formatShortDate} />
|
||||||
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} allowDecimals={false} />
|
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} allowDecimals={false} />
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<Legend formatter={(value: string) => <span className="text-xs text-slate-400">{value}</span>} />
|
<Legend formatter={(value: string) => <span className="text-xs text-ink-muted">{value}</span>} />
|
||||||
<Line type="monotone" dataKey="completed_count" name="Completed" stroke="#10b981" strokeWidth={2} dot={false} />
|
<Line type="monotone" dataKey="completed_count" name="Completed" stroke="#10b981" strokeWidth={2} dot={false} />
|
||||||
<Line type="monotone" dataKey="failed_count" name="Failed" stroke="#ef4444" strokeWidth={2} dot={false} />
|
<Line type="monotone" dataKey="failed_count" name="Failed" stroke="#ef4444" strokeWidth={2} dot={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center text-sm text-slate-500">No job trend data</div>
|
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No job trend data</div>
|
||||||
)}
|
)}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
@@ -212,28 +213,28 @@ export default function DashboardPage() {
|
|||||||
{(issuanceRate || []).length > 0 ? (
|
{(issuanceRate || []).length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={issuanceRate}>
|
<BarChart data={issuanceRate}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||||
<XAxis dataKey="date" tick={{ fill: '#94a3b8', fontSize: 11 }} tickFormatter={formatShortDate} />
|
<XAxis dataKey="date" tick={{ fill: '#64748b', fontSize: 11 }} tickFormatter={formatShortDate} />
|
||||||
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} allowDecimals={false} />
|
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} allowDecimals={false} />
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<Bar dataKey="issued_count" name="Issued" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="issued_count" name="Issued" fill="#2ea88f" radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center text-sm text-slate-500">No issuance data</div>
|
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No issuance data</div>
|
||||||
)}
|
)}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Expiring Certificates */}
|
{/* Expiring Certificates */}
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-semibold text-slate-300">Certificates Expiring Soon</h3>
|
<h3 className="text-sm font-semibold text-ink-muted">Certificates Expiring Soon</h3>
|
||||||
<button onClick={() => navigate('/certificates')} className="text-xs text-blue-400 hover:text-blue-300">View all</button>
|
<button onClick={() => navigate('/certificates')} className="text-xs text-brand-400 hover:text-brand-500">View all</button>
|
||||||
</div>
|
</div>
|
||||||
{!certs?.data?.length ? (
|
{!certs?.data?.length ? (
|
||||||
<p className="text-sm text-slate-500">No certificates</p>
|
<p className="text-sm text-ink-faint">No certificates</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{certs.data
|
{certs.data
|
||||||
@@ -246,17 +247,17 @@ export default function DashboardPage() {
|
|||||||
<div
|
<div
|
||||||
key={c.id}
|
key={c.id}
|
||||||
onClick={() => navigate(`/certificates/${c.id}`)}
|
onClick={() => navigate(`/certificates/${c.id}`)}
|
||||||
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-700/50 cursor-pointer transition-colors"
|
className="flex items-center justify-between py-2 px-3 rounded hover:bg-surface-muted cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-slate-200">{c.common_name}</div>
|
<div className="text-sm text-ink">{c.common_name}</div>
|
||||||
<div className="text-xs text-slate-500">{c.environment || 'no env'}</div>
|
<div className="text-xs text-ink-faint">{c.environment || 'no env'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className={`text-sm ${expiryColor(days)}`}>
|
<div className={`text-sm ${expiryColor(days)}`}>
|
||||||
{days <= 0 ? 'Expired' : `${days} days`}
|
{days <= 0 ? 'Expired' : `${days} days`}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500">{formatDate(c.expires_at)}</div>
|
<div className="text-xs text-ink-faint">{formatDate(c.expires_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -266,20 +267,20 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Jobs */}
|
{/* Recent Jobs */}
|
||||||
<div className="card p-5">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-semibold text-slate-300">Recent Jobs</h3>
|
<h3 className="text-sm font-semibold text-ink-muted">Recent Jobs</h3>
|
||||||
<button onClick={() => navigate('/jobs')} className="text-xs text-blue-400 hover:text-blue-300">View all</button>
|
<button onClick={() => navigate('/jobs')} className="text-xs text-brand-400 hover:text-brand-500">View all</button>
|
||||||
</div>
|
</div>
|
||||||
{!jobs?.data?.length ? (
|
{!jobs?.data?.length ? (
|
||||||
<p className="text-sm text-slate-500">No jobs</p>
|
<p className="text-sm text-ink-faint">No jobs</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{jobs.data.slice(0, 5).map(j => (
|
{jobs.data.slice(0, 5).map(j => (
|
||||||
<div key={j.id} className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-700/50 transition-colors">
|
<div key={j.id} className="flex items-center justify-between py-2 px-3 rounded hover:bg-surface-muted transition-colors">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-slate-200">{j.type}</div>
|
<div className="text-sm text-ink">{j.type}</div>
|
||||||
<div className="text-xs text-slate-500 font-mono">{j.certificate_id}</div>
|
<div className="text-xs text-ink-faint font-mono">{j.certificate_id}</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={j.status} />
|
<StatusBadge status={j.status} />
|
||||||
</div>
|
</div>
|
||||||
@@ -291,10 +292,10 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* Pending Jobs Banner */}
|
{/* Pending Jobs Banner */}
|
||||||
{pendingJobs > 0 && (
|
{pendingJobs > 0 && (
|
||||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg px-5 py-4 flex items-center justify-between">
|
<div className="bg-brand-50 border border-brand-200 rounded px-5 py-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-blue-400">{pendingJobs} pending job{pendingJobs > 1 ? 's' : ''}</p>
|
<p className="text-sm font-medium text-brand-600">{pendingJobs} pending job{pendingJobs > 1 ? 's' : ''}</p>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">Jobs are waiting to be processed</p>
|
<p className="text-xs text-brand-600/70 mt-0.5">Jobs are waiting to be processed</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => navigate('/jobs')} className="btn btn-primary text-xs">View Jobs</button>
|
<button onClick={() => navigate('/jobs')} className="btn btn-primary text-xs">View Jobs</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export default function IssuersPage() {
|
|||||||
label: 'Issuer',
|
label: 'Issuer',
|
||||||
render: (i) => (
|
render: (i) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-slate-200">{i.name}</div>
|
<div className="font-medium text-ink">{i.name}</div>
|
||||||
<div className="text-xs text-slate-500 font-mono">{i.id}</div>
|
<div className="text-xs text-ink-faint font-mono">{i.id}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -63,9 +63,9 @@ export default function IssuersPage() {
|
|||||||
key: 'config',
|
key: 'config',
|
||||||
label: 'Config',
|
label: 'Config',
|
||||||
render: (i) => {
|
render: (i) => {
|
||||||
if (!i.config || Object.keys(i.config).length === 0) return <span className="text-slate-500">—</span>;
|
if (!i.config || Object.keys(i.config).length === 0) return <span className="text-ink-faint">—</span>;
|
||||||
return (
|
return (
|
||||||
<span className="text-xs text-slate-400 font-mono truncate max-w-xs block">
|
<span className="text-xs text-ink-muted font-mono truncate max-w-xs block">
|
||||||
{JSON.stringify(i.config).slice(0, 60)}
|
{JSON.stringify(i.config).slice(0, 60)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -74,7 +74,7 @@ export default function IssuersPage() {
|
|||||||
{
|
{
|
||||||
key: 'created',
|
key: 'created',
|
||||||
label: 'Created',
|
label: 'Created',
|
||||||
render: (i) => <span className="text-xs text-slate-400">{formatDateTime(i.created_at)}</span>,
|
render: (i) => <span className="text-xs text-ink-muted">{formatDateTime(i.created_at)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@@ -84,13 +84,13 @@ export default function IssuersPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); testMutation.mutate(i.id); }}
|
onClick={(e) => { e.stopPropagation(); testMutation.mutate(i.id); }}
|
||||||
disabled={testMutation.isPending}
|
disabled={testMutation.isPending}
|
||||||
className="text-xs text-blue-400 hover:text-blue-300 transition-colors"
|
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
|
||||||
>
|
>
|
||||||
Test
|
Test
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete issuer ${i.name}?`)) deleteMutation.mutate(i.id); }}
|
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete issuer ${i.name}?`)) deleteMutation.mutate(i.id); }}
|
||||||
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -103,7 +103,7 @@ export default function IssuersPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader title="Issuers" subtitle={data ? `${data.total} issuers` : undefined} />
|
<PageHeader title="Issuers" subtitle={data ? `${data.total} issuers` : undefined} />
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div className={`mx-6 mt-3 rounded-lg px-4 py-3 text-sm ${testResult.ok ? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-400' : 'bg-red-500/10 border border-red-500/20 text-red-400'}`}>
|
<div className={`mx-6 mt-3 rounded px-4 py-3 text-sm ${testResult.ok ? 'bg-emerald-100 border border-emerald-200 text-emerald-700' : 'bg-red-50 border border-red-200 text-red-700'}`}>
|
||||||
{testResult.id}: {testResult.msg}
|
{testResult.id}: {testResult.msg}
|
||||||
<button onClick={() => setTestResult(null)} className="ml-3 text-xs opacity-60 hover:opacity-100">dismiss</button>
|
<button onClick={() => setTestResult(null)} className="ml-3 text-xs opacity-60 hover:opacity-100">dismiss</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,20 +35,20 @@ export default function JobsPage() {
|
|||||||
label: 'Job',
|
label: 'Job',
|
||||||
render: (j) => (
|
render: (j) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-mono text-xs text-slate-200">{j.id}</div>
|
<div className="font-mono text-xs text-ink">{j.id}</div>
|
||||||
<div className="text-xs text-slate-500">{j.type}</div>
|
<div className="text-xs text-ink-faint">{j.type}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
|
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
|
||||||
{ key: 'cert', label: 'Certificate', render: (j) => <span className="text-xs text-slate-400 font-mono">{j.certificate_id}</span> },
|
{ key: 'cert', label: 'Certificate', render: (j) => <span className="text-xs text-ink-muted font-mono">{j.certificate_id}</span> },
|
||||||
{
|
{
|
||||||
key: 'attempts',
|
key: 'attempts',
|
||||||
label: 'Attempts',
|
label: 'Attempts',
|
||||||
render: (j) => <span className="text-slate-300">{j.attempts}/{j.max_attempts}</span>,
|
render: (j) => <span className="text-ink-muted">{j.attempts}/{j.max_attempts}</span>,
|
||||||
},
|
},
|
||||||
{ key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-slate-400">{formatDateTime(j.scheduled_at)}</span> },
|
{ key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.scheduled_at)}</span> },
|
||||||
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-slate-400">{formatDateTime(j.completed_at)}</span> },
|
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
label: '',
|
label: '',
|
||||||
@@ -68,11 +68,11 @@ export default function JobsPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Jobs" subtitle={data ? `${data.total} jobs` : undefined} />
|
<PageHeader title="Jobs" subtitle={data ? `${data.total} jobs` : undefined} />
|
||||||
<div className="px-6 py-3 flex gap-3 border-b border-slate-700/50">
|
<div className="px-6 py-3 flex gap-3 border-b border-surface-border/50">
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={e => setStatusFilter(e.target.value)}
|
onChange={e => setStatusFilter(e.target.value)}
|
||||||
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-300"
|
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
|
||||||
>
|
>
|
||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
<option value="Pending">Pending</option>
|
<option value="Pending">Pending</option>
|
||||||
@@ -84,7 +84,7 @@ export default function JobsPage() {
|
|||||||
<select
|
<select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={e => setTypeFilter(e.target.value)}
|
onChange={e => setTypeFilter(e.target.value)}
|
||||||
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-300"
|
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
|
||||||
>
|
>
|
||||||
<option value="">All types</option>
|
<option value="">All types</option>
|
||||||
<option value="Renewal">Renewal</option>
|
<option value="Renewal">Renewal</option>
|
||||||
|
|||||||
@@ -24,16 +24,16 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
<div className="min-h-screen bg-page flex items-center justify-center px-4">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-3xl font-bold text-blue-400 mb-2">certctl</h1>
|
<h1 className="text-4xl font-bold text-brand-400 mb-2">certctl</h1>
|
||||||
<p className="text-sm text-slate-400 uppercase tracking-wider">Certificate Control Plane</p>
|
<p className="text-sm text-ink-muted uppercase tracking-wider">Certificate Control Plane</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="card p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="bg-surface border border-surface-border rounded p-6 space-y-4 shadow-sm">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="api-key" className="block text-sm font-medium text-slate-300 mb-1.5">
|
<label htmlFor="api-key" className="block text-sm font-medium text-ink-muted mb-1.5">
|
||||||
API Key
|
API Key
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -43,12 +43,12 @@ export default function LoginPage() {
|
|||||||
onChange={(e) => setKey(e.target.value)}
|
onChange={(e) => setKey(e.target.value)}
|
||||||
placeholder="Enter your API key"
|
placeholder="Enter your API key"
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2.5 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2.5 text-sm text-ink placeholder-ink-faint focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2 text-sm text-red-400">
|
<div className="bg-red-50 border border-red-200 rounded px-3 py-2 text-sm text-red-700">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -56,13 +56,13 @@ export default function LoginPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || !key.trim()}
|
disabled={submitting || !key.trim()}
|
||||||
className="w-full btn-primary py-2.5 text-sm font-medium rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full bg-brand-400 hover:bg-brand-500 text-white py-2.5 text-sm font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{submitting ? 'Verifying...' : 'Sign In'}
|
{submitting ? 'Verifying...' : 'Sign In'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-xs text-slate-500 text-center">
|
<p className="text-xs text-ink-muted text-center">
|
||||||
The API key is set via <code className="text-slate-400">CERTCTL_AUTH_SECRET</code> on the server.
|
The API key is set via <code className="text-ink-faint bg-page px-1 py-0.5 rounded">CERTCTL_AUTH_SECRET</code> on the server.
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function NotificationsPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Notifications" />
|
<PageHeader title="Notifications" />
|
||||||
<div className="flex items-center justify-center flex-1 text-slate-400">Loading...</div>
|
<div className="flex items-center justify-center flex-1 text-ink-muted">Loading...</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,17 +80,17 @@ export default function NotificationsPage() {
|
|||||||
title="Notifications"
|
title="Notifications"
|
||||||
subtitle={`${filtered.length} notifications${unreadCount ? ` (${unreadCount} unread)` : ''}`}
|
subtitle={`${filtered.length} notifications${unreadCount ? ` (${unreadCount} unread)` : ''}`}
|
||||||
/>
|
/>
|
||||||
<div className="px-4 py-3 flex flex-wrap items-center gap-3 border-b border-slate-700/50">
|
<div className="px-4 py-3 flex flex-wrap items-center gap-3 border-b border-surface-border/50">
|
||||||
<div className="flex rounded overflow-hidden border border-slate-600">
|
<div className="flex rounded overflow-hidden border border-surface-border">
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('grouped')}
|
onClick={() => setViewMode('grouped')}
|
||||||
className={`px-3 py-1.5 text-xs transition-colors ${viewMode === 'grouped' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:text-slate-200'}`}
|
className={`px-3 py-1.5 text-xs transition-colors ${viewMode === 'grouped' ? 'bg-brand-400 text-white' : 'bg-surface text-ink-muted hover:text-ink'}`}
|
||||||
>
|
>
|
||||||
Grouped
|
Grouped
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('list')}
|
onClick={() => setViewMode('list')}
|
||||||
className={`px-3 py-1.5 text-xs transition-colors ${viewMode === 'list' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:text-slate-200'}`}
|
className={`px-3 py-1.5 text-xs transition-colors ${viewMode === 'list' ? 'bg-brand-400 text-white' : 'bg-surface text-ink-muted hover:text-ink'}`}
|
||||||
>
|
>
|
||||||
List
|
List
|
||||||
</button>
|
</button>
|
||||||
@@ -98,7 +98,7 @@ export default function NotificationsPage() {
|
|||||||
<select
|
<select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={(e) => setTypeFilter(e.target.value)}
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-blue-500"
|
className="bg-surface border border-surface-border rounded px-3 py-1.5 text-xs text-ink focus:outline-none focus:border-brand-400"
|
||||||
>
|
>
|
||||||
<option value="">All types</option>
|
<option value="">All types</option>
|
||||||
{types.map(t => <option key={t} value={t}>{t.replace(/([A-Z])/g, ' $1').trim()}</option>)}
|
{types.map(t => <option key={t} value={t}>{t.replace(/([A-Z])/g, ' $1').trim()}</option>)}
|
||||||
@@ -106,7 +106,7 @@ export default function NotificationsPage() {
|
|||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-blue-500"
|
className="bg-surface border border-surface-border rounded px-3 py-1.5 text-xs text-ink focus:outline-none focus:border-brand-400"
|
||||||
>
|
>
|
||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
{statuses.map(s => <option key={s} value={s}>{s}</option>)}
|
{statuses.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
@@ -114,7 +114,7 @@ export default function NotificationsPage() {
|
|||||||
{(typeFilter || statusFilter) && (
|
{(typeFilter || statusFilter) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setTypeFilter(''); setStatusFilter(''); }}
|
onClick={() => { setTypeFilter(''); setStatusFilter(''); }}
|
||||||
className="text-xs text-slate-400 hover:text-slate-200 transition-colors"
|
className="text-xs text-ink-muted hover:text-ink transition-colors"
|
||||||
>
|
>
|
||||||
Clear filters
|
Clear filters
|
||||||
</button>
|
</button>
|
||||||
@@ -123,15 +123,15 @@ export default function NotificationsPage() {
|
|||||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
{viewMode === 'grouped' ? (
|
{viewMode === 'grouped' ? (
|
||||||
grouped.length === 0 ? (
|
grouped.length === 0 ? (
|
||||||
<div className="text-center py-16 text-slate-500">No notifications</div>
|
<div className="text-center py-16 text-ink-faint">No notifications</div>
|
||||||
) : (
|
) : (
|
||||||
grouped.map(([certId, items]) => (
|
grouped.map(([certId, items]) => (
|
||||||
<div key={certId} className="card p-4">
|
<div key={certId} className="card p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-xs font-mono text-slate-400">
|
<span className="text-xs font-mono text-ink-muted">
|
||||||
{certId === 'general' ? 'General' : certId}
|
{certId === 'general' ? 'General' : certId}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-slate-500">{items.length} notification{items.length !== 1 ? 's' : ''}</span>
|
<span className="text-xs text-ink-faint">{items.length} notification{items.length !== 1 ? 's' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{items.map((n) => (
|
{items.map((n) => (
|
||||||
@@ -143,7 +143,7 @@ export default function NotificationsPage() {
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
filtered.length === 0 ? (
|
filtered.length === 0 ? (
|
||||||
<div className="text-center py-16 text-slate-500">No notifications</div>
|
<div className="text-center py-16 text-ink-faint">No notifications</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filtered.map((n) => (
|
{filtered.map((n) => (
|
||||||
@@ -160,23 +160,23 @@ export default function NotificationsPage() {
|
|||||||
function NotificationRow({ notification: n, onMarkRead }: { notification: Notification; onMarkRead: () => void }) {
|
function NotificationRow({ notification: n, onMarkRead }: { notification: Notification; onMarkRead: () => void }) {
|
||||||
const isUnread = n.status === 'Pending' || n.status === 'pending';
|
const isUnread = n.status === 'Pending' || n.status === 'pending';
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-start justify-between py-2 px-3 rounded-lg transition-colors ${isUnread ? 'bg-slate-700/30 border-l-2 border-blue-500' : 'hover:bg-slate-700/20'}`}>
|
<div className={`flex items-start justify-between py-2 px-3 rounded transition-colors ${isUnread ? 'bg-surface-muted border-l-2 border-brand-400' : 'hover:bg-surface-muted'}`}>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-sm text-slate-200">{n.type.replace(/([A-Z])/g, ' $1').trim()}</span>
|
<span className="text-sm text-ink">{n.type.replace(/([A-Z])/g, ' $1').trim()}</span>
|
||||||
<StatusBadge status={n.status} />
|
<StatusBadge status={n.status} />
|
||||||
<span className="text-xs text-slate-500">{n.channel}</span>
|
<span className="text-xs text-ink-faint">{n.channel}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 truncate">{n.message || n.subject}</p>
|
<p className="text-xs text-ink-muted truncate">{n.message || n.subject}</p>
|
||||||
<div className="flex items-center gap-3 mt-1">
|
<div className="flex items-center gap-3 mt-1">
|
||||||
<span className="text-xs text-slate-500">{n.recipient}</span>
|
<span className="text-xs text-ink-faint">{n.recipient}</span>
|
||||||
<span className="text-xs text-slate-600">{timeAgo(n.created_at)}</span>
|
<span className="text-xs text-ink-faint">{timeAgo(n.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onMarkRead(); }}
|
onClick={(e) => { e.stopPropagation(); onMarkRead(); }}
|
||||||
className="ml-3 text-xs text-blue-400 hover:text-blue-300 transition-colors whitespace-nowrap"
|
className="ml-3 text-xs text-brand-400 hover:text-brand-500 transition-colors whitespace-nowrap"
|
||||||
>
|
>
|
||||||
Mark read
|
Mark read
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default function OwnersPage() {
|
|||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: deleteOwner,
|
mutationFn: deleteOwner,
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['owners'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['owners'] }),
|
||||||
|
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const teamMap = new Map<string, Team>();
|
const teamMap = new Map<string, Team>();
|
||||||
@@ -34,15 +35,15 @@ export default function OwnersPage() {
|
|||||||
label: 'Owner',
|
label: 'Owner',
|
||||||
render: (o) => (
|
render: (o) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-slate-200">{o.name}</div>
|
<div className="font-medium text-ink">{o.name}</div>
|
||||||
<div className="text-xs text-slate-500 font-mono">{o.id}</div>
|
<div className="text-xs text-ink-faint font-mono">{o.id}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'email',
|
key: 'email',
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
render: (o) => <span className="text-slate-300">{o.email || '\u2014'}</span>,
|
render: (o) => <span className="text-ink">{o.email || '\u2014'}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'team',
|
key: 'team',
|
||||||
@@ -50,14 +51,14 @@ export default function OwnersPage() {
|
|||||||
render: (o) => {
|
render: (o) => {
|
||||||
const team = teamMap.get(o.team_id);
|
const team = teamMap.get(o.team_id);
|
||||||
return team
|
return team
|
||||||
? <span className="text-blue-400">{team.name}</span>
|
? <span className="text-brand-400">{team.name}</span>
|
||||||
: <span className="text-slate-500 font-mono text-xs">{o.team_id || '\u2014'}</span>;
|
: <span className="text-ink-faint font-mono text-xs">{o.team_id || '\u2014'}</span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'created',
|
key: 'created',
|
||||||
label: 'Created',
|
label: 'Created',
|
||||||
render: (o) => <span className="text-xs text-slate-400">{formatDateTime(o.created_at)}</span>,
|
render: (o) => <span className="text-xs text-ink-muted">{formatDateTime(o.created_at)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@@ -65,7 +66,7 @@ export default function OwnersPage() {
|
|||||||
render: (o) => (
|
render: (o) => (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete owner ${o.name}?`)) deleteMutation.mutate(o.id); }}
|
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete owner ${o.name}?`)) deleteMutation.mutate(o.id); }}
|
||||||
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ const severityStyles: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const severityDots: Record<string, string> = {
|
const severityDots: Record<string, string> = {
|
||||||
low: 'bg-blue-400',
|
low: 'bg-emerald-500',
|
||||||
medium: 'bg-amber-400',
|
medium: 'bg-amber-500',
|
||||||
high: 'bg-orange-400',
|
high: 'bg-orange-500',
|
||||||
critical: 'bg-red-400',
|
critical: 'bg-red-500',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PoliciesPage() {
|
export default function PoliciesPage() {
|
||||||
@@ -52,12 +52,12 @@ export default function PoliciesPage() {
|
|||||||
label: 'Rule',
|
label: 'Rule',
|
||||||
render: (p) => (
|
render: (p) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-slate-200">{p.name}</div>
|
<div className="font-medium text-ink">{p.name}</div>
|
||||||
<div className="text-xs text-slate-500">{p.id}</div>
|
<div className="text-xs text-ink-faint">{p.id}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: 'type', label: 'Type', render: (p) => <span className="text-sm text-slate-300">{p.type.replace(/_/g, ' ')}</span> },
|
{ key: 'type', label: 'Type', render: (p) => <span className="text-sm text-ink">{p.type.replace(/_/g, ' ')}</span> },
|
||||||
{
|
{
|
||||||
key: 'severity',
|
key: 'severity',
|
||||||
label: 'Severity',
|
label: 'Severity',
|
||||||
@@ -67,9 +67,9 @@ export default function PoliciesPage() {
|
|||||||
key: 'config',
|
key: 'config',
|
||||||
label: 'Config',
|
label: 'Config',
|
||||||
render: (p) => {
|
render: (p) => {
|
||||||
if (!p.config || Object.keys(p.config).length === 0) return <span className="text-slate-500">—</span>;
|
if (!p.config || Object.keys(p.config).length === 0) return <span className="text-ink-faint">—</span>;
|
||||||
return (
|
return (
|
||||||
<span className="text-xs text-slate-400 font-mono truncate max-w-xs block">
|
<span className="text-xs text-ink-muted font-mono truncate max-w-xs block">
|
||||||
{JSON.stringify(p.config).slice(0, 50)}
|
{JSON.stringify(p.config).slice(0, 50)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -81,20 +81,20 @@ export default function PoliciesPage() {
|
|||||||
render: (p) => (
|
render: (p) => (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); toggleMutation.mutate({ id: p.id, enabled: !p.enabled }); }}
|
onClick={(e) => { e.stopPropagation(); toggleMutation.mutate({ id: p.id, enabled: !p.enabled }); }}
|
||||||
className={`text-xs font-medium transition-colors ${p.enabled ? 'text-emerald-400 hover:text-emerald-300' : 'text-slate-500 hover:text-slate-300'}`}
|
className={`text-xs font-medium transition-colors ${p.enabled ? 'text-emerald-600 hover:text-emerald-700' : 'text-ink-faint hover:text-ink-muted'}`}
|
||||||
>
|
>
|
||||||
{p.enabled ? 'Enabled' : 'Disabled'}
|
{p.enabled ? 'Enabled' : 'Disabled'}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: 'created', label: 'Created', render: (p) => <span className="text-xs text-slate-400">{formatDateTime(p.created_at)}</span> },
|
{ key: 'created', label: 'Created', render: (p) => <span className="text-xs text-ink-muted">{formatDateTime(p.created_at)}</span> },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
label: '',
|
label: '',
|
||||||
render: (p) => (
|
render: (p) => (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete policy ${p.name}?`)) deleteMutation.mutate(p.id); }}
|
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete policy ${p.name}?`)) deleteMutation.mutate(p.id); }}
|
||||||
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -106,18 +106,18 @@ export default function PoliciesPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader title="Policies" subtitle={data ? `${data.total} rules` : undefined} />
|
<PageHeader title="Policies" subtitle={data ? `${data.total} rules` : undefined} />
|
||||||
{policies.length > 0 && (
|
{policies.length > 0 && (
|
||||||
<div className="px-4 py-3 flex flex-wrap gap-4 border-b border-slate-700/50">
|
<div className="px-4 py-3 flex flex-wrap gap-4 border-b border-surface-border/50">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-slate-400">Enabled:</span>
|
<span className="text-xs text-ink-muted">Enabled:</span>
|
||||||
<span className="text-xs font-medium text-emerald-400">{enabledCount}</span>
|
<span className="text-xs font-medium text-emerald-600">{enabledCount}</span>
|
||||||
<span className="text-xs text-slate-600">/</span>
|
<span className="text-xs text-ink-faint">/</span>
|
||||||
<span className="text-xs text-slate-400">{policies.length}</span>
|
<span className="text-xs text-ink-muted">{policies.length}</span>
|
||||||
</div>
|
</div>
|
||||||
{Object.entries(bySeverity).map(([sev, count]) => (
|
{Object.entries(bySeverity).map(([sev, count]) => (
|
||||||
<div key={sev} className="flex items-center gap-1.5">
|
<div key={sev} className="flex items-center gap-1.5">
|
||||||
<div className={`w-2 h-2 rounded-full ${severityDots[sev] || 'bg-slate-400'}`} />
|
<div className={`w-2 h-2 rounded-full ${severityDots[sev] || 'bg-slate-400'}`} />
|
||||||
<span className="text-xs text-slate-300 capitalize">{sev}</span>
|
<span className="text-xs text-ink capitalize">{sev}</span>
|
||||||
<span className="text-xs text-slate-500">{count}</span>
|
<span className="text-xs text-ink-faint">{count}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ export default function ProfilesPage() {
|
|||||||
label: 'Profile',
|
label: 'Profile',
|
||||||
render: (p) => (
|
render: (p) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-slate-200">{p.name}</div>
|
<div className="font-medium text-ink">{p.name}</div>
|
||||||
<div className="text-xs text-slate-500 font-mono">{p.id}</div>
|
<div className="text-xs text-ink-faint font-mono">{p.id}</div>
|
||||||
{p.description && (
|
{p.description && (
|
||||||
<div className="text-xs text-slate-400 mt-0.5 max-w-xs truncate">{p.description}</div>
|
<div className="text-xs text-ink-muted mt-0.5 max-w-xs truncate">{p.description}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -61,9 +61,9 @@ export default function ProfilesPage() {
|
|||||||
label: 'Max TTL',
|
label: 'Max TTL',
|
||||||
render: (p) => (
|
render: (p) => (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-200">{formatTTL(p.max_ttl_seconds)}</span>
|
<span className="text-ink">{formatTTL(p.max_ttl_seconds)}</span>
|
||||||
{p.allow_short_lived && (
|
{p.allow_short_lived && (
|
||||||
<span className="ml-2 text-xs text-amber-400 bg-amber-400/10 px-1.5 py-0.5 rounded">
|
<span className="ml-2 text-xs text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded">
|
||||||
short-lived
|
short-lived
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -76,7 +76,7 @@ export default function ProfilesPage() {
|
|||||||
render: (p) => (
|
render: (p) => (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{(p.allowed_ekus || []).map((eku, i) => (
|
{(p.allowed_ekus || []).map((eku, i) => (
|
||||||
<span key={i} className="text-xs text-slate-400">{eku}</span>
|
<span key={i} className="text-xs text-ink-muted">{eku}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -86,8 +86,8 @@ export default function ProfilesPage() {
|
|||||||
label: 'SPIFFE',
|
label: 'SPIFFE',
|
||||||
render: (p) => (
|
render: (p) => (
|
||||||
p.spiffe_uri_pattern
|
p.spiffe_uri_pattern
|
||||||
? <span className="text-xs text-blue-400 font-mono">{p.spiffe_uri_pattern}</span>
|
? <span className="text-xs text-brand-400 font-mono">{p.spiffe_uri_pattern}</span>
|
||||||
: <span className="text-slate-500">—</span>
|
: <span className="text-ink-faint">—</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -98,7 +98,7 @@ export default function ProfilesPage() {
|
|||||||
{
|
{
|
||||||
key: 'created',
|
key: 'created',
|
||||||
label: 'Created',
|
label: 'Created',
|
||||||
render: (p) => <span className="text-xs text-slate-400">{formatDateTime(p.created_at)}</span>,
|
render: (p) => <span className="text-xs text-ink-muted">{formatDateTime(p.created_at)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@@ -106,7 +106,7 @@ export default function ProfilesPage() {
|
|||||||
render: (p) => (
|
render: (p) => (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete profile ${p.name}?`)) deleteMutation.mutate(p.id); }}
|
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete profile ${p.name}?`)) deleteMutation.mutate(p.id); }}
|
||||||
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ function formatTTL(seconds: number): string {
|
|||||||
function ttlRemaining(expiresAt: string): { text: string; color: string; seconds: number } {
|
function ttlRemaining(expiresAt: string): { text: string; color: string; seconds: number } {
|
||||||
const diff = new Date(expiresAt).getTime() - Date.now();
|
const diff = new Date(expiresAt).getTime() - Date.now();
|
||||||
const secs = Math.floor(diff / 1000);
|
const secs = Math.floor(diff / 1000);
|
||||||
if (secs <= 0) return { text: 'Expired', color: 'text-red-400', seconds: 0 };
|
if (secs <= 0) return { text: 'Expired', color: 'text-red-600', seconds: 0 };
|
||||||
if (secs < 300) return { text: `${secs}s`, color: 'text-red-400', seconds: secs };
|
if (secs < 300) return { text: `${secs}s`, color: 'text-red-600', seconds: secs };
|
||||||
if (secs < 1800) return { text: `${Math.round(secs / 60)}m`, color: 'text-amber-400', seconds: secs };
|
if (secs < 1800) return { text: `${Math.round(secs / 60)}m`, color: 'text-amber-600', seconds: secs };
|
||||||
return { text: formatTTL(secs), color: 'text-emerald-400', seconds: secs };
|
return { text: formatTTL(secs), color: 'text-emerald-600', seconds: secs };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ShortLivedPage() {
|
export default function ShortLivedPage() {
|
||||||
@@ -75,8 +75,8 @@ export default function ShortLivedPage() {
|
|||||||
label: 'Certificate',
|
label: 'Certificate',
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-slate-200">{c.common_name}</div>
|
<div className="font-medium text-ink">{c.common_name}</div>
|
||||||
<div className="text-xs text-slate-500 mt-0.5">{c.id}</div>
|
<div className="text-xs text-ink-faint mt-0.5">{c.id}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -103,15 +103,15 @@ export default function ShortLivedPage() {
|
|||||||
const profile = profileMap.get(c.certificate_profile_id);
|
const profile = profileMap.get(c.certificate_profile_id);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-slate-300">{profile?.name || c.certificate_profile_id || '—'}</div>
|
<div className="text-sm text-ink">{profile?.name || c.certificate_profile_id || '—'}</div>
|
||||||
{profile && <div className="text-xs text-slate-500">Max TTL: {formatTTL(profile.max_ttl_seconds)}</div>}
|
{profile && <div className="text-xs text-ink-faint">Max TTL: {formatTTL(profile.max_ttl_seconds)}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ key: 'env', label: 'Environment', render: (c) => <span className="text-slate-300">{c.environment || '—'}</span> },
|
{ key: 'env', label: 'Environment', render: (c) => <span className="text-ink">{c.environment || '—'}</span> },
|
||||||
{ key: 'issuer', label: 'Issuer', render: (c) => <span className="text-slate-400 text-xs">{c.issuer_id}</span> },
|
{ key: 'issuer', label: 'Issuer', render: (c) => <span className="text-ink-muted text-xs">{c.issuer_id}</span> },
|
||||||
{ key: 'expires', label: 'Expires At', render: (c) => <span className="text-xs text-slate-400">{formatDateTime(c.expires_at)}</span> },
|
{ key: 'expires', label: 'Expires At', render: (c) => <span className="text-xs text-ink-muted">{formatDateTime(c.expires_at)}</span> },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -121,21 +121,21 @@ export default function ShortLivedPage() {
|
|||||||
subtitle={`${shortLivedCerts.length} active ephemeral certificates`}
|
subtitle={`${shortLivedCerts.length} active ephemeral certificates`}
|
||||||
/>
|
/>
|
||||||
{/* Stats bar */}
|
{/* Stats bar */}
|
||||||
<div className="px-6 py-3 flex gap-6 border-b border-slate-700/50">
|
<div className="px-6 py-3 flex gap-6 border-b border-surface-border/50">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-emerald-400" />
|
<div className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||||
<span className="text-xs text-slate-400">Active:</span>
|
<span className="text-xs text-ink-muted">Active:</span>
|
||||||
<span className="text-xs font-medium text-emerald-400">{active}</span>
|
<span className="text-xs font-medium text-emerald-600">{active}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-red-400" />
|
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||||
<span className="text-xs text-slate-400">Expired:</span>
|
<span className="text-xs text-ink-muted">Expired:</span>
|
||||||
<span className="text-xs font-medium text-red-400">{expired}</span>
|
<span className="text-xs font-medium text-red-600">{expired}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-blue-400" />
|
<div className="w-2 h-2 rounded-full bg-brand-400" />
|
||||||
<span className="text-xs text-slate-400">Profiles:</span>
|
<span className="text-xs text-ink-muted">Profiles:</span>
|
||||||
<span className="text-xs font-medium text-blue-400">{profiles.size}</span>
|
<span className="text-xs font-medium text-brand-400">{profiles.size}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
|
|||||||
const canProceedToReview = name && targetType && fields.filter(f => f.required).every(f => config[f.key]);
|
const canProceedToReview = name && targetType && fields.filter(f => f.required).every(f => config[f.key]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
<div className="bg-slate-800 border border-slate-600 rounded-xl p-6 w-full max-w-lg shadow-2xl" onClick={e => e.stopPropagation()}>
|
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-lg shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
{/* Step indicators */}
|
{/* Step indicators */}
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
{['Select Type', 'Configure', 'Review'].map((label, i) => {
|
{['Select Type', 'Configure', 'Review'].map((label, i) => {
|
||||||
@@ -93,36 +93,36 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
|
|||||||
return (
|
return (
|
||||||
<div key={label} className="flex items-center gap-2">
|
<div key={label} className="flex items-center gap-2">
|
||||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
|
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||||
isDone ? 'bg-emerald-500 text-white' : isActive ? 'bg-blue-500 text-white' : 'bg-slate-700 text-slate-400'
|
isDone ? 'bg-emerald-600 text-white' : isActive ? 'bg-brand-400 text-white' : 'bg-surface-border text-ink-muted'
|
||||||
}`}>
|
}`}>
|
||||||
{isDone ? '✓' : i + 1}
|
{isDone ? '✓' : i + 1}
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs ${isActive ? 'text-slate-200' : 'text-slate-500'}`}>{label}</span>
|
<span className={`text-xs ${isActive ? 'text-ink' : 'text-ink-faint'}`}>{label}</span>
|
||||||
{i < 2 && <div className="w-8 h-px bg-slate-700" />}
|
{i < 2 && <div className="w-8 h-px bg-surface-border" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-4">{error}</div>}
|
{error && <div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-4">{error}</div>}
|
||||||
|
|
||||||
{/* Step 1: Select Type */}
|
{/* Step 1: Select Type */}
|
||||||
{step === 'type' && (
|
{step === 'type' && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-slate-200 mb-4">Select Target Type</h2>
|
<h2 className="text-lg font-semibold text-ink mb-4">Select Target Type</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{TARGET_TYPES.map(t => (
|
{TARGET_TYPES.map(t => (
|
||||||
<button
|
<button
|
||||||
key={t.value}
|
key={t.value}
|
||||||
onClick={() => { setTargetType(t.value); setConfig({}); }}
|
onClick={() => { setTargetType(t.value); setConfig({}); }}
|
||||||
className={`w-full text-left px-4 py-3 rounded-lg border transition-colors ${
|
className={`w-full text-left px-4 py-3 rounded border transition-colors ${
|
||||||
targetType === t.value
|
targetType === t.value
|
||||||
? 'border-blue-500 bg-blue-500/10'
|
? 'border-brand-400 bg-brand-50'
|
||||||
: 'border-slate-600 hover:border-slate-500 bg-slate-900'
|
: 'border-surface-border hover:border-surface-border bg-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="text-sm font-medium text-slate-200">{t.label}</div>
|
<div className="text-sm font-medium text-ink">{t.label}</div>
|
||||||
<div className="text-xs text-slate-400 mt-0.5">{t.description}</div>
|
<div className="text-xs text-ink-muted mt-0.5">{t.description}</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -137,35 +137,35 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
|
|||||||
{/* Step 2: Configure */}
|
{/* Step 2: Configure */}
|
||||||
{step === 'config' && (
|
{step === 'config' && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-slate-200 mb-4">
|
<h2 className="text-lg font-semibold text-ink mb-4">
|
||||||
Configure {typeLabels[targetType] || targetType} Target
|
Configure {typeLabels[targetType] || targetType} Target
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-slate-400 block mb-1">Target Name *</label>
|
<label className="text-xs text-ink-muted block mb-1">Target Name *</label>
|
||||||
<input value={name} onChange={e => setName(e.target.value)}
|
<input value={name} onChange={e => setName(e.target.value)}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||||
placeholder="web-server-1" />
|
placeholder="web-server-1" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-slate-400 block mb-1">Hostname</label>
|
<label className="text-xs text-ink-muted block mb-1">Hostname</label>
|
||||||
<input value={hostname} onChange={e => setHostname(e.target.value)}
|
<input value={hostname} onChange={e => setHostname(e.target.value)}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||||
placeholder="web1.example.com" />
|
placeholder="web1.example.com" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-slate-400 block mb-1">Agent ID</label>
|
<label className="text-xs text-ink-muted block mb-1">Agent ID</label>
|
||||||
<input value={agentId} onChange={e => setAgentId(e.target.value)}
|
<input value={agentId} onChange={e => setAgentId(e.target.value)}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||||
placeholder="agent-web1" />
|
placeholder="agent-web1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{fields.map(f => (
|
{fields.map(f => (
|
||||||
<div key={f.key}>
|
<div key={f.key}>
|
||||||
<label className="text-xs text-slate-400 block mb-1">{f.label} {f.required ? '*' : ''}</label>
|
<label className="text-xs text-ink-muted block mb-1">{f.label} {f.required ? '*' : ''}</label>
|
||||||
<input value={config[f.key] || ''} onChange={e => setConfig(c => ({ ...c, [f.key]: e.target.value }))}
|
<input value={config[f.key] || ''} onChange={e => setConfig(c => ({ ...c, [f.key]: e.target.value }))}
|
||||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||||
placeholder={f.placeholder} />
|
placeholder={f.placeholder} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -184,32 +184,32 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
|
|||||||
{/* Step 3: Review */}
|
{/* Step 3: Review */}
|
||||||
{step === 'review' && (
|
{step === 'review' && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-slate-200 mb-4">Review Target</h2>
|
<h2 className="text-lg font-semibold text-ink mb-4">Review Target</h2>
|
||||||
<div className="bg-slate-900 rounded-lg p-4 space-y-2 text-sm">
|
<div className="bg-page rounded p-4 space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-slate-400">Name</span>
|
<span className="text-ink-muted">Name</span>
|
||||||
<span className="text-slate-200">{name}</span>
|
<span className="text-ink">{name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-slate-400">Type</span>
|
<span className="text-ink-muted">Type</span>
|
||||||
<span className="text-slate-200">{typeLabels[targetType] || targetType}</span>
|
<span className="text-ink">{typeLabels[targetType] || targetType}</span>
|
||||||
</div>
|
</div>
|
||||||
{hostname && (
|
{hostname && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-slate-400">Hostname</span>
|
<span className="text-ink-muted">Hostname</span>
|
||||||
<span className="text-slate-200 font-mono text-xs">{hostname}</span>
|
<span className="text-ink font-mono text-xs">{hostname}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{agentId && (
|
{agentId && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-slate-400">Agent</span>
|
<span className="text-ink-muted">Agent</span>
|
||||||
<span className="text-slate-200 font-mono text-xs">{agentId}</span>
|
<span className="text-ink font-mono text-xs">{agentId}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{Object.entries(config).filter(([, v]) => v).map(([k, v]) => (
|
{Object.entries(config).filter(([, v]) => v).map(([k, v]) => (
|
||||||
<div key={k} className="flex justify-between">
|
<div key={k} className="flex justify-between">
|
||||||
<span className="text-slate-400">{k.replace(/_/g, ' ')}</span>
|
<span className="text-ink-muted">{k.replace(/_/g, ' ')}</span>
|
||||||
<span className="text-slate-200 font-mono text-xs truncate max-w-xs">{v}</span>
|
<span className="text-ink font-mono text-xs truncate max-w-xs">{v}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -250,8 +250,8 @@ export default function TargetsPage() {
|
|||||||
label: 'Target',
|
label: 'Target',
|
||||||
render: (t) => (
|
render: (t) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-slate-200">{t.name}</div>
|
<div className="font-medium text-ink">{t.name}</div>
|
||||||
<div className="text-xs text-slate-500 font-mono">{t.id}</div>
|
<div className="text-xs text-ink-faint font-mono">{t.id}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -265,12 +265,12 @@ export default function TargetsPage() {
|
|||||||
{
|
{
|
||||||
key: 'hostname',
|
key: 'hostname',
|
||||||
label: 'Hostname',
|
label: 'Hostname',
|
||||||
render: (t) => <span className="text-slate-300 font-mono text-xs">{t.hostname || '\u2014'}</span>,
|
render: (t) => <span className="text-ink font-mono text-xs">{t.hostname || '\u2014'}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'agent',
|
key: 'agent',
|
||||||
label: 'Agent',
|
label: 'Agent',
|
||||||
render: (t) => <span className="text-xs text-slate-400 font-mono">{t.agent_id || '\u2014'}</span>,
|
render: (t) => <span className="text-xs text-ink-muted font-mono">{t.agent_id || '\u2014'}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
@@ -280,7 +280,7 @@ export default function TargetsPage() {
|
|||||||
{
|
{
|
||||||
key: 'created',
|
key: 'created',
|
||||||
label: 'Created',
|
label: 'Created',
|
||||||
render: (t) => <span className="text-xs text-slate-400">{formatDateTime(t.created_at)}</span>,
|
render: (t) => <span className="text-xs text-ink-muted">{formatDateTime(t.created_at)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@@ -288,7 +288,7 @@ export default function TargetsPage() {
|
|||||||
render: (t) => (
|
render: (t) => (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete target ${t.name}?`)) deleteMutation.mutate(t.id); }}
|
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete target ${t.name}?`)) deleteMutation.mutate(t.id); }}
|
||||||
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default function TeamsPage() {
|
|||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: deleteTeam,
|
mutationFn: deleteTeam,
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['teams'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['teams'] }),
|
||||||
|
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const columns: Column<Team>[] = [
|
const columns: Column<Team>[] = [
|
||||||
@@ -26,8 +27,8 @@ export default function TeamsPage() {
|
|||||||
label: 'Team',
|
label: 'Team',
|
||||||
render: (t) => (
|
render: (t) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-slate-200">{t.name}</div>
|
<div className="font-medium text-ink">{t.name}</div>
|
||||||
<div className="text-xs text-slate-500 font-mono">{t.id}</div>
|
<div className="text-xs text-ink-faint font-mono">{t.id}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -35,13 +36,13 @@ export default function TeamsPage() {
|
|||||||
key: 'description',
|
key: 'description',
|
||||||
label: 'Description',
|
label: 'Description',
|
||||||
render: (t) => (
|
render: (t) => (
|
||||||
<span className="text-slate-300 text-sm max-w-sm truncate block">{t.description || '\u2014'}</span>
|
<span className="text-ink text-sm max-w-sm truncate block">{t.description || '\u2014'}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'created',
|
key: 'created',
|
||||||
label: 'Created',
|
label: 'Created',
|
||||||
render: (t) => <span className="text-xs text-slate-400">{formatDateTime(t.created_at)}</span>,
|
render: (t) => <span className="text-xs text-ink-muted">{formatDateTime(t.created_at)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@@ -49,7 +50,7 @@ export default function TeamsPage() {
|
|||||||
render: (t) => (
|
render: (t) => (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete team ${t.name}?`)) deleteMutation.mutate(t.id); }}
|
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete team ${t.name}?`)) deleteMutation.mutate(t.id); }}
|
||||||
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,7 +6,59 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// === certctl brand palette (from logo) ===
|
||||||
|
brand: {
|
||||||
|
50: '#eefbf6',
|
||||||
|
100: '#d5f5e9',
|
||||||
|
200: '#afe9d5',
|
||||||
|
300: '#7ad8bc',
|
||||||
|
400: '#2ea88f', // Primary teal — logo "ctl"
|
||||||
|
500: '#1f9680',
|
||||||
|
600: '#147868',
|
||||||
|
700: '#106055',
|
||||||
|
800: '#0f4d44',
|
||||||
|
900: '#0d3f39',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
blue: '#3b7dd8', // Logo blue arrows
|
||||||
|
orange: '#e8873a', // Logo orange arrows
|
||||||
|
green: '#4ebe6e', // Logo green highlights
|
||||||
|
},
|
||||||
|
// Light content area
|
||||||
|
page: '#f0f4f8', // Light blue-gray page background
|
||||||
|
surface: {
|
||||||
|
DEFAULT: '#ffffff', // Cards — white
|
||||||
|
hover: '#f8fafc', // Hover on cards
|
||||||
|
border: '#e2e8f0', // Card/table borders
|
||||||
|
muted: '#f1f5f9', // Zebra stripes, subtle fills
|
||||||
|
},
|
||||||
|
// Dark sidebar
|
||||||
|
sidebar: {
|
||||||
|
DEFAULT: '#0c2e25', // Deep teal-black
|
||||||
|
hover: '#134438',
|
||||||
|
active: '#185c4a',
|
||||||
|
border: '#1a5c48',
|
||||||
|
text: '#94d2be', // Muted teal for inactive nav
|
||||||
|
},
|
||||||
|
// Text on light backgrounds
|
||||||
|
ink: {
|
||||||
|
DEFAULT: '#1e293b', // Primary text
|
||||||
|
muted: '#64748b', // Secondary text
|
||||||
|
faint: '#94a3b8', // Tertiary/placeholder
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
DEFAULT: '0.375rem',
|
||||||
|
sm: '0.25rem',
|
||||||
|
md: '0.5rem',
|
||||||
|
lg: '0.75rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}
|
}
|
||||||
|
|||||||