From ea8337b9447966d1cbe5130b9c0dce074affc8c1 Mon Sep 17 00:00:00 2001 From: Draco-Lunaris-Echo Date: Fri, 5 Jun 2026 16:17:17 -0500 Subject: [PATCH] feat: add CRL health status schema and UI (PR 3 of 6) * feat: add CRL health status schema and UI (PR 3 of 6) * fix(lint): strict equality for crl_age_seconds --------- Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com> --- crates/pm-agent-client/src/types.rs | 10 ++ crates/pm-core/src/models.rs | 12 ++ crates/pm-web/src/routes/hosts.rs | 5 +- crates/pm-web/src/routes/status.rs | 43 ++++++ crates/pm-worker/Cargo.toml | 2 +- crates/pm-worker/src/health_poller.rs | 191 ++++++++++++++++---------- docs/REST_API.md | 20 +++ frontend/src/pages/DashboardPage.tsx | 52 +++++++ frontend/src/pages/HostDetailPage.tsx | 50 +++++++ frontend/src/pages/HostsPage.tsx | 17 ++- frontend/src/types/index.ts | 8 ++ migrations/021_crl_health_status.sql | 13 ++ 12 files changed, 345 insertions(+), 78 deletions(-) mode change 100755 => 100644 crates/pm-agent-client/src/types.rs mode change 100755 => 100644 crates/pm-web/src/routes/hosts.rs mode change 100755 => 100644 crates/pm-web/src/routes/status.rs create mode 100644 migrations/021_crl_health_status.sql diff --git a/crates/pm-agent-client/src/types.rs b/crates/pm-agent-client/src/types.rs old mode 100755 new mode 100644 index 5d67ee8..0ee4cba --- a/crates/pm-agent-client/src/types.rs +++ b/crates/pm-agent-client/src/types.rs @@ -57,6 +57,16 @@ pub struct HealthData { pub uptime_seconds: u64, /// Agent software version string. pub version: String, + /// CRL status reported by the agent: `"valid"`, `"expired"`, `"missing"`, `"invalid"`. + /// Absent for older agents that do not report CRL status. + #[serde(default)] + pub crl_status: Option, + /// Seconds since the agent's CRL was last refreshed. + #[serde(default)] + pub crl_age_seconds: Option, + /// When the agent's CRL expires / next update is due (ISO-8601). + #[serde(default)] + pub crl_next_update: Option, } // ============================================================ diff --git a/crates/pm-core/src/models.rs b/crates/pm-core/src/models.rs index 0d5698f..6454133 100644 --- a/crates/pm-core/src/models.rs +++ b/crates/pm-core/src/models.rs @@ -94,6 +94,15 @@ pub struct Host { pub notes: String, pub registered_at: DateTime, pub updated_at: DateTime, + /// CRL status reported by the agent: valid, expired, missing, invalid, or NULL for older agents. + #[serde(skip_serializing_if = "Option::is_none")] + pub crl_status: Option, + /// Seconds since the agent's CRL was last refreshed. + #[serde(skip_serializing_if = "Option::is_none")] + pub crl_age_seconds: Option, + /// When the agent's CRL expires / next update is due. + #[serde(skip_serializing_if = "Option::is_none")] + pub crl_next_update: Option>, } /// Payload for registering a new host. @@ -129,6 +138,9 @@ pub struct HostSummary { pub patches_missing: i32, pub health_check_status: Option, pub registered_at: DateTime, + /// CRL status reported by the agent: valid, expired, missing, invalid, or NULL for older agents. + #[serde(skip_serializing_if = "Option::is_none")] + pub crl_status: Option, } // ============================================================ diff --git a/crates/pm-web/src/routes/hosts.rs b/crates/pm-web/src/routes/hosts.rs old mode 100755 new mode 100644 index d9341d6..bb13de1 --- a/crates/pm-web/src/routes/hosts.rs +++ b/crates/pm-web/src/routes/hosts.rs @@ -133,6 +133,7 @@ async fn list_hosts( ELSE 'all_healthy' END AS health_check_status, h.registered_at + h.crl_status FROM hosts h LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id ORDER BY h.fqdn @@ -166,6 +167,7 @@ async fn list_hosts( ELSE 'all_healthy' END AS health_check_status, h.registered_at + h.crl_status FROM hosts h LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id WHERE @@ -319,7 +321,8 @@ async fn get_host( SELECT id, fqdn, host(ip_address)::text AS ip_address, display_name, os_family, os_name, arch, agent_version, health_status, last_health_at, last_patch_at, agent_port, notes, - registered_at, updated_at + registered_at, updated_at, + crl_status, crl_age_seconds, crl_next_update FROM hosts WHERE id = $1 ) h "#, diff --git a/crates/pm-web/src/routes/status.rs b/crates/pm-web/src/routes/status.rs old mode 100755 new mode 100644 index bd5db21..5caa853 --- a/crates/pm-web/src/routes/status.rs +++ b/crates/pm-web/src/routes/status.rs @@ -24,6 +24,16 @@ pub struct FleetStatus { pub total_pending_patches: i64, pub hosts_requiring_reboot: i64, pub compliance_pct: f64, + /// Hosts with CRL status 'valid'. + pub crl_valid: i64, + /// Hosts with CRL status 'expired'. + pub crl_expired: i64, + /// Hosts with CRL status 'missing' (agent reports missing CRL). + pub crl_missing: i64, + /// Hosts with CRL status 'invalid' (security event — needs immediate attention). + pub crl_invalid: i64, + /// Hosts not reporting CRL status (older agents or no data yet). + pub crl_not_reporting: i64, } // ── GET /api/v1/status/fleet ────────────────────────────────────────────────── @@ -132,6 +142,34 @@ pub async fn fleet_status( // Round to one decimal place. let compliance_pct = (compliance_pct * 10.0).round() / 10.0; + // ── 5. CRL status counts ──────────────────────────────────────────────── + let (crl_valid, crl_expired, crl_missing, crl_invalid, crl_not_reporting): ( + i64, + i64, + i64, + i64, + i64, + ) = sqlx::query_as( + r#" + SELECT + COALESCE(SUM(CASE WHEN crl_status = 'valid' THEN 1 END), 0), + COALESCE(SUM(CASE WHEN crl_status = 'expired' THEN 1 END), 0), + COALESCE(SUM(CASE WHEN crl_status = 'missing' THEN 1 END), 0), + COALESCE(SUM(CASE WHEN crl_status = 'invalid' THEN 1 END), 0), + COALESCE(SUM(CASE WHEN crl_status IS NULL THEN 1 END), 0) + FROM hosts + "#, + ) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "fleet_status: failed to query CRL status counts"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), + ) + })?; + Ok(Json(FleetStatus { total_hosts, healthy, @@ -141,5 +179,10 @@ pub async fn fleet_status( total_pending_patches, hosts_requiring_reboot, compliance_pct, + crl_valid, + crl_expired, + crl_missing, + crl_invalid, + crl_not_reporting, })) } diff --git a/crates/pm-worker/Cargo.toml b/crates/pm-worker/Cargo.toml index 9885895..6a45b24 100644 --- a/crates/pm-worker/Cargo.toml +++ b/crates/pm-worker/Cargo.toml @@ -15,13 +15,13 @@ pm-agent-client = { path = "../pm-agent-client" } tokio = { workspace = true, features = ["full"] } sqlx = { workspace = true } serde = { workspace = true } +chrono = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } uuid = { workspace = true } -chrono = { workspace = true } futures = { workspace = true } rustls = { workspace = true } tokio-rustls = { version = "0.26" } diff --git a/crates/pm-worker/src/health_poller.rs b/crates/pm-worker/src/health_poller.rs index 5479a6b..dca04b9 100644 --- a/crates/pm-worker/src/health_poller.rs +++ b/crates/pm-worker/src/health_poller.rs @@ -116,8 +116,9 @@ pub async fn run_health_poller(pool: PgPool, config: Arc) { /// Poll a single host, persist the result, and return the determined status. /// -/// Also updates `agent_version` from the health response and -/// `os_family`/`os_name`/`arch` from the `/system/info` endpoint when available. +/// Also updates `agent_version` from the health response, +/// `os_family`/`os_name`/`arch` from the `/system/info` endpoint when available, +/// and CRL status fields from the health response when reported by the agent. async fn poll_host_health( pool: PgPool, host: HostRow, @@ -125,79 +126,107 @@ async fn poll_host_health( client_key: &[u8], ca_cert: &[u8], ) -> HostHealthStatus { - // Determine status, payload, agent version, and optional system info. - let (status, payload, agent_version, sys_info) = match AgentClient::new( - &host.ip_address, - host.agent_port as u16, - client_cert, - client_key, - ca_cert, - ) { - Err(e) => { - tracing::warn!( - host_id = %host.id, - error = %e, - "Health poller: failed to build AgentClient" - ); - ( - HostHealthStatus::Unreachable, - serde_json::Value::Object(Default::default()), - None, - None, - ) - }, - Ok(client) => { - let (status, payload, version) = match client.health().await { - Ok(data) => { - let payload = serde_json::to_value(&data).unwrap_or_default(); - (HostHealthStatus::Healthy, payload, Some(data.version)) - }, - Err(AgentClientError::Timeout) => { - tracing::warn!(host_id = %host.id, "Health poller: agent timed out"); - ( - HostHealthStatus::Unreachable, - serde_json::Value::Object(Default::default()), - None, - ) - }, - Err(AgentClientError::Connect(_)) => { - tracing::warn!(host_id = %host.id, "Health poller: agent connection refused"); - ( - HostHealthStatus::Unreachable, - serde_json::Value::Object(Default::default()), - None, - ) - }, - Err(e) => { - tracing::warn!(host_id = %host.id, error = %e, "Health poller: agent error"); - ( - HostHealthStatus::Degraded, - serde_json::Value::Object(Default::default()), - None, - ) - }, - }; - - // Try to fetch system info for OS/arch details (best-effort). - let sys_info = if status != HostHealthStatus::Unreachable { - match client.system_info().await { - Ok(info) => Some(info), - Err(e) => { - tracing::debug!( - host_id = %host.id, - error = %e, - "Health poller: failed to get system info (non-fatal)" - ); - None + // Determine status, payload, agent version, optional system info, and CRL fields. + let (status, payload, agent_version, sys_info, crl_status, crl_age_seconds, crl_next_update) = + match AgentClient::new( + &host.ip_address, + host.agent_port as u16, + client_cert, + client_key, + ca_cert, + ) { + Err(e) => { + tracing::warn!( + host_id = %host.id, + error = %e, + "Health poller: failed to build AgentClient" + ); + ( + HostHealthStatus::Unreachable, + serde_json::Value::Object(Default::default()), + None, + None, + None, + None, + None, + ) + }, + Ok(client) => { + let (status, payload, version, crl_status, crl_age, crl_next) = match client + .health() + .await + { + Ok(data) => { + let payload = serde_json::to_value(&data).unwrap_or_default(); + let crl_status = data.crl_status.clone(); + let crl_age = data.crl_age_seconds; + let crl_next = data.crl_next_update.clone(); + ( + HostHealthStatus::Healthy, + payload, + Some(data.version), + crl_status, + crl_age, + crl_next, + ) }, - } - } else { - None - }; + Err(AgentClientError::Timeout) => { + tracing::warn!(host_id = %host.id, "Health poller: agent timed out"); + ( + HostHealthStatus::Unreachable, + serde_json::Value::Object(Default::default()), + None, + None, + None, + None, + ) + }, + Err(AgentClientError::Connect(_)) => { + tracing::warn!(host_id = %host.id, "Health poller: agent connection refused"); + ( + HostHealthStatus::Unreachable, + serde_json::Value::Object(Default::default()), + None, + None, + None, + None, + ) + }, + Err(e) => { + tracing::warn!(host_id = %host.id, error = %e, "Health poller: agent error"); + ( + HostHealthStatus::Degraded, + serde_json::Value::Object(Default::default()), + None, + None, + None, + None, + ) + }, + }; - (status, payload, version, sys_info) - }, - }; + // Try to fetch system info for OS/arch details (best-effort). + let sys_info = if status != HostHealthStatus::Unreachable { + match client.system_info().await { + Ok(info) => Some(info), + Err(e) => { + tracing::debug!( + host_id = %host.id, + error = %e, + "Health poller: failed to get system info (non-fatal)" + ); + None + }, + } + } else { + None + }; + + ( + status, payload, version, sys_info, crl_status, crl_age, crl_next, + ) + }, + }; // Insert into host_health_data. if let Err(e) = sqlx::query( @@ -220,7 +249,13 @@ async fn poll_host_health( .as_ref() .map(|i| format!("{} {}", i.os, i.os_version)); - // Update hosts table with health status, agent version, and OS details. + // Parse CRL next_update from ISO-8601 string to DateTime if present. + let crl_next_update_dt: Option> = crl_next_update + .as_ref() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.to_utc()); + + // Update hosts table with health status, agent version, OS details, and CRL fields. // COALESCE preserves existing values when new data is unavailable. if let Err(e) = sqlx::query( r#" @@ -229,7 +264,10 @@ async fn poll_host_health( agent_version = COALESCE($3, agent_version), os_family = COALESCE($4, os_family), os_name = COALESCE($5, os_name), - arch = COALESCE($6, arch) + arch = COALESCE($6, arch), + crl_status = COALESCE($7, crl_status), + crl_age_seconds = COALESCE($8, crl_age_seconds), + crl_next_update = COALESCE($9, crl_next_update) WHERE id = $1 "#, ) @@ -239,6 +277,9 @@ async fn poll_host_health( .bind(sys_info.as_ref().map(|i| i.os.as_str())) .bind(os_name_from_sysinfo) .bind(sys_info.as_ref().map(|i| i.architecture.as_str())) + .bind(&crl_status) + .bind(crl_age_seconds) + .bind(crl_next_update_dt) .execute(&pool) .await { diff --git a/docs/REST_API.md b/docs/REST_API.md index a21425f..2c99489 100644 --- a/docs/REST_API.md +++ b/docs/REST_API.md @@ -141,6 +141,26 @@ Each job summary object includes: | GET | `/reports/vulnerability` | Generate vulnerability exposure report | | GET | `/reports/audit` | Generate audit trail report | +### CRL Status Fields + +Host list and detail responses include CRL (Certificate Revocation List) status fields: + +| Field | Type | Description | +|-------|------|-------------| +| `crl_status` | `string?` | CRL status: `valid`, `expired`, `missing`, `invalid`, or `null` (older agents) | +| `crl_age_seconds` | `integer?` | Seconds since the agent's CRL was last refreshed | +| `crl_next_update` | `datetime?` | When the agent's CRL expires (ISO-8601) | + +Fleet status response includes CRL counts: + +| Field | Type | Description | +|-------|------|-------------| +| `crl_valid` | `integer` | Hosts with CRL status `valid` | +| `crl_expired` | `integer` | Hosts with CRL status `expired` | +| `crl_missing` | `integer` | Hosts with CRL status `missing` | +| `crl_invalid` | `integer` | Hosts with CRL status `invalid` (security event) | +| `crl_not_reporting` | `integer` | Hosts not reporting CRL status (older agents) | + ## 14. Real-Time Updates (WebSocket) | Method | Endpoint | Description | |--------|----------|-------------| diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 14b2456..71d0449 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -22,6 +22,7 @@ import { RestartAlt, Refresh as RefreshIcon, Security as SecurityIcon, + VerifiedUser as VerifiedUserIcon, } from '@mui/icons-material' import { fleetApi, certsApi } from '../api/client' import type { FleetStatus } from '../types' @@ -237,6 +238,57 @@ export default function DashboardPage() { + + {/* ── Row 4: CRL Status ── */} + + + + + + CRL Status + + + + + + + {status.crl_valid} + + Valid + + + + + + {status.crl_expired} + + Expired + + + + + + {status.crl_missing} + + Missing + + + + + + {status.crl_invalid} + + Invalid + + + + {status.crl_not_reporting > 0 && ( + + {status.crl_not_reporting} host{status.crl_not_reporting !== 1 ? 's' : ''} not reporting CRL status + + )} + + )} diff --git a/frontend/src/pages/HostDetailPage.tsx b/frontend/src/pages/HostDetailPage.tsx index 6c4fea5..a6dea79 100644 --- a/frontend/src/pages/HostDetailPage.tsx +++ b/frontend/src/pages/HostDetailPage.tsx @@ -46,6 +46,9 @@ import { Schedule as ScheduleIcon, VpnKey as VpnKeyIcon, ContentCopy as CopyIcon, + VerifiedUser as VerifiedUserIcon, + Security as SecurityIcon, + WarningAmber as WarningAmberIcon, } from '@mui/icons-material' import { apiClient, hostsApi, maintenanceWindowsApi, healthChecksApi, certsApi } from '../api/client' import { useAuthStore } from '../store/authStore' @@ -1035,6 +1038,53 @@ export default function HostDetailPage() { + {/* ── CRL Status ─────────────────────────────────────────────────── */} + + + + CRL Status + + + {host?.crl_status === undefined || host?.crl_status === null ? ( + + CRL status not available (agent version does not support CRL) + + ) : ( + + + Status + {host.crl_status === 'valid' ? ( + } label="Valid" color="success" size="small" /> + ) : host.crl_status === 'expired' ? ( + } label="Expired" color="warning" size="small" /> + ) : host.crl_status === 'missing' ? ( + } label="Missing" color="warning" size="small" /> + ) : host.crl_status === 'invalid' ? ( + } label="Invalid" color="error" size="small" /> + ) : ( + {String(host.crl_status)} + )} + + + CRL Age + + {host.crl_age_seconds !== null + ? (() => { const s = Number(host.crl_age_seconds); return s < 3600 ? `${Math.round(s / 60)} minutes ago` : s < 86400 ? `${Math.round(s / 3600)} hours ago` : `${Math.round(s / 86400)} days ago`; })() + : '—'} + + + + Next Update + + {host.crl_next_update + ? new Date(host.crl_next_update as string).toLocaleString() + : '—'} + + + + )} + + {/* ── Maintenance Windows ──────────────────────────────────────────── */} diff --git a/frontend/src/pages/HostsPage.tsx b/frontend/src/pages/HostsPage.tsx index e43a4ae..c8d819a 100644 --- a/frontend/src/pages/HostsPage.tsx +++ b/frontend/src/pages/HostsPage.tsx @@ -5,7 +5,7 @@ import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, TextField, Toolbar, Tooltip, Typography, } from '@mui/material' -import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon, Pending as PendingIcon, GppMaybe as GppMaybeIcon, CheckCircleOutline as CheckCircleOutlineIcon, WarningAmber as WarningAmberIcon } from '@mui/icons-material' +import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon, Pending as PendingIcon, GppMaybe as GppMaybeIcon, CheckCircleOutline as CheckCircleOutlineIcon, WarningAmber as WarningAmberIcon, VerifiedUser as VerifiedUserIcon, Security as SecurityIcon } from '@mui/icons-material' import { useNavigate } from 'react-router-dom' import { apiClient, hostsApi, enrollmentApi } from '../api/client' import { useAuthStore } from '../store/authStore' @@ -182,6 +182,7 @@ export default function HostsPage() { OS Health Checks + CRL Agent {canWrite && Actions} @@ -201,6 +202,7 @@ export default function HostsPage() { {(req.os_details['name'] as string) ?? 'Unknown'} + {canWrite && e.stopPropagation()}> @@ -240,6 +242,19 @@ export default function HostsPage() { )} + + {h.crl_status === 'valid' ? ( + + ) : h.crl_status === 'expired' ? ( + + ) : h.crl_status === 'missing' ? ( + + ) : h.crl_status === 'invalid' ? ( + + ) : ( + + )} + {h.agent_version ?? '—'} {canWrite && e.stopPropagation()}> diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0360095..7fb618e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -27,6 +27,9 @@ export interface Host { patches_missing: number registered_at: string health_check_status?: 'all_healthy' | 'some_unhealthy' | 'none' + crl_status?: 'valid' | 'expired' | 'missing' | 'invalid' + crl_age_seconds?: number + crl_next_update?: string } export interface CreateHostRequest { @@ -98,6 +101,11 @@ export interface FleetStatus { total_pending_patches: number hosts_requiring_reboot: number compliance_pct: number + crl_valid: number + crl_expired: number + crl_missing: number + crl_invalid: number + crl_not_reporting: number } export interface PatchInfo { diff --git a/migrations/021_crl_health_status.sql b/migrations/021_crl_health_status.sql new file mode 100644 index 0000000..8523fe3 --- /dev/null +++ b/migrations/021_crl_health_status.sql @@ -0,0 +1,13 @@ +-- 021_crl_health_status.sql +-- Add CRL health status columns to the hosts table for tracking +-- Certificate Revocation List status reported by agents. + +-- CRL status values: 'valid', 'expired', 'missing', 'invalid', or NULL +-- (NULL = older agent that does not report CRL status) +ALTER TABLE hosts ADD COLUMN IF NOT EXISTS crl_status TEXT; + +-- Seconds since the agent's CRL was last refreshed (NULL if not reported) +ALTER TABLE hosts ADD COLUMN IF NOT EXISTS crl_age_seconds BIGINT; + +-- When the agent's CRL expires / next update is due (NULL if not reported) +ALTER TABLE hosts ADD COLUMN IF NOT EXISTS crl_next_update TIMESTAMPTZ;