Files
RustProxy/src/admin/certificates.rs
T
2026-10-08 10:36:34 +02:00

450 lines
16 KiB
Rust

async fn certificate_deploy(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Json(input): Json<CertificateDeploy>,
) -> Result<Json<Value>, ApiError> {
let directory = state.policy.cert_dir.clone().ok_or_else(|| {
ApiError(
StatusCode::CONFLICT,
"certificate storage is unavailable; configure the certificate directory".into(),
)
})?;
let CertificateDeploy {
certificate_id,
cert_pem,
key_pem,
} = input;
let certificate_id = certificate_id.trim().to_owned();
if !crate::config::valid_id(&certificate_id) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid certificate id".into(),
));
}
let snapshot = state.config.load_full();
let expected_revision = snapshot.envelope.revision;
let mut config = snapshot.envelope.config.clone();
let existing_index = config
.tls
.certificates
.iter()
.position(|certificate| certificate.id == certificate_id);
let previous_pair = if let Some(index) = existing_index {
let selected = &config.tls.certificates[index];
if selected.issuer.is_some() {
return Err(ApiError(
StatusCode::CONFLICT,
format!(
"certificate {certificate_id} is managed by RustProxy ACME; use a separate manual certificate id for an external issuer"
),
));
}
if crate::tls::certificate_files_are_external(&selected.cert_file, &selected.key_file)
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?
{
return Err(ApiError(
StatusCode::CONFLICT,
format!(
"certificate {certificate_id} uses external file paths; renew those files in place and call the certificate reload endpoint instead of deploy"
),
));
}
Some((selected.cert_file.clone(), selected.key_file.clone()))
} else {
None
};
let key = zeroize::Zeroizing::new(key_pem);
let permit = state
.certificate_slots
.clone()
.try_acquire_owned()
.map_err(|_| {
ApiError(
StatusCode::TOO_MANY_REQUESTS,
"certificate deployment queue is full".into(),
)
})?;
let id_for_stage = certificate_id.clone();
let current_pair = previous_pair.clone();
let deployment = tokio::task::spawn_blocking(move || {
let _permit = permit;
crate::tls::reconcile_external_pair(
&directory,
&id_for_stage,
current_pair
.as_ref()
.map(|(cert_file, key_file)| (cert_file.as_str(), key_file.as_str())),
cert_pem.as_bytes(),
key.as_bytes(),
)
})
.await
.map_err(|_| {
ApiError(
StatusCode::INTERNAL_SERVER_ERROR,
"certificate deployment task failed".into(),
)
})?
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?;
match deployment {
crate::tls::ExternalPairDeployment::Unchanged { covered_hosts } => {
let index = existing_index.expect("unchanged deployment requires an existing pair");
let cert_file = config.tls.certificates[index].cert_file.clone();
let key_file = config.tls.certificates[index].key_file.clone();
if config.tls.certificates[index].covered_hosts == covered_hosts {
// A no-op does not pass through update_as(), so explicitly retain
// the same optimistic-concurrency guarantee as a real publish.
if state.config.load().envelope.revision != expected_revision {
return Err(ApiError(
StatusCode::CONFLICT,
"configuration changed during certificate deployment; retry".into(),
));
}
return Ok(Json(json!({
"deployed": true,
"operation": "unchanged",
"created": false,
"changed": false,
"certificate_id": certificate_id,
"revision": expected_revision,
"cert_file": cert_file,
"key_file": key_file,
"covered_hosts": covered_hosts,
"public_tls": true,
"http3_new_handshakes": state.config.load().quic_tls.is_some(),
"administration_restart_required": false
})));
}
config.tls.certificates[index].covered_hosts = covered_hosts.clone();
let updated = state
.update_as(
Update {
expected_revision,
config,
},
session.username.clone(),
)
.await?;
state
.store
.audit(
&session.username,
"certificate.deploy",
&format!(
"certificate_id={certificate_id}; operation=metadata-updated; cert_file={cert_file}"
),
)
.await
.map_err(auth::internal)?;
return Ok(Json(json!({
"deployed": true,
"operation": "metadata-updated",
"created": false,
"changed": true,
"certificate_id": certificate_id,
"revision": updated.revision,
"cert_file": cert_file,
"key_file": key_file,
"covered_hosts": covered_hosts,
"public_tls": true,
"http3_new_handshakes": state.config.load().quic_tls.is_some(),
"administration_restart_required": false
})));
}
crate::tls::ExternalPairDeployment::Staged {
cert_file,
key_file,
covered_hosts,
} => {
let created = existing_index.is_none();
if let Some(index) = existing_index {
let selected = &mut config.tls.certificates[index];
selected.cert_file = cert_file.clone();
selected.key_file = key_file.clone();
selected.covered_hosts = covered_hosts.clone();
} else {
config.tls.certificates.push(crate::tls::Certificate {
issuer: None,
id: certificate_id.clone(),
covered_hosts: covered_hosts.clone(),
hosts: Vec::new(),
cert_file: cert_file.clone(),
key_file: key_file.clone(),
});
}
let administration_restart_required = config.administration.tls
&& config.administration.certificate_id.as_deref() == Some(certificate_id.as_str());
let result = state
.update_as(
Update {
expected_revision,
config,
},
session.username.clone(),
)
.await;
let updated = match result {
Ok(updated) => updated,
Err(error) => {
let rollback_directory = state.policy.cert_dir.clone();
let rollback_cert = cert_file.clone();
let rollback_key = key_file.clone();
if let Some(rollback_directory) = rollback_directory {
match tokio::task::spawn_blocking(move || {
crate::tls::remove_external_pair(
&rollback_directory,
&rollback_cert,
&rollback_key,
)
})
.await
{
Err(join_error) => {
tracing::warn!(%join_error, "external certificate rollback task failed")
}
Ok(Err(rollback_error)) => {
tracing::warn!(%rollback_error, "external certificate rollback failed")
}
Ok(Ok(())) => {}
}
}
return Err(error);
}
};
if let Some(previous_pair) = previous_pair {
let previous_still_referenced =
updated.config.tls.certificates.iter().any(|certificate| {
certificate.cert_file == previous_pair.0
|| certificate.key_file == previous_pair.1
});
if !previous_still_referenced
&& previous_pair.0.starts_with("external-")
&& previous_pair.1.starts_with("external-")
{
if let Some(cleanup_directory) = state.policy.cert_dir.clone() {
let cleanup_cert = previous_pair.0.clone();
let cleanup_key = previous_pair.1.clone();
match tokio::task::spawn_blocking(move || {
crate::tls::remove_external_pair(
&cleanup_directory,
&cleanup_cert,
&cleanup_key,
)
})
.await
{
Err(join_error) => {
tracing::warn!(%join_error, "previous external certificate cleanup task failed")
}
Ok(Err(cleanup_error)) => {
tracing::warn!(%cleanup_error, "previous external certificate cleanup failed")
}
Ok(Ok(())) => {}
}
}
}
}
let operation = if created { "created" } else { "updated" };
state
.store
.audit(
&session.username,
"certificate.deploy",
&format!(
"certificate_id={certificate_id}; operation={operation}; cert_file={cert_file}"
),
)
.await
.map_err(auth::internal)?;
Ok(Json(json!({
"deployed": true,
"operation": operation,
"created": created,
"changed": true,
"certificate_id": certificate_id,
"revision": updated.revision,
"cert_file": cert_file,
"key_file": key_file,
"covered_hosts": covered_hosts,
"public_tls": true,
"http3_new_handshakes": state.config.load().quic_tls.is_some(),
"administration_restart_required": administration_restart_required
})))
}
}
}
async fn certificate_reload(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
) -> Result<Json<Value>, ApiError> {
let revision = state.reload_certificates().await?;
let snapshot = state.config.load_full();
let admin_certificate_restart_required = snapshot.envelope.config.administration.tls
&& snapshot
.envelope
.config
.administration
.certificate_id
.is_some();
state
.store
.audit(
&session.username,
"certificate.reload",
"re-read configured certificate files",
)
.await
.map_err(auth::internal)?;
Ok(Json(json!({
"reloaded": true,
"revision": revision,
"public_tls": true,
"http3_new_handshakes": snapshot.quic_tls.is_some(),
"administration_restart_required": admin_certificate_restart_required
})))
}
async fn certificate_reload_one(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(certificate_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !crate::config::valid_id(&certificate_id) {
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate not found".into(),
));
}
let revision = state.reload_certificate(&certificate_id).await?;
let snapshot = state.config.load_full();
let admin_certificate_restart_required = snapshot.envelope.config.administration.tls
&& snapshot
.envelope
.config
.administration
.certificate_id
.as_deref()
== Some(certificate_id.as_str());
state
.store
.audit(
&session.username,
"certificate.reload_one",
&format!("certificate_id={certificate_id}"),
)
.await
.map_err(auth::internal)?;
Ok(Json(json!({
"reloaded": true,
"certificate_id": certificate_id,
"revision": revision,
"public_tls": true,
"http3_new_handshakes": snapshot.quic_tls.is_some(),
"administration_restart_required": admin_certificate_restart_required
})))
}
async fn acme_status(State(state): State<Arc<App>>) -> Json<Value> {
let snapshot = state.config.load();
let log_directories: std::collections::BTreeMap<String, String> = state
.policy
.cert_dir
.as_deref()
.map(|directory| {
snapshot
.envelope
.config
.tls
.certificates
.iter()
.filter_map(|certificate| {
certificate.issuer.as_ref().map(|issuer| {
(
certificate.id.clone(),
crate::acme::certbot_log_directory(
directory,
&issuer.provider,
&issuer.environment,
&certificate.id,
),
)
})
})
.collect()
})
.unwrap_or_default();
Json(json!({
"job":state.acme.view(),
"runtime":state.acme.runtime_view(),
"providers":["http01","cloudflare","ovh","rfc2136"],
"automatic_renewal":true,
"interval_seconds":43200,
"renewals":state.acme.renewal_view(),
"log_directories":log_directories
}))
}
async fn acme_issue(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Json(input): Json<Value>,
) -> Result<Response, ApiError> {
let job = crate::acme::Manager::start(state, input, session.username).await?;
Ok((StatusCode::ACCEPTED, Json(json!({"job":job}))).into_response())
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BasicHash {
username: String,
password: String,
}
async fn basic_hash(
State(state): State<Arc<App>>,
Json(input): Json<BasicHash>,
) -> Result<Json<Value>, ApiError> {
crate::basic_auth::validate_username(&input.username)
.map_err(|_| ApiError(StatusCode::BAD_REQUEST, "basic.invalid_username".into()))?;
// The semaphore is held by the blocking worker even if the HTTP client disconnects.
let permit = state
.basic_workers
.clone()
.try_acquire_owned()
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "basic.busy".into()))?;
let password = zeroize::Zeroizing::new(input.password);
auth::validate_password(&password)
.map_err(|_| ApiError(StatusCode::BAD_REQUEST, "auth.password_policy".into()))?;
if password.chars().any(char::is_control) {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"auth.password_policy".into(),
));
}
let hash = tokio::task::spawn_blocking(move || {
let _permit = permit;
crate::auth::hash_sync(&password)
})
.await
.map_err(auth::internal)?
.map_err(auth::internal)?;
Ok(Json(
json!({"username":input.username,"password_hash":hash}),
))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RenewRequest {
certificate_id: String,
#[serde(default)]
dry_run: bool,
}