Private
Public Access
1
0

feat(pki): add CRL generation, distribution endpoint, and enrollment bundle extension (#26)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 6s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m10s
CI Pipeline / Security Audit (push) Successful in 1m26s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped

* feat(pki): add CRL generation, distribution endpoint, and enrollment bundle extension

Implements manager-side CRL infrastructure for issue #7:
- Add CertAuthority::generate_crl() using rcgen 0.13
- Add GET /api/v1/pki/crl.pem public endpoint
- Extend PkiBundle with ca_chain and crl_pem fields
- Update enrollment route to include CRL in bundle
- Mount pki route as public endpoint
- Add proptest dev-dependency

* style: fix cargo fmt in enrollment.rs

---------

Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
This commit is contained in:
Draco-Lunaris-Echo
2026-06-05 12:54:14 -05:00
committed by GitHub
parent 80ffb6b62f
commit 5aec9e629c
11 changed files with 871 additions and 4 deletions

View File

@ -303,10 +303,17 @@ async fn approve_enrollment(
// server memory beyond the signing operation.
//
// See: https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/9
//
// Include the full CA chain (for root mode, same as ca_crt; for sub-CA,
// includes intermediate + root) and the current CRL.
let ca_chain = issued.ca_root_pem.clone(); // Root mode: chain is just the root cert
let crl_pem = state.ca.generate_crl(&state.db).await.unwrap_or_default(); // Empty string on failure: agent falls back to WebPKI-only
let pki = PkiBundle {
ca_crt: issued.ca_root_pem,
ca_chain,
server_crt: issued.server_cert_pem,
server_key: issued.server_key_pem,
crl_pem,
};
state.approved_enrollments.insert(
enrollment_request.polling_token.clone(),

4
crates/pm-web/src/routes/mod.rs Executable file → Normal file
View File

@ -8,10 +8,10 @@ pub mod health_checks;
pub mod hosts;
pub mod jobs;
pub mod maintenance_windows;
pub mod pki;
pub mod reports;
pub mod settings;
pub mod sso;
pub mod status;
pub mod users;
pub mod ws;
pub mod reports;

View File

@ -0,0 +1,61 @@
//! PKI endpoints for certificate revocation list (CRL) distribution.
//!
//! This module exposes the CRL endpoint that agents poll every 24 hours to
//! check for revoked certificates. The CRL is signed by the internal CA and
//! is publicly accessible (CRLs are self-authenticating — they carry the CA
//! signature and do not require client authentication).
use crate::AppState;
use axum::{
extract::State,
http::{header, StatusCode},
response::IntoResponse,
routing::get,
Router,
};
/// Define public PKI routes.
///
/// These endpoints are **unauthenticated** because CRLs are self-authenticating:
/// the agent verifies the CRL signature against its pinned CA certificate.
/// No client certificate or API key is required.
pub fn router() -> Router<AppState> {
Router::new().route("/pki/crl.pem", get(get_crl))
}
/// `GET /api/v1/pki/crl.pem`
///
/// Returns the current Certificate Revocation List (CRL) as a PEM-encoded
/// X.509 CRL. The CRL is signed by the internal CA and contains the serial
/// numbers of all revoked certificates that have not yet expired.
///
/// # Cache headers
///
/// The response includes `Cache-Control: max-age=3600` (1 hour) to allow
/// intermediate caches to serve the CRL. Agents refresh every 24 hours,
/// so a 1-hour cache is a reasonable balance between freshness and load.
///
/// # CRL generation
///
/// The CRL is generated on demand from the `certificates` table. For our
/// target scale (max ~2500 clients), this is a fast query and the resulting
/// CRL is KB-range. If performance becomes a concern, the CRL can be cached
/// in memory and regenerated on a schedule (see background task in main.rs).
async fn get_crl(State(state): State<AppState>) -> impl IntoResponse {
match state.ca.generate_crl(&state.db).await {
Ok(crl_pem) => (
StatusCode::OK,
[(
header::CONTENT_TYPE,
"application/x-pem-file; charset=utf-8",
)],
[(header::CACHE_CONTROL, "max-age=3600")],
crl_pem,
)
.into_response(),
Err(e) => {
tracing::error!(error = %e, "Failed to generate CRL");
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate CRL").into_response()
},
}
}