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:
Nils Andreas Svee committed 2026-05-05 00:45:56 +07:00
1 parent 5132e0e247
commit 8c8a679533
8 files changed
+204 -7

No files matched your search

@@ -17,3 +17,4 @@ mod negotiation;
mod no_test;
mod open;
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 { .. })
));
}
}
+1
View File
@@ -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);
+1 -1
View File
@@ -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::{
+4 -4
View File
@@ -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)
}
+93 -1
View File
@@ -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<u32, ClientError> {
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<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(
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",
@@ -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(), &params);
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();