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.
This commit is contained in:
8 files changed
+204
-7
No files matched your search
@@ -13,7 +13,7 @@ use clap::{Parser, ValueEnum};
|
|||||||
use irtt_client::{
|
use irtt_client::{
|
||||||
ClientConfig, ClientEvent, NegotiatedParams, NegotiationPolicy, OneWayDelaySample, PacketMeta,
|
ClientConfig, ClientEvent, NegotiatedParams, NegotiationPolicy, OneWayDelaySample, PacketMeta,
|
||||||
ReceivedStatsSample, RttSample, ServerTiming, SocketConfig, WarningKind, MAX_DSCP_CODEPOINT,
|
ReceivedStatsSample, RttSample, ServerTiming, SocketConfig, WarningKind, MAX_DSCP_CODEPOINT,
|
||||||
MAX_SERVER_FILL_BYTES,
|
MAX_SERVER_FILL_BYTES, MAX_TTL,
|
||||||
};
|
};
|
||||||
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
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)]
|
#[arg(long, default_value_t = 0, value_name = "0..=63", value_parser = parse_dscp)]
|
||||||
pub dscp: u8,
|
pub dscp: u8,
|
||||||
|
|
||||||
|
/// Outgoing IPv4 TTL or IPv6 unicast hop limit.
|
||||||
|
#[arg(long, value_name = "1..=255", value_parser = parse_ttl)]
|
||||||
|
pub ttl: Option<u32>,
|
||||||
|
|
||||||
/// Accept safe server restrictions during negotiation.
|
/// Accept safe server restrictions during negotiation.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub loose: bool,
|
pub loose: bool,
|
||||||
@@ -100,6 +104,7 @@ impl CliArgs {
|
|||||||
NegotiationPolicy::Strict
|
NegotiationPolicy::Strict
|
||||||
},
|
},
|
||||||
socket_config: SocketConfig {
|
socket_config: SocketConfig {
|
||||||
|
ttl: self.ttl,
|
||||||
recv_timeout: Some(DEFAULT_RECV_TIMEOUT),
|
recv_timeout: Some(DEFAULT_RECV_TIMEOUT),
|
||||||
..SocketConfig::default()
|
..SocketConfig::default()
|
||||||
},
|
},
|
||||||
@@ -279,6 +284,16 @@ pub fn parse_dscp(input: &str) -> Result<u8, String> {
|
|||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn parse_ttl(input: &str) -> Result<u32, String> {
|
||||||
|
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<String> {
|
pub fn format_event(event: &ClientEvent, mode: OutputMode) -> Option<String> {
|
||||||
if matches!(event, ClientEvent::EchoSent { .. }) {
|
if matches!(event, ClientEvent::EchoSent { .. }) {
|
||||||
return None;
|
return None;
|
||||||
@@ -695,6 +710,7 @@ mod tests {
|
|||||||
assert_eq!(args.stats, ReceivedStatsArg::Both);
|
assert_eq!(args.stats, ReceivedStatsArg::Both);
|
||||||
assert_eq!(args.server_fill, None);
|
assert_eq!(args.server_fill, None);
|
||||||
assert_eq!(args.dscp, 0);
|
assert_eq!(args.dscp, 0);
|
||||||
|
assert_eq!(args.ttl, None);
|
||||||
assert!(!args.loose);
|
assert!(!args.loose);
|
||||||
assert_eq!(args.output, OutputMode::Human);
|
assert_eq!(args.output, OutputMode::Human);
|
||||||
|
|
||||||
@@ -706,6 +722,7 @@ mod tests {
|
|||||||
assert_eq!(config.stamp_at, StampAt::Both);
|
assert_eq!(config.stamp_at, StampAt::Both);
|
||||||
assert_eq!(config.clock, Clock::Both);
|
assert_eq!(config.clock, Clock::Both);
|
||||||
assert_eq!(config.dscp, 0);
|
assert_eq!(config.dscp, 0);
|
||||||
|
assert_eq!(config.socket_config.ttl, None);
|
||||||
assert_eq!(config.server_fill, None);
|
assert_eq!(config.server_fill, None);
|
||||||
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
|
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
|
||||||
assert!(!args.is_continuous());
|
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::<u32>().unwrap())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn maps_length_option() {
|
fn maps_length_option() {
|
||||||
let args = parse(&["--length", "1472", "127.0.0.1:2112"]).unwrap();
|
let args = parse(&["--length", "1472", "127.0.0.1:2112"]).unwrap();
|
||||||
@@ -871,6 +899,7 @@ mod tests {
|
|||||||
assert!(help.contains("--stats <STATS>"));
|
assert!(help.contains("--stats <STATS>"));
|
||||||
assert!(help.contains("--sfill <STRING>"));
|
assert!(help.contains("--sfill <STRING>"));
|
||||||
assert!(help.contains("--dscp <0..=63>"));
|
assert!(help.contains("--dscp <0..=63>"));
|
||||||
|
assert!(help.contains("--ttl <1..=255>"));
|
||||||
assert!(help.contains("--loose"));
|
assert!(help.contains("--loose"));
|
||||||
assert!(help.contains("DSCP codepoint"));
|
assert!(help.contains("DSCP codepoint"));
|
||||||
}
|
}
|
||||||
@@ -892,6 +921,10 @@ mod tests {
|
|||||||
&["--dscp", "64", "127.0.0.1:2112"],
|
&["--dscp", "64", "127.0.0.1:2112"],
|
||||||
&["--dscp", "-1", "127.0.0.1:2112"],
|
&["--dscp", "-1", "127.0.0.1:2112"],
|
||||||
&["--dscp", "abc", "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", "-1", "127.0.0.1:2112"],
|
||||||
&["--length", "65508", "127.0.0.1:2112"],
|
&["--length", "65508", "127.0.0.1:2112"],
|
||||||
] {
|
] {
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ mod negotiation;
|
|||||||
mod no_test;
|
mod no_test;
|
||||||
mod open;
|
mod open;
|
||||||
mod probes_replies;
|
mod probes_replies;
|
||||||
|
mod ttl;
|
||||||
@@ -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 { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ use irtt_proto::{Clock, ReceivedStats, StampAt};
|
|||||||
|
|
||||||
pub(crate) const DEFAULT_PORT: u16 = 2112;
|
pub(crate) const DEFAULT_PORT: u16 = 2112;
|
||||||
pub const MAX_DSCP_CODEPOINT: u8 = 63;
|
pub const MAX_DSCP_CODEPOINT: u8 = 63;
|
||||||
|
pub const MAX_TTL: u32 = 255;
|
||||||
pub const MAX_SERVER_FILL_BYTES: usize = 32;
|
pub const MAX_SERVER_FILL_BYTES: usize = 32;
|
||||||
pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3);
|
pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3);
|
||||||
pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
|
pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ mod timing;
|
|||||||
pub use client::Client;
|
pub use client::Client;
|
||||||
pub use config::{
|
pub use config::{
|
||||||
ClientConfig, NegotiationPolicy, RecvBudget, RunMode, SocketConfig, MAX_DSCP_CODEPOINT,
|
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 error::{ClientError, EventSubscriptionError};
|
||||||
pub use event::{
|
pub use event::{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use socket2::{Domain, Protocol, Socket, Type};
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::{ClientConfig, SocketConfig, DEFAULT_PORT, MIN_OPEN_TIMEOUT},
|
config::{ClientConfig, SocketConfig, DEFAULT_PORT, MIN_OPEN_TIMEOUT},
|
||||||
error::ClientError,
|
error::ClientError,
|
||||||
|
socket_options::apply_ttl_to_socket,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) fn validate_open_timeouts(timeouts: &[Duration]) -> Result<(), ClientError> {
|
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() {
|
if config.ipv6_only && remote.is_ipv6() {
|
||||||
socket.set_only_v6(true)?;
|
socket.set_only_v6(true)?;
|
||||||
}
|
}
|
||||||
if let Some(ttl) = config.ttl {
|
|
||||||
socket.set_ttl(ttl)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let bind_addr = config.bind_addr.unwrap_or_else(|| {
|
let bind_addr = config.bind_addr.unwrap_or_else(|| {
|
||||||
if remote.is_ipv4() {
|
if remote.is_ipv4() {
|
||||||
SocketAddr::from(([0, 0, 0, 0], 0))
|
SocketAddr::from(([0, 0, 0, 0], 0))
|
||||||
@@ -89,6 +86,9 @@ pub(crate) fn connect_udp_socket(
|
|||||||
socket.connect(&remote.into())?;
|
socket.connect(&remote.into())?;
|
||||||
|
|
||||||
let socket: UdpSocket = socket.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)?;
|
socket.set_read_timeout(config.recv_timeout)?;
|
||||||
Ok(socket)
|
Ok(socket)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ use std::{
|
|||||||
net::{SocketAddr, UdpSocket},
|
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<u32, ClientError> {
|
pub(crate) fn dscp_codepoint_to_traffic_class(dscp: u8) -> Result<u32, ClientError> {
|
||||||
if dscp > MAX_DSCP_CODEPOINT {
|
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")
|
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)]
|
#[cfg(test)]
|
||||||
pub(crate) fn socket_traffic_class(
|
pub(crate) fn socket_traffic_class(
|
||||||
socket: &UdpSocket,
|
socket: &UdpSocket,
|
||||||
@@ -42,6 +67,32 @@ pub(crate) fn socket_traffic_class(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn socket_ttl(socket: &UdpSocket, remote: SocketAddr) -> Result<u32, ClientError> {
|
||||||
|
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<u32> {
|
||||||
|
if remote.is_ipv4() {
|
||||||
|
socket2::SockRef::from(socket).ttl()
|
||||||
|
} else {
|
||||||
|
socket2::SockRef::from(socket).unicast_hops_v6()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_socket_traffic_class(
|
fn set_socket_traffic_class(
|
||||||
socket: &UdpSocket,
|
socket: &UdpSocket,
|
||||||
remote: SocketAddr,
|
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]
|
#[test]
|
||||||
#[cfg(not(any(
|
#[cfg(not(any(
|
||||||
target_os = "fuchsia",
|
target_os = "fuchsia",
|
||||||
|
|||||||
@@ -392,6 +392,27 @@ fn backend_basic_open_echo_close() {
|
|||||||
client.close(ClientTimestamp::now()).unwrap();
|
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]
|
#[test]
|
||||||
fn dscp_configured_open_close_smoke() {
|
fn dscp_configured_open_close_smoke() {
|
||||||
let mut params = default_params();
|
let mut params = default_params();
|
||||||
|
|||||||
Reference in new issue
Block a user