diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3c4668d..fefbca0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -12,8 +12,14 @@ services: volumes: - postgres_data:/var/lib/postgresql/data - ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql - - ../migrations/seed.sql:/docker-entrypoint-initdb.d/002_seed.sql - - ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/003_seed_demo.sql + - ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql + - ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql + - ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql + - ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql + - ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql + - ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql + - ../migrations/seed.sql:/docker-entrypoint-initdb.d/010_seed.sql + - ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/011_seed_demo.sql networks: - certctl-network healthcheck: @@ -39,6 +45,7 @@ services: CERTCTL_LOG_LEVEL: info CERTCTL_AUTH_TYPE: none CERTCTL_KEYGEN_MODE: server # Demo uses server-side keygen; production should use "agent" + CERTCTL_NETWORK_SCAN_ENABLED: "true" # Enable network scan GUI with seeded demo targets ports: - "8443:8443" networks: diff --git a/docs/demo-advanced.md b/docs/demo-advanced.md index 39ad200..7450c09 100644 --- a/docs/demo-advanced.md +++ b/docs/demo-advanced.md @@ -876,14 +876,14 @@ curl -s -X POST $API/api/v1/agent-groups \ ## Part 12: Interactive Approval Workflow -For high-value certificates, you may want human oversight before renewal proceeds. Create a policy that requires approval: +For high-value certificates, you may want human oversight before renewal proceeds. The demo includes 2 pre-seeded `AwaitingApproval` renewal jobs (for `auth-production` and `payments-production`). Open **Jobs** in the sidebar — you'll see the amber "Pending Approval" banner and Approve/Reject buttons immediately. ```bash -# Check jobs that need approval +# Check jobs that need approval (demo includes 2) curl -s "$API/api/v1/jobs?status=AwaitingApproval" | jq '.data[] | {id, type, certificate_id, status}' ``` -If there are jobs awaiting approval, approve or reject them: +Approve or reject them: ```bash # Approve a job @@ -1029,6 +1029,8 @@ The MCP server is perfect for: certctl discovers existing certificates two ways: **filesystem scanning** (agents scan local directories) and **network scanning** (the server probes TLS endpoints). Both feed into the same triage pipeline. +**The demo comes pre-loaded with discovery data:** 9 discovered certificates (3 Unmanaged from filesystem scans, 3 Unmanaged from network scans, 2 Managed, 1 Dismissed), 3 discovery scans, and 3 network scan targets with recent scan results. Open **Discovery** in the sidebar to see the triage workflow immediately. The steps below show how to configure discovery from scratch. + ### Filesystem Discovery (Agent-Side) Configure the demo agent to scan for certificates. In the Docker Compose setup, agents have a `/tmp/certs` directory (created by the seed script). Restart the agent with discovery enabled: @@ -1049,7 +1051,7 @@ certctl-agent --agent-id a-demo-1 --key-dir /tmp/keys --discovery-dirs /tmp/cert ### Network Discovery (Server-Side) -The server can also discover certificates by actively probing TLS endpoints — no agent required. Create a scan target and trigger a scan: +The server can also discover certificates by actively probing TLS endpoints — no agent required. Network scanning is enabled by default in the Docker Compose demo (`CERTCTL_NETWORK_SCAN_ENABLED=true`), with 3 pre-configured scan targets. You can create additional targets: ```bash # Create a network scan target diff --git a/docs/quickstart.md b/docs/quickstart.md index ac3337f..9f9554a 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -101,7 +101,9 @@ Explore the sidebar: Certificates, Agents, Policies, Jobs, Audit Trail, Notifica **"Can I revoke a compromised cert?"** — Click any active certificate, then "Revoke." A modal with RFC 5280 reason codes (Key Compromise, Superseded, Cessation of Operation). After revocation, CRL and OCSP are served automatically — clients stop trusting the cert immediately. -**"What about certificates already in production?"** — Click "Discovered Certificates." Agents scan local filesystems for existing certs. The server probes TLS endpoints on configured CIDR ranges. Both feed into a triage workflow: claim unmanaged certs to bring them under automation, or dismiss them. +**"What about certificates already in production?"** — Click "Discovery" in the sidebar. The demo comes pre-loaded with 9 discovered certificates — some found by agents scanning filesystems, some found by the server probing TLS endpoints on the network. You'll see Unmanaged certs waiting for triage (including an expired printer cert and an expiring switch management cert), certs already linked to managed inventory, and one that was dismissed. Claim unmanaged certs to bring them under automation, or dismiss them. Click "Network Scans" to see the 3 configured scan targets with recent scan results. + +**"I need to approve a renewal before it proceeds"** — Click "Jobs" in the sidebar. You'll see an amber banner: "2 jobs awaiting approval." These are renewal jobs for `auth-production` and `payments-production` that require human sign-off before proceeding. Click Approve or Reject with a reason — the decision is recorded in the audit trail. **"Show me the agent fleet"** — Click "Agents." Four agents online, one offline. Click "Fleet Overview" for OS/architecture grouping, version distribution, and per-platform listing. Agents generate ECDSA P-256 keys locally — private keys never leave your infrastructure. @@ -254,9 +256,12 @@ curl -s http://localhost:8443/api/v1/crl | jq . ### Interactive approval workflow -For high-value certificates where you want human oversight: +For high-value certificates where you want human oversight. The demo includes 2 pre-seeded jobs in `AwaitingApproval` status (for `auth-production` and `payments-production`). Open **Jobs** in the sidebar and you'll see the amber "Pending Approval" banner immediately. ```bash +# List jobs awaiting approval (demo includes 2) +curl -s "http://localhost:8443/api/v1/jobs?status=AwaitingApproval" | jq '.data[] | {id, certificate_id, status}' + # Approve a pending job curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/approve \ -H "Content-Type: application/json" \ @@ -272,6 +277,8 @@ curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/reject \ Find certificates already running in your infrastructure — ones you didn't issue through certctl. +The demo environment comes pre-loaded with 9 discovered certificates (from agent filesystem scans and server-side network scans), 3 network scan targets, and recent scan history. Open **Discovery** and **Network Scans** in the sidebar to see the triage workflow immediately. + ### Filesystem discovery (agent-based) ```bash @@ -355,11 +362,15 @@ Exposes 78 MCP tools covering the REST API via stdio transport. Ask Claude: "Wha | Teams | 5 | Platform, Security, Payments, Frontend, Data | | Owners | 5 | Alice, Bob, Carol, Dave, Eve | | Issuers | 4 | Local Dev CA, Let's Encrypt Staging, step-ca Internal, DigiCert (disabled) | -| Agents | 5 | ag-web-prod, ag-web-staging, ag-lb-prod, ag-iis-prod, ag-data-prod | +| Agents | 6 | ag-web-prod, ag-web-staging, ag-lb-prod, ag-iis-prod, ag-data-prod, server-scanner (network discovery) | | Targets | 5 | NGINX (prod/staging/data), F5 LB, IIS | | Certificates | 15 | Various statuses: Active, Expiring, Expired, Failed, Wildcard | +| Discovered Certs | 9 | 5 Unmanaged (filesystem + network), 2 Managed (linked), 1 Dismissed, network-discovered expired printer cert | +| Discovery Scans | 3 | Agent filesystem scans + network TLS scan | +| Network Scan Targets | 3 | DC1 Web Servers, DC2 Application Tier, DMZ Public Endpoints | +| Jobs (Approval) | 2 | AwaitingApproval renewal jobs for auth-prod and payments-prod | | Policies | 4 | Required owner, allowed environments, max lifetime, min renewal window | -| Profiles | 3 | Default TLS, Short-Lived, High-Security | +| Profiles | 4 | Standard TLS, Internal mTLS, Short-Lived, High Security | | Agent Groups | 5 | Linux agents, ARM agents, Production subnet, etc. | ## Dashboard Demo Mode diff --git a/internal/api/handler/network_scan_handler_test.go b/internal/api/handler/network_scan_handler_test.go index 6d93782..4164a83 100644 --- a/internal/api/handler/network_scan_handler_test.go +++ b/internal/api/handler/network_scan_handler_test.go @@ -77,8 +77,8 @@ func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID strin func TestListNetworkScanTargets(t *testing.T) { svc := &mockNetworkScanService{ targets: []*domain.NetworkScanTarget{ - {ID: "nst-1", Name: "target1", CIDRs: []string{"10.0.0.0/24"}, Ports: []int{443}}, - {ID: "nst-2", Name: "target2", CIDRs: []string{"192.168.0.0/16"}, Ports: []int{443, 8443}}, + {ID: "nst-1", Name: "target1", CIDRs: []string{"10.0.0.0/24"}, Ports: []int64{443}}, + {ID: "nst-2", Name: "target2", CIDRs: []string{"192.168.0.0/16"}, Ports: []int64{443, 8443}}, }, } h := NewNetworkScanHandler(svc) @@ -118,7 +118,7 @@ func TestCreateNetworkScanTarget(t *testing.T) { body, _ := json.Marshal(map[string]interface{}{ "name": "Production", "cidrs": []string{"10.0.0.0/24"}, - "ports": []int{443}, + "ports": []int64{443}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets", bytes.NewReader(body)) diff --git a/internal/domain/network_scan.go b/internal/domain/network_scan.go index 9ffcd99..28bb381 100644 --- a/internal/domain/network_scan.go +++ b/internal/domain/network_scan.go @@ -7,7 +7,7 @@ type NetworkScanTarget struct { ID string `json:"id"` Name string `json:"name"` CIDRs []string `json:"cidrs"` - Ports []int `json:"ports"` + Ports []int64 `json:"ports"` Enabled bool `json:"enabled"` ScanIntervalHours int `json:"scan_interval_hours"` TimeoutMs int `json:"timeout_ms"` diff --git a/internal/domain/network_scan_test.go b/internal/domain/network_scan_test.go index babe285..4398535 100644 --- a/internal/domain/network_scan_test.go +++ b/internal/domain/network_scan_test.go @@ -10,7 +10,7 @@ func TestNetworkScanTarget_Defaults(t *testing.T) { ID: "nst-test", Name: "Test Target", CIDRs: []string{"10.0.0.0/24"}, - Ports: []int{443}, + Ports: []int64{443}, Enabled: true, ScanIntervalHours: 6, TimeoutMs: 5000, @@ -35,7 +35,7 @@ func TestNetworkScanTarget_WithScanResults(t *testing.T) { ID: "nst-prod", Name: "Production Network", CIDRs: []string{"192.168.1.0/24", "10.0.0.0/16"}, - Ports: []int{443, 8443, 636}, + Ports: []int64{443, 8443, 636}, Enabled: true, ScanIntervalHours: 1, TimeoutMs: 3000, diff --git a/internal/service/network_scan.go b/internal/service/network_scan.go index 566fc20..e1710e4 100644 --- a/internal/service/network_scan.go +++ b/internal/service/network_scan.go @@ -76,7 +76,7 @@ func (s *NetworkScanService) CreateTarget(ctx context.Context, target *domain.Ne } } if len(target.Ports) == 0 { - target.Ports = []int{443} + target.Ports = []int64{443} } if target.ScanIntervalHours == 0 { target.ScanIntervalHours = 6 @@ -276,7 +276,7 @@ func (s *NetworkScanService) scanTarget(ctx context.Context, target *domain.Netw } // expandEndpoints converts CIDR ranges and ports into a list of "ip:port" endpoints. -func (s *NetworkScanService) expandEndpoints(cidrs []string, ports []int) []string { +func (s *NetworkScanService) expandEndpoints(cidrs []string, ports []int64) []string { var endpoints []string for _, cidr := range cidrs { diff --git a/internal/service/network_scan_test.go b/internal/service/network_scan_test.go index cc8b1e4..18803eb 100644 --- a/internal/service/network_scan_test.go +++ b/internal/service/network_scan_test.go @@ -123,7 +123,7 @@ func TestNetworkScanService_CreateTarget(t *testing.T) { target, err := svc.CreateTarget(context.Background(), &domain.NetworkScanTarget{ Name: "Test Network", CIDRs: []string{"10.0.0.0/24"}, - Ports: []int{443, 8443}, + Ports: []int64{443, 8443}, }) if err != nil { t.Fatalf("CreateTarget failed: %v", err) @@ -221,7 +221,7 @@ func TestNetworkScanService_ListTargets(t *testing.T) { func TestExpandEndpoints(t *testing.T) { svc := &NetworkScanService{} - endpoints := svc.expandEndpoints([]string{"192.168.1.1"}, []int{443, 8443}) + endpoints := svc.expandEndpoints([]string{"192.168.1.1"}, []int64{443, 8443}) if len(endpoints) != 2 { t.Errorf("expected 2 endpoints, got %d: %v", len(endpoints), endpoints) } diff --git a/migrations/seed_demo.sql b/migrations/seed_demo.sql index d34e86e..6cc9b62 100644 --- a/migrations/seed_demo.sql +++ b/migrations/seed_demo.sql @@ -214,3 +214,113 @@ INSERT INTO agent_group_members (agent_group_id, agent_id, membership_type, crea ('ag-manual', 'ag-web-staging', 'include', NOW()), ('ag-manual', 'ag-iis-prod', 'exclude', NOW()) ON CONFLICT (agent_group_id, agent_id) DO NOTHING; + +-- Sentinel agent for network-discovered certificates (created by server on startup, seed for demo) +INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash, os, architecture, ip_address, version) VALUES + ('server-scanner', 'Network Scanner (Server-Side)', 'certctl-server', 'online', NOW(), NOW() - INTERVAL '30 days', 'sentinel_no_auth', 'linux', 'amd64', '127.0.0.1', '2.0.5') +ON CONFLICT (id) DO NOTHING; + +-- Discovery Scans — show recent scan activity from agents +INSERT INTO discovery_scans (id, agent_id, directories, certificates_found, certificates_new, errors_count, scan_duration_ms, started_at, completed_at) VALUES + ('ds-web-prod-01', 'ag-web-prod', '{/etc/nginx/ssl,/etc/pki/tls/certs}', 4, 2, 0, 1250, NOW() - INTERVAL '3 hours', NOW() - INTERVAL '3 hours' + INTERVAL '1 second'), + ('ds-data-prod-01', 'ag-data-prod', '{/etc/nginx/ssl,/opt/certs}', 3, 1, 0, 980, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '2 hours' + INTERVAL '1 second'), + ('ds-network-scan-01','server-scanner', '{network-scan}', 3, 3, 0, 4500, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour' + INTERVAL '5 seconds') +ON CONFLICT (id) DO NOTHING; + +-- Discovered Certificates — populate discovery triage page with realistic mix +INSERT INTO discovered_certificates (id, fingerprint_sha256, common_name, sans, serial_number, issuer_dn, subject_dn, not_before, not_after, key_algorithm, key_size, is_ca, pem_data, source_path, source_format, agent_id, discovery_scan_id, managed_certificate_id, status, first_seen_at, last_seen_at) VALUES + -- Unmanaged: found on filesystem, not yet claimed + ('dc-unmanaged-01', 'sha256:f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0', + 'internal-service.example.com', ARRAY['internal-service.example.com', 'internal-svc.local'], + '1A:2B:3C:4D:5E:6F:00:11', 'CN=Example Internal CA,O=Example Corp', + 'CN=internal-service.example.com,O=Example Corp', NOW() - INTERVAL '200 days', NOW() + INTERVAL '20 days', + 'RSA', 2048, false, '', '/etc/pki/tls/certs/internal-svc.pem', 'PEM', + 'ag-web-prod', 'ds-web-prod-01', NULL, 'Unmanaged', + NOW() - INTERVAL '7 days', NOW() - INTERVAL '3 hours'), + + ('dc-unmanaged-02', 'sha256:a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0', + 'monitoring.internal.example.com', ARRAY['monitoring.internal.example.com', 'prometheus.internal.example.com'], + '2B:3C:4D:5E:6F:7A:00:22', 'CN=Let''s Encrypt Authority X3,O=Let''s Encrypt', + 'CN=monitoring.internal.example.com', NOW() - INTERVAL '60 days', NOW() + INTERVAL '30 days', + 'ECDSA', 256, false, '', '/opt/certs/monitoring.pem', 'PEM', + 'ag-data-prod', 'ds-data-prod-01', NULL, 'Unmanaged', + NOW() - INTERVAL '5 days', NOW() - INTERVAL '2 hours'), + + ('dc-unmanaged-03', 'sha256:1122334455667788990011223344556677889900', + 'db-replication.example.com', ARRAY['db-replication.example.com'], + '3C:4D:5E:6F:7A:8B:00:33', 'CN=Example Internal CA,O=Example Corp', + 'CN=db-replication.example.com,O=Example Corp', NOW() - INTERVAL '300 days', NOW() - INTERVAL '10 days', + 'RSA', 4096, false, '', '/etc/pki/tls/certs/db-repl.pem', 'PEM', + 'ag-web-prod', 'ds-web-prod-01', NULL, 'Unmanaged', + NOW() - INTERVAL '7 days', NOW() - INTERVAL '3 hours'), + + -- Managed: already linked to managed certificates + ('dc-managed-01', 'sha256:ab12cd34ef56ab12cd34ef56ab12cd34ef56ab12', + 'api.example.com', ARRAY['api.example.com', 'api-v2.example.com'], + '0A:1B:2C:3D:4E:5F:00:01', 'CN=CertCtl Demo CA', + 'CN=api.example.com', NOW() - INTERVAL '15 days', NOW() + INTERVAL '75 days', + 'ECDSA', 256, false, '', '/etc/nginx/ssl/cert.pem', 'PEM', + 'ag-web-prod', 'ds-web-prod-01', 'mc-api-prod', 'Managed', + NOW() - INTERVAL '15 days', NOW() - INTERVAL '3 hours'), + + ('dc-managed-02', 'sha256:cd34ef56ab12cd34ef56ab12cd34ef56ab12cd34', + 'data.example.com', ARRAY['data.example.com', 'analytics.example.com'], + '0A:1B:2C:3D:4E:5F:00:06', 'CN=CertCtl Demo CA', + 'CN=data.example.com', NOW() - INTERVAL '35 days', NOW() + INTERVAL '55 days', + 'ECDSA', 256, false, '', '/etc/nginx/ssl/cert.pem', 'PEM', + 'ag-data-prod', 'ds-data-prod-01', 'mc-data-prod', 'Managed', + NOW() - INTERVAL '35 days', NOW() - INTERVAL '2 hours'), + + -- Dismissed: triaged and explicitly ignored + ('dc-dismissed-01', 'sha256:9988776655443322110099887766554433221100', + 'test-selfsigned.local', ARRAY['test-selfsigned.local', 'localhost'], + '00:00:00:00:00:00:FF:01', 'CN=test-selfsigned.local', + 'CN=test-selfsigned.local', NOW() - INTERVAL '365 days', NOW() + INTERVAL '365 days', + 'RSA', 2048, false, '', '/etc/pki/tls/certs/test.pem', 'PEM', + 'ag-web-prod', 'ds-web-prod-01', NULL, 'Dismissed', + NOW() - INTERVAL '7 days', NOW() - INTERVAL '3 hours'), + + -- Network-discovered certs (from server-scanner sentinel agent) + ('dc-network-01', 'sha256:net1aabbccdd11223344556677889900aabbccdd', + 'switch-mgmt.example.com', ARRAY['switch-mgmt.example.com'], + '5E:6F:7A:8B:9C:0D:00:44', 'CN=Example Network CA,O=Example Corp', + 'CN=switch-mgmt.example.com,O=Example Corp', NOW() - INTERVAL '180 days', NOW() + INTERVAL '5 days', + 'RSA', 2048, false, '', '10.0.1.50:443', 'TLS', + 'server-scanner', 'ds-network-scan-01', NULL, 'Unmanaged', + NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour'), + + ('dc-network-02', 'sha256:net2eeff00112233445566778899aabbccddeeff', + 'printer.example.com', ARRAY['printer.example.com'], + '6F:7A:8B:9C:0D:1E:00:55', 'CN=printer.example.com', + 'CN=printer.example.com', NOW() - INTERVAL '400 days', NOW() - INTERVAL '30 days', + 'RSA', 1024, false, '', '10.0.2.100:443', 'TLS', + 'server-scanner', 'ds-network-scan-01', NULL, 'Unmanaged', + NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour'), + + ('dc-network-03', 'sha256:net3001122334455667788990011223344556677', + 'vpn-appliance.example.com', ARRAY['vpn-appliance.example.com', '10.0.1.1'], + '7A:8B:9C:0D:1E:2F:00:66', 'CN=Fortinet CA,O=Fortinet', + 'CN=vpn-appliance.example.com', NOW() - INTERVAL '90 days', NOW() + INTERVAL '275 days', + 'RSA', 2048, false, '', '10.0.1.1:443', 'TLS', + 'server-scanner', 'ds-network-scan-01', NULL, 'Unmanaged', + NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour') +ON CONFLICT (id) DO NOTHING; + +-- Jobs — add AwaitingApproval jobs for approval workflow demo +INSERT INTO jobs (id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts, last_error, scheduled_at, created_at) VALUES + ('job-approval-01', 'renewal', 'mc-auth-prod', NULL, 'ag-web-prod', 'AwaitingApproval', 0, 3, NULL, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour'), + ('job-approval-02', 'renewal', 'mc-pay-prod', NULL, 'ag-web-prod', 'AwaitingApproval', 0, 3, NULL, NOW() - INTERVAL '30 minutes', NOW() - INTERVAL '30 minutes') +ON CONFLICT (id) DO NOTHING; + +-- Update network scan targets with last_scan data so GUI shows recent activity +UPDATE network_scan_targets SET + last_scan_at = NOW() - INTERVAL '1 hour', + last_scan_duration_ms = 4500, + last_scan_certs_found = 3 +WHERE id = 'nst-dc1-web'; + +UPDATE network_scan_targets SET + last_scan_at = NOW() - INTERVAL '2 hours', + last_scan_duration_ms = 8200, + last_scan_certs_found = 0 +WHERE id = 'nst-dc2-apps'; diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index dba9d0e..b081021 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -69,7 +69,7 @@ export default function Layout() {