Compare commits

...

4 Commits

Author SHA1 Message Date
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
22 changed files with 1717 additions and 40 deletions
+10 -7
View File
@@ -24,7 +24,7 @@ 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.3](https://img.shields.io/badge/version-v2.0.3-brightgreen) ![Version: v2.0.4](https://img.shields.io/badge/version-v2.0.4-brightgreen)
## Documentation ## Documentation
@@ -65,18 +65,18 @@ It's also **target-agnostic**. Agents deploy certificates to NGINX, Apache, and
## 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. 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 — a triage workflow in the GUI lets you claim discovered certificates into your inventory. The **EST server** (RFC 7030) enables device and WiFi certificate enrollment via industry-standard Enrollment over Secure Transport. Interactive **approval workflows** let you require human sign-off on renewals before deployment. 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.
**Core capabilities:** **Core capabilities:**
- **Full lifecycle automation** — issuance, renewal, deployment, and revocation with zero human intervention. Configurable renewal policies trigger jobs automatically based on expiration thresholds. - **Full lifecycle automation** — issuance, renewal, deployment, and revocation with zero human intervention. Configurable renewal policies trigger jobs automatically based on expiration thresholds.
- **CA-agnostic issuer connectors** — Local CA (self-signed + sub-CA for enterprise root chains), ACME v2 with HTTP-01, DNS-01, and DNS-PERSIST-01 challenges (Let's Encrypt, Sectigo, any ACME-compatible CA), Smallstep step-ca (native /sign API), and OpenSSL/Custom CA (delegate to any shell script). Pluggable interface — add your own CA in one file. - **CA-agnostic issuer connectors** — Local CA (self-signed + sub-CA for enterprise root chains), ACME v2 with HTTP-01, DNS-01, and DNS-PERSIST-01 challenges (Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, any ACME-compatible CA), External Account Binding (EAB) for CAs that require it (auto-fetched for ZeroSSL), 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. - **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. - **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). - **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. - **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. - **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. - **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, real-time short-lived credential tracking, discovery triage workflow, network scan management, and interactive approval/rejection flow.
- **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. - **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. - **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. - **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.
@@ -220,6 +220,8 @@ All server environment variables use the `CERTCTL_` prefix:
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation mode: `agent` (production) or `server` (demo only) | | `CERTCTL_KEYGEN_MODE` | `agent` | Key generation mode: `agent` (production) or `server` (demo only) |
| `CERTCTL_ACME_DIRECTORY_URL` | — | ACME directory URL (e.g., Let's Encrypt staging) | | `CERTCTL_ACME_DIRECTORY_URL` | — | ACME directory URL (e.g., Let's Encrypt staging) |
| `CERTCTL_ACME_EMAIL` | — | Contact email for ACME account registration | | `CERTCTL_ACME_EMAIL` | — | Contact email for ACME account registration |
| `CERTCTL_ACME_EAB_KID` | — | External Account Binding Key ID (required by ZeroSSL, Google Trust Services, SSL.com) |
| `CERTCTL_ACME_EAB_HMAC` | — | External Account Binding HMAC key (base64url-encoded) |
| `CERTCTL_ACME_CHALLENGE_TYPE` | — | ACME challenge type: `http-01` (default), `dns-01`, or `dns-persist-01` | | `CERTCTL_ACME_CHALLENGE_TYPE` | — | ACME challenge type: `http-01` (default), `dns-01`, or `dns-persist-01` |
| `CERTCTL_CA_CERT_PATH` | — | Path to CA certificate for sub-CA mode | | `CERTCTL_CA_CERT_PATH` | — | Path to CA certificate for sub-CA mode |
| `CERTCTL_CA_KEY_PATH` | — | Path to CA private key for sub-CA mode | | `CERTCTL_CA_KEY_PATH` | — | Path to CA private key for sub-CA mode |
@@ -587,10 +589,11 @@ All nine development milestones (M1M9) are complete. The backend covers the f
- **M21: Network Cert Discovery** ✅ — server-side active TLS scanning of CIDR ranges and ports, concurrent probing (50 goroutines), CIDR expansion with /20 safety cap, sentinel agent pattern for discovery pipeline reuse, CRUD API for scan targets, scheduler integration (6h default) - **M21: Network Cert Discovery** ✅ — server-side active TLS scanning of CIDR ranges and ports, concurrent probing (50 goroutines), CIDR expansion with /20 safety cap, sentinel agent pattern for discovery pipeline reuse, CRUD API for scan targets, scheduler integration (6h default)
- **M22: Prometheus Metrics** ✅ — `GET /api/v1/metrics/prometheus` returns Prometheus exposition format (`text/plain; version=0.0.4`), 11 metrics with `certctl_` prefix, compatible with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics - **M22: Prometheus Metrics** ✅ — `GET /api/v1/metrics/prometheus` returns Prometheus exposition format (`text/plain; version=0.0.4`), 11 metrics with `certctl_` prefix, compatible with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics
- **M23: EST Server (RFC 7030)** ✅ — Enrollment over Secure Transport for device/WiFi certificate enrollment, 4 endpoints under /.well-known/est/, PKCS#7 certs-only wire format, base64-encoded DER CSR input, configurable issuer + profile binding, audit trail, 28 new tests - **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
- **M24: Discovery, Network Scan & Approval GUI** ✅ — discovery triage page for claiming/dismissing discovered certificates, network scan target management (CRUD with schedule/interval config), interactive approval workflow (approve/reject buttons on Jobs page with reason modal), 13 new frontend tests
- **Compliance Mapping** ✅ — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 capability mapping documentation - **Compliance Mapping** ✅ — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 capability mapping documentation
- **M24: S/MIME Certificate Support** (Planned — v2.0.2) — wire profile EKU constraints through the issuance pipeline so certctl can issue S/MIME (emailProtection), code signing, and custom EKU certificates, not just TLS - **M25: S/MIME Certificate Support** (Planned — v2.0.2) — wire profile EKU constraints through the issuance pipeline so certctl can issue S/MIME (emailProtection), code signing, and custom EKU certificates, not just TLS
- **M25: Traefik + Caddy Targets** (Planned — v2.1.x) — Traefik (file provider, auto-reload on filesystem change) and Caddy (Admin API, hot-reload) deployment target connectors - **M26: Traefik + Caddy Targets** (Planned — v2.1.x) — Traefik (file provider, auto-reload on filesystem change) and Caddy (Admin API, hot-reload) deployment target connectors
- **M26: Certificate Export** (Planned — v2.1.x) — single-certificate download in PFX/PKCS12, DER, and PEM formats with optional chain inclusion, GUI download button on certificate detail page - **M27: Certificate Export** (Planned — v2.1.x) — single-certificate download in PFX/PKCS12, DER, and PEM formats with optional chain inclusion, GUI download button on certificate detail page
### V3: certctl Pro ### V3: certctl Pro
+4 -1
View File
@@ -97,11 +97,14 @@ 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), DNS-01 (for wildcards), and DNS-PERSIST-01 (standing record) 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"),
EABKid: os.Getenv("CERTCTL_ACME_EAB_KID"),
EABHmac: os.Getenv("CERTCTL_ACME_EAB_HMAC"),
ChallengeType: os.Getenv("CERTCTL_ACME_CHALLENGE_TYPE"), ChallengeType: os.Getenv("CERTCTL_ACME_CHALLENGE_TYPE"),
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"), DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"),
DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"), DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"),
+39 -3
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 + DNS-PERSIST-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,7 +128,7 @@ 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.
@@ -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, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, Sectigo, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance, order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. The interface also includes `GetCACertPEM(ctx)` for CA chain distribution (used by the EST server's `/cacerts` endpoint). 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
+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
+35
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.
+67
View File
@@ -2,6 +2,49 @@
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:
@@ -159,11 +202,35 @@ DNS-PERSIST-01 configuration:
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. 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). 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_EAB_KID` — External Account Binding Key ID (required by ZeroSSL, Google Trust Services, SSL.com)
- `CERTCTL_ACME_EAB_HMAC` — External Account Binding HMAC key (base64url-encoded)
- `CERTCTL_ACME_CHALLENGE_TYPE``http-01` (default), `dns-01`, or `dns-persist-01` - `CERTCTL_ACME_CHALLENGE_TYPE``http-01` (default), `dns-01`, or `dns-persist-01`
- `CERTCTL_ACME_DNS_PRESENT_SCRIPT` — Path to DNS record creation script (dns-01 and 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_CLEANUP_SCRIPT` — Path to DNS record cleanup script (dns-01 only, not used by dns-persist-01)
+80
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:
@@ -845,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
@@ -1045,6 +1103,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.
--- ---
+25 -1
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
+103 -17
View File
@@ -2,6 +2,37 @@
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
@@ -1459,6 +1490,26 @@ curl -s -H "$AUTH" "$SERVER/api/v1/issuers/iss-acme-le" | jq '{id, type}'
--- ---
**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.
@@ -3315,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 | Theme consistency | Check page styling | Light content area, teal sidebar, branded colors, readable text | PASS if theme consistent across all pages | | 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 |
--- ---
+92
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"
@@ -201,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
@@ -216,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:
+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)
}
}
+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;
+3 -1
View File
@@ -16,6 +16,8 @@ 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' },
]; ];
@@ -67,7 +69,7 @@ export default function Layout() {
</nav> </nav>
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between"> <div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.3</span> <span className="text-[10px] text-brand-300/60 font-mono">v2.0.4</span>
{authRequired && ( {authRequired && (
<button <button
onClick={logout} onClick={logout}
+4
View File
@@ -19,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',
+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>
+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 })}
/>
)}
</>
);
}
+113 -9
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,6 +68,21 @@ 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',
@@ -53,14 +107,33 @@ export default function JobsPage() {
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,6 +141,27 @@ export default function JobsPage() {
return ( return (
<> <>
<PageHeader title="Jobs" subtitle={data ? `${data.total} jobs` : undefined} /> <PageHeader title="Jobs" subtitle={data ? `${data.total} jobs` : undefined} />
{/* 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"> <div className="px-6 py-3 flex gap-3 border-b border-surface-border/50">
<select <select
value={statusFilter} value={statusFilter}
@@ -76,6 +170,8 @@ export default function JobsPage() {
> >
<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>
@@ -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 })}
/>
)}
</> </>
); );
} }
+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)}
/>
)}
</>
);
}