From 8c8a679533dc945c01683330ec883adfcd063d3b Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Tue, 5 May 2026 00:45:56 +0700 Subject: [PATCH] feat(cli): add support for configuring outgoing TTL and hop limit Introduce a `--ttl` flag to allow users to set the IPv4 TTL or IPv6 hop limit, providing control over packet lifespan and helping diagnose network topology issues. --- crates/irtt-cli/src/lib.rs | 35 +++++++- crates/irtt-client/src/client/tests/mod.rs | 1 + crates/irtt-client/src/client/tests/ttl.rs | 49 ++++++++++ crates/irtt-client/src/config.rs | 1 + crates/irtt-client/src/lib.rs | 2 +- crates/irtt-client/src/socket.rs | 8 +- crates/irtt-client/src/socket_options.rs | 94 +++++++++++++++++++- crates/irtt-client/tests/protocol_options.rs | 21 +++++ 8 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 crates/irtt-client/src/client/tests/ttl.rs diff --git a/crates/irtt-cli/src/lib.rs b/crates/irtt-cli/src/lib.rs index dbfe112..56650b5 100644 --- a/crates/irtt-cli/src/lib.rs +++ b/crates/irtt-cli/src/lib.rs @@ -13,7 +13,7 @@ use clap::{Parser, ValueEnum}; use irtt_client::{ ClientConfig, ClientEvent, NegotiatedParams, NegotiationPolicy, OneWayDelaySample, PacketMeta, ReceivedStatsSample, RttSample, ServerTiming, SocketConfig, WarningKind, MAX_DSCP_CODEPOINT, - MAX_SERVER_FILL_BYTES, + MAX_SERVER_FILL_BYTES, MAX_TTL, }; use irtt_proto::{Clock, ReceivedStats, StampAt}; @@ -73,6 +73,10 @@ pub struct CliArgs { #[arg(long, default_value_t = 0, value_name = "0..=63", value_parser = parse_dscp)] pub dscp: u8, + /// Outgoing IPv4 TTL or IPv6 unicast hop limit. + #[arg(long, value_name = "1..=255", value_parser = parse_ttl)] + pub ttl: Option, + /// Accept safe server restrictions during negotiation. #[arg(long)] pub loose: bool, @@ -100,6 +104,7 @@ impl CliArgs { NegotiationPolicy::Strict }, socket_config: SocketConfig { + ttl: self.ttl, recv_timeout: Some(DEFAULT_RECV_TIMEOUT), ..SocketConfig::default() }, @@ -279,6 +284,16 @@ pub fn parse_dscp(input: &str) -> Result { Ok(value) } +pub fn parse_ttl(input: &str) -> Result { + let value: u32 = input + .parse() + .map_err(|_| format!("invalid TTL/hop limit {input:?}"))?; + if value == 0 || value > MAX_TTL { + return Err(format!("TTL/hop limit must be in range 1..={MAX_TTL}")); + } + Ok(value) +} + pub fn format_event(event: &ClientEvent, mode: OutputMode) -> Option { if matches!(event, ClientEvent::EchoSent { .. }) { return None; @@ -695,6 +710,7 @@ mod tests { assert_eq!(args.stats, ReceivedStatsArg::Both); assert_eq!(args.server_fill, None); assert_eq!(args.dscp, 0); + assert_eq!(args.ttl, None); assert!(!args.loose); assert_eq!(args.output, OutputMode::Human); @@ -706,6 +722,7 @@ mod tests { assert_eq!(config.stamp_at, StampAt::Both); assert_eq!(config.clock, Clock::Both); assert_eq!(config.dscp, 0); + assert_eq!(config.socket_config.ttl, None); assert_eq!(config.server_fill, None); assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict); assert!(!args.is_continuous()); @@ -838,6 +855,17 @@ mod tests { } } + #[test] + fn maps_ttl_values() { + for value in ["1", "64", "255"] { + let args = parse(&["--ttl", value, "127.0.0.1:2112"]).unwrap(); + assert_eq!( + args.to_client_config().socket_config.ttl, + Some(value.parse::().unwrap()) + ); + } + } + #[test] fn maps_length_option() { let args = parse(&["--length", "1472", "127.0.0.1:2112"]).unwrap(); @@ -871,6 +899,7 @@ mod tests { assert!(help.contains("--stats ")); assert!(help.contains("--sfill ")); assert!(help.contains("--dscp <0..=63>")); + assert!(help.contains("--ttl <1..=255>")); assert!(help.contains("--loose")); assert!(help.contains("DSCP codepoint")); } @@ -892,6 +921,10 @@ mod tests { &["--dscp", "64", "127.0.0.1:2112"], &["--dscp", "-1", "127.0.0.1:2112"], &["--dscp", "abc", "127.0.0.1:2112"], + &["--ttl", "0", "127.0.0.1:2112"], + &["--ttl", "256", "127.0.0.1:2112"], + &["--ttl", "-1", "127.0.0.1:2112"], + &["--ttl", "abc", "127.0.0.1:2112"], &["--length", "-1", "127.0.0.1:2112"], &["--length", "65508", "127.0.0.1:2112"], ] { diff --git a/crates/irtt-client/src/client/tests/mod.rs b/crates/irtt-client/src/client/tests/mod.rs index 359815a..65975bb 100644 --- a/crates/irtt-client/src/client/tests/mod.rs +++ b/crates/irtt-client/src/client/tests/mod.rs @@ -17,3 +17,4 @@ mod negotiation; mod no_test; mod open; mod probes_replies; +mod ttl; diff --git a/crates/irtt-client/src/client/tests/ttl.rs b/crates/irtt-client/src/client/tests/ttl.rs new file mode 100644 index 0000000..f1b30c8 --- /dev/null +++ b/crates/irtt-client/src/client/tests/ttl.rs @@ -0,0 +1,49 @@ +use crate::socket_options::socket_ttl; + +use super::*; + +#[test] +fn connect_accepts_valid_ttl_config_values() { + for ttl in [None, Some(1), Some(255)] { + let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9))); + config.socket_config.ttl = ttl; + Client::connect(config).unwrap(); + } +} + +#[test] +fn connect_applies_configured_ipv4_ttl_before_open_and_close_preserves_it() { + let params = Params { + protocol_version: PROTOCOL_VERSION, + duration_ns: 3_000_000_000, + interval_ns: 1_000_000_000, + received_stats: ReceivedStats::Both, + stamp_at: StampAt::Both, + clock: Clock::Both, + ..Params::default() + }; + let server = open_success_server(params.clone()); + let mut config = default_test_config(server.addr); + config.socket_config.ttl = Some(64); + let mut client = Client::connect(config).unwrap(); + + assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64); + + client.open(ClientTimestamp::now()).unwrap(); + server.join(); + + client.close(ClientTimestamp::now()).unwrap(); + assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64); +} + +#[test] +fn connect_rejects_invalid_ttl_values() { + for ttl in [0, 256] { + let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9))); + config.socket_config.ttl = Some(ttl); + assert!(matches!( + Client::connect(config), + Err(ClientError::InvalidConfig { .. }) + )); + } +} diff --git a/crates/irtt-client/src/config.rs b/crates/irtt-client/src/config.rs index c409fd8..95fdcbd 100644 --- a/crates/irtt-client/src/config.rs +++ b/crates/irtt-client/src/config.rs @@ -4,6 +4,7 @@ use irtt_proto::{Clock, ReceivedStats, StampAt}; pub(crate) const DEFAULT_PORT: u16 = 2112; pub const MAX_DSCP_CODEPOINT: u8 = 63; +pub const MAX_TTL: u32 = 255; pub const MAX_SERVER_FILL_BYTES: usize = 32; pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3); pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1); diff --git a/crates/irtt-client/src/lib.rs b/crates/irtt-client/src/lib.rs index e5db983..d1b44a6 100644 --- a/crates/irtt-client/src/lib.rs +++ b/crates/irtt-client/src/lib.rs @@ -14,7 +14,7 @@ mod timing; pub use client::Client; pub use config::{ ClientConfig, NegotiationPolicy, RecvBudget, RunMode, SocketConfig, MAX_DSCP_CODEPOINT, - MAX_SERVER_FILL_BYTES, + MAX_SERVER_FILL_BYTES, MAX_TTL, }; pub use error::{ClientError, EventSubscriptionError}; pub use event::{ diff --git a/crates/irtt-client/src/socket.rs b/crates/irtt-client/src/socket.rs index a662a09..672885a 100644 --- a/crates/irtt-client/src/socket.rs +++ b/crates/irtt-client/src/socket.rs @@ -8,6 +8,7 @@ use socket2::{Domain, Protocol, Socket, Type}; use crate::{ config::{ClientConfig, SocketConfig, DEFAULT_PORT, MIN_OPEN_TIMEOUT}, error::ClientError, + socket_options::apply_ttl_to_socket, }; pub(crate) fn validate_open_timeouts(timeouts: &[Duration]) -> Result<(), ClientError> { @@ -74,10 +75,6 @@ pub(crate) fn connect_udp_socket( if config.ipv6_only && remote.is_ipv6() { socket.set_only_v6(true)?; } - if let Some(ttl) = config.ttl { - socket.set_ttl(ttl)?; - } - let bind_addr = config.bind_addr.unwrap_or_else(|| { if remote.is_ipv4() { SocketAddr::from(([0, 0, 0, 0], 0)) @@ -89,6 +86,9 @@ pub(crate) fn connect_udp_socket( socket.connect(&remote.into())?; let socket: UdpSocket = socket.into(); + if let Some(ttl) = config.ttl { + apply_ttl_to_socket(&socket, remote, ttl)?; + } socket.set_read_timeout(config.recv_timeout)?; Ok(socket) } diff --git a/crates/irtt-client/src/socket_options.rs b/crates/irtt-client/src/socket_options.rs index 8f0ee93..f665d71 100644 --- a/crates/irtt-client/src/socket_options.rs +++ b/crates/irtt-client/src/socket_options.rs @@ -3,7 +3,10 @@ use std::{ net::{SocketAddr, UdpSocket}, }; -use crate::{config::MAX_DSCP_CODEPOINT, error::ClientError}; +use crate::{ + config::{MAX_DSCP_CODEPOINT, MAX_TTL}, + error::ClientError, +}; pub(crate) fn dscp_codepoint_to_traffic_class(dscp: u8) -> Result { if dscp > MAX_DSCP_CODEPOINT { @@ -30,6 +33,28 @@ pub(crate) fn clear_dscp_on_socket( set_socket_traffic_class(socket, remote, 0, "clear DSCP before close") } +pub(crate) fn validate_ttl(ttl: u32) -> Result<(), ClientError> { + if ttl == 0 || ttl > MAX_TTL { + return Err(ClientError::InvalidConfig { + reason: format!("ttl must be in range 1..={MAX_TTL}"), + }); + } + Ok(()) +} + +pub(crate) fn apply_ttl_to_socket( + socket: &UdpSocket, + remote: SocketAddr, + ttl: u32, +) -> Result<(), ClientError> { + validate_ttl(ttl)?; + set_socket_ttl(socket, remote, ttl).map_err(|source| ClientError::SocketOption { + operation: "set TTL/hop limit", + remote, + source, + }) +} + #[cfg(test)] pub(crate) fn socket_traffic_class( socket: &UdpSocket, @@ -42,6 +67,32 @@ pub(crate) fn socket_traffic_class( }) } +#[cfg(test)] +pub(crate) fn socket_ttl(socket: &UdpSocket, remote: SocketAddr) -> Result { + get_socket_ttl(socket, remote).map_err(|source| ClientError::SocketOption { + operation: "read TTL/hop limit socket option", + remote, + source, + }) +} + +fn set_socket_ttl(socket: &UdpSocket, remote: SocketAddr, ttl: u32) -> io::Result<()> { + if remote.is_ipv4() { + socket2::SockRef::from(socket).set_ttl(ttl) + } else { + socket2::SockRef::from(socket).set_unicast_hops_v6(ttl) + } +} + +#[cfg(test)] +fn get_socket_ttl(socket: &UdpSocket, remote: SocketAddr) -> io::Result { + if remote.is_ipv4() { + socket2::SockRef::from(socket).ttl() + } else { + socket2::SockRef::from(socket).unicast_hops_v6() + } +} + fn set_socket_traffic_class( socket: &UdpSocket, remote: SocketAddr, @@ -272,6 +323,47 @@ mod tests { )); } + #[test] + fn validates_ttl_range() { + assert!(validate_ttl(1).is_ok()); + assert!(validate_ttl(64).is_ok()); + assert!(validate_ttl(255).is_ok()); + assert!(matches!( + validate_ttl(0), + Err(ClientError::InvalidConfig { .. }) + )); + assert!(matches!( + validate_ttl(256), + Err(ClientError::InvalidConfig { .. }) + )); + } + + #[test] + fn ipv4_socket_option_sets_ttl() { + let remote = SocketAddr::from(([127, 0, 0, 1], 9)); + let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + socket.connect(remote).unwrap(); + + apply_ttl_to_socket(&socket, remote, 64).unwrap(); + assert_eq!(socket_ttl(&socket, remote).unwrap(), 64); + + apply_ttl_to_socket(&socket, remote, 1).unwrap(); + assert_eq!(socket_ttl(&socket, remote).unwrap(), 1); + } + + #[test] + fn ipv6_socket_option_sets_unicast_hop_limit() { + let remote = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 9)); + let socket = UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 0))).unwrap(); + socket.connect(remote).unwrap(); + + apply_ttl_to_socket(&socket, remote, 64).unwrap(); + assert_eq!(socket_ttl(&socket, remote).unwrap(), 64); + + apply_ttl_to_socket(&socket, remote, 1).unwrap(); + assert_eq!(socket_ttl(&socket, remote).unwrap(), 1); + } + #[test] #[cfg(not(any( target_os = "fuchsia", diff --git a/crates/irtt-client/tests/protocol_options.rs b/crates/irtt-client/tests/protocol_options.rs index ce64eef..34cbb60 100644 --- a/crates/irtt-client/tests/protocol_options.rs +++ b/crates/irtt-client/tests/protocol_options.rs @@ -392,6 +392,27 @@ fn backend_basic_open_echo_close() { client.close(ClientTimestamp::now()).unwrap(); } +#[test] +fn backend_ttl_smoke() { + let params = default_params(); + let peer = BackendPeer::start_open_echo(params.clone(), None); + let mut config = config_for_params(peer.addr(), ¶ms); + config.socket_config.ttl = Some(64); + let mut client = Client::connect(config).unwrap(); + + let outcome = client.open(ClientTimestamp::now()).unwrap(); + assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. })); + + let sent = client.send_probe().unwrap(); + assert_eq!(sent.len(), 1); + + let events = client.recv_once().unwrap(); + assert_eq!(events.len(), 1); + assert!(matches!(events[0], ClientEvent::EchoReply { .. })); + + client.close(ClientTimestamp::now()).unwrap(); +} + #[test] fn dscp_configured_open_close_smoke() { let mut params = default_params();