feat(app): parse target-level HMAC authentication

This commit is contained in:
Nils Andreas Svee committed 2026-09-01 01:49:48 +07:00
1 parent e8b3f64292
commit 344533f8b6
5 files changed
+421 -155

No files matched your search

+5 -6
View File
@@ -11,12 +11,12 @@ pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")]
pub struct ClientArgs {
/// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
#[arg(
value_name = "TARGET",
num_args = 0..,
value_parser = parse_target,
long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client host-a:2112 eu=host-b:2112"
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client ams=host-a:2112@hmac=secret public=host-b:2112@hmac=\n\nA target without @hmac= inherits --hmac. @hmac= explicitly disables it for that target."
)]
pub targets: Vec<TargetArg>,
@@ -159,8 +159,7 @@ mod tests {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.targets.len(), 1);
assert_eq!(args.targets[0].label, None);
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
assert_eq!(args.pacing, GroupPacingArg::Staggered);
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
assert_eq!(args.interval, Duration::from_secs(1));
@@ -254,8 +253,8 @@ mod tests {
#[test]
fn invalid_labelled_target_syntax_is_rejected() {
assert!(parse(&["=127.0.0.1:2112"]).is_err());
assert!(parse(&["label="]).is_err());
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
assert!(parse(&["label="]).unwrap().prepare().is_err());
}
#[test]
+5 -6
View File
@@ -14,13 +14,13 @@ pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
pub struct TuiArgs {
/// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
#[arg(
value_name = "TARGET",
num_args = 1..,
required = true,
value_parser = parse_target,
long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example us=host-b.example"
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table. A target without @hmac= inherits --hmac; @hmac= disables it for that target.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example@hmac=secret us=host-b.example"
)]
pub targets: Vec<TargetArg>,
@@ -107,8 +107,7 @@ mod tests {
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.targets.len(), 1);
assert_eq!(args.targets[0].label, None);
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
assert_eq!(args.pacing, GroupPacingArg::Staggered);
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
assert!(args.is_continuous());
@@ -181,8 +180,8 @@ mod tests {
#[test]
fn invalid_labelled_target_syntax_is_rejected() {
assert!(parse(&["=127.0.0.1:2112"]).is_err());
assert!(parse(&["label="]).is_err());
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
assert!(parse(&["label="]).unwrap().prepare().is_err());
}
#[test]
+2 -8
View File
@@ -119,17 +119,11 @@ mod tests {
}
fn unlabeled(addr: &str) -> TargetArg {
TargetArg {
label: None,
addr: addr.to_owned(),
}
TargetArg::new(addr)
}
fn labeled(label: &str, addr: &str) -> TargetArg {
TargetArg {
label: Some(label.to_owned()),
addr: addr.to_owned(),
}
TargetArg::new(format!("{label}={addr}"))
}
fn prepare(
+395 -130
View File
@@ -1,26 +1,99 @@
use std::collections::HashSet;
use std::{collections::HashSet, fmt};
use clap::ValueEnum;
use irtt_client::managed::{ManagedPacing, ManagedTargetConfig, TargetId};
use irtt_client::{
managed::{ManagedPacing, ManagedTargetConfig, TargetId},
ClientAuthConfig,
};
#[derive(Debug, Clone, PartialEq, Eq)]
/// One raw positional target captured by Clap.
///
/// Parsing happens during run preparation so invalid target syntax never makes
/// Clap include the original argument (which may carry an HMAC key) in an
/// error diagnostic.
#[derive(Clone, PartialEq, Eq)]
pub struct TargetArg {
pub label: Option<String>,
pub addr: String,
input: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
impl TargetArg {
pub fn new(input: impl Into<String>) -> Self {
Self {
input: input.into(),
}
}
}
impl fmt::Debug for TargetArg {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("TargetArg(<redacted>)")
}
}
/// Capture one positional target without exposing it through Clap diagnostics.
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
Ok(TargetArg::new(input))
}
#[derive(Clone, PartialEq, Eq)]
pub enum TargetAuth {
Inherit,
Override(Vec<u8>),
Disable,
}
impl fmt::Debug for TargetAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Inherit => f.write_str("Inherit"),
Self::Override(_) => f.write_str("Override(<redacted>)"),
Self::Disable => f.write_str("Disable"),
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct TargetSpec {
pub label: String,
pub addr: String,
pub auth: TargetAuth,
}
#[derive(Debug, Clone)]
impl fmt::Debug for TargetSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TargetSpec")
.field("label", &self.label)
.field("addr", &self.addr)
.field("auth", &self.auth)
.finish()
}
}
#[derive(Clone)]
pub struct PreparedTarget {
pub label: String,
pub managed: ManagedTargetConfig,
}
impl fmt::Debug for PreparedTarget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PreparedTarget")
.field("label", &self.label)
.field("server_addr", &self.managed.server_addr)
.field(
"auth",
&match &self.managed.auth {
None => TargetAuth::Inherit,
Some(ClientAuthConfig { hmac_key: Some(_) }) => {
TargetAuth::Override(Vec::new())
}
Some(ClientAuthConfig { hmac_key: None }) => TargetAuth::Disable,
},
)
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum GroupPacingArg {
Staggered,
@@ -36,191 +109,383 @@ impl From<GroupPacingArg> for ManagedPacing {
}
}
/// Parse one positional target argument, as `TARGET` or `LABEL=TARGET`.
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
match input.split_once('=') {
None => Ok(TargetArg {
label: None,
addr: input.to_owned(),
}),
Some((label, addr)) => {
if label.is_empty() {
return Err("target label must not be empty".to_owned());
}
if addr.is_empty() {
return Err("target address must not be empty".to_owned());
}
Ok(TargetArg {
label: Some(label.to_owned()),
addr: addr.to_owned(),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TargetParseError {
EmptyLabel,
EmptyAddress,
InvalidEscape,
EmptyTarget,
}
impl fmt::Display for TargetParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::EmptyLabel => "target label must not be empty",
Self::EmptyAddress => "target address must not be empty",
Self::InvalidEscape => "invalid target escape sequence",
Self::EmptyTarget => "target must not be empty",
};
f.write_str(message)
}
}
pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
fn split_unescaped(input: &str, delimiter: char) -> Result<Vec<&str>, TargetParseError> {
let mut parts = Vec::new();
let mut start = 0;
let mut escaped = false;
for (index, ch) in input.char_indices() {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == delimiter {
parts.push(&input[start..index]);
start = index + ch.len_utf8();
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
parts.push(&input[start..]);
Ok(parts)
}
fn first_unescaped(input: &str, delimiter: char) -> Result<Option<usize>, TargetParseError> {
let mut escaped = false;
for (index, ch) in input.char_indices() {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == delimiter {
return Ok(Some(index));
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
Ok(None)
}
fn unescape(input: &str) -> Result<String, TargetParseError> {
let mut output = String::with_capacity(input.len());
let mut escaped = false;
for ch in input.chars() {
if escaped {
if !matches!(ch, '\\' | '=' | ';' | ',' | '@') {
return Err(TargetParseError::InvalidEscape);
}
output.push(ch);
escaped = false;
} else if ch == '\\' {
escaped = true;
} else {
output.push(ch);
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
Ok(output)
}
fn hmac_modifier(input: &str) -> Result<Option<usize>, TargetParseError> {
let mut escaped = false;
for (index, ch) in input.char_indices() {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if input[index..].starts_with("@hmac=") {
return Ok(Some(index));
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
Ok(None)
}
fn parse_target_syntax(
input: &str,
) -> Result<(Option<String>, String, TargetAuth), TargetParseError> {
if input.is_empty() {
return Err(TargetParseError::EmptyTarget);
}
let (main, auth) = match hmac_modifier(input)? {
Some(index) => {
let value = unescape(&input[index + "@hmac=".len()..])?;
let auth = if value.is_empty() {
TargetAuth::Disable
} else {
TargetAuth::Override(value.into_bytes())
};
(&input[..index], auth)
}
None => (input, TargetAuth::Inherit),
};
let (label, address) = match first_unescaped(main, '=')? {
None => (None, unescape(main)?),
Some(index) => {
let label = unescape(&main[..index])?;
let address = unescape(&main[index + '='.len_utf8()..])?;
(Some(label), address)
}
};
if label.as_deref().is_some_and(str::is_empty) {
return Err(TargetParseError::EmptyLabel);
}
if address.is_empty() {
return Err(TargetParseError::EmptyAddress);
}
Ok((label, address, auth))
}
pub(crate) fn target_specs_with_empty(
targets: &[TargetArg],
allow_empty: bool,
) -> Result<Vec<TargetSpec>, String> {
let mut specs = Vec::with_capacity(targets.len());
let mut unlabeled_counts = std::collections::HashMap::<&str, usize>::new();
for target in targets {
let label = match &target.label {
Some(label) => label.clone(),
let mut unlabeled_counts = std::collections::HashMap::<String, usize>::new();
for (index, target) in targets.iter().enumerate() {
let (explicit_label, addr, auth) = parse_target_syntax(&target.input)
.map_err(|error| format!("invalid target {}: {error}", index + 1))?;
let label = match explicit_label {
Some(label) => label,
None => {
let count = unlabeled_counts.entry(target.addr.as_str()).or_default();
let count = unlabeled_counts.entry(addr.clone()).or_default();
*count += 1;
if *count == 1 {
target.addr.clone()
addr.clone()
} else {
format!("{}#{}", target.addr, *count)
format!("{}#{}", addr, *count)
}
}
};
specs.push(TargetSpec {
label,
addr: target.addr.clone(),
});
specs.push(TargetSpec { label, addr, auth });
}
if specs.is_empty() {
if specs.is_empty() && !allow_empty {
return Err("at least one target is required unless --list-columns is set".to_owned());
}
let mut labels = HashSet::new();
for spec in &specs {
if !labels.insert(spec.label.clone()) {
return Err(format!("duplicate target label {:?}", spec.label));
return Err("duplicate target label".to_owned());
}
}
Ok(specs)
}
pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
target_specs_with_empty(targets, false)
}
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
let mut targets = Vec::with_capacity(specs.len());
for spec in specs {
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
managed.auth = match spec.auth {
TargetAuth::Inherit => None,
TargetAuth::Override(hmac_key) => Some(ClientAuthConfig {
hmac_key: Some(hmac_key),
}),
TargetAuth::Disable => Some(ClientAuthConfig { hmac_key: None }),
};
targets.push(PreparedTarget {
label: spec.label.clone(),
managed: ManagedTargetConfig::new(TargetId::from(spec.label), spec.addr),
label: spec.label,
managed,
});
}
Ok(targets)
}
/// Parse one complete stdin target set after its line terminator was removed.
///
/// Commas frame stdin elements only; positional target arguments do not use
/// this framing and may therefore contain raw commas.
pub fn parse_stdin_target_set(
record: &str,
maximum_targets: usize,
) -> Result<Vec<PreparedTarget>, String> {
if record == "[]" {
return Ok(Vec::new());
}
let elements = split_unescaped(record, ',').map_err(|error| error.to_string())?;
if elements.len() > maximum_targets {
return Err(format!(
"target set exceeds the {maximum_targets}-target limit"
));
}
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
prepare_managed_targets(target_specs_with_empty(&args, true)?)
}
#[cfg(test)]
mod tests {
use super::*;
fn unlabeled(addr: &str) -> TargetArg {
TargetArg {
label: None,
addr: addr.to_owned(),
}
fn target(input: &str) -> TargetArg {
TargetArg::new(input)
}
fn labeled(label: &str, addr: &str) -> TargetArg {
TargetArg {
label: Some(label.to_owned()),
addr: addr.to_owned(),
}
fn specs(inputs: &[&str]) -> Result<Vec<TargetSpec>, String> {
target_specs(&inputs.iter().map(|input| target(input)).collect::<Vec<_>>())
}
#[test]
fn parse_target_without_equals_is_unlabeled() {
let target = parse_target("host.example").unwrap();
assert_eq!(target.label, None);
assert_eq!(target.addr, "host.example");
let target = parse_target("host.example:2112").unwrap();
assert_eq!(target.label, None);
assert_eq!(target.addr, "host.example:2112");
let target = parse_target("[::1]:2112").unwrap();
assert_eq!(target.label, None);
assert_eq!(target.addr, "[::1]:2112");
fn legacy_target_syntax_and_ipv6_remain_supported() {
let parsed = specs(&[
"host.example",
"host.example:2112",
"[::1]:2112",
"eu=host.example",
])
.unwrap();
assert_eq!(parsed[0].label, "host.example");
assert_eq!(parsed[1].label, "host.example:2112");
assert_eq!(parsed[2].label, "[::1]:2112");
assert_eq!(parsed[3].label, "eu");
assert!(parsed
.iter()
.all(|target| matches!(target.auth, TargetAuth::Inherit)));
}
#[test]
fn parse_target_with_equals_splits_on_first() {
let target = parse_target("eu=host.example").unwrap();
assert_eq!(target.label, Some("eu".to_owned()));
assert_eq!(target.addr, "host.example");
let target = parse_target("eu=host.example:2112").unwrap();
assert_eq!(target.label, Some("eu".to_owned()));
assert_eq!(target.addr, "host.example:2112");
fn target_hmac_supports_inherit_override_disable_and_base64_padding() {
let parsed = specs(&[
"default=default.example",
"private=private.example@hmac=YWJjZA==",
"public=public.example@hmac=",
])
.unwrap();
assert!(matches!(parsed[0].auth, TargetAuth::Inherit));
assert_eq!(parsed[1].auth, TargetAuth::Override(b"YWJjZA==".to_vec()));
assert!(matches!(parsed[2].auth, TargetAuth::Disable));
}
#[test]
fn parse_target_rejects_empty_label_or_address() {
assert!(parse_target("=host.example").is_err());
assert!(parse_target("eu=").is_err());
fn target_delimiters_are_escaped_contextually() {
let parsed = specs(&["eu\\=west=host\\@hmac=literal@hmac=a\\@b\\,c\\=d\\\\e"]).unwrap();
assert_eq!(parsed[0].label, "eu=west");
assert_eq!(parsed[0].addr, "host@hmac=literal");
assert_eq!(parsed[0].auth, TargetAuth::Override(b"a@b,c=d\\e".to_vec()));
}
#[test]
fn positional_commas_are_not_reserved() {
let parsed = specs(&["edge=host,region@hmac=key,part"]).unwrap();
assert_eq!(parsed[0].addr, "host,region");
assert_eq!(parsed[0].auth, TargetAuth::Override(b"key,part".to_vec()));
}
#[test]
fn parser_diagnostics_and_debug_do_not_reveal_hmac_values() {
let secret = "very-secret-value";
let error = specs(&[&format!("target=host@hmac={secret}\\q")]).unwrap_err();
assert!(!error.contains(secret));
assert!(!format!("{:?}", target(&format!("target=host@hmac={secret}"))).contains(secret));
assert!(
!format!("{:?}", TargetAuth::Override(secret.as_bytes().to_vec())).contains(secret)
);
}
#[test]
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
let targets = vec![unlabeled("host-a:2112"), unlabeled("host-a:2112")];
let specs = target_specs(&targets).unwrap();
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[1].label, "host-a:2112#2");
let targets = vec![
unlabeled("host-a:2112"),
labeled("host-a:2112", "host-b:2112"),
];
let err = target_specs(&targets).unwrap_err();
assert!(err.contains("duplicate target label"));
let parsed = specs(&["host-a:2112", "host-a:2112"]).unwrap();
assert_eq!(parsed[0].label, "host-a:2112");
assert_eq!(parsed[1].label, "host-a:2112#2");
assert_eq!(
specs(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap_err(),
"duplicate target label"
);
}
#[test]
fn target_specs_preserve_argument_order_with_mixed_labels() {
let targets = vec![
unlabeled("local"),
labeled("eu", "eu.example"),
unlabeled("backup"),
labeled("us", "us.example"),
];
let specs = target_specs(&targets).unwrap();
let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect();
assert_eq!(labels, ["local", "eu", "backup", "us"]);
let addrs: Vec<_> = specs.iter().map(|spec| spec.addr.as_str()).collect();
assert_eq!(addrs, ["local", "eu.example", "backup", "us.example"]);
}
#[test]
fn target_specs_count_unlabeled_occurrences_independently_of_explicit_labels() {
let targets = vec![unlabeled("foo"), labeled("eu", "foo"), unlabeled("foo")];
let specs = target_specs(&targets).unwrap();
let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect();
assert_eq!(labels, ["foo", "eu", "foo#2"]);
fn target_specs_preserve_argument_order_and_unlabeled_counts() {
let parsed = specs(&["local", "eu=eu.example", "foo", "us=us.example", "foo"]).unwrap();
let labels: Vec<_> = parsed.iter().map(|target| target.label.as_str()).collect();
assert_eq!(labels, ["local", "eu", "foo", "us", "foo#2"]);
let addresses: Vec<_> = parsed.iter().map(|target| target.addr.as_str()).collect();
assert_eq!(
addresses,
["local", "eu.example", "foo", "us.example", "foo"]
);
}
#[test]
fn target_specs_reject_generated_suffix_collisions() {
let targets = vec![
unlabeled("host.example"),
unlabeled("host.example"),
labeled("host.example#2", "other.example"),
];
let err = target_specs(&targets).unwrap_err();
assert!(err.contains("duplicate target label"));
assert_eq!(
specs(&[
"host.example",
"host.example",
"host.example#2=other.example"
])
.unwrap_err(),
"duplicate target label"
);
}
#[test]
fn target_preparation_preserves_hostname_and_allows_duplicate_endpoints() {
let specs = vec![
TargetSpec {
label: "a".to_owned(),
addr: "example.test".to_owned(),
},
TargetSpec {
label: "b".to_owned(),
addr: "example.test".to_owned(),
},
];
let targets = prepare_managed_targets(specs).unwrap();
assert_eq!(targets[0].managed.server_addr, "example.test");
assert_eq!(targets[1].managed.server_addr, "example.test");
fn malformed_target_syntax_is_rejected_without_echoing_input() {
for input in ["=host", "label=", "host@hmac=value\\q", "host\\x"] {
assert!(specs(&[input]).is_err(), "{input}");
}
}
#[test]
fn target_preparation_maps_auth_to_managed_config() {
let prepared =
prepare_managed_targets(specs(&["a=one@hmac=key", "b=two@hmac="]).unwrap()).unwrap();
assert_eq!(
prepared[0]
.managed
.auth
.as_ref()
.unwrap()
.hmac_key
.as_deref(),
Some(b"key".as_slice())
);
assert_eq!(prepared[1].managed.auth.as_ref().unwrap().hmac_key, None);
}
#[test]
fn target_preparation_preserves_duplicate_endpoints() {
let prepared =
prepare_managed_targets(specs(&["a=example.test", "b=example.test"]).unwrap()).unwrap();
assert_eq!(prepared[0].managed.server_addr, "example.test");
assert_eq!(prepared[1].managed.server_addr, "example.test");
}
#[test]
fn stdin_target_sets_frame_commas_without_trimming_payload() {
let parsed = parse_stdin_target_set("a=one@hmac=x\\,y,b=two", 128).unwrap();
assert_eq!(
parsed[0].managed.auth.as_ref().unwrap().hmac_key.as_deref(),
Some(b"x,y".as_slice())
);
assert_eq!(parsed[1].label, "b");
assert_eq!(
parse_stdin_target_set(" []", 128).unwrap()[0]
.managed
.server_addr,
" []"
);
assert_eq!(
parse_stdin_target_set("[] ", 128).unwrap()[0]
.managed
.server_addr,
"[] "
);
assert!(parse_stdin_target_set("", 128).is_err());
assert!(parse_stdin_target_set("a=one,b=two", 1).is_err());
}
#[test]
fn stdin_empty_set_is_exactly_bracket_pair() {
assert!(parse_stdin_target_set("[]", 128).unwrap().is_empty());
}
}
+14 -5
View File
@@ -6,7 +6,7 @@ irtt-client - IRTT-compatible stream client
## SYNOPSIS
`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET*]...
`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET[@hmac=KEY]*]...
`irtt-client` `--list-columns`
@@ -18,17 +18,26 @@ eligible runs.
## TARGETS
Every target argument accepts `[LABEL=]TARGET`. An optional `LABEL=` prefix
assigns the logical target name used in output; without it, the target
string itself is used as the label. Labeled and unlabeled targets can be
freely mixed in one argument list.
Every target argument accepts `[LABEL=]TARGET[@hmac=KEY]`. An optional
`LABEL=` prefix assigns the logical target name used in output; without it,
the target string itself is used as the label. Labeled and unlabeled targets
can be freely mixed in one argument list. Prefer explicit labels for
long-running dynamic target sets: the label is the stable target identity
across replacements.
```sh
irtt-client host-a:2112
irtt-client eu=host-a:2112 us=host-b:2112
irtt-client host-a:2112 eu=host-b:2112
irtt-client --hmac default-secret eu=host-a:2112@hmac=eu-secret public=host-b:2112@hmac=
```
Targets without `@hmac=` inherit the global `--hmac` value. `@hmac=KEY`
overrides it for that target, and `@hmac=` explicitly disables HMAC for that
target. The modifier uses `@`, not a shell command separator; quote an argument
if its key needs shell quoting. A literal `@` immediately before `hmac=` in
the target portion is written as `\@`.
Multiple targets are probed concurrently. By default, a multi-target session
uses **staggered** pacing, spacing active targets across the probe interval;
pass `--pacing burst` to send one probe to every active target back-to-back