feat(server): add open session core #74
No files matched your search
@@ -39,7 +39,7 @@ For compatibility questions, the verified spec and black-box observations define
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Main boundary (`irtt-server` is planned, not yet implemented):
|
Main boundary:
|
||||||
|
|
||||||
> `irtt-proto` — wire semantics shared by client/server. `irtt-client` — client/session behavior. `irtt-server` — server/session behavior and Tokio runtime/orchestration. `irtt-stats` — statistics over client-facing events/results as currently designed. `irtt-cli` — presentation/orchestration.
|
> `irtt-proto` — wire semantics shared by client/server. `irtt-client` — client/session behavior. `irtt-server` — server/session behavior and Tokio runtime/orchestration. `irtt-stats` — statistics over client-facing events/results as currently designed. `irtt-cli` — presentation/orchestration.
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ Main boundary (`irtt-server` is planned, not yet implemented):
|
|||||||
- `AsyncClient`: optional Tokio low-level adapter.
|
- `AsyncClient`: optional Tokio low-level adapter.
|
||||||
- `ManagedClientTask` / `ManagedClientHandle`: unified Tokio managed driver/control surface.
|
- `ManagedClientTask` / `ManagedClientHandle`: unified Tokio managed driver/control surface.
|
||||||
- `BlockingManagedClient`: synchronous owner using a dedicated current-thread Tokio runtime.
|
- `BlockingManagedClient`: synchronous owner using a dedicated current-thread Tokio runtime.
|
||||||
- `irtt-server`: planned; see "Server architecture (`irtt-server`)" below.
|
- `irtt-server`: exists; see "Server architecture (`irtt-server`)" below.
|
||||||
- `irtt-stats`: cumulative/rolling/finite statistics over client events. No wire/session state machine logic.
|
- `irtt-stats`: cumulative/rolling/finite statistics over client events. No wire/session state machine logic.
|
||||||
- `irtt-cli`: argument parsing, managed orchestration, output, summaries, TUI, diagnostics, and exit behavior. Do not duplicate protocol/session state machines here.
|
- `irtt-cli`: argument parsing, managed orchestration, output, summaries, TUI, diagnostics, and exit behavior. Do not duplicate protocol/session state machines here.
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
|
|||||||
|
|
||||||
### Server architecture (`irtt-server`)
|
### Server architecture (`irtt-server`)
|
||||||
|
|
||||||
`irtt-server` is a planned first-class reusable server crate. It does not exist yet; this section describes the intended boundary for it, not an implemented API.
|
`irtt-server` is a first-class reusable server crate. It currently contains the deterministic OPEN/session core — packet admission, authentication policy, open negotiation, the bounded session table and open reply construction. ECHO processing, client/server close and session lifetime remain unimplemented, as does the Tokio runtime; see `crates/irtt-server/AGENTS.md`. The rest of this section describes the boundary the crate is built to, including the parts not yet written.
|
||||||
|
|
||||||
- It shares wire semantics with the client through `irtt-proto` and follows the same clean-room boundary.
|
- It shares wire semantics with the client through `irtt-proto` and follows the same clean-room boundary.
|
||||||
- It must **not** depend on `irtt-client` merely to reuse protocol behavior.
|
- It must **not** depend on `irtt-client` merely to reuse protocol behavior.
|
||||||
@@ -67,7 +67,7 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
|
|||||||
- Runtime/server orchestration belongs around that core and may freely use Tokio for UDP sockets, `recv_from`/`send_to`, IPv4/IPv6 listeners, timers, session expiry, max-duration deadlines, shutdown, bounded control/config channels where useful, and `select!`-style orchestration.
|
- Runtime/server orchestration belongs around that core and may freely use Tokio for UDP sockets, `recv_from`/`send_to`, IPv4/IPv6 listeners, timers, session expiry, max-duration deadlines, shutdown, bounded control/config channels where useful, and `select!`-style orchestration.
|
||||||
- The CLI server applet, when added, should remain thin orchestration/configuration over reusable `irtt-server`, not contain a second server state machine.
|
- The CLI server applet, when added, should remain thin orchestration/configuration over reusable `irtt-server`, not contain a second server state machine.
|
||||||
- Resource policy: the server should use bounded session/resource policy. Upstream's observed lack of useful default session/per-peer bounds (see `IRTT_SERVER_PROTOCOL_SPEC.md`) is not an interoperability requirement and must not be reproduced merely for compatibility.
|
- Resource policy: the server should use bounded session/resource policy. Upstream's observed lack of useful default session/per-peer bounds (see `IRTT_SERVER_PROTOCOL_SPEC.md`) is not an interoperability requirement and must not be reproduced merely for compatibility.
|
||||||
- Testing: once a real `irtt-server` exists, prefer it for normal compliant fake-peer behavior where practical. Keep narrow adversarial/raw peers for intentionally malformed, ambiguous, or non-compliant wire behavior.
|
- Testing: prefer `irtt-server` for normal compliant fake-peer behavior where practical, as far as its implemented behavior reaches. Keep narrow adversarial/raw peers for intentionally malformed, ambiguous, or non-compliant wire behavior.
|
||||||
|
|
||||||
## Engineering rules
|
## Engineering rules
|
||||||
|
|
||||||
|
|||||||
Generated
+9
@@ -726,6 +726,15 @@ dependencies = [
|
|||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "irtt-server"
|
||||||
|
version = "0.4.0"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"irtt-proto",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-stats"
|
name = "irtt-stats"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
members = [
|
members = [
|
||||||
"crates/irtt-proto",
|
"crates/irtt-proto",
|
||||||
"crates/irtt-client",
|
"crates/irtt-client",
|
||||||
|
"crates/irtt-server",
|
||||||
"crates/irtt-stats",
|
"crates/irtt-stats",
|
||||||
"crates/irtt-cli",
|
"crates/irtt-cli",
|
||||||
]
|
]
|
||||||
@@ -18,6 +19,7 @@ readme = "README.md"
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
irtt-proto = { version = "0.4.0", path = "crates/irtt-proto" }
|
irtt-proto = { version = "0.4.0", path = "crates/irtt-proto" }
|
||||||
irtt-client = { version = "0.4.0", path = "crates/irtt-client" }
|
irtt-client = { version = "0.4.0", path = "crates/irtt-client" }
|
||||||
|
irtt-server = { version = "0.4.0", path = "crates/irtt-server" }
|
||||||
irtt-stats = { version = "0.4.0", path = "crates/irtt-stats" }
|
irtt-stats = { version = "0.4.0", path = "crates/irtt-stats" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|||||||
@@ -139,6 +139,16 @@ fn params_from_config_rejects_invalid_values() {
|
|||||||
},
|
},
|
||||||
"dscp",
|
"dscp",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
// The unspecified clock is the absent-tag wire default, not a
|
||||||
|
// client mode; a client that needs timestamps must name a clock.
|
||||||
|
"unspecified clock",
|
||||||
|
ClientConfig {
|
||||||
|
clock: Clock::Unspecified,
|
||||||
|
..ClientConfig::default()
|
||||||
|
},
|
||||||
|
"clock must be wall, monotonic, or both",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"empty server fill",
|
"empty server fill",
|
||||||
ClientConfig {
|
ClientConfig {
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ pub struct ClientConfig {
|
|||||||
/// Requested timestamp placement in echo replies.
|
/// Requested timestamp placement in echo replies.
|
||||||
pub stamp_at: StampAt,
|
pub stamp_at: StampAt,
|
||||||
/// Requested server clock sources for timestamp fields.
|
/// Requested server clock sources for timestamp fields.
|
||||||
|
///
|
||||||
|
/// [`Clock::Unspecified`] is the wire default meaning "no clock tag was
|
||||||
|
/// sent" and is not a client mode; opening a session with it is rejected.
|
||||||
|
/// Request [`Clock::Wall`], [`Clock::Monotonic`], or [`Clock::Both`].
|
||||||
pub clock: Clock,
|
pub clock: Clock,
|
||||||
/// Requested DSCP codepoint.
|
/// Requested DSCP codepoint.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use irtt_proto::{
|
use irtt_proto::{
|
||||||
decode_echo_reply, echo_packet_len, encode_request, flags, EchoReply, OpenReply, Params,
|
decode_echo_reply, echo_packet_len, encode_request, flags, Clock, EchoReply, OpenReply, Params,
|
||||||
RequestToEncode, ServerFill, TimestampFields, PROTOCOL_VERSION,
|
RequestToEncode, ServerFill, TimestampFields, PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -989,6 +989,11 @@ fn validate_protocol_config(config: &ClientConfig) -> Result<(), ClientError> {
|
|||||||
reason: "interval must be greater than zero".to_owned(),
|
reason: "interval must be greater than zero".to_owned(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if config.clock == Clock::Unspecified {
|
||||||
|
return Err(ClientError::InvalidConfig {
|
||||||
|
reason: "clock must be wall, monotonic, or both".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
if config.dscp > MAX_DSCP_CODEPOINT {
|
if config.dscp > MAX_DSCP_CODEPOINT {
|
||||||
return Err(ClientError::InvalidConfig {
|
return Err(ClientError::InvalidConfig {
|
||||||
reason: format!("dscp must be <= {MAX_DSCP_CODEPOINT}"),
|
reason: format!("dscp must be <= {MAX_DSCP_CODEPOINT}"),
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ fn clock_modes_drive_reply_event_clock_fields() {
|
|||||||
assert_timing_fields(reply.server_timing, true, true, false, false, true, true);
|
assert_timing_fields(reply.server_timing, true, true, false, false, true, true);
|
||||||
assert_one_way_presence(reply.one_way, true, true);
|
assert_one_way_presence(reply.one_way, true, true);
|
||||||
}
|
}
|
||||||
|
Clock::Unspecified => unreachable!("clients never request an unspecified clock"),
|
||||||
}
|
}
|
||||||
assert_processing_subtracted(reply);
|
assert_processing_subtracted(reply);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ fn backend_clock_smoke() {
|
|||||||
assert!(st.send_wall_ns.is_some());
|
assert!(st.send_wall_ns.is_some());
|
||||||
assert!(st.send_mono_ns.is_some());
|
assert!(st.send_mono_ns.is_some());
|
||||||
}
|
}
|
||||||
|
Clock::Unspecified => {
|
||||||
|
unreachable!("clients never request an unspecified clock")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
other => panic!("expected EchoReply, got {other:?}"),
|
other => panic!("expected EchoReply, got {other:?}"),
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ use crate::{
|
|||||||
/// header.
|
/// header.
|
||||||
const MIDPOINT_COMPAT_EXTRA: usize = TIMESTAMP_SIZE;
|
const MIDPOINT_COMPAT_EXTRA: usize = TIMESTAMP_SIZE;
|
||||||
|
|
||||||
|
/// Whether the negotiation selects a midpoint timestamp from exactly one clock,
|
||||||
|
/// which is the only case the upstream dual-field compatibility form applies to.
|
||||||
|
///
|
||||||
|
/// [`Clock::Unspecified`] selects no clock and therefore lays out no midpoint
|
||||||
|
/// field at all, so it is not a single-clock negotiation.
|
||||||
fn is_midpoint_single_clock(params: &Params) -> bool {
|
fn is_midpoint_single_clock(params: &Params) -> bool {
|
||||||
params.stamp_at == StampAt::Midpoint && !matches!(params.clock, Clock::Both)
|
params.stamp_at == StampAt::Midpoint && matches!(params.clock, Clock::Wall | Clock::Monotonic)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total datagram length of the upstream dual-midpoint compatibility form.
|
/// Total datagram length of the upstream dual-midpoint compatibility form.
|
||||||
@@ -135,7 +140,7 @@ pub fn decode_echo_reply(
|
|||||||
match params.clock {
|
match params.clock {
|
||||||
Clock::Wall => (Some(wall), None),
|
Clock::Wall => (Some(wall), None),
|
||||||
Clock::Monotonic => (None, Some(mono)),
|
Clock::Monotonic => (None, Some(mono)),
|
||||||
Clock::Both => {
|
Clock::Unspecified | Clock::Both => {
|
||||||
unreachable!("midpoint compat only applies to single-clock negotiations")
|
unreachable!("midpoint compat only applies to single-clock negotiations")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ mod tests {
|
|||||||
ReceivedStats::Both => RECV_COUNT_SIZE + RECV_WINDOW_SIZE,
|
ReceivedStats::Both => RECV_COUNT_SIZE + RECV_WINDOW_SIZE,
|
||||||
};
|
};
|
||||||
let clock_count = match clock {
|
let clock_count = match clock {
|
||||||
|
Clock::Unspecified => 0,
|
||||||
Clock::Wall | Clock::Monotonic => 1,
|
Clock::Wall | Clock::Monotonic => 1,
|
||||||
Clock::Both => 2,
|
Clock::Both => 2,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
//! performs no additional validation, so callers that build `Params` manually
|
//! performs no additional validation, so callers that build `Params` manually
|
||||||
//! are responsible for validating those values before sending them.
|
//! are responsible for validating those values before sending them.
|
||||||
//!
|
//!
|
||||||
|
//! Parameters are optional on the wire, and an absent tag takes its wire
|
||||||
|
//! default — zero for every integer field, and [`Clock::Unspecified`] for the
|
||||||
|
//! clock. A receiver that must distinguish an omitted tag from one explicitly
|
||||||
|
//! encoded as zero uses [`Params::decode_with_presence`], which runs the same
|
||||||
|
//! parser and additionally reports a [`ParamPresence`].
|
||||||
|
//!
|
||||||
//! The crate also provides packet layout calculation, packet encoding and
|
//! The crate also provides packet layout calculation, packet encoding and
|
||||||
//! decoding, and optional HMAC placement, computation, and verification
|
//! decoding, and optional HMAC placement, computation, and verification
|
||||||
//! helpers.
|
//! helpers.
|
||||||
@@ -51,7 +57,10 @@ pub use flags::*;
|
|||||||
pub use hmac::{compute_hmac, compute_hmac_in_place, verify_hmac, verify_packet_hmac};
|
pub use hmac::{compute_hmac, compute_hmac_in_place, verify_hmac, verify_packet_hmac};
|
||||||
pub use layout::{echo_header_len, echo_packet_len, PacketLayout};
|
pub use layout::{echo_header_len, echo_packet_len, PacketLayout};
|
||||||
pub use open::{decode_open_reply, encode_open_reply, OpenReply};
|
pub use open::{decode_open_reply, encode_open_reply, OpenReply};
|
||||||
pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt, MAX_SERVER_FILL_BYTES};
|
pub use params::{
|
||||||
|
Clock, DecodedParams, ParamPresence, Params, ReceivedStats, ServerFill, StampAt,
|
||||||
|
MAX_SERVER_FILL_BYTES,
|
||||||
|
};
|
||||||
pub use request::{
|
pub use request::{
|
||||||
decode_request, encode_request, DecodedRequest, DecodedRequestKind, RequestToEncode,
|
decode_request, encode_request, DecodedRequest, DecodedRequestKind, RequestToEncode,
|
||||||
};
|
};
|
||||||
|
|||||||
+281
-10
@@ -11,6 +11,11 @@ pub const MAX_SERVER_FILL_BYTES: usize = 32;
|
|||||||
/// Higher-level callers should validate user or configuration input before
|
/// Higher-level callers should validate user or configuration input before
|
||||||
/// encoding. The normal `irtt-client` configuration path enforces
|
/// encoding. The normal `irtt-client` configuration path enforces
|
||||||
/// [`MAX_SERVER_FILL_BYTES`] for `server_fill`.
|
/// [`MAX_SERVER_FILL_BYTES`] for `server_fill`.
|
||||||
|
///
|
||||||
|
/// [`Params::default`] is the **wire** default: every integer field is zero and
|
||||||
|
/// [`clock`](Params::clock) is [`Clock::Unspecified`], which is what an open
|
||||||
|
/// request with an empty parameter payload means. It is not a set of sensible
|
||||||
|
/// client settings; a client builds its request from its own configuration.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub struct Params {
|
pub struct Params {
|
||||||
pub protocol_version: i64,
|
pub protocol_version: i64,
|
||||||
@@ -63,21 +68,67 @@ impl Params {
|
|||||||
///
|
///
|
||||||
/// This includes invalid enum values, malformed UTF-8, and `server_fill`
|
/// This includes invalid enum values, malformed UTF-8, and `server_fill`
|
||||||
/// values longer than [`MAX_SERVER_FILL_BYTES`].
|
/// values longer than [`MAX_SERVER_FILL_BYTES`].
|
||||||
|
///
|
||||||
|
/// Absent parameters take their wire default, so a caller cannot tell an
|
||||||
|
/// omitted tag from one explicitly encoded as zero. Use
|
||||||
|
/// [`decode_with_presence`](Params::decode_with_presence) when that
|
||||||
|
/// distinction matters; both share one parser.
|
||||||
pub fn decode(input: &[u8]) -> Result<Self> {
|
pub fn decode(input: &[u8]) -> Result<Self> {
|
||||||
|
Self::decode_with_presence(input).map(|decoded| decoded.params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes parameters and additionally reports which known tags appeared.
|
||||||
|
///
|
||||||
|
/// This is the same parser and the same validation as [`decode`], with the
|
||||||
|
/// presence of each known tag retained. A receiver needs it because the
|
||||||
|
/// protocol gives an omitted tag and an explicit zero the same value but
|
||||||
|
/// not the same meaning: an absent Duration or Interval is accepted as the
|
||||||
|
/// wire default zero, while one explicitly encoded as zero is invalid.
|
||||||
|
///
|
||||||
|
/// Presence means the tag appeared at least once. Repeated known tags keep
|
||||||
|
/// last-value-wins, and unknown tags remain ignored and untracked.
|
||||||
|
///
|
||||||
|
/// [`decode`]: Params::decode
|
||||||
|
pub fn decode_with_presence(input: &[u8]) -> Result<DecodedParams> {
|
||||||
let mut params = Self::default();
|
let mut params = Self::default();
|
||||||
|
let mut presence = ParamPresence::default();
|
||||||
let mut pos = 0;
|
let mut pos = 0;
|
||||||
while pos < input.len() {
|
while pos < input.len() {
|
||||||
let (tag, used) = varint::decode_uvarint(&input[pos..])?;
|
let (tag, used) = varint::decode_uvarint(&input[pos..])?;
|
||||||
pos += used;
|
pos += used;
|
||||||
match tag {
|
match tag {
|
||||||
1 => params.protocol_version = read_int(input, &mut pos)?,
|
1 => {
|
||||||
2 => params.duration_ns = read_int(input, &mut pos)?,
|
params.protocol_version = read_int(input, &mut pos)?;
|
||||||
3 => params.interval_ns = read_int(input, &mut pos)?,
|
presence.protocol_version = true;
|
||||||
4 => params.length = read_int(input, &mut pos)?,
|
}
|
||||||
5 => params.received_stats = ReceivedStats::try_from(read_int(input, &mut pos)?)?,
|
2 => {
|
||||||
6 => params.stamp_at = StampAt::try_from(read_int(input, &mut pos)?)?,
|
params.duration_ns = read_int(input, &mut pos)?;
|
||||||
7 => params.clock = Clock::try_from(read_int(input, &mut pos)?)?,
|
presence.duration_ns = true;
|
||||||
8 => params.dscp = read_int(input, &mut pos)?,
|
}
|
||||||
|
3 => {
|
||||||
|
params.interval_ns = read_int(input, &mut pos)?;
|
||||||
|
presence.interval_ns = true;
|
||||||
|
}
|
||||||
|
4 => {
|
||||||
|
params.length = read_int(input, &mut pos)?;
|
||||||
|
presence.length = true;
|
||||||
|
}
|
||||||
|
5 => {
|
||||||
|
params.received_stats = ReceivedStats::try_from(read_int(input, &mut pos)?)?;
|
||||||
|
presence.received_stats = true;
|
||||||
|
}
|
||||||
|
6 => {
|
||||||
|
params.stamp_at = StampAt::try_from(read_int(input, &mut pos)?)?;
|
||||||
|
presence.stamp_at = true;
|
||||||
|
}
|
||||||
|
7 => {
|
||||||
|
params.clock = Clock::try_from(read_int(input, &mut pos)?)?;
|
||||||
|
presence.clock = true;
|
||||||
|
}
|
||||||
|
8 => {
|
||||||
|
params.dscp = read_int(input, &mut pos)?;
|
||||||
|
presence.dscp = true;
|
||||||
|
}
|
||||||
9 => {
|
9 => {
|
||||||
let (len, used) = varint::decode_uvarint(&input[pos..])?;
|
let (len, used) = varint::decode_uvarint(&input[pos..])?;
|
||||||
pos += used;
|
pos += used;
|
||||||
@@ -97,6 +148,7 @@ impl Params {
|
|||||||
.to_owned();
|
.to_owned();
|
||||||
pos += len;
|
pos += len;
|
||||||
params.server_fill = Some(ServerFill { value });
|
params.server_fill = Some(ServerFill { value });
|
||||||
|
presence.server_fill = true;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let (_, used) = varint::decode_uvarint(&input[pos..])?;
|
let (_, used) = varint::decode_uvarint(&input[pos..])?;
|
||||||
@@ -104,10 +156,38 @@ impl Params {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(params)
|
Ok(DecodedParams { params, presence })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decoded parameters together with which known tags the payload carried.
|
||||||
|
///
|
||||||
|
/// Produced by [`Params::decode_with_presence`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct DecodedParams {
|
||||||
|
/// The decoded values. Absent parameters hold their wire default.
|
||||||
|
pub params: Params,
|
||||||
|
/// Which known tags appeared in the payload.
|
||||||
|
pub presence: ParamPresence,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which known parameter tags a decoded payload carried.
|
||||||
|
///
|
||||||
|
/// A field is `true` when its tag appeared at least once, whatever value it
|
||||||
|
/// carried. Unknown tags are ignored and are not represented here.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct ParamPresence {
|
||||||
|
pub protocol_version: bool,
|
||||||
|
pub duration_ns: bool,
|
||||||
|
pub interval_ns: bool,
|
||||||
|
pub length: bool,
|
||||||
|
pub received_stats: bool,
|
||||||
|
pub stamp_at: bool,
|
||||||
|
pub clock: bool,
|
||||||
|
pub dscp: bool,
|
||||||
|
pub server_fill: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ServerFill {
|
pub struct ServerFill {
|
||||||
pub value: String,
|
pub value: String,
|
||||||
@@ -179,12 +259,31 @@ impl TryFrom<i64> for StampAt {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which server clock sources supply timestamp fields.
|
||||||
|
///
|
||||||
|
/// # The zero value
|
||||||
|
///
|
||||||
|
/// [`Clock::Unspecified`] is the **wire default**, meaning the Clock tag was
|
||||||
|
/// absent from an open parameter payload — which is valid, and is what an empty
|
||||||
|
/// payload decodes to. It does not mean a peer may send an explicit Clock tag
|
||||||
|
/// encoding zero: [`TryFrom<i64>`](Clock::try_from) still rejects an explicit
|
||||||
|
/// zero as an invalid enum value, so this state is only ever reached by
|
||||||
|
/// omission. [`encode`](Params::encode) omits the tag for `Unspecified` rather
|
||||||
|
/// than emitting an explicit zero, which round-trips absence faithfully.
|
||||||
|
///
|
||||||
|
/// `Unspecified` selects no clock, so [`has_wall`](Clock::has_wall) and
|
||||||
|
/// [`has_mono`](Clock::has_mono) are both false for it and no timestamp field
|
||||||
|
/// is laid out. A client that wants timestamps must request a real clock; the
|
||||||
|
/// `irtt-client` configuration path rejects `Unspecified`.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
#[repr(i64)]
|
#[repr(i64)]
|
||||||
pub enum Clock {
|
pub enum Clock {
|
||||||
|
/// Wire default for an absent Clock tag. Never produced from an explicit
|
||||||
|
/// encoded value.
|
||||||
|
#[default]
|
||||||
|
Unspecified = 0,
|
||||||
Wall = 1,
|
Wall = 1,
|
||||||
Monotonic = 2,
|
Monotonic = 2,
|
||||||
#[default]
|
|
||||||
Both = 3,
|
Both = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +300,11 @@ impl Clock {
|
|||||||
impl TryFrom<i64> for Clock {
|
impl TryFrom<i64> for Clock {
|
||||||
type Error = ProtoError;
|
type Error = ProtoError;
|
||||||
|
|
||||||
|
/// Converts an **explicitly encoded** Clock value.
|
||||||
|
///
|
||||||
|
/// Zero is rejected: it is only valid as an absent tag, never as an encoded
|
||||||
|
/// one. [`Clock::Unspecified`] is therefore unreachable through this
|
||||||
|
/// conversion by design.
|
||||||
fn try_from(value: i64) -> Result<Self> {
|
fn try_from(value: i64) -> Result<Self> {
|
||||||
match value {
|
match value {
|
||||||
1 => Ok(Self::Wall),
|
1 => Ok(Self::Wall),
|
||||||
@@ -244,6 +348,13 @@ mod tests {
|
|||||||
encoded
|
encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn encode_int(tag: u64, value: i64) -> Vec<u8> {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
varint::encode_uvarint(tag, &mut encoded);
|
||||||
|
varint::encode_varint(value, &mut encoded);
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn params_round_trip() {
|
fn params_round_trip() {
|
||||||
let params = Params {
|
let params = Params {
|
||||||
@@ -452,6 +563,166 @@ mod tests {
|
|||||||
value: 0,
|
value: 0,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Params::decode_with_presence(&encoded),
|
||||||
|
Err(ProtoError::InvalidEnum {
|
||||||
|
name: "Clock",
|
||||||
|
value: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_clock_values_one_to_three_are_accepted() {
|
||||||
|
for (value, expected) in [(1, Clock::Wall), (2, Clock::Monotonic), (3, Clock::Both)] {
|
||||||
|
let decoded = Params::decode_with_presence(&encode_int(7, value)).unwrap();
|
||||||
|
assert_eq!(decoded.params.clock, expected);
|
||||||
|
assert!(decoded.presence.clock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_payload_decodes_to_wire_defaults_with_nothing_present() {
|
||||||
|
let decoded = Params::decode_with_presence(&[]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decoded.params, Params::default());
|
||||||
|
assert_eq!(decoded.params.protocol_version, 0);
|
||||||
|
assert_eq!(decoded.params.duration_ns, 0);
|
||||||
|
assert_eq!(decoded.params.interval_ns, 0);
|
||||||
|
assert_eq!(decoded.params.length, 0);
|
||||||
|
assert_eq!(decoded.params.dscp, 0);
|
||||||
|
assert_eq!(decoded.params.received_stats, ReceivedStats::None);
|
||||||
|
assert_eq!(decoded.params.stamp_at, StampAt::None);
|
||||||
|
// The wire default clock is zero, not `Both`.
|
||||||
|
assert_eq!(decoded.params.clock, Clock::Unspecified);
|
||||||
|
assert!(!decoded.params.clock.has_wall());
|
||||||
|
assert!(!decoded.params.clock.has_mono());
|
||||||
|
assert_eq!(decoded.params.server_fill, None);
|
||||||
|
|
||||||
|
assert_eq!(decoded.presence, ParamPresence::default());
|
||||||
|
assert_eq!(
|
||||||
|
decoded.presence,
|
||||||
|
ParamPresence {
|
||||||
|
protocol_version: false,
|
||||||
|
duration_ns: false,
|
||||||
|
interval_ns: false,
|
||||||
|
length: false,
|
||||||
|
received_stats: false,
|
||||||
|
stamp_at: false,
|
||||||
|
clock: false,
|
||||||
|
dscp: false,
|
||||||
|
server_fill: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_and_explicitly_zero_parameters_share_a_value_but_not_a_presence() {
|
||||||
|
// Duration and Interval are the pair a server must tell apart: absent is
|
||||||
|
// accepted, explicitly zero is not.
|
||||||
|
let absent = Params::decode_with_presence(&encode_int(1, 1)).unwrap();
|
||||||
|
assert_eq!(absent.params.duration_ns, 0);
|
||||||
|
assert!(!absent.presence.duration_ns);
|
||||||
|
assert_eq!(absent.params.interval_ns, 0);
|
||||||
|
assert!(!absent.presence.interval_ns);
|
||||||
|
assert_eq!(absent.params.clock, Clock::Unspecified);
|
||||||
|
assert!(!absent.presence.clock);
|
||||||
|
|
||||||
|
let explicit_duration = Params::decode_with_presence(&encode_int(2, 0)).unwrap();
|
||||||
|
assert_eq!(explicit_duration.params.duration_ns, 0);
|
||||||
|
assert!(explicit_duration.presence.duration_ns);
|
||||||
|
|
||||||
|
let explicit_interval = Params::decode_with_presence(&encode_int(3, 0)).unwrap();
|
||||||
|
assert_eq!(explicit_interval.params.interval_ns, 0);
|
||||||
|
assert!(explicit_interval.presence.interval_ns);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_known_tag_reports_its_own_presence() {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
for (tag, value) in [
|
||||||
|
(1, 1),
|
||||||
|
(2, 5),
|
||||||
|
(3, 6),
|
||||||
|
(4, 7),
|
||||||
|
(5, 3),
|
||||||
|
(6, 4),
|
||||||
|
(7, 3),
|
||||||
|
(8, 184),
|
||||||
|
] {
|
||||||
|
encoded.extend_from_slice(&encode_int(tag, value));
|
||||||
|
}
|
||||||
|
encoded.extend_from_slice(&encode_server_fill_value(b"rand"));
|
||||||
|
|
||||||
|
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decoded.presence,
|
||||||
|
ParamPresence {
|
||||||
|
protocol_version: true,
|
||||||
|
duration_ns: true,
|
||||||
|
interval_ns: true,
|
||||||
|
length: true,
|
||||||
|
received_stats: true,
|
||||||
|
stamp_at: true,
|
||||||
|
clock: true,
|
||||||
|
dscp: true,
|
||||||
|
server_fill: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unspecified_clock_is_encoded_by_omission() {
|
||||||
|
let params = Params {
|
||||||
|
protocol_version: 1,
|
||||||
|
clock: Clock::Unspecified,
|
||||||
|
..Params::default()
|
||||||
|
};
|
||||||
|
let encoded = params.encode();
|
||||||
|
|
||||||
|
assert_eq!(encoded, encode_int(1, 1));
|
||||||
|
assert!(
|
||||||
|
!encoded.windows(2).any(|bytes| bytes[0] == 7),
|
||||||
|
"an unspecified clock must omit the tag rather than encode an explicit zero"
|
||||||
|
);
|
||||||
|
assert_round_trip(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_and_decode_with_presence_agree() {
|
||||||
|
let mut payloads = vec![
|
||||||
|
Vec::new(),
|
||||||
|
encode_int(1, 1),
|
||||||
|
encode_int(2, 0),
|
||||||
|
encode_server_fill_value(b"rand"),
|
||||||
|
];
|
||||||
|
payloads.push(
|
||||||
|
Params {
|
||||||
|
protocol_version: 1,
|
||||||
|
duration_ns: 3_000_000_000,
|
||||||
|
interval_ns: 1_000_000_000,
|
||||||
|
length: 1472,
|
||||||
|
received_stats: ReceivedStats::Both,
|
||||||
|
stamp_at: StampAt::Both,
|
||||||
|
clock: Clock::Both,
|
||||||
|
dscp: 184,
|
||||||
|
server_fill: Some(ServerFill {
|
||||||
|
value: "rand".to_owned(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
.encode(),
|
||||||
|
);
|
||||||
|
// Malformed payloads must fail identically through both entry points.
|
||||||
|
payloads.push(vec![1, 0x80]);
|
||||||
|
payloads.push(encode_int(7, 0));
|
||||||
|
|
||||||
|
for payload in payloads {
|
||||||
|
assert_eq!(
|
||||||
|
Params::decode(&payload),
|
||||||
|
Params::decode_with_presence(&payload).map(|decoded| decoded.params),
|
||||||
|
"decode and decode_with_presence disagreed on {payload:02x?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ fn midpoint_dual_field_reply_is_accepted_wherever_length_identifies_it() {
|
|||||||
// The first wire field must not be mistaken for the
|
// The first wire field must not be mistaken for the
|
||||||
// negotiated monotonic value.
|
// negotiated monotonic value.
|
||||||
Clock::Monotonic => (None, Some(222)),
|
Clock::Monotonic => (None, Some(222)),
|
||||||
Clock::Both => unreachable!(),
|
Clock::Unspecified | Clock::Both => unreachable!(),
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reply.timestamps.midpoint_wall, expected_wall,
|
reply.timestamps.midpoint_wall, expected_wall,
|
||||||
@@ -463,7 +463,7 @@ fn midpoint_dual_field_reply_of_equal_length_is_accepted_but_not_correctable() {
|
|||||||
assert_eq!(reply.timestamps.midpoint_wall, None);
|
assert_eq!(reply.timestamps.midpoint_wall, None);
|
||||||
assert_eq!(reply.timestamps.midpoint_mono, Some(111));
|
assert_eq!(reply.timestamps.midpoint_mono, Some(111));
|
||||||
}
|
}
|
||||||
Clock::Both => unreachable!(),
|
Clock::Unspecified | Clock::Both => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# `irtt-server` guidance
|
||||||
|
|
||||||
|
Repository-wide guidance in the root `AGENTS.md` also applies here.
|
||||||
|
|
||||||
|
## Where server behavior comes from
|
||||||
|
|
||||||
|
Server wire behavior comes from the sanitized clean server documents, not from
|
||||||
|
upstream implementation material:
|
||||||
|
|
||||||
|
- `docs/protocol/IRTT_SERVER_PROTOCOL_SPEC.md`
|
||||||
|
- `docs/protocol/test-vectors/SERVER_BEHAVIORAL_VECTORS.md`
|
||||||
|
- `docs/protocol/BLACKBOX_VERIFICATION_REPORT.md`
|
||||||
|
- `docs/protocol/IRTT_CLIENT_PROTOCOL_SPEC.md` for the shared wire format
|
||||||
|
|
||||||
|
Never derive behavior from upstream `heistp/irtt` source or tests. If the clean
|
||||||
|
material does not establish a semantic, state the ambiguity instead of guessing.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- Protocol behavior comes from `irtt-proto`. `irtt-server` must **not** depend
|
||||||
|
on `irtt-client` to reuse wire logic; a needed primitive belongs in
|
||||||
|
`irtt-proto`, where both sides get it.
|
||||||
|
- Do not reimplement magic validation, flag rules, HMAC placement, request
|
||||||
|
classification, token/header offsets or parameter parsing here.
|
||||||
|
- No statistics, CLI or presentation concerns.
|
||||||
|
- `SocketAddr` belongs here: endpoint identity is session state. It does not
|
||||||
|
belong in `irtt-proto`.
|
||||||
|
|
||||||
|
## Core versus runtime
|
||||||
|
|
||||||
|
- Deterministic protocol and session logic — admission, negotiation, the session
|
||||||
|
table, resource decisions, packet construction — belongs in the core.
|
||||||
|
- Socket and runtime orchestration — `UdpSocket`, IPv4/IPv6 listeners,
|
||||||
|
`recv_from`/`send_to`, timers, expiry, shutdown, `select!`, socket options —
|
||||||
|
belongs around the core.
|
||||||
|
- The server is intentionally **Tokio-native**. Tokio does not need to be
|
||||||
|
optional for this crate.
|
||||||
|
- Do not add a blocking server, an alternate-runtime variant, a transport trait,
|
||||||
|
a runtime abstraction layer, or a mirrored client-style API family. There is
|
||||||
|
no product requirement for any of them, and `ServerCore` being public is not a
|
||||||
|
promise of runtime independence.
|
||||||
|
|
||||||
|
## Rejection is silence
|
||||||
|
|
||||||
|
Malformed, unauthenticated, unsupported and policy-refused input is discarded
|
||||||
|
without a reply and without disturbing any live session. The protocol defines no
|
||||||
|
error reply, no reset and no NACK. Reserve the error type for internal failures
|
||||||
|
on the server's own side; an inbound `ProtoError` must never surface as a server
|
||||||
|
failure. Hostile UDP must leave the server running.
|
||||||
|
|
||||||
|
Do not let reply behavior reveal which stage rejected a datagram — in
|
||||||
|
particular, an authentication failure must be indistinguishable from an unknown
|
||||||
|
token.
|
||||||
|
|
||||||
|
## Resource policy
|
||||||
|
|
||||||
|
Total session and resource state must stay bounded. A single unauthenticated
|
||||||
|
datagram creates a session and opens are never deduplicated, so an unbounded
|
||||||
|
table is a liability. Upstream's observed absence of session and per-peer bounds
|
||||||
|
is explicitly **not** a compatibility target and must not be reproduced.
|
||||||
|
|
||||||
|
Keep fallible preparation before irreversible state mutation: a reply is
|
||||||
|
prepared before a session is inserted, so an internal failure cannot leave a
|
||||||
|
half-created session.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Drive the core with real packets built by the production encoders. Do not
|
||||||
|
maintain shadow encoders for well-formed wire data.
|
||||||
|
- Hand-build packets only where the point is a payload a compliant encoder
|
||||||
|
cannot produce — truncated varints, out-of-range enums, corrupted MACs.
|
||||||
|
- Token generation is the only nondeterministic part; tests inject a scripted
|
||||||
|
source so identity, collisions and allocation failure are assertable. Keep
|
||||||
|
that seam private.
|
||||||
|
- As the server gains behavior, prefer it over fake peers for normal compliant
|
||||||
|
behavior in other crates' tests. Keep small raw/adversarial peers for
|
||||||
|
intentionally malformed or non-compliant wire behavior.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "irtt-server"
|
||||||
|
version = "0.4.0"
|
||||||
|
description = "IRTT-compatible Rust server library"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
readme.workspace = true
|
||||||
|
keywords = ["irtt", "networking", "latency", "server"]
|
||||||
|
categories = ["network-programming"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
irtt-proto.workspace = true
|
||||||
|
getrandom = "0.4.3"
|
||||||
|
thiserror = "2.0.20"
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/// Default bound on the number of simultaneously live sessions.
|
||||||
|
///
|
||||||
|
/// This is `irtt-rs` resource policy, not an interoperability requirement. The
|
||||||
|
/// protocol has no rejection response, so a server refuses by staying silent
|
||||||
|
/// and the client sees an open timeout it already has to tolerate. Upstream was
|
||||||
|
/// observed to impose no session bound at all; the server specification
|
||||||
|
/// explicitly records that as an upstream policy choice which a compatible
|
||||||
|
/// server is not expected to reproduce.
|
||||||
|
pub const DEFAULT_MAX_SESSIONS: usize = 1024;
|
||||||
|
|
||||||
|
/// Configuration for a [`ServerCore`](crate::ServerCore).
|
||||||
|
///
|
||||||
|
/// Fields are private and set through consuming builder methods, so later
|
||||||
|
/// slices can add configuration without breaking construction. Only the
|
||||||
|
/// settings this OPEN/session slice actually uses exist yet; parameter
|
||||||
|
/// restriction knobs, lifetime policy and rate limits arrive with the slices
|
||||||
|
/// that implement them.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
hmac_key: Option<Vec<u8>>,
|
||||||
|
max_sessions: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerConfig {
|
||||||
|
/// Sets the HMAC key, enabling authentication for this server.
|
||||||
|
///
|
||||||
|
/// Authentication is global: with a key configured, every request must
|
||||||
|
/// carry `FLAG_HMAC` and a valid MAC, and every reply is authenticated.
|
||||||
|
/// Without one, any request carrying `FLAG_HMAC` is dropped.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_hmac_key(mut self, key: impl Into<Vec<u8>>) -> Self {
|
||||||
|
self.hmac_key = Some(key.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the maximum number of simultaneously live sessions.
|
||||||
|
///
|
||||||
|
/// Once the table holds this many sessions, a session-creating open is
|
||||||
|
/// silently dropped; nothing is evicted. Zero refuses every
|
||||||
|
/// session-creating open while still answering no-test opens, which create
|
||||||
|
/// no state. There is deliberately no unbounded setting.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_max_sessions(mut self, max_sessions: usize) -> Self {
|
||||||
|
self.max_sessions = max_sessions;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The configured HMAC key, if this server authenticates.
|
||||||
|
#[must_use]
|
||||||
|
pub fn hmac_key(&self) -> Option<&[u8]> {
|
||||||
|
self.hmac_key.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The maximum number of simultaneously live sessions.
|
||||||
|
#[must_use]
|
||||||
|
pub fn max_sessions(&self) -> usize {
|
||||||
|
self.max_sessions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerConfig {
|
||||||
|
/// An unauthenticated server bounded to [`DEFAULT_MAX_SESSIONS`] sessions.
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
hmac_key: None,
|
||||||
|
max_sessions: DEFAULT_MAX_SESSIONS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
use std::{collections::HashMap, net::SocketAddr};
|
||||||
|
|
||||||
|
use irtt_proto::{
|
||||||
|
decode_request, encode_open_reply, verify_packet_hmac, DecodedParams, DecodedRequestKind,
|
||||||
|
OpenReply, Params, FLAG_CLOSE, FLAG_OPEN, FLAG_REPLY,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::ServerConfig,
|
||||||
|
error::ServerError,
|
||||||
|
negotiate::negotiate_params,
|
||||||
|
session::Session,
|
||||||
|
token::{OsTokenSource, TokenSource, TOKEN_ATTEMPTS},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The deterministic protocol and session engine of the server.
|
||||||
|
///
|
||||||
|
/// `ServerCore` owns admission, negotiation, the session table and reply
|
||||||
|
/// construction. It performs no I/O and keeps no clock: a caller hands it a
|
||||||
|
/// source endpoint and the bytes of one received datagram, and gets back the
|
||||||
|
/// bytes to send to that endpoint, or nothing. Everything it does is a pure
|
||||||
|
/// function of its state and that input, except for drawing session tokens.
|
||||||
|
///
|
||||||
|
/// The Tokio runtime slice will wrap it directly:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// recv_from -> core.handle_datagram(peer, packet) -> Some(reply) -> send_to
|
||||||
|
/// -> None -> send nothing
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// It is public as a low-level engine for a pre-1.0 crate, not as a promise of
|
||||||
|
/// runtime independence. The server is intentionally Tokio-native; this is not
|
||||||
|
/// a transport abstraction, and there is no blocking or alternate-runtime
|
||||||
|
/// counterpart.
|
||||||
|
///
|
||||||
|
/// # Scope
|
||||||
|
///
|
||||||
|
/// This slice implements open handling and session creation. Echo and close
|
||||||
|
/// requests are structurally recognized and then deliberately ignored — no
|
||||||
|
/// reply, no session mutation — until their own slices.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ServerCore {
|
||||||
|
config: ServerConfig,
|
||||||
|
sessions: HashMap<u64, Session>,
|
||||||
|
tokens: Box<dyn TokenSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerCore {
|
||||||
|
/// Creates a server core drawing session tokens from the operating system's
|
||||||
|
/// random source.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(config: ServerConfig) -> Self {
|
||||||
|
Self::with_token_source(config, Box::new(OsTokenSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_token_source(config: ServerConfig, tokens: Box<dyn TokenSource>) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
sessions: HashMap::new(),
|
||||||
|
tokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This core's configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub fn config(&self) -> &ServerConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number of live sessions.
|
||||||
|
///
|
||||||
|
/// This is the observable a caller genuinely needs — how much session state
|
||||||
|
/// the server is holding against its bound. The sessions themselves stay
|
||||||
|
/// crate-private until their shape settles.
|
||||||
|
#[must_use]
|
||||||
|
pub fn session_count(&self) -> usize {
|
||||||
|
self.sessions.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Looks up a live session by token.
|
||||||
|
///
|
||||||
|
/// This is a lookup by token alone and is therefore *not* an admission
|
||||||
|
/// check: a request is bound to a session only when the token matches and
|
||||||
|
/// it arrived from the session's exact endpoint.
|
||||||
|
//
|
||||||
|
// Called only by tests until echo and close processing arrive, which is
|
||||||
|
// what a token lookup is for.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn session(&self, token: u64) -> Option<&Session> {
|
||||||
|
self.sessions.get(&token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles one received datagram, returning the reply to send, if any.
|
||||||
|
///
|
||||||
|
/// `Ok(None)` means "answer nothing". The protocol defines no error reply,
|
||||||
|
/// no reset and no NACK, so every rejection is a silent discard and a
|
||||||
|
/// client distinguishes "rejected" from "lost" only by timing out. A
|
||||||
|
/// datagram is discarded when it is structurally invalid, carries the reply
|
||||||
|
/// flag, disagrees with this server's authentication configuration, fails
|
||||||
|
/// authentication, carries a malformed or out-of-range open parameter
|
||||||
|
/// payload, would exceed the session bound, or is a request kind this slice
|
||||||
|
/// does not implement. None of that mutates any live session.
|
||||||
|
///
|
||||||
|
/// Authentication is checked before open parameters are decoded. That
|
||||||
|
/// ordering is deliberate: a packet that fails authentication is discarded
|
||||||
|
/// without parsing untrusted parameter data, and no reply behavior may
|
||||||
|
/// reveal which stage rejected it.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`ServerError`] only for an internal failure — the random source
|
||||||
|
/// failing, no unique token being found within the retry budget, or a
|
||||||
|
/// server-generated reply failing to encode. Rejected input is never an
|
||||||
|
/// error.
|
||||||
|
pub fn handle_datagram(
|
||||||
|
&mut self,
|
||||||
|
peer: SocketAddr,
|
||||||
|
packet: &[u8],
|
||||||
|
) -> Result<Option<Vec<u8>>, ServerError> {
|
||||||
|
let Ok(request) = decode_request(packet) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
// The HMAC flag must agree with this server's configuration in both
|
||||||
|
// directions of mismatch, and the MAC itself is verified separately.
|
||||||
|
if request.hmac_present != self.config.hmac_key().is_some() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if let Some(key) = self.config.hmac_key() {
|
||||||
|
if verify_packet_hmac(key, packet).is_err() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match request.kind {
|
||||||
|
DecodedRequestKind::Open { no_test, params } => self.handle_open(peer, no_test, params),
|
||||||
|
// Not implemented in this slice. A close must not remove a session
|
||||||
|
// and an echo must not advance receive state until the slices that
|
||||||
|
// implement those behaviors; emitting a placeholder reply now would
|
||||||
|
// be worse than silence.
|
||||||
|
DecodedRequestKind::Close { .. } | DecodedRequestKind::Echo { .. } => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_open(
|
||||||
|
&mut self,
|
||||||
|
peer: SocketAddr,
|
||||||
|
no_test: bool,
|
||||||
|
encoded_params: &[u8],
|
||||||
|
) -> Result<Option<Vec<u8>>, ServerError> {
|
||||||
|
// The parameter decoder already rejects a truncated tag or value, a
|
||||||
|
// varint overflow, an out-of-range stats/stamp-at/clock enum, and a
|
||||||
|
// server-fill value that is oversized, short-buffered or not UTF-8.
|
||||||
|
let Ok(decoded) = Params::decode_with_presence(encoded_params) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !open_params_are_admissible(&decoded) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let params = negotiate_params(decoded.params, &self.config);
|
||||||
|
|
||||||
|
if no_test {
|
||||||
|
// No-test validates parameters without running a test: same
|
||||||
|
// negotiation, a zero token, and no state at all. It stays
|
||||||
|
// serviceable at capacity precisely because it creates nothing.
|
||||||
|
let reply = OpenReply {
|
||||||
|
flags: FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE,
|
||||||
|
token: 0,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
return self.encode_reply(&reply).map(Some);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.sessions.len() >= self.config.max_sessions() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every fallible step runs before the table is touched, so an internal
|
||||||
|
// failure cannot leave a half-created session behind.
|
||||||
|
let token = self.allocate_token()?;
|
||||||
|
let reply = OpenReply {
|
||||||
|
flags: FLAG_OPEN | FLAG_REPLY,
|
||||||
|
token,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
let packet = self.encode_reply(&reply)?;
|
||||||
|
|
||||||
|
// The reply is encoded before its params move into the session, so
|
||||||
|
// nothing is cloned to keep both.
|
||||||
|
self.sessions
|
||||||
|
.insert(token, Session::new(peer, reply.params));
|
||||||
|
Ok(Some(packet))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a reply this server constructed. Any failure here is internal.
|
||||||
|
fn encode_reply(&self, reply: &OpenReply) -> Result<Vec<u8>, ServerError> {
|
||||||
|
encode_open_reply(reply, self.config.hmac_key())
|
||||||
|
.map_err(|source| ServerError::ReplyEncoding { source })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws a token that is non-zero and unique among live sessions.
|
||||||
|
///
|
||||||
|
/// Zero is reserved for no-test replies and is never issued to a session. A
|
||||||
|
/// value colliding with a live token is discarded rather than allowed to
|
||||||
|
/// overwrite that session. Both cases redraw, up to [`TOKEN_ATTEMPTS`].
|
||||||
|
fn allocate_token(&mut self) -> Result<u64, ServerError> {
|
||||||
|
for _ in 0..TOKEN_ATTEMPTS {
|
||||||
|
let token = self.tokens.next_token()?;
|
||||||
|
if token != 0 && !self.sessions.contains_key(&token) {
|
||||||
|
return Ok(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ServerError::TokenExhausted {
|
||||||
|
attempts: TOKEN_ATTEMPTS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the open parameter rules the decoder cannot express.
|
||||||
|
///
|
||||||
|
/// Duration and Interval are accepted when absent, taking the wire default
|
||||||
|
/// zero, and rejected when explicitly present and not positive. The presence
|
||||||
|
/// distinction is why the decoder reports it: testing the value alone would
|
||||||
|
/// wrongly reject an open that simply omitted the tag, which includes the empty
|
||||||
|
/// parameter payload.
|
||||||
|
///
|
||||||
|
/// Every other known parameter is already validated or deliberately
|
||||||
|
/// unrestricted here. Protocol version is accepted at any value and rewritten
|
||||||
|
/// during negotiation. Length and DSCP are accepted as decoded, including zero,
|
||||||
|
/// negative and out-of-byte-range values; a later slice may restrict a DSCP it
|
||||||
|
/// cannot actually apply to the socket. Unknown tags were ignored by the
|
||||||
|
/// decoder and are not reflected in the reply.
|
||||||
|
fn open_params_are_admissible(decoded: &DecodedParams) -> bool {
|
||||||
|
let admissible = |present: bool, value: i64| !present || value > 0;
|
||||||
|
admissible(decoded.presence.duration_ns, decoded.params.duration_ns)
|
||||||
|
&& admissible(decoded.presence.interval_ns, decoded.params.interval_ns)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
use irtt_proto::ProtoError;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// An internal server failure.
|
||||||
|
///
|
||||||
|
/// This type deliberately does **not** describe rejected traffic. Malformed,
|
||||||
|
/// unauthenticated, unsupported and policy-refused datagrams are not errors:
|
||||||
|
/// the protocol defines no rejection response, so they are answered with
|
||||||
|
/// silence and reported as `Ok(None)` from
|
||||||
|
/// [`ServerCore::handle_datagram`](crate::ServerCore::handle_datagram). An
|
||||||
|
/// inbound [`ProtoError`] never reaches a caller — that is the whole point of
|
||||||
|
/// the admission boundary, which turns hostile UDP into a dropped datagram
|
||||||
|
/// rather than a failure.
|
||||||
|
///
|
||||||
|
/// Every variant here is instead something wrong on *this* side, where the
|
||||||
|
/// server could not do its job for a request it had already accepted.
|
||||||
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum ServerError {
|
||||||
|
/// The operating system's random source failed, so no session token could
|
||||||
|
/// be drawn.
|
||||||
|
#[error("the operating system random source failed: {reason}")]
|
||||||
|
RandomSource { reason: String },
|
||||||
|
/// No unique non-zero token was found within the retry budget.
|
||||||
|
///
|
||||||
|
/// Each attempt drew a fresh value and rejected it as zero or as a
|
||||||
|
/// collision with a live session. With a working random source this is
|
||||||
|
/// vanishingly unlikely; it exists so token allocation cannot spin forever
|
||||||
|
/// on a degenerate source.
|
||||||
|
#[error("no unique non-zero session token after {attempts} attempts")]
|
||||||
|
TokenExhausted { attempts: u32 },
|
||||||
|
/// A reply the server itself constructed could not be encoded.
|
||||||
|
///
|
||||||
|
/// The `ProtoError` here always describes server-generated bytes, never
|
||||||
|
/// anything a peer sent.
|
||||||
|
#[error("could not encode a server-generated reply: {source}")]
|
||||||
|
ReplyEncoding {
|
||||||
|
#[source]
|
||||||
|
source: ProtoError,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//! IRTT-compatible server behavior.
|
||||||
|
//!
|
||||||
|
//! This crate shares wire semantics with the client through `irtt-proto` and
|
||||||
|
//! does not depend on `irtt-client`.
|
||||||
|
//!
|
||||||
|
//! # Structure
|
||||||
|
//!
|
||||||
|
//! [`ServerCore`] is the deterministic protocol and session engine: packet
|
||||||
|
//! admission, authentication policy, open negotiation, the session table,
|
||||||
|
//! resource decisions and reply construction. It performs no I/O. Socket and
|
||||||
|
//! runtime orchestration — the UDP listener, address handling, timers, expiry
|
||||||
|
//! and shutdown — will live around it and is intentionally Tokio-native. There
|
||||||
|
//! is no blocking or alternate-runtime counterpart and no transport
|
||||||
|
//! abstraction.
|
||||||
|
//!
|
||||||
|
//! # Rejection is silence
|
||||||
|
//!
|
||||||
|
//! The protocol defines no error reply, no reset and no NACK. Malformed,
|
||||||
|
//! unauthenticated and policy-refused datagrams are discarded without a
|
||||||
|
//! response and without disturbing any live session, and a client tells
|
||||||
|
//! "rejected" from "lost" only by timing out. [`ServerError`] is therefore
|
||||||
|
//! reserved for internal failures on the server's own side.
|
||||||
|
//!
|
||||||
|
//! # Resource policy
|
||||||
|
//!
|
||||||
|
//! Total session state is bounded (see [`DEFAULT_MAX_SESSIONS`]). A single
|
||||||
|
//! unauthenticated datagram creates a session and opens are never deduplicated,
|
||||||
|
//! so an unbounded table is a liability rather than a compatibility feature.
|
||||||
|
//! Upstream's observed lack of session bounds is explicitly not a compatibility
|
||||||
|
//! target.
|
||||||
|
//!
|
||||||
|
//! # Current scope
|
||||||
|
//!
|
||||||
|
//! Open handling and session creation only. Echo processing, client-initiated
|
||||||
|
//! close, session lifetime and expiry, and the Tokio runtime are separate
|
||||||
|
//! slices; structurally valid echo and close requests are currently accepted by
|
||||||
|
//! the admission boundary and then ignored.
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod core;
|
||||||
|
mod error;
|
||||||
|
mod negotiate;
|
||||||
|
mod session;
|
||||||
|
mod token;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
pub use config::{ServerConfig, DEFAULT_MAX_SESSIONS};
|
||||||
|
pub use core::ServerCore;
|
||||||
|
pub use error::ServerError;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use irtt_proto::{Params, PROTOCOL_VERSION};
|
||||||
|
|
||||||
|
use crate::config::ServerConfig;
|
||||||
|
|
||||||
|
/// Produces the parameters the server returns in an open reply and will enforce
|
||||||
|
/// for the session.
|
||||||
|
///
|
||||||
|
/// This slice applies the minimum the specification requires: the requested
|
||||||
|
/// known parameters, with the protocol version rewritten to
|
||||||
|
/// [`PROTOCOL_VERSION`]. A server must return version 1 so that version-1
|
||||||
|
/// clients accept the session; any requested value, including an absent,
|
||||||
|
/// zero, negative or future one, is accepted and answered with 1. Detecting a
|
||||||
|
/// version mismatch is therefore client-side only.
|
||||||
|
///
|
||||||
|
/// Nothing else is restricted yet. A server *may* reduce or replace requested
|
||||||
|
/// parameters, and later policy slices will add the maximum test duration
|
||||||
|
/// clamp, minimum interval, idle-timeout interval cap, maximum packet length,
|
||||||
|
/// timestamp allowance, DSCP policy and server-fill allow-list. This function
|
||||||
|
/// is where they belong, which is why the configuration is threaded through
|
||||||
|
/// even though no knob reads it yet.
|
||||||
|
pub(crate) fn negotiate_params(mut requested: Params, _config: &ServerConfig) -> Params {
|
||||||
|
requested.protocol_version = PROTOCOL_VERSION;
|
||||||
|
requested
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use irtt_proto::Params;
|
||||||
|
|
||||||
|
/// A live session created by an accepted open request.
|
||||||
|
///
|
||||||
|
/// This slice records only what identifies the session and what was negotiated
|
||||||
|
/// for it. Echo receive state (counts, windows, sequence tracking) and lifetime
|
||||||
|
/// state (activity, deadlines, expiry) belong to the slices that implement
|
||||||
|
/// those behaviors, and are deliberately absent rather than present as
|
||||||
|
/// placeholders.
|
||||||
|
///
|
||||||
|
/// Deliberately crate-private. The next slices are all but certain to add and
|
||||||
|
/// restructure fields here, and nothing outside the crate needs a session yet;
|
||||||
|
/// publishing internal session state before ECHO even exists would buy nothing
|
||||||
|
/// and commit us to a shape we are about to change.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct Session {
|
||||||
|
peer: SocketAddr,
|
||||||
|
params: Params,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub(crate) fn new(peer: SocketAddr, params: Params) -> Self {
|
||||||
|
Self { peer, params }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exact source endpoint the open request arrived from.
|
||||||
|
///
|
||||||
|
/// A session is bound to this endpoint, not merely to its address: address
|
||||||
|
/// family, address, UDP source port and — for IPv6 — the scope identifier
|
||||||
|
/// all form part of its identity. Echo and close processing, once
|
||||||
|
/// implemented, must require both a token match and an exact endpoint match
|
||||||
|
/// before touching a session.
|
||||||
|
//
|
||||||
|
// Read only by tests until then. The endpoint has to be captured at open
|
||||||
|
// time — it is not recoverable later — so this slice stores state whose
|
||||||
|
// production consumer is the next one, and the tests prove it is stored
|
||||||
|
// exactly.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn peer(&self) -> SocketAddr {
|
||||||
|
self.peer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The negotiated parameters the server returned for this session and will
|
||||||
|
/// enforce for it.
|
||||||
|
//
|
||||||
|
// Also awaiting its production consumer: echo replies are built from the
|
||||||
|
// negotiated params.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn params(&self) -> &Params {
|
||||||
|
&self.params
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
use irtt_proto::{verify_packet_hmac, FLAG_HMAC};
|
||||||
|
|
||||||
|
use super::support::{
|
||||||
|
client_params, core_with_tokens, expect_normal_open_reply, open_request,
|
||||||
|
open_request_with_raw_params, param_int, peer, ScriptedTokens, KEY, OTHER_KEY,
|
||||||
|
};
|
||||||
|
use crate::ServerConfig;
|
||||||
|
|
||||||
|
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||||
|
|
||||||
|
/// Asserts a datagram is silently discarded, leaving no trace at all: no reply,
|
||||||
|
/// no session, and no session token drawn.
|
||||||
|
fn assert_discarded(config: ServerConfig, name: &str, packet: &[u8]) {
|
||||||
|
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||||
|
let mut core = core_with_tokens(config, tokens.clone());
|
||||||
|
|
||||||
|
let reply = core
|
||||||
|
.handle_datagram(peer(), packet)
|
||||||
|
.expect("a rejected datagram is not a server error");
|
||||||
|
|
||||||
|
assert!(reply.is_none(), "{name} must be answered with silence");
|
||||||
|
assert_eq!(core.session_count(), 0, "{name} must create no session");
|
||||||
|
assert_eq!(
|
||||||
|
tokens.remaining(),
|
||||||
|
1,
|
||||||
|
"{name} must not consume session capacity or a token"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_authenticating_server_discards_everything_that_does_not_authenticate() {
|
||||||
|
let config = ServerConfig::default().with_hmac_key(KEY);
|
||||||
|
|
||||||
|
assert_discarded(
|
||||||
|
config.clone(),
|
||||||
|
"open with no MAC field at all",
|
||||||
|
&open_request(&client_params(), None),
|
||||||
|
);
|
||||||
|
assert_discarded(
|
||||||
|
config.clone(),
|
||||||
|
"open signed with a different key",
|
||||||
|
&open_request(&client_params(), Some(OTHER_KEY)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut corrupted = open_request(&client_params(), Some(KEY));
|
||||||
|
corrupted[5] ^= 0x80;
|
||||||
|
assert_discarded(
|
||||||
|
config.clone(),
|
||||||
|
"open with one bit flipped in the MAC",
|
||||||
|
&corrupted,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut zeroed = open_request(&client_params(), Some(KEY));
|
||||||
|
zeroed[4..20].fill(0);
|
||||||
|
assert_discarded(config.clone(), "open with an all-zero MAC", &zeroed);
|
||||||
|
|
||||||
|
let mut truncated = open_request(&client_params(), Some(KEY));
|
||||||
|
truncated.drain(4..12);
|
||||||
|
assert_discarded(
|
||||||
|
config.clone(),
|
||||||
|
"open with a truncated MAC field",
|
||||||
|
&truncated,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut flag_cleared = open_request(&client_params(), Some(KEY));
|
||||||
|
flag_cleared[3] &= !FLAG_HMAC;
|
||||||
|
assert_discarded(
|
||||||
|
config,
|
||||||
|
"open with a valid MAC but the HMAC flag cleared",
|
||||||
|
&flag_cleared,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_authenticating_server_discards_authenticated_requests() {
|
||||||
|
assert_discarded(
|
||||||
|
ServerConfig::default(),
|
||||||
|
"authenticated open to a server with no key",
|
||||||
|
&open_request(&client_params(), Some(KEY)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_authenticating_server_answers_a_valid_open_with_an_authenticated_reply() {
|
||||||
|
let mut core = core_with_tokens(
|
||||||
|
ServerConfig::default().with_hmac_key(KEY),
|
||||||
|
ScriptedTokens::new([TOKEN_A]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let packet = core
|
||||||
|
.handle_datagram(peer(), &open_request(&client_params(), Some(KEY)))
|
||||||
|
.unwrap()
|
||||||
|
.expect("a correctly authenticated open must be answered");
|
||||||
|
|
||||||
|
let reply = expect_normal_open_reply(&packet, Some(KEY));
|
||||||
|
assert_eq!(reply.token, TOKEN_A);
|
||||||
|
assert_ne!(packet[3] & FLAG_HMAC, 0, "the reply must set FLAG_HMAC");
|
||||||
|
verify_packet_hmac(KEY, &packet).expect("the reply must carry a valid MAC");
|
||||||
|
assert_eq!(core.session_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_authenticating_server_answers_an_ordinary_open() {
|
||||||
|
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 unauthenticated open must be answered");
|
||||||
|
|
||||||
|
expect_normal_open_reply(&packet, None);
|
||||||
|
assert_eq!(packet[3] & FLAG_HMAC, 0);
|
||||||
|
assert_eq!(core.session_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authentication_is_checked_before_parameters_are_decoded() {
|
||||||
|
// Both of these are dropped, and neither reveals which stage rejected it.
|
||||||
|
// The pairing is the point: bad parameters under a valid MAC still drop,
|
||||||
|
// and a bad MAC drops without the parameters ever being parsed.
|
||||||
|
let config = ServerConfig::default().with_hmac_key(KEY);
|
||||||
|
let bad_params = param_int(2, 0);
|
||||||
|
|
||||||
|
assert_discarded(
|
||||||
|
config.clone(),
|
||||||
|
"authenticated open with an explicit zero duration",
|
||||||
|
&open_request_with_raw_params(&bad_params, Some(KEY)),
|
||||||
|
);
|
||||||
|
assert_discarded(
|
||||||
|
config,
|
||||||
|
"wrongly keyed open with an explicit zero duration",
|
||||||
|
&open_request_with_raw_params(&bad_params, Some(OTHER_KEY)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_authentication_leaves_a_live_session_untouched() {
|
||||||
|
let mut core = core_with_tokens(
|
||||||
|
ServerConfig::default().with_hmac_key(KEY),
|
||||||
|
ScriptedTokens::new([TOKEN_A]),
|
||||||
|
);
|
||||||
|
core.handle_datagram(peer(), &open_request(&client_params(), Some(KEY)))
|
||||||
|
.unwrap()
|
||||||
|
.expect("the session-creating open must succeed");
|
||||||
|
let session = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||||
|
|
||||||
|
for bad in [
|
||||||
|
open_request(&client_params(), None),
|
||||||
|
open_request(&client_params(), Some(OTHER_KEY)),
|
||||||
|
] {
|
||||||
|
assert_eq!(core.handle_datagram(peer(), &bad).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(core.session_count(), 1);
|
||||||
|
assert_eq!(core.session(TOKEN_A), Some(&session));
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//! Deterministic tests for the server core.
|
||||||
|
//!
|
||||||
|
//! The core takes a source endpoint and datagram bytes and returns reply bytes,
|
||||||
|
//! so these drive it directly; no socket is bound. Requests are built with the
|
||||||
|
//! production encoders wherever the wire form is well-formed, and by hand only
|
||||||
|
//! where the point of the test is a payload a compliant encoder cannot produce.
|
||||||
|
//!
|
||||||
|
//! Tokens come from a scripted source so session identity, collisions and
|
||||||
|
//! allocation failure are all assertable.
|
||||||
|
|
||||||
|
mod hmac;
|
||||||
|
mod no_test;
|
||||||
|
mod open;
|
||||||
|
mod params;
|
||||||
|
mod sessions;
|
||||||
|
mod support;
|
||||||
|
mod tokens;
|
||||||
|
mod unsupported;
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
use irtt_proto::{verify_packet_hmac, Params};
|
||||||
|
|
||||||
|
use super::support::{
|
||||||
|
client_params, core_with_tokens, expect_no_test_reply, no_test_request, open_request,
|
||||||
|
open_request_with_raw_params, param_int, peer, ScriptedTokens, KEY,
|
||||||
|
};
|
||||||
|
use crate::ServerConfig;
|
||||||
|
|
||||||
|
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||||
|
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_no_test_open_replies_with_a_zero_token_and_creates_nothing() {
|
||||||
|
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||||
|
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
|
||||||
|
|
||||||
|
let packet = core
|
||||||
|
.handle_datagram(peer(), &no_test_request(&client_params(), None))
|
||||||
|
.unwrap()
|
||||||
|
.expect("a no-test open must be answered");
|
||||||
|
|
||||||
|
let reply = expect_no_test_reply(&packet, None);
|
||||||
|
assert_eq!(
|
||||||
|
reply.params,
|
||||||
|
Params {
|
||||||
|
protocol_version: 1,
|
||||||
|
..client_params()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(core.session_count(), 0, "no-test creates no session");
|
||||||
|
assert_eq!(
|
||||||
|
tokens.remaining(),
|
||||||
|
1,
|
||||||
|
"no-test must not draw a session token"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_no_test_open_negotiates_exactly_as_a_normal_open_does() {
|
||||||
|
let requested = client_params();
|
||||||
|
|
||||||
|
let mut normal_core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||||
|
let normal = normal_core
|
||||||
|
.handle_datagram(peer(), &open_request(&requested, None))
|
||||||
|
.unwrap()
|
||||||
|
.expect("normal open must be answered");
|
||||||
|
|
||||||
|
let mut no_test_core =
|
||||||
|
core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||||
|
let no_test = no_test_core
|
||||||
|
.handle_datagram(peer(), &no_test_request(&requested, None))
|
||||||
|
.unwrap()
|
||||||
|
.expect("no-test open must be answered");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
expect_no_test_reply(&no_test, None).params,
|
||||||
|
super::support::decode_reply(&normal, None).params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_no_test_open_is_rejected_on_the_same_parameters_a_normal_open_is() {
|
||||||
|
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||||
|
|
||||||
|
let request = {
|
||||||
|
let mut packet = no_test_request(&client_params(), None);
|
||||||
|
// Replace the parameter payload with an explicit zero duration.
|
||||||
|
packet.truncate(4);
|
||||||
|
packet.extend_from_slice(¶m_int(2, 0));
|
||||||
|
packet
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(core.handle_datagram(peer(), &request).unwrap(), None);
|
||||||
|
assert_eq!(core.session_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_no_test_open_is_still_answered_when_no_session_could_be_created() {
|
||||||
|
// No-test consumes no capacity, so it must keep working exactly where a
|
||||||
|
// session-creating open would be refused.
|
||||||
|
let mut refuses_everything = core_with_tokens(
|
||||||
|
ServerConfig::default().with_max_sessions(0),
|
||||||
|
ScriptedTokens::new([TOKEN_A]),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
refuses_everything
|
||||||
|
.handle_datagram(peer(), &open_request(&client_params(), None))
|
||||||
|
.unwrap(),
|
||||||
|
None,
|
||||||
|
"max_sessions 0 refuses a session-creating open"
|
||||||
|
);
|
||||||
|
let packet = refuses_everything
|
||||||
|
.handle_datagram(peer(), &no_test_request(&client_params(), None))
|
||||||
|
.unwrap()
|
||||||
|
.expect("no-test must be served at max_sessions 0");
|
||||||
|
expect_no_test_reply(&packet, None);
|
||||||
|
|
||||||
|
let mut full = core_with_tokens(
|
||||||
|
ServerConfig::default().with_max_sessions(1),
|
||||||
|
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
|
||||||
|
);
|
||||||
|
full.handle_datagram(peer(), &open_request(&client_params(), None))
|
||||||
|
.unwrap()
|
||||||
|
.expect("the first open fills the table");
|
||||||
|
assert_eq!(full.session_count(), 1);
|
||||||
|
|
||||||
|
let packet = full
|
||||||
|
.handle_datagram(peer(), &no_test_request(&client_params(), None))
|
||||||
|
.unwrap()
|
||||||
|
.expect("no-test must be served with a full session table");
|
||||||
|
expect_no_test_reply(&packet, None);
|
||||||
|
assert_eq!(full.session_count(), 1, "no-test disturbed the table");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_authenticated_no_test_reply_is_authenticated_and_still_creates_nothing() {
|
||||||
|
let mut core = core_with_tokens(
|
||||||
|
ServerConfig::default().with_hmac_key(KEY),
|
||||||
|
ScriptedTokens::new([TOKEN_A]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let packet = core
|
||||||
|
.handle_datagram(peer(), &no_test_request(&client_params(), Some(KEY)))
|
||||||
|
.unwrap()
|
||||||
|
.expect("an authenticated no-test open must be answered");
|
||||||
|
|
||||||
|
expect_no_test_reply(&packet, Some(KEY));
|
||||||
|
verify_packet_hmac(KEY, &packet).expect("the no-test reply must carry a valid MAC");
|
||||||
|
assert_eq!(core.session_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_no_test_open_is_answered() {
|
||||||
|
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||||
|
|
||||||
|
let mut request = open_request_with_raw_params(&[], None);
|
||||||
|
request[3] |= irtt_proto::FLAG_CLOSE;
|
||||||
|
|
||||||
|
let packet = core
|
||||||
|
.handle_datagram(peer(), &request)
|
||||||
|
.unwrap()
|
||||||
|
.expect("an empty no-test open must be answered");
|
||||||
|
|
||||||
|
let reply = expect_no_test_reply(&packet, None);
|
||||||
|
assert_eq!(
|
||||||
|
reply.params,
|
||||||
|
Params {
|
||||||
|
protocol_version: 1,
|
||||||
|
..Params::default()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(core.session_count(), 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt};
|
||||||
|
|
||||||
|
use super::support::{
|
||||||
|
client_params, core_with_tokens, expect_normal_open_reply, open_request,
|
||||||
|
open_request_with_raw_params, param_int, peer, ScriptedTokens,
|
||||||
|
};
|
||||||
|
use crate::ServerConfig;
|
||||||
|
|
||||||
|
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(¶m_int(1, 0), None),
|
||||||
|
Some(_) => open_request(¶ms, 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() {
|
||||||
|
let mut core = core_with_tokens(ServerConfig::default(), 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.len(), 14);
|
||||||
|
|
||||||
|
let reply = expect_normal_open_reply(&packet, None);
|
||||||
|
assert_eq!(
|
||||||
|
reply.params,
|
||||||
|
Params {
|
||||||
|
protocol_version: 1,
|
||||||
|
..Params::default()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
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(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||||
|
|
||||||
|
let mut payload = param_int(1, 1);
|
||||||
|
payload.extend_from_slice(¶m_int(42, 7));
|
||||||
|
payload.extend_from_slice(¶m_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.len(), 14);
|
||||||
|
assert_eq!(core.session_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_slice_restricts_nothing_but_the_protocol_version() {
|
||||||
|
// These are the values a later restriction-policy slice will deliberately
|
||||||
|
// start changing. Pinning them now makes that a visible decision rather
|
||||||
|
// than an accident.
|
||||||
|
let unrestricted = Params {
|
||||||
|
protocol_version: 2,
|
||||||
|
duration_ns: 3_600_000_000_000,
|
||||||
|
interval_ns: 1,
|
||||||
|
length: 65_535,
|
||||||
|
received_stats: ReceivedStats::Window,
|
||||||
|
stamp_at: StampAt::Midpoint,
|
||||||
|
clock: Clock::Monotonic,
|
||||||
|
dscp: 184,
|
||||||
|
server_fill: Some(ServerFill {
|
||||||
|
value: "pattern:abc".to_owned(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut core = core_with_tokens(ServerConfig::default(), 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 negative_and_out_of_range_length_and_dscp_survive_negotiation() {
|
||||||
|
// Length and DSCP are accepted as decoded in this slice, including values a
|
||||||
|
// socket could never carry. Restricting a DSCP the server cannot apply is a
|
||||||
|
// later policy decision, not an admission rule.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt};
|
||||||
|
|
||||||
|
use super::support::{
|
||||||
|
core_with_tokens, expect_normal_open_reply, open_request, open_request_with_raw_params,
|
||||||
|
param_int, param_server_fill, peer, ScriptedTokens,
|
||||||
|
};
|
||||||
|
use crate::ServerConfig;
|
||||||
|
|
||||||
|
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||||
|
|
||||||
|
fn reject_case(name: &str, payload: Vec<u8>) {
|
||||||
|
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||||
|
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
|
||||||
|
|
||||||
|
let reply = core
|
||||||
|
.handle_datagram(peer(), &open_request_with_raw_params(&payload, None))
|
||||||
|
.expect("rejected parameters are not a server error");
|
||||||
|
|
||||||
|
assert!(reply.is_none(), "{name} must be answered with silence");
|
||||||
|
assert_eq!(core.session_count(), 0, "{name} must create no session");
|
||||||
|
assert_eq!(
|
||||||
|
tokens.remaining(),
|
||||||
|
1,
|
||||||
|
"{name} must not consume a session token"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_or_out_of_range_parameters_are_dropped_without_a_session() {
|
||||||
|
// Truncation and overflow.
|
||||||
|
reject_case("truncated tag varint", vec![0x80]);
|
||||||
|
reject_case("truncated value varint", vec![2]);
|
||||||
|
reject_case("truncated multi-byte value", vec![2, 0x80]);
|
||||||
|
reject_case(
|
||||||
|
"varint overflow",
|
||||||
|
vec![
|
||||||
|
1, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Duration and Interval: present and not positive.
|
||||||
|
reject_case("explicit duration zero", param_int(2, 0));
|
||||||
|
reject_case("negative duration", param_int(2, -1));
|
||||||
|
reject_case("explicit interval zero", param_int(3, 0));
|
||||||
|
reject_case("negative interval", param_int(3, -5));
|
||||||
|
|
||||||
|
// Enum values outside their defined range.
|
||||||
|
reject_case("received stats above range", param_int(5, 4));
|
||||||
|
reject_case("negative received stats", param_int(5, -1));
|
||||||
|
reject_case("stamp at above range", param_int(6, 5));
|
||||||
|
reject_case("explicit clock zero", param_int(7, 0));
|
||||||
|
reject_case("clock above range", param_int(7, 4));
|
||||||
|
|
||||||
|
// Server fill.
|
||||||
|
reject_case(
|
||||||
|
"server fill declaring more than the payload holds",
|
||||||
|
param_server_fill(10, b"ab"),
|
||||||
|
);
|
||||||
|
reject_case(
|
||||||
|
"server fill longer than 32 bytes",
|
||||||
|
param_server_fill(33, b"0123456789abcdef0123456789abcdefx"),
|
||||||
|
);
|
||||||
|
reject_case(
|
||||||
|
"server fill that is not utf-8",
|
||||||
|
param_server_fill(1, &[0xff]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_duration_interval_and_clock_are_accepted_as_wire_defaults() {
|
||||||
|
// The whole reason the decoder reports presence: an omitted tag and one
|
||||||
|
// explicitly encoded as zero share a value but not a verdict.
|
||||||
|
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||||
|
|
||||||
|
let packet = core
|
||||||
|
.handle_datagram(
|
||||||
|
peer(),
|
||||||
|
&open_request_with_raw_params(¶m_int(1, 1), None),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.expect("absent duration, interval and clock are all valid");
|
||||||
|
|
||||||
|
let reply = expect_normal_open_reply(&packet, None);
|
||||||
|
assert_eq!(reply.params.duration_ns, 0);
|
||||||
|
assert_eq!(reply.params.interval_ns, 0);
|
||||||
|
assert_eq!(reply.params.clock, Clock::Unspecified);
|
||||||
|
assert_eq!(core.session_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_optional_parameter_values_survive_unchanged() {
|
||||||
|
for (name, requested) in [
|
||||||
|
(
|
||||||
|
"clock wall",
|
||||||
|
Params {
|
||||||
|
clock: Clock::Wall,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"clock monotonic",
|
||||||
|
Params {
|
||||||
|
clock: Clock::Monotonic,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"clock both",
|
||||||
|
Params {
|
||||||
|
clock: Clock::Both,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"received stats and stamp at",
|
||||||
|
Params {
|
||||||
|
received_stats: ReceivedStats::Both,
|
||||||
|
stamp_at: StampAt::Midpoint,
|
||||||
|
clock: Clock::Wall,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"positive duration and interval",
|
||||||
|
Params {
|
||||||
|
duration_ns: 1,
|
||||||
|
interval_ns: 1,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"maximum length server fill",
|
||||||
|
Params {
|
||||||
|
server_fill: Some(ServerFill {
|
||||||
|
value: "0123456789abcdef0123456789abcdef".to_owned(),
|
||||||
|
}),
|
||||||
|
..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!("{name} must be answered"));
|
||||||
|
|
||||||
|
let reply = expect_normal_open_reply(&packet, None);
|
||||||
|
assert_eq!(
|
||||||
|
reply.params,
|
||||||
|
Params {
|
||||||
|
protocol_version: 1,
|
||||||
|
..requested
|
||||||
|
},
|
||||||
|
"{name} must survive negotiation unchanged"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
use super::support::{
|
||||||
|
client_params, core_with_tokens, expect_normal_open_reply, open_request, other_peer, peer,
|
||||||
|
ScriptedTokens,
|
||||||
|
};
|
||||||
|
use crate::{ServerConfig, DEFAULT_MAX_SESSIONS};
|
||||||
|
|
||||||
|
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||||
|
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||||
|
const TOKEN_C: u64 = 0x2122_2324_2526_2728;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_session_bound_is_finite() {
|
||||||
|
let bound = ServerConfig::default().max_sessions();
|
||||||
|
|
||||||
|
assert_eq!(bound, DEFAULT_MAX_SESSIONS);
|
||||||
|
assert!(bound > 0, "the default bound must admit sessions");
|
||||||
|
assert!(
|
||||||
|
bound < usize::MAX,
|
||||||
|
"there is deliberately no unlimited default"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_opens_from_one_endpoint_create_independent_sessions() {
|
||||||
|
// Opens are never deduplicated by endpoint or by parameters: a client
|
||||||
|
// retransmission legitimately produces a second, independent session.
|
||||||
|
let mut core = core_with_tokens(
|
||||||
|
ServerConfig::default(),
|
||||||
|
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
|
||||||
|
);
|
||||||
|
let request = open_request(&client_params(), None);
|
||||||
|
|
||||||
|
let first = core
|
||||||
|
.handle_datagram(peer(), &request)
|
||||||
|
.unwrap()
|
||||||
|
.expect("first open must be answered");
|
||||||
|
let second = core
|
||||||
|
.handle_datagram(peer(), &request)
|
||||||
|
.unwrap()
|
||||||
|
.expect("second open must be answered");
|
||||||
|
|
||||||
|
let first = expect_normal_open_reply(&first, None);
|
||||||
|
let second = expect_normal_open_reply(&second, None);
|
||||||
|
|
||||||
|
assert_ne!(first.token, 0);
|
||||||
|
assert_ne!(second.token, 0);
|
||||||
|
assert_ne!(first.token, second.token);
|
||||||
|
assert_eq!(core.session_count(), 2);
|
||||||
|
assert_eq!(core.session(first.token).unwrap().peer(), peer());
|
||||||
|
assert_eq!(core.session(second.token).unwrap().peer(), peer());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_session_table_drops_new_opens_without_evicting_anything() {
|
||||||
|
let tokens = ScriptedTokens::new([TOKEN_A, TOKEN_B, TOKEN_C]);
|
||||||
|
let mut core = core_with_tokens(ServerConfig::default().with_max_sessions(2), tokens.clone());
|
||||||
|
let request = open_request(&client_params(), None);
|
||||||
|
|
||||||
|
core.handle_datagram(peer(), &request)
|
||||||
|
.unwrap()
|
||||||
|
.expect("first open must be answered");
|
||||||
|
core.handle_datagram(other_peer(), &request)
|
||||||
|
.unwrap()
|
||||||
|
.expect("second open must be answered");
|
||||||
|
assert_eq!(core.session_count(), 2);
|
||||||
|
assert_eq!(core.session(TOKEN_A).unwrap().peer(), peer());
|
||||||
|
assert_eq!(core.session(TOKEN_B).unwrap().peer(), other_peer());
|
||||||
|
|
||||||
|
// Refusal is silence: the protocol has no rejection reply, so the client
|
||||||
|
// simply sees an open timeout.
|
||||||
|
assert_eq!(core.handle_datagram(peer(), &request).unwrap(), None);
|
||||||
|
|
||||||
|
assert_eq!(core.session_count(), 2, "nothing may be evicted");
|
||||||
|
assert_eq!(core.session(TOKEN_A).unwrap().peer(), peer());
|
||||||
|
assert_eq!(core.session(TOKEN_B).unwrap().peer(), other_peer());
|
||||||
|
assert_eq!(core.session(TOKEN_C), None);
|
||||||
|
assert_eq!(
|
||||||
|
tokens.remaining(),
|
||||||
|
1,
|
||||||
|
"a refused open must not commit a token"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_session_bound_refuses_every_session_creating_open() {
|
||||||
|
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||||
|
let mut core = core_with_tokens(ServerConfig::default().with_max_sessions(0), tokens.clone());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
core.handle_datagram(peer(), &open_request(&client_params(), None))
|
||||||
|
.unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(core.session_count(), 0);
|
||||||
|
assert_eq!(tokens.remaining(), 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
net::SocketAddr,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use irtt_proto::{
|
||||||
|
decode_open_reply, encode_request, varint, OpenReply, PacketLayout, Params, RequestToEncode,
|
||||||
|
FLAG_CLOSE, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{core::ServerCore, error::ServerError, token::TokenSource, ServerConfig};
|
||||||
|
|
||||||
|
pub(crate) const KEY: &[u8] = b"correctkey";
|
||||||
|
pub(crate) const OTHER_KEY: &[u8] = b"wrongkey";
|
||||||
|
|
||||||
|
pub(crate) fn peer() -> SocketAddr {
|
||||||
|
"198.51.100.7:41234".parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn other_peer() -> SocketAddr {
|
||||||
|
"198.51.100.7:41235".parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token source handing out a fixed script of values, then failing.
|
||||||
|
///
|
||||||
|
/// Draining the script is itself a failure rather than a wrap-around, so a test
|
||||||
|
/// that draws more tokens than it scripted is a loud test bug instead of a
|
||||||
|
/// silent repeat.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct ScriptedTokens {
|
||||||
|
values: Arc<Mutex<VecDeque<Result<u64, ServerError>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptedTokens {
|
||||||
|
pub(crate) fn new<I>(values: I) -> Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = u64>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
values: Arc::new(Mutex::new(values.into_iter().map(Ok).collect())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A source whose every draw reports a random-source failure.
|
||||||
|
pub(crate) fn failing() -> Self {
|
||||||
|
Self {
|
||||||
|
values: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many scripted values are still unused.
|
||||||
|
pub(crate) fn remaining(&self) -> usize {
|
||||||
|
self.values.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenSource for ScriptedTokens {
|
||||||
|
fn next_token(&mut self) -> Result<u64, ServerError> {
|
||||||
|
self.values
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.pop_front()
|
||||||
|
.unwrap_or(Err(ServerError::RandomSource {
|
||||||
|
reason: "scripted failure".to_owned(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A core with deterministic tokens, so session identity is assertable.
|
||||||
|
pub(crate) fn core_with_tokens(config: ServerConfig, tokens: ScriptedTokens) -> ServerCore {
|
||||||
|
ServerCore::with_token_source(config, Box::new(tokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a normal open request carrying `params`.
|
||||||
|
pub(crate) fn open_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8> {
|
||||||
|
encode_request(
|
||||||
|
RequestToEncode::Open {
|
||||||
|
params,
|
||||||
|
no_test: false,
|
||||||
|
},
|
||||||
|
hmac_key,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a no-test open request carrying `params`.
|
||||||
|
pub(crate) fn no_test_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8> {
|
||||||
|
encode_request(
|
||||||
|
RequestToEncode::Open {
|
||||||
|
params,
|
||||||
|
no_test: true,
|
||||||
|
},
|
||||||
|
hmac_key,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds an open request around a raw parameter payload.
|
||||||
|
///
|
||||||
|
/// Used for payloads a compliant encoder cannot produce, such as a truncated
|
||||||
|
/// varint or an out-of-range enum value.
|
||||||
|
pub(crate) fn open_request_with_raw_params(payload: &[u8], hmac_key: Option<&[u8]>) -> Vec<u8> {
|
||||||
|
let mut packet = irtt_proto::MAGIC.to_vec();
|
||||||
|
packet.push(if hmac_key.is_some() {
|
||||||
|
FLAG_OPEN | FLAG_HMAC
|
||||||
|
} else {
|
||||||
|
FLAG_OPEN
|
||||||
|
});
|
||||||
|
if hmac_key.is_some() {
|
||||||
|
packet.extend_from_slice(&[0; irtt_proto::HMAC_SIZE]);
|
||||||
|
}
|
||||||
|
packet.extend_from_slice(payload);
|
||||||
|
if let Some(key) = hmac_key {
|
||||||
|
irtt_proto::compute_hmac_in_place(key, &mut packet, hmac_field_offset()).unwrap();
|
||||||
|
}
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The authentication field sits immediately after the fixed header.
|
||||||
|
fn hmac_field_offset() -> usize {
|
||||||
|
PacketLayout::open_request(false).header_len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes one integer parameter as `tag` + zigzag value.
|
||||||
|
pub(crate) fn param_int(tag: u64, value: i64) -> Vec<u8> {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
varint::encode_uvarint(tag, &mut encoded);
|
||||||
|
varint::encode_varint(value, &mut encoded);
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a `server_fill` parameter with an explicit declared length, so tests
|
||||||
|
/// can declare a length the payload does not actually carry.
|
||||||
|
pub(crate) fn param_server_fill(declared_len: u64, value: &[u8]) -> Vec<u8> {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
varint::encode_uvarint(9, &mut encoded);
|
||||||
|
varint::encode_uvarint(declared_len, &mut encoded);
|
||||||
|
encoded.extend_from_slice(value);
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Params a well-behaved client would request.
|
||||||
|
pub(crate) fn client_params() -> Params {
|
||||||
|
Params {
|
||||||
|
protocol_version: 1,
|
||||||
|
duration_ns: 3_000_000_000,
|
||||||
|
interval_ns: 1_000_000_000,
|
||||||
|
length: 1472,
|
||||||
|
received_stats: irtt_proto::ReceivedStats::Both,
|
||||||
|
stamp_at: irtt_proto::StampAt::Both,
|
||||||
|
clock: irtt_proto::Clock::Both,
|
||||||
|
dscp: 184,
|
||||||
|
server_fill: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a reply the server produced, checking it against the server's key.
|
||||||
|
pub(crate) fn decode_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> OpenReply {
|
||||||
|
decode_open_reply(packet, hmac_key).expect("server reply must decode")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn expect_normal_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> OpenReply {
|
||||||
|
let reply = decode_reply(packet, hmac_key);
|
||||||
|
let expected = FLAG_OPEN | FLAG_REPLY | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
|
||||||
|
assert_eq!(reply.flags, expected, "normal open reply flags");
|
||||||
|
assert_ne!(reply.token, 0, "a session-creating reply needs a token");
|
||||||
|
assert_eq!(reply.params.protocol_version, 1);
|
||||||
|
reply
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn expect_no_test_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> OpenReply {
|
||||||
|
let reply = decode_reply(packet, hmac_key);
|
||||||
|
let expected =
|
||||||
|
FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
|
||||||
|
assert_eq!(reply.flags, expected, "no-test reply flags");
|
||||||
|
assert_eq!(reply.token, 0, "a no-test reply carries a zero token");
|
||||||
|
reply
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use super::support::{
|
||||||
|
client_params, core_with_tokens, expect_normal_open_reply, open_request, peer, ScriptedTokens,
|
||||||
|
};
|
||||||
|
use crate::{token::TOKEN_ATTEMPTS, ServerConfig, ServerError};
|
||||||
|
|
||||||
|
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||||
|
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_draw_is_discarded_and_redrawn() {
|
||||||
|
// Zero is reserved for no-test replies and is never issued to a session.
|
||||||
|
let mut core = core_with_tokens(
|
||||||
|
ServerConfig::default(),
|
||||||
|
ScriptedTokens::new([0, 0, TOKEN_A]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let packet = core
|
||||||
|
.handle_datagram(peer(), &open_request(&client_params(), None))
|
||||||
|
.unwrap()
|
||||||
|
.expect("a zero draw must be retried, not surfaced");
|
||||||
|
|
||||||
|
assert_eq!(expect_normal_open_reply(&packet, None).token, TOKEN_A);
|
||||||
|
assert_eq!(core.session_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_colliding_draw_is_discarded_and_never_overwrites_the_live_session() {
|
||||||
|
let mut core = core_with_tokens(
|
||||||
|
ServerConfig::default(),
|
||||||
|
ScriptedTokens::new([TOKEN_A, TOKEN_A, TOKEN_B]),
|
||||||
|
);
|
||||||
|
let request = open_request(&client_params(), None);
|
||||||
|
|
||||||
|
core.handle_datagram(peer(), &request)
|
||||||
|
.unwrap()
|
||||||
|
.expect("first open must be answered");
|
||||||
|
let first = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||||
|
|
||||||
|
let packet = core
|
||||||
|
.handle_datagram(peer(), &request)
|
||||||
|
.unwrap()
|
||||||
|
.expect("a collision must be retried, not surfaced");
|
||||||
|
|
||||||
|
assert_eq!(expect_normal_open_reply(&packet, None).token, TOKEN_B);
|
||||||
|
assert_eq!(core.session_count(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
core.session(TOKEN_A),
|
||||||
|
Some(&first),
|
||||||
|
"the colliding token's session must be untouched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exhausting_the_retry_budget_is_an_internal_error_that_creates_nothing() {
|
||||||
|
let mut core = core_with_tokens(
|
||||||
|
ServerConfig::default(),
|
||||||
|
ScriptedTokens::new([TOKEN_A, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||||
|
);
|
||||||
|
let request = open_request(&client_params(), None);
|
||||||
|
|
||||||
|
core.handle_datagram(peer(), &request)
|
||||||
|
.unwrap()
|
||||||
|
.expect("first open must be answered");
|
||||||
|
let first = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
core.handle_datagram(peer(), &request),
|
||||||
|
Err(ServerError::TokenExhausted {
|
||||||
|
attempts: TOKEN_ATTEMPTS
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(core.session_count(), 1, "no session may be half-created");
|
||||||
|
assert_eq!(core.session(TOKEN_A), Some(&first));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failing_random_source_is_reported_and_creates_nothing() {
|
||||||
|
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::failing());
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
core.handle_datagram(peer(), &open_request(&client_params(), None)),
|
||||||
|
Err(ServerError::RandomSource { .. })
|
||||||
|
));
|
||||||
|
assert_eq!(core.session_count(), 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//! Requests this slice deliberately does not implement.
|
||||||
|
//!
|
||||||
|
//! Echo and close are recognized by the admission boundary and then ignored.
|
||||||
|
//! These tests exist to keep the scope boundary honest: a half-implemented
|
||||||
|
//! lifecycle must not appear by accident before the slices that own it.
|
||||||
|
|
||||||
|
use irtt_proto::{encode_request, RequestToEncode};
|
||||||
|
|
||||||
|
use super::support::{client_params, core_with_tokens, open_request, peer, ScriptedTokens, KEY};
|
||||||
|
use crate::ServerConfig;
|
||||||
|
|
||||||
|
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||||
|
|
||||||
|
fn open_one_session(hmac_key: Option<&[u8]>) -> crate::ServerCore {
|
||||||
|
let mut config = ServerConfig::default();
|
||||||
|
if let Some(key) = hmac_key {
|
||||||
|
config = config.with_hmac_key(key);
|
||||||
|
}
|
||||||
|
let mut core = core_with_tokens(config, ScriptedTokens::new([TOKEN_A]));
|
||||||
|
core.handle_datagram(peer(), &open_request(&client_params(), hmac_key))
|
||||||
|
.unwrap()
|
||||||
|
.expect("the session-creating open must succeed");
|
||||||
|
core
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_valid_echo_is_neither_answered_nor_counted_yet() {
|
||||||
|
for hmac_key in [None, Some(KEY)] {
|
||||||
|
let mut core = open_one_session(hmac_key);
|
||||||
|
let session = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||||
|
|
||||||
|
let echo = encode_request(
|
||||||
|
RequestToEncode::Echo {
|
||||||
|
token: TOKEN_A,
|
||||||
|
sequence: 0,
|
||||||
|
params: session.params(),
|
||||||
|
payload: &[],
|
||||||
|
},
|
||||||
|
hmac_key,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(core.handle_datagram(peer(), &echo).unwrap(), None);
|
||||||
|
assert_eq!(core.session_count(), 1);
|
||||||
|
assert_eq!(core.session(TOKEN_A), Some(&session));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_valid_close_does_not_remove_the_session_yet() {
|
||||||
|
for hmac_key in [None, Some(KEY)] {
|
||||||
|
let mut core = open_one_session(hmac_key);
|
||||||
|
let session = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||||
|
|
||||||
|
let close = encode_request(RequestToEncode::Close { token: TOKEN_A }, hmac_key).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
|
||||||
|
assert_eq!(core.session_count(), 1);
|
||||||
|
assert_eq!(core.session(TOKEN_A), Some(&session));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structurally_invalid_datagrams_are_discarded() {
|
||||||
|
let mut core = open_one_session(None);
|
||||||
|
let session = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||||
|
|
||||||
|
let mut bad_magic = open_request(&client_params(), None);
|
||||||
|
bad_magic[0] ^= 0x01;
|
||||||
|
|
||||||
|
let mut reserved_flag = open_request(&client_params(), None);
|
||||||
|
reserved_flag[3] |= 0x10;
|
||||||
|
|
||||||
|
let mut reply_flag = open_request(&client_params(), None);
|
||||||
|
reply_flag[3] |= irtt_proto::FLAG_REPLY;
|
||||||
|
|
||||||
|
for (name, packet) in [
|
||||||
|
("empty datagram", Vec::new()),
|
||||||
|
("datagram shorter than the header", vec![0x14, 0xa7, 0x5b]),
|
||||||
|
("bad magic", bad_magic),
|
||||||
|
("reserved flag bit", reserved_flag),
|
||||||
|
("reply flag", reply_flag),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
core.handle_datagram(peer(), &packet).unwrap(),
|
||||||
|
None,
|
||||||
|
"{name} must be discarded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(core.session_count(), 1);
|
||||||
|
assert_eq!(core.session(TOKEN_A), Some(&session));
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//! Session token generation.
|
||||||
|
//!
|
||||||
|
//! A token is the only thing standing between an off-path attacker and a usable
|
||||||
|
//! session on an unauthenticated server, so production tokens come from the
|
||||||
|
//! operating system's cryptographically secure random source. Nothing here
|
||||||
|
//! derives a token from a counter, a timestamp, a hash of the source address,
|
||||||
|
//! or any other predictable value, and there is no fallback that would do so if
|
||||||
|
//! the random source fails.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use crate::error::ServerError;
|
||||||
|
|
||||||
|
/// How many values token allocation may draw before giving up.
|
||||||
|
///
|
||||||
|
/// Allocation rejects a drawn value that is zero (reserved for no-test replies)
|
||||||
|
/// or that collides with a live session, and draws again. With a working random
|
||||||
|
/// source neither outcome is realistically reachable even once; the bound
|
||||||
|
/// exists so a degenerate source cannot spin the allocator forever.
|
||||||
|
pub(crate) const TOKEN_ATTEMPTS: u32 = 8;
|
||||||
|
|
||||||
|
/// A source of candidate session tokens.
|
||||||
|
///
|
||||||
|
/// Deliberately private: it exists so tests can drive the zero, collision and
|
||||||
|
/// failure paths deterministically, not as a public extension point. The
|
||||||
|
/// production implementation is [`OsTokenSource`].
|
||||||
|
pub(crate) trait TokenSource: fmt::Debug + Send {
|
||||||
|
/// Draws the next candidate token. The value is *not* required to be
|
||||||
|
/// non-zero or unique; that is the allocator's job.
|
||||||
|
fn next_token(&mut self) -> Result<u64, ServerError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws tokens from the operating system's random source.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(crate) struct OsTokenSource;
|
||||||
|
|
||||||
|
impl TokenSource for OsTokenSource {
|
||||||
|
fn next_token(&mut self) -> Result<u64, ServerError> {
|
||||||
|
getrandom::u64().map_err(|source| ServerError::RandomSource {
|
||||||
|
reason: source.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user