Skip to main content

moqtap_proxy/
listener.rs

1//! Unified listener — one UDP endpoint that accepts both raw-QUIC MoQT
2//! and WebTransport clients, dispatching per connection based on the
3//! ALPN the client negotiated during the TLS handshake.
4
5use std::net::SocketAddr;
6use std::sync::Arc;
7
8use moqtap_codec::version::DraftVersion;
9use rustls::pki_types::{CertificateDer, PrivateKeyDer};
10
11use crate::error::ProxyError;
12
13/// WebTransport ALPN identifier.
14const H3_ALPN: &[u8] = b"h3";
15
16/// Configuration for the proxy's listener.
17pub struct ListenerConfig {
18    /// Address to bind to (e.g., `"0.0.0.0:4443"`).
19    pub bind_addr: SocketAddr,
20    /// TLS certificate chain (DER-encoded).
21    pub cert_chain: Vec<CertificateDer<'static>>,
22    /// TLS private key (DER-encoded).
23    pub key_der: PrivateKeyDer<'static>,
24}
25
26/// A client connection that has completed its handshake and is ready
27/// for MoQT session handling.
28///
29/// Produced by [`Listener::accept`]. Each variant corresponds to a
30/// distinct client-facing transport that MoQT can run over.
31pub enum AcceptedConn {
32    /// Raw QUIC connection speaking MoQT directly. The negotiated ALPN
33    /// (`moq-00`, `moqt-15`, `moqt-16`, `moqt-17`, …) is returned so
34    /// callers can resolve the draft version.
35    Quic {
36        /// The accepted QUIC connection.
37        conn: quinn::Connection,
38        /// The ALPN negotiated with the client.
39        alpn: Vec<u8>,
40    },
41    /// WebTransport session, with the H3 + extended-CONNECT dance
42    /// already completed by the listener.
43    #[cfg(feature = "webtransport")]
44    WebTransport(wtransport::Connection),
45}
46
47/// Build the ALPN list the server advertises to clients — every MoQT
48/// QUIC ALPN we support, plus `h3` when the WebTransport feature is on.
49///
50/// The list is derived from [`DraftVersion::quic_alpn`] so adding a new
51/// draft there automatically flows through to the proxy with no other
52/// changes required.
53fn advertised_alpns() -> Vec<Vec<u8>> {
54    // Dedup: drafts 07–14 all map to `moq-00`, so iterate every draft
55    // and keep unique ALPNs.
56    let mut out: Vec<Vec<u8>> = Vec::new();
57    for d in [
58        DraftVersion::Draft07,
59        DraftVersion::Draft08,
60        DraftVersion::Draft09,
61        DraftVersion::Draft10,
62        DraftVersion::Draft11,
63        DraftVersion::Draft12,
64        DraftVersion::Draft13,
65        DraftVersion::Draft14,
66        DraftVersion::Draft15,
67        DraftVersion::Draft16,
68        DraftVersion::Draft17,
69        DraftVersion::Draft18,
70    ] {
71        let alpn = d.quic_alpn().to_vec();
72        if !out.iter().any(|existing| existing == &alpn) {
73            out.push(alpn);
74        }
75    }
76    #[cfg(feature = "webtransport")]
77    out.push(H3_ALPN.to_vec());
78    out
79}
80
81/// A transport-agnostic MoQT listener that accepts both raw-QUIC and
82/// WebTransport clients on the same UDP port.
83pub struct Listener {
84    endpoint: quinn::Endpoint,
85}
86
87impl Listener {
88    /// Bind to the configured address and start listening.
89    ///
90    /// The listener advertises every supported MoQT ALPN (`moq-00` and
91    /// `moqt-<N>` for all known drafts) plus `h3` for WebTransport. The
92    /// client picks which one to speak; the proxy forwards whatever
93    /// arrives.
94    pub fn bind(config: ListenerConfig) -> Result<Self, ProxyError> {
95        let mut server_tls = rustls::ServerConfig::builder()
96            .with_no_client_auth()
97            .with_single_cert(config.cert_chain, config.key_der)
98            .map_err(|e| ProxyError::TlsConfig(format!("server cert config: {e}")))?;
99
100        server_tls.alpn_protocols = advertised_alpns();
101        server_tls.max_early_data_size = u32::MAX;
102
103        let quic_server_config: quinn::crypto::rustls::QuicServerConfig =
104            server_tls.try_into().map_err(|e| ProxyError::TlsConfig(format!("{e}")))?;
105
106        let server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_server_config));
107
108        let endpoint = quinn::Endpoint::server(server_config, config.bind_addr)
109            .map_err(|e| ProxyError::Listener(e.to_string()))?;
110
111        Ok(Self { endpoint })
112    }
113
114    /// Accept the next incoming connection and dispatch based on the
115    /// ALPN negotiated during the TLS handshake.
116    ///
117    /// Raw-QUIC connections are returned immediately with the negotiated
118    /// ALPN so the caller can pick the MoQT draft. For `h3` clients the
119    /// listener drives the HTTP/3 + extended-CONNECT handshake to
120    /// completion before returning a ready `wtransport::Connection`.
121    pub async fn accept(&self) -> Result<AcceptedConn, ProxyError> {
122        let incoming = self
123            .endpoint
124            .accept()
125            .await
126            .ok_or_else(|| ProxyError::Listener("endpoint closed".to_string()))?;
127
128        let mut connecting = incoming.accept().map_err(|e| ProxyError::Listener(e.to_string()))?;
129
130        // Peeking at handshake_data resolves as soon as the server has
131        // processed the ClientHello, so the ALPN is known before the
132        // full handshake completes — and the Connecting is still live.
133        let hs_data = connecting
134            .handshake_data()
135            .await
136            .map_err(|e| ProxyError::Listener(format!("handshake data: {e}")))?;
137
138        let alpn = hs_data
139            .downcast::<quinn::crypto::rustls::HandshakeData>()
140            .ok()
141            .and_then(|hd| hd.protocol)
142            .map(|p| p.to_vec())
143            .unwrap_or_default();
144
145        if alpn == H3_ALPN {
146            #[cfg(feature = "webtransport")]
147            {
148                let session_fut =
149                    wtransport::endpoint::IncomingSessionFuture::with_quic_connecting(connecting);
150                let session_request = session_fut
151                    .await
152                    .map_err(|e| ProxyError::Listener(format!("webtransport handshake: {e}")))?;
153                let conn = session_request
154                    .accept()
155                    .await
156                    .map_err(|e| ProxyError::Listener(format!("webtransport accept: {e}")))?;
157                Ok(AcceptedConn::WebTransport(conn))
158            }
159            #[cfg(not(feature = "webtransport"))]
160            {
161                drop(connecting);
162                Err(ProxyError::Listener(
163                    "client negotiated h3 but webtransport feature is not enabled".to_string(),
164                ))
165            }
166        } else {
167            let conn = connecting.await.map_err(|e| ProxyError::Listener(e.to_string()))?;
168            Ok(AcceptedConn::Quic { conn, alpn })
169        }
170    }
171
172    /// Get the local address this listener is bound to.
173    pub fn local_addr(&self) -> Result<SocketAddr, ProxyError> {
174        self.endpoint.local_addr().map_err(|e| ProxyError::Listener(e.to_string()))
175    }
176
177    /// Stop accepting new connections.
178    pub fn close(&self) {
179        self.endpoint.close(0u32.into(), b"proxy shutting down");
180    }
181}