Compare commits

...

27 Commits

Author SHA1 Message Date
shankar0123 ef92b07448 docs: update enterprise comparison to 80% of capabilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 20:33:03 -04:00
shankar0123 5b301f9354 docs: remove open-source competitor comparisons from why-certctl
Keep only paid competitors (CertKit, KeyTalk, Venafi/Keyfactor).
Remove ACME clients, Certimate, CZERTAINLY, cert-manager sections
to avoid driving traffic to free alternatives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 20:31:38 -04:00
shankar0123 2e297b430e docs: compress why-certctl comparisons to one paragraph each
Replace verbose bullet-list comparisons with dense single-paragraph
summaries for all 7 competitors. Each paragraph covers what the tool
is, what it lacks vs certctl, and where it leads. 48 lines cut.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 20:30:11 -04:00
shankar0123 7bc6ad9823 docs: tighten README and why-certctl for scannability
README: Remove Contents section (GitHub auto-generates ToC), replace
12-bullet Core capabilities block with link to Feature Inventory,
replace 21-row Database Schema table with one-liner linking to
Architecture Guide. Visitors now hit screenshots ~60 lines sooner.

why-certctl: Remove Feature Summary section (duplicated README and
Feature Inventory content). Competitive comparisons remain as the
focused value of this page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 20:27:24 -04:00
shankar0123 6ccdf45179 docs: remove comparison tables from README and why-certctl
The detailed prose comparisons in why-certctl.md are sufficient.
Tables were redundant with the per-competitor sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 20:24:19 -04:00
shankar0123 69483786aa fix: restore Contents as vertical bulleted list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 20:21:11 -04:00
shankar0123 1f5ab16b18 fix: render Contents as inline text instead of bullet list
Remove list markers so dot-separated links flow as a single line
on GitHub instead of rendering as three bullet points.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 20:19:54 -04:00
shankar0123 a8d04cded4 docs: expand competitive comparison with CertWarden, Certimate, CZERTAINLY, KeyTalk
README: Replace old 5-column comparison table with 7-competitor table
(certctl, CertKit, CertWarden, Certimate, CZERTAINLY, KeyTalk, cert-manager)
with Free tier row. Remove CertKit from documentation table link text.
Version badge v2.0.4 → v2.0.5, add Why certctl? and Feature Inventory
to docs table, condense ToC, trim Configuration/API/Roadmap sections
with links to detailed docs.

why-certctl.md: Add detailed comparison sections for Certimate (cloud/CDN
focus, no agent, ACME-only), CZERTAINLY (K8s-required microservices,
pluggable connectors, broader vision), and KeyTalk (proprietary, multi-cert-type,
no public docs). Add 14-row summary comparison table covering all 7 competitors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 20:18:23 -04:00
shankar0123 8308beb5bb fix: Docker Compose missing migrations, network scan []int crash, demo seed data
Three bugs fixed:
- Docker Compose only mounted migration 000001; migrations 000002-000007
  (profiles, agent groups, revocation, discovery, network scans) never ran,
  breaking half the demo features. Now mounts all 7 migrations in order.
- Network Scans page crashed with pq.Array scan error because lib/pq
  doesn't support []int, only []int64. Changed Ports field accordingly.
- Dashboard pie chart displayed "RenewalInProgress" without spaces.
  Added formatStatus() helper for PascalCase → spaced display.

Also adds first-run demo experience improvements:
- 9 discovered certificates (filesystem + network scan mix)
- 3 discovery scans with recent timestamps
- 2 AwaitingApproval renewal jobs for approval workflow demo
- CERTCTL_NETWORK_SCAN_ENABLED=true in Docker Compose
- Network scan targets seeded with last_scan results
- Version badge updated to v2.0.5
- Docs updated (quickstart, advanced demo) to reference seeded data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 18:33:50 -04:00
shankar0123 b9633e5b1a docs: add GUI references to discovery and network scan documentation
Update concepts.md and connectors.md to mention the Discovery and
Network Scans dashboard pages alongside existing API documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 16:19:14 -04:00
shankar0123 d55807947e docs: add M24 GUI tests to testing guide (discovery, network scan, approval)
Adds Part 19.5 (approval workflow), 19.6 (discovery triage),
19.7 (network scan management) to GUI testing section. Renumbers
existing 19.5 Other Pages to 19.8 and Cross-Cutting to 19.9.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 16:12:36 -04:00
shankar0123 d9fd0a147e feat(gui): add discovery triage, network scan management, and approval workflow pages (M24)
Three new GUI surfaces closing the backend-to-frontend gap for V2:

- Discovery triage page: summary stats bar, DataTable with claim/dismiss
  actions, status/agent filters, collapsible scan history panel
- Network scan target management: CRUD with create modal, enable/disable
  toggle, Scan Now button, last scan results display
- Jobs page approval workflow: Approve/Reject buttons for AwaitingApproval
  jobs, rejection reason modal, pending approval banner with count,
  AwaitingApproval/AwaitingCSR added to status filter dropdown

Also adds 13 new frontend tests, 4 API types, 12 API client functions,
2 sidebar nav items, 2 routes, and discovery status badge styles.

Docs updated: README, architecture, quickstart, demo-advanced, CLAUDE.md,
roadmap. Version bumped to v2.0.4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 15:59:27 -04:00
shankar0123 03593d4304 feat: wire ACME EAB into account registration + ZeroSSL auto-fetch
EAB credentials (KID + HMAC) were defined in the ACME connector config
but never wired into the acme.Account registration call. This fixes the
dead code and adds automatic EAB credential fetching for ZeroSSL — when
the directory URL is detected as ZeroSSL and no EAB credentials are
provided, certctl calls ZeroSSL's public API to get them automatically.

Changes:
- Wire EABKid/EABHmac into acme.Account.ExternalAccountBinding
- Add isZeroSSL() detection and fetchZeroSSLEAB() auto-fetch
- Add CERTCTL_ACME_EAB_KID/CERTCTL_ACME_EAB_HMAC env vars to main.go
- Add 13 ACME connector tests (config validation, EAB decode, ZeroSSL
  auto-EAB with mock servers, URL detection)
- Update docs: README, architecture, connectors, demo-advanced,
  testing-guide with EAB/auto-EAB documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 15:34:48 -04:00
shankar0123 87355c3efb docs: add table of contents to all major documentation files
Navigation menus for testing guide, architecture, concepts,
connectors, quickstart, advanced demo, and three compliance docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 23:38:28 -04:00
shankar0123 f92d148881 chore: bump version badge to v2.0.3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 23:29:33 -04:00
shankar0123 50c520e1ff feat: dashboard theme overhaul — light content area with branded teal sidebar
Complete frontend visual redesign using certctl logo color palette:
- Deep teal sidebar (#0c2e25) with prominent centered logo (64px in white pill)
- Light content area (#f0f4f8) with white cards and visible borders
- Brand colors from logo: teal (#2ea88f), blue (#3b7dd8), orange (#e8873a), green (#4ebe6e)
- Inter + JetBrains Mono typography, colored stat card top borders
- All 17 pages + 7 components updated (25 files, ~700 lines changed)
- 15 new dashboard screenshots replacing old dark theme screenshots
- Prometheus metrics e2e test added, integration test mock fixes
- Docs updated: architecture.md theme description, testing-guide.md DNS-PERSIST-01 coverage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 23:27:42 -04:00
shankar0123 8380cb7946 docs: remove stats tagline from README header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 14:29:56 -04:00
shankar0123 6d8ab54f46 chore: bump version badge to v2.0.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 14:24:50 -04:00
shankar0123 e19c240a79 feat: add ACME DNS-PERSIST-01 challenge support (IETF draft-ietf-acme-dns-persist)
Standing TXT record at _validation-persist.<domain> eliminates per-renewal
DNS updates. Auto-fallback to dns-01 if CA doesn't offer dns-persist-01.
ScriptDNSSolver extended with PresentPersist method. Configurable via
CERTCTL_ACME_CHALLENGE_TYPE=dns-persist-01 and
CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN env vars.

Also fixes IsExpired edge-case test in discovery_test.go that always failed
due to time.Now() drift between test setup and method invocation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 14:23:46 -04:00
shankar0123 5c38bc3bfe docs: clean up connector guide language
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 11:55:01 -04:00
shankar0123 b5687aece8 docs: add brief descriptions to screenshot thumbnails
Uses <sub> tags for small text under each screenshot label.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 11:37:14 -04:00
shankar0123 cdb6ebdb6a docs: compact screenshots to 3-per-row grid layout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 11:35:16 -04:00
shankar0123 bb85f1a56e docs: shrink README screenshots to thumbnails with click-to-expand
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 11:33:41 -04:00
shankar0123 44c4d89011 docs: move architecture mermaid diagrams out of README
Remove both mermaid flowcharts from README to reduce visual noise.
Architecture doc already has a more detailed version. Replace with
a one-line text summary linking to docs/architecture.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 11:02:38 -04:00
shankar0123 eaccbcdcf1 docs: remove placeholder Pro waitlist CTA from README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:30:14 -04:00
shankar0123 4e3cff0729 docs: update README with planned V2 milestones and integration coverage
Add Traefik/Caddy to deployment targets table and architecture
diagram, S/MIME to core capabilities, M24/M25/M26 to V2 roadmap
section, version badge to v2.0.1, stats to 95+ endpoints and
930+ tests. Clarify Vault PKI and DigiCert as future. Expand V4
description. Add OpenSSL/Custom CA note for ADCS integrations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:28:50 -04:00
shankar0123 09c819d424 docs: add Scarf Docker pull URLs across README, release workflow, and features
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 21:33:41 -04:00
78 changed files with 3081 additions and 950 deletions
+2 -2
View File
@@ -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
+123 -337
View File
@@ -7,8 +7,6 @@
# certctl — Self-Hosted Certificate Lifecycle Platform # certctl — Self-Hosted Certificate Lifecycle Platform
90+ API endpoints. 21 database tables. 900+ tests. Full GUI. Ships with Docker Compose.
```mermaid ```mermaid
timeline timeline
title TLS Certificate Maximum Lifespan (CA/Browser Forum Ballot SC-081v3) title TLS Certificate Maximum Lifespan (CA/Browser Forum Ballot SC-081v3)
@@ -26,36 +24,20 @@ certctl is a self-hosted platform that automates the entire certificate lifecycl
[![License](https://img.shields.io/badge/license-BSL%201.1-blue.svg)](LICENSE) [![License](https://img.shields.io/badge/license-BSL%201.1-blue.svg)](LICENSE)
[![Go Report Card](https://goreportcard.com/badge/github.com/shankar0123/certctl)](https://goreportcard.com/report/github.com/shankar0123/certctl) [![Go Report Card](https://goreportcard.com/badge/github.com/shankar0123/certctl)](https://goreportcard.com/report/github.com/shankar0123/certctl)
![Version: v2.0.0](https://img.shields.io/badge/version-v2.0.0-brightgreen) ![Version: v2.0.5](https://img.shields.io/badge/version-v2.0.5-brightgreen)
## Documentation ## Documentation
| Guide | Description | | Guide | Description |
|-------|-------------| |-------|-------------|
| [Why certctl?](docs/why-certctl.md) | Competitive positioning — how certctl compares to open-source and enterprise certificate management platforms |
| [Concepts](docs/concepts.md) | TLS certificates explained from scratch — for beginners who know nothing about certs | | [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 | | [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 | | [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 | | [Architecture](docs/architecture.md) | System design, data flow diagrams, security model |
| [Feature Inventory](docs/features.md) | Complete reference of all V2 capabilities, API endpoints, and configuration |
| [Connectors](docs/connectors.md) | Build custom issuer, target, and notifier connectors | | [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 | | [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 |
## 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 ## Why certctl Exists
@@ -63,69 +45,63 @@ Certificate lifecycle tooling today falls into two camps: expensive enterprise p
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. 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 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. 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.
For a detailed comparison with CertKit, KeyTalk, and enterprise platforms (Venafi, Keyfactor), see [Why certctl?](docs/why-certctl.md)
## 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** (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. certctl gives you a single pane of glass for every TLS certificate in your organization:
**Core capabilities:** - **Web dashboard** — full certificate inventory with status, ownership, expiration heatmaps, and bulk operations
- **REST API** — 95 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation
- **Agents** — generate private keys locally, discover existing certs on disk, submit CSRs (private keys never leave your servers)
- **Network scanner** — discovers certificates on TLS endpoints across CIDR ranges without requiring agents
- **EST server** (RFC 7030) — device and WiFi certificate enrollment via industry-standard protocol
- **Approval workflows** — require human sign-off on renewals before deployment
- **Background scheduler** — watches expiration dates and triggers renewals automatically, handling constant rotation at 47-day lifespans without human involvement
- **Full lifecycle automation** — issuance, renewal, deployment, and revocation with zero human intervention. Configurable renewal policies trigger jobs automatically based on expiration thresholds. For the full capability breakdown — issuer connectors, revocation infrastructure, policy engine, observability, EST enrollment, and more — see the [Feature Inventory](docs/features.md).
- **CA-agnostic issuer connectors** — Local CA (self-signed + sub-CA for enterprise root chains), ACME v2 with HTTP-01 and DNS-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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
```mermaid
flowchart LR
subgraph "Control Plane"
API["REST API + Dashboard\n:8443"]
PG[("PostgreSQL")]
end
subgraph "Your Infrastructure"
A1["Agent"] --> T1["NGINX"]
A2["Agent"] --> T2["Apache / HAProxy"]
A3["Agent"] --> T3["F5 · IIS"]
end
API --> PG
A1 & A2 & A3 -->|"CSR + status\n(no private keys)"| API
API -->|"Signed certs"| A1 & A2 & A3
API -->|"Issue/Renew"| CA["Certificate Authorities\nLocal CA · ACME · step-ca · OpenSSL"]
```
### Screenshots ### Screenshots
| | | <table>
|---|---| <tr>
| ![Dashboard](docs/screenshots/v2/dashboard.png) | ![Certificates](docs/screenshots/v2/certificates.png) | <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** — real-time stats, expiration heatmap, renewal trends, issuance rate | **Certificates** — full inventory with status filters, environment, owner, team | <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>
| ![Agents](docs/screenshots/v2/agents.png) | ![Fleet Overview](docs/screenshots/v2/fleet-overview.png) | <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, OS/arch, IP, version tracking | **Fleet Overview** — OS distribution, status breakdown, version analysis | </tr>
| ![Jobs](docs/screenshots/v2/jobs.png) | ![Notifications](docs/screenshots/v2/notifications.png) | <tr>
| **Jobs** — issuance, renewal, deployment job queue with status filters | **Notifications** — expiration warnings, renewal results, unread/all toggle | <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>
| ![Policies](docs/screenshots/v2/policies.png) | ![Profiles](docs/screenshots/v2/profiles.png) | <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>
| **Policies** — enforcement rules for ownership, environments, lifetime, renewal | **Profiles** — enrollment templates with key types, max TTL, crypto constraints | <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>
| ![Issuers](docs/screenshots/v2/issuers.png) | ![Targets](docs/screenshots/v2/targets.png) | </tr>
| **Issuers** — CA connectors (Local CA, Let's Encrypt, step-ca, DigiCert) | **Targets** — deployment targets (NGINX, F5 BIG-IP, IIS, HAProxy) | <tr>
| ![Owners](docs/screenshots/v2/owners.png) | ![Teams](docs/screenshots/v2/teams.png) | <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>
| **Owners** — certificate ownership with email and team assignment | **Teams** — organizational grouping for notification routing | <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>
| ![Agent Groups](docs/screenshots/v2/agent-groups.png) | ![Audit Trail](docs/screenshots/v2/audit-trail.png) | <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>
| **Agent Groups** — dynamic grouping by OS, arch, CIDR, version | **Audit Trail** — immutable log with filters, CSV/JSON export | </tr>
| ![Short-Lived](docs/screenshots/v2/short-lived.png) | | <tr>
| **Short-Lived Credentials** — ephemeral certs with live TTL countdown | | <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
@@ -172,30 +148,7 @@ export CERTCTL_AGENT_ID=agent-local-01
## 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.25 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
@@ -204,93 +157,33 @@ flowchart TB
- **Handler → Service → Repository layering.** Handlers define their own service interfaces for clean dependency inversion. No global service singletons. - **Handler → Service → Repository layering.** Handlers define their own service interfaces for clean dependency inversion. No global service singletons.
- **Idempotent migrations.** All schema uses `IF NOT EXISTS` and seed data uses `ON CONFLICT (id) DO NOTHING`, safe for repeated execution. - **Idempotent migrations.** All schema uses `IF NOT EXISTS` and seed data uses `ON CONFLICT (id) DO NOTHING`, safe for repeated execution.
### Database Schema PostgreSQL 16 with 21 tables covering certificates, versions, policies, issuers, targets, agents, jobs, teams, owners, profiles, agent groups, revocations, discovery, network scans, and audit events. See the [Architecture Guide](docs/architecture.md) for the full schema.
| Table | Purpose |
|-------|---------|
| `managed_certificates` | Certificate records with metadata, status, expiry, tags |
| `certificate_versions` | Historical versions with PEM chains and CSRs |
| `renewal_policies` | Renewal window, auto-renew settings, retry config, alert thresholds |
| `issuers` | CA configurations (Local CA, ACME, etc.) |
| `deployment_targets` | Target systems (NGINX, F5, IIS) with agent assignments |
| `agents` | Registered agents with heartbeat tracking, OS/arch/IP metadata |
| `jobs` | Issuance, renewal, deployment, and validation jobs |
| `teams` | Organizational groups for certificate ownership |
| `owners` | Individual owners with email for notifications |
| `policy_rules` | Enforcement rules (allowed issuers, environments, metadata) |
| `policy_violations` | Flagged non-compliance with severity levels |
| `audit_events` | Immutable action log (append-only, no update/delete) |
| `notification_events` | Email and webhook notification records |
| `certificate_target_mappings` | Many-to-many cert ↔ target relationships |
| `certificate_profiles` | Named enrollment profiles with allowed key types, max TTL, crypto constraints |
| `agent_groups` | Dynamic device grouping by OS, architecture, IP CIDR, version |
| `agent_group_members` | Manual include/exclude membership for agent groups |
| `certificate_revocations` | Revocation records with RFC 5280 reason codes, serial numbers, issuer notification status |
| `discovered_certificates` | Filesystem and network-discovered certificates with fingerprint deduplication |
| `discovery_scans` | Discovery scan history with timestamps and agent attribution |
| `network_scan_targets` | Network scan target definitions with CIDRs, ports, schedule, and scan metrics |
## Configuration ## Configuration
All server environment variables use the `CERTCTL_` prefix: All environment variables use the `CERTCTL_` prefix. Key settings:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `CERTCTL_SERVER_HOST` | `127.0.0.1` | Server bind address |
| `CERTCTL_SERVER_PORT` | `8080` | Server listen port |
| `CERTCTL_DATABASE_URL` | `postgres://localhost/certctl` | PostgreSQL connection string | | `CERTCTL_DATABASE_URL` | `postgres://localhost/certctl` | PostgreSQL connection string |
| `CERTCTL_DATABASE_MAX_CONNS` | `25` | Connection pool size |
| `CERTCTL_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
| `CERTCTL_LOG_FORMAT` | `json` | Log format: `json` or `text` |
| `CERTCTL_AUTH_TYPE` | `api-key` | Auth mode: `api-key`, `jwt`, or `none` | | `CERTCTL_AUTH_TYPE` | `api-key` | Auth mode: `api-key`, `jwt`, or `none` |
| `CERTCTL_AUTH_SECRET` | — | Required for `api-key` and `jwt` auth types | | `CERTCTL_AUTH_SECRET` | — | Required for `api-key` and `jwt` auth types |
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation mode: `agent` (production) or `server` (demo only) | | `CERTCTL_KEYGEN_MODE` | `agent` | Key generation: `agent` (production) or `server` (demo only) |
| `CERTCTL_ACME_DIRECTORY_URL` | — | ACME directory URL (e.g., Let's Encrypt staging) | | `CERTCTL_SERVER_PORT` | `8080` | Server listen port |
| `CERTCTL_ACME_DIRECTORY_URL` | — | ACME directory URL (e.g., Let's Encrypt) |
| `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_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_CORS_ORIGINS` | — | Comma-separated allowed CORS origins (empty = same-origin, `*` = all) |
| `CERTCTL_RATE_LIMIT_ENABLED` | `true` | Enable/disable token bucket rate limiting |
| `CERTCTL_RATE_LIMIT_RPS` | `50` | Requests per second limit |
| `CERTCTL_RATE_LIMIT_BURST` | `100` | Maximum burst size for rate limiter |
| `CERTCTL_DATABASE_MIGRATIONS_PATH` | `./migrations` | Path to SQL migration files |
| `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL` | `1h` | How often the scheduler checks for expiring certs |
| `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_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_CLEANUP_SCRIPT` | — | Script to remove DNS-01 `_acme-challenge` TXT record |
| `CERTCTL_STEPCA_URL` | — | step-ca server URL |
| `CERTCTL_STEPCA_PROVISIONER` | — | step-ca JWK provisioner name |
| `CERTCTL_STEPCA_KEY_PATH` | — | Path to step-ca provisioner private key (JWK JSON) |
| `CERTCTL_STEPCA_PASSWORD` | — | step-ca provisioner key password |
| `CERTCTL_OPENSSL_SIGN_SCRIPT` | — | Script for OpenSSL/Custom CA certificate signing |
| `CERTCTL_OPENSSL_REVOKE_SCRIPT` | — | Script for OpenSSL/Custom CA certificate revocation |
| `CERTCTL_OPENSSL_CRL_SCRIPT` | — | Script for OpenSSL/Custom CA CRL generation |
| `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_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_TEAMS_WEBHOOK_URL` | — | Microsoft Teams incoming webhook URL |
| `CERTCTL_PAGERDUTY_ROUTING_KEY` | — | PagerDuty Events API v2 routing key |
| `CERTCTL_OPSGENIE_API_KEY` | — | OpsGenie Alert API key |
Agent environment variables: Agent settings:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `CERTCTL_SERVER_URL` | `http://localhost:8080` | Control plane URL | | `CERTCTL_SERVER_URL` | `http://localhost:8080` | Control plane URL |
| `CERTCTL_API_KEY` | — | Agent API key | | `CERTCTL_API_KEY` | — | Agent API key |
| `CERTCTL_AGENT_NAME` | `certctl-agent` | Agent display name |
| `CERTCTL_AGENT_ID` | — | Registered agent ID (required) | | `CERTCTL_AGENT_ID` | — | Registered agent ID (required) |
| `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Directory for storing private keys (agent keygen mode) | | `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Private key storage directory |
| `CERTCTL_DISCOVERY_DIRS` | — | Comma-separated directories to scan for existing certificates (e.g., `/etc/nginx/certs,/etc/ssl/certs`) | | `CERTCTL_DISCOVERY_DIRS` | — | Directories to scan for existing certs (comma-separated) |
Docker Compose overrides these for the demo stack (see `deploy/docker-compose.yml`): port `8443`, auth type `none`, database pointing to the postgres container. For the full configuration reference — including ACME DNS challenges, sub-CA mode, step-ca, OpenSSL/Custom CA, EST enrollment, network scanning, notification connectors (Slack, Teams, PagerDuty, OpsGenie), scheduler intervals, CORS, and rate limiting — see the [Feature Inventory](docs/features.md). Docker Compose overrides for the demo stack are in `deploy/docker-compose.yml`.
## MCP Server (AI Integration) ## MCP Server (AI Integration)
@@ -361,153 +254,41 @@ certctl-cli certs list --format json # JSON output (default: table)
## API Overview ## API Overview
All endpoints are under `/api/v1/` and return JSON. List endpoints support pagination (`?page=1&per_page=50`). Full request/response schemas are available in the [OpenAPI 3.1 spec](api/openapi.yaml). 95 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml).
### Certificates ### Key Endpoints
``` ```
# Certificate lifecycle
GET /api/v1/certificates List (filter, sort, cursor, sparse fields) GET /api/v1/certificates List (filter, sort, cursor, sparse fields)
POST /api/v1/certificates Create POST /api/v1/certificates/{id}/renew Trigger renewal → 202 Accepted
GET /api/v1/certificates/{id} Get POST /api/v1/certificates/{id}/revoke Revoke with RFC 5280 reason code
PUT /api/v1/certificates/{id} Update GET /api/v1/crl/{issuer_id} DER-encoded X.509 CRL
DELETE /api/v1/certificates/{id} Archive (soft delete) GET /api/v1/ocsp/{issuer_id}/{serial} OCSP responder (good/revoked/unknown)
GET /api/v1/certificates/{id}/versions Version history
GET /api/v1/certificates/{id}/deployments List deployment targets # Agent operations
POST /api/v1/certificates/{id}/renew Trigger renewal → 202 Accepted POST /api/v1/agents/{id}/csr Submit CSR for issuance
POST /api/v1/certificates/{id}/deploy Trigger deployment → 202 Accepted GET /api/v1/agents/{id}/work Poll for pending deployment jobs
POST /api/v1/certificates/{id}/revoke Revoke with RFC 5280 reason code POST /api/v1/agents/{id}/discoveries Submit certificate discovery scan results
GET /api/v1/crl Certificate Revocation List (JSON)
GET /api/v1/crl/{issuer_id} DER-encoded X.509 CRL # Discovery & network scanning
GET /api/v1/ocsp/{issuer_id}/{serial} OCSP responder (good/revoked/unknown) GET /api/v1/discovered-certificates List discovered certs (?agent_id, ?status)
POST /api/v1/discovered-certificates/{id}/claim Link to managed cert
POST /api/v1/network-scan-targets/{id}/scan Trigger immediate TLS scan
# Jobs & approval
POST /api/v1/jobs/{id}/approve Approve interactive renewal
POST /api/v1/jobs/{id}/reject Reject interactive renewal
# Observability
GET /api/v1/metrics/prometheus Prometheus exposition format
GET /api/v1/stats/summary Dashboard summary
# EST enrollment (RFC 7030)
POST /.well-known/est/simpleenroll Device certificate enrollment
GET /.well-known/est/cacerts CA certificate chain (PKCS#7)
``` ```
### Agents Full CRUD is available for certificates, agents, issuers, targets, teams, owners, policies, profiles, agent groups, notifications, and audit events. See the [OpenAPI spec](api/openapi.yaml) or [Feature Inventory](docs/features.md) for the complete endpoint reference.
```
GET /api/v1/agents List
POST /api/v1/agents Register
GET /api/v1/agents/{id} Get
POST /api/v1/agents/{id}/heartbeat Record heartbeat
POST /api/v1/agents/{id}/csr Submit CSR for issuance
GET /api/v1/agents/{id}/certificates/{certId} Retrieve signed certificate
GET /api/v1/agents/{id}/work Poll for pending deployment jobs
POST /api/v1/agents/{id}/jobs/{jobId}/status Report job completion/failure
POST /api/v1/agents/{id}/discoveries Submit certificate discovery scan results
```
### Certificate Discovery
```
GET /api/v1/discovered-certificates List discovered certificates (?agent_id, ?status)
GET /api/v1/discovered-certificates/{id} Get discovery detail
POST /api/v1/discovered-certificates/{id}/claim Link discovered cert to managed cert
POST /api/v1/discovered-certificates/{id}/dismiss Dismiss discovery
GET /api/v1/discovery-scans List discovery scan history
GET /api/v1/discovery-summary Aggregated discovery status (new, claimed, dismissed counts)
```
### Infrastructure
```
GET /api/v1/issuers List issuers
POST /api/v1/issuers Create
GET /api/v1/issuers/{id} Get
PUT /api/v1/issuers/{id} Update
DELETE /api/v1/issuers/{id} Delete
POST /api/v1/issuers/{id}/test Test connectivity
GET /api/v1/targets List deployment targets
POST /api/v1/targets Create
GET /api/v1/targets/{id} Get
PUT /api/v1/targets/{id} Update
DELETE /api/v1/targets/{id} Delete
```
### Organization
```
GET /api/v1/teams List teams
POST /api/v1/teams Create
GET /api/v1/teams/{id} Get
PUT /api/v1/teams/{id} Update
DELETE /api/v1/teams/{id} Delete
GET /api/v1/owners List owners
POST /api/v1/owners Create
GET /api/v1/owners/{id} Get
PUT /api/v1/owners/{id} Update
DELETE /api/v1/owners/{id} Delete
```
### Operations
```
GET /api/v1/jobs List (filter: status, type)
GET /api/v1/jobs/{id} Get
POST /api/v1/jobs/{id}/cancel Cancel
POST /api/v1/jobs/{id}/approve Approve (interactive renewal)
POST /api/v1/jobs/{id}/reject Reject (interactive renewal)
GET /api/v1/policies List policy rules
POST /api/v1/policies Create
GET /api/v1/policies/{id} Get
PUT /api/v1/policies/{id} Update (enable/disable)
DELETE /api/v1/policies/{id} Delete
GET /api/v1/policies/{id}/violations List violations for rule
GET /api/v1/profiles List certificate profiles
POST /api/v1/profiles Create
GET /api/v1/profiles/{id} Get
PUT /api/v1/profiles/{id} Update
DELETE /api/v1/profiles/{id} Delete
GET /api/v1/agent-groups List agent groups
POST /api/v1/agent-groups Create
GET /api/v1/agent-groups/{id} Get
PUT /api/v1/agent-groups/{id} Update
DELETE /api/v1/agent-groups/{id} Delete
GET /api/v1/agent-groups/{id}/members List members
GET /api/v1/audit Query audit trail
GET /api/v1/audit/{id} Get audit event
GET /api/v1/notifications List notifications
GET /api/v1/notifications/{id} Get notification
POST /api/v1/notifications/{id}/read Mark as read
```
### Observability
```
GET /api/v1/stats/summary Dashboard summary (totals, expiring, agents, jobs)
GET /api/v1/stats/certificates-by-status Certificate counts grouped by status
GET /api/v1/stats/expiration-timeline Expiration buckets (?days=30)
GET /api/v1/stats/job-trends Job success/failure over time (?days=7)
GET /api/v1/stats/issuance-rate Certificate issuance rate (?days=7)
GET /api/v1/metrics JSON metrics (gauges, counters, uptime)
GET /api/v1/metrics/prometheus Prometheus exposition format (text/plain)
```
### Network Discovery
```
GET /api/v1/network-scan-targets List scan targets
POST /api/v1/network-scan-targets Create scan target (CIDRs, ports, schedule)
GET /api/v1/network-scan-targets/{id} Get scan target
PUT /api/v1/network-scan-targets/{id} Update scan target
DELETE /api/v1/network-scan-targets/{id} Delete scan target
POST /api/v1/network-scan-targets/{id}/scan Trigger immediate scan
```
### Auth
```
GET /api/v1/auth/info Auth mode info (no auth required)
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
```
GET /health Server health check
GET /ready Readiness check
```
## Supported Integrations ## Supported Integrations
@@ -515,13 +296,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 |
@@ -529,9 +310,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 |
@@ -587,41 +369,45 @@ make docker-clean # Stop + remove volumes
- Immutable append-only log in PostgreSQL (`audit_events` table) - Immutable append-only log in PostgreSQL (`audit_events` table)
- Every lifecycle action attributed to an actor with timestamp and resource reference - Every lifecycle action attributed to an actor with timestamp and resource reference
- No update or delete operations on audit records - No update or delete operations on audit records
- Every API call recorded to audit trail with method, path, actor, SHA-256 body hash, response status, and latency (M19) - Every API call recorded to audit trail with method, path, actor, SHA-256 body hash, response status, and latency
## Roadmap ## Roadmap
### V1 (v1.0.0 released) ### V1 (v1.0.0)
All nine development milestones (M1M9) 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. Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR.
### 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
- **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) 18 milestones complete, 950+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
- **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)
- **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 **What shipped (all ✅):**
- **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 - **Issuers** — Sub-CA mode (enterprise root chains), ACME DNS-01 + DNS-PERSIST-01 (wildcard certs, any DNS provider), step-ca (native /sign API), OpenSSL/Custom CA (script-based signing)
- **M14: Observability** ✅ — dashboard charts (expiration heatmap, cert status distribution, job trends, issuance rate), agent fleet overview with OS/arch grouping, JSON metrics endpoint, stats API (5 endpoints), structured logging with request IDs, deployment rollback - **Revocation** — RFC 5280 reason codes, DER-encoded X.509 CRL, embedded OCSP responder, short-lived cert exemption
- **M18a: MCP Server** ✅ (V2.1) — AI-native integration, all 78 REST API endpoints exposed as MCP tools for Claude, Cursor, OpenClaw, and any MCP-compatible client - **Profiles + Ownership** — certificate profiles (key types, max TTL, crypto constraints), ownership tracking (owners + teams), dynamic agent groups, interactive renewal approval
- **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 - **GUI Operations** — bulk renew/revoke/reassign, deployment timeline, inline policy editor, target wizard, audit export (CSV/JSON), short-lived credentials view
- **M16a: Notifier Connectors** ✅ — Slack (incoming webhook), Microsoft Teams (MessageCard), PagerDuty (Events API v2), OpsGenie (Alert API v2) — config-driven enablement via env vars - **Discovery** — filesystem scanning (PEM/DER) + network TLS scanning (CIDR ranges), triage workflow (claim/dismiss), network scan target management
- **M17: Additional Connectors** ✅ — OpenSSL/Custom CA issuer connector (script-based signing with configurable timeout) - **Observability** — Prometheus + JSON metrics, 5 stats API endpoints, dashboard charts (heatmap, trends, distribution), agent fleet overview, structured logging
- **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 - **EST Server** (RFC 7030) — device/WiFi certificate enrollment, PKCS#7 wire format, configurable issuer + profile binding
- **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`) - **MCP Server** — 78 API operations as AI tools for Claude, Cursor, and any MCP-compatible client
- **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 - **CLI** — 12 subcommands (list/get/renew/revoke certs, agents, jobs, import, status), JSON/table output
- **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) - **Notifications** — Slack, Microsoft Teams, PagerDuty, OpsGenie connectors
- **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 - **API Enhancements** — sparse fields, sort, time-range filters, cursor pagination, immutable API audit logging
- **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 alignment guides
- **Compliance Mapping** ✅ — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 capability mapping documentation
**Coming next:**
- **Post-Deployment TLS Verification** (v2.0.6) — agent-side TLS probe confirms the target is serving the correct certificate by SHA-256 fingerprint match
- **Traefik + Caddy Targets** (v2.1.x) — Traefik (file provider, auto-reload) and Caddy (Admin API, hot-reload)
- **Certificate Export** (v2.1.x) — single-cert download in PFX/PKCS12, DER, and PEM formats
- **S/MIME Support** (v2.2.x) — profile EKU constraints for S/MIME (emailProtection), code signing, and custom EKUs
### V3: certctl Pro ### V3: certctl Pro
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.
> **Need SSO, RBAC, F5/IIS deployment, or real-time fleet operations?** [Join the certctl Pro waitlist](https://forms.gle/YOUR_FORM_ID) — early access shipping Q2 2026.
### 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
+11 -7
View File
@@ -97,14 +97,18 @@ func main() {
localCA := local.New(localCAConfig, logger) localCA := local.New(localCAConfig, logger)
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, ZeroSSL, Sectigo, Google Trust Services, 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.
// EAB (External Account Binding) required by ZeroSSL, Google Trust Services, SSL.com.
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"), EABKid: os.Getenv("CERTCTL_ACME_EAB_KID"),
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"), EABHmac: os.Getenv("CERTCTL_ACME_EAB_HMAC"),
DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"), ChallengeType: os.Getenv("CERTCTL_ACME_CHALLENGE_TYPE"),
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_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")
+9 -2
View File
@@ -12,8 +12,14 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql - ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/002_seed.sql - ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/003_seed_demo.sql - ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql
- ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/010_seed.sql
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/011_seed_demo.sql
networks: networks:
- certctl-network - certctl-network
healthcheck: healthcheck:
@@ -39,6 +45,7 @@ services:
CERTCTL_LOG_LEVEL: info CERTCTL_LOG_LEVEL: info
CERTCTL_AUTH_TYPE: none CERTCTL_AUTH_TYPE: none
CERTCTL_KEYGEN_MODE: server # Demo uses server-side keygen; production should use "agent" CERTCTL_KEYGEN_MODE: server # Demo uses server-side keygen; production should use "agent"
CERTCTL_NETWORK_SCAN_ENABLED: "true" # Enable network scan GUI with seeded demo targets
ports: ports:
- "8443:8443" - "8443:8443"
networks: networks:
+41 -5
View File
@@ -1,5 +1,41 @@
# Architecture Guide # Architecture Guide
## Contents
1. [Overview](#overview)
2. [System Components](#system-components)
- [Control Plane (Server)](#control-plane-server)
- [Agents](#agents)
- [Web Dashboard](#web-dashboard)
- [PostgreSQL Database](#postgresql-database)
3. [Data Flow: Certificate Lifecycle](#data-flow-certificate-lifecycle)
- [Create Managed Certificate](#1-create-managed-certificate)
- [Certificate Issuance](#2-certificate-issuance)
- [Deploy Certificate to Target](#3-deploy-certificate-to-target)
- [Revoke a Certificate](#35-revoke-a-certificate)
- [Automatic Renewal](#4-automatic-renewal)
4. [Connector Architecture](#connector-architecture)
- [IssuerConnectorAdapter (Dependency Inversion)](#issuerconnectoradapter-dependency-inversion)
- [Issuer Connector](#issuer-connector)
- [Target Connector](#target-connector)
- [Notifier Connector](#notifier-connector)
- [EST Server (RFC 7030)](#est-server-rfc-7030)
5. [Security Model](#security-model)
- [Private Key Management](#private-key-management)
- [Authentication](#authentication)
- [Audit Trail](#audit-trail)
- [API Audit Log](#api-audit-log)
- [Logging](#logging)
6. [API Design](#api-design)
7. [MCP Server](#mcp-server)
8. [CLI Tool](#cli-tool)
9. [Deployment Topologies](#deployment-topologies)
- [Docker Compose (Development / Small Deployments)](#docker-compose-development--small-deployments)
- [Production (Kubernetes)](#production-kubernetes)
10. [Discovery Data Flow (M18b + M21)](#discovery-data-flow-m18b--m21)
11. [Testing Strategy](#testing-strategy)
12. [What's Next](#whats-next)
## Overview ## Overview
Certctl is a certificate management platform with a **decoupled control-plane and agent architecture**. The control plane orchestrates certificate issuance and renewal, while agents deployed across your infrastructure handle key generation, certificate deployment, and local validation — private keys never leave the infrastructure they were generated on. Certctl is a certificate management platform with a **decoupled control-plane and agent architecture**. The control plane orchestrates certificate issuance and renewal, while agents deployed across your infrastructure handle key generation, certificate deployment, and local validation — private keys never leave the infrastructure they were generated on.
@@ -41,7 +77,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)\n(EAB, ZeroSSL auto-EAB)"]
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)"]
@@ -92,14 +128,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**: 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** (21 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 for AwaitingApproval jobs), 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), discovered certificates triage (claim/dismiss unmanaged certs discovered by agents or network scans), network scan targets management (CRUD for network scan targets + Scan Now button), 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
@@ -527,7 +563,7 @@ type Connector interface {
} }
``` ```
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), **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), 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). 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, ZeroSSL, Sectigo, Google Trust Services, 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 and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), 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. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required. The interface also includes `GetCACertPEM(ctx)` for CA chain distribution (used by the EST server's `/cacerts` endpoint).
### Target Connector ### Target Connector
@@ -869,7 +905,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.
+18
View File
@@ -2,6 +2,24 @@
NIST SP 800-57 Part 1 Rev 5 (May 2020) is the authoritative US government guidance on cryptographic key management. This document maps certctl's implementation to its recommendations. certctl follows NIST guidance where applicable; this guide documents the alignment and identifies gaps for future roadmap planning. NIST SP 800-57 Part 1 Rev 5 (May 2020) is the authoritative US government guidance on cryptographic key management. This document maps certctl's implementation to its recommendations. certctl follows NIST guidance where applicable; this guide documents the alignment and identifies gaps for future roadmap planning.
## Contents
1. [Key Generation (Section 6.1)](#key-generation-section-61)
2. [Key Storage and Protection (Sections 6.3, 6.4)](#key-storage-and-protection-sections-63-64)
3. [Cryptoperiods (Section 5.3, Table 1)](#cryptoperiods-section-53-table-1)
4. [Key States and Transitions (Section 5.2)](#key-states-and-transitions-section-52)
5. [Algorithm Recommendations (Section 5.1, SP 800-131A)](#algorithm-recommendations-section-51-sp-800-131a)
6. [Key Distribution and Transport (Section 6.2)](#key-distribution-and-transport-section-62)
7. [Revocation and Compromise (NIST SP 800-57 Part 3)](#revocation-and-compromise-nist-sp-800-57-part-3)
8. [Alignment Summary Table](#alignment-summary-table)
9. [Gaps and Remediation Roadmap](#gaps-and-remediation-roadmap)
- [V2 (Current)](#v2-current)
- [V3 (Planned: 2026)](#v3-planned-2026)
- [V5 (Planned: 2027+)](#v5-planned-2027)
- [Post-Quantum (2027+)](#post-quantum-2027)
10. [References](#references)
11. [Questions or Corrections?](#questions-or-corrections)
## Key Generation (Section 6.1) ## Key Generation (Section 6.1)
certctl generates certificate keys on agent infrastructure using Go's `crypto/rand` for entropy, backed by `/dev/urandom` on Linux and `CryptGenRandom` on Windows. Key generation happens as follows: certctl generates certificate keys on agent infrastructure using Go's `crypto/rand` for entropy, backed by `/dev/urandom` on Linux and `CryptGenRandom` on Windows. Key generation happens as follows:
+28
View File
@@ -4,6 +4,34 @@ This guide maps certctl's existing capabilities to PCI-DSS 4.0 requirements rele
Organizations subject to PCI-DSS typically need to demonstrate control over certificate issuance, renewal, rotation, revocation, and key management. Certctl automates the technical controls for certificate lifecycle; compliance depends on how you deploy, monitor, and audit it. Organizations subject to PCI-DSS typically need to demonstrate control over certificate issuance, renewal, rotation, revocation, and key management. Certctl automates the technical controls for certificate lifecycle; compliance depends on how you deploy, monitor, and audit it.
## Contents
1. [How to Use This Guide](#how-to-use-this-guide)
2. [Requirement 4: Protect Data in Transit](#requirement-4-protect-data-in-transit)
- [4.2.1 — Strong Cryptography for Transmission](#421--strong-cryptography-for-transmission)
- [4.2.2 — Certificate Inventory and Validation](#422--certificate-inventory-and-validation)
3. [Requirement 3: Protect Stored Cardholder Data (Key Management)](#requirement-3-protect-stored-cardholder-data-key-management)
- [3.6 — Cryptographic Key Documentation](#36--cryptographic-key-documentation)
- [3.7 — Key Lifecycle Procedures](#37--key-lifecycle-procedures)
4. [Requirement 8: Identify and Authenticate](#requirement-8-identify-and-authenticate)
- [8.3 — Strong Authentication](#83--strong-authentication)
- [8.6 — Application Account Management](#86--application-account-management)
5. [Requirement 10: Log and Monitor](#requirement-10-log-and-monitor)
- [10.2 — Implement Automated Audit Logging](#102--implement-automated-audit-logging)
- [10.3 — Protect Audit Trail](#103--protect-audit-trail)
- [10.4 — Promptly Review and Address Audit Trail Exceptions](#104--promptly-review-and-address-audit-trail-exceptions)
- [10.7 — Retain and Protect Audit Trail History](#107--retain-and-protect-audit-trail-history)
6. [Requirement 6: Develop and Maintain Secure Systems and Applications](#requirement-6-develop-and-maintain-secure-systems-and-applications)
- [6.3.1 — Security Coding Practices](#631--security-coding-practices)
- [6.5.10 — Broken Authentication and Cryptography Prevention](#6510--broken-authentication-and-cryptography-prevention)
7. [Requirement 7: Restrict Access by Business Need-to-Know](#requirement-7-restrict-access-by-business-need-to-know)
- [7.2 — Implement Access Control](#72--implement-access-control)
8. [Evidence Summary Table](#evidence-summary-table)
9. [Operator Responsibilities](#operator-responsibilities)
10. [V3 Enhancements for PCI-DSS](#v3-enhancements-for-pci-dss)
11. [Next Steps for Compliance](#next-steps-for-compliance)
12. [Questions?](#questions)
## How to Use This Guide ## How to Use This Guide
Your QSA will request evidence that your certificate and key management systems meet specific PCI-DSS 4.0 requirements. For each applicable requirement, this guide identifies: Your QSA will request evidence that your certificate and key management systems meet specific PCI-DSS 4.0 requirements. For each applicable requirement, this guide identifies:
+22
View File
@@ -14,6 +14,28 @@ Each section includes:
- **V2 vs V3 status** — whether feature is in the free community edition (V2) or paid Pro edition (V3) - **V2 vs V3 status** — whether feature is in the free community edition (V2) or paid Pro edition (V3)
- **Operator responsibility** — aspects your organization must handle outside of certctl - **Operator responsibility** — aspects your organization must handle outside of certctl
## Contents
1. [How to Use This Guide](#how-to-use-this-guide)
2. [CC6: Logical and Physical Access Controls](#cc6-logical-and-physical-access-controls)
- [CC6.1 — Logical Access Security](#cc61--logical-access-security)
- [CC6.2 — Prior to Issuing System Credentials](#cc62--prior-to-issuing-system-credentials)
- [CC6.3 — Authentication Policies](#cc63--authentication-policies)
- [CC6.7 — Information Transmission Protection](#cc67--information-transmission-protection)
3. [CC7: System Operations](#cc7-system-operations)
- [CC7.1 — System Monitoring](#cc71--system-monitoring)
- [CC7.2 — Anomaly Detection](#cc72--anomaly-detection)
- [CC7.3 — Incident Response](#cc73--incident-response)
- [CC7.4 — Identify and Develop Risk Mitigation Activities](#cc74--identify-and-develop-risk-mitigation-activities)
4. [A1: Availability](#a1-availability)
- [A1.1/A1.2 — Availability and Recovery](#a11a12--availability-and-recovery)
5. [CC8: Change Management](#cc8-change-management)
- [CC8.1 — Change Control](#cc81--change-control)
6. [Evidence Summary Table](#evidence-summary-table)
7. [What Requires Operator Action](#what-requires-operator-action)
8. [V3 Enhancements](#v3-enhancements)
9. [Conclusion](#conclusion)
## CC6: Logical and Physical Access Controls ## CC6: Logical and Physical Access Controls
### CC6.1 — Logical Access Security ### CC6.1 — Logical Access Security
+41 -4
View File
@@ -2,6 +2,41 @@
If you've never worked with TLS certificates before, this guide will get you up to speed. By the end, you'll understand what certificates are, why they matter, and why the industry's move toward shorter certificate lifespans — down to 47 days by 2029 — makes automated lifecycle management essential. If you've never worked with TLS certificates before, this guide will get you up to speed. By the end, you'll understand what certificates are, why they matter, and why the industry's move toward shorter certificate lifespans — down to 47 days by 2029 — makes automated lifecycle management essential.
## Contents
1. [What Is a TLS Certificate?](#what-is-a-tls-certificate)
2. [Why Do Certificates Expire?](#why-do-certificates-expire)
3. [The Cast of Characters](#the-cast-of-characters)
- [Certificate Authority (CA)](#certificate-authority-ca)
- [ACME Protocol](#acme-protocol)
- [EST Protocol (Enrollment over Secure Transport)](#est-protocol-enrollment-over-secure-transport)
- [Private Key](#private-key)
- [Subject Alternative Names (SANs)](#subject-alternative-names-sans)
- [Certificate Chain](#certificate-chain)
4. [How certctl Works](#how-certctl-works)
- [The Control Plane (Server)](#the-control-plane-server)
- [Agents](#agents)
- [Deployment Targets](#deployment-targets)
5. [The Certificate Lifecycle](#the-certificate-lifecycle)
6. [Why Not Just Use Certbot?](#why-not-just-use-certbot)
7. [Key Concepts in certctl](#key-concepts-in-certctl)
- [Teams and Owners](#teams-and-owners)
- [Agent Groups](#agent-groups)
- [Certificate Profiles](#certificate-profiles)
- [Interactive Renewal Approval](#interactive-renewal-approval)
- [Certificate Revocation](#certificate-revocation)
- [Short-Lived Certificates](#short-lived-certificates)
- [Policies](#policies)
- [Jobs](#jobs)
- [Audit Trail](#audit-trail)
- [Notifications](#notifications)
- [CLI](#cli)
- [MCP Server (AI Integration)](#mcp-server-ai-integration)
- [EST Enrollment (Device Certificates)](#est-enrollment-device-certificates)
- [Certificate Discovery](#certificate-discovery)
- [Observability](#observability)
8. [What's Next](#whats-next)
## What Is a TLS Certificate? ## What Is a TLS Certificate?
When you visit `https://yourbank.com`, your browser checks a digital document called a **TLS certificate** before sending any data. That certificate proves two things: (1) you're really talking to yourbank.com and not an imposter, and (2) everything sent between you and the server is encrypted. When you visit `https://yourbank.com`, your browser checks a digital document called a **TLS certificate** before sending any data. That certificate proves two things: (1) you're really talking to yourbank.com and not an imposter, and (2) everything sent between you and the server is encrypted.
@@ -34,9 +69,9 @@ 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 Protocol (Enrollment over Secure Transport)
@@ -211,10 +246,12 @@ Certificate discovery is the process of automatically finding existing certifica
**How it works:** There are two discovery modes. *Filesystem discovery* — agents scan configured directories (configured via `CERTCTL_DISCOVERY_DIRS`) for certificate files. On startup and every 6 hours, the agent walks directories recursively, parses PEM and DER files, extracts metadata, and reports findings to the control plane. *Network discovery* — the control plane itself probes TLS endpoints across configured CIDR ranges and ports (enabled via `CERTCTL_NETWORK_SCAN_ENABLED=true`). It connects to each endpoint, extracts certificates from the TLS handshake, and feeds results into the same discovery pipeline. This finds certificates on services you may not have agents on. In both cases, the server deduplicates by fingerprint and stores discovered certs with a status: **Unmanaged** (discovered but not yet managed), **Managed** (linked to a control plane cert), or **Dismissed** (operator decided not to manage it). **How it works:** There are two discovery modes. *Filesystem discovery* — agents scan configured directories (configured via `CERTCTL_DISCOVERY_DIRS`) for certificate files. On startup and every 6 hours, the agent walks directories recursively, parses PEM and DER files, extracts metadata, and reports findings to the control plane. *Network discovery* — the control plane itself probes TLS endpoints across configured CIDR ranges and ports (enabled via `CERTCTL_NETWORK_SCAN_ENABLED=true`). It connects to each endpoint, extracts certificates from the TLS handshake, and feeds results into the same discovery pipeline. This finds certificates on services you may not have agents on. In both cases, the server deduplicates by fingerprint and stores discovered certs with a status: **Unmanaged** (discovered but not yet managed), **Managed** (linked to a control plane cert), or **Dismissed** (operator decided not to manage it).
This gives you a three-step triage workflow: This gives you a three-step triage workflow:
1. **Discover** — Agents find all existing certs on your infrastructure 1. **Discover** — Agents scan filesystems and the server probes network endpoints to find all existing certs
2. **Triage** — Operators review discoveries and decide: claim it (enroll for management), or dismiss it (not worth managing) 2. **Triage** — Operators review discoveries in the **Discovery** dashboard page and decide: claim it (link to a managed certificate) or dismiss it (not worth managing). The dashboard shows a summary stats bar (Unmanaged/Managed/Dismissed counts), filters by status and agent, and provides one-click claim and dismiss actions.
3. **Baseline** — Once triaged, you have a complete baseline of what's deployed, what you're managing, and what's unmanaged 3. **Baseline** — Once triaged, you have a complete baseline of what's deployed, what you're managing, and what's unmanaged
Network scan targets are managed from the **Network Scans** dashboard page — create CIDR ranges and ports to probe, enable/disable targets, trigger on-demand scans, and view results. Discovered certificates from network scans appear in the same Discovery triage page alongside filesystem discoveries.
This is a prerequisite for multi-CA migration, compliance audits, and building confidence that you've found all the certificates that matter. This is a prerequisite for multi-CA migration, compliance audits, and building confidence that you've found all the certificates that matter.
### Observability ### Observability
+100 -16
View File
@@ -2,11 +2,54 @@
Connectors extend certctl to integrate with external systems for certificate issuance, deployment, and notifications. This guide covers the connector interfaces, built-in implementations, and how to build your own. Connectors extend certctl to integrate with external systems for certificate issuance, deployment, and notifications. This guide covers the connector interfaces, built-in implementations, and how to build your own.
## Contents
1. [Overview](#overview)
2. [Issuer Connector](#issuer-connector)
- [Interface](#interface)
- [Built-in: Local CA](#built-in-local-ca)
- [Built-in: ACME v2 (Let's Encrypt, Sectigo, ZeroSSL)](#built-in-acme-v2-lets-encrypt-sectigo-zerossl)
- [Built-in: step-ca (Smallstep Private CA)](#built-in-step-ca-smallstep-private-ca)
- [OpenSSL / Custom CA](#openssl--custom-ca)
- [Revocation Across Issuers](#revocation-across-issuers)
- [EST Integration (GetCACertPEM)](#est-integration-getcacertpem)
- [Planned Issuers](#planned-issuers)
- [Building a Custom Issuer](#building-a-custom-issuer)
3. [Target Connector](#target-connector)
- [Interface](#interface-1)
- [Built-in: NGINX](#built-in-nginx)
- [Built-in: Apache httpd](#built-in-apache-httpd)
- [Built-in: HAProxy](#built-in-haproxy)
- [F5 BIG-IP (Interface Only)](#f5-big-ip-interface-only)
- [IIS (Interface Only, Dual-Mode)](#iis-interface-only-dual-mode)
4. [Notifier Connector](#notifier-connector)
- [Interface](#interface-2)
5. [Registering a Connector](#registering-a-connector)
- [IssuerConnectorAdapter](#issuerconnectoradapter)
- [Notifier Registration](#notifier-registration)
6. [Testing Connectors](#testing-connectors)
- [Unit Tests](#unit-tests)
- [Integration Tests](#integration-tests)
7. [Best Practices](#best-practices)
8. [Agent Discovery Scanner](#agent-discovery-scanner)
- [Configuration](#configuration)
- [How It Works](#how-it-works)
- [API Endpoints](#api-endpoints)
- [Use Cases](#use-cases)
9. [Network Certificate Scanner (M21)](#network-certificate-scanner-m21)
- [Configuration](#configuration-1)
- [Creating Scan Targets](#creating-scan-targets)
- [How It Works](#how-it-works-1)
- [API Endpoints](#api-endpoints-1)
- [Scheduler Integration](#scheduler-integration)
- [Use Cases](#use-cases-1)
10. [What's Next](#whats-next)
## Overview ## Overview
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)
@@ -116,12 +159,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
{ {
@@ -143,14 +188,53 @@ 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.
ZeroSSL configuration (requires External Account Binding):
```json
{
"directory_url": "https://acme.zerossl.com/v2/DV90",
"email": "admin@example.com",
"eab_kid": "your-zerossl-eab-kid",
"eab_hmac": "your-zerossl-eab-hmac-base64url"
}
```
ZeroSSL, Google Trust Services, and SSL.com require External Account Binding (EAB) for ACME account registration. For most CAs, get your EAB credentials from the CA's dashboard and provide them via `eab_kid` and `eab_hmac`. The HMAC key must be base64url-encoded (no padding). CAs that don't require EAB (Let's Encrypt, Buypass) ignore these fields.
**ZeroSSL auto-EAB:** When the directory URL points to ZeroSSL and no EAB credentials are provided, certctl automatically fetches them from ZeroSSL's public API (`api.zerossl.com/acme/eab-credentials-email`) using your configured email address. No dashboard visit required — just set the directory URL and email, and it works. This is the same approach used by Caddy and acme.sh.
Minimal ZeroSSL configuration (auto-EAB):
```json
{
"directory_url": "https://acme.zerossl.com/v2/DV90",
"email": "admin@example.com"
}
```
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_EAB_KID` — External Account Binding Key ID (required by ZeroSSL, Google Trust Services, SSL.com)
- `CERTCTL_ACME_DNS_PRESENT_SCRIPT` — Path to DNS record creation script (dns-01 only) - `CERTCTL_ACME_EAB_HMAC` — External Account Binding HMAC key (base64url-encoded)
- `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` — Path to DNS record cleanup script (dns-01 only) - `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 and dns-persist-01)
- `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.
@@ -227,7 +311,7 @@ Note: EST (Enrollment over Secure Transport) is not a connector — it's a proto
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.
@@ -417,11 +501,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
@@ -438,9 +522,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.
@@ -632,7 +716,7 @@ The agent scans these directories on startup and every 6 hours, looking for cert
1. **Scan**: Agent recursively walks directories, extracts certificates 1. **Scan**: Agent recursively walks directories, extracts certificates
2. **Deduplicate**: Control plane deduplicates by SHA-256 fingerprint (same cert in multiple locations is one discovery) 2. **Deduplicate**: Control plane deduplicates by SHA-256 fingerprint (same cert in multiple locations is one discovery)
3. **Store**: Discovered certificates stored with metadata (agent ID, file path, found date, fingerprint) 3. **Store**: Discovered certificates stored with metadata (agent ID, file path, found date, fingerprint)
4. **Triage**: Operators query discovered certs via API, claim to link to managed certificates, or dismiss false positives 4. **Triage**: Operators review discovered certs in the **Discovery** dashboard page (or via API) — claim to link to managed certificates, or dismiss false positives. The dashboard shows summary stats, filters by status and agent, and provides one-click claim/dismiss actions.
### API Endpoints ### API Endpoints
@@ -680,10 +764,10 @@ export CERTCTL_NETWORK_SCAN_INTERVAL=6h # default
### Creating Scan Targets ### Creating Scan Targets
Network scan targets define which CIDR ranges and ports to probe: Network scan targets can be managed from the **Network Scans** dashboard page (create, edit, enable/disable, trigger on-demand scans) or via the API. Targets define which CIDR ranges and ports to probe:
```bash ```bash
# Create a scan target for your internal network # Create a scan target for your internal network (or use the dashboard's "+ New Target" button)
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 '{
@@ -703,7 +787,7 @@ curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
3. **Extract**: Certificate metadata extracted from TLS handshake (CN, SANs, serial, issuer, key info, fingerprint) 3. **Extract**: Certificate metadata extracted from TLS handshake (CN, SANs, serial, issuer, key info, fingerprint)
4. **Pipeline**: Results fed into the same `DiscoveryService.ProcessDiscoveryReport()` as filesystem discovery 4. **Pipeline**: Results fed into the same `DiscoveryService.ProcessDiscoveryReport()` as filesystem discovery
5. **Deduplicate**: Sentinel agent ID (`server-scanner`) with source_path as `ip:port` ensures proper dedup 5. **Deduplicate**: Sentinel agent ID (`server-scanner`) with source_path as `ip:port` ensures proper dedup
6. **Triage**: Discovered certs appear in `GET /api/v1/discovered-certificates` with `agent_id=server-scanner` 6. **Triage**: Discovered certs appear in the **Discovery** dashboard page (and via `GET /api/v1/discovered-certificates`) with `agent_id=server-scanner`
### API Endpoints ### API Endpoints
+102 -5
View File
@@ -5,6 +5,41 @@ This demo goes beyond browsing pre-loaded data. You'll create a team, register a
**Time**: 15-20 minutes **Time**: 15-20 minutes
**Prerequisites**: certctl running via Docker Compose (see [Quick Start](quickstart.md)) **Prerequisites**: certctl running via Docker Compose (see [Quick Start](quickstart.md))
## Contents
1. [Setup](#setup)
2. [How the pieces fit together](#how-the-pieces-fit-together)
3. [Alternative Issuers Reference](#alternative-issuers-reference)
- [Sub-CA Mode](#sub-ca-mode-local-ca-chained-to-enterprise-root)
- [ACME with ZeroSSL](#acme-with-zerossl-auto-eab)
- [ACME with DNS-01 Challenges](#acme-with-dns-01-challenges-wildcard-certificates)
- [ACME with DNS-PERSIST-01](#acme-with-dns-persist-01-zero-touch-renewals)
- [step-ca (Smallstep Private CA)](#step-ca-smallstep-private-ca)
- [OpenSSL / Custom CA](#openssl--custom-ca-script-based)
4. [Part 1: Build the Organization Structure](#part-1-build-the-organization-structure)
5. [Part 2: Verify the Issuer](#part-2-verify-the-issuer)
6. [Part 3: Create a Managed Certificate](#part-3-create-a-managed-certificate)
7. [Part 4: Trigger Certificate Renewal](#part-4-trigger-certificate-renewal)
8. [Part 4.5: Manage Deployment Targets](#part-45-manage-deployment-targets)
9. [Part 5: Deploy the Certificate](#part-5-deploy-the-certificate)
10. [Part 6: View the Audit Trail](#part-6-view-the-audit-trail-immutable-api-audit-log)
11. [Part 7: Check Notifications](#part-7-check-notifications)
12. [Part 8: Create a Second Certificate and Compare](#part-8-create-a-second-certificate-and-compare)
13. [Part 8.5: Revoke a Certificate](#part-85-revoke-a-certificate)
14. [Part 9: Policy Violations](#part-9-policy-violations)
15. [Part 9.5: Dashboard Stats and Metrics](#part-95-dashboard-stats-and-metrics)
16. [Part 10: Certificate Profiles](#part-10-certificate-profiles)
17. [Part 11: Agent Groups](#part-11-agent-groups)
18. [Part 12: Interactive Approval Workflow](#part-12-interactive-approval-workflow)
19. [Part 13: Advanced Query Features](#part-13-advanced-query-features)
20. [Part 14: CLI Tool](#part-14-cli-tool-m16b)
21. [Part 15: MCP Server for AI Integration](#part-15-mcp-server-for-ai-integration-m18a)
22. [Part 16: Certificate Discovery](#part-16-certificate-discovery-m18b--m21)
23. [End-to-End Architecture Summary](#end-to-end-architecture-summary)
24. [Full Automated Script](#full-automated-script)
25. [What to Show Stakeholders](#what-to-show-stakeholders)
26. [Teardown](#teardown)
## Setup ## Setup
Make sure certctl is running: Make sure certctl is running:
@@ -62,6 +97,27 @@ docker compose -f deploy/docker-compose.yml restart server
The CA key can be RSA, ECDSA, or PKCS#8 format. The connector validates that the certificate has `IsCA=true` and `KeyUsageCertSign`. The CA key can be RSA, ECDSA, or PKCS#8 format. The connector validates that the certificate has `IsCA=true` and `KeyUsageCertSign`.
### ACME with ZeroSSL (Auto-EAB)
ZeroSSL is a free ACME CA that requires External Account Binding (EAB) for account registration. certctl auto-fetches EAB credentials from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — you just need an email address:
```bash
# Minimal config — certctl auto-fetches EAB credentials from ZeroSSL
export CERTCTL_ACME_DIRECTORY_URL="https://acme.zerossl.com/v2/DV90"
export CERTCTL_ACME_EMAIL="ops@example.com"
```
No dashboard visit, no manual EAB credential copy-paste. certctl calls `api.zerossl.com/acme/eab-credentials-email` with your email, gets back a KID + HMAC key, and uses them for ACME account registration automatically.
If you already have EAB credentials (e.g., from the ZeroSSL dashboard or for other CAs like Google Trust Services or SSL.com), you can provide them explicitly:
```bash
export CERTCTL_ACME_DIRECTORY_URL="https://acme.zerossl.com/v2/DV90"
export CERTCTL_ACME_EMAIL="ops@example.com"
export CERTCTL_ACME_EAB_KID="your-key-id"
export CERTCTL_ACME_EAB_HMAC="your-base64url-hmac-key"
```
### ACME with DNS-01 Challenges (Wildcard Certificates) ### ACME with DNS-01 Challenges (Wildcard Certificates)
For Let's Encrypt or other ACME providers with wildcard support: For Let's Encrypt or other ACME providers with wildcard support:
@@ -97,6 +153,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 +292,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). V2 adds the OpenSSL/Custom CA connector (script-based signing). 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
@@ -805,14 +876,14 @@ curl -s -X POST $API/api/v1/agent-groups \
## Part 12: Interactive Approval Workflow ## Part 12: Interactive Approval Workflow
For high-value certificates, you may want human oversight before renewal proceeds. Create a policy that requires approval: For high-value certificates, you may want human oversight before renewal proceeds. The demo includes 2 pre-seeded `AwaitingApproval` renewal jobs (for `auth-production` and `payments-production`). Open **Jobs** in the sidebar — you'll see the amber "Pending Approval" banner and Approve/Reject buttons immediately.
```bash ```bash
# Check jobs that need approval # Check jobs that need approval (demo includes 2)
curl -s "$API/api/v1/jobs?status=AwaitingApproval" | jq '.data[] | {id, type, certificate_id, status}' curl -s "$API/api/v1/jobs?status=AwaitingApproval" | jq '.data[] | {id, type, certificate_id, status}'
``` ```
If there are jobs awaiting approval, approve or reject them: Approve or reject them:
```bash ```bash
# Approve a job # Approve a job
@@ -830,6 +901,8 @@ curl -s -X POST $API/api/v1/jobs/JOB_ID/reject \
**Why interactive approval:** Not every certificate renewal should be automatic. PCI-scoped certificates, certs with specific compliance requirements, or certificates being migrated between issuers benefit from a human checkpoint. The AwaitingApproval state creates that checkpoint without blocking the entire job pipeline. **Why interactive approval:** Not every certificate renewal should be automatic. PCI-scoped certificates, certs with specific compliance requirements, or certificates being migrated between issuers benefit from a human checkpoint. The AwaitingApproval state creates that checkpoint without blocking the entire job pipeline.
**In the dashboard:** Click "Jobs" in the sidebar, filter by status "AwaitingApproval", and you'll see a list of renewal jobs waiting for approval. Each job shows the certificate, issuer, and requested validity period. Click a job to open its detail view and see the Approve / Reject buttons with a reason text field. After approval or rejection, the job status updates in real-time and the audit trail records the decision.
--- ---
## Part 13: Advanced Query Features ## Part 13: Advanced Query Features
@@ -956,6 +1029,8 @@ The MCP server is perfect for:
certctl discovers existing certificates two ways: **filesystem scanning** (agents scan local directories) and **network scanning** (the server probes TLS endpoints). Both feed into the same triage pipeline. certctl discovers existing certificates two ways: **filesystem scanning** (agents scan local directories) and **network scanning** (the server probes TLS endpoints). Both feed into the same triage pipeline.
**The demo comes pre-loaded with discovery data:** 9 discovered certificates (3 Unmanaged from filesystem scans, 3 Unmanaged from network scans, 2 Managed, 1 Dismissed), 3 discovery scans, and 3 network scan targets with recent scan results. Open **Discovery** in the sidebar to see the triage workflow immediately. The steps below show how to configure discovery from scratch.
### Filesystem Discovery (Agent-Side) ### Filesystem Discovery (Agent-Side)
Configure the demo agent to scan for certificates. In the Docker Compose setup, agents have a `/tmp/certs` directory (created by the seed script). Restart the agent with discovery enabled: Configure the demo agent to scan for certificates. In the Docker Compose setup, agents have a `/tmp/certs` directory (created by the seed script). Restart the agent with discovery enabled:
@@ -976,7 +1051,7 @@ certctl-agent --agent-id a-demo-1 --key-dir /tmp/keys --discovery-dirs /tmp/cert
### Network Discovery (Server-Side) ### Network Discovery (Server-Side)
The server can also discover certificates by actively probing TLS endpoints — no agent required. Create a scan target and trigger a scan: The server can also discover certificates by actively probing TLS endpoints — no agent required. Network scanning is enabled by default in the Docker Compose demo (`CERTCTL_NETWORK_SCAN_ENABLED=true`), with 3 pre-configured scan targets. You can create additional targets:
```bash ```bash
# Create a network scan target # Create a network scan target
@@ -1030,6 +1105,28 @@ curl -s -X POST "$API/api/v1/discovered-certificates/$DISCOVERED_ID/dismiss" \
**How it works:** Filesystem discovery: the agent scans `CERTCTL_DISCOVERY_DIRS` on startup and every 6 hours, extracts metadata (common name, SANs, issuer, expiration, key type, fingerprint) from all PEM and DER files, and POSTs findings to `POST /api/v1/agents/{id}/discoveries`. Network discovery: the server expands CIDR ranges (capped at /20 = 4096 IPs), connects to each IP:port via TLS, extracts the peer certificate chain, and stores results using `server-scanner` as a sentinel agent ID. Both sources deduplicate by fingerprint and store results with a status: **Unmanaged** (discovered, not yet managed), **Managed** (linked to a control plane cert), or **Dismissed** (operator decided not to manage). This gives you a triage workflow: discover → review → claim or dismiss. **How it works:** Filesystem discovery: the agent scans `CERTCTL_DISCOVERY_DIRS` on startup and every 6 hours, extracts metadata (common name, SANs, issuer, expiration, key type, fingerprint) from all PEM and DER files, and POSTs findings to `POST /api/v1/agents/{id}/discoveries`. Network discovery: the server expands CIDR ranges (capped at /20 = 4096 IPs), connects to each IP:port via TLS, extracts the peer certificate chain, and stores results using `server-scanner` as a sentinel agent ID. Both sources deduplicate by fingerprint and store results with a status: **Unmanaged** (discovered, not yet managed), **Managed** (linked to a control plane cert), or **Dismissed** (operator decided not to manage). This gives you a triage workflow: discover → review → claim or dismiss.
### Discovery & Network Scans in the Dashboard
**Discovered Certificates Page:** Click "Discovery" in the sidebar to see a triage workflow. The page lists all discovered certificates grouped by status (Unmanaged, Managed, Dismissed). For each Unmanaged certificate, you see:
- Common name and SANs
- Issuer and subject DN
- Expiration date
- Fingerprint (helps dedup)
- Source (agent ID or `server-scanner` for network scans)
- Action buttons: Claim (manage this cert), Dismiss (ignore it)
Click "Claim" to bring an unmanaged certificate under certctl's control. Click "Dismiss" to remove it from the triage queue.
**Network Scans Page:** Click "Network Scans" in the sidebar to manage network scan targets. The page shows all configured scan targets with:
- Target name and description
- CIDR ranges and ports scanned
- Enabled/disabled toggle
- Scan interval and connection timeout
- Last scan timestamp and result summary
- Action buttons: Edit, Delete, Scan Now (immediate)
Click "Scan Now" to trigger an immediate TLS probe of the target's IP ranges. Results appear within seconds in the Discovered Certificates page as entries with `agent_id=server-scanner`.
**In the dashboard**, click "Discovered Certificates" in the sidebar to see what agents and network scans found — claim unmanaged certs to bring them under certctl's management, or dismiss them. **In the dashboard**, click "Discovered Certificates" in the sidebar to see what agents and network scans found — claim unmanaged certs to bring them under certctl's management, or dismiss them.
--- ---
+9 -7
View File
@@ -288,9 +288,10 @@ 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
@@ -1035,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
@@ -1117,9 +1118,10 @@ 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 |
+40 -5
View File
@@ -6,6 +6,30 @@ This guide gets you running in 5 minutes and walks you through everything certct
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.
## Contents
1. [Prerequisites](#prerequisites)
2. [Start Everything](#start-everything)
3. [Open the Dashboard](#open-the-dashboard)
4. [Explore the API](#explore-the-api)
- [Core operations](#core-operations)
- [Sorting, filtering, and pagination](#sorting-filtering-and-pagination)
- [Stats and metrics](#stats-and-metrics)
5. [Create Your First Certificate](#create-your-first-certificate)
- [Revoke a certificate](#revoke-a-certificate)
- [Interactive approval workflow](#interactive-approval-workflow)
6. [Certificate Discovery](#certificate-discovery)
- [Filesystem discovery (agent-based)](#filesystem-discovery-agent-based)
- [Network discovery (agentless)](#network-discovery-agentless)
- [Triage discovered certificates](#triage-discovered-certificates)
7. [CLI Tool](#cli-tool)
8. [MCP Server (AI Integration)](#mcp-server-ai-integration)
9. [Demo Data Reference](#demo-data-reference)
10. [Dashboard Demo Mode](#dashboard-demo-mode)
11. [Presenting to Stakeholders](#presenting-to-stakeholders)
12. [Tear Down](#tear-down)
13. [What's Next](#whats-next)
## Prerequisites ## Prerequisites
You need **Docker** and **Docker Compose** installed. That's it. You need **Docker** and **Docker Compose** installed. That's it.
@@ -65,7 +89,7 @@ The dashboard comes pre-loaded with 15 demo certificates across multiple teams,
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). 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. Explore the sidebar: Certificates, Agents, Policies, Jobs, Audit Trail, Notifications, Profiles, Teams, Owners, Agent Groups, Fleet Overview, Short-Lived Credentials, Discovery, and Network Scans.
### Scenarios to walk through ### Scenarios to walk through
@@ -77,7 +101,9 @@ Explore the sidebar: Certificates, Agents, Policies, Jobs, Audit Trail, Notifica
**"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. **"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. **"What about certificates already in production?"** — Click "Discovery" in the sidebar. The demo comes pre-loaded with 9 discovered certificates — some found by agents scanning filesystems, some found by the server probing TLS endpoints on the network. You'll see Unmanaged certs waiting for triage (including an expired printer cert and an expiring switch management cert), certs already linked to managed inventory, and one that was dismissed. Claim unmanaged certs to bring them under automation, or dismiss them. Click "Network Scans" to see the 3 configured scan targets with recent scan results.
**"I need to approve a renewal before it proceeds"** — Click "Jobs" in the sidebar. You'll see an amber banner: "2 jobs awaiting approval." These are renewal jobs for `auth-production` and `payments-production` that require human sign-off before proceeding. Click Approve or Reject with a reason — the decision is recorded in the audit trail.
**"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. **"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.
@@ -230,9 +256,12 @@ curl -s http://localhost:8443/api/v1/crl | jq .
### Interactive approval workflow ### Interactive approval workflow
For high-value certificates where you want human oversight: For high-value certificates where you want human oversight. The demo includes 2 pre-seeded jobs in `AwaitingApproval` status (for `auth-production` and `payments-production`). Open **Jobs** in the sidebar and you'll see the amber "Pending Approval" banner immediately.
```bash ```bash
# List jobs awaiting approval (demo includes 2)
curl -s "http://localhost:8443/api/v1/jobs?status=AwaitingApproval" | jq '.data[] | {id, certificate_id, status}'
# 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 \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
@@ -248,6 +277,8 @@ curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/reject \
Find certificates already running in your infrastructure — ones you didn't issue through certctl. Find certificates already running in your infrastructure — ones you didn't issue through certctl.
The demo environment comes pre-loaded with 9 discovered certificates (from agent filesystem scans and server-side network scans), 3 network scan targets, and recent scan history. Open **Discovery** and **Network Scans** in the sidebar to see the triage workflow immediately.
### Filesystem discovery (agent-based) ### Filesystem discovery (agent-based)
```bash ```bash
@@ -331,11 +362,15 @@ Exposes 78 MCP tools covering the REST API via stdio transport. Ask Claude: "Wha
| Teams | 5 | Platform, Security, Payments, Frontend, Data | | Teams | 5 | Platform, Security, Payments, Frontend, Data |
| Owners | 5 | Alice, Bob, Carol, Dave, Eve | | Owners | 5 | Alice, Bob, Carol, Dave, Eve |
| Issuers | 4 | Local Dev CA, Let's Encrypt Staging, step-ca Internal, DigiCert (disabled) | | 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 | | Agents | 6 | ag-web-prod, ag-web-staging, ag-lb-prod, ag-iis-prod, ag-data-prod, server-scanner (network discovery) |
| Targets | 5 | NGINX (prod/staging/data), F5 LB, IIS | | Targets | 5 | NGINX (prod/staging/data), F5 LB, IIS |
| Certificates | 15 | Various statuses: Active, Expiring, Expired, Failed, Wildcard | | Certificates | 15 | Various statuses: Active, Expiring, Expired, Failed, Wildcard |
| Discovered Certs | 9 | 5 Unmanaged (filesystem + network), 2 Managed (linked), 1 Dismissed, network-discovered expired printer cert |
| Discovery Scans | 3 | Agent filesystem scans + network TLS scan |
| Network Scan Targets | 3 | DC1 Web Servers, DC2 Application Tier, DMZ Public Endpoints |
| Jobs (Approval) | 2 | AwaitingApproval renewal jobs for auth-prod and payments-prod |
| Policies | 4 | Required owner, allowed environments, max lifetime, min renewal window | | Policies | 4 | Required owner, allowed environments, max lifetime, min renewal window |
| Profiles | 3 | Default TLS, Short-Lived, High-Security | | Profiles | 4 | Standard TLS, Internal mTLS, Short-Lived, High Security |
| Agent Groups | 5 | Linux agents, ARM agents, Production subnet, etc. | | Agent Groups | 5 | Linux agents, ARM agents, Production subnet, etc. |
## Dashboard Demo Mode ## Dashboard Demo Mode
Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+166 -42
View File
@@ -2,11 +2,42 @@
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. 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.
## Contents
- [Prerequisites](#prerequisites)
- [Part 1: Infrastructure & Deployment](#part-1-infrastructure--deployment)
- [Part 2: Authentication & Security](#part-2-authentication--security)
- [Part 3: Certificate Lifecycle (CRUD)](#part-3-certificate-lifecycle-crud)
- [Part 4: Renewal Workflow](#part-4-renewal-workflow)
- [Part 5: Revocation](#part-5-revocation)
- [Part 6: Issuer Connectors](#part-6-issuer-connectors)
- [Part 7: Target Connectors & Deployment](#part-7-target-connectors--deployment)
- [Part 8: Agent Operations](#part-8-agent-operations)
- [Part 9: Job System](#part-9-job-system)
- [Part 10: Policies & Profiles](#part-10-policies--profiles)
- [Part 11: Ownership, Teams & Agent Groups](#part-11-ownership-teams--agent-groups)
- [Part 12: Notifications](#part-12-notifications)
- [Part 13: Observability](#part-13-observability)
- [Part 14: Audit Trail](#part-14-audit-trail)
- [Part 15: Certificate Discovery (Filesystem + Network)](#part-15-certificate-discovery-filesystem--network)
- [Part 16: Enhanced Query API](#part-16-enhanced-query-api)
- [Part 17: CLI Tool](#part-17-cli-tool)
- [Part 18: MCP Server](#part-18-mcp-server)
- [Part 19: GUI Testing](#part-19-gui-testing)
- [Part 20: Background Scheduler](#part-20-background-scheduler)
- [Part 21: Error Handling](#part-21-error-handling)
- [Part 22: Performance Spot Checks](#part-22-performance-spot-checks)
- [Part 23: Structured Logging Verification](#part-23-structured-logging-verification)
- [Part 24: Documentation Verification](#part-24-documentation-verification)
- [Part 25: Regression Tests](#part-25-regression-tests)
- [Part 26: EST Server (RFC 7030)](#part-26-est-server-rfc-7030)
- [Release Sign-Off](#release-sign-off)
--- ---
## 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 +1454,62 @@ 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.
---
**Test 6.2.3 — Configure ACME with External Account Binding (ZeroSSL)**
Edit `deploy/docker-compose.yml` to set EAB environment variables:
- `CERTCTL_ACME_DIRECTORY_URL: https://acme.zerossl.com/v2/DV90`
- `CERTCTL_ACME_EAB_KID: your-zerossl-kid`
- `CERTCTL_ACME_EAB_HMAC: your-base64url-hmac-key`
Restart and verify the issuer accepts the config:
```bash
curl -s -H "$AUTH" "$SERVER/api/v1/issuers/iss-acme-prod" | jq '{id, type}'
```
**What:** Verifies that ACME issuers read External Account Binding credentials from environment variables.
**Why:** ZeroSSL, Google Trust Services, and SSL.com require EAB for ACME account registration. Without EAB, account creation fails and no certificates can be issued from these CAs.
**Expected:** HTTP 200. ACME issuer functional with EAB credentials loaded.
**PASS if** HTTP 200 and issuer responds. **FAIL** if 500 or startup errors related to EAB.
---
## 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.
@@ -2368,8 +2455,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.
--- ---
@@ -2380,12 +2467,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")
@@ -2395,10 +2482,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.
--- ---
@@ -3279,26 +3366,61 @@ Open `http://localhost:8443` in a browser.
| 19.4.5 | Inline policy editor | Click edit on policy section | Dropdown selectors appear, save/cancel buttons | PASS if edit mode works | | 19.4.5 | Inline policy editor | Click edit on policy section | Dropdown selectors appear, save/cancel buttons | PASS if edit mode works |
| 19.4.6 | Revoke button | Click revoke | Reason modal, status updates after | PASS if revocation completes | | 19.4.6 | Revoke button | Click revoke | Reason modal, status updates after | PASS if revocation completes |
### 19.5 Other Pages ### 19.5 Jobs Page — Approval Workflow
| Test ID | Test | Page | Expected | Pass/Fail Criteria |
|---------|------|------|----------|-------------------|
| 19.5.1 | Target wizard | Targets → New Target | 3-step wizard (type → config → review) | PASS if all 3 steps work |
| 19.5.2 | Audit filters | Audit | Time, actor, action filters work | PASS if filters change results |
| 19.5.3 | Audit export | Audit → Export | CSV/JSON file downloads | PASS if file downloads |
| 19.5.4 | Short-lived creds | Short-Lived | Certs with TTL < 1h, countdown timers | PASS if timers count down |
| 19.5.5 | Agent list | Agents | OS/Arch column visible | PASS if metadata shown |
| 19.5.6 | Agent detail | Click agent | System Information card | PASS if OS, arch, IP shown |
| 19.5.7 | Fleet overview | Fleet Overview | OS/arch grouping charts | PASS if pie charts render |
### 19.6 Cross-Cutting
| Test ID | Test | Action | Expected | Pass/Fail Criteria | | Test ID | Test | Action | Expected | Pass/Fail Criteria |
|---------|------|--------|----------|-------------------| |---------|------|--------|----------|-------------------|
| 19.6.1 | Sidebar nav | Click all sidebar links | All pages load without errors | PASS if no broken routes | | 19.5.1 | Approval banner | Navigate to Jobs with AwaitingApproval jobs | Amber banner shows count of pending approvals | PASS if banner visible with correct count |
| 19.6.2 | Logout | Click logout | Returns to login screen | PASS if login page shown | | 19.5.2 | Approve button | Find AwaitingApproval job, click Approve | Job status changes to Running/Completed | PASS if status transitions |
| 19.6.3 | 401 redirect | Expire/remove auth token | Auto-redirect to login | PASS if login page shown | | 19.5.3 | Reject button | Find AwaitingApproval job, click Reject | Modal opens with reason input | PASS if modal appears |
| 19.6.4 | Dark theme | Check page styling | Dark background, readable text | PASS if theme consistent | | 19.5.4 | Reject with reason | Enter reason, submit rejection | Job status changes, modal closes | PASS if job rejected |
| 19.5.5 | Status filter | Select "Awaiting Approval" from status dropdown | Only AwaitingApproval jobs shown | PASS if filter works |
| 19.5.6 | AwaitingCSR filter | Select "Awaiting CSR" from status dropdown | Only AwaitingCSR jobs shown | PASS if filter works |
### 19.6 Discovery Triage Page
| Test ID | Test | Action | Expected | Pass/Fail Criteria |
|---------|------|--------|----------|-------------------|
| 19.6.1 | Summary stats | Navigate to Discovery | Stats bar shows Unmanaged/Managed/Dismissed counts | PASS if all 3 counts visible |
| 19.6.2 | Table loads | View Discovery page | Table populated with discovered certificates | PASS if certs listed |
| 19.6.3 | Status filter | Select "Unmanaged" from status dropdown | Only Unmanaged certs shown | PASS if filter works |
| 19.6.4 | Agent filter | Select agent from dropdown | Certs filtered by agent | PASS if filter works |
| 19.6.5 | Claim button | Click Claim on Unmanaged cert | Modal opens with managed cert ID input | PASS if modal appears |
| 19.6.6 | Claim submit | Enter cert ID, submit claim | Cert status changes to Managed, modal closes | PASS if status updates |
| 19.6.7 | Dismiss button | Click Dismiss on Unmanaged cert | Cert status changes to Dismissed | PASS if status updates |
| 19.6.8 | Scan history | Click "Show Scan History" | Collapsible panel shows scan records with agent, directories, counts | PASS if scan history visible |
### 19.7 Network Scan Management Page
| Test ID | Test | Action | Expected | Pass/Fail Criteria |
|---------|------|--------|----------|-------------------|
| 19.7.1 | Table loads | Navigate to Network Scans | Table with seed scan targets | PASS if targets listed |
| 19.7.2 | New Target button | Click "+ New Target" | Create modal opens | PASS if modal visible |
| 19.7.3 | Create target | Fill name, CIDRs, ports, submit | New target appears in table | PASS if target created |
| 19.7.4 | Enable toggle | Click toggle on a target | Enabled state flips | PASS if toggle works |
| 19.7.5 | Scan Now | Click Scan Now on a target | Scan triggered (check last_scan_at updates) | PASS if scan initiated |
| 19.7.6 | Delete target | Click Delete on a target | Target removed from table | PASS if target gone |
### 19.8 Other Pages
| Test ID | Test | Page | Expected | Pass/Fail Criteria |
|---------|------|------|----------|-------------------|
| 19.8.1 | Target wizard | Targets → New Target | 3-step wizard (type → config → review) | PASS if all 3 steps work |
| 19.8.2 | Audit filters | Audit | Time, actor, action filters work | PASS if filters change results |
| 19.8.3 | Audit export | Audit → Export | CSV/JSON file downloads | PASS if file downloads |
| 19.8.4 | Short-lived creds | Short-Lived | Certs with TTL < 1h, countdown timers | PASS if timers count down |
| 19.8.5 | Agent list | Agents | OS/Arch column visible | PASS if metadata shown |
| 19.8.6 | Agent detail | Click agent | System Information card | PASS if OS, arch, IP shown |
| 19.8.7 | Fleet overview | Fleet Overview | OS/arch grouping charts | PASS if pie charts render |
### 19.9 Cross-Cutting
| Test ID | Test | Action | Expected | Pass/Fail Criteria |
|---------|------|--------|----------|-------------------|
| 19.9.1 | Sidebar nav | Click all sidebar links | All 21 pages load without errors | PASS if no broken routes |
| 19.9.2 | Logout | Click logout | Returns to login screen | PASS if login page shown |
| 19.9.3 | 401 redirect | Expire/remove auth token | Auto-redirect to login | PASS if login page shown |
| 19.9.4 | Theme consistency | Check page styling | Light content area, teal sidebar, branded colors, readable text | PASS if theme consistent across all pages |
--- ---
@@ -3698,36 +3820,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/quickstart.md` | Quick start + 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).
--- ---
@@ -3812,14 +3936,14 @@ 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
@@ -4104,5 +4228,5 @@ All 26 parts must pass before tagging v2.0.1.
| Part 25: Regression Tests | ☐ | | | | | Part 25: Regression Tests | ☐ | | | |
| Part 26: EST Server (RFC 7030) | ☐ | | | | | 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.
+82
View File
@@ -0,0 +1,82 @@
# Why certctl?
Certificate management is broken at every scale between "one domain on Let's Encrypt" and "Fortune 500 budget for Venafi."
If you run a personal blog, Certbot works fine. If your company spends $200K/year on Keyfactor, you're covered. But if you're an ops engineer managing 20-500 certificates across NGINX, Apache, HAProxy, and maybe a private CA — the tools available today either don't do enough or cost too much.
certctl fills that gap.
## The Problem
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/) in April 2025, mandating a phased reduction in TLS certificate lifetimes: 200 days as of March 2026, 100 days by March 2027, and 47 days by March 2029. That means every organization needs automated certificate renewal — not eventually, but now.
The existing options for automation are:
- **ACME clients** (Certbot, Lego, CertWarden): Handle issuance and renewal for ACME-compatible CAs, but don't manage deployment to target servers, don't provide inventory visibility, don't support non-ACME CAs, and don't offer audit trails or policy enforcement.
- **Kubernetes-native** (cert-manager): Works well inside Kubernetes, but if your infrastructure includes bare-metal servers, VMs, or network appliances alongside Kubernetes, you need a separate solution for everything cert-manager can't reach.
- **Commercial SaaS** (CertKit, Sectigo CLM): Handle more of the lifecycle but are proprietary, cloud-dependent, and priced per certificate — costs scale linearly with your infrastructure.
- **Enterprise platforms** (Venafi, Keyfactor, AppViewX): Comprehensive but start at $75K/year and require dedicated teams to operate.
## What certctl Does Differently
certctl is a self-hosted certificate lifecycle platform. It handles issuance, renewal, deployment, revocation, discovery, and monitoring — with three design decisions that no other tool at any price point combines:
### 1. Private Keys Never Leave Your Infrastructure
certctl agents generate private keys locally using ECDSA P-256. The agent creates a CSR and submits it to the control plane. The signed certificate comes back. The private key stays on the agent's filesystem with 0600 permissions.
This isn't a premium feature — it's the default behavior in the free tier. Most competitors either generate keys server-side (creating a single point of compromise) or gate key isolation behind paid tiers.
### 2. CA-Agnostic Issuer Architecture
certctl works with any certificate authority, not just ACME providers:
- **ACME** (Let's Encrypt, ZeroSSL, Google Trust Services, Buypass) — HTTP-01 and DNS-01 challenges, DNS-PERSIST-01 for zero-touch renewals, External Account Binding
- **step-ca** (Smallstep) — native /sign API with JWK provisioner authentication
- **Local CA** — self-signed or sub-CA mode (chain to your enterprise root CA, e.g. ADCS)
- **OpenSSL / Custom CA** — delegate signing to any shell script with configurable timeout
- **EST enrollment** (RFC 7030) — device certificate enrollment for WiFi/802.1X, MDM, and IoT
Every issuer connector implements the same interface. Switching CAs or running multiple CAs in parallel requires zero code changes — just configuration.
### 3. Post-Deployment Verification (coming in v2.0.6)
Every other tool in this space stops at "the deployment command succeeded." certctl is adding a step nobody else has: after deploying a certificate to a target, the agent connects back to the target's TLS endpoint and verifies the served certificate matches what was deployed, using SHA-256 fingerprint comparison.
A reload command can exit 0 while the certificate doesn't take effect — wrong virtual host, stale cache, config that validates but doesn't apply. certctl will catch this.
## How certctl Compares
### vs. CertKit
Closest competitor architecturally — agent-based, private key isolation (Keystore), multi-platform. certctl leads on issuer coverage (ACME + step-ca + Local CA + OpenSSL + EST vs. ACME-only), PKI compliance (CRL, OCSP, RFC 5280 revocation, immutable audit trail — all missing from CertKit today), policy engine (5 rule types vs. none), and network discovery (CIDR TLS scanning vs. none). certctl is source-available (BSL 1.1 → Apache 2.0) with no cert limit; CertKit is proprietary SaaS with a 3-cert free tier. Where CertKit leads: more deployment targets today (adds LiteSpeed, IIS, auto-detection), Windows support, Kubernetes, and polished SaaS onboarding.
### vs. KeyTalk
Commercial (proprietary) PKI platform from a Dutch company — on-prem appliance, cloud, or managed service. Broader cert type coverage (TLS, S/MIME, device auth, VPN) and DigiCert + SCEP integrations. No public documentation on policy engine, API surface, or audit capabilities. No free tier, no public pricing. certctl trades breadth of cert types for full transparency — source-available, public API spec, free community edition with no limits.
### vs. Enterprise Platforms (Venafi, Keyfactor)
Comprehensive solutions with decades of features — at $75K-$250K+/yr. certctl targets organizations that need 80% of those capabilities at 1% of the cost. The trade-off: no SSO/RBAC yet (coming in certctl Pro), no F5/IIS target connectors yet, no SLA-backed support.
## Getting Started
```bash
# Clone and start with Docker Compose (includes demo data)
git clone https://github.com/shankar0123/certctl.git
cd certctl/deploy
docker compose up -d
# Open the dashboard
open http://localhost:8443
```
The demo seeds 15 certificates, 5 agents, 5 deployment targets, discovery data, network scan targets, and pending approval jobs so you can explore every feature immediately.
See the [Quickstart Guide](quickstart.md) for a full walkthrough.
## License
certctl is licensed under the [Business Source License 1.1](../LICENSE). The licensed work is free to use for any purpose other than offering a competing managed service. The license converts to Apache 2.0 on March 1, 2033.
The source is available, auditable, and self-hostable. You own your data, your keys, and your deployment.
@@ -77,8 +77,8 @@ func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID strin
func TestListNetworkScanTargets(t *testing.T) { func TestListNetworkScanTargets(t *testing.T) {
svc := &mockNetworkScanService{ svc := &mockNetworkScanService{
targets: []*domain.NetworkScanTarget{ targets: []*domain.NetworkScanTarget{
{ID: "nst-1", Name: "target1", CIDRs: []string{"10.0.0.0/24"}, Ports: []int{443}}, {ID: "nst-1", Name: "target1", CIDRs: []string{"10.0.0.0/24"}, Ports: []int64{443}},
{ID: "nst-2", Name: "target2", CIDRs: []string{"192.168.0.0/16"}, Ports: []int{443, 8443}}, {ID: "nst-2", Name: "target2", CIDRs: []string{"192.168.0.0/16"}, Ports: []int64{443, 8443}},
}, },
} }
h := NewNetworkScanHandler(svc) h := NewNetworkScanHandler(svc)
@@ -118,7 +118,7 @@ func TestCreateNetworkScanTarget(t *testing.T) {
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]interface{}{
"name": "Production", "name": "Production",
"cidrs": []string{"10.0.0.0/24"}, "cidrs": []string{"10.0.0.0/24"},
"ports": []int{443}, "ports": []int64{443},
}) })
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets", bytes.NewReader(body))
+6 -5
View File
@@ -67,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.
+244 -15
View File
@@ -6,12 +6,16 @@ import (
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/x509" "crypto/x509"
"encoding/base64"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"net/url"
"strings"
"sync" "sync"
"time" "time"
@@ -28,21 +32,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 +98,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 +153,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 +173,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)
} }
@@ -188,6 +205,33 @@ func (c *Connector) ensureClient(ctx context.Context) error {
acct := &acme.Account{ acct := &acme.Account{
Contact: []string{"mailto:" + c.config.Email}, Contact: []string{"mailto:" + c.config.Email},
} }
// Auto-fetch EAB credentials from ZeroSSL if directory URL is ZeroSSL and no EAB provided.
// ZeroSSL offers a public endpoint that returns EAB credentials given an email address,
// so users don't need to visit the ZeroSSL dashboard manually.
if c.config.EABKid == "" && c.config.EABHmac == "" && isZeroSSL(c.config.DirectoryURL) {
kid, hmac, eabErr := fetchZeroSSLEAB(ctx, c.config.Email)
if eabErr != nil {
return fmt.Errorf("failed to auto-fetch ZeroSSL EAB credentials: %w", eabErr)
}
c.config.EABKid = kid
c.config.EABHmac = hmac
c.logger.Info("auto-fetched EAB credentials from ZeroSSL", "eab_kid", kid)
}
// External Account Binding (required by ZeroSSL, Google Trust Services, SSL.com, etc.)
if c.config.EABKid != "" && c.config.EABHmac != "" {
hmacKey, decodeErr := base64.RawURLEncoding.DecodeString(c.config.EABHmac)
if decodeErr != nil {
return fmt.Errorf("failed to decode EAB HMAC key (expected base64url): %w", decodeErr)
}
acct.ExternalAccountBinding = &acme.ExternalAccountBinding{
KID: c.config.EABKid,
Key: hmacKey,
}
c.logger.Info("using External Account Binding for ACME registration", "eab_kid", c.config.EABKid)
}
_, err = c.client.Register(ctx, acct, acme.AcceptTOS) _, err = c.client.Register(ctx, acct, acme.AcceptTOS)
if err != nil { if err != nil {
// Account may already exist, try to get it // Account may already exist, try to get it
@@ -203,6 +247,67 @@ func (c *Connector) ensureClient(ctx context.Context) error {
return nil return nil
} }
// zeroSSLEABEndpoint is the ZeroSSL API endpoint for auto-generating EAB credentials.
// Variable (not const) to allow test overrides.
var zeroSSLEABEndpoint = "https://api.zerossl.com/acme/eab-credentials-email"
// isZeroSSL returns true if the ACME directory URL points to ZeroSSL.
func isZeroSSL(directoryURL string) bool {
return strings.Contains(strings.ToLower(directoryURL), "zerossl.com")
}
// fetchZeroSSLEAB retrieves EAB credentials from ZeroSSL's public API endpoint.
// ZeroSSL provides this so users don't need to visit the dashboard manually.
// Returns (kid, hmac_key, error). The HMAC key is already base64url-encoded.
func fetchZeroSSLEAB(ctx context.Context, email string) (string, string, error) {
if email == "" {
return "", "", fmt.Errorf("email is required for ZeroSSL EAB auto-fetch")
}
form := url.Values{"email": {email}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, zeroSSLEABEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("ZeroSSL API returned status %d: %s", resp.StatusCode, string(body))
}
var result struct {
Success bool `json:"success"`
EABKid string `json:"eab_kid"`
EABHmac string `json:"eab_hmac_key"`
ErrorMsg string `json:"error"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", "", fmt.Errorf("parse response: %w", err)
}
if !result.Success || result.EABKid == "" || result.EABHmac == "" {
errDetail := result.ErrorMsg
if errDetail == "" {
errDetail = string(body)
}
return "", "", fmt.Errorf("ZeroSSL EAB generation failed: %s", errDetail)
}
return result.EABKid, result.EABHmac, nil
}
// IssueCertificate submits a certificate issuance request to the ACME CA. // IssueCertificate submits a certificate issuance request to the ACME CA.
// //
// Flow: // Flow:
@@ -335,12 +440,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 +606,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}.
+264
View File
@@ -0,0 +1,264 @@
package acme
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
}
func TestValidateConfig_MissingDirectoryURL(t *testing.T) {
c := New(nil, testLogger())
cfg, _ := json.Marshal(map[string]string{"email": "test@example.com"})
err := c.ValidateConfig(context.Background(), cfg)
if err == nil || !strings.Contains(err.Error(), "directory_url is required") {
t.Fatalf("expected directory_url error, got: %v", err)
}
}
func TestValidateConfig_MissingEmail(t *testing.T) {
c := New(nil, testLogger())
cfg, _ := json.Marshal(map[string]string{"directory_url": "https://example.com/directory"})
err := c.ValidateConfig(context.Background(), cfg)
if err == nil || !strings.Contains(err.Error(), "email is required") {
t.Fatalf("expected email error, got: %v", err)
}
}
func TestValidateConfig_InvalidChallengeType(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`)
}))
defer srv.Close()
c := New(nil, testLogger())
cfg, _ := json.Marshal(map[string]string{
"directory_url": srv.URL,
"email": "test@example.com",
"challenge_type": "invalid-challenge",
})
err := c.ValidateConfig(context.Background(), cfg)
if err == nil || !strings.Contains(err.Error(), "invalid challenge_type") {
t.Fatalf("expected invalid challenge_type error, got: %v", err)
}
}
func TestValidateConfig_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`)
}))
defer srv.Close()
c := New(nil, testLogger())
cfg, _ := json.Marshal(map[string]string{
"directory_url": srv.URL,
"email": "test@example.com",
})
err := c.ValidateConfig(context.Background(), cfg)
if err != nil {
t.Fatalf("expected success, got: %v", err)
}
}
func TestValidateConfig_EABFieldsPreserved(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`)
}))
defer srv.Close()
c := New(nil, testLogger())
cfg, _ := json.Marshal(map[string]string{
"directory_url": srv.URL,
"email": "test@example.com",
"eab_kid": "kid-12345",
"eab_hmac": base64.RawURLEncoding.EncodeToString([]byte("test-hmac-key")),
})
err := c.ValidateConfig(context.Background(), cfg)
if err != nil {
t.Fatalf("expected success, got: %v", err)
}
if c.config.EABKid != "kid-12345" {
t.Fatalf("expected EABKid to be preserved, got: %s", c.config.EABKid)
}
if c.config.EABHmac == "" {
t.Fatal("expected EABHmac to be preserved")
}
}
func TestEnsureClient_EABDecodeError(t *testing.T) {
c := New(&Config{
DirectoryURL: "https://acme.example.com/directory",
Email: "test@example.com",
EABKid: "kid-12345",
EABHmac: "!!!not-valid-base64url!!!",
}, testLogger())
err := c.ensureClient(context.Background())
if err == nil || !strings.Contains(err.Error(), "decode EAB HMAC") {
t.Fatalf("expected EAB decode error, got: %v", err)
}
}
func TestEnsureClient_EABBindingSet(t *testing.T) {
// We can't fully mock the ACME protocol (JWS nonce exchange), but we can
// verify that valid EAB credentials are decoded and attached to the account
// without panicking. The ensureClient call will fail at the network level
// (no real ACME server), but it must NOT fail at EAB decoding.
hmacKey := base64.RawURLEncoding.EncodeToString([]byte("test-hmac-secret-key"))
c := New(&Config{
DirectoryURL: "https://127.0.0.1:1/directory", // unreachable — that's fine
Email: "test@example.com",
EABKid: "kid-zerossl-12345",
EABHmac: hmacKey,
}, testLogger())
err := c.ensureClient(context.Background())
// Expected: network error (unreachable server), NOT an EAB decode error
if err != nil && strings.Contains(err.Error(), "decode EAB HMAC") {
t.Fatalf("EAB decode should not fail with valid base64url key, got: %v", err)
}
// We expect some error (network unreachable) — that's correct
if err == nil {
t.Log("ensureClient succeeded (unexpected but not a failure for this test)")
}
}
// --- ZeroSSL auto-EAB tests ---
func TestIsZeroSSL(t *testing.T) {
tests := []struct {
url string
expect bool
}{
{"https://acme.zerossl.com/v2/DV90", true},
{"https://ACME.ZEROSSL.COM/v2/DV90", true},
{"https://acme-v02.api.letsencrypt.org/directory", false},
{"https://acme.example.com/directory", false},
{"", false},
}
for _, tt := range tests {
if got := isZeroSSL(tt.url); got != tt.expect {
t.Errorf("isZeroSSL(%q) = %v, want %v", tt.url, got, tt.expect)
}
}
}
func TestFetchZeroSSLEAB_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
t.Errorf("expected form content-type, got %s", ct)
}
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
if email := r.FormValue("email"); email != "test@example.com" {
t.Errorf("expected email test@example.com, got %s", email)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"eab_kid":"kid_abc123","eab_hmac_key":"dGVzdC1obWFjLWtleQ"}`)
}))
defer srv.Close()
// Override the endpoint for testing
origEndpoint := zeroSSLEABEndpoint
defer func() { zeroSSLEABEndpoint = origEndpoint }()
zeroSSLEABEndpoint = srv.URL
kid, hmac, err := fetchZeroSSLEAB(context.Background(), "test@example.com")
if err != nil {
t.Fatalf("expected success, got: %v", err)
}
if kid != "kid_abc123" {
t.Errorf("expected kid_abc123, got %s", kid)
}
if hmac != "dGVzdC1obWFjLWtleQ" {
t.Errorf("expected dGVzdC1obWFjLWtleQ, got %s", hmac)
}
}
func TestFetchZeroSSLEAB_EmptyEmail(t *testing.T) {
_, _, err := fetchZeroSSLEAB(context.Background(), "")
if err == nil || !strings.Contains(err.Error(), "email is required") {
t.Fatalf("expected email required error, got: %v", err)
}
}
func TestFetchZeroSSLEAB_APIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"success":false,"error":"invalid email"}`)
}))
defer srv.Close()
origEndpoint := zeroSSLEABEndpoint
defer func() { zeroSSLEABEndpoint = origEndpoint }()
zeroSSLEABEndpoint = srv.URL
_, _, err := fetchZeroSSLEAB(context.Background(), "bad@example.com")
if err == nil || !strings.Contains(err.Error(), "status 400") {
t.Fatalf("expected API error, got: %v", err)
}
}
func TestFetchZeroSSLEAB_MissingCredentials(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":false,"error":"rate limited"}`)
}))
defer srv.Close()
origEndpoint := zeroSSLEABEndpoint
defer func() { zeroSSLEABEndpoint = origEndpoint }()
zeroSSLEABEndpoint = srv.URL
_, _, err := fetchZeroSSLEAB(context.Background(), "test@example.com")
if err == nil || !strings.Contains(err.Error(), "EAB generation failed") {
t.Fatalf("expected EAB generation failed error, got: %v", err)
}
}
func TestEnsureClient_ZeroSSLAutoEAB(t *testing.T) {
// Mock ZeroSSL EAB endpoint
eabSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"eab_kid":"auto-kid-123","eab_hmac_key":"dGVzdC1obWFjLWtleQ"}`)
}))
defer eabSrv.Close()
origEndpoint := zeroSSLEABEndpoint
defer func() { zeroSSLEABEndpoint = origEndpoint }()
zeroSSLEABEndpoint = eabSrv.URL
// Use an unreachable ACME directory — we only care that auto-EAB fetch happens
c := New(&Config{
DirectoryURL: "https://acme.zerossl.com/v2/DV90",
Email: "test@example.com",
// EABKid and EABHmac intentionally empty — should auto-fetch
}, testLogger())
err := c.ensureClient(context.Background())
// Will fail at ACME protocol level (unreachable ZeroSSL directory), but
// EAB credentials should have been auto-fetched and set on config
if c.config.EABKid != "auto-kid-123" {
t.Errorf("expected auto-fetched EABKid, got: %s (err: %v)", c.config.EABKid, err)
}
if c.config.EABHmac != "dGVzdC1obWFjLWtleQ" {
t.Errorf("expected auto-fetched EABHmac, got: %s", c.config.EABHmac)
}
}
+18
View File
@@ -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)
}
})
}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -7,7 +7,7 @@ type NetworkScanTarget struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
CIDRs []string `json:"cidrs"` CIDRs []string `json:"cidrs"`
Ports []int `json:"ports"` Ports []int64 `json:"ports"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
ScanIntervalHours int `json:"scan_interval_hours"` ScanIntervalHours int `json:"scan_interval_hours"`
TimeoutMs int `json:"timeout_ms"` TimeoutMs int `json:"timeout_ms"`
+2 -2
View File
@@ -10,7 +10,7 @@ func TestNetworkScanTarget_Defaults(t *testing.T) {
ID: "nst-test", ID: "nst-test",
Name: "Test Target", Name: "Test Target",
CIDRs: []string{"10.0.0.0/24"}, CIDRs: []string{"10.0.0.0/24"},
Ports: []int{443}, Ports: []int64{443},
Enabled: true, Enabled: true,
ScanIntervalHours: 6, ScanIntervalHours: 6,
TimeoutMs: 5000, TimeoutMs: 5000,
@@ -35,7 +35,7 @@ func TestNetworkScanTarget_WithScanResults(t *testing.T) {
ID: "nst-prod", ID: "nst-prod",
Name: "Production Network", Name: "Production Network",
CIDRs: []string{"192.168.1.0/24", "10.0.0.0/16"}, CIDRs: []string{"192.168.1.0/24", "10.0.0.0/16"},
Ports: []int{443, 8443, 636}, Ports: []int64{443, 8443, 636},
Enabled: true, Enabled: true,
ScanIntervalHours: 1, ScanIntervalHours: 1,
TimeoutMs: 3000, TimeoutMs: 3000,
+55
View File
@@ -937,6 +937,61 @@ func generateE2ECSRBase64DER(t *testing.T, cn string, sans []string) string {
return base64.StdEncoding.EncodeToString(csrDER) 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). // TestESTEndpoints exercises the EST (RFC 7030) enrollment endpoints end-to-end (M23).
func TestESTEndpoints(t *testing.T) { func TestESTEndpoints(t *testing.T) {
server, _, _, _ := setupTestServer(t) server, _, _, _ := setupTestServer(t)
-1
View File
@@ -804,4 +804,3 @@ func TestRevocationEndpoints(t *testing.T) {
}) })
} }
// mockNetworkScanService is defined in lifecycle_test.go (same package)
+2 -2
View File
@@ -76,7 +76,7 @@ func (s *NetworkScanService) CreateTarget(ctx context.Context, target *domain.Ne
} }
} }
if len(target.Ports) == 0 { if len(target.Ports) == 0 {
target.Ports = []int{443} target.Ports = []int64{443}
} }
if target.ScanIntervalHours == 0 { if target.ScanIntervalHours == 0 {
target.ScanIntervalHours = 6 target.ScanIntervalHours = 6
@@ -276,7 +276,7 @@ func (s *NetworkScanService) scanTarget(ctx context.Context, target *domain.Netw
} }
// expandEndpoints converts CIDR ranges and ports into a list of "ip:port" endpoints. // expandEndpoints converts CIDR ranges and ports into a list of "ip:port" endpoints.
func (s *NetworkScanService) expandEndpoints(cidrs []string, ports []int) []string { func (s *NetworkScanService) expandEndpoints(cidrs []string, ports []int64) []string {
var endpoints []string var endpoints []string
for _, cidr := range cidrs { for _, cidr := range cidrs {
+2 -2
View File
@@ -123,7 +123,7 @@ func TestNetworkScanService_CreateTarget(t *testing.T) {
target, err := svc.CreateTarget(context.Background(), &domain.NetworkScanTarget{ target, err := svc.CreateTarget(context.Background(), &domain.NetworkScanTarget{
Name: "Test Network", Name: "Test Network",
CIDRs: []string{"10.0.0.0/24"}, CIDRs: []string{"10.0.0.0/24"},
Ports: []int{443, 8443}, Ports: []int64{443, 8443},
}) })
if err != nil { if err != nil {
t.Fatalf("CreateTarget failed: %v", err) t.Fatalf("CreateTarget failed: %v", err)
@@ -221,7 +221,7 @@ func TestNetworkScanService_ListTargets(t *testing.T) {
func TestExpandEndpoints(t *testing.T) { func TestExpandEndpoints(t *testing.T) {
svc := &NetworkScanService{} svc := &NetworkScanService{}
endpoints := svc.expandEndpoints([]string{"192.168.1.1"}, []int{443, 8443}) endpoints := svc.expandEndpoints([]string{"192.168.1.1"}, []int64{443, 8443})
if len(endpoints) != 2 { if len(endpoints) != 2 {
t.Errorf("expected 2 endpoints, got %d: %v", len(endpoints), endpoints) t.Errorf("expected 2 endpoints, got %d: %v", len(endpoints), endpoints)
} }
+110
View File
@@ -214,3 +214,113 @@ INSERT INTO agent_group_members (agent_group_id, agent_id, membership_type, crea
('ag-manual', 'ag-web-staging', 'include', NOW()), ('ag-manual', 'ag-web-staging', 'include', NOW()),
('ag-manual', 'ag-iis-prod', 'exclude', NOW()) ('ag-manual', 'ag-iis-prod', 'exclude', NOW())
ON CONFLICT (agent_group_id, agent_id) DO NOTHING; ON CONFLICT (agent_group_id, agent_id) DO NOTHING;
-- Sentinel agent for network-discovered certificates (created by server on startup, seed for demo)
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash, os, architecture, ip_address, version) VALUES
('server-scanner', 'Network Scanner (Server-Side)', 'certctl-server', 'online', NOW(), NOW() - INTERVAL '30 days', 'sentinel_no_auth', 'linux', 'amd64', '127.0.0.1', '2.0.5')
ON CONFLICT (id) DO NOTHING;
-- Discovery Scans — show recent scan activity from agents
INSERT INTO discovery_scans (id, agent_id, directories, certificates_found, certificates_new, errors_count, scan_duration_ms, started_at, completed_at) VALUES
('ds-web-prod-01', 'ag-web-prod', '{/etc/nginx/ssl,/etc/pki/tls/certs}', 4, 2, 0, 1250, NOW() - INTERVAL '3 hours', NOW() - INTERVAL '3 hours' + INTERVAL '1 second'),
('ds-data-prod-01', 'ag-data-prod', '{/etc/nginx/ssl,/opt/certs}', 3, 1, 0, 980, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '2 hours' + INTERVAL '1 second'),
('ds-network-scan-01','server-scanner', '{network-scan}', 3, 3, 0, 4500, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour' + INTERVAL '5 seconds')
ON CONFLICT (id) DO NOTHING;
-- Discovered Certificates — populate discovery triage page with realistic mix
INSERT INTO discovered_certificates (id, fingerprint_sha256, common_name, sans, serial_number, issuer_dn, subject_dn, not_before, not_after, key_algorithm, key_size, is_ca, pem_data, source_path, source_format, agent_id, discovery_scan_id, managed_certificate_id, status, first_seen_at, last_seen_at) VALUES
-- Unmanaged: found on filesystem, not yet claimed
('dc-unmanaged-01', 'sha256:f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0',
'internal-service.example.com', ARRAY['internal-service.example.com', 'internal-svc.local'],
'1A:2B:3C:4D:5E:6F:00:11', 'CN=Example Internal CA,O=Example Corp',
'CN=internal-service.example.com,O=Example Corp', NOW() - INTERVAL '200 days', NOW() + INTERVAL '20 days',
'RSA', 2048, false, '', '/etc/pki/tls/certs/internal-svc.pem', 'PEM',
'ag-web-prod', 'ds-web-prod-01', NULL, 'Unmanaged',
NOW() - INTERVAL '7 days', NOW() - INTERVAL '3 hours'),
('dc-unmanaged-02', 'sha256:a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0',
'monitoring.internal.example.com', ARRAY['monitoring.internal.example.com', 'prometheus.internal.example.com'],
'2B:3C:4D:5E:6F:7A:00:22', 'CN=Let''s Encrypt Authority X3,O=Let''s Encrypt',
'CN=monitoring.internal.example.com', NOW() - INTERVAL '60 days', NOW() + INTERVAL '30 days',
'ECDSA', 256, false, '', '/opt/certs/monitoring.pem', 'PEM',
'ag-data-prod', 'ds-data-prod-01', NULL, 'Unmanaged',
NOW() - INTERVAL '5 days', NOW() - INTERVAL '2 hours'),
('dc-unmanaged-03', 'sha256:1122334455667788990011223344556677889900',
'db-replication.example.com', ARRAY['db-replication.example.com'],
'3C:4D:5E:6F:7A:8B:00:33', 'CN=Example Internal CA,O=Example Corp',
'CN=db-replication.example.com,O=Example Corp', NOW() - INTERVAL '300 days', NOW() - INTERVAL '10 days',
'RSA', 4096, false, '', '/etc/pki/tls/certs/db-repl.pem', 'PEM',
'ag-web-prod', 'ds-web-prod-01', NULL, 'Unmanaged',
NOW() - INTERVAL '7 days', NOW() - INTERVAL '3 hours'),
-- Managed: already linked to managed certificates
('dc-managed-01', 'sha256:ab12cd34ef56ab12cd34ef56ab12cd34ef56ab12',
'api.example.com', ARRAY['api.example.com', 'api-v2.example.com'],
'0A:1B:2C:3D:4E:5F:00:01', 'CN=CertCtl Demo CA',
'CN=api.example.com', NOW() - INTERVAL '15 days', NOW() + INTERVAL '75 days',
'ECDSA', 256, false, '', '/etc/nginx/ssl/cert.pem', 'PEM',
'ag-web-prod', 'ds-web-prod-01', 'mc-api-prod', 'Managed',
NOW() - INTERVAL '15 days', NOW() - INTERVAL '3 hours'),
('dc-managed-02', 'sha256:cd34ef56ab12cd34ef56ab12cd34ef56ab12cd34',
'data.example.com', ARRAY['data.example.com', 'analytics.example.com'],
'0A:1B:2C:3D:4E:5F:00:06', 'CN=CertCtl Demo CA',
'CN=data.example.com', NOW() - INTERVAL '35 days', NOW() + INTERVAL '55 days',
'ECDSA', 256, false, '', '/etc/nginx/ssl/cert.pem', 'PEM',
'ag-data-prod', 'ds-data-prod-01', 'mc-data-prod', 'Managed',
NOW() - INTERVAL '35 days', NOW() - INTERVAL '2 hours'),
-- Dismissed: triaged and explicitly ignored
('dc-dismissed-01', 'sha256:9988776655443322110099887766554433221100',
'test-selfsigned.local', ARRAY['test-selfsigned.local', 'localhost'],
'00:00:00:00:00:00:FF:01', 'CN=test-selfsigned.local',
'CN=test-selfsigned.local', NOW() - INTERVAL '365 days', NOW() + INTERVAL '365 days',
'RSA', 2048, false, '', '/etc/pki/tls/certs/test.pem', 'PEM',
'ag-web-prod', 'ds-web-prod-01', NULL, 'Dismissed',
NOW() - INTERVAL '7 days', NOW() - INTERVAL '3 hours'),
-- Network-discovered certs (from server-scanner sentinel agent)
('dc-network-01', 'sha256:net1aabbccdd11223344556677889900aabbccdd',
'switch-mgmt.example.com', ARRAY['switch-mgmt.example.com'],
'5E:6F:7A:8B:9C:0D:00:44', 'CN=Example Network CA,O=Example Corp',
'CN=switch-mgmt.example.com,O=Example Corp', NOW() - INTERVAL '180 days', NOW() + INTERVAL '5 days',
'RSA', 2048, false, '', '10.0.1.50:443', 'TLS',
'server-scanner', 'ds-network-scan-01', NULL, 'Unmanaged',
NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour'),
('dc-network-02', 'sha256:net2eeff00112233445566778899aabbccddeeff',
'printer.example.com', ARRAY['printer.example.com'],
'6F:7A:8B:9C:0D:1E:00:55', 'CN=printer.example.com',
'CN=printer.example.com', NOW() - INTERVAL '400 days', NOW() - INTERVAL '30 days',
'RSA', 1024, false, '', '10.0.2.100:443', 'TLS',
'server-scanner', 'ds-network-scan-01', NULL, 'Unmanaged',
NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour'),
('dc-network-03', 'sha256:net3001122334455667788990011223344556677',
'vpn-appliance.example.com', ARRAY['vpn-appliance.example.com', '10.0.1.1'],
'7A:8B:9C:0D:1E:2F:00:66', 'CN=Fortinet CA,O=Fortinet',
'CN=vpn-appliance.example.com', NOW() - INTERVAL '90 days', NOW() + INTERVAL '275 days',
'RSA', 2048, false, '', '10.0.1.1:443', 'TLS',
'server-scanner', 'ds-network-scan-01', NULL, 'Unmanaged',
NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour')
ON CONFLICT (id) DO NOTHING;
-- Jobs — add AwaitingApproval jobs for approval workflow demo
INSERT INTO jobs (id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts, last_error, scheduled_at, created_at) VALUES
('job-approval-01', 'renewal', 'mc-auth-prod', NULL, 'ag-web-prod', 'AwaitingApproval', 0, 3, NULL, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour'),
('job-approval-02', 'renewal', 'mc-pay-prod', NULL, 'ag-web-prod', 'AwaitingApproval', 0, 3, NULL, NOW() - INTERVAL '30 minutes', NOW() - INTERVAL '30 minutes')
ON CONFLICT (id) DO NOTHING;
-- Update network scan targets with last_scan data so GUI shows recent activity
UPDATE network_scan_targets SET
last_scan_at = NOW() - INTERVAL '1 hour',
last_scan_duration_ms = 4500,
last_scan_certs_found = 3
WHERE id = 'nst-dc1-web';
UPDATE network_scan_targets SET
last_scan_at = NOW() - INTERVAL '2 hours',
last_scan_duration_ms = 8200,
last_scan_certs_found = 0
WHERE id = 'nst-dc2-apps';
+112
View File
@@ -61,6 +61,18 @@ import {
getJobTrends, getJobTrends,
getIssuanceRate, getIssuanceRate,
getMetrics, getMetrics,
getDiscoveredCertificates,
getDiscoveredCertificate,
claimDiscoveredCertificate,
dismissDiscoveredCertificate,
getDiscoveryScans,
getDiscoverySummary,
getNetworkScanTargets,
getNetworkScanTarget,
createNetworkScanTarget,
updateNetworkScanTarget,
deleteNetworkScanTarget,
triggerNetworkScan,
} from './client'; } from './client';
// Mock global fetch // Mock global fetch
@@ -686,4 +698,104 @@ describe('API Client', () => {
expect(result.status).toBe('ok'); expect(result.status).toBe('ok');
}); });
}); });
// ─── Discovery ────────────────────────────────────
describe('Discovery', () => {
it('getDiscoveredCertificates calls with params', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getDiscoveredCertificates({ status: 'Unmanaged' });
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/discovered-certificates');
expect(mockFetch.mock.calls[0][0]).toContain('status=Unmanaged');
});
it('getDiscoveredCertificate calls with id', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'dc-1', common_name: 'test.example.com' }));
const result = await getDiscoveredCertificate('dc-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/discovered-certificates/dc-1');
expect(result.common_name).toBe('test.example.com');
});
it('claimDiscoveredCertificate sends POST with managed cert id', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'claimed' }));
await claimDiscoveredCertificate('dc-1', 'mc-api-prod');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/discovered-certificates/dc-1/claim');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ managed_certificate_id: 'mc-api-prod' });
});
it('dismissDiscoveredCertificate sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'dismissed' }));
await dismissDiscoveredCertificate('dc-1');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/discovered-certificates/dc-1/dismiss');
expect(init.method).toBe('POST');
});
it('getDiscoveryScans calls endpoint', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getDiscoveryScans();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/discovery-scans');
});
it('getDiscoverySummary calls endpoint', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ Unmanaged: 5, Managed: 3, Dismissed: 1 }));
const result = await getDiscoverySummary();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/discovery-summary');
expect(result.Unmanaged).toBe(5);
});
});
// ─── Network Scan Targets ────────────────────────
describe('Network Scan Targets', () => {
it('getNetworkScanTargets calls endpoint', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getNetworkScanTargets();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/network-scan-targets');
});
it('getNetworkScanTarget calls with id', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'nst-1', name: 'DMZ' }));
const result = await getNetworkScanTarget('nst-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/network-scan-targets/nst-1');
expect(result.name).toBe('DMZ');
});
it('createNetworkScanTarget sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'nst-new', name: 'Production' }));
await createNetworkScanTarget({ name: 'Production', cidrs: ['10.0.0.0/24'], ports: [443] });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/network-scan-targets');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body);
expect(body.name).toBe('Production');
expect(body.cidrs).toEqual(['10.0.0.0/24']);
});
it('updateNetworkScanTarget sends PUT', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'nst-1', enabled: false }));
await updateNetworkScanTarget('nst-1', { enabled: false });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/network-scan-targets/nst-1');
expect(init.method).toBe('PUT');
});
it('deleteNetworkScanTarget sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({}, 204));
await deleteNetworkScanTarget('nst-1');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/network-scan-targets/nst-1');
expect(init.method).toBe('DELETE');
});
it('triggerNetworkScan sends POST to scan endpoint', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'scan triggered' }));
await triggerNetworkScan('nst-1');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/network-scan-targets/nst-1/scan');
expect(init.method).toBe('POST');
});
});
}); });
+48 -1
View File
@@ -1,4 +1,4 @@
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse } from './types'; import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget } from './types';
const BASE = '/api/v1'; const BASE = '/api/v1';
@@ -258,6 +258,53 @@ export const approveRenewal = (jobId: string) =>
export const rejectRenewal = (jobId: string, reason: string) => export const rejectRenewal = (jobId: string, reason: string) =>
fetchJSON<{ message: string }>(`${BASE}/jobs/${jobId}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }); fetchJSON<{ message: string }>(`${BASE}/jobs/${jobId}/reject`, { method: 'POST', body: JSON.stringify({ reason }) });
// Discovery
export const getDiscoveredCertificates = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
return fetchJSON<PaginatedResponse<DiscoveredCertificate>>(`${BASE}/discovered-certificates?${qs}`);
};
export const getDiscoveredCertificate = (id: string) =>
fetchJSON<DiscoveredCertificate>(`${BASE}/discovered-certificates/${id}`);
export const claimDiscoveredCertificate = (id: string, managedCertificateId: string) =>
fetchJSON<{ message: string }>(`${BASE}/discovered-certificates/${id}/claim`, {
method: 'POST',
body: JSON.stringify({ managed_certificate_id: managedCertificateId }),
});
export const dismissDiscoveredCertificate = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/discovered-certificates/${id}/dismiss`, { method: 'POST' });
export const getDiscoveryScans = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
return fetchJSON<PaginatedResponse<DiscoveryScan>>(`${BASE}/discovery-scans?${qs}`);
};
export const getDiscoverySummary = () =>
fetchJSON<DiscoverySummary>(`${BASE}/discovery-summary`);
// Network Scan Targets
export const getNetworkScanTargets = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
return fetchJSON<PaginatedResponse<NetworkScanTarget>>(`${BASE}/network-scan-targets?${qs}`);
};
export const getNetworkScanTarget = (id: string) =>
fetchJSON<NetworkScanTarget>(`${BASE}/network-scan-targets/${id}`);
export const createNetworkScanTarget = (data: Partial<NetworkScanTarget>) =>
fetchJSON<NetworkScanTarget>(`${BASE}/network-scan-targets`, { method: 'POST', body: JSON.stringify(data) });
export const updateNetworkScanTarget = (id: string, data: Partial<NetworkScanTarget>) =>
fetchJSON<NetworkScanTarget>(`${BASE}/network-scan-targets/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteNetworkScanTarget = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/network-scan-targets/${id}`, { method: 'DELETE' });
export const triggerNetworkScan = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/network-scan-targets/${id}/scan`, { method: 'POST' });
// Stats // Stats
export const getDashboardSummary = () => export const getDashboardSummary = () =>
fetchJSON<DashboardSummary>(`${BASE}/stats/summary`); fetchJSON<DashboardSummary>(`${BASE}/stats/summary`);
+61
View File
@@ -244,6 +244,67 @@ export interface IssuanceRateDataPoint {
issued_count: number; issued_count: number;
} }
// Discovery types
export interface DiscoveredCertificate {
id: string;
fingerprint_sha256: string;
common_name: string;
sans: string[];
serial_number: string;
issuer_dn: string;
subject_dn: string;
not_before?: string;
not_after?: string;
key_algorithm: string;
key_size: number;
is_ca: boolean;
source_path: string;
source_format: string;
agent_id: string;
discovery_scan_id?: string;
managed_certificate_id?: string;
status: string;
first_seen_at: string;
last_seen_at: string;
dismissed_at?: string;
created_at: string;
updated_at: string;
}
export interface DiscoveryScan {
id: string;
agent_id: string;
directories: string[];
certificates_found: number;
certificates_new: number;
errors_count: number;
scan_duration_ms: number;
started_at: string;
completed_at?: string;
}
export interface DiscoverySummary {
Unmanaged: number;
Managed: number;
Dismissed: number;
}
// Network scan types
export interface NetworkScanTarget {
id: string;
name: string;
cidrs: string[];
ports: number[];
enabled: boolean;
scan_interval_hours: number;
timeout_ms: number;
last_scan_at?: string;
last_scan_duration_ms?: number;
last_scan_certs_found?: number;
created_at: string;
updated_at: string;
}
export interface MetricsResponse { export interface MetricsResponse {
gauge: { gauge: {
certificate_total: number; certificate_total: number;
Binary file not shown.

After

Width:  |  Height:  |  Size: 755 KiB

+3 -3
View File
@@ -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>
); );
+8 -8
View File
@@ -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>
))} ))}
+4 -4
View File
@@ -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>
+4 -4
View File
@@ -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
+26 -15
View File
@@ -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' },
@@ -15,13 +16,15 @@ const nav = [
{ to: '/owners', label: 'Owners', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' }, { to: '/owners', label: 'Owners', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' },
{ to: '/teams', label: 'Teams', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' }, { to: '/teams', label: 'Teams', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' },
{ to: '/agent-groups', label: 'Agent Groups', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10 M9 3v2m6-2v2' }, { to: '/agent-groups', label: 'Agent Groups', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10 M9 3v2m6-2v2' },
{ to: '/discovery', label: 'Discovery', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' },
{ to: '/network-scans', label: 'Network Scans', icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 12l2 2 4-4' },
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' }, { to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' }, { to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
]; ];
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 +35,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 +67,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.5</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 +84,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>
+3 -3
View File
@@ -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>
+7
View File
@@ -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',
@@ -16,6 +19,10 @@ const statusStyles: Record<string, string> = {
Online: 'badge-success', Online: 'badge-success',
Offline: 'badge-danger', Offline: 'badge-danger',
Stale: 'badge-warning', Stale: 'badge-warning',
// Discovery statuses
Unmanaged: 'badge-warning',
Managed: 'badge-success',
Dismissed: 'badge-neutral',
// Notification statuses // Notification statuses
sent: 'badge-success', sent: 'badge-success',
pending: 'badge-warning', pending: 'badge-warning',
+34 -11
View File
@@ -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; }
} }
+4
View File
@@ -23,6 +23,8 @@ import AgentGroupsPage from './pages/AgentGroupsPage';
import AuditPage from './pages/AuditPage'; import AuditPage from './pages/AuditPage';
import ShortLivedPage from './pages/ShortLivedPage'; import ShortLivedPage from './pages/ShortLivedPage';
import AgentFleetPage from './pages/AgentFleetPage'; import AgentFleetPage from './pages/AgentFleetPage';
import DiscoveryPage from './pages/DiscoveryPage';
import NetworkScanPage from './pages/NetworkScanPage';
import './index.css'; import './index.css';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -61,6 +63,8 @@ createRoot(document.getElementById('root')!).render(
<Route path="agent-groups" element={<AgentGroupsPage />} /> <Route path="agent-groups" element={<AgentGroupsPage />} />
<Route path="audit" element={<AuditPage />} /> <Route path="audit" element={<AuditPage />} />
<Route path="short-lived" element={<ShortLivedPage />} /> <Route path="short-lived" element={<ShortLivedPage />} />
<Route path="discovery" element={<DiscoveryPage />} />
<Route path="network-scans" element={<NetworkScanPage />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
+24 -24
View File
@@ -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'}
+40 -40
View File
@@ -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>
+6 -6
View File
@@ -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>
+7 -7
View File
@@ -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>,
}, },
]; ];
+26 -26
View File
@@ -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">&mdash;</span>; if (!e.details || Object.keys(e.details).length === 0) return <span className="text-ink-faint">&mdash;</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>
+76 -76
View File
@@ -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 && <> &middot; Revoked {formatDateTime(cert.revoked_at)}</>} {cert.revoked_at && <> &middot; 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>
+52 -52
View File
@@ -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>
+53 -49
View File
@@ -13,34 +13,38 @@ import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import { daysUntil, expiryColor, formatDate } from '../api/utils'; import { daysUntil, expiryColor, formatDate } from '../api/utils';
// Convert PascalCase status like "RenewalInProgress" to "Renewal In Progress"
const formatStatus = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1 $2');
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
Active: '#10b981', Active: '#10b981',
Expiring: '#f59e0b', Expiring: '#f59e0b',
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 +52,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 +64,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}
@@ -148,7 +152,7 @@ export default function DashboardPage() {
outerRadius={90} outerRadius={90}
paddingAngle={2} paddingAngle={2}
dataKey="value" dataKey="value"
label={({ name, value }) => `${name}: ${value}`} label={({ name, value }) => `${formatStatus(name || '')}: ${value}`}
labelLine={false} labelLine={false}
> >
{pieData.map((entry, index) => ( {pieData.map((entry, index) => (
@@ -159,12 +163,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">{formatStatus(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 +177,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 +197,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 +216,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 +250,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 +270,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 +295,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>
+317
View File
@@ -0,0 +1,317 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getDiscoveredCertificates,
getDiscoverySummary,
getDiscoveryScans,
claimDiscoveredCertificate,
dismissDiscoveredCertificate,
getAgents,
} from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils';
import type { DiscoveredCertificate, DiscoveryScan } from '../api/types';
function ClaimModal({ cert, onClose, onClaim }: { cert: DiscoveredCertificate; onClose: () => void; onClaim: (managedCertId: string) => void }) {
const [managedCertId, setManagedCertId] = useState('');
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4" onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-surface-border">
<h3 className="text-lg font-semibold text-ink">Claim Certificate</h3>
<p className="text-sm text-ink-muted mt-1">
Link <span className="font-mono text-xs">{cert.common_name}</span> to a managed certificate
</p>
</div>
<div className="px-6 py-4">
<label className="block text-sm font-medium text-ink mb-1">Managed Certificate ID</label>
<input
type="text"
value={managedCertId}
onChange={e => setManagedCertId(e.target.value)}
placeholder="e.g., mc-api-prod"
className="w-full border border-surface-border rounded px-3 py-2 text-sm text-ink bg-white font-mono focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
<p className="text-xs text-ink-faint mt-2">Enter the ID of the managed certificate this discovered cert belongs to.</p>
</div>
<div className="px-6 py-3 border-t border-surface-border flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2 text-sm text-ink-muted hover:text-ink rounded border border-surface-border">
Cancel
</button>
<button
onClick={() => onClaim(managedCertId)}
disabled={!managedCertId.trim()}
className="px-4 py-2 text-sm text-white bg-brand-600 hover:bg-brand-700 rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Claim
</button>
</div>
</div>
</div>
);
}
function ScanHistoryPanel({ scans }: { scans: DiscoveryScan[] }) {
if (scans.length === 0) return <p className="text-sm text-ink-muted py-4 text-center">No scans recorded yet</p>;
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-ink-faint border-b border-surface-border">
<th className="px-4 py-2">Agent</th>
<th className="px-4 py-2">Directories</th>
<th className="px-4 py-2">Found</th>
<th className="px-4 py-2">New</th>
<th className="px-4 py-2">Errors</th>
<th className="px-4 py-2">Duration</th>
<th className="px-4 py-2">Started</th>
</tr>
</thead>
<tbody>
{scans.map(s => (
<tr key={s.id} className="border-b border-surface-border/50 hover:bg-surface-hover">
<td className="px-4 py-2 font-mono text-xs">{s.agent_id}</td>
<td className="px-4 py-2 text-xs text-ink-muted">{s.directories?.join(', ') || '—'}</td>
<td className="px-4 py-2">{s.certificates_found}</td>
<td className="px-4 py-2 text-green-600">{s.certificates_new}</td>
<td className="px-4 py-2">{s.errors_count > 0 ? <span className="text-red-500">{s.errors_count}</span> : '0'}</td>
<td className="px-4 py-2 text-ink-muted">{s.scan_duration_ms}ms</td>
<td className="px-4 py-2 text-xs text-ink-muted">{formatDateTime(s.started_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default function DiscoveryPage() {
const [statusFilter, setStatusFilter] = useState('');
const [agentFilter, setAgentFilter] = useState('');
const [claimingCert, setClaimingCert] = useState<DiscoveredCertificate | null>(null);
const [showScans, setShowScans] = useState(false);
const queryClient = useQueryClient();
const params: Record<string, string> = {};
if (statusFilter) params.status = statusFilter;
if (agentFilter) params.agent_id = agentFilter;
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['discovered-certificates', params],
queryFn: () => getDiscoveredCertificates(params),
refetchInterval: 30000,
});
const { data: summary } = useQuery({
queryKey: ['discovery-summary'],
queryFn: getDiscoverySummary,
refetchInterval: 30000,
});
const { data: scansData } = useQuery({
queryKey: ['discovery-scans'],
queryFn: () => getDiscoveryScans(),
enabled: showScans,
});
const { data: agentsData } = useQuery({
queryKey: ['agents-for-filter'],
queryFn: () => getAgents({ per_page: '200' }),
});
const claimMutation = useMutation({
mutationFn: ({ id, managedCertId }: { id: string; managedCertId: string }) =>
claimDiscoveredCertificate(id, managedCertId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['discovered-certificates'] });
queryClient.invalidateQueries({ queryKey: ['discovery-summary'] });
setClaimingCert(null);
},
});
const dismissMutation = useMutation({
mutationFn: dismissDiscoveredCertificate,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['discovered-certificates'] });
queryClient.invalidateQueries({ queryKey: ['discovery-summary'] });
},
});
const formatExpiry = (notAfter?: string) => {
if (!notAfter) return '—';
const d = new Date(notAfter);
const now = new Date();
const days = Math.floor((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (days < 0) return <span className="text-red-500">Expired {Math.abs(days)}d ago</span>;
if (days < 30) return <span className="text-amber-500">{days}d left</span>;
return <span className="text-ink-muted">{days}d left</span>;
};
const discoveryStatusStyle: Record<string, string> = {
Unmanaged: 'badge badge-warning',
Managed: 'badge badge-success',
Dismissed: 'badge badge-neutral',
};
const columns: Column<DiscoveredCertificate>[] = [
{
key: 'common_name',
label: 'Common Name',
render: (c) => (
<div>
<div className="font-medium text-sm text-ink">{c.common_name || '(no CN)'}</div>
{c.sans?.length > 0 && (
<div className="text-xs text-ink-faint truncate max-w-[200px]" title={c.sans.join(', ')}>
{c.sans.slice(0, 2).join(', ')}{c.sans.length > 2 ? ` +${c.sans.length - 2}` : ''}
</div>
)}
</div>
),
},
{
key: 'status',
label: 'Status',
render: (c) => <span className={discoveryStatusStyle[c.status] || 'badge badge-neutral'}>{c.status}</span>,
},
{
key: 'source',
label: 'Source',
render: (c) => (
<div>
<div className="font-mono text-xs text-ink-muted">{c.agent_id}</div>
<div className="text-xs text-ink-faint truncate max-w-[180px]" title={c.source_path}>{c.source_path}</div>
</div>
),
},
{
key: 'issuer',
label: 'Issuer',
render: (c) => <span className="text-xs text-ink-muted truncate max-w-[150px]" title={c.issuer_dn}>{c.issuer_dn?.split(',')[0] || '—'}</span>,
},
{
key: 'expiry',
label: 'Expiry',
render: (c) => <span className="text-xs">{formatExpiry(c.not_after)}</span>,
},
{
key: 'fingerprint',
label: 'Fingerprint',
render: (c) => <span className="font-mono text-[10px] text-ink-faint">{c.fingerprint_sha256?.substring(0, 16)}...</span>,
},
{
key: 'actions',
label: '',
render: (c) => (
c.status === 'Unmanaged' ? (
<div className="flex gap-2">
<button
onClick={(e) => { e.stopPropagation(); setClaimingCert(c); }}
className="text-xs text-brand-600 hover:text-brand-700 font-medium"
>
Claim
</button>
<button
onClick={(e) => { e.stopPropagation(); dismissMutation.mutate(c.id); }}
disabled={dismissMutation.isPending}
className="text-xs text-ink-faint hover:text-ink-muted"
>
Dismiss
</button>
</div>
) : null
),
},
];
return (
<>
<PageHeader title="Certificate Discovery" subtitle={data ? `${data.total} discovered certificates` : undefined} />
{/* Summary stats bar */}
{summary && (
<div className="px-6 py-3 flex gap-4 border-b border-surface-border/50">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-amber-400"></span>
<span className="text-sm text-ink"><strong>{summary.Unmanaged || 0}</strong> Unmanaged</span>
</div>
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-green-400"></span>
<span className="text-sm text-ink"><strong>{summary.Managed || 0}</strong> Managed</span>
</div>
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-gray-400"></span>
<span className="text-sm text-ink"><strong>{summary.Dismissed || 0}</strong> Dismissed</span>
</div>
<div className="ml-auto">
<button
onClick={() => setShowScans(!showScans)}
className="text-xs text-brand-600 hover:text-brand-700 font-medium"
>
{showScans ? 'Hide' : 'Show'} Scan History
</button>
</div>
</div>
)}
{/* Scan history collapsible */}
{showScans && (
<div className="border-b border-surface-border/50 bg-surface-subtle">
<div className="px-6 py-2">
<h3 className="text-sm font-semibold text-ink mb-2">Recent Scans</h3>
<ScanHistoryPanel scans={scansData?.data || []} />
</div>
</div>
)}
{/* Filters */}
<div className="px-6 py-3 flex gap-3 border-b border-surface-border/50">
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
>
<option value="">All statuses</option>
<option value="Unmanaged">Unmanaged</option>
<option value="Managed">Managed</option>
<option value="Dismissed">Dismissed</option>
</select>
<select
value={agentFilter}
onChange={e => setAgentFilter(e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
>
<option value="">All agents</option>
{agentsData?.data?.map(a => (
<option key={a.id} value={a.id}>{a.name || a.id}</option>
))}
</select>
</div>
{/* Table */}
<div className="flex-1 overflow-y-auto">
{error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
) : (
<DataTable
columns={columns}
data={data?.data || []}
isLoading={isLoading}
emptyMessage="No discovered certificates. Agents will report findings once discovery scanning is configured."
/>
)}
</div>
{claimingCert && (
<ClaimModal
cert={claimingCert}
onClose={() => setClaimingCert(null)}
onClaim={(managedCertId) => claimMutation.mutate({ id: claimingCert.id, managedCertId })}
/>
)}
</>
);
}
+8 -8
View File
@@ -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">&mdash;</span>; if (!i.config || Object.keys(i.config).length === 0) return <span className="text-ink-faint">&mdash;</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>
+122 -18
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getJobs, cancelJob } from '../api/client'; import { getJobs, cancelJob, approveRenewal, rejectRenewal } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable'; import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable'; import type { Column } from '../components/DataTable';
@@ -9,9 +9,48 @@ import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils'; import { formatDateTime } from '../api/utils';
import type { Job } from '../api/types'; import type { Job } from '../api/types';
function RejectModal({ job, onClose, onReject }: { job: Job; onClose: () => void; onReject: (reason: string) => void }) {
const [reason, setReason] = useState('');
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4" onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-surface-border">
<h3 className="text-lg font-semibold text-ink">Reject Job</h3>
<p className="text-sm text-ink-muted mt-1">
Rejecting job <span className="font-mono text-xs">{job.id}</span> for certificate <span className="font-mono text-xs">{job.certificate_id}</span>
</p>
</div>
<div className="px-6 py-4">
<label className="block text-sm font-medium text-ink mb-1">Reason</label>
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Why is this renewal being rejected?"
className="w-full border border-surface-border rounded px-3 py-2 text-sm text-ink bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
rows={3}
/>
</div>
<div className="px-6 py-3 border-t border-surface-border flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2 text-sm text-ink-muted hover:text-ink rounded border border-surface-border">
Cancel
</button>
<button
onClick={() => onReject(reason)}
disabled={!reason.trim()}
className="px-4 py-2 text-sm text-white bg-red-600 hover:bg-red-700 rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Reject
</button>
</div>
</div>
</div>
);
}
export default function JobsPage() { export default function JobsPage() {
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const [typeFilter, setTypeFilter] = useState(''); const [typeFilter, setTypeFilter] = useState('');
const [rejectingJob, setRejectingJob] = useState<Job | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const params: Record<string, string> = {}; const params: Record<string, string> = {};
@@ -29,38 +68,72 @@ export default function JobsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
}); });
const approveMutation = useMutation({
mutationFn: approveRenewal,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
});
const rejectMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) => rejectRenewal(id, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobs'] });
setRejectingJob(null);
},
});
const awaitingCount = data?.data?.filter(j => j.status === 'AwaitingApproval').length || 0;
const columns: Column<Job>[] = [ const columns: Column<Job>[] = [
{ {
key: 'id', key: 'id',
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: '',
render: (j) => ( render: (j) => (
j.status === 'Pending' || j.status === 'Running' ? ( <div className="flex gap-2">
<button {j.status === 'AwaitingApproval' && (
onClick={(e) => { e.stopPropagation(); cancelMutation.mutate(j.id); }} <>
className="text-xs text-red-400 hover:text-red-300" <button
> onClick={(e) => { e.stopPropagation(); approveMutation.mutate(j.id); }}
Cancel disabled={approveMutation.isPending}
</button> className="text-xs text-green-600 hover:text-green-700 font-medium"
) : null >
Approve
</button>
<button
onClick={(e) => { e.stopPropagation(); setRejectingJob(j); }}
className="text-xs text-red-500 hover:text-red-600 font-medium"
>
Reject
</button>
</>
)}
{(j.status === 'Pending' || j.status === 'Running') && (
<button
onClick={(e) => { e.stopPropagation(); cancelMutation.mutate(j.id); }}
className="text-xs text-red-400 hover:text-red-300"
>
Cancel
</button>
)}
</div>
), ),
}, },
]; ];
@@ -68,14 +141,37 @@ 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">
{/* Pending approval banner */}
{awaitingCount > 0 && (
<div className="mx-6 mt-3 px-4 py-2.5 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-2">
<svg className="w-4 h-4 text-amber-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<span className="text-sm text-amber-800">
<strong>{awaitingCount}</strong> job{awaitingCount !== 1 ? 's' : ''} awaiting approval
</span>
{statusFilter !== 'AwaitingApproval' && (
<button
onClick={() => setStatusFilter('AwaitingApproval')}
className="text-xs text-amber-700 hover:text-amber-900 underline ml-1"
>
Show only
</button>
)}
</div>
)}
<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>
<option value="AwaitingApproval">Awaiting Approval</option>
<option value="AwaitingCSR">Awaiting CSR</option>
<option value="Running">Running</option> <option value="Running">Running</option>
<option value="Completed">Completed</option> <option value="Completed">Completed</option>
<option value="Failed">Failed</option> <option value="Failed">Failed</option>
@@ -84,7 +180,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>
@@ -100,6 +196,14 @@ export default function JobsPage() {
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No jobs found" /> <DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No jobs found" />
)} )}
</div> </div>
{rejectingJob && (
<RejectModal
job={rejectingJob}
onClose={() => setRejectingJob(null)}
onReject={(reason) => rejectMutation.mutate({ id: rejectingJob.id, reason })}
/>
)}
</> </>
); );
} }
+10 -10
View File
@@ -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>
+268
View File
@@ -0,0 +1,268 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getNetworkScanTargets,
createNetworkScanTarget,
updateNetworkScanTarget,
deleteNetworkScanTarget,
triggerNetworkScan,
} from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils';
import type { NetworkScanTarget } from '../api/types';
function CreateScanTargetModal({ onClose, onCreate }: {
onClose: () => void;
onCreate: (data: Partial<NetworkScanTarget>) => void;
}) {
const [name, setName] = useState('');
const [cidrs, setCidrs] = useState('');
const [ports, setPorts] = useState('443');
const [interval, setInterval] = useState('6');
const [timeout, setTimeout] = useState('5000');
const handleSubmit = () => {
const cidrList = cidrs.split('\n').map(s => s.trim()).filter(Boolean);
const portList = ports.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
onCreate({
name,
cidrs: cidrList,
ports: portList,
scan_interval_hours: parseInt(interval, 10),
timeout_ms: parseInt(timeout, 10),
enabled: true,
});
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4" onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-surface-border">
<h3 className="text-lg font-semibold text-ink">New Scan Target</h3>
<p className="text-sm text-ink-muted mt-1">Define a network range to scan for TLS certificates</p>
</div>
<div className="px-6 py-4 space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g., Production DMZ"
className="w-full border border-surface-border rounded px-3 py-2 text-sm text-ink bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">CIDR Ranges (one per line)</label>
<textarea
value={cidrs}
onChange={e => setCidrs(e.target.value)}
placeholder={"10.0.1.0/24\n10.0.2.0/24\n192.168.1.100/32"}
className="w-full border border-surface-border rounded px-3 py-2 text-sm text-ink bg-white font-mono focus:outline-none focus:ring-2 focus:ring-brand-500"
rows={3}
/>
<p className="text-xs text-ink-faint mt-1">Maximum /20 per CIDR (4096 IPs)</p>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-ink mb-1">Ports</label>
<input
type="text"
value={ports}
onChange={e => setPorts(e.target.value)}
placeholder="443,8443"
className="w-full border border-surface-border rounded px-3 py-2 text-sm text-ink bg-white font-mono focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Interval (hrs)</label>
<input
type="number"
value={interval}
onChange={e => setInterval(e.target.value)}
min="1"
className="w-full border border-surface-border rounded px-3 py-2 text-sm text-ink bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Timeout (ms)</label>
<input
type="number"
value={timeout}
onChange={e => setTimeout(e.target.value)}
min="1000"
step="1000"
className="w-full border border-surface-border rounded px-3 py-2 text-sm text-ink bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
</div>
</div>
<div className="px-6 py-3 border-t border-surface-border flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2 text-sm text-ink-muted hover:text-ink rounded border border-surface-border">
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!name.trim() || !cidrs.trim()}
className="px-4 py-2 text-sm text-white bg-brand-600 hover:bg-brand-700 rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Create
</button>
</div>
</div>
</div>
);
}
export default function NetworkScanPage() {
const [showCreate, setShowCreate] = useState(false);
const queryClient = useQueryClient();
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['network-scan-targets'],
queryFn: () => getNetworkScanTargets(),
refetchInterval: 30000,
});
const createMutation = useMutation({
mutationFn: createNetworkScanTarget,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['network-scan-targets'] });
setShowCreate(false);
},
});
const deleteMutation = useMutation({
mutationFn: deleteNetworkScanTarget,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['network-scan-targets'] }),
});
const toggleMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
updateNetworkScanTarget(id, { enabled }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['network-scan-targets'] }),
});
const scanMutation = useMutation({
mutationFn: triggerNetworkScan,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['network-scan-targets'] }),
});
const columns: Column<NetworkScanTarget>[] = [
{
key: 'name',
label: 'Name',
render: (t) => (
<div>
<div className="font-medium text-sm text-ink">{t.name}</div>
<div className="font-mono text-xs text-ink-faint">{t.id}</div>
</div>
),
},
{
key: 'cidrs',
label: 'CIDRs',
render: (t) => (
<div className="font-mono text-xs text-ink-muted">
{t.cidrs?.slice(0, 2).join(', ')}{(t.cidrs?.length || 0) > 2 ? ` +${t.cidrs.length - 2}` : ''}
</div>
),
},
{
key: 'ports',
label: 'Ports',
render: (t) => <span className="font-mono text-xs text-ink-muted">{t.ports?.join(', ')}</span>,
},
{
key: 'interval',
label: 'Interval',
render: (t) => <span className="text-sm text-ink-muted">{t.scan_interval_hours}h</span>,
},
{
key: 'last_scan',
label: 'Last Scan',
render: (t) => (
<div>
<div className="text-xs text-ink-muted">{t.last_scan_at ? formatDateTime(t.last_scan_at) : 'Never'}</div>
{t.last_scan_certs_found != null && (
<div className="text-xs text-ink-faint">{t.last_scan_certs_found} certs found</div>
)}
</div>
),
},
{
key: 'enabled',
label: 'Enabled',
render: (t) => (
<button
onClick={(e) => { e.stopPropagation(); toggleMutation.mutate({ id: t.id, enabled: !t.enabled }); }}
className={`relative w-9 h-5 rounded-full transition-colors ${t.enabled ? 'bg-brand-500' : 'bg-gray-300'}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${t.enabled ? 'translate-x-4' : ''}`} />
</button>
),
},
{
key: 'actions',
label: '',
render: (t) => (
<div className="flex gap-2">
<button
onClick={(e) => { e.stopPropagation(); scanMutation.mutate(t.id); }}
disabled={scanMutation.isPending}
className="text-xs text-brand-600 hover:text-brand-700 font-medium"
>
Scan Now
</button>
<button
onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(t.id); }}
disabled={deleteMutation.isPending}
className="text-xs text-red-400 hover:text-red-500"
>
Delete
</button>
</div>
),
},
];
return (
<>
<PageHeader
title="Network Scanning"
subtitle={data ? `${data.total} scan targets` : undefined}
action={
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 text-sm text-white bg-brand-600 hover:bg-brand-700 rounded-lg shadow-sm"
>
+ New Target
</button>
}
/>
<div className="flex-1 overflow-y-auto">
{error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
) : (
<DataTable
columns={columns}
data={data?.data || []}
isLoading={isLoading}
emptyMessage="No scan targets configured. Create one to start discovering certificates on your network."
/>
)}
</div>
{showCreate && (
<CreateScanTargetModal
onClose={() => setShowCreate(false)}
onCreate={(d) => createMutation.mutate(d)}
/>
)}
</>
);
}
+19 -19
View File
@@ -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>
+7 -7
View File
@@ -35,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',
@@ -51,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',
@@ -66,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>
+19 -19
View File
@@ -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">&mdash;</span>; if (!p.config || Object.keys(p.config).length === 0) return <span className="text-ink-faint">&mdash;</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>
+10 -10
View File
@@ -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">&mdash;</span> : <span className="text-ink-faint">&mdash;</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>
+21 -21
View File
@@ -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">
+39 -39
View File
@@ -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>
+5 -5
View File
@@ -27,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>
), ),
}, },
@@ -36,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',
@@ -50,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>
+53 -1
View File
@@ -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: [],
} }