moqtap_proxy/lib.rs
1#![deny(missing_docs)]
2
3//! MoQT intercepting proxy — transparent stream forwarding with inline
4//! frame parsing, observation and impairment.
5//!
6//! This crate provides a transparent proxy that sits between a MoQT client
7//! and relay, forwarding all bytes bidirectionally while parsing MoQT frames
8//! inline to emit structured events. It does not participate in MoQT state
9//! management — it observes, and it does what a hook tells it to do, but it
10//! never acts as an endpoint.
11//!
12//! # Observing
13//!
14//! [`proxy::TransparentProxy`] accepts client connections and hands each one
15//! to a [`session::ProxySession`]. Every frame the session parses is reported
16//! to an [`observer::ProxyObserver`] as a [`event::ProxyEvent`]. An observer
17//! that answers `false` to [`observer::ProxyObserver::wants_events`] asks for
18//! no parsing of its own; what the session parses then depends entirely on
19//! the hook.
20//!
21//! # Acting — the hook API
22//!
23//! [`hook::ProxyHook`] is the acting side. It has one method per *site* —
24//! control message, stream open, stream header, object, datagram, stream end
25//! — plus [`hook::ProxyHook::interest`]. Every method is synchronous and
26//! defaulted, so a hook implements only the sites it cares about, and the
27//! trait stays usable as `Arc<dyn ProxyHook>`. The two stream-decision sites
28//! return an [`action::StreamAction`]; every other site returns an
29//! [`action::Action`] — pass, replace the frame or just its payload, delay,
30//! hold behind an [`action::Gate`], elide, truncate, reset the stream, close
31//! the session.
32//!
33//! What the session arms is decided **once**, at session start, from
34//! [`hook::ProxyHook::interest`]. An [`action::Interest::NONE`] hook adds
35//! nothing to the path the observer already asked for — with a no-op
36//! observer that is the byte pump, bit for bit: no framing, no control
37//! parse, no datagram decode, and no site is ever called. Wider interest is
38//! paid for structurally — [`action::Interest::OBJECTS`] puts data streams
39//! through [`framer::ObjectFramer`], which buffers an object whole, up to
40//! [`framer::FramerConfig`]'s cap, before it forwards it.
41//!
42//! # What cannot be asked for
43//!
44//! Not every action is representable at every site, on every draft, on every
45//! kind of stream — on the drafts where a subgroup's ID is defined by its
46//! first object, eliding that object would redefine the subgroup, and
47//! resetting a control stream is a protocol violation whatever the reason.
48//! [`capability::Capabilities`] answers that question ahead of
49//! time, per draft, and hands back a [`capability::Support`]; the engine asks
50//! it again when the action arrives. An action it refuses is reported as
51//! [`event::ProxyEvent::ActionRefused`] carrying the [`capability::Refusal`]
52//! that explains it, and the frame is forwarded unchanged. Nothing a hook can
53//! return tears a session down by accident.
54//!
55//! A limit the proxy hit in *itself* — a stream it could not frame, a queue
56//! that filled, a hold it clamped, a release timer coarser than it asked for —
57//! is reported as [`event::ProxyEvent::Impairment`] rather than absorbed
58//! silently.
59//!
60//! Those reports arrive **after** the thing they describe, never before: the
61//! reset has been handed to the transport, the datagram has come back
62//! refused, the queue has already been abandoned. So an impairment is a fact
63//! about the past, and an action a guard refuses produces none at all — which
64//! matters because nothing retracts one. `ProxyEvent::ActionApplied` is the
65//! deliberate exception and says so on itself: the engine emits it on
66//! admission and the transport can still decline, which is what
67//! [`event::ProxyEvent::ActionFailed`] is for.
68//!
69//! Each impairment also names *which connection* it is about, and that is not
70//! the same as the direction it was noticed on. Most of them report a failure
71//! to write — a queue that could not be flushed, a datagram the far side
72//! refused, a destination stream that had to be reset — so they belong to the
73//! opposite leg from the one the bytes arrived on. A reader who mapped
74//! [`event::ProxySide`] onto a connection would attribute those to the healthy
75//! leg, plausibly and silently, so the event carries the leg outright and
76//! leaves it absent for the four reports that are about a profile, a draft or
77//! the host rather than about a connection at all.
78//!
79//! # Three layers to intervene at
80//!
81//! Everything below is one of three, stacked, and which one a question
82//! belongs to is usually the whole of the answer. Underneath the transport
83//! is a **socket** the caller supplies — [`listener::Listener::bind_with_socket`]
84//! for the client leg, [`session::ProxySessionConfig::upstream_socket`] for
85//! the relay leg — which sees UDP datagrams before QUIC does and can lose,
86//! delay, reorder or meter them. In the middle are that leg's **transport
87//! parameters**, a [`transport::TransportProfile`] or a raw
88//! `quinn::TransportConfig`, which decide what QUIC *does* about all that:
89//! the windows, the congestion controller, the loss-detection thresholds,
90//! the MTU floor. Above QUIC is the **object scheduler**, a
91//! [`shape::ShapeProfile`], which paces and starves MoQT objects that QUIC
92//! has already delivered intact — never reorders them, because a
93//! destination stream keeps one FIFO whose head gates everything behind it.
94//!
95//! They compose in one direction: **the lower layer acts and the upper one
96//! reacts.** A datagram the socket drops becomes a retransmission whose timing
97//! the transport parameters govern, and the bytes arrive late at the framer,
98//! which is where the scheduler's queue fills. Nothing runs the other way — an
99//! object this crate held back or removed was never handed to QUIC at all, so
100//! there is nothing for QUIC to repair and the peer's stack cannot tell it
101//! apart from a relay that published less. That is also why a run picks a
102//! layer rather than turning all three on: *does the player survive a bad
103//! network?* is the bottom two, *does it survive a bad relay?* is the top one,
104//! and a run with both armed cannot attribute what it saw to either.
105//!
106//! The one place the stack is read upwards is worth knowing, because it
107//! looks like a bug from inside the top layer: [`shape::Overflow::Block`]
108//! stalls this proxy's read loop and reaches the publisher only if the
109//! middle layer lets it, since a receive window wide enough absorbs the
110//! backpressure before the sender ever notices. Reporting is layered the
111//! same way and does not aggregate — [`shape::ShapeStats`] and
112//! [`event::ProxyEvent::Impairment`] describe the object layer only, and a
113//! datagram lost beneath QUIC is in neither.
114//!
115//! # The conditions these three layers reach
116//!
117//! An inventory rather than a tutorial: the network and delivery conditions
118//! each layer can produce, and the type that produces each. It is here so that
119//! "can this reproduce X?" is answered by a list with names in it rather than
120//! by reading three modules. Every row was checked against the code.
121//!
122//! **Socket layer** — a `quinn_netem::DirectionProfile`, one per direction, so
123//! every row below is independently settable uplink and downlink:
124//!
125//! | Condition | Produced by |
126//! |---|---|
127//! | Random loss | `LossModel::Bernoulli` |
128//! | Bursty loss | `LossModel::GilbertElliott` |
129//! | Deterministic loss pattern | `LossModel::Pattern` |
130//! | Fixed added latency | `DelayModel` |
131//! | Jitter, correlated | `DelayModel::{Uniform, Normal, Pareto, ParetoNormal}` |
132//! | Latency spikes | `DirectionProfile::timeline` over `delay` |
133//! | Reordering | `ReorderModel` |
134//! | Duplication | `DupModel` |
135//! | Corruption | `CorruptModel` |
136//! | Sustained bandwidth limit | `RateModel::{bps, burst_bytes}` |
137//! | Bandwidth step, ramp, oscillation | `DirectionProfile::timeline` over `rate` |
138//! | Bufferbloat | `RateModel::queue_bytes` |
139//! | Blackout or handover window | `DirectionProfile::blackouts` |
140//! | Asymmetric uplink/downlink | `ImpairProfile::{uplink, downlink}` |
141//! | High-BDP path | `Preset::Satellite`, with the windows below |
142//! | MTU black hole | `DirectionProfile::mtu_blackhole` |
143//!
144//! **Transport layer** — a [`transport::TransportProfile`], per leg:
145//!
146//! | Condition | Produced by |
147//! |---|---|
148//! | Flow-control stall | `receive_window`, `stream_receive_window`, `send_window` |
149//! | Stream-concurrency starvation | `max_concurrent_uni_streams`, `max_concurrent_bidi_streams` |
150//! | Idle timeout and keep-alive | `max_idle_timeout`, `keep_alive_interval` |
151//! | Congestion-controller sensitivity | `Congestion::{Cubic, Bbr, NewReno}`, `initial_rtt` |
152//! | Loss-detection sensitivity | `packet_threshold`, `time_threshold`, `persistent_congestion_threshold`, `ack_frequency` |
153//! | Abrupt connection close | [`control::ProxyControl::close_session`] |
154//!
155//! **Object layer** — an [`action::Action`] returned from a hook, or a
156//! [`shape::ShapeProfile`] with no hook at all:
157//!
158//! | Condition | Produced by |
159//! |---|---|
160//! | Object drop, per track/group/subgroup/id | [`action::Action::Drop`] with [`action::DropMode`] |
161//! | Object delay, group hold | [`action::Action::Delay`], [`action::Action::Hold`] |
162//! | Partial object, truncated stream | [`action::Action::Truncate`] |
163//! | Stream abort mid-subgroup | [`action::Action::ResetStream`] |
164//! | Object payload corruption | [`action::Action::Replace`], [`action::Action::ReplacePayload`] |
165//! | Cross-stream reordering | `OpenAfter` at the stream-open site |
166//! | Head-of-line blocking | `SerializeAfter` |
167//! | Priority inversion, track starvation | [`shape::Discipline::StrictPriority`], [`shape::Discipline::WeightedRoundRobin`] |
168//! | Per-track bandwidth budgets | [`shape::ClassRule`] keyed on `track_alias` or `priority` |
169//! | Control-message drop, delay, rewrite | [`hook::ProxyHook::on_control_message`] |
170//! | Protocol fault injection | [`control::ProxyControl::inject_control`], raw bytes |
171//! | Datagram-mode impairment | [`hook::ProxyHook::on_datagram`] |
172//!
173//! Two cross-cutting properties, each with a limit worth knowing:
174//!
175//! * **Reproducibility.** Every impairment decision comes from
176//! `Pcg32::seeded(profile.seed, stream_id)`, so a seed and a packet index
177//! reproduce a decision sequence exactly. Wall-clock packet *timing* is not
178//! reproducible and is not claimed to be — see the pacer's own docs for why a
179//! stream of deadlines reproduces a rate rather than a spacing.
180//! * **Evidence.** [`event::ProxyEvent::Impairment`] with
181//! [`event::ImpairmentKind`], [`event::ShapeOutcome`] and [`event::Effect`]
182//! report the object layer; `quinn_netem::StatsSnapshot` reports the socket
183//! layer. They do not aggregate, deliberately. The counts divide on the
184//! same principle: what a **hook** decided is on [`instrument::Counters`]
185//! and what a **profile** did is on [`shape::ProxyStats`], because a hook
186//! runs whether or not a profile was configured, and a figure on the wrong
187//! side of that line can only ever be a partial count.
188//!
189//! # Shaping — impairment without a hook
190//!
191//! A [`shape::ShapeProfile`] on [`session::ProxySessionConfig::shape`] is the
192//! other way to impair a session, and it is **configuration rather than hook
193//! code**: named token buckets, [`shape::ClassRule`]s that aim a
194//! [`shape::Matcher`] at one of them, a bounded-queue policy, and a
195//! [`shape::Discipline`] arbitrating between classes that share a bucket.
196//! *Starve the video track while audio flows* is then a value a caller builds,
197//! with no `ProxyHook` involved at all — a profile arms object framing on its
198//! own, because classification needs the [`framer::ObjectMeta`] only the
199//! framing path produces. A mistyped bucket name is rejected by
200//! [`shape::ShapeProfile::try_new`] rather than becoming a silently inert rule,
201//! and what a run actually did is readable *while it runs* from
202//! [`session::ProxySession::shape_stats`], or for a whole proxy — every session
203//! it has accepted, ended ones included — from
204//! [`control::ProxyControl::stats`]. The proxy-wide form is cumulative where
205//! [`control::ProxyControl::sessions`] is instantaneous, and it splits its byte
206//! figures across both connections and both directions, so *the shaper read
207//! this from the client and wrote that to the relay* is two numbers rather than
208//! one.
209//!
210//! Ordering is not a policy option. Each unit is classified and charged
211//! individually, but a destination stream keeps one FIFO whose head gates
212//! everything behind it, because object IDs are delta-encoded on drafts
213//! 14-23 and the framer's re-encoding primitive handles removal, not
214//! reordering. A stream carrying two classes says so, once, as
215//! `ImpairmentKind::ClassChangedMidStream`.
216//!
217//! What shaping deliberately does **not** cover, because a limit discovered
218//! from a green run is worse than one read here:
219//!
220//! * **Control streams are never shaped**, on any path — no scheduler is
221//! installed on them, so no control frame can reach a bucket even by
222//! accident. Pacing SUBSCRIBE behind a video bucket would make MoQT's
223//! idle steady state look like a dead session.
224//! * **Datagrams are policed, not paced.** A datagram-aimed class discards
225//! what its bucket has no tokens for, on arrival, and never delays
226//! anything: there is no queue on that path and there should not be one,
227//! because a FIFO would impose a delivery order the protocol does not
228//! have. So a datagram-mode audio track can be held to a rate and cannot
229//! be smoothed, and a rule asking for [`action::Action::Delay`] or
230//! [`action::Action::Hold`] at the datagram site is refused as
231//! `Refusal::WrongSite` rather than approximated. Reordering and delay
232//! under a whole connection are `quinn-netem`'s, one layer down.
233//! * **A `Fetch`-aimed class can miss one stream at a time on drafts
234//! 18-23**, whose fetch objects carry a Group ID *difference* the fetch's
235//! Group Order gives a direction to. That order is settled on the control
236//! plane and never reaches the data stream, so the session reads it off the
237//! FETCH and hands it to the framer; a fetch stream naming a request the
238//! session never carried is abandoned at its header, no rule ever sees a
239//! unit of it, and it reports itself as
240//! `Impairment { FramerBypass { FetchGroupOrderUnknown } }`.
241//! * **On drafts 15, 16 and 17 an elide on a fetch unit is paid for rather
242//! than refused.** Those drafts let a fetch object leave a field off and
243//! take the previous object's, so deleting one object's bytes would move
244//! every Location behind it while the stream still decoded. The framer
245//! re-encodes the survivor's framing against the frame now in front of it,
246//! which settles the debt in one frame exactly as the subgroup fix-up
247//! does.
248//! * **There is no `Expiry::Drop`.** A unit dropped at release time cannot
249//! arm the framer's positional elide fix-up — its successors are already
250//! framed — so on drafts 14-23 it would shift every later object's
251//! absolute ID and report corruption as loss. Under the default
252//! [`shape::Expiry::Deliver`] a starved class is **late, never lossy**;
253//! [`shape::Expiry::ResetStream`] abandons the stream instead. Discarding
254//! an *arriving* unit is [`shape::Overflow::DropTail`]'s job, which runs
255//! early enough to pay the fix-up.
256//! * **[`shape::Overflow::Block`] stops this proxy's read loop; it does not
257//! stall the peer.** At quinn's defaults a stream's receive window is
258//! 1.25 MB and the connection-level `receive_window` is `VarInt::MAX` —
259//! effectively unlimited — so a blocked stream still absorbs its queue
260//! plus ~1.25 MB before the sender's write blocks, and blocked streams
261//! never slow the connection as a whole. Assert `Block` on this crate's
262//! own counters and event order, never on the peer's send rate. Making
263//! backpressure reach the publisher is a transport setting rather than a
264//! missing feature: put a small `stream_receive_window` on the leg that
265//! *receives* from it, through
266//! [`transport::TransportProfile`], or on the raw config directly with
267//! [`listener::ListenerConfig::transport_config`] or
268//! [`session::ProxySessionConfig::upstream_transport_config`]. Measured
269//! against one session with a two-object queue held shut: at quinn's
270//! default (a 1 250 000-byte per-stream window) the source pushed about
271//! 1.25 MB before its first write pended; behind a 64 KiB window it
272//! stalled at 82 370 bytes, with the shaper's own numbers identical in
273//! both runs.
274//!
275//! Note that 82 370 is *past* a 64 KiB window, by 16 834 bytes, and that
276//! is not an anomaly — it is what having a reader means. The source's
277//! allowance is the window plus everything the proxy has already drained
278//! off the wire, so with a proxy reading, the stall lands beyond the
279//! window; against a receiver that never reads at all it lands a little
280//! short of it, by however much of the final write did not fit. Both are
281//! correct, for different readers. Either way the number moves with the
282//! writer's chunk size and the reader's queue depth, so it is a property
283//! of the fixture and not a constant to assert against.
284//! * **[`shape::BucketConfig::ceil_bps`] is accepted and never borrowed
285//! against.** A profile setting it above `rate_bps` measures a flat
286//! `rate_bps`. There is no runtime report for it, deliberately:
287//! `ShapeError` has no variant a bucket misconfiguration could land in,
288//! and `ImpairmentKind::ShapeRuleUnmatchable` is keyed on a *wire* field
289//! and a draft, so reusing it would make a per-draft report fire on a
290//! draft-independent fact.
291//!
292//! # Below QUIC — the socket seams
293//!
294//! Everything above shapes MoQT objects, which sit *above* QUIC: damage
295//! there is indistinguishable from a relay that dropped or delayed media,
296//! and QUIC will never repair it because as far as QUIC is concerned
297//! nothing was lost. The other place to impair traffic is *below* QUIC,
298//! on the datagrams themselves, where damage is precisely what the peer's
299//! QUIC stack is built to absorb — loss triggers retransmission, jitter
300//! inflates the RTT estimate, a rate limit drives the congestion
301//! controller. The two answer different questions and compose freely.
302//!
303//! [`listener::Listener::bind_with_socket`] builds the client-facing
304//! endpoint over a socket the caller already owns, and
305//! [`session::ProxySessionConfig::upstream_socket`] does the same for the
306//! relay leg. Both take any `Arc<dyn quinn::AsyncUdpSocket>`, so this
307//! crate stays agnostic: a tap, a counter and an impairment shim are the
308//! same seam, and none of them is a dependency here.
309//!
310//! Three things a caller has to know:
311//!
312//! * **The client-facing seam reaches WebTransport clients too**, and by
313//! construction rather than by luck. The proxy never builds a
314//! WebTransport endpoint on that side — it builds the QUIC endpoint,
315//! reads the negotiated ALPN off the handshake, and hands an `h3`
316//! client's still-connecting QUIC connection to the WebTransport library
317//! to finish. That library adopts a connection already living on this
318//! endpoint rather than binding one, so there is no second datagram path.
319//! * **A WebTransport *upstream* cannot honour a socket, and says so.**
320//! Setting one there returns
321//! [`error::ProxyError::UpstreamSocketUnsupported`] and connects to
322//! nothing. `upstream_transport_config` is merely ignored in the same
323//! situation, and the asymmetry is the point: a dropped transport config
324//! yields a working connection with unchosen windows, while a dropped
325//! socket yields a relay leg that bypasses the caller's decorator
326//! entirely — every impairment armed on it reported and applied to
327//! nothing, and a run that looks clean because it *is* clean.
328//! * **One socket per session on the upstream seam.** Two endpoints reading
329//! one socket steal each other's datagrams, and a packet for a connection
330//! an endpoint does not own is discarded, so sessions that run
331//! concurrently need a socket each.
332//!
333//! Those two seams are reached by building a [`listener::Listener`] or a
334//! [`session::ProxySession`] yourself. A
335//! [`proxy::TransparentProxy`] binds its own listener inside `run()` and
336//! copies its session template per connection, so it reaches both through
337//! one call instead: `proxy::TransparentProxy::set_impaired_socket` takes
338//! a leg's socket **and** the `quinn_netem::ImpairHandle` it was wrapped
339//! with, together, and is what makes
340//! `control::ProxyControl::set_impair` able to arm anything. Together
341//! because nothing can check that a handle belongs to the socket beside it —
342//! `AsyncUdpSocket` is a trait object with no downcast and the decorator
343//! exposes no accessor — so taking them as two independent settings would
344//! make a mismatched pair expressible, and a mismatched pair impairs traffic
345//! nobody sends.
346//!
347//! Those two method names, and every `quinn_netem::` name on this page, are
348//! in plain code font rather than linked because all of them exist only under
349//! the `impair` feature, while this page is built without it. A link to one
350//! would resolve to nothing, and an unresolved intra-doc link is an error
351//! under the documentation build rather than a warning.
352//!
353//! Nothing that happens under a supplied socket appears in this crate's
354//! reporting. [`shape::ShapeStats`] and
355//! [`event::ProxyEvent::Impairment`] describe the object layer; a datagram
356//! dropped beneath QUIC is in neither, and the socket is where to count it.
357//!
358//! # Transport parameters, per leg
359//!
360//! A [`transport::Leg`] is a *connection* — this proxy holds two, one to
361//! the client and one to the relay — and each carries its own QUIC
362//! transport parameters. Do not read it as
363//! [`event::ProxySide`], which is a *direction of travel* over a leg and
364//! therefore has four variants where `Leg` has two; both types exist in
365//! this crate and the compiler will not catch the confusion.
366//!
367//! Each leg takes those parameters one of two ways, never both at once. A
368//! raw `quinn::TransportConfig` is installed as it was given. A
369//! [`transport::TransportProfile`] is a value that can be written down,
370//! checked and stored, and the leg builds a config from it through a
371//! [`transport::TransportInstaller`] — [`transport::DefaultInstaller`]
372//! unless the caller supplies one. Either way the config is installed
373//! before the endpoint is built and before anything is dialled, so a
374//! refusal costs no socket and cannot be mistaken for a network fault.
375//!
376//! Setting both on one leg is [`error::ProxyError::TransportConfigAndProfile`]
377//! rather than a merge, and that is a fact about `quinn::TransportConfig`
378//! rather than a policy: it has no `Clone` and no getters, so no merge can
379//! be written that does not discard the caller's config while keeping the
380//! profile — the failure the tests would not see. A caller who wants both
381//! applies the profile to their own config with
382//! [`transport::TransportProfile::apply_to`], or supplies an installer that
383//! builds the base itself.
384//!
385//! # Capturing the QUIC layer
386//!
387//! Under the off-by-default `qlog` feature, `qlog::QlogSpec` says where a
388//! connection's QUIC-level capture should be written. It is written in plain
389//! code font here for the same reason the `quinn_netem` names above are: this
390//! page is built without that feature, and a link to an item that does not
391//! exist in the build is an error rather than a warning.
392//!
393//! **Each leg carries a spec of its own**, beside the transport settings it
394//! already carries: `ListenerConfig::qlog` for the client leg and
395//! `ProxySessionConfig::upstream_qlog` for the relay leg. The leg builds its
396//! `quinn::TransportConfig` once — applying its
397//! [`transport::TransportProfile`] first, through its
398//! [`transport::TransportInstaller`] if it has one — and installs the sink
399//! on what came back, before the endpoint exists, because quinn accepts a
400//! sink in exactly one place and that place is a method on a config. A spec
401//! on its own is enough: the leg builds a default config for the sink to go
402//! on rather than attaching the capture to a config no connection uses. A
403//! leg naming a **raw** `quinn::TransportConfig` and a spec together is
404//! refused with `ProxyError::TransportConfigAndQlog` naming the leg, for the
405//! same reason a raw config and a profile are: the config is the caller's
406//! and this crate may not mutate it behind their back.
407//!
408//! `QlogSpec::attach_to` is that installation step on its own, for a caller
409//! building a config by hand, and `QlogSpec::into_stream` is the seam under
410//! it for one being installed somewhere this crate does not reach. Both
411//! write the file's preamble, so the artifact exists from that moment.
412//!
413//! **A spec is single-use and therefore refused on a proxy template.** It owns
414//! its writer and is consumed when it becomes a sink, so it has no `Clone` and
415//! there is exactly one of it, while [`proxy::TransparentProxy`] copies both
416//! leg configs — once for the listener, once per accepted connection. A
417//! `ProxyConfig` carrying one on either leg is refused by
418//! `TransparentProxy::run` with `ProxyError::QlogOnProxyTemplate`, before it
419//! binds, rather than dropped: a proxy that came up anyway would report success
420//! and leave the caller's file uncreated. Capture by building the
421//! [`listener::ListenerConfig`] and calling [`listener::Listener::bind`]
422//! yourself, or by driving a [`session::ProxySession`] directly — which is also
423//! the only shape in which *one capture per connection* is expressible, since
424//! one sink shared by an endpoint's connections writes all of them into one
425//! file, behind one preamble, with no record saying where one ends.
426//!
427//! The capture is the layer *underneath* everything else this crate reports.
428//! quinn records packets sent and received, congestion and loss, and nothing
429//! above QUIC — no object, no stream id, no shaping class — so a datagram
430//! dropped beneath QUIC appears there and in none of the counters, while an
431//! object dropped by a shaping rule appears in the counters and not there.
432//! Two facts about the artifact are worth knowing before anyone builds a
433//! check on one: the file and its preamble are written when the sink is
434//! built, so a capture that reached no connection is still a valid, non-empty
435//! file; and a connection that carried nothing still sends packets, so a
436//! non-zero byte total is not by itself evidence of traffic. The module
437//! documentation carries the measurements.
438//!
439//! # Asking a proxy that is already running
440//!
441//! Everything above is settled before the thing it configures exists — the
442//! listener consumes its config as it binds, each session copies its own
443//! before it dials, and a hook's interest is sampled once at session start.
444//! That is what makes a run reproducible from the values that started it,
445//! and it is also why there was no way to ask a live proxy anything.
446//!
447//! [`proxy::TransparentProxy::control`] hands back a
448//! [`control::ProxyControl`], and it can be called before
449//! [`proxy::TransparentProxy::run`] is awaited — which is the usual case,
450//! because `run()` does not return until the proxy is finished. The handle
451//! is therefore defined for a proxy that has not bound yet:
452//! [`control::ProxyControl::local_addr`] answers
453//! [`error::ProxyError::NotBound`] until the endpoint exists, which is how a
454//! proxy configured with port 0 tells its caller which port it chose.
455//!
456//! [`control::ProxyControl::sessions`] lists the sessions that are live at
457//! that instant, and it is a **census rather than a log**: an id appears
458//! when its session starts running and is gone once that session ends, for
459//! any reason, including the ones no teardown site could be written for. A
460//! caller that wants the history reads
461//! [`event::ProxyEvent::SessionStarted`] and
462//! [`event::ProxyEvent::SessionEnded`] from its observer instead, and should
463//! expect the two views to disagree at the edges — the events are emitted
464//! around accepting a connection, the census around running the session, and
465//! a session whose upstream connect fails is in the first and only briefly
466//! in the second.
467//!
468//! Requests that *act* on a live session — as opposed to reporting on one —
469//! are refused with a [`control::ControlError`] rather than by returning
470//! success and doing nothing. The type is neither `Eq` nor
471//! `#[non_exhaustive]`, so a caller outside this crate can match every
472//! variant with no wildcard arm and find out at compile time when a new
473//! refusal appears.
474//!
475//! Three of those requests act on one named session, and each names the
476//! consequence it produces rather than the state it changes:
477//!
478//! * [`control::ProxyControl::close_session`] gives the session's egress
479//! queues a bounded window — [`action::EgressConfig::drain_timeout`], 100
480//! ms by default — to flush, then closes both legs with the code and
481//! reason it was given, whether or not the window was enough. Anything
482//! still queued when it expires is abandoned and named, per stream, as
483//! [`event::ImpairmentKind::QueuedBytesAtTeardown`], so what the peer
484//! received plus what the impairments name is what was queued when the
485//! close was asked for.
486//! * [`control::ProxyControl::reset_stream`] resets one live forwarded
487//! stream, which the destination peer sees as `RESET_STREAM` carrying the
488//! requested code.
489//! * [`control::ProxyControl::inject_control`] writes a framed control
490//! message onto the session's existing control stream, between two
491//! forwarded messages, so the peer decodes it in sequence.
492//!
493//! Only the first of those three produces observer events, and it produces
494//! them through the session rather than from the call: a
495//! [`event::ProxyEvent::SessionEnded`] whose reason names the **control
496//! plane** rather than a hook — both reach the same latch, and reporting an
497//! operator's close as a hook's was a sentence about the run that was simply
498//! untrue — plus whatever the expired drain window abandoned. The other two
499//! emit nothing, and each says why on its own page: neither has anything to
500//! report that the caller does not already hold in the return value, and
501//! neither may borrow an existing event without making it ambiguous for the
502//! readers that already rely on it. What they produce is observable at the
503//! peer, which is where a consequence belongs.
504//!
505//! # Reconfiguring a proxy that is already running
506//!
507//! The other requests change what the proxy *is*. They reach three
508//! different distances, and the distances are not a matter of how they were
509//! implemented — they are what the thing being changed will admit. Read
510//! them as reach first and as settings second, because a caller who reads
511//! them the other way round will measure the wrong connection and believe
512//! the answer.
513//!
514//! **`control::ProxyControl::set_impair` reaches traffic already in
515//! flight.** It arms a datagram impairment on one leg's socket, below QUIC,
516//! so the next datagram that leg passes carries it. Datagrams already
517//! handed to the operating system are gone.
518//! `control::ProxyControl::clear_impair` removes it and is idempotent.
519//! Both need the leg's socket **and** the `quinn_netem::ImpairHandle` it was
520//! wrapped with to have been handed over together, before `run()`, through
521//! `proxy::TransparentProxy::set_impaired_socket`; a leg the proxy holds
522//! no handle for is refused rather than accepted and ignored, because a
523//! profile armed on a socket nobody's traffic crosses is reported as applied
524//! and does nothing, and the run that follows looks clean because it *is*
525//! clean.
526//!
527//! **[`control::ProxyControl::set_shaper_enabled`] reaches the next release
528//! decision each queue makes**, on every session already running. It stops
529//! the token buckets and the discipline without discarding the profile, so
530//! switching it back on resumes the same configuration and the same
531//! counters. A stream held by a bucket that will refill resumes within one
532//! pacing interval; a stream held by a bucket configured at **zero** does
533//! not resume until its `max_hold` clamp, because the wait it is on is a
534//! timer a per-stream queue armed and nothing session-wide reaches one.
535//!
536//! **[`control::ProxyControl::set_shape`] reaches the next stream** a
537//! running session forwards, and every session accepted afterwards. Not the
538//! next object: a stream's egress queue holds the scheduler its units were
539//! admitted under, and a class is an index into that scheduler's class list,
540//! so a unit classified against one profile and released against another
541//! charges a class that was not the one matched. Two further limits, both
542//! structural: a session that started **unshaped** stays unshaped, because
543//! framing is armed at session start and there are no objects to classify;
544//! and a running session takes the new profile only if its **class list is
545//! unchanged** — same names, same order — because
546//! [`shape::ShapeStats`] rows are pre-sized per session and charged by
547//! position, so a different list would keep every number right and every
548//! label on it wrong. Sessions accepted afterwards have no such limit. To
549//! move a running session onto a different class list, end it with
550//! `close_session`.
551//!
552//! **[`control::ProxyControl::set_transport`] reaches no connection that
553//! already exists — ever.** A QUIC connection takes its
554//! `quinn::TransportConfig` once, at setup, and keeps it for life; quinn
555//! offers four setters on a live connection (two stream-count limits, two
556//! windows) and no way to replace the configuration behind it. So a profile
557//! set on [`transport::Leg::Upstream`] reaches the next connection this
558//! proxy **opens**, which is the next session it accepts, and a profile set
559//! on [`transport::Leg::Client`] reaches the next connection it
560//! **accepts** — which the proxy does not initiate and which may never
561//! arrive. On a proxy that never dials or accepts again, the call is a
562//! permanent silent no-op that returns `Ok(())`, and nothing in the return
563//! value distinguishes that from a setting that reached everything
564//! afterwards. The one instrument that makes it bite on a session already
565//! running is `close_session`: a leg that reconnects is a leg that installs
566//! it. A WebTransport upstream cannot take one at all — that endpoint is
567//! built inside the WebTransport library, which takes no transport config
568//! and hands back no endpoint — and says so with
569//! [`control::ControlError::Unsupported`] rather than storing it.
570//!
571//! # Writing the configuration down
572//!
573//! Everything above is configured in Rust, which is what a test wants and not
574//! what an operator wants. The off-by-default `serde` feature derives
575//! `Serialize` and `Deserialize` on the configuration types — the shaping
576//! profile with its buckets, classes and matchers, and both legs' transport
577//! parameters — so a caller can read them from a file instead of building
578//! them in code. It is written in plain code font here for the reason the
579//! `quinn_netem` names above are: this page is built without that feature, so
580//! a link would resolve to nothing.
581//!
582//! The feature buys the types and nothing more. This crate reads no file,
583//! interprets no run, and has no opinion about what a run *is*: a caller
584//! deserializes what it wants, hands the profiles to a session, and drives
585//! whatever changes part-way through against [`control::ProxyControl`]
586//! itself. That is the same line [`hook::ProxyHook`] draws — the mechanism
587//! and the extension point are here, and what to do with them belongs to
588//! whoever wrote the consumer.
589//!
590//! One property is worth knowing before reading a profile from a file.
591//! [`shape::ShapeProfile`]'s fields are private so that
592//! [`shape::ShapeProfile::try_new`] is the only way to build one, and its
593//! `Deserialize` is routed through a public-field mirror whose `TryFrom` calls
594//! that constructor. A profile read from a file therefore runs the same seven
595//! validations as one built in Rust, and there is no deserialization path that
596//! skips them — including the one that catches a class naming a bucket that
597//! does not exist, which would otherwise parse, arm, report shaping and shape
598//! nothing.
599//!
600//! # Instrumentation
601//!
602//! [`instrument::Recorder`] counts the slow paths: framers created, objects
603//! elided, actions refused, egress items queued, release lateness. This is
604//! what makes `Interest::NONE` a falsifiable claim rather than a promise —
605//! a session that declared no interest ends with an all-zero
606//! [`instrument::Counters`]. [`shape::ShapeStats`] is its sibling rather
607//! than an extension of it: one row per configured class plus a default
608//! row, an unshapeable row and the session totals, where a class that saw
609//! nothing reports a **zero row** and never an absent one. Its two
610//! duration-valued fields are reported and never asserted — machine load
611//! moves them, and their load-independent companion counts are what a gate
612//! reads.
613//!
614//! Every session total is reported three times: flat, and again under
615//! [`shape::ShapeStats::uplink`] and [`shape::ShapeStats::downlink`]. The
616//! flat figure is **defined as the sum of the two**, so the views cannot
617//! disagree and no existing reader breaks. It matters on a session that
618//! shapes both legs, where one number cannot tell an uplink stall from a
619//! downlink one and a report will attribute downlink starvation to the
620//! uplink class.
621//!
622//! [`shape::ProxyStats`], read through [`control::ProxyControl::stats`], is
623//! the same figures kept a second time for a whole proxy — cumulative across
624//! every session it has accepted, ended ones included, and charged by the
625//! same writers, so no figure can reach a session's rows and miss it. It
626//! carries one axis a session's own totals do not: a **leg** as well as a
627//! direction, because a proxy holds two connections and a byte crosses both.
628//! Read [`shape::LegStats`] before reading a cell — a figure is charged
629//! where it was measured, so `per_leg[Client].uplink` is what was read from
630//! the client and `per_leg[Upstream].uplink` is what was written to the
631//! relay, and the difference between them is what the shaper kept back.
632//!
633//! **Every field here has a producer**, and it took a deletion rather than
634//! five new writers to make that true. A zero that looks like a measurement
635//! is how a clean run gets believed, and five fields here were one. Two of
636//! them counted what a **hook** did — a deferral and a truncation — while
637//! everything on this page is gated on a configured [`shape::ShapeProfile`],
638//! so in this type they could only ever have been partial counts; they are
639//! [`instrument::Counters::units_delayed`] and
640//! [`instrument::Counters::objects_truncated`] now, beside the elide count
641//! that had already settled where a hook's decision belongs. The other three
642//! were `Duration` totals a reader cannot calibrate, and the timing
643//! dimension is reported as a distribution by
644//! [`instrument::Counters::release_errors`] instead.
645//!
646//! Separately, and a different kind of zero: the three event figures on a
647//! [`shape::DirectionStats`] are charged where traffic arrived, so they read
648//! zero in a *departure* cell. That is stated on the fields as well.
649//!
650//! # Migrating
651//!
652//! *From 0.4*: [`session::ProxySessionConfig`] gained the `shape`,
653//! `upstream_socket`, `upstream_transport_profile` and `upstream_installer`
654//! fields, and [`listener::ListenerConfig`] gained `transport_profile` and
655//! `installer`. Neither is `#[non_exhaustive]`, so an exhaustive struct
656//! literal must name them all — `None` on each is 0.4 behaviour exactly, and
657//! [`session::ProxySessionConfig::default()`](session::ProxySessionConfig)
658//! is unaffected. [`listener::Listener::bind`] is unchanged and still binds
659//! its own socket. `StreamCtx::new` gained a seventh argument, which only
660//! affects code that builds one by hand to unit-test its own hook.
661//!
662//! *From 0.3*: the 0.3 hook trait survives as [`hook::LegacyProxyHook`],
663//! deprecated. Wrap an existing implementation in [`hook::LegacyHook`] to run
664//! it against this engine unchanged; its control-message and datagram
665//! rewrites become [`action::Action::Replace`].
666
667// Declared alphabetically, which says nothing about the graph. What does:
668// `instrument`, `qlog` and `types` name nothing else in this crate, so each of
669// them sits below every consumer it has and can be read, replaced or compiled
670// on its own. `tests/leaf_modules.rs` is what keeps that true — a single
671// `use crate::…` added to one of the three would end it silently otherwise.
672pub mod action;
673pub mod capability;
674pub mod control;
675pub mod error;
676pub mod event;
677pub mod framer;
678pub mod hook;
679pub mod instrument;
680pub mod listener;
681pub mod observer;
682pub mod parser;
683pub mod proxy;
684pub mod session;
685pub mod shape;
686pub mod transport;
687pub mod types;
688
689// Engine internals. Not `pub mod`: the deferred-write queue, the action
690// executor and the release wheel are how the sites above are implemented,
691// not part of what this crate promises. Keeping them private also keeps
692// them out of `#![deny(missing_docs)]` and lets any of the three be
693// replaced without a breaking change.
694//
695mod egress;
696mod exec;
697mod release_timer;
698
699#[cfg(feature = "cert-gen")]
700pub mod cert;
701
702// Behind `qlog`, which is off by default because turning it on adds a
703// derive-macro toolchain to everybody's build. What it holds is a description
704// of where a QUIC-layer capture should be written; the capture itself is
705// written by quinn.
706#[cfg(feature = "qlog")]
707pub mod qlog;