v0.24.9
This commit is contained in:
1 parent
1297ea2920
commit
dc0fcabf51
49 files changed
+2632
-252
No files matched your search
+133
-2
@@ -1,9 +1,11 @@
|
||||
//! Bounded control-plane adapter for Certbot HTTP-01/DNS-01 providers. Never on traffic paths.
|
||||
//! Provider credentials enter via stdin, not argv, logs, SQLite or API responses.
|
||||
//! Provider credentials enter via stdin, are encrypted in Secret Store, and never
|
||||
//! travel through argv, logs or API responses.
|
||||
use crate::{
|
||||
runtime::{ApiError, App},
|
||||
tls::Certificate,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -39,6 +41,100 @@ pub(crate) fn certbot_log_directory(directory: &std::path::Path, provider: &str,
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
fn provider_credential_bytes(request: &Value, provider: &str) -> Result<Zeroizing<Vec<u8>>, ApiError> {
|
||||
fn credential<'a>(request: &'a Value, name: &str) -> Result<&'a str, ApiError> {
|
||||
let invalid = || ApiError(StatusCode::BAD_REQUEST, "acme.invalid_api_credential".into());
|
||||
let value = request.get(name).and_then(Value::as_str).ok_or_else(invalid)?;
|
||||
let valid = (8..=4096).contains(&value.len())
|
||||
&& value.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric()
|
||||
|| matches!(byte, b'_' | b'.' | b'/' | b'+' | b'-' | b'=')
|
||||
});
|
||||
if valid { Ok(value) } else { Err(invalid()) }
|
||||
}
|
||||
let bytes = match provider {
|
||||
"cloudflare" => format!("dns_cloudflare_api_token = {}\n", credential(request, "api_token")?).into_bytes(),
|
||||
"ovh" => {
|
||||
if request.get("endpoint").is_some_and(|value| !value.is_string()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_ovh_endpoint".into()));
|
||||
}
|
||||
let endpoint = request.get("endpoint").and_then(Value::as_str).unwrap_or("ovh-eu");
|
||||
if !matches!(endpoint, "ovh-eu" | "ovh-ca") {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_ovh_endpoint".into()));
|
||||
}
|
||||
format!(
|
||||
"dns_ovh_endpoint = {}\ndns_ovh_application_key = {}\ndns_ovh_application_secret = {}\ndns_ovh_consumer_key = {}\n",
|
||||
endpoint,
|
||||
credential(request, "application_key")?,
|
||||
credential(request, "application_secret")?,
|
||||
credential(request, "consumer_key")?
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
"rfc2136" => {
|
||||
let dns_server = request
|
||||
.get("dns_server")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| value.parse::<std::net::IpAddr>().ok())
|
||||
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_dns_server".into()))?;
|
||||
if request.get("dns_port").is_some_and(|value| value.as_u64().is_none()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_limits".into()));
|
||||
}
|
||||
let dns_port = request.get("dns_port").and_then(Value::as_u64).unwrap_or(53);
|
||||
if !(1..=65535).contains(&dns_port) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_limits".into()));
|
||||
}
|
||||
let raw_name = request.get("key_name").and_then(Value::as_str).unwrap_or("");
|
||||
let key_name = raw_name.to_ascii_lowercase();
|
||||
let key_name = key_name.trim_end_matches('.');
|
||||
let valid_name = !key_name.is_empty()
|
||||
&& key_name.len() <= 253
|
||||
&& key_name.split('.').all(|label| {
|
||||
!label.is_empty()
|
||||
&& label.len() <= 63
|
||||
&& label.bytes().enumerate().all(|(index, byte)| {
|
||||
(byte.is_ascii_lowercase() || byte.is_ascii_digit())
|
||||
|| (byte == b'-' && index > 0 && index + 1 < label.len())
|
||||
})
|
||||
});
|
||||
if !valid_name {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_domain".into()));
|
||||
}
|
||||
if request.get("key_algorithm").is_some_and(|value| !value.is_string()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_algorithm".into()));
|
||||
}
|
||||
let key_algorithm = request
|
||||
.get("key_algorithm")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("HMAC-SHA256");
|
||||
if !matches!(key_algorithm, "HMAC-SHA256" | "HMAC-SHA384" | "HMAC-SHA512") {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_algorithm".into()));
|
||||
}
|
||||
let key_secret = request.get("key_secret").and_then(Value::as_str).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_api_credential".into()))?;
|
||||
let decoded = Zeroizing::new(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(key_secret)
|
||||
.map_err(|_| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_secret".into()))?,
|
||||
);
|
||||
if key_secret.len() > 1024 || !(16..=512).contains(&decoded.len()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_secret".into()));
|
||||
}
|
||||
if request.get("sign_query").is_some_and(|value| !value.is_boolean()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_sign_query".into()));
|
||||
}
|
||||
let sign_query = request.get("sign_query").and_then(Value::as_bool).unwrap_or(false);
|
||||
format!(
|
||||
"dns_rfc2136_server = {dns_server}\ndns_rfc2136_port = {dns_port}\ndns_rfc2136_name = {key_name}.\ndns_rfc2136_secret = {key_secret}\ndns_rfc2136_algorithm = {key_algorithm}\ndns_rfc2136_sign_query = {sign_query}\n"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
"http01" => Vec::new(),
|
||||
_ => return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_provider".into())),
|
||||
};
|
||||
Ok(Zeroizing::new(bytes))
|
||||
}
|
||||
|
||||
|
||||
pub struct Manager {
|
||||
slot: Arc<Semaphore>,
|
||||
job: Mutex<Option<Job>>,
|
||||
@@ -137,6 +233,22 @@ impl Manager {
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "acme.job_busy".into()))?;
|
||||
if provider != "http01" {
|
||||
let data_dir = state.policy.data_dir.as_ref().ok_or_else(|| {
|
||||
ApiError(StatusCode::CONFLICT, "acme.secret_store_unavailable".into())
|
||||
})?;
|
||||
let credential = provider_credential_bytes(&request, &provider)?;
|
||||
crate::secret_store::put(
|
||||
&state.store,
|
||||
data_dir,
|
||||
&crate::secret_store::acme_secret_id(&id, &provider),
|
||||
&provider,
|
||||
credential.as_ref(),
|
||||
serde_json::json!({"certificate_id": id.clone(), "purpose": "acme_dns"}),
|
||||
)
|
||||
.await
|
||||
.map_err(crate::auth::internal)?;
|
||||
}
|
||||
let raw = Zeroizing::new(serde_json::to_string(&request).map_err(crate::auth::internal)?);
|
||||
for key in [
|
||||
"key_secret",
|
||||
@@ -789,9 +901,28 @@ impl Manager {
|
||||
.await
|
||||
.map_err(crate::auth::internal)?;
|
||||
*state.acme.job.lock().unwrap_or_else(|e| e.into_inner()) = Some(job.clone());
|
||||
let request = serde_json::json!({"action":"renew","certificate_id":cert.id,"provider":issuer.provider,
|
||||
let mut request = serde_json::json!({"action":"renew","certificate_id":cert.id,"provider":issuer.provider,
|
||||
"environment":issuer.environment,"cert_file":cert.cert_file,"key_file":cert.key_file,"dry_run":dry_run});
|
||||
if issuer.provider != "http01" {
|
||||
let data_dir = state.policy.data_dir.as_ref().ok_or_else(|| {
|
||||
ApiError(StatusCode::CONFLICT, "acme.secret_store_unavailable".into())
|
||||
})?;
|
||||
let credential = crate::secret_store::get(
|
||||
&state.store,
|
||||
data_dir,
|
||||
&crate::secret_store::acme_secret_id(&cert.id, &issuer.provider),
|
||||
Some(&issuer.provider),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| ApiError(StatusCode::CONFLICT, "acme.secret_missing".into()))?;
|
||||
request["credential_ini_b64"] = Value::String(
|
||||
base64::engine::general_purpose::STANDARD.encode(credential.as_ref())
|
||||
);
|
||||
}
|
||||
let raw = Zeroizing::new(request.to_string());
|
||||
if let Some(Value::String(secret)) = request.get_mut("credential_ini_b64") {
|
||||
secret.zeroize();
|
||||
}
|
||||
let task_job = job.clone();
|
||||
let tasks = state.tunnels.clone();
|
||||
tasks.spawn(async move {
|
||||
|
||||
Reference in new issue
Block a user