From 5ea97e927deaef76afe691e17e92c9fcf5832aa0 Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Thu, 16 Jul 2026 14:40:59 -0400 Subject: [PATCH 01/13] examples: add top-level rustdoc comment for client --- examples/client.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/client.rs b/examples/client.rs index 601fc668..ed14938b 100644 --- a/examples/client.rs +++ b/examples/client.rs @@ -1,3 +1,5 @@ +//! A simple example using tokio-rustls to make an HTTP GET request. + use std::error::Error as StdError; use std::io; use std::net::ToSocketAddrs; From 86e31591f725ec48b3e96c0867aa7cf0d0ca9793 Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Thu, 16 Jul 2026 14:41:26 -0400 Subject: [PATCH 02/13] examples: top-down ordering in client --- examples/client.rs | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/examples/client.rs b/examples/client.rs index ed14938b..29e37893 100644 --- a/examples/client.rs +++ b/examples/client.rs @@ -13,26 +13,6 @@ use tokio::io::{AsyncWriteExt, copy, split, stdin as tokio_stdin, stdout as toki use tokio::net::TcpStream; use tokio_rustls::{TlsConnector, rustls}; -/// Tokio Rustls client example -#[derive(FromArgs)] -struct Options { - /// host - #[argh(positional)] - host: String, - - /// port - #[argh(option, short = 'p', default = "443")] - port: u16, - - /// domain - #[argh(option, short = 'd')] - domain: Option, - - /// cafile - #[argh(option, short = 'c')] - cafile: Option, -} - #[tokio::main] async fn main() -> Result<(), Box> { let options: Options = argh::from_env(); @@ -80,3 +60,23 @@ async fn main() -> Result<(), Box> { Ok(()) } + +/// Tokio Rustls client example +#[derive(FromArgs)] +struct Options { + /// host + #[argh(positional)] + host: String, + + /// port + #[argh(option, short = 'p', default = "443")] + port: u16, + + /// domain + #[argh(option, short = 'd')] + domain: Option, + + /// cafile + #[argh(option, short = 'c')] + cafile: Option, +} From 65f78458e15433e20baf8217aea25044213a2b9c Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Thu, 16 Jul 2026 14:42:25 -0400 Subject: [PATCH 03/13] examples: remove unnecessary client comment --- examples/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/client.rs b/examples/client.rs index 29e37893..4c903a21 100644 --- a/examples/client.rs +++ b/examples/client.rs @@ -35,7 +35,7 @@ async fn main() -> Result<(), Box> { let config = rustls::ClientConfig::builder() .with_root_certificates(root_cert_store) - .with_no_client_auth(); // i guess this was previously the default? + .with_no_client_auth(); let connector = TlsConnector::from(Arc::new(config)); let stream = TcpStream::connect(&addr).await?; From 830ea25624c326c378f81c5a78842627097007fe Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Thu, 16 Jul 2026 14:43:47 -0400 Subject: [PATCH 04/13] examples: tidy client imports * Avoid the `rustls::` prefix for `RootCertStore` and `ClientConfig` * Consistently use the `tokio_rustls::rustls` re-export. --- examples/client.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/examples/client.rs b/examples/client.rs index 4c903a21..c08a00c4 100644 --- a/examples/client.rs +++ b/examples/client.rs @@ -7,11 +7,12 @@ use std::path::PathBuf; use std::sync::Arc; use argh::FromArgs; -use rustls::pki_types::pem::PemObject; -use rustls::pki_types::{CertificateDer, ServerName}; use tokio::io::{AsyncWriteExt, copy, split, stdin as tokio_stdin, stdout as tokio_stdout}; use tokio::net::TcpStream; -use tokio_rustls::{TlsConnector, rustls}; +use tokio_rustls::TlsConnector; +use tokio_rustls::rustls::pki_types::pem::PemObject; +use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName}; +use tokio_rustls::rustls::{ClientConfig, RootCertStore}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -24,7 +25,7 @@ async fn main() -> Result<(), Box> { let domain = options.domain.unwrap_or(options.host); let content = format!("GET / HTTP/1.0\r\nHost: {}\r\n\r\n", domain); - let mut root_cert_store = rustls::RootCertStore::empty(); + let mut root_cert_store = RootCertStore::empty(); if let Some(cafile) = &options.cafile { for cert in CertificateDer::pem_file_iter(cafile)? { root_cert_store.add(cert?)?; @@ -33,7 +34,7 @@ async fn main() -> Result<(), Box> { root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); } - let config = rustls::ClientConfig::builder() + let config = ClientConfig::builder() .with_root_certificates(root_cert_store) .with_no_client_auth(); let connector = TlsConnector::from(Arc::new(config)); From 995d9ad2fed79cc7d136ebfa07eebf99ce897ac8 Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Fri, 17 Jul 2026 10:17:56 -0400 Subject: [PATCH 05/13] examples: add timeouts to client Demonstrates using tokio-rustls and externally applying timeouts to TCP connect, TLS handshake, reads/writes on a TLS stream, and graceful shutdown. --- examples/client.rs | 132 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 107 insertions(+), 25 deletions(-) diff --git a/examples/client.rs b/examples/client.rs index c08a00c4..4c2c0ca4 100644 --- a/examples/client.rs +++ b/examples/client.rs @@ -1,14 +1,28 @@ -//! A simple example using tokio-rustls to make an HTTP GET request. +//! An example using tokio-rustls to make an HTTP GET request, demonstrating +//! how to apply timeouts to every phase of the connection lifecycle using +//! only `tokio::time` and the public tokio-rustls API: +//! +//! * TCP connect +//! * TLS handshake +//! * individual reads/writes on the established stream +//! * graceful TLS shutdown (close notify) +//! +//! The handshake timeout also applies unchanged to `TlsConnector::connect_with` +//! and `TlsConnectorWithAlpn::connect`, they all return the same `Connect` +//! future. use std::error::Error as StdError; +use std::future::Future; use std::io; use std::net::ToSocketAddrs; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use argh::FromArgs; -use tokio::io::{AsyncWriteExt, copy, split, stdin as tokio_stdin, stdout as tokio_stdout}; +use tokio::io::{AsyncReadExt, AsyncWriteExt, stdout as tokio_stdout}; use tokio::net::TcpStream; +use tokio::time::timeout; use tokio_rustls::TlsConnector; use tokio_rustls::rustls::pki_types::pem::PemObject; use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName}; @@ -23,7 +37,7 @@ async fn main() -> Result<(), Box> { .next() .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?; let domain = options.domain.unwrap_or(options.host); - let content = format!("GET / HTTP/1.0\r\nHost: {}\r\n\r\n", domain); + let content = format!("GET / HTTP/1.0\r\nHost: {domain}\r\n\r\n",); let mut root_cert_store = RootCertStore::empty(); if let Some(cafile) = &options.cafile { @@ -34,35 +48,70 @@ async fn main() -> Result<(), Box> { root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); } - let config = ClientConfig::builder() - .with_root_certificates(root_cert_store) - .with_no_client_auth(); - let connector = TlsConnector::from(Arc::new(config)); - - let stream = TcpStream::connect(&addr).await?; - - let (mut stdin, mut stdout) = (tokio_stdin(), tokio_stdout()); - + let connector = TlsConnector::from(Arc::new( + ClientConfig::builder() + .with_root_certificates(root_cert_store) + .with_no_client_auth(), + )); + + // TCP connect, bounded by the connect timeout. + let stream = with_timeout( + TcpStream::connect(&addr), + "TCP connect", + Duration::from_secs(options.connect_timeout), + ) + .await?; + + // TLS handshake, bounded by the handshake timeout. + // + // On timeout the `Connect` future, and the socket it owns, is dropped. let domain = ServerName::try_from(domain.as_str())?.to_owned(); - let mut stream = connector.connect(domain, stream).await?; - stream.write_all(content.as_bytes()).await?; - - let (mut reader, mut writer) = split(stream); - - tokio::select! { - ret = copy(&mut reader, &mut stdout) => { - ret?; - }, - ret = copy(&mut stdin, &mut writer) => { - ret?; - writer.shutdown().await? + let mut stream = with_timeout( + connector.connect(domain, stream), + "TLS handshake", + Duration::from_secs(options.handshake_timeout), + ) + .await?; + + let io_timeout = Duration::from_secs(options.io_timeout); + + // request/response, with a timeout on each individual write and + // read. + // + // Note a single `timeout()` around `read_to_end()` would bound the + // *total* response time instead. Wrapping each `read()` bounds idle time. + with_timeout( + stream.write_all(content.as_bytes()), + "request write", + io_timeout, + ) + .await?; + + let mut stdout = tokio_stdout(); + let mut buf = vec![0u8; 8192]; + loop { + let n = with_timeout(stream.read(&mut buf), "response read", io_timeout).await?; + if n == 0 { + break; } + stdout.write_all(&buf[..n]).await?; } + stdout.flush().await?; + + // graceful TLS shutdown. + // + // `shutdown()` writes a close_notify alert, so it too can block on a stalled peer. + with_timeout( + stream.shutdown(), + "TLS shutdown", + Duration::from_secs(options.shutdown_timeout), + ) + .await?; Ok(()) } -/// Tokio Rustls client example +/// Tokio Rustls client example with timeouts #[derive(FromArgs)] struct Options { /// host @@ -80,4 +129,37 @@ struct Options { /// cafile #[argh(option, short = 'c')] cafile: Option, + + /// TCP connect timeout (seconds) + #[argh(option, default = "10")] + connect_timeout: u64, + + /// TLS handshake timeout (seconds) + #[argh(option, default = "10")] + handshake_timeout: u64, + + /// per-read/write timeout on the established stream (seconds) + #[argh(option, default = "30")] + io_timeout: u64, + + /// graceful TLS shutdown (close_notify) timeout (seconds) + #[argh(option, default = "5")] + shutdown_timeout: u64, +} + +/// Await `fut` for at most `duration`, converting an elapsed timeout into an `io::Error`. +/// +/// On timeout, the returned error describes the `phase` that timed out. +async fn with_timeout( + fut: impl Future>, + phase: &str, + duration: Duration, +) -> io::Result { + match timeout(duration, fut).await { + Ok(result) => result, + Err(_) => Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("{phase} timed out after {duration:?}"), + )), + } } From e17393c17aa018710630adb775e8a2c51d111d0c Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Thu, 16 Jul 2026 14:47:51 -0400 Subject: [PATCH 06/13] examples: add top-level rustdoc comment for server --- examples/server.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/server.rs b/examples/server.rs index e4b1cb35..7c171777 100644 --- a/examples/server.rs +++ b/examples/server.rs @@ -1,3 +1,8 @@ +//! A simple example using tokio-rustls to build an echo/HTTP server. +//! +//! When -echo_mode is provided, data read by the server is written back to the client. +//! Otherwise, a fixed HTTP 200 is returned to all requests. + use std::error::Error as StdError; use std::io; use std::net::ToSocketAddrs; From fac3456ccc55f227abc28889208a496110c55b4b Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Thu, 16 Jul 2026 14:48:15 -0400 Subject: [PATCH 07/13] examples: top-down ordering in server --- examples/server.rs | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/examples/server.rs b/examples/server.rs index 7c171777..4bbb5d4d 100644 --- a/examples/server.rs +++ b/examples/server.rs @@ -16,26 +16,6 @@ use tokio::io::{AsyncWriteExt, copy, sink, split}; use tokio::net::TcpListener; use tokio_rustls::{TlsAcceptor, rustls}; -/// Tokio Rustls server example -#[derive(FromArgs)] -struct Options { - /// bind addr - #[argh(positional)] - addr: String, - - /// cert file - #[argh(option, short = 'c')] - cert: PathBuf, - - /// key file - #[argh(option, short = 'k')] - key: PathBuf, - - /// echo mode - #[argh(switch, short = 'e')] - echo_mode: bool, -} - #[tokio::main] async fn main() -> Result<(), Box> { let options: Options = argh::from_env(); @@ -94,3 +74,23 @@ async fn main() -> Result<(), Box> { }); } } + +/// Tokio Rustls server example +#[derive(FromArgs)] +struct Options { + /// bind addr + #[argh(positional)] + addr: String, + + /// cert file + #[argh(option, short = 'c')] + cert: PathBuf, + + /// key file + #[argh(option, short = 'k')] + key: PathBuf, + + /// echo mode + #[argh(switch, short = 'e')] + echo_mode: bool, +} From 0afc7b1bfcfb2ac1e11dc2796816b856a7e38e63 Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Thu, 16 Jul 2026 14:49:51 -0400 Subject: [PATCH 08/13] examples: tidy server imports * Avoid the `rustls::` prefix for `ServerConfig`. * Consistently use the `tokio_rustls::rustls` re-export. --- examples/server.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/server.rs b/examples/server.rs index 4bbb5d4d..62c05311 100644 --- a/examples/server.rs +++ b/examples/server.rs @@ -10,11 +10,12 @@ use std::path::PathBuf; use std::sync::Arc; use argh::FromArgs; -use rustls::pki_types::pem::PemObject; -use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use tokio::io::{AsyncWriteExt, copy, sink, split}; use tokio::net::TcpListener; -use tokio_rustls::{TlsAcceptor, rustls}; +use tokio_rustls::TlsAcceptor; +use tokio_rustls::rustls::ServerConfig; +use tokio_rustls::rustls::pki_types::pem::PemObject; +use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -29,7 +30,7 @@ async fn main() -> Result<(), Box> { let key = PrivateKeyDer::from_pem_file(&options.key)?; let flag_echo = options.echo_mode; - let config = rustls::ServerConfig::builder() + let config = ServerConfig::builder() .with_no_client_auth() .with_single_cert(certs, key)?; let acceptor = TlsAcceptor::from(Arc::new(config)); From b9a18bc0443a44680b2e2c90cca85f126ea136b5 Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Fri, 17 Jul 2026 13:38:30 -0400 Subject: [PATCH 09/13] examples: remove server HTTP response mode Always echo data read from the client back to it, removing the -e/--echo_mode flag and the fixed HTTP 200 response mode. This keeps the example focused on demonstrating tokio-rustls rather than showing two ways to respond. IMO a fixed HTTP response that doesn't take into account any details of the request isn't especially useful. --- examples/server.rs | 38 ++++++++------------------------------ 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/examples/server.rs b/examples/server.rs index 62c05311..b8ae4ad2 100644 --- a/examples/server.rs +++ b/examples/server.rs @@ -1,7 +1,6 @@ -//! A simple example using tokio-rustls to build an echo/HTTP server. +//! A simple example using tokio-rustls to build an echo server. //! -//! When -echo_mode is provided, data read by the server is written back to the client. -//! Otherwise, a fixed HTTP 200 is returned to all requests. +//! Data read by the server is written back to the client. use std::error::Error as StdError; use std::io; @@ -10,7 +9,7 @@ use std::path::PathBuf; use std::sync::Arc; use argh::FromArgs; -use tokio::io::{AsyncWriteExt, copy, sink, split}; +use tokio::io::{AsyncWriteExt, copy, split}; use tokio::net::TcpListener; use tokio_rustls::TlsAcceptor; use tokio_rustls::rustls::ServerConfig; @@ -28,7 +27,6 @@ async fn main() -> Result<(), Box> { .ok_or_else(|| io::Error::from(io::ErrorKind::AddrNotAvailable))?; let certs = CertificateDer::pem_file_iter(&options.cert)?.collect::, _>>()?; let key = PrivateKeyDer::from_pem_file(&options.key)?; - let flag_echo = options.echo_mode; let config = ServerConfig::builder() .with_no_client_auth() @@ -42,28 +40,12 @@ async fn main() -> Result<(), Box> { let acceptor = acceptor.clone(); let fut = async move { - let mut stream = acceptor.accept(stream).await?; + let stream = acceptor.accept(stream).await?; - if flag_echo { - let (mut reader, mut writer) = split(stream); - let n = copy(&mut reader, &mut writer).await?; - writer.flush().await?; - println!("Echo: {} - {}", peer_addr, n); - } else { - let mut output = sink(); - stream - .write_all( - &b"HTTP/1.0 200 ok\r\n\ - Connection: close\r\n\ - Content-length: 12\r\n\ - \r\n\ - Hello world!"[..], - ) - .await?; - stream.shutdown().await?; - copy(&mut stream, &mut output).await?; - println!("Hello: {}", peer_addr); - } + let (mut reader, mut writer) = split(stream); + let n = copy(&mut reader, &mut writer).await?; + writer.flush().await?; + println!("Echo: {} - {}", peer_addr, n); Ok(()) as io::Result<()> }; @@ -90,8 +72,4 @@ struct Options { /// key file #[argh(option, short = 'k')] key: PathBuf, - - /// echo mode - #[argh(switch, short = 'e')] - echo_mode: bool, } From 3cfea1bc08231dcfb10eeabd30ca65b35489e5f3 Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Fri, 17 Jul 2026 13:38:54 -0400 Subject: [PATCH 10/13] examples: add lazy acceptor path to server Adds a -l/--lazy flag that performs the TLS handshake with LazyConfigAcceptor instead of TlsAcceptor, demonstrating access to the ClientHello (e.g. for SNI-based config selection) before completing the handshake with a chosen ServerConfig. --- examples/server.rs | 70 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/examples/server.rs b/examples/server.rs index b8ae4ad2..9c9760b8 100644 --- a/examples/server.rs +++ b/examples/server.rs @@ -1,6 +1,10 @@ //! A simple example using tokio-rustls to build an echo server. //! //! Data read by the server is written back to the client. +//! +//! The TLS handshake is performed via `TlsAcceptor::accept` (the default +//! path), or via `LazyConfigAcceptor` when `--lazy` is provided, +//! demonstrating access to the ClientHello before completing the handshake. use std::error::Error as StdError; use std::io; @@ -10,11 +14,13 @@ use std::sync::Arc; use argh::FromArgs; use tokio::io::{AsyncWriteExt, copy, split}; -use tokio::net::TcpListener; -use tokio_rustls::TlsAcceptor; +use tokio::net::{TcpListener, TcpStream}; use tokio_rustls::rustls::ServerConfig; use tokio_rustls::rustls::pki_types::pem::PemObject; use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use tokio_rustls::rustls::server::Acceptor; +use tokio_rustls::server::TlsStream; +use tokio_rustls::{LazyConfigAcceptor, TlsAcceptor}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -25,22 +31,30 @@ async fn main() -> Result<(), Box> { .to_socket_addrs()? .next() .ok_or_else(|| io::Error::from(io::ErrorKind::AddrNotAvailable))?; - let certs = CertificateDer::pem_file_iter(&options.cert)?.collect::, _>>()?; - let key = PrivateKeyDer::from_pem_file(&options.key)?; - - let config = ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(certs, key)?; - let acceptor = TlsAcceptor::from(Arc::new(config)); + let config = Arc::new( + ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + CertificateDer::pem_file_iter(&options.cert)?.collect::>()?, + PrivateKeyDer::from_pem_file(&options.key)?, + )?, + ); + let acceptor = TlsAcceptor::from(config.clone()); let listener = TcpListener::bind(&addr).await?; loop { - let (stream, peer_addr) = listener.accept().await?; let acceptor = acceptor.clone(); + let config = config.clone(); + let (stream, peer_addr) = listener.accept().await?; let fut = async move { - let stream = acceptor.accept(stream).await?; + let stream = match options.lazy { + // Accept the connection lazily. + true => accept_lazy(config, stream).await?, + // Or, directly accept the stream. + false => acceptor.accept(stream).await?, + }; let (mut reader, mut writer) = split(stream); let n = copy(&mut reader, &mut writer).await?; @@ -52,7 +66,7 @@ async fn main() -> Result<(), Box> { tokio::spawn(async move { if let Err(err) = fut.await { - eprintln!("{:?}", err); + eprintln!("{peer_addr}: {err:?}"); } }); } @@ -72,4 +86,36 @@ struct Options { /// key file #[argh(option, short = 'k')] key: PathBuf, + + /// use LazyConfigAcceptor instead of TlsAcceptor + #[argh(switch, short = 'l')] + lazy: bool, +} + +/// Accept a TLS connection with `LazyConfigAcceptor`. +/// +/// The handshake has two await points: +/// +/// 1. reading the ClientHello +/// 2. driving the rest of the handshake with the chosen config +/// +/// Between the two the ClientHello is available, e.g. for SNI-based config +/// selection. +async fn accept_lazy( + config: Arc, + stream: TcpStream, +) -> io::Result> { + let acceptor = LazyConfigAcceptor::new(Acceptor::default(), stream); + tokio::pin!(acceptor); + + // Read the ClientHello. + let start = acceptor.as_mut().await?; + + // The ClientHello is now available, e.g. for SNI-based config selection. + if let Some(sni) = start.client_hello().server_name() { + println!("ClientHello with SNI: {sni}"); + } + + // Complete the handshake. + start.into_stream(config).await } From ca045977a431551bfc4ae9e1c4d118de8795d92e Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Fri, 17 Jul 2026 13:39:15 -0400 Subject: [PATCH 11/13] examples: add timeouts to server Demonstrates using tokio-rustls to accept & handle a client request (both directly, and lazily), while applying timeouts to the TLS handshake, individual reads/writes on the established stream, and the graceful TLS shutdown (close_notify). In the lazy path a single Instant deadline (via timeout_at) spans both the wait for the ClientHello and the rest of the handshake, and take_io() is used to answer a stalled client in plaintext. --- examples/server.rs | 146 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 124 insertions(+), 22 deletions(-) diff --git a/examples/server.rs b/examples/server.rs index 9c9760b8..5997715c 100644 --- a/examples/server.rs +++ b/examples/server.rs @@ -1,20 +1,25 @@ -//! A simple example using tokio-rustls to build an echo server. +//! An example using tokio-rustls to build an echo server, demonstrating how +//! to apply timeouts to every phase of each accepted connection. //! -//! Data read by the server is written back to the client. -//! -//! The TLS handshake is performed via `TlsAcceptor::accept` (the default -//! path), or via `LazyConfigAcceptor` when `--lazy` is provided, -//! demonstrating access to the ClientHello before completing the handshake. +//! * TLS handshake via `TlsAcceptor::accept` (the default path) +//! * TLS handshake via `LazyConfigAcceptor` (`--lazy`), with one deadline +//! spanning both the wait for the ClientHello and the rest of the +//! handshake, and `take_io()` used to answer a stalled client in plaintext +//! * individual reads/writes on the established stream +//! * graceful TLS shutdown (close notify) use std::error::Error as StdError; +use std::future::Future; use std::io; use std::net::ToSocketAddrs; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use argh::FromArgs; -use tokio::io::{AsyncWriteExt, copy, split}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; +use tokio::time::{Instant, timeout, timeout_at}; use tokio_rustls::rustls::ServerConfig; use tokio_rustls::rustls::pki_types::pem::PemObject; use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; @@ -32,6 +37,10 @@ async fn main() -> Result<(), Box> { .next() .ok_or_else(|| io::Error::from(io::ErrorKind::AddrNotAvailable))?; + let handshake_timeout = Duration::from_secs(options.handshake_timeout); + let io_timeout = Duration::from_secs(options.io_timeout); + let shutdown_timeout = Duration::from_secs(options.shutdown_timeout); + let config = Arc::new( ServerConfig::builder() .with_no_client_auth() @@ -50,15 +59,16 @@ async fn main() -> Result<(), Box> { let fut = async move { let stream = match options.lazy { - // Accept the connection lazily. - true => accept_lazy(config, stream).await?, - // Or, directly accept the stream. - false => acceptor.accept(stream).await?, + // Accept the connection lazily, applying the handshake_timeout. + true => accept_lazy(config, stream, handshake_timeout).await?, + // Or, directly accept the stream, applying the handshake_timeout. + false => { + with_timeout(acceptor.accept(stream), "TLS handshake", handshake_timeout) + .await? + } }; - let (mut reader, mut writer) = split(stream); - let n = copy(&mut reader, &mut writer).await?; - writer.flush().await?; + let n = serve(stream, io_timeout, shutdown_timeout).await?; println!("Echo: {} - {}", peer_addr, n); Ok(()) as io::Result<()> @@ -72,7 +82,7 @@ async fn main() -> Result<(), Box> { } } -/// Tokio Rustls server example +/// Tokio Rustls server example with timeouts #[derive(FromArgs)] struct Options { /// bind addr @@ -90,32 +100,124 @@ struct Options { /// use LazyConfigAcceptor instead of TlsAcceptor #[argh(switch, short = 'l')] lazy: bool, + + /// TLS handshake timeout (seconds) + #[argh(option, default = "5")] + handshake_timeout: u64, + + /// per-read/write timeout on the established stream (seconds) + #[argh(option, default = "30")] + io_timeout: u64, + + /// graceful TLS shutdown (close_notify) timeout (seconds) + #[argh(option, default = "5")] + shutdown_timeout: u64, } -/// Accept a TLS connection with `LazyConfigAcceptor`. +/// Accept a TLS connection with `LazyConfigAcceptor` under a single deadline. /// /// The handshake has two await points: /// /// 1. reading the ClientHello /// 2. driving the rest of the handshake with the chosen config /// -/// Between the two the ClientHello is available, e.g. for SNI-based config -/// selection. +/// A shared `Instant` deadline (via `timeout_at`) keeps the total bounded +/// rather than granting each phase its own budget. +/// +/// If the client stalls before even sending a ClientHello we can take the +/// socket back with `take_io()` and respond in plaintext before hanging up. async fn accept_lazy( config: Arc, stream: TcpStream, + handshake_timeout: Duration, ) -> io::Result> { + let deadline = Instant::now() + handshake_timeout; + let acceptor = LazyConfigAcceptor::new(Acceptor::default(), stream); tokio::pin!(acceptor); - // Read the ClientHello. - let start = acceptor.as_mut().await?; + // Read the ClientHello, respecting the deadline. + let start = match timeout_at(deadline, acceptor.as_mut()).await { + Ok(result) => result?, + Err(_) => { + // No ClientHello arrived in time. No TLS handshake has happened yet, + // so we can still answer in plaintext. + if let Some(mut stream) = acceptor.take_io() { + let _ = stream + .write_all(b"HTTP/1.0 408 Request Timeout\r\n\r\n") + .await; + } + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("timed out awaiting ClientHello after {handshake_timeout:?}"), + )); + } + }; // The ClientHello is now available, e.g. for SNI-based config selection. if let Some(sni) = start.client_hello().server_name() { println!("ClientHello with SNI: {sni}"); } - // Complete the handshake. - start.into_stream(config).await + // Complete the handshake, respecting the deadline. + match timeout_at(deadline, start.into_stream(config)).await { + Ok(result) => result, + Err(_elapsed) => Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("TLS handshake timed out after {handshake_timeout:?}"), + )), + } +} + +/// Serve a single established TLS `stream`, respecting timeouts. +/// +/// Echoes one request worth of bytes, bounding each read/write with the `io_timeout`, and the +/// final graceful shutdown with the `shutdown_timeout.` +async fn serve( + mut stream: TlsStream, + io_timeout: Duration, + shutdown_timeout: Duration, +) -> io::Result { + let mut echoed = 0; + let mut buf = vec![0u8; 8192]; + loop { + // Bounding each read() limits how long the peer may sit idle. A + // single timeout() around the whole loop would bound total + // connection time instead. + // For an idle peer, stop echoing and fall through to the graceful + // shutdown below so the peer sees close_notify rather than an abrupt + // close. + let n = match timeout(io_timeout, stream.read(&mut buf)).await { + Ok(result) => result?, + Err(_elapsed) => break, + }; + if n == 0 { + break; + } + with_timeout(stream.write_all(&buf[..n]), "write", io_timeout).await?; + echoed += n as u64; + } + with_timeout(stream.flush(), "flush", io_timeout).await?; + + // shutdown() writes a close_notify alert, so it too can block on a + // stalled peer. + with_timeout(stream.shutdown(), "TLS shutdown", shutdown_timeout).await?; + Ok(echoed) +} + +/// Await `fut` for at most `duration`, converting an elapsed timeout into an `io::Error`. +/// +/// On timeout, the returned error describes the `phase` that timed out. +async fn with_timeout( + fut: impl Future>, + phase: &str, + duration: Duration, +) -> io::Result { + match timeout(duration, fut).await { + Ok(result) => result, + Err(_) => Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("{phase} timed out after {duration:?}"), + )), + } } From 902e69046a1598017f409397d02b61c4da22d07f Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Thu, 16 Jul 2026 15:40:18 -0400 Subject: [PATCH 12/13] client: add rustdoc hinting towards timeout wrapping --- src/client.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/client.rs b/src/client.rs index a94b64a8..bb01cf94 100644 --- a/src/client.rs +++ b/src/client.rs @@ -35,6 +35,12 @@ impl TlsConnector { self } + /// Returns a future that performs a TLS handshake with `domain` using the `stream`. + /// + /// You likely want to wrap this in a timeout (for example with [`tokio::time::timeout`][]) + /// to bound the handshake time. + /// + /// [`tokio::time::timeout`]: https://docs.rs/tokio/latest/tokio/time/fn.timeout.html #[inline] pub fn connect(&self, domain: ServerName<'static>, stream: IO) -> Connect where @@ -43,6 +49,15 @@ impl TlsConnector { self.connect_impl(domain, stream, None, |_| ()) } + /// Similar to [`Self::connect()`], but calls `f` before performing the handshake. + /// + /// As with [`Self::connect()`] you likely want to wrap this in a timeout to + /// bound the handshake time. + /// + /// The `f` handler is given a mutable reference to a [`ClientConnection`][] that can + /// be used for tasks like writing early data. + /// + /// [`ClientConnection`]: https://docs.rs/rustls/latest/rustls/client/struct.ClientConnection.html #[inline] pub fn connect_with(&self, domain: ServerName<'static>, stream: IO, f: F) -> Connect where @@ -128,6 +143,12 @@ pub struct TlsConnectorWithAlpn<'c> { } impl TlsConnectorWithAlpn<'_> { + /// Returns a future that performs a TLS handshake with `domain` using the `stream`. + /// + /// You likely want to wrap this in a timeout (for example with [`tokio::time::timeout`][]) + /// to bound the handshake time. + /// + /// [`tokio::time::timeout`]: https://docs.rs/tokio/latest/tokio/time/fn.timeout.html #[inline] pub fn connect(self, domain: ServerName<'static>, stream: IO) -> Connect where @@ -137,6 +158,15 @@ impl TlsConnectorWithAlpn<'_> { .connect_impl(domain, stream, Some(self.alpn_protocols), |_| ()) } + /// Similar to [`Self::connect()`], but calls `f` before performing the handshake. + /// + /// As with [`Self::connect()`] you likely want to wrap this in a timeout to + /// bound the handshake time. + /// + /// The `f` handler is given a mutable reference to a [`ClientConnection`][] that can + /// be used for tasks like writing early data. + /// + /// [`ClientConnection`]: https://docs.rs/rustls/latest/rustls/client/struct.ClientConnection.html #[inline] pub fn connect_with(self, domain: ServerName<'static>, stream: IO, f: F) -> Connect where From a45e9ae5715c8038a4e37438540c646cdddf419d Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Thu, 16 Jul 2026 15:41:01 -0400 Subject: [PATCH 13/13] server: add rustdoc hinting towards timeout wrapping --- src/server.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/server.rs b/src/server.rs index a43dd8fc..2b068984 100644 --- a/src/server.rs +++ b/src/server.rs @@ -27,6 +27,12 @@ impl From> for TlsAcceptor { } impl TlsAcceptor { + /// Returns a future for completing a TLS handshake for a client using `stream`. + /// + /// You likely want to wrap this in a timeout (for example with [`tokio::time::timeout`][]) + /// to bound the handshake time. + /// + /// [`tokio::time::timeout`]: https://docs.rs/tokio/latest/tokio/time/fn.timeout.html #[inline] pub fn accept(&self, stream: IO) -> Accept where @@ -35,6 +41,18 @@ impl TlsAcceptor { self.accept_with(stream, |_| ()) } + /// Similar to [`Self::accept()`], but calls `f` before performing the handshake. + /// + /// As with [`Self::accept()`] you likely want to wrap this in a timeout to + /// bound the handshake time. + /// + /// The `f` handler is given a mutable reference to a [`ServerConnection`][] that can be used + /// to configure the connection before the handshake, for example, adjusting the buffer limit. + /// + /// Because no data has been read from `stream` yet when `f` is called ClientHello + /// dependent state (like early data) is not yet available. + /// + /// [`ServerConnection`]: https://docs.rs/rustls/latest/rustls/server/struct.ServerConnection.html pub fn accept_with(&self, stream: IO, f: F) -> Accept where IO: AsyncRead + AsyncWrite + Unpin, @@ -67,6 +85,14 @@ impl TlsAcceptor { } } +/// A future for reading a `ClientHello` from `io` without committing to a [`ServerConfig`][]. +/// +/// Awaiting it yields a [`StartHandshake`], which exposes the +/// [`ClientHello`][] (for example, to choose a config based on SNI) and performs +/// the rest of the handshake via [`StartHandshake::into_stream()`]. +/// +/// [`ServerConfig`]: https://docs.rs/rustls/latest/rustls/server/struct.ServerConfig.html +/// [`ClientHello`]: https://docs.rs/rustls/latest/rustls/server/struct.ClientHello.html pub struct LazyConfigAcceptor { acceptor: rustls::server::Acceptor, io: Option, @@ -77,6 +103,22 @@ impl LazyConfigAcceptor where IO: AsyncRead + AsyncWrite + Unpin, { + /// Returns a new `LazyConfigAcceptor` that reads a `ClientHello` from `io`. + /// + /// You likely want to wrap awaiting the acceptor in a timeout to bound how long the + /// peer may take to send the `ClientHello`. + /// + /// Note that awaiting the acceptor is only the first half of the handshake and + /// [`StartHandshake::into_stream()`] performs the rest. + /// + /// To bound the time for the complete handshake, share one deadline across + /// both awaits (for example with [`tokio::time::timeout_at`][]) rather than giving each + /// its own timeout. + /// + /// If a timeout elapses before the `ClientHello` arrives, [`Self::take_io()`] can + /// recover the `io`, for example to answer the peer in plaintext before closing. + /// + /// [`tokio::time::timeout_at`]: https://docs.rs/tokio/latest/tokio/time/fn.timeout_at.html #[inline] pub fn new(acceptor: rustls::server::Acceptor, io: IO) -> Self { Self { @@ -218,10 +260,27 @@ where self.accepted.client_hello() } + /// Returns a future that performs the rest of the TLS handshake using `config`. + /// + /// You likely want to wrap this in a timeout to bound the handshake time. Ideally + /// with [`tokio::time::timeout_at`][], reusing the deadline that also bounded + /// awaiting the [`LazyConfigAcceptor`] so both halves of the handshake share + /// one budget. See [`LazyConfigAcceptor::new()`]. + /// + /// [`tokio::time::timeout_at`]: https://docs.rs/tokio/latest/tokio/time/fn.timeout_at.html pub fn into_stream(self, config: Arc) -> Accept { self.into_stream_with(config, |_| ()) } + /// Similar to [`Self::into_stream()`], but calls `f` before performing the handshake. + /// + /// As with [`Self::into_stream()`] you likely want to wrap this in a timeout to + /// bound the handshake time. + /// + /// The `f` handler is given a mutable reference to a [`ServerConnection`][] that can be + /// used to configure the connection before the handshake. + /// + /// [`ServerConnection`]: https://docs.rs/rustls/latest/rustls/server/struct.ServerConnection.html pub fn into_stream_with(self, config: Arc, f: F) -> Accept where F: FnOnce(&mut ServerConnection),