From 8eea4fce03903ad46bb94fcdd0d8f990b6deb208 Mon Sep 17 00:00:00 2001 From: Shankar Date: Fri, 27 Mar 2026 15:59:27 -0400 Subject: [PATCH] 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 --- README.md | 13 +- docs/architecture.md | 2 +- docs/demo-advanced.md | 24 +++ docs/quickstart.md | 2 +- web/src/api/client.test.ts | 112 ++++++++++ web/src/api/client.ts | 49 ++++- web/src/api/types.ts | 61 ++++++ web/src/components/Layout.tsx | 4 +- web/src/components/StatusBadge.tsx | 4 + web/src/main.tsx | 4 + web/src/pages/DiscoveryPage.tsx | 317 +++++++++++++++++++++++++++++ web/src/pages/JobsPage.tsx | 122 ++++++++++- web/src/pages/NetworkScanPage.tsx | 268 ++++++++++++++++++++++++ 13 files changed, 963 insertions(+), 19 deletions(-) create mode 100644 web/src/pages/DiscoveryPage.tsx create mode 100644 web/src/pages/NetworkScanPage.tsx diff --git a/README.md b/README.md index d08a600..ebd999b 100644 --- a/README.md +++ b/README.md @@ -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) [![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 @@ -65,7 +65,7 @@ It's also **target-agnostic**. Agents deploy certificates to NGINX, Apache, and ## 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:** @@ -76,7 +76,7 @@ certctl gives you a single pane of glass for every TLS certificate in your organ - **Revocation infrastructure** — RFC 5280 revocation with all standard reason codes, DER-encoded X.509 CRL per issuer, embedded OCSP responder, and short-lived certificate exemption (certs under 1 hour skip CRL/OCSP). - **Policy engine** — 5 rule types with violation tracking and severity levels. Certificate profiles enforce allowed key types, maximum TTL, and crypto constraints at enrollment time. - **Immutable audit trail** — every action recorded to an append-only log. Every API call recorded with method, path, actor, SHA-256 body hash, response status, and latency. No update or delete on audit records. -- **Operational dashboard** — Full React GUI with certificate inventory, bulk operations (multi-select renew/revoke/reassign), deployment timeline visualization, inline policy editing, agent fleet overview, expiration heatmaps, and real-time short-lived credential tracking. +- **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. - **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. @@ -589,10 +589,11 @@ All nine development milestones (M1–M9) 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) - **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 +- **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 -- **M24: S/MIME Certificate Support** (Planned — v2.0.2) — wire profile EKU constraints through the issuance pipeline so certctl can issue S/MIME (emailProtection), code signing, and custom EKU certificates, not just TLS -- **M25: Traefik + Caddy Targets** (Planned — v2.1.x) — Traefik (file provider, auto-reload on filesystem change) and Caddy (Admin API, hot-reload) deployment target connectors -- **M26: Certificate Export** (Planned — v2.1.x) — single-certificate download in PFX/PKCS12, DER, and PEM formats with optional chain inclusion, GUI download button on certificate detail page +- **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 +- **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 +- **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 diff --git a/docs/architecture.md b/docs/architecture.md index 460f6d4..02b03e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,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). -**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. diff --git a/docs/demo-advanced.md b/docs/demo-advanced.md index f1c9a27..39ad200 100644 --- a/docs/demo-advanced.md +++ b/docs/demo-advanced.md @@ -901,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. +**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 @@ -1101,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. +### 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. --- diff --git a/docs/quickstart.md b/docs/quickstart.md index 9871d3e..ac3337f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -89,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). -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 diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index b473ecb..33a989a 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -61,6 +61,18 @@ import { getJobTrends, getIssuanceRate, getMetrics, + getDiscoveredCertificates, + getDiscoveredCertificate, + claimDiscoveredCertificate, + dismissDiscoveredCertificate, + getDiscoveryScans, + getDiscoverySummary, + getNetworkScanTargets, + getNetworkScanTarget, + createNetworkScanTarget, + updateNetworkScanTarget, + deleteNetworkScanTarget, + triggerNetworkScan, } from './client'; // Mock global fetch @@ -686,4 +698,104 @@ describe('API Client', () => { 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'); + }); + }); }); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 466eac1..bf71133 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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'; @@ -258,6 +258,53 @@ export const approveRenewal = (jobId: string) => export const rejectRenewal = (jobId: string, reason: string) => fetchJSON<{ message: string }>(`${BASE}/jobs/${jobId}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }); +// Discovery +export const getDiscoveredCertificates = (params: Record = {}) => { + const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString(); + return fetchJSON>(`${BASE}/discovered-certificates?${qs}`); +}; + +export const getDiscoveredCertificate = (id: string) => + fetchJSON(`${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 = {}) => { + const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString(); + return fetchJSON>(`${BASE}/discovery-scans?${qs}`); +}; + +export const getDiscoverySummary = () => + fetchJSON(`${BASE}/discovery-summary`); + +// Network Scan Targets +export const getNetworkScanTargets = (params: Record = {}) => { + const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString(); + return fetchJSON>(`${BASE}/network-scan-targets?${qs}`); +}; + +export const getNetworkScanTarget = (id: string) => + fetchJSON(`${BASE}/network-scan-targets/${id}`); + +export const createNetworkScanTarget = (data: Partial) => + fetchJSON(`${BASE}/network-scan-targets`, { method: 'POST', body: JSON.stringify(data) }); + +export const updateNetworkScanTarget = (id: string, data: Partial) => + fetchJSON(`${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 export const getDashboardSummary = () => fetchJSON(`${BASE}/stats/summary`); diff --git a/web/src/api/types.ts b/web/src/api/types.ts index e1f0fae..1941411 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -244,6 +244,67 @@ export interface IssuanceRateDataPoint { 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 { gauge: { certificate_total: number; diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 5c03c0d..dba9d0e 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -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: '/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: '/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: '/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() {
- v2.0.3 + v2.0.4 {authRequired && ( + +
+ + + ); +} + +function ScanHistoryPanel({ scans }: { scans: DiscoveryScan[] }) { + if (scans.length === 0) return

No scans recorded yet

; + return ( +
+ + + + + + + + + + + + + + {scans.map(s => ( + + + + + + + + + + ))} + +
AgentDirectoriesFoundNewErrorsDurationStarted
{s.agent_id}{s.directories?.join(', ') || '—'}{s.certificates_found}{s.certificates_new}{s.errors_count > 0 ? {s.errors_count} : '0'}{s.scan_duration_ms}ms{formatDateTime(s.started_at)}
+
+ ); +} + +export default function DiscoveryPage() { + const [statusFilter, setStatusFilter] = useState(''); + const [agentFilter, setAgentFilter] = useState(''); + const [claimingCert, setClaimingCert] = useState(null); + const [showScans, setShowScans] = useState(false); + const queryClient = useQueryClient(); + + const params: Record = {}; + 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 Expired {Math.abs(days)}d ago; + if (days < 30) return {days}d left; + return {days}d left; + }; + + const discoveryStatusStyle: Record = { + Unmanaged: 'badge badge-warning', + Managed: 'badge badge-success', + Dismissed: 'badge badge-neutral', + }; + + const columns: Column[] = [ + { + key: 'common_name', + label: 'Common Name', + render: (c) => ( +
+
{c.common_name || '(no CN)'}
+ {c.sans?.length > 0 && ( +
+ {c.sans.slice(0, 2).join(', ')}{c.sans.length > 2 ? ` +${c.sans.length - 2}` : ''} +
+ )} +
+ ), + }, + { + key: 'status', + label: 'Status', + render: (c) => {c.status}, + }, + { + key: 'source', + label: 'Source', + render: (c) => ( +
+
{c.agent_id}
+
{c.source_path}
+
+ ), + }, + { + key: 'issuer', + label: 'Issuer', + render: (c) => {c.issuer_dn?.split(',')[0] || '—'}, + }, + { + key: 'expiry', + label: 'Expiry', + render: (c) => {formatExpiry(c.not_after)}, + }, + { + key: 'fingerprint', + label: 'Fingerprint', + render: (c) => {c.fingerprint_sha256?.substring(0, 16)}..., + }, + { + key: 'actions', + label: '', + render: (c) => ( + c.status === 'Unmanaged' ? ( +
+ + +
+ ) : null + ), + }, + ]; + + return ( + <> + + + {/* Summary stats bar */} + {summary && ( +
+
+ + {summary.Unmanaged || 0} Unmanaged +
+
+ + {summary.Managed || 0} Managed +
+
+ + {summary.Dismissed || 0} Dismissed +
+
+ +
+
+ )} + + {/* Scan history collapsible */} + {showScans && ( +
+
+

Recent Scans

+ +
+
+ )} + + {/* Filters */} +
+ + +
+ + {/* Table */} +
+ {error ? ( + refetch()} /> + ) : ( + + )} +
+ + {claimingCert && ( + setClaimingCert(null)} + onClaim={(managedCertId) => claimMutation.mutate({ id: claimingCert.id, managedCertId })} + /> + )} + + ); +} diff --git a/web/src/pages/JobsPage.tsx b/web/src/pages/JobsPage.tsx index 0560129..95332f7 100644 --- a/web/src/pages/JobsPage.tsx +++ b/web/src/pages/JobsPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; 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 DataTable from '../components/DataTable'; import type { Column } from '../components/DataTable'; @@ -9,9 +9,48 @@ import ErrorState from '../components/ErrorState'; import { formatDateTime } from '../api/utils'; import type { Job } from '../api/types'; +function RejectModal({ job, onClose, onReject }: { job: Job; onClose: () => void; onReject: (reason: string) => void }) { + const [reason, setReason] = useState(''); + return ( +
+
e.stopPropagation()}> +
+

Reject Job

+

+ Rejecting job {job.id} for certificate {job.certificate_id} +

+
+
+ +