feat: all-inclusive agent cert bundle - server cert + client cert + CA root in one issuance
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 5s
CI Pipeline / Clippy Lints (push) Successful in 45s
CI Pipeline / Rust Unit Tests (push) Successful in 1m2s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 10s
CI Pipeline / Build .deb & Release (push) Has been skipped
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 5s
CI Pipeline / Clippy Lints (push) Successful in 45s
CI Pipeline / Rust Unit Tests (push) Successful in 1m2s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 10s
CI Pipeline / Build .deb & Release (push) Has been skipped
This commit is contained in:
@ -1,11 +1,12 @@
|
|||||||
//! Internal Certificate Authority for Linux Patch Manager.
|
//! Internal Certificate Authority for Linux Patch Manager.
|
||||||
//!
|
//!
|
||||||
//! Issues and renews mTLS client certificates for agent communication.
|
//! Issues and renews mTLS client certificates and agent server certificates
|
||||||
//! Uses rcgen (ECDSA P-256) for all certificate generation.
|
//! for agent communication. Uses rcgen (ECDSA P-256) for all certificate
|
||||||
//! CA key and certificate are stored on disk under `base_dir`
|
//! generation. CA key and certificate are stored on disk under `base_dir`
|
||||||
//! (default: /etc/patch-manager/ca/).
|
//! (default: /etc/patch-manager/ca/). Certificate metadata is persisted in
|
||||||
//! Certificate metadata is persisted in the `certificates` PostgreSQL table.
|
//! the `certificates` PostgreSQL table.
|
||||||
|
|
||||||
|
use std::net::IpAddr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@ -26,19 +27,26 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
/// Returned by [`CertAuthority::issue_client_cert`] and [`CertAuthority::renew_cert`].
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct IssuedCert {
|
pub struct IssuedCert {
|
||||||
/// PEM-encoded public certificate.
|
/// PEM-encoded client certificate (mTLS).
|
||||||
pub cert_pem: String,
|
pub cert_pem: String,
|
||||||
/// PEM-encoded private key (PKCS#8).
|
/// PEM-encoded client private key (PKCS#8).
|
||||||
pub key_pem: String,
|
pub key_pem: String,
|
||||||
/// Hex-encoded 16-byte random serial number.
|
/// Hex-encoded 16-byte random serial number (client cert).
|
||||||
pub serial_number: String,
|
pub serial_number: String,
|
||||||
/// Certificate expiry timestamp (UTC).
|
/// Certificate expiry timestamp (UTC).
|
||||||
pub expires_at: DateTime<Utc>,
|
pub expires_at: DateTime<Utc>,
|
||||||
|
/// 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
|
// CertAuthority
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -240,15 +248,19 @@ impl CertAuthority {
|
|||||||
/// * Key usage: Digital Signature
|
/// * Key usage: Digital Signature
|
||||||
/// * Extended key usage: Client Authentication
|
/// * Extended key usage: Client Authentication
|
||||||
///
|
///
|
||||||
/// The certificate PEM is stored in `certificates`.
|
/// Also issues a server certificate for the agent's TLS listener
|
||||||
/// The private key is returned to the caller **only** and never persisted.
|
/// (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(
|
pub async fn issue_client_cert(
|
||||||
&self,
|
&self,
|
||||||
host_id: Uuid,
|
host_id: Uuid,
|
||||||
hostname: &str,
|
hostname: &str,
|
||||||
|
ip_address: &str,
|
||||||
db: &PgPool,
|
db: &PgPool,
|
||||||
) -> Result<IssuedCert> {
|
) -> Result<IssuedCert> {
|
||||||
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 =
|
let key =
|
||||||
KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).context("generate client key pair")?;
|
KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).context("generate client key pair")?;
|
||||||
@ -288,14 +300,99 @@ impl CertAuthority {
|
|||||||
"Client certificate issued successfully"
|
"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 {
|
Ok(IssuedCert {
|
||||||
cert_pem,
|
cert_pem,
|
||||||
key_pem,
|
key_pem,
|
||||||
serial_number: serial_hex,
|
serial_number: serial_hex,
|
||||||
expires_at,
|
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=<hostname>-server`
|
||||||
|
/// * Key usage: Digital Signature
|
||||||
|
/// * Extended key usage: Server Authentication
|
||||||
|
/// * SANs: DNS `<hostname>` + IP `<ip_address>` (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<Utc>)> {
|
||||||
|
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::<IpAddr>() {
|
||||||
|
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.
|
/// Revoke a certificate by database ID.
|
||||||
///
|
///
|
||||||
/// Sets `status = 'revoked'` and `revoked_at = NOW()` in the `certificates` table.
|
/// 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
|
/// Renew a certificate: revoke the existing cert and issue a new one with
|
||||||
/// the same `host_id` and `common_name`.
|
/// 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<IssuedCert> {
|
pub async fn renew_cert(&self, cert_id: Uuid, db: &PgPool) -> Result<IssuedCert> {
|
||||||
tracing::info!(cert_id = %cert_id, "Renewing certificate");
|
tracing::info!(cert_id = %cert_id, "Renewing certificate");
|
||||||
|
|
||||||
@ -338,15 +438,25 @@ impl CertAuthority {
|
|||||||
.context("certificate has no host_id (cannot renew root CA)")?;
|
.context("certificate has no host_id (cannot renew root CA)")?;
|
||||||
let common_name: String = row.try_get("common_name").context("fetch common_name")?;
|
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.
|
// Revoke the old cert first.
|
||||||
self.revoke_cert(cert_id, db).await?;
|
self.revoke_cert(cert_id, db).await?;
|
||||||
|
|
||||||
// Issue a fresh cert with the same CN.
|
// 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!(
|
tracing::info!(
|
||||||
old_cert_id = %cert_id,
|
old_cert_id = %cert_id,
|
||||||
new_serial = %issued.serial_number,
|
new_serial = %issued.serial_number,
|
||||||
"Certificate renewed"
|
"Certificate renewed"
|
||||||
);
|
);
|
||||||
Ok(issued)
|
Ok(issued)
|
||||||
|
|||||||
@ -26,6 +26,7 @@ use pm_auth::rbac::AuthUser;
|
|||||||
use pm_core::audit::{log_event, AuditAction};
|
use pm_core::audit::{log_event, AuditAction};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use sqlx::Row;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
@ -123,6 +124,21 @@ fn db_error(e: sqlx::Error) -> (StatusCode, Json<Value>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 ───────────────────────────────────────────────────
|
// ── GET /api/v1/ca/root.crt ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Download the root CA certificate as a PEM file.
|
/// Download the root CA certificate as a PEM file.
|
||||||
@ -231,6 +247,7 @@ async fn download_client_cert(
|
|||||||
FROM certificates
|
FROM certificates
|
||||||
WHERE host_id = $1
|
WHERE host_id = $1
|
||||||
AND status = 'active'::cert_status
|
AND status = 'active'::cert_status
|
||||||
|
AND common_name NOT LIKE '%-server'
|
||||||
ORDER BY issued_at DESC
|
ORDER BY issued_at DESC
|
||||||
LIMIT 1"#,
|
LIMIT 1"#,
|
||||||
)
|
)
|
||||||
@ -272,8 +289,8 @@ async fn download_client_cert(
|
|||||||
|
|
||||||
// ── POST /api/v1/hosts/:host_id/certificates ─────────────────────────────────
|
// ── POST /api/v1/hosts/:host_id/certificates ─────────────────────────────────
|
||||||
|
|
||||||
/// Issue a new mTLS client certificate for a host.
|
/// Issue a new mTLS client certificate (and server certificate) for a host.
|
||||||
/// **The private key is returned only once — the caller must save it.**
|
/// **The private keys are returned only once — the caller must save them.**
|
||||||
async fn issue_client_cert(
|
async fn issue_client_cert(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
@ -282,9 +299,26 @@ async fn issue_client_cert(
|
|||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
require_admin(&auth)?;
|
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
|
let issued = state
|
||||||
.ca
|
.ca
|
||||||
.issue_client_cert(host_id, &req.hostname, &state.db)
|
.issue_client_cert(host_id, &req.hostname, &ip_address, &state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!(error = %e, %host_id, hostname = %req.hostname,
|
tracing::error!(error = %e, %host_id, hostname = %req.hostname,
|
||||||
@ -302,19 +336,13 @@ async fn issue_client_cert(
|
|||||||
Some(&auth.username),
|
Some(&auth.username),
|
||||||
Some("certificate"),
|
Some("certificate"),
|
||||||
Some(&host_id.to_string()),
|
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,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(issued_cert_json(&issued)))
|
||||||
"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(),
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /api/v1/certificates/:cert_id/renew ─────────────────────────────────
|
// ── POST /api/v1/certificates/:cert_id/renew ─────────────────────────────────
|
||||||
@ -352,25 +380,19 @@ async fn renew_cert(
|
|||||||
Some(&auth.username),
|
Some(&auth.username),
|
||||||
Some("certificate"),
|
Some("certificate"),
|
||||||
Some(&cert_id.to_string()),
|
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,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(issued_cert_json(&issued)))
|
||||||
"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(),
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /api/v1/hosts/:host_id/certificates/reissue ────────────────────────
|
// ── POST /api/v1/hosts/:host_id/certificates/reissue ────────────────────────
|
||||||
|
|
||||||
/// Revoke ALL active certificates for a host and issue a new one.
|
/// Revoke ALL active certificates for a host and issue new ones.
|
||||||
/// The private key is returned only once — the caller must save it.
|
/// The private keys are returned only once — the caller must save them.
|
||||||
async fn reissue_host_cert(
|
async fn reissue_host_cert(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
@ -378,13 +400,13 @@ async fn reissue_host_cert(
|
|||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
require_admin(&auth)?;
|
require_admin(&auth)?;
|
||||||
|
|
||||||
// Look up the host's FQDN for the new certificate CN.
|
// Look up the host's FQDN and IP address for the new certificate CN and SANs.
|
||||||
let fqdn: String = sqlx::query_scalar("SELECT fqdn FROM hosts WHERE id = $1")
|
let row = sqlx::query("SELECT fqdn, ip_address::text AS ip_address FROM hosts WHERE id = $1")
|
||||||
.bind(host_id)
|
.bind(host_id)
|
||||||
.fetch_one(&state.db)
|
.fetch_one(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.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") {
|
if e.to_string().contains("no rows") {
|
||||||
(
|
(
|
||||||
StatusCode::NOT_FOUND,
|
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.
|
// Revoke all active certificates for this host.
|
||||||
let revoked = sqlx::query(
|
let revoked = sqlx::query(
|
||||||
"UPDATE certificates SET status = 'revoked'::cert_status, revoked_at = NOW() \
|
"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");
|
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
|
let issued = state
|
||||||
.ca
|
.ca
|
||||||
.issue_client_cert(host_id, &fqdn, &state.db)
|
.issue_client_cert(host_id, &fqdn, &ip_address, &state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!(error = %e, %host_id, "Failed to issue new cert during reissue");
|
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(&auth.username),
|
||||||
Some("certificate"),
|
Some("certificate"),
|
||||||
Some(&host_id.to_string()),
|
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,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(issued_cert_json(&issued)))
|
||||||
"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(),
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DELETE /api/v1/certificates/:cert_id ─────────────────────────────────────
|
// ── DELETE /api/v1/certificates/:cert_id ─────────────────────────────────────
|
||||||
|
|||||||
@ -149,10 +149,10 @@ interface KeyDisplayDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function KeyDisplayDialog({ open, cert, hostname, onClose }: 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 [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)
|
await navigator.clipboard.writeText(text)
|
||||||
setCopiedField(field)
|
setCopiedField(field)
|
||||||
setTimeout(() => setCopiedField(null), 2000)
|
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('ca.crt', cert.ca_root_pem)
|
||||||
zip.file('client.crt', cert.cert_pem)
|
zip.file('client.crt', cert.cert_pem)
|
||||||
zip.file('client.key', cert.key_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 blob = await zip.generateAsync({ type: 'blob' })
|
||||||
downloadBlob(blob, `${hostname || 'host'}-certs.zip`)
|
downloadBlob(blob, `${hostname || 'host'}-certs.zip`)
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||||
<DialogTitle>Certificate Issued — Save Your Private Key</DialogTitle>
|
<DialogTitle>Agent Certificate Bundle Issued — Save Your Private Keys</DialogTitle>
|
||||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
||||||
<Alert severity="warning">
|
<Alert severity="warning">
|
||||||
<strong>This private key will NOT be shown again.</strong> Copy and store it securely
|
<strong>Private keys will NOT be shown again.</strong> Copy and store them securely
|
||||||
before closing this dialog.
|
before closing this dialog.
|
||||||
</Alert>
|
</Alert>
|
||||||
{cert && (
|
{cert && (
|
||||||
<>
|
<>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
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)}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
{/* CA Root Certificate */}
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
<Typography variant="subtitle2">CA Root Certificate (ca.crt)</Typography>
|
<Typography variant="subtitle2">CA Root Certificate (ca.crt)</Typography>
|
||||||
<Tooltip title={copiedField === 'ca' ? 'Copied!' : 'Copy CA root cert to clipboard'}>
|
<Tooltip title={copiedField === 'ca' ? 'Copied!' : 'Copy CA root cert to clipboard'}>
|
||||||
<Button
|
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.ca_root_pem, 'ca')} variant="outlined">
|
||||||
size="small"
|
|
||||||
startIcon={<CopyIcon />}
|
|
||||||
onClick={() => handleCopy(cert.ca_root_pem, 'ca')}
|
|
||||||
variant="outlined"
|
|
||||||
>
|
|
||||||
{copiedField === 'ca' ? 'Copied!' : 'Copy CA Root'}
|
{copiedField === 'ca' ? 'Copied!' : 'Copy CA Root'}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box component="pre" sx={preStyle}>{cert.ca_root_pem}</Box>
|
||||||
component="pre"
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
borderRadius: 1,
|
|
||||||
fontSize: 12,
|
|
||||||
overflow: 'auto',
|
|
||||||
maxHeight: 150,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cert.ca_root_pem}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Client Certificate (mTLS) */}
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
<Typography variant="subtitle2">Certificate (client.crt)</Typography>
|
<Typography variant="subtitle2">Client Certificate — mTLS (client.crt)</Typography>
|
||||||
<Tooltip title={copiedField === 'cert' ? 'Copied!' : 'Copy certificate to clipboard'}>
|
<Tooltip title={copiedField === 'cert' ? 'Copied!' : 'Copy client cert to clipboard'}>
|
||||||
<Button
|
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.cert_pem, 'cert')} variant="outlined">
|
||||||
size="small"
|
{copiedField === 'cert' ? 'Copied!' : 'Copy Client Cert'}
|
||||||
startIcon={<CopyIcon />}
|
|
||||||
onClick={() => handleCopy(cert.cert_pem, 'cert')}
|
|
||||||
variant="outlined"
|
|
||||||
>
|
|
||||||
{copiedField === 'cert' ? 'Copied!' : 'Copy Cert'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box component="pre" sx={preStyle}>{cert.cert_pem}</Box>
|
||||||
component="pre"
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
borderRadius: 1,
|
|
||||||
fontSize: 12,
|
|
||||||
overflow: 'auto',
|
|
||||||
maxHeight: 200,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cert.cert_pem}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Client Private Key */}
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
<Typography variant="subtitle2" color="error">Private Key (client.key)</Typography>
|
<Typography variant="subtitle2" color="error">Client Private Key (client.key)</Typography>
|
||||||
<Tooltip title={copiedField === 'key' ? 'Copied!' : 'Copy private key to clipboard'}>
|
<Tooltip title={copiedField === 'key' ? 'Copied!' : 'Copy client key to clipboard'}>
|
||||||
<Button
|
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.key_pem, 'key')} variant="outlined" color="error">
|
||||||
size="small"
|
{copiedField === 'key' ? 'Copied!' : 'Copy Client Key'}
|
||||||
startIcon={<CopyIcon />}
|
|
||||||
onClick={() => handleCopy(cert.key_pem, 'key')}
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
>
|
|
||||||
{copiedField === 'key' ? 'Copied!' : 'Copy Key'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box component="pre" sx={preStyle}>{cert.key_pem}</Box>
|
||||||
component="pre"
|
</Box>
|
||||||
sx={{
|
|
||||||
p: 2,
|
{/* Server Certificate (Agent TLS) */}
|
||||||
bgcolor: 'grey.100',
|
<Box>
|
||||||
borderRadius: 1,
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
fontSize: 12,
|
<Typography variant="subtitle2">Server Certificate — Agent TLS (server.crt)</Typography>
|
||||||
overflow: 'auto',
|
<Tooltip title={copiedField === 'server-cert' ? 'Copied!' : 'Copy server cert to clipboard'}>
|
||||||
maxHeight: 200,
|
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.server_cert_pem, 'server-cert')} variant="outlined">
|
||||||
fontFamily: 'monospace',
|
{copiedField === 'server-cert' ? 'Copied!' : 'Copy Server Cert'}
|
||||||
whiteSpace: 'pre-wrap',
|
</Button>
|
||||||
wordBreak: 'break-all',
|
</Tooltip>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cert.key_pem}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box component="pre" sx={preStyle}>{cert.server_cert_pem}</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Server Private Key */}
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography variant="subtitle2" color="error">Server Private Key (server.key)</Typography>
|
||||||
|
<Tooltip title={copiedField === 'server-key' ? 'Copied!' : 'Copy server key to clipboard'}>
|
||||||
|
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.server_key_pem, 'server-key')} variant="outlined" color="error">
|
||||||
|
{copiedField === 'server-key' ? 'Copied!' : 'Copy Server Key'}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Box component="pre" sx={preStyle}>{cert.server_key_pem}</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -291,7 +276,7 @@ function KeyDisplayDialog({ open, cert, hostname, onClose }: KeyDisplayDialogPro
|
|||||||
>
|
>
|
||||||
{downloading ? <CircularProgress size={20} /> : 'Download Bundle (.zip)'}
|
{downloading ? <CircularProgress size={20} /> : 'Download Bundle (.zip)'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" onClick={onClose}>I Have Saved the Key</Button>
|
<Button variant="contained" onClick={onClose}>I Have Saved the Keys</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -320,10 +320,10 @@ interface KeyDisplayDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function KeyDisplayDialog({ open, cert, hostname, onClose }: 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 [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)
|
await navigator.clipboard.writeText(text)
|
||||||
setCopiedField(field)
|
setCopiedField(field)
|
||||||
setTimeout(() => setCopiedField(null), 2000)
|
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('ca.crt', cert.ca_root_pem)
|
||||||
zip.file('client.crt', cert.cert_pem)
|
zip.file('client.crt', cert.cert_pem)
|
||||||
zip.file('client.key', cert.key_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 blob = await zip.generateAsync({ type: 'blob' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
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 (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||||
<DialogTitle>Certificate Issued — Save Your Private Key</DialogTitle>
|
<DialogTitle>Agent Certificate Bundle Issued — Save Your Private Keys</DialogTitle>
|
||||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
||||||
<Alert severity="warning">
|
<Alert severity="warning">
|
||||||
<strong>This private key will NOT be shown again.</strong> Copy and store it securely
|
<strong>Private keys will NOT be shown again.</strong> Copy and store them securely
|
||||||
before closing this dialog.
|
before closing this dialog.
|
||||||
</Alert>
|
</Alert>
|
||||||
{cert && (
|
{cert && (
|
||||||
<>
|
<>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
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()}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
{/* CA Root Certificate */}
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
<Typography variant="subtitle2">CA Root Certificate (ca.crt)</Typography>
|
<Typography variant="subtitle2">CA Root Certificate (ca.crt)</Typography>
|
||||||
<Tooltip title={copiedField === 'ca' ? 'Copied!' : 'Copy CA root cert to clipboard'}>
|
<Tooltip title={copiedField === 'ca' ? 'Copied!' : 'Copy CA root cert to clipboard'}>
|
||||||
<Button
|
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.ca_root_pem, 'ca')} variant="outlined">
|
||||||
size="small"
|
|
||||||
startIcon={<CopyIcon />}
|
|
||||||
onClick={() => handleCopy(cert.ca_root_pem, 'ca')}
|
|
||||||
variant="outlined"
|
|
||||||
>
|
|
||||||
{copiedField === 'ca' ? 'Copied!' : 'Copy CA Root'}
|
{copiedField === 'ca' ? 'Copied!' : 'Copy CA Root'}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box component="pre" sx={preStyle}>{cert.ca_root_pem}</Box>
|
||||||
component="pre"
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
borderRadius: 1,
|
|
||||||
fontSize: 12,
|
|
||||||
overflow: 'auto',
|
|
||||||
maxHeight: 150,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cert.ca_root_pem}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Client Certificate (mTLS) */}
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
<Typography variant="subtitle2">Certificate (client.crt)</Typography>
|
<Typography variant="subtitle2">Client Certificate — mTLS (client.crt)</Typography>
|
||||||
<Tooltip title={copiedField === 'cert' ? 'Copied!' : 'Copy certificate to clipboard'}>
|
<Tooltip title={copiedField === 'cert' ? 'Copied!' : 'Copy client cert to clipboard'}>
|
||||||
<Button
|
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.cert_pem, 'cert')} variant="outlined">
|
||||||
size="small"
|
{copiedField === 'cert' ? 'Copied!' : 'Copy Client Cert'}
|
||||||
startIcon={<CopyIcon />}
|
|
||||||
onClick={() => handleCopy(cert.cert_pem, 'cert')}
|
|
||||||
variant="outlined"
|
|
||||||
>
|
|
||||||
{copiedField === 'cert' ? 'Copied!' : 'Copy Cert'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box component="pre" sx={preStyle}>{cert.cert_pem}</Box>
|
||||||
component="pre"
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
borderRadius: 1,
|
|
||||||
fontSize: 12,
|
|
||||||
overflow: 'auto',
|
|
||||||
maxHeight: 200,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cert.cert_pem}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Client Private Key */}
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
<Typography variant="subtitle2" color="error">Private Key (client.key)</Typography>
|
<Typography variant="subtitle2" color="error">Client Private Key (client.key)</Typography>
|
||||||
<Tooltip title={copiedField === 'key' ? 'Copied!' : 'Copy private key to clipboard'}>
|
<Tooltip title={copiedField === 'key' ? 'Copied!' : 'Copy client key to clipboard'}>
|
||||||
<Button
|
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.key_pem, 'key')} variant="outlined" color="error">
|
||||||
size="small"
|
{copiedField === 'key' ? 'Copied!' : 'Copy Client Key'}
|
||||||
startIcon={<CopyIcon />}
|
|
||||||
onClick={() => handleCopy(cert.key_pem, 'key')}
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
>
|
|
||||||
{copiedField === 'key' ? 'Copied!' : 'Copy Key'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box component="pre" sx={preStyle}>{cert.key_pem}</Box>
|
||||||
component="pre"
|
</Box>
|
||||||
sx={{
|
|
||||||
p: 2,
|
{/* Server Certificate (Agent TLS) */}
|
||||||
bgcolor: 'grey.100',
|
<Box>
|
||||||
borderRadius: 1,
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
fontSize: 12,
|
<Typography variant="subtitle2">Server Certificate — Agent TLS (server.crt)</Typography>
|
||||||
overflow: 'auto',
|
<Tooltip title={copiedField === 'server-cert' ? 'Copied!' : 'Copy server cert to clipboard'}>
|
||||||
maxHeight: 200,
|
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.server_cert_pem, 'server-cert')} variant="outlined">
|
||||||
fontFamily: 'monospace',
|
{copiedField === 'server-cert' ? 'Copied!' : 'Copy Server Cert'}
|
||||||
whiteSpace: 'pre-wrap',
|
</Button>
|
||||||
wordBreak: 'break-all',
|
</Tooltip>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cert.key_pem}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box component="pre" sx={preStyle}>{cert.server_cert_pem}</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Server Private Key */}
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography variant="subtitle2" color="error">Server Private Key (server.key)</Typography>
|
||||||
|
<Tooltip title={copiedField === 'server-key' ? 'Copied!' : 'Copy server key to clipboard'}>
|
||||||
|
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.server_key_pem, 'server-key')} variant="outlined" color="error">
|
||||||
|
{copiedField === 'server-key' ? 'Copied!' : 'Copy Server Key'}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Box component="pre" sx={preStyle}>{cert.server_key_pem}</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -467,7 +452,7 @@ function KeyDisplayDialog({ open, cert, hostname, onClose }: KeyDisplayDialogPro
|
|||||||
>
|
>
|
||||||
{downloading ? <CircularProgress size={20} /> : 'Download Bundle (.zip)'}
|
{downloading ? <CircularProgress size={20} /> : 'Download Bundle (.zip)'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" onClick={onClose}>I Have Saved the Key</Button>
|
<Button variant="contained" onClick={onClose}>I Have Saved the Keys</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -203,6 +203,9 @@ export interface IssuedCert {
|
|||||||
key_pem: string
|
key_pem: string
|
||||||
serial_number: string
|
serial_number: string
|
||||||
expires_at: string
|
expires_at: string
|
||||||
|
server_cert_pem: string
|
||||||
|
server_key_pem: string
|
||||||
|
server_serial_number: string
|
||||||
ca_root_pem: string
|
ca_root_pem: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user