1// Package quicnettest provides a deterministic, unprivileged UDP carriage
2// simulator for adverse-network tests of QUIC-like traffic.
3//
4// The package is test support: it is stdlib-only, runs entirely in user space,
5// and never needs netem, root, or another privileged facility. A [Proxy] sits
6// between a UDP client and a UDP server and impairs both directions
7// independently:
8//
9// - deterministic packet loss,
10// - deterministic packet duplication,
11// - delay-based reordering,
12// - constant latency plus bounded uniform jitter,
13// - scheduled or manually toggled blackout windows,
14// - NAT-style rebinding that changes the proxy's upstream source address.
15//
16// # Determinism
17//
18// Every random decision uses either the per-direction source seeded from
19// [Config.Seed] or the caller-supplied [Config.RNG], so a given seed and packet
20// sequence always produce the same decisions, in the documented order. Random
21// draws are consumed only for impairments that are enabled, so a zeroed
22// [LinkConfig] never touches the RNG. [Config.Clock] defaults to wall time; pass
23// a [ManualClock] to advance the schedule without sleeping.
24//
25// # Bounds
26//
27// The proxy runs exactly three goroutines (one reader per socket direction plus
28// one scheduler) regardless of traffic, and never spawns per-packet work. The
29// packet budget is [Config.QueueCapacity]: queued and deadline-pending packets
30// together may not exceed it. When the budget is exhausted the arriving packet
31// is dropped and counted in [LinkStats.OverflowDrops] instead of blocking the
32// reader. Packet buffers are bounded by [Config.MaxPacketSize], and larger
33// datagrams are dropped and counted in [LinkStats.OversizeDrops].
34//
35// # Shutdown
36//
37// [Proxy.Close] is idempotent, wakes every goroutine, and waits for them to
38// exit. Errors observed while shutting down are not counted as delivery errors,
39// so [Proxy.Stats] stays meaningful after Close.
40package quicnettest
41
42import (
43 "errors"
44 "fmt"
45 "math/rand"
46 "net"
47 "strconv"
48 "sync"
49 "time"
50)
51
52const (
53 // DefaultQueueCapacity bounds in-flight packets per proxy.
54 DefaultQueueCapacity = 256
55 // DefaultMaxPacketSize bounds a single datagram. QUIC-like traffic stays
56 // well below this; larger datagrams are dropped rather than buffered.
57 DefaultMaxPacketSize = 1500
58 seedStride = int64(2654435761)
59)
60
61// Direction identifies one carriage direction of the proxy.
62type Direction int
63
64const (
65 // ToServer is the client -> proxy -> server direction.
66 ToServer Direction = iota
67 // ToClient is the server -> proxy -> client direction.
68 ToClient
69)
70
71// String implements fmt.Stringer.
72func (d Direction) String() string {
73 switch d {
74 case ToServer:
75 return "to-server"
76 case ToClient:
77 return "to-client"
78 default:
79 return "direction(" + strconv.Itoa(int(d)) + ")"
80 }
81}
82
83// RNG is the deterministic randomness seam for impairment decisions. The
84// standard library's *rand.Rand satisfies it. The default source is seeded per
85// direction and only ever touched by that direction's reader goroutine, so the
86// simulation is race-free and reproducible. An injected RNG is shared by both
87// directions and must be safe for concurrent use; it is intended for scripted
88// single-direction tests.
89type RNG interface {
90 Intn(n int) int
91}
92
93// Blackout is a window during which every packet on a link is dropped. Start and
94// End are relative to proxy creation, so the schedule never depends on wall
95// time.
96type Blackout struct {
97 Start time.Duration
98 End time.Duration
99}
100
101// LinkConfig describes the impairment of one carriage direction. Percentages
102// are in [0,100]; 100 always triggers and 0 never draws from the RNG.
103type LinkConfig struct {
104 // BaseLatency is added to every packet before it is scheduled.
105 BaseLatency time.Duration
106 // Jitter adds a uniform random delay in [0, Jitter] on top of BaseLatency.
107 Jitter time.Duration
108 // LossPercent drops packets with uniform probability.
109 LossPercent int
110 // DuplicatePercent schedules one extra copy of a packet.
111 DuplicatePercent int
112 // DuplicateDelay separates the extra copy from the original deadline.
113 DuplicateDelay time.Duration
114 // ReorderPercent delays a packet by ReorderDelay, which moves it behind
115 // packets scheduled later in the same direction.
116 ReorderPercent int
117 // ReorderDelay is the reordering window.
118 ReorderDelay time.Duration
119 // Blackouts lists drop windows relative to proxy creation.
120 Blackouts []Blackout
121}
122
123func (l LinkConfig) validate(direction Direction) error {
124 if l.BaseLatency < 0 || l.Jitter < 0 || l.DuplicateDelay < 0 || l.ReorderDelay < 0 {
125 return fmt.Errorf("quicnettest: %s link durations must not be negative", direction)
126 }
127 for name, percent := range map[string]int{
128 "loss": l.LossPercent,
129 "duplicate": l.DuplicatePercent,
130 "reorder": l.ReorderPercent,
131 } {
132 if percent < 0 || percent > 100 {
133 return fmt.Errorf("quicnettest: %s link %s percent %d is outside [0,100]", direction, name, percent)
134 }
135 }
136 for i, window := range l.Blackouts {
137 if window.Start < 0 || window.End <= window.Start {
138 return fmt.Errorf("quicnettest: %s link blackout %d must satisfy 0 <= start < end", direction, i)
139 }
140 }
141 return nil
142}
143
144// Config configures a [Proxy].
145type Config struct {
146 // ListenAddr optionally selects the client-facing fixture address.
147 // Nil retains the ephemeral loopback default.
148 ListenAddr *net.UDPAddr
149 // ServerAddr is the UDP server the proxy forwards to. Required.
150 ServerAddr *net.UDPAddr
151 // Clock drives all scheduling. Defaults to [SystemClock].
152 Clock Clock
153 // Seed deterministically seeds the default per-direction RNG.
154 Seed int64
155 // RNG replaces the seeded source. Tests use it to script decisions.
156 RNG RNG
157 // QueueCapacity bounds queued and deadline-pending packets.
158 // Defaults to [DefaultQueueCapacity].
159 QueueCapacity int
160 // MaxPacketSize bounds a single datagram. Defaults to
161 // [DefaultMaxPacketSize].
162 MaxPacketSize int
163 // ToServer impairs client -> server traffic.
164 ToServer LinkConfig
165 // ToClient impairs server -> client traffic.
166 ToClient LinkConfig
167 // NATRebindAfterPackets changes the proxy's upstream source address after
168 // every N client -> server packets have been sent. Zero disables automatic
169 // rebinding; [Proxy.Rebind] is always available.
170 NATRebindAfterPackets int
171 // OnPacket, when set, observes every ingested packet. It is called from a
172 // reader goroutine and must not block.
173 OnPacket func(Event)
174}
175
176func (c Config) withDefaults() Config {
177 if c.Clock == nil {
178 c.Clock = SystemClock{}
179 }
180 if c.QueueCapacity <= 0 {
181 c.QueueCapacity = DefaultQueueCapacity
182 }
183 if c.MaxPacketSize <= 0 {
184 c.MaxPacketSize = DefaultMaxPacketSize
185 }
186 return c
187}
188
189func (c Config) validate() error {
190 if c.ServerAddr == nil {
191 return errors.New("quicnettest: ServerAddr is required")
192 }
193 if c.ServerAddr.Port < 1 || c.ServerAddr.Port > 65535 {
194 return fmt.Errorf("quicnettest: ServerAddr port %d is out of range", c.ServerAddr.Port)
195 }
196 if c.NATRebindAfterPackets < 0 {
197 return errors.New("quicnettest: NATRebindAfterPackets must not be negative")
198 }
199 if err := c.ToServer.validate(ToServer); err != nil {
200 return err
201 }
202 return c.ToClient.validate(ToClient)
203}
204
205// Decision is the deterministic impairment outcome for one ingested packet.
206type Decision struct {
207 // Drop reports that the packet was discarded.
208 Drop bool
209 // Blackout reports that a blackout window caused the drop.
210 Blackout bool
211 // Oversize reports that the datagram exceeded MaxPacketSize.
212 Oversize bool
213 // Duplicates is the number of extra copies scheduled (0 or 1).
214 Duplicates int
215 // Reordered reports that the reordering window applied.
216 Reordered bool
217 // ExtraDelay is the random component added on top of BaseLatency.
218 ExtraDelay time.Duration
219}
220
221// Event describes one ingested packet and its outcome.
222type Event struct {
223 Direction Direction
224 Sequence uint64
225 Bytes int
226 // At is the clock time at which the packet was ingested.
227 At time.Time
228 // Due is the first copy's delivery deadline; zero for drops.
229 Due time.Time
230 Decision Decision
231}
232
233// LinkStats audits one direction.
234type LinkStats struct {
235 // Received counts in-budget packets accepted from the source socket.
236 // Oversize datagrams are counted only in OversizeDrops.
237 Received uint64
238 // Sent counts packets handed to the destination socket. It is incremented
239 // before the write, so it is stable once the peer has observed a packet.
240 Sent uint64
241 // LossDrops counts seeded loss drops.
242 LossDrops uint64
243 // BlackoutDrops counts drops inside a blackout window or manual blackout.
244 BlackoutDrops uint64
245 // OversizeDrops counts datagrams larger than MaxPacketSize.
246 OversizeDrops uint64
247 // OverflowDrops counts packets dropped because QueueCapacity was full.
248 OverflowDrops uint64
249 // Duplicates counts extra copies scheduled.
250 Duplicates uint64
251 // Reordered counts packets delayed by the reordering window.
252 Reordered uint64
253 // DeliveryErrors counts socket write failures outside shutdown.
254 DeliveryErrors uint64
255}
256
257// String implements fmt.Stringer.
258func (l LinkStats) String() string {
259 return fmt.Sprintf("received:%d sent:%d loss:%d blackout:%d oversize:%d overflow:%d duplicates:%d reordered:%d delivery-errors:%d",
260 l.Received, l.Sent, l.LossDrops, l.BlackoutDrops, l.OversizeDrops, l.OverflowDrops,
261 l.Duplicates, l.Reordered, l.DeliveryErrors)
262}
263
264// Stats is a snapshot of proxy counters.
265type Stats struct {
266 ToServer LinkStats
267 ToClient LinkStats
268 // StrayPackets counts upstream datagrams that did not come from ServerAddr
269 // or arrived before any client address was known.
270 StrayPackets uint64
271 // Rebinds counts successful upstream source-address changes.
272 Rebinds uint64
273 // RebindErrors counts automatic rebinds that failed to open a socket.
274 RebindErrors uint64
275}
276
277// String implements fmt.Stringer.
278func (s Stats) String() string {
279 return fmt.Sprintf("to-server{%s} to-client{%s} stray:%d rebinds:%d rebind-errors:%d",
280 s.ToServer, s.ToClient, s.StrayPackets, s.Rebinds, s.RebindErrors)
281}
282
283// impairer applies one LinkConfig to one direction.
284type impairer struct {
285 link LinkConfig
286 rng RNG
287 start time.Time
288}
289
290// decide applies impairments in a fixed order so a given seed and packet
291// sequence always produces the same decisions:
292//
293// 1. link blackout window,
294// 2. loss,
295// 3. duplication,
296// 4. reordering (extra ReorderDelay),
297// 5. jitter (uniform in [0, Jitter]).
298func (im *impairer) decide(now time.Time) Decision {
299 var decision Decision
300 if im.blackout(now) {
301 decision.Drop = true
302 decision.Blackout = true
303 return decision
304 }
305 if im.link.LossPercent > 0 && im.rng.Intn(100) < im.link.LossPercent {
306 decision.Drop = true
307 return decision
308 }
309 if im.link.DuplicatePercent > 0 && im.rng.Intn(100) < im.link.DuplicatePercent {
310 decision.Duplicates = 1
311 }
312 if im.link.ReorderPercent > 0 && im.rng.Intn(100) < im.link.ReorderPercent {
313 decision.Reordered = true
314 decision.ExtraDelay += im.link.ReorderDelay
315 }
316 if im.link.Jitter > 0 {
317 decision.ExtraDelay += time.Duration(im.rng.Intn(int(im.link.Jitter) + 1))
318 }
319 return decision
320}
321
322func (im *impairer) blackout(now time.Time) bool {
323 elapsed := now.Sub(im.start)
324 for _, window := range im.link.Blackouts {
325 if elapsed >= window.Start && elapsed < window.End {
326 return true
327 }
328 }
329 return false
330}
331
332// lockedRNG serializes an injected RNG shared by both directions.
333type lockedRNG struct {
334 mu sync.Mutex
335 rng RNG
336}
337
338func (l *lockedRNG) Intn(n int) int {
339 l.mu.Lock()
340 defer l.mu.Unlock()
341 return l.rng.Intn(n)
342}
343
344func newRNG(seed int64, direction Direction) RNG {
345 return rand.New(rand.NewSource(seed + int64(direction)*seedStride))
346}