feat(app): add address-family selection and dual-stack targets
This commit is contained in:
16 files changed
+334
-44
No files matched your search
@@ -57,7 +57,7 @@ irtt-proto = { workspace = true, optional = true }
|
|||||||
irtt-server = { workspace = true, optional = true }
|
irtt-server = { workspace = true, optional = true }
|
||||||
irtt-stats = { workspace = true, optional = true }
|
irtt-stats = { workspace = true, optional = true }
|
||||||
ratatui = { version = "0.30.2", optional = true }
|
ratatui = { version = "0.30.2", optional = true }
|
||||||
tokio = { version = "1.53.2", default-features = false, features = ["rt", "time", "sync", "macros"], optional = true }
|
tokio = { version = "1.53.2", default-features = false, features = ["rt", "net", "time", "sync", "macros"], optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
irtt-server.workspace = true
|
irtt-server.workspace = true
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ impl ClientArgs {
|
|||||||
///
|
///
|
||||||
/// Targets are only required here. `--list-columns` returns before this is
|
/// Targets are only required here. `--list-columns` returns before this is
|
||||||
/// called, so listing columns still needs no target.
|
/// called, so listing columns still needs no target.
|
||||||
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||||
if self.targets_stdin && !self.is_continuous() {
|
if self.targets_stdin && !self.is_continuous() {
|
||||||
return Err("--targets-stdin requires --duration 0".to_owned());
|
return Err("--targets-stdin requires --duration 0".to_owned());
|
||||||
}
|
}
|
||||||
@@ -105,6 +105,7 @@ impl ClientArgs {
|
|||||||
stdin_controlled: self.targets_stdin,
|
stdin_controlled: self.targets_stdin,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_continuous(&self) -> bool {
|
pub fn is_continuous(&self) -> bool {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::{
|
|||||||
use irtt_client::{
|
use irtt_client::{
|
||||||
managed::{
|
managed::{
|
||||||
ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent, ManagedStatus,
|
ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent, ManagedStatus,
|
||||||
ManagedTargetConfig, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
|
ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
|
||||||
},
|
},
|
||||||
ClientEvent,
|
ClientEvent,
|
||||||
};
|
};
|
||||||
@@ -22,7 +22,7 @@ use crate::shared::client::{
|
|||||||
request_managed_stop_once, should_print_final_summary,
|
request_managed_stop_once, should_print_final_summary,
|
||||||
},
|
},
|
||||||
worker::ManagedWorker,
|
worker::ManagedWorker,
|
||||||
ManagedRunSetup, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
|
ManagedRunSetup, TargetSpec, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||||
};
|
};
|
||||||
|
|
||||||
use irtt_stats::{EventStatsUpdate, StatsCollector, StatsConfig};
|
use irtt_stats::{EventStatsUpdate, StatsCollector, StatsConfig};
|
||||||
@@ -46,9 +46,10 @@ pub async fn run_stream(
|
|||||||
print!("{}", OutputConfig::list_columns());
|
print!("{}", OutputConfig::list_columns());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let setup = args
|
let setup = tokio::select! {
|
||||||
.prepare()
|
result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?,
|
||||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
_ = shutdown.wait_for(|requested| *requested) => return Ok(()),
|
||||||
|
};
|
||||||
let multi_target = setup.is_multi_target();
|
let multi_target = setup.is_multi_target();
|
||||||
let output_config = OutputConfig::new(
|
let output_config = OutputConfig::new(
|
||||||
args.format,
|
args.format,
|
||||||
@@ -191,7 +192,7 @@ pub async fn run_stream(
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
enum StdinUpdate {
|
enum StdinUpdate {
|
||||||
Targets(Vec<ManagedTargetConfig>),
|
Targets(Vec<TargetSpec>),
|
||||||
Stop(StdinStop),
|
Stop(StdinStop),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,9 +287,7 @@ fn read_stdin_target_sets<R: BufRead>(
|
|||||||
}
|
}
|
||||||
match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) {
|
match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) {
|
||||||
Ok(targets) => {
|
Ok(targets) => {
|
||||||
updates.send_replace(StdinUpdate::Targets(
|
updates.send_replace(StdinUpdate::Targets(targets));
|
||||||
targets.into_iter().map(|target| target.managed).collect(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!(
|
updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!(
|
||||||
@@ -449,6 +448,9 @@ async fn run_stdin_stream(
|
|||||||
let mut stats = BTreeMap::new();
|
let mut stats = BTreeMap::new();
|
||||||
let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT);
|
let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT);
|
||||||
let mut stdin_changed = false;
|
let mut stdin_changed = false;
|
||||||
|
let mut preparation = setup.preparation.clone();
|
||||||
|
let mut desired_specs = None;
|
||||||
|
let mut preparing = None;
|
||||||
let mut desired = None;
|
let mut desired = None;
|
||||||
let mut pending = None;
|
let mut pending = None;
|
||||||
let mut submitted = Vec::new();
|
let mut submitted = Vec::new();
|
||||||
@@ -465,13 +467,25 @@ async fn run_stdin_stream(
|
|||||||
} else if stdin_changed || stdin.has_changed().unwrap_or(true) {
|
} else if stdin_changed || stdin.has_changed().unwrap_or(true) {
|
||||||
stdin_changed = false;
|
stdin_changed = false;
|
||||||
match stdin.borrow_and_update().clone() {
|
match stdin.borrow_and_update().clone() {
|
||||||
StdinUpdate::Targets(targets) => {
|
StdinUpdate::Targets(specs) => {
|
||||||
desired = Some(targets);
|
desired_specs = Some(specs);
|
||||||
|
desired = None;
|
||||||
retry_after = None;
|
retry_after = None;
|
||||||
}
|
}
|
||||||
StdinUpdate::Stop(reason) => stop = Some(reason),
|
StdinUpdate::Stop(reason) => stop = Some(reason),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if stop.is_none() && preparing.is_none() {
|
||||||
|
if let Some(specs) = desired_specs.take() {
|
||||||
|
// Only one preparation runs at a time. Newer records supersede
|
||||||
|
// its result without piling up uncancellable system DNS jobs.
|
||||||
|
preparing = Some(Box::pin(
|
||||||
|
preparation
|
||||||
|
.clone()
|
||||||
|
.prepare(specs, Some(STDIN_MAX_DESIRED_TARGETS)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
if stop.is_none()
|
if stop.is_none()
|
||||||
&& pending.is_none()
|
&& pending.is_none()
|
||||||
&& !retry_after
|
&& !retry_after
|
||||||
@@ -494,6 +508,7 @@ async fn run_stdin_stream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if stop.is_some() {
|
if stop.is_some() {
|
||||||
|
preparing = None;
|
||||||
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &snapshot));
|
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &snapshot));
|
||||||
if request_managed_stop_once(&mut stop_requested) {
|
if request_managed_stop_once(&mut stop_requested) {
|
||||||
drop(handle.stop());
|
drop(handle.stop());
|
||||||
@@ -508,6 +523,25 @@ async fn run_stdin_stream(
|
|||||||
_ = shutdown.changed(), if stop.is_none() => {}
|
_ = shutdown.changed(), if stop.is_none() => {}
|
||||||
_ = stdin.changed(), if stop.is_none() => { stdin_changed = true; }
|
_ = stdin.changed(), if stop.is_none() => { stdin_changed = true; }
|
||||||
changed = status.changed() => { if changed.is_err() { break; } }
|
changed = status.changed() => { if changed.is_err() { break; } }
|
||||||
|
result = async { preparing.as_mut().expect("guarded target preparation").await }, if preparing.is_some() && stop.is_none() => {
|
||||||
|
preparing = None;
|
||||||
|
// Let the next loop consume any newer record, including EOF,
|
||||||
|
// before considering this prepared revision for submission.
|
||||||
|
if desired_specs.is_none() && !stdin.has_changed().unwrap_or(true) {
|
||||||
|
match result {
|
||||||
|
Ok((targets, next_preparation)) => {
|
||||||
|
preparation = next_preparation;
|
||||||
|
desired = Some(targets.into_iter().map(|target| target.managed).collect());
|
||||||
|
retry_after = None;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
stop = Some(StdinStop::Fatal(format!("invalid --targets-stdin target set: {error}")));
|
||||||
|
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow()));
|
||||||
|
if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
result = async { pending.as_mut().expect("guarded pending receipt").await }, if pending.is_some() && stop.is_none() => {
|
result = async { pending.as_mut().expect("guarded pending receipt").await }, if pending.is_some() && stop.is_none() => {
|
||||||
pending = None;
|
pending = None;
|
||||||
match result {
|
match result {
|
||||||
@@ -515,7 +549,7 @@ async fn run_stdin_stream(
|
|||||||
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => {
|
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => {
|
||||||
// A newer desired set supersedes this rejected set. Otherwise,
|
// A newer desired set supersedes this rejected set. Otherwise,
|
||||||
// retry only after the driver's status advances from submission.
|
// retry only after the driver's status advances from submission.
|
||||||
if desired.is_none() {
|
if desired.is_none() && desired_specs.is_none() && preparing.is_none() {
|
||||||
desired = Some(std::mem::take(&mut submitted));
|
desired = Some(std::mem::take(&mut submitted));
|
||||||
} else {
|
} else {
|
||||||
retry_after = None;
|
retry_after = None;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ impl TuiArgs {
|
|||||||
///
|
///
|
||||||
/// The TUI always requires a target: the parser rejects an empty target
|
/// The TUI always requires a target: the parser rejects an empty target
|
||||||
/// list, and this rejects a target set that cannot be labelled uniquely.
|
/// list, and this rejects a target set that cannot be labelled uniquely.
|
||||||
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||||
prepare_managed_run(
|
prepare_managed_run(
|
||||||
&self.common,
|
&self.common,
|
||||||
self.duration,
|
self.duration,
|
||||||
@@ -59,6 +59,7 @@ impl TuiArgs {
|
|||||||
stdin_controlled: false,
|
stdin_controlled: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_continuous(&self) -> bool {
|
pub fn is_continuous(&self) -> bool {
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ pub async fn run_tui(
|
|||||||
args: TuiArgs,
|
args: TuiArgs,
|
||||||
mut shutdown: watch::Receiver<bool>,
|
mut shutdown: watch::Receiver<bool>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let setup = args
|
let setup = tokio::select! {
|
||||||
.prepare()
|
result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?,
|
||||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
_ = shutdown.wait_for(|requested| *requested) => return Ok(()),
|
||||||
|
};
|
||||||
let continuous = args.is_continuous();
|
let continuous = args.is_continuous();
|
||||||
if *shutdown.borrow() {
|
if *shutdown.borrow() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ use irtt_proto::{Clock, ReceivedStats, StampAt};
|
|||||||
|
|
||||||
#[derive(Clone, clap::Args)]
|
#[derive(Clone, clap::Args)]
|
||||||
pub struct CommonClientArgs {
|
pub struct CommonClientArgs {
|
||||||
|
/// Use IPv4 only.
|
||||||
|
#[arg(short = '4', long, conflicts_with_all = ["ipv6", "dual_stack"])]
|
||||||
|
pub ipv4: bool,
|
||||||
|
|
||||||
|
/// Use IPv6 only.
|
||||||
|
#[arg(short = '6', long, conflicts_with_all = ["ipv4", "dual_stack"])]
|
||||||
|
pub ipv6: bool,
|
||||||
|
|
||||||
|
/// Probe available IPv4 and IPv6 paths separately for each hostname.
|
||||||
|
#[arg(long, conflicts_with_all = ["ipv4", "ipv6"], long_help = "Probe available IPv4 and IPv6 paths separately for each hostname. Labels gain /v4 or /v6; IP literals remain single targets. Families are discovered when a target is added; reconnects resolve the original hostname again within the chosen family.")]
|
||||||
|
pub dual_stack: bool,
|
||||||
|
|
||||||
/// Probe interval.
|
/// Probe interval.
|
||||||
#[arg(long, default_value = "1s", value_parser = parse_duration)]
|
#[arg(long, default_value = "1s", value_parser = parse_duration)]
|
||||||
pub interval: Duration,
|
pub interval: Duration,
|
||||||
@@ -66,6 +78,9 @@ pub struct CommonClientArgs {
|
|||||||
impl fmt::Debug for CommonClientArgs {
|
impl fmt::Debug for CommonClientArgs {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.debug_struct("CommonClientArgs")
|
f.debug_struct("CommonClientArgs")
|
||||||
|
.field("ipv4", &self.ipv4)
|
||||||
|
.field("ipv6", &self.ipv6)
|
||||||
|
.field("dual_stack", &self.dual_stack)
|
||||||
.field("interval", &self.interval)
|
.field("interval", &self.interval)
|
||||||
.field("length", &self.length)
|
.field("length", &self.length)
|
||||||
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
|
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use irtt_client::{
|
use irtt_client::{
|
||||||
Authentication, ClientConfig, HmacKey, NegotiationPolicy, OpenPolicy, SessionRequest,
|
AddressFamily, Authentication, ClientConfig, HmacKey, NegotiationPolicy, OpenPolicy,
|
||||||
SocketConfig,
|
SessionRequest, SocketConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::args::CommonClientArgs;
|
use super::args::CommonClientArgs;
|
||||||
|
|
||||||
impl CommonClientArgs {
|
impl CommonClientArgs {
|
||||||
|
pub fn address_family(&self) -> AddressFamily {
|
||||||
|
if self.ipv4 {
|
||||||
|
AddressFamily::Ipv4
|
||||||
|
} else if self.ipv6 {
|
||||||
|
AddressFamily::Ipv6
|
||||||
|
} else {
|
||||||
|
AddressFamily::Any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the shared client configuration template for a managed run.
|
/// Build the shared client configuration template for a managed run.
|
||||||
///
|
///
|
||||||
/// The config is endpoint-independent; managed targets own their addresses.
|
/// The config is endpoint-independent; managed targets own their addresses.
|
||||||
pub fn to_client_config(&self, duration: Duration) -> ClientConfig {
|
pub fn to_client_config(&self, duration: Duration) -> ClientConfig {
|
||||||
ClientConfig {
|
ClientConfig {
|
||||||
|
address_family: self.address_family(),
|
||||||
open: OpenPolicy {
|
open: OpenPolicy {
|
||||||
negotiation: if self.loose {
|
negotiation: if self.loose {
|
||||||
NegotiationPolicy::Loose
|
NegotiationPolicy::Loose
|
||||||
|
|||||||
@@ -15,6 +15,6 @@ pub use prepare::{
|
|||||||
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
|
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||||
};
|
};
|
||||||
pub use targets::{
|
pub use targets::{
|
||||||
parse_stdin_target_set, parse_target, prepare_managed_targets, target_specs, GroupPacingArg,
|
parse_stdin_target_set, parse_target, target_specs, GroupPacingArg, PreparedTarget, TargetArg,
|
||||||
PreparedTarget, TargetArg, TargetSpec,
|
TargetPreparation, TargetSpec,
|
||||||
};
|
};
|
||||||
@@ -16,7 +16,7 @@ use irtt_client::{
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
args::CommonClientArgs,
|
args::CommonClientArgs,
|
||||||
targets::{prepare_managed_targets, target_specs, GroupPacingArg, TargetArg},
|
targets::{target_specs, GroupPacingArg, TargetArg, TargetPreparation},
|
||||||
PreparedTarget,
|
PreparedTarget,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,6 +52,8 @@ pub struct ManagedRunSetup {
|
|||||||
pub pacing: ManagedPacing,
|
pub pacing: ManagedPacing,
|
||||||
/// Whether this stream may replace its desired set from stdin.
|
/// Whether this stream may replace its desired set from stdin.
|
||||||
pub stdin_controlled: bool,
|
pub stdin_controlled: bool,
|
||||||
|
/// Preparation policy and family selections for later stdin declarations.
|
||||||
|
pub preparation: TargetPreparation,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ManagedRunSetup {
|
impl ManagedRunSetup {
|
||||||
@@ -98,7 +100,7 @@ impl ManagedRunSetup {
|
|||||||
///
|
///
|
||||||
/// Targets are validated before any configuration is built, so a bad target set
|
/// Targets are validated before any configuration is built, so a bad target set
|
||||||
/// fails before the caller can act on a half-prepared run.
|
/// fails before the caller can act on a half-prepared run.
|
||||||
pub fn prepare_managed_run(
|
pub async fn prepare_managed_run(
|
||||||
common: &CommonClientArgs,
|
common: &CommonClientArgs,
|
||||||
duration: Duration,
|
duration: Duration,
|
||||||
selection: TargetSelection<'_>,
|
selection: TargetSelection<'_>,
|
||||||
@@ -113,11 +115,19 @@ pub fn prepare_managed_run(
|
|||||||
} else {
|
} else {
|
||||||
target_specs(selection.targets)?
|
target_specs(selection.targets)?
|
||||||
};
|
};
|
||||||
let targets = prepare_managed_targets(specs)?;
|
let (targets, preparation) = TargetPreparation::new(common.address_family(), common.dual_stack)
|
||||||
|
.prepare(
|
||||||
|
specs,
|
||||||
|
selection
|
||||||
|
.stdin_controlled
|
||||||
|
.then_some(STDIN_MAX_DESIRED_TARGETS),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(ManagedRunSetup {
|
Ok(ManagedRunSetup {
|
||||||
targets,
|
targets,
|
||||||
client: common.to_client_config(duration),
|
client: common.to_client_config(duration),
|
||||||
pacing: selection.pacing.into(),
|
pacing: selection.pacing.into(),
|
||||||
stdin_controlled: selection.stdin_controlled,
|
stdin_controlled: selection.stdin_controlled,
|
||||||
|
preparation,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
use std::{collections::HashSet, fmt};
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
fmt, io,
|
||||||
|
net::{IpAddr, SocketAddr},
|
||||||
|
};
|
||||||
|
|
||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
use irtt_client::{
|
use irtt_client::{
|
||||||
managed::{ManagedPacing, ManagedTargetConfig, TargetAuth, TargetId},
|
managed::{ManagedPacing, ManagedTargetConfig, TargetAuth, TargetId},
|
||||||
Authentication, HmacKey,
|
AddressFamily, Authentication, HmacKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// One raw positional target captured by Clap.
|
/// One raw positional target captured by Clap.
|
||||||
@@ -63,6 +67,7 @@ impl fmt::Debug for PreparedTarget {
|
|||||||
f.debug_struct("PreparedTarget")
|
f.debug_struct("PreparedTarget")
|
||||||
.field("label", &self.label)
|
.field("label", &self.label)
|
||||||
.field("server_addr", &self.managed.server_addr)
|
.field("server_addr", &self.managed.server_addr)
|
||||||
|
.field("address_family", &self.managed.address_family)
|
||||||
.field("auth", &self.managed.auth)
|
.field("auth", &self.managed.auth)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
@@ -257,17 +262,146 @@ pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
|
|||||||
target_specs_with_empty(targets, false)
|
target_specs_with_empty(targets, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
|
/// Family selections for the current desired declarations, never resolved IPs.
|
||||||
let mut targets = Vec::with_capacity(specs.len());
|
/// Unchanged stdin declarations retain their selections; removed declarations
|
||||||
for spec in specs {
|
/// are forgotten, so adding them again performs fresh discovery.
|
||||||
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
|
#[derive(Debug, Clone)]
|
||||||
managed.auth = spec.auth;
|
pub struct TargetPreparation {
|
||||||
targets.push(PreparedTarget {
|
family: AddressFamily,
|
||||||
label: spec.label,
|
dual_stack: bool,
|
||||||
managed,
|
discovered: HashMap<String, (String, [bool; 2])>,
|
||||||
});
|
}
|
||||||
|
|
||||||
|
impl TargetPreparation {
|
||||||
|
pub fn new(family: AddressFamily, dual_stack: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
family,
|
||||||
|
dual_stack,
|
||||||
|
discovered: HashMap::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(targets)
|
|
||||||
|
pub async fn prepare(
|
||||||
|
mut self,
|
||||||
|
specs: Vec<TargetSpec>,
|
||||||
|
maximum_targets: Option<usize>,
|
||||||
|
) -> Result<(Vec<PreparedTarget>, Self), String> {
|
||||||
|
let mut targets = Vec::with_capacity(specs.len());
|
||||||
|
let mut discovered = HashMap::new();
|
||||||
|
let mut labels = HashSet::new();
|
||||||
|
for (index, spec) in specs.into_iter().enumerate() {
|
||||||
|
let families = if let Some(ip) = literal_ip(&spec.addr) {
|
||||||
|
match (self.family, ip) {
|
||||||
|
(AddressFamily::Ipv4, IpAddr::V6(_)) => {
|
||||||
|
return Err(format!(
|
||||||
|
"target {}: IPv6 literal conflicts with --ipv4",
|
||||||
|
index + 1
|
||||||
|
))
|
||||||
|
}
|
||||||
|
(AddressFamily::Ipv6, IpAddr::V4(_)) => {
|
||||||
|
return Err(format!(
|
||||||
|
"target {}: IPv4 literal conflicts with --ipv6",
|
||||||
|
index + 1
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
} else if self.dual_stack {
|
||||||
|
let families = match self.discovered.get(&spec.label) {
|
||||||
|
Some((addr, families)) if *addr == spec.addr => *families,
|
||||||
|
_ => {
|
||||||
|
let endpoint = discovery_endpoint(&spec.addr)
|
||||||
|
.map_err(|error| format!("target {}: {error}", index + 1))?;
|
||||||
|
discovery_families(tokio::net::lookup_host(endpoint).await)
|
||||||
|
.map_err(|error| format!("target {}: {error}", index + 1))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
discovered.insert(spec.label.clone(), (spec.addr.clone(), families));
|
||||||
|
Some(families)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
for target in expand_target(spec, families) {
|
||||||
|
if !labels.insert(target.label.clone()) {
|
||||||
|
return Err("duplicate target label after address-family expansion".to_owned());
|
||||||
|
}
|
||||||
|
targets.push(target);
|
||||||
|
}
|
||||||
|
if let Some(maximum) = maximum_targets.filter(|maximum| targets.len() > *maximum) {
|
||||||
|
return Err(format!(
|
||||||
|
"target set exceeds the {maximum}-target limit after address-family expansion"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.discovered = discovered;
|
||||||
|
Ok((targets, self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn literal_ip(endpoint: &str) -> Option<IpAddr> {
|
||||||
|
endpoint
|
||||||
|
.parse::<SocketAddr>()
|
||||||
|
.map(|addr| addr.ip())
|
||||||
|
.ok()
|
||||||
|
.or_else(|| endpoint.parse().ok())
|
||||||
|
.or_else(|| {
|
||||||
|
if endpoint.starts_with('[') && endpoint.ends_with(']') {
|
||||||
|
format!("{endpoint}:2112")
|
||||||
|
.parse::<SocketAddr>()
|
||||||
|
.ok()
|
||||||
|
.map(|addr| addr.ip())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discovery_endpoint(endpoint: &str) -> Result<(&str, u16), String> {
|
||||||
|
match endpoint.rsplit_once(':') {
|
||||||
|
Some((host, port)) => {
|
||||||
|
let port = port.parse().map_err(|_| "invalid server port".to_owned())?;
|
||||||
|
if host.is_empty() || host.contains(':') || host.starts_with('[') {
|
||||||
|
return Err("invalid server endpoint".to_owned());
|
||||||
|
}
|
||||||
|
Ok((host, port))
|
||||||
|
}
|
||||||
|
None => Ok((endpoint, 2112)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discovery_families(
|
||||||
|
result: io::Result<impl Iterator<Item = SocketAddr>>,
|
||||||
|
) -> Result<[bool; 2], String> {
|
||||||
|
let mut families = [false; 2];
|
||||||
|
for addr in result.map_err(|_| "DNS lookup failed".to_owned())? {
|
||||||
|
families[usize::from(addr.is_ipv6())] = true;
|
||||||
|
}
|
||||||
|
if families == [false; 2] {
|
||||||
|
return Err("DNS lookup returned no usable addresses".to_owned());
|
||||||
|
}
|
||||||
|
Ok(families)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_target(spec: TargetSpec, families: Option<[bool; 2]>) -> Vec<PreparedTarget> {
|
||||||
|
let selections = match families {
|
||||||
|
None => vec![(spec.label, None)],
|
||||||
|
Some(families) => [("v4", AddressFamily::Ipv4), ("v6", AddressFamily::Ipv6)]
|
||||||
|
.into_iter()
|
||||||
|
.zip(families)
|
||||||
|
.filter(|(_, exists)| *exists)
|
||||||
|
.map(|((suffix, family), _)| (format!("{}/{suffix}", spec.label), Some(family)))
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
selections
|
||||||
|
.into_iter()
|
||||||
|
.map(|(label, family)| {
|
||||||
|
let mut managed =
|
||||||
|
ManagedTargetConfig::new(TargetId::from(label.clone()), spec.addr.clone());
|
||||||
|
managed.address_family = family;
|
||||||
|
managed.auth = spec.auth.clone();
|
||||||
|
PreparedTarget { label, managed }
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse one complete stdin target set after its line terminator was removed.
|
/// Parse one complete stdin target set after its line terminator was removed.
|
||||||
@@ -277,7 +411,7 @@ pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTar
|
|||||||
pub fn parse_stdin_target_set(
|
pub fn parse_stdin_target_set(
|
||||||
record: &str,
|
record: &str,
|
||||||
maximum_targets: usize,
|
maximum_targets: usize,
|
||||||
) -> Result<Vec<PreparedTarget>, String> {
|
) -> Result<Vec<TargetSpec>, String> {
|
||||||
if record == "[]" {
|
if record == "[]" {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
@@ -288,5 +422,5 @@ pub fn parse_stdin_target_set(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
||||||
prepare_managed_targets(target_specs_with_empty(&args, true)?)
|
target_specs_with_empty(&args, true)
|
||||||
}
|
}
|
||||||
@@ -59,9 +59,17 @@ Configuration is grouped by ownership:
|
|||||||
probe tracking policy.
|
probe tracking policy.
|
||||||
|
|
||||||
Clone one config to connect to multiple endpoints. Managed targets retain their
|
Clone one config to connect to multiple endpoints. Managed targets retain their
|
||||||
own ID, endpoint, and authentication inheritance/override; shared
|
own ID, endpoint, address-family override, and authentication inheritance/override; shared
|
||||||
`ManagedClientConfig.client` is reusable without endpoint mutation.
|
`ManagedClientConfig.client` is reusable without endpoint mutation.
|
||||||
|
|
||||||
|
`ManagedTargetConfig.address_family` defaults to `None`, inheriting the shared
|
||||||
|
family policy. `Some(AddressFamily::Ipv4)`, `Some(AddressFamily::Ipv6)`, or
|
||||||
|
`Some(AddressFamily::Any)` overrides it for that target. Every new generation
|
||||||
|
resolves the original endpoint again; callers can constrain a hostname without
|
||||||
|
replacing it with a resolved IP. Changing the family specification replaces
|
||||||
|
that target's generation, including switching between inheritance and an equal
|
||||||
|
explicit override. Hostname expansion and discovery are frontend policies.
|
||||||
|
|
||||||
Blocking receive policy belongs to `Client`:
|
Blocking receive policy belongs to `Client`:
|
||||||
`client.set_recv_timeout(Some(duration))?` sets it before or after opening,
|
`client.set_recv_timeout(Some(duration))?` sets it before or after opening,
|
||||||
and `client.set_recv_timeout(None)?` restores the default indefinite wait.
|
and `client.set_recv_timeout(None)?` restores the default indefinite wait.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::{fmt, net::SocketAddr, sync::Arc, time::Duration};
|
|||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::{Authentication, ClientConfig, ClientError, ClientEvent};
|
use crate::{AddressFamily, Authentication, ClientConfig, ClientError, ClientEvent};
|
||||||
|
|
||||||
use super::TargetId;
|
use super::TargetId;
|
||||||
|
|
||||||
@@ -48,11 +48,16 @@ impl TargetAuth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Endpoint and authentication specification for one managed target.
|
/// Endpoint, address-family and authentication specification for one managed target.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct ManagedTargetConfig {
|
pub struct ManagedTargetConfig {
|
||||||
pub id: TargetId,
|
pub id: TargetId,
|
||||||
pub server_addr: String,
|
pub server_addr: String,
|
||||||
|
/// Override the shared address-family policy. `None` inherits it.
|
||||||
|
///
|
||||||
|
/// The endpoint remains a hostname when supplied as one, so every new
|
||||||
|
/// generation resolves it again using the effective family policy.
|
||||||
|
pub address_family: Option<AddressFamily>,
|
||||||
pub auth: TargetAuth,
|
pub auth: TargetAuth,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +66,7 @@ impl ManagedTargetConfig {
|
|||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
server_addr: server_addr.into(),
|
server_addr: server_addr.into(),
|
||||||
|
address_family: None,
|
||||||
auth: TargetAuth::Inherit,
|
auth: TargetAuth::Inherit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,7 +92,7 @@ pub enum ManagedCompletionPolicy {
|
|||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct ManagedClientConfig {
|
pub struct ManagedClientConfig {
|
||||||
/// Reusable session/socket configuration shared across target generations.
|
/// Reusable session/socket configuration shared across target generations.
|
||||||
/// Each target supplies its endpoint and resolves its authentication policy.
|
/// Each target supplies its endpoint and resolves its family and authentication policies.
|
||||||
pub client: ClientConfig,
|
pub client: ClientConfig,
|
||||||
pub pacing: ManagedPacing,
|
pub pacing: ManagedPacing,
|
||||||
pub completion: ManagedCompletionPolicy,
|
pub completion: ManagedCompletionPolicy,
|
||||||
|
|||||||
@@ -397,6 +397,9 @@ fn build_task(
|
|||||||
.checked_add(1)
|
.checked_add(1)
|
||||||
.ok_or(ManagedConfigError::GenerationExhausted)?;
|
.ok_or(ManagedConfigError::GenerationExhausted)?;
|
||||||
let mut client_config = config.client.clone();
|
let mut client_config = config.client.clone();
|
||||||
|
client_config.address_family = target
|
||||||
|
.address_family
|
||||||
|
.unwrap_or(config.client.address_family);
|
||||||
client_config.auth = target.auth.resolve(&config.client.auth);
|
client_config.auth = target.auth.resolve(&config.client.auth);
|
||||||
validate_target_config(&client_config).map_err(|source| {
|
validate_target_config(&client_config).map_err(|source| {
|
||||||
ManagedConfigError::InvalidTarget {
|
ManagedConfigError::InvalidTarget {
|
||||||
|
|||||||
@@ -191,6 +191,9 @@ impl ManagedClientTask {
|
|||||||
return Err(ManagedCommandApplyError::DuplicateTargetId { id: target.id });
|
return Err(ManagedCommandApplyError::DuplicateTargetId { id: target.id });
|
||||||
}
|
}
|
||||||
let mut client_config = self.config.client.clone();
|
let mut client_config = self.config.client.clone();
|
||||||
|
client_config.address_family = target
|
||||||
|
.address_family
|
||||||
|
.unwrap_or(self.config.client.address_family);
|
||||||
client_config.auth = target.auth.resolve(&self.config.client.auth);
|
client_config.auth = target.auth.resolve(&self.config.client.auth);
|
||||||
validate_target_config(&client_config).map_err(|source| {
|
validate_target_config(&client_config).map_err(|source| {
|
||||||
ManagedCommandApplyError::InvalidTarget {
|
ManagedCommandApplyError::InvalidTarget {
|
||||||
|
|||||||
+44
-1
@@ -58,6 +58,41 @@ once per interval instead. Final per-target summaries print in the order
|
|||||||
targets were supplied on the command line, not the order in which they
|
targets were supplied on the command line, not the order in which they
|
||||||
finish or their labels sort alphabetically.
|
finish or their labels sort alphabetically.
|
||||||
|
|
||||||
|
## ADDRESS FAMILY
|
||||||
|
|
||||||
|
By default, each target uses the first address returned by the system resolver,
|
||||||
|
whether IPv4 or IPv6. Use `-4` / `--ipv4` to select IPv4 only, or
|
||||||
|
`-6` / `--ipv6` to select IPv6 only. Hostnames are resolved when each session
|
||||||
|
connects; these flags do not perform an extra discovery lookup. An explicit
|
||||||
|
IP literal from the other family is rejected during preparation.
|
||||||
|
|
||||||
|
Use `--dual-stack` to compare IPv4 and IPv6 paths as separate measurements:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
irtt-client -6 host.example
|
||||||
|
irtt-client --dual-stack cov=uk-cov1.irtt.lochnair.net
|
||||||
|
```
|
||||||
|
|
||||||
|
For each hostname, the client discovers which address families the system
|
||||||
|
resolver currently returns. IPv4 results create `cov/v4`, IPv6 results create
|
||||||
|
`cov/v6`, and both create both targets, in that order. Without an alias,
|
||||||
|
`host.example` becomes `host.example/v4` and/or `host.example/v6`. Multiple
|
||||||
|
addresses within one family still produce only one target. Explicit IP literals
|
||||||
|
keep their original label and remain single targets.
|
||||||
|
|
||||||
|
Each target has its own session, statistics, and failures. Its original hostname
|
||||||
|
is retained: every new session resolves it again and selects the first address
|
||||||
|
matching its family. Families are discovered only when a declaration is added,
|
||||||
|
so a newly appearing family does not create another target automatically.
|
||||||
|
Discovery failure or a successful lookup returning no addresses rejects the
|
||||||
|
whole target set. A missing family is normal and simply creates no target for
|
||||||
|
that family. Final labels must be unique, including generated `/v4` and `/v6`
|
||||||
|
labels; choose another alias if one collides with an explicit label.
|
||||||
|
|
||||||
|
`-4`, `-6`, and `--dual-stack` are mutually exclusive. Expanded targets use
|
||||||
|
ordinary multi-target pacing and each sends its own probes, so dual-stack can
|
||||||
|
double the number of probes per interval compared with one target per hostname.
|
||||||
|
|
||||||
## DYNAMIC TARGET SETS FROM STANDARD INPUT
|
## DYNAMIC TARGET SETS FROM STANDARD INPUT
|
||||||
|
|
||||||
`--targets-stdin` is available only with continuous mode (`--duration 0`).
|
`--targets-stdin` is available only with continuous mode (`--duration 0`).
|
||||||
@@ -75,6 +110,13 @@ are retired, unchanged target configurations retain their generation, and a
|
|||||||
changed address or HMAC setting creates a fresh generation. Under transient
|
changed address or HMAC setting creates a fresh generation. Under transient
|
||||||
live-generation backpressure, only the latest unapplied desired set is kept.
|
live-generation backpressure, only the latest unapplied desired set is kept.
|
||||||
|
|
||||||
|
The same address-family preparation applies to initial and stdin targets.
|
||||||
|
With `--dual-stack`, unchanged labels and endpoints retain their discovered
|
||||||
|
families across records, including HMAC-only changes. Removing a declaration
|
||||||
|
and later adding it again performs fresh discovery. New declarations are
|
||||||
|
prepared asynchronously while existing measurements continue. A preparation
|
||||||
|
error stops the stream gracefully without applying a partial set.
|
||||||
|
|
||||||
Commas frame targets in stdin records; escape a literal comma within one
|
Commas frame targets in stdin records; escape a literal comma within one
|
||||||
target as `\,`. Target parsing inside each element is otherwise the same as
|
target as `\,`. Target parsing inside each element is otherwise the same as
|
||||||
for positional arguments, including the other escapes above, `@hmac=KEY`, and
|
for positional arguments, including the other escapes above, `@hmac=KEY`, and
|
||||||
@@ -107,7 +149,8 @@ revision to become applicable.
|
|||||||
|
|
||||||
The maximum stdin record payload is 64 KiB. Stdin-controlled mode bounds each
|
The maximum stdin record payload is 64 KiB. Stdin-controlled mode bounds each
|
||||||
desired set to 128 targets and retains at most 256 live target generations
|
desired set to 128 targets and retains at most 256 live target generations
|
||||||
while replacements drain.
|
while replacements drain. The 128-target limit applies after dual-stack
|
||||||
|
expansion: a hostname producing both families consumes two target slots.
|
||||||
|
|
||||||
## FINITE VERSUS CONTINUOUS OPERATION
|
## FINITE VERSUS CONTINUOUS OPERATION
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,21 @@ Target syntax, multiple targets, and pacing are identical to
|
|||||||
`--sfill`, `--dscp`, `--ttl`, `--loose`) is shared with `irtt-client` as
|
`--sfill`, `--dscp`, `--ttl`, `--loose`) is shared with `irtt-client` as
|
||||||
well; consult that manual for their meaning.
|
well; consult that manual for their meaning.
|
||||||
|
|
||||||
|
Address-family controls are shared too: `-4` / `--ipv4` uses IPv4 only,
|
||||||
|
`-6` / `--ipv6` uses IPv6 only, and `--dual-stack` gives each available family
|
||||||
|
of a hostname its own measurement row and graph series. These switches are
|
||||||
|
mutually exclusive. Explicit IP literals remain single targets; a literal
|
||||||
|
conflicting with `-4` or `-6` is rejected before the dashboard starts.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
irtt-tui -4 uk-cov1.irtt.lochnair.net
|
||||||
|
irtt-tui --dual-stack cov=uk-cov1.irtt.lochnair.net
|
||||||
|
```
|
||||||
|
|
||||||
|
The second command creates `cov/v4` and/or `cov/v6` according to the system
|
||||||
|
resolver's initial results, letting you compare the two paths directly.
|
||||||
|
See the client manual's address-family section for discovery errors and labels.
|
||||||
|
|
||||||
## CONTINUOUS DEFAULT
|
## CONTINUOUS DEFAULT
|
||||||
|
|
||||||
Unlike `irtt-client`, **the TUI defaults to continuous mode**
|
Unlike `irtt-client`, **the TUI defaults to continuous mode**
|
||||||
@@ -38,6 +53,11 @@ sessions continue undisturbed. Even if every target fails, the TUI stays alive
|
|||||||
until you quit. Explicit stops, removed or replaced targets, and no-test
|
until you quit. Explicit stops, removed or replaced targets, and no-test
|
||||||
completion do not trigger retries. Quitting cancels pending retry work.
|
completion do not trigger retries. Quitting cancels pending retry work.
|
||||||
|
|
||||||
|
With `--dual-stack`, a failed family reconnects independently while its healthy
|
||||||
|
sibling continues. Reconnect resolves the original hostname again within the
|
||||||
|
target's family. It does not discover additional families or pin the address
|
||||||
|
returned by initial discovery.
|
||||||
|
|
||||||
Each reconnect keeps the same logical target row. Statistics reset when a new
|
Each reconnect keeps the same logical target row. Statistics reset when a new
|
||||||
session generation is accepted, including attempts that fail before opening.
|
session generation is accepted, including attempts that fail before opening.
|
||||||
Retained graph history and the age of the last actual reply remain; the graph
|
Retained graph history and the age of the last actual reply remain; the graph
|
||||||
|
|||||||
Reference in new issue
Block a user