mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:51:41 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef92b07448 | |||
| 5b301f9354 | |||
| 2e297b430e | |||
| 7bc6ad9823 | |||
| 6ccdf45179 | |||
| 69483786aa | |||
| 1f5ab16b18 | |||
| a8d04cded4 | |||
| 8308beb5bb | |||
| b9633e5b1a | |||
| d55807947e | |||
| d9fd0a147e |
@@ -24,36 +24,20 @@ certctl is a self-hosted platform that automates the entire certificate lifecycl
|
||||
|
||||
[](LICENSE)
|
||||
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
||||

|
||||

|
||||
|
||||
## Documentation
|
||||
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [Why certctl?](docs/why-certctl.md) | Competitive positioning — how certctl compares to open-source and enterprise certificate management platforms |
|
||||
| [Concepts](docs/concepts.md) | TLS certificates explained from scratch — for beginners who know nothing about certs |
|
||||
| [Quick Start](docs/quickstart.md) | Get running in 5 minutes — dashboard, API, CLI, discovery, stakeholder demo flow |
|
||||
| [Advanced Demo](docs/demo-advanced.md) | Issue a certificate end-to-end with technical deep-dives |
|
||||
| [Architecture](docs/architecture.md) | System design, data flow diagrams, security model |
|
||||
| [Feature Inventory](docs/features.md) | Complete reference of all V2 capabilities, API endpoints, and configuration |
|
||||
| [Connectors](docs/connectors.md) | Build custom issuer, target, and notifier connectors |
|
||||
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
||||
| [Manual Testing Guide](docs/testing-guide.md) | Extensively tested — full V2 QA runbook with exact commands and pass/fail criteria |
|
||||
|
||||
## Contents
|
||||
|
||||
- [Why certctl Exists](#why-certctl-exists)
|
||||
- [What It Does](#what-it-does)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Architecture](#architecture)
|
||||
- [Configuration](#configuration)
|
||||
- [MCP Server (AI Integration)](#mcp-server-ai-integration)
|
||||
- [CLI](#cli)
|
||||
- [API Overview](#api-overview)
|
||||
- [Supported Integrations](#supported-integrations)
|
||||
- [Development](#development)
|
||||
- [Security](#security)
|
||||
- [Roadmap](#roadmap)
|
||||
- [License](#license)
|
||||
|
||||
## Why certctl Exists
|
||||
|
||||
@@ -63,25 +47,21 @@ certctl fills that gap. It's **CA-agnostic** — the issuer connector interface
|
||||
|
||||
It's also **target-agnostic**. Agents deploy certificates to NGINX, Apache, and HAProxy today, with Traefik and Caddy support coming next — all using the same pluggable connector model for any server that accepts cert files. The control plane never initiates outbound connections — agents poll for work, which means certctl works behind firewalls, across network zones, and in air-gapped environments.
|
||||
|
||||
For a detailed comparison with CertKit, KeyTalk, and enterprise platforms (Venafi, Keyfactor), see [Why certctl?](docs/why-certctl.md)
|
||||
|
||||
## What It Does
|
||||
|
||||
certctl gives you a single pane of glass for every TLS certificate in your organization. The **web dashboard** shows your full certificate inventory — what's healthy, what's expiring, what's already expired, and who owns each one. The **REST API** (95 endpoints under `/api/v1/` + `/.well-known/est/`) lets you automate everything. **Agents** deployed on your infrastructure generate private keys locally, discover existing certificates on disk, and submit CSRs — private keys never leave your servers. The **network scanner** discovers certificates on TLS endpoints across your infrastructure without requiring agents. The **EST server** (RFC 7030) enables device and WiFi certificate enrollment via industry-standard Enrollment over Secure Transport. The background scheduler watches expiration dates and triggers renewals automatically — when certificate lifespans drop to 47 days, certctl handles the constant rotation without human involvement.
|
||||
certctl gives you a single pane of glass for every TLS certificate in your organization:
|
||||
|
||||
**Core capabilities:**
|
||||
- **Web dashboard** — full certificate inventory with status, ownership, expiration heatmaps, and bulk operations
|
||||
- **REST API** — 95 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation
|
||||
- **Agents** — generate private keys locally, discover existing certs on disk, submit CSRs (private keys never leave your servers)
|
||||
- **Network scanner** — discovers certificates on TLS endpoints across CIDR ranges without requiring agents
|
||||
- **EST server** (RFC 7030) — device and WiFi certificate enrollment via industry-standard protocol
|
||||
- **Approval workflows** — require human sign-off on renewals before deployment
|
||||
- **Background scheduler** — watches expiration dates and triggers renewals automatically, handling constant rotation at 47-day lifespans without human involvement
|
||||
|
||||
- **Full lifecycle automation** — issuance, renewal, deployment, and revocation with zero human intervention. Configurable renewal policies trigger jobs automatically based on expiration thresholds.
|
||||
- **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.
|
||||
- **Certificate discovery** — agents scan filesystems for existing PEM/DER certificates and report findings for triage. The network scanner probes TLS endpoints across CIDR ranges to find certificates you didn't know existed.
|
||||
- **Revocation infrastructure** — RFC 5280 revocation with all standard reason codes, DER-encoded X.509 CRL per issuer, embedded OCSP responder, and short-lived certificate exemption (certs under 1 hour skip CRL/OCSP).
|
||||
- **Policy engine** — 5 rule types with violation tracking and severity levels. Certificate profiles enforce allowed key types, maximum TTL, and crypto constraints at enrollment time.
|
||||
- **Immutable audit trail** — every action recorded to an append-only log. Every API call recorded with method, path, actor, SHA-256 body hash, response status, and latency. No update or delete on audit records.
|
||||
- **Operational dashboard** — Full React GUI with certificate inventory, bulk operations (multi-select renew/revoke/reassign), deployment timeline visualization, inline policy editing, agent fleet overview, expiration heatmaps, and real-time short-lived credential tracking.
|
||||
- **Observability** — JSON and Prometheus metrics endpoints, 5 stats API endpoints for dashboards, structured slog logging with request ID propagation. Compatible with Prometheus, Grafana Agent, Datadog Agent, and Victoria Metrics.
|
||||
- **Notifications** — threshold-based alerting with deduplication. Routes to email, webhooks, Slack, Microsoft Teams, PagerDuty, and OpsGenie.
|
||||
- **EST enrollment (RFC 7030)** — built-in Enrollment over Secure Transport server for device certificate enrollment. Supports WiFi/802.1X, MDM, and IoT use cases. PKCS#7 certs-only wire format, accepts PEM or base64-encoded DER CSRs, configurable issuer and profile binding.
|
||||
- **Multi-purpose certificates** — certificate profiles support arbitrary EKU (Extended Key Usage) constraints. TLS (serverAuth/clientAuth) today, with S/MIME (emailProtection) and code signing support coming in v2.0.2.
|
||||
- **AI and CLI access** — MCP server exposes all 78 API operations as tools for Claude, Cursor, and any MCP-compatible client. CLI tool with 12 subcommands for terminal workflows and scripting.
|
||||
For the full capability breakdown — issuer connectors, revocation infrastructure, policy engine, observability, EST enrollment, and more — see the [Feature Inventory](docs/features.md).
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -177,96 +157,33 @@ export CERTCTL_AGENT_ID=agent-local-01
|
||||
- **Handler → Service → Repository layering.** Handlers define their own service interfaces for clean dependency inversion. No global service singletons.
|
||||
- **Idempotent migrations.** All schema uses `IF NOT EXISTS` and seed data uses `ON CONFLICT (id) DO NOTHING`, safe for repeated execution.
|
||||
|
||||
### Database Schema
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `managed_certificates` | Certificate records with metadata, status, expiry, tags |
|
||||
| `certificate_versions` | Historical versions with PEM chains and CSRs |
|
||||
| `renewal_policies` | Renewal window, auto-renew settings, retry config, alert thresholds |
|
||||
| `issuers` | CA configurations (Local CA, ACME, etc.) |
|
||||
| `deployment_targets` | Target systems (NGINX, F5, IIS) with agent assignments |
|
||||
| `agents` | Registered agents with heartbeat tracking, OS/arch/IP metadata |
|
||||
| `jobs` | Issuance, renewal, deployment, and validation jobs |
|
||||
| `teams` | Organizational groups for certificate ownership |
|
||||
| `owners` | Individual owners with email for notifications |
|
||||
| `policy_rules` | Enforcement rules (allowed issuers, environments, metadata) |
|
||||
| `policy_violations` | Flagged non-compliance with severity levels |
|
||||
| `audit_events` | Immutable action log (append-only, no update/delete) |
|
||||
| `notification_events` | Email and webhook notification records |
|
||||
| `certificate_target_mappings` | Many-to-many cert ↔ target relationships |
|
||||
| `certificate_profiles` | Named enrollment profiles with allowed key types, max TTL, crypto constraints |
|
||||
| `agent_groups` | Dynamic device grouping by OS, architecture, IP CIDR, version |
|
||||
| `agent_group_members` | Manual include/exclude membership for agent groups |
|
||||
| `certificate_revocations` | Revocation records with RFC 5280 reason codes, serial numbers, issuer notification status |
|
||||
| `discovered_certificates` | Filesystem and network-discovered certificates with fingerprint deduplication |
|
||||
| `discovery_scans` | Discovery scan history with timestamps and agent attribution |
|
||||
| `network_scan_targets` | Network scan target definitions with CIDRs, ports, schedule, and scan metrics |
|
||||
PostgreSQL 16 with 21 tables covering certificates, versions, policies, issuers, targets, agents, jobs, teams, owners, profiles, agent groups, revocations, discovery, network scans, and audit events. See the [Architecture Guide](docs/architecture.md) for the full schema.
|
||||
|
||||
## Configuration
|
||||
|
||||
All server environment variables use the `CERTCTL_` prefix:
|
||||
All environment variables use the `CERTCTL_` prefix. Key settings:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SERVER_HOST` | `127.0.0.1` | Server bind address |
|
||||
| `CERTCTL_SERVER_PORT` | `8080` | Server listen port |
|
||||
| `CERTCTL_DATABASE_URL` | `postgres://localhost/certctl` | PostgreSQL connection string |
|
||||
| `CERTCTL_DATABASE_MAX_CONNS` | `25` | Connection pool size |
|
||||
| `CERTCTL_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||
| `CERTCTL_LOG_FORMAT` | `json` | Log format: `json` or `text` |
|
||||
| `CERTCTL_AUTH_TYPE` | `api-key` | Auth mode: `api-key`, `jwt`, or `none` |
|
||||
| `CERTCTL_AUTH_SECRET` | — | Required for `api-key` and `jwt` auth types |
|
||||
| `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_KEYGEN_MODE` | `agent` | Key generation: `agent` (production) or `server` (demo only) |
|
||||
| `CERTCTL_SERVER_PORT` | `8080` | Server listen port |
|
||||
| `CERTCTL_ACME_DIRECTORY_URL` | — | ACME directory URL (e.g., Let's Encrypt) |
|
||||
| `CERTCTL_ACME_EMAIL` | — | Contact email for ACME account registration |
|
||||
| `CERTCTL_ACME_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_CA_CERT_PATH` | — | Path to CA certificate for sub-CA mode |
|
||||
| `CERTCTL_CA_KEY_PATH` | — | Path to CA private key for sub-CA mode |
|
||||
| `CERTCTL_CORS_ORIGINS` | — | Comma-separated allowed CORS origins (empty = same-origin, `*` = all) |
|
||||
| `CERTCTL_RATE_LIMIT_ENABLED` | `true` | Enable/disable token bucket rate limiting |
|
||||
| `CERTCTL_RATE_LIMIT_RPS` | `50` | Requests per second limit |
|
||||
| `CERTCTL_RATE_LIMIT_BURST` | `100` | Maximum burst size for rate limiter |
|
||||
| `CERTCTL_DATABASE_MIGRATIONS_PATH` | `./migrations` | Path to SQL migration files |
|
||||
| `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL` | `1h` | How often the scheduler checks for expiring certs |
|
||||
| `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | `30s` | How often the scheduler processes pending jobs |
|
||||
| `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | `2m` | How often the scheduler checks agent health |
|
||||
| `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | `1m` | How often the scheduler processes pending notifications |
|
||||
| `CERTCTL_ACME_DNS_PRESENT_SCRIPT` | — | Script to create DNS TXT record (`_acme-challenge` for dns-01, `_validation-persist` for dns-persist-01) |
|
||||
| `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` | — | Script to remove DNS-01 `_acme-challenge` TXT record (not used by dns-persist-01) |
|
||||
| `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN` | — | CA issuer domain for dns-persist-01 (e.g., `letsencrypt.org`) |
|
||||
| `CERTCTL_STEPCA_URL` | — | step-ca server URL |
|
||||
| `CERTCTL_STEPCA_PROVISIONER` | — | step-ca JWK provisioner name |
|
||||
| `CERTCTL_STEPCA_KEY_PATH` | — | Path to step-ca provisioner private key (JWK JSON) |
|
||||
| `CERTCTL_STEPCA_PASSWORD` | — | step-ca provisioner key password |
|
||||
| `CERTCTL_OPENSSL_SIGN_SCRIPT` | — | Script for OpenSSL/Custom CA certificate signing |
|
||||
| `CERTCTL_OPENSSL_REVOKE_SCRIPT` | — | Script for OpenSSL/Custom CA certificate revocation |
|
||||
| `CERTCTL_OPENSSL_CRL_SCRIPT` | — | Script for OpenSSL/Custom CA CRL generation |
|
||||
| `CERTCTL_OPENSSL_TIMEOUT_SECONDS` | `30` | Timeout for OpenSSL script execution |
|
||||
| `CERTCTL_NETWORK_SCAN_ENABLED` | `false` | Enable server-side network certificate discovery (TLS scanning) |
|
||||
| `CERTCTL_NETWORK_SCAN_INTERVAL` | `6h` | How often the scheduler runs network scans |
|
||||
| `CERTCTL_EST_ENABLED` | `false` | Enable EST (RFC 7030) enrollment endpoints under /.well-known/est/ |
|
||||
| `CERTCTL_EST_ISSUER_ID` | `iss-local` | Issuer connector ID used for EST certificate enrollment |
|
||||
| `CERTCTL_EST_PROFILE_ID` | — | Optional certificate profile ID to constrain EST enrollments |
|
||||
| `CERTCTL_SLACK_WEBHOOK_URL` | — | Slack incoming webhook URL for notifications |
|
||||
| `CERTCTL_TEAMS_WEBHOOK_URL` | — | Microsoft Teams incoming webhook URL |
|
||||
| `CERTCTL_PAGERDUTY_ROUTING_KEY` | — | PagerDuty Events API v2 routing key |
|
||||
| `CERTCTL_OPSGENIE_API_KEY` | — | OpsGenie Alert API key |
|
||||
|
||||
Agent environment variables:
|
||||
Agent settings:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SERVER_URL` | `http://localhost:8080` | Control plane URL |
|
||||
| `CERTCTL_API_KEY` | — | Agent API key |
|
||||
| `CERTCTL_AGENT_NAME` | `certctl-agent` | Agent display name |
|
||||
| `CERTCTL_AGENT_ID` | — | Registered agent ID (required) |
|
||||
| `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Directory for storing private keys (agent keygen mode) |
|
||||
| `CERTCTL_DISCOVERY_DIRS` | — | Comma-separated directories to scan for existing certificates (e.g., `/etc/nginx/certs,/etc/ssl/certs`) |
|
||||
| `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Private key storage directory |
|
||||
| `CERTCTL_DISCOVERY_DIRS` | — | Directories to scan for existing certs (comma-separated) |
|
||||
|
||||
Docker Compose overrides these for the demo stack (see `deploy/docker-compose.yml`): port `8443`, auth type `none`, database pointing to the postgres container.
|
||||
For the full configuration reference — including ACME DNS challenges, sub-CA mode, step-ca, OpenSSL/Custom CA, EST enrollment, network scanning, notification connectors (Slack, Teams, PagerDuty, OpsGenie), scheduler intervals, CORS, and rate limiting — see the [Feature Inventory](docs/features.md). Docker Compose overrides for the demo stack are in `deploy/docker-compose.yml`.
|
||||
|
||||
## MCP Server (AI Integration)
|
||||
|
||||
@@ -337,153 +254,41 @@ certctl-cli certs list --format json # JSON output (default: table)
|
||||
|
||||
## API Overview
|
||||
|
||||
All endpoints are under `/api/v1/` and return JSON. List endpoints support pagination (`?page=1&per_page=50`). Full request/response schemas are available in the [OpenAPI 3.1 spec](api/openapi.yaml).
|
||||
95 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml).
|
||||
|
||||
### Certificates
|
||||
### Key Endpoints
|
||||
```
|
||||
# Certificate lifecycle
|
||||
GET /api/v1/certificates List (filter, sort, cursor, sparse fields)
|
||||
POST /api/v1/certificates Create
|
||||
GET /api/v1/certificates/{id} Get
|
||||
PUT /api/v1/certificates/{id} Update
|
||||
DELETE /api/v1/certificates/{id} Archive (soft delete)
|
||||
GET /api/v1/certificates/{id}/versions Version history
|
||||
GET /api/v1/certificates/{id}/deployments List deployment targets
|
||||
POST /api/v1/certificates/{id}/renew Trigger renewal → 202 Accepted
|
||||
POST /api/v1/certificates/{id}/deploy Trigger deployment → 202 Accepted
|
||||
POST /api/v1/certificates/{id}/revoke Revoke with RFC 5280 reason code
|
||||
GET /api/v1/crl Certificate Revocation List (JSON)
|
||||
GET /api/v1/crl/{issuer_id} DER-encoded X.509 CRL
|
||||
GET /api/v1/ocsp/{issuer_id}/{serial} OCSP responder (good/revoked/unknown)
|
||||
POST /api/v1/certificates/{id}/renew Trigger renewal → 202 Accepted
|
||||
POST /api/v1/certificates/{id}/revoke Revoke with RFC 5280 reason code
|
||||
GET /api/v1/crl/{issuer_id} DER-encoded X.509 CRL
|
||||
GET /api/v1/ocsp/{issuer_id}/{serial} OCSP responder (good/revoked/unknown)
|
||||
|
||||
# Agent operations
|
||||
POST /api/v1/agents/{id}/csr Submit CSR for issuance
|
||||
GET /api/v1/agents/{id}/work Poll for pending deployment jobs
|
||||
POST /api/v1/agents/{id}/discoveries Submit certificate discovery scan results
|
||||
|
||||
# Discovery & network scanning
|
||||
GET /api/v1/discovered-certificates List discovered certs (?agent_id, ?status)
|
||||
POST /api/v1/discovered-certificates/{id}/claim Link to managed cert
|
||||
POST /api/v1/network-scan-targets/{id}/scan Trigger immediate TLS scan
|
||||
|
||||
# Jobs & approval
|
||||
POST /api/v1/jobs/{id}/approve Approve interactive renewal
|
||||
POST /api/v1/jobs/{id}/reject Reject interactive renewal
|
||||
|
||||
# Observability
|
||||
GET /api/v1/metrics/prometheus Prometheus exposition format
|
||||
GET /api/v1/stats/summary Dashboard summary
|
||||
|
||||
# EST enrollment (RFC 7030)
|
||||
POST /.well-known/est/simpleenroll Device certificate enrollment
|
||||
GET /.well-known/est/cacerts CA certificate chain (PKCS#7)
|
||||
```
|
||||
|
||||
### Agents
|
||||
```
|
||||
GET /api/v1/agents List
|
||||
POST /api/v1/agents Register
|
||||
GET /api/v1/agents/{id} Get
|
||||
POST /api/v1/agents/{id}/heartbeat Record heartbeat
|
||||
POST /api/v1/agents/{id}/csr Submit CSR for issuance
|
||||
GET /api/v1/agents/{id}/certificates/{certId} Retrieve signed certificate
|
||||
GET /api/v1/agents/{id}/work Poll for pending deployment jobs
|
||||
POST /api/v1/agents/{id}/jobs/{jobId}/status Report job completion/failure
|
||||
POST /api/v1/agents/{id}/discoveries Submit certificate discovery scan results
|
||||
```
|
||||
|
||||
### Certificate Discovery
|
||||
```
|
||||
GET /api/v1/discovered-certificates List discovered certificates (?agent_id, ?status)
|
||||
GET /api/v1/discovered-certificates/{id} Get discovery detail
|
||||
POST /api/v1/discovered-certificates/{id}/claim Link discovered cert to managed cert
|
||||
POST /api/v1/discovered-certificates/{id}/dismiss Dismiss discovery
|
||||
GET /api/v1/discovery-scans List discovery scan history
|
||||
GET /api/v1/discovery-summary Aggregated discovery status (new, claimed, dismissed counts)
|
||||
```
|
||||
|
||||
### Infrastructure
|
||||
```
|
||||
GET /api/v1/issuers List issuers
|
||||
POST /api/v1/issuers Create
|
||||
GET /api/v1/issuers/{id} Get
|
||||
PUT /api/v1/issuers/{id} Update
|
||||
DELETE /api/v1/issuers/{id} Delete
|
||||
POST /api/v1/issuers/{id}/test Test connectivity
|
||||
|
||||
GET /api/v1/targets List deployment targets
|
||||
POST /api/v1/targets Create
|
||||
GET /api/v1/targets/{id} Get
|
||||
PUT /api/v1/targets/{id} Update
|
||||
DELETE /api/v1/targets/{id} Delete
|
||||
```
|
||||
|
||||
### Organization
|
||||
```
|
||||
GET /api/v1/teams List teams
|
||||
POST /api/v1/teams Create
|
||||
GET /api/v1/teams/{id} Get
|
||||
PUT /api/v1/teams/{id} Update
|
||||
DELETE /api/v1/teams/{id} Delete
|
||||
GET /api/v1/owners List owners
|
||||
POST /api/v1/owners Create
|
||||
GET /api/v1/owners/{id} Get
|
||||
PUT /api/v1/owners/{id} Update
|
||||
DELETE /api/v1/owners/{id} Delete
|
||||
```
|
||||
|
||||
### Operations
|
||||
```
|
||||
GET /api/v1/jobs List (filter: status, type)
|
||||
GET /api/v1/jobs/{id} Get
|
||||
POST /api/v1/jobs/{id}/cancel Cancel
|
||||
POST /api/v1/jobs/{id}/approve Approve (interactive renewal)
|
||||
POST /api/v1/jobs/{id}/reject Reject (interactive renewal)
|
||||
|
||||
GET /api/v1/policies List policy rules
|
||||
POST /api/v1/policies Create
|
||||
GET /api/v1/policies/{id} Get
|
||||
PUT /api/v1/policies/{id} Update (enable/disable)
|
||||
DELETE /api/v1/policies/{id} Delete
|
||||
GET /api/v1/policies/{id}/violations List violations for rule
|
||||
|
||||
GET /api/v1/profiles List certificate profiles
|
||||
POST /api/v1/profiles Create
|
||||
GET /api/v1/profiles/{id} Get
|
||||
PUT /api/v1/profiles/{id} Update
|
||||
DELETE /api/v1/profiles/{id} Delete
|
||||
|
||||
GET /api/v1/agent-groups List agent groups
|
||||
POST /api/v1/agent-groups Create
|
||||
GET /api/v1/agent-groups/{id} Get
|
||||
PUT /api/v1/agent-groups/{id} Update
|
||||
DELETE /api/v1/agent-groups/{id} Delete
|
||||
GET /api/v1/agent-groups/{id}/members List members
|
||||
|
||||
GET /api/v1/audit Query audit trail
|
||||
GET /api/v1/audit/{id} Get audit event
|
||||
GET /api/v1/notifications List notifications
|
||||
GET /api/v1/notifications/{id} Get notification
|
||||
POST /api/v1/notifications/{id}/read Mark as read
|
||||
```
|
||||
|
||||
### Observability
|
||||
```
|
||||
GET /api/v1/stats/summary Dashboard summary (totals, expiring, agents, jobs)
|
||||
GET /api/v1/stats/certificates-by-status Certificate counts grouped by status
|
||||
GET /api/v1/stats/expiration-timeline Expiration buckets (?days=30)
|
||||
GET /api/v1/stats/job-trends Job success/failure over time (?days=7)
|
||||
GET /api/v1/stats/issuance-rate Certificate issuance rate (?days=7)
|
||||
GET /api/v1/metrics JSON metrics (gauges, counters, uptime)
|
||||
GET /api/v1/metrics/prometheus Prometheus exposition format (text/plain)
|
||||
```
|
||||
|
||||
### Network Discovery
|
||||
```
|
||||
GET /api/v1/network-scan-targets List scan targets
|
||||
POST /api/v1/network-scan-targets Create scan target (CIDRs, ports, schedule)
|
||||
GET /api/v1/network-scan-targets/{id} Get scan target
|
||||
PUT /api/v1/network-scan-targets/{id} Update scan target
|
||||
DELETE /api/v1/network-scan-targets/{id} Delete scan target
|
||||
POST /api/v1/network-scan-targets/{id}/scan Trigger immediate scan
|
||||
```
|
||||
|
||||
### Auth
|
||||
```
|
||||
GET /api/v1/auth/info Auth mode info (no auth required)
|
||||
GET /api/v1/auth/check Validate credentials
|
||||
```
|
||||
|
||||
### EST Enrollment (RFC 7030)
|
||||
```
|
||||
GET /.well-known/est/cacerts CA certificate chain (PKCS#7 certs-only)
|
||||
POST /.well-known/est/simpleenroll Simple enrollment (PEM or base64-DER CSR)
|
||||
POST /.well-known/est/simplereenroll Simple re-enrollment (certificate renewal)
|
||||
GET /.well-known/est/csrattrs CSR attributes request
|
||||
```
|
||||
|
||||
### Health
|
||||
```
|
||||
GET /health Server health check
|
||||
GET /ready Readiness check
|
||||
```
|
||||
Full CRUD is available for certificates, agents, issuers, targets, teams, owners, policies, profiles, agent groups, notifications, and audit events. See the [OpenAPI spec](api/openapi.yaml) or [Feature Inventory](docs/features.md) for the complete endpoint reference.
|
||||
|
||||
## Supported Integrations
|
||||
|
||||
@@ -564,35 +369,38 @@ make docker-clean # Stop + remove volumes
|
||||
- Immutable append-only log in PostgreSQL (`audit_events` table)
|
||||
- Every lifecycle action attributed to an actor with timestamp and resource reference
|
||||
- No update or delete operations on audit records
|
||||
- Every API call recorded to audit trail with method, path, actor, SHA-256 body hash, response status, and latency (M19)
|
||||
- Every API call recorded to audit trail with method, path, actor, SHA-256 body hash, response status, and latency
|
||||
|
||||
## Roadmap
|
||||
|
||||
### V1 (v1.0.0 released)
|
||||
All nine development milestones (M1–M9) are complete. The backend covers the full certificate lifecycle: Local CA and ACME v2 issuers, NGINX/Apache/HAProxy/F5/IIS target connectors, threshold-based expiration alerting, agent-side ECDSA P-256 key generation, API auth with rate limiting, and a full React dashboard wired to the real API. The CI pipeline runs build, vet, test with coverage gates (service layer 30%+, handler layer 50%+), frontend type checking, Vitest test suite, and Vite production build on every push. Docker images are published to GitHub Container Registry on every version tag via the release workflow.
|
||||
### V1 (v1.0.0)
|
||||
Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR.
|
||||
|
||||
### V2: Operational Maturity
|
||||
- **M10: Agent Metadata + Targets** ✅ — agents report OS, architecture, IP, hostname, version via heartbeat; Apache httpd and HAProxy target connectors
|
||||
- **M11: Crypto Policy + Profiles + Ownership** ✅ — certificate profiles (named enrollment profiles with allowed key types, max TTL, crypto constraints), certificate ownership tracking (owners + teams + notification routing), dynamic agent groups (OS/arch/IP CIDR/version matching), interactive renewal approval (AwaitingApproval state)
|
||||
- **M12: Sub-CA + DNS-01 + step-ca** ✅ — Local CA sub-CA mode (enterprise root chain with RSA/ECDSA/PKCS#8), ACME DNS-01 challenges (script-based DNS hooks for any provider, wildcard cert support), ACME DNS-PERSIST-01 challenges (standing TXT record, no per-renewal DNS updates, auto-fallback to dns-01), step-ca issuer connector (native /sign API with JWK provisioner auth)
|
||||
- **M15a: Core Revocation** ✅ — revocation API with all RFC 5280 reason codes, JSON CRL endpoint, webhook + email revocation notifications, best-effort issuer notification, `certificate_revocations` table with idempotent recording, 48 new tests
|
||||
- **M15b: OCSP + Revocation GUI** ✅ — embedded OCSP responder (GET /api/v1/ocsp/{issuer_id}/{serial}), DER-encoded X.509 CRL (GET /api/v1/crl/{issuer_id}), short-lived cert exemption (TTL < 1h skip CRL/OCSP), revocation GUI with reason modal, ~31 new tests
|
||||
- **M13: GUI Operations** ✅ — bulk cert operations (multi-select → renew, revoke, reassign owner), deployment status timeline, inline policy/profile editor, target connector configuration wizard, audit trail export (CSV/JSON), short-lived credentials dashboard view
|
||||
- **M14: Observability** ✅ — dashboard charts (expiration heatmap, cert status distribution, job trends, issuance rate), agent fleet overview with OS/arch grouping, JSON metrics endpoint, stats API (5 endpoints), structured logging with request IDs, deployment rollback
|
||||
- **M18a: MCP Server** ✅ (V2.1) — AI-native integration, all 78 REST API endpoints exposed as MCP tools for Claude, Cursor, OpenClaw, and any MCP-compatible client
|
||||
- **M19: Immutable API Audit Log** ✅ — every API call recorded to immutable audit trail (method, path, actor, SHA-256 body hash, status, latency), async recording via goroutine, configurable path exclusions
|
||||
- **M16a: Notifier Connectors** ✅ — Slack (incoming webhook), Microsoft Teams (MessageCard), PagerDuty (Events API v2), OpsGenie (Alert API v2) — config-driven enablement via env vars
|
||||
- **M17: Additional Connectors** ✅ — OpenSSL/Custom CA issuer connector (script-based signing with configurable timeout)
|
||||
- **M16b: CLI + Bulk Import** ✅ — `certctl-cli` with 12 subcommands (certs list/get/renew/revoke, agents list/get, jobs list/get/cancel, import, status, version), stdlib-only, JSON/table output
|
||||
- **M20: Enhanced Query API** ✅ — sparse field selection (`?fields=`), sort with direction (`?sort=-notAfter`), time-range filters (`expires_before`, `created_after`, etc.), cursor-based pagination (`?cursor=&page_size=`), `GET /certificates/{id}/deployments`, additional filters (`agent_id`, `profile_id`)
|
||||
- **M18b: Filesystem Cert Discovery** ✅ — agents scan configured directories (PEM/DER), report findings to control plane, deduplication by SHA-256 fingerprint, claim/dismiss/triage workflow via API
|
||||
- **M21: Network Cert Discovery** ✅ — server-side active TLS scanning of CIDR ranges and ports, concurrent probing (50 goroutines), CIDR expansion with /20 safety cap, sentinel agent pattern for discovery pipeline reuse, CRUD API for scan targets, scheduler integration (6h default)
|
||||
- **M22: Prometheus Metrics** ✅ — `GET /api/v1/metrics/prometheus` returns Prometheus exposition format (`text/plain; version=0.0.4`), 11 metrics with `certctl_` prefix, compatible with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics
|
||||
- **M23: EST Server (RFC 7030)** ✅ — Enrollment over Secure Transport for device/WiFi certificate enrollment, 4 endpoints under /.well-known/est/, PKCS#7 certs-only wire format, base64-encoded DER CSR input, configurable issuer + profile binding, audit trail, 28 new tests
|
||||
- **Compliance Mapping** ✅ — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 capability mapping documentation
|
||||
- **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
|
||||
|
||||
18 milestones complete, 950+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
|
||||
|
||||
**What shipped (all ✅):**
|
||||
|
||||
- **Issuers** — Sub-CA mode (enterprise root chains), ACME DNS-01 + DNS-PERSIST-01 (wildcard certs, any DNS provider), step-ca (native /sign API), OpenSSL/Custom CA (script-based signing)
|
||||
- **Revocation** — RFC 5280 reason codes, DER-encoded X.509 CRL, embedded OCSP responder, short-lived cert exemption
|
||||
- **Profiles + Ownership** — certificate profiles (key types, max TTL, crypto constraints), ownership tracking (owners + teams), dynamic agent groups, interactive renewal approval
|
||||
- **GUI Operations** — bulk renew/revoke/reassign, deployment timeline, inline policy editor, target wizard, audit export (CSV/JSON), short-lived credentials view
|
||||
- **Discovery** — filesystem scanning (PEM/DER) + network TLS scanning (CIDR ranges), triage workflow (claim/dismiss), network scan target management
|
||||
- **Observability** — Prometheus + JSON metrics, 5 stats API endpoints, dashboard charts (heatmap, trends, distribution), agent fleet overview, structured logging
|
||||
- **EST Server** (RFC 7030) — device/WiFi certificate enrollment, PKCS#7 wire format, configurable issuer + profile binding
|
||||
- **MCP Server** — 78 API operations as AI tools for Claude, Cursor, and any MCP-compatible client
|
||||
- **CLI** — 12 subcommands (list/get/renew/revoke certs, agents, jobs, import, status), JSON/table output
|
||||
- **Notifications** — Slack, Microsoft Teams, PagerDuty, OpsGenie connectors
|
||||
- **API Enhancements** — sparse fields, sort, time-range filters, cursor pagination, immutable API audit logging
|
||||
- **Compliance Mapping** — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides
|
||||
|
||||
**Coming next:**
|
||||
|
||||
- **Post-Deployment TLS Verification** (v2.0.6) — agent-side TLS probe confirms the target is serving the correct certificate by SHA-256 fingerprint match
|
||||
- **Traefik + Caddy Targets** (v2.1.x) — Traefik (file provider, auto-reload) and Caddy (Admin API, hot-reload)
|
||||
- **Certificate Export** (v2.1.x) — single-cert download in PFX/PKCS12, DER, and PEM formats
|
||||
- **S/MIME Support** (v2.2.x) — profile EKU constraints for S/MIME (emailProtection), code signing, and custom EKUs
|
||||
|
||||
### V3: certctl Pro
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+4
-2
@@ -246,10 +246,12 @@ Certificate discovery is the process of automatically finding existing certifica
|
||||
**How it works:** There are two discovery modes. *Filesystem discovery* — agents scan configured directories (configured via `CERTCTL_DISCOVERY_DIRS`) for certificate files. On startup and every 6 hours, the agent walks directories recursively, parses PEM and DER files, extracts metadata, and reports findings to the control plane. *Network discovery* — the control plane itself probes TLS endpoints across configured CIDR ranges and ports (enabled via `CERTCTL_NETWORK_SCAN_ENABLED=true`). It connects to each endpoint, extracts certificates from the TLS handshake, and feeds results into the same discovery pipeline. This finds certificates on services you may not have agents on. In both cases, the server deduplicates by fingerprint and stores discovered certs with a status: **Unmanaged** (discovered but not yet managed), **Managed** (linked to a control plane cert), or **Dismissed** (operator decided not to manage it).
|
||||
|
||||
This gives you a three-step triage workflow:
|
||||
1. **Discover** — Agents find all existing certs on your infrastructure
|
||||
2. **Triage** — Operators review discoveries and decide: claim it (enroll for management), or dismiss it (not worth managing)
|
||||
1. **Discover** — Agents scan filesystems and the server probes network endpoints to find all existing certs
|
||||
2. **Triage** — Operators review discoveries in the **Discovery** dashboard page and decide: claim it (link to a managed certificate) or dismiss it (not worth managing). The dashboard shows a summary stats bar (Unmanaged/Managed/Dismissed counts), filters by status and agent, and provides one-click claim and dismiss actions.
|
||||
3. **Baseline** — Once triaged, you have a complete baseline of what's deployed, what you're managing, and what's unmanaged
|
||||
|
||||
Network scan targets are managed from the **Network Scans** dashboard page — create CIDR ranges and ports to probe, enable/disable targets, trigger on-demand scans, and view results. Discovered certificates from network scans appear in the same Discovery triage page alongside filesystem discoveries.
|
||||
|
||||
This is a prerequisite for multi-CA migration, compliance audits, and building confidence that you've found all the certificates that matter.
|
||||
|
||||
### Observability
|
||||
|
||||
+4
-4
@@ -716,7 +716,7 @@ The agent scans these directories on startup and every 6 hours, looking for cert
|
||||
1. **Scan**: Agent recursively walks directories, extracts certificates
|
||||
2. **Deduplicate**: Control plane deduplicates by SHA-256 fingerprint (same cert in multiple locations is one discovery)
|
||||
3. **Store**: Discovered certificates stored with metadata (agent ID, file path, found date, fingerprint)
|
||||
4. **Triage**: Operators query discovered certs via API, claim to link to managed certificates, or dismiss false positives
|
||||
4. **Triage**: Operators review discovered certs in the **Discovery** dashboard page (or via API) — claim to link to managed certificates, or dismiss false positives. The dashboard shows summary stats, filters by status and agent, and provides one-click claim/dismiss actions.
|
||||
|
||||
### API Endpoints
|
||||
|
||||
@@ -764,10 +764,10 @@ export CERTCTL_NETWORK_SCAN_INTERVAL=6h # default
|
||||
|
||||
### Creating Scan Targets
|
||||
|
||||
Network scan targets define which CIDR ranges and ports to probe:
|
||||
Network scan targets can be managed from the **Network Scans** dashboard page (create, edit, enable/disable, trigger on-demand scans) or via the API. Targets define which CIDR ranges and ports to probe:
|
||||
|
||||
```bash
|
||||
# Create a scan target for your internal network
|
||||
# Create a scan target for your internal network (or use the dashboard's "+ New Target" button)
|
||||
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
@@ -787,7 +787,7 @@ curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
||||
3. **Extract**: Certificate metadata extracted from TLS handshake (CN, SANs, serial, issuer, key info, fingerprint)
|
||||
4. **Pipeline**: Results fed into the same `DiscoveryService.ProcessDiscoveryReport()` as filesystem discovery
|
||||
5. **Deduplicate**: Sentinel agent ID (`server-scanner`) with source_path as `ip:port` ensures proper dedup
|
||||
6. **Triage**: Discovered certs appear in `GET /api/v1/discovered-certificates` with `agent_id=server-scanner`
|
||||
6. **Triage**: Discovered certs appear in the **Discovery** dashboard page (and via `GET /api/v1/discovered-certificates`) with `agent_id=server-scanner`
|
||||
|
||||
### API Endpoints
|
||||
|
||||
|
||||
+30
-4
@@ -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
|
||||
@@ -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
|
||||
@@ -1027,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:
|
||||
@@ -1047,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
|
||||
@@ -1101,6 +1105,28 @@ curl -s -X POST "$API/api/v1/discovered-certificates/$DISCOVERED_ID/dismiss" \
|
||||
|
||||
**How it works:** Filesystem discovery: the agent scans `CERTCTL_DISCOVERY_DIRS` on startup and every 6 hours, extracts metadata (common name, SANs, issuer, expiration, key type, fingerprint) from all PEM and DER files, and POSTs findings to `POST /api/v1/agents/{id}/discoveries`. Network discovery: the server expands CIDR ranges (capped at /20 = 4096 IPs), connects to each IP:port via TLS, extracts the peer certificate chain, and stores results using `server-scanner` as a sentinel agent ID. Both sources deduplicate by fingerprint and store results with a status: **Unmanaged** (discovered, not yet managed), **Managed** (linked to a control plane cert), or **Dismissed** (operator decided not to manage). This gives you a triage workflow: discover → review → claim or dismiss.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
+16
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+52
-17
@@ -3366,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.6 | Revoke button | Click revoke | Reason modal, status updates after | PASS if revocation completes |
|
||||
|
||||
### 19.5 Other Pages
|
||||
|
||||
| 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
|
||||
### 19.5 Jobs Page — Approval Workflow
|
||||
|
||||
| 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.6.2 | Logout | Click logout | Returns to login screen | PASS if login page shown |
|
||||
| 19.6.3 | 401 redirect | Expire/remove auth token | Auto-redirect to login | PASS if login page shown |
|
||||
| 19.6.4 | Theme consistency | Check page styling | Light content area, teal sidebar, branded colors, readable text | PASS if theme consistent across all pages |
|
||||
| 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.5.2 | Approve button | Find AwaitingApproval job, click Approve | Job status changes to Running/Completed | PASS if status transitions |
|
||||
| 19.5.3 | Reject button | Find AwaitingApproval job, click Reject | Modal opens with reason input | PASS if modal appears |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Why certctl?
|
||||
|
||||
Certificate management is broken at every scale between "one domain on Let's Encrypt" and "Fortune 500 budget for Venafi."
|
||||
|
||||
If you run a personal blog, Certbot works fine. If your company spends $200K/year on Keyfactor, you're covered. But if you're an ops engineer managing 20-500 certificates across NGINX, Apache, HAProxy, and maybe a private CA — the tools available today either don't do enough or cost too much.
|
||||
|
||||
certctl fills that gap.
|
||||
|
||||
## The Problem
|
||||
|
||||
The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) in April 2025, mandating a phased reduction in TLS certificate lifetimes: 200 days as of March 2026, 100 days by March 2027, and 47 days by March 2029. That means every organization needs automated certificate renewal — not eventually, but now.
|
||||
|
||||
The existing options for automation are:
|
||||
|
||||
- **ACME clients** (Certbot, Lego, CertWarden): Handle issuance and renewal for ACME-compatible CAs, but don't manage deployment to target servers, don't provide inventory visibility, don't support non-ACME CAs, and don't offer audit trails or policy enforcement.
|
||||
- **Kubernetes-native** (cert-manager): Works well inside Kubernetes, but if your infrastructure includes bare-metal servers, VMs, or network appliances alongside Kubernetes, you need a separate solution for everything cert-manager can't reach.
|
||||
- **Commercial SaaS** (CertKit, Sectigo CLM): Handle more of the lifecycle but are proprietary, cloud-dependent, and priced per certificate — costs scale linearly with your infrastructure.
|
||||
- **Enterprise platforms** (Venafi, Keyfactor, AppViewX): Comprehensive but start at $75K/year and require dedicated teams to operate.
|
||||
|
||||
## What certctl Does Differently
|
||||
|
||||
certctl is a self-hosted certificate lifecycle platform. It handles issuance, renewal, deployment, revocation, discovery, and monitoring — with three design decisions that no other tool at any price point combines:
|
||||
|
||||
### 1. Private Keys Never Leave Your Infrastructure
|
||||
|
||||
certctl agents generate private keys locally using ECDSA P-256. The agent creates a CSR and submits it to the control plane. The signed certificate comes back. The private key stays on the agent's filesystem with 0600 permissions.
|
||||
|
||||
This isn't a premium feature — it's the default behavior in the free tier. Most competitors either generate keys server-side (creating a single point of compromise) or gate key isolation behind paid tiers.
|
||||
|
||||
### 2. CA-Agnostic Issuer Architecture
|
||||
|
||||
certctl works with any certificate authority, not just ACME providers:
|
||||
|
||||
- **ACME** (Let's Encrypt, ZeroSSL, Google Trust Services, Buypass) — HTTP-01 and DNS-01 challenges, DNS-PERSIST-01 for zero-touch renewals, External Account Binding
|
||||
- **step-ca** (Smallstep) — native /sign API with JWK provisioner authentication
|
||||
- **Local CA** — self-signed or sub-CA mode (chain to your enterprise root CA, e.g. ADCS)
|
||||
- **OpenSSL / Custom CA** — delegate signing to any shell script with configurable timeout
|
||||
- **EST enrollment** (RFC 7030) — device certificate enrollment for WiFi/802.1X, MDM, and IoT
|
||||
|
||||
Every issuer connector implements the same interface. Switching CAs or running multiple CAs in parallel requires zero code changes — just configuration.
|
||||
|
||||
### 3. Post-Deployment Verification (coming in v2.0.6)
|
||||
|
||||
Every other tool in this space stops at "the deployment command succeeded." certctl is adding a step nobody else has: after deploying a certificate to a target, the agent connects back to the target's TLS endpoint and verifies the served certificate matches what was deployed, using SHA-256 fingerprint comparison.
|
||||
|
||||
A reload command can exit 0 while the certificate doesn't take effect — wrong virtual host, stale cache, config that validates but doesn't apply. certctl will catch this.
|
||||
|
||||
## How certctl Compares
|
||||
|
||||
### vs. CertKit
|
||||
|
||||
Closest competitor architecturally — agent-based, private key isolation (Keystore), multi-platform. certctl leads on issuer coverage (ACME + step-ca + Local CA + OpenSSL + EST vs. ACME-only), PKI compliance (CRL, OCSP, RFC 5280 revocation, immutable audit trail — all missing from CertKit today), policy engine (5 rule types vs. none), and network discovery (CIDR TLS scanning vs. none). certctl is source-available (BSL 1.1 → Apache 2.0) with no cert limit; CertKit is proprietary SaaS with a 3-cert free tier. Where CertKit leads: more deployment targets today (adds LiteSpeed, IIS, auto-detection), Windows support, Kubernetes, and polished SaaS onboarding.
|
||||
|
||||
### vs. KeyTalk
|
||||
|
||||
Commercial (proprietary) PKI platform from a Dutch company — on-prem appliance, cloud, or managed service. Broader cert type coverage (TLS, S/MIME, device auth, VPN) and DigiCert + SCEP integrations. No public documentation on policy engine, API surface, or audit capabilities. No free tier, no public pricing. certctl trades breadth of cert types for full transparency — source-available, public API spec, free community edition with no limits.
|
||||
|
||||
### vs. Enterprise Platforms (Venafi, Keyfactor)
|
||||
|
||||
Comprehensive solutions with decades of features — at $75K-$250K+/yr. certctl targets organizations that need 80% of those capabilities at 1% of the cost. The trade-off: no SSO/RBAC yet (coming in certctl Pro), no F5/IIS target connectors yet, no SLA-backed support.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# Clone and start with Docker Compose (includes demo data)
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
cd certctl/deploy
|
||||
docker compose up -d
|
||||
|
||||
# Open the dashboard
|
||||
open http://localhost:8443
|
||||
```
|
||||
|
||||
The demo seeds 15 certificates, 5 agents, 5 deployment targets, discovery data, network scan targets, and pending approval jobs so you can explore every feature immediately.
|
||||
|
||||
See the [Quickstart Guide](quickstart.md) for a full walkthrough.
|
||||
|
||||
## License
|
||||
|
||||
certctl is licensed under the [Business Source License 1.1](../LICENSE). The licensed work is free to use for any purpose other than offering a competing managed service. The license converts to Apache 2.0 on March 1, 2033.
|
||||
|
||||
The source is available, auditable, and self-hostable. You own your data, your keys, and your deployment.
|
||||
@@ -77,8 +77,8 @@ func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID strin
|
||||
func TestListNetworkScanTargets(t *testing.T) {
|
||||
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))
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+48
-1
@@ -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<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
|
||||
export const getDashboardSummary = () =>
|
||||
fetchJSON<DashboardSummary>(`${BASE}/stats/summary`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
</nav>
|
||||
|
||||
<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.5</span>
|
||||
{authRequired && (
|
||||
<button
|
||||
onClick={logout}
|
||||
|
||||
@@ -19,6 +19,10 @@ const statusStyles: Record<string, string> = {
|
||||
Online: 'badge-success',
|
||||
Offline: 'badge-danger',
|
||||
Stale: 'badge-warning',
|
||||
// Discovery statuses
|
||||
Unmanaged: 'badge-warning',
|
||||
Managed: 'badge-success',
|
||||
Dismissed: 'badge-neutral',
|
||||
// Notification statuses
|
||||
sent: 'badge-success',
|
||||
pending: 'badge-warning',
|
||||
|
||||
@@ -23,6 +23,8 @@ import AgentGroupsPage from './pages/AgentGroupsPage';
|
||||
import AuditPage from './pages/AuditPage';
|
||||
import ShortLivedPage from './pages/ShortLivedPage';
|
||||
import AgentFleetPage from './pages/AgentFleetPage';
|
||||
import DiscoveryPage from './pages/DiscoveryPage';
|
||||
import NetworkScanPage from './pages/NetworkScanPage';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -61,6 +63,8 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="agent-groups" element={<AgentGroupsPage />} />
|
||||
<Route path="audit" element={<AuditPage />} />
|
||||
<Route path="short-lived" element={<ShortLivedPage />} />
|
||||
<Route path="discovery" element={<DiscoveryPage />} />
|
||||
<Route path="network-scans" element={<NetworkScanPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -13,6 +13,9 @@ import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { daysUntil, expiryColor, formatDate } from '../api/utils';
|
||||
|
||||
// Convert PascalCase status like "RenewalInProgress" to "Renewal In Progress"
|
||||
const formatStatus = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
Active: '#10b981',
|
||||
Expiring: '#f59e0b',
|
||||
@@ -149,7 +152,7 @@ export default function DashboardPage() {
|
||||
outerRadius={90}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={({ name, value }) => `${name}: ${value}`}
|
||||
label={({ name, value }) => `${formatStatus(name || '')}: ${value}`}
|
||||
labelLine={false}
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
@@ -160,7 +163,7 @@ export default function DashboardPage() {
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
formatter={(value: string) => <span className="text-xs text-ink-muted">{value}</span>}
|
||||
formatter={(value: string) => <span className="text-xs text-ink-muted">{formatStatus(value)}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
<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() {
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [rejectingJob, setRejectingJob] = useState<Job | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
@@ -29,6 +68,21 @@ export default function JobsPage() {
|
||||
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>[] = [
|
||||
{
|
||||
key: 'id',
|
||||
@@ -53,14 +107,33 @@ export default function JobsPage() {
|
||||
key: 'actions',
|
||||
label: '',
|
||||
render: (j) => (
|
||||
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>
|
||||
) : null
|
||||
<div className="flex gap-2">
|
||||
{j.status === 'AwaitingApproval' && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); approveMutation.mutate(j.id); }}
|
||||
disabled={approveMutation.isPending}
|
||||
className="text-xs text-green-600 hover:text-green-700 font-medium"
|
||||
>
|
||||
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 (
|
||||
<>
|
||||
<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">
|
||||
<select
|
||||
value={statusFilter}
|
||||
@@ -76,6 +170,8 @@ export default function JobsPage() {
|
||||
>
|
||||
<option value="">All statuses</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="Completed">Completed</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" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rejectingJob && (
|
||||
<RejectModal
|
||||
job={rejectingJob}
|
||||
onClose={() => setRejectingJob(null)}
|
||||
onReject={(reason) => rejectMutation.mutate({ id: rejectingJob.id, reason })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user