confium_net_quic/transport.rs
1//! Connected QUIC transport, shared framing, and the registry kind.
2//!
3//! [`QuicTransport`] owns one bidirectional QUIC stream on a Quinn
4//! `Connection`. Each `send` is written as a 4-byte big-endian length
5//! prefix followed by the payload; each `recv` reads exactly one framed
6//! message. This mirrors the TCP transport so cross-transport message
7//! semantics are identical: one `send` is observed as exactly one
8//! `recv` payload, even though the underlying QUIC stream is a byte
9//! stream.
10
11use std::net::IpAddr;
12use std::net::Ipv4Addr;
13use std::net::Ipv6Addr;
14use std::net::SocketAddr;
15
16use quinn::Connection;
17use quinn::Endpoint;
18use url::Url;
19
20use confium_net::Listener;
21use confium_net::Result;
22use confium_net::Transport;
23use confium_net::error::ClosedSnafu;
24use confium_net::error::MalformedUrlSnafu;
25use confium_net::registry::TransportKind;
26
27use crate::runtime::Handle;
28use crate::tls;
29
30/// Maximum payload size for a single frame (8 MiB). Guards against a
31/// malicious or buggy peer sending a gigantic length prefix that would
32/// cause the receiver to allocate unbounded memory. Matches the TCP
33/// transport's limit.
34pub(crate) const MAX_FRAME_LEN: u32 = 8 * 1024 * 1024;
35
36/// Connected QUIC transport. Owns the async runtime, the Quinn
37/// endpoint, the connection, and the bidirectional stream used for
38/// framed messages.
39pub struct QuicTransport {
40 rt: Handle,
41 // Order matters: fields drop in declaration order. The connection
42 // must outlive the streams (they borrow its internal state), and
43 // the endpoint must outlive the connection (it owns the UDP
44 // socket the connection sends over).
45 endpoint: Option<Endpoint>,
46 conn: Option<Connection>,
47 stream: Option<quinn::SendStream>,
48 recv: Option<quinn::RecvStream>,
49}
50
51impl QuicTransport {
52 /// Wrap an already-accepted connection (server side). Used by
53 /// [`crate::listener::QuicListener`] for inbound peers.
54 ///
55 /// Completes a 1-byte handshake so the client's `connect()` knows
56 /// the server is ready before the client is allowed to send data
57 /// or tear the connection down. Without this handshake a client
58 /// that connects, sends, and closes quickly can race the server's
59 /// `accept_bi` (see the note on connection establishment order in
60 /// [`QuicTransport::connect`]).
61 pub(crate) fn from_connection(rt: Handle, conn: Connection) -> std::io::Result<Self> {
62 let (mut send, mut recv) = rt
63 .block_on(conn.accept_bi())
64 .map_err(|e| io_error("accept_bi (server)", e))?;
65 // Read the client's SYN byte, then write the ACK byte. Both
66 // are bare single bytes outside the length-prefix framing —
67 // they exist only to coordinate stream readiness and are
68 // consumed entirely before any framed data crosses the wire.
69 rt.block_on(async {
70 let mut syn = [0u8; 1];
71 loop {
72 match recv
73 .read(&mut syn)
74 .await
75 .map_err(|e| io_error("handshake syn read", e))?
76 {
77 None => {
78 return Err(std::io::Error::new(
79 std::io::ErrorKind::UnexpectedEof,
80 "no SYN byte from client",
81 ));
82 }
83 Some(0) => continue,
84 Some(_) => break,
85 }
86 }
87 send.write_all(b"\x01")
88 .await
89 .map_err(|e| io_error("handshake ack write", e))
90 })?;
91 Ok(Self {
92 rt,
93 endpoint: None,
94 conn: Some(conn),
95 stream: Some(send),
96 recv: Some(recv),
97 })
98 }
99
100 /// Connect a new endpoint to `host:port`, honoring the
101 /// address-family hint `quic4` / `quic6` / `quic` encoded in
102 /// `scheme`.
103 ///
104 /// After the QUIC handshake completes, the client opens a
105 /// bidirectional stream and completes a 1-byte SYN/ACK exchange
106 /// with the server. `connect()` does not return until the server
107 /// has acknowledged, which closes the establishment-order race:
108 /// the client cannot send data or drop the connection before the
109 /// server has called `from_connection` and is ready to receive.
110 pub(crate) fn connect(scheme: &str, host: &str, port: u16) -> std::io::Result<Self> {
111 let rt = Handle::new()?;
112 let addr = resolve_addr(scheme, host, port)?;
113
114 let client_cfg = tls::client_config().map_err(|e| io_error_str("client TLS config", &e))?;
115
116 // Bind a client-side endpoint on the matching address family.
117 // Quinn requires a local socket even for clients; bind to the
118 // wildcard of the same family as the destination so the kernel
119 // picks a route.
120 let bind_addr = match addr {
121 SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
122 SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0),
123 };
124
125 // Everything from here runs inside `block_on` so quinn's
126 // internal driver tasks (spawned via `tokio::spawn` when the
127 // endpoint starts processing) are driven by this runtime.
128 let (endpoint, conn) = rt.block_on(async {
129 let endpoint =
130 Endpoint::client(bind_addr).map_err(|e| io_error("bind client endpoint", e))?;
131 let conn = endpoint
132 .connect_with(client_cfg, addr, "localhost")
133 .map_err(|e| io_error("start connect", e))?
134 .await
135 .map_err(|e| io_error("connect handshake", e))?;
136 Ok::<(Endpoint, Connection), std::io::Error>((endpoint, conn))
137 })?;
138
139 // Open the bidirectional stream and complete the SYN/ACK
140 // handshake (see `from_connection`). The SYN byte also forces
141 // quinn to emit a STREAM frame immediately — without it,
142 // `open_bi` is lazy and the server's `accept_bi` would not
143 // resolve until the first real `send`, which is too late for
144 // the establishment-order race described above.
145 let (mut send, mut recv) = rt
146 .block_on(conn.open_bi())
147 .map_err(|e| io_error("open_bi (client)", e))?;
148 rt.block_on(async {
149 send.write_all(b"\x01")
150 .await
151 .map_err(|e| io_error("handshake syn write", e))?;
152 let mut ack = [0u8; 1];
153 loop {
154 match recv
155 .read(&mut ack)
156 .await
157 .map_err(|e| io_error("handshake ack read", e))?
158 {
159 None => {
160 return Err(std::io::Error::new(
161 std::io::ErrorKind::UnexpectedEof,
162 "no ACK byte from server",
163 ));
164 }
165 Some(0) => continue,
166 Some(_) => break,
167 }
168 }
169 Ok::<(), std::io::Error>(())
170 })?;
171
172 Ok(Self {
173 rt,
174 endpoint: Some(endpoint),
175 conn: Some(conn),
176 stream: Some(send),
177 recv: Some(recv),
178 })
179 }
180}
181
182impl Transport for QuicTransport {
183 fn send(&mut self, data: &[u8]) -> Result<()> {
184 let stream = match &mut self.stream {
185 Some(s) => s,
186 None => return ClosedSnafu.fail(),
187 };
188 self.rt
189 .block_on(async {
190 write_frame(stream, data).await?;
191 Ok::<(), std::io::Error>(())
192 })
193 .map_err(quinn_io_to_closed)?;
194 Ok(())
195 }
196
197 fn recv(&mut self, buf: &mut [u8]) -> Result<usize> {
198 let recv = match &mut self.recv {
199 Some(r) => r,
200 None => return ClosedSnafu.fail(),
201 };
202 match self.rt.block_on(read_frame(recv, buf)) {
203 Ok(Some(n)) => Ok(n),
204 Ok(None) => ClosedSnafu.fail(),
205 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => ClosedSnafu.fail(),
206 Err(e) => Err(quinn_io_to_closed(e)),
207 }
208 }
209
210 fn close(&mut self) -> Result<()> {
211 // Gracefully close the send half (signals FIN to the peer so
212 // its `recv` observes clean end-of-stream) and stop the recv
213 // half. After `finish` we sleep briefly so quinn's endpoint
214 // driver has a chance to flush the FIN and any pending stream
215 // data to the wire before the transport drops and tears down
216 // the connection. Without this, the implicit connection close
217 // that happens on Drop could race ahead of the flush and the
218 // peer would see a `Closed` instead of the buffered message.
219 //
220 // We deliberately do NOT call `reset` (discards in-flight
221 // data) or `Endpoint::close` / `Connection::close` (sends
222 // CONNECTION_CLOSE which aborts everything).
223 self.flush_and_close_streams();
224 Ok(())
225 }
226}
227
228impl Drop for QuicTransport {
229 fn drop(&mut self) {
230 self.flush_and_close_streams();
231 // Drop order (fields drop in declaration order): stream,
232 // recv, conn, endpoint. The connection is dropped before the
233 // endpoint, which lets quinn flush any pending stream data
234 // before releasing the UDP socket.
235 self.conn.take();
236 self.endpoint.take();
237 }
238}
239
240impl QuicTransport {
241 /// Shared teardown: finish the send half, stop the recv half, and
242 /// give the runtime a moment to flush before the caller drops the
243 /// connection. See [`QuicTransport::close`] for why the sleep is
244 /// necessary.
245 fn flush_and_close_streams(&mut self) {
246 if let Some(mut s) = self.stream.take() {
247 self.rt.block_on(async {
248 let _ = s.finish();
249 // Brief sleep — loopback round-trips in well under
250 // 10 ms in practice. We cannot use `SendStream::closed`
251 // (quinn does not expose it) to wait for a precise
252 // ack, so a conservative timer is the practical
253 // alternative.
254 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
255 });
256 }
257 if let Some(mut r) = self.recv.take() {
258 self.rt.block_on(async {
259 let _ = r.stop(0u32.into());
260 });
261 }
262 }
263}
264
265// ---- registry kind ---------------------------------------------------
266
267/// Registry kind for `quic`, `quic4`, `quic6`.
268pub struct QuicTransportKind;
269
270impl TransportKind for QuicTransportKind {
271 fn schemes(&self) -> &'static [&'static str] {
272 &["quic", "quic4", "quic6"]
273 }
274
275 fn connect(&self, url: &Url) -> Result<Box<dyn Transport>> {
276 let scheme = url.scheme();
277 let (host, port) = host_port(url, scheme)?;
278 match QuicTransport::connect(scheme, host, port) {
279 Ok(t) => Ok(Box::new(t)),
280 Err(_) => MalformedUrlSnafu {
281 scheme,
282 url: url.to_string(),
283 reason: "could not connect to peer",
284 }
285 .fail(),
286 }
287 }
288
289 fn listen(&self, url: &Url) -> Result<Box<dyn Listener>> {
290 let scheme = url.scheme();
291 let (host, port) = host_port(url, scheme)?;
292 match crate::listener::QuicListener::bind(scheme, host, port) {
293 Ok(l) => Ok(Box::new(l)),
294 Err(_) => MalformedUrlSnafu {
295 scheme,
296 url: url.to_string(),
297 reason: "could not bind to address",
298 }
299 .fail(),
300 }
301 }
302}
303
304// ---- shared helpers --------------------------------------------------
305
306/// Extract the `(host, port)` pair from a `quic*://` URL. The port is
307/// mandatory.
308pub(crate) fn host_port<'a>(url: &'a Url, scheme: &str) -> Result<(&'a str, u16)> {
309 let host = url.host_str().unwrap_or("");
310 if host.is_empty() {
311 return MalformedUrlSnafu {
312 scheme,
313 url: url.to_string(),
314 reason: "missing host (use quic://<host>:<port>)",
315 }
316 .fail();
317 }
318 let port = match url.port() {
319 Some(p) => p,
320 None => {
321 return MalformedUrlSnafu {
322 scheme,
323 url: url.to_string(),
324 reason: "missing port (use quic://<host>:<port>)",
325 }
326 .fail();
327 }
328 };
329 Ok((host, port))
330}
331
332/// Map a scheme to its address-family constraint: `Some(true)` for
333/// `quic6`, `Some(false)` for `quic4`, `None` for `quic` (any family).
334pub(crate) fn address_family(scheme: &str) -> Option<bool> {
335 match scheme {
336 "quic4" => Some(false),
337 "quic6" => Some(true),
338 _ => None,
339 }
340}
341
342/// Resolve `(scheme, host, port)` to a [`SocketAddr`], honoring the
343/// `quic4` / `quic6` family hint. For bare `quic`, the host must be an
344/// IP literal (no DNS — Confium transport URLs address concrete peers).
345///
346/// IPv6 hosts arrive bracketed from `url::Url` (e.g. `"[::1]"`); the
347/// brackets are stripped before parsing.
348pub(crate) fn resolve_addr(scheme: &str, host: &str, port: u16) -> std::io::Result<SocketAddr> {
349 let host = host
350 .strip_prefix('[')
351 .and_then(|h| h.strip_suffix(']'))
352 .unwrap_or(host);
353 match address_family(scheme) {
354 Some(false) => {
355 let ip: Ipv4Addr = host
356 .parse()
357 .map_err(|_| invalid_host(host, "not an IPv4 literal"))?;
358 Ok(SocketAddr::new(IpAddr::V4(ip), port))
359 }
360 Some(true) => {
361 let ip: Ipv6Addr = host
362 .parse()
363 .map_err(|_| invalid_host(host, "not an IPv6 literal"))?;
364 Ok(SocketAddr::new(IpAddr::V6(ip), port))
365 }
366 None => {
367 let ip: IpAddr = host
368 .parse()
369 .map_err(|_| invalid_host(host, "not an IP literal"))?;
370 Ok(SocketAddr::new(ip, port))
371 }
372 }
373}
374
375fn invalid_host(host: &str, reason: &str) -> std::io::Error {
376 std::io::Error::new(
377 std::io::ErrorKind::InvalidInput,
378 format!("invalid host '{host}': {reason}"),
379 )
380}
381
382// ---- async framing ---------------------------------------------------
383
384/// Write one length-prefixed frame to the Quinn send stream.
385async fn write_frame(w: &mut quinn::SendStream, data: &[u8]) -> std::io::Result<()> {
386 let len = u32::try_from(data.len()).map_err(|_| {
387 std::io::Error::new(
388 std::io::ErrorKind::InvalidInput,
389 "frame exceeds 4 GiB length-prefix limit",
390 )
391 })?;
392 w.write_all(&len.to_be_bytes()).await?;
393 w.write_all(data).await?;
394 // Do not call `finish()` here: we keep the stream open for further
395 // frames. The peer reads framed messages back-to-back.
396 Ok(())
397}
398
399/// Read exactly one length-prefixed frame from the Quinn receive
400/// stream into `buf`. Returns `Ok(None)` on clean EOF at a frame
401/// boundary.
402async fn read_frame(r: &mut quinn::RecvStream, buf: &mut [u8]) -> std::io::Result<Option<usize>> {
403 let mut prefix = [0u8; 4];
404 if !fill_exact(r, &mut prefix).await? {
405 return Ok(None);
406 }
407 let len = u32::from_be_bytes(prefix);
408 if len > MAX_FRAME_LEN {
409 return Err(std::io::Error::new(
410 std::io::ErrorKind::InvalidData,
411 format!("frame length {len} exceeds maximum {MAX_FRAME_LEN}"),
412 ));
413 }
414 let len = len as usize;
415 let n = std::cmp::min(len, buf.len());
416 read_exact(r, &mut buf[..n]).await?;
417 // Drain and discard any bytes beyond the caller's buffer so the
418 // stream is positioned at the start of the next frame.
419 let mut remaining = len - n;
420 let mut sink = [0u8; 4096];
421 while remaining > 0 {
422 let want = remaining.min(sink.len());
423 match r.read(&mut sink[..want]).await? {
424 None => {
425 return Err(std::io::Error::new(
426 std::io::ErrorKind::UnexpectedEof,
427 "stream closed mid-frame",
428 ));
429 }
430 Some(0) => {
431 return Err(std::io::Error::new(
432 std::io::ErrorKind::UnexpectedEof,
433 "stream closed mid-frame",
434 ));
435 }
436 Some(got) => remaining -= got,
437 }
438 }
439 Ok(Some(n))
440}
441
442async fn fill_exact(r: &mut quinn::RecvStream, buf: &mut [u8]) -> std::io::Result<bool> {
443 let mut filled = 0;
444 while filled < buf.len() {
445 match r.read(&mut buf[filled..]).await? {
446 None | Some(0) => {
447 if filled == 0 {
448 return Ok(false);
449 }
450 return Err(std::io::Error::new(
451 std::io::ErrorKind::UnexpectedEof,
452 "stream closed inside length prefix",
453 ));
454 }
455 Some(n) => filled += n,
456 }
457 }
458 Ok(true)
459}
460
461async fn read_exact(r: &mut quinn::RecvStream, buf: &mut [u8]) -> std::io::Result<()> {
462 let mut filled = 0;
463 while filled < buf.len() {
464 match r.read(&mut buf[filled..]).await? {
465 None | Some(0) => {
466 return Err(std::io::Error::new(
467 std::io::ErrorKind::UnexpectedEof,
468 "stream closed mid-frame",
469 ));
470 }
471 Some(n) => filled += n,
472 }
473 }
474 Ok(())
475}
476
477// ---- error mapping ---------------------------------------------------
478
479fn io_error<E: std::fmt::Display>(ctx: &str, e: E) -> std::io::Error {
480 std::io::Error::other(format!("{ctx}: {e}"))
481}
482
483fn io_error_str(ctx: &str, e: &str) -> std::io::Error {
484 std::io::Error::other(format!("{ctx}: {e}"))
485}
486
487/// Map an [`std::io::Error`] from the async QUIC path to a
488/// [`confium_net::Error`]. As with the TCP transport, the confium-net
489/// error type does not carry an arbitrary I/O source, so QUIC failures
490/// are reported as `Closed` — the transport is unusable after an I/O
491/// failure.
492pub(crate) fn quinn_io_to_closed(_: std::io::Error) -> confium_net::Error {
493 ClosedSnafu.build()
494}