feat: cert bundle download with CA root, re-issue endpoint, and enhanced cert UI
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 4s
CI Pipeline / Clippy Lints (push) Successful in 46s
CI Pipeline / Rust Unit Tests (push) Successful in 1m2s
CI Pipeline / Security Audit (push) Successful in 5s
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 4s
CI Pipeline / Clippy Lints (push) Successful in 46s
CI Pipeline / Rust Unit Tests (push) Successful in 1m2s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 10s
CI Pipeline / Build .deb & Release (push) Has been skipped
This commit is contained in:
@ -50,6 +50,7 @@ pub enum AuditAction {
|
||||
HealthCheckCreated,
|
||||
HealthCheckUpdated,
|
||||
HealthCheckDeleted,
|
||||
CertificateReissued,
|
||||
}
|
||||
|
||||
impl AuditAction {
|
||||
@ -86,6 +87,7 @@ impl AuditAction {
|
||||
Self::HealthCheckCreated => "health_check_created",
|
||||
Self::HealthCheckUpdated => "health_check_updated",
|
||||
Self::HealthCheckDeleted => "health_check_deleted",
|
||||
Self::CertificateReissued => "certificate_reissued",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
//! host_cert_router() → merged under /api/v1/hosts
|
||||
//! GET /:host_id/client.crt download_client_cert (admin only)
|
||||
//! POST /:host_id/certificates issue_client_cert (admin only)
|
||||
//! POST /:host_id/certificates/reissue reissue_host_cert (admin only)
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
@ -50,6 +51,7 @@ pub fn host_cert_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/{host_id}/client.crt", get(download_client_cert))
|
||||
.route("/{host_id}/certificates", post(issue_client_cert))
|
||||
.route("/{host_id}/certificates/reissue", post(reissue_host_cert))
|
||||
}
|
||||
|
||||
// ── Shared types ──────────────────────────────────────────────────────────────
|
||||
@ -311,6 +313,7 @@ async fn issue_client_cert(
|
||||
"key_pem": issued.key_pem,
|
||||
"serial_number": issued.serial_number,
|
||||
"expires_at": issued.expires_at,
|
||||
"ca_root_pem": state.ca.root_cert_pem(),
|
||||
})))
|
||||
}
|
||||
|
||||
@ -360,6 +363,82 @@ async fn renew_cert(
|
||||
"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 ────────────────────────
|
||||
|
||||
/// Revoke ALL active certificates for a host and issue a new one.
|
||||
/// The private key is returned only once — the caller must save it.
|
||||
async fn reissue_host_cert(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(host_id): Path<Uuid>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
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")
|
||||
.bind(host_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %host_id, "Failed to fetch host FQDN");
|
||||
if e.to_string().contains("no rows") {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": { "code": "not_found", "message": "Host not found" } })),
|
||||
)
|
||||
} else {
|
||||
db_error(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
// Revoke all active certificates for this host.
|
||||
let revoked = sqlx::query(
|
||||
"UPDATE certificates SET status = 'revoked'::cert_status, revoked_at = NOW() \
|
||||
WHERE host_id = $1 AND status = 'active'::cert_status",
|
||||
)
|
||||
.bind(host_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
|
||||
tracing::info!(%host_id, rows_revoked = revoked.rows_affected(), "Revoked all active certs for host");
|
||||
|
||||
// Issue a new certificate using the host's FQDN.
|
||||
let issued = state
|
||||
.ca
|
||||
.issue_client_cert(host_id, &fqdn, &state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %host_id, "Failed to issue new cert during reissue");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })),
|
||||
)
|
||||
})?;
|
||||
|
||||
log_event(
|
||||
&state.db,
|
||||
AuditAction::CertificateReissued,
|
||||
Some(auth.user_id),
|
||||
Some(&auth.username),
|
||||
Some("certificate"),
|
||||
Some(&host_id.to_string()),
|
||||
json!({ "hostname": &fqdn, "serial_number": issued.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(),
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user