Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- **`--dscp` flag** — set DSCP/TOS marking on TCP and UDP client sockets for QoS policy testing. Accepts numeric values (0-255) or standard DSCP names (EF, AF11-AF43, CS0-CS7). QUIC warns and ignores the flag; non-Unix platforms warn instead of applying socket marking.
- **`omit_secs` config support** (issue #43) — `[client] omit_secs = N` in config file sets default `--omit` value.

## [0.9.5] - 2026-03-17

### Added
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ no_tui = false
theme = "default" # or dracula, catppuccin, nord, matrix, etc.
timestamp_format = "relative" # or "iso8601", "unix"
address_family = "dual" # "ipv4", "ipv6", or "dual"
omit_secs = 0 # omit first N seconds (TCP ramp-up)
psk = "my-secret-key"
log_file = "~/.config/xfr/xfr.log"
log_level = "info"
Expand All @@ -398,6 +399,8 @@ log_file = "~/.config/xfr/xfr-server.log"
log_level = "info"
```

`[client] omit_secs` sets the default for `--omit`. An explicit CLI `--omit` value, including `--omit 0`, takes precedence over the config file.

Environment variables override config file:

```bash
Expand Down Expand Up @@ -438,6 +441,7 @@ See `examples/grafana-dashboard.json` for a sample Grafana dashboard.
| `--ipv6` | `-6` | false | Force IPv6 only |
| `--bind` | | none | Local address to bind (e.g., 192.168.1.100) |
| `--cport` | | none | Client source port for firewall traversal (UDP/QUIC/TCP data streams) |
| `--dscp` | | none | DSCP/TOS marking for TCP/UDP QoS testing (0-255 or name: EF, AF11, CS1, etc.) |
| `--mptcp` | | false | MPTCP mode (client-only, Linux 5.6+; server auto-enables) |
| `--random` | | true | Use random payload data for client-sent TCP/UDP traffic (default) |
| `--zeros` | | false | Use zero-filled payload data (client-sent traffic only) |
Expand Down Expand Up @@ -473,6 +477,8 @@ See `examples/grafana-dashboard.json` for a sample Grafana dashboard.

TCP and UDP tests use random payloads by default to avoid inflated results on WAN-optimized or compressing paths. `--random` and `--zeros` control client-sent traffic. Server-sent TCP/UDP traffic also defaults to random, but payload mode is not negotiated over the wire.

`--dscp` applies to TCP and UDP client sockets. QUIC ignores it because the underlying socket is managed by Quinn, and non-Unix platforms currently warn instead of applying socket marking.

## Security Considerations

### Transport Encryption
Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@
- [ ] **Suppress UDP/QUIC send errors on graceful shutdown** - UDP shows `Connection refused (os error 111)`, QUIC shows `sending stopped by peer: error 0` when server tears down sockets before client finishes sending; cosmetic but noisy
- [ ] **Summary on early exit** (issue #35) - catch SIGINT/SIGTERM, trigger cancel path, and display test summary with accumulated stats instead of silently exiting. Server-side disconnect errors on client kill are a separate cleanup
- [x] **Delta retransmits in plain-text interval output** (issue #36) - plain-text interval reports now show retransmit deltas instead of cumulative totals while the final summary remains cumulative. TUI stats remain cumulative by design, and server-reported per-stream interval deltas continue in JSON/CSV output
- [ ] **`omit_secs` in config file** (issue #43) — add `omit_secs` to `[client]` config so `--omit` can be set as a default
- [x] **`omit_secs` in config file** (issue #43) — `[client] omit_secs` in config.toml sets default `--omit` value
- [ ] **Group CLI help by client/server** (issue #43) — restructure `--help` output into client-only, server-only, and shared sections like iperf3

### Code Quality
Expand Down Expand Up @@ -175,7 +175,7 @@
- [x] **Random payload data** (`--random`) - fill send buffers with random bytes to defeat WAN optimizer/compression/dedup bias (issue #34). Both client and server TCP/UDP; QUIC skipped (already encrypted). Fill-once per buffer, no per-write overhead. `--zeros` only affects client-sent traffic; server payload mode not yet negotiated over wire
- [ ] **Configurable UDP packet size** (`--packet-size`) - set UDP datagram size for jumbo frame validation and MTU path testing; iperf3 `--set-mss` is TCP-only (issue esnet/iperf#861)
- [ ] **Get server output** (`--get-server-output`) - return server's JSON result to client (iperf3 parity)
- [ ] **DSCP/TOS marking** (`--dscp`) - set IP_TOS on sockets for QoS policy testing; single `setsockopt` call, same pattern as `--congestion`. iperf3 has `-S`
- [x] **DSCP/TOS marking** (`--dscp`) - set DSCP/TOS on client TCP/UDP sockets for QoS policy testing; QUIC ignores it. Accepts numeric (0-255) or DSCP names (EF, AF11-AF43, CS0-CS7)
- [ ] **TCP Fast Open** (`--fast-open`) - reduce handshake latency for short tests; `setsockopt(TCP_FASTOPEN)` on server, `MSG_FASTOPEN` on client connect
- [ ] **CC algorithm A/B comparison** (`xfr cca-compare <host>`) - run back-to-back tests with different congestion control algorithms (BBR, CUBIC, Reno, etc.) and produce side-by-side comparison (throughput, retransmits, RTT). Leverages existing `--congestion` and `xfr diff`. BBR vs CUBIC is one of the most common network testing questions
- [ ] **Server-side bandwidth caps** (`--max-bandwidth`) - per-test server-enforced bandwidth limit to prevent abuse of public servers. iperf3 issue #937 is highly requested. Distinct from `--rate-limit` (which limits concurrent tests per IP)
Expand Down
35 changes: 29 additions & 6 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,22 +198,40 @@ xfr <host> --bind 10.0.0.1:0 # IP with auto-assigned port
xfr <host> --bind [::1] # IPv6 address
```

**Note:** TCP mode does not support explicit port binding (use IP only).
**Note:** Plain `--bind IP:PORT` is still not supported for TCP control sockets. Use `--bind IP` for source-IP selection, or combine `--bind IP --cport PORT` to pin TCP data-stream source ports.

## Client Source Port (`--cport`)

Pin the client's local source port for firewall traversal (UDP and QUIC only):
Pin the client's local source port for firewall traversal:

```bash
xfr <host> --cport 5300 # TCP data stream with source port 5300
xfr <host> --cport 5300 -P 4 # 4 TCP data streams on ports 5300-5303
xfr <host> -u --cport 5300 # UDP with source port 5300
xfr <host> -u --cport 5300 -P 4 # 4 UDP streams on ports 5300-5303
xfr <host> --quic --cport 5300 # QUIC with source port 5300
xfr <host> --bind 10.0.0.1 --cport 5300 # Combine with --bind for IP + port
```

Multi-stream UDP assigns sequential ports starting from the specified port. QUIC multiplexes all streams on a single port, so only one port is needed regardless of `-P`.
Multi-stream TCP and UDP assign sequential ports starting from the specified port. QUIC multiplexes all streams on a single port, so only one port is needed regardless of `-P`.

**Not supported with TCP** — TCP already uses single-port mode (all connections go through port 5201), so source port pinning is unnecessary. Stateful firewalls handle TCP ephemeral source ports automatically.
For TCP, `--cport` only applies to data streams. The control connection still uses an ephemeral source port, and xfr fails fast if that ephemeral port overlaps the requested TCP data-port range.

## DSCP / TOS Marking (`--dscp`)

Mark client TCP or UDP sockets for QoS / DSCP testing:

```bash
xfr <host> --dscp EF # Expedited Forwarding (46 << 2)
xfr <host> -u --dscp AF31 # Assured Forwarding class 3 drop precedence 1
xfr <host> --dscp 184 # Raw TOS byte value
```

`--dscp` accepts either a raw TOS byte (`0-255`) or DSCP names such as `EF`, `AF11-AF43`, `CS0-CS7`, and `VA`.

- TCP and UDP apply the marking on client sockets after connect.
- QUIC ignores `--dscp` because the underlying UDP socket is owned by Quinn.
- On non-Unix platforms, xfr currently warns instead of applying socket marking.

## Dual-Stack and IPv6 Support

Expand Down Expand Up @@ -288,6 +306,7 @@ no_tui = false
theme = "default"
timestamp_format = "relative" # relative, iso8601, unix
address_family = "dual" # ipv4, ipv6, dual
omit_secs = 0 # omit first N seconds (TCP ramp-up)
psk = "my-secret-key"
log_file = "~/.config/xfr/xfr.log"
log_level = "info"
Expand All @@ -308,6 +327,8 @@ log_file = "~/.config/xfr/xfr-server.log"
log_level = "info"
```

`[client] omit_secs` sets the default for `--omit`. An explicit CLI `--omit` value, including `--omit 0`, overrides the config file.

### Environment Variables

Environment variables override config file settings:
Expand Down Expand Up @@ -524,9 +545,11 @@ See `xfr --help` for complete CLI documentation.
| `--ipv4` | `-4` | false | Force IPv4 only |
| `--ipv6` | `-6` | false | Force IPv6 only |
| `--bind` | | none | Local address to bind (IP or IP:port) |
| `--cport` | | none | Client source port for firewall traversal (UDP/QUIC only) |
| `--cport` | | none | Client source port for firewall traversal (UDP/QUIC/TCP data streams) |
| `--dscp` | | none | DSCP/TOS marking for TCP/UDP QoS testing (0-255 or name: EF, AF11, CS1, etc.) |
| `--mptcp` | | false | MPTCP mode (Multi-Path TCP, Linux 5.6+) |
| `--random` | | false | Use random client payload bytes (TCP/UDP upload; ignored for QUIC, partial in reverse/bidir) |
| `--random` | | true | Use random payload data for client-sent TCP/UDP traffic (default) |
| `--zeros` | | false | Use zero-filled payload data (client-sent traffic only) |

### Server-Specific Flags

Expand Down
3 changes: 3 additions & 0 deletions docs/xfr.1
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ Local address to bind to (e.g., 192.168.1.100 or ::1)
.BI \-\-cport " PORT"
Client source port for firewall traversal (UDP/QUIC/TCP data streams)
.TP
.BI \-\-dscp " VALUE"
Set DSCP/TOS marking on sockets for QoS policy testing. Accepts numeric values (0\-255) or standard DSCP names: EF, AF11\-AF43, CS0\-CS7, VA.
.TP
.B \-\-mptcp
Use MPTCP (Multi\-Path TCP, Linux 5.6+)
.TP
Expand Down
3 changes: 3 additions & 0 deletions examples/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ json_output = false
# Disable TUI by default
no_tui = false

# Omit first N seconds from interval output (TCP ramp-up)
# omit_secs = 3

[server]
# Default port
port = 5201
Expand Down
19 changes: 19 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ pub struct ClientConfig {
pub mptcp: bool,
/// Use random payload data for client-sent traffic
pub random_payload: bool,
/// DSCP/TOS value for IP_TOS socket option
pub dscp: Option<u8>,
}

impl Default for ClientConfig {
Expand All @@ -111,6 +113,7 @@ impl Default for ClientConfig {
sequential_ports: false,
mptcp: false,
random_payload: true,
dscp: None,
}
}
}
Expand Down Expand Up @@ -658,6 +661,7 @@ impl Client {
let duration = self.config.duration;
let bind_addr = stream_bind_addr(base_bind_addr, self.config.sequential_ports, i);
let mptcp = self.config.mptcp;
let dscp = self.config.dscp;
let test_id = test_id.clone();
let stream_index = i as u16;
let handshake_limiter = handshake_limiter.clone();
Expand Down Expand Up @@ -690,6 +694,13 @@ impl Client {
Ok(mut stream) => {
debug!("Connected to data port {}", port);

// Set DSCP/TOS marking if requested
if let Some(tos) = dscp
&& let Err(e) = net::set_tos_on_tcp(&stream, tos)
{
warn!("Failed to set IP_TOS: {}", e);
}

// Single-port mode: send DataHello to identify stream
if single_port_mode {
let hello = ControlMessage::DataHello {
Expand Down Expand Up @@ -866,6 +877,7 @@ impl Client {
let duration = self.config.duration;
let random_payload = self.config.random_payload;
let bind_addr = stream_bind_addr(base_bind_addr, self.config.sequential_ports, i);
let dscp = self.config.dscp;

handles.push(tokio::spawn(async move {
// Create UDP socket matching the server's address family for cross-platform compatibility.
Expand Down Expand Up @@ -896,6 +908,13 @@ impl Client {

debug!("UDP connected to {}", server_port);

// Set DSCP/TOS marking if requested
if let Some(tos) = dscp
&& let Err(e) = net::set_tos_on_udp(&socket, tos)
{
warn!("Failed to set IP_TOS on UDP socket: {}", e);
}

match direction {
Direction::Upload => {
if let Err(e) = udp::send_udp_paced(
Expand Down
14 changes: 14 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ pub struct ClientDefaults {

/// Address family preference (ipv4, ipv6, dual)
pub address_family: Option<String>,

/// Omit first N seconds from interval output (TCP ramp-up)
pub omit_secs: Option<u64>,
}

/// Default settings for server mode
Expand Down Expand Up @@ -193,6 +196,17 @@ allowed_clients = ["192.168.1.0/24"]
assert_eq!(config.presets[0].bandwidth_limit, Some("100M".to_string()));
}

#[test]
fn test_parse_client_omit_secs() {
let toml = r#"
[client]
omit_secs = 3
"#;

let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.client.omit_secs, Some(3));
}

#[test]
fn test_parse_server_no_mdns() {
let toml = r#"
Expand Down
Loading