diff --git a/Cargo.lock b/Cargo.lock index 4de59e42..127ce4f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -985,7 +985,7 @@ dependencies = [ [[package]] name = "common" -version = "0.7.1" +version = "0.7.2" dependencies = [ "ahash 0.8.11", "arc-swap", @@ -994,6 +994,7 @@ dependencies = [ "chrono", "decancer", "directory", + "dns-update", "futures", "hostname 0.4.0", "hyper 1.2.0", @@ -1496,7 +1497,7 @@ dependencies = [ [[package]] name = "directory" -version = "0.7.1" +version = "0.7.2" dependencies = [ "ahash 0.8.11", "argon2", @@ -1587,6 +1588,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" +[[package]] +name = "dns-update" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1be40017dbee3b3cb51abd068fd8319d83b95433cca53d83fef589a11ba0cd51" +dependencies = [ + "hickory-client", + "reqwest 0.12.3", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", +] + [[package]] name = "doc-comment" version = "0.3.3" @@ -1771,6 +1786,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "enum-as-inner" version = "0.6.0" @@ -2299,6 +2320,26 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-client" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f3e08124cf0ddda93b1186d4af73599de401f3b52f14cd9aaa719049379462e" +dependencies = [ + "cfg-if", + "data-encoding", + "futures-channel", + "futures-util", + "hickory-proto", + "once_cell", + "radix_trie", + "rand", + "rustls 0.21.10", + "thiserror", + "tokio", + "tracing", +] + [[package]] name = "hickory-proto" version = "0.24.0" @@ -2306,12 +2347,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "091a6fbccf4860009355e3efc52ff4acf37a63489aad7435372d44ceeb6fbbcf" dependencies = [ "async-trait", + "bytes", "cfg-if", "data-encoding", "enum-as-inner", "futures-channel", "futures-io", "futures-util", + "h2 0.3.26", + "http 0.2.12", "idna 0.4.0", "ipnet", "once_cell", @@ -2657,7 +2701,7 @@ checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" [[package]] name = "imap" -version = "0.7.1" +version = "0.7.2" dependencies = [ "ahash 0.8.11", "common", @@ -2853,7 +2897,7 @@ dependencies = [ [[package]] name = "jmap" -version = "0.7.1" +version = "0.7.2" dependencies = [ "aes", "aes-gcm", @@ -3267,7 +3311,7 @@ dependencies = [ [[package]] name = "mail-server" -version = "0.7.1" +version = "0.7.2" dependencies = [ "common", "directory", @@ -3285,7 +3329,7 @@ dependencies = [ [[package]] name = "managesieve" -version = "0.7.1" +version = "0.7.2" dependencies = [ "ahash 0.8.11", "bincode", @@ -3538,6 +3582,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nix" version = "0.26.4" @@ -3553,7 +3606,7 @@ dependencies = [ [[package]] name = "nlp" -version = "0.7.1" +version = "0.7.2" dependencies = [ "ahash 0.8.11", "bincode", @@ -4394,6 +4447,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.8.5" @@ -5349,9 +5412,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.115" +version = "1.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12dc5c46daa8e9fdf4f5e71b6cf9a53f2487da0e86e55808e2d35539666497dd" +checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" dependencies = [ "itoa", "ryu", @@ -5566,7 +5629,7 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "smtp" -version = "0.7.1" +version = "0.7.2" dependencies = [ "ahash 0.8.11", "bincode", @@ -5682,7 +5745,7 @@ dependencies = [ [[package]] name = "stalwart-cli" -version = "0.7.1" +version = "0.7.2" dependencies = [ "clap", "console", @@ -5713,7 +5776,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "store" -version = "0.7.1" +version = "0.7.2" dependencies = [ "ahash 0.8.11", "arc-swap", @@ -6556,7 +6619,7 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "utils" -version = "0.7.1" +version = "0.7.2" dependencies = [ "ahash 0.8.11", "base64 0.21.7", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 385d2fa8..46e2302f 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. "] license = "AGPL-3.0-only" repository = "https://github.com/stalwartlabs/cli" homepage = "https://github.com/stalwartlabs/cli" -version = "0.7.1" +version = "0.7.2" edition = "2021" readme = "README.md" resolver = "2" diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 68d45b97..a481c20a 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "common" -version = "0.7.1" +version = "0.7.2" edition = "2021" resolver = "2" @@ -15,6 +15,7 @@ mail-parser = { version = "0.9", features = ["full_encoding", "ludicrous_mode"] mail-auth = { version = "0.3" } mail-send = { version = "0.4", default-features = false, features = ["cram-md5"] } smtp-proto = { version = "0.1", features = ["serde_support"] } +dns-update = { version = "0.1" } ahash = { version = "0.8.0", features = ["serde"] } parking_lot = "0.12.1" regex = "1.7.0" diff --git a/crates/common/src/config/server/listener.rs b/crates/common/src/config/server/listener.rs index cb4e6b5e..f5349721 100644 --- a/crates/common/src/config/server/listener.rs +++ b/crates/common/src/config/server/listener.rs @@ -36,11 +36,7 @@ use utils::config::{ }; use crate::{ - listener::{ - acme::{directory::ACME_TLS_ALPN_NAME, AcmeResolver}, - tls::CertificateResolver, - TcpAcceptor, - }, + listener::{tls::CertificateResolver, TcpAcceptor}, SharedCore, }; @@ -219,14 +215,6 @@ impl Servers { pub fn parse_tcp_acceptors(&mut self, config: &mut Config, core: SharedCore) { let resolver = Arc::new(CertificateResolver::new(core.clone())); - let acme_config = { - let mut challenge = ServerConfig::builder() - .with_no_client_auth() - .with_cert_resolver(Arc::new(AcmeResolver::new(core))); - - challenge.alpn_protocols.push(ACME_TLS_ALPN_NAME.to_vec()); - Arc::new(challenge) - }; for id_ in config .sub_keys("server.listener", ".protocol") @@ -318,8 +306,7 @@ impl Servers { let default_config = Arc::new(server_config); TcpAcceptor::Tls { acceptor: TlsAcceptor::from(default_config.clone()), - acme_config: acme_config.clone(), - default_config, + config: default_config, implicit: config .property_or_default(("server.listener", id, "tls.implicit"), "false") .unwrap_or(false), diff --git a/crates/common/src/config/server/tls.rs b/crates/common/src/config/server/tls.rs index 429b439d..0d3d4182 100644 --- a/crates/common/src/config/server/tls.rs +++ b/crates/common/src/config/server/tls.rs @@ -30,6 +30,8 @@ use std::{ use ahash::{AHashMap, AHashSet}; use arc_swap::ArcSwap; +use base64::{engine::general_purpose::STANDARD, Engine}; +use dns_update::{DnsUpdater, TsigAlgorithm}; use rcgen::generate_simple_self_signed; use rustls::{ crypto::ring::sign::any_supported_type, @@ -47,7 +49,7 @@ use x509_parser::{ }; use crate::listener::{ - acme::{directory::LETS_ENCRYPT_PRODUCTION_DIRECTORY, AcmeProvider}, + acme::{directory::LETS_ENCRYPT_PRODUCTION_DIRECTORY, AcmeProvider, ChallengeSettings}, tls::TlsManager, }; @@ -64,18 +66,19 @@ impl TlsManager { parse_certificates(config, &mut certificates, &mut subject_names); // Parse ACME providers - for acme_id in config + 'outer: for acme_id in config .sub_keys("acme", ".directory") .map(|s| s.to_string()) .collect::>() { + let acme_id = acme_id.as_str(); let directory = config - .value(("acme", acme_id.as_str(), "directory")) + .value(("acme", acme_id, "directory")) .unwrap_or(LETS_ENCRYPT_PRODUCTION_DIRECTORY) .trim() .to_string(); let contact = config - .values(("acme", acme_id.as_str(), "contact")) + .values(("acme", acme_id, "contact")) .filter_map(|(_, v)| { let v = v.trim().to_string(); if !v.is_empty() { @@ -86,7 +89,7 @@ impl TlsManager { }) .collect::>(); let renew_before: Duration = config - .property_or_default(("acme", acme_id.as_str(), "renew-before"), "30d") + .property_or_default(("acme", acme_id, "renew-before"), "30d") .unwrap_or_else(|| Duration::from_secs(30 * 24 * 60 * 60)); if directory.is_empty() { @@ -99,15 +102,59 @@ impl TlsManager { continue; } + // Parse challenge type + let challenge = match config + .value(("acme", acme_id, "challenge")) + .unwrap_or("tls-alpn-01") + { + "tls-alpn-01" => ChallengeSettings::TlsAlpn01, + "http-01" => ChallengeSettings::Http01, + "dns-01" => match build_dns_updater(config, acme_id) { + Some(updater) => ChallengeSettings::Dns01 { + updater, + origin: config + .value(("acme", acme_id, "origin")) + .map(|s| s.to_string()), + polling_interval: config + .property_or_default(("acme", acme_id, "polling-interval"), "15s") + .unwrap_or_else(|| Duration::from_secs(15)), + propagation_timeout: config + .property_or_default(("acme", acme_id, "propagation-timeout"), "1m") + .unwrap_or_else(|| Duration::from_secs(60)), + ttl: config + .property_or_default(("acme", acme_id, "ttl"), "5m") + .unwrap_or_else(|| Duration::from_secs(5 * 60)) + .as_secs() as u32, + }, + None => { + continue; + } + }, + _ => { + config + .new_parse_error(("acme", acme_id, "challenge"), "Invalid challenge type"); + continue; + } + }; + // Domains covered by this ACME manager let domains = config - .values(("acme", acme_id.as_str(), "domains")) - .map(|(_, v)| v.to_string()) + .values(("acme", acme_id, "domains")) + .map(|(_, s)| s.trim().to_string()) .collect::>(); + if !matches!(challenge, ChallengeSettings::Dns01 { .. }) + && domains.iter().any(|d| d.starts_with("*.")) + { + config.new_parse_error( + ("acme", acme_id, "domains"), + "Wildcard domains are only supported with DNS-01 challenge", + ); + continue 'outer; + } // This ACME manager is the default when SNI is not available let default = config - .property::(("acme", acme_id.as_str(), "default")) + .property::(("acme", acme_id, "default")) .unwrap_or_default(); // Add domains for self-signed certificate @@ -119,6 +166,7 @@ impl TlsManager { directory, domains, contact, + challenge, renew_before, default, ) { @@ -139,8 +187,6 @@ impl TlsManager { TlsManager { certificates: ArcSwap::from_pointee(certificates), acme_providers, - acme_auth_keys: Default::default(), - acme_in_progress: false.into(), self_signed_cert: build_self_signed_cert(subject_names.into_iter().collect::>()) .or_else(|err| { config.new_build_error("certificate.self-signed", err); @@ -152,6 +198,77 @@ impl TlsManager { } } +#[allow(clippy::unnecessary_to_owned)] +fn build_dns_updater(config: &mut Config, acme_id: &str) -> Option { + match config.value_require(("acme", acme_id, "provider"))? { + "rfc2136-tsig" => { + let algorithm: TsigAlgorithm = config + .value_require(("acme", acme_id, "algorithm"))? + .parse() + .map_err(|_| { + config.new_parse_error(("acme", acme_id, "algorithm"), "Invalid algorithm") + }) + .ok()?; + let key = STANDARD + .decode(config.value_require(("acme", acme_id, "secret"))?) + .map_err(|_| { + config.new_parse_error( + ("acme", acme_id, "secret"), + "Failed to base64 decode secret", + ) + }) + .ok()?; + let host = config.value_require(("acme", acme_id, "host"))?.to_string(); + let port = config + .property_or_default::(("acme", acme_id, "port"), "53") + .unwrap_or(53); + let protocol = if config.value(("acme", acme_id, "protocol")) == Some("tcp") { + "tcp" + } else { + "udp" + }; + + DnsUpdater::new_rfc2136_tsig( + format!("{protocol}://{host}:{port}"), + config.value_require(("acme", acme_id, "key"))?.to_string(), + key, + algorithm, + ) + .map_err(|err| { + config.new_build_error( + ("acme", acme_id, "provider"), + format!("Failed to create RFC2136-TSIG DNS updater: {err}"), + ) + }) + .ok() + } + "cloudflare" => { + let timeout = config + .property_or_default(("acme", acme_id, "timeout"), "30s") + .unwrap_or_else(|| Duration::from_secs(30)); + + DnsUpdater::new_cloudflare( + config + .value_require(("acme", acme_id, "secret"))? + .to_string(), + config.value(("acme", acme_id, "user")), + timeout.into(), + ) + .map_err(|err| { + config.new_build_error( + ("acme", acme_id, "provider"), + format!("Failed to create Cloudflare DNS updater: {err}"), + ) + }) + .ok() + } + _ => { + config.new_parse_error(("acme", acme_id, "provider"), "Unsupported provider"); + None + } + } +} + pub(crate) fn parse_certificates( config: &mut Config, certificates: &mut AHashMap>, diff --git a/crates/common/src/config/smtp/resolver.rs b/crates/common/src/config/smtp/resolver.rs index ef75ff9f..9e76058c 100644 --- a/crates/common/src/config/smtp/resolver.rs +++ b/crates/common/src/config/smtp/resolver.rs @@ -171,6 +171,8 @@ impl Resolvers { if let Some(attempts) = config.property("resolver.attempts") { opts.attempts = attempts; } + // We already have a cache, so disable the built-in cache + opts.cache_size = 0; // Prepare DNSSEC resolver options let config_dnssec = resolver_config.clone(); diff --git a/crates/common/src/listener/acme/directory.rs b/crates/common/src/listener/acme/directory.rs index bcf08762..2a25e61e 100644 --- a/crates/common/src/listener/acme/directory.rs +++ b/crates/common/src/listener/acme/directory.rs @@ -10,13 +10,14 @@ use reqwest::{Method, Response, StatusCode}; use ring::error::{KeyRejected, Unspecified}; use ring::rand::SystemRandom; use ring::signature::{EcdsaKeyPair, EcdsaSigningAlgorithm, ECDSA_P256_SHA256_FIXED_SIGNING}; -use rustls::crypto::ring::sign::any_ecdsa_type; -use rustls::sign::CertifiedKey; -use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::json; +use store::write::Bincode; +use store::Serialize; -use super::jose::{key_authorization_sha256, sign, JoseError}; +use super::jose::{ + key_authorization, key_authorization_sha256, key_authorization_sha256_base64, sign, JoseError, +}; pub const LETS_ENCRYPT_STAGING_DIRECTORY: &str = "https://acme-staging-v02.api.letsencrypt.org/directory"; @@ -138,33 +139,41 @@ impl Account { Ok(self.request(&url, "").await?.1) } - pub fn tls_alpn_01<'a>( + pub fn http_proof(&self, challenge: &Challenge) -> Result, DirectoryError> { + key_authorization(&self.key_pair, &challenge.token) + .map(|key| key.into_bytes()) + .map_err(Into::into) + } + + pub fn dns_proof(&self, challenge: &Challenge) -> Result { + key_authorization_sha256_base64(&self.key_pair, &challenge.token).map_err(Into::into) + } + + pub fn tls_alpn_key( &self, - challenges: &'a [Challenge], + challenge: &Challenge, domain: String, - ) -> Result<(&'a Challenge, CertifiedKey), DirectoryError> { - let challenge = challenges - .iter() - .find(|c| c.typ == ChallengeType::TlsAlpn01); - let challenge = match challenge { - Some(challenge) => challenge, - None => return Err(DirectoryError::NoTlsAlpn01Challenge), - }; + ) -> Result, DirectoryError> { let mut params = rcgen::CertificateParams::new(vec![domain]); let key_auth = key_authorization_sha256(&self.key_pair, &challenge.token)?; params.alg = &PKCS_ECDSA_P256_SHA256; params.custom_extensions = vec![CustomExtension::new_acme_identifier(key_auth.as_ref())]; let cert = Certificate::from_params(params)?; - let pk = any_ecdsa_type(&PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from( - cert.serialize_private_key_der(), - ))) - .unwrap(); - let certified_key = - CertifiedKey::new(vec![CertificateDer::from(cert.serialize_der()?)], pk); - Ok((challenge, certified_key)) + + Ok(Bincode::new(SerializedCert { + certificate: cert.serialize_der()?, + private_key: cert.serialize_private_key_der(), + }) + .serialize()) } } +#[derive(Debug, Clone, serde::Serialize, Deserialize)] +pub struct SerializedCert { + pub certificate: Vec, + pub private_key: Vec, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Directory { @@ -187,7 +196,7 @@ impl Directory { } } -#[derive(Debug, Deserialize, Eq, PartialEq)] +#[derive(Debug, Deserialize, Eq, PartialEq, Clone, Copy)] pub enum ChallengeType { #[serde(rename = "http-01")] Http01, @@ -223,6 +232,7 @@ pub struct Auth { pub status: AuthStatus, pub identifier: Identifier, pub challenges: Vec, + pub wildcard: Option, } #[derive(Debug, Deserialize)] @@ -236,7 +246,7 @@ pub enum AuthStatus { Deactivated, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, serde::Serialize, Deserialize)] #[serde(tag = "type", content = "value", rename_all = "camelCase")] pub enum Identifier { Dns(String), @@ -251,7 +261,7 @@ pub struct Challenge { pub error: Option, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, serde::Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Problem { #[serde(rename = "type")] @@ -271,7 +281,7 @@ pub enum DirectoryError { KeyRejected(KeyRejected), Crypto(Unspecified), MissingHeader(&'static str), - NoTlsAlpn01Challenge, + NoChallenge(ChallengeType), } #[allow(unused_mut)] diff --git a/crates/common/src/listener/acme/jose.rs b/crates/common/src/listener/acme/jose.rs index f8eb7472..7d5834af 100644 --- a/crates/common/src/listener/acme/jose.rs +++ b/crates/common/src/listener/acme/jose.rs @@ -31,13 +31,26 @@ pub(crate) fn sign( Ok(serde_json::to_string(&body)?) } +pub(crate) fn key_authorization(key: &EcdsaKeyPair, token: &str) -> Result { + Ok(format!( + "{}.{}", + token, + Jwk::new(key).thumb_sha256_base64()? + )) +} + pub(crate) fn key_authorization_sha256( key: &EcdsaKeyPair, token: &str, ) -> Result { - let jwk = Jwk::new(key); - let key_authorization = format!("{}.{}", token, jwk.thumb_sha256_base64()?); - Ok(digest(&SHA256, key_authorization.as_bytes())) + key_authorization(key, token).map(|s| digest(&SHA256, s.as_bytes())) +} + +pub(crate) fn key_authorization_sha256_base64( + key: &EcdsaKeyPair, + token: &str, +) -> Result { + key_authorization_sha256(key, token).map(|s| URL_SAFE_NO_PAD.encode(s.as_ref())) } #[derive(Serialize)] diff --git a/crates/common/src/listener/acme/mod.rs b/crates/common/src/listener/acme/mod.rs index 73f463d2..ceb6a07e 100644 --- a/crates/common/src/listener/acme/mod.rs +++ b/crates/common/src/listener/acme/mod.rs @@ -27,18 +27,16 @@ pub mod jose; pub mod order; pub mod resolver; -use std::{ - fmt::Debug, - sync::{atomic::Ordering, Arc}, - time::Duration, -}; +use std::{fmt::Debug, sync::Arc, time::Duration}; use arc_swap::ArcSwap; +use dns_update::DnsUpdater; +use rustls::sign::CertifiedKey; -use crate::{Core, SharedCore}; +use crate::Core; use self::{ - directory::Account, + directory::{Account, ChallengeType}, order::{CertParseError, OrderError}, }; @@ -47,13 +45,27 @@ pub struct AcmeProvider { pub directory_url: String, pub domains: Vec, pub contact: Vec, + pub challenge: ChallengeSettings, renew_before: chrono::Duration, account_key: ArcSwap>, default: bool, } -pub struct AcmeResolver { - pub core: SharedCore, +#[derive(Clone)] +pub enum ChallengeSettings { + Http01, + TlsAlpn01, + Dns01 { + updater: DnsUpdater, + origin: Option, + polling_interval: Duration, + propagation_timeout: Duration, + ttl: u32, + }, +} + +pub struct StaticResolver { + pub key: Option>, } #[derive(Debug)] @@ -73,6 +85,7 @@ impl AcmeProvider { directory_url: String, domains: Vec, contact: Vec, + challenge: ChallengeSettings, renew_before: Duration, default: bool, ) -> utils::config::Result { @@ -92,6 +105,7 @@ impl AcmeProvider { renew_before: chrono::Duration::from_std(renew_before).unwrap(), domains, account_key: Default::default(), + challenge, default, }) } @@ -116,20 +130,34 @@ impl Core { }) } - pub fn has_acme_order_in_progress(&self) -> bool { - self.tls.acme_in_progress.load(Ordering::Relaxed) + pub fn has_acme_tls_providers(&self) -> bool { + self.tls + .acme_providers + .values() + .any(|p| matches!(p.challenge, ChallengeSettings::TlsAlpn01)) + } + + pub fn has_acme_http_providers(&self) -> bool { + self.tls + .acme_providers + .values() + .any(|p| matches!(p.challenge, ChallengeSettings::Http01)) } } -impl AcmeResolver { - pub fn new(core: SharedCore) -> Self { - Self { core } +impl ChallengeSettings { + pub fn challenge_type(&self) -> ChallengeType { + match self { + ChallengeSettings::Http01 => ChallengeType::Http01, + ChallengeSettings::TlsAlpn01 => ChallengeType::TlsAlpn01, + ChallengeSettings::Dns01 { .. } => ChallengeType::Dns01, + } } } -impl Debug for AcmeResolver { +impl Debug for StaticResolver { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("AcmeResolver").finish() + f.debug_struct("StaticResolver").finish() } } @@ -140,6 +168,7 @@ impl Clone for AcmeProvider { directory_url: self.directory_url.clone(), domains: self.domains.clone(), contact: self.contact.clone(), + challenge: self.challenge.clone(), renew_before: self.renew_before, account_key: ArcSwap::from_pointee(self.account_key.load().as_ref().clone()), default: self.default, diff --git a/crates/common/src/listener/acme/order.rs b/crates/common/src/listener/acme/order.rs index 02e5918e..c4b178d5 100644 --- a/crates/common/src/listener/acme/order.rs +++ b/crates/common/src/listener/acme/order.rs @@ -1,18 +1,20 @@ // Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0. use chrono::{DateTime, TimeZone, Utc}; +use dns_update::DnsRecord; use futures::future::try_join_all; use rcgen::{CertificateParams, DistinguishedName, PKCS_ECDSA_P256_SHA256}; use rustls::crypto::ring::sign::any_ecdsa_type; use rustls::sign::CertifiedKey; use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; use std::fmt::Debug; -use std::sync::atomic::Ordering; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; +use utils::suffixlist::DomainPart; use x509_parser::parse_x509_certificate; use crate::listener::acme::directory::Identifier; +use crate::listener::acme::ChallengeSettings; use crate::Core; use super::directory::{Account, Auth, AuthStatus, Directory, DirectoryError, Order, OrderStatus}; @@ -27,6 +29,8 @@ pub enum OrderError { BadAuth(Auth), TooManyAttemptsAuth(String), ProcessingTimeout(Order), + Store(store::Error), + Dns(dns_update::Error), } #[derive(Debug)] @@ -80,7 +84,6 @@ impl Core { pub async fn renew(&self, provider: &AcmeProvider) -> Result { let mut backoff = 0; - self.tls.acme_in_progress.store(true, Ordering::Relaxed); loop { match self.order(provider).await { Ok(pem) => return self.process_cert(provider, pem, false).await, @@ -203,21 +206,165 @@ impl Core { let (domain, challenge_url) = match auth.status { AuthStatus::Pending => { let Identifier::Dns(domain) = auth.identifier; + let challenge_type = provider.challenge.challenge_type(); tracing::info!( context = "acme", event = "challenge", domain = domain, + challenge = ?challenge_type, "Requesting challenge for domain {domain}" ); - let (challenge, auth_key) = - account.tls_alpn_01(&auth.challenges, domain.clone())?; - self.set_auth_key(provider, domain.clone(), Arc::new(auth_key)); + let challenge = auth + .challenges + .iter() + .find(|c| c.typ == challenge_type) + .ok_or(DirectoryError::NoChallenge(challenge_type))?; + + match &provider.challenge { + ChallengeSettings::TlsAlpn01 => { + self.storage + .lookup + .key_set( + format!("acme:{domain}").into_bytes(), + account.tls_alpn_key(challenge, domain.clone())?, + 3600.into(), + ) + .await?; + } + ChallengeSettings::Http01 => { + self.storage + .lookup + .key_set( + format!("acme:{}", challenge.token).into_bytes(), + account.http_proof(challenge)?, + 3600.into(), + ) + .await?; + } + ChallengeSettings::Dns01 { + updater, + origin, + polling_interval, + propagation_timeout, + ttl, + } => { + let dns_proof = account.dns_proof(challenge)?; + let domain = domain.strip_prefix("*.").unwrap_or(&domain); + let name = format!("_acme-challenge.{}", domain); + let origin = origin + .clone() + .or_else(|| { + self.smtp.resolvers.psl.domain_part(domain, DomainPart::Sld) + }) + .unwrap_or_else(|| domain.to_string()); + + // First try deleting the record + if let Err(err) = updater.delete(&name, &origin).await { + // Errors are expected if the record does not exist + tracing::trace!( + context = "acme", + event = "dns-delete", + name = name, + origin = origin, + error = ?err, + ); + } + + // Create the record + if let Err(err) = updater + .create( + &name, + DnsRecord::TXT { + content: dns_proof.clone(), + }, + *ttl, + &origin, + ) + .await + { + tracing::warn!( + context = "acme", + event = "dns-create", + name = name, + origin = origin, + error = ?err, + "Failed to create DNS record.", + ); + return Err(OrderError::Dns(err)); + } + + tracing::info!( + context = "acme", + event = "dns-create", + name = name, + origin = origin, + "Successfully created DNS record.", + ); + + // Wait for changes to propagate + let wait_until = Instant::now() + *propagation_timeout; + let mut did_propagate = false; + while Instant::now() < wait_until { + match self.smtp.resolvers.dns.txt_raw_lookup(&name).await { + Ok(result) => { + let result = std::str::from_utf8(&result).unwrap_or_default(); + if result.contains(&dns_proof) { + did_propagate = true; + break; + } else { + tracing::debug!( + context = "acme", + event = "dns-lookup", + name = name, + origin = origin, + contents = ?result, + expected_proof = ?dns_proof, + "DNS record has not propagated yet.", + ); + } + } + Err(err) => { + tracing::trace!( + context = "acme", + event = "dns-lookup", + name = name, + origin = origin, + error = ?err, + "Failed to lookup DNS record.", + ); + } + } + + tokio::time::sleep(*polling_interval).await; + } + + if did_propagate { + tracing::info!( + context = "acme", + event = "dns-lookup", + name = name, + origin = origin, + "DNS changes have been propagated.", + ); + } else { + tracing::warn!( + context = "acme", + event = "dns-lookup", + name = name, + origin = origin, + "DNS changes have not been propagated within the timeout.", + ); + } + } + } + account.challenge(&challenge.url).await?; (domain, challenge.url.clone()) } AuthStatus::Valid => return Ok(()), _ => return Err(OrderError::BadAuth(auth)), }; + for i in 0u64..5 { tokio::time::sleep(Duration::from_secs(1u64 << i)).await; let auth = account.auth(url).await?; @@ -232,7 +379,16 @@ impl Core { ); account.challenge(&challenge_url).await? } - AuthStatus::Valid => return Ok(()), + AuthStatus::Valid => { + tracing::debug!( + context = "acme", + event = "auth-valid", + domain = domain, + "Authorization for domain {domain} is valid", + ); + + return Ok(()); + } _ => return Err(OrderError::BadAuth(auth)), } } @@ -305,3 +461,9 @@ impl From for AcmeError { Self::Order(OrderError::from(err)) } } + +impl From for OrderError { + fn from(value: store::Error) -> Self { + Self::Store(value) + } +} diff --git a/crates/common/src/listener/acme/resolver.rs b/crates/common/src/listener/acme/resolver.rs index b5843a5f..a283162a 100644 --- a/crates/common/src/listener/acme/resolver.rs +++ b/crates/common/src/listener/acme/resolver.rs @@ -21,16 +21,20 @@ * for more details. */ -use std::sync::{atomic::Ordering, Arc}; +use std::sync::Arc; use rustls::{ + crypto::ring::sign::any_ecdsa_type, server::{ClientHello, ResolvesServerCert}, sign::CertifiedKey, + ServerConfig, }; +use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use store::write::Bincode; -use crate::{listener::tls::AcmeAuthKey, Core}; +use crate::{listener::acme::directory::SerializedCert, Core}; -use super::{directory::ACME_TLS_ALPN_NAME, AcmeProvider, AcmeResolver}; +use super::{directory::ACME_TLS_ALPN_NAME, AcmeProvider, StaticResolver}; impl Core { pub(crate) fn set_cert(&self, provider: &AcmeProvider, cert: Arc) { @@ -52,57 +56,71 @@ impl Core { } self.tls.certificates.store(certificates.into()); - - // Remove auth keys - let mut auth_keys = self.tls.acme_auth_keys.lock(); - auth_keys.retain(|_, v| v.provider_id != provider.id); - self.tls - .acme_in_progress - .store(!auth_keys.is_empty(), Ordering::Relaxed); - } - pub(crate) fn set_auth_key( - &self, - provider: &AcmeProvider, - domain: String, - cert: Arc, - ) { - self.tls - .acme_auth_keys - .lock() - .insert(domain, AcmeAuthKey::new(provider.id.clone(), cert)); } } -impl ResolvesServerCert for AcmeResolver { - fn resolve(&self, client_hello: ClientHello) -> Option> { - let core = self.core.load(); - if core.has_acme_order_in_progress() && client_hello.is_tls_alpn_challenge() { - match client_hello.server_name() { - Some(domain) => { - tracing::trace!( - context = "acme", - event = "auth-key", - domain = %domain, - "Found client supplied SNI"); +impl ResolvesServerCert for StaticResolver { + fn resolve(&self, _: ClientHello) -> Option> { + self.key.clone() + } +} - core.tls - .acme_auth_keys - .lock() - .get(domain) - .map(|ak| ak.key.clone()) - } - None => { - tracing::debug!( - context = "acme", - event = "error", - reason = "missing-sni", - "client did not supply SNI" - ); - None +pub(crate) fn build_acme_static_resolver(key: Option>) -> Arc { + 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) +} + +impl Core { + pub(crate) async fn build_acme_certificate(&self, domain: &str) -> Option> { + match self + .storage + .lookup + .key_get::>(format!("acme:{domain}").into_bytes()) + .await + { + Ok(Some(cert)) => { + match any_ecdsa_type(&PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from( + cert.inner.private_key, + ))) { + Ok(key) => Some(Arc::new(CertifiedKey::new( + vec![CertificateDer::from(cert.inner.certificate)], + key, + ))), + Err(err) => { + tracing::error!( + context = "acme", + event = "error", + domain = %domain, + reason = %err, + "Failed to parse private key", + ); + None + } } } - } else { - core.resolve_certificate(client_hello.server_name()) + Err(err) => { + tracing::error!( + context = "acme", + event = "error", + domain = %domain, + reason = %err, + "Failed to lookup token", + ); + None + } + Ok(None) => { + tracing::debug!( + context = "acme", + event = "error", + domain = %domain, + reason = "missing-token", + "Token not found in lookup store" + ); + None + } } } } diff --git a/crates/common/src/listener/listen.rs b/crates/common/src/listener/listen.rs index 9f1426ad..76e90368 100644 --- a/crates/common/src/listener/listen.rs +++ b/crates/common/src/listener/listen.rs @@ -115,7 +115,7 @@ impl Server { match stream { Ok((stream, remote_addr)) => { let core = core.as_ref().load(); - let enable_acme = is_https && core.has_acme_order_in_progress(); + let enable_acme = (is_https && core.has_acme_tls_providers()).then_some(core.clone()); if has_proxies && instance.proxy_networks.iter().any(|network| network.matches(&remote_addr.ip())) { let instance = instance.clone(); diff --git a/crates/common/src/listener/mod.rs b/crates/common/src/listener/mod.rs index c0af39ec..2f19efb9 100644 --- a/crates/common/src/listener/mod.rs +++ b/crates/common/src/listener/mod.rs @@ -35,6 +35,7 @@ use utils::config::ipmask::IpAddrMask; use crate::{ config::server::ServerProtocol, expr::{functions::ResolveVariable, *}, + Core, }; use self::limiter::{ConcurrencyLimiter, InFlight}; @@ -58,8 +59,7 @@ pub struct ServerInstance { #[derive(Default)] pub enum TcpAcceptor { Tls { - acme_config: Arc, - default_config: Arc, + config: Arc, acceptor: TlsAcceptor, implicit: bool, }, @@ -99,7 +99,7 @@ pub trait SessionManager: Sync + Send + 'static + Clone { &self, mut session: SessionData, is_tls: bool, - enable_acme: bool, + acme_core: Option>, ) { let manager = self.clone(); @@ -108,7 +108,7 @@ pub trait SessionManager: Sync + Send + 'static + Clone { match session .instance .acceptor - .accept(session.stream, enable_acme) + .accept(session.stream, acme_core) .await { TcpAcceptorResult::Tls(accept) => match accept.await { @@ -177,14 +177,10 @@ impl Debug for TcpAcceptor { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Tls { - acme_config, - default_config, - implicit, - .. + config, implicit, .. } => f .debug_struct("Tls") - .field("acme_config", acme_config) - .field("default_config", default_config) + .field("config", config) .field("implicit", implicit) .finish(), Self::Plain => write!(f, "Plain"), diff --git a/crates/common/src/listener/tls.rs b/crates/common/src/listener/tls.rs index 0c54523e..2b39832f 100644 --- a/crates/common/src/listener/tls.rs +++ b/crates/common/src/listener/tls.rs @@ -24,12 +24,11 @@ use std::{ cmp::Ordering, fmt::{self, Formatter}, - sync::{atomic::AtomicBool, Arc}, + sync::Arc, }; use ahash::AHashMap; use arc_swap::ArcSwap; -use parking_lot::Mutex; use rustls::{ server::{ClientHello, ResolvesServerCert}, sign::CertifiedKey, @@ -42,7 +41,10 @@ use tokio_rustls::{Accept, LazyConfigAcceptor}; use crate::{Core, SharedCore}; use super::{ - acme::{resolver::IsTlsAlpnChallenge, AcmeProvider}, + acme::{ + resolver::{build_acme_static_resolver, IsTlsAlpnChallenge}, + AcmeProvider, + }, SessionStream, TcpAcceptor, TcpAcceptorResult, }; @@ -53,17 +55,9 @@ pub static TLS12_VERSION: &[&SupportedProtocolVersion] = &[&TLS12]; pub struct TlsManager { pub certificates: ArcSwap>>, pub acme_providers: AHashMap, - pub(crate) acme_auth_keys: Mutex>, - pub acme_in_progress: AtomicBool, pub self_signed_cert: Option>, } -#[derive(Clone)] -pub(crate) struct AcmeAuthKey { - pub provider_id: String, - pub key: Arc, -} - #[derive(Clone)] pub struct CertificateResolver { pub core: SharedCore, @@ -75,12 +69,6 @@ impl CertificateResolver { } } -impl AcmeAuthKey { - pub fn new(provider_id: String, key: Arc) -> Self { - Self { provider_id, key } - } -} - impl ResolvesServerCert for CertificateResolver { fn resolve(&self, hello: ClientHello<'_>) -> Option> { self.core @@ -139,24 +127,54 @@ impl Core { } impl TcpAcceptor { - pub async fn accept(&self, stream: IO, enable_acme: bool) -> TcpAcceptorResult + pub async fn accept( + &self, + stream: IO, + enable_acme: Option>, + ) -> TcpAcceptorResult where IO: SessionStream, { match self { TcpAcceptor::Tls { - acme_config, - default_config, + config, acceptor, implicit, - } if *implicit => { - if !enable_acme { - TcpAcceptorResult::Tls(acceptor.accept(stream)) - } else { + } if *implicit => match enable_acme { + None => TcpAcceptorResult::Tls(acceptor.accept(stream)), + Some(core) => { match LazyConfigAcceptor::new(Default::default(), stream).await { Ok(start_handshake) => { - if start_handshake.client_hello().is_tls_alpn_challenge() { - match start_handshake.into_stream(acme_config.clone()).await { + 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; + + tracing::trace!( + context = "acme", + event = "auth-key", + domain = %domain, + found_key = key.is_some(), + "Client supplied SNI"); + key + } + None => { + tracing::debug!( + context = "acme", + event = "error", + reason = "missing-sni", + "Client did not supply SNI" + ); + None + } + }; + + match start_handshake + .into_stream(build_acme_static_resolver(key)) + .await + { Ok(mut tls) => { tracing::debug!( context = "acme", @@ -176,7 +194,7 @@ impl TcpAcceptor { } } else { return TcpAcceptorResult::Tls( - start_handshake.into_stream(default_config.clone()), + start_handshake.into_stream(config.clone()), ); } } @@ -192,7 +210,7 @@ impl TcpAcceptor { TcpAcceptorResult::Close } - } + }, _ => TcpAcceptorResult::Plain(stream), } } @@ -225,11 +243,6 @@ impl Clone for TlsManager { Self { certificates: ArcSwap::from_pointee(self.certificates.load().as_ref().clone()), acme_providers: self.acme_providers.clone(), - acme_auth_keys: Mutex::new(self.acme_auth_keys.lock().clone()), - acme_in_progress: self - .acme_in_progress - .load(std::sync::atomic::Ordering::Relaxed) - .into(), self_signed_cert: self.self_signed_cert.clone(), } } diff --git a/crates/common/src/scripts/plugins/text.rs b/crates/common/src/scripts/plugins/text.rs index afe001a0..8426c7a1 100644 --- a/crates/common/src/scripts/plugins/text.rs +++ b/crates/common/src/scripts/plugins/text.rs @@ -23,18 +23,12 @@ use nlp::tokenizers::types::{TokenType, TypesTokenizer}; use sieve::{runtime::Variable, FunctionMap}; +use utils::suffixlist::DomainPart; use crate::scripts::functions::{html::html_to_tokens, text::tokenize_words, ApplyString}; use super::PluginContext; -#[derive(PartialEq, Eq, Clone, Copy)] -enum MatchPart { - Sld, - Tld, - Host, -} - pub fn register_tokenize(plugin_id: u32, fnc_map: &mut FunctionMap) { fnc_map.set_external_function("tokenize", plugin_id, 2); } @@ -78,51 +72,20 @@ pub fn exec_tokenize(ctx: PluginContext<'_>) -> Variable { pub fn exec_domain_part(ctx: PluginContext<'_>) -> Variable { let v = ctx.arguments; - let match_part = match v[1].to_string().as_ref() { - "sld" => MatchPart::Sld, - "tld" => MatchPart::Tld, - "host" => MatchPart::Host, + let part = match v[1].to_string().as_ref() { + "sld" => DomainPart::Sld, + "tld" => DomainPart::Tld, + "host" => DomainPart::Host, _ => return Variable::default(), }; v[0].transform(|domain| { - let d = domain.trim().to_lowercase(); - let mut seen_dot = false; - for (pos, ch) in d.as_bytes().iter().enumerate().rev() { - if *ch == b'.' { - if seen_dot { - let maybe_domain = - std::str::from_utf8(&d.as_bytes()[pos + 1..]).unwrap_or_default(); - if !ctx.core.smtp.resolvers.psl.contains(maybe_domain) { - return if match_part == MatchPart::Sld { - maybe_domain - } else { - std::str::from_utf8(&d.as_bytes()[..pos]).unwrap_or_default() - } - .to_string() - .into(); - } - } else if match_part == MatchPart::Tld { - return std::str::from_utf8(&d.as_bytes()[pos + 1..]) - .unwrap_or_default() - .to_string() - .into(); - } else { - seen_dot = true; - } - } - } - - if seen_dot { - if match_part == MatchPart::Sld { - d.into() - } else { - Variable::default() - } - } else if match_part == MatchPart::Host { - d.into() - } else { - Variable::default() - } + ctx.core + .smtp + .resolvers + .psl + .domain_part(domain, part) + .map(Variable::from) + .unwrap_or_default() }) } diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml index d7a66ced..b119039f 100644 --- a/crates/directory/Cargo.toml +++ b/crates/directory/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "directory" -version = "0.7.1" +version = "0.7.2" edition = "2021" resolver = "2" diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml index ecde6d58..7f9918bf 100644 --- a/crates/imap/Cargo.toml +++ b/crates/imap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imap" -version = "0.7.1" +version = "0.7.2" edition = "2021" resolver = "2" diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index 4221969e..7d7753b8 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap" -version = "0.7.1" +version = "0.7.2" edition = "2021" resolver = "2" diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index b5dafe7d..0f9102a7 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -228,6 +228,25 @@ impl JMAP { Err(err) => err.into_http_response(), }; } + ("acme-challenge", &Method::GET) if self.core.has_acme_http_providers() => { + if let Some(token) = path.next() { + return match self + .core + .storage + .lookup + .key_get::(format!("acme:{token}").into_bytes()) + .await + { + Ok(Some(proof)) => Resource { + content_type: "text/plain", + contents: proof.into_bytes(), + } + .into_http_response(), + Ok(None) => RequestError::not_found().into_http_response(), + Err(err) => err.into_http_response(), + }; + } + } (_, &Method::OPTIONS) => { return ().into_http_response(); } diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index 02762dea..0fc6b8b4 100644 --- a/crates/main/Cargo.toml +++ b/crates/main/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art" keywords = ["imap", "jmap", "smtp", "email", "mail", "server"] categories = ["email"] license = "AGPL-3.0-only" -version = "0.7.1" +version = "0.7.2" edition = "2021" resolver = "2" diff --git a/crates/managesieve/Cargo.toml b/crates/managesieve/Cargo.toml index 0c72ae42..e0d4edba 100644 --- a/crates/managesieve/Cargo.toml +++ b/crates/managesieve/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "managesieve" -version = "0.7.1" +version = "0.7.2" edition = "2021" resolver = "2" diff --git a/crates/nlp/Cargo.toml b/crates/nlp/Cargo.toml index d942da73..963c9c9f 100644 --- a/crates/nlp/Cargo.toml +++ b/crates/nlp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nlp" -version = "0.7.1" +version = "0.7.2" edition = "2021" resolver = "2" diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index 5f9ed6b8..05847634 100644 --- a/crates/smtp/Cargo.toml +++ b/crates/smtp/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art/smtp" keywords = ["smtp", "email", "mail", "server"] categories = ["email"] license = "AGPL-3.0-only" -version = "0.7.1" +version = "0.7.2" edition = "2021" resolver = "2" diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index 5d156934..f28b578f 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "store" -version = "0.7.1" +version = "0.7.2" edition = "2021" resolver = "2" diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index ce90aa4c..a086f47f 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "utils" -version = "0.7.1" +version = "0.7.2" edition = "2021" resolver = "2" diff --git a/crates/utils/src/suffixlist.rs b/crates/utils/src/suffixlist.rs index 0098e387..8613febc 100644 --- a/crates/utils/src/suffixlist.rs +++ b/crates/utils/src/suffixlist.rs @@ -35,12 +35,60 @@ pub struct PublicSuffix { pub wildcards: Vec, } +#[derive(PartialEq, Eq, Clone, Copy)] +pub enum DomainPart { + Sld, + Tld, + Host, +} + impl PublicSuffix { pub fn contains(&self, suffix: &str) -> bool { self.suffixes.contains(suffix) || (!self.exceptions.contains(suffix) && self.wildcards.iter().any(|w| suffix.ends_with(w))) } + + pub fn domain_part(&self, domain: &str, part: DomainPart) -> Option { + let d = domain.trim().to_lowercase(); + let mut seen_dot = false; + for (pos, ch) in d.as_bytes().iter().enumerate().rev() { + if *ch == b'.' { + if seen_dot { + let maybe_domain = + std::str::from_utf8(&d.as_bytes()[pos + 1..]).unwrap_or_default(); + if !self.contains(maybe_domain) { + return if part == DomainPart::Sld { + maybe_domain + } else { + std::str::from_utf8(&d.as_bytes()[..pos]).unwrap_or_default() + } + .to_string() + .into(); + } + } else if part == DomainPart::Tld { + return std::str::from_utf8(&d.as_bytes()[pos + 1..]) + .unwrap_or_default() + .to_string() + .into(); + } else { + seen_dot = true; + } + } + } + + if seen_dot { + if part == DomainPart::Sld { + d.into() + } else { + None + } + } else if part == DomainPart::Host { + d.into() + } else { + None + } + } } impl From<&str> for PublicSuffix { diff --git a/tests/resources/docker/Docker.pebble b/tests/resources/acme/Docker.pebble similarity index 100% rename from tests/resources/docker/Docker.pebble rename to tests/resources/acme/Docker.pebble diff --git a/tests/resources/acme/config.toml b/tests/resources/acme/config.toml new file mode 100644 index 00000000..d5e114b3 --- /dev/null +++ b/tests/resources/acme/config.toml @@ -0,0 +1,68 @@ +acme.pebble.contact = "postmaster@example.org" +acme.pebble.directory = "https://localhost:14000/dir" +#acme.pebble.domains = "mail.example.org" +acme.pebble.renew-before = "30d" + +acme.pebble.challenge = "tls-alpn-01" +#acme.pebble.challenge = "http-01" +#acme.pebble.challenge = "dns-01" + +acme.pebble.domains = "*.example.org" +acme.pebble.provider = "cloudflare" +acme.pebble.secret = "" + +authentication.fallback-admin.secret = "secret" +authentication.fallback-admin.user = "admin" +config.local-keys.0 = "*" +directory.internal.store = "rocksdb" +directory.internal.type = "internal" +lookup.default.hostname = "mail.example.org" +lookup.default.domain = "example.org" +oauth.key = "0Wn7rO4UdmBoE8mp3cDcD9Qlpz3na74z7fGRoSuq8fVsGPelLl3KrHomBN8h2biA" +queue.quota.size.enable = true +queue.quota.size.messages = 100000 +queue.quota.size.size = 10737418240 +queue.throttle.rcpt.concurrency = 5 +queue.throttle.rcpt.enable = true +queue.throttle.rcpt.key = "rcpt_domain" +report.analysis.addresses = "postmaster@*" +server.http.permissive-cors = true +server.listener.http.bind = "[::]:5002" +server.listener.http.protocol = "http" +server.listener.https.bind = "[::]:5001" +server.listener.https.protocol = "http" +server.listener.https.tls.implicit = true +server.listener.imap.bind = "[::]:143" +server.listener.imap.protocol = "imap" +server.listener.imaptls.bind = "[::]:993" +server.listener.imaptls.protocol = "imap" +server.listener.imaptls.tls.implicit = true +server.listener.sieve.bind = "[::]:4190" +server.listener.sieve.protocol = "managesieve" +server.listener.smtp.bind = "[::]:25" +server.listener.smtp.protocol = "smtp" +server.listener.submission.bind = "[::]:587" +server.listener.submission.protocol = "smtp" +server.listener.submissions.bind = "[::]:465" +server.listener.submissions.protocol = "smtp" +server.listener.submissions.tls.implicit = true +session.throttle.ip.concurrency = 5 +session.throttle.ip.enable = true +session.throttle.ip.key = "remote_ip" +session.throttle.sender.enable = true +session.throttle.sender.key.0 = "sender_domain" +session.throttle.sender.key.1 = "rcpt" +session.throttle.sender.rate = "25/1h" +storage.blob = "rocksdb" +storage.data = "rocksdb" +storage.directory = "internal" +storage.fts = "rocksdb" +storage.lookup = "rocksdb" +store.rocksdb.compression = "lz4" +store.rocksdb.path = "/tmp/stalwart-temp-data" +store.rocksdb.type = "rocksdb" +tracer.stdout.ansi = true +tracer.stdout.enable = true +tracer.stdout.level = "trace" +tracer.stdout.type = "stdout" +version.spam-filter = 1.0 diff --git a/tests/resources/docker/docker-compose-pebble.yaml b/tests/resources/acme/docker-compose-pebble.yaml similarity index 91% rename from tests/resources/docker/docker-compose-pebble.yaml rename to tests/resources/acme/docker-compose-pebble.yaml index 990b5d26..4330e19d 100644 --- a/tests/resources/docker/docker-compose-pebble.yaml +++ b/tests/resources/acme/docker-compose-pebble.yaml @@ -1,13 +1,14 @@ # docker-compose -f docker-compose-pebble.yaml up # curl --request POST --data '{"ip":"192.168.5.2"}' http://localhost:8055/set-default-ipv4 # HTTPS port should be 5001 +# HTTP port should be 5002 # Directory https://localhost:14000/dir version: '3' services: pebble: image: letsencrypt/pebble:latest - command: pebble -config /test/config/pebble-config.json -strict -dnsserver 10.30.50.3:8053 + command: pebble -config /test/config/pebble-config.json -strict -dnsserver 8.8.8.8:53 #-dnsserver 10.30.50.3:8053 ports: - 14000:14000 # HTTPS ACME API - 15000:15000 # HTTPS Management API diff --git a/tests/resources/acme/test_acme.sh b/tests/resources/acme/test_acme.sh new file mode 100644 index 00000000..f266b917 --- /dev/null +++ b/tests/resources/acme/test_acme.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +rm -Rf /tmp/stalwart-temp-data +mkdir -p /tmp/stalwart-temp-data +cp ./tests/resources/acme/config.toml /tmp/stalwart-temp-data/config.toml + +curl --request POST --data '{"ip":"192.168.5.2"}' http://localhost:8055/set-default-ipv4 + +cargo run -p mail-server --no-default-features --features "sqlite foundationdb postgres mysql rocks elastic s3 redis" -- --config=/tmp/stalwart-temp-data/config.toml diff --git a/tests/resources/docker/Docker.haproxy b/tests/resources/proxy-protocol/Docker.haproxy similarity index 100% rename from tests/resources/docker/Docker.haproxy rename to tests/resources/proxy-protocol/Docker.haproxy diff --git a/tests/resources/docker/haproxy.cfg b/tests/resources/proxy-protocol/haproxy.cfg similarity index 100% rename from tests/resources/docker/haproxy.cfg rename to tests/resources/proxy-protocol/haproxy.cfg diff --git a/tests/src/jmap/push_subscription.rs b/tests/src/jmap/push_subscription.rs index 08dde51f..de2c3f08 100644 --- a/tests/src/jmap/push_subscription.rs +++ b/tests/src/jmap/push_subscription.rs @@ -308,7 +308,7 @@ impl common::listener::SessionManager for SessionManager { session .instance .acceptor - .accept(session.stream, false) + .accept(session.stream, None) .await .unwrap_tls() .await diff --git a/tests/src/smtp/session.rs b/tests/src/smtp/session.rs index 5ee3a74d..91cf6381 100644 --- a/tests/src/smtp/session.rs +++ b/tests/src/smtp/session.rs @@ -368,8 +368,7 @@ impl TestServerInstance for ServerInstance { id: "smtp".to_string(), protocol: ServerProtocol::Smtp, acceptor: TcpAcceptor::Tls { - acme_config: tls_config.clone(), - default_config: tls_config.clone(), + config: tls_config.clone(), acceptor: TlsAcceptor::from(tls_config), implicit: false, },