450 lines
16 KiB
Rust
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,
|
|
}
|