Skip to content

Commit c99ecb4

Browse files
Frandoclaude
andcommitted
test: add ECN transparency tests using noq-udp
Verify that ECN bits survive end-to-end through patchbay's network stack. UDP tests use noq-udp's sendmsg/recvmsg cmsg handling (the same path QUIC uses in production) to set ECN on the sender and read it back on the receiver, rather than just checking the local socket option. Four tests: - ecn_bits_preserved_direct: ECT(0) through veth+bridge - ecn_bits_preserved_through_nat: ECT(0) through NAT masquerade - ecn_all_codepoints: ECT(0), ECT(1), and CE through direct path - tcp_ecn_negotiation: TCP ECN via sysctl, verified with TCP_INFO Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent f2582ba commit c99ecb4

4 files changed

Lines changed: 293 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

patchbay/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,6 @@ ctor = "0.6"
3737
futures-buffered = "0.2"
3838
hickory-resolver = { version = "0.25", default-features = false, features = ["system-config", "tokio"] }
3939
n0-tracing-test = "0.3.0"
40+
noq-udp = "0.9"
41+
socket2 = "0.5"
4042
testdir = "0.9"

patchbay/src/tests/ecn.rs

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
//! ECN (Explicit Congestion Notification) transparency tests.
2+
//!
3+
//! Verifies that ECN bits in the IP TOS field survive end-to-end through
4+
//! patchbay's network stack (veth pairs, bridges, NAT).
5+
//!
6+
//! UDP tests use noq-udp to set ECN via sendmsg cmsg and read it back via
7+
//! recvmsg cmsg on the receiving side, matching how QUIC implementations
8+
//! handle ECN in production.
9+
10+
use std::{
11+
io::IoSliceMut,
12+
net::{IpAddr, SocketAddr, UdpSocket},
13+
os::fd::AsRawFd,
14+
};
15+
16+
use noq_udp::{EcnCodepoint, RecvMeta, Transmit, UdpSocketState};
17+
use socket2::{Domain, Protocol, SockAddr, Socket, Type};
18+
19+
use super::*;
20+
21+
fn udp4_pair(bind: SocketAddr) -> Result<(Socket, UdpSocketState)> {
22+
let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
23+
sock.bind(&SockAddr::from(bind))?;
24+
sock.set_nonblocking(true)?;
25+
let state = UdpSocketState::new((&sock).into())?;
26+
Ok((sock, state))
27+
}
28+
29+
fn send_ecn(
30+
sock: &Socket,
31+
state: &UdpSocketState,
32+
dst: SocketAddr,
33+
ecn: EcnCodepoint,
34+
) -> Result<()> {
35+
state.send(
36+
sock.into(),
37+
&Transmit {
38+
destination: dst,
39+
ecn: Some(ecn),
40+
contents: b"ECN_PROBE",
41+
segment_size: None,
42+
src_ip: None,
43+
},
44+
)?;
45+
Ok(())
46+
}
47+
48+
fn recv_ecn(sock: &Socket, state: &UdpSocketState) -> Result<Option<EcnCodepoint>> {
49+
let mut buf = [0u8; 128];
50+
let mut meta = RecvMeta::default();
51+
state.recv(
52+
sock.into(),
53+
&mut [IoSliceMut::new(&mut buf)],
54+
std::slice::from_mut(&mut meta),
55+
)?;
56+
Ok(meta.ecn)
57+
}
58+
59+
/// ECN bits are preserved through a direct (no NAT) path, verified via
60+
/// noq-udp sendmsg/recvmsg cmsg handling (the same path QUIC uses).
61+
#[tokio::test(flavor = "current_thread")]
62+
#[traced_test]
63+
async fn ecn_bits_preserved_direct() -> Result<()> {
64+
check_caps()?;
65+
let lab = Lab::new().await?;
66+
let dc = lab.add_router("dc").build().await?;
67+
let sender = lab.add_device("sender").uplink(dc.id()).build().await?;
68+
let receiver = lab.add_device("receiver").uplink(dc.id()).build().await?;
69+
70+
let recv_ip = receiver.ip().unwrap();
71+
let recv_addr = SocketAddr::new(IpAddr::V4(recv_ip), 22_000);
72+
73+
let (bound_tx, bound_rx) = oneshot::channel::<Result<()>>();
74+
75+
let recv_task = receiver.spawn(move |_| async move {
76+
let (sock, state) = udp4_pair(recv_addr)?;
77+
let tokio_sock = tokio::net::UdpSocket::from_std(UdpSocket::from(sock.try_clone()?))?;
78+
let _ = bound_tx.send(Ok(()));
79+
80+
tokio::time::timeout(Duration::from_secs(2), tokio_sock.readable()).await??;
81+
let ecn = recv_ecn(&sock, &state)?;
82+
assert_eq!(
83+
ecn,
84+
Some(EcnCodepoint::Ect0),
85+
"ECN should be ECT(0), got {ecn:?}"
86+
);
87+
anyhow::Ok(())
88+
})?;
89+
90+
bound_rx.await??;
91+
92+
let send_task = sender.spawn(move |_| async move {
93+
let (sock, state) = udp4_pair("0.0.0.0:0".parse()?)?;
94+
send_ecn(&sock, &state, recv_addr, EcnCodepoint::Ect0)?;
95+
anyhow::Ok(())
96+
})?;
97+
98+
send_task.await??;
99+
recv_task.await??;
100+
Ok(())
101+
}
102+
103+
/// ECN bits are preserved through NAT (masquerade).
104+
#[tokio::test(flavor = "current_thread")]
105+
#[traced_test]
106+
async fn ecn_bits_preserved_through_nat() -> Result<()> {
107+
check_caps()?;
108+
let lab = Lab::new().await?;
109+
let dc = lab.add_router("dc").build().await?;
110+
let nat = lab.add_router("nat").nat(Nat::Home).build().await?;
111+
let server = lab.add_device("server").uplink(dc.id()).build().await?;
112+
let client = lab.add_device("client").uplink(nat.id()).build().await?;
113+
114+
let server_ip = server.ip().unwrap();
115+
let server_addr = SocketAddr::new(IpAddr::V4(server_ip), 22_001);
116+
117+
let (bound_tx, bound_rx) = oneshot::channel::<Result<()>>();
118+
119+
let server_task = server.spawn(move |_| async move {
120+
let (sock, state) = udp4_pair(server_addr)?;
121+
let tokio_sock = tokio::net::UdpSocket::from_std(UdpSocket::from(sock.try_clone()?))?;
122+
let _ = bound_tx.send(Ok(()));
123+
124+
tokio::time::timeout(Duration::from_secs(2), tokio_sock.readable()).await??;
125+
let ecn = recv_ecn(&sock, &state)?;
126+
assert_eq!(
127+
ecn,
128+
Some(EcnCodepoint::Ect0),
129+
"ECN through NAT should be ECT(0), got {ecn:?}"
130+
);
131+
anyhow::Ok(())
132+
})?;
133+
134+
bound_rx.await??;
135+
136+
let client_task = client.spawn(move |_| async move {
137+
let (sock, state) = udp4_pair("0.0.0.0:0".parse()?)?;
138+
send_ecn(&sock, &state, server_addr, EcnCodepoint::Ect0)?;
139+
anyhow::Ok(())
140+
})?;
141+
142+
client_task.await??;
143+
server_task.await??;
144+
Ok(())
145+
}
146+
147+
/// All ECN codepoints (ECT(0), ECT(1), CE) survive a direct path.
148+
#[tokio::test(flavor = "current_thread")]
149+
#[traced_test]
150+
async fn ecn_all_codepoints() -> Result<()> {
151+
check_caps()?;
152+
let lab = Lab::new().await?;
153+
let dc = lab.add_router("dc").build().await?;
154+
let sender = lab.add_device("sender").uplink(dc.id()).build().await?;
155+
let receiver = lab.add_device("receiver").uplink(dc.id()).build().await?;
156+
157+
let recv_ip = receiver.ip().unwrap();
158+
let recv_addr = SocketAddr::new(IpAddr::V4(recv_ip), 22_003);
159+
160+
let (bound_tx, bound_rx) = oneshot::channel::<Result<()>>();
161+
162+
let recv_task = receiver.spawn(move |_| async move {
163+
let (sock, state) = udp4_pair(recv_addr)?;
164+
let tokio_sock = tokio::net::UdpSocket::from_std(UdpSocket::from(sock.try_clone()?))?;
165+
let _ = bound_tx.send(Ok(()));
166+
167+
for expected in [EcnCodepoint::Ect0, EcnCodepoint::Ect1, EcnCodepoint::Ce] {
168+
loop {
169+
tokio::time::timeout(Duration::from_secs(2), tokio_sock.readable()).await??;
170+
match recv_ecn(&sock, &state) {
171+
Ok(ecn) => {
172+
assert_eq!(ecn, Some(expected), "expected {expected:?}, got {ecn:?}");
173+
break;
174+
}
175+
Err(e)
176+
if e.downcast_ref::<std::io::Error>()
177+
.is_some_and(|e| e.kind() == std::io::ErrorKind::WouldBlock) =>
178+
{
179+
continue
180+
}
181+
Err(e) => return Err(e),
182+
}
183+
}
184+
}
185+
anyhow::Ok(())
186+
})?;
187+
188+
bound_rx.await??;
189+
190+
let send_task = sender.spawn(move |_| async move {
191+
let (sock, state) = udp4_pair("0.0.0.0:0".parse()?)?;
192+
for codepoint in [EcnCodepoint::Ect0, EcnCodepoint::Ect1, EcnCodepoint::Ce] {
193+
send_ecn(&sock, &state, recv_addr, codepoint)?;
194+
tokio::time::sleep(Duration::from_millis(10)).await;
195+
}
196+
anyhow::Ok(())
197+
})?;
198+
199+
send_task.await??;
200+
recv_task.await??;
201+
Ok(())
202+
}
203+
204+
/// TCP ECN negotiation (SYN with ECE+CWR) works through patchbay.
205+
#[tokio::test(flavor = "current_thread")]
206+
#[traced_test]
207+
async fn tcp_ecn_negotiation() -> Result<()> {
208+
check_caps()?;
209+
let lab = Lab::new().await?;
210+
let dc = lab.add_router("dc").build().await?;
211+
let server = lab.add_device("server").uplink(dc.id()).build().await?;
212+
let client = lab.add_device("client").uplink(dc.id()).build().await?;
213+
214+
let server_ip = server.ip().unwrap();
215+
let server_addr = SocketAddr::new(IpAddr::V4(server_ip), 22_002);
216+
217+
server.run_sync(|| {
218+
std::fs::write("/proc/sys/net/ipv4/tcp_ecn", "1")?;
219+
Ok(())
220+
})?;
221+
client.run_sync(|| {
222+
std::fs::write("/proc/sys/net/ipv4/tcp_ecn", "1")?;
223+
Ok(())
224+
})?;
225+
226+
let server_task = server.spawn(move |_| async move {
227+
let listener = tokio::net::TcpListener::bind(server_addr).await?;
228+
let (mut stream, _) = listener.accept().await?;
229+
use tokio::io::AsyncWriteExt;
230+
stream.write_all(b"ECN_OK").await?;
231+
stream.shutdown().await?;
232+
anyhow::Ok(())
233+
})?;
234+
235+
let client_result = client.spawn(move |_| async move {
236+
tokio::time::sleep(Duration::from_millis(50)).await;
237+
let mut stream = tokio::net::TcpStream::connect(server_addr).await?;
238+
use tokio::io::AsyncReadExt;
239+
let mut buf = [0u8; 16];
240+
let n = stream.read(&mut buf).await?;
241+
assert_eq!(&buf[..n], b"ECN_OK");
242+
243+
let raw_fd = stream.as_raw_fd();
244+
let mut info: libc::tcp_info = unsafe { std::mem::zeroed() };
245+
let mut len = size_of::<libc::tcp_info>() as libc::socklen_t;
246+
let ret = unsafe {
247+
libc::getsockopt(
248+
raw_fd,
249+
libc::IPPROTO_TCP,
250+
libc::TCP_INFO,
251+
&mut info as *mut _ as *mut libc::c_void,
252+
&mut len,
253+
)
254+
};
255+
assert_eq!(
256+
ret,
257+
0,
258+
"getsockopt TCP_INFO: {}",
259+
std::io::Error::last_os_error()
260+
);
261+
// tcpi_options bit 3 (0x08) is TCPI_OPT_ECN.
262+
let ecn_negotiated = info.tcpi_options & 0x08 != 0;
263+
assert!(
264+
ecn_negotiated,
265+
"TCP ECN should be negotiated (tcpi_options={:#04x})",
266+
info.tcpi_options
267+
);
268+
269+
anyhow::Ok(())
270+
})?;
271+
272+
client_result.await??;
273+
server_task.await??;
274+
Ok(())
275+
}

patchbay/src/tests/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ use crate::{check_caps, config};
3636
mod alloc;
3737
mod devtools;
3838
mod dns;
39+
mod ecn;
3940
mod firewall;
4041
mod hairpin;
4142
mod holepunch;

0 commit comments

Comments
 (0)