Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions config.example.huml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ server::
addr: "0.0.0.0:25"
- ::
addr: "0.0.0.0:587"
tls::
cert_path: "/etc/ssl/certs/smtp.crt"
key_path: "/etc/ssl/private/smtp.key"
mode: "starttls"
- ::
addr: "0.0.0.0:465"
tls::
cert_path: "/etc/ssl/certs/smtp.crt"
key_path: "/etc/ssl/private/smtp.key"
mode: "implicit"
- ::
addr: "127.0.0.1:2525"
max_retries: 5
Expand Down
10 changes: 8 additions & 2 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,22 @@ bind = "0.0.0.0:8080"
[[server.listeners]]
addr = "0.0.0.0:25"

# SMTP submission port (plaintext)
# SMTP submission port (STARTTLS: starts plaintext, upgrades when the
# client issues STARTTLS)
[[server.listeners]]
addr = "0.0.0.0:587"
[server.listeners.tls]
cert_path = "/etc/ssl/certs/smtp.crt"
key_path = "/etc/ssl/private/smtp.key"
mode = "starttls"

# SMTPS port (TLS)
# SMTPS port (implicit TLS: the connection is TLS from the first byte)
[[server.listeners]]
addr = "0.0.0.0:465"
[server.listeners.tls]
cert_path = "/etc/ssl/certs/smtp.crt"
key_path = "/etc/ssl/private/smtp.key"
mode = "implicit" # default when omitted

# Local testing port (plaintext)
[[server.listeners]]
Expand Down
6 changes: 4 additions & 2 deletions docs/PRODUCTION_HARDENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,11 @@ Production deployments should set `server.helo_hostname` to the public FQDN for

---

### 8. STARTTLS is broken on plaintext listeners
### 8. ~~STARTTLS is broken on plaintext listeners~~ → Addressed

**Problem:** Two issues:
**Status:** Addressed. Listeners now take `mode = "starttls"` in their TLS config to accept plaintext connections and upgrade via STARTTLS (RFC 3207). The upgrade happens in place through `smtp::MaybeTlsStream` (no raw-FD tricks), any bytes pipelined after the STARTTLS command are discarded to prevent plaintext injection, and the SMTP session is fully reset after the handshake.

**Problem (original):** Two issues:
1. `main.rs` never calls `SmtpServer::with_tls()`, so the SMTP session never has a `tls_acceptor` and STARTTLS is never advertised on plaintext listeners.
2. `upgrade_to_tls()` in `smtp/src/lib.rs` uses `unsafe { TcpStream::from_raw_fd() }` which creates two owners of the same FD — a use-after-free risk.

Expand Down
17 changes: 15 additions & 2 deletions docs/src/pages/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,29 @@ For new or low-volume senders, keep `min_idle` and `max_size` small so strict re

```toml
[[server.listeners]]
addr = "0.0.0.0:25" # Bind address and port
addr = "0.0.0.0:465" # Bind address and port
# Optional TLS configuration
[server.listeners.tls]
cert_path = "/path/to/cert.pem"
key_path = "/path/to/key.pem"
mode = "implicit" # "implicit" (default) or "starttls"

[[server.listeners]]
addr = "127.0.0.1:2525" # Second listener without TLS
addr = "0.0.0.0:587" # STARTTLS listener: accepts plaintext and
[server.listeners.tls] # upgrades to TLS when the client asks
cert_path = "/path/to/cert.pem"
key_path = "/path/to/key.pem"
mode = "starttls"

[[server.listeners]]
addr = "127.0.0.1:2525" # Listener without TLS
```

Each listener can negotiate TLS in one of two ways:

- `mode = "implicit"` (the default): the TLS handshake happens as soon as the connection opens, before any SMTP traffic. Use this for SMTPS (port 465).
- `mode = "starttls"`: the connection starts in plaintext and the server advertises `STARTTLS` in its EHLO response; the session is upgraded to TLS when the client issues the `STARTTLS` command (RFC 3207). Use this for submission (port 587).

## Authentication (`[[server.auth]]`)

```toml
Expand Down
9 changes: 8 additions & 1 deletion docs/src/pages/reference/example-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,18 @@ max_size = 10

# Multiple listeners
[[server.listeners]]
addr = "0.0.0.0:25"
addr = "0.0.0.0:465" # Implicit TLS (default mode)
[server.listeners.tls]
cert_path = "/etc/hedwig/server.crt"
key_path = "/etc/hedwig/server.key"

[[server.listeners]]
addr = "0.0.0.0:587" # Plaintext with STARTTLS upgrade
[server.listeners.tls]
cert_path = "/etc/hedwig/server.crt"
key_path = "/etc/hedwig/server.key"
mode = "starttls"

[[server.listeners]]
addr = "127.0.0.1:2525" # Local plaintext listener

Expand Down
5 changes: 5 additions & 0 deletions docs/src/pages/reference/huml-example.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,16 @@ server::
addr: "0.0.0.0:25"
- ::
addr: "0.0.0.0:587"
tls::
cert_path: "/etc/ssl/certs/smtp.crt"
key_path: "/etc/ssl/private/smtp.key"
mode: "starttls"
- ::
addr: "0.0.0.0:465"
tls::
cert_path: "/etc/ssl/certs/smtp.crt"
key_path: "/etc/ssl/private/smtp.key"
mode: "implicit"
- ::
addr: "127.0.0.1:2525"
max_retries: 5
Expand Down
13 changes: 13 additions & 0 deletions smtp-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,19 @@ pub struct CfgListener {
pub struct CfgTls {
pub cert_path: String,
pub key_path: String,
/// How TLS is negotiated on this listener: "implicit" wraps the
/// connection in TLS immediately (e.g. port 465), "starttls" accepts
/// plaintext and upgrades when the client issues STARTTLS (e.g. port 587).
#[serde(default)]
pub mode: TlsMode,
}

#[derive(Debug, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TlsMode {
#[default]
Implicit,
Starttls,
}

#[derive(Debug, Deserialize, Clone)]
Expand Down
46 changes: 33 additions & 13 deletions smtp-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use futures::StreamExt;
use miette::{bail, Context, IntoDiagnostic, Result};
use mta_sts::refresher;
use rustls::pki_types::CertificateDer;
use smtp::{SmtpServer, SmtpStream};
use smtp::{MaybeTlsStream, SmtpServer, SmtpStream};
use std::sync::Arc;
use storage::{fs_storage::FileSystemStorage, sqlite_storage::SqliteStorage, Status, Storage};
use subtle::ConstantTimeEq;
Expand Down Expand Up @@ -294,10 +294,10 @@ async fn run_server(config_path: &str) -> Result<()> {
.into_diagnostic()
.wrap_err_with(|| format!("Failed to bind to address: {}", listener_config.addr))?;

let tls_status = if listener_config.tls.is_some() {
"TLS"
} else {
"plaintext"
let tls_status = match &listener_config.tls {
Some(tls) if tls.mode == config::TlsMode::Starttls => "STARTTLS",
Some(_) => "TLS",
None => "plaintext",
};
info!(
storage_type = cfg.storage.storage_type,
Expand All @@ -310,6 +310,11 @@ async fn run_server(config_path: &str) -> Result<()> {
for (listener, acceptor_index) in listeners {
let server_clone = smtp_server.clone();
let tls_acceptor = tls_acceptors[acceptor_index].clone();
let tls_mode = cfg.server.listeners[acceptor_index]
.tls
.as_ref()
.map(|tls| tls.mode)
.unwrap_or_default();
let shutdown = shutdown_token.clone();
let listener_addr = cfg.server.listeners[acceptor_index].addr.clone();
let conn_semaphore = Arc::clone(&conn_semaphore);
Expand Down Expand Up @@ -349,11 +354,18 @@ async fn run_server(config_path: &str) -> Result<()> {
// Hold the permit for the lifetime of this connection.
let _permit = permit;

let mut boxed_socket: Box<dyn SmtpStream> =
if let Some(acceptor) = tls_acceptor {
match acceptor.accept(socket).await {
Ok(tls_stream) => Box::new(tls_stream),
Err(e) => {
let mut boxed_socket: Box<dyn SmtpStream> = match tls_acceptor {
// Implicit TLS: the handshake happens before any SMTP traffic.
// Bounded so a silent client can't hold a connection permit forever.
Some(acceptor) if tls_mode == config::TlsMode::Implicit => {
match tokio::time::timeout(
cmd_timeout,
acceptor.accept(socket),
)
.await
{
Ok(Ok(tls_stream)) => Box::new(tls_stream),
Ok(Err(e)) => {
if e.kind() == std::io::ErrorKind::UnexpectedEof {
debug!("TLS handshake failed: {}", e);
} else {
Expand All @@ -362,10 +374,18 @@ async fn run_server(config_path: &str) -> Result<()> {

return;
}
Err(_) => {
debug!("TLS handshake timed out");
return;
}
}
} else {
Box::new(socket)
};
}
// STARTTLS: start in plaintext, upgrade when the client asks.
Some(acceptor) => {
Box::new(MaybeTlsStream::Plain(socket, Some(acceptor)))
}
None => Box::new(socket),
};

if let Err(e) = server_clone.handle_client(&mut boxed_socket).await {
error!("Error handling client: {:#}", e);
Expand Down
2 changes: 0 additions & 2 deletions smtp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,5 @@ thiserror = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
tokio-rustls = "0.26.1"
rustls = { version = "0.23", features = ["ring"] }
rustls-pemfile = "2.2.0"
bytes = "1.10.0"
memchr = "2.7.4"
Loading
Loading