feat(app): add address-family selection and dual-stack targets

This commit is contained in:
Nils Andreas Svee committed 2026-10-07 14:17:11 +07:00
1 parent 988a360fef
commit da7472e10a
16 files changed
+334 -44

No files matched your search

+148 -14
View File
@@ -1,9 +1,13 @@
use std::{collections::HashSet, fmt};
use std::{
collections::{HashMap, HashSet},
fmt, io,
net::{IpAddr, SocketAddr},
};
use clap::ValueEnum;
use irtt_client::{
managed::{ManagedPacing, ManagedTargetConfig, TargetAuth, TargetId},
Authentication, HmacKey,
AddressFamily, Authentication, HmacKey,
};
/// One raw positional target captured by Clap.
@@ -63,6 +67,7 @@ impl fmt::Debug for PreparedTarget {
f.debug_struct("PreparedTarget")
.field("label", &self.label)
.field("server_addr", &self.managed.server_addr)
.field("address_family", &self.managed.address_family)
.field("auth", &self.managed.auth)
.finish()
}
@@ -257,17 +262,146 @@ pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
target_specs_with_empty(targets, false)
}
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
let mut targets = Vec::with_capacity(specs.len());
for spec in specs {
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
managed.auth = spec.auth;
targets.push(PreparedTarget {
label: spec.label,
managed,
});
/// Family selections for the current desired declarations, never resolved IPs.
/// Unchanged stdin declarations retain their selections; removed declarations
/// are forgotten, so adding them again performs fresh discovery.
#[derive(Debug, Clone)]
pub struct TargetPreparation {
family: AddressFamily,
dual_stack: bool,
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.
@@ -277,7 +411,7 @@ pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTar
pub fn parse_stdin_target_set(
record: &str,
maximum_targets: usize,
) -> Result<Vec<PreparedTarget>, String> {
) -> Result<Vec<TargetSpec>, String> {
if record == "[]" {
return Ok(Vec::new());
}
@@ -288,5 +422,5 @@ pub fn parse_stdin_target_set(
));
}
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)
}