Skip to content

Commit fcdf4ac

Browse files
committed
feat: multi-session support in reflector
1 parent 116a508 commit fcdf4ac

7 files changed

Lines changed: 534 additions & 60 deletions

File tree

README.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ stamp-suite is a Rust implementation of the Simple Two-Way Active Measurement Pr
1515

1616
- Full RFC 8762 compliance (unauthenticated and authenticated modes)
1717
- HMAC authentication support
18-
- Stateful reflector mode (RFC 8972)
18+
- Stateful reflector mode with per-client session tracking (RFC 8972 Section 4)
1919
- Support for both NTP and PTP timestamp formats
2020
- Real TTL/Hop Limit capture on all platforms
2121
- Async I/O using Tokio
@@ -68,6 +68,13 @@ stamp-suite -i --local-addr 192.168.1.100 --local-port 8620
6868

6969
# With verbose per-packet statistics
7070
stamp-suite -i -R
71+
72+
# Stateful reflector mode (RFC 8972) - maintains independent sequence
73+
# counters per client, allowing detection of reflector-side packet loss
74+
stamp-suite -i --stateful-reflector
75+
76+
# Stateful mode with custom session timeout (default: 300 seconds)
77+
stamp-suite -i --stateful-reflector --session-timeout 600
7178
```
7279

7380
### Session-Sender (Client)
@@ -101,9 +108,11 @@ Options:
101108
-L, --timeout <TIMEOUT> Timeout for lost packets in seconds [default: 5]
102109
-4 Force IPv4 addresses
103110
-6 Force IPv6 addresses
104-
-A, --auth-mode <AUTH_MODE> Work mode: A=auth, E=encrypted, O=open [default: AEO]
111+
-A, --auth-mode <AUTH_MODE> Work mode: A=authenticated, O=open [default: O]
105112
-R Print individual statistics for each packet
106113
-i, --is-reflector Run as Session Reflector instead of Sender
114+
--stateful-reflector Enable stateful reflector mode (RFC 8972 Section 4)
115+
--session-timeout <SECONDS> Session timeout for stateful mode [default: 300]
107116
-h, --help Print help
108117
-V, --version Print version
109118
```
@@ -152,17 +161,15 @@ The project is functional for STAMP measurements with the following features:
152161

153162
- Full RFC 8762 compliance (unauthenticated and authenticated modes)
154163
- HMAC authentication support
155-
- Stateful reflector mode (RFC 8972)
164+
- Stateful reflector mode with per-client session tracking (RFC 8972 Section 4)
156165
- Real TTL capture on all major platforms
157166

158167
Current limitations:
159168

160-
- Single session per instance (no multi-session support yet)
161169
- No STAMP-MIB support
162170

163171
### Roadmap
164172

165-
- [ ] Multi-session support for reflector
166173
- [ ] DSCP/ECN field handling
167174
- [ ] Enhanced statistics and reporting
168175

src/configuration.rs

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,16 @@ pub struct Configuration {
8282
#[clap(long)]
8383
pub strict_packets: bool,
8484

85-
/// Use stateful reflector mode with independent sequence counter (RFC 8972).
86-
/// When enabled, the reflector maintains its own sequence counter instead of
87-
/// echoing the sender's sequence number.
85+
/// Enable stateful reflector mode per RFC 8972 Section 4. The reflector maintains
86+
/// independent sequence counters for each client (IP:port) instead of echoing
87+
/// the sender's sequence number, allowing clients to detect reflector-side packet loss.
8888
#[clap(long)]
8989
pub stateful_reflector: bool,
90+
91+
/// Session timeout in seconds for stateful reflector mode. Sessions inactive for
92+
/// this duration may be cleaned up. Default: 300 (5 minutes). Set to 0 to disable.
93+
#[clap(long, default_value_t = 300)]
94+
pub session_timeout: u64,
9095
}
9196

9297
impl Configuration {
@@ -102,6 +107,11 @@ impl Configuration {
102107
}
103108

104109
// Validate auth mode - only A (authenticated) and O (open) are valid per RFC 8762
110+
if self.auth_mode.is_empty() {
111+
return Err(ConfigurationError::InvalidConfiguration(
112+
"Auth mode cannot be empty. Valid options: A (authenticated), O (open)".to_string(),
113+
));
114+
}
105115
for c in self.auth_mode.chars() {
106116
if c != 'A' && c != 'O' {
107117
return Err(ConfigurationError::InvalidConfiguration(format!(
@@ -165,7 +175,7 @@ mod tests {
165175
"--timeout",
166176
"5",
167177
"--auth-mode",
168-
"AEO",
178+
"AO",
169179
"--is-reflector",
170180
];
171181
let conf = Configuration::parse_from(args);
@@ -177,8 +187,9 @@ mod tests {
177187
assert_eq!(conf.send_delay, 1000);
178188
assert_eq!(conf.count, 1000);
179189
assert_eq!(conf.timeout, 5);
180-
assert_eq!(conf.auth_mode, "AEO");
190+
assert_eq!(conf.auth_mode, "AO");
181191
assert!(conf.is_reflector);
192+
assert!(conf.validate().is_ok());
182193
}
183194

184195
#[test]
@@ -331,9 +342,8 @@ mod tests {
331342
fn test_auth_mode_empty() {
332343
let args = vec!["test", "--auth-mode", ""];
333344
let conf = Configuration::parse_from(args);
334-
assert!(conf.validate().is_ok()); // Empty is valid (no modes enabled)
335-
assert!(!is_auth(&conf.auth_mode));
336-
assert!(!is_open(&conf.auth_mode));
345+
// Empty auth mode is invalid - must specify at least one mode
346+
assert!(conf.validate().is_err());
337347
}
338348

339349
#[test]
@@ -447,4 +457,37 @@ mod tests {
447457

448458
assert!(!conf.stateful_reflector);
449459
}
460+
461+
#[test]
462+
fn test_session_timeout_default() {
463+
let args = vec!["test"];
464+
let conf = Configuration::parse_from(args);
465+
466+
assert_eq!(conf.session_timeout, 300);
467+
}
468+
469+
#[test]
470+
fn test_session_timeout_custom() {
471+
let args = vec!["test", "--session-timeout", "600"];
472+
let conf = Configuration::parse_from(args);
473+
474+
assert_eq!(conf.session_timeout, 600);
475+
}
476+
477+
#[test]
478+
fn test_session_timeout_zero_disables() {
479+
let args = vec!["test", "--session-timeout", "0"];
480+
let conf = Configuration::parse_from(args);
481+
482+
assert_eq!(conf.session_timeout, 0);
483+
}
484+
485+
#[test]
486+
fn test_stateful_reflector_with_timeout() {
487+
let args = vec!["test", "--stateful-reflector", "--session-timeout", "120"];
488+
let conf = Configuration::parse_from(args);
489+
490+
assert!(conf.stateful_reflector);
491+
assert_eq!(conf.session_timeout, 120);
492+
}
450493
}

src/receiver/mod.rs

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,12 @@ pub const AUTH_BASE_SIZE: usize = 112;
9999

100100
/// Assembles an unauthenticated reflected packet with symmetric size (RFC 8762 Section 4.3).
101101
///
102-
/// Preserves the original packet length by copying extra bytes beyond the base 44 bytes.
102+
/// Preserves the original packet length by padding with zeros beyond the base 44 bytes.
103+
/// Per RFC 8762 Section 4.2.1, extra octets SHOULD be filled with zeros.
103104
///
104105
/// # Arguments
105106
/// * `packet` - The received unauthenticated test packet
106-
/// * `original_data` - The original received packet data
107+
/// * `original_data` - The original received packet data (used only for length)
107108
/// * `cs` - Clock format to use for timestamps
108109
/// * `rcvt` - Receive timestamp when the packet was received
109110
/// * `ttl` - TTL/Hop Limit value from the received packet's IP header
@@ -128,9 +129,9 @@ pub fn assemble_unauth_answer_symmetric(
128129
);
129130
let mut response = base.to_bytes().to_vec();
130131

131-
// Copy extra bytes beyond base packet (RFC 8762 Section 4.3)
132+
// Pad with zeros to match original length (RFC 8762 Section 4.2.1)
132133
if original_data.len() > UNAUTH_BASE_SIZE {
133-
response.extend_from_slice(&original_data[UNAUTH_BASE_SIZE..]);
134+
response.resize(original_data.len(), 0);
134135
}
135136

136137
response
@@ -184,11 +185,12 @@ pub fn assemble_auth_answer(
184185

185186
/// Assembles an authenticated reflected packet with symmetric size (RFC 8762 Section 4.3).
186187
///
187-
/// Preserves the original packet length by copying extra bytes beyond the base 112 bytes.
188+
/// Preserves the original packet length by padding with zeros beyond the base 112 bytes.
189+
/// Per RFC 8762 Section 4.2.1, extra octets SHOULD be filled with zeros.
188190
///
189191
/// # Arguments
190192
/// * `packet` - The received authenticated test packet
191-
/// * `original_data` - The original received packet data
193+
/// * `original_data` - The original received packet data (used only for length)
192194
/// * `cs` - Clock format to use for timestamps
193195
/// * `rcvt` - Receive timestamp when the packet was received
194196
/// * `ttl` - TTL/Hop Limit value from the received packet's IP header
@@ -217,9 +219,9 @@ pub fn assemble_auth_answer_symmetric(
217219
);
218220
let mut response = base.to_bytes().to_vec();
219221

220-
// Copy extra bytes beyond base packet (RFC 8762 Section 4.3)
222+
// Pad with zeros to match original length (RFC 8762 Section 4.2.1)
221223
if original_data.len() > AUTH_BASE_SIZE {
222-
response.extend_from_slice(&original_data[AUTH_BASE_SIZE..]);
224+
response.resize(original_data.len(), 0);
223225
}
224226

225227
response
@@ -511,8 +513,8 @@ mod tests {
511513

512514
// Response should be 48 bytes (44 base + 4 extra)
513515
assert_eq!(response.len(), 48);
514-
// Extra bytes should be preserved at the end
515-
assert_eq!(&response[44..], &[0xAA, 0xBB, 0xCC, 0xDD]);
516+
// Extra bytes should be zeros per RFC 8762 Section 4.2.1
517+
assert_eq!(&response[44..], &[0x00, 0x00, 0x00, 0x00]);
516518
}
517519

518520
#[test]
@@ -545,8 +547,8 @@ mod tests {
545547

546548
// Response should be 117 bytes (112 base + 5 extra)
547549
assert_eq!(response.len(), 117);
548-
// Extra bytes should be preserved at the end
549-
assert_eq!(&response[112..], &[0x11, 0x22, 0x33, 0x44, 0x55]);
550+
// Extra bytes should be zeros per RFC 8762 Section 4.2.1
551+
assert_eq!(&response[112..], &[0x00, 0x00, 0x00, 0x00, 0x00]);
550552
}
551553

552554
#[test]

src/receiver/nix.rs

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//!
33
//! Preferred on Linux systems. No special privileges required for regular UDP sockets.
44
5-
use std::{io::IoSliceMut, net::SocketAddr, os::fd::AsRawFd};
5+
use std::{io::IoSliceMut, net::SocketAddr, os::fd::AsRawFd, sync::Arc};
66

77
use nix::{
88
libc,
@@ -15,7 +15,7 @@ use crate::{
1515
crypto::HmacKey,
1616
error_estimate::ErrorEstimate,
1717
packets::{PacketAuthenticated, PacketUnauthenticated},
18-
session::Session,
18+
session::SessionManager,
1919
time::generate_timestamp,
2020
};
2121

@@ -109,10 +109,15 @@ pub async fn run_receiver(conf: &Configuration) {
109109
log::info!("HMAC authentication enabled");
110110
}
111111

112-
// Create session for stateful reflector mode (RFC 8972)
113-
let reflector_session = if conf.stateful_reflector {
112+
// Create session manager for stateful reflector mode (RFC 8972)
113+
let session_manager: Option<Arc<SessionManager>> = if conf.stateful_reflector {
114+
let timeout = if conf.session_timeout > 0 {
115+
Some(std::time::Duration::from_secs(conf.session_timeout))
116+
} else {
117+
None
118+
};
114119
log::info!("Stateful reflector mode enabled (RFC 8972)");
115-
Some(Session::new(0))
120+
Some(Arc::new(SessionManager::new(timeout)))
116121
} else {
117122
None
118123
};
@@ -143,7 +148,7 @@ pub async fn run_receiver(conf: &Configuration) {
143148
) {
144149
Ok(msg) => {
145150
let len = msg.bytes;
146-
let src_addr = msg.address;
151+
let src_storage = msg.address;
147152

148153
// Extract TTL from control messages
149154
let ttl = match extract_ttl_from_cmsgs(&msg) {
@@ -153,6 +158,25 @@ pub async fn run_receiver(conf: &Configuration) {
153158
continue;
154159
}
155160
};
161+
162+
// Convert source address for session lookup and response
163+
let src_addr: SocketAddr = match src_storage {
164+
Some(ref src) => {
165+
if let Some(v4) = src.as_sockaddr_in() {
166+
std::net::SocketAddrV4::new(v4.ip(), v4.port()).into()
167+
} else if let Some(v6) = src.as_sockaddr_in6() {
168+
std::net::SocketAddrV6::new(v6.ip(), v6.port(), 0, 0).into()
169+
} else {
170+
eprintln!("Unknown source address type");
171+
continue;
172+
}
173+
}
174+
None => {
175+
eprintln!("No source address available");
176+
continue;
177+
}
178+
};
179+
156180
let rcvt = generate_timestamp(conf.clock_source);
157181

158182
let response = if use_auth {
@@ -179,9 +203,9 @@ pub async fn run_receiver(conf: &Configuration) {
179203
}
180204

181205
// Generate reflector sequence number only after successful validation
182-
let reflector_seq = reflector_session
206+
let reflector_seq = session_manager
183207
.as_ref()
184-
.map(|s| s.generate_sequence_number());
208+
.map(|mgr| mgr.generate_sequence_number(src_addr));
185209

186210
// Use symmetric assembly to preserve original packet length (RFC 8762 Section 4.3)
187211
Some(assemble_auth_answer_symmetric(
@@ -209,9 +233,9 @@ pub async fn run_receiver(conf: &Configuration) {
209233
match packet_result {
210234
Ok(packet) => {
211235
// Generate reflector sequence number only after successful validation
212-
let reflector_seq = reflector_session
236+
let reflector_seq = session_manager
213237
.as_ref()
214-
.map(|s| s.generate_sequence_number());
238+
.map(|mgr| mgr.generate_sequence_number(src_addr));
215239

216240
// Use symmetric assembly to preserve original packet length (RFC 8762 Section 4.3)
217241
Some(assemble_unauth_answer_symmetric(
@@ -232,20 +256,8 @@ pub async fn run_receiver(conf: &Configuration) {
232256
};
233257

234258
if let Some(response_buf) = response {
235-
if let Some(src) = src_addr {
236-
// Convert SockaddrStorage back to SocketAddr
237-
let dest: SocketAddr = if let Some(v4) = src.as_sockaddr_in() {
238-
std::net::SocketAddrV4::new(v4.ip(), v4.port()).into()
239-
} else if let Some(v6) = src.as_sockaddr_in6() {
240-
std::net::SocketAddrV6::new(v6.ip(), v6.port(), 0, 0).into()
241-
} else {
242-
eprintln!("Unknown source address type");
243-
continue;
244-
};
245-
246-
if let Err(e) = tokio_socket.send_to(&response_buf, dest).await {
247-
eprintln!("Failed to send response: {}", e);
248-
}
259+
if let Err(e) = tokio_socket.send_to(&response_buf, src_addr).await {
260+
eprintln!("Failed to send response: {}", e);
249261
}
250262
}
251263
}

0 commit comments

Comments
 (0)