Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::jose::{Body, eab_sign, sign};
|
||||
use crate::network::acme::http::{get_header, https};
|
||||
use crate::network::acme::{AcmeError, AcmeResult, Directory};
|
||||
use aws_lc_rs::rand::SystemRandom;
|
||||
use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, EcdsaSigningAlgorithm};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::{self, URL_SAFE_NO_PAD};
|
||||
use registry::schema::structs::AcmeProvider;
|
||||
use reqwest::Method;
|
||||
use utils::sanitize_email;
|
||||
|
||||
static ALG: &EcdsaSigningAlgorithm = &ECDSA_P256_SHA256_FIXED_SIGNING;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EabSettings {
|
||||
pub kid: String,
|
||||
pub hmac_key: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct NewAccountPayload<'x> {
|
||||
#[serde(rename = "termsOfServiceAgreed")]
|
||||
tos_agreed: bool,
|
||||
contact: &'x [String],
|
||||
#[serde(rename = "externalAccountBinding")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
eab: Option<Body>,
|
||||
}
|
||||
|
||||
pub async fn acme_create_account(
|
||||
provider: &mut AcmeProvider,
|
||||
eab: Option<EabSettings>,
|
||||
) -> AcmeResult<()> {
|
||||
if provider.contact.is_empty() {
|
||||
return Err(AcmeError::Invalid(
|
||||
"At least one contact email is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
for contact in provider.contact.iter_mut() {
|
||||
let email = sanitize_email(contact.trim().strip_prefix("mailto:").unwrap_or(contact))
|
||||
.ok_or_else(|| AcmeError::Invalid(format!("Invalid contact email: {}", contact)))?;
|
||||
*contact = format!("mailto:{}", email);
|
||||
}
|
||||
|
||||
let directory = Directory::discover(&provider.directory, provider.max_retries as u32).await?;
|
||||
let account_key = EcdsaKeyPair::generate_pkcs8(ALG, &SystemRandom::new()).unwrap();
|
||||
let key_pair = EcdsaKeyPair::from_pkcs8(ALG, account_key.as_ref())
|
||||
.map_err(|err| AcmeError::Crypto(format!("Failed to create ECDSA key pair: {}", err)))?;
|
||||
let eab = if let Some(eab) = &eab {
|
||||
eab_sign(&key_pair, &eab.kid, &eab.hmac_key, &directory.new_account)?.into()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let payload = serde_json::to_string(&NewAccountPayload {
|
||||
tos_agreed: true,
|
||||
contact: provider.contact.as_slice(),
|
||||
eab,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let body = sign(
|
||||
&key_pair,
|
||||
None,
|
||||
directory.nonce(provider.max_retries as u32).await?,
|
||||
&directory.new_account,
|
||||
&payload,
|
||||
)?;
|
||||
|
||||
provider.account_uri = get_header(
|
||||
&https(
|
||||
&directory.new_account,
|
||||
Method::POST,
|
||||
Some(body),
|
||||
provider.max_retries as u32,
|
||||
)
|
||||
.await?,
|
||||
"Location",
|
||||
)?;
|
||||
provider.account_key = URL_SAFE_NO_PAD.encode(account_key.as_ref());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl EabSettings {
|
||||
pub fn new(kid: impl Into<String>, hmac_key: impl AsRef<[u8]>) -> AcmeResult<Self> {
|
||||
let key = general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(hmac_key.as_ref())
|
||||
.map_err(|err| AcmeError::Invalid(format!("Failed to decode EAB HMAC key: {}", err)))?;
|
||||
Ok(Self {
|
||||
kid: kid.into(),
|
||||
hmac_key: key,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
|
||||
|
||||
use super::jose::{
|
||||
key_authorization, key_authorization_sha256, key_authorization_sha256_base64, sign,
|
||||
};
|
||||
use crate::network::acme::http::{get_header, https, parse_alternate_links, parse_retry_after};
|
||||
use crate::network::acme::{
|
||||
AcmeError, AcmeResult, Auth, AuthStatus, Challenge, ChallengeType, Directory, Identifier,
|
||||
Order, SerializedCert,
|
||||
};
|
||||
use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, EcdsaSigningAlgorithm};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use rcgen::{CustomExtension, KeyPair, PKCS_ECDSA_P256_SHA256};
|
||||
use registry::schema::structs::AcmeProvider;
|
||||
use reqwest::Method;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use store::Serialize;
|
||||
use store::write::Archiver;
|
||||
|
||||
pub const ACME_TLS_ALPN_NAME: &[u8] = b"acme-tls/1";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AcmeRequestBuilder {
|
||||
pub key_pair: EcdsaKeyPair,
|
||||
pub directory: Directory,
|
||||
pub kid: String,
|
||||
pub challenge: ChallengeType,
|
||||
pub max_retries: u32,
|
||||
pub preferred_chain: Option<String>,
|
||||
}
|
||||
|
||||
pub struct AcmeResponse<L, B> {
|
||||
pub location: L,
|
||||
pub body: B,
|
||||
pub retry_after: Option<Duration>,
|
||||
pub alternates: Vec<String>,
|
||||
}
|
||||
|
||||
static ALG: &EcdsaSigningAlgorithm = &ECDSA_P256_SHA256_FIXED_SIGNING;
|
||||
|
||||
impl AcmeRequestBuilder {
|
||||
pub async fn new(provider: AcmeProvider) -> AcmeResult<Self> {
|
||||
let directory =
|
||||
Directory::discover(&provider.directory, provider.max_retries as u32).await?;
|
||||
let key_pair = EcdsaKeyPair::from_pkcs8(
|
||||
ALG,
|
||||
&URL_SAFE_NO_PAD
|
||||
.decode(&provider.account_key)
|
||||
.map_err(|err| {
|
||||
AcmeError::Crypto(format!("Failed to decode account key: {}", err))
|
||||
})?,
|
||||
)
|
||||
.map_err(|err| AcmeError::Crypto(format!("Failed to create ECDSA key pair: {}", err)))?;
|
||||
|
||||
Ok(Self {
|
||||
key_pair,
|
||||
directory,
|
||||
kid: provider.account_uri,
|
||||
challenge: provider.challenge_type.into(),
|
||||
max_retries: provider.max_retries as u32,
|
||||
preferred_chain: provider.preferred_chain,
|
||||
})
|
||||
}
|
||||
|
||||
async fn request(
|
||||
&self,
|
||||
url: impl AsRef<str>,
|
||||
payload: &str,
|
||||
) -> AcmeResult<AcmeResponse<Option<String>, String>> {
|
||||
let body = sign(
|
||||
&self.key_pair,
|
||||
Some(&self.kid),
|
||||
self.directory.nonce(self.max_retries).await?,
|
||||
url.as_ref(),
|
||||
payload,
|
||||
)?;
|
||||
let response = https(url.as_ref(), Method::POST, Some(body), self.max_retries).await?;
|
||||
|
||||
Ok(AcmeResponse {
|
||||
location: get_header(&response, "Location").ok(),
|
||||
retry_after: parse_retry_after(&response),
|
||||
alternates: parse_alternate_links(&response),
|
||||
body: response.text().await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn new_order(&self, domains: Vec<String>) -> AcmeResult<AcmeResponse<String, Order>> {
|
||||
let domains: Vec<Identifier> = domains.into_iter().map(Identifier::Dns).collect();
|
||||
let payload = json!({
|
||||
"identifiers": domains,
|
||||
})
|
||||
.to_string();
|
||||
let response = self.request(&self.directory.new_order, &payload).await?;
|
||||
Ok(AcmeResponse {
|
||||
location: response.location.ok_or(AcmeError::Invalid(format!(
|
||||
"Missing Location header in new order response from {}",
|
||||
self.directory.new_order
|
||||
)))?,
|
||||
body: serde_json::from_str(&response.body).map_err(AcmeError::Json)?,
|
||||
retry_after: response.retry_after,
|
||||
alternates: response.alternates,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn auth(
|
||||
&self,
|
||||
url: impl AsRef<str>,
|
||||
) -> AcmeResult<AcmeResponse<Option<String>, Auth>> {
|
||||
AcmeResponse::parse(self.request(url, "").await?)
|
||||
}
|
||||
|
||||
pub async fn challenge(&self, url: impl AsRef<str>) -> AcmeResult<()> {
|
||||
self.request(&url, "{}").await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn order(
|
||||
&self,
|
||||
url: impl AsRef<str>,
|
||||
) -> AcmeResult<AcmeResponse<Option<String>, Order>> {
|
||||
AcmeResponse::parse(self.request(&url, "").await?)
|
||||
}
|
||||
|
||||
pub async fn finalize(
|
||||
&self,
|
||||
url: impl AsRef<str>,
|
||||
csr: Vec<u8>,
|
||||
) -> AcmeResult<AcmeResponse<Option<String>, Order>> {
|
||||
let payload = format!("{{\"csr\":\"{}\"}}", URL_SAFE_NO_PAD.encode(csr));
|
||||
AcmeResponse::parse(self.request(&url, &payload).await?)
|
||||
}
|
||||
|
||||
pub async fn certificate(
|
||||
&self,
|
||||
url: impl AsRef<str>,
|
||||
) -> AcmeResult<AcmeResponse<Option<String>, String>> {
|
||||
self.request(&url, "").await
|
||||
}
|
||||
|
||||
pub fn http_proof(&self, challenge: &Challenge) -> AcmeResult<Vec<u8>> {
|
||||
let challenge_token = challenge.token.as_deref().ok_or_else(|| {
|
||||
AcmeError::Invalid("Missing http-01 challenge token in response".to_string())
|
||||
})?;
|
||||
key_authorization(&self.key_pair, challenge_token).map(|key| key.into_bytes())
|
||||
}
|
||||
|
||||
pub fn dns_proof(&self, challenge: &Challenge) -> AcmeResult<String> {
|
||||
let challenge_token = challenge.token.as_deref().ok_or_else(|| {
|
||||
AcmeError::Invalid("Missing dns-01 challenge token in response".to_string())
|
||||
})?;
|
||||
key_authorization_sha256_base64(&self.key_pair, challenge_token)
|
||||
}
|
||||
|
||||
pub fn tls_alpn_key(&self, challenge: &Challenge, domain: String) -> AcmeResult<Vec<u8>> {
|
||||
let challenge_token = challenge.token.as_deref().ok_or_else(|| {
|
||||
AcmeError::Invalid("Missing tls-alpn-01 challenge token in response".to_string())
|
||||
})?;
|
||||
let mut params = rcgen::CertificateParams::new(vec![domain]).map_err(|err| {
|
||||
AcmeError::Crypto(format!("Failed to create certificate params: {}", err))
|
||||
})?;
|
||||
let key_auth = key_authorization_sha256(&self.key_pair, challenge_token)?;
|
||||
params.custom_extensions = vec![CustomExtension::new_acme_identifier(key_auth.as_ref())];
|
||||
let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)
|
||||
.map_err(|err| AcmeError::Crypto(format!("Failed to generate key pair: {}", err)))?;
|
||||
let cert = params.self_signed(&key_pair).map_err(|err| {
|
||||
AcmeError::Crypto(format!(
|
||||
"Failed to generate TLS-ALPN-01 certificate: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
|
||||
Archiver::new(SerializedCert {
|
||||
certificate: cert.der().to_vec(),
|
||||
private_key: key_pair.serialize_der(),
|
||||
})
|
||||
.untrusted()
|
||||
.serialize()
|
||||
.map_err(|_| AcmeError::Crypto("Failed to serialize certificate".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Directory {
|
||||
pub async fn discover(url: impl AsRef<str>, max_retries: u32) -> AcmeResult<Self> {
|
||||
serde_json::from_str(
|
||||
&https(url, Method::GET, None, max_retries)
|
||||
.await?
|
||||
.text()
|
||||
.await?,
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn nonce(&self, max_retries: u32) -> AcmeResult<String> {
|
||||
get_header(
|
||||
&https(&self.new_nonce.as_str(), Method::HEAD, None, max_retries).await?,
|
||||
"replay-nonce",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<L, T: DeserializeOwned> AcmeResponse<L, T> {
|
||||
pub fn parse(input: AcmeResponse<L, String>) -> AcmeResult<AcmeResponse<L, T>> {
|
||||
serde_json::from_str(&input.body)
|
||||
.map_err(|err| {
|
||||
AcmeError::Invalid(format!(
|
||||
"ACME response parsing error: {}, body: {}",
|
||||
err, input.body
|
||||
))
|
||||
})
|
||||
.map(|body| AcmeResponse {
|
||||
location: input.location,
|
||||
body,
|
||||
retry_after: input.retry_after,
|
||||
alternates: input.alternates,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<L, T> AcmeResponse<L, T> {
|
||||
pub fn assert_reasonable_retry_after(self, max_retries: u32) -> AcmeResult<Self> {
|
||||
if let Some(retry_after) = self.retry_after
|
||||
&& retry_after > Duration::from_secs(10 * 60)
|
||||
{
|
||||
trc::event!(
|
||||
Acme(trc::AcmeEvent::RenewBackoff),
|
||||
Elapsed = retry_after,
|
||||
Reason = "ACME server requested an excessively long Retry-After",
|
||||
);
|
||||
|
||||
return Err(AcmeError::Backoff {
|
||||
max_retries,
|
||||
wait: retry_after.into(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl ChallengeType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Http01 => "http-01",
|
||||
Self::Dns01 => "dns-01",
|
||||
Self::TlsAlpn01 => "tls-alpn-01",
|
||||
Self::DnsPersist01 => "dns-persist-01",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Valid => "valid",
|
||||
Self::Invalid => "invalid",
|
||||
Self::Revoked => "revoked",
|
||||
Self::Expired => "expired",
|
||||
Self::Deactivated => "deactivated",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::network::acme::{AcmeError, AcmeResult};
|
||||
use chrono::{DateTime, Utc};
|
||||
use hyper::{
|
||||
Method, StatusCode,
|
||||
header::{CONTENT_TYPE, USER_AGENT},
|
||||
};
|
||||
use reqwest::Response;
|
||||
use std::time::Duration;
|
||||
|
||||
#[allow(unused_mut)]
|
||||
pub(crate) async fn https(
|
||||
url: impl AsRef<str>,
|
||||
method: Method,
|
||||
body: Option<String>,
|
||||
max_retries: u32,
|
||||
) -> AcmeResult<Response> {
|
||||
let url = url.as_ref();
|
||||
|
||||
#[allow(unused_mut)]
|
||||
#[allow(unused_assignments)]
|
||||
let mut allow_invalid_certs = false;
|
||||
|
||||
#[cfg(any(feature = "dev_mode", feature = "test_mode"))]
|
||||
{
|
||||
allow_invalid_certs =
|
||||
url.starts_with("https://localhost") || url.starts_with("https://127.0.0.1");
|
||||
}
|
||||
|
||||
let mut request = utils::http::http1_client_builder(allow_invalid_certs)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()?
|
||||
.request(method, url)
|
||||
.header(USER_AGENT, crate::USER_AGENT);
|
||||
|
||||
if let Some(body) = body {
|
||||
request = request
|
||||
.header(CONTENT_TYPE, "application/jose+json")
|
||||
.body(body);
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
if response.status().is_success() {
|
||||
Ok(response)
|
||||
} else if matches!(
|
||||
response.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE
|
||||
) {
|
||||
let wait = parse_retry_after(&response);
|
||||
|
||||
trc::event!(
|
||||
Acme(trc::AcmeEvent::RenewBackoff),
|
||||
Url = url.to_string(),
|
||||
Code = response.status().as_u16(),
|
||||
Elapsed = wait.unwrap_or_default(),
|
||||
);
|
||||
|
||||
Err(AcmeError::Backoff { wait, max_retries })
|
||||
} else {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
Err(AcmeError::HttpStatus(format!(
|
||||
"Unexpected status {}: {}",
|
||||
status, text
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_header(response: &Response, header: &'static str) -> AcmeResult<String> {
|
||||
match response.headers().get_all(header).iter().next_back() {
|
||||
Some(value) => Ok(value
|
||||
.to_str()
|
||||
.map_err(|err| {
|
||||
AcmeError::Invalid(format!("Failed to read header {}: {}", header, err))
|
||||
})?
|
||||
.to_string()),
|
||||
None => Err(AcmeError::Invalid(format!("Missing header: {}", header))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_alternate_links(response: &Response) -> Vec<String> {
|
||||
alternate_links(
|
||||
response
|
||||
.headers()
|
||||
.get_all("Link")
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok()),
|
||||
)
|
||||
}
|
||||
|
||||
fn alternate_links<'a>(values: impl Iterator<Item = &'a str>) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
for value in values {
|
||||
for link in value.split(',') {
|
||||
let mut url = None;
|
||||
let mut is_alternate = false;
|
||||
for (index, part) in link.split(';').enumerate() {
|
||||
let part = part.trim();
|
||||
if index == 0 {
|
||||
url = part
|
||||
.strip_prefix('<')
|
||||
.and_then(|part| part.strip_suffix('>'));
|
||||
} else if let Some(rel) = part.strip_prefix("rel=") {
|
||||
is_alternate = rel.trim_matches('"') == "alternate";
|
||||
}
|
||||
}
|
||||
if is_alternate && let Some(url) = url {
|
||||
urls.push(url.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
urls
|
||||
}
|
||||
|
||||
pub(crate) fn parse_retry_after(response: &Response) -> Option<Duration> {
|
||||
let value = response.headers().get("Retry-After")?.to_str().ok()?;
|
||||
if let Ok(secs) = value.parse::<u64>() {
|
||||
Some(Duration::from_secs(secs + 1))
|
||||
} else if let Ok(dt) = DateTime::parse_from_rfc2822(value) {
|
||||
Utc::now()
|
||||
.signed_duration_since(dt.with_timezone(&Utc))
|
||||
.to_std()
|
||||
.map(|dur| dur + Duration::from_secs(1))
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::alternate_links;
|
||||
|
||||
#[test]
|
||||
fn parses_single_alternate_link() {
|
||||
let links =
|
||||
alternate_links([r#"<https://acme.example/cert/1/1>;rel="alternate""#].into_iter());
|
||||
assert_eq!(links, vec!["https://acme.example/cert/1/1".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_alternates_in_one_header() {
|
||||
let links = alternate_links(
|
||||
[r#"<https://acme.example/cert/1/1>;rel="alternate", <https://acme.example/cert/1/2>;rel="alternate""#]
|
||||
.into_iter(),
|
||||
);
|
||||
assert_eq!(
|
||||
links,
|
||||
vec![
|
||||
"https://acme.example/cert/1/1".to_string(),
|
||||
"https://acme.example/cert/1/2".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_alternates_across_multiple_headers() {
|
||||
let links = alternate_links(
|
||||
[
|
||||
r#"<https://acme.example/cert/1/1>;rel="alternate""#,
|
||||
r#"<https://acme.example/cert/1/2>;rel="alternate""#,
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
assert_eq!(
|
||||
links,
|
||||
vec![
|
||||
"https://acme.example/cert/1/1".to_string(),
|
||||
"https://acme.example/cert/1/2".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_alternate_relations() {
|
||||
let links = alternate_links(
|
||||
[r#"<https://acme.example/index>;rel="index", <https://acme.example/cert/1/1>;rel="alternate""#]
|
||||
.into_iter(),
|
||||
);
|
||||
assert_eq!(links, vec!["https://acme.example/cert/1/1".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_unquoted_rel_and_extra_whitespace() {
|
||||
let links =
|
||||
alternate_links([r#" <https://acme.example/cert/1/1> ; rel=alternate "#].into_iter());
|
||||
assert_eq!(links, vec!["https://acme.example/cert/1/1".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_empty_when_no_alternates() {
|
||||
let links = alternate_links([r#"<https://acme.example/dir>;rel="index""#].into_iter());
|
||||
assert!(links.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
|
||||
|
||||
use crate::network::acme::{AcmeError, AcmeResult};
|
||||
use aws_lc_rs::digest::{Digest, SHA256, digest};
|
||||
use aws_lc_rs::hmac;
|
||||
use aws_lc_rs::rand::SystemRandom;
|
||||
use aws_lc_rs::signature::{EcdsaKeyPair, KeyPair};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use serde::Serialize;
|
||||
|
||||
pub(crate) fn sign(
|
||||
key: &EcdsaKeyPair,
|
||||
kid: Option<&str>,
|
||||
nonce: String,
|
||||
url: &str,
|
||||
payload: &str,
|
||||
) -> AcmeResult<String> {
|
||||
let jwk = match kid {
|
||||
None => Some(Jwk::new(key)),
|
||||
Some(_) => None,
|
||||
};
|
||||
let protected = Protected::encode("ES256", jwk, kid, nonce.into(), url)?;
|
||||
let payload = URL_SAFE_NO_PAD.encode(payload);
|
||||
let combined = format!("{}.{}", protected, payload);
|
||||
let signature = key
|
||||
.sign(&SystemRandom::new(), combined.as_bytes())
|
||||
.map_err(|err| AcmeError::Crypto(format!("Failed to sign payload: {}", err)))?;
|
||||
|
||||
serde_json::to_string(&Body {
|
||||
protected,
|
||||
payload,
|
||||
signature: URL_SAFE_NO_PAD.encode(signature.as_ref()),
|
||||
})
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub(crate) fn eab_sign(
|
||||
key: &EcdsaKeyPair,
|
||||
kid: &str,
|
||||
hmac_key: &[u8],
|
||||
url: &str,
|
||||
) -> AcmeResult<Body> {
|
||||
let protected = Protected::encode("HS256", None, kid.into(), None, url)?;
|
||||
let payload = Jwk::new(key).base64()?;
|
||||
let combined = format!("{}.{}", protected, payload);
|
||||
|
||||
let key = hmac::Key::new(hmac::HMAC_SHA256, hmac_key);
|
||||
let tag = hmac::sign(&key, combined.as_bytes());
|
||||
let signature = URL_SAFE_NO_PAD.encode(tag.as_ref());
|
||||
|
||||
Ok(Body {
|
||||
protected,
|
||||
payload,
|
||||
signature,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn key_authorization(key: &EcdsaKeyPair, token: &str) -> AcmeResult<String> {
|
||||
Ok(format!(
|
||||
"{}.{}",
|
||||
token,
|
||||
Jwk::new(key).thumb_sha256_base64()?
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn key_authorization_sha256(key: &EcdsaKeyPair, token: &str) -> AcmeResult<Digest> {
|
||||
key_authorization(key, token).map(|s| digest(&SHA256, s.as_bytes()))
|
||||
}
|
||||
|
||||
pub(crate) fn key_authorization_sha256_base64(
|
||||
key: &EcdsaKeyPair,
|
||||
token: &str,
|
||||
) -> AcmeResult<String> {
|
||||
key_authorization_sha256(key, token).map(|s| URL_SAFE_NO_PAD.encode(s.as_ref()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct Body {
|
||||
protected: String,
|
||||
payload: String,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Protected<'a> {
|
||||
alg: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
jwk: Option<Jwk>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
kid: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
nonce: Option<String>,
|
||||
url: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> Protected<'a> {
|
||||
fn encode(
|
||||
alg: &'static str,
|
||||
jwk: Option<Jwk>,
|
||||
kid: Option<&'a str>,
|
||||
nonce: Option<String>,
|
||||
url: &'a str,
|
||||
) -> AcmeResult<String> {
|
||||
serde_json::to_vec(&Protected {
|
||||
alg,
|
||||
jwk,
|
||||
kid,
|
||||
nonce,
|
||||
url,
|
||||
})
|
||||
.map_err(Into::into)
|
||||
.map(|v| URL_SAFE_NO_PAD.encode(v.as_slice()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Jwk {
|
||||
alg: &'static str,
|
||||
crv: &'static str,
|
||||
kty: &'static str,
|
||||
#[serde(rename = "use")]
|
||||
u: &'static str,
|
||||
x: String,
|
||||
y: String,
|
||||
}
|
||||
|
||||
impl Jwk {
|
||||
pub(crate) fn new(key: &EcdsaKeyPair) -> Self {
|
||||
let (x, y) = key.public_key().as_ref()[1..].split_at(32);
|
||||
Self {
|
||||
alg: "ES256",
|
||||
crv: "P-256",
|
||||
kty: "EC",
|
||||
u: "sig",
|
||||
x: URL_SAFE_NO_PAD.encode(x),
|
||||
y: URL_SAFE_NO_PAD.encode(y),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn base64(&self) -> AcmeResult<String> {
|
||||
serde_json::to_vec(self)
|
||||
.map_err(Into::into)
|
||||
.map(|v| URL_SAFE_NO_PAD.encode(v.as_slice()))
|
||||
}
|
||||
|
||||
pub(crate) fn thumb_sha256_base64(&self) -> AcmeResult<String> {
|
||||
Ok(URL_SAFE_NO_PAD.encode(digest(
|
||||
&SHA256,
|
||||
&serde_json::to_vec(&JwkThumb {
|
||||
crv: self.crv,
|
||||
kty: self.kty,
|
||||
x: &self.x,
|
||||
y: &self.y,
|
||||
})
|
||||
.map_err(AcmeError::Json)?,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct JwkThumb<'a> {
|
||||
crv: &'a str,
|
||||
kty: &'a str,
|
||||
x: &'a str,
|
||||
y: &'a str,
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod account;
|
||||
pub mod directory;
|
||||
pub mod http;
|
||||
pub mod jose;
|
||||
pub mod order;
|
||||
pub mod renew;
|
||||
pub mod resolver;
|
||||
|
||||
use crate::network::dns::update::DnsUpdater;
|
||||
use chrono::{DateTime, Utc};
|
||||
use registry::schema::enums::AcmeChallengeType;
|
||||
use rustls::sign::CertifiedKey;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
fmt::{Debug, Display},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use store::registry::write::RegistryWriteResult;
|
||||
|
||||
pub type AcmeResult<T> = Result<T, AcmeError>;
|
||||
|
||||
pub enum AcmeError {
|
||||
Http(reqwest::Error),
|
||||
HttpStatus(String),
|
||||
Json(serde_json::Error),
|
||||
Crypto(String),
|
||||
Invalid(String),
|
||||
NotDue(String),
|
||||
Dns(String),
|
||||
AuthInvalid(String),
|
||||
OrderInvalid(String),
|
||||
ChallengeNotSupported {
|
||||
requested: ChallengeType,
|
||||
supported: Vec<Challenge>,
|
||||
},
|
||||
Internal(trc::Error),
|
||||
Registry(RegistryWriteResult),
|
||||
OrderTimeout {
|
||||
max_retries: u32,
|
||||
},
|
||||
AuthTimeout {
|
||||
max_retries: u32,
|
||||
},
|
||||
Backoff {
|
||||
max_retries: u32,
|
||||
wait: Option<Duration>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(
|
||||
rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug, Clone, serde::Serialize, Deserialize,
|
||||
)]
|
||||
pub struct SerializedCert {
|
||||
pub certificate: Vec<u8>,
|
||||
pub private_key: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct PemCert {
|
||||
pub certificate: String,
|
||||
pub private_key: String,
|
||||
}
|
||||
|
||||
pub struct ParsedCert {
|
||||
pub sans: Vec<String>,
|
||||
pub issuer: String,
|
||||
pub valid_not_before: DateTime<Utc>,
|
||||
pub valid_not_after: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Directory {
|
||||
pub new_nonce: String,
|
||||
pub new_account: String,
|
||||
pub new_order: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Eq, PartialEq, Clone, Copy)]
|
||||
pub enum ChallengeType {
|
||||
#[serde(rename = "http-01")]
|
||||
Http01,
|
||||
#[serde(rename = "dns-01")]
|
||||
Dns01,
|
||||
#[serde(rename = "dns-persist-01")]
|
||||
DnsPersist01,
|
||||
#[serde(rename = "tls-alpn-01")]
|
||||
TlsAlpn01,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Order {
|
||||
#[serde(flatten)]
|
||||
pub status: OrderStatus,
|
||||
pub authorizations: Vec<String>,
|
||||
pub finalize: String,
|
||||
pub error: Option<Problem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum OrderStatus {
|
||||
Pending,
|
||||
Ready,
|
||||
Valid { certificate: String },
|
||||
Invalid,
|
||||
Processing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Auth {
|
||||
pub status: AuthStatus,
|
||||
pub identifier: Identifier,
|
||||
pub challenges: Vec<Challenge>,
|
||||
pub wildcard: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AuthStatus {
|
||||
Pending,
|
||||
Valid,
|
||||
Invalid,
|
||||
Revoked,
|
||||
Expired,
|
||||
Deactivated,
|
||||
}
|
||||
|
||||
pub struct AcmeDnsParameters {
|
||||
pub updater: DnsUpdater,
|
||||
pub origin: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "value", rename_all = "camelCase")]
|
||||
pub enum Identifier {
|
||||
Dns(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Challenge {
|
||||
#[serde(rename = "type")]
|
||||
pub typ: ChallengeType,
|
||||
pub url: String,
|
||||
pub token: Option<String>,
|
||||
pub error: Option<Problem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Problem {
|
||||
#[serde(rename = "type")]
|
||||
pub typ: Option<String>,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
pub struct StaticResolver {
|
||||
pub key: Option<Arc<CertifiedKey>>,
|
||||
}
|
||||
|
||||
impl Debug for StaticResolver {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("StaticResolver").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for AcmeError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
AcmeError::Http(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for AcmeError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
AcmeError::Json(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<trc::Error> for AcmeError {
|
||||
fn from(err: trc::Error) -> Self {
|
||||
AcmeError::Internal(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AcmeChallengeType> for ChallengeType {
|
||||
fn from(value: AcmeChallengeType) -> Self {
|
||||
match value {
|
||||
AcmeChallengeType::Http01 => ChallengeType::Http01,
|
||||
AcmeChallengeType::Dns01 => ChallengeType::Dns01,
|
||||
AcmeChallengeType::TlsAlpn01 => ChallengeType::TlsAlpn01,
|
||||
AcmeChallengeType::DnsPersist01 => ChallengeType::DnsPersist01,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for AuthStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AuthStatus::Pending => write!(f, "pending"),
|
||||
AuthStatus::Valid => write!(f, "valid"),
|
||||
AuthStatus::Invalid => write!(f, "invalid"),
|
||||
AuthStatus::Revoked => write!(f, "revoked"),
|
||||
AuthStatus::Expired => write!(f, "expired"),
|
||||
AuthStatus::Deactivated => write!(f, "deactivated"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Auth {
|
||||
pub fn to_error(&self) -> String {
|
||||
let mut errors = format!("Status: {}", self.status);
|
||||
for challenge in &self.challenges {
|
||||
if let Some(error) = &challenge.error {
|
||||
errors.push_str(&format!(
|
||||
"; Challenge type: {}, error: {}",
|
||||
challenge.typ.as_str(),
|
||||
error
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
||||
impl Identifier {
|
||||
pub fn hostname(&self) -> &str {
|
||||
match self {
|
||||
Identifier::Dns(hostname) => hostname,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Problem {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(typ) = &self.typ {
|
||||
if let Some(detail) = &self.detail {
|
||||
write!(f, "{}: {}", typ, detail)
|
||||
} else {
|
||||
write!(f, "{}", typ)
|
||||
}
|
||||
} else if let Some(detail) = &self.detail {
|
||||
write!(f, "{}", detail)
|
||||
} else {
|
||||
write!(f, "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for AcmeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AcmeError::Http(err) => write!(f, "HTTP error: {}", err),
|
||||
AcmeError::HttpStatus(status) => write!(f, "HTTP error: {}", status),
|
||||
AcmeError::Json(err) => write!(f, "JSON error: {}", err),
|
||||
AcmeError::Dns(err) => write!(f, "DNS error: {}", err),
|
||||
AcmeError::Crypto(err) => write!(f, "Cryptographic error: {}", err),
|
||||
AcmeError::Invalid(err) => write!(f, "Invalid request: {}", err),
|
||||
AcmeError::NotDue(err) => write!(f, "{}", err),
|
||||
AcmeError::AuthInvalid(status) => write!(f, "Authentication failed: {:?}", status),
|
||||
AcmeError::OrderTimeout { .. } => write!(f, "Order processing timed out"),
|
||||
AcmeError::OrderInvalid(reason) => write!(f, "Order is invalid: {}", reason),
|
||||
AcmeError::AuthTimeout { .. } => write!(f, "Authentication timed out"),
|
||||
AcmeError::ChallengeNotSupported {
|
||||
requested,
|
||||
supported,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"Challenge type {:?} not supported. Supported types: {:?}",
|
||||
requested, supported
|
||||
)
|
||||
}
|
||||
AcmeError::Internal(err) => write!(f, "Internal error: {}", err),
|
||||
AcmeError::Registry(err) => write!(f, "Registry error: {:?}", err),
|
||||
AcmeError::Backoff { wait, .. } => {
|
||||
if let Some(time) = wait {
|
||||
write!(f, "Rate limited. Retry after {} seconds", time.as_secs())
|
||||
} else {
|
||||
write!(f, "Rate limited. Retry after some time")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
|
||||
|
||||
use crate::network::acme::directory::AcmeRequestBuilder;
|
||||
use crate::network::acme::{
|
||||
AcmeDnsParameters, AcmeError, AcmeResult, AuthStatus, ChallengeType, Identifier, OrderStatus,
|
||||
ParsedCert, PemCert,
|
||||
};
|
||||
use crate::{KV_ACME, Server};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use dns_update::DnsRecord;
|
||||
use futures::future::try_join_all;
|
||||
use rcgen::{CertificateParams, DistinguishedName, KeyPair, PKCS_ECDSA_P256_SHA256};
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::time::Duration;
|
||||
use store::dispatch::lookup::KeyValue;
|
||||
use trc::AcmeEvent;
|
||||
use x509_parser::parse_x509_certificate;
|
||||
use x509_parser::prelude::{GeneralName, ParsedExtension};
|
||||
|
||||
const HOSTNAMES: &[&str] = &["mta-sts", "ua-auto-config", "autoconfig", "autodiscover"];
|
||||
|
||||
impl AcmeRequestBuilder {
|
||||
pub fn build_domains(
|
||||
&self,
|
||||
server: &Server,
|
||||
domain: &str,
|
||||
hostnames: &[String],
|
||||
) -> Vec<String> {
|
||||
if hostnames.is_empty() {
|
||||
if matches!(
|
||||
self.challenge,
|
||||
ChallengeType::Dns01 | ChallengeType::DnsPersist01
|
||||
) {
|
||||
vec![format!("*.{domain}"), domain.to_string()]
|
||||
} else {
|
||||
let server_name = server.core.network.server_name.as_str();
|
||||
let domain_suffix = format!(".{domain}");
|
||||
let matches_zone = |name: &str| name == domain || name.ends_with(&domain_suffix);
|
||||
|
||||
// Add technical domains
|
||||
let mut domains = HOSTNAMES
|
||||
.iter()
|
||||
.map(|hostname| format!("{hostname}.{domain}"))
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
// Add server name if it matches the domain (including the apex itself)
|
||||
if matches_zone(server_name) {
|
||||
domains.insert(server_name.to_string());
|
||||
}
|
||||
|
||||
// Add mail exchangers
|
||||
for exchanger in &server.core.network.info.mxs {
|
||||
if let Some(exchanger) = &exchanger.hostname
|
||||
&& matches_zone(exchanger)
|
||||
{
|
||||
domains.insert(exchanger.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Add service hosts
|
||||
for (_, service) in &server.core.network.info.services {
|
||||
if let Some(service) = &service.hostname
|
||||
&& matches_zone(service)
|
||||
{
|
||||
domains.insert(service.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
domains.into_iter().collect()
|
||||
}
|
||||
} else {
|
||||
hostnames
|
||||
.iter()
|
||||
.map(|h| {
|
||||
if h.contains('.') {
|
||||
h.clone()
|
||||
} else {
|
||||
format!("{h}.{domain}")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn renew(
|
||||
&self,
|
||||
server: &Server,
|
||||
domains: Vec<String>,
|
||||
reuse_key_pem: Option<String>,
|
||||
dns_parameters: Option<AcmeDnsParameters>,
|
||||
) -> AcmeResult<PemCert> {
|
||||
let mut params = CertificateParams::new(domains.clone()).map_err(|err| {
|
||||
AcmeError::Crypto(format!("Failed to create certificate params: {}", err))
|
||||
})?;
|
||||
params.distinguished_name = DistinguishedName::new();
|
||||
let key_pair = match reuse_key_pem {
|
||||
Some(pem) => KeyPair::from_pem(&pem).map_err(|err| {
|
||||
AcmeError::Crypto(format!("Failed to load private key for reuse: {}", err))
|
||||
})?,
|
||||
None => KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).map_err(|err| {
|
||||
AcmeError::Crypto(format!("Failed to generate key pair: {}", err))
|
||||
})?,
|
||||
};
|
||||
let response = self.new_order(domains.clone()).await?;
|
||||
let order_url = response.location;
|
||||
let mut order = response.body;
|
||||
let mut retry_after = None;
|
||||
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::OrderStart),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Details = order_url.to_string(),
|
||||
Hostname = domains.as_slice(),
|
||||
Type = self.challenge.as_str(),
|
||||
);
|
||||
|
||||
loop {
|
||||
match order.status {
|
||||
OrderStatus::Pending => {
|
||||
if matches!(self.challenge, ChallengeType::Dns01) {
|
||||
for url in &order.authorizations {
|
||||
self.authorize(server, url, dns_parameters.as_ref()).await?;
|
||||
}
|
||||
} else {
|
||||
let auth_futures = order
|
||||
.authorizations
|
||||
.iter()
|
||||
.map(|url| self.authorize(server, url, dns_parameters.as_ref()));
|
||||
try_join_all(auth_futures).await?;
|
||||
}
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::AuthCompleted),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Hostname = domains.as_slice(),
|
||||
);
|
||||
let response = self.order(&order_url).await?;
|
||||
order = response.body;
|
||||
retry_after = response.retry_after;
|
||||
}
|
||||
OrderStatus::Processing => {
|
||||
for i in 0u64..10 {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::OrderProcessing),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Hostname = domains.as_slice(),
|
||||
Total = i,
|
||||
);
|
||||
|
||||
tokio::time::sleep(
|
||||
retry_after.unwrap_or_else(|| Duration::from_secs(1u64 << i)),
|
||||
)
|
||||
.await;
|
||||
let response = self
|
||||
.order(&order_url)
|
||||
.await?
|
||||
.assert_reasonable_retry_after(self.max_retries)?;
|
||||
order = response.body;
|
||||
retry_after = response.retry_after;
|
||||
if order.status != OrderStatus::Processing {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if order.status == OrderStatus::Processing {
|
||||
return Err(AcmeError::OrderTimeout {
|
||||
max_retries: self.max_retries,
|
||||
});
|
||||
}
|
||||
}
|
||||
OrderStatus::Ready => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::OrderReady),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Hostname = domains.as_slice(),
|
||||
);
|
||||
|
||||
let csr = params.serialize_request(&key_pair).map_err(|err| {
|
||||
AcmeError::Crypto(format!("Failed to serialize CSR: {}", err))
|
||||
})?;
|
||||
let csr = csr.der().to_vec();
|
||||
order = self.finalize(order.finalize, csr).await?.body;
|
||||
}
|
||||
OrderStatus::Valid { certificate } => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::OrderValid),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Hostname = domains.as_slice(),
|
||||
);
|
||||
|
||||
let certificate = self.select_certificate(&domains, certificate).await?;
|
||||
|
||||
return Ok(PemCert {
|
||||
certificate,
|
||||
private_key: key_pair.serialize_pem(),
|
||||
});
|
||||
}
|
||||
OrderStatus::Invalid => {
|
||||
let reason = if let Some(reason) = order.error {
|
||||
reason.to_string()
|
||||
} else {
|
||||
"Unknown reason".to_string()
|
||||
};
|
||||
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::OrderInvalid),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Details = order_url.to_string(),
|
||||
Hostname = domains.as_slice(),
|
||||
Reason = reason.clone(),
|
||||
);
|
||||
|
||||
return Err(AcmeError::OrderInvalid(reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn authorize(
|
||||
&self,
|
||||
server: &Server,
|
||||
url: &String,
|
||||
dns_parameters: Option<&AcmeDnsParameters>,
|
||||
) -> AcmeResult<()> {
|
||||
let response = self
|
||||
.auth(url)
|
||||
.await?
|
||||
.assert_reasonable_retry_after(self.max_retries)?;
|
||||
let mut retry_after = response.retry_after;
|
||||
let auth = response.body;
|
||||
|
||||
let (domain, challenge_url) = match auth.status {
|
||||
AuthStatus::Pending => {
|
||||
let Identifier::Dns(domain) = auth.identifier;
|
||||
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::AuthStart),
|
||||
Hostname = domain.to_string(),
|
||||
Type = self.challenge.as_str(),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
);
|
||||
|
||||
let challenge = auth
|
||||
.challenges
|
||||
.iter()
|
||||
.find(|c| c.typ == self.challenge)
|
||||
.ok_or(AcmeError::ChallengeNotSupported {
|
||||
requested: self.challenge,
|
||||
supported: auth.challenges.clone(),
|
||||
})?;
|
||||
|
||||
match &self.challenge {
|
||||
ChallengeType::TlsAlpn01 => {
|
||||
server
|
||||
.in_memory_store()
|
||||
.key_set(
|
||||
KeyValue::with_prefix(
|
||||
KV_ACME,
|
||||
&domain,
|
||||
self.tls_alpn_key(challenge, domain.clone())?,
|
||||
)
|
||||
.expires(3600),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ChallengeType::Http01 => {
|
||||
server
|
||||
.in_memory_store()
|
||||
.key_set(
|
||||
KeyValue::with_prefix(
|
||||
KV_ACME,
|
||||
challenge.token.as_deref().ok_or_else(|| {
|
||||
AcmeError::Invalid(
|
||||
"Missing http-01 challenge token in response"
|
||||
.to_string(),
|
||||
)
|
||||
})?,
|
||||
self.http_proof(challenge)?,
|
||||
)
|
||||
.expires(3600),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ChallengeType::Dns01 => {
|
||||
let dns_parameters = dns_parameters.unwrap();
|
||||
let domain = domain.strip_prefix("*.").unwrap_or(&domain);
|
||||
|
||||
let zone = dns_parameters
|
||||
.origin
|
||||
.as_deref()
|
||||
.or_else(|| psl::domain_str(domain))
|
||||
.unwrap_or(domain);
|
||||
|
||||
let proof = self.dns_proof(challenge)?;
|
||||
let challenge_name = format!("_acme-challenge.{}", domain);
|
||||
dns_parameters
|
||||
.updater
|
||||
.set_rrset(
|
||||
zone,
|
||||
&challenge_name,
|
||||
dns_update::DnsRecordType::TXT,
|
||||
vec![DnsRecord::TXT(proof.clone())],
|
||||
)
|
||||
.await
|
||||
.map_err(AcmeError::Dns)?;
|
||||
dns_parameters
|
||||
.updater
|
||||
.wait_for_txt_propagation(&challenge_name, zone, &proof)
|
||||
.await;
|
||||
}
|
||||
ChallengeType::DnsPersist01 => {}
|
||||
ChallengeType::Unknown => unreachable!(),
|
||||
}
|
||||
|
||||
self.challenge(&challenge.url).await?;
|
||||
(domain, challenge.url.clone())
|
||||
}
|
||||
AuthStatus::Valid => return Ok(()),
|
||||
_ => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::AuthError),
|
||||
Hostname = auth.identifier.hostname().to_string(),
|
||||
Type = self.challenge.as_str(),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Details = url.to_string(),
|
||||
Reason = auth.to_error(),
|
||||
);
|
||||
|
||||
return Err(AcmeError::AuthInvalid(auth.to_error()));
|
||||
}
|
||||
};
|
||||
|
||||
for i in 0u64..5 {
|
||||
tokio::time::sleep(retry_after.unwrap_or_else(|| Duration::from_secs(1u64 << i))).await;
|
||||
let response = self
|
||||
.auth(url)
|
||||
.await?
|
||||
.assert_reasonable_retry_after(self.max_retries)?;
|
||||
retry_after = response.retry_after;
|
||||
|
||||
match response.body.status {
|
||||
AuthStatus::Pending => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::AuthPending),
|
||||
Hostname = domain.to_string(),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Total = i,
|
||||
);
|
||||
|
||||
self.challenge(&challenge_url).await?
|
||||
}
|
||||
AuthStatus::Valid => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::AuthValid),
|
||||
Hostname = domain.to_string(),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::AuthError),
|
||||
Hostname = domain.to_string(),
|
||||
Type = self.challenge.as_str(),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Details = url.to_string(),
|
||||
Reason = response.body.to_error(),
|
||||
);
|
||||
|
||||
return Err(AcmeError::AuthInvalid(response.body.to_error()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::AuthTooManyAttempts),
|
||||
Hostname = domain.to_string(),
|
||||
Type = self.challenge.as_str(),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Details = url.to_string(),
|
||||
Total = 5u64,
|
||||
);
|
||||
|
||||
Err(AcmeError::AuthTimeout {
|
||||
max_retries: self.max_retries,
|
||||
})
|
||||
}
|
||||
|
||||
async fn select_certificate(&self, domains: &[String], url: String) -> AcmeResult<String> {
|
||||
let response = self.certificate(url).await?;
|
||||
let Some(preferred) = self.preferred_chain.as_deref() else {
|
||||
return Ok(response.body);
|
||||
};
|
||||
|
||||
if chain_matches(&response.body, preferred) {
|
||||
return Ok(response.body);
|
||||
}
|
||||
|
||||
for alternate in &response.alternates {
|
||||
match self.certificate(alternate).await {
|
||||
Ok(alternate) if chain_matches(&alternate.body, preferred) => {
|
||||
return Ok(alternate.body);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::ProcessCert),
|
||||
Url = alternate.to_string(),
|
||||
Hostname = domains,
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::ProcessCert),
|
||||
Hostname = domains,
|
||||
Reason = format!(
|
||||
"Preferred certificate chain '{preferred}' not offered by the CA; using the default chain",
|
||||
),
|
||||
);
|
||||
|
||||
Ok(response.body)
|
||||
}
|
||||
}
|
||||
|
||||
fn chain_matches(pem_chain: &str, preferred: &str) -> bool {
|
||||
let Ok(blocks) = pem::parse_many(pem_chain) else {
|
||||
return false;
|
||||
};
|
||||
let Some(top) = blocks.last() else {
|
||||
return false;
|
||||
};
|
||||
let Ok((_, cert)) = parse_x509_certificate(top.contents()) else {
|
||||
return false;
|
||||
};
|
||||
cert.issuer()
|
||||
.iter_common_name()
|
||||
.filter_map(|cn| cn.as_str().ok())
|
||||
.any(|cn| cn == preferred)
|
||||
}
|
||||
|
||||
impl ParsedCert {
|
||||
pub fn parse(certificate: impl AsRef<[u8]>) -> AcmeResult<ParsedCert> {
|
||||
let der = pem::parse_many(certificate)
|
||||
.map_err(|err| AcmeError::Crypto(format!("Failed to parse PEM: {}", err)))?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| AcmeError::Crypto("No certificates found in PEM".to_string()))?;
|
||||
Self::parse_der(der.contents())
|
||||
}
|
||||
|
||||
pub fn parse_der(der: &[u8]) -> AcmeResult<ParsedCert> {
|
||||
parse_x509_certificate(der)
|
||||
.map_err(|err| AcmeError::Crypto(format!("Failed to parse X.509 certificate: {}", err)))
|
||||
.and_then(|(_, cert)| {
|
||||
// Add CNs and SANs to the list of names
|
||||
let mut names: BTreeSet<String> = BTreeSet::new();
|
||||
for name in cert.subject().iter_common_name() {
|
||||
if let Ok(name) = name.as_str() {
|
||||
names.insert(name.into());
|
||||
}
|
||||
}
|
||||
for ext in cert.extensions() {
|
||||
if let ParsedExtension::SubjectAlternativeName(san) = ext.parsed_extension() {
|
||||
for name in &san.general_names {
|
||||
let name = match name {
|
||||
GeneralName::DNSName(name) => (*name).into(),
|
||||
GeneralName::IPAddress(ip) => match ip.len() {
|
||||
4 => Ipv4Addr::from(<[u8; 4]>::try_from(*ip).unwrap())
|
||||
.to_string(),
|
||||
16 => Ipv6Addr::from(<[u8; 16]>::try_from(*ip).unwrap())
|
||||
.to_string(),
|
||||
_ => continue,
|
||||
},
|
||||
_ => {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
names.insert(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ParsedCert {
|
||||
sans: names.into_iter().collect(),
|
||||
issuer: cert.tbs_certificate.issuer().to_string(),
|
||||
valid_not_before: Utc
|
||||
.timestamp_opt(cert.tbs_certificate.validity().not_before.timestamp(), 0)
|
||||
.single()
|
||||
.ok_or_else(|| {
|
||||
AcmeError::Crypto(
|
||||
"Certificate not_before time is out of range".to_string(),
|
||||
)
|
||||
})?,
|
||||
valid_not_after: Utc
|
||||
.timestamp_opt(cert.tbs_certificate.validity().not_after.timestamp(), 0)
|
||||
.single()
|
||||
.ok_or_else(|| {
|
||||
AcmeError::Crypto(
|
||||
"Certificate not_after time is out of range".to_string(),
|
||||
)
|
||||
})?,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::chain_matches;
|
||||
use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, PKCS_ECDSA_P256_SHA256};
|
||||
|
||||
fn self_signed_pem(common_name: &str) -> String {
|
||||
let mut params = CertificateParams::new(vec!["host.example".to_string()]).unwrap();
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, common_name);
|
||||
params.distinguished_name = dn;
|
||||
let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
params.self_signed(&key_pair).unwrap().pem()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_top_certificate_issuer() {
|
||||
let chain = self_signed_pem("ISRG Root X1");
|
||||
assert!(chain_matches(&chain, "ISRG Root X1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_is_case_sensitive() {
|
||||
let chain = self_signed_pem("ISRG Root X1");
|
||||
assert!(!chain_matches(&chain, "isrg root x1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_is_exact_not_substring() {
|
||||
let chain = self_signed_pem("ISRG Root X10");
|
||||
assert!(!chain_matches(&chain, "ISRG Root X1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_match_unrelated_issuer() {
|
||||
let chain = self_signed_pem("ISRG Root X2");
|
||||
assert!(!chain_matches(&chain, "ISRG Root X1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_topmost_certificate_not_leaf() {
|
||||
let leaf = self_signed_pem("Leaf Issuer");
|
||||
let top = self_signed_pem("ISRG Root X1");
|
||||
let chain = format!("{leaf}{top}");
|
||||
assert!(chain_matches(&chain, "ISRG Root X1"));
|
||||
assert!(!chain_matches(&chain, "Leaf Issuer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unparseable_chain() {
|
||||
assert!(!chain_matches("not a pem", "ISRG Root X1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
Server,
|
||||
ipc::{BroadcastEvent, RegistryChange},
|
||||
network::acme::{
|
||||
AcmeDnsParameters, AcmeError, AcmeResult, ParsedCert, directory::AcmeRequestBuilder,
|
||||
},
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::{AcmeChallengeType, AcmeRenewBefore, DnsRecordType},
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
AcmeProvider, Certificate, CertificateManagement, DnsManagement, Domain, PublicText,
|
||||
PublicTextValue, SecretText, SecretTextValue, SystemSettings, Task, TaskDnsManagement,
|
||||
TaskDomainManagement, TaskStatus,
|
||||
},
|
||||
},
|
||||
types::{datetime::UTCDateTime, id::ObjectId, map::Map},
|
||||
};
|
||||
use store::{
|
||||
registry::{
|
||||
RegistryQuery,
|
||||
write::{RegistryWrite, RegistryWriteResult},
|
||||
},
|
||||
write::now,
|
||||
};
|
||||
use types::id::Id;
|
||||
|
||||
impl Server {
|
||||
pub async fn acme_renew(&self, domain_id: Id) -> AcmeResult<Vec<Task>> {
|
||||
let Some(domain) = self.registry().object::<Domain>(domain_id).await? else {
|
||||
return Err(AcmeError::Invalid(format!(
|
||||
"Domain with ID {} not found",
|
||||
domain_id
|
||||
)));
|
||||
};
|
||||
let cert = match domain.certificate_management {
|
||||
CertificateManagement::Manual => {
|
||||
return Err(AcmeError::Invalid(
|
||||
"ACME not configured for domain".to_string(),
|
||||
));
|
||||
}
|
||||
CertificateManagement::Automatic(props) => props,
|
||||
};
|
||||
let Some(acme_provider) = self
|
||||
.registry()
|
||||
.object::<AcmeProvider>(cert.acme_provider_id)
|
||||
.await?
|
||||
else {
|
||||
return Err(AcmeError::Invalid(format!(
|
||||
"ACME provider with ID {} not found",
|
||||
cert.acme_provider_id
|
||||
)));
|
||||
};
|
||||
let challenge_type = acme_provider.challenge_type;
|
||||
let renew_before = acme_provider.renew_before;
|
||||
let reuse_key = acme_provider.reuse_key;
|
||||
let request = AcmeRequestBuilder::new(acme_provider).await?;
|
||||
let domains = request.build_domains(
|
||||
self,
|
||||
&domain.name,
|
||||
&cert.subject_alternative_names.into_inner(),
|
||||
);
|
||||
|
||||
if let Some(renew_at) = self
|
||||
.acme_certificate_renewal_due(&domains, renew_before, now())
|
||||
.await?
|
||||
{
|
||||
return Err(AcmeError::NotDue(format!(
|
||||
"Certificate for domain {} is still valid; renewal is not due until {}",
|
||||
domain.name,
|
||||
UTCDateTime::from_timestamp(renew_at as i64)
|
||||
)));
|
||||
}
|
||||
|
||||
let dns_parameters = match &domain.dns_management {
|
||||
DnsManagement::Automatic(props) if challenge_type == AcmeChallengeType::Dns01 => {
|
||||
match self.build_dns_updater(props.dns_server_id).await? {
|
||||
Ok(updater) => Some(AcmeDnsParameters {
|
||||
updater,
|
||||
origin: props.origin.clone(),
|
||||
}),
|
||||
Err(err) => {
|
||||
return Err(AcmeError::Invalid(format!(
|
||||
"Failed to build DNS updater: {}",
|
||||
err
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if challenge_type == AcmeChallengeType::Dns01 && dns_parameters.is_none() {
|
||||
return Err(AcmeError::Invalid(
|
||||
"ACME provider requires DNS challenge but a DNS provider was not configured"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let reuse_key_pem = if reuse_key {
|
||||
match self.acme_certificate_by_domains(&domains).await? {
|
||||
Some(certificate) => certificate
|
||||
.private_key
|
||||
.secret()
|
||||
.await
|
||||
.map(std::borrow::Cow::into_owned)
|
||||
.map_err(|err| {
|
||||
AcmeError::Crypto(format!("Failed to load certificate private key: {err}"))
|
||||
})?
|
||||
.into(),
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let pem_cert = request
|
||||
.renew(self, domains, reuse_key_pem, dns_parameters)
|
||||
.await?;
|
||||
let parsed_cert = ParsedCert::parse(&pem_cert.certificate)?;
|
||||
let mut new_sans = parsed_cert.sans.clone();
|
||||
new_sans.sort();
|
||||
let certificate = Certificate {
|
||||
private_key: SecretText::Text(SecretTextValue {
|
||||
secret: pem_cert.private_key,
|
||||
}),
|
||||
certificate: PublicText::Text(PublicTextValue {
|
||||
value: pem_cert.certificate,
|
||||
}),
|
||||
issuer: parsed_cert.issuer,
|
||||
not_valid_after: UTCDateTime::from_timestamp(parsed_cert.valid_not_after.timestamp()),
|
||||
not_valid_before: UTCDateTime::from_timestamp(parsed_cert.valid_not_before.timestamp()),
|
||||
subject_alternative_names: Map::new(parsed_cert.sans),
|
||||
};
|
||||
let now = now();
|
||||
let expires_in = (parsed_cert.valid_not_after.timestamp() as u64).saturating_sub(now);
|
||||
if expires_in < 3600 {
|
||||
return Err(AcmeError::Invalid(format!(
|
||||
"Certificate expires in {} seconds, expected at least 3600 seconds",
|
||||
expires_in
|
||||
)));
|
||||
}
|
||||
|
||||
match self
|
||||
.registry()
|
||||
.write(RegistryWrite::insert(&certificate.into()))
|
||||
.await?
|
||||
{
|
||||
RegistryWriteResult::Success(id) => {
|
||||
// Repoint the default certificate to the renewed object when it
|
||||
// tracks the same SAN set, so its id does not go stale
|
||||
if let Some(old) = self
|
||||
.registry()
|
||||
.get(ObjectType::SystemSettings.singleton())
|
||||
.await?
|
||||
{
|
||||
let mut settings = SystemSettings::from(old.clone());
|
||||
if let Some(default_id) = settings.default_certificate_id
|
||||
&& let Some(default_cert) =
|
||||
self.registry().object::<Certificate>(default_id).await?
|
||||
{
|
||||
let mut default_sans =
|
||||
default_cert.subject_alternative_names.clone().into_inner();
|
||||
default_sans.sort();
|
||||
if default_sans == new_sans {
|
||||
settings.default_certificate_id = Some(id);
|
||||
if let Err(err) = self
|
||||
.registry()
|
||||
.write(RegistryWrite::update(
|
||||
Id::singleton(),
|
||||
&settings.into(),
|
||||
&old,
|
||||
))
|
||||
.await
|
||||
{
|
||||
trc::error!(
|
||||
err.details(
|
||||
"Failed to update default certificate after ACME renewal."
|
||||
)
|
||||
.caused_by(trc::location!())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reload registry
|
||||
let change = RegistryChange::Insert(ObjectId::new(ObjectType::Certificate, id));
|
||||
Box::pin(self.reload_registry(change)).await?;
|
||||
self.cluster_broadcast(BroadcastEvent::RegistryChange(change))
|
||||
.await;
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
let renew_at = Self::acme_renewal_due_at(
|
||||
parsed_cert.valid_not_before.timestamp(),
|
||||
parsed_cert.valid_not_after.timestamp(),
|
||||
renew_before,
|
||||
);
|
||||
|
||||
trc::event!(
|
||||
Acme(trc::AcmeEvent::OrderCompleted),
|
||||
Domain = domain.name.clone(),
|
||||
Hostname = new_sans.as_slice(),
|
||||
Id = id.to_string(),
|
||||
ValidFrom =
|
||||
trc::Value::Timestamp(parsed_cert.valid_not_before.timestamp() as u64),
|
||||
ValidTo = trc::Value::Timestamp(parsed_cert.valid_not_after.timestamp() as u64),
|
||||
NextRetry = trc::Value::Timestamp(renew_at as u64),
|
||||
);
|
||||
|
||||
tasks.push(Task::AcmeRenewal(TaskDomainManagement {
|
||||
domain_id,
|
||||
status: TaskStatus::at(renew_at),
|
||||
}));
|
||||
|
||||
// Update TLSA records
|
||||
if let DnsManagement::Automatic(props) = &domain.dns_management
|
||||
&& props.publish_records.contains(&DnsRecordType::Tlsa)
|
||||
{
|
||||
tasks.push(Task::DnsManagement(TaskDnsManagement {
|
||||
domain_id,
|
||||
on_success_renew_certificate: false,
|
||||
status: TaskStatus::now(),
|
||||
update_records: Map::new(vec![DnsRecordType::Tlsa]),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(tasks)
|
||||
}
|
||||
err => Err(AcmeError::Registry(err)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn acme_certificate_by_domains(
|
||||
&self,
|
||||
domains: &[String],
|
||||
) -> AcmeResult<Option<Certificate>> {
|
||||
let mut wanted = domains.iter().collect::<Vec<_>>();
|
||||
wanted.sort();
|
||||
let Some(reference) = wanted.first() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let candidate_ids = self
|
||||
.registry()
|
||||
.query::<Vec<Id>>(
|
||||
RegistryQuery::new(ObjectType::Certificate)
|
||||
.text(Property::SubjectAlternativeNames, reference.as_str()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for id in candidate_ids {
|
||||
let Some(certificate) = self.registry().object::<Certificate>(id).await? else {
|
||||
continue;
|
||||
};
|
||||
let mut sans = certificate
|
||||
.subject_alternative_names
|
||||
.iter()
|
||||
.collect::<Vec<_>>();
|
||||
sans.sort();
|
||||
if sans == wanted {
|
||||
return Ok(Some(certificate));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn acme_certificate_renewal_due(
|
||||
&self,
|
||||
domains: &[String],
|
||||
renew_before: AcmeRenewBefore,
|
||||
now: u64,
|
||||
) -> AcmeResult<Option<u64>> {
|
||||
let now = now as i64;
|
||||
let Some(certificate) = self.acme_certificate_by_domains(domains).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let not_valid_after = certificate.not_valid_after.timestamp();
|
||||
if not_valid_after <= now {
|
||||
return Ok(None);
|
||||
}
|
||||
let not_valid_before = certificate.not_valid_before.timestamp();
|
||||
let renew_at = Self::acme_renewal_due_at(not_valid_before, not_valid_after, renew_before);
|
||||
Ok(if now < renew_at {
|
||||
Some(renew_at as u64)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn acme_renewal_due_at(
|
||||
not_valid_before: i64,
|
||||
not_valid_after: i64,
|
||||
renew_before: AcmeRenewBefore,
|
||||
) -> i64 {
|
||||
let total = not_valid_after.saturating_sub(not_valid_before);
|
||||
let (numerator, denominator) = match renew_before {
|
||||
AcmeRenewBefore::R12 => (1, 2),
|
||||
AcmeRenewBefore::R23 => (2, 3),
|
||||
AcmeRenewBefore::R34 => (3, 4),
|
||||
AcmeRenewBefore::R45 => (4, 5),
|
||||
};
|
||||
not_valid_before + total * numerator / denominator
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
KV_ACME, Server,
|
||||
network::acme::{SerializedCert, StaticResolver, directory::ACME_TLS_ALPN_NAME},
|
||||
};
|
||||
use rustls::{
|
||||
ServerConfig,
|
||||
crypto::aws_lc_rs::sign::any_ecdsa_type,
|
||||
server::{ClientHello, ResolvesServerCert},
|
||||
sign::CertifiedKey,
|
||||
};
|
||||
use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
use std::sync::Arc;
|
||||
use store::{
|
||||
dispatch::lookup::KeyValue,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
use trc::AcmeEvent;
|
||||
|
||||
impl Server {
|
||||
pub(crate) async fn build_acme_certificate(&self, domain: &str) -> Option<Arc<CertifiedKey>> {
|
||||
match self
|
||||
.in_memory_store()
|
||||
.key_get::<Archive<AlignedBytes>>(KeyValue::<()>::build_key(KV_ACME, domain))
|
||||
.await
|
||||
{
|
||||
Ok(Some(cert_)) => match cert_.unarchive::<SerializedCert>() {
|
||||
Ok(cert) => {
|
||||
match any_ecdsa_type(&PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(
|
||||
cert.private_key.as_ref(),
|
||||
))) {
|
||||
Ok(key) => Some(Arc::new(CertifiedKey::new(
|
||||
vec![CertificateDer::from(cert.certificate.to_vec())],
|
||||
key,
|
||||
))),
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::Error),
|
||||
Domain = domain.to_string(),
|
||||
Reason = err.to_string(),
|
||||
Details = "Failed to parse private key"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::Error),
|
||||
Domain = domain.to_string(),
|
||||
CausedBy = err,
|
||||
Details = "Failed to unarchive certificate"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::Error),
|
||||
Domain = domain.to_string(),
|
||||
CausedBy = err
|
||||
);
|
||||
None
|
||||
}
|
||||
Ok(None) => {
|
||||
trc::event!(Acme(AcmeEvent::TokenNotFound), Domain = domain.to_string());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn has_acme_tls_providers(&self) -> bool {
|
||||
self.core.network.has_acme_tls_challenge
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn has_acme_http_providers(&self) -> bool {
|
||||
self.core.network.has_acme_http_challenge
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvesServerCert for StaticResolver {
|
||||
fn resolve(&self, _: ClientHello) -> Option<Arc<CertifiedKey>> {
|
||||
self.key.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_acme_static_resolver(key: Option<Arc<CertifiedKey>>) -> Arc<ServerConfig> {
|
||||
let mut challenge = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_cert_resolver(Arc::new(StaticResolver { key }));
|
||||
challenge.alpn_protocols.push(ACME_TLS_ALPN_NAME.to_vec());
|
||||
Arc::new(challenge)
|
||||
}
|
||||
|
||||
pub trait IsTlsAlpnChallenge {
|
||||
fn is_tls_alpn_challenge(&self) -> bool;
|
||||
}
|
||||
|
||||
impl IsTlsAlpnChallenge for ClientHello<'_> {
|
||||
fn is_tls_alpn_challenge(&self) -> bool {
|
||||
self.alpn().into_iter().flatten().eq([ACME_TLS_ALPN_NAME])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use std::{
|
||||
net::IpAddr,
|
||||
sync::{Arc, atomic::AtomicU64},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ahash::AHashMap;
|
||||
use arc_swap::ArcSwap;
|
||||
use mail_auth::common::resolver::ToReverseName;
|
||||
use store::write::now;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::{Server, config::network::AsnGeoLookupConfig, manager::fetch_resource};
|
||||
|
||||
pub struct AsnGeoLookupData {
|
||||
pub lock: Semaphore,
|
||||
expires: AtomicU64,
|
||||
asn: ArcSwap<Data<Arc<AsnData>>>,
|
||||
country: ArcSwap<Data<Arc<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub struct AsnData {
|
||||
pub id: u32,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub struct AsnGeoLookupResult {
|
||||
pub asn: Option<Arc<AsnData>>,
|
||||
pub country: Option<Arc<String>>,
|
||||
}
|
||||
|
||||
struct Data<T> {
|
||||
ip4_ranges: Vec<IpRange<u32, T>>,
|
||||
ip6_ranges: Vec<IpRange<u128, T>>,
|
||||
}
|
||||
|
||||
pub struct IpRange<I: Ord, T> {
|
||||
pub start: I,
|
||||
pub end: I,
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub async fn lookup_asn_country(&self, ip: IpAddr) -> AsnGeoLookupResult {
|
||||
let mut result = AsnGeoLookupResult::default();
|
||||
|
||||
match &self.core.network.asn_geo_lookup {
|
||||
AsnGeoLookupConfig::Resource { .. } if !ip.is_loopback() => {
|
||||
let asn_geo = &self.inner.data.asn_geo_data;
|
||||
|
||||
if asn_geo.expires.load(std::sync::atomic::Ordering::Relaxed) <= now()
|
||||
&& asn_geo.lock.available_permits() > 0
|
||||
{
|
||||
self.refresh_asn_geo_tables();
|
||||
}
|
||||
|
||||
result.asn = asn_geo.asn.load().lookup(ip).cloned();
|
||||
result.country = asn_geo.country.load().lookup(ip).cloned();
|
||||
}
|
||||
AsnGeoLookupConfig::Dns {
|
||||
zone_ipv4,
|
||||
zone_ipv6,
|
||||
separator,
|
||||
index_asn,
|
||||
index_asn_name,
|
||||
index_country,
|
||||
} if !ip.is_loopback() => {
|
||||
let zone = if ip.is_ipv4() { zone_ipv4 } else { zone_ipv6 };
|
||||
match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.txt_raw_lookup(format!("{}.{}.", ip.to_reverse_name(), zone))
|
||||
.await
|
||||
.map(String::from_utf8)
|
||||
{
|
||||
Ok(Ok(entry)) => {
|
||||
let mut asn = None;
|
||||
let mut asn_name = None;
|
||||
let mut country = None;
|
||||
|
||||
for (idx, part) in entry.split(separator).enumerate() {
|
||||
let part = part.trim();
|
||||
if !part.is_empty() {
|
||||
if idx == *index_asn {
|
||||
asn = part.parse::<u32>().ok();
|
||||
} else if index_asn_name.is_some_and(|i| i == idx) {
|
||||
asn_name = Some(part.to_string());
|
||||
} else if index_country.is_some_and(|i| i == idx) {
|
||||
country = Some(part.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(asn) = asn {
|
||||
result.asn = Some(Arc::new(AsnData {
|
||||
id: asn,
|
||||
name: asn_name,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(country) = country {
|
||||
result.country = Some(Arc::new(country));
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
trc::event!(
|
||||
Resource(trc::ResourceEvent::Error),
|
||||
Details = "Failed to UTF-8 decode ASN/Geo data",
|
||||
Hostname = format!("{}.{}.", ip.to_reverse_name(), zone),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Resource(trc::ResourceEvent::Error),
|
||||
Details = "Failed to lookup ASN/Geo data",
|
||||
Hostname = format!("{}.{}.", ip.to_reverse_name(), zone),
|
||||
CausedBy = err.to_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn refresh_asn_geo_tables(&self) {
|
||||
let server = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let asn_geo = &server.inner.data.asn_geo_data;
|
||||
let _permit = asn_geo.lock.acquire().await;
|
||||
|
||||
if asn_geo.expires.load(std::sync::atomic::Ordering::Relaxed) > now() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let AsnGeoLookupConfig::Resource {
|
||||
expires,
|
||||
timeout,
|
||||
max_size,
|
||||
asn_resources,
|
||||
geo_resources,
|
||||
headers,
|
||||
} = &server.core.network.asn_geo_lookup
|
||||
{
|
||||
let mut asn_data = Data::new();
|
||||
let mut country_data = Data::new();
|
||||
|
||||
for (is_asn, url) in asn_resources
|
||||
.iter()
|
||||
.map(|url| (true, url))
|
||||
.chain(geo_resources.iter().map(|url| (false, url)))
|
||||
{
|
||||
let time = Instant::now();
|
||||
match fetch_resource(url, headers.clone().into(), *timeout, *max_size)
|
||||
.await
|
||||
.map(String::from_utf8)
|
||||
{
|
||||
Ok(Ok(data)) => {
|
||||
let mut has_errors = false;
|
||||
let mut asn_mappings = AHashMap::new();
|
||||
let mut geo_mappings = AHashMap::new();
|
||||
|
||||
let mut from_ip = None;
|
||||
let mut to_ip = None;
|
||||
let mut asn = None;
|
||||
let mut details = None;
|
||||
|
||||
let mut in_quote = false;
|
||||
let mut col_num = 0;
|
||||
let mut col_start = 0;
|
||||
let mut line_start = 0;
|
||||
|
||||
for (idx, ch) in data.char_indices() {
|
||||
match ch {
|
||||
'"' => in_quote = !in_quote,
|
||||
',' | '\n' if !in_quote => {
|
||||
let column =
|
||||
data.get(col_start..idx).unwrap_or_default().trim();
|
||||
match col_num {
|
||||
0 => from_ip = column.parse::<IpAddr>().ok(),
|
||||
1 => to_ip = column.parse::<IpAddr>().ok(),
|
||||
2 if is_asn => asn = column.parse::<u32>().ok(),
|
||||
2 | 3 => {
|
||||
let column = column
|
||||
.strip_prefix('"')
|
||||
.and_then(|s| s.strip_suffix('"'))
|
||||
.unwrap_or(column);
|
||||
if !column.is_empty() || details.is_none() {
|
||||
details = Some(column);
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
|
||||
if ch == '\n' {
|
||||
let is_success = match (from_ip, to_ip, asn, details) {
|
||||
(
|
||||
Some(from_ip),
|
||||
Some(to_ip),
|
||||
Some(asn),
|
||||
asn_name,
|
||||
) if is_asn => {
|
||||
let data = asn_mappings
|
||||
.entry(asn)
|
||||
.or_insert_with(|| {
|
||||
Arc::new(AsnData {
|
||||
id: asn,
|
||||
name: asn_name.map(String::from),
|
||||
})
|
||||
})
|
||||
.clone();
|
||||
asn_data.insert(from_ip, to_ip, data)
|
||||
}
|
||||
(Some(from_ip), Some(to_ip), _, Some(code))
|
||||
if !is_asn && [2, 3].contains(&code.len()) =>
|
||||
{
|
||||
let code = code.to_uppercase();
|
||||
let data = geo_mappings
|
||||
.entry(code.clone())
|
||||
.or_insert_with(|| Arc::new(code))
|
||||
.clone();
|
||||
country_data.insert(from_ip, to_ip, data)
|
||||
}
|
||||
(None, None, _, _) => true, // Ignore empty rows
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !is_success && !has_errors {
|
||||
trc::event!(
|
||||
Resource(trc::ResourceEvent::Error),
|
||||
Details = "Invalid ASN/Geo data",
|
||||
Url = url.clone(),
|
||||
Details = data
|
||||
.get(line_start..idx)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
);
|
||||
has_errors = true;
|
||||
}
|
||||
|
||||
col_num = 0;
|
||||
from_ip = None;
|
||||
to_ip = None;
|
||||
asn = None;
|
||||
details = None;
|
||||
line_start = idx + 1;
|
||||
} else {
|
||||
col_num += 1;
|
||||
}
|
||||
col_start = idx + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Resource(trc::ResourceEvent::DownloadExternal),
|
||||
Details = "Downloaded ASN/Geo data",
|
||||
Url = url.clone(),
|
||||
Elapsed = time.elapsed()
|
||||
);
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
trc::event!(
|
||||
Resource(trc::ResourceEvent::Error),
|
||||
Details = "Failed to UTF-8 decode ASN/Geo data",
|
||||
Url = url.clone(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Resource(trc::ResourceEvent::Error),
|
||||
Details = "Failed to download ASN/Geo data",
|
||||
Url = url.clone(),
|
||||
CausedBy = err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let expires = if !asn_data.is_empty() || !country_data.is_empty() {
|
||||
*expires
|
||||
} else {
|
||||
Duration::from_secs(60)
|
||||
};
|
||||
|
||||
if !asn_data.is_empty() {
|
||||
asn_geo.asn.store(Arc::new(asn_data.sorted()));
|
||||
}
|
||||
if !country_data.is_empty() {
|
||||
asn_geo.country.store(Arc::new(country_data.sorted()));
|
||||
}
|
||||
|
||||
asn_geo.expires.store(
|
||||
now() + expires.as_secs(),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Data<T> {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
ip4_ranges: Vec::new(),
|
||||
ip6_ranges: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lookup(&self, ip: IpAddr) -> Option<&T> {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => {
|
||||
let ip = u32::from(ip);
|
||||
match self.ip4_ranges.binary_search_by(|range| {
|
||||
if ip < range.start {
|
||||
std::cmp::Ordering::Greater
|
||||
} else if ip > range.end {
|
||||
std::cmp::Ordering::Less
|
||||
} else {
|
||||
std::cmp::Ordering::Equal
|
||||
}
|
||||
}) {
|
||||
Ok(idx) => Some(&self.ip4_ranges[idx].data),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
IpAddr::V6(ip) => {
|
||||
let ip = u128::from(ip);
|
||||
match self.ip6_ranges.binary_search_by(|range| {
|
||||
if ip < range.start {
|
||||
std::cmp::Ordering::Greater
|
||||
} else if ip > range.end {
|
||||
std::cmp::Ordering::Less
|
||||
} else {
|
||||
std::cmp::Ordering::Equal
|
||||
}
|
||||
}) {
|
||||
Ok(idx) => Some(&self.ip6_ranges[idx].data),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, from_ip: IpAddr, to_ip: IpAddr, data: T) -> bool {
|
||||
match (from_ip, to_ip) {
|
||||
(IpAddr::V4(from), IpAddr::V4(to)) => {
|
||||
self.ip4_ranges.push(IpRange {
|
||||
start: u32::from(from),
|
||||
end: u32::from(to),
|
||||
data,
|
||||
});
|
||||
true
|
||||
}
|
||||
(IpAddr::V6(from), IpAddr::V6(to)) => {
|
||||
self.ip6_ranges.push(IpRange {
|
||||
start: u128::from(from),
|
||||
end: u128::from(to),
|
||||
data,
|
||||
});
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sorted(mut self) -> Self {
|
||||
self.ip4_ranges.sort_unstable_by_key(|range| range.start);
|
||||
self.ip6_ranges.sort_unstable_by_key(|range| range.start);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.ip4_ranges.is_empty() && self.ip6_ranges.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AsnGeoLookupData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
lock: Semaphore::new(1),
|
||||
expires: AtomicU64::new(0),
|
||||
asn: ArcSwap::new(Arc::new(Data::new())),
|
||||
country: ArcSwap::new(Arc::new(Data::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{Server, manager::application::Resource};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::Event;
|
||||
use registry::schema::enums::ServiceProtocol;
|
||||
use std::fmt::Write;
|
||||
|
||||
impl Server {
|
||||
pub async fn handle_autodiscover_request(
|
||||
&self,
|
||||
body: Option<Vec<u8>>,
|
||||
) -> trc::Result<Resource<Vec<u8>>> {
|
||||
// Obtain parameters
|
||||
let emailaddress = parse_autodiscover_request(body.as_deref().unwrap_or_default())
|
||||
.map_err(|err| {
|
||||
trc::ResourceEvent::BadParameters
|
||||
.into_err()
|
||||
.details("Failed to parse autodiscover request")
|
||||
.ctx(trc::Key::Reason, err)
|
||||
})?;
|
||||
let default_host = &self.core.network.server_name;
|
||||
|
||||
// Build XML response
|
||||
let mut config = String::with_capacity(1024);
|
||||
let _ = writeln!(&mut config, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"<Autodiscover xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006\">"
|
||||
);
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t<Response xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a\">"
|
||||
);
|
||||
let _ = writeln!(&mut config, "\t\t<User>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<DisplayName>{emailaddress}</DisplayName>"
|
||||
);
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<AutoDiscoverSMTPAddress>{emailaddress}</AutoDiscoverSMTPAddress>"
|
||||
);
|
||||
// DeploymentId is a required field of User but we are not a MS Exchange server so use a random value
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<DeploymentId>644560b8-a1ce-429c-8ace-23395843f701</DeploymentId>"
|
||||
);
|
||||
let _ = writeln!(&mut config, "\t\t</User>");
|
||||
let _ = writeln!(&mut config, "\t\t<Account>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
|
||||
for (protocol, service) in &self.core.network.info.services {
|
||||
let (protocol, ports) = match protocol {
|
||||
ServiceProtocol::Imap => ("IMAP", [143, 993]),
|
||||
ServiceProtocol::Pop3 => ("POP3", [110, 995]),
|
||||
ServiceProtocol::Smtp => ("SMTP", [587, 465]),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
for (is_tls, port) in ports.into_iter().enumerate() {
|
||||
if is_tls == 1 || service.cleartext {
|
||||
let server_name = service.hostname.as_deref().unwrap_or(default_host);
|
||||
let _ = writeln!(&mut config, "\t\t\t<Protocol>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Type>{protocol}</Type>",);
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Server>{server_name}</Server>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Port>{port}</Port>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<LoginName>{emailaddress}</LoginName>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<AuthRequired>on</AuthRequired>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<DirectoryPort>0</DirectoryPort>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<ReferralPort>0</ReferralPort>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t\t<SSL>{}</SSL>",
|
||||
if is_tls == 1 { "on" } else { "off" }
|
||||
);
|
||||
if is_tls == 1 {
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Encryption>TLS</Encryption>");
|
||||
}
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<SPA>off</SPA>");
|
||||
let _ = writeln!(&mut config, "\t\t\t</Protocol>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = writeln!(&mut config, "\t\t</Account>");
|
||||
let _ = writeln!(&mut config, "\t</Response>");
|
||||
let _ = writeln!(&mut config, "</Autodiscover>");
|
||||
|
||||
Ok(Resource::new(
|
||||
"application/xml; charset=utf-8",
|
||||
config.into_bytes(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
|
||||
if bytes.is_empty() {
|
||||
return Err("Empty request body".to_string());
|
||||
}
|
||||
|
||||
let mut reader = Reader::from_reader(bytes);
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut buf = Vec::with_capacity(128);
|
||||
|
||||
'outer: for tag_name in ["Autodiscover", "Request", "EMailAddress"] {
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(e)) => {
|
||||
let found_tag_name = e.name();
|
||||
if tag_name
|
||||
.as_bytes()
|
||||
.eq_ignore_ascii_case(found_tag_name.as_ref())
|
||||
{
|
||||
continue 'outer;
|
||||
} else if tag_name == "EMailAddress" {
|
||||
// Skip unsupported tags under Request, such as AcceptableResponseSchema
|
||||
let mut tag_count = 0;
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::End(_)) => {
|
||||
if tag_count == 0 {
|
||||
break;
|
||||
} else {
|
||||
tag_count -= 1;
|
||||
}
|
||||
}
|
||||
Ok(Event::Start(_)) => {
|
||||
tag_count += 1;
|
||||
}
|
||||
Ok(Event::Eof) => {
|
||||
return Err(format!(
|
||||
"Expected value, found unexpected EOF at position {}.",
|
||||
reader.buffer_position()
|
||||
));
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Expected tag {}, found unexpected tag {} at position {}.",
|
||||
tag_name,
|
||||
String::from_utf8_lossy(found_tag_name.as_ref()),
|
||||
reader.buffer_position()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Event::Decl(_) | Event::Text(_)) => (),
|
||||
Err(e) => {
|
||||
return Err(format!(
|
||||
"Error at position {}: {:?}",
|
||||
reader.buffer_position(),
|
||||
e
|
||||
));
|
||||
}
|
||||
Ok(event) => {
|
||||
return Err(format!(
|
||||
"Expected tag {}, found unexpected event {event:?} at position {}.",
|
||||
tag_name,
|
||||
reader.buffer_position()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(Event::Text(text)) = reader.read_event_into(&mut buf)
|
||||
&& let Ok(text) = text.xml_content(XmlVersion::Implicit1_0)
|
||||
&& text.contains('@')
|
||||
{
|
||||
return Ok(text.trim().to_lowercase());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Expected email address, found unexpected value at position {}.",
|
||||
reader.buffer_position()
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_autodiscover() {
|
||||
let r = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
|
||||
<Request>
|
||||
<EMailAddress>[email protected]</EMailAddress>
|
||||
<AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
|
||||
</Request>
|
||||
</Autodiscover>"#;
|
||||
|
||||
assert_eq!(
|
||||
super::parse_autodiscover_request(r.as_bytes()).unwrap(),
|
||||
"[email protected]"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{Server, manager::application::Resource};
|
||||
use utils::url_params::UrlParams;
|
||||
|
||||
impl Server {
|
||||
pub async fn handle_autodiscover_v2_request(
|
||||
&self,
|
||||
query: Option<&str>,
|
||||
path_email: Option<&str>,
|
||||
) -> trc::Result<Result<Resource<Vec<u8>>, String>> {
|
||||
// Parse query parameters
|
||||
let params = UrlParams::new(query);
|
||||
let emailaddress = path_email
|
||||
.filter(|email| !email.is_empty())
|
||||
.or_else(|| params.get("Email"))
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
let protocol = params.get("Protocol").unwrap_or_default();
|
||||
|
||||
// Validate email address
|
||||
let Some((_, domain)) = emailaddress.rsplit_once('@') else {
|
||||
return Err(trc::ResourceEvent::BadParameters
|
||||
.into_err()
|
||||
.details("Missing domain in email address"));
|
||||
};
|
||||
|
||||
if domain.is_empty() {
|
||||
return Err(trc::ResourceEvent::BadParameters
|
||||
.into_err()
|
||||
.details("Missing domain in email address"));
|
||||
}
|
||||
|
||||
if protocol.eq_ignore_ascii_case("autodiscoverv1") {
|
||||
let server_name = &self.core.network.server_name;
|
||||
let body = format!(
|
||||
"{{\"Protocol\":\"AutodiscoverV1\",\
|
||||
\"Url\":\"https://{server_name}/autodiscover/autodiscover.xml\"}}"
|
||||
);
|
||||
Ok(Ok(Resource::new(
|
||||
"application/json; charset=utf-8",
|
||||
body.into_bytes(),
|
||||
)))
|
||||
} else {
|
||||
let safe_protocol: String = protocol
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect();
|
||||
let err = format!(
|
||||
"{{\"ErrorCode\":\"InvalidProtocol\",\
|
||||
\"ErrorMessage\":\"The given protocol value \
|
||||
'{safe_protocol}' is invalid. \
|
||||
Supported values are 'AutodiscoverV1'\"}}"
|
||||
);
|
||||
Ok(Err(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{Server, manager::application::Resource};
|
||||
use registry::schema::enums::ServiceProtocol;
|
||||
use std::fmt::Write;
|
||||
use utils::url_params::UrlParams;
|
||||
|
||||
impl Server {
|
||||
pub async fn handle_autoconfig_request(
|
||||
&self,
|
||||
uri: Option<&str>,
|
||||
) -> trc::Result<Resource<Vec<u8>>> {
|
||||
// Obtain parameters
|
||||
let params = UrlParams::new(uri);
|
||||
let emailaddress_param = params
|
||||
.get("emailaddress")
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
let default_host = &self.core.network.server_name;
|
||||
let (emailaddress, domain) = if let Some((_, domain)) = emailaddress_param.rsplit_once('@')
|
||||
{
|
||||
(emailaddress_param.as_str(), domain)
|
||||
} else {
|
||||
("%EMAILADDRESS%", default_host.as_str())
|
||||
};
|
||||
|
||||
// Build XML response
|
||||
let mut config = String::with_capacity(1024);
|
||||
config.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
config.push_str("<clientConfig version=\"1.1\">\n");
|
||||
let _ = writeln!(&mut config, "\t<emailProvider id=\"{domain}\">");
|
||||
let _ = writeln!(&mut config, "\t\t<domain>{domain}</domain>");
|
||||
let _ = writeln!(&mut config, "\t\t<displayName>{emailaddress}</displayName>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t<displayShortName>{domain}</displayShortName>"
|
||||
);
|
||||
for (protocol, service) in &self.core.network.info.services {
|
||||
let (protocol, tag, ports) = match protocol {
|
||||
ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]),
|
||||
ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]),
|
||||
ServiceProtocol::Pop3 => ("pop3", "incomingServer", [110, 995]),
|
||||
_ => continue,
|
||||
};
|
||||
for (is_tls, port) in ports.into_iter().enumerate() {
|
||||
if is_tls == 1 || service.cleartext {
|
||||
let server_name = service.hostname.as_deref().unwrap_or(default_host);
|
||||
let _ = writeln!(&mut config, "\t\t<{tag} type=\"{protocol}\">");
|
||||
let _ = writeln!(&mut config, "\t\t\t<hostname>{server_name}</hostname>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<port>{port}</port>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<socketType>{}</socketType>",
|
||||
if is_tls == 1 { "SSL" } else { "STARTTLS" }
|
||||
);
|
||||
let _ = writeln!(&mut config, "\t\t\t<username>{emailaddress}</username>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<authentication>password-cleartext</authentication>"
|
||||
);
|
||||
let _ = writeln!(&mut config, "\t\t</{tag}>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config.push_str("\t</emailProvider>\n");
|
||||
|
||||
for (protocol, service) in &self.core.network.info.services {
|
||||
let (tag, protocol, url) = match protocol {
|
||||
ServiceProtocol::Carddav => ("addressBook", "carddav", "card"),
|
||||
ServiceProtocol::Caldav => ("calendar", "caldav", "cal"),
|
||||
ServiceProtocol::Webdav => ("fileShare", "webdav", "file"),
|
||||
_ => continue,
|
||||
};
|
||||
let server_name = service.hostname.as_deref().unwrap_or(default_host);
|
||||
|
||||
let _ = writeln!(&mut config, "\t<{tag} type=\"{protocol}\">");
|
||||
let _ = writeln!(&mut config, "\t\t<username>{emailaddress}</username>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t<authentication>http-basic</authentication>"
|
||||
);
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t<serverURL>https://{server_name}/dav/{url}</serverURL>"
|
||||
);
|
||||
let _ = writeln!(&mut config, "\t</{tag}>");
|
||||
}
|
||||
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t<clientConfigUpdate url=\"https://autoconfig.{domain}/mail/config-v1.1.xml\"></clientConfigUpdate>"
|
||||
);
|
||||
config.push_str("</clientConfig>\n");
|
||||
|
||||
Ok(Resource::new(
|
||||
"application/xml; charset=utf-8",
|
||||
config.into_bytes(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod autodiscover;
|
||||
pub mod autodiscover_v2;
|
||||
pub mod legacy_autoconfig;
|
||||
pub mod pacc;
|
||||
@@ -0,0 +1,358 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Top-level configuration document.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Configuration {
|
||||
/// Supported protocols and their server endpoints.
|
||||
pub protocols: Protocols,
|
||||
|
||||
/// Authentication mechanisms the provider supports.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub authentication: Option<Authentication>,
|
||||
|
||||
/// Informational metadata about the provider.
|
||||
pub info: Info,
|
||||
}
|
||||
|
||||
/// The `protocols` object listing available protocol endpoints.
|
||||
///
|
||||
/// HTTP-based protocols (JMAP, CalDAV, CardDAV, WebDAV) use [`HttpServer`].
|
||||
/// Text-based protocols (IMAP, POP3, SMTP, ManageSieve) use [`TextServer`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Protocols {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jmap: Option<HttpServer>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub imap: Option<TextServer>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pop3: Option<TextServer>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub smtp: Option<TextServer>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caldav: Option<HttpServer>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub carddav: Option<HttpServer>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub webdav: Option<HttpServer>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub managesieve: Option<TextServer>,
|
||||
}
|
||||
|
||||
/// An HTTP-based protocol endpoint (JMAP, CalDAV, CardDAV, WebDAV).
|
||||
///
|
||||
/// The `url` MUST use the `https` scheme and the default port 443.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct HttpServer {
|
||||
/// HTTPS URL of the protocol endpoint.
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// A text-based protocol endpoint (IMAP, POP3, SMTP, ManageSieve).
|
||||
///
|
||||
/// Connections use TLS on the protocol's default port
|
||||
/// (993 IMAP, 995 POP3, 465 SMTP).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TextServer {
|
||||
/// Hostname of the server.
|
||||
pub host: String,
|
||||
}
|
||||
|
||||
/// Authentication mechanisms supported by the provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Authentication {
|
||||
/// OAuth Profile for Open Public Clients configuration.
|
||||
#[serde(rename = "oauth-public", skip_serializing_if = "Option::is_none")]
|
||||
pub oauth_public: Option<OAuthPublic>,
|
||||
|
||||
/// Whether the provider supports username/password authentication.
|
||||
pub password: bool,
|
||||
}
|
||||
|
||||
/// OAuth Profile for Open Public Clients parameters.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct OAuthPublic {
|
||||
/// The authorization server's issuer identifier (RFC 8414).
|
||||
/// Must be an `https` URL with no query or fragment components.
|
||||
pub issuer: String,
|
||||
}
|
||||
|
||||
/// Informational metadata presented to users and developers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct Info {
|
||||
/// Provider identity information (required).
|
||||
pub provider: Provider,
|
||||
|
||||
/// Help links for users and developers.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub help: Option<Help>,
|
||||
}
|
||||
|
||||
/// Provider identity.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct Provider {
|
||||
/// Display name of the provider (≤ 60 characters, SHOULD ≤ 30).
|
||||
pub name: String,
|
||||
|
||||
/// Short name (≤ 20 characters, SHOULD ≤ 12).
|
||||
#[serde(rename = "shortName", skip_serializing_if = "Option::is_none")]
|
||||
pub short_name: Option<String>,
|
||||
|
||||
/// Logo image variants.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logo: Option<Vec<Logo>>,
|
||||
}
|
||||
|
||||
/// A single logo variant.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct Logo {
|
||||
/// URL where the logo can be retrieved.
|
||||
pub url: String,
|
||||
|
||||
/// Media type of the logo image (e.g. `image/svg+xml`, `image/png`).
|
||||
#[serde(rename = "content-type")]
|
||||
pub content_type: String,
|
||||
|
||||
/// Image width in pixels. Omitted for SVG.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub width: Option<u32>,
|
||||
|
||||
/// Image height in pixels. Omitted for SVG.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub height: Option<u32>,
|
||||
}
|
||||
|
||||
/// Help links for users and developers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct Help {
|
||||
/// URL with user-facing documentation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub documentation: Option<String>,
|
||||
|
||||
/// URL with developer-facing documentation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub developer: Option<String>,
|
||||
|
||||
/// Contact URIs (e.g. `mailto:` URLs). NOT for end-user display.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The full example from Section 4.1 of the draft.
|
||||
const EXAMPLE_JSON: &str = r#"{
|
||||
"protocols": {
|
||||
"jmap": {
|
||||
"url": "https://jmap.example.com/session"
|
||||
},
|
||||
"imap": {
|
||||
"host": "imap.example.com"
|
||||
},
|
||||
"pop3": {
|
||||
"host": "pop3.example.com"
|
||||
},
|
||||
"smtp": {
|
||||
"host": "smtp.example.com"
|
||||
},
|
||||
"caldav": {
|
||||
"url": "https://sync.example.com/calendar/"
|
||||
},
|
||||
"carddav": {
|
||||
"url": "https://sync.example.com/contacts/"
|
||||
}
|
||||
},
|
||||
"authentication": {
|
||||
"oauth-public": {
|
||||
"issuer": "https://auth.example.com/"
|
||||
},
|
||||
"password": true
|
||||
},
|
||||
"info": {
|
||||
"provider": {
|
||||
"name": "Example Provider Name",
|
||||
"shortName": "Example",
|
||||
"logo": [
|
||||
{
|
||||
"url": "https://www.example.net/logo.svg",
|
||||
"content-type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
},
|
||||
"help": {
|
||||
"documentation": "https://help.example.net/howto/set-up-your-mail-app.html",
|
||||
"developer": "https://developer.example.net/client-apps/",
|
||||
"contact": ["mailto:[email protected]"]
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn deserialize_full_example() {
|
||||
let config: Configuration =
|
||||
serde_json::from_str(EXAMPLE_JSON).expect("failed to deserialize");
|
||||
|
||||
// Protocols
|
||||
assert_eq!(
|
||||
config.protocols.jmap.as_ref().unwrap().url,
|
||||
"https://jmap.example.com/session"
|
||||
);
|
||||
assert_eq!(
|
||||
config.protocols.imap.as_ref().unwrap().host,
|
||||
"imap.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
config.protocols.smtp.as_ref().unwrap().host,
|
||||
"smtp.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
config.protocols.pop3.as_ref().unwrap().host,
|
||||
"pop3.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
config.protocols.caldav.as_ref().unwrap().url,
|
||||
"https://sync.example.com/calendar/"
|
||||
);
|
||||
assert_eq!(
|
||||
config.protocols.carddav.as_ref().unwrap().url,
|
||||
"https://sync.example.com/contacts/"
|
||||
);
|
||||
assert!(config.protocols.webdav.is_none());
|
||||
assert!(config.protocols.managesieve.is_none());
|
||||
|
||||
// Authentication
|
||||
let auth = config.authentication.as_ref().unwrap();
|
||||
assert!(auth.password);
|
||||
assert_eq!(
|
||||
auth.oauth_public.as_ref().unwrap().issuer,
|
||||
"https://auth.example.com/"
|
||||
);
|
||||
|
||||
// Info
|
||||
assert_eq!(config.info.provider.name, "Example Provider Name");
|
||||
assert_eq!(config.info.provider.short_name.as_deref(), Some("Example"));
|
||||
|
||||
let logos = config.info.provider.logo.as_ref().unwrap();
|
||||
assert_eq!(logos.len(), 1);
|
||||
assert_eq!(logos[0].content_type, "image/svg+xml");
|
||||
assert!(logos[0].width.is_none());
|
||||
|
||||
let help = config.info.help.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
help.documentation.as_deref(),
|
||||
Some("https://help.example.net/howto/set-up-your-mail-app.html")
|
||||
);
|
||||
assert_eq!(
|
||||
help.contact.as_ref().unwrap(),
|
||||
&["mailto:[email protected]"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let config: Configuration =
|
||||
serde_json::from_str(EXAMPLE_JSON).expect("failed to deserialize");
|
||||
let serialized = serde_json::to_string_pretty(&config).expect("failed to serialize");
|
||||
let roundtripped: Configuration =
|
||||
serde_json::from_str(&serialized).expect("failed to re-deserialize");
|
||||
assert_eq!(config, roundtripped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_config() {
|
||||
let json = r#"{
|
||||
"protocols": {},
|
||||
"info": {
|
||||
"provider": {
|
||||
"name": "Minimal"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let config: Configuration = serde_json::from_str(json).expect("failed to deserialize");
|
||||
assert_eq!(config.info.provider.name, "Minimal");
|
||||
assert!(config.authentication.is_none());
|
||||
assert!(config.protocols.jmap.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unknown_properties() {
|
||||
let json = r#"{
|
||||
"protocols": {
|
||||
"imap": { "host": "imap.example.com" },
|
||||
"future-protocol": { "endpoint": "wss://example.com" }
|
||||
},
|
||||
"info": {
|
||||
"provider": { "name": "Test" }
|
||||
},
|
||||
"futureField": 42
|
||||
}"#;
|
||||
let config: Configuration = serde_json::from_str(json).expect("should ignore unknowns");
|
||||
assert_eq!(
|
||||
config.protocols.imap.as_ref().unwrap().host,
|
||||
"imap.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logo_with_dimensions() {
|
||||
let json = r#"{
|
||||
"protocols": {},
|
||||
"info": {
|
||||
"provider": {
|
||||
"name": "Test",
|
||||
"logo": [
|
||||
{
|
||||
"url": "https://example.com/logo.svg",
|
||||
"content-type": "image/svg+xml"
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/logo-128.png",
|
||||
"content-type": "image/png",
|
||||
"width": 128,
|
||||
"height": 128
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/logo-512.png",
|
||||
"content-type": "image/png",
|
||||
"width": 512,
|
||||
"height": 512
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let config: Configuration = serde_json::from_str(json).unwrap();
|
||||
let logos = config.info.provider.logo.as_ref().unwrap();
|
||||
assert_eq!(logos.len(), 3);
|
||||
assert!(logos[0].width.is_none());
|
||||
assert_eq!(logos[1].width, Some(128));
|
||||
assert_eq!(logos[2].height, Some(512));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_only_auth() {
|
||||
let json = r#"{
|
||||
"protocols": { "imap": { "host": "mail.example.com" } },
|
||||
"authentication": { "password": true },
|
||||
"info": { "provider": { "name": "PW Only" } }
|
||||
}"#;
|
||||
let config: Configuration = serde_json::from_str(json).unwrap();
|
||||
let auth = config.authentication.unwrap();
|
||||
assert!(auth.password);
|
||||
assert!(auth.oauth_public.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::config::smtp::auth::{rsa_key_parse, simple_pem_parse};
|
||||
use chrono::Utc;
|
||||
use dns_update::{DnsRecord, NamedDnsRecord};
|
||||
use mail_auth::common::crypto::Ed25519Key;
|
||||
use mail_auth::dkim::generate::DkimKeyPair;
|
||||
use mail_builder::encoders::Base64Encoder;
|
||||
use pkcs8::Document;
|
||||
use registry::schema::enums::DkimSignatureType;
|
||||
use registry::schema::structs::DkimSignature;
|
||||
use rsa::pkcs1::DecodeRsaPublicKey;
|
||||
use store::rand::distr::Alphanumeric;
|
||||
use store::rand::{self, RngExt};
|
||||
|
||||
pub async fn generate_dkim_private_key(
|
||||
key_type: DkimSignatureType,
|
||||
) -> trc::Result<Result<String, String>> {
|
||||
let private_key = tokio::task::spawn_blocking(move || match key_type {
|
||||
DkimSignatureType::Dkim1RsaSha256 | DkimSignatureType::Dkim2RsaSha256 => {
|
||||
DkimKeyPair::generate_rsa(2048).map(|key| (key, "RSA PRIVATE KEY"))
|
||||
}
|
||||
DkimSignatureType::Dkim1Ed25519Sha256 | DkimSignatureType::Dkim2Ed25519Sha256 => {
|
||||
DkimKeyPair::generate_ed25519().map(|key| (key, "PRIVATE KEY"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
trc::EventType::Server(trc::ServerEvent::ThreadError)
|
||||
.reason(err)
|
||||
.caused_by(trc::location!())
|
||||
})?;
|
||||
|
||||
Ok(private_key
|
||||
.map(|(private_key, pk_type)| {
|
||||
let mut pem = format!("-----BEGIN {pk_type}-----\n").into_bytes();
|
||||
let mut lf_count = 65;
|
||||
for ch in Base64Encoder::new()
|
||||
.encode(private_key.private_key())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
pem.push(ch);
|
||||
lf_count -= 1;
|
||||
if lf_count == 0 {
|
||||
pem.push(b'\n');
|
||||
lf_count = 65;
|
||||
}
|
||||
}
|
||||
if lf_count != 65 {
|
||||
pem.push(b'\n');
|
||||
}
|
||||
pem.extend_from_slice(format!("-----END {pk_type}-----\n").as_bytes());
|
||||
|
||||
String::from_utf8(pem).unwrap_or_default()
|
||||
})
|
||||
.map_err(|err| err.to_string()))
|
||||
}
|
||||
|
||||
pub async fn generate_dkim_public_key(key: &DkimSignature) -> trc::Result<String> {
|
||||
let is_rsa = matches!(
|
||||
key,
|
||||
DkimSignature::Dkim1RsaSha256(_) | DkimSignature::Dkim2RsaSha256(_)
|
||||
);
|
||||
let pem = key
|
||||
.private_key()
|
||||
.secret()
|
||||
.await
|
||||
.map_err(|err| trc::DkimEvent::BuildError.reason(err))?;
|
||||
|
||||
if is_rsa {
|
||||
rsa_key_parse(pem.as_bytes())
|
||||
.and_then(|pk| {
|
||||
Document::from_pkcs1_der(&pk.public_key()).map_err(|err| {
|
||||
trc::EventType::Dkim(trc::DkimEvent::BuildError)
|
||||
.into_err()
|
||||
.reason(err)
|
||||
})
|
||||
})
|
||||
.map(|pk| {
|
||||
String::from_utf8(
|
||||
Base64Encoder::new()
|
||||
.encode(pk.as_bytes())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
} else {
|
||||
simple_pem_parse(&pem)
|
||||
.ok_or_else(|| {
|
||||
trc::EventType::Dkim(trc::DkimEvent::BuildError)
|
||||
.into_err()
|
||||
.details("Failed to parse private key PEM")
|
||||
})
|
||||
.and_then(|der| {
|
||||
Ed25519Key::from_pkcs8_maybe_unchecked_der(&der).map_err(|err| {
|
||||
trc::EventType::Dkim(trc::DkimEvent::BuildError)
|
||||
.into_err()
|
||||
.reason(err)
|
||||
})
|
||||
})
|
||||
.map(|pk| {
|
||||
String::from_utf8(
|
||||
Base64Encoder::new()
|
||||
.encode(&pk.public_key())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_dkim_dns_record(
|
||||
key: &DkimSignature,
|
||||
domain: &str,
|
||||
) -> trc::Result<NamedDnsRecord> {
|
||||
let public_key = generate_dkim_public_key(key).await?;
|
||||
|
||||
let (selector, record) = match key {
|
||||
DkimSignature::Dkim1Ed25519Sha256(sign) => (
|
||||
&sign.selector,
|
||||
format!("v=DKIM1; k=ed25519; h=sha256; p={public_key}"),
|
||||
),
|
||||
DkimSignature::Dkim2Ed25519Sha256(sign) => (
|
||||
&sign.selector,
|
||||
format!("v=DKIM1; k=ed25519; h=sha256; p={public_key}"),
|
||||
),
|
||||
DkimSignature::Dkim1RsaSha256(sign) => (
|
||||
&sign.selector,
|
||||
format!("v=DKIM1; k=rsa; h=sha256; p={public_key}"),
|
||||
),
|
||||
DkimSignature::Dkim2RsaSha256(sign) => (
|
||||
&sign.selector,
|
||||
format!("v=DKIM1; k=rsa; h=sha256; p={public_key}"),
|
||||
),
|
||||
};
|
||||
|
||||
Ok(NamedDnsRecord {
|
||||
name: format!("{selector}._domainkey.{domain}."),
|
||||
record: DnsRecord::TXT(record),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generate_dkim_dns_record_name(key: &DkimSignature, domain: &str) -> String {
|
||||
format!("{}._domainkey.{domain}.", key.selector())
|
||||
}
|
||||
|
||||
/// Generate a DKIM selector from a template string.
|
||||
///
|
||||
/// Supported variables:
|
||||
/// - `{algorithm}`: signing algorithm in lowercase (`rsa`, `ed25519`)
|
||||
/// - `{hash}`: hash algorithm (`sha256`)
|
||||
/// - `{version}`: DKIM version number (`1`)
|
||||
/// - `{date-<fmt>}`: current UTC date formatted with chrono strftime (e.g. `{date-%Y%m%d}`)
|
||||
/// - `{epoch}`: current UTC unix timestamp
|
||||
/// - `{random}`: random 8-character alphanumeric string
|
||||
///
|
||||
pub fn generate_dkim_selector(
|
||||
template: &str,
|
||||
sig_type: DkimSignatureType,
|
||||
) -> Result<String, String> {
|
||||
let now = Utc::now();
|
||||
let mut result = Vec::with_capacity(template.len());
|
||||
let mut chars = template.as_bytes();
|
||||
|
||||
while !chars.is_empty() {
|
||||
// Find next '{' or consume literal text
|
||||
let Some(open) = memchr(b'{', chars) else {
|
||||
// No more variables: append remaining literal
|
||||
// SAFETY: template is valid UTF-8, and we only slice on ASCII boundaries
|
||||
result.extend(
|
||||
chars
|
||||
.iter()
|
||||
.filter(|&&c| c.is_ascii_alphanumeric() || c == b'.' || c == b'-' || c == b'_'),
|
||||
);
|
||||
break;
|
||||
};
|
||||
|
||||
// Append literal before '{'
|
||||
if open > 0 {
|
||||
result.extend(
|
||||
chars[..open]
|
||||
.iter()
|
||||
.filter(|&&c| c.is_ascii_alphanumeric() || c == b'.' || c == b'-' || c == b'_'),
|
||||
);
|
||||
}
|
||||
|
||||
// Find matching '}'
|
||||
let rest = chars.get(open + 1..).unwrap_or_default();
|
||||
let Some(close) = memchr(b'}', rest) else {
|
||||
return Err("unclosed '{' in template".into());
|
||||
};
|
||||
|
||||
let var =
|
||||
std::str::from_utf8(&rest[..close]).map_err(|_| "invalid UTF-8 in variable name")?;
|
||||
|
||||
match var {
|
||||
"algorithm" => result.extend_from_slice(sig_type.algorithm().as_bytes()),
|
||||
"hash" => result.extend_from_slice(sig_type.hash().as_bytes()),
|
||||
"version" => result.extend_from_slice(sig_type.version().as_bytes()),
|
||||
"epoch" => {
|
||||
result.extend_from_slice(now.timestamp().to_string().as_bytes());
|
||||
}
|
||||
"random" => {
|
||||
let rand_str: String = rand::rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(8)
|
||||
.map(|ch| char::from(ch.to_ascii_lowercase()))
|
||||
.collect::<String>();
|
||||
result.extend(rand_str.as_bytes());
|
||||
}
|
||||
v => {
|
||||
if let Some(fmt) = v.strip_prefix("date-") {
|
||||
if fmt.is_empty() {
|
||||
return Err("empty strftime format in {date-}".into());
|
||||
}
|
||||
let formatted = now.format(fmt).to_string();
|
||||
if formatted.is_empty() {
|
||||
return Err(format!("date format '{fmt}' produced empty output"));
|
||||
}
|
||||
result.extend(formatted.as_bytes().iter().filter(|&&c| {
|
||||
c.is_ascii_alphanumeric() || c == b'.' || c == b'-' || c == b'_'
|
||||
}));
|
||||
} else {
|
||||
return Err(format!("unrecognized variable '{{{var}}}'"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chars = rest.get(close + 1..).unwrap_or_default();
|
||||
}
|
||||
|
||||
if !result.is_empty() {
|
||||
Ok(String::from_utf8(result).unwrap_or_default())
|
||||
} else {
|
||||
Err("Selector cannot be empty".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn memchr(needle: u8, haystack: &[u8]) -> Option<usize> {
|
||||
haystack.iter().position(|&b| b == needle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_algorithm_date() {
|
||||
let sel = generate_dkim_selector(
|
||||
"{algorithm}-{date-%Y%m%d}",
|
||||
DkimSignatureType::Dkim1RsaSha256,
|
||||
)
|
||||
.unwrap();
|
||||
let today = Utc::now().format("%Y%m%d").to_string();
|
||||
assert_eq!(sel, format!("rsa-{today}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variables() {
|
||||
let sel = generate_dkim_selector(
|
||||
"v{version}-{algorithm}-{hash}-{epoch}",
|
||||
DkimSignatureType::Dkim1Ed25519Sha256,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(sel.starts_with("v1-ed25519-sha256-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_only() {
|
||||
let sel =
|
||||
generate_dkim_selector("my-static-selector", DkimSignatureType::default()).unwrap();
|
||||
assert_eq!(sel, "my-static-selector");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_chars_stripped() {
|
||||
let sel = generate_dkim_selector("{algorithm} {hash}", DkimSignatureType::Dkim1RsaSha256)
|
||||
.unwrap();
|
||||
assert_eq!(sel, "rsasha256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrecognized_variable_errors() {
|
||||
let err = generate_dkim_selector("{bogus}", DkimSignatureType::default()).unwrap_err();
|
||||
assert!(err.contains("unrecognized variable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_brace_errors() {
|
||||
let err = generate_dkim_selector("{algorithm", DkimSignatureType::default()).unwrap_err();
|
||||
assert!(err.contains("unclosed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_after_sanitization_errors() {
|
||||
let err = generate_dkim_selector(" ", DkimSignatureType::default()).unwrap_err();
|
||||
assert!(err.contains("empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_date_format_errors() {
|
||||
let err = generate_dkim_selector("{date-}", DkimSignatureType::default()).unwrap_err();
|
||||
assert!(err.contains("empty strftime"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_month_only() {
|
||||
let sel = generate_dkim_selector("{date-%Y%m}", DkimSignatureType::Dkim1RsaSha256).unwrap();
|
||||
let expected = Utc::now().format("%Y%m").to_string();
|
||||
assert_eq!(sel, expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod records;
|
||||
pub mod resolve;
|
||||
pub mod update;
|
||||
@@ -0,0 +1,429 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record};
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use dns_update::{
|
||||
CAARecord, DnsRecord, KeyValue, MXRecord, NamedDnsRecord, SRVRecord, TLSARecord, TlsaCertUsage,
|
||||
TlsaMatching, TlsaSelector, bind::BindSerializer,
|
||||
};
|
||||
use registry::schema::{
|
||||
enums::{DnsRecordType, ServiceProtocol},
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{AcmeProvider, CertificateManagement, DkimSignature, DnsManagement, Domain},
|
||||
};
|
||||
use reqwest::Url;
|
||||
use sha2::{Digest, Sha256};
|
||||
use store::registry::RegistryQuery;
|
||||
use trc::AddContext;
|
||||
use types::id::Id;
|
||||
use x509_parser::parse_x509_certificate;
|
||||
|
||||
impl Server {
|
||||
pub async fn build_dns_records(
|
||||
&self,
|
||||
domain_id: Id,
|
||||
domain: &Domain,
|
||||
record_types: &[DnsRecordType],
|
||||
) -> trc::Result<Vec<NamedDnsRecord>> {
|
||||
let mut records = Vec::new();
|
||||
let network = &self.core.network;
|
||||
let default_host = network.server_name.as_str();
|
||||
let domain_name = domain.name.as_str();
|
||||
let domain_name_suffix = format!(".{domain_name}");
|
||||
|
||||
for record_type in record_types {
|
||||
match record_type {
|
||||
DnsRecordType::Dkim => {
|
||||
let signature_ids = self
|
||||
.registry()
|
||||
.query::<Vec<Id>>(
|
||||
RegistryQuery::new(ObjectType::DkimSignature)
|
||||
.equal(Property::DomainId, domain_id.document_id()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for id in signature_ids {
|
||||
let Some(key) = self.registry().object::<DkimSignature>(id).await? else {
|
||||
continue;
|
||||
};
|
||||
if !key.is_published() {
|
||||
continue;
|
||||
}
|
||||
records.push(generate_dkim_dns_record(&key, domain_name).await?);
|
||||
}
|
||||
}
|
||||
DnsRecordType::Mx => {
|
||||
for mx in &network.info.mxs {
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("{domain_name}."),
|
||||
record: DnsRecord::MX(MXRecord {
|
||||
exchange: format!(
|
||||
"{}.",
|
||||
mx.hostname.as_deref().unwrap_or(default_host)
|
||||
),
|
||||
priority: mx.priority as u16,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
DnsRecordType::Spf => {
|
||||
let mxs = network
|
||||
.info
|
||||
.mxs
|
||||
.iter()
|
||||
.map(|mx| mx.hostname.as_deref().unwrap_or(default_host))
|
||||
.collect::<AHashSet<_>>();
|
||||
|
||||
for mx in mxs {
|
||||
if mx.ends_with(&domain_name_suffix) {
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("{mx}."),
|
||||
record: DnsRecord::TXT("v=spf1 a -all".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("{domain_name}."),
|
||||
record: DnsRecord::TXT("v=spf1 mx -all".to_string()),
|
||||
});
|
||||
}
|
||||
DnsRecordType::Dmarc => {
|
||||
if let Some(uri) = &domain.report_address_uri {
|
||||
let contents = if uri.starts_with("mailto:") && !uri.contains('@') {
|
||||
format!("v=DMARC1; p=reject; rua={uri}@{domain_name}",)
|
||||
} else {
|
||||
format!("v=DMARC1; p=reject; rua={uri}",)
|
||||
};
|
||||
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("_dmarc.{domain_name}."),
|
||||
record: DnsRecord::TXT(contents),
|
||||
});
|
||||
}
|
||||
}
|
||||
DnsRecordType::TlsRpt => {
|
||||
if let Some(uri) = &domain.report_address_uri {
|
||||
let contents = if uri.starts_with("mailto:") && !uri.contains('@') {
|
||||
format!("v=TLSRPTv1; rua={uri}@{domain_name}",)
|
||||
} else {
|
||||
format!("v=TLSRPTv1; rua={uri}",)
|
||||
};
|
||||
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("_smtp._tls.{domain_name}."),
|
||||
record: DnsRecord::TXT(contents),
|
||||
});
|
||||
}
|
||||
}
|
||||
DnsRecordType::MtaSts => {
|
||||
if let Some(policy) = &self.core.smtp.session.mta_sts_policy {
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("mta-sts.{domain_name}."),
|
||||
record: DnsRecord::CNAME(format!("{default_host}.")),
|
||||
});
|
||||
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("_mta-sts.{domain_name}."),
|
||||
record: DnsRecord::TXT(format!("v=STSv1; id={}", policy.id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
DnsRecordType::AutoConfig => {
|
||||
let pacc_digest = Sha256::digest(&self.get_pacc_for_domain(domain_name).await?);
|
||||
let pacc_digest_encoded = general_purpose::STANDARD.encode(pacc_digest);
|
||||
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("ua-auto-config.{domain_name}."),
|
||||
record: DnsRecord::CNAME(format!("{default_host}.")),
|
||||
});
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("_ua-auto-config.{domain_name}."),
|
||||
record: DnsRecord::TXT(format!(
|
||||
"v=UAAC1; a=sha256; d={pacc_digest_encoded}"
|
||||
)),
|
||||
});
|
||||
}
|
||||
DnsRecordType::AutoConfigLegacy => {
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("autoconfig.{domain_name}."),
|
||||
record: DnsRecord::CNAME(format!("{default_host}.")),
|
||||
});
|
||||
}
|
||||
DnsRecordType::AutoDiscover => {
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("autodiscover.{domain_name}."),
|
||||
record: DnsRecord::CNAME(format!("{default_host}.")),
|
||||
});
|
||||
}
|
||||
DnsRecordType::Srv => {
|
||||
for (protocol, service) in &network.info.services {
|
||||
let target =
|
||||
format!("{}.", service.hostname.as_deref().unwrap_or(default_host));
|
||||
let services = match protocol {
|
||||
ServiceProtocol::Jmap
|
||||
| ServiceProtocol::Caldav
|
||||
| ServiceProtocol::Carddav => {
|
||||
let name = match protocol {
|
||||
ServiceProtocol::Jmap => "jmap",
|
||||
ServiceProtocol::Caldav => "caldavs",
|
||||
ServiceProtocol::Carddav => "carddavs",
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("_{name}._tcp.{domain_name}."),
|
||||
record: DnsRecord::SRV(SRVRecord {
|
||||
target: target.clone(),
|
||||
priority: 0,
|
||||
weight: 1,
|
||||
port: 443,
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
ServiceProtocol::Webdav | ServiceProtocol::Managesieve => continue,
|
||||
ServiceProtocol::Imap => [("imap", 143), ("imaps", 993)],
|
||||
ServiceProtocol::Pop3 => [("pop3", 110), ("pop3s", 995)],
|
||||
ServiceProtocol::Smtp => [("submission", 587), ("submissions", 465)],
|
||||
};
|
||||
|
||||
for (is_tls, (service_name, port)) in services.into_iter().enumerate() {
|
||||
if is_tls == 1 || service.cleartext {
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("_{service_name}._tcp.{domain_name}."),
|
||||
record: DnsRecord::SRV(SRVRecord {
|
||||
target: target.clone(),
|
||||
priority: 0,
|
||||
weight: 1,
|
||||
port,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DnsRecordType::Caa => {
|
||||
if let CertificateManagement::Automatic(props) = &domain.certificate_management
|
||||
&& let Some(provider) = self
|
||||
.registry()
|
||||
.object::<AcmeProvider>(props.acme_provider_id)
|
||||
.await?
|
||||
&& let Ok(provider_url) = Url::parse(&provider.directory)
|
||||
&& let Some(provider_name) = provider_domain(&provider_url)
|
||||
{
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("{domain_name}."),
|
||||
record: DnsRecord::CAA(CAARecord::Issue {
|
||||
issuer_critical: false,
|
||||
name: provider_name.to_string().into(),
|
||||
options: vec![KeyValue {
|
||||
key: "accounturi".to_string(),
|
||||
value: provider.account_uri.clone(),
|
||||
}],
|
||||
}),
|
||||
});
|
||||
|
||||
if let Some(uri) = &domain.report_address_uri
|
||||
&& uri.starts_with("mailto:")
|
||||
{
|
||||
let url = if !uri.contains('@') {
|
||||
format!("{uri}@{domain_name}")
|
||||
} else {
|
||||
uri.to_string()
|
||||
};
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("{domain_name}."),
|
||||
record: DnsRecord::CAA(CAARecord::Iodef {
|
||||
issuer_critical: false,
|
||||
url,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// ACME DNS-PERSIST-01 validation record
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("_validation-persist.{domain_name}."),
|
||||
record: DnsRecord::TXT(format!(
|
||||
"{provider_name}; accounturi={}{}",
|
||||
provider.account_uri,
|
||||
if props.subject_alternative_names.is_empty() {
|
||||
"; policy=wildcard"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)),
|
||||
});
|
||||
}
|
||||
}
|
||||
DnsRecordType::Tlsa => {
|
||||
let mut hostnames: AHashMap<String, AHashSet<u16>> = AHashMap::new();
|
||||
|
||||
for mx in &network.info.mxs {
|
||||
let hostname = mx.hostname.as_deref().unwrap_or(default_host);
|
||||
if hostname.ends_with(&domain_name_suffix) || hostname == domain_name {
|
||||
hostnames
|
||||
.entry(hostname.to_string())
|
||||
.or_default()
|
||||
.insert(25);
|
||||
}
|
||||
}
|
||||
|
||||
for (protocol, service) in &network.info.services {
|
||||
let hostname = service.hostname.as_deref().unwrap_or(default_host);
|
||||
if hostname.ends_with(&domain_name_suffix) || hostname == domain_name {
|
||||
let port = match protocol {
|
||||
ServiceProtocol::Imap => 993,
|
||||
ServiceProtocol::Pop3 => 995,
|
||||
ServiceProtocol::Smtp => 465,
|
||||
ServiceProtocol::Jmap
|
||||
| ServiceProtocol::Caldav
|
||||
| ServiceProtocol::Carddav
|
||||
| ServiceProtocol::Webdav => 443,
|
||||
ServiceProtocol::Managesieve => continue,
|
||||
};
|
||||
hostnames
|
||||
.entry(hostname.to_string())
|
||||
.or_default()
|
||||
.insert(port);
|
||||
}
|
||||
}
|
||||
|
||||
for (record_name, record_type) in [
|
||||
("ua-auto-config", DnsRecordType::AutoConfig),
|
||||
("autoconfig", DnsRecordType::AutoConfigLegacy),
|
||||
("autodiscover", DnsRecordType::AutoDiscover),
|
||||
("mta-sts", DnsRecordType::MtaSts),
|
||||
] {
|
||||
if matches!(&domain.dns_management, DnsManagement::Automatic(props) if props.publish_records.contains(&record_type))
|
||||
|| matches!(domain.dns_management, DnsManagement::Manual)
|
||||
{
|
||||
hostnames
|
||||
.entry(format!("{record_name}.{domain_name}"))
|
||||
.or_default()
|
||||
.insert(443);
|
||||
}
|
||||
}
|
||||
|
||||
for (hostname, ports) in hostnames {
|
||||
if let Some(key) = self.resolve_certificate(&hostname) {
|
||||
for (cert_num, cert) in key.cert.iter().enumerate() {
|
||||
let parsed_cert = match parse_x509_certificate(cert) {
|
||||
Ok((_, parsed_cert)) => parsed_cert,
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.reason(err)
|
||||
.caused_by(trc::location!())
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let cert_usage = if cert_num == 0 {
|
||||
TlsaCertUsage::DaneEe
|
||||
} else {
|
||||
TlsaCertUsage::DaneTa
|
||||
};
|
||||
let cert_data = sha2::Sha256::digest(parsed_cert.subject_pki.raw);
|
||||
|
||||
for port in &ports {
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("_{port}._tcp.{hostname}."),
|
||||
record: DnsRecord::TLSA(TLSARecord {
|
||||
cert_usage,
|
||||
selector: TlsaSelector::Spki,
|
||||
matching: TlsaMatching::Sha256,
|
||||
cert_data: cert_data.to_vec(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
pub async fn build_bind_dns_records(
|
||||
&self,
|
||||
domain_id: Id,
|
||||
domain: &Domain,
|
||||
) -> trc::Result<String> {
|
||||
self.build_dns_records(
|
||||
domain_id,
|
||||
domain,
|
||||
&[
|
||||
DnsRecordType::Dkim,
|
||||
DnsRecordType::Tlsa,
|
||||
DnsRecordType::Spf,
|
||||
DnsRecordType::Mx,
|
||||
DnsRecordType::Dmarc,
|
||||
DnsRecordType::Srv,
|
||||
DnsRecordType::MtaSts,
|
||||
DnsRecordType::TlsRpt,
|
||||
DnsRecordType::Caa,
|
||||
DnsRecordType::AutoConfig,
|
||||
DnsRecordType::AutoConfigLegacy,
|
||||
DnsRecordType::AutoDiscover,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map(|records| BindSerializer::serialize(&records))
|
||||
}
|
||||
|
||||
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
|
||||
self.get_directory_for_domain(domain_name)
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|directory| {
|
||||
directory
|
||||
.and_then(|directory| {
|
||||
directory
|
||||
.oidc_discovery_document()
|
||||
.map(|doc| self.core.network.info.pacc.build(&doc.url))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
self.core
|
||||
.network
|
||||
.info
|
||||
.pacc
|
||||
.build(&self.core.network.http.url_https)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Pacc {
|
||||
pub fn build(&self, endpoint: &str) -> String {
|
||||
let mut response =
|
||||
String::with_capacity(self.prefix.len() + self.suffix.len() + endpoint.len());
|
||||
response.push_str(&self.prefix);
|
||||
response.push_str(endpoint);
|
||||
response.push_str(&self.suffix);
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
#[allow(unused)]
|
||||
fn provider_domain(url: &Url) -> Option<&str> {
|
||||
#[cfg(feature = "test_mode")]
|
||||
{
|
||||
Some("pebble.letsencrypt.org")
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
{
|
||||
url.host_str().and_then(psl::domain_str)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::Server;
|
||||
use mail_auth::{Error, IpLookupStrategy};
|
||||
use std::net::IpAddr;
|
||||
|
||||
impl Server {
|
||||
pub async fn dns_exists_mx(&self, entry: &str) -> trc::Result<bool> {
|
||||
match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.mx_lookup(entry, Some(&self.inner.cache.dns_mx))
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(result.rrset.iter().any(|mx| !mx.exchanges.is_empty())),
|
||||
Err(Error::Dns(mail_auth::DnsError::RecordNotFound(_))) => Ok(false),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn dns_exists_ip(&self, entry: &str) -> trc::Result<bool> {
|
||||
match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.ip_lookup(
|
||||
entry,
|
||||
IpLookupStrategy::Ipv4thenIpv6,
|
||||
10,
|
||||
Some(&self.inner.cache.dns_ipv4),
|
||||
Some(&self.inner.cache.dns_ipv6),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(!result.is_empty()),
|
||||
Err(Error::Dns(mail_auth::DnsError::RecordNotFound(_))) => Ok(false),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn dns_exists_ptr(&self, entry: &str) -> trc::Result<bool> {
|
||||
if let Ok(addr) = entry.parse::<IpAddr>() {
|
||||
match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.ptr_lookup(addr, Some(&self.inner.cache.dns_ptr))
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(!result.rrset.is_empty()),
|
||||
Err(Error::Dns(mail_auth::DnsError::RecordNotFound(_))) => Ok(false),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
} else {
|
||||
Err(trc::EventType::Resource(trc::ResourceEvent::BadParameters).into_err())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn dns_exists_ipv4(&self, entry: &str) -> trc::Result<bool> {
|
||||
match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.ipv4_lookup(entry, Some(&self.inner.cache.dns_ipv4))
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(!result.rrset.is_empty()),
|
||||
Err(Error::Dns(mail_auth::DnsError::RecordNotFound(_))) => Ok(false),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn dns_exists_ipv6(&self, entry: &str) -> trc::Result<bool> {
|
||||
match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.ipv6_lookup(entry, Some(&self.inner.cache.dns_ipv6))
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(!result.rrset.is_empty()),
|
||||
Err(Error::Dns(mail_auth::DnsError::RecordNotFound(_))) => Ok(false),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{ThrottleKey, ThrottleKeyHasher, ThrottleKeyHasherBuilder};
|
||||
use std::{
|
||||
hash::{BuildHasher, Hasher},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[repr(transparent)]
|
||||
pub struct ConcurrencyLimiter(Arc<ConcurrencyLimiterInner>);
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ConcurrencyLimiterInner {
|
||||
max_concurrent: u64,
|
||||
concurrent: AtomicU64,
|
||||
}
|
||||
|
||||
pub struct InFlight(Arc<ConcurrencyLimiterInner>);
|
||||
|
||||
impl Drop for InFlight {
|
||||
fn drop(&mut self) {
|
||||
self.0.concurrent.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl ConcurrencyLimiter {
|
||||
pub fn new(max_concurrent: u64) -> Self {
|
||||
ConcurrencyLimiter(Arc::new(ConcurrencyLimiterInner {
|
||||
max_concurrent,
|
||||
concurrent: AtomicU64::new(0),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn is_allowed(&self) -> LimiterResult {
|
||||
if self.0.concurrent.load(Ordering::Relaxed) < self.0.max_concurrent {
|
||||
// Return in-flight request
|
||||
self.0.concurrent.fetch_add(1, Ordering::Relaxed);
|
||||
LimiterResult::Allowed(InFlight(self.0.clone()))
|
||||
} else {
|
||||
LimiterResult::Forbidden
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_is_allowed(&self) -> bool {
|
||||
self.0.concurrent.load(Ordering::Relaxed) < self.0.max_concurrent
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.0.concurrent.load(Ordering::Relaxed) > 0
|
||||
}
|
||||
|
||||
pub fn max_concurrent(&self) -> u64 {
|
||||
self.0.max_concurrent
|
||||
}
|
||||
}
|
||||
|
||||
impl InFlight {
|
||||
pub fn num_concurrent(&self) -> u64 {
|
||||
self.0.concurrent.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum LimiterResult {
|
||||
Allowed(InFlight),
|
||||
Forbidden,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl From<LimiterResult> for Option<InFlight> {
|
||||
fn from(result: LimiterResult) -> Self {
|
||||
match result {
|
||||
LimiterResult::Allowed(in_flight) => Some(in_flight),
|
||||
LimiterResult::Forbidden => None,
|
||||
LimiterResult::Disabled => Some(InFlight(Arc::new(ConcurrencyLimiterInner::default()))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for ThrottleKey {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.hash == other.hash
|
||||
}
|
||||
}
|
||||
|
||||
impl std::hash::Hash for ThrottleKey {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.hash.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<[u8]> for ThrottleKey {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
&self.hash
|
||||
}
|
||||
}
|
||||
|
||||
impl Hasher for ThrottleKeyHasher {
|
||||
fn finish(&self) -> u64 {
|
||||
self.hash
|
||||
}
|
||||
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
debug_assert!(
|
||||
bytes.len() >= std::mem::size_of::<u64>(),
|
||||
"ThrottleKeyHasher: input too short {bytes:?}"
|
||||
);
|
||||
self.hash = bytes
|
||||
.get(0..std::mem::size_of::<u64>())
|
||||
.map_or(0, |b| u64::from_ne_bytes(b.try_into().unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
impl BuildHasher for ThrottleKeyHasherBuilder {
|
||||
type Hasher = ThrottleKeyHasher;
|
||||
|
||||
fn build_hasher(&self) -> Self::Hasher {
|
||||
ThrottleKeyHasher::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{
|
||||
ServerInstance, SessionData, SessionManager, SessionStream, TcpAcceptor,
|
||||
limiter::{ConcurrencyLimiter, LimiterResult},
|
||||
};
|
||||
use crate::{
|
||||
BuildServer, Inner, Server,
|
||||
config::server::{Listener, Listeners, ServerProtocol, TcpListener},
|
||||
};
|
||||
use proxy_header::io::ProxiedStream;
|
||||
use rustls::crypto::aws_lc_rs::cipher_suite::TLS13_AES_128_GCM_SHA256;
|
||||
use std::{
|
||||
io,
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use store::registry::bootstrap::Bootstrap;
|
||||
use tokio::{net::TcpStream, sync::watch, time::timeout};
|
||||
use tokio_rustls::server::TlsStream;
|
||||
use trc::{EventType, HttpEvent, ImapEvent, ManageSieveEvent, Pop3Event, SmtpEvent};
|
||||
use utils::UnwrapFailure;
|
||||
|
||||
impl Listener {
|
||||
pub fn spawn(
|
||||
self,
|
||||
manager: impl SessionManager,
|
||||
inner: Arc<Inner>,
|
||||
acceptor: TcpAcceptor,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
) {
|
||||
// Prepare instance
|
||||
let instance = Arc::new(ServerInstance {
|
||||
id: self.id,
|
||||
protocol: self.protocol,
|
||||
proxy_networks: self.proxy_networks,
|
||||
limiter: ConcurrencyLimiter::new(self.max_connections),
|
||||
tls_timeout: self.tls_timeout,
|
||||
acceptor,
|
||||
shutdown_rx,
|
||||
span_id_gen: self.span_id_gen,
|
||||
});
|
||||
let is_tls = matches!(instance.acceptor, TcpAcceptor::Tls { implicit, .. } if implicit);
|
||||
let is_https = is_tls && self.protocol == ServerProtocol::Http;
|
||||
let has_proxies = !instance.proxy_networks.is_empty();
|
||||
|
||||
// Spawn listeners
|
||||
for listener in self.listeners {
|
||||
let local_addr = listener.addr;
|
||||
|
||||
// Obtain TCP options
|
||||
let opts = SocketOpts {
|
||||
nodelay: listener.nodelay,
|
||||
ttl: listener.ttl,
|
||||
};
|
||||
|
||||
// Bind socket
|
||||
let listener = match listener.listen() {
|
||||
Ok(listener) => {
|
||||
trc::event!(
|
||||
Network(trc::NetworkEvent::ListenStart),
|
||||
ListenerId = instance.id.clone(),
|
||||
LocalIp = local_addr.ip(),
|
||||
LocalPort = local_addr.port(),
|
||||
Tls = is_tls,
|
||||
);
|
||||
|
||||
listener
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Network(trc::NetworkEvent::ListenError),
|
||||
ListenerId = instance.id.clone(),
|
||||
LocalIp = local_addr.ip(),
|
||||
LocalPort = local_addr.port(),
|
||||
Tls = is_tls,
|
||||
Reason = err,
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Spawn listener
|
||||
let mut shutdown_rx = instance.shutdown_rx.clone();
|
||||
let manager = manager.clone();
|
||||
let instance = instance.clone();
|
||||
let inner = inner.clone();
|
||||
tokio::spawn(async move {
|
||||
let (span_start, span_end) = match self.protocol {
|
||||
ServerProtocol::Smtp | ServerProtocol::Lmtp => (
|
||||
EventType::Smtp(SmtpEvent::ConnectionStart),
|
||||
EventType::Smtp(SmtpEvent::ConnectionEnd),
|
||||
),
|
||||
ServerProtocol::Imap => (
|
||||
EventType::Imap(ImapEvent::ConnectionStart),
|
||||
EventType::Imap(ImapEvent::ConnectionEnd),
|
||||
),
|
||||
ServerProtocol::Pop3 => (
|
||||
EventType::Pop3(Pop3Event::ConnectionStart),
|
||||
EventType::Pop3(Pop3Event::ConnectionEnd),
|
||||
),
|
||||
ServerProtocol::Http => (
|
||||
EventType::Http(HttpEvent::ConnectionStart),
|
||||
EventType::Http(HttpEvent::ConnectionEnd),
|
||||
),
|
||||
ServerProtocol::ManageSieve => (
|
||||
EventType::ManageSieve(ManageSieveEvent::ConnectionStart),
|
||||
EventType::ManageSieve(ManageSieveEvent::ConnectionEnd),
|
||||
),
|
||||
};
|
||||
|
||||
const ACCEPT_BACKOFF: Duration = Duration::from_millis(5);
|
||||
const MAX_ACCEPT_BACKOFF: Duration = Duration::from_secs(1);
|
||||
let mut accept_backoff = ACCEPT_BACKOFF;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
stream = listener.accept() => {
|
||||
match stream {
|
||||
Ok((stream, remote_addr)) => {
|
||||
accept_backoff = ACCEPT_BACKOFF;
|
||||
|
||||
let server = inner.build_server();
|
||||
let enable_acme = (is_https && server.has_acme_tls_providers()).then(|| server.clone());
|
||||
|
||||
if has_proxies && instance.proxy_networks.iter().any(|network| network.matches(&remote_addr.ip())) {
|
||||
let instance = instance.clone();
|
||||
let manager = manager.clone();
|
||||
|
||||
// Set socket options
|
||||
opts.apply(&stream);
|
||||
|
||||
tokio::spawn(async move {
|
||||
match ProxiedStream::create_from_tokio(stream, Default::default()).await {
|
||||
Ok(stream) =>{
|
||||
let (remote_addr, local_addr) = stream.proxy_header()
|
||||
.proxied_address()
|
||||
.map(|addr| {
|
||||
let local_addr = match addr.destination.ip() {
|
||||
IpAddr::V6(ip) => ip
|
||||
.to_ipv4_mapped()
|
||||
.map(|ip| SocketAddr::new(IpAddr::V4(ip), addr.destination.port()))
|
||||
.unwrap_or(addr.destination),
|
||||
_ => addr.destination,
|
||||
};
|
||||
|
||||
(addr.source, local_addr)
|
||||
})
|
||||
.unwrap_or((remote_addr, local_addr));
|
||||
if let Some(session) = instance.build_session(stream, local_addr, remote_addr, &server) {
|
||||
// Spawn session
|
||||
manager.spawn(session, is_tls, enable_acme, span_start, span_end);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Network(trc::NetworkEvent::ProxyError),
|
||||
ListenerId = instance.id.clone(),
|
||||
LocalIp = local_addr.ip(),
|
||||
LocalPort = local_addr.port(),
|
||||
Tls = is_tls,
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if let Some(session) = instance.build_session(stream, local_addr, remote_addr, &server) {
|
||||
// Set socket options
|
||||
opts.apply(&session.stream);
|
||||
|
||||
// Spawn session
|
||||
manager.spawn(session, is_tls, enable_acme, span_start, span_end);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if matches!(
|
||||
err.kind(),
|
||||
std::io::ErrorKind::ConnectionAborted
|
||||
| std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::Interrupted
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Network(trc::NetworkEvent::AcceptError),
|
||||
ListenerId = instance.id.clone(),
|
||||
LocalIp = local_addr.ip(),
|
||||
LocalPort = local_addr.port(),
|
||||
Tls = is_tls,
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(accept_backoff) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
manager.shutdown().await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
accept_backoff = (accept_backoff * 2).min(MAX_ACCEPT_BACKOFF);
|
||||
}
|
||||
}
|
||||
},
|
||||
_ = shutdown_rx.changed() => {
|
||||
|
||||
trc::event!(
|
||||
Network(trc::NetworkEvent::ListenStop),
|
||||
ListenerId = instance.id.clone(),
|
||||
LocalIp = local_addr.ip(),
|
||||
Tls = is_tls,
|
||||
LocalPort = local_addr.port(),
|
||||
);
|
||||
|
||||
manager.shutdown().await;
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait BuildSession {
|
||||
fn build_session<T: SessionStream>(
|
||||
&self,
|
||||
stream: T,
|
||||
local_addr: SocketAddr,
|
||||
remote_addr: SocketAddr,
|
||||
server: &Server,
|
||||
) -> Option<SessionData<T>>;
|
||||
}
|
||||
|
||||
impl BuildSession for Arc<ServerInstance> {
|
||||
fn build_session<T: SessionStream>(
|
||||
&self,
|
||||
stream: T,
|
||||
local_addr: SocketAddr,
|
||||
remote_addr: SocketAddr,
|
||||
server: &Server,
|
||||
) -> Option<SessionData<T>> {
|
||||
// Convert mapped IPv6 addresses to IPv4
|
||||
let remote_ip = match remote_addr.ip() {
|
||||
IpAddr::V6(ip) => ip
|
||||
.to_ipv4_mapped()
|
||||
.map(IpAddr::V4)
|
||||
.unwrap_or(IpAddr::V6(ip)),
|
||||
remote_ip => remote_ip,
|
||||
};
|
||||
let remote_port = remote_addr.port();
|
||||
|
||||
// Check if blocked
|
||||
if server.is_ip_blocked(remote_ip) {
|
||||
trc::event!(
|
||||
Security(trc::SecurityEvent::IpBlocked),
|
||||
ListenerId = self.id.clone(),
|
||||
LocalPort = local_addr.port(),
|
||||
RemoteIp = remote_ip,
|
||||
RemotePort = remote_port,
|
||||
);
|
||||
None
|
||||
} else if let LimiterResult::Allowed(in_flight) = self.limiter.is_allowed() {
|
||||
// Enforce concurrency
|
||||
SessionData {
|
||||
stream,
|
||||
in_flight,
|
||||
local_ip: local_addr.ip(),
|
||||
local_port: local_addr.port(),
|
||||
session_id: 0,
|
||||
remote_ip,
|
||||
remote_port,
|
||||
protocol: self.protocol,
|
||||
instance: self.clone(),
|
||||
}
|
||||
.into()
|
||||
} else {
|
||||
trc::event!(
|
||||
Limit(trc::LimitEvent::ConcurrentConnection),
|
||||
ListenerId = self.id.clone(),
|
||||
LocalPort = local_addr.port(),
|
||||
RemoteIp = remote_ip,
|
||||
RemotePort = remote_port,
|
||||
Limit = self.limiter.max_concurrent(),
|
||||
);
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SocketOpts {
|
||||
pub nodelay: bool,
|
||||
pub ttl: Option<u32>,
|
||||
}
|
||||
|
||||
impl SocketOpts {
|
||||
pub fn apply(&self, stream: &TcpStream) {
|
||||
// Set TCP options
|
||||
if let Err(err) = stream.set_nodelay(self.nodelay) {
|
||||
trc::event!(
|
||||
Network(trc::NetworkEvent::SetOptError),
|
||||
Reason = err.to_string(),
|
||||
Details = "Failed to set TCP_NODELAY",
|
||||
);
|
||||
}
|
||||
if let Some(ttl) = self.ttl
|
||||
&& let Err(err) = stream.set_ttl(ttl)
|
||||
{
|
||||
trc::event!(
|
||||
Network(trc::NetworkEvent::SetOptError),
|
||||
Reason = err.to_string(),
|
||||
Details = "Failed to set TTL",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Listeners {
|
||||
pub fn bind_and_drop_priv(&self, bp: &mut Bootstrap) {
|
||||
// Bind as root
|
||||
for server in &self.servers {
|
||||
for listener in &server.listeners {
|
||||
if let Err(err) = listener.socket.bind(listener.addr) {
|
||||
bp.build_error(
|
||||
server.registry_id,
|
||||
format!("Failed to bind to {}: {}", listener.addr, err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop privileges
|
||||
#[cfg(not(target_env = "msvc"))]
|
||||
{
|
||||
if let Ok(run_as_user) = std::env::var("RUN_AS_USER") {
|
||||
let mut pd = privdrop::PrivDrop::default()
|
||||
.user(run_as_user)
|
||||
.fallback_to_ids_if_names_are_numeric();
|
||||
if let Ok(run_as_group) = std::env::var("RUN_AS_GROUP") {
|
||||
pd = pd
|
||||
.group(run_as_group)
|
||||
.fallback_to_ids_if_names_are_numeric();
|
||||
}
|
||||
pd.apply().failed("Failed to drop privileges");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn(
|
||||
mut self,
|
||||
spawn: impl Fn(Listener, TcpAcceptor, watch::Receiver<bool>),
|
||||
) -> (watch::Sender<bool>, watch::Receiver<bool>) {
|
||||
// Spawn listeners
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
for server in self.servers {
|
||||
let acceptor = self
|
||||
.tcp_acceptors
|
||||
.remove(&server.id)
|
||||
.unwrap_or(TcpAcceptor::Plain);
|
||||
|
||||
spawn(server, acceptor, shutdown_rx.clone());
|
||||
}
|
||||
(shutdown_tx, shutdown_rx)
|
||||
}
|
||||
}
|
||||
|
||||
impl TcpListener {
|
||||
pub fn listen(self) -> Result<tokio::net::TcpListener, String> {
|
||||
self.socket
|
||||
.listen(self.backlog.unwrap_or(1024))
|
||||
.map_err(|err| format!("Failed to listen on {}: {}", self.addr, err))
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerInstance {
|
||||
pub async fn tls_accept<T: SessionStream>(
|
||||
&self,
|
||||
stream: T,
|
||||
session_id: u64,
|
||||
) -> Result<TlsStream<T>, ()> {
|
||||
match &self.acceptor {
|
||||
TcpAcceptor::Tls { acceptor, .. } => {
|
||||
match timeout(self.tls_timeout, acceptor.accept(stream))
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(io::Error::from(io::ErrorKind::TimedOut)))
|
||||
{
|
||||
Ok(stream) => {
|
||||
trc::event!(
|
||||
Tls(trc::TlsEvent::Handshake),
|
||||
ListenerId = self.id.clone(),
|
||||
SpanId = session_id,
|
||||
Version = format!(
|
||||
"{:?}",
|
||||
stream
|
||||
.get_ref()
|
||||
.1
|
||||
.protocol_version()
|
||||
.unwrap_or(rustls::ProtocolVersion::TLSv1_3)
|
||||
),
|
||||
Details = format!(
|
||||
"{:?}",
|
||||
stream
|
||||
.get_ref()
|
||||
.1
|
||||
.negotiated_cipher_suite()
|
||||
.unwrap_or(TLS13_AES_128_GCM_SHA256)
|
||||
)
|
||||
);
|
||||
Ok(stream)
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Tls(trc::TlsEvent::HandshakeError),
|
||||
ListenerId = self.id.clone(),
|
||||
SpanId = session_id,
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
TcpAcceptor::Plain => {
|
||||
trc::event!(
|
||||
Tls(trc::TlsEvent::NotConfigured),
|
||||
ListenerId = self.id.clone(),
|
||||
SpanId = session_id,
|
||||
);
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use self::limiter::{ConcurrencyLimiter, InFlight};
|
||||
use crate::{
|
||||
Server,
|
||||
config::server::ServerProtocol,
|
||||
expr::{functions::ResolveVariable, *},
|
||||
};
|
||||
use compact_str::ToCompactString;
|
||||
use registry::{schema::enums::ExpressionVariable, types::ipmask::IpAddrOrMask};
|
||||
use rustls::ServerConfig;
|
||||
use std::fmt::Debug;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
io,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncRead, AsyncWrite},
|
||||
sync::watch,
|
||||
time::timeout,
|
||||
};
|
||||
use tokio_rustls::{Accept, TlsAcceptor};
|
||||
use trc::{Event, EventType, Key};
|
||||
use utils::snowflake::SnowflakeIdGenerator;
|
||||
|
||||
pub mod acme;
|
||||
pub mod asn;
|
||||
pub mod autoconfig;
|
||||
pub mod dkim;
|
||||
pub mod dns;
|
||||
pub mod limiter;
|
||||
pub mod listen;
|
||||
pub mod mta;
|
||||
pub mod security;
|
||||
pub mod stream;
|
||||
pub mod tls;
|
||||
pub mod webpush;
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum RcptResolution {
|
||||
Accept,
|
||||
Expand(Arc<[Box<str>]>),
|
||||
Rewrite(String),
|
||||
#[default]
|
||||
UnknownRecipient,
|
||||
UnknownDomain,
|
||||
}
|
||||
|
||||
pub struct ServerInstance {
|
||||
pub id: String,
|
||||
pub protocol: ServerProtocol,
|
||||
pub acceptor: TcpAcceptor,
|
||||
pub limiter: ConcurrencyLimiter,
|
||||
pub proxy_networks: Vec<IpAddrOrMask>,
|
||||
pub tls_timeout: Duration,
|
||||
pub shutdown_rx: watch::Receiver<bool>,
|
||||
pub span_id_gen: Arc<SnowflakeIdGenerator>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub enum TcpAcceptor {
|
||||
Tls {
|
||||
config: Arc<ServerConfig>,
|
||||
acceptor: TlsAcceptor,
|
||||
implicit: bool,
|
||||
},
|
||||
#[default]
|
||||
Plain,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum TcpAcceptorResult<IO>
|
||||
where
|
||||
IO: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
Tls(Accept<IO>),
|
||||
Plain(IO),
|
||||
Close,
|
||||
}
|
||||
|
||||
pub struct SessionData<T: SessionStream> {
|
||||
pub stream: T,
|
||||
pub local_ip: IpAddr,
|
||||
pub local_port: u16,
|
||||
pub remote_ip: IpAddr,
|
||||
pub remote_port: u16,
|
||||
pub protocol: ServerProtocol,
|
||||
pub session_id: u64,
|
||||
pub in_flight: InFlight,
|
||||
pub instance: Arc<ServerInstance>,
|
||||
}
|
||||
|
||||
pub trait SessionStream: AsyncRead + AsyncWrite + Unpin + 'static + Sync + Send {
|
||||
fn is_tls(&self) -> bool;
|
||||
fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionResult {
|
||||
Continue,
|
||||
Close,
|
||||
UpgradeTls,
|
||||
}
|
||||
|
||||
pub trait SessionManager: Sync + Send + 'static + Clone {
|
||||
fn spawn<T: SessionStream>(
|
||||
&self,
|
||||
mut session: SessionData<T>,
|
||||
is_tls: bool,
|
||||
acme_core: Option<Server>,
|
||||
span_start: EventType,
|
||||
span_end: EventType,
|
||||
) {
|
||||
let manager = self.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let start_time = Instant::now();
|
||||
let local_port = session.local_port;
|
||||
let session_id;
|
||||
|
||||
if is_tls {
|
||||
let tls_timeout = session.instance.tls_timeout;
|
||||
match timeout(
|
||||
tls_timeout,
|
||||
session
|
||||
.instance
|
||||
.acceptor
|
||||
.accept(session.stream, acme_core, &session.instance),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(TcpAcceptorResult::Tls(accept)) => {
|
||||
match timeout(tls_timeout.saturating_sub(start_time.elapsed()), accept)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(io::Error::from(io::ErrorKind::TimedOut)))
|
||||
{
|
||||
Ok(stream) => {
|
||||
// Generate sessionId
|
||||
session.session_id = session.instance.span_id_gen.generate();
|
||||
session_id = session.session_id;
|
||||
|
||||
// Send span
|
||||
Event::with_keys(
|
||||
span_start,
|
||||
vec![
|
||||
(Key::ListenerId, session.instance.id.clone().into()),
|
||||
(Key::LocalPort, session.local_port.into()),
|
||||
(Key::RemoteIp, session.remote_ip.into()),
|
||||
(Key::RemotePort, session.remote_port.into()),
|
||||
(Key::SpanId, session.session_id.into()),
|
||||
],
|
||||
)
|
||||
.send_with_metrics();
|
||||
|
||||
manager
|
||||
.handle(SessionData {
|
||||
stream,
|
||||
local_ip: session.local_ip,
|
||||
local_port: session.local_port,
|
||||
remote_ip: session.remote_ip,
|
||||
remote_port: session.remote_port,
|
||||
protocol: session.protocol,
|
||||
session_id: session.session_id,
|
||||
in_flight: session.in_flight,
|
||||
instance: session.instance,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Tls(trc::TlsEvent::HandshakeError),
|
||||
ListenerId = session.instance.id.clone(),
|
||||
LocalPort = local_port,
|
||||
RemoteIp = session.remote_ip,
|
||||
RemotePort = session.remote_port,
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(TcpAcceptorResult::Plain(stream)) => {
|
||||
// Generate sessionId
|
||||
session.session_id = session.instance.span_id_gen.generate();
|
||||
session_id = session.session_id;
|
||||
|
||||
// Send span
|
||||
Event::with_keys(
|
||||
span_start,
|
||||
vec![
|
||||
(Key::ListenerId, session.instance.id.clone().into()),
|
||||
(Key::LocalPort, session.local_port.into()),
|
||||
(Key::RemoteIp, session.remote_ip.into()),
|
||||
(Key::RemotePort, session.remote_port.into()),
|
||||
(Key::SpanId, session.session_id.into()),
|
||||
],
|
||||
)
|
||||
.send_with_metrics();
|
||||
|
||||
session.stream = stream;
|
||||
manager.handle(session).await;
|
||||
}
|
||||
Ok(TcpAcceptorResult::Close) => return,
|
||||
Err(_) => {
|
||||
trc::event!(
|
||||
Tls(trc::TlsEvent::HandshakeError),
|
||||
ListenerId = session.instance.id.clone(),
|
||||
LocalPort = local_port,
|
||||
RemoteIp = session.remote_ip,
|
||||
RemotePort = session.remote_port,
|
||||
Reason = io::Error::from(io::ErrorKind::TimedOut).to_string(),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Generate sessionId
|
||||
session.session_id = session.instance.span_id_gen.generate();
|
||||
session_id = session.session_id;
|
||||
|
||||
// Send span
|
||||
Event::with_keys(
|
||||
span_start,
|
||||
vec![
|
||||
(Key::ListenerId, session.instance.id.clone().into()),
|
||||
(Key::LocalPort, session.local_port.into()),
|
||||
(Key::RemoteIp, session.remote_ip.into()),
|
||||
(Key::RemotePort, session.remote_port.into()),
|
||||
(Key::SpanId, session.session_id.into()),
|
||||
],
|
||||
)
|
||||
.send_with_metrics();
|
||||
|
||||
manager.handle(session).await;
|
||||
}
|
||||
|
||||
// End span
|
||||
Event::with_keys(
|
||||
span_end,
|
||||
vec![
|
||||
(Key::SpanId, session_id.into()),
|
||||
(Key::Elapsed, start_time.elapsed().into()),
|
||||
],
|
||||
)
|
||||
.send_with_metrics();
|
||||
});
|
||||
}
|
||||
|
||||
fn handle<T: SessionStream>(
|
||||
self,
|
||||
session: SessionData<T>,
|
||||
) -> impl std::future::Future<Output = ()> + Send;
|
||||
|
||||
fn shutdown(&self) -> impl std::future::Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl<T: SessionStream> ResolveVariable for SessionData<T> {
|
||||
fn resolve_variable(&self, variable: ExpressionVariable) -> crate::expr::Variable<'_> {
|
||||
match variable {
|
||||
ExpressionVariable::RemoteIp => self.remote_ip.to_compact_string().into(),
|
||||
ExpressionVariable::RemotePort => self.remote_port.into(),
|
||||
ExpressionVariable::LocalIp => self.local_ip.to_compact_string().into(),
|
||||
ExpressionVariable::LocalPort => self.local_port.into(),
|
||||
ExpressionVariable::Listener => self.instance.id.as_str().into(),
|
||||
ExpressionVariable::Protocol => self.protocol.as_str().into(),
|
||||
ExpressionVariable::IsTls => self.stream.is_tls().into(),
|
||||
_ => crate::expr::Variable::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_global(&self, _: &str) -> Variable<'_> {
|
||||
Variable::Integer(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for TcpAcceptor {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Tls {
|
||||
config, implicit, ..
|
||||
} => f
|
||||
.debug_struct("Tls")
|
||||
.field("config", config)
|
||||
.field("implicit", implicit)
|
||||
.finish(),
|
||||
Self::Plain => write!(f, "Plain"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_global_ip(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => is_global_ipv4(ip),
|
||||
IpAddr::V6(ip) => is_global_ipv6(ip),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_global_ipv4(ip: &Ipv4Addr) -> bool {
|
||||
let [a, b, c, _] = ip.octets();
|
||||
let is_this_network = a == 0;
|
||||
let is_shared = a == 100 && (64..128).contains(&b);
|
||||
let is_protocol_assignment = a == 192 && b == 0 && c == 0;
|
||||
let is_benchmarking = a == 198 && (b & 0xfe) == 18;
|
||||
let is_relay_6to4 = a == 192 && b == 88 && c == 99;
|
||||
let is_reserved = a >= 240;
|
||||
|
||||
!(ip.is_unspecified()
|
||||
|| ip.is_loopback()
|
||||
|| ip.is_private()
|
||||
|| ip.is_link_local()
|
||||
|| ip.is_multicast()
|
||||
|| ip.is_broadcast()
|
||||
|| ip.is_documentation()
|
||||
|| is_this_network
|
||||
|| is_shared
|
||||
|| is_protocol_assignment
|
||||
|| is_benchmarking
|
||||
|| is_relay_6to4
|
||||
|| is_reserved)
|
||||
}
|
||||
|
||||
fn is_global_ipv6(ip: &Ipv6Addr) -> bool {
|
||||
if ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(ip) = ip.to_ipv4() {
|
||||
return is_global_ipv4(&ip);
|
||||
}
|
||||
|
||||
let segments = ip.segments();
|
||||
|
||||
if segments[0] == 0x2002 {
|
||||
return is_global_ipv4(&Ipv4Addr::from(
|
||||
((segments[1] as u32) << 16) | segments[2] as u32,
|
||||
));
|
||||
}
|
||||
|
||||
if segments[0] == 0x0064 && segments[1] == 0xff9b {
|
||||
let is_well_known_prefix =
|
||||
segments[2] == 0 && segments[3] == 0 && segments[4] == 0 && segments[5] == 0;
|
||||
|
||||
return is_well_known_prefix
|
||||
&& is_global_ipv4(&Ipv4Addr::from(
|
||||
((segments[6] as u32) << 16) | segments[7] as u32,
|
||||
));
|
||||
}
|
||||
|
||||
let is_unique_local = (segments[0] & 0xfe00) == 0xfc00;
|
||||
let is_link_local = (segments[0] & 0xffc0) == 0xfe80;
|
||||
let is_site_local = (segments[0] & 0xffc0) == 0xfec0;
|
||||
let is_discard_only =
|
||||
segments[0] == 0x0100 && segments[1] == 0 && segments[2] == 0 && segments[3] == 0;
|
||||
let is_documentation = segments[0] == 0x2001 && segments[1] == 0x0db8;
|
||||
let is_teredo = segments[0] == 0x2001 && segments[1] == 0;
|
||||
let is_orchid = segments[0] == 0x2001 && (segments[1] & 0xfff0) == 0x0020;
|
||||
|
||||
!(is_unique_local
|
||||
|| is_link_local
|
||||
|| is_site_local
|
||||
|| is_discard_only
|
||||
|| is_documentation
|
||||
|| is_teredo
|
||||
|| is_orchid)
|
||||
}
|
||||
|
||||
pub fn ip_to_bytes(ip: &IpAddr) -> Vec<u8> {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => ip.octets().to_vec(),
|
||||
IpAddr::V6(ip) => ip.octets().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ip_to_bytes_prefix(prefix: u8, ip: &IpAddr) -> Vec<u8> {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => {
|
||||
let mut buf = Vec::with_capacity(5);
|
||||
buf.push(prefix);
|
||||
buf.extend_from_slice(&ip.octets());
|
||||
buf
|
||||
}
|
||||
IpAddr::V6(ip) => {
|
||||
let mut buf = Vec::with_capacity(17);
|
||||
buf.push(prefix);
|
||||
buf.extend_from_slice(&ip.octets());
|
||||
buf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_global_ip;
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[test]
|
||||
fn global_ip_classification() {
|
||||
for ip in [
|
||||
"8.8.8.8",
|
||||
"1.1.1.1",
|
||||
"93.184.216.34",
|
||||
"172.32.0.1",
|
||||
"100.63.255.255",
|
||||
"100.128.0.1",
|
||||
"198.20.0.1",
|
||||
"192.0.3.1",
|
||||
"2606:4700::1111",
|
||||
"2a00:1450:4001::200e",
|
||||
"::ffff:8.8.8.8",
|
||||
"64:ff9b::808:808",
|
||||
"2002:0808:0808::",
|
||||
"2001:db9::1",
|
||||
] {
|
||||
assert!(
|
||||
is_global_ip(&ip.parse::<IpAddr>().unwrap()),
|
||||
"expected {ip} to be global"
|
||||
);
|
||||
}
|
||||
|
||||
for ip in [
|
||||
"127.0.0.1",
|
||||
"127.1.2.3",
|
||||
"10.0.0.1",
|
||||
"172.16.0.1",
|
||||
"172.31.255.255",
|
||||
"192.168.1.1",
|
||||
"169.254.169.254",
|
||||
"100.64.0.1",
|
||||
"100.127.255.255",
|
||||
"198.18.0.1",
|
||||
"198.19.255.255",
|
||||
"192.0.0.1",
|
||||
"192.0.2.5",
|
||||
"192.88.99.1",
|
||||
"240.0.0.1",
|
||||
"255.255.255.255",
|
||||
"224.0.0.1",
|
||||
"0.0.0.0",
|
||||
"0.1.2.3",
|
||||
"::1",
|
||||
"::",
|
||||
"fc00::1",
|
||||
"fd12:3456::1",
|
||||
"fe80::1",
|
||||
"febf::1",
|
||||
"fec0::1",
|
||||
"2001:db8::1",
|
||||
"ff02::1",
|
||||
"::ffff:127.0.0.1",
|
||||
"::ffff:10.0.0.1",
|
||||
"::127.0.0.1",
|
||||
"64:ff9b::7f00:1",
|
||||
"64:ff9b::a00:1",
|
||||
"64:ff9b:1::7f00:1",
|
||||
"2002:7f00:1::",
|
||||
"2002:c0a8:101::",
|
||||
"100::1",
|
||||
"2001::1",
|
||||
"2001:20::1",
|
||||
] {
|
||||
assert!(
|
||||
!is_global_ip(&ip.parse::<IpAddr>().unwrap()),
|
||||
"expected {ip} to be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
Server,
|
||||
auth::{DOMAIN_FLAG_RELAY, DOMAIN_FLAG_SUB_ADDRESSING, EmailAddressRef, EmailCache},
|
||||
config::{
|
||||
mailstore::spamfilter::SpamClassifier,
|
||||
smtp::{
|
||||
auth::DkimSigners,
|
||||
queue::{
|
||||
ConnectionStrategy, DEFAULT_QUEUE_NAME, MxConfig, QueueExpiry, QueueName,
|
||||
QueueStrategy, RequireOptional, RoutingStrategy, TlsStrategy, VirtualQueue,
|
||||
},
|
||||
},
|
||||
},
|
||||
expr::{Variable, functions::ResolveVariable},
|
||||
manager::SPAM_CLASSIFIER_KEY,
|
||||
network::RcptResolution,
|
||||
};
|
||||
use directory::Recipient;
|
||||
use mail_auth::IpLookupStrategy;
|
||||
use registry::schema::{enums::ExpressionVariable, structs::MaskedEmail};
|
||||
use sieve::Sieve;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
sync::{Arc, LazyLock},
|
||||
time::Duration,
|
||||
};
|
||||
use store::{
|
||||
Deserialize, IterateParams, ValueKey,
|
||||
write::{AlignedBytes, Archive, QueueClass, ValueClass, now},
|
||||
};
|
||||
use trc::{AddContext, SpamEvent};
|
||||
use types::id::Id;
|
||||
|
||||
impl Server {
|
||||
pub async fn rcpt_resolve(
|
||||
&self,
|
||||
rcpt: &str,
|
||||
allow_catch_all: bool,
|
||||
session_id: u64,
|
||||
) -> trc::Result<RcptResolution> {
|
||||
// Obtain domain settings
|
||||
let Some((local_part, domain_part)) = rcpt.rsplit_once('@') else {
|
||||
return Ok(RcptResolution::UnknownDomain);
|
||||
};
|
||||
let Some(domain) = self.domain(domain_part).await? else {
|
||||
return Ok(RcptResolution::UnknownDomain);
|
||||
};
|
||||
|
||||
// Sub-addressing resolution
|
||||
let local_part_orig = local_part;
|
||||
let mut local_part = Cow::Borrowed(local_part);
|
||||
if domain.flags & DOMAIN_FLAG_SUB_ADDRESSING != 0 {
|
||||
if let Some(sub_addressing) = &domain.sub_addressing_custom {
|
||||
// Custom sub-addressing resolution
|
||||
if let Some(result) = self
|
||||
.eval_if::<String, _>(
|
||||
sub_addressing,
|
||||
&AddressResolver(local_part.as_ref()),
|
||||
session_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
local_part = Cow::Owned(result);
|
||||
}
|
||||
} else if let Some((new_local_part, _)) = rcpt.split_once('+') {
|
||||
local_part = Cow::Borrowed(new_local_part);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Obtain external directory, if configured
|
||||
let directory = self
|
||||
.get_directory_for_cached_domain(&domain)
|
||||
.filter(|directory| directory.can_lookup_recipients());
|
||||
if let Some(directory) = directory {
|
||||
let is_subaddressed = local_part.as_ref() != local_part_orig;
|
||||
let address = if is_subaddressed {
|
||||
Cow::Owned(format!("{local_part}@{domain_part}"))
|
||||
} else {
|
||||
Cow::Borrowed(rcpt)
|
||||
};
|
||||
match directory.recipient(address.as_ref()).await? {
|
||||
Recipient::Account(account) => {
|
||||
Box::pin(self.synchronize_account(account)).await?;
|
||||
return Ok(if is_subaddressed {
|
||||
RcptResolution::Rewrite(address.into_owned())
|
||||
} else {
|
||||
RcptResolution::Accept
|
||||
});
|
||||
}
|
||||
Recipient::Group(group) => {
|
||||
Box::pin(self.synchronize_group(group)).await?;
|
||||
return Ok(if is_subaddressed {
|
||||
RcptResolution::Rewrite(address.into_owned())
|
||||
} else {
|
||||
RcptResolution::Accept
|
||||
});
|
||||
}
|
||||
Recipient::Invalid => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Try resolving address from registry
|
||||
if let Some(address_type) = self
|
||||
.rcpt_id_from_parts(local_part.as_ref(), domain.id)
|
||||
.await?
|
||||
{
|
||||
match address_type {
|
||||
EmailCache::Account(id) if directory.is_none() => {
|
||||
if self.try_account(id).await?.is_some() {
|
||||
return if local_part.as_ref() == local_part_orig {
|
||||
Ok(RcptResolution::Accept)
|
||||
} else {
|
||||
Ok(RcptResolution::Rewrite(format!(
|
||||
"{local_part}@{domain_part}"
|
||||
)))
|
||||
};
|
||||
} else {
|
||||
self.inner
|
||||
.cache
|
||||
.emails
|
||||
.remove(&EmailAddressRef::new(local_part.as_ref(), domain.id));
|
||||
}
|
||||
}
|
||||
EmailCache::MailingList(id) => {
|
||||
if let Some(list) = self.try_list(id).await? {
|
||||
return Ok(RcptResolution::Expand(list.recipients.clone()));
|
||||
} else {
|
||||
self.inner
|
||||
.cache
|
||||
.emails
|
||||
.remove(&EmailAddressRef::new(local_part.as_ref(), domain.id));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Catch-all resolution
|
||||
if allow_catch_all && let Some(catch_all) = &domain.catch_all {
|
||||
return Ok(
|
||||
match Box::pin(self.rcpt_resolve(catch_all, false, session_id)).await? {
|
||||
resolution @ (RcptResolution::Expand(_) | RcptResolution::Rewrite(_)) => {
|
||||
resolution
|
||||
}
|
||||
_ => RcptResolution::Rewrite(catch_all.to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Verify whether domain relaying is enabled
|
||||
if domain.flags & DOMAIN_FLAG_RELAY != 0 {
|
||||
Ok(RcptResolution::Accept)
|
||||
} else {
|
||||
Ok(RcptResolution::UnknownRecipient)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_dkim_signers(
|
||||
&self,
|
||||
domain: &str,
|
||||
session_id: u64,
|
||||
) -> trc::Result<Option<Arc<DkimSigners>>> {
|
||||
if let Some(signers) = self.dkim_signers(domain).await? {
|
||||
Ok(Some(signers))
|
||||
} else {
|
||||
trc::event!(
|
||||
Dkim(trc::DkimEvent::SignerNotFound),
|
||||
Id = domain.to_string(),
|
||||
SpanId = session_id,
|
||||
);
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_trusted_sieve_script(&self, name: &str, session_id: u64) -> Option<&Arc<Sieve>> {
|
||||
self.core.sieve.trusted_script(name).or_else(|| {
|
||||
trc::event!(
|
||||
Sieve(trc::SieveEvent::ScriptNotFound),
|
||||
Id = name.to_string(),
|
||||
SpanId = session_id,
|
||||
);
|
||||
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_untrusted_sieve_script(&self, name: &str, session_id: u64) -> Option<&Arc<Sieve>> {
|
||||
self.core.sieve.untrusted_script(name).or_else(|| {
|
||||
trc::event!(
|
||||
Sieve(trc::SieveEvent::ScriptNotFound),
|
||||
Id = name.to_string(),
|
||||
SpanId = session_id,
|
||||
);
|
||||
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_route_or_default(&self, name: &str, session_id: u64) -> &RoutingStrategy {
|
||||
static LOCAL_GATEWAY: RoutingStrategy = RoutingStrategy::Local;
|
||||
static MX_GATEWAY: RoutingStrategy = RoutingStrategy::Mx(MxConfig {
|
||||
max_mx: 5,
|
||||
max_multi_homed: 2,
|
||||
ip_lookup_strategy: IpLookupStrategy::Ipv4thenIpv6,
|
||||
});
|
||||
self.core
|
||||
.smtp
|
||||
.queue
|
||||
.routing_strategy
|
||||
.get(name)
|
||||
.unwrap_or_else(|| match name {
|
||||
"local" => &LOCAL_GATEWAY,
|
||||
"mx" => &MX_GATEWAY,
|
||||
_ => {
|
||||
trc::event!(
|
||||
Smtp(trc::SmtpEvent::IdNotFound),
|
||||
Id = name.to_string(),
|
||||
Details = "Gateway not found",
|
||||
SpanId = session_id,
|
||||
);
|
||||
&MX_GATEWAY
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_virtual_queue_or_default(&self, name: &QueueName) -> &VirtualQueue {
|
||||
static DEFAULT_QUEUE: VirtualQueue = VirtualQueue { threads: 25 };
|
||||
self.core
|
||||
.smtp
|
||||
.queue
|
||||
.virtual_queues
|
||||
.get(name)
|
||||
.unwrap_or_else(|| {
|
||||
if name != &DEFAULT_QUEUE_NAME {
|
||||
trc::event!(
|
||||
Smtp(trc::SmtpEvent::IdNotFound),
|
||||
Id = name.to_string(),
|
||||
Details = "Virtual queue not found",
|
||||
);
|
||||
}
|
||||
|
||||
&DEFAULT_QUEUE
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_queue_or_default(&self, name: &str, session_id: u64) -> &QueueStrategy {
|
||||
static DEFAULT_SCHEDULE: LazyLock<QueueStrategy> = LazyLock::new(|| QueueStrategy {
|
||||
retry: vec![
|
||||
120, // 2 minutes
|
||||
300, // 5 minutes
|
||||
600, // 10 minutes
|
||||
900, // 15 minutes
|
||||
1800, // 30 minutes
|
||||
3600, // 1 hour
|
||||
7200, // 2 hours
|
||||
],
|
||||
notify: vec![
|
||||
86400, // 1 day
|
||||
259200, // 3 days
|
||||
],
|
||||
expiry: QueueExpiry::Ttl(432000), // 5 days
|
||||
virtual_queue: QueueName::default(),
|
||||
});
|
||||
self.core
|
||||
.smtp
|
||||
.queue
|
||||
.queue_strategy
|
||||
.get(name)
|
||||
.unwrap_or_else(|| {
|
||||
if name != "default" {
|
||||
trc::event!(
|
||||
Smtp(trc::SmtpEvent::IdNotFound),
|
||||
Id = name.to_string(),
|
||||
Details = "Queue strategy not found",
|
||||
SpanId = session_id,
|
||||
);
|
||||
}
|
||||
|
||||
&DEFAULT_SCHEDULE
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_tls_or_default(&self, name: &str, session_id: u64) -> &TlsStrategy {
|
||||
static DEFAULT_TLS: TlsStrategy = TlsStrategy {
|
||||
dane: RequireOptional::Optional,
|
||||
mta_sts: RequireOptional::Optional,
|
||||
tls: RequireOptional::Optional,
|
||||
allow_invalid_certs: false,
|
||||
timeout_tls: Duration::from_secs(3 * 60),
|
||||
timeout_mta_sts: Duration::from_secs(5 * 60),
|
||||
};
|
||||
self.core
|
||||
.smtp
|
||||
.queue
|
||||
.tls_strategy
|
||||
.get(name)
|
||||
.unwrap_or_else(|| {
|
||||
if name != "default" {
|
||||
trc::event!(
|
||||
Smtp(trc::SmtpEvent::IdNotFound),
|
||||
Id = name.to_string(),
|
||||
Details = "TLS strategy not found",
|
||||
SpanId = session_id,
|
||||
);
|
||||
}
|
||||
|
||||
&DEFAULT_TLS
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_connection_or_default(&self, name: &str, session_id: u64) -> &ConnectionStrategy {
|
||||
static DEFAULT_CONNECTION: ConnectionStrategy = ConnectionStrategy {
|
||||
source_ipv4: Vec::new(),
|
||||
source_ipv6: Vec::new(),
|
||||
ehlo_hostname: None,
|
||||
timeout_connect: Duration::from_secs(5 * 60),
|
||||
timeout_greeting: Duration::from_secs(5 * 60),
|
||||
timeout_ehlo: Duration::from_secs(5 * 60),
|
||||
timeout_mail: Duration::from_secs(5 * 60),
|
||||
timeout_rcpt: Duration::from_secs(5 * 60),
|
||||
timeout_data: Duration::from_secs(10 * 60),
|
||||
};
|
||||
|
||||
self.core
|
||||
.smtp
|
||||
.queue
|
||||
.connection_strategy
|
||||
.get(name)
|
||||
.unwrap_or_else(|| {
|
||||
if name != "default" {
|
||||
trc::event!(
|
||||
Smtp(trc::SmtpEvent::IdNotFound),
|
||||
Id = name.to_string(),
|
||||
Details = "Connection strategy not found",
|
||||
SpanId = session_id,
|
||||
);
|
||||
}
|
||||
|
||||
&DEFAULT_CONNECTION
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn spam_model_reload(&self) -> trc::Result<()> {
|
||||
if self.core.spam.classifier.is_some() {
|
||||
if let Some(model) = self
|
||||
.blob_store()
|
||||
.get_blob(SPAM_CLASSIFIER_KEY, 0..usize::MAX)
|
||||
.await
|
||||
.and_then(|archive| match archive {
|
||||
Some(archive) => <Archive<AlignedBytes> as Deserialize>::deserialize(&archive)
|
||||
.and_then(|archive| archive.deserialize_untrusted::<SpamClassifier>())
|
||||
.map(Some),
|
||||
None => Ok(None),
|
||||
})
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
let last_trained_at = match &model {
|
||||
SpamClassifier::FhClassifier {
|
||||
last_trained_at, ..
|
||||
} => Some(*last_trained_at),
|
||||
SpamClassifier::CcfhClassifier {
|
||||
last_trained_at, ..
|
||||
} => Some(*last_trained_at),
|
||||
SpamClassifier::Disabled => None,
|
||||
};
|
||||
|
||||
trc::event!(
|
||||
Spam(SpamEvent::ModelLoaded),
|
||||
Details = last_trained_at.map(trc::Value::Timestamp),
|
||||
);
|
||||
self.inner.data.spam_classifier.store(Arc::new(model));
|
||||
} else {
|
||||
trc::event!(Spam(SpamEvent::ModelNotFound));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn total_queued_messages(&self) -> trc::Result<u64> {
|
||||
let mut total = 0;
|
||||
self.store()
|
||||
.iterate(
|
||||
IterateParams::new(
|
||||
ValueKey::from(ValueClass::Queue(QueueClass::Message(0))),
|
||||
ValueKey::from(ValueClass::Queue(QueueClass::Message(u64::MAX))),
|
||||
)
|
||||
.no_values(),
|
||||
|_, _| {
|
||||
total += 1;
|
||||
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| total)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AddressResolver<'x>(pub &'x str);
|
||||
|
||||
impl ResolveVariable for AddressResolver<'_> {
|
||||
fn resolve_variable(&'_ self, _: ExpressionVariable) -> crate::expr::Variable<'_> {
|
||||
Variable::from(self.0)
|
||||
}
|
||||
|
||||
fn resolve_global(&self, _: &str) -> Variable<'_> {
|
||||
Variable::Integer(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
KV_RATE_LIMIT_AUTH, KV_RATE_LIMIT_LOITER, KV_RATE_LIMIT_RCPT, KV_RATE_LIMIT_SCAN, Server,
|
||||
ipc::{BroadcastEvent, RegistryChange},
|
||||
network::ip_to_bytes,
|
||||
};
|
||||
use ahash::AHashSet;
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::{BlockReason, PasswordHashAlgorithm, PasswordStrength},
|
||||
prelude::{Object, ObjectType},
|
||||
structs::{self, AllowedIp, BlockedIp, Rate, SystemSettings},
|
||||
},
|
||||
types::{datetime::UTCDateTime, ipmask::IpAddrOrMask},
|
||||
};
|
||||
use std::{fmt::Debug, hash::Hash, net::IpAddr};
|
||||
use store::{
|
||||
registry::{
|
||||
bootstrap::Bootstrap,
|
||||
write::{RegistryWrite, RegistryWriteResult},
|
||||
},
|
||||
write::now,
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::id::Id;
|
||||
use utils::glob::{GlobPattern, MatchType};
|
||||
use zxcvbn::Score;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Security {
|
||||
pub allowed_ip_addresses: AHashSet<IpWithTtl<IpAddr>>,
|
||||
pub allowed_ip_networks: Vec<IpWithTtl<IpAddrOrMask>>,
|
||||
pub has_allowed_networks: bool,
|
||||
pub auth_ban_period: Option<u64>,
|
||||
pub abuse_ban_period: Option<u64>,
|
||||
pub loiter_ban_period: Option<u64>,
|
||||
pub scan_ban_period: Option<u64>,
|
||||
|
||||
pub http_banned_paths: Vec<MatchType>,
|
||||
pub scanner_fail_rate: Option<Rate>,
|
||||
|
||||
pub auth_fail_rate: Option<Rate>,
|
||||
pub rcpt_fail_rate: Option<Rate>,
|
||||
pub loiter_fail_rate: Option<Rate>,
|
||||
|
||||
pub default_role_ids_user: Vec<Id>,
|
||||
pub default_role_ids_group: Vec<Id>,
|
||||
pub default_role_ids_tenant: Vec<Id>,
|
||||
pub default_role_ids_admin: Vec<Id>,
|
||||
|
||||
pub password_hash_algorithm: PasswordHashAlgorithm,
|
||||
pub password_max_length: u32,
|
||||
pub password_min_length: u32,
|
||||
pub password_min_strength: Score,
|
||||
pub password_default_expiration: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct BlockedIps {
|
||||
pub blocked_ip_addresses: AHashSet<IpWithTtl<IpAddr>>,
|
||||
pub blocked_ip_networks: Vec<IpWithTtl<IpAddrOrMask>>,
|
||||
pub has_blocked_networks: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IpWithTtl<T: PartialEq + Eq + Hash> {
|
||||
pub ip: T,
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
impl Security {
|
||||
pub async fn parse(bp: &mut Bootstrap) -> Self {
|
||||
let mut allowed_ip_addresses = AHashSet::new();
|
||||
let mut allowed_ip_networks = Vec::new();
|
||||
let mut expired_allows = Vec::new();
|
||||
let now = now();
|
||||
|
||||
for ip in bp.list_infallible::<AllowedIp>().await {
|
||||
let id = ip.id;
|
||||
let revision = ip.revision;
|
||||
let ip = ip.object;
|
||||
let expires_at = ip
|
||||
.expires_at
|
||||
.as_ref()
|
||||
.map(|dt| dt.timestamp() as u64)
|
||||
.unwrap_or(u64::MAX);
|
||||
|
||||
if expires_at > now {
|
||||
if let Some(ip) = ip.address.try_to_ip() {
|
||||
allowed_ip_addresses.insert(IpWithTtl::new(ip, expires_at));
|
||||
} else {
|
||||
let ip_with_ttl = IpWithTtl::new(ip.address, expires_at);
|
||||
|
||||
if !allowed_ip_networks.contains(&ip_with_ttl) {
|
||||
allowed_ip_networks.push(ip_with_ttl);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
expired_allows.push((
|
||||
id,
|
||||
ip.address.clone(),
|
||||
Object {
|
||||
inner: ip.into(),
|
||||
revision,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Add proxy protocol IPs as allowed
|
||||
let system = bp.setting_infallible::<SystemSettings>().await;
|
||||
for ip in system.proxy_trusted_networks {
|
||||
if let Some(ip) = ip.try_to_ip() {
|
||||
allowed_ip_addresses.insert(IpWithTtl::new(ip, u64::MAX));
|
||||
} else {
|
||||
let ip_with_ttl = IpWithTtl::new(ip, u64::MAX);
|
||||
if !allowed_ip_networks.contains(&ip_with_ttl) {
|
||||
allowed_ip_networks.push(ip_with_ttl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !expired_allows.is_empty() {
|
||||
for (id, _, object) in &expired_allows {
|
||||
if let Err(err) = bp
|
||||
.registry
|
||||
.write(RegistryWrite::delete_object(*id, object))
|
||||
.await
|
||||
{
|
||||
trc::error!(
|
||||
err.details("Failed to delete expired allowed IP from registry.")
|
||||
.caused_by(trc::location!())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Security(trc::SecurityEvent::IpAllowExpired),
|
||||
Details = expired_allows
|
||||
.into_iter()
|
||||
.map(|(_, ip, _)| trc::Value::from(ip.into_inner().0))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
{
|
||||
// Add loopback addresses
|
||||
allowed_ip_addresses.insert(IpWithTtl::new(
|
||||
IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
|
||||
u64::MAX,
|
||||
));
|
||||
allowed_ip_addresses.insert(IpWithTtl::new(
|
||||
IpAddr::V6(std::net::Ipv6Addr::LOCALHOST),
|
||||
u64::MAX,
|
||||
));
|
||||
}
|
||||
|
||||
let is_recovery_mode = bp.registry.is_recovery_mode();
|
||||
let security = bp.setting_infallible::<structs::Security>().await;
|
||||
let auth = bp.setting_infallible::<structs::Authentication>().await;
|
||||
Security {
|
||||
has_allowed_networks: !allowed_ip_networks.is_empty(),
|
||||
allowed_ip_addresses,
|
||||
allowed_ip_networks,
|
||||
auth_ban_period: security.auth_ban_period.map(|v| v.as_secs()),
|
||||
abuse_ban_period: security.abuse_ban_period.map(|v| v.as_secs()),
|
||||
loiter_ban_period: security.loiter_ban_period.map(|v| v.as_secs()),
|
||||
scan_ban_period: security.scan_ban_period.map(|v| v.as_secs()),
|
||||
auth_fail_rate: security.auth_ban_rate.filter(|_| !is_recovery_mode),
|
||||
rcpt_fail_rate: security.abuse_ban_rate.filter(|_| !is_recovery_mode),
|
||||
loiter_fail_rate: security.loiter_ban_rate.filter(|_| !is_recovery_mode),
|
||||
http_banned_paths: if is_recovery_mode {
|
||||
Vec::new()
|
||||
} else {
|
||||
security
|
||||
.scan_ban_paths
|
||||
.iter()
|
||||
.map(|pattern| MatchType::Matches(GlobPattern::compile(pattern, true)))
|
||||
.collect()
|
||||
},
|
||||
scanner_fail_rate: security.scan_ban_rate.filter(|_| !is_recovery_mode),
|
||||
default_role_ids_user: auth.default_user_role_ids.into_inner(),
|
||||
default_role_ids_group: auth.default_group_role_ids.into_inner(),
|
||||
default_role_ids_tenant: auth.default_tenant_role_ids.into_inner(),
|
||||
default_role_ids_admin: auth.default_admin_role_ids.into_inner(),
|
||||
password_hash_algorithm: auth.password_hash_algorithm,
|
||||
password_max_length: auth.password_max_length as u32,
|
||||
password_min_length: auth.password_min_length as u32,
|
||||
password_min_strength: match auth.password_min_strength {
|
||||
PasswordStrength::Zero => Score::Zero,
|
||||
PasswordStrength::One => Score::One,
|
||||
PasswordStrength::Two => Score::Two,
|
||||
PasswordStrength::Three => Score::Three,
|
||||
PasswordStrength::Four => Score::Four,
|
||||
},
|
||||
password_default_expiration: auth.password_default_expiry.map(|v| v.as_secs()),
|
||||
}
|
||||
}
|
||||
|
||||
fn ban_period(&self, reason: BlockReason) -> Option<u64> {
|
||||
match reason {
|
||||
BlockReason::RcptToFailure => self.abuse_ban_period,
|
||||
BlockReason::AuthFailure => self.auth_ban_period,
|
||||
BlockReason::Loitering => self.loiter_ban_period,
|
||||
BlockReason::PortScanning => self.scan_ban_period,
|
||||
BlockReason::Manual | BlockReason::Other => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub async fn is_rcpt_fail2banned(&self, ip: IpAddr, rcpt: &str) -> trc::Result<bool> {
|
||||
if let Some(rate) = &self.core.network.security.rcpt_fail_rate {
|
||||
let is_allowed = self.is_ip_allowed(ip)
|
||||
|| (self
|
||||
.in_memory_store()
|
||||
.is_rate_allowed(KV_RATE_LIMIT_RCPT, &ip_to_bytes(&ip), rate, false)
|
||||
.await?
|
||||
.is_none()
|
||||
&& self
|
||||
.in_memory_store()
|
||||
.is_rate_allowed(KV_RATE_LIMIT_RCPT, rcpt.as_bytes(), rate, false)
|
||||
.await?
|
||||
.is_none());
|
||||
|
||||
if !is_allowed {
|
||||
return self
|
||||
.block_ip(ip, BlockReason::RcptToFailure)
|
||||
.await
|
||||
.map(|_| true);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn is_scanner_fail2banned(&self, ip: IpAddr) -> trc::Result<bool> {
|
||||
if let Some(rate) = &self.core.network.security.scanner_fail_rate {
|
||||
let is_allowed = self.is_ip_allowed(ip)
|
||||
|| self
|
||||
.in_memory_store()
|
||||
.is_rate_allowed(KV_RATE_LIMIT_SCAN, &ip_to_bytes(&ip), rate, false)
|
||||
.await?
|
||||
.is_none();
|
||||
|
||||
if !is_allowed {
|
||||
return self
|
||||
.block_ip(ip, BlockReason::PortScanning)
|
||||
.await
|
||||
.map(|_| true);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn is_http_banned_path(&self, path: &str, ip: IpAddr) -> trc::Result<bool> {
|
||||
let paths = &self.core.network.security.http_banned_paths;
|
||||
|
||||
if !paths.is_empty() && paths.iter().any(|p| p.matches(path)) && !self.is_ip_allowed(ip) {
|
||||
self.block_ip(ip, BlockReason::PortScanning)
|
||||
.await
|
||||
.map(|_| true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_loiter_fail2banned(&self, ip: IpAddr) -> trc::Result<bool> {
|
||||
if let Some(rate) = &self.core.network.security.loiter_fail_rate {
|
||||
let is_allowed = self.is_ip_allowed(ip)
|
||||
|| self
|
||||
.in_memory_store()
|
||||
.is_rate_allowed(KV_RATE_LIMIT_LOITER, &ip_to_bytes(&ip), rate, false)
|
||||
.await?
|
||||
.is_none();
|
||||
|
||||
if !is_allowed {
|
||||
return self
|
||||
.block_ip(ip, BlockReason::Loitering)
|
||||
.await
|
||||
.map(|_| true);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn is_auth_fail2banned(&self, ip: IpAddr, login: Option<&str>) -> trc::Result<bool> {
|
||||
if let Some(rate) = &self.core.network.security.auth_fail_rate {
|
||||
let login = login.unwrap_or_default();
|
||||
let is_allowed = self.is_ip_allowed(ip)
|
||||
|| (self
|
||||
.in_memory_store()
|
||||
.is_rate_allowed(KV_RATE_LIMIT_AUTH, &ip_to_bytes(&ip), rate, false)
|
||||
.await?
|
||||
.is_none()
|
||||
&& (login.is_empty()
|
||||
|| self
|
||||
.in_memory_store()
|
||||
.is_rate_allowed(KV_RATE_LIMIT_AUTH, login.as_bytes(), rate, false)
|
||||
.await?
|
||||
.is_none()));
|
||||
if !is_allowed {
|
||||
return self
|
||||
.block_ip(ip, BlockReason::AuthFailure)
|
||||
.await
|
||||
.map(|_| true);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn block_ip(&self, ip: IpAddr, reason: BlockReason) -> trc::Result<()> {
|
||||
// Add IP to blocked list
|
||||
let now = now();
|
||||
let expires_at = self
|
||||
.core
|
||||
.network
|
||||
.security
|
||||
.ban_period(reason)
|
||||
.map(|v| now + v);
|
||||
self.inner
|
||||
.data
|
||||
.blocked_ips
|
||||
.write()
|
||||
.blocked_ip_addresses
|
||||
.insert(IpWithTtl::new(ip, expires_at.unwrap_or(u64::MAX)));
|
||||
|
||||
// Write blocked IP to config
|
||||
let RegistryWriteResult::Success(id) = self
|
||||
.registry()
|
||||
.write(RegistryWrite::insert(
|
||||
&BlockedIp {
|
||||
address: IpAddrOrMask::from_ip(ip),
|
||||
created_at: UTCDateTime::from_timestamp(now as i64),
|
||||
expires_at: expires_at.map(|ts| UTCDateTime::from_timestamp(ts as i64)),
|
||||
reason,
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Increment version
|
||||
self.cluster_broadcast(BroadcastEvent::RegistryChange(RegistryChange::Insert(
|
||||
ObjectType::BlockedIp.id(id),
|
||||
)))
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn has_auth_fail2ban(&self) -> bool {
|
||||
self.core.network.security.auth_fail_rate.is_some()
|
||||
}
|
||||
|
||||
pub fn is_ip_blocked(&self, ip: IpAddr) -> bool {
|
||||
let blocked_ips = self.inner.data.blocked_ips.read();
|
||||
(blocked_ips
|
||||
.blocked_ip_addresses
|
||||
.get(&IpWithTtl::new(ip, 0))
|
||||
.is_some_and(|v| !v.is_expired())
|
||||
|| (blocked_ips.has_blocked_networks
|
||||
&& blocked_ips
|
||||
.blocked_ip_networks
|
||||
.iter()
|
||||
.any(|network| network.ip.matches(&ip) && !network.is_expired())))
|
||||
&& !self.is_ip_allowed(ip)
|
||||
}
|
||||
|
||||
pub fn is_ip_allowed(&self, ip: IpAddr) -> bool {
|
||||
self.core
|
||||
.network
|
||||
.security
|
||||
.allowed_ip_addresses
|
||||
.get(&IpWithTtl::new(ip, 0))
|
||||
.is_some_and(|v| !v.is_expired())
|
||||
|| (self.core.network.security.has_allowed_networks
|
||||
&& self
|
||||
.core
|
||||
.network
|
||||
.security
|
||||
.allowed_ip_networks
|
||||
.iter()
|
||||
.any(|network| network.ip.matches(&ip) && !network.is_expired()))
|
||||
}
|
||||
|
||||
pub fn is_secure_password(&self, password: &str, user_inputs: &[&str]) -> Result<(), String> {
|
||||
if (password.len() as u32) > self.core.network.security.password_max_length {
|
||||
Err(format!(
|
||||
"Password must be at most {} characters long.",
|
||||
self.core.network.security.password_max_length
|
||||
))
|
||||
} else if (password.len() as u32) < self.core.network.security.password_min_length {
|
||||
Err(format!(
|
||||
"Password must be at least {} characters long.",
|
||||
self.core.network.security.password_min_length
|
||||
))
|
||||
} else if self.core.network.security.password_min_strength > Score::Zero {
|
||||
let entropy = zxcvbn::zxcvbn(password, user_inputs);
|
||||
if entropy.score() >= self.core.network.security.password_min_strength {
|
||||
Ok(())
|
||||
} else if let Some(feedback) = entropy.feedback() {
|
||||
Err(format!("Password is too weak. {feedback}"))
|
||||
} else {
|
||||
Err("Password is too weak.".to_string())
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockedIps {
|
||||
pub async fn parse(bp: &mut Bootstrap) -> Self {
|
||||
let mut ips = Self::default();
|
||||
|
||||
if bp.registry.is_recovery_mode() {
|
||||
return ips;
|
||||
}
|
||||
|
||||
let mut expired_blocks = Vec::new();
|
||||
let now = now() as i64;
|
||||
|
||||
for ip in bp.list_infallible::<BlockedIp>().await {
|
||||
let id = ip.id;
|
||||
let revision = ip.revision;
|
||||
let ip = ip.object;
|
||||
let expires_at = ip
|
||||
.expires_at
|
||||
.as_ref()
|
||||
.map(|dt| dt.timestamp() as u64)
|
||||
.unwrap_or(u64::MAX);
|
||||
|
||||
if ip.expires_at.as_ref().is_none_or(|ip| ip.timestamp() > now) {
|
||||
if let Some(ip) = ip.address.try_to_ip() {
|
||||
ips.blocked_ip_addresses
|
||||
.insert(IpWithTtl::new(ip, expires_at));
|
||||
} else {
|
||||
ips.blocked_ip_networks
|
||||
.push(IpWithTtl::new(ip.address, expires_at));
|
||||
}
|
||||
} else {
|
||||
expired_blocks.push((
|
||||
id,
|
||||
ip.address.clone(),
|
||||
Object {
|
||||
inner: ip.into(),
|
||||
revision,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !expired_blocks.is_empty() {
|
||||
for (id, _, object) in &expired_blocks {
|
||||
if let Err(err) = bp
|
||||
.registry
|
||||
.write(RegistryWrite::delete_object(*id, object))
|
||||
.await
|
||||
{
|
||||
trc::error!(
|
||||
err.details("Failed to delete expired blocked IP from registry.")
|
||||
.caused_by(trc::location!())
|
||||
);
|
||||
}
|
||||
}
|
||||
trc::event!(
|
||||
Security(trc::SecurityEvent::IpBlockExpired),
|
||||
Details = expired_blocks
|
||||
.into_iter()
|
||||
.map(|(_, ip, _)| trc::Value::from(ip.into_inner().0))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
ips.has_blocked_networks = !ips.blocked_ip_networks.is_empty();
|
||||
ips
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PartialEq + Eq + Hash> Hash for IpWithTtl<T> {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.ip.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PartialEq + Eq + Hash> PartialEq for IpWithTtl<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.ip == other.ip
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PartialEq + Eq + Hash> Eq for IpWithTtl<T> {}
|
||||
|
||||
impl<T: PartialEq + Eq + Hash> IpWithTtl<T> {
|
||||
pub fn new(ip: T, expires_at: u64) -> Self {
|
||||
Self { ip, expires_at }
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.expires_at <= now()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use proxy_header::io::ProxiedStream;
|
||||
use tokio::{
|
||||
io::{AsyncRead, AsyncWrite},
|
||||
net::TcpStream,
|
||||
};
|
||||
use tokio_rustls::server::TlsStream;
|
||||
|
||||
use super::SessionStream;
|
||||
|
||||
impl SessionStream for TcpStream {
|
||||
fn is_tls(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>) {
|
||||
(Cow::Borrowed(""), Cow::Borrowed(""))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: SessionStream> SessionStream for TlsStream<T> {
|
||||
fn is_tls(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>) {
|
||||
let (_, conn) = self.get_ref();
|
||||
|
||||
(
|
||||
match conn
|
||||
.protocol_version()
|
||||
.unwrap_or(rustls::ProtocolVersion::Unknown(0))
|
||||
{
|
||||
rustls::ProtocolVersion::SSLv2 => "SSLv2",
|
||||
rustls::ProtocolVersion::SSLv3 => "SSLv3",
|
||||
rustls::ProtocolVersion::TLSv1_0 => "TLSv1.0",
|
||||
rustls::ProtocolVersion::TLSv1_1 => "TLSv1.1",
|
||||
rustls::ProtocolVersion::TLSv1_2 => "TLSv1.2",
|
||||
rustls::ProtocolVersion::TLSv1_3 => "TLSv1.3",
|
||||
rustls::ProtocolVersion::DTLSv1_0 => "DTLSv1.0",
|
||||
rustls::ProtocolVersion::DTLSv1_2 => "DTLSv1.2",
|
||||
rustls::ProtocolVersion::DTLSv1_3 => "DTLSv1.3",
|
||||
_ => "unknown",
|
||||
}
|
||||
.into(),
|
||||
match conn.negotiated_cipher_suite() {
|
||||
Some(rustls::SupportedCipherSuite::Tls13(cs)) => {
|
||||
cs.common.suite.as_str().unwrap_or("unknown")
|
||||
}
|
||||
Some(rustls::SupportedCipherSuite::Tls12(cs)) => {
|
||||
cs.common.suite.as_str().unwrap_or("unknown")
|
||||
}
|
||||
None => "unknown",
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionStream for ProxiedStream<TcpStream> {
|
||||
fn is_tls(&self) -> bool {
|
||||
self.proxy_header()
|
||||
.ssl()
|
||||
.is_some_and(|ssl| ssl.client_ssl())
|
||||
}
|
||||
|
||||
fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>) {
|
||||
self.proxy_header()
|
||||
.ssl()
|
||||
.map(|ssl| {
|
||||
(
|
||||
ssl.version().unwrap_or("unknown").to_string().into(),
|
||||
ssl.cipher().unwrap_or("unknown").to_string().into(),
|
||||
)
|
||||
})
|
||||
.unwrap_or((Cow::Borrowed("unknown"), Cow::Borrowed("unknown")))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct NullIo {
|
||||
pub tx_buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl AsyncWrite for NullIo {
|
||||
fn poll_write(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<Result<usize, std::io::Error>> {
|
||||
self.tx_buf.extend_from_slice(buf);
|
||||
std::task::Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Result<(), std::io::Error>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Result<(), std::io::Error>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for NullIo {
|
||||
fn poll_read(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
_buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionStream for NullIo {
|
||||
fn is_tls(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn tls_version_and_cipher(
|
||||
&self,
|
||||
) -> (
|
||||
std::borrow::Cow<'static, str>,
|
||||
std::borrow::Cow<'static, str>,
|
||||
) {
|
||||
(
|
||||
std::borrow::Cow::Borrowed(""),
|
||||
std::borrow::Cow::Borrowed(""),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{
|
||||
ServerInstance, SessionStream, TcpAcceptor, TcpAcceptorResult,
|
||||
acme::resolver::{IsTlsAlpnChallenge, build_acme_static_resolver},
|
||||
};
|
||||
use crate::{Inner, Server};
|
||||
use rustls::{
|
||||
SupportedProtocolVersion,
|
||||
server::{ClientHello, ResolvesServerCert},
|
||||
sign::CertifiedKey,
|
||||
version::{TLS12, TLS13},
|
||||
};
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
fmt::{self, Formatter},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
|
||||
use tokio_rustls::{Accept, LazyConfigAcceptor};
|
||||
|
||||
pub static TLS13_VERSION: &[&SupportedProtocolVersion] = &[&TLS13];
|
||||
pub static TLS12_VERSION: &[&SupportedProtocolVersion] = &[&TLS12];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CertificateResolver {
|
||||
pub inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl CertificateResolver {
|
||||
pub fn new(inner: Arc<Inner>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvesServerCert for CertificateResolver {
|
||||
fn resolve(&self, hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
|
||||
self.resolve_certificate(hello.server_name())
|
||||
}
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn resolve_certificate(&self, name: &str) -> Option<Arc<CertifiedKey>> {
|
||||
let certs = self.inner.data.tls_certificates.load();
|
||||
|
||||
certs
|
||||
.get(name)
|
||||
.or_else(|| {
|
||||
// Try with a wildcard certificate
|
||||
name.split_once('.')
|
||||
.and_then(|(_, domain)| certs.get(domain))
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl CertificateResolver {
|
||||
pub(crate) fn resolve_certificate(&self, name: Option<&str>) -> Option<Arc<CertifiedKey>> {
|
||||
let certs = self.inner.data.tls_certificates.load();
|
||||
|
||||
name.map_or_else(
|
||||
|| certs.get("*"),
|
||||
|name| {
|
||||
certs
|
||||
.get(name)
|
||||
.or_else(|| {
|
||||
// Try with a wildcard certificate
|
||||
name.split_once('.')
|
||||
.and_then(|(_, domain)| certs.get(domain))
|
||||
})
|
||||
.or_else(|| {
|
||||
trc::event!(
|
||||
Tls(trc::TlsEvent::CertificateNotFound),
|
||||
Hostname = name.to_string(),
|
||||
);
|
||||
certs.get("*")
|
||||
})
|
||||
},
|
||||
)
|
||||
.or_else(|| match certs.len().cmp(&1) {
|
||||
Ordering::Equal => certs.values().next(),
|
||||
Ordering::Greater => {
|
||||
trc::event!(
|
||||
Tls(trc::TlsEvent::MultipleCertificatesAvailable),
|
||||
Total = certs.len(),
|
||||
);
|
||||
certs.values().next()
|
||||
}
|
||||
Ordering::Less => {
|
||||
trc::event!(
|
||||
Tls(trc::TlsEvent::NoCertificatesAvailable),
|
||||
Total = certs.len(),
|
||||
);
|
||||
self.inner.data.tls_self_signed_cert.as_ref()
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl TcpAcceptor {
|
||||
pub async fn accept<IO>(
|
||||
&self,
|
||||
stream: IO,
|
||||
enable_acme: Option<Server>,
|
||||
instance: &ServerInstance,
|
||||
) -> TcpAcceptorResult<IO>
|
||||
where
|
||||
IO: SessionStream,
|
||||
{
|
||||
match self {
|
||||
TcpAcceptor::Tls {
|
||||
config,
|
||||
acceptor,
|
||||
implicit,
|
||||
} if *implicit => match enable_acme {
|
||||
None => TcpAcceptorResult::Tls(acceptor.accept(stream)),
|
||||
Some(core) => {
|
||||
match LazyConfigAcceptor::new(Default::default(), stream).await {
|
||||
Ok(start_handshake) => {
|
||||
if core.has_acme_tls_providers()
|
||||
&& start_handshake.client_hello().is_tls_alpn_challenge()
|
||||
{
|
||||
let key = match start_handshake.client_hello().server_name() {
|
||||
Some(domain) => {
|
||||
let key = core.build_acme_certificate(domain).await;
|
||||
|
||||
trc::event!(
|
||||
Acme(trc::AcmeEvent::ClientSuppliedSni),
|
||||
ListenerId = instance.id.clone(),
|
||||
Domain = domain.to_string(),
|
||||
Result = key.is_some(),
|
||||
);
|
||||
|
||||
key
|
||||
}
|
||||
None => {
|
||||
trc::event!(
|
||||
Acme(trc::AcmeEvent::ClientMissingSni),
|
||||
ListenerId = instance.id.clone(),
|
||||
);
|
||||
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
match start_handshake
|
||||
.into_stream(build_acme_static_resolver(key))
|
||||
.await
|
||||
{
|
||||
Ok(mut tls) => {
|
||||
trc::event!(
|
||||
Acme(trc::AcmeEvent::TlsAlpnReceived),
|
||||
ListenerId = instance.id.clone(),
|
||||
);
|
||||
|
||||
let _ = tls.shutdown().await;
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Acme(trc::AcmeEvent::TlsAlpnError),
|
||||
ListenerId = instance.id.clone(),
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return TcpAcceptorResult::Tls(
|
||||
start_handshake.into_stream(config.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Tls(trc::TlsEvent::HandshakeError),
|
||||
ListenerId = instance.id.clone(),
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TcpAcceptorResult::Close
|
||||
}
|
||||
},
|
||||
_ => TcpAcceptorResult::Plain(stream),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_tls(&self) -> bool {
|
||||
matches!(self, TcpAcceptor::Tls { .. })
|
||||
}
|
||||
}
|
||||
|
||||
impl<IO> TcpAcceptorResult<IO>
|
||||
where
|
||||
IO: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
pub fn unwrap_tls(self) -> Accept<IO> {
|
||||
match self {
|
||||
TcpAcceptorResult::Tls(accept) => accept,
|
||||
_ => panic!("unwrap_tls called on non-TLS acceptor"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for CertificateResolver {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CertificateResolver").finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use p256::{
|
||||
SecretKey,
|
||||
ecdsa::{Signature, SigningKey, signature::Signer},
|
||||
pkcs8::{DecodePrivateKey, PrivateKeyInfo, der::SecretDocument},
|
||||
};
|
||||
|
||||
const VAPID_TOKEN_TTL: u64 = 12 * 60 * 60;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Vapid {
|
||||
key: VapidKey,
|
||||
contact: Option<String>,
|
||||
}
|
||||
|
||||
impl Vapid {
|
||||
pub fn new(key: VapidKey, contact: Option<String>) -> Self {
|
||||
Self { key, contact }
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> &str {
|
||||
self.key.public_key()
|
||||
}
|
||||
|
||||
pub fn authorization(&self, endpoint: &str, now: u64) -> Option<String> {
|
||||
self.key
|
||||
.authorization(endpoint, self.contact.as_deref(), now)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VapidKey {
|
||||
signing_key: SigningKey,
|
||||
public_key: String,
|
||||
}
|
||||
|
||||
impl VapidKey {
|
||||
pub fn from_pkcs8_pem(pem: &str) -> Result<Self, String> {
|
||||
let pem = pem.trim_start_matches('\u{feff}').trim();
|
||||
|
||||
if let Ok(key) = SigningKey::from_pkcs8_pem(pem) {
|
||||
return Ok(Self::from_signing_key(key));
|
||||
}
|
||||
if let Ok(secret) = SecretKey::from_sec1_pem(pem) {
|
||||
return Ok(Self::from_signing_key(secret.into()));
|
||||
}
|
||||
if let Some(secret) = secret_key_from_explicit_params(pem) {
|
||||
return Ok(Self::from_signing_key(secret.into()));
|
||||
}
|
||||
|
||||
Err(SigningKey::from_pkcs8_pem(pem)
|
||||
.err()
|
||||
.map(|err| {
|
||||
format!(
|
||||
"{err}. Re-encode the key as named-curve PKCS#8, \
|
||||
e.g. `openssl pkey -in key.pem -out key_pkcs8.pem`."
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "unsupported VAPID key encoding".to_string()))
|
||||
}
|
||||
|
||||
fn from_signing_key(signing_key: SigningKey) -> Self {
|
||||
let public_key = URL_SAFE_NO_PAD.encode(
|
||||
signing_key
|
||||
.verifying_key()
|
||||
.to_encoded_point(false)
|
||||
.as_bytes(),
|
||||
);
|
||||
Self {
|
||||
signing_key,
|
||||
public_key,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> &str {
|
||||
&self.public_key
|
||||
}
|
||||
|
||||
pub fn authorization(&self, endpoint: &str, contact: Option<&str>, now: u64) -> Option<String> {
|
||||
let mut claims = serde_json::Map::new();
|
||||
claims.insert("aud".into(), endpoint_origin(endpoint)?.into());
|
||||
claims.insert("exp".into(), (now + VAPID_TOKEN_TTL).into());
|
||||
if let Some(sub) = contact {
|
||||
claims.insert("sub".into(), sub.into());
|
||||
}
|
||||
|
||||
let header = URL_SAFE_NO_PAD.encode(br#"{"typ":"JWT","alg":"ES256"}"#);
|
||||
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).ok()?);
|
||||
let signing_input = format!("{header}.{payload}");
|
||||
let signature: Signature = self.signing_key.sign(signing_input.as_bytes());
|
||||
|
||||
Some(format!(
|
||||
"vapid t={signing_input}.{}, k={}",
|
||||
URL_SAFE_NO_PAD.encode(signature.to_bytes()),
|
||||
self.public_key
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_origin(url: &str) -> Option<String> {
|
||||
let (scheme, rest) = url.split_once("://")?;
|
||||
let scheme = scheme.to_ascii_lowercase();
|
||||
let authority = rest.split(['/', '?', '#']).next()?;
|
||||
let authority = authority
|
||||
.rsplit_once('@')
|
||||
.map(|(_, host)| host)
|
||||
.unwrap_or(authority);
|
||||
if authority.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (host, port) = if let Some(rest) = authority.strip_prefix('[') {
|
||||
let (addr, tail) = rest.split_once(']')?;
|
||||
(
|
||||
format!("[{}]", addr.to_ascii_lowercase()),
|
||||
tail.strip_prefix(':').filter(|port| !port.is_empty()),
|
||||
)
|
||||
} else if let Some((host, port)) = authority.rsplit_once(':') {
|
||||
(
|
||||
host.to_ascii_lowercase(),
|
||||
Some(port).filter(|p| !p.is_empty()),
|
||||
)
|
||||
} else {
|
||||
(authority.to_ascii_lowercase(), None)
|
||||
};
|
||||
|
||||
match port {
|
||||
Some(port)
|
||||
if !((scheme == "https" && port == "443") || (scheme == "http" && port == "80")) =>
|
||||
{
|
||||
Some(format!("{scheme}://{host}:{port}"))
|
||||
}
|
||||
_ => Some(format!("{scheme}://{host}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_contact(contact: &str) -> Option<String> {
|
||||
let contact = contact.trim();
|
||||
|
||||
match contact.split_once(':') {
|
||||
Some((scheme, _)) if scheme.eq_ignore_ascii_case("mailto") => Some(contact.to_string()),
|
||||
Some((scheme, _)) if scheme.eq_ignore_ascii_case("https") => Some(contact.to_string()),
|
||||
Some(_) => None,
|
||||
None if contact.contains('@') => Some(format!("mailto:{contact}")),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_pkcs8_pem() -> Result<String, String> {
|
||||
use p256::elliptic_curve::rand_core::OsRng;
|
||||
use p256::pkcs8::{EncodePrivateKey, LineEnding};
|
||||
|
||||
SigningKey::random(&mut OsRng)
|
||||
.to_pkcs8_pem(LineEnding::LF)
|
||||
.map(|pem| pem.to_string())
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn secret_key_from_explicit_params(pem: &str) -> Option<SecretKey> {
|
||||
let (_, document) = SecretDocument::from_pem(pem).ok()?;
|
||||
let private_key_info = PrivateKeyInfo::try_from(document.as_bytes()).ok()?;
|
||||
SecretKey::from_sec1_der(private_key_info.private_key).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
|
||||
|
||||
fn test_key() -> VapidKey {
|
||||
VapidKey::from_pkcs8_pem(&generate_pkcs8_pem().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_key_round_trips_through_pkcs8_pem() {
|
||||
let pem = generate_pkcs8_pem().unwrap();
|
||||
assert_eq!(
|
||||
VapidKey::from_pkcs8_pem(&pem).unwrap().public_key(),
|
||||
VapidKey::from_pkcs8_pem(&pem).unwrap().public_key()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_origin_normalizes() {
|
||||
assert_eq!(
|
||||
endpoint_origin("HTTPS://Push.Example.COM:443/push?x=1").unwrap(),
|
||||
"https://push.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoint_origin("https://127.0.0.1:19000/push").unwrap(),
|
||||
"https://127.0.0.1:19000"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoint_origin("https://user:[email protected]/fcm/send/x").unwrap(),
|
||||
"https://fcm.googleapis.com"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoint_origin("http://[2001:DB8::1]:80/p").unwrap(),
|
||||
"http://[2001:db8::1]"
|
||||
);
|
||||
assert!(endpoint_origin("not-a-url").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_signs_a_verifiable_es256_token() {
|
||||
let key = test_key();
|
||||
let now = 1_700_000_000;
|
||||
let header = key
|
||||
.authorization(
|
||||
"https://push.example.com/push/abc?token=1",
|
||||
Some("mailto:[email protected]"),
|
||||
now,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (token, advertised_key) = header
|
||||
.strip_prefix("vapid ")
|
||||
.and_then(|rest| rest.split_once(", "))
|
||||
.unwrap();
|
||||
let jwt = token.strip_prefix("t=").unwrap();
|
||||
assert_eq!(advertised_key.strip_prefix("k=").unwrap(), key.public_key());
|
||||
|
||||
let parts = jwt.split('.').collect::<Vec<_>>();
|
||||
assert_eq!(parts.len(), 3);
|
||||
|
||||
let verifying_key =
|
||||
VerifyingKey::from_sec1_bytes(&URL_SAFE_NO_PAD.decode(key.public_key()).unwrap())
|
||||
.unwrap();
|
||||
let signature = Signature::from_slice(&URL_SAFE_NO_PAD.decode(parts[2]).unwrap()).unwrap();
|
||||
verifying_key
|
||||
.verify(format!("{}.{}", parts[0], parts[1]).as_bytes(), &signature)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
URL_SAFE_NO_PAD.decode(parts[0]).unwrap(),
|
||||
br#"{"typ":"JWT","alg":"ES256"}"#
|
||||
);
|
||||
let claims: serde_json::Value =
|
||||
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap();
|
||||
assert_eq!(claims["aud"], "https://push.example.com");
|
||||
assert_eq!(claims["sub"], "mailto:[email protected]");
|
||||
assert_eq!(claims["exp"], now + VAPID_TOKEN_TTL);
|
||||
}
|
||||
|
||||
const SEC1_PEM: &str = "-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIP4Zv7be5hDH0x4ur6ditW+whzyZBXK1Vyjn6aIDo0jhoAoGCCqGSM49
|
||||
AwEHoUQDQgAEVc4PXr+z61s9/dIas44+S0Nza3gm1UW/avddp99dUsEi3JV0H4Yk
|
||||
1yfqVJ/O9KPvQ69uMAY0t3A5lx/GvOOZfg==
|
||||
-----END EC PRIVATE KEY-----";
|
||||
|
||||
const PKCS8_NAMED_PEM: &str = "-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg/hm/tt7mEMfTHi6v
|
||||
p2K1b7CHPJkFcrVXKOfpogOjSOGhRANCAARVzg9ev7PrWz390hqzjj5LQ3NreCbV
|
||||
Rb9q912n311SwSLclXQfhiTXJ+pUn870o+9Dr24wBjS3cDmXH8a845l+
|
||||
-----END PRIVATE KEY-----";
|
||||
|
||||
const PKCS8_EXPLICIT_PEM: &str = "-----BEGIN PRIVATE KEY-----
|
||||
MIIBeQIBADCCAQMGByqGSM49AgEwgfcCAQEwLAYHKoZIzj0BAQIhAP////8AAAAB
|
||||
AAAAAAAAAAAAAAAA////////////////MFsEIP////8AAAABAAAAAAAAAAAAAAAA
|
||||
///////////////8BCBaxjXYqjqT57PrvVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwMV
|
||||
AMSdNgiG5wSTamZ44ROdJreBn36QBEEEaxfR8uEsQkf4vOblY6RA8ncDfYEt6zOg
|
||||
9KE5RdiYwpZP40Li/hp/m47n60p8D54WK84zV2sxXs7LtkBoN79R9QIhAP////8A
|
||||
AAAA//////////+85vqtpxeehPO5ysL8YyVRAgEBBG0wawIBAQQg/hm/tt7mEMfT
|
||||
Hi6vp2K1b7CHPJkFcrVXKOfpogOjSOGhRANCAARVzg9ev7PrWz390hqzjj5LQ3Nr
|
||||
eCbVRb9q912n311SwSLclXQfhiTXJ+pUn870o+9Dr24wBjS3cDmXH8a845l+
|
||||
-----END PRIVATE KEY-----";
|
||||
|
||||
const PKCS8_P384_PEM: &str = "-----BEGIN PRIVATE KEY-----
|
||||
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCUx+yT22yGHP9q+Y1y
|
||||
UedDkevSvPaUuSPH8Q4FJBdYKKLqX4a5VdBIOonKPC4Yj7yhZANiAAQPRBsMOJy/
|
||||
B4yDfR2rGOd2H6Kv3fQNHPj9Nu5Tks8QYMLzrX8ONCNoFnNUQl9S0r0QS6phVqD0
|
||||
1kt0wbEvKr7mPM/R8XS8dX0xYC58CXHqBsTM0piQN2R7kqWDJ5i4OjE=
|
||||
-----END PRIVATE KEY-----";
|
||||
|
||||
#[test]
|
||||
fn accepts_equivalent_p256_encodings() {
|
||||
let named = VapidKey::from_pkcs8_pem(PKCS8_NAMED_PEM).unwrap();
|
||||
let sec1 = VapidKey::from_pkcs8_pem(SEC1_PEM).unwrap();
|
||||
let explicit = VapidKey::from_pkcs8_pem(PKCS8_EXPLICIT_PEM).unwrap();
|
||||
|
||||
assert_eq!(named.public_key(), sec1.public_key());
|
||||
assert_eq!(named.public_key(), explicit.public_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_pem_with_leading_bom_and_whitespace() {
|
||||
let dirty = format!("\u{feff} \n{PKCS8_NAMED_PEM}\n ");
|
||||
assert_eq!(
|
||||
VapidKey::from_pkcs8_pem(&dirty).unwrap().public_key(),
|
||||
VapidKey::from_pkcs8_pem(PKCS8_NAMED_PEM)
|
||||
.unwrap()
|
||||
.public_key()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_curve_key() {
|
||||
assert!(VapidKey::from_pkcs8_pem(PKCS8_P384_PEM).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage_with_actionable_error() {
|
||||
let err = VapidKey::from_pkcs8_pem("not a key").err().unwrap();
|
||||
assert!(err.contains("openssl pkey"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_is_normalized_to_a_uri() {
|
||||
for (input, expected) in [
|
||||
("[email protected]", Some("mailto:[email protected]")),
|
||||
(" [email protected] ", Some("mailto:[email protected]")),
|
||||
("mailto:[email protected]", Some("mailto:[email protected]")),
|
||||
("MAILTO:[email protected]", Some("MAILTO:[email protected]")),
|
||||
(
|
||||
"https://stalw.art/contact",
|
||||
Some("https://stalw.art/contact"),
|
||||
),
|
||||
("stalw.art", None),
|
||||
("http://stalw.art", None),
|
||||
("tel:+123456789", None),
|
||||
("", None),
|
||||
] {
|
||||
assert_eq!(
|
||||
normalize_contact(input).as_deref(),
|
||||
expected,
|
||||
"unexpected normalization of {input:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_omits_subject_when_no_contact() {
|
||||
let key = test_key();
|
||||
let header = key
|
||||
.authorization("https://fcm.googleapis.com/fcm/send/xyz", None, 0)
|
||||
.unwrap();
|
||||
let payload = header.split('.').nth(1).unwrap();
|
||||
let claims: serde_json::Value =
|
||||
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap();
|
||||
assert_eq!(claims["aud"], "https://fcm.googleapis.com");
|
||||
assert!(claims.get("sub").is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user