feat(app): add stdin-managed targets and per-target HMAC #136
No files matched your search
@@ -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]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user