From ab241c4a2b7c1323414d68d0ab34cf507c3ec843 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Wed, 21 Oct 2026 00:20:15 +0700 Subject: [PATCH] feat(app/client): expose socket bind options --- Cargo.lock | 10 ++++ crates/irtt-app/Cargo.toml | 1 + crates/irtt-app/src/shared/client/args.rs | 54 +++++++++++++++++++-- crates/irtt-app/src/shared/client/config.rs | 7 +++ docs/irtt-client.md | 19 ++++++++ 5 files changed, 87 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93d2db5..cc459e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -251,6 +251,15 @@ dependencies = [ "clap_derive", ] +[[package]] +name = "clap-num" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "822c4000301ac390e65995c62207501e3ef800a1fc441df913a5e8e4dc374816" +dependencies = [ + "num-traits", +] + [[package]] name = "clap_builder" version = "4.6.7" @@ -819,6 +828,7 @@ version = "0.8.0" dependencies = [ "chrono", "clap", + "clap-num", "crossterm", "ctrlc", "futures-core", diff --git a/crates/irtt-app/Cargo.toml b/crates/irtt-app/Cargo.toml index d4894f4..485647f 100644 --- a/crates/irtt-app/Cargo.toml +++ b/crates/irtt-app/Cargo.toml @@ -49,6 +49,7 @@ tui =["dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary [dependencies] chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true } clap = { version = "4.6.7", features = ["derive", "env"] } +clap-num = "=1.2.0" crossterm = { version = "0.29.0", features = ["event-stream"], optional = true } futures-core = { version = "0.3.34", default-features = false, optional = true } ctrlc = { version = "3.5.2", features = ["termination"] } diff --git a/crates/irtt-app/src/shared/client/args.rs b/crates/irtt-app/src/shared/client/args.rs index 3f53acb..77de651 100644 --- a/crates/irtt-app/src/shared/client/args.rs +++ b/crates/irtt-app/src/shared/client/args.rs @@ -1,4 +1,4 @@ -use std::{fmt, time::Duration}; +use std::{fmt, net::IpAddr, net::SocketAddr, time::Duration}; use clap::ValueEnum; use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH}; @@ -70,6 +70,38 @@ pub struct CommonClientArgs { #[arg(long, value_name = "1..=255", value_parser = parse_ttl)] pub ttl: Option, + #[arg( + long = "bind-addr", + value_name = "IP[:PORT]", + value_parser = parse_bind_addr, + long_help = "Local IP address and optional UDP port to bind. \ + If no port is specified, the operating system selects \ + an ephemeral port. IPv6 addresses with an explicit \ + port must use [ADDR]:PORT syntax." + )] + pub bind_addr: Option, + + #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] + #[arg( + long = "bind-device", + value_name = "DEVICE", + help = "Bind the socket to a network device" + )] + pub bind_to_device: Option, + + #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] + #[arg( + long, + value_name = "MARK", + value_parser = clap_num::maybe_hex::, + help = "Set the outgoing socket firewall mark" + )] + pub mark: Option, + + #[cfg(target_os = "freebsd")] + #[arg(long, value_name = "FIB", help = "Set the socket routing table (FIB)")] + pub fib: Option, + /// Accept safe server restrictions during negotiation. #[arg(long)] pub loose: bool, @@ -77,7 +109,8 @@ pub struct CommonClientArgs { impl fmt::Debug for CommonClientArgs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CommonClientArgs") + let mut debug = f.debug_struct("CommonClientArgs"); + debug .field("ipv4", &self.ipv4) .field("ipv6", &self.ipv6) .field("dual_stack", &self.dual_stack) @@ -90,8 +123,14 @@ impl fmt::Debug for CommonClientArgs { .field("server_fill", &self.server_fill) .field("dscp", &self.dscp) .field("ttl", &self.ttl) - .field("loose", &self.loose) - .finish() + .field("bind_addr", &self.bind_addr); + #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] + debug + .field("bind_to_device", &self.bind_to_device) + .field("mark", &self.mark); + #[cfg(target_os = "freebsd")] + debug.field("fib", &self.fib); + debug.field("loose", &self.loose).finish() } } @@ -152,6 +191,13 @@ impl From for ReceivedStats { } } +fn parse_bind_addr(input: &str) -> Result { + input + .parse::() + .or_else(|_| input.parse::().map(|ip| SocketAddr::new(ip, 0))) + .map_err(|_| "expected an IP address, optionally with a port".to_owned()) +} + pub fn parse_duration(input: &str) -> Result { let (number, unit) = split_duration(input)?; let value: u64 = number diff --git a/crates/irtt-app/src/shared/client/config.rs b/crates/irtt-app/src/shared/client/config.rs index dd251ee..62b7532 100644 --- a/crates/irtt-app/src/shared/client/config.rs +++ b/crates/irtt-app/src/shared/client/config.rs @@ -50,6 +50,13 @@ impl CommonClientArgs { Authentication::Hmac(HmacKey::new(key.as_bytes())) }), socket: SocketConfig { + bind_addr: self.bind_addr, + #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] + bind_to_device: self.bind_to_device.clone(), + #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] + mark: self.mark, + #[cfg(target_os = "freebsd")] + fib: self.fib, ttl: self.ttl, ..SocketConfig::default() }, diff --git a/docs/irtt-client.md b/docs/irtt-client.md index 83f2764..fdf9ac8 100644 --- a/docs/irtt-client.md +++ b/docs/irtt-client.md @@ -93,6 +93,25 @@ labels; choose another alias if one collides with an explicit label. ordinary multi-target pacing and each sends its own probes, so dual-stack can double the number of probes per interval compared with one target per hostname. +## LOCAL SOCKET CONFIGURATION + +The client supports configuring its local UDP sockets using the following options: + +- `--bind-addr IP[:PORT]` binds sockets to the specified local IP address and optional port. If no port is specified, or the port is `0`, the operating system assigns an ephemeral port. IPv6 addresses with an explicit port must use `[ADDR]:PORT` syntax. The address family must match the target's resolved address family. +- `--bind-device DEVICE` (Linux, Android, Fuchsia) binds sockets to a network interface using `SO_BINDTODEVICE`. +- `--mark MARK` (Linux, Android, Fuchsia) sets the socket's firewall mark using `SO_MARK`, allowing traffic to be selected by policy routing or firewall rules. Accepts a decimal or 0x-prefixed hexadecimal unsigned 32-bit integer. +- `--fib FIB` (FreeBSD) selects a routing table using `SO_SETFIB`. The FIB is specified as a decimal unsigned 32-bit integer. + +For example, bind IPv4 probes to a specific source address and interface, with a firewall mark: + +```sh +irtt-client -4 --bind-addr 192.0.2.10 --bind-device wan0 --mark 1234 host.example +``` + +Setting a firewall mark or binding to a network device may require elevated privileges or appropriate capabilities. If the operating system rejects a socket option, socket setup fails. + +A fixed local port may cause binding conflicts when multiple target sockets are created. Use an ephemeral port unless a specific source port is required. A family-specific bind address is also incompatible with targets using the other address family. + ## DYNAMIC TARGET SETS FROM STANDARD INPUT `--targets-stdin` is available only with continuous mode (`--duration 0`).