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 // ServerAddr is the UDP server the proxy forwards to. Required.
147 ServerAddr *net.UDPAddr
148 // Clock drives all scheduling. Defaults to [SystemClock].
149 Clock Clock
150 // Seed deterministically seeds the default per-direction RNG.
151 Seed int64
152 // RNG replaces the seeded source. Tests use it to script decisions.
153 RNG RNG
154 // QueueCapacity bounds queued and deadline-pending packets.
155 // Defaults to [DefaultQueueCapacity].
156 QueueCapacity int
157 // MaxPacketSize bounds a single datagram. Defaults to
158 // [DefaultMaxPacketSize].
159 MaxPacketSize int
160 // ToServer impairs client -> server traffic.
161 ToServer LinkConfig
162 // ToClient impairs server -> client traffic.
163 ToClient LinkConfig
164 // NATRebindAfterPackets changes the proxy's upstream source address after
165 // every N client -> server packets have been sent. Zero disables automatic
166 // rebinding; [Proxy.Rebind] is always available.
167 NATRebindAfterPackets int
168 // OnPacket, when set, observes every ingested packet. It is called from a
169 // reader goroutine and must not block.
170 OnPacket func(Event)
171}
172
173func (c Config) withDefaults() Config {
174 if c.Clock == nil {
175 c.Clock = SystemClock{}
176 }
177 if c.QueueCapacity <= 0 {
178 c.QueueCapacity = DefaultQueueCapacity
179 }
180 if c.MaxPacketSize <= 0 {
181 c.MaxPacketSize = DefaultMaxPacketSize
182 }
183 return c
184}
185
186func (c Config) validate() error {
187 if c.ServerAddr == nil {
188 return errors.New("quicnettest: ServerAddr is required")
189 }
190 if c.ServerAddr.Port < 1 || c.ServerAddr.Port > 65535 {
191 return fmt.Errorf("quicnettest: ServerAddr port %d is out of range", c.ServerAddr.Port)
192 }
193 if c.NATRebindAfterPackets < 0 {
194 return errors.New("quicnettest: NATRebindAfterPackets must not be negative")
195 }
196 if err := c.ToServer.validate(ToServer); err != nil {
197 return err
198 }
199 return c.ToClient.validate(ToClient)
200}
201
202// Decision is the deterministic impairment outcome for one ingested packet.
203type Decision struct {
204 // Drop reports that the packet was discarded.
205 Drop bool
206 // Blackout reports that a blackout window caused the drop.
207 Blackout bool
208 // Oversize reports that the datagram exceeded MaxPacketSize.
209 Oversize bool
210 // Duplicates is the number of extra copies scheduled (0 or 1).
211 Duplicates int
212 // Reordered reports that the reordering window applied.
213 Reordered bool
214 // ExtraDelay is the random component added on top of BaseLatency.
215 ExtraDelay time.Duration
216}
217
218// Event describes one ingested packet and its outcome.
219type Event struct {
220 Direction Direction
221 Sequence uint64
222 Bytes int
223 // At is the clock time at which the packet was ingested.
224 At time.Time
225 // Due is the first copy's delivery deadline; zero for drops.
226 Due time.Time
227 Decision Decision
228}
229
230// LinkStats audits one direction.
231type LinkStats struct {
232 // Received counts in-budget packets accepted from the source socket.
233 // Oversize datagrams are counted only in OversizeDrops.
234 Received uint64
235 // Sent counts packets handed to the destination socket. It is incremented
236 // before the write, so it is stable once the peer has observed a packet.
237 Sent uint64
238 // LossDrops counts seeded loss drops.
239 LossDrops uint64
240 // BlackoutDrops counts drops inside a blackout window or manual blackout.
241 BlackoutDrops uint64
242 // OversizeDrops counts datagrams larger than MaxPacketSize.
243 OversizeDrops uint64
244 // OverflowDrops counts packets dropped because QueueCapacity was full.
245 OverflowDrops uint64
246 // Duplicates counts extra copies scheduled.
247 Duplicates uint64
248 // Reordered counts packets delayed by the reordering window.
249 Reordered uint64
250 // DeliveryErrors counts socket write failures outside shutdown.
251 DeliveryErrors uint64
252}
253
254// String implements fmt.Stringer.
255func (l LinkStats) String() string {
256 return fmt.Sprintf("received:%d sent:%d loss:%d blackout:%d oversize:%d overflow:%d duplicates:%d reordered:%d delivery-errors:%d",
257 l.Received, l.Sent, l.LossDrops, l.BlackoutDrops, l.OversizeDrops, l.OverflowDrops,
258 l.Duplicates, l.Reordered, l.DeliveryErrors)
259}
260
261// Stats is a snapshot of proxy counters.
262type Stats struct {
263 ToServer LinkStats
264 ToClient LinkStats
265 // StrayPackets counts upstream datagrams that did not come from ServerAddr
266 // or arrived before any client address was known.
267 StrayPackets uint64
268 // Rebinds counts successful upstream source-address changes.
269 Rebinds uint64
270 // RebindErrors counts automatic rebinds that failed to open a socket.
271 RebindErrors uint64
272}
273
274// String implements fmt.Stringer.
275func (s Stats) String() string {
276 return fmt.Sprintf("to-server{%s} to-client{%s} stray:%d rebinds:%d rebind-errors:%d",
277 s.ToServer, s.ToClient, s.StrayPackets, s.Rebinds, s.RebindErrors)
278}
279
280// impairer applies one LinkConfig to one direction.
281type impairer struct {
282 link LinkConfig
283 rng RNG
284 start time.Time
285}
286
287// decide applies impairments in a fixed order so a given seed and packet
288// sequence always produces the same decisions:
289//
290// 1. link blackout window,
291// 2. loss,
292// 3. duplication,
293// 4. reordering (extra ReorderDelay),
294// 5. jitter (uniform in [0, Jitter]).
295func (im *impairer) decide(now time.Time) Decision {
296 var decision Decision
297 if im.blackout(now) {
298 decision.Drop = true
299 decision.Blackout = true
300 return decision
301 }
302 if im.link.LossPercent > 0 && im.rng.Intn(100) < im.link.LossPercent {
303 decision.Drop = true
304 return decision
305 }
306 if im.link.DuplicatePercent > 0 && im.rng.Intn(100) < im.link.DuplicatePercent {
307 decision.Duplicates = 1
308 }
309 if im.link.ReorderPercent > 0 && im.rng.Intn(100) < im.link.ReorderPercent {
310 decision.Reordered = true
311 decision.ExtraDelay += im.link.ReorderDelay
312 }
313 if im.link.Jitter > 0 {
314 decision.ExtraDelay += time.Duration(im.rng.Intn(int(im.link.Jitter) + 1))
315 }
316 return decision
317}
318
319func (im *impairer) blackout(now time.Time) bool {
320 elapsed := now.Sub(im.start)
321 for _, window := range im.link.Blackouts {
322 if elapsed >= window.Start && elapsed < window.End {
323 return true
324 }
325 }
326 return false
327}
328
329// lockedRNG serializes an injected RNG shared by both directions.
330type lockedRNG struct {
331 mu sync.Mutex
332 rng RNG
333}
334
335func (l *lockedRNG) Intn(n int) int {
336 l.mu.Lock()
337 defer l.mu.Unlock()
338 return l.rng.Intn(n)
339}
340
341func newRNG(seed int64, direction Direction) RNG {
342 return rand.New(rand.NewSource(seed + int64(direction)*seedStride))
343}