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}