1435 lines
43 KiB
Rust
1435 lines
43 KiB
Rust
use anyhow::{bail, Context, Result};
|
|
use hickory_resolver::net::runtime::TokioRuntimeProvider;
|
|
use hickory_resolver::proto::rr::rdata::SRV;
|
|
use hickory_resolver::TokioResolver;
|
|
use reqwest::Client;
|
|
use std::{fmt, net::IpAddr};
|
|
use tokio::{
|
|
io::{AsyncReadExt, AsyncWriteExt},
|
|
net::TcpStream,
|
|
time::{timeout, Duration},
|
|
};
|
|
use tracing::{debug, info, warn};
|
|
use tracing_subscriber::filter::LevelFilter;
|
|
|
|
const DEFAULT_TS3_PROTO: &str = "udp";
|
|
const DEFAULT_TSDNS_PROTO: &str = "tcp";
|
|
const NICK_RESOLVE_URL: &str = "https://named.myteamspeak.com/lookup";
|
|
const TSDNS_PORT: u16 = 41144;
|
|
const TSDNS_TERMINATOR: &[u8] = b"\n\r\r\r\n";
|
|
const TSDNS_TIMEOUT: Duration = Duration::from_secs(3);
|
|
const TS3_SRV_FALLBACK_DISCOVERY_BUDGET: Duration = Duration::from_millis(900);
|
|
const TSDNS_FALLBACK_DISCOVERY_BUDGET: Duration = Duration::from_millis(900);
|
|
pub const DEFAULT_TEAMSPEAK_PORT: u16 = 9987;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Args {
|
|
pub host: String,
|
|
pub service: String,
|
|
pub protocol: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct BuildInfo {
|
|
pub version: String,
|
|
pub commit: String,
|
|
pub date: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SrvRecord {
|
|
pub priority: u16,
|
|
pub weight: u16,
|
|
pub port: u16,
|
|
pub target: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ClientResolutionMethod {
|
|
Direct,
|
|
Ts3Srv,
|
|
TsdnsSrv,
|
|
TsdnsTcp,
|
|
Dns,
|
|
Nick,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ClientResolution {
|
|
pub input: String,
|
|
pub address: String,
|
|
pub method: ClientResolutionMethod,
|
|
pub resolution: Option<Resolution>,
|
|
}
|
|
|
|
impl SrvRecord {
|
|
pub fn resolved(&self) -> String {
|
|
format_host_port(self.target.trim_end_matches('.'), self.port)
|
|
}
|
|
}
|
|
|
|
impl Resolution {
|
|
pub fn connection_address(&self) -> String {
|
|
match self {
|
|
Resolution::Dns { selected, .. } => {
|
|
format_host_port(&selected.to_string(), DEFAULT_TEAMSPEAK_PORT)
|
|
}
|
|
Resolution::Srv { selected, .. } | Resolution::Nick { selected, .. } => {
|
|
selected.resolved()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Resolution {
|
|
Dns {
|
|
host: String,
|
|
addresses: Vec<IpAddr>,
|
|
selected: IpAddr,
|
|
},
|
|
Srv {
|
|
service: String,
|
|
host: String,
|
|
protocol: String,
|
|
records: Vec<SrvRecord>,
|
|
selected: SrvRecord,
|
|
},
|
|
Nick {
|
|
nick: String,
|
|
address: String,
|
|
protocol: String,
|
|
records: Vec<SrvRecord>,
|
|
selected: SrvRecord,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ChanoraResolver {
|
|
resolver: Option<TokioResolver>,
|
|
http: Client,
|
|
}
|
|
|
|
impl fmt::Display for BuildInfo {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(
|
|
f,
|
|
"{{version: {}, commit: {}, date: {}}}",
|
|
self.version, self.commit, self.date
|
|
)
|
|
}
|
|
}
|
|
|
|
pub fn build_info() -> BuildInfo {
|
|
BuildInfo {
|
|
version: option_env!("CHANORA_RESOLVER_BUILD_VERSION")
|
|
.unwrap_or(env!("CARGO_PKG_VERSION"))
|
|
.to_string(),
|
|
commit: option_env!("CHANORA_RESOLVER_BUILD_COMMIT")
|
|
.unwrap_or("unknown")
|
|
.to_string(),
|
|
date: option_env!("CHANORA_RESOLVER_BUILD_DATE")
|
|
.unwrap_or("unknown")
|
|
.to_string(),
|
|
}
|
|
}
|
|
|
|
pub fn setup_log() {
|
|
let level = std::env::var("CHANORA_RESOLVER_LOG_LEVEL").unwrap_or_else(|_| "info".to_string());
|
|
let filter = match level.to_lowercase().as_str() {
|
|
"debug" => LevelFilter::DEBUG,
|
|
"info" => LevelFilter::INFO,
|
|
"warn" => LevelFilter::WARN,
|
|
"error" => LevelFilter::ERROR,
|
|
_ => LevelFilter::INFO,
|
|
};
|
|
|
|
let _ = tracing_subscriber::fmt()
|
|
.with_max_level(filter)
|
|
.with_ansi(false)
|
|
.with_target(false)
|
|
.without_time()
|
|
.with_writer(std::io::stdout)
|
|
.try_init();
|
|
}
|
|
|
|
impl ChanoraResolver {
|
|
pub fn new() -> Result<Self> {
|
|
let resolver = match srv_resolver_builder() {
|
|
Ok(builder) => match builder.build() {
|
|
Ok(resolver) => Some(resolver),
|
|
Err(err) => {
|
|
warn!(err = %err, "srv resolver unavailable");
|
|
None
|
|
}
|
|
},
|
|
Err(err) => {
|
|
warn!(err = %err, "srv resolver unavailable");
|
|
None
|
|
}
|
|
};
|
|
|
|
Ok(Self {
|
|
resolver,
|
|
http: Client::new(),
|
|
})
|
|
}
|
|
|
|
pub async fn resolve(&self, args: &Args) -> Result<Resolution> {
|
|
let normalized = normalize_args(args.clone());
|
|
validate_args(&normalized)?;
|
|
|
|
debug!(build = %build_info(), "app start");
|
|
debug!(
|
|
host = %normalized.host,
|
|
service = %normalized.service,
|
|
protocol = %normalized.protocol,
|
|
"normalized flags"
|
|
);
|
|
|
|
self.resolve_normalized(&normalized).await
|
|
}
|
|
|
|
pub async fn resolve_connection_address(&self, args: &Args) -> Result<String> {
|
|
Ok(self.resolve(args).await?.connection_address())
|
|
}
|
|
|
|
pub async fn resolve_client_address(&self, input: &str) -> Result<String> {
|
|
Ok(self.resolve_client_request(input).await?.address)
|
|
}
|
|
|
|
pub async fn resolve_client_request(&self, input: &str) -> Result<ClientResolution> {
|
|
let request = normalize_client_input(input)?;
|
|
let host = request.host;
|
|
info!(input = %input, host = %host, "client request resolution started");
|
|
|
|
if request.port.is_none() && should_try_server_name(&host) {
|
|
match self.lookup_server_name(&host).await {
|
|
Ok(Some(alias)) => {
|
|
info!(
|
|
input = %input,
|
|
alias_host = %alias.host,
|
|
alias_port = ?alias.port,
|
|
"server name resolved"
|
|
);
|
|
return self
|
|
.resolve_client_host(
|
|
input,
|
|
alias.host,
|
|
match alias.port {
|
|
PortResolution::Explicit(port) => Some(port),
|
|
PortResolution::KeepInput => None,
|
|
},
|
|
true,
|
|
)
|
|
.await;
|
|
}
|
|
Ok(None) if is_bare_numeric_name(&host) => {
|
|
bail!("could not resolve TeamSpeak server name '{host}': not found");
|
|
}
|
|
Ok(None) => {}
|
|
Err(err) if is_bare_numeric_name(&host) => {
|
|
bail!("could not resolve TeamSpeak server name '{host}': {err}");
|
|
}
|
|
Err(err) => {
|
|
debug!(err = %err, "server name lookup failed, continuing with host resolution");
|
|
}
|
|
}
|
|
}
|
|
|
|
self.resolve_client_host(input, host, request.port, false)
|
|
.await
|
|
}
|
|
|
|
async fn resolve_client_host(
|
|
&self,
|
|
input: &str,
|
|
host: String,
|
|
port: Option<u16>,
|
|
from_server_name: bool,
|
|
) -> Result<ClientResolution> {
|
|
let mut errors = Vec::new();
|
|
let fallback_port = port.unwrap_or(DEFAULT_TEAMSPEAK_PORT);
|
|
|
|
if host.parse::<IpAddr>().is_ok() {
|
|
return Ok(client_resolution_with_optional_resolution(
|
|
input,
|
|
if from_server_name {
|
|
ClientResolutionMethod::Nick
|
|
} else {
|
|
ClientResolutionMethod::Direct
|
|
},
|
|
format_host_port(&host, fallback_port),
|
|
None,
|
|
));
|
|
}
|
|
|
|
let mut direct = if is_bare_numeric_name(&host) {
|
|
errors.push("dns: skipped bare numeric server name".to_string());
|
|
None
|
|
} else {
|
|
match self.resolve_dns(&host).await {
|
|
Ok(resolution) => Some(resolution),
|
|
Err(err) => {
|
|
errors.push(format!("dns: {err}"));
|
|
None
|
|
}
|
|
}
|
|
};
|
|
|
|
if should_return_direct_dns_before_discovery(port) {
|
|
if let Some(resolution) = direct.take() {
|
|
return Ok(direct_client_resolution(
|
|
input,
|
|
from_server_name,
|
|
fallback_port,
|
|
resolution,
|
|
));
|
|
}
|
|
}
|
|
|
|
let ts3 = self.resolve_ts3(&host, "");
|
|
match ts3_srv_discovery_budget(direct.is_some()) {
|
|
Some(budget) => match timeout(budget, ts3).await {
|
|
Ok(Ok(resolution)) => {
|
|
let selected = match &resolution {
|
|
Resolution::Srv { selected, .. } => selected.clone(),
|
|
_ => unreachable!("ts3 resolution must be an srv result"),
|
|
};
|
|
return self
|
|
.client_srv_resolution(
|
|
input,
|
|
ClientResolutionMethod::Ts3Srv,
|
|
&selected,
|
|
resolution,
|
|
)
|
|
.await;
|
|
}
|
|
Ok(Err(err)) => errors.push(format!("ts3 srv: {err}")),
|
|
Err(_) => errors.push(format!(
|
|
"ts3 srv: discovery timed out after {}ms",
|
|
budget.as_millis()
|
|
)),
|
|
},
|
|
None => match ts3.await {
|
|
Ok(resolution) => {
|
|
let selected = match &resolution {
|
|
Resolution::Srv { selected, .. } => selected.clone(),
|
|
_ => unreachable!("ts3 resolution must be an srv result"),
|
|
};
|
|
return self
|
|
.client_srv_resolution(
|
|
input,
|
|
ClientResolutionMethod::Ts3Srv,
|
|
&selected,
|
|
resolution,
|
|
)
|
|
.await;
|
|
}
|
|
Err(err) => errors.push(format!("ts3 srv: {err}")),
|
|
},
|
|
}
|
|
|
|
let tsdns = self.resolve_tsdns_candidates(input, &host, fallback_port);
|
|
match tsdns_discovery_budget(direct.is_some()) {
|
|
Some(budget) => match timeout(budget, tsdns).await {
|
|
Ok(Ok(resolution)) => return Ok(resolution),
|
|
Ok(Err(err)) => errors.push(format!("tsdns: {err}")),
|
|
Err(_) => errors.push(format!(
|
|
"tsdns: discovery timed out after {}ms",
|
|
budget.as_millis()
|
|
)),
|
|
},
|
|
None => match tsdns.await {
|
|
Ok(resolution) => return Ok(resolution),
|
|
Err(err) => errors.push(format!("tsdns: {err}")),
|
|
},
|
|
}
|
|
|
|
if let Some(resolution) = direct {
|
|
return Ok(direct_client_resolution(
|
|
input,
|
|
from_server_name,
|
|
fallback_port,
|
|
resolution,
|
|
));
|
|
}
|
|
|
|
bail!(
|
|
"could not resolve TeamSpeak address '{host}': {}",
|
|
errors.join("; ")
|
|
)
|
|
}
|
|
|
|
async fn resolve_tsdns_candidates(
|
|
&self,
|
|
input: &str,
|
|
query_host: &str,
|
|
fallback_port: u16,
|
|
) -> Result<ClientResolution> {
|
|
let mut errors = Vec::new();
|
|
match self
|
|
.resolve_tsdns_srv_candidates(input, query_host, fallback_port)
|
|
.await
|
|
{
|
|
Ok(resolution) => return Ok(resolution),
|
|
Err(err) => errors.push(format!("tsdns srv: {err}")),
|
|
}
|
|
|
|
match self
|
|
.resolve_tsdns_tcp_candidates(input, query_host, fallback_port)
|
|
.await
|
|
{
|
|
Ok(resolution) => return Ok(resolution),
|
|
Err(err) => errors.push(format!("tsdns tcp: {err}")),
|
|
}
|
|
|
|
bail!("{}", errors.join("; "))
|
|
}
|
|
|
|
async fn client_srv_resolution(
|
|
&self,
|
|
input: &str,
|
|
method: ClientResolutionMethod,
|
|
selected: &SrvRecord,
|
|
resolution: Resolution,
|
|
) -> Result<ClientResolution> {
|
|
let target = selected.target.trim_end_matches('.');
|
|
let addresses = self
|
|
.lookup_ips(target, selected.port)
|
|
.await
|
|
.with_context(|| format!("srv target dns lookup failed for {target}"))?;
|
|
if addresses.is_empty() {
|
|
bail!("no addresses found for srv target: {target}");
|
|
}
|
|
|
|
Ok(client_resolution_with_address(
|
|
input,
|
|
method,
|
|
format_host_port(&addresses[0].to_string(), selected.port),
|
|
resolution,
|
|
))
|
|
}
|
|
|
|
async fn resolve_tsdns_srv_candidates(
|
|
&self,
|
|
input: &str,
|
|
query_host: &str,
|
|
fallback_port: u16,
|
|
) -> Result<ClientResolution> {
|
|
let mut errors = Vec::new();
|
|
for srv_host in tsdns_candidate_hosts(query_host) {
|
|
match self.resolve_tsdns(&srv_host, "").await {
|
|
Ok(resolution) => {
|
|
return self
|
|
.client_tsdns_srv_resolution(input, query_host, fallback_port, resolution)
|
|
.await;
|
|
}
|
|
Err(err) => errors.push(format!("{srv_host}: {err}")),
|
|
}
|
|
}
|
|
|
|
bail!("{}", errors.join("; "))
|
|
}
|
|
|
|
async fn client_tsdns_srv_resolution(
|
|
&self,
|
|
input: &str,
|
|
query_host: &str,
|
|
fallback_port: u16,
|
|
resolution: Resolution,
|
|
) -> Result<ClientResolution> {
|
|
let selected = match &resolution {
|
|
Resolution::Srv { selected, .. } => selected,
|
|
_ => bail!("tsdns srv resolution must be an srv result"),
|
|
};
|
|
let server = selected.target.trim_end_matches('.');
|
|
let endpoint = self
|
|
.query_tsdns_server(server, selected.port, query_host)
|
|
.await?;
|
|
self.client_resolved_tsdns_endpoint(input, endpoint, fallback_port, Some(resolution))
|
|
.await
|
|
}
|
|
|
|
async fn resolve_tsdns_tcp_candidates(
|
|
&self,
|
|
input: &str,
|
|
query_host: &str,
|
|
fallback_port: u16,
|
|
) -> Result<ClientResolution> {
|
|
let mut errors = Vec::new();
|
|
for server in tsdns_candidate_hosts(query_host) {
|
|
match self
|
|
.query_tsdns_server(&server, TSDNS_PORT, query_host)
|
|
.await
|
|
{
|
|
Ok(endpoint) => {
|
|
return self
|
|
.client_resolved_tsdns_endpoint(input, endpoint, fallback_port, None)
|
|
.await;
|
|
}
|
|
Err(err) => errors.push(format!("{server}: {err}")),
|
|
}
|
|
}
|
|
|
|
bail!("{}", errors.join("; "))
|
|
}
|
|
|
|
async fn client_resolved_tsdns_endpoint(
|
|
&self,
|
|
input: &str,
|
|
endpoint: ServerNameResolution,
|
|
fallback_port: u16,
|
|
resolution: Option<Resolution>,
|
|
) -> Result<ClientResolution> {
|
|
let method = if resolution.is_some() {
|
|
ClientResolutionMethod::TsdnsSrv
|
|
} else {
|
|
ClientResolutionMethod::TsdnsTcp
|
|
};
|
|
let port = endpoint.port.resolve(fallback_port);
|
|
let address = if endpoint.host.parse::<IpAddr>().is_ok() {
|
|
format_host_port(&endpoint.host, port)
|
|
} else {
|
|
let addresses = self
|
|
.lookup_ips(&endpoint.host, port)
|
|
.await
|
|
.with_context(|| format!("dns lookup failed for tsdns result {}", endpoint.host))?;
|
|
if addresses.is_empty() {
|
|
bail!("no addresses found for tsdns result: {}", endpoint.host);
|
|
}
|
|
format_host_port(&addresses[0].to_string(), port)
|
|
};
|
|
|
|
Ok(client_resolution_with_optional_resolution(
|
|
input, method, address, resolution,
|
|
))
|
|
}
|
|
|
|
async fn query_tsdns_server(
|
|
&self,
|
|
server: &str,
|
|
port: u16,
|
|
query_host: &str,
|
|
) -> Result<ServerNameResolution> {
|
|
let addresses = self.lookup_ips(server, port).await?;
|
|
let mut errors = Vec::new();
|
|
|
|
for addr in addresses {
|
|
let socket = std::net::SocketAddr::new(addr, port);
|
|
match self.query_tsdns_socket(socket, query_host).await {
|
|
Ok(endpoint) => return Ok(endpoint),
|
|
Err(err) => errors.push(format!("{socket}: {err}")),
|
|
}
|
|
}
|
|
|
|
bail!("{}", errors.join("; "))
|
|
}
|
|
|
|
async fn query_tsdns_socket(
|
|
&self,
|
|
socket: std::net::SocketAddr,
|
|
query_host: &str,
|
|
) -> Result<ServerNameResolution> {
|
|
let mut stream = timeout(TSDNS_TIMEOUT, TcpStream::connect(socket))
|
|
.await
|
|
.context("tsdns connect timed out")?
|
|
.context("tsdns connect failed")?;
|
|
|
|
let mut request = query_host.trim().to_lowercase().into_bytes();
|
|
request.extend_from_slice(TSDNS_TERMINATOR);
|
|
timeout(TSDNS_TIMEOUT, stream.write_all(&request))
|
|
.await
|
|
.context("tsdns write timed out")?
|
|
.context("tsdns write failed")?;
|
|
|
|
let mut buffer = vec![0; 512];
|
|
let size = timeout(TSDNS_TIMEOUT, stream.read(&mut buffer))
|
|
.await
|
|
.context("tsdns read timed out")?
|
|
.context("tsdns read failed")?;
|
|
buffer.truncate(size);
|
|
|
|
let value = String::from_utf8(buffer).context("tsdns response returned invalid utf-8")?;
|
|
let value = value.trim();
|
|
if value.is_empty() {
|
|
bail!("empty tsdns response");
|
|
}
|
|
if value == "404" {
|
|
bail!("tsdns name not found");
|
|
}
|
|
|
|
parse_resolved_server_name(value)
|
|
}
|
|
|
|
async fn lookup_server_name(&self, name: &str) -> Result<Option<ServerNameResolution>> {
|
|
if name.is_empty() {
|
|
bail!("host cannot be empty");
|
|
}
|
|
|
|
info!(name = %name, "trying to resolve server name");
|
|
let value = lookup_named_value(&self.http, name, true, "server name")
|
|
.await?
|
|
.ok_or_else(|| anyhow::anyhow!("server name lookup returned no data"))?;
|
|
let endpoint = parse_resolved_server_name(value.trim())?;
|
|
info!(
|
|
name = %name,
|
|
host = %endpoint.host,
|
|
port = ?endpoint.port,
|
|
"server name successfully resolved"
|
|
);
|
|
Ok(Some(endpoint))
|
|
}
|
|
|
|
async fn resolve_normalized(&self, args: &Args) -> Result<Resolution> {
|
|
match args.service.as_str() {
|
|
"dns" => self.resolve_dns(&args.host).await,
|
|
"ts3" => self.resolve_ts3(&args.host, &args.protocol).await,
|
|
"tsdns" => self.resolve_tsdns(&args.host, &args.protocol).await,
|
|
"nick" => self.resolve_nick(&args.host, &args.protocol).await,
|
|
other => bail!("unsupported service: {other}"),
|
|
}
|
|
}
|
|
|
|
pub async fn resolve_dns(&self, host: &str) -> Result<Resolution> {
|
|
info!(host = %host, service = "dns", "dns lookup started");
|
|
let lookup = tokio::net::lookup_host((host, DEFAULT_TEAMSPEAK_PORT))
|
|
.await
|
|
.context("dns lookup failed")?;
|
|
let addrs = collect_ips(lookup);
|
|
|
|
if addrs.is_empty() {
|
|
bail!("no addresses found for host: {host}");
|
|
}
|
|
|
|
debug!(host = %host, addresses = ?addrs, "dns lookup successful");
|
|
info!(host = %host, addr = %addrs[0], "dns lookup result");
|
|
Ok(Resolution::Dns {
|
|
host: host.to_string(),
|
|
selected: addrs[0],
|
|
addresses: addrs,
|
|
})
|
|
}
|
|
|
|
pub async fn resolve_ts3(&self, host: &str, proto: &str) -> Result<Resolution> {
|
|
self.resolve_srv("ts3", DEFAULT_TS3_PROTO, host, proto)
|
|
.await
|
|
}
|
|
|
|
pub async fn resolve_tsdns(&self, host: &str, proto: &str) -> Result<Resolution> {
|
|
self.resolve_srv("tsdns", DEFAULT_TSDNS_PROTO, host, proto)
|
|
.await
|
|
}
|
|
|
|
pub async fn resolve_nick(&self, nick: &str, proto: &str) -> Result<Resolution> {
|
|
let proto = if proto.is_empty() {
|
|
DEFAULT_TS3_PROTO
|
|
} else {
|
|
proto
|
|
};
|
|
if proto == DEFAULT_TS3_PROTO {
|
|
debug!(protocol = %proto, "using default protocol for nick srv lookup");
|
|
}
|
|
|
|
info!(nick = %nick, service = "nick", "nick lookup started");
|
|
let addr = self.lookup_nick(nick).await?;
|
|
if addr.is_empty() {
|
|
bail!("no address found for nick: {nick}");
|
|
}
|
|
|
|
debug!(nick = %nick, address = %addr, "nick lookup successful");
|
|
let records = self
|
|
.lookup_srv("ts3", proto, &addr)
|
|
.await
|
|
.context("ts3 srv lookup for nick failed")?;
|
|
if records.is_empty() {
|
|
bail!("no srv records found for nick: {nick}");
|
|
}
|
|
|
|
debug!(nick = %nick, srv_records = ?records, "ts3 srv lookup for nick successful");
|
|
info!(
|
|
nick = %nick,
|
|
resolved = %records[0].resolved(),
|
|
"nick lookup result"
|
|
);
|
|
Ok(Resolution::Nick {
|
|
nick: nick.to_string(),
|
|
address: addr,
|
|
protocol: proto.to_string(),
|
|
selected: records[0].clone(),
|
|
records,
|
|
})
|
|
}
|
|
|
|
async fn resolve_srv(
|
|
&self,
|
|
service: &str,
|
|
default_proto: &str,
|
|
host: &str,
|
|
proto: &str,
|
|
) -> Result<Resolution> {
|
|
let proto = if proto.is_empty() {
|
|
default_proto
|
|
} else {
|
|
proto
|
|
};
|
|
if proto == default_proto {
|
|
debug!(protocol = %proto, "using default protocol for srv lookup");
|
|
}
|
|
|
|
info!(
|
|
host = %host,
|
|
service = %service,
|
|
protocol = %proto,
|
|
"{} srv lookup started",
|
|
service
|
|
);
|
|
let records = self.lookup_srv(service, proto, host).await?;
|
|
if records.is_empty() {
|
|
if service == "ts3" {
|
|
bail!("no SRV records found for host: {host}");
|
|
}
|
|
bail!("no srv records found for host: {host}");
|
|
}
|
|
|
|
debug!(
|
|
host = %host,
|
|
srv_records = ?records,
|
|
"{} srv lookup successful",
|
|
service
|
|
);
|
|
info!(
|
|
host = %host,
|
|
resolved = %records[0].resolved(),
|
|
"{} srv lookup result",
|
|
service
|
|
);
|
|
Ok(Resolution::Srv {
|
|
service: service.to_string(),
|
|
host: host.to_string(),
|
|
protocol: proto.to_string(),
|
|
selected: records[0].clone(),
|
|
records,
|
|
})
|
|
}
|
|
|
|
async fn lookup_srv(&self, service: &str, proto: &str, host: &str) -> Result<Vec<SrvRecord>> {
|
|
if host.is_empty() {
|
|
bail!("host cannot be empty");
|
|
}
|
|
|
|
if service == "ts3" && proto == "tcp" {
|
|
bail!("srv lookup for ts3 over tcp is not supported");
|
|
}
|
|
|
|
let query = format!("_{service}._{proto}.{host}");
|
|
let resolver = self.resolver.as_ref().context("srv resolver unavailable")?;
|
|
let result = resolver
|
|
.srv_lookup(query)
|
|
.await
|
|
.context("srv lookup failed")?;
|
|
let mut records = result
|
|
.answers()
|
|
.iter()
|
|
.filter_map(|record| {
|
|
record.try_borrow::<SRV>().map(|record| {
|
|
let data = record.data();
|
|
SrvRecord {
|
|
priority: data.priority,
|
|
weight: data.weight,
|
|
port: data.port,
|
|
target: data.target.to_string(),
|
|
}
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
sort_srv_records(&mut records);
|
|
Ok(records)
|
|
}
|
|
|
|
async fn lookup_ips(&self, host: &str, port: u16) -> Result<Vec<IpAddr>> {
|
|
let lookup = tokio::net::lookup_host((host, port))
|
|
.await
|
|
.context("dns lookup failed")?;
|
|
Ok(collect_ips(lookup))
|
|
}
|
|
|
|
async fn lookup_nick(&self, nick: &str) -> Result<String> {
|
|
if nick.is_empty() {
|
|
bail!("host cannot be empty");
|
|
}
|
|
|
|
let value = lookup_named_value(&self.http, nick, false, "nick")
|
|
.await?
|
|
.ok_or_else(|| anyhow::anyhow!("no data returned for nick: {nick}"))?;
|
|
|
|
if value.trim().is_empty() {
|
|
bail!("no data returned for nick: {nick}");
|
|
}
|
|
|
|
Ok(value)
|
|
}
|
|
}
|
|
|
|
fn srv_resolver_builder() -> Result<hickory_resolver::ResolverBuilder<TokioRuntimeProvider>> {
|
|
TokioResolver::builder_tokio().context("failed to initialize DNS resolver")
|
|
}
|
|
|
|
pub fn normalize_args(mut args: Args) -> Args {
|
|
args.host = args.host.trim().to_lowercase();
|
|
args.service = args.service.trim().to_lowercase();
|
|
args.protocol = args.protocol.trim().to_lowercase();
|
|
args
|
|
}
|
|
|
|
pub fn validate_args(args: &Args) -> Result<()> {
|
|
if args.host.is_empty() {
|
|
bail!("host cannot be empty");
|
|
}
|
|
|
|
match args.service.as_str() {
|
|
"dns" | "ts3" | "tsdns" | "nick" => {}
|
|
"" => bail!("service cannot be empty"),
|
|
other => bail!("unsupported service: {other}"),
|
|
}
|
|
|
|
match args.protocol.as_str() {
|
|
"" | "udp" | "tcp" => {}
|
|
other => bail!("unsupported protocol: {other}"),
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn run(args: Args) -> Result<()> {
|
|
let client = ChanoraResolver::new()?;
|
|
let normalized = normalize_args(args);
|
|
validate_args(&normalized)?;
|
|
|
|
debug!(build = %build_info(), "app start");
|
|
debug!(
|
|
host = %normalized.host,
|
|
service = %normalized.service,
|
|
protocol = %normalized.protocol,
|
|
"normalized flags"
|
|
);
|
|
|
|
client.resolve_normalized(&normalized).await.map(|_| ())
|
|
}
|
|
|
|
fn collect_ips(lookup: impl Iterator<Item = std::net::SocketAddr>) -> Vec<IpAddr> {
|
|
let mut addrs = lookup.map(|addr| addr.ip()).collect::<Vec<_>>();
|
|
addrs.sort_by_key(|addr| match addr {
|
|
IpAddr::V4(_) => 0u8,
|
|
IpAddr::V6(_) => 1u8,
|
|
});
|
|
addrs.dedup();
|
|
addrs
|
|
}
|
|
|
|
fn sort_srv_records(records: &mut [SrvRecord]) {
|
|
records.sort_by(|left, right| {
|
|
left.priority
|
|
.cmp(&right.priority)
|
|
.then_with(|| right.weight.cmp(&left.weight))
|
|
.then_with(|| left.target.cmp(&right.target))
|
|
.then_with(|| left.port.cmp(&right.port))
|
|
});
|
|
}
|
|
|
|
fn format_host_port(host: &str, port: u16) -> String {
|
|
if host.contains(':') && !host.starts_with('[') {
|
|
format!("[{host}]:{port}")
|
|
} else {
|
|
format!("{host}:{port}")
|
|
}
|
|
}
|
|
|
|
fn should_return_direct_dns_before_discovery(port: Option<u16>) -> bool {
|
|
port.is_some()
|
|
}
|
|
|
|
fn tsdns_discovery_budget(has_dns_fallback: bool) -> Option<Duration> {
|
|
has_dns_fallback.then_some(TSDNS_FALLBACK_DISCOVERY_BUDGET)
|
|
}
|
|
|
|
fn ts3_srv_discovery_budget(has_dns_fallback: bool) -> Option<Duration> {
|
|
has_dns_fallback.then_some(TS3_SRV_FALLBACK_DISCOVERY_BUDGET)
|
|
}
|
|
|
|
fn direct_client_resolution(
|
|
input: &str,
|
|
from_server_name: bool,
|
|
fallback_port: u16,
|
|
resolution: Resolution,
|
|
) -> ClientResolution {
|
|
let selected = match &resolution {
|
|
Resolution::Dns { selected, .. } => *selected,
|
|
_ => unreachable!("direct fallback must be a dns resolution"),
|
|
};
|
|
client_resolution_with_address(
|
|
input,
|
|
if from_server_name {
|
|
ClientResolutionMethod::Nick
|
|
} else {
|
|
ClientResolutionMethod::Dns
|
|
},
|
|
format_host_port(&selected.to_string(), fallback_port),
|
|
resolution,
|
|
)
|
|
}
|
|
|
|
fn client_resolution_with_address(
|
|
input: &str,
|
|
method: ClientResolutionMethod,
|
|
address: String,
|
|
resolution: Resolution,
|
|
) -> ClientResolution {
|
|
client_resolution_with_optional_resolution(input, method, address, Some(resolution))
|
|
}
|
|
|
|
fn client_resolution_with_optional_resolution(
|
|
input: &str,
|
|
method: ClientResolutionMethod,
|
|
address: String,
|
|
resolution: Option<Resolution>,
|
|
) -> ClientResolution {
|
|
info!(
|
|
input = %input,
|
|
method = ?method,
|
|
addr = %address,
|
|
"client request resolution result"
|
|
);
|
|
ClientResolution {
|
|
input: input.to_string(),
|
|
address,
|
|
method,
|
|
resolution,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct ClientRequest {
|
|
host: String,
|
|
port: Option<u16>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct ServerNameResolution {
|
|
host: String,
|
|
port: PortResolution,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum PortResolution {
|
|
Explicit(u16),
|
|
KeepInput,
|
|
}
|
|
|
|
impl PortResolution {
|
|
fn resolve(&self, fallback: u16) -> u16 {
|
|
match self {
|
|
PortResolution::Explicit(port) => *port,
|
|
PortResolution::KeepInput => fallback,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_resolved_server_name(value: &str) -> Result<ServerNameResolution> {
|
|
let value = value.trim().trim_matches('/');
|
|
if value.is_empty() {
|
|
bail!("resolved server name cannot be empty");
|
|
}
|
|
|
|
let value = strip_ts3server_scheme(value);
|
|
let (authority, query_port) = split_authority_and_port(value)?;
|
|
let authority = authority.trim_matches('/');
|
|
if authority.is_empty() {
|
|
bail!("resolved server name cannot be empty");
|
|
}
|
|
|
|
let authority = authority.trim().to_lowercase();
|
|
let (host, explicit_port) = match parse_host_port_components(&authority, true)? {
|
|
Some((host, port)) => (host, port),
|
|
None => (
|
|
authority,
|
|
query_port
|
|
.map(PortResolution::Explicit)
|
|
.unwrap_or(PortResolution::KeepInput),
|
|
),
|
|
};
|
|
|
|
Ok(ServerNameResolution {
|
|
host,
|
|
port: match explicit_port {
|
|
PortResolution::KeepInput => query_port
|
|
.map(PortResolution::Explicit)
|
|
.unwrap_or(PortResolution::KeepInput),
|
|
explicit => explicit,
|
|
},
|
|
})
|
|
}
|
|
|
|
fn should_try_server_name(host: &str) -> bool {
|
|
!host.contains('.') && host.parse::<IpAddr>().is_err()
|
|
}
|
|
|
|
fn tsdns_candidate_hosts(host: &str) -> Vec<String> {
|
|
let labels = host
|
|
.trim_end_matches('.')
|
|
.split('.')
|
|
.filter(|label| !label.is_empty())
|
|
.collect::<Vec<_>>();
|
|
if labels.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut candidates = Vec::new();
|
|
if labels.len() >= 2 {
|
|
candidates.push(labels[labels.len() - 2..].join("."));
|
|
}
|
|
|
|
let full = labels.join(".");
|
|
if !candidates.iter().any(|candidate| candidate == &full) {
|
|
candidates.push(full);
|
|
}
|
|
candidates
|
|
}
|
|
|
|
async fn lookup_named_value(
|
|
http: &Client,
|
|
name: &str,
|
|
allow_not_found: bool,
|
|
label: &str,
|
|
) -> Result<Option<String>> {
|
|
let response = http
|
|
.get(NICK_RESOLVE_URL)
|
|
.query(&[("name", name)])
|
|
.send()
|
|
.await
|
|
.with_context(|| format!("{label} lookup failed"))?;
|
|
read_lookup_body(response, allow_not_found, label).await
|
|
}
|
|
|
|
async fn read_lookup_body(
|
|
response: reqwest::Response,
|
|
allow_not_found: bool,
|
|
label: &str,
|
|
) -> Result<Option<String>> {
|
|
if allow_not_found && response.status() == reqwest::StatusCode::NOT_FOUND {
|
|
return Ok(None);
|
|
}
|
|
if !response.status().is_success() {
|
|
bail!("unexpected status code: {}", response.status());
|
|
}
|
|
|
|
let bytes = response
|
|
.bytes()
|
|
.await
|
|
.with_context(|| format!("{label} lookup failed"))?;
|
|
if bytes.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let value = String::from_utf8(bytes.to_vec())
|
|
.with_context(|| format!("{label} lookup returned invalid utf-8"))?;
|
|
Ok(Some(value))
|
|
}
|
|
|
|
fn normalize_client_input(input: &str) -> Result<ClientRequest> {
|
|
let value = input.trim();
|
|
if value.is_empty() {
|
|
bail!("host cannot be empty");
|
|
}
|
|
|
|
let value = strip_ts3server_scheme(value);
|
|
let (authority, query_port) = split_authority_and_port(value)?;
|
|
let authority = authority.trim_matches('/');
|
|
if authority.is_empty() {
|
|
bail!("host cannot be empty");
|
|
}
|
|
|
|
let authority = authority.trim().to_lowercase();
|
|
let (host, explicit_port) = parse_host_port_components(&authority, false)?
|
|
.unwrap_or((authority, PortResolution::KeepInput));
|
|
|
|
Ok(ClientRequest {
|
|
host,
|
|
port: match explicit_port {
|
|
PortResolution::Explicit(port) => Some(port),
|
|
PortResolution::KeepInput => query_port,
|
|
},
|
|
})
|
|
}
|
|
|
|
fn strip_ts3server_scheme(value: &str) -> &str {
|
|
value
|
|
.get(..12)
|
|
.filter(|prefix| prefix.eq_ignore_ascii_case("ts3server://"))
|
|
.map(|_| &value[12..])
|
|
.unwrap_or(value)
|
|
}
|
|
|
|
fn split_authority_and_port(value: &str) -> Result<(&str, Option<u16>)> {
|
|
let Some((authority, query)) = value.split_once('?') else {
|
|
return Ok((value, None));
|
|
};
|
|
Ok((authority, query_port(query)?))
|
|
}
|
|
|
|
fn query_port(query: &str) -> Result<Option<u16>> {
|
|
for pair in query.split('&') {
|
|
let Some((key, value)) = pair.split_once('=') else {
|
|
continue;
|
|
};
|
|
if key.eq_ignore_ascii_case("port") {
|
|
return Ok(Some(parse_port(value)?));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
fn parse_host_port_components(
|
|
input: &str,
|
|
allow_keep_input: bool,
|
|
) -> Result<Option<(String, PortResolution)>> {
|
|
if let Some(rest) = input.strip_prefix('[') {
|
|
let Some(end) = rest.find(']') else {
|
|
bail!("invalid bracketed ipv6 address");
|
|
};
|
|
let host = &rest[..end];
|
|
let suffix = &rest[end + 1..];
|
|
if suffix.is_empty() {
|
|
return Ok(Some((host.to_string(), PortResolution::KeepInput)));
|
|
}
|
|
let Some(port) = suffix.strip_prefix(':') else {
|
|
bail!("invalid bracketed ipv6 address");
|
|
};
|
|
return Ok(Some((
|
|
host.to_string(),
|
|
parse_resolved_port(port, allow_keep_input)?,
|
|
)));
|
|
}
|
|
|
|
let Some((host, port)) = input.rsplit_once(':') else {
|
|
return Ok(None);
|
|
};
|
|
|
|
if host.contains(':') {
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(Some((
|
|
host.to_string(),
|
|
parse_resolved_port(port, allow_keep_input)?,
|
|
)))
|
|
}
|
|
|
|
fn is_bare_numeric_name(host: &str) -> bool {
|
|
!host.is_empty() && host.chars().all(|value| value.is_ascii_digit())
|
|
}
|
|
|
|
fn parse_port(value: &str) -> Result<u16> {
|
|
if value.is_empty() {
|
|
bail!("port cannot be empty");
|
|
}
|
|
value
|
|
.parse::<u16>()
|
|
.with_context(|| format!("invalid port: {value}"))
|
|
}
|
|
|
|
fn parse_resolved_port(value: &str, allow_keep_input: bool) -> Result<PortResolution> {
|
|
if value.eq_ignore_ascii_case("$PORT") {
|
|
if !allow_keep_input {
|
|
bail!("port placeholder is not allowed in client input");
|
|
}
|
|
return Ok(PortResolution::KeepInput);
|
|
}
|
|
Ok(PortResolution::Explicit(parse_port(value)?))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn normalize_args_trims_and_lowercases() {
|
|
let args = normalize_args(Args {
|
|
host: " Example.COM ".into(),
|
|
service: " DNS ".into(),
|
|
protocol: " TCP ".into(),
|
|
});
|
|
|
|
assert_eq!(
|
|
args,
|
|
Args {
|
|
host: "example.com".into(),
|
|
service: "dns".into(),
|
|
protocol: "tcp".into(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_args_rejects_bad_service_and_proto() {
|
|
let err = validate_args(&Args {
|
|
host: "example.com".into(),
|
|
service: "bad".into(),
|
|
protocol: "".into(),
|
|
})
|
|
.unwrap_err();
|
|
assert!(err.to_string().contains("unsupported service"));
|
|
|
|
let err = validate_args(&Args {
|
|
host: "example.com".into(),
|
|
service: "dns".into(),
|
|
protocol: "bad".into(),
|
|
})
|
|
.unwrap_err();
|
|
assert!(err.to_string().contains("unsupported protocol"));
|
|
}
|
|
|
|
#[test]
|
|
fn srv_records_sort_by_preferred_order() {
|
|
let mut records = vec![
|
|
SrvRecord {
|
|
priority: 20,
|
|
weight: 10,
|
|
port: 9987,
|
|
target: "late.example.com.".into(),
|
|
},
|
|
SrvRecord {
|
|
priority: 10,
|
|
weight: 5,
|
|
port: 9987,
|
|
target: "fallback.example.com.".into(),
|
|
},
|
|
SrvRecord {
|
|
priority: 10,
|
|
weight: 20,
|
|
port: 9987,
|
|
target: "voice.example.com.".into(),
|
|
},
|
|
];
|
|
|
|
sort_srv_records(&mut records);
|
|
|
|
assert_eq!(records[0].target, "voice.example.com.");
|
|
assert_eq!(records[1].target, "fallback.example.com.");
|
|
assert_eq!(records[2].target, "late.example.com.");
|
|
}
|
|
|
|
#[test]
|
|
fn resolution_exposes_connection_address() {
|
|
let dns = Resolution::Dns {
|
|
host: "localhost".into(),
|
|
addresses: vec!["::1".parse().unwrap()],
|
|
selected: "::1".parse().unwrap(),
|
|
};
|
|
assert_eq!(dns.connection_address(), "[::1]:9987");
|
|
|
|
let srv = Resolution::Srv {
|
|
service: "ts3".into(),
|
|
host: "example.com".into(),
|
|
protocol: "udp".into(),
|
|
records: vec![SrvRecord {
|
|
priority: 0,
|
|
weight: 0,
|
|
port: 9988,
|
|
target: "voice.example.com.".into(),
|
|
}],
|
|
selected: SrvRecord {
|
|
priority: 0,
|
|
weight: 0,
|
|
port: 9988,
|
|
target: "voice.example.com.".into(),
|
|
},
|
|
};
|
|
assert_eq!(srv.connection_address(), "voice.example.com:9988");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tsdns_endpoint_preserves_srv_method_metadata() {
|
|
let resolver = ChanoraResolver {
|
|
resolver: None,
|
|
http: Client::new(),
|
|
};
|
|
let endpoint = ServerNameResolution {
|
|
host: "127.0.0.1".into(),
|
|
port: PortResolution::Explicit(9988),
|
|
};
|
|
let srv_resolution = Resolution::Srv {
|
|
service: "tsdns".into(),
|
|
host: "example.com".into(),
|
|
protocol: "tcp".into(),
|
|
records: vec![SrvRecord {
|
|
priority: 0,
|
|
weight: 0,
|
|
port: TSDNS_PORT,
|
|
target: "tsdns.example.com.".into(),
|
|
}],
|
|
selected: SrvRecord {
|
|
priority: 0,
|
|
weight: 0,
|
|
port: TSDNS_PORT,
|
|
target: "tsdns.example.com.".into(),
|
|
},
|
|
};
|
|
|
|
let via_srv = resolver
|
|
.client_resolved_tsdns_endpoint(
|
|
"example.com",
|
|
endpoint.clone(),
|
|
DEFAULT_TEAMSPEAK_PORT,
|
|
Some(srv_resolution),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(via_srv.method, ClientResolutionMethod::TsdnsSrv);
|
|
|
|
let via_tcp = resolver
|
|
.client_resolved_tsdns_endpoint("example.com", endpoint, DEFAULT_TEAMSPEAK_PORT, None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(via_tcp.method, ClientResolutionMethod::TsdnsTcp);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_client_input_handles_chanora_request_shapes() {
|
|
assert_eq!(
|
|
normalize_client_input(" Voice.Example.com ").unwrap(),
|
|
ClientRequest {
|
|
host: "voice.example.com".into(),
|
|
port: None,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
normalize_client_input("voice.example.com:9988").unwrap(),
|
|
ClientRequest {
|
|
host: "voice.example.com".into(),
|
|
port: Some(9988),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
normalize_client_input("ts3server://voice.example.com?port=9989").unwrap(),
|
|
ClientRequest {
|
|
host: "voice.example.com".into(),
|
|
port: Some(9989),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
normalize_client_input("[::1]:9987").unwrap(),
|
|
ClientRequest {
|
|
host: "::1".into(),
|
|
port: Some(9987),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
normalize_client_input("[::1]").unwrap(),
|
|
ClientRequest {
|
|
host: "::1".into(),
|
|
port: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bare_numeric_names_are_not_dns_hosts() {
|
|
assert!(is_bare_numeric_name("6666"));
|
|
assert!(!is_bare_numeric_name("127.0.0.1"));
|
|
assert!(!is_bare_numeric_name("voice6666"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_resolved_server_name_accepts_host_and_port_shapes() {
|
|
assert_eq!(
|
|
parse_resolved_server_name("teamspeak.app").unwrap(),
|
|
ServerNameResolution {
|
|
host: "teamspeak.app".into(),
|
|
port: PortResolution::KeepInput,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
parse_resolved_server_name("185.250.249.77:10075").unwrap(),
|
|
ServerNameResolution {
|
|
host: "185.250.249.77".into(),
|
|
port: PortResolution::Explicit(10075),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
parse_resolved_server_name("ts3server://Voice.TeamSpeak.com?port=9989").unwrap(),
|
|
ServerNameResolution {
|
|
host: "voice.teamspeak.com".into(),
|
|
port: PortResolution::Explicit(9989),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
parse_resolved_server_name("voice.teamspeak.com:$PORT").unwrap(),
|
|
ServerNameResolution {
|
|
host: "voice.teamspeak.com".into(),
|
|
port: PortResolution::KeepInput,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn server_name_lookup_is_only_for_dotless_hosts() {
|
|
assert!(should_try_server_name("6666"));
|
|
assert!(should_try_server_name("wwb"));
|
|
assert!(!should_try_server_name("voice.teamspeak.com"));
|
|
assert!(!should_try_server_name("127.0.0.1"));
|
|
}
|
|
|
|
#[test]
|
|
fn tsdns_candidates_include_parent_then_full_host() {
|
|
assert_eq!(
|
|
tsdns_candidate_hosts("voice.teamspeak.com"),
|
|
vec!["teamspeak.com", "voice.teamspeak.com"]
|
|
);
|
|
assert_eq!(
|
|
tsdns_candidate_hosts("teamspeak.app"),
|
|
vec!["teamspeak.app"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_ports_use_direct_dns_fast_path() {
|
|
assert!(should_return_direct_dns_before_discovery(Some(9987)));
|
|
assert!(!should_return_direct_dns_before_discovery(None));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explicit_port_client_request_uses_dns_result() {
|
|
let resolver = ChanoraResolver {
|
|
resolver: None,
|
|
http: Client::new(),
|
|
};
|
|
|
|
let resolved = resolver
|
|
.resolve_client_request("localhost:10075")
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(resolved.method, ClientResolutionMethod::Dns);
|
|
assert!(resolved.address.ends_with(":10075"));
|
|
}
|
|
|
|
#[test]
|
|
fn tsdns_discovery_is_bounded_when_dns_fallback_exists() {
|
|
assert_eq!(
|
|
tsdns_discovery_budget(true),
|
|
Some(TSDNS_FALLBACK_DISCOVERY_BUDGET)
|
|
);
|
|
assert_eq!(tsdns_discovery_budget(false), None);
|
|
}
|
|
|
|
#[test]
|
|
fn ts3_srv_discovery_is_bounded_when_dns_fallback_exists() {
|
|
assert_eq!(
|
|
ts3_srv_discovery_budget(true),
|
|
Some(TS3_SRV_FALLBACK_DISCOVERY_BUDGET)
|
|
);
|
|
assert_eq!(ts3_srv_discovery_budget(false), None);
|
|
}
|
|
}
|