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