Skip to content

Commit ce39b02

Browse files
ivmarkovsentinelt
andauthored
PR 667 - with the review feedback (#674)
* Fix TLS server gating for IDF without CONFIG_ESP_TLS_SERVER ESP-IDF removed the CONFIG_ESP_TLS_SERVER Kconfig option starting with v5.3; server support is always available under mbedTLS. The `esp_idf_esp_tls_server` cfg is therefore never emitted on current IDF, which silently compiled out all server-side TLS support: ServerConfig, negotiate_server, and related helpers. Keep `esp_idf_esp_tls_server` for IDF ≤ 5.2 (where the option still exists and defaults to off), and also enable the APIs on IDF ≥ 5.3 when using mbedTLS (`esp_idf_version_at_least_5_3_0` + `esp_idf_esp_tls_using_mbedtls`). * Add async server TLS handshake helpers and timeout field Expose negotiate_server_init/continue and tls_handshake_timeout_ms so callers can progress handshakes on a non-blocking socket without blocking their event loop. * Add non-blocking TLS server example * Gate async TLS server APIs on ESP-IDF 5.5.1+ esp_tls_server_session_init/continue_async and esp_tls_cfg_server::tls_handshake_timeout_ms were added in ESP-IDF 5.5.1. Compiling them under the broader ≥5.3 server gate broke CI on v5.3.4 and v5.4.3. Keep blocking ServerConfig/negotiate_server on the ≥5.3 dual gate; require 5.5.1 + mbedTLS for the async helpers, timeout field, and tls_server example. * Implement all code review feedback --------- Co-authored-by: Sławek Piotrowski <sentinel@atteo.com>
1 parent a7b1da3 commit ce39b02

7 files changed

Lines changed: 540 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
}
2121
```
2222
- HTTP: Add `keep_alive: Option<KeepAlive>` and `so_linger: Option<Duration>` to server `Configuration`
23+
- TLS: Add `tls_handshake_timeout_ms: u32` to server `ServerConfig` on ESP-IDF >= 5.5.0 (`0` keeps the ESP-TLS default of 10 seconds; only honored by the blocking `EspTls::negotiate_server`). Struct literals on those IDF versions must set the new field (or use `ServerConfig::default()` / `..Default::default()`).
2324
- New events need to be handled in the WiFi event loop:
2425
- `WifiEvent::StaNeighborRep` / `StaNeighborRepRef` (v5.3.0+)
2526
- `WifiEvent::ApWrongPassword` / `ApWrongPasswordRef` (v5.3.3+, v5.4.1+, v5.5.0+)
@@ -43,11 +44,13 @@ remote_component = { name = "espressif/lan87xx", version = "1.*" }
4344
- BT: Fixed panic when an A2DP sink disconnects from the ESP while streaming audio.
4445
- Thread: `scan`, `energy_scan` and the IPv6 receive callbacks no longer pass the wrong context pointer to OpenThread (the closure box instead of the `ThreadDriverInner`), fixing a type-confusion crash when the callbacks fire.
4546
- Ethernet: `mod eth` is enabled again on ESP-IDF 6.0+ when SPI Ethernet PHYs are provided as managed components (`espressif/w5500`, `espressif/dm9051`, `espressif/ksz8851snl`), not only via the removed in-tree `CONFIG_ETH_SPI_ETHERNET_*` Kconfig options
47+
- TLS: Server-side APIs (`ServerConfig`, `negotiate_server`, …) are available again on ESP-IDF >= 5.3 with mbedTLS. `CONFIG_ESP_TLS_SERVER` was removed in v5.3, so the old `esp_idf_esp_tls_server` cfg was never set and those APIs were compiled out; gating now uses `esp_idf_esp_tls_server` (IDF ≤ 5.2) or `esp_idf_version_at_least_5_3_0` + `esp_idf_esp_tls_using_mbedtls`.
4648

4749
### Added
4850
- Compatibility with ESP-IDF V6.0, and some pre-release 6.0.x.
4951
- Added support for the Generic Ethernet PHY driver: particularly useful on ESP-IDF 6.0+ as it is built-in.
5052
- Added early support for the NimBLE low-resource-use BLE stack, currently only GAP and GATT Server support. See examples/ble_gatt_server.rs
53+
- TLS: Async server handshake (requires ESP-IDF 5.5.0): `EspAsyncTls::negotiate_server`. Also check the new `tls_server_async` example.
5154

5255
## [0.52.1] - 2026-03-10
5356

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ futures = "0.3"
6868
serde = { version = "1", default-features = false, features = ["derive"] }
6969
serde_json = { version = "1", default-features = false, features = ["alloc"] }
7070
postcard = "1"
71-
async-io = "2"
71+
# `async-io-mini` is a fork of `async-io` optimized for ESP-IDF: much smaller
72+
# RAM footprint (no large reactor thread stack, no per-fd heap bookkeeping)
73+
async-io = { version = "0.4", package = "async-io-mini", default-features = false, features = ["futures-io"] }
7274

7375
# TODO: Remove these before next release, needed for IDF 6.0+ support
7476
[patch.crates-io]

examples/tls_async.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ mod example {
5252
//
5353
// To use async networking IO, make your `main()` minimal by just spawning all work in a new thread
5454
std::thread::Builder::new()
55-
.stack_size(60000) // EspTls uses very large TLS buffers (16K+)
55+
// Enough for the mbedTLS handshake processing; the large TLS buffers
56+
// live on the heap. Note that on ESP-IDF thread stacks are allocated
57+
// from the heap too, so an over-sized stack directly reduces the
58+
// memory available for TLS sessions
59+
.stack_size(20000)
5660
.spawn(run_main)
5761
.unwrap()
5862
.join()
@@ -65,8 +69,8 @@ mod example {
6569
// If you use `tokio`, you still have to do the same as it also uses the `eventfd` syscall
6670
let _mounted_eventfs = esp_idf_svc::io::vfs::MountedEventfs::mount(5)?;
6771

68-
// You can use `esp_idf_svc::hal::task::block_on` as well
69-
async_io::block_on(pin!(async move {
72+
// Any executor can drive the future; we just use the ESP-IDF-native `block_on`
73+
esp_idf_svc::hal::task::block_on(pin!(async move {
7074
let _wifi = wifi_create().await?; // Keep it around so that the wifi connection is not dropped
7175

7276
run().await?;

examples/tls_server_async.rs

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
//! Async TLS "Hello, world" HTTP/1.0 server using `EspAsyncTls::negotiate_server`
2+
//! (ESP-IDF >= 5.5.0, mbedTLS).
3+
//!
4+
//! Multiple connections are served concurrently, each by its own task spawned
5+
//! on a local executor.
6+
//!
7+
//! Add your own ssid and password, flash, then point your browser to
8+
//! `https://<esp-ip>` (the demo cert is self-signed, so accept the browser
9+
//! warning), or run:
10+
//!
11+
//! ```text
12+
//! curl -k https://<esp-ip>
13+
//! ```
14+
15+
#![allow(unknown_lints)]
16+
#![allow(unexpected_cfgs)]
17+
18+
#[cfg(all(
19+
not(any(esp32h2, esp32h4, esp32p4)),
20+
esp_idf_esp_tls_using_mbedtls,
21+
esp_idf_version_at_least_5_5_0,
22+
))]
23+
fn main() -> anyhow::Result<()> {
24+
example::main()
25+
}
26+
27+
#[cfg(any(esp32h2, esp32h4, esp32p4))]
28+
fn main() -> anyhow::Result<()> {
29+
panic!("ESP32-H2, ESP32-H4 and ESP32-P4 do not have a Wifi radio (but you could enable the esp-wifi-remote component to use them with a WiFi co-processor)");
30+
}
31+
32+
#[cfg(all(
33+
not(any(esp32h2, esp32h4, esp32p4)),
34+
not(all(esp_idf_esp_tls_using_mbedtls, esp_idf_version_at_least_5_5_0)),
35+
))]
36+
fn main() -> anyhow::Result<()> {
37+
panic!(
38+
"This example requires ESP-IDF ≥ 5.5.0 with the mbedTLS ESP-TLS stack \
39+
(CONFIG_ESP_TLS_USING_MBEDTLS=y) for EspAsyncTls::negotiate_server."
40+
);
41+
}
42+
43+
#[cfg(all(
44+
not(any(esp32h2, esp32h4, esp32p4)),
45+
esp_idf_esp_tls_using_mbedtls,
46+
esp_idf_version_at_least_5_5_0,
47+
))]
48+
mod example {
49+
use core::cell::Cell;
50+
use core::pin::pin;
51+
52+
use std::net::{TcpListener, TcpStream};
53+
use std::os::fd::{AsRawFd, IntoRawFd};
54+
use std::rc::Rc;
55+
use std::time::Duration;
56+
57+
use async_io::Async;
58+
59+
use futures::executor::{LocalPool, LocalSpawner};
60+
use futures::future::{select, Either};
61+
use futures::task::LocalSpawnExt;
62+
63+
use esp_idf_svc::log::EspLogger;
64+
use esp_idf_svc::sys::EspError;
65+
use esp_idf_svc::timer::EspTaskTimerService;
66+
use esp_idf_svc::tls::{EspAsyncTls, ServerConfig, X509};
67+
68+
use log::{info, warn};
69+
70+
const SSID: &str = env!("WIFI_SSID");
71+
const PASSWORD: &str = env!("WIFI_PASS");
72+
73+
const PORT: u16 = 443;
74+
75+
/// Each TLS session allocates tens of KBs of heap (mbedTLS buffers), so
76+
/// too many concurrent sessions exhaust the heap and fail with
77+
/// `MBEDTLS_ERR_SSL_ALLOC_FAILED` (-0x7F00); connections beyond the cap
78+
/// are dropped at accept time. Browsers easily hit this by opening
79+
/// several speculative sockets at once.
80+
const MAX_SESSIONS: usize = 2;
81+
82+
/// Drop peers that never finish the handshake. `tls_handshake_timeout_ms`
83+
/// is only honored by the blocking `EspTls::negotiate_server`, so the async
84+
/// handshake is raced against a timer instead.
85+
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
86+
87+
// Demo self-signed cert/key. Generate your own with:
88+
// openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \
89+
// -days 3650 -nodes -subj "/CN=esp32.local"
90+
// Trailing "\0": ESP-TLS expects NUL-terminated PEM.
91+
const SERVER_CERT: &[u8] = concat!(include_str!("tls_server_cert.pem"), "\0").as_bytes();
92+
const SERVER_KEY: &[u8] = concat!(include_str!("tls_server_key.pem"), "\0").as_bytes();
93+
94+
/// The canned response served to every request. HTTP/1.0 with
95+
/// `Connection: close`, so the connection is simply closed after the
96+
/// response and no keep-alive/chunked machinery is necessary.
97+
const RESPONSE: &[u8] = b"HTTP/1.0 200 OK\r\n\
98+
Connection: close\r\n\
99+
Content-Type: text/plain\r\n\
100+
Content-Length: 13\r\n\
101+
\r\n\
102+
Hello, world!";
103+
104+
pub fn main() -> anyhow::Result<()> {
105+
esp_idf_svc::sys::link_patches();
106+
EspLogger::initialize_default();
107+
108+
// This thread is necessary because the ESP IDF main task thread is running with a very low priority that cannot be raised
109+
// (lower than the hidden posix thread in `async-io-mini`)
110+
// As a result, the main thread is constantly starving because of the higher prio `async-io-mini` thread
111+
//
112+
// To use async networking IO, make your `main()` minimal by just spawning all work in a new thread
113+
std::thread::Builder::new()
114+
// Enough for the mbedTLS handshake processing; the large TLS buffers
115+
// live on the heap. Note that on ESP-IDF thread stacks are allocated
116+
// from the heap too, so an over-sized stack directly reduces the
117+
// memory available for TLS sessions
118+
.stack_size(20000)
119+
.spawn(run_main)
120+
.unwrap()
121+
.join()
122+
.unwrap()
123+
}
124+
125+
fn run_main() -> anyhow::Result<()> {
126+
// `async-io-mini` uses the ESP IDF `eventfd` syscall to implement async IO.
127+
// If you use `tokio`, you still have to do the same as it also uses the `eventfd` syscall
128+
let _mounted_eventfs = esp_idf_svc::io::vfs::MountedEventfs::mount(5)?;
129+
130+
// A local executor rather than a plain `block_on`, so that each accepted
131+
// connection can be served concurrently by its own spawned task
132+
let mut pool = LocalPool::new();
133+
let spawner = pool.spawner();
134+
135+
pool.run_until(async move {
136+
let wifi = wifi_create().await?; // Keep it around so that the wifi connection is not dropped
137+
138+
let ip_info = wifi.sta_netif().get_ip_info()?;
139+
140+
run(&spawner, ip_info.ip).await
141+
})
142+
}
143+
144+
async fn run(spawner: &LocalSpawner, ip: impl core::fmt::Display) -> anyhow::Result<()> {
145+
let listener = Async::<TcpListener>::bind(([0, 0, 0, 0], PORT))?;
146+
147+
info!(
148+
"TLS Hello-World server on port {PORT}; point your browser to https://{ip}:{PORT} \
149+
(accept the self-signed cert warning) or run `curl -k https://{ip}:{PORT}`"
150+
);
151+
152+
// All tasks run on the same thread, so a plain `Rc<Cell>` is enough
153+
// to track the number of active sessions
154+
let active = Rc::new(Cell::new(0));
155+
156+
loop {
157+
let (stream, peer) = listener.accept().await?;
158+
159+
if active.get() >= MAX_SESSIONS {
160+
warn!("Too many sessions, dropping {peer}");
161+
continue;
162+
}
163+
164+
info!("Accepted {peer}");
165+
166+
active.set(active.get() + 1);
167+
168+
let active = active.clone();
169+
170+
spawner.spawn_local(async move {
171+
if let Err(e) = handle(stream).await {
172+
warn!("Connection to {peer} failed: {e:?}");
173+
}
174+
175+
active.set(active.get() - 1);
176+
})?;
177+
}
178+
}
179+
180+
async fn handle(stream: Async<TcpStream>) -> anyhow::Result<()> {
181+
let mut tls = EspAsyncTls::adopt(EspTlsSocket::new(stream))?;
182+
183+
let cfg = ServerConfig {
184+
server_cert: Some(X509::pem_until_nul(SERVER_CERT)),
185+
server_key: Some(X509::pem_until_nul(SERVER_KEY)),
186+
..ServerConfig::new()
187+
};
188+
189+
let mut timer = EspTaskTimerService::new()?.timer_async()?;
190+
191+
{
192+
let tls_task = pin!(tls.negotiate_server(&cfg));
193+
let timer_task = pin!(timer.after(HANDSHAKE_TIMEOUT));
194+
195+
match select(tls_task, timer_task).await {
196+
Either::Left((res, _)) => res?,
197+
Either::Right(_) => anyhow::bail!("Handshake timed out"),
198+
}
199+
}
200+
201+
info!("Handshake complete");
202+
203+
// Read the request up to the end of its headers (an empty line);
204+
// this demo replies the same to every request, so the contents are ignored.
205+
// Requests with a body (e.g. POST) are not handled, as the body would
206+
// arrive after the headers and is simply never read.
207+
let mut buf = [0; 1024];
208+
let mut len = 0;
209+
210+
loop {
211+
let n = tls.read(&mut buf[len..]).await?;
212+
if n == 0 {
213+
info!("Peer closed the connection mid-request");
214+
return Ok(());
215+
}
216+
217+
len += n;
218+
219+
if buf[..len].windows(4).any(|w| w == b"\r\n\r\n") {
220+
break;
221+
}
222+
223+
if len == buf.len() {
224+
// Request headers bigger than our buffer; just respond anyway
225+
break;
226+
}
227+
}
228+
229+
tls.write_all(RESPONSE).await?;
230+
231+
info!("Request served");
232+
233+
Ok(())
234+
}
235+
236+
async fn wifi_create() -> Result<esp_idf_svc::wifi::EspWifi<'static>, EspError> {
237+
use esp_idf_svc::eventloop::*;
238+
use esp_idf_svc::hal::peripherals::Peripherals;
239+
use esp_idf_svc::nvs::*;
240+
use esp_idf_svc::timer::*;
241+
use esp_idf_svc::wifi::*;
242+
243+
let sys_loop = EspSystemEventLoop::take().unwrap();
244+
let timer_service = EspTimerService::new().unwrap();
245+
let nvs = EspDefaultNvsPartition::take().unwrap();
246+
247+
let peripherals = Peripherals::take()?;
248+
249+
let mut esp_wifi = EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs.clone()))?;
250+
let mut wifi = AsyncWifi::wrap(&mut esp_wifi, sys_loop.clone(), timer_service.clone())?;
251+
252+
wifi.set_configuration(&Configuration::Client(ClientConfiguration {
253+
ssid: SSID.try_into().unwrap(),
254+
password: PASSWORD.try_into().unwrap(),
255+
..Default::default()
256+
}))?;
257+
258+
wifi.start().await?;
259+
info!("Wifi started");
260+
261+
wifi.connect().await?;
262+
info!("Wifi connected");
263+
264+
wifi.wait_netif_up().await?;
265+
info!("Wifi netif up");
266+
267+
Ok(esp_wifi)
268+
}
269+
270+
//
271+
// Implement `esp_idf_svc::tls::PollableSocket` for `async-io` sockets
272+
// you can do the same for e.g. `tokio` if you plan to use `tokio` instead of `async-io`
273+
//
274+
275+
pub struct EspTlsSocket(Option<async_io::Async<TcpStream>>);
276+
277+
impl EspTlsSocket {
278+
pub const fn new(socket: async_io::Async<TcpStream>) -> Self {
279+
Self(Some(socket))
280+
}
281+
282+
pub fn handle(&self) -> i32 {
283+
self.0.as_ref().unwrap().as_raw_fd()
284+
}
285+
286+
pub fn poll_readable(
287+
&self,
288+
ctx: &mut core::task::Context,
289+
) -> core::task::Poll<Result<(), esp_idf_svc::sys::EspError>> {
290+
self.0
291+
.as_ref()
292+
.unwrap()
293+
.poll_readable(ctx)
294+
.map_err(|_| EspError::from_infallible::<{ esp_idf_svc::sys::ESP_FAIL }>())
295+
}
296+
297+
pub fn poll_writeable(
298+
&self,
299+
ctx: &mut core::task::Context,
300+
) -> core::task::Poll<Result<(), esp_idf_svc::sys::EspError>> {
301+
self.0
302+
.as_ref()
303+
.unwrap()
304+
.poll_writable(ctx)
305+
.map_err(|_| EspError::from_infallible::<{ esp_idf_svc::sys::ESP_FAIL }>())
306+
}
307+
308+
fn release(&mut self) -> Result<(), esp_idf_svc::sys::EspError> {
309+
let socket = self.0.take().unwrap();
310+
let _ = socket.into_inner().unwrap().into_raw_fd();
311+
312+
Ok(())
313+
}
314+
}
315+
316+
impl esp_idf_svc::tls::Socket for EspTlsSocket {
317+
fn handle(&self) -> i32 {
318+
EspTlsSocket::handle(self)
319+
}
320+
321+
fn release(&mut self) -> Result<(), esp_idf_svc::sys::EspError> {
322+
EspTlsSocket::release(self)
323+
}
324+
}
325+
326+
impl esp_idf_svc::tls::PollableSocket for EspTlsSocket {
327+
fn poll_readable(
328+
&self,
329+
ctx: &mut core::task::Context,
330+
) -> core::task::Poll<Result<(), esp_idf_svc::sys::EspError>> {
331+
EspTlsSocket::poll_readable(self, ctx)
332+
}
333+
334+
fn poll_writable(
335+
&self,
336+
ctx: &mut core::task::Context,
337+
) -> core::task::Poll<Result<(), esp_idf_svc::sys::EspError>> {
338+
EspTlsSocket::poll_writeable(self, ctx)
339+
}
340+
}
341+
}

0 commit comments

Comments
 (0)