From 5914c9b297c845867ae8a15f3477106e4afd8c46 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 6 May 2026 01:29:25 +0000 Subject: [PATCH] feat: all-inclusive agent cert bundle - server cert + client cert + CA root in one issuance --- crates/pm-ca/src/ca.rs | 140 ++++++++++++++++++++--- crates/pm-web/src/routes/ca.rs | 93 ++++++++++------ frontend/src/pages/CertificatesPage.tsx | 141 +++++++++++------------- frontend/src/pages/HostDetailPage.tsx | 141 +++++++++++------------- frontend/src/types/index.ts | 3 + 5 files changed, 313 insertions(+), 205 deletions(-) diff --git a/crates/pm-ca/src/ca.rs b/crates/pm-ca/src/ca.rs index fe88f2c..6da4983 100644 --- a/crates/pm-ca/src/ca.rs +++ b/crates/pm-ca/src/ca.rs @@ -1,11 +1,12 @@ //! Internal Certificate Authority for Linux Patch Manager. //! -//! Issues and renews mTLS client certificates for agent communication. -//! Uses rcgen (ECDSA P-256) for all certificate generation. -//! CA key and certificate are stored on disk under `base_dir` -//! (default: /etc/patch-manager/ca/). -//! Certificate metadata is persisted in the `certificates` PostgreSQL table. +//! Issues and renews mTLS client certificates and agent server certificates +//! for agent communication. Uses rcgen (ECDSA P-256) for all certificate +//! generation. CA key and certificate are stored on disk under `base_dir` +//! (default: /etc/patch-manager/ca/). Certificate metadata is persisted in +//! the `certificates` PostgreSQL table. +use std::net::IpAddr; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; @@ -26,19 +27,26 @@ use uuid::Uuid; /// Returned by [`CertAuthority::issue_client_cert`] and [`CertAuthority::renew_cert`]. /// -/// The private key is intentionally **not** stored in the database. +/// The private keys are intentionally **not** stored in the database. #[derive(Debug, Clone)] pub struct IssuedCert { - /// PEM-encoded public certificate. + /// PEM-encoded client certificate (mTLS). pub cert_pem: String, - /// PEM-encoded private key (PKCS#8). + /// PEM-encoded client private key (PKCS#8). pub key_pem: String, - /// Hex-encoded 16-byte random serial number. + /// Hex-encoded 16-byte random serial number (client cert). pub serial_number: String, /// Certificate expiry timestamp (UTC). pub expires_at: DateTime, + /// PEM-encoded agent server certificate (for TLS listener). + pub server_cert_pem: String, + /// PEM-encoded agent server private key (PKCS#8). + pub server_key_pem: String, + /// Hex-encoded serial number of the server certificate. + pub server_serial_number: String, + /// PEM-encoded CA root certificate. + pub ca_root_pem: String, } - // --------------------------------------------------------------------------- // CertAuthority // --------------------------------------------------------------------------- @@ -240,15 +248,19 @@ impl CertAuthority { /// * Key usage: Digital Signature /// * Extended key usage: Client Authentication /// - /// The certificate PEM is stored in `certificates`. - /// The private key is returned to the caller **only** and never persisted. + /// Also issues a server certificate for the agent's TLS listener + /// (see [`issue_server_cert`]). + /// + /// The certificate PEMs are stored in `certificates`. + /// The private keys are returned to the caller **only** and never persisted. pub async fn issue_client_cert( &self, host_id: Uuid, hostname: &str, + ip_address: &str, db: &PgPool, ) -> Result { - tracing::info!(host_id = %host_id, hostname, "Issuing mTLS client certificate"); + tracing::info!(host_id = %host_id, hostname, ip_address, "Issuing mTLS client certificate"); let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).context("generate client key pair")?; @@ -288,14 +300,99 @@ impl CertAuthority { "Client certificate issued successfully" ); + // Also issue a server certificate for the agent's TLS listener. + let (server_cert_pem, server_key_pem, server_serial_number, _server_expires_at) = + self.issue_server_cert(host_id, hostname, ip_address, db).await?; + Ok(IssuedCert { cert_pem, key_pem, serial_number: serial_hex, expires_at, + server_cert_pem, + server_key_pem, + server_serial_number, + ca_root_pem: self.root_cert_pem().to_owned(), }) } + /// Issue a one-year server certificate for a managed host's agent TLS listener. + /// + /// * Subject: `CN=-server` + /// * Key usage: Digital Signature + /// * Extended key usage: Server Authentication + /// * SANs: DNS `` + IP `` (if valid) + /// + /// The certificate PEM is stored in `certificates` with common_name + /// `{hostname}-server` to distinguish from client certs. + /// The private key is returned to the caller **only** and never persisted. + /// + /// Returns `(cert_pem, key_pem, serial_number, expires_at)`. + pub async fn issue_server_cert( + &self, + host_id: Uuid, + hostname: &str, + ip_address: &str, + db: &PgPool, + ) -> Result<(String, String, String, DateTime)> { + tracing::info!(host_id = %host_id, hostname, ip_address, "Issuing agent server certificate"); + + let key = + KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).context("generate server key pair")?; + + let server_cn = format!("{hostname}-server"); + let (mut params, serial_hex, expires_at) = base_params(&server_cn, 365)?; + params.is_ca = IsCa::ExplicitNoCa; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + + // Build SANs: DNS hostname + optional IP address + let mut sans = vec![SanType::DnsName( + Ia5String::try_from(hostname.to_owned()).context("hostname is not valid IA5")?, + )]; + if let Ok(ip) = ip_address.parse::() { + sans.push(SanType::IpAddress(ip)); + } else { + tracing::warn!( + ip_address, + "Could not parse IP address for server cert SANs, skipping IP SAN" + ); + } + params.subject_alt_names = sans; + + let (ca_key, ca_cert) = self.ca_objects()?; + let cert = params + .signed_by(&key, &ca_cert, &ca_key) + .context("sign server cert with CA")?; + + let cert_pem = cert.pem(); + let key_pem = key.serialize_pem(); + + sqlx::query( + "INSERT INTO certificates \ + (host_id, serial_number, common_name, status, expires_at, cert_pem) \ + VALUES ($1, $2, $3, 'active'::cert_status, $4, $5)", + ) + .bind(host_id) + .bind(&serial_hex) + .bind(&server_cn) + .bind(expires_at) + .bind(&cert_pem) + .execute(db) + .await + .context("insert server cert into database")?; + + tracing::info!( + host_id = %host_id, + hostname, + serial = %serial_hex, + expires_at = %expires_at, + "Server certificate issued successfully" + ); + + Ok((cert_pem, key_pem, serial_hex, expires_at)) + } + /// Revoke a certificate by database ID. /// /// Sets `status = 'revoked'` and `revoked_at = NOW()` in the `certificates` table. @@ -323,6 +420,9 @@ impl CertAuthority { /// Renew a certificate: revoke the existing cert and issue a new one with /// the same `host_id` and `common_name`. + /// + /// Also issues a new server certificate and populates all `IssuedCert` fields. + /// The host's IP address is looked up from the database for server cert SANs. pub async fn renew_cert(&self, cert_id: Uuid, db: &PgPool) -> Result { tracing::info!(cert_id = %cert_id, "Renewing certificate"); @@ -338,15 +438,25 @@ impl CertAuthority { .context("certificate has no host_id (cannot renew root CA)")?; let common_name: String = row.try_get("common_name").context("fetch common_name")?; + // Look up the host's IP address for the server cert SANs. + let ip_address: String = + sqlx::query_scalar("SELECT ip_address::text FROM hosts WHERE id = $1") + .bind(host_id) + .fetch_one(db) + .await + .context("fetch host IP address for renewal")?; + // Revoke the old cert first. self.revoke_cert(cert_id, db).await?; // Issue a fresh cert with the same CN. - let issued = self.issue_client_cert(host_id, &common_name, db).await?; + let issued = self + .issue_client_cert(host_id, &common_name, &ip_address, db) + .await?; tracing::info!( old_cert_id = %cert_id, - new_serial = %issued.serial_number, + new_serial = %issued.serial_number, "Certificate renewed" ); Ok(issued) diff --git a/crates/pm-web/src/routes/ca.rs b/crates/pm-web/src/routes/ca.rs index 545022c..76a837c 100644 --- a/crates/pm-web/src/routes/ca.rs +++ b/crates/pm-web/src/routes/ca.rs @@ -26,6 +26,7 @@ use pm_auth::rbac::AuthUser; use pm_core::audit::{log_event, AuditAction}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use sqlx::Row; use uuid::Uuid; use crate::AppState; @@ -123,6 +124,21 @@ fn db_error(e: sqlx::Error) -> (StatusCode, Json) { ) } +// ── Helper: build the full IssuedCert JSON response ────────────────────────── + +fn issued_cert_json(issued: &pm_ca::IssuedCert) -> Value { + json!({ + "cert_pem": issued.cert_pem, + "key_pem": issued.key_pem, + "serial_number": issued.serial_number, + "expires_at": issued.expires_at, + "server_cert_pem": issued.server_cert_pem, + "server_key_pem": issued.server_key_pem, + "server_serial_number": issued.server_serial_number, + "ca_root_pem": issued.ca_root_pem, + }) +} + // ── GET /api/v1/ca/root.crt ─────────────────────────────────────────────────── /// Download the root CA certificate as a PEM file. @@ -231,6 +247,7 @@ async fn download_client_cert( FROM certificates WHERE host_id = $1 AND status = 'active'::cert_status + AND common_name NOT LIKE '%-server' ORDER BY issued_at DESC LIMIT 1"#, ) @@ -272,8 +289,8 @@ async fn download_client_cert( // ── POST /api/v1/hosts/:host_id/certificates ───────────────────────────────── -/// Issue a new mTLS client certificate for a host. -/// **The private key is returned only once — the caller must save it.** +/// Issue a new mTLS client certificate (and server certificate) for a host. +/// **The private keys are returned only once — the caller must save them.** async fn issue_client_cert( State(state): State, auth: AuthUser, @@ -282,9 +299,26 @@ async fn issue_client_cert( ) -> Result, (StatusCode, Json)> { require_admin(&auth)?; + // Look up the host's IP address from the database. + let ip_address: String = sqlx::query_scalar("SELECT ip_address::text FROM hosts WHERE id = $1") + .bind(host_id) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, %host_id, "Failed to fetch host IP address"); + if e.to_string().contains("no rows") { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": { "code": "not_found", "message": "Host not found" } })), + ) + } else { + db_error(e) + } + })?; + let issued = state .ca - .issue_client_cert(host_id, &req.hostname, &state.db) + .issue_client_cert(host_id, &req.hostname, &ip_address, &state.db) .await .map_err(|e| { tracing::error!(error = %e, %host_id, hostname = %req.hostname, @@ -302,19 +336,13 @@ async fn issue_client_cert( Some(&auth.username), Some("certificate"), Some(&host_id.to_string()), - json!({ "hostname": req.hostname, "serial_number": issued.serial_number }), + json!({ "hostname": req.hostname, "serial_number": issued.serial_number, "server_serial_number": issued.server_serial_number }), None, None, ) .await; - Ok(Json(json!({ - "cert_pem": issued.cert_pem, - "key_pem": issued.key_pem, - "serial_number": issued.serial_number, - "expires_at": issued.expires_at, - "ca_root_pem": state.ca.root_cert_pem(), - }))) + Ok(Json(issued_cert_json(&issued))) } // ── POST /api/v1/certificates/:cert_id/renew ───────────────────────────────── @@ -352,25 +380,19 @@ async fn renew_cert( Some(&auth.username), Some("certificate"), Some(&cert_id.to_string()), - json!({ "serial_number": issued.serial_number }), + json!({ "serial_number": issued.serial_number, "server_serial_number": issued.server_serial_number }), None, None, ) .await; - Ok(Json(json!({ - "cert_pem": issued.cert_pem, - "key_pem": issued.key_pem, - "serial_number": issued.serial_number, - "expires_at": issued.expires_at, - "ca_root_pem": state.ca.root_cert_pem(), - }))) + Ok(Json(issued_cert_json(&issued))) } // ── POST /api/v1/hosts/:host_id/certificates/reissue ──────────────────────── -/// Revoke ALL active certificates for a host and issue a new one. -/// The private key is returned only once — the caller must save it. +/// Revoke ALL active certificates for a host and issue new ones. +/// The private keys are returned only once — the caller must save them. async fn reissue_host_cert( State(state): State, auth: AuthUser, @@ -378,13 +400,13 @@ async fn reissue_host_cert( ) -> Result, (StatusCode, Json)> { require_admin(&auth)?; - // Look up the host's FQDN for the new certificate CN. - let fqdn: String = sqlx::query_scalar("SELECT fqdn FROM hosts WHERE id = $1") + // Look up the host's FQDN and IP address for the new certificate CN and SANs. + let row = sqlx::query("SELECT fqdn, ip_address::text AS ip_address FROM hosts WHERE id = $1") .bind(host_id) .fetch_one(&state.db) .await .map_err(|e| { - tracing::error!(error = %e, %host_id, "Failed to fetch host FQDN"); + tracing::error!(error = %e, %host_id, "Failed to fetch host FQDN/IP"); if e.to_string().contains("no rows") { ( StatusCode::NOT_FOUND, @@ -395,6 +417,15 @@ async fn reissue_host_cert( } })?; + let fqdn: String = row.try_get("fqdn").map_err(|e| { + tracing::error!(error = %e, %host_id, "Failed to read fqdn"); + db_error(e) + })?; + let ip_address: String = row.try_get("ip_address").map_err(|e| { + tracing::error!(error = %e, %host_id, "Failed to read ip_address"); + db_error(e) + })?; + // Revoke all active certificates for this host. let revoked = sqlx::query( "UPDATE certificates SET status = 'revoked'::cert_status, revoked_at = NOW() \ @@ -407,10 +438,10 @@ async fn reissue_host_cert( tracing::info!(%host_id, rows_revoked = revoked.rows_affected(), "Revoked all active certs for host"); - // Issue a new certificate using the host's FQDN. + // Issue a new certificate bundle using the host's FQDN and IP. let issued = state .ca - .issue_client_cert(host_id, &fqdn, &state.db) + .issue_client_cert(host_id, &fqdn, &ip_address, &state.db) .await .map_err(|e| { tracing::error!(error = %e, %host_id, "Failed to issue new cert during reissue"); @@ -427,19 +458,13 @@ async fn reissue_host_cert( Some(&auth.username), Some("certificate"), Some(&host_id.to_string()), - json!({ "hostname": &fqdn, "serial_number": issued.serial_number, "rows_revoked": revoked.rows_affected() }), + json!({ "hostname": &fqdn, "serial_number": issued.serial_number, "server_serial_number": issued.server_serial_number, "rows_revoked": revoked.rows_affected() }), None, None, ) .await; - Ok(Json(json!({ - "cert_pem": issued.cert_pem, - "key_pem": issued.key_pem, - "serial_number": issued.serial_number, - "expires_at": issued.expires_at, - "ca_root_pem": state.ca.root_cert_pem(), - }))) + Ok(Json(issued_cert_json(&issued))) } // ── DELETE /api/v1/certificates/:cert_id ───────────────────────────────────── diff --git a/frontend/src/pages/CertificatesPage.tsx b/frontend/src/pages/CertificatesPage.tsx index 1a4918e..0fb5632 100644 --- a/frontend/src/pages/CertificatesPage.tsx +++ b/frontend/src/pages/CertificatesPage.tsx @@ -149,10 +149,10 @@ interface KeyDisplayDialogProps { } function KeyDisplayDialog({ open, cert, hostname, onClose }: KeyDisplayDialogProps) { - const [copiedField, setCopiedField] = useState<'cert' | 'key' | 'ca' | null>(null) + const [copiedField, setCopiedField] = useState<'ca' | 'cert' | 'key' | 'server-cert' | 'server-key' | null>(null) const [downloading, setDownloading] = useState(false) - const handleCopy = async (text: string, field: 'cert' | 'key' | 'ca') => { + const handleCopy = async (text: string, field: 'ca' | 'cert' | 'key' | 'server-cert' | 'server-key') => { await navigator.clipboard.writeText(text) setCopiedField(field) setTimeout(() => setCopiedField(null), 2000) @@ -166,6 +166,8 @@ function KeyDisplayDialog({ open, cert, hostname, onClose }: KeyDisplayDialogPro zip.file('ca.crt', cert.ca_root_pem) zip.file('client.crt', cert.cert_pem) zip.file('client.key', cert.key_pem) + zip.file('server.crt', cert.server_cert_pem) + zip.file('server.key', cert.server_key_pem) const blob = await zip.generateAsync({ type: 'blob' }) downloadBlob(blob, `${hostname || 'host'}-certs.zip`) } finally { @@ -173,112 +175,95 @@ function KeyDisplayDialog({ open, cert, hostname, onClose }: KeyDisplayDialogPro } } + const preStyle = { + p: 2, + bgcolor: 'grey.100', + borderRadius: 1, + fontSize: 12, + overflow: 'auto', + maxHeight: 150, + fontFamily: 'monospace' as const, + whiteSpace: 'pre-wrap' as const, + wordBreak: 'break-all' as const, + } + return ( - Certificate Issued — Save Your Private Key + Agent Certificate Bundle Issued — Save Your Private Keys - This private key will NOT be shown again. Copy and store it securely + Private keys will NOT be shown again. Copy and store them securely before closing this dialog. {cert && ( <> - Serial: {cert.serial_number}  |  Expires: {fmtDate(cert.expires_at)} + Client Serial: {cert.serial_number}  |  Server Serial: {cert.server_serial_number}  |  Expires: {fmtDate(cert.expires_at)} + + {/* CA Root Certificate */} CA Root Certificate (ca.crt) - - - {cert.ca_root_pem} - + {cert.ca_root_pem} + + {/* Client Certificate (mTLS) */} - Certificate (client.crt) - - - - {cert.cert_pem} - + {cert.cert_pem} + + {/* Client Private Key */} - Private Key (client.key) - - - - {cert.key_pem} + {cert.key_pem} + + + {/* Server Certificate (Agent TLS) */} + + + Server Certificate — Agent TLS (server.crt) + + + + {cert.server_cert_pem} + + + {/* Server Private Key */} + + + Server Private Key (server.key) + + + + + {cert.server_key_pem} )} @@ -291,7 +276,7 @@ function KeyDisplayDialog({ open, cert, hostname, onClose }: KeyDisplayDialogPro > {downloading ? : 'Download Bundle (.zip)'} - + ) diff --git a/frontend/src/pages/HostDetailPage.tsx b/frontend/src/pages/HostDetailPage.tsx index 3c5fad3..472f471 100644 --- a/frontend/src/pages/HostDetailPage.tsx +++ b/frontend/src/pages/HostDetailPage.tsx @@ -320,10 +320,10 @@ interface KeyDisplayDialogProps { } function KeyDisplayDialog({ open, cert, hostname, onClose }: KeyDisplayDialogProps) { - const [copiedField, setCopiedField] = useState<'cert' | 'key' | 'ca' | null>(null) + const [copiedField, setCopiedField] = useState<'ca' | 'cert' | 'key' | 'server-cert' | 'server-key' | null>(null) const [downloading, setDownloading] = useState(false) - const handleCopy = async (text: string, field: 'cert' | 'key' | 'ca') => { + const handleCopy = async (text: string, field: 'ca' | 'cert' | 'key' | 'server-cert' | 'server-key') => { await navigator.clipboard.writeText(text) setCopiedField(field) setTimeout(() => setCopiedField(null), 2000) @@ -337,6 +337,8 @@ function KeyDisplayDialog({ open, cert, hostname, onClose }: KeyDisplayDialogPro zip.file('ca.crt', cert.ca_root_pem) zip.file('client.crt', cert.cert_pem) zip.file('client.key', cert.key_pem) + zip.file('server.crt', cert.server_cert_pem) + zip.file('server.key', cert.server_key_pem) const blob = await zip.generateAsync({ type: 'blob' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') @@ -349,112 +351,95 @@ function KeyDisplayDialog({ open, cert, hostname, onClose }: KeyDisplayDialogPro } } + const preStyle = { + p: 2, + bgcolor: 'grey.100', + borderRadius: 1, + fontSize: 12, + overflow: 'auto', + maxHeight: 150, + fontFamily: 'monospace' as const, + whiteSpace: 'pre-wrap' as const, + wordBreak: 'break-all' as const, + } + return ( - Certificate Issued — Save Your Private Key + Agent Certificate Bundle Issued — Save Your Private Keys - This private key will NOT be shown again. Copy and store it securely + Private keys will NOT be shown again. Copy and store them securely before closing this dialog. {cert && ( <> - Serial: {cert.serial_number}  |  Expires: {new Date(cert.expires_at).toLocaleDateString()} + Client Serial: {cert.serial_number}  |  Server Serial: {cert.server_serial_number}  |  Expires: {new Date(cert.expires_at).toLocaleDateString()} + + {/* CA Root Certificate */} CA Root Certificate (ca.crt) - - - {cert.ca_root_pem} - + {cert.ca_root_pem} + + {/* Client Certificate (mTLS) */} - Certificate (client.crt) - - - - {cert.cert_pem} - + {cert.cert_pem} + + {/* Client Private Key */} - Private Key (client.key) - - - - {cert.key_pem} + {cert.key_pem} + + + {/* Server Certificate (Agent TLS) */} + + + Server Certificate — Agent TLS (server.crt) + + + + {cert.server_cert_pem} + + + {/* Server Private Key */} + + + Server Private Key (server.key) + + + + + {cert.server_key_pem} )} @@ -467,7 +452,7 @@ function KeyDisplayDialog({ open, cert, hostname, onClose }: KeyDisplayDialogPro > {downloading ? : 'Download Bundle (.zip)'} - + ) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4a56fbd..f6eba75 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -203,6 +203,9 @@ export interface IssuedCert { key_pem: string serial_number: string expires_at: string + server_cert_pem: string + server_key_pem: string + server_serial_number: string ca_root_pem: string }