Skip to main content

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}