feat(app): add stdin-managed targets and per-target HMAC #136

Merged
Lochnair merged 4 commits from feature/targets-stdin into main 2026-09-01 04:06:19 +00:00
5 changed files with 421 additions and 155 deletions
Showing only changes of commit 344533f8b6 - Show all commits

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)] #[derive(Debug, Clone, Parser)]
#[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")] #[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")]
pub struct ClientArgs { 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( #[arg(
value_name = "TARGET", value_name = "TARGET",
num_args = 0.., num_args = 0..,
value_parser = parse_target, 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>, pub targets: Vec<TargetArg>,
@@ -159,8 +159,7 @@ mod tests {
let args = parse(&["127.0.0.1:2112"]).unwrap(); let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.targets.len(), 1); assert_eq!(args.targets.len(), 1);
assert_eq!(args.targets[0].label, None); assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
assert_eq!(args.pacing, GroupPacingArg::Staggered); assert_eq!(args.pacing, GroupPacingArg::Staggered);
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION); assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
assert_eq!(args.interval, Duration::from_secs(1)); assert_eq!(args.interval, Duration::from_secs(1));
@@ -254,8 +253,8 @@ mod tests {
#[test] #[test]
fn invalid_labelled_target_syntax_is_rejected() { fn invalid_labelled_target_syntax_is_rejected() {
assert!(parse(&["=127.0.0.1:2112"]).is_err()); assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
assert!(parse(&["label="]).is_err()); assert!(parse(&["label="]).unwrap().prepare().is_err());
} }
#[test] #[test]
+5 -6
View File
@@ -14,13 +14,13 @@ pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
#[derive(Debug, Clone, Parser)] #[derive(Debug, Clone, Parser)]
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")] #[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
pub struct TuiArgs { 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( #[arg(
value_name = "TARGET", value_name = "TARGET",
num_args = 1.., num_args = 1..,
required = true, required = true,
value_parser = parse_target, 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>, pub targets: Vec<TargetArg>,
@@ -107,8 +107,7 @@ mod tests {
fn tui_parser_defaults_to_continuous_and_has_no_output_option() { fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
let args = parse(&["127.0.0.1:2112"]).unwrap(); let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.targets.len(), 1); assert_eq!(args.targets.len(), 1);
assert_eq!(args.targets[0].label, None); assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
assert_eq!(args.pacing, GroupPacingArg::Staggered); assert_eq!(args.pacing, GroupPacingArg::Staggered);
assert_eq!(args.duration, DEFAULT_TUI_DURATION); assert_eq!(args.duration, DEFAULT_TUI_DURATION);
assert!(args.is_continuous()); assert!(args.is_continuous());
@@ -181,8 +180,8 @@ mod tests {
#[test] #[test]
fn invalid_labelled_target_syntax_is_rejected() { fn invalid_labelled_target_syntax_is_rejected() {
assert!(parse(&["=127.0.0.1:2112"]).is_err()); assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
assert!(parse(&["label="]).is_err()); assert!(parse(&["label="]).unwrap().prepare().is_err());
} }
#[test] #[test]
+2 -8
View File
@@ -119,17 +119,11 @@ mod tests {
} }
fn unlabeled(addr: &str) -> TargetArg { fn unlabeled(addr: &str) -> TargetArg {
TargetArg { TargetArg::new(addr)
label: None,
addr: addr.to_owned(),
}
} }
fn labeled(label: &str, addr: &str) -> TargetArg { fn labeled(label: &str, addr: &str) -> TargetArg {
TargetArg { TargetArg::new(format!("{label}={addr}"))
label: Some(label.to_owned()),
addr: addr.to_owned(),
}
} }
fn prepare( fn prepare(
+395 -130
View File
@@ -1,26 +1,99 @@
use std::collections::HashSet; use std::{collections::HashSet, fmt};
use clap::ValueEnum; 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 struct TargetArg {
pub label: Option<String>, input: String,
pub addr: 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 struct TargetSpec {
pub label: String, pub label: String,
pub addr: 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 struct PreparedTarget {
pub label: String, pub label: String,
pub managed: ManagedTargetConfig, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum GroupPacingArg { pub enum GroupPacingArg {
Staggered, Staggered,
@@ -36,191 +109,383 @@ impl From<GroupPacingArg> for ManagedPacing {
} }
} }
/// Parse one positional target argument, as `TARGET` or `LABEL=TARGET`. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub fn parse_target(input: &str) -> Result<TargetArg, String> { enum TargetParseError {
match input.split_once('=') { EmptyLabel,
None => Ok(TargetArg { EmptyAddress,
label: None, InvalidEscape,
addr: input.to_owned(), EmptyTarget,
}), }
Some((label, addr)) => {
if label.is_empty() { impl fmt::Display for TargetParseError {
return Err("target label must not be empty".to_owned()); fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
} let message = match self {
if addr.is_empty() { Self::EmptyLabel => "target label must not be empty",
return Err("target address must not be empty".to_owned()); Self::EmptyAddress => "target address must not be empty",
} Self::InvalidEscape => "invalid target escape sequence",
Ok(TargetArg { Self::EmptyTarget => "target must not be empty",
label: Some(label.to_owned()), };
addr: addr.to_owned(), 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 specs = Vec::with_capacity(targets.len());
let mut unlabeled_counts = std::collections::HashMap::<&str, usize>::new(); let mut unlabeled_counts = std::collections::HashMap::<String, usize>::new();
for target in targets { for (index, target) in targets.iter().enumerate() {
let label = match &target.label { let (explicit_label, addr, auth) = parse_target_syntax(&target.input)
Some(label) => label.clone(), .map_err(|error| format!("invalid target {}: {error}", index + 1))?;
let label = match explicit_label {
Some(label) => label,
None => { None => {
let count = unlabeled_counts.entry(target.addr.as_str()).or_default(); let count = unlabeled_counts.entry(addr.clone()).or_default();
*count += 1; *count += 1;
if *count == 1 { if *count == 1 {
target.addr.clone() addr.clone()
} else { } else {
format!("{}#{}", target.addr, *count) format!("{}#{}", addr, *count)
} }
} }
}; };
specs.push(TargetSpec { specs.push(TargetSpec { label, addr, auth });
label,
addr: target.addr.clone(),
});
} }
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()); return Err("at least one target is required unless --list-columns is set".to_owned());
} }
let mut labels = HashSet::new(); let mut labels = HashSet::new();
for spec in &specs { for spec in &specs {
if !labels.insert(spec.label.clone()) { if !labels.insert(spec.label.clone()) {
return Err(format!("duplicate target label {:?}", spec.label)); return Err("duplicate target label".to_owned());
} }
} }
Ok(specs) 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> { pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
let mut targets = Vec::with_capacity(specs.len()); let mut targets = Vec::with_capacity(specs.len());
for spec in specs { 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 { targets.push(PreparedTarget {
label: spec.label.clone(), label: spec.label,
managed: ManagedTargetConfig::new(TargetId::from(spec.label), spec.addr), managed,
}); });
} }
Ok(targets) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn unlabeled(addr: &str) -> TargetArg { fn target(input: &str) -> TargetArg {
TargetArg { TargetArg::new(input)
label: None,
addr: addr.to_owned(),
}
} }
fn labeled(label: &str, addr: &str) -> TargetArg { fn specs(inputs: &[&str]) -> Result<Vec<TargetSpec>, String> {
TargetArg { target_specs(&inputs.iter().map(|input| target(input)).collect::<Vec<_>>())
label: Some(label.to_owned()),
addr: addr.to_owned(),
}
} }
#[test] #[test]
fn parse_target_without_equals_is_unlabeled() { fn legacy_target_syntax_and_ipv6_remain_supported() {
let target = parse_target("host.example").unwrap(); let parsed = specs(&[
assert_eq!(target.label, None); "host.example",
assert_eq!(target.addr, "host.example"); "host.example:2112",
"[::1]:2112",
let target = parse_target("host.example:2112").unwrap(); "eu=host.example",
assert_eq!(target.label, None); ])
assert_eq!(target.addr, "host.example:2112"); .unwrap();
assert_eq!(parsed[0].label, "host.example");
let target = parse_target("[::1]:2112").unwrap(); assert_eq!(parsed[1].label, "host.example:2112");
assert_eq!(target.label, None); assert_eq!(parsed[2].label, "[::1]:2112");
assert_eq!(target.addr, "[::1]:2112"); assert_eq!(parsed[3].label, "eu");
assert!(parsed
.iter()
.all(|target| matches!(target.auth, TargetAuth::Inherit)));
} }
#[test] #[test]
fn parse_target_with_equals_splits_on_first() { fn target_hmac_supports_inherit_override_disable_and_base64_padding() {
let target = parse_target("eu=host.example").unwrap(); let parsed = specs(&[
assert_eq!(target.label, Some("eu".to_owned())); "default=default.example",
assert_eq!(target.addr, "host.example"); "private=private.example@hmac=YWJjZA==",
"public=public.example@hmac=",
let target = parse_target("eu=host.example:2112").unwrap(); ])
assert_eq!(target.label, Some("eu".to_owned())); .unwrap();
assert_eq!(target.addr, "host.example:2112"); 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] #[test]
fn parse_target_rejects_empty_label_or_address() { fn target_delimiters_are_escaped_contextually() {
assert!(parse_target("=host.example").is_err()); let parsed = specs(&["eu\\=west=host\\@hmac=literal@hmac=a\\@b\\,c\\=d\\\\e"]).unwrap();
assert!(parse_target("eu=").is_err()); 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] #[test]
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() { fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
let targets = vec![unlabeled("host-a:2112"), unlabeled("host-a:2112")]; let parsed = specs(&["host-a:2112", "host-a:2112"]).unwrap();
let specs = target_specs(&targets).unwrap(); assert_eq!(parsed[0].label, "host-a:2112");
assert_eq!(parsed[1].label, "host-a:2112#2");
assert_eq!(specs[0].label, "host-a:2112"); assert_eq!(
assert_eq!(specs[1].label, "host-a:2112#2"); specs(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap_err(),
"duplicate target label"
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"));
} }
#[test] #[test]
fn target_specs_preserve_argument_order_with_mixed_labels() { fn target_specs_preserve_argument_order_and_unlabeled_counts() {
let targets = vec![ let parsed = specs(&["local", "eu=eu.example", "foo", "us=us.example", "foo"]).unwrap();
unlabeled("local"), let labels: Vec<_> = parsed.iter().map(|target| target.label.as_str()).collect();
labeled("eu", "eu.example"), assert_eq!(labels, ["local", "eu", "foo", "us", "foo#2"]);
unlabeled("backup"), let addresses: Vec<_> = parsed.iter().map(|target| target.addr.as_str()).collect();
labeled("us", "us.example"), assert_eq!(
]; addresses,
let specs = target_specs(&targets).unwrap(); ["local", "eu.example", "foo", "us.example", "foo"]
);
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"]);
} }
#[test] #[test]
fn target_specs_reject_generated_suffix_collisions() { fn target_specs_reject_generated_suffix_collisions() {
let targets = vec![ assert_eq!(
unlabeled("host.example"), specs(&[
unlabeled("host.example"), "host.example",
labeled("host.example#2", "other.example"), "host.example",
]; "host.example#2=other.example"
let err = target_specs(&targets).unwrap_err(); ])
assert!(err.contains("duplicate target label")); .unwrap_err(),
"duplicate target label"
);
} }
#[test] #[test]
fn target_preparation_preserves_hostname_and_allows_duplicate_endpoints() { fn malformed_target_syntax_is_rejected_without_echoing_input() {
let specs = vec![ for input in ["=host", "label=", "host@hmac=value\\q", "host\\x"] {
TargetSpec { assert!(specs(&[input]).is_err(), "{input}");
label: "a".to_owned(), }
addr: "example.test".to_owned(), }
},
TargetSpec { #[test]
label: "b".to_owned(), fn target_preparation_maps_auth_to_managed_config() {
addr: "example.test".to_owned(), let prepared =
}, prepare_managed_targets(specs(&["a=one@hmac=key", "b=two@hmac="]).unwrap()).unwrap();
]; assert_eq!(
let targets = prepare_managed_targets(specs).unwrap(); prepared[0]
assert_eq!(targets[0].managed.server_addr, "example.test"); .managed
assert_eq!(targets[1].managed.server_addr, "example.test"); .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 ## SYNOPSIS
`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET*]... `irtt-client` [*OPTIONS*] [*[LABEL=]TARGET[@hmac=KEY]*]...
`irtt-client` `--list-columns` `irtt-client` `--list-columns`
@@ -18,17 +18,26 @@ eligible runs.
## TARGETS ## TARGETS
Every target argument accepts `[LABEL=]TARGET`. An optional `LABEL=` prefix Every target argument accepts `[LABEL=]TARGET[@hmac=KEY]`. An optional
assigns the logical target name used in output; without it, the target `LABEL=` prefix assigns the logical target name used in output; without it,
string itself is used as the label. Labeled and unlabeled targets can be the target string itself is used as the label. Labeled and unlabeled targets
freely mixed in one argument list. 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 ```sh
irtt-client host-a:2112 irtt-client host-a:2112
irtt-client eu=host-a:2112 us=host-b:2112 irtt-client eu=host-a:2112 us=host-b:2112
irtt-client host-a:2112 eu=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 Multiple targets are probed concurrently. By default, a multi-target session
uses **staggered** pacing, spacing active targets across the probe interval; uses **staggered** pacing, spacing active targets across the probe interval;
pass `--pacing burst` to send one probe to every active target back-to-back pass `--pacing burst` to send one probe to every active target back-to-back