diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index 7b2a0864..23351923 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -98,9 +98,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -408,7 +408,10 @@ dependencies = [ "testresult", "thiserror", "tokio", + "tokio-io-timeout", + "tokio-rustls", "viadkim", + "webpki-roots", ] [[package]] @@ -933,9 +936,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -1336,6 +1339,21 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -1345,6 +1363,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.103.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1609,9 +1638,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.0" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", @@ -1624,6 +1653,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-io-timeout" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" +dependencies = [ + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -1635,6 +1674,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -1867,6 +1916,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "widestring" version = "1.2.1" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index ba032860..e6a4db8e 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -23,6 +23,13 @@ viadkim = { version = "0.2.0" } hickory-resolver = { version = "0.25.2", features = ["dnssec-ring"] } lru = "0.16.3" parking_lot = "0.12.5" +tokio-rustls = { version = "0.26.4", default-features = false, features = [ + "ring", + "logging", + "tls12", +] } +webpki-roots = "1.0.6" +tokio-io-timeout = "1.2.1" [dev-dependencies] rstest = "0.26.1" diff --git a/filtermail/README.md b/filtermail/README.md index d0b044b1..500bdaed 100644 --- a/filtermail/README.md +++ b/filtermail/README.md @@ -11,16 +11,16 @@ and handles per-sender rate limiting. ## Usage ```plain -filtermail (incoming|outgoing) +filtermail (incoming|outgoing|transport) ``` where `` is a path to `chatmail.ini` configuration file. -Filtermail can be used in `incoming` or `outgoing` mode that apply different settings -to filter either incoming or outgoing emails. +Filtermail can be used in `incoming`, `outgoing` or `transport` mode. ### Incoming mode -Filtermail in incoming mode performs following steps: +Filtermail in incoming mode acts as a proxy filter +for messages received from remote MTAs and performs following steps: 1. Rejects messages if `DATA` exceeds configured message size limit. 2. Rejects messages that do not meet at least one of the following criteria: @@ -43,7 +43,8 @@ Filtermail in incoming mode performs following steps: ### Outgoing mode -Filtermail in outgoing mode performs following steps: +Filtermail in outgoing mode acts as a proxy filter +for messages received from clients and performs following steps: 1. Rejects messages at `MAIL FROM` stage if the address exceeded rate limit. 2. Rejects messages if `DATA` exceeds configured message size limit. @@ -55,6 +56,15 @@ Filtermail in outgoing mode performs following steps: - self-sent Autocrypt Setup Message, - all recipients match `passthrough_recipients`. +### Transport mode + +Filtermail in transport mode is used for final delivery to remote MTAs. +As opposed to incoming/outgoing, it accepts connections from postfix over LMTP instead of SMTP, +to allow returning per-recipient status back to postfix. +Received message is split per-domain and sent to recipients' MX servers over SMTP, +enforcing TLS. +As opposed to postfix, IPv4 and IPv6 connections are tried in parallel and first successful connection is used. + ## Configuration ### chatmail.ini @@ -66,6 +76,8 @@ but implements a custom parser that only requires a small subset of configuratio defaults to `10080`. - `filtermail_smtp_port_incoming` - port to listen on in incoming mode, defaults to `10081`. +- `filtermail_lmtp_port_transport` - port to listen on in transport mode, + defaults to `10083`. - `postfix_reinject_port` - port to reinject messages to postfix in outgoing mode, defaults to `10025`. - `postfix_reinject_port_incoming` - port to reinject messages to postfix in incoming mode, @@ -113,7 +125,7 @@ Although unsupported, it may still work outside of this context or even without with few considerations: - Filtermail expects to receive messages from a trusted server, - and thus should not be exposed directly to the internet. + and thus should not listen on ports exposed directly to the internet. - Issues outside of chatmail relay context are not necessarily considered bugs; PRs fixing them are not guaranteed to be accepted. (Trivial changes may still be considered, diff --git a/filtermail/src/config.rs b/filtermail/src/config.rs index ad6831c5..71a9bc18 100644 --- a/filtermail/src/config.rs +++ b/filtermail/src/config.rs @@ -14,6 +14,8 @@ pub struct Config { pub filtermail_smtp_port: u16, #[serde(default = "Config::default_filtermail_smtp_port_incoming")] pub filtermail_smtp_port_incoming: u16, + #[serde(default = "Config::default_filtermail_lmtp_port_transport")] + pub filtermail_lmtp_port_transport: u16, #[serde(default = "Config::default_postfix_host")] pub postfix_host: String, #[serde(default = "Config::default_postfix_reinject_port")] @@ -108,6 +110,9 @@ impl Config { const fn default_filtermail_smtp_port_incoming() -> u16 { 10081 } + const fn default_filtermail_lmtp_port_transport() -> u16 { + 10083 + } fn default_postfix_host() -> String { "127.0.0.1".to_owned() } @@ -138,6 +143,7 @@ impl Default for Config { filtermail_host: Self::default_filtermail_host(), filtermail_smtp_port: Self::default_filtermail_smtp_port(), filtermail_smtp_port_incoming: Self::default_filtermail_smtp_port_incoming(), + filtermail_lmtp_port_transport: Self::default_filtermail_lmtp_port_transport(), postfix_host: Self::default_postfix_host(), postfix_reinject_port: Self::default_postfix_reinject_port(), postfix_reinject_port_incoming: Self::default_postfix_reinject_port_incoming(), diff --git a/filtermail/src/dkim_verifier.rs b/filtermail/src/dkim_verifier.rs index 97a396f9..b12e0cb3 100644 --- a/filtermail/src/dkim_verifier.rs +++ b/filtermail/src/dkim_verifier.rs @@ -1,4 +1,3 @@ -use hickory_resolver::name_server::TokioConnectionProvider; use hickory_resolver::{Name, TokioResolver}; use lru::LruCache; use std::io; @@ -29,7 +28,7 @@ fn normalize_rdata(txt_data: &str) -> String { /// DNS resolver for DKIM TXT records, that caches RDATA in memory. #[derive(Clone)] struct CachedResolver { - dns_resolver: TokioResolver, + dns_resolver: Arc, // Note: Arc is required despite we are holding the whole handler in an Arc, // because viadkim will internally clone the resolver (LookupTxt + Clone + 'static) // to parallelize lookups in case of multiple signatures... @@ -38,26 +37,13 @@ struct CachedResolver { impl CachedResolver { /// Creates a new [`CachedResolver`]. - pub fn new() -> Result { - // Use resolv.conf - let dns_resolver = { - let mut builder = TokioResolver::builder(TokioConnectionProvider::default())?; - // https://github.com/hickory-dns/hickory-dns/issues/3519 - builder.options_mut().validate = true; - builder.build() - }; - - assert!( - dns_resolver.options().validate, - "incorrect resolver config: DNSSEC disabled; exiting" - ); - + pub fn new(dns_resolver: Arc) -> Self { let cache = Arc::new(parking_lot::Mutex::new(LruCache::new(LRU_CACHE_CAPACITY))); - Ok(Self { + Self { dns_resolver, cache, - }) + } } /// Invalidates the cached RDATA for a given selector and domain. @@ -148,7 +134,6 @@ impl LookupTxt for MockResolver { } /// Either a real resolver or a mock. -#[allow(clippy::large_enum_variant)] #[derive(Clone)] enum Resolver { /// A [`CachedResolver`] @@ -190,13 +175,13 @@ pub struct DkimVerifier { impl DkimVerifier { /// Creates a new [`DkimVerifier`] with the provided resolver. - pub fn new() -> Result { - let resolver = CachedResolver::new()?.into(); + pub fn new(dns_resolver: Arc) -> Self { + let resolver = CachedResolver::new(dns_resolver).into(); let config = viadkim::Config { lookup_timeout: Duration::from_secs(60), ..Default::default() }; - Ok(Self { resolver, config }) + Self { resolver, config } } /// Creates a new [`DkimVerifier`] with a mock resolver that always returns the provided TXT record. diff --git a/filtermail/src/error.rs b/filtermail/src/error.rs index b8593634..32bea648 100644 --- a/filtermail/src/error.rs +++ b/filtermail/src/error.rs @@ -1,5 +1,7 @@ //! Error types. +use tokio_rustls::rustls; + /// Error type for filtermail. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -19,6 +21,12 @@ pub enum Error { }, #[error("Invalid email address: {0}")] InvalidEmailAddress(String), + #[error("Failed to connect to any of the following addresses: {0:?}")] + ConnectionFailed(Vec), + #[error(transparent)] + Tls(#[from] rustls::Error), + #[error(transparent)] + InvalidDnsName(#[from] rustls::pki_types::InvalidDnsNameError), } impl Error { @@ -34,4 +42,13 @@ impl Error { _ => "451 Local error".to_string(), } } + + /// Same as [`smtp_response`](Self::smtp_response) but formats the same response + /// for each recipient, as expected by LMTP. + pub fn lmtp_response(&self, recipient_count: usize) -> String { + let response = self.smtp_response(); + std::iter::repeat_n(response, recipient_count) + .collect::>() + .join("\r\n") + } } diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index b2921b7d..7b9182e3 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -6,29 +6,29 @@ use crate::dkim_verifier::DkimVerifier; use crate::message::{check_encrypted, is_securejoin}; pub use crate::smtp_server::Envelope; use crate::smtp_server::SmtpHandler; -use crate::utils::{AddressDomain, extract_address, log_eml}; +use crate::utils::{AddressDomain, build_resolver, extract_address, log_eml}; use async_trait::async_trait; +use hickory_resolver::TokioResolver; use mailparse::{MailHeaderMap, parse_mail}; -use std::net::SocketAddr; use std::str::FromStr; +use std::sync::Arc; /// Handler for incoming SMTP messages. pub struct IncomingBeforeQueueHandler { config: Config, + dns_resolver: Arc, dkim_verifier: DkimVerifier, skip_dkim: bool, - reinject_addr: SocketAddr, } impl IncomingBeforeQueueHandler { pub fn new(config: Config, skip_dkim: bool) -> Result { - let reinject_addr = - crate::resolve_addr(&config.postfix_host, config.postfix_reinject_port_incoming)?; + let dns_resolver = Arc::new(build_resolver()?); Ok(Self { config, - dkim_verifier: DkimVerifier::new()?, + dns_resolver: dns_resolver.clone(), + dkim_verifier: DkimVerifier::new(dns_resolver), skip_dkim, - reinject_addr, }) } @@ -144,13 +144,20 @@ impl SmtpHandler for IncomingBeforeQueueHandler { async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { log::debug!("Re-injecting the mail that passed checks"); - - crate::smtp_client::send(self.reinject_addr, envelope) - .await - .map_err(|e| { - log::warn!("Failed to re-inject mail: {}", e); - e.smtp_response() - })?; + let hostname = format!("[{}]", self.config.filtermail_host); + crate::smtp_client::send( + &self.config.postfix_host, + self.config.postfix_reinject_port_incoming, + envelope, + &hostname, + None, + self.dns_resolver.clone(), + ) + .await + .map_err(|e| { + log::warn!("Failed to re-inject mail: {}", e); + e.smtp_response() + })?; Ok(()) } diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs index d923b599..47d2ed45 100644 --- a/filtermail/src/main.rs +++ b/filtermail/src/main.rs @@ -33,40 +33,45 @@ pub(crate) mod openpgp; pub(crate) mod outbound; pub(crate) mod smtp_client; pub(crate) mod smtp_server; +mod tls; +mod transport; pub(crate) mod utils; +use crate::transport::TransportHandler; use config::Config; use env_logger::Env; use inbound::IncomingBeforeQueueHandler; use outbound::OutgoingBeforeQueueHandler; use smtp_server::run_smtp_server; use std::env; -use std::net::{SocketAddr, ToSocketAddrs}; use std::process; +use std::str::FromStr; use std::sync::Arc; const ENCRYPTION_NEEDED_523: &str = "523 Encryption Needed: Invalid Unencrypted Mail"; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] enum Mode { Outgoing, Incoming, + Transport, } -impl std::str::FromStr for Mode { +impl FromStr for Mode { type Err = &'static str; - fn from_str(s: &str) -> Result { - match s { + fn from_str(mode: &str) -> Result { + match mode { "outgoing" => Ok(Mode::Outgoing), "incoming" => Ok(Mode::Incoming), - _ => Err("Error: mode must be 'incoming' or 'outgoing'"), + "transport" => Ok(Mode::Transport), + _ => Err("Error: mode must be 'incoming', 'outgoing' or 'transport'"), } } } #[tokio::main] -async fn main() { +async fn main() -> Result<(), error::Error> { // default to info level let env = Env::new().filter_or("RUST_LOG", "info"); env_logger::Builder::from_env(env) @@ -74,13 +79,17 @@ async fn main() { .format_timestamp(None) .init(); + tokio_rustls::rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to set up rustls crypto provider."); + let args: Vec = env::args().collect(); if args.len() != 3 { eprintln!( "Usage: {} ", args.first().unwrap_or(&"filtermail".to_string()) ); - eprintln!(" mode: incoming or outgoing"); + eprintln!(" mode: incoming, outgoing or transport"); process::exit(1); } @@ -91,7 +100,7 @@ async fn main() { unreachable!("args length checked above") }; - let mode = match mode.parse::() { + let mode = match Mode::from_str(mode) { Ok(mode) => mode, Err(e) => { eprintln!("{e}"); @@ -107,45 +116,65 @@ async fn main() { } }; - if mode == Mode::Outgoing { - let addr = (config.filtermail_host, config.filtermail_smtp_port); - let handler = Arc::new(OutgoingBeforeQueueHandler::new(config.clone()).unwrap()); - let max_size = config.max_message_size; - log::debug!("Outgoing SMTP server listening on {}:{}", addr.0, addr.1); + match mode { + Mode::Outgoing => { + let addr = (config.filtermail_host, config.filtermail_smtp_port); + let handler = Arc::new(OutgoingBeforeQueueHandler::new(config.clone())?); + let max_size = config.max_message_size; + log::debug!("Outgoing SMTP server listening on {}:{}", addr.0, addr.1); - if let Err(e) = run_smtp_server(&addr, handler, max_size).await { - eprintln!("Server error: {}", e); - process::exit(1); + if let Err(e) = run_smtp_server(&addr, handler, max_size).await { + eprintln!("Server error: {}", e); + process::exit(1); + } } - } else { - // Skip DKIM verification (used for tests). - let skip_dkim = env::var("FILTERMAIL_SKIP_DKIM") - .map(|val| val == "1" || val.eq_ignore_ascii_case("true")) - .unwrap_or(false); + Mode::Incoming => { + // Skip DKIM verification (used for tests). + let skip_dkim = env::var("FILTERMAIL_SKIP_DKIM") + .map(|val| val == "1" || val.eq_ignore_ascii_case("true")) + .unwrap_or(false); - if skip_dkim { - log::warn!("DKIM verification DISABLED! This should not be used in production."); + if skip_dkim { + log::warn!("DKIM verification DISABLED! This should not be used in production."); + } + + let handler = Arc::new(IncomingBeforeQueueHandler::new(config.clone(), skip_dkim)?); + let max_size = config.max_message_size; + + let mut server_set = tokio::task::JoinSet::new(); + + let addr_smtp = (config.filtermail_host, config.filtermail_smtp_port_incoming); + let handler_smtp = handler.clone(); + server_set + .spawn(async move { run_smtp_server(&addr_smtp, handler_smtp, max_size).await }); + log::debug!( + "Incoming SMTP server listening on {}:{}", + addr_smtp.0, + addr_smtp.1 + ); + + while let Some(result) = server_set.join_next().await { + if let Err(e) = result { + eprintln!("Server error: {}", e); + process::exit(1); + } + } } + Mode::Transport => { + let addr = ( + config.filtermail_host, + config.filtermail_lmtp_port_transport, + ); + let handler = Arc::new(TransportHandler::new(config.clone())?); + let max_size = config.max_message_size; + log::debug!("Transport SMTP server listening on {}:{}", addr.0, addr.1); - let addr = (config.filtermail_host, config.filtermail_smtp_port_incoming); - let handler = Arc::new( - // We want to panic here if the handler cannot be created. - IncomingBeforeQueueHandler::new(config.clone(), skip_dkim).unwrap(), - ); - let max_size = config.max_message_size; - log::debug!("Incoming SMTP server listening on {}:{}", addr.0, addr.1); - - if let Err(e) = run_smtp_server(&addr, handler, max_size).await { - eprintln!("Server error: {}", e); - process::exit(1); + if let Err(e) = run_smtp_server(&addr, handler, max_size).await { + eprintln!("Server error: {}", e); + process::exit(1); + } } - } -} - -fn resolve_addr(host: &str, port: u16) -> Result { - log::debug!("Resolving {host}"); - Ok((host, port) - .to_socket_addrs()? - .next() - .ok_or(std::io::Error::other("Cannot resolve host"))?) + }; + + Ok(()) } diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 5c60808b..8b9379e2 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -5,28 +5,28 @@ use crate::config::Config; use crate::message::{check_encrypted, is_securejoin, recipient_matches_passthrough}; pub use crate::smtp_server::Envelope; use crate::smtp_server::SmtpHandler; -use crate::utils::extract_address; +use crate::utils::{build_resolver, extract_address}; use async_trait::async_trait; use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; +use hickory_resolver::TokioResolver; use mailparse::{MailHeaderMap, parse_mail}; -use std::net::SocketAddr; +use std::sync::Arc; /// Handler for outgoing SMTP messages. pub struct OutgoingBeforeQueueHandler { config: Config, - reinject_addr: SocketAddr, + dns_resolver: Arc, send_rate_limiter: DefaultKeyedRateLimiter, } impl OutgoingBeforeQueueHandler { pub fn new(config: Config) -> Result { - let reinject_addr = - crate::resolve_addr(&config.postfix_host, config.postfix_reinject_port)?; let quota = Quota::per_minute(config.max_user_send_per_minute) .allow_burst(config.max_user_send_burst_size); + let dns_resolver = Arc::new(build_resolver()?); Ok(Self { config, - reinject_addr, + dns_resolver, send_rate_limiter: RateLimiter::keyed(quota), }) } @@ -142,13 +142,20 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> { log::debug!("Re-injecting the mail that passed checks"); - - crate::smtp_client::send(self.reinject_addr, envelope) - .await - .map_err(|e| { - log::warn!("Failed to re-inject mail: {}", e); - e.smtp_response() - })?; + let hostname = format!("[{}]", self.config.filtermail_host); + crate::smtp_client::send( + &self.config.postfix_host, + self.config.postfix_reinject_port, + envelope, + &hostname, + None, + self.dns_resolver.clone(), + ) + .await + .map_err(|e| { + log::warn!("Failed to re-inject mail: {}", e); + e.smtp_response() + })?; Ok(()) } diff --git a/filtermail/src/smtp_client.rs b/filtermail/src/smtp_client.rs index 64e9ad95..8ffb89e7 100644 --- a/filtermail/src/smtp_client.rs +++ b/filtermail/src/smtp_client.rs @@ -1,22 +1,173 @@ use crate::smtp_server::Envelope; -use std::net::SocketAddr; +use hickory_resolver::TokioResolver; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufStream}; -use tokio::net::TcpSocket; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::TcpStream; +use tokio::task::JoinSet; +use tokio_io_timeout::TimeoutStream; +use tokio_rustls::rustls::client::ClientSessionMemoryCache; + +/// A [`TcpStream`] used for SMTP communication. +#[expect(clippy::large_enum_variant)] +enum SmtpStream { + /// A plain TCP stream. + Plain(Pin>>), + /// A TLS-encrypted stream. + Tls(tokio_rustls::TlsStream>>>), +} + +impl SmtpStream { + /// Creates a new plain SMTP stream from a raw TCP stream, + /// with read and write timeouts set to 60 seconds. + fn plain(stream: TcpStream) -> Self { + let mut timeout_stream = TimeoutStream::new(stream); + timeout_stream.set_write_timeout(Some(Duration::from_secs(60))); + timeout_stream.set_read_timeout(Some(Duration::from_secs(60))); + Self::Plain(Box::pin(timeout_stream)) + } +} + +#[derive(Debug, Clone)] +pub struct TlsConfig { + pub(crate) allow_invalid_cert: bool, + pub(crate) session_cache: Arc, +} + +impl AsyncWrite for SmtpStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.get_mut() { + SmtpStream::Plain(stream) => Pin::new(stream).poll_write(cx, buf), + SmtpStream::Tls(stream) => Pin::new(stream).poll_write(cx, buf), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + SmtpStream::Plain(stream) => Pin::new(stream).poll_flush(cx), + SmtpStream::Tls(stream) => Pin::new(stream).poll_flush(cx), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + SmtpStream::Plain(stream) => Pin::new(stream).poll_shutdown(cx), + SmtpStream::Tls(stream) => Pin::new(stream).poll_shutdown(cx), + } + } +} + +impl AsyncRead for SmtpStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + SmtpStream::Plain(stream) => Pin::new(stream).poll_read(cx, buf), + SmtpStream::Tls(stream) => Pin::new(stream).poll_read(cx, buf), + } + } +} + +/// Converts address and port to a list of socket addresses. +/// +/// Performs non-blocking DNS resolution if address is a domain name, +/// or returns a single socket address if address is an IP. +async fn to_socket_addrs( + address: &str, + port: u16, + dns_resolver: Arc, +) -> Result, crate::error::Error> { + log::trace!("Resolving {address}..."); + if let Ok(ip) = address.parse() { + Ok(vec![std::net::SocketAddr::new(ip, port)]) + } else { + let lookup = dns_resolver.lookup_ip(address).await?; + Ok(lookup + .iter() + .map(|ip| std::net::SocketAddr::new(ip, port)) + .collect()) + } +} + +/// Establishes a TCP connection to the given address and port, trying all resolved IPs in parallel. +async fn establish_tcp_connection( + address: &str, + port: u16, + dns_resolver: Arc, +) -> Result { + let mut set: JoinSet> = JoinSet::new(); + + let socket_addrs = to_socket_addrs(address, port, dns_resolver).await?; + + for addr in socket_addrs.clone() { + set.spawn(async move { + log::trace!("SMTP client: connecting to {addr}..."); + let stream = + tokio::time::timeout(Duration::from_secs(60), TcpStream::connect(addr)).await??; + stream.set_nodelay(true)?; + Ok(stream) + }); + } + let mut stream: Option = None; + while let Some(result) = set.join_next().await { + match result { + Ok(Ok(s)) => { + stream = Some(s); + break; + } + Ok(Err(e)) => log::debug!("Failed to connect to socket: {e}"), + Err(e) => log::debug!("Failed to join task: {e}"), + } + } + + match stream { + Some(s) => Ok(s), + None => Err(crate::error::Error::ConnectionFailed( + socket_addrs.into_iter().map(|a| a.to_string()).collect(), + )), + } +} /// Sends an email using an SMTP server at `smtp_addr`. -pub async fn send(smtp_addr: SocketAddr, envelope: &Envelope) -> Result<(), crate::error::Error> { - let socket = TcpSocket::new_v4()?; +/// +/// If `tls_config` is provided, the connection will be upgraded to TLS. +/// The client will fail early if the server does not support STARTTLS. +/// +/// If `address` is a domain that resolves to multiple IP addresses, +/// all will be tried in parallel and the first successful connection will be used. +pub async fn send( + address: &str, + port: u16, + envelope: &Envelope, + client_hostname: &str, + tls_config: Option, + dns_resolver: Arc, +) -> Result<(), crate::error::Error> { + let stream = establish_tcp_connection(address, port, dns_resolver).await?; - // Disable Nagle's algorithm. - socket.set_nodelay(true)?; + log::debug!( + "SMTP client: successfully connected to {}", + stream.peer_addr()? + ); - let stream = socket.connect(smtp_addr).await?; - - let mut buf_stream = BufStream::new(stream); + let mut buf_stream = BufStream::new(SmtpStream::plain(stream)); let mut response = String::new(); macro_rules! smtp_write { ($command: expr) => { + log::trace!( + "SMTP client: sending: {}", + String::from_utf8_lossy($command) + ); buf_stream.write_all($command).await?; buf_stream.flush().await?; }; @@ -24,14 +175,24 @@ pub async fn send(smtp_addr: SocketAddr, envelope: &Envelope) -> Result<(), crat macro_rules! smtp_read { ($context:expr, $expected_code:expr) => { - buf_stream.read_line(&mut response).await?; + response.clear(); + let mut next_line = String::new(); + buf_stream.read_line(&mut next_line).await?; + response.push_str(&next_line); + while let Some(c) = next_line.as_bytes().get(3) + && *c == b'-' + { + next_line.clear(); + buf_stream.read_line(&mut next_line).await?; + response.push_str(&next_line); + } + log::trace!("SMTP response for {}:\n{}", $context, response); if !response.starts_with($expected_code) { return Err(crate::error::Error::MailSend { context: $context.to_string(), raw_smtp_answer: response.clone(), }); } - response.clear(); }; } @@ -45,8 +206,59 @@ pub async fn send(smtp_addr: SocketAddr, envelope: &Envelope) -> Result<(), crat // Read initial greeting smtp_read!("initial greeting", "220"); - // Greet (Using HELO as we don't want to deal with extended SMTP anyway.) - smtp_cmd!(b"HELO localhost\r\n", "HELO", "250"); + if tls_config.is_some() { + smtp_cmd!( + format!("EHLO {client_hostname}\r\n").as_bytes(), + "EHLO", + "250" + ); + } else { + smtp_cmd!( + format!("HELO {client_hostname}\r\n").as_bytes(), + "HELO", + "250" + ); + }; + + // STARTTLS + if let Some(tls_config) = tls_config { + if !response.to_uppercase().contains("STARTTLS") { + // TLS was requested, but server doesn't support STARTTLS. + return Err(crate::error::Error::MailSend { + context: "STARTTLS".to_string(), + raw_smtp_answer: response.clone(), + }); + } + + log::trace!("Initiating STARTTLS..."); + smtp_cmd!(b"STARTTLS\r\n", "STARTTLS", "220"); + + let stream = buf_stream.into_inner(); + let raw_tcp = match stream { + SmtpStream::Plain(s) => s, + SmtpStream::Tls(_) => { + unreachable!("This is the first and only place we upgrade to TLS.") + } + }; + + let tls_stream = crate::tls::wrap_rustls( + address, + raw_tcp, + tls_config.session_cache, + tls_config.allow_invalid_cert, + ) + .await?; + + let smtp_stream = SmtpStream::Tls(tls_stream); + + buf_stream = BufStream::new(smtp_stream); + + smtp_cmd!( + format!("EHLO {client_hostname}\r\n").as_bytes(), + "EHLO after STARTTLS", + "250" + ); + } // MAIL FROM smtp_cmd!( diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index ef7357a2..63f899ab 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -50,7 +50,7 @@ pub async fn run_smtp_server( addr: &impl tokio::net::ToSocketAddrs, handler: Arc, max_size: usize, -) -> Result<(), Box> +) -> Result<(), crate::error::Error> where H: SmtpHandler + 'static, { @@ -112,7 +112,12 @@ where if cmd.to_uppercase().starts_with("HELO") { writer.write_all(b"250-filtermail\r\n250 OK\r\n").await?; writer.flush().await?; - } else if cmd.to_uppercase().starts_with("EHLO") { + } else if cmd.to_uppercase().starts_with("EHLO") + // We support LMTP, but it's not validated; + // service that expects LMTP will send LMTP responses no matter the greeting. + // Sufficient for our internal use case. + || cmd.to_uppercase().starts_with("LHLO") + { writer .write_all(b"250-filtermail\r\n250-XFORWARD ADDR\r\n250 OK\r\n") .await?; diff --git a/filtermail/src/tls.rs b/filtermail/src/tls.rs new file mode 100644 index 00000000..978454a3 --- /dev/null +++ b/filtermail/src/tls.rs @@ -0,0 +1,46 @@ +//! TLS support. +use std::sync::Arc; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio_rustls::rustls::client::ClientSessionMemoryCache; +use tokio_rustls::{TlsStream, rustls}; + +mod danger; +use danger::NoCertificateVerification; + +pub async fn wrap_rustls( + hostname: &str, + stream: IO, + resumption_store: Arc, + dangerous_no_cert_verification: bool, +) -> Result, crate::error::Error> +where + IO: AsyncRead + AsyncWrite + Unpin, +{ + let mut root_cert_store = rustls::RootCertStore::empty(); + root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + + let mut config = rustls::ClientConfig::builder() + .with_root_certificates(root_cert_store) + .with_no_client_auth(); + + // Enable TLS 1.3 session resumption + // as defined in . + // + // Obsolete TLS 1.2 mechanisms defined in RFC 5246 + // and RFC 5077 have worse security + // and are not worth increasing + // attack surface: . + config.resumption = rustls::client::Resumption::store(resumption_store) + .tls12_resumption(rustls::client::Tls12Resumption::Disabled); + + if dangerous_no_cert_verification { + config + .dangerous() + .set_certificate_verifier(Arc::new(NoCertificateVerification::default())); + } + + let tls = tokio_rustls::TlsConnector::from(Arc::new(config)); + let name = rustls::pki_types::ServerName::try_from(hostname)?.to_owned(); + let tls_stream = tls.connect(name, stream).await?; + Ok(tls_stream.into()) +} diff --git a/filtermail/src/tls/danger.rs b/filtermail/src/tls/danger.rs new file mode 100644 index 00000000..1254fcf4 --- /dev/null +++ b/filtermail/src/tls/danger.rs @@ -0,0 +1,49 @@ +//! Dangerous TLS implementation of accepting invalid certificates for Rustls. + +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use tokio_rustls::rustls; + +#[derive(Debug, Default)] +pub(super) struct NoCertificateVerification(); + +impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + let provider = rustls::crypto::ring::default_provider(); + let supported_schemes = &provider.signature_verification_algorithms; + rustls::crypto::verify_tls12_signature(message, cert, dss, supported_schemes) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + let provider = rustls::crypto::ring::default_provider(); + let supported_schemes = &provider.signature_verification_algorithms; + rustls::crypto::verify_tls13_signature(message, cert, dss, supported_schemes) + } + + fn supported_verify_schemes(&self) -> Vec { + let provider = rustls::crypto::ring::default_provider(); + provider + .signature_verification_algorithms + .supported_schemes() + } +} diff --git a/filtermail/src/transport.rs b/filtermail/src/transport.rs new file mode 100644 index 00000000..b13dde8e --- /dev/null +++ b/filtermail/src/transport.rs @@ -0,0 +1,227 @@ +use crate::config::Config; +use crate::smtp_client::TlsConfig; +use crate::smtp_server::{Envelope, SmtpHandler}; +use crate::utils::{AddressDomain, build_resolver}; +use async_trait::async_trait; +use hickory_resolver::TokioResolver; +use std::collections::BTreeMap; +use std::str::FromStr; +use std::sync::Arc; +use tokio::task::JoinSet; +use tokio_rustls::rustls; + +pub struct TransportHandler { + config: Config, + dns_resolver: Arc, + tls_resumption_store: Arc, +} + +impl TransportHandler { + pub fn new(config: Config) -> Result { + let dns_resolver = Arc::new(build_resolver()?); + let tls_resumption_store = Arc::new(rustls::client::ClientSessionMemoryCache::new(256)); + Ok(Self { + config, + dns_resolver, + tls_resumption_store, + }) + } + + /// Handles a single email transaction for a single recipient domain. + async fn handle_single_domain( + tls_resumption_store: Arc, + dns_resolver: Arc, + domain: AddressDomain, + envelope: Envelope, + client_hostname: String, + ) -> Result { + let mut allow_invalid_cert = false; + let mut skip_tls = false; + + let mx_hosts = match domain { + // no-DNS setup; assume the ip from email address is the destination. + AddressDomain::Literal(ip) => { + // We allow self-signed certs on IP-based relays. + allow_invalid_cert = true; + vec![(0, ip)] + } + AddressDomain::Name(mx_domain) => { + if mx_domain.eq_ignore_ascii_case("nauta.cu") { + // Special case; We don't want to defederate nauta.cu, + // which doesn't support STARTTLS at all. + skip_tls = true; + } else if mx_domain.starts_with('_') { + // We use domains starting with `_` for test deployments. + // (You can't request a non-wildcard cert for such domain) + allow_invalid_cert = true; + } + let query = format!("{mx_domain}."); + let mx_records = dns_resolver.mx_lookup(query).await.map_err(|e| { + if e.is_no_records_found() { + format!("512 No MX records for {mx_domain}") + } else if e.is_nx_domain() { + format!("512 Domain {mx_domain} does not exist") + } else { + format!("421 DNS resolution failed for {mx_domain}") + } + })?; + + let mut hosts: Vec<(u16, String)> = mx_records + .iter() + .map(|mx| { + let host = mx.exchange().to_string().trim_end_matches('.').to_string(); + (mx.preference(), host) + }) + .collect(); + hosts.sort(); + hosts + } + }; + + let tls_config = match skip_tls { + true => None, + false => Some(TlsConfig { + allow_invalid_cert, + session_cache: tls_resumption_store, + }), + }; + + // we try sequentially in order of MX preference, + // but the IPv4 and IPv6 connections (after `smtp_client::send` resolves mx hostname) + // happens in parallel. + 'try_relay: for (_, mx_host) in mx_hosts { + match crate::smtp_client::send( + &mx_host, + 25, + &envelope, + &client_hostname, + tls_config.clone(), + dns_resolver.clone(), + ) + .await + { + Ok(_) => { + return Ok("250 Ok".to_string()); + } + Err(error) => { + match error { + // We only want to try other MX hosts if we encounter a problem + // related to connection. + // (So we don't spam other servers if the message is actually rejected.) + crate::error::Error::Io(io_err) => { + log::warn!("I/O error relaying to mail server {mx_host}: {io_err}"); + continue 'try_relay; + } + crate::error::Error::ConnectionFailed(_) => { + log::warn!("Failed to connect to mail server {mx_host}: {error}"); + continue 'try_relay; + } + crate::error::Error::Tls(tls_err) => { + log::warn!("TLS error relaying to mail server {mx_host}: {tls_err}"); + continue 'try_relay; + } + _ => { + log::warn!("Message rejected by mail server {mx_host}: {error}"); + return Err(error.smtp_response()); + } + } + } + } + } + + Err("421 Failed to connect to any mail server".to_string()) + } +} + +#[async_trait] +impl SmtpHandler for TransportHandler { + /// NO-OP + fn handle_mail(&self, _: &str) -> Result<(), String> { + Ok(()) + } + + /// NO-OP + async fn check_data(&self, _: &mut Envelope) -> Result<(), String> { + Ok(()) + } + + /// NO-OP + async fn reinject_mail(&self, _: &Envelope) -> Result<(), String> { + Ok(()) + } + + /// Handles the DATA command and returns LMTP responses as single string. + /// + /// Never returns an error, as LMTP response is composite. + async fn handle_data(&self, envelope: &mut Envelope) -> Result { + let mut domain_rcpts_map = BTreeMap::new(); + + for rcpt in &envelope.rcpt_to { + let domain = AddressDomain::from_str(rcpt) + // Currently we cancel all transactions if any recipient address is invalid. + .map_err(|e| e.lmtp_response(envelope.rcpt_to.len()))?; + domain_rcpts_map + .entry(domain) + .or_insert_with(Vec::new) + .push(rcpt.to_string()); + } + + // one transaction per domain + let mut transactions = JoinSet::new(); + let mut task_id_domain_map = BTreeMap::new(); + + for (rcpt_domain, rcpts) in &domain_rcpts_map { + let domain_envelope = { + let mut envelope = envelope.clone(); + envelope.rcpt_to = rcpts.clone(); + envelope + }; + let task_id = transactions + .spawn(Self::handle_single_domain( + self.tls_resumption_store.clone(), + self.dns_resolver.clone(), + rcpt_domain.clone(), + domain_envelope, + self.config.mail_domain.clone(), + )) + .id(); + task_id_domain_map.insert(task_id, rcpt_domain); + } + + let mut rcpt_response_map = BTreeMap::new(); + while let Some(result) = transactions.join_next_with_id().await { + let domain_response = match result { + Ok((id, Ok(resp))) | Ok((id, Err(resp))) => { + task_id_domain_map.remove(&id).map(|domain| (domain, resp)) + } + Err(e) => { + log::error!("Failed to join task: {e}"); + task_id_domain_map + .remove(&e.id()) + .map(|domain| (domain, "451 Local error".to_string())) + } + }; + + if let Some((domain, smtp_response)) = domain_response + && let Some(rcpts) = domain_rcpts_map.get(domain) + { + for rcpt in rcpts { + rcpt_response_map.insert(rcpt, smtp_response.clone()); + } + } + } + + // compose lmtp response... + let ordered_responses: Vec = envelope + .rcpt_to + .iter() + .map(|rcpt| { + rcpt_response_map + .remove(rcpt) + .unwrap_or_else(|| "451 Local error".to_string()) + }) + .collect(); + + Ok(ordered_responses.join("\r\n")) + } +} diff --git a/filtermail/src/utils.rs b/filtermail/src/utils.rs index d9a0adfa..bf32acb7 100644 --- a/filtermail/src/utils.rs +++ b/filtermail/src/utils.rs @@ -1,3 +1,5 @@ +use hickory_resolver::TokioResolver; +use hickory_resolver::name_server::TokioConnectionProvider; use mailparse::MailAddr; use std::path::PathBuf; use std::str::FromStr; @@ -30,7 +32,7 @@ pub fn extract_address(input: &str) -> Option { /// Domain part of an email address, either a domain-literal (IP address in square brackets with /// optional protocol prefix) or a regular domain name. -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord)] pub enum AddressDomain { /// Domain literal, e.g. /// - `192.0.2.0` in `test@[192.0.2.0]`, @@ -76,6 +78,15 @@ impl FromStr for AddressDomain { } } +impl AsRef for AddressDomain { + fn as_ref(&self) -> &str { + match self { + AddressDomain::Literal(literal) => literal.as_ref(), + AddressDomain::Name(name) => name.as_ref(), + } + } +} + /// Logs email to `/tmp/filtermail-rejected//.eml` /// and returns the file path. /// @@ -93,6 +104,23 @@ pub async fn log_eml(reason: &str, data: &[u8]) -> Result Result { + let dns_resolver = { + let mut builder = TokioResolver::builder(TokioConnectionProvider::default())?; + // https://github.com/hickory-dns/hickory-dns/issues/3519 + builder.options_mut().validate = true; + builder.build() + }; + + assert!( + dns_resolver.options().validate, + "incorrect resolver config: DNSSEC disabled; exiting" + ); + + Ok(dns_resolver) +} + #[cfg(test)] mod tests { use super::*;