async fn certificate_deploy( State(state): State>, Extension(session): Extension, Json(input): Json, ) -> Result, 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>, Extension(session): Extension, ) -> Result, 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>, Extension(session): Extension, Path(certificate_id): Path, ) -> Result, 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>) -> Json { let snapshot = state.config.load(); let log_directories: std::collections::BTreeMap = 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>, Extension(session): Extension, Json(input): Json, ) -> Result { 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>, Json(input): Json, ) -> Result, 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, }