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;
12use crate::transport::{self, TransportInstaller, TransportProfile};
13use crate::types::Leg;
14
15/// WebTransport ALPN identifier.
16const H3_ALPN: &[u8] = b"h3";
17
18/// Configuration for the proxy's listener.
19pub struct ListenerConfig {
20    /// Address to bind to (e.g., `"0.0.0.0:4443"`).
21    pub bind_addr: SocketAddr,
22    /// TLS certificate chain (DER-encoded).
23    pub cert_chain: Vec<CertificateDer<'static>>,
24    /// TLS private key (DER-encoded).
25    pub key_der: PrivateKeyDer<'static>,
26    /// Optional QUIC transport parameters — flow-control windows, MTU,
27    /// keep-alive, congestion control — applied to every client
28    /// connection this listener accepts.
29    ///
30    /// `None` installs no parameters of its own: the leg then takes
31    /// [`ListenerConfig::transport_profile`] if it names one, and quinn's
32    /// defaults otherwise.
33    ///
34    /// Setting this **and** [`ListenerConfig::transport_profile`] is
35    /// refused by [`Listener::bind`] rather than merged, with
36    /// [`ProxyError::TransportConfigAndProfile`] naming
37    /// [`Leg::Client`] — see that variant for why no merge is possible.
38    pub transport_config: Option<Arc<quinn::TransportConfig>>,
39    /// The same parameters as [`ListenerConfig::transport_config`], as a
40    /// value that can be written down, checked and stored.
41    ///
42    /// `Some(_)` builds the client leg's `quinn::TransportConfig` from this
43    /// profile — through [`ListenerConfig::installer`], or through
44    /// [`transport::DefaultInstaller`] when there is none — and installs it
45    /// before the endpoint exists. A profile the installer refuses is
46    /// [`ProxyError::TransportProfile`], and nothing is bound.
47    ///
48    /// `None` installs no profile: the leg then takes
49    /// [`ListenerConfig::transport_config`] if it names one, and quinn's
50    /// defaults otherwise. It is the *only* alternative to
51    /// `transport_config`, never a companion to it: a leg naming both is
52    /// refused at bind time.
53    pub transport_profile: Option<TransportProfile>,
54    /// How [`ListenerConfig::transport_profile`] becomes the config this
55    /// leg installs.
56    ///
57    /// `None` uses [`transport::DefaultInstaller`], which applies the
58    /// profile over a fresh `quinn::TransportConfig::default()`. Supply one
59    /// to start from a base of your own instead — the trait exists because
60    /// a `quinn::TransportConfig` cannot be cloned, so the only way to have
61    /// a base *and* a profile is to build the base again for each leg.
62    ///
63    /// **Inert without a profile.** [`TransportInstaller::build`] takes a
64    /// profile, so an installer set beside an empty
65    /// [`ListenerConfig::transport_profile`] is never called and the leg
66    /// installs nothing. It is said here because a setting that is quietly
67    /// ignored is the failure this crate is least willing to hide.
68    ///
69    /// **It composes with a `qlog` spec** — named in plain code font
70    /// because that field exists only under the `qlog` feature, so a link
71    /// from this always-compiled one would not resolve. A leg carrying a
72    /// profile, a spec and an installer builds its config here, once, and
73    /// the capture sink is attached to what came back;
74    /// [`TransportInstaller::build`] returns an owned
75    /// `quinn::TransportConfig` precisely so that the two can stack.
76    pub installer: Option<Arc<dyn TransportInstaller>>,
77    /// Where this leg's QUIC-level capture is written, if it is captured at
78    /// all.
79    ///
80    /// `Some(_)` builds the client leg's `quinn::TransportConfig`, installs
81    /// the sink built from this spec on it, and hands that to the endpoint
82    /// — all before the endpoint exists, because quinn accepts a sink in
83    /// exactly one place and that place is a method which mutates a
84    /// `quinn::TransportConfig`. It composes with
85    /// [`ListenerConfig::transport_profile`], which is applied to the same
86    /// config first, and **not** with
87    /// [`ListenerConfig::transport_config`]: a leg naming a raw config and
88    /// a spec is refused at bind time with
89    /// [`ProxyError::TransportConfigAndQlog`] naming [`Leg::Client`], for
90    /// the reason written out on that variant.
91    ///
92    /// A spec on its own, with neither of the other two fields set, is
93    /// enough: the leg builds a `quinn::TransportConfig::default()` for the
94    /// sink to go on and installs it, rather than installing nothing and
95    /// leaving the capture attached to a config no connection uses.
96    ///
97    /// `None` is how a leg says it does not want a capture. A spec that
98    /// names no writer is not that: it is refused with
99    /// [`ProxyError::Qlog`], because a spec is how a caller *asks* for a
100    /// capture.
101    ///
102    /// # Single-use, and therefore refused on a proxy template
103    ///
104    /// A [`QlogSpec`] owns its writer and is consumed when it becomes a
105    /// sink, so it has no `Clone`. [`TransparentProxy`] copies its
106    /// [`ListenerConfig`] template to build the listener it binds, and a
107    /// copy has nothing it could hand over — so a spec set on a
108    /// `ProxyConfig` could only be taken, counted as configured and
109    /// delivered nowhere. `TransparentProxy::run` therefore **refuses** such
110    /// a template with [`ProxyError::QlogOnProxyTemplate`], before it binds,
111    /// rather than dropping the field and coming up: a proxy that ran anyway
112    /// would report success and leave the caller's file uncreated, which
113    /// reads as a run that produced no events.
114    ///
115    /// Capture a client leg by building the [`ListenerConfig`] here and
116    /// calling [`Listener::bind`] yourself, which is also the only shape in
117    /// which one capture per connection is expressible: one sink shared by
118    /// an endpoint's connections writes all of them into one file, behind
119    /// one preamble, with no record saying where one ends.
120    ///
121    /// [`ProxyError::TransportConfigAndQlog`]: crate::error::ProxyError::TransportConfigAndQlog
122    /// [`ProxyError::Qlog`]: crate::error::ProxyError::Qlog
123    /// [`ProxyError::QlogOnProxyTemplate`]: crate::error::ProxyError::QlogOnProxyTemplate
124    /// [`QlogSpec`]: crate::qlog::QlogSpec
125    /// [`TransparentProxy`]: crate::proxy::TransparentProxy
126    #[cfg(feature = "qlog")]
127    pub qlog: Option<crate::qlog::QlogSpec>,
128}
129
130/// A client connection that has completed its handshake and is ready
131/// for MoQT session handling.
132///
133/// Produced by [`Listener::accept`]. Each variant corresponds to a
134/// distinct client-facing transport that MoQT can run over.
135pub enum AcceptedConn {
136    /// Raw QUIC connection speaking MoQT directly. The negotiated ALPN
137    /// (`moq-00`, `moqt-15`, `moqt-16`, `moqt-17`, …) is returned so
138    /// callers can resolve the draft version.
139    Quic {
140        /// The accepted QUIC connection.
141        conn: quinn::Connection,
142        /// The ALPN negotiated with the client.
143        alpn: Vec<u8>,
144    },
145    /// WebTransport session, with the H3 + extended-CONNECT dance
146    /// already completed by the listener.
147    #[cfg(feature = "webtransport")]
148    WebTransport(wtransport::Connection),
149}
150
151/// A client connection whose handshake has not finished. Produced by
152/// [`Listener::accept_handshake`].
153pub struct Handshake {
154    connecting: quinn::Connecting,
155}
156
157impl Handshake {
158    /// The client's address.
159    pub fn remote_address(&self) -> SocketAddr {
160        self.connecting.remote_address()
161    }
162
163    /// Finish the handshake. For an `h3` client this runs the HTTP/3
164    /// handshake up to the WebTransport request and returns it unanswered.
165    pub async fn complete(self) -> Result<ClientRequest, ProxyError> {
166        let mut connecting = self.connecting;
167        // Resolves once the ClientHello is processed, so the ALPN is known
168        // while the connection is still connecting.
169        let hs_data = connecting
170            .handshake_data()
171            .await
172            .map_err(|e| ProxyError::Listener(format!("handshake data: {e}")))?;
173
174        let alpn = hs_data
175            .downcast::<quinn::crypto::rustls::HandshakeData>()
176            .ok()
177            .and_then(|hd| hd.protocol)
178            .map(|p| p.to_vec())
179            .unwrap_or_default();
180
181        if alpn == H3_ALPN {
182            #[cfg(feature = "webtransport")]
183            {
184                let session_fut =
185                    wtransport::endpoint::IncomingSessionFuture::with_quic_connecting(connecting);
186                let inner = session_fut
187                    .await
188                    .map_err(|e| ProxyError::Listener(format!("webtransport handshake: {e}")))?;
189                Ok(ClientRequest::WebTransport(WebTransportRequest { inner: Box::new(inner) }))
190            }
191            #[cfg(not(feature = "webtransport"))]
192            {
193                drop(connecting);
194                Err(ProxyError::Listener(
195                    "client negotiated h3 but webtransport feature is not enabled".to_string(),
196                ))
197            }
198        } else {
199            let conn = connecting.await.map_err(|e| ProxyError::Listener(e.to_string()))?;
200            Ok(ClientRequest::Quic { conn, alpn })
201        }
202    }
203}
204
205/// A client whose handshake has finished. A WebTransport request is still
206/// waiting for its answer.
207pub enum ClientRequest {
208    /// Raw QUIC connection speaking MoQT directly, with the ALPN it
209    /// negotiated.
210    Quic {
211        /// The accepted QUIC connection.
212        conn: quinn::Connection,
213        /// The ALPN negotiated with the client.
214        alpn: Vec<u8>,
215    },
216    /// A WebTransport session request, not yet answered.
217    #[cfg(feature = "webtransport")]
218    WebTransport(WebTransportRequest),
219}
220
221/// How a request is refused.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum Rejection {
224    /// `403`.
225    Forbidden,
226    /// `404`.
227    NotFound,
228    /// `429`.
229    TooManyRequests,
230}
231
232impl Rejection {
233    /// The HTTP status this sends.
234    pub fn status(self) -> u16 {
235        match self {
236            Rejection::Forbidden => 403,
237            Rejection::NotFound => 404,
238            Rejection::TooManyRequests => 429,
239        }
240    }
241}
242
243/// A WebTransport session request the listener has not answered.
244#[cfg(feature = "webtransport")]
245pub struct WebTransportRequest {
246    inner: Box<wtransport::endpoint::SessionRequest>,
247}
248
249#[cfg(feature = "webtransport")]
250impl WebTransportRequest {
251    /// The client's address.
252    pub fn remote_address(&self) -> SocketAddr {
253        self.inner.remote_address()
254    }
255
256    /// The request's `:authority`.
257    pub fn authority(&self) -> &str {
258        self.inner.authority()
259    }
260
261    /// The request's `:path`.
262    pub fn path(&self) -> &str {
263        self.inner.path()
264    }
265
266    /// Every header field of the request.
267    pub fn headers(&self) -> &std::collections::HashMap<String, String> {
268        self.inner.headers()
269    }
270
271    /// The `WT-Available-Protocols` field as the client sent it, if it sent
272    /// one. HTTP/3 field names are lower case.
273    pub fn available_protocols(&self) -> Option<&str> {
274        self.inner.headers().get("wt-available-protocols").map(String::as_str)
275    }
276
277    /// Accept the session, sending `headers` in the response.
278    pub async fn accept(
279        self,
280        headers: Vec<(String, String)>,
281    ) -> Result<wtransport::Connection, ProxyError> {
282        self.inner
283            .accept_with_headers(headers)
284            .await
285            .map_err(|e| ProxyError::Listener(format!("webtransport accept: {e}")))
286    }
287
288    /// Refuse the session.
289    pub async fn reject(self, rejection: Rejection) {
290        match rejection {
291            Rejection::Forbidden => self.inner.forbidden().await,
292            Rejection::NotFound => self.inner.not_found().await,
293            Rejection::TooManyRequests => self.inner.too_many_requests().await,
294        }
295    }
296}
297
298/// Build the ALPN list the server advertises to clients — every MoQT
299/// QUIC ALPN we support, plus `h3` when the WebTransport feature is on.
300///
301/// Each ALPN string comes from [`DraftVersion::quic_alpn`] and the set of
302/// drafts is [`DraftVersion::ALL`], so a draft joining the series is
303/// advertised without an edit here. That matters more than it looks: a draft
304/// missing from this list fails a client of that draft at the TLS handshake
305/// ("peer doesn't support any known protocol"), before it sends a single MoQT
306/// frame. `tests/control_plane_uni.rs` has a per-draft row that catches it.
307fn advertised_alpns() -> Vec<Vec<u8>> {
308    // Dedup: drafts 07–14 all map to `moq-00`, so iterate every draft
309    // and keep unique ALPNs.
310    let mut out: Vec<Vec<u8>> = Vec::new();
311    for d in DraftVersion::ALL {
312        let alpn = d.quic_alpn().to_vec();
313        if !out.iter().any(|existing| existing == &alpn) {
314            out.push(alpn);
315        }
316    }
317    #[cfg(feature = "webtransport")]
318    out.push(H3_ALPN.to_vec());
319    out
320}
321
322/// A transport-agnostic MoQT listener that accepts both raw-QUIC and
323/// WebTransport clients on the same UDP port.
324pub struct Listener {
325    endpoint: quinn::Endpoint,
326    /// The server configuration this endpoint was built with, kept so that
327    /// [`Listener::set_transport`] can replace one field of it without
328    /// rebuilding the rest.
329    ///
330    /// A clone of the value handed to quinn rather than a fresh build, and
331    /// the difference is not an optimisation. Rebuilding would re-parse the
332    /// certificate — which means retaining the private key here, and
333    /// `PrivateKeyDer` is not `Clone` — and `quinn::ServerConfig::with_crypto`
334    /// draws a fresh random handshake-token master key each time it is
335    /// called, which would invalidate every retry token already outstanding.
336    /// Keeping the built value costs one `Arc` per field and none of that.
337    server_config: quinn::ServerConfig,
338}
339
340impl Listener {
341    /// Bind to the configured address and start listening.
342    ///
343    /// The listener advertises every supported MoQT ALPN (`moq-00` and
344    /// `moqt-<N>` for all known drafts) plus `h3` for WebTransport. The
345    /// client picks which one to speak; the proxy forwards whatever
346    /// arrives.
347    ///
348    /// This binds an ordinary UDP socket at [`ListenerConfig::bind_addr`],
349    /// wraps it with quinn's default runtime adapter and hands it to
350    /// [`Listener::bind_with_socket`]. Must therefore be called from
351    /// inside a tokio runtime context — as it always had to be, because
352    /// quinn reaches for the same runtime when it binds a socket itself.
353    pub fn bind(config: ListenerConfig) -> Result<Self, ProxyError> {
354        let runtime = quinn::default_runtime()
355            .ok_or_else(|| ProxyError::Listener("no async runtime found".to_string()))?;
356        let socket = std::net::UdpSocket::bind(config.bind_addr)
357            .map_err(|e| ProxyError::Listener(e.to_string()))?;
358        let socket =
359            runtime.wrap_udp_socket(socket).map_err(|e| ProxyError::Listener(e.to_string()))?;
360
361        Self::bind_with_socket(config, socket)
362    }
363
364    /// Bind the listener over a caller-supplied abstract socket.
365    ///
366    /// Every datagram this listener sends to, or receives from, a client
367    /// passes through `socket`, so a caller that supplies a decorating
368    /// implementation — a tap, a counter, a network-impairment shim —
369    /// observes and can alter the whole client-facing leg. Ownership is
370    /// shared, so the caller keeps its handle on the socket after the
371    /// endpoint is running.
372    ///
373    /// [`ListenerConfig::bind_addr`] is ignored here: `socket` is already
374    /// bound, and its address is the one [`Listener::local_addr`] reports.
375    /// The rest of the configuration — the certificate, the advertised
376    /// ALPN list, the transport parameters — applies exactly as it does to
377    /// [`Listener::bind`], which is a thin wrapper around this function.
378    ///
379    /// # One socket covers WebTransport clients too
380    ///
381    /// This single seam reaches raw-QUIC and WebTransport clients alike,
382    /// because on the client-facing side the proxy never builds a
383    /// WebTransport endpoint of its own. It builds the QUIC endpoint here,
384    /// reads the negotiated ALPN off the handshake, and for `h3` clients
385    /// hands the still-connecting QUIC connection to the WebTransport
386    /// library to finish. The library adopts a connection that already
387    /// lives on this endpoint rather than binding a socket for it, so
388    /// there is no second datagram path to intercept.
389    ///
390    /// The relay leg to the upstream relay is a separate endpoint and is
391    /// not affected by this socket.
392    pub fn bind_with_socket(
393        config: ListenerConfig,
394        socket: Arc<dyn quinn::AsyncUdpSocket>,
395    ) -> Result<Self, ProxyError> {
396        // First, before the certificate is parsed and long before the
397        // endpoint is built. Every refusal this can produce is a fault in
398        // what the caller wrote rather than in the world, so a caller must
399        // not have to get a working certificate before hearing about one,
400        // and none of them must ever arrive attached to a live endpoint
401        // that then has to be torn down. It is also where a capture's sink
402        // is built, which writes the file's preamble — so a leg whose spec
403        // was refused has written nothing anywhere.
404        let transport = transport::resolve(
405            Leg::Client,
406            config.transport_config,
407            config.transport_profile.as_ref(),
408            config.installer.as_ref(),
409            // Moved out of the config rather than borrowed: a spec owns its
410            // writer and is consumed when it becomes a sink, so there is
411            // nothing here a second bind could use.
412            #[cfg(feature = "qlog")]
413            config.qlog,
414        )?;
415
416        let mut server_tls = rustls::ServerConfig::builder()
417            .with_no_client_auth()
418            .with_single_cert(config.cert_chain, config.key_der)
419            .map_err(|e| ProxyError::TlsConfig(format!("server cert config: {e}")))?;
420
421        server_tls.alpn_protocols = advertised_alpns();
422        server_tls.max_early_data_size = u32::MAX;
423
424        let quic_server_config: quinn::crypto::rustls::QuicServerConfig =
425            server_tls.try_into().map_err(|e| ProxyError::TlsConfig(format!("{e}")))?;
426
427        let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_server_config));
428        if let Some(transport) = transport {
429            server_config.transport_config(transport);
430        }
431
432        let runtime = quinn::default_runtime()
433            .ok_or_else(|| ProxyError::Listener("no async runtime found".to_string()))?;
434
435        let endpoint = quinn::Endpoint::new_with_abstract_socket(
436            quinn::EndpointConfig::default(),
437            Some(server_config.clone()),
438            socket,
439            runtime,
440        )
441        .map_err(|e| ProxyError::Listener(e.to_string()))?;
442
443        Ok(Self { endpoint, server_config })
444    }
445
446    /// Install `transport` as the QUIC transport parameters this listener
447    /// gives to the connections it accepts **from now on**.
448    ///
449    /// # It cannot reach a connection that already exists
450    ///
451    /// A quinn connection takes its `TransportConfig` once, out of the
452    /// server configuration in force when its handshake began, and keeps
453    /// that `Arc` for as long as it lives. There is no way to hand a live
454    /// connection a different one — quinn exposes four setters on an
455    /// accepted connection (the two stream-count limits and the two
456    /// windows) and nothing else. So this changes what the *next* accepted
457    /// connection gets and leaves every connection already running exactly
458    /// as it was.
459    ///
460    /// That is worth stating rather than glossing, because the failure it
461    /// produces is silent: on a proxy nobody is connecting to any more,
462    /// this call succeeds, changes the endpoint, and never reaches a single
463    /// packet.
464    ///
465    /// Everything else about the endpoint — the certificate, the advertised
466    /// ALPN list, the handshake token key — is carried over from the
467    /// configuration the listener bound with, so a client's view of this
468    /// server is unchanged apart from the transport parameters.
469    pub(crate) fn set_transport(&self, transport: std::sync::Arc<quinn::TransportConfig>) {
470        let mut config = self.server_config.clone();
471        config.transport_config(transport);
472        self.endpoint.set_server_config(Some(config));
473    }
474
475    /// Accept the next incoming connection and dispatch based on the
476    /// ALPN negotiated during the TLS handshake.
477    ///
478    /// Raw-QUIC connections are returned with the negotiated ALPN so the
479    /// caller can pick the MoQT draft. An `h3` client's WebTransport request
480    /// is accepted with no response headers before this returns; use
481    /// [`Listener::accept_request`] to see the request and answer it yourself.
482    pub async fn accept(&self) -> Result<AcceptedConn, ProxyError> {
483        match self.accept_request().await? {
484            ClientRequest::Quic { conn, alpn } => Ok(AcceptedConn::Quic { conn, alpn }),
485            #[cfg(feature = "webtransport")]
486            ClientRequest::WebTransport(request) => {
487                Ok(AcceptedConn::WebTransport(request.accept(Vec::new()).await?))
488            }
489        }
490    }
491
492    /// Accept the next incoming connection and run its handshake, leaving a
493    /// WebTransport request unanswered.
494    pub async fn accept_request(&self) -> Result<ClientRequest, ProxyError> {
495        self.accept_handshake().await?.complete().await
496    }
497
498    /// Accept the next incoming connection without waiting for its
499    /// handshake, so one slow client does not hold up the next.
500    ///
501    /// A client refused before its handshake starts, such as one offering an
502    /// ALPN this listener does not advertise, is skipped. Errors only when the
503    /// endpoint is closed.
504    pub async fn accept_handshake(&self) -> Result<Handshake, ProxyError> {
505        loop {
506            let incoming = self
507                .endpoint
508                .accept()
509                .await
510                .ok_or_else(|| ProxyError::Listener("endpoint closed".to_string()))?;
511            if let Ok(connecting) = incoming.accept() {
512                return Ok(Handshake { connecting });
513            }
514        }
515    }
516
517    /// Get the local address this listener is bound to.
518    pub fn local_addr(&self) -> Result<SocketAddr, ProxyError> {
519        self.endpoint.local_addr().map_err(|e| ProxyError::Listener(e.to_string()))
520    }
521
522    /// Stop accepting new connections.
523    pub fn close(&self) {
524        self.endpoint.close(0u32.into(), b"proxy shutting down");
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use std::sync::atomic::{AtomicUsize, Ordering};
531
532    use rustls::pki_types::PrivatePkcs8KeyDer;
533
534    use super::*;
535    use crate::transport::TransportProfileError;
536
537    /// A certificate this listener will never get as far as parsing.
538    ///
539    /// Every refusal tested below has to be reported *before* the TLS
540    /// build, so the tests hand over ten bytes of nothing. If one of them
541    /// ever fails with a `TlsConfig` error, the check has drifted later
542    /// than the certificate and a caller now has to hold a valid identity
543    /// before they can be told their two transport fields contradict.
544    fn unusable_identity() -> (Vec<CertificateDer<'static>>, PrivateKeyDer<'static>) {
545        (
546            vec![CertificateDer::from(vec![0u8; 10])],
547            PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(vec![0u8; 10])),
548        )
549    }
550
551    /// A real self-signed `localhost` pair, for the one test that has to
552    /// bind successfully.
553    fn usable_identity() -> (Vec<CertificateDer<'static>>, PrivateKeyDer<'static>) {
554        let key_pair = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
555            .expect("a key pair for a test certificate");
556        let params =
557            rcgen::CertificateParams::new(vec!["localhost".into()]).expect("certificate params");
558        let cert = params.self_signed(&key_pair).expect("self-sign");
559        (
560            vec![CertificateDer::from(cert.der().to_vec())],
561            PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())),
562        )
563    }
564
565    fn config(identity: (Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)) -> ListenerConfig {
566        let (cert_chain, key_der) = identity;
567        ListenerConfig {
568            bind_addr: "127.0.0.1:0".parse().expect("a literal address"),
569            cert_chain,
570            key_der,
571            transport_config: None,
572            transport_profile: None,
573            installer: None,
574            #[cfg(feature = "qlog")]
575            qlog: None,
576        }
577    }
578
579    /// Counts the builds and returns a config built the default way.
580    struct CountingInstaller(Arc<AtomicUsize>);
581
582    impl TransportInstaller for CountingInstaller {
583        fn build(
584            &self,
585            profile: &TransportProfile,
586        ) -> Result<quinn::TransportConfig, TransportProfileError> {
587            self.0.fetch_add(1, Ordering::Relaxed);
588            profile.into_config()
589        }
590    }
591
592    #[tokio::test]
593    async fn a_client_leg_naming_both_a_config_and_a_profile_is_refused_at_bind() {
594        let mut config = config(unusable_identity());
595        config.transport_config = Some(Arc::new(quinn::TransportConfig::default()));
596        config.transport_profile = Some(TransportProfile::default());
597
598        let err = Listener::bind(config).err().expect("a contradiction is not a listener");
599        assert!(
600            matches!(err, ProxyError::TransportConfigAndProfile { leg: Leg::Client }),
601            "the client leg's contradiction has to be reported as the client leg's: {err}"
602        );
603    }
604
605    /// The client leg's other contradiction, refused as its own thing and
606    /// with nothing written anywhere.
607    ///
608    /// Two halves. The first is that the refusal is
609    /// `TransportConfigAndQlog` and not `TransportConfigAndProfile`: the
610    /// two pairs have different fixes, and a caller told the wrong one goes
611    /// looking at the wrong half of their configuration. The second is what
612    /// makes this more than a claim about a return value — the sink writes
613    /// its preamble the instant it is built, so a writer that is still
614    /// empty afterwards is proof that no sink was built and no capture was
615    /// quietly begun on a leg that then refused to bind.
616    #[cfg(feature = "qlog")]
617    #[tokio::test]
618    async fn a_client_leg_naming_both_a_config_and_a_spec_is_refused_as_that() {
619        /// Everything written to it, readable while the writer is alive.
620        #[derive(Clone)]
621        struct Captured(Arc<std::sync::Mutex<Vec<u8>>>);
622
623        impl std::io::Write for Captured {
624            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
625                self.0.lock().expect("no test holds this across a panic").extend_from_slice(buf);
626                Ok(buf.len())
627            }
628
629            fn flush(&mut self) -> std::io::Result<()> {
630                Ok(())
631            }
632        }
633
634        let sink = Arc::new(std::sync::Mutex::new(Vec::new()));
635        let mut config = config(unusable_identity());
636        config.transport_config = Some(Arc::new(quinn::TransportConfig::default()));
637        config.qlog = Some(crate::qlog::QlogSpec {
638            writer: Some(Box::new(Captured(Arc::clone(&sink)))),
639            title: Some("client leg".to_string()),
640            description: None,
641        });
642
643        let err = Listener::bind(config).err().expect("a contradiction is not a listener");
644        assert!(
645            matches!(err, ProxyError::TransportConfigAndQlog { leg: Leg::Client }),
646            "a raw config and a spec is a different fault from a raw config and a profile, with a \
647             different fix, and one refusal covering both would send the caller to the wrong half \
648             of their configuration: {err}"
649        );
650        assert!(
651            sink.lock().expect("uncontended").is_empty(),
652            "the preamble is written the moment a sink is built, so anything here means the \
653             refused leg began a capture on its way to refusing"
654        );
655    }
656
657    #[tokio::test]
658    async fn a_client_leg_whose_profile_cannot_be_honoured_does_not_bind() {
659        let mut config = config(unusable_identity());
660        // quinn raises anything under 1200 to 1200 without a word, so a
661        // listener that came up here would be running at an MTU nobody
662        // asked for.
663        config.transport_profile =
664            Some(TransportProfile { initial_mtu: Some(900), ..Default::default() });
665
666        let err = Listener::bind(config).err().expect("an unhonourable profile is not a listener");
667        assert!(
668            matches!(
669                err,
670                ProxyError::TransportProfile {
671                    leg: Leg::Client,
672                    source: TransportProfileError::MtuBelowFloor { .. },
673                }
674            ),
675            "{err}"
676        );
677    }
678
679    #[tokio::test]
680    async fn a_profile_carrying_client_leg_installs_and_binds() {
681        let _ = rustls::crypto::ring::default_provider().install_default();
682
683        let builds = Arc::new(AtomicUsize::new(0));
684        let mut config = config(usable_identity());
685        config.transport_profile = Some(TransportProfile {
686            initial_mtu: Some(1350),
687            receive_window: Some(4 * 1024 * 1024),
688            ..Default::default()
689        });
690        config.installer = Some(Arc::new(CountingInstaller(Arc::clone(&builds))));
691
692        let listener = Listener::bind(config).expect("a valid profile binds a listener");
693        assert!(listener.local_addr().is_ok(), "the endpoint is live");
694        assert_eq!(
695            builds.load(Ordering::Relaxed),
696            1,
697            "the leg has to build its config through the installer, once, before the endpoint \
698             exists — a leg that bound without consulting it would be running on quinn's \
699             defaults and reporting success"
700        );
701        listener.close();
702    }
703}