mirror of
https://github.com/chatmail/relay.git
synced 2026-08-10 18:40:51 +00:00
feat: https transport channel (#122)
Transport mode: Implements an additional mail delivery channel over HTTPS. Incoming mode: Adds a http server listening for incoming messages delivered over HTTPS. Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
This commit is contained in:
committed by
GitHub
parent
1b1585eb7d
commit
0a652e61ee
Generated
+178
@@ -107,6 +107,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
@@ -463,6 +469,10 @@ dependencies = [
|
||||
"env_logger",
|
||||
"governor",
|
||||
"hickory-resolver",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"lru",
|
||||
"mailparse",
|
||||
@@ -486,6 +496,12 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -643,6 +659,25 @@ dependencies = [
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
@@ -721,6 +756,109 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.3",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.1.1"
|
||||
@@ -867,6 +1005,12 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.23"
|
||||
@@ -1821,6 +1965,19 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
@@ -1851,6 +2008,12 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
@@ -1882,6 +2045,12 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -1973,6 +2142,15 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||
dependencies = [
|
||||
"try-lock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
|
||||
@@ -31,6 +31,21 @@ tokio-rustls = { version = "0.26.4", default-features = false, features = [
|
||||
webpki-roots = "1.0.6"
|
||||
tokio-io-timeout = "1.2.1"
|
||||
retainer = "0.4.0"
|
||||
hyper = { version = "1.8.1", features = ["server"] }
|
||||
hyper-util = { version = "0.1.20", features = [
|
||||
"tokio",
|
||||
"server",
|
||||
"server-auto",
|
||||
"client-legacy",
|
||||
] }
|
||||
http-body-util = "0.1.3"
|
||||
hyper-rustls = { version = "0.27.9", default-features = false, features = [
|
||||
"ring",
|
||||
"http1",
|
||||
"http2",
|
||||
"logging",
|
||||
"tls12",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.26.1"
|
||||
|
||||
+73
-3
@@ -19,6 +19,29 @@ Filtermail can be used in `incoming`, `outgoing` or `transport` mode.
|
||||
|
||||
### Incoming mode
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
|
||||
subgraph chatmail relay
|
||||
subgraph postfix
|
||||
smtpd1[smtpd]
|
||||
smtpd2[smtpd] --> queue[...]
|
||||
end
|
||||
nginx[nginx]
|
||||
smtpd1 -.SMTP :10081.-> filtermail[filtermail-incoming]
|
||||
nginx -.HTTP :10082.-> filtermail
|
||||
filtermail --SMTP :10026--> smtpd2
|
||||
end
|
||||
mta[Sender's relay] -.SMTP :25.-> smtpd1
|
||||
mta -.HTTPS /mxdeliv.-> nginx
|
||||
|
||||
style postfix fill:#363
|
||||
style smtpd1 fill:#252
|
||||
style smtpd2 fill:#252
|
||||
style queue fill:#252
|
||||
style filtermail fill:#225
|
||||
```
|
||||
|
||||
Filtermail in incoming mode acts as a proxy filter
|
||||
for messages received from remote MTAs and performs following steps:
|
||||
|
||||
@@ -41,8 +64,32 @@ for messages received from remote MTAs and performs following steps:
|
||||
5. In case of a DKIM failure,
|
||||
the message is saved to `/tmp/filtermail-rejected/dkim-verify` directory for later inspection.
|
||||
|
||||
In contrast to outgoing mode, incoming mode starts with not only SMTP but also HTTP listener.
|
||||
Built-in HTTP server doesn't handle TLS, and should be placed behind a TLS-terminating reverse proxy.
|
||||
|
||||
### Outgoing mode
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
|
||||
subgraph chatmail relay
|
||||
subgraph postfix
|
||||
smtpd1[smtpd]
|
||||
smtpd2[smtpd] --> queue[...]
|
||||
end
|
||||
smtpd1 --SMTP :10080--> filtermail[filtermail-outgoing]
|
||||
filtermail --SMTP :10025--> smtpd2
|
||||
open-dkim[OpenDKIM] <--milter--> smtpd2
|
||||
end
|
||||
client[Client] --SMTP :587--> smtpd1
|
||||
|
||||
style postfix fill:#363
|
||||
style smtpd1 fill:#252
|
||||
style smtpd2 fill:#252
|
||||
style queue fill:#252
|
||||
style filtermail fill:#225
|
||||
```
|
||||
|
||||
Filtermail in outgoing mode acts as a proxy filter
|
||||
for messages received from clients and performs following steps:
|
||||
|
||||
@@ -58,12 +105,33 @@ for messages received from clients and performs following steps:
|
||||
|
||||
### Transport mode
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
|
||||
subgraph chatmail relay
|
||||
subgraph postfix
|
||||
qmgr[...] --> lmtp[default-transport lmtp]
|
||||
end
|
||||
|
||||
lmtp --LMTP :10083--> filtermail[filtermail-transport]
|
||||
end
|
||||
filtermail -.SMTP :25.-> mta[Recipient's relay]
|
||||
filtermail -.HTTPS /mxdeliv.-> mta
|
||||
|
||||
style postfix fill:#363
|
||||
style qmgr fill:#252
|
||||
style lmtp fill:#252
|
||||
style filtermail fill:#225
|
||||
```
|
||||
|
||||
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,
|
||||
Received message is split per-domain and sent to recipients' MX servers over HTTP and SMTP,
|
||||
enforcing TLS.
|
||||
As opposed to postfix, IPv4 and IPv6 connections are tried in parallel and first successful connection is used.
|
||||
HTTP delivery channel is preferred,
|
||||
and SMTP is used only if HTTP delivery fails.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -74,8 +142,10 @@ but implements a custom parser that only requires a small subset of configuratio
|
||||
|
||||
- `filtermail_smtp_port` - port to listen on in outgoing mode,
|
||||
defaults to `10080`.
|
||||
- `filtermail_smtp_port_incoming` - port to listen on in incoming mode,
|
||||
- `filtermail_smtp_port_incoming` - SMTP port to listen on in incoming mode,
|
||||
defaults to `10081`.
|
||||
- `filtermail_http_port_incoming` - HTTP port to listen on in incoming mode,
|
||||
defaults to `10082`.
|
||||
- `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,
|
||||
@@ -124,7 +194,7 @@ Additional options that can be set using environment variables:
|
||||
Although unsupported, it may still work outside of this context or even without postfix,
|
||||
with few considerations:
|
||||
|
||||
- Filtermail expects to receive messages from a trusted server,
|
||||
- Filtermail expects to receive messages from trusted clients,
|
||||
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.
|
||||
|
||||
@@ -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_http_port_incoming")]
|
||||
pub filtermail_http_port_incoming: u16,
|
||||
#[serde(default = "Config::default_filtermail_lmtp_port_transport")]
|
||||
pub filtermail_lmtp_port_transport: u16,
|
||||
#[serde(default = "Config::default_postfix_host")]
|
||||
@@ -110,6 +112,9 @@ impl Config {
|
||||
const fn default_filtermail_smtp_port_incoming() -> u16 {
|
||||
10081
|
||||
}
|
||||
const fn default_filtermail_http_port_incoming() -> u16 {
|
||||
10082
|
||||
}
|
||||
const fn default_filtermail_lmtp_port_transport() -> u16 {
|
||||
10083
|
||||
}
|
||||
@@ -143,6 +148,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_http_port_incoming: Self::default_filtermail_http_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(),
|
||||
|
||||
@@ -27,6 +27,12 @@ pub enum Error {
|
||||
Tls(#[from] rustls::Error),
|
||||
#[error(transparent)]
|
||||
InvalidDnsName(#[from] rustls::pki_types::InvalidDnsNameError),
|
||||
#[error(transparent)]
|
||||
Hyper(#[from] hyper::Error),
|
||||
#[error(transparent)]
|
||||
HyperHttp(#[from] hyper::http::Error),
|
||||
#[error(transparent)]
|
||||
HyperClient(#[from] hyper_util::client::legacy::Error),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
use crate::smtp_server::{Envelope, SmtpHandler};
|
||||
use http_body_util::combinators::BoxBody;
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper::body::{Bytes, Incoming};
|
||||
use hyper::service::Service;
|
||||
use hyper::{Request, Response};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use std::convert::Infallible;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
/// Runs the HTTP server on the specified address with the given handler and maximum message size.
|
||||
pub async fn run_http_server<H>(
|
||||
addr: &impl tokio::net::ToSocketAddrs,
|
||||
handler: Arc<H>,
|
||||
max_size: usize,
|
||||
) -> Result<(), crate::error::Error>
|
||||
where
|
||||
H: SmtpHandler + 'static,
|
||||
{
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
loop {
|
||||
let (socket, _) = listener.accept().await?;
|
||||
|
||||
// Disable Nagle's algorithm.
|
||||
socket.set_nodelay(true)?;
|
||||
|
||||
let handler = handler.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(socket, handler, max_size).await {
|
||||
log::error!("Error handling connection: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a single HTTP connection.
|
||||
async fn handle_connection<H>(
|
||||
socket: TcpStream,
|
||||
handler: Arc<H>,
|
||||
max_size: usize,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
H: SmtpHandler + 'static,
|
||||
{
|
||||
let service = MxDelivService::new(handler, max_size);
|
||||
|
||||
hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new())
|
||||
.serve_connection(TokioIo::new(socket), service)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct MxDelivService<H: SmtpHandler> {
|
||||
handler: Arc<H>,
|
||||
max_size: usize,
|
||||
}
|
||||
|
||||
impl<H: SmtpHandler> MxDelivService<H> {
|
||||
/// Creates a new [`MxDelivService`].
|
||||
fn new(handler: Arc<H>, max_size: usize) -> Self {
|
||||
Self { handler, max_size }
|
||||
}
|
||||
}
|
||||
|
||||
impl<H: SmtpHandler + 'static> Service<Request<Incoming>> for MxDelivService<H> {
|
||||
type Response = Response<BoxBody<Bytes, Infallible>>;
|
||||
type Error = crate::error::Error;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
|
||||
|
||||
fn call(&self, req: Request<Incoming>) -> Self::Future {
|
||||
let handler = self.handler.clone();
|
||||
let max_size = self.max_size;
|
||||
|
||||
let fut = async move {
|
||||
if req.method() != hyper::Method::POST {
|
||||
return Ok(Response::builder().status(405).body(
|
||||
// This is client's implementation error if it happens,
|
||||
// so we don't care about sending a proper smtp response.
|
||||
Full::new(Bytes::from("Method Not Allowed")).boxed(),
|
||||
)?);
|
||||
}
|
||||
|
||||
let mail_from = req
|
||||
.headers()
|
||||
.get(crate::transport::HEADER_MAIL_FROM)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
match handler.handle_mail(&mail_from) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
return Ok(Response::builder()
|
||||
.status(400)
|
||||
.body(Full::new(Bytes::from(e)).boxed())?);
|
||||
}
|
||||
};
|
||||
|
||||
let rcpt_to = req
|
||||
.headers()
|
||||
.get_all(crate::transport::HEADER_RCPT_TO)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
|
||||
let body_limited = http_body_util::Limited::new(req.into_body(), max_size);
|
||||
let body_bytes = match body_limited.collect().await {
|
||||
Ok(body) => body.to_bytes(),
|
||||
Err(_) => {
|
||||
return Ok(Response::builder().status(413).body(
|
||||
Full::new(Bytes::from("552 Message exceeds maximum size")).boxed(),
|
||||
)?);
|
||||
}
|
||||
};
|
||||
|
||||
let mut envelope = Envelope {
|
||||
origin_ip: "".to_string(),
|
||||
mail_from,
|
||||
rcpt_to,
|
||||
data: body_bytes.to_vec(),
|
||||
};
|
||||
|
||||
log::debug!("(HTTP) MAIL FROM:<{}>", envelope.mail_from);
|
||||
for rcpt in &envelope.rcpt_to {
|
||||
log::debug!("(HTTP) RCPT TO:<{}>", rcpt);
|
||||
}
|
||||
|
||||
log::trace!(
|
||||
"(HTTP) DATA:\n{:?}",
|
||||
String::from_utf8_lossy(&envelope.data)
|
||||
);
|
||||
|
||||
match handler.handle_data(&mut envelope).await {
|
||||
Ok(response) => Ok(Response::builder()
|
||||
.status(201)
|
||||
.body(Full::new(Bytes::from(response)).boxed())?),
|
||||
Err(e) => Ok(Response::builder()
|
||||
.status(400)
|
||||
.body(Full::new(Bytes::from(e)).boxed())?),
|
||||
}
|
||||
};
|
||||
|
||||
Box::pin(fut)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
mod config;
|
||||
mod dkim_verifier;
|
||||
pub(crate) mod error;
|
||||
mod http_server;
|
||||
pub(crate) mod inbound;
|
||||
pub(crate) mod message;
|
||||
pub(crate) mod openpgp;
|
||||
@@ -37,6 +38,7 @@ mod tls;
|
||||
mod transport;
|
||||
pub(crate) mod utils;
|
||||
|
||||
use crate::http_server::run_http_server;
|
||||
use crate::transport::TransportHandler;
|
||||
use config::Config;
|
||||
use env_logger::Env;
|
||||
@@ -153,6 +155,17 @@ async fn main() -> Result<(), error::Error> {
|
||||
addr_smtp.1
|
||||
);
|
||||
|
||||
let addr_http = (config.filtermail_host, config.filtermail_http_port_incoming);
|
||||
let handler_http = handler.clone();
|
||||
|
||||
server_set
|
||||
.spawn(async move { run_http_server(&addr_http, handler_http, max_size).await });
|
||||
log::debug!(
|
||||
"Incoming HTTP server listening on {}:{}",
|
||||
addr_http.0,
|
||||
addr_http.1
|
||||
);
|
||||
|
||||
while let Some(result) = server_set.join_next().await {
|
||||
if let Err(e) = result {
|
||||
eprintln!("Server error: {}", e);
|
||||
|
||||
+14
-6
@@ -16,8 +16,19 @@ pub async fn wrap_rustls<IO>(
|
||||
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 config = configure_rustls(resumption_store, dangerous_no_cert_verification);
|
||||
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())
|
||||
}
|
||||
|
||||
pub fn configure_rustls(
|
||||
resumption_store: Arc<ClientSessionMemoryCache>,
|
||||
dangerous_no_cert_verification: bool,
|
||||
) -> rustls::ClientConfig {
|
||||
let root_cert_store =
|
||||
rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
|
||||
let mut config = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_cert_store)
|
||||
@@ -39,8 +50,5 @@ where
|
||||
.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())
|
||||
config
|
||||
}
|
||||
|
||||
+172
-3
@@ -1,45 +1,123 @@
|
||||
use crate::config::Config;
|
||||
use crate::smtp_client::{SmtpConnectionPool, TlsConfig};
|
||||
use crate::smtp_server::{Envelope, SmtpHandler};
|
||||
use crate::tls;
|
||||
use crate::utils::{AddressDomain, build_resolver};
|
||||
use async_trait::async_trait;
|
||||
use hickory_resolver::TokioResolver;
|
||||
use http_body_util::BodyExt;
|
||||
use hyper::body::Bytes;
|
||||
use hyper_rustls::HttpsConnector;
|
||||
use hyper_util::client::legacy::connect::HttpConnector;
|
||||
use std::collections::BTreeMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tokio::task::JoinSet;
|
||||
use std::time::Duration;
|
||||
use tokio::task::{JoinHandle, JoinSet};
|
||||
use tokio_rustls::rustls;
|
||||
|
||||
pub const HEADER_MAIL_FROM: &str = "X-MAIL-FROM";
|
||||
pub const HEADER_RCPT_TO: &str = "X-MAIL-TO";
|
||||
|
||||
/// Cheaply clonable HTTPS client.
|
||||
///
|
||||
/// Holds regular secure variant and relaxed - without certificate verification.
|
||||
///
|
||||
/// Connection pool handled internally by [`hyper_util::client::legacy::Client`].
|
||||
#[derive(Clone)]
|
||||
struct HttpsClient {
|
||||
pub secure: hyper_util::client::legacy::Client<
|
||||
HttpsConnector<HttpConnector>,
|
||||
http_body_util::Full<Bytes>,
|
||||
>,
|
||||
pub relaxed: hyper_util::client::legacy::Client<
|
||||
HttpsConnector<HttpConnector>,
|
||||
http_body_util::Full<Bytes>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl HttpsClient {
|
||||
/// Creates a new `[HttpsClient]`.
|
||||
pub fn new(tls_resumption_store: Arc<rustls::client::ClientSessionMemoryCache>) -> Self {
|
||||
let tls_client_config = tls::configure_rustls(tls_resumption_store.clone(), false);
|
||||
let https_connector = hyper_rustls::HttpsConnectorBuilder::new()
|
||||
.with_tls_config(tls_client_config)
|
||||
.https_only()
|
||||
.enable_http1()
|
||||
.enable_http2()
|
||||
.build();
|
||||
let https_client =
|
||||
hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
|
||||
.build(https_connector);
|
||||
|
||||
let tls_client_config_relaxed = tls::configure_rustls(tls_resumption_store, true);
|
||||
let https_connector_relaxed = hyper_rustls::HttpsConnectorBuilder::new()
|
||||
.with_tls_config(tls_client_config_relaxed)
|
||||
.https_only()
|
||||
.enable_http1()
|
||||
.enable_http2()
|
||||
.build();
|
||||
let https_client_relaxed =
|
||||
hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
|
||||
.build(https_connector_relaxed);
|
||||
|
||||
Self {
|
||||
secure: https_client,
|
||||
relaxed: https_client_relaxed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TransportHandler {
|
||||
config: Config,
|
||||
dns_resolver: Arc<TokioResolver>,
|
||||
tls_resumption_store: Arc<rustls::client::ClientSessionMemoryCache>,
|
||||
smtp_connection_pool: Arc<SmtpConnectionPool>,
|
||||
https_client: HttpsClient,
|
||||
mxdeliv_unsupported_hosts: Arc<retainer::Cache<String, bool>>,
|
||||
monitor_handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TransportHandler {
|
||||
pub fn new(config: Config) -> Result<Self, crate::error::Error> {
|
||||
let dns_resolver = Arc::new(build_resolver()?);
|
||||
let tls_resumption_store = Arc::new(rustls::client::ClientSessionMemoryCache::new(256));
|
||||
let https_client = HttpsClient::new(tls_resumption_store.clone());
|
||||
|
||||
let mxdeliv_cache = Arc::new(retainer::Cache::new());
|
||||
let mxdeliv_cache_clone = mxdeliv_cache.clone();
|
||||
|
||||
let monitor_handle = tokio::spawn(async move {
|
||||
mxdeliv_cache_clone
|
||||
.monitor(4, 0.25, Duration::from_secs(10))
|
||||
.await
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
dns_resolver,
|
||||
tls_resumption_store,
|
||||
smtp_connection_pool: SmtpConnectionPool::new(),
|
||||
https_client,
|
||||
mxdeliv_unsupported_hosts: mxdeliv_cache,
|
||||
monitor_handle,
|
||||
})
|
||||
}
|
||||
|
||||
/// Handles a single email transaction for a single recipient domain.
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
async fn handle_single_domain(
|
||||
tls_resumption_store: Arc<rustls::client::ClientSessionMemoryCache>,
|
||||
smtp_connection_pool: Arc<SmtpConnectionPool>,
|
||||
mxdeliv_unsupported_hosts: Arc<retainer::Cache<String, bool>>,
|
||||
https_client: HttpsClient,
|
||||
dns_resolver: Arc<TokioResolver>,
|
||||
domain: AddressDomain,
|
||||
envelope: Envelope,
|
||||
client_hostname: String,
|
||||
) -> Result<String, String> {
|
||||
let mut allow_invalid_cert = false;
|
||||
let mut skip_tls = false;
|
||||
let mut skip_tls = false; // only respected by smtp channel
|
||||
|
||||
let mx_hosts = match domain {
|
||||
// no-DNS setup; assume the ip from email address is the destination.
|
||||
@@ -110,6 +188,34 @@ impl TransportHandler {
|
||||
// but the IPv4 and IPv6 connections (after `smtp_client::send` resolves mx hostname)
|
||||
// happens in parallel.
|
||||
'try_relay: for (_, mx_host) in mx_hosts {
|
||||
let skip_mxdeliv = mxdeliv_unsupported_hosts
|
||||
.get(&mx_host)
|
||||
.await
|
||||
.map(|guard| *guard.value())
|
||||
.unwrap_or(false);
|
||||
|
||||
// HTTPS channel
|
||||
if skip_mxdeliv {
|
||||
log::debug!("Skipping HTTP delivery to host that failed recently: {mx_host}");
|
||||
} else {
|
||||
match Self::https_delivery(
|
||||
https_client.clone(),
|
||||
mx_host.clone(),
|
||||
&envelope,
|
||||
allow_invalid_cert,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
return Ok("250 Ok (HTTPS)".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("HTTPS delivery to {mx_host} failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SMTP channel (fallback)
|
||||
match crate::smtp_client::send(
|
||||
&mx_host,
|
||||
25,
|
||||
@@ -122,7 +228,14 @@ impl TransportHandler {
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
return Ok("250 Ok".to_string());
|
||||
// Switches this host to SMTP for 30 minutes.
|
||||
// Note: this MUST happen only after a successful SMTP delivery,
|
||||
// or otherwise any http error will lock us out of any way to
|
||||
// deliver to a relay with a blocked port 25 for 30 minutes.
|
||||
mxdeliv_unsupported_hosts
|
||||
.insert(mx_host.clone(), true, Duration::from_mins(30))
|
||||
.await;
|
||||
return Ok("250 Ok (SMTP)".to_string());
|
||||
}
|
||||
Err(error) => {
|
||||
match error {
|
||||
@@ -152,6 +265,60 @@ impl TransportHandler {
|
||||
|
||||
Err("421 Failed to connect to any mail server".to_string())
|
||||
}
|
||||
|
||||
/// Performs mail delivery to `mx_host` over HTTPS.
|
||||
///
|
||||
/// Times out after 60s.
|
||||
async fn https_delivery(
|
||||
https_client: HttpsClient,
|
||||
mx_host: String,
|
||||
envelope: &Envelope,
|
||||
allow_invalid_cert: bool,
|
||||
) -> Result<(), crate::error::Error> {
|
||||
let request: hyper::Request<http_body_util::Full<Bytes>> = {
|
||||
let mut builder = hyper::Request::builder()
|
||||
.method(hyper::Method::POST)
|
||||
.uri(format!("https://{mx_host}/mxdeliv"));
|
||||
|
||||
if !envelope.mail_from.is_empty() {
|
||||
builder = builder.header(HEADER_MAIL_FROM, &envelope.mail_from);
|
||||
}
|
||||
|
||||
for rcpt_to in &envelope.rcpt_to {
|
||||
builder = builder.header(HEADER_RCPT_TO, rcpt_to);
|
||||
}
|
||||
|
||||
builder.body(http_body_util::Full::from(envelope.data.clone()))?
|
||||
};
|
||||
|
||||
let client = if allow_invalid_cert {
|
||||
https_client.relaxed
|
||||
} else {
|
||||
https_client.secure
|
||||
};
|
||||
|
||||
let response = tokio::time::timeout(Duration::from_secs(60), client.request(request))
|
||||
.await
|
||||
.map_err(|_| crate::error::Error::MailSend {
|
||||
context: "HTTPS delivery".to_string(),
|
||||
raw_smtp_answer: "[timeout]".to_string(),
|
||||
})??;
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let response_body = response.collect().await?.to_bytes();
|
||||
Err(crate::error::Error::MailSend {
|
||||
context: "HTTPS delivery".to_string(),
|
||||
raw_smtp_answer: String::from_utf8_lossy(&response_body).into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TransportHandler {
|
||||
fn drop(&mut self) {
|
||||
self.monitor_handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -201,6 +368,8 @@ impl SmtpHandler for TransportHandler {
|
||||
.spawn(Self::handle_single_domain(
|
||||
self.tls_resumption_store.clone(),
|
||||
self.smtp_connection_pool.clone(),
|
||||
self.mxdeliv_unsupported_hosts.clone(),
|
||||
self.https_client.clone(),
|
||||
self.dns_resolver.clone(),
|
||||
rcpt_domain.clone(),
|
||||
domain_envelope,
|
||||
|
||||
Reference in New Issue
Block a user