Skip to main content

moqtap_proxy/
proxy.rs

1//! Transparent proxy orchestrator — accept loop and session management.
2//!
3//! The proxy binds a single listener that accepts raw-QUIC MoQT and
4//! WebTransport clients simultaneously, negotiated via ALPN. Each
5//! accepted connection is handed to a [`ProxySession`] that forwards
6//! traffic to the configured upstream relay.
7
8use std::collections::HashMap;
9use std::net::SocketAddr;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::Arc;
12use std::time::Duration;
13
14use tokio_util::sync::CancellationToken;
15
16use crate::control::{ControlPlane, LegSetup, ProxyControl};
17use crate::error::ProxyError;
18use crate::event::{ProxyEvent, SessionId};
19use crate::hook::{NoOpHook, ProxyHook};
20use crate::listener::{ClientRequest, Handshake, Listener, ListenerConfig, Rejection};
21use crate::observer::ProxyObserver;
22use crate::session::{ProxySession, ProxySessionConfig, UpstreamTransportType};
23
24/// Configuration for the transparent proxy.
25pub struct ProxyConfig {
26    /// Listener configuration (bind address, certs).
27    pub listener: ListenerConfig,
28    /// Per-session configuration (upstream address, TLS, transport).
29    pub session: ProxySessionConfig,
30}
31
32/// How long a client may take from its first packet to a finished handshake
33/// (for WebTransport, to its session request).
34pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
35
36/// Decides, for each client, where its session goes and how it runs.
37///
38/// Called once per client, after its handshake and before the relay is
39/// dialled. `config` is the proxy's own session configuration; return it,
40/// changed or not, or refuse the client.
41pub trait Router: Send + Sync {
42    /// Route one client.
43    fn route(
44        &self,
45        request: &RouteRequest<'_>,
46        config: ProxySessionConfig,
47    ) -> Result<ProxySessionConfig, Rejection>;
48}
49
50impl<F> Router for F
51where
52    F: Fn(&RouteRequest<'_>, ProxySessionConfig) -> Result<ProxySessionConfig, Rejection>
53        + Send
54        + Sync,
55{
56    fn route(
57        &self,
58        request: &RouteRequest<'_>,
59        config: ProxySessionConfig,
60    ) -> Result<ProxySessionConfig, Rejection> {
61        self(request, config)
62    }
63}
64
65/// One client, as a [`Router`] sees it.
66pub struct RouteRequest<'a> {
67    /// The id the session will run under.
68    pub session_id: SessionId,
69    /// The client's address.
70    pub remote_address: SocketAddr,
71    /// What the client asked for.
72    pub client: RouteClient<'a>,
73}
74
75/// What a client asked for.
76pub enum RouteClient<'a> {
77    /// A raw-QUIC client and the ALPN it negotiated. A refusal closes the
78    /// connection.
79    Quic {
80        /// The negotiated ALPN.
81        alpn: &'a [u8],
82    },
83    /// A WebTransport session request. A refusal answers it with the
84    /// rejection's status.
85    WebTransport {
86        /// The request's `:authority`.
87        authority: &'a str,
88        /// The request's `:path`.
89        path: &'a str,
90        /// Every header field of the request.
91        headers: &'a HashMap<String, String>,
92    },
93}
94
95/// A transparent MoQT proxy that accepts client connections and forwards
96/// traffic to an upstream relay.
97///
98/// Each accepted connection spawns a [`ProxySession`] that handles
99/// bidirectional stream forwarding with inline MoQT frame parsing. The
100/// client-facing transport (raw QUIC vs WebTransport) is chosen by the
101/// client via ALPN and dispatched automatically — no configuration
102/// required.
103pub struct TransparentProxy {
104    config: ProxyConfig,
105    observer: Arc<dyn ProxyObserver>,
106    hook: Arc<dyn ProxyHook>,
107    cancel: CancellationToken,
108    next_session_id: AtomicU64,
109    /// What a [`ProxyControl`] reads and what every session registers with.
110    ///
111    /// Constructed with the proxy rather than with the listener, because
112    /// [`TransparentProxy::control`] has to answer before `run()` is
113    /// awaited: a caller that owns the proxy is usually the caller about to
114    /// give up its thread of control to the accept loop, so a handle that
115    /// could only be taken afterwards could not be taken at all.
116    control: Arc<ControlPlane>,
117    /// The socket the client-facing endpoint is bound over, when the caller
118    /// supplied one.
119    ///
120    /// `None` — the ordinary case — makes `run()` bind its own socket at
121    /// [`ListenerConfig::bind_addr`], which is what this proxy has always
122    /// done. `Some(_)` is set only by
123    /// `TransparentProxy::set_impaired_socket` — named in plain code font
124    /// because it exists only under the `impair` feature, so a link to it
125    /// from this always-compiled field would not resolve — which is also
126    /// where the handle that arms it is recorded, so the socket and the
127    /// thing that impairs it can only arrive together.
128    client_socket: Option<Arc<dyn quinn::AsyncUdpSocket>>,
129    /// Picks each session's configuration; `None` gives every session the
130    /// template.
131    router: Option<Arc<dyn Router>>,
132}
133
134impl TransparentProxy {
135    /// Create a new proxy with the given configuration and observer.
136    pub fn new(config: ProxyConfig, observer: Arc<dyn ProxyObserver>) -> Self {
137        let control = ControlPlane::new(LegSetup::from_config(&config));
138        Self {
139            config,
140            observer,
141            hook: Arc::new(NoOpHook),
142            cancel: CancellationToken::new(),
143            next_session_id: AtomicU64::new(1),
144            control,
145            client_socket: None,
146            router: None,
147        }
148    }
149
150    /// Create a new proxy with a custom hook for frame mutation.
151    pub fn with_hook(
152        config: ProxyConfig,
153        observer: Arc<dyn ProxyObserver>,
154        hook: Arc<dyn ProxyHook>,
155    ) -> Self {
156        let control = ControlPlane::new(LegSetup::from_config(&config));
157        Self {
158            config,
159            observer,
160            hook,
161            cancel: CancellationToken::new(),
162            next_session_id: AtomicU64::new(1),
163            control,
164            client_socket: None,
165            router: None,
166        }
167    }
168
169    /// Run one leg over `socket`, and let
170    /// [`ProxyControl::set_impair`](crate::control::ProxyControl::set_impair)
171    /// arm `handle` on it.
172    ///
173    /// Both halves at once, deliberately. `quinn`'s `AsyncUdpSocket` is a
174    /// trait object with no downcast, and `quinn_netem` exposes no accessor
175    /// from a wrapped socket back to its handle, so nothing anywhere can
176    /// check that a handle belongs to the socket beside it. Taking them as
177    /// two independent settings would make a mismatched pair expressible —
178    /// and a mismatched pair arms an impairment that is reported as applied
179    /// and crossed by nobody's traffic, which is the exact failure the whole
180    /// impairment surface exists to make loud. Taking them together does not
181    /// *prove* they match, but it removes every way of supplying them that
182    /// does not.
183    ///
184    /// Call before [`TransparentProxy::run`]. The client leg's socket is
185    /// consumed when the endpoint is bound and the relay leg's when a
186    /// session dials, so a socket set afterwards reaches the client leg
187    /// never and the relay leg only from the next session on.
188    ///
189    /// # What each leg does with it
190    ///
191    /// [`Leg::Client`](crate::transport::Leg::Client) binds the listener's
192    /// endpoint over it, which covers raw-QUIC and WebTransport clients
193    /// alike — the proxy builds one QUIC endpoint on that side and hands
194    /// `h3` connections to the WebTransport library already connected, so
195    /// there is no second datagram path.
196    ///
197    /// [`Leg::Upstream`](crate::transport::Leg::Upstream) sets
198    /// [`ProxySessionConfig::upstream_socket`], which **every** session this
199    /// proxy accepts then builds its relay endpoint over. Two endpoints
200    /// reading one socket take each other's datagrams, so an upstream socket
201    /// set here is only sound for a proxy handling one client at a time — a
202    /// capture or impairment harness rather than a fan-out deployment. That
203    /// is a property of the socket seam and not of this call; it is repeated
204    /// here because this is now the easiest way to reach it.
205    ///
206    /// A WebTransport upstream refuses a socket outright when it dials, with
207    /// [`ProxyError::UpstreamSocketUnsupported`], rather than connecting
208    /// around it.
209    #[cfg(feature = "impair")]
210    pub fn set_impaired_socket(
211        &mut self,
212        leg: crate::types::Leg,
213        socket: Arc<dyn quinn::AsyncUdpSocket>,
214        handle: quinn_netem::ImpairHandle,
215    ) {
216        match leg {
217            crate::types::Leg::Client => self.client_socket = Some(socket),
218            crate::types::Leg::Upstream => self.config.session.upstream_socket = Some(socket),
219        }
220        self.control.set_impair_handle(leg, handle);
221    }
222
223    /// Route every client through `router`, which picks its session's
224    /// configuration or refuses it. Call before [`TransparentProxy::run`].
225    pub fn set_router(&mut self, router: Arc<dyn Router>) {
226        self.router = Some(router);
227    }
228
229    /// Returns a cancellation token that can be used to trigger shutdown.
230    pub fn cancel_token(&self) -> CancellationToken {
231        self.cancel.clone()
232    }
233
234    /// A handle onto this proxy — the address it bound, and the sessions it
235    /// is running.
236    ///
237    /// Callable at any point after the proxy is constructed, and meant to be
238    /// called **before** [`TransparentProxy::run`] is awaited: `run()` does
239    /// not return until the proxy is finished, so a caller that waited for
240    /// it would never get a handle to a proxy that was doing anything. Every
241    /// question a handle answers is defined for a proxy that has not bound
242    /// yet — see [`ProxyControl`].
243    ///
244    /// Cheap, and cheap to keep: the handle is one `Arc` onto state the
245    /// proxy already holds, and clones of it share that state rather than
246    /// copying it.
247    pub fn control(&self) -> ProxyControl {
248        ProxyControl::new(Arc::clone(&self.control))
249    }
250
251    /// Run the proxy accept loop. Blocks until cancelled or a fatal
252    /// listener error occurs.
253    pub async fn run(&self) -> Result<(), ProxyError> {
254        // Behind an `Arc`, and published, because the bound endpoint is no
255        // longer only this loop's business: it is the one place the port a
256        // `bind_addr` of `:0` resolved to can be read, and a `ProxyControl`
257        // taken before this function was even called has to be able to read
258        // it. The guard un-publishes on every way out of this function —
259        // cancellation below, the `?` on a fatal accept error, and this
260        // whole future being dropped by whoever spawned it — so a handle
261        // never reports an address for an endpoint that is gone.
262        // Before the bind, because a template carrying a capture spec is a
263        // contradiction in what the caller wrote rather than a fact about
264        // the world, and because a proxy that came up anyway would be this
265        // crate's cardinal failure with nothing to give it away: it would
266        // bind, accept, forward, report success, and leave the file the
267        // caller was watching uncreated. Both legs are checked here rather
268        // than at the two copies below, so the answer arrives before a
269        // socket exists and does not depend on a connection being accepted.
270        #[cfg(feature = "qlog")]
271        {
272            if self.config.listener.qlog.is_some() {
273                return Err(ProxyError::QlogOnProxyTemplate { leg: crate::types::Leg::Client });
274            }
275            if self.config.session.upstream_qlog.is_some() {
276                return Err(ProxyError::QlogOnProxyTemplate { leg: crate::types::Leg::Upstream });
277            }
278        }
279
280        let config = self.listener_config();
281        let listener = Arc::new(match &self.client_socket {
282            // A supplied socket replaces the bind and nothing else: the same
283            // certificate, the same ALPN list and the same transport
284            // parameters follow, and `bind_addr` is then ignored because the
285            // socket is already bound.
286            Some(socket) => Listener::bind_with_socket(config, Arc::clone(socket))?,
287            None => Listener::bind(config)?,
288        });
289        let _bound = self.control.publish_listener(Arc::clone(&listener));
290
291        loop {
292            tokio::select! {
293                handshake = listener.accept_handshake() => {
294                    self.dispatch(handshake?);
295                }
296                _ = self.cancel.cancelled() => {
297                    listener.close();
298                    return Ok(());
299                }
300            }
301        }
302    }
303
304    /// Finish one client's handshake, route it and run its session, on a
305    /// task of its own so a slow client holds up nobody.
306    fn dispatch(&self, handshake: Handshake) {
307        let spawner = Spawner {
308            session_id: self.next_session_id(),
309            template: self.session_config(),
310            router: self.router.clone(),
311            observer: Arc::clone(&self.observer),
312            hook: Arc::clone(&self.hook),
313            cancel: self.cancel.clone(),
314            control: Arc::clone(&self.control),
315        };
316        let cancel = self.cancel.clone();
317        tokio::spawn(async move {
318            let request = tokio::select! {
319                done = tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake.complete()) => done,
320                _ = cancel.cancelled() => return,
321            };
322            if let Ok(Ok(request)) = request {
323                spawner.run(request).await;
324            }
325        });
326    }
327
328    fn next_session_id(&self) -> SessionId {
329        SessionId(self.next_session_id.fetch_add(1, Ordering::Relaxed))
330    }
331
332    // ── The two hand-written copies ────────────────────────────────
333    //
334    // This proxy holds one template per leg and copies it — once for the
335    // listener it binds, once per connection it accepts — because neither
336    // config is `Clone`: the listener owns a `PrivateKeyDer`, which is
337    // cloned through `clone_key`, and the session config owns
338    // `Arc<dyn>`s that are cheap to share but not derivable.
339    // A hand-written copy is exactly where a field added later gets forgotten,
340    // and this crate's cardinal failure is a setting that is accepted, reported
341    // as applied, and silently reaches nothing: the caller sets a window, the
342    // proxy comes up, and the run is believed. So both functions below start by
343    // **destructuring the template with no `..`**. That is not decoration — a
344    // field added to either config stops this file compiling with *pattern does
345    // not mention field*, at the one place that has to learn about it, rather
346    // than passing the build and dropping the value at run time. Do not add
347    // `..` to either pattern; it would put the trap back.
348    //
349    // Two fields do not come from the template unconditionally, and both
350    // read the control plane first: the client leg's transport parameters
351    // and the session's shape profile. A live request that set either of
352    // them has to win over the value the proxy was built with, or the
353    // request would apply to sessions already running and not to the ones
354    // accepted after it — the reverse of what every caller expects.
355    //
356    // And one field per leg is deliberately *not* carried: the qlog spec,
357    // which owns a writer, has no `Clone` and is consumed when it becomes a
358    // sink. A template that is copied — once here, once per accepted
359    // connection — has nothing it could hand over. The pattern still names
360    // it, so the decision is visible where the copy is made rather than
361    // inferred from its absence, and both fields say so in their own
362    // documentation.
363    //
364    // Neither copy has to *report* that, because `run` has already refused a
365    // template holding either spec before it binds. These two `None`s are
366    // therefore what a template with no spec in it produces, and never a
367    // value being dropped: if the refusal above is ever removed, these lines
368    // become exactly the silent failure the paragraph above is about.
369
370    /// A fresh [`ListenerConfig`] carrying every field of this proxy's
371    /// template — every field but one, and the exception is stated at the
372    /// pattern below rather than left to be noticed.
373    fn listener_config(&self) -> ListenerConfig {
374        let ListenerConfig {
375            bind_addr,
376            cert_chain,
377            key_der,
378            transport_config,
379            transport_profile,
380            installer,
381            // Discarded rather than bound, because there is nothing a copy
382            // could do with it. A `QlogSpec` owns a `Box<dyn Write>`, has
383            // no `Clone`, and is consumed the moment it becomes a sink, so
384            // a template cannot hand one to anything and this copy leaves
385            // the field `None`. Naming it here is still what the pattern is
386            // for: the next field added to `ListenerConfig` stops this file
387            // compiling, and this one had to be *decided* rather than
388            // forgotten.
389            #[cfg(feature = "qlog")]
390                qlog: _,
391        } = &self.config.listener;
392
393        // A profile installed through the control plane has already been
394        // built — through this leg's own installer — so it arrives as a
395        // finished config and displaces both of the template's transport
396        // fields. Leaving `transport_profile` beside it would be the one
397        // combination `Listener::bind` refuses outright, and this proxy
398        // would stop binding.
399        let live = self.control.client_transport();
400        let (transport_config, transport_profile) = match live {
401            Some(config) => (Some(config), None),
402            None => (transport_config.clone(), transport_profile.clone()),
403        };
404
405        ListenerConfig {
406            bind_addr: *bind_addr,
407            cert_chain: cert_chain.clone(),
408            key_der: key_der.clone_key(),
409            transport_config,
410            transport_profile,
411            installer: installer.clone(),
412            // The one field of either template that a copy cannot carry.
413            // A spec set on a `ProxyConfig` therefore reaches no leg, which
414            // is said on the field itself as well: capture a client leg by
415            // building the `ListenerConfig` and calling `Listener::bind`
416            // directly, which is also the only place one writer per
417            // connection is expressible.
418            #[cfg(feature = "qlog")]
419            qlog: None,
420        }
421    }
422
423    /// A fresh [`ProxySessionConfig`] carrying every field of this proxy's
424    /// template, built once per accepted connection.
425    fn session_config(&self) -> ProxySessionConfig {
426        let ProxySessionConfig {
427            draft,
428            upstream_transport,
429            upstream_addr,
430            skip_upstream_cert_verify,
431            upstream_ca_certs,
432            upstream_connect_timeout_secs,
433            upstream_transport_config,
434            upstream_transport_profile,
435            upstream_installer,
436            // Discarded for the same reason the client leg's spec is, and
437            // it bites harder here: this copy is made once per accepted
438            // connection, and one spec — one writer, consumed when it
439            // becomes a sink — cannot be divided between them. A relay leg
440            // is captured by driving `ProxySession` directly, one spec per
441            // session.
442            #[cfg(feature = "qlog")]
443                upstream_qlog: _,
444            upstream_socket,
445            egress,
446            shape,
447            upstream_server_certificate_hashes,
448        } = &self.config.session;
449
450        // As on the client leg, a live profile displaces both of the
451        // template's transport fields rather than joining one of them: a leg
452        // naming a raw config and a profile at once is refused when it
453        // dials, so leaving the template's config in place would turn every
454        // session accepted after the request into an upstream-connect
455        // failure. Stored as a *profile* rather than as the config it built,
456        // so each session still runs the installer once for its own
457        // connection exactly as a configured profile does.
458        let live_transport = self.control.upstream_transport();
459        let (upstream_transport_config, upstream_transport_profile) = match live_transport {
460            Some(profile) => (None, Some(profile)),
461            None => (upstream_transport_config.clone(), upstream_transport_profile.clone()),
462        };
463
464        ProxySessionConfig {
465            draft: *draft,
466            upstream_transport: upstream_transport.clone(),
467            upstream_addr: upstream_addr.clone(),
468            skip_upstream_cert_verify: *skip_upstream_cert_verify,
469            upstream_ca_certs: upstream_ca_certs.clone(),
470            upstream_connect_timeout_secs: *upstream_connect_timeout_secs,
471            upstream_transport_config,
472            upstream_transport_profile,
473            upstream_installer: upstream_installer.clone(),
474            // Not carried, as on the listener above and for the same
475            // reason. A spec set on a proxy template reaches no session.
476            #[cfg(feature = "qlog")]
477            upstream_qlog: None,
478            // Every session this proxy accepts gets a clone of the *same*
479            // socket, and each builds its own upstream endpoint over it.
480            // Two endpoints reading one socket take each other's
481            // datagrams, so a socket set here is only sound for a proxy
482            // handling one client connection at a time — a capture or
483            // impairment harness, not a fan-out deployment. A caller
484            // wanting one socket per session drives `ProxySession`
485            // directly, which is where the seam is.
486            upstream_socket: upstream_socket.clone(),
487            egress: *egress,
488            // Cloned, not moved: this builds one config *per accepted
489            // connection* from a template the proxy keeps. `ShapeProfile`
490            // is not `Copy` either — it owns its bucket and class vectors
491            // — so the clone is explicit here exactly as
492            // `upstream_ca_certs`' is above.
493            //
494            // A profile installed through the control plane wins over the
495            // template, and it wins for good: once set, every session
496            // accepted afterwards shapes with it, including on a proxy whose
497            // template had no profile at all. That is what makes
498            // `set_shape` reach a proxy uniformly rather than only the
499            // sessions that happened to be running when it was called.
500            shape: self.control.shape().snapshot().1.or_else(|| shape.clone()),
501            upstream_server_certificate_hashes: upstream_server_certificate_hashes.clone(),
502        }
503    }
504}
505
506/// What one client's task needs from its proxy.
507struct Spawner {
508    session_id: SessionId,
509    template: ProxySessionConfig,
510    router: Option<Arc<dyn Router>>,
511    observer: Arc<dyn ProxyObserver>,
512    hook: Arc<dyn ProxyHook>,
513    cancel: CancellationToken,
514    control: Arc<ControlPlane>,
515}
516
517impl Spawner {
518    async fn run(mut self, request: ClientRequest) {
519        match request {
520            ClientRequest::Quic { conn, alpn } => {
521                let remote_address = conn.remote_address();
522                let routed = self.route(remote_address, RouteClient::Quic { alpn: &alpn });
523                let config = match routed {
524                    Ok(config) => config,
525                    Err(rejection) => {
526                        conn.close(0u32.into(), rejection_reason(rejection));
527                        return;
528                    }
529                };
530                let session = self.session(config, alpn, remote_address, "QUIC");
531                let _ = session.run(conn).await;
532            }
533            #[cfg(feature = "webtransport")]
534            ClientRequest::WebTransport(request) => {
535                let remote_address = request.remote_address();
536                let routed = self.route(
537                    remote_address,
538                    RouteClient::WebTransport {
539                        authority: request.authority(),
540                        path: request.path(),
541                        headers: request.headers(),
542                    },
543                );
544                let config = match routed {
545                    Ok(config) => config,
546                    Err(rejection) => {
547                        request.reject(rejection).await;
548                        return;
549                    }
550                };
551                // `h3` names no draft; the session takes the relay's
552                // `WT-Protocol`, or the configured draft and the SETUP peek.
553                let session = self.session(config, Vec::new(), remote_address, "WebTransport");
554                let _ = session.run_webtransport_request(request).await;
555            }
556        }
557    }
558
559    fn route(
560        &mut self,
561        remote_address: SocketAddr,
562        client: RouteClient<'_>,
563    ) -> Result<ProxySessionConfig, Rejection> {
564        let template = std::mem::take(&mut self.template);
565        match &self.router {
566            None => Ok(template),
567            Some(router) => router.route(
568                &RouteRequest { session_id: self.session_id, remote_address, client },
569                template,
570            ),
571        }
572    }
573
574    fn session(
575        &self,
576        config: ProxySessionConfig,
577        client_alpn: Vec<u8>,
578        client_addr: SocketAddr,
579        client_transport: &str,
580    ) -> ProxySession {
581        if self.observer.wants_events() {
582            self.observer.on_event(&ProxyEvent::SessionStarted {
583                session_id: self.session_id,
584                client_addr,
585                client_transport: client_transport.to_string(),
586            });
587        }
588        let mut session = ProxySession::new(
589            self.session_id,
590            config,
591            client_alpn,
592            Arc::clone(&self.observer),
593            Arc::clone(&self.hook),
594            self.cancel.child_token(),
595        );
596        session.attach_control(Arc::clone(&self.control));
597        session
598    }
599}
600
601/// The close reason a refused raw-QUIC client is given.
602fn rejection_reason(rejection: Rejection) -> &'static [u8] {
603    match rejection {
604        Rejection::Forbidden => b"forbidden",
605        Rejection::NotFound => b"not found",
606        Rejection::TooManyRequests => b"too many requests",
607    }
608}
609
610impl LegSetup {
611    /// What the control plane needs to know about the two legs, read off the
612    /// configuration the proxy is being built with.
613    ///
614    /// The installers are shared rather than copied, so a live
615    /// [`TransportProfile`](crate::transport::TransportProfile) is built by
616    /// the same installer a configured one would have been.
617    fn from_config(config: &ProxyConfig) -> Self {
618        Self {
619            client_installer: config.listener.installer.clone(),
620            upstream_installer: config.session.upstream_installer.clone(),
621            // Read once, here, because it cannot change: the upstream
622            // transport is a field of the template and no request replaces
623            // it. A WebTransport upstream builds its endpoint inside the
624            // WebTransport library, which takes no `quinn::TransportConfig`
625            // and returns no endpoint to install one on, so a transport
626            // profile aimed at that leg has nowhere to go.
627            upstream_webtransport: matches!(
628                config.session.upstream_transport,
629                UpstreamTransportType::WebTransport { .. }
630            ),
631        }
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use std::time::Duration;
638
639    use moqtap_codec::version::DraftVersion;
640    use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
641
642    use super::*;
643    use crate::action::EgressConfig;
644    use crate::observer::NoOpProxyObserver;
645    use crate::session::UpstreamTransportType;
646    use crate::shape::{BucketConfig, ClassRule, Discipline, QueueConfig, ShapeProfile};
647    use crate::transport::{TransportInstaller, TransportProfile, TransportProfileError};
648
649    /// An installer the copy tests only ever compare by address.
650    struct MarkerInstaller;
651
652    impl TransportInstaller for MarkerInstaller {
653        fn build(
654            &self,
655            profile: &TransportProfile,
656        ) -> Result<quinn::TransportConfig, TransportProfileError> {
657            profile.into_config()
658        }
659    }
660
661    fn marker_profile(initial_mtu: u16) -> TransportProfile {
662        TransportProfile { initial_mtu: Some(initial_mtu), ..Default::default() }
663    }
664
665    /// A socket to hand the session template, so the field being carried is
666    /// checkable by address rather than by "both are `None`".
667    fn abstract_socket() -> Arc<dyn quinn::AsyncUdpSocket> {
668        let socket = std::net::UdpSocket::bind("127.0.0.1:0").expect("an ephemeral port");
669        quinn::default_runtime()
670            .expect("these tests run under tokio")
671            .wrap_udp_socket(socket)
672            .expect("wrap the socket")
673    }
674
675    /// A template in which **no carryable field holds its default**.
676    ///
677    /// That is the whole design of these two tests. A copy that dropped a
678    /// field would leave the copy holding a default, and a template built
679    /// from defaults could not tell the two apart — every assertion would
680    /// pass against a copy that carried nothing at all. So every field
681    /// below is set to something the corresponding `Default` is not.
682    ///
683    /// The two qlog specs are the exception, and they are marked where they
684    /// are set: a spec cannot be copied by anything, so there is no version
685    /// of this fixture in which the copy carrying it is the correct
686    /// behaviour to check for.
687    fn distinctive_config(socket: Arc<dyn quinn::AsyncUdpSocket>) -> ProxyConfig {
688        let egress = EgressConfig {
689            max_pending_bytes: 12_345,
690            max_hold: Duration::from_secs(7),
691            ..Default::default()
692        };
693
694        ProxyConfig {
695            listener: ListenerConfig {
696                bind_addr: "127.0.0.1:4443".parse().expect("a literal address"),
697                cert_chain: vec![CertificateDer::from(vec![1u8; 10])],
698                key_der: PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(vec![2u8; 10])),
699                // Not both at once — the two are mutually exclusive on a
700                // leg — so the raw config is the upstream template's and
701                // the profile is this one's, and each is carried by only
702                // one of the two copies.
703                transport_config: None,
704                transport_profile: Some(marker_profile(1350)),
705                installer: Some(Arc::new(MarkerInstaller)),
706                // The one field left at its default, because a spec cannot
707                // be carried by a copy at all — it owns a writer, has no
708                // `Clone`, and is consumed when it becomes a sink. Setting
709                // one here would make the fixture describe a template whose
710                // copy is *documented* to drop it, which is a fact about
711                // the type rather than a copy worth checking.
712                #[cfg(feature = "qlog")]
713                qlog: None,
714            },
715            session: ProxySessionConfig {
716                draft: DraftVersion::Draft19,
717                upstream_transport: UpstreamTransportType::WebTransport {
718                    url: "https://relay.invalid:4443/moq".to_string(),
719                },
720                upstream_addr: "relay.invalid:4443".to_string(),
721                skip_upstream_cert_verify: true,
722                upstream_ca_certs: vec![vec![3u8; 4]],
723                upstream_connect_timeout_secs: 11,
724                upstream_transport_config: Some(Arc::new(quinn::TransportConfig::default())),
725                upstream_transport_profile: None,
726                upstream_installer: Some(Arc::new(MarkerInstaller)),
727                // Left at its default for the reason given on the listener
728                // half above.
729                #[cfg(feature = "qlog")]
730                upstream_qlog: None,
731                upstream_socket: Some(socket),
732                egress,
733                // One bucket and one class naming it, which is the smallest
734                // profile there is: an empty one is `ShapeError::NoClasses`,
735                // because a profile with no classes shapes nothing. The
736                // names are distinctive for the same reason every other
737                // field here is — a copy that dropped this would otherwise
738                // be indistinguishable from one that carried it.
739                shape: Some(
740                    ShapeProfile::try_new(
741                        vec![BucketConfig { name: "marker".to_string(), ..Default::default() }],
742                        vec![ClassRule {
743                            name: "marker class".to_string(),
744                            bucket: "marker".to_string(),
745                            ..Default::default()
746                        }],
747                        QueueConfig::default(),
748                        Discipline::Fifo,
749                    )
750                    .expect("one class naming its own bucket is a valid profile"),
751                ),
752                upstream_server_certificate_hashes: vec![[4u8; 32]],
753            },
754        }
755    }
756
757    fn proxy(config: ProxyConfig) -> TransparentProxy {
758        TransparentProxy::new(config, Arc::new(NoOpProxyObserver))
759    }
760
761    /// The listener template survives the copy `run` makes of it.
762    ///
763    /// A field this copy drops is not a compile error and not a test
764    /// failure anywhere else in the crate: the caller sets it, the proxy
765    /// binds, and the setting reaches nothing. The destructuring pattern in
766    /// `listener_config` is the first line of defence and this is the
767    /// second — it fails if a field is named in the pattern and then
768    /// written from the wrong place, which the compiler cannot see.
769    #[tokio::test]
770    async fn the_listener_copy_carries_every_field() {
771        let template = distinctive_config(abstract_socket());
772        let expected_installer =
773            Arc::clone(template.listener.installer.as_ref().expect("set above"));
774        let proxy = proxy(template);
775        let copy = proxy.listener_config();
776        let template = &proxy.config.listener;
777
778        assert_eq!(copy.bind_addr, template.bind_addr);
779        assert_eq!(copy.cert_chain, template.cert_chain);
780        assert_eq!(
781            copy.key_der.secret_der(),
782            template.key_der.secret_der(),
783            "`clone_key` rather than `clone`: the key is the one field that cannot be derived"
784        );
785        assert!(copy.transport_config.is_none(), "the template names no raw config");
786        assert_eq!(copy.transport_profile, template.transport_profile);
787        assert!(
788            Arc::ptr_eq(copy.installer.as_ref().expect("carried"), &expected_installer),
789            "the copy shares the caller's installer rather than substituting the default one"
790        );
791    }
792
793    /// The session template survives the copy made per accepted
794    /// connection.
795    #[tokio::test]
796    async fn the_session_copy_carries_every_field() {
797        let template = distinctive_config(abstract_socket());
798        let expected_installer =
799            Arc::clone(template.session.upstream_installer.as_ref().expect("set above"));
800        let expected_raw =
801            Arc::clone(template.session.upstream_transport_config.as_ref().expect("set above"));
802        let expected_socket =
803            Arc::clone(template.session.upstream_socket.as_ref().expect("set above"));
804        let proxy = proxy(template);
805        let copy = proxy.session_config();
806        let template = &proxy.config.session;
807
808        assert_eq!(copy.draft, template.draft);
809        assert_eq!(
810            format!("{:?}", copy.upstream_transport),
811            format!("{:?}", template.upstream_transport),
812            "`UpstreamTransportType` has no `PartialEq`, so the comparison is its `Debug`"
813        );
814        assert_eq!(copy.upstream_addr, template.upstream_addr);
815        assert_eq!(copy.skip_upstream_cert_verify, template.skip_upstream_cert_verify);
816        assert_eq!(copy.upstream_ca_certs, template.upstream_ca_certs);
817        assert_eq!(copy.upstream_connect_timeout_secs, template.upstream_connect_timeout_secs);
818        assert!(Arc::ptr_eq(
819            copy.upstream_transport_config.as_ref().expect("carried"),
820            &expected_raw
821        ));
822        assert!(copy.upstream_transport_profile.is_none(), "the template names no profile");
823        assert!(
824            Arc::ptr_eq(copy.upstream_installer.as_ref().expect("carried"), &expected_installer),
825            "the copy shares the caller's installer rather than substituting the default one"
826        );
827        assert!(
828            Arc::ptr_eq(copy.upstream_socket.as_ref().expect("carried"), &expected_socket),
829            "a dropped socket is the loudest of these failures: every impairment armed on it \
830             would be applied to nothing and the run would look clean"
831        );
832        assert_eq!(copy.egress, template.egress);
833        assert_eq!(copy.shape, template.shape);
834        assert_eq!(
835            copy.upstream_server_certificate_hashes,
836            template.upstream_server_certificate_hashes
837        );
838    }
839
840    /// The mutually exclusive halves of the two templates, swapped.
841    ///
842    /// The pair above sets the profile on one leg and the raw config on the
843    /// other, so between them every one of the four fields is checked —
844    /// but only in one arrangement each. This runs the other arrangement,
845    /// so neither copy can be carrying a field by reading it off the wrong
846    /// leg.
847    #[tokio::test]
848    async fn the_copies_carry_the_other_arrangement_too() {
849        let mut template = distinctive_config(abstract_socket());
850        template.listener.transport_profile = None;
851        template.listener.transport_config = Some(Arc::new(quinn::TransportConfig::default()));
852        template.session.upstream_transport_config = None;
853        template.session.upstream_transport_profile = Some(marker_profile(1400));
854
855        let expected_raw =
856            Arc::clone(template.listener.transport_config.as_ref().expect("set above"));
857        let proxy = proxy(template);
858
859        let listener = proxy.listener_config();
860        assert!(
861            Arc::ptr_eq(listener.transport_config.as_ref().expect("carried"), &expected_raw),
862            "the client leg's raw config has to reach the listener unmodified"
863        );
864        assert!(listener.transport_profile.is_none());
865
866        let session = proxy.session_config();
867        assert_eq!(session.upstream_transport_profile, Some(marker_profile(1400)));
868        assert!(session.upstream_transport_config.is_none());
869    }
870
871    /// Records every profile it is asked to build.
872    #[derive(Default)]
873    struct RecordingInstaller(std::sync::Mutex<Vec<TransportProfile>>);
874
875    impl TransportInstaller for RecordingInstaller {
876        fn build(
877            &self,
878            profile: &TransportProfile,
879        ) -> Result<quinn::TransportConfig, TransportProfileError> {
880            self.0.lock().expect("uncontended").push(profile.clone());
881            profile.into_config()
882        }
883    }
884
885    #[tokio::test]
886    async fn a_live_client_leg_profile_gets_the_default_keep_alive() {
887        let installer = Arc::new(RecordingInstaller::default());
888        let mut template = distinctive_config(abstract_socket());
889        template.listener.installer = Some(installer.clone());
890        let proxy = proxy(template);
891
892        let profile = TransportProfile {
893            max_idle_timeout: Some(Duration::from_secs(4)),
894            ..Default::default()
895        };
896        proxy.control().set_transport(crate::types::Leg::Client, profile).expect("valid");
897
898        let seen = installer.0.lock().expect("uncontended");
899        let built = seen.last().expect("the installer built the profile");
900        assert_eq!(built.keep_alive_interval, Some(Duration::from_secs(2)));
901    }
902}