irtt-server 0.5.0

IRTT-compatible Rust server library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt, TimestampFields};

use super::support::{
    client_params, core_with_tokens, echo_request, expect_echo_reply, expect_normal_open_reply,
    open_request, open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY,
};
use crate::{ServerConfig, TimestampAllowance, DEFAULT_MAX_PACKET_LENGTH};

const TOKEN_A: u64 = 0x7896_b6ab_8771_5213;

#[test]
fn an_ordinary_open_is_answered_and_creates_one_session() {
    let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));

    let packet = core
        .handle_datagram(peer(), &open_request(&client_params(), None))
        .unwrap()
        .expect("an ordinary open must be answered");

    let reply = expect_normal_open_reply(&packet, None);
    assert_eq!(reply.token, TOKEN_A);
    assert_eq!(core.session_count(), 1);

    let session = core.session(TOKEN_A).expect("the session must be live");
    assert_eq!(session.params(), &reply.params);
}

#[test]
fn a_session_records_the_exact_source_endpoint() {
    // Address family, address, port and — where present — the IPv6 scope all
    // form part of session identity, so the endpoint is stored verbatim rather
    // than normalized to an address.
    for endpoint in [
        "198.51.100.7:41234",
        "198.51.100.7:41235",
        "[2001:db8::1]:2112",
        "[fe80::1%3]:2112",
    ] {
        let endpoint = endpoint.parse().unwrap();
        let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));

        core.handle_datagram(endpoint, &open_request(&client_params(), None))
            .unwrap()
            .expect("open must be answered");

        assert_eq!(core.session(TOKEN_A).unwrap().peer(), endpoint);
    }
}

#[test]
fn any_requested_protocol_version_is_accepted_and_answered_with_one() {
    // Version mismatch is detected client-side only: the server rewrites the
    // version and creates a normal session whatever was asked for.
    for requested in [Some(1), Some(0), Some(2), Some(-1), None] {
        let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
        let params = Params {
            protocol_version: requested.unwrap_or(0),
            ..client_params()
        };
        let request = match requested {
            // An explicit zero cannot be encoded by `Params::encode`, which
            // omits zero-valued integers, so encode that tag by hand.
            Some(0) => open_request_with_raw_params(&param_int(1, 0), None),
            Some(_) => open_request(&params, None),
            None => open_request(
                &Params {
                    protocol_version: 0,
                    ..params
                },
                None,
            ),
        };

        let packet = core
            .handle_datagram(peer(), &request)
            .unwrap()
            .unwrap_or_else(|| panic!("protocol version {requested:?} must be answered"));

        let reply = expect_normal_open_reply(&packet, None);
        assert_eq!(reply.params.protocol_version, 1);
        assert_eq!(core.session_count(), 1);
    }
}

#[test]
fn an_empty_parameter_payload_is_answered_with_only_a_version() {
    // The interval floor is disabled here so that the reply carries nothing but
    // the version: with one configured, an absent Interval negotiates up to it
    // and is encoded, which is interval policy rather than what this test is
    // about.
    let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));

    let packet = core
        .handle_datagram(peer(), &open_request_with_raw_params(&[], None))
        .unwrap()
        .expect("an empty parameter payload is a valid open");

    // header (4) + token (8) + ProtocolVersion=1 (2). Every other parameter
    // negotiates to its wire-default zero and is omitted entirely, which is
    // only reachable if absent parameters really did decode as zero.
    assert_eq!(packet.bytes().len(), 14);

    let reply = expect_normal_open_reply(&packet, None);
    assert_eq!(
        reply.params,
        Params {
            protocol_version: 1,
            ..Params::default()
        }
    );
    // An unspecified clock is not itself a defect. Only pairing it with a
    // selected `stamp_at` is, so a session requesting no timestamps at all is
    // fully valid with no Clock tag.
    assert_eq!(reply.params.stamp_at, StampAt::None);
    assert_eq!(reply.params.clock, Clock::Unspecified);
    assert_eq!(core.session_count(), 1);
}

#[test]
fn unknown_parameter_tags_are_ignored_and_not_reflected_in_the_reply() {
    let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));

    let mut payload = param_int(1, 1);
    payload.extend_from_slice(&param_int(42, 7));
    payload.extend_from_slice(&param_int(200, 9));

    let packet = core
        .handle_datagram(peer(), &open_request_with_raw_params(&payload, None))
        .unwrap()
        .expect("unknown tags must not prevent an open");

    let reply = expect_normal_open_reply(&packet, None);
    assert_eq!(
        reply.params,
        Params {
            protocol_version: 1,
            ..Params::default()
        }
    );
    // The reply is the same 14 bytes an empty payload produces: no unknown tag
    // was copied through.
    assert_eq!(packet.bytes().len(), 14);
    assert_eq!(core.session_count(), 1);
}

#[test]
fn selecting_timestamps_without_a_clock_is_silently_refused() {
    // Timestamps from no clock at all describe a session the server could not
    // run: the negotiated layout carries no timestamp field to fill. The open
    // is discarded rather than repaired — synthesizing a clock would answer
    // with a session the client never asked for, and dropping `stamp_at` would
    // silently discard the measurement it did ask for.
    //
    // Only an *absent* Clock tag reaches this: an explicit zero is already out
    // of range for the decoder, and a conforming client always sends a clock
    // when it selects timestamps.
    //
    // A server whose timestamp allowance is none accepts the very same request,
    // because then no timestamp is negotiated to be unproducible; see
    // `a_timestamp_disallowing_server_accepts_a_request_that_omitted_its_clock`.
    for stamp_at in [
        StampAt::Send,
        StampAt::Receive,
        StampAt::Both,
        StampAt::Midpoint,
    ] {
        let requested = Params {
            clock: Clock::Unspecified,
            stamp_at,
            ..client_params()
        };
        let tokens = ScriptedTokens::new([TOKEN_A]);
        let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());

        let reply = core
            .handle_datagram(peer(), &open_request(&requested, None))
            .expect("an unusable timestamp request is not a server error");

        assert!(
            reply.is_none(),
            "{stamp_at:?} without a clock must be silent"
        );
        assert_eq!(
            core.session_count(),
            0,
            "{stamp_at:?} without a clock must create no session"
        );
        assert_eq!(
            tokens.remaining(),
            1,
            "{stamp_at:?} without a clock must not consume a token"
        );
    }
}

#[test]
fn a_timestamp_disallowing_server_accepts_a_request_that_omitted_its_clock() {
    // The same request the test above refuses, against a server that provides no
    // timestamps at all. The restriction runs before the effective-session
    // check, so `stamp_at` is already none by the time executability is judged:
    // the absent clock then selects nothing that was going to be reported, and
    // the session is one the server can run exactly as negotiated.
    //
    // This is what the ordering exists for. Validating the *request* instead
    // would refuse an open that this policy makes perfectly serviceable.
    let requested = Params {
        clock: Clock::Unspecified,
        stamp_at: StampAt::Both,
        ..client_params()
    };
    let mut core = core_with_tokens(
        unthrottled().with_timestamp_allowance(TimestampAllowance::None),
        ScriptedTokens::new([TOKEN_A]),
    );

    let packet = core
        .handle_datagram(peer(), &open_request(&requested, None))
        .unwrap()
        .expect("an omitted clock is safe once no timestamps are negotiated");

    let reply = expect_normal_open_reply(&packet, None);
    assert_eq!(reply.params.stamp_at, StampAt::None);
    assert_eq!(
        reply.params.clock,
        Clock::Unspecified,
        "the absent clock tag stays absent"
    );
    assert_eq!(core.session_count(), 1);

    let echo = core
        .handle_datagram(
            peer(),
            &echo_request(reply.token, 0, &reply.params, &[], None),
        )
        .unwrap()
        .expect("the session must serve echoes");
    assert_eq!(
        expect_echo_reply(&echo, &reply.params, None).timestamps,
        TimestampFields::default(),
        "no timestamps were negotiated, so the reply carries none"
    );
}

#[test]
fn current_non_length_parameters_survive_negotiation_unchanged() {
    // Statistics, timestamps and DSCP are what the capability restrictions
    // change when they are configured; this is the default configuration, which
    // restricts none of them. Version, an oversized packet
    // length, the duration maximum and the interval floor and cap are already
    // restricted and have their own tests; the length here is deliberately
    // under the default maximum, and the configuration below leaves duration
    // and interval unrestricted, so this test says nothing about either policy.
    //
    // The server fill below is a *valid* descriptor, which is what survives
    // unchanged. Fill negotiation has its own restriction rule and its own
    // tests in `fill`.
    //
    // This is about what *negotiation* rewrites, which is a separate question
    // from whether the core will acknowledge the result: an open whose
    // effective parameters are incoherent is still discarded, as
    // `selecting_timestamps_without_a_clock_is_silently_refused` shows.
    let unrestricted = Params {
        protocol_version: 2,
        duration_ns: 3_600_000_000_000,
        interval_ns: 1,
        length: 1472,
        received_stats: ReceivedStats::Window,
        stamp_at: StampAt::Midpoint,
        clock: Clock::Monotonic,
        dscp: 184,
        server_fill: Some(ServerFill {
            value: "pattern:abcd".to_owned(),
        }),
    };

    // No duration maximum is configured by default, and the interval floor is
    // disabled, so a one-nanosecond interval is left where it was asked for.
    let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
    let packet = core
        .handle_datagram(peer(), &open_request(&unrestricted, None))
        .unwrap()
        .expect("open must be answered");

    let reply = expect_normal_open_reply(&packet, None);
    assert_eq!(
        reply.params,
        Params {
            protocol_version: 1,
            ..unrestricted
        }
    );
}

#[test]
fn a_positive_length_above_the_configured_maximum_is_reduced_to_it() {
    // The reduced value is what the client is told and what the session holds,
    // because it is what the server will actually emit. Both have to agree: an
    // upstream client treats a reply shorter than the negotiated length as
    // fatal, so an honest reduction at open is the only serviceable answer.
    let tokens = ScriptedTokens::new([TOKEN_A]);
    let mut core = core_with_tokens(
        ServerConfig::default().with_max_packet_length(64),
        tokens.clone(),
    );
    let requested = Params {
        length: 1000,
        ..client_params()
    };

    let packet = core
        .handle_datagram(peer(), &open_request(&requested, None))
        .unwrap()
        .expect("a reducible length must still be answered");

    let reply = expect_normal_open_reply(&packet, None);
    assert_eq!(reply.params.length, 64);
    assert_eq!(core.session_count(), 1);
    assert_eq!(tokens.remaining(), 0, "one token was consumed");
    let session = core.session(TOKEN_A).expect("the session must be live");
    assert_eq!(
        session.params().length,
        64,
        "the session must enforce the reduced length"
    );
}

#[test]
fn the_default_maximum_reduces_an_absurd_requested_length() {
    // The largest length the wire can carry is representable as a `usize` on a
    // 64-bit target, so packet sizing would happily have turned it into an
    // allocation; the configured maximum is what stops it. Restriction happens
    // during negotiation, before any sizing, so this holds on a 32-bit target
    // too — the request never reaches a conversion that could fail.
    let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
    let requested = Params {
        length: i64::MAX,
        ..client_params()
    };

    let packet = core
        .handle_datagram(peer(), &open_request(&requested, None))
        .unwrap()
        .expect("an absurd length is reduced, not refused");

    let reply = expect_normal_open_reply(&packet, None);
    assert_eq!(reply.params.length, DEFAULT_MAX_PACKET_LENGTH as i64);
    let session = core.session(TOKEN_A).expect("the session must be live");
    assert_eq!(session.params().length, DEFAULT_MAX_PACKET_LENGTH as i64);
}

#[test]
fn an_open_whose_echo_field_block_exceeds_the_maximum_is_silently_refused() {
    // Length itself satisfies the policy — zero asks for nothing beyond the
    // mandatory fields — but the received-statistics fields alone make the
    // smallest compliant echo reply larger than 20 bytes. Capping the Length
    // parameter is therefore not enough on its own: this session could never be
    // answered, so it is never acknowledged.
    let requested = Params {
        protocol_version: 1,
        length: 0,
        received_stats: ReceivedStats::Both,
        stamp_at: StampAt::None,
        clock: Clock::Unspecified,
        ..Params::default()
    };
    let tokens = ScriptedTokens::new([TOKEN_A]);
    let mut core = core_with_tokens(
        ServerConfig::default().with_max_packet_length(20),
        tokens.clone(),
    );

    assert_eq!(
        core.handle_datagram(peer(), &open_request(&requested, None))
            .expect("an unservable packet size is not a server error"),
        None
    );
    assert_eq!(core.session_count(), 0);
    assert_eq!(tokens.remaining(), 1, "no token may be drawn");
}

#[test]
fn authentication_counts_toward_the_executable_echo_size() {
    // An authentication field adds 16 bytes to the mandatory echo block, so a
    // maximum between the two floors admits the same minimal session on an
    // unauthenticated server and refuses it on an authenticated one. The sizes
    // themselves come from `irtt_proto`; what this pins is that the server asks
    // about its own authentication mode rather than assuming the smaller floor.
    let minimal = Params {
        protocol_version: 1,
        ..Params::default()
    };

    let tokens = ScriptedTokens::new([TOKEN_A]);
    let mut authenticated = core_with_tokens(
        ServerConfig::default()
            .with_hmac_key(KEY)
            .with_max_packet_length(31),
        tokens.clone(),
    );
    assert_eq!(
        authenticated
            .handle_datagram(peer(), &open_request(&minimal, Some(KEY)))
            .expect("an unservable packet size is not a server error"),
        None
    );
    assert_eq!(authenticated.session_count(), 0);
    assert_eq!(tokens.remaining(), 1, "no token may be drawn");

    let mut unauthenticated = core_with_tokens(
        ServerConfig::default().with_max_packet_length(31),
        ScriptedTokens::new([TOKEN_A]),
    );
    let packet = unauthenticated
        .handle_datagram(peer(), &open_request(&minimal, None))
        .unwrap()
        .expect("the same session fits without an authentication field");
    expect_normal_open_reply(&packet, None);
    assert_eq!(unauthenticated.session_count(), 1);
}

#[test]
fn negative_and_out_of_range_length_and_dscp_survive_negotiation() {
    // These lengths are all at or below the default maximum, and DSCP is
    // accepted as decoded, including values a socket could never carry:
    // negotiation restricts neither, and a value that is not a byte is simply
    // transported unmarked (see `traffic_class`).
    //
    // A negative length in particular stays negative in both the reply and the
    // stored session; the packet-length policy reduces oversized *positive*
    // lengths and touches nothing else. It does not need clamping here to be
    // executable: echo packet sizing in `irtt-proto` floors it at the required
    // field block, so the session this creates can be run as negotiated.
    for (length, dscp) in [(-1, -1), (0, 0), (1472, 184), (-4096, 512)] {
        let requested = Params {
            protocol_version: 1,
            length,
            dscp,
            ..Params::default()
        };
        let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));

        let packet = core
            .handle_datagram(peer(), &open_request(&requested, None))
            .unwrap()
            .unwrap_or_else(|| panic!("length {length} dscp {dscp} must be answered"));

        let reply = expect_normal_open_reply(&packet, None);
        assert_eq!(reply.params.length, length);
        assert_eq!(reply.params.dscp, dscp);
        assert_eq!(core.session_count(), 1);
        let session = core.session(TOKEN_A).expect("the session must be live");
        assert_eq!(
            session.params(),
            &reply.params,
            "the session must hold the negotiated values verbatim"
        );
    }
}