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:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
+101
View File
@@ -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,
})
}
}
+271
View File
@@ -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",
}
}
}
+200
View File
@@ -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());
}
}
+173
View File
@@ -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,
}
+295
View File
@@ -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")
}
}
}
}
}
+567
View File
@@ -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"));
}
}
+311
View File
@@ -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
}
}
+111
View File
@@ -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])
}
}
+400
View File
@@ -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());
}
}
+317
View File
@@ -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);
}
}
+9
View File
@@ -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;
+429
View File
@@ -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)
}
}
+96
View File
@@ -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
+127
View File
@@ -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()
}
}
+440
View File
@@ -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(())
}
}
}
}
+476
View File
@@ -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"
);
}
}
}
+419
View File
@@ -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)
}
}
+515
View File
@@ -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()
}
}
+143
View File
@@ -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(""),
)
}
}
+214
View File
@@ -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()
}
}
+349
View File
@@ -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());
}
}