use local_ip_address::{Error, list_afinet_netifas, local_ip, local_ipv6};
use std::ffi::OsString;
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr},
sync::OnceLock,
};
const DEFAULT_LOOPBACK: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
static LOCAL_IP_FOR_ADVERTISE: OnceLock<IpAddr> = OnceLock::new();
pub(crate) fn host_override_from_env(name: &str) -> anyhow::Result<Option<String>> {
host_override_from_lookup(name, |key| std::env::var_os(key))
}
fn host_override_from_lookup(
name: &str,
mut get_env: impl FnMut(&str) -> Option<OsString>,
) -> anyhow::Result<Option<String>> {
let Some(value) = get_env(name) else {
return Ok(None);
};
let value = value
.into_string()
.map_err(|_| anyhow::anyhow!("{name} must contain valid Unicode"))?;
let value = value.trim();
Ok((!value.is_empty()).then(|| value.to_string()))
}
pub trait IpResolver {
fn local_ip(&self) -> Result<IpAddr, Error>;
fn local_ipv6(&self) -> Result<IpAddr, Error>;
fn list_afinet_netifas(&self) -> Result<Vec<(String, IpAddr)>, Error> {
list_afinet_netifas()
}
fn list_up_afinet_netifas(&self) -> Result<Vec<(String, IpAddr)>, Error> {
self.list_afinet_netifas()
}
}
pub struct DefaultIpResolver;
impl IpResolver for DefaultIpResolver {
fn local_ip(&self) -> Result<IpAddr, Error> {
local_ip()
}
fn local_ipv6(&self) -> Result<IpAddr, Error> {
local_ipv6()
}
#[cfg(unix)]
fn list_up_afinet_netifas(&self) -> Result<Vec<(String, IpAddr)>, Error> {
let interfaces = nix::ifaddrs::getifaddrs()
.map_err(|error| Error::StrategyError(format!("getifaddrs failed: {error}")))?;
Ok(up_ip_interfaces(interfaces))
}
}
#[cfg(unix)]
fn up_ip_interfaces(
interfaces: impl IntoIterator<Item = nix::ifaddrs::InterfaceAddress>,
) -> Vec<(String, IpAddr)> {
use nix::net::if_::InterfaceFlags;
interfaces
.into_iter()
.filter(|interface| interface.flags.contains(InterfaceFlags::IFF_UP))
.filter_map(|interface| {
let address = interface.address?;
let ip = if let Some(address) = address.as_sockaddr_in() {
IpAddr::V4(address.ip())
} else {
IpAddr::V6(address.as_sockaddr_in6()?.ip())
};
Some((interface.interface_name, ip))
})
.collect()
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum IpResolutionError {
#[error("failed to enumerate network interfaces: {0}")]
InterfaceEnumeration(#[source] Error),
#[error("interface not found: {0}")]
InterfaceNotFound(String),
#[error("interface has no usable IP address: {0}")]
NoUsableInterfaceAddress(String),
#[error("IP address is not usable: {0}")]
UnusableAddress(IpAddr),
#[error("invalid IP literal: {0}")]
InvalidLiteral(String),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct ResolvedHost {
bind_ip: IpAddr,
advertise_ip: IpAddr,
used_loopback_fallback: bool,
}
impl ResolvedHost {
pub(crate) fn same_address(address: IpAddr) -> Self {
Self {
bind_ip: address,
advertise_ip: address,
used_loopback_fallback: false,
}
}
pub(crate) fn loopback_fallback(address: IpAddr) -> Self {
Self {
bind_ip: address,
advertise_ip: address,
used_loopback_fallback: true,
}
}
pub(crate) fn bind_ip(self) -> IpAddr {
self.bind_ip
}
pub(crate) fn advertise_ip(self) -> IpAddr {
self.advertise_ip
}
pub(crate) fn used_loopback_fallback(self) -> bool {
self.used_loopback_fallback
}
}
#[derive(Default)]
struct AddressCandidates {
ipv4: Option<Ipv4Addr>,
ipv6: Option<Ipv6Addr>,
ipv4_loopback: Option<Ipv4Addr>,
ipv6_loopback: Option<Ipv6Addr>,
}
impl AddressCandidates {
fn consider(&mut self, address: IpAddr) {
let address = address.to_canonical();
if !is_usable(address) {
return;
}
match address {
IpAddr::V4(address) if address.is_loopback() => {
self.ipv4_loopback.get_or_insert(address);
}
IpAddr::V4(address) => {
self.ipv4.get_or_insert(address);
}
IpAddr::V6(address) if address.is_loopback() => {
self.ipv6_loopback.get_or_insert(address);
}
IpAddr::V6(address) => {
self.ipv6.get_or_insert(address);
}
}
}
fn preferred_non_loopback(&self) -> Option<IpAddr> {
self.ipv4
.map(IpAddr::V4)
.or_else(|| self.ipv6.map(IpAddr::V6))
}
fn preferred_loopback(&self) -> Option<IpAddr> {
self.ipv4_loopback
.map(IpAddr::V4)
.or_else(|| self.ipv6_loopback.map(IpAddr::V6))
}
fn non_loopback_for(&self, family: IpAddr) -> Option<IpAddr> {
match family {
IpAddr::V4(_) => self.ipv4.map(IpAddr::V4),
IpAddr::V6(_) => self.ipv6.map(IpAddr::V6),
}
}
fn loopback_for(&self, family: IpAddr) -> Option<IpAddr> {
match family {
IpAddr::V4(_) => self.ipv4_loopback.map(IpAddr::V4),
IpAddr::V6(_) => self.ipv6_loopback.map(IpAddr::V6),
}
}
fn other_non_loopback(&self, family: IpAddr) -> Option<IpAddr> {
match family {
IpAddr::V4(_) => self.ipv6.map(IpAddr::V6),
IpAddr::V6(_) => self.ipv4.map(IpAddr::V4),
}
}
}
fn local_candidates<R: IpResolver>(resolver: &R) -> Result<AddressCandidates, IpResolutionError> {
let interfaces = resolver
.list_up_afinet_netifas()
.map_err(IpResolutionError::InterfaceEnumeration)?;
let mut candidates = AddressCandidates::default();
for routed in [resolver.local_ip(), resolver.local_ipv6()]
.into_iter()
.flatten()
{
let routed = routed.to_canonical();
if interfaces
.iter()
.any(|(_, address)| address.to_canonical() == routed)
{
candidates.consider(routed);
}
}
for (_, address) in interfaces {
candidates.consider(address);
}
Ok(candidates)
}
pub(crate) fn resolve_local_host<R: IpResolver>(
resolver: &R,
) -> Result<ResolvedHost, IpResolutionError> {
let candidates = local_candidates(resolver)?;
if let Some(address) = candidates.preferred_non_loopback() {
return Ok(ResolvedHost::same_address(address));
}
Ok(ResolvedHost::loopback_fallback(
candidates.preferred_loopback().unwrap_or(DEFAULT_LOOPBACK),
))
}
pub(crate) fn resolve_host_or_interface<R: IpResolver>(
host_or_interface: &str,
resolver: &R,
) -> Result<ResolvedHost, IpResolutionError> {
let host_or_interface = host_or_interface.trim();
if let Some(address) = parse_ip_literal(host_or_interface)? {
if address.is_unspecified() {
return resolve_wildcard(address, &local_candidates(resolver)?);
}
validate_configured_address(address)?;
return Ok(ResolvedHost::same_address(address));
}
let interfaces = resolver
.list_afinet_netifas()
.map_err(IpResolutionError::InterfaceEnumeration)?;
let mut interface_found = false;
let mut candidates = AddressCandidates::default();
for (name, address) in interfaces {
if name != host_or_interface {
continue;
}
interface_found = true;
candidates.consider(address);
}
if !interface_found {
return Err(IpResolutionError::InterfaceNotFound(
host_or_interface.to_string(),
));
}
candidates
.preferred_non_loopback()
.or_else(|| candidates.preferred_loopback())
.map(ResolvedHost::same_address)
.ok_or_else(|| IpResolutionError::NoUsableInterfaceAddress(host_or_interface.to_string()))
}
fn resolve_wildcard(
wildcard: IpAddr,
candidates: &AddressCandidates,
) -> Result<ResolvedHost, IpResolutionError> {
if let Some(advertise_ip) = candidates.non_loopback_for(wildcard) {
return Ok(ResolvedHost {
bind_ip: wildcard,
advertise_ip,
used_loopback_fallback: false,
});
}
if let Some(advertise_ip) = candidates.other_non_loopback(wildcard) {
return Ok(ResolvedHost {
bind_ip: unspecified_for(advertise_ip),
advertise_ip,
used_loopback_fallback: false,
});
}
let advertise_ip = candidates
.loopback_for(wildcard)
.or_else(|| candidates.preferred_loopback())
.unwrap_or_else(|| loopback_for(wildcard));
Ok(ResolvedHost {
bind_ip: unspecified_for(advertise_ip),
advertise_ip,
used_loopback_fallback: true,
})
}
pub(crate) fn resolve_advertise_ip_for_bind<R: IpResolver>(
bind_ip: IpAddr,
resolver: &R,
) -> Result<IpAddr, IpResolutionError> {
let bind_ip = bind_ip.to_canonical();
if !bind_ip.is_unspecified() {
validate_configured_address(bind_ip)?;
return Ok(bind_ip);
}
let candidates = local_candidates(resolver)?;
let dual_stack_fallback = bind_ip
.is_ipv6()
.then(|| candidates.other_non_loopback(bind_ip))
.flatten();
Ok(candidates
.non_loopback_for(bind_ip)
.or(dual_stack_fallback)
.or_else(|| candidates.loopback_for(bind_ip))
.unwrap_or_else(|| loopback_for(bind_ip)))
}
fn unspecified_for(address: IpAddr) -> IpAddr {
match address {
IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED),
}
}
fn loopback_for(address: IpAddr) -> IpAddr {
match address {
IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::LOCALHOST),
}
}
fn parse_ip_literal(value: &str) -> Result<Option<IpAddr>, IpResolutionError> {
let has_open_bracket = value.starts_with('[');
let has_close_bracket = value.ends_with(']');
if has_open_bracket || has_close_bracket {
if !(has_open_bracket && has_close_bracket) {
return Err(IpResolutionError::InvalidLiteral(value.to_string()));
}
let inner = &value[1..value.len() - 1];
return inner
.parse::<Ipv6Addr>()
.map(|address| Some(IpAddr::V6(address).to_canonical()))
.map_err(|_| IpResolutionError::InvalidLiteral(value.to_string()));
}
match value.parse::<IpAddr>() {
Ok(address) => Ok(Some(address.to_canonical())),
Err(_) if looks_like_ip_literal(value) => {
Err(IpResolutionError::InvalidLiteral(value.to_string()))
}
Err(_) => Ok(None),
}
}
fn looks_like_ip_literal(value: &str) -> bool {
value.bytes().filter(|byte| *byte == b':').count() >= 2
|| (value.contains('.')
&& value
.chars()
.all(|character| character.is_ascii_digit() || character == '.'))
}
fn validate_configured_address(address: IpAddr) -> Result<(), IpResolutionError> {
if is_usable(address) {
Ok(())
} else {
Err(IpResolutionError::UnusableAddress(address))
}
}
fn is_usable(address: IpAddr) -> bool {
match address {
IpAddr::V4(address) => {
!address.is_unspecified() && !address.is_multicast() && !address.is_broadcast()
}
IpAddr::V6(address) => {
!address.is_unspecified() && !address.is_multicast() && !address.is_unicast_link_local()
}
}
}
pub fn local_ip_for_advertise() -> String {
cached_local_ip_for_advertise(&LOCAL_IP_FOR_ADVERTISE, &DefaultIpResolver)
}
#[deprecated(note = "DYN_TCP_RPC_HOST is resolved by the request-plane server at startup")]
pub fn tcp_rpc_host_from_env() -> String {
std::env::var(crate::config::environment_names::request_plane::DYN_TCP_RPC_HOST)
.unwrap_or_else(|_| local_ip_for_advertise())
}
fn cached_local_ip_for_advertise<R: IpResolver>(cache: &OnceLock<IpAddr>, resolver: &R) -> String {
if let Some(address) = cache.get() {
return format_host(*address);
}
match resolve_local_host(resolver) {
Ok(resolved) if !resolved.used_loopback_fallback() => {
let address = resolved.advertise_ip();
if cache.set(address).is_err() {
return format_host(*cache.get().expect("advertisement cache was initialized"));
}
format_host(address)
}
Ok(resolved) => {
let loopback = resolved.advertise_ip();
tracing::warn!(
%loopback,
"No usable non-loopback IP address found; advertising loopback"
);
format_host(loopback)
}
Err(error) => {
tracing::warn!(
%error,
loopback = %DEFAULT_LOOPBACK,
"Failed to resolve a usable local IP address; advertising loopback"
);
format_host(DEFAULT_LOOPBACK)
}
}
}
fn format_host(address: IpAddr) -> String {
match address {
IpAddr::V6(_) => format!("[{address}]"),
IpAddr::V4(_) => address.to_string(),
}
}
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use std::cell::Cell;
#[derive(Clone, Copy)]
pub(crate) enum ProbeOutcome {
Found(&'static str),
NotFound,
Strategy(&'static str),
Platform(&'static str),
}
impl ProbeOutcome {
fn result(self) -> Result<IpAddr, Error> {
match self {
Self::Found(address) => Ok(address.parse().expect("valid test address")),
Self::NotFound => Err(Error::LocalIpAddressNotFound),
Self::Strategy(message) => Err(Error::StrategyError(message.to_string())),
Self::Platform(platform) => Err(Error::PlatformNotSupported(platform.to_string())),
}
}
fn error(self) -> Error {
self.result().unwrap_err()
}
}
pub(crate) struct StubResolver {
pub(crate) ipv4: ProbeOutcome,
pub(crate) ipv6: ProbeOutcome,
pub(crate) interfaces: Vec<(&'static str, IpAddr)>,
pub(crate) down_interfaces: Vec<&'static str>,
pub(crate) interface_error: Option<ProbeOutcome>,
pub(crate) interface_calls: Cell<usize>,
}
impl StubResolver {
pub(crate) fn new(ipv4: ProbeOutcome, ipv6: ProbeOutcome) -> Self {
Self {
ipv4,
ipv6,
interfaces: vec![
("lo", IpAddr::V4(Ipv4Addr::LOCALHOST)),
("lo", IpAddr::V6(Ipv6Addr::LOCALHOST)),
],
down_interfaces: Vec::new(),
interface_error: None,
interface_calls: Cell::new(0),
}
}
pub(crate) fn not_found() -> Self {
Self::new(ProbeOutcome::NotFound, ProbeOutcome::NotFound)
}
}
impl IpResolver for StubResolver {
fn local_ip(&self) -> Result<IpAddr, Error> {
self.ipv4.result()
}
fn local_ipv6(&self) -> Result<IpAddr, Error> {
self.ipv6.result()
}
fn list_afinet_netifas(&self) -> Result<Vec<(String, IpAddr)>, Error> {
self.interface_calls.set(self.interface_calls.get() + 1);
if let Some(error) = self.interface_error {
return Err(error.error());
}
Ok(self
.interfaces
.iter()
.map(|(name, address)| ((*name).to_string(), *address))
.collect())
}
fn list_up_afinet_netifas(&self) -> Result<Vec<(String, IpAddr)>, Error> {
Ok(self
.list_afinet_netifas()?
.into_iter()
.filter(|(name, _)| !self.down_interfaces.contains(&name.as_str()))
.collect())
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::{ProbeOutcome, StubResolver};
use super::*;
use ProbeOutcome::{Found, NotFound, Platform, Strategy};
fn ip(address: &str) -> IpAddr {
address.parse().unwrap()
}
fn interface(name: &'static str, addresses: &[&str]) -> Vec<(&'static str, IpAddr)> {
addresses
.iter()
.map(|address| (name, ip(address)))
.collect()
}
#[test]
fn ipv6_only_inventory_uses_global_ipv6_when_family_probes_fail() {
let mut resolver = StubResolver::new(Strategy("IPv4 failed"), NotFound);
resolver.interfaces = vec![
("lo", ip("127.0.0.1")),
("lo", ip("::1")),
("eth0", ip("2001:db8::5")),
];
let resolved = resolve_local_host(&resolver).unwrap();
assert_eq!(resolved.bind_ip(), ip("2001:db8::5"));
assert!(!resolved.used_loopback_fallback());
assert_eq!(resolver.interface_calls.get(), 1);
}
#[test]
fn routed_address_is_preferred_over_earlier_interfaces() {
let mut resolver = StubResolver::new(Found("10.3.10.72"), Found("2001:db8::72"));
resolver.interfaces = vec![
("lo", ip("127.0.0.1")),
("bmc_redfish0", ip("10.0.1.2")),
("bmc_redfish0", ip("fd00::2")),
("eth0", ip("10.3.10.72")),
("eth0", ip("2001:db8::72")),
];
assert_eq!(
resolve_local_host(&resolver).unwrap().advertise_ip(),
ip("10.3.10.72")
);
let wildcard = resolve_host_or_interface("0.0.0.0", &resolver).unwrap();
assert_eq!(wildcard.bind_ip(), ip("0.0.0.0"));
assert_eq!(wildcard.advertise_ip(), ip("10.3.10.72"));
assert_eq!(
resolve_advertise_ip_for_bind(ip("0.0.0.0"), &resolver).unwrap(),
ip("10.3.10.72")
);
assert_eq!(
resolve_advertise_ip_for_bind(ip("::"), &resolver).unwrap(),
ip("2001:db8::72")
);
assert_eq!(
resolve_host_or_interface("bmc_redfish0", &resolver)
.unwrap()
.advertise_ip(),
ip("10.0.1.2")
);
}
#[test]
fn routed_address_outside_up_snapshot_is_ignored() {
for routed in ["198.51.100.1", "192.0.2.99"] {
let mut resolver = StubResolver::new(Found(routed), NotFound);
resolver.interfaces = vec![
("lo", ip("127.0.0.1")),
("stale0", ip("198.51.100.1")),
("eth0", ip("192.0.2.10")),
];
resolver.down_interfaces.push("stale0");
assert_eq!(
resolve_local_host(&resolver).unwrap().advertise_ip(),
ip("192.0.2.10")
);
}
}
#[cfg(unix)]
#[test]
fn system_inventory_keeps_only_up_ip_addresses() {
use nix::{
ifaddrs::InterfaceAddress, net::if_::InterfaceFlags, sys::socket::SockaddrStorage,
};
use std::{net::SocketAddr, os::fd::AsRawFd, os::unix::net::UnixDatagram};
let socket = UnixDatagram::unbound().unwrap();
let unsupported =
nix::sys::socket::getsockname::<SockaddrStorage>(socket.as_raw_fd()).unwrap();
let up = InterfaceFlags::IFF_UP;
let entries = [
(
"stale0",
InterfaceFlags::empty(),
Some(SocketAddr::new(ip("198.51.100.1"), 0).into()),
),
("eth0", up, Some(SocketAddr::new(ip("192.0.2.1"), 0).into())),
(
"eth0",
up,
Some(SocketAddr::new(ip("2001:db8::1"), 0).into()),
),
(
"lo",
up | InterfaceFlags::IFF_LOOPBACK,
Some(SocketAddr::new(ip("127.0.0.1"), 0).into()),
),
("unsupported", up, Some(unsupported)),
("no-address", up, None),
];
let interfaces = entries
.into_iter()
.map(|(name, flags, address)| InterfaceAddress {
interface_name: name.to_string(),
flags,
address,
netmask: None,
broadcast: None,
destination: None,
});
assert_eq!(
up_ip_interfaces(interfaces),
vec![
("eth0".to_string(), ip("192.0.2.1")),
("eth0".to_string(), ip("2001:db8::1")),
("lo".to_string(), ip("127.0.0.1")),
]
);
}
#[test]
fn automatic_selection_excludes_down_interfaces_but_explicit_lookup_keeps_them() {
for active in [Some("192.0.2.1"), Some("2001:db8::1"), None] {
let mut resolver = StubResolver::not_found();
resolver
.interfaces
.insert(0, ("stale0", ip("198.51.100.1")));
resolver.down_interfaces.push("stale0");
if let Some(address) = active {
resolver.interfaces.push(("eth0", ip(address)));
}
let expected = active.map(ip).unwrap_or(DEFAULT_LOOPBACK);
assert_eq!(
resolve_local_host(&resolver).unwrap().advertise_ip(),
expected
);
assert_eq!(
resolve_host_or_interface("0.0.0.0", &resolver)
.unwrap()
.advertise_ip(),
expected
);
assert_eq!(
resolve_advertise_ip_for_bind(unspecified_for(expected), &resolver).unwrap(),
expected
);
assert_eq!(
resolve_host_or_interface("stale0", &resolver)
.unwrap()
.advertise_ip(),
ip("198.51.100.1")
);
}
}
#[test]
fn configured_literals_are_parsed_and_validated() {
let resolver = StubResolver::not_found();
for (literal, expected) in [
("192.0.2.10", "192.0.2.10"),
("169.254.1.1", "169.254.1.1"),
("2001:db8::2", "2001:db8::2"),
("[2001:db8::2]", "2001:db8::2"),
("fd00::2", "fd00::2"),
(" 127.0.0.1 ", "127.0.0.1"),
("\t[::1] ", "::1"),
("::ffff:192.0.2.10", "192.0.2.10"),
("[::ffff:127.0.0.1]", "127.0.0.1"),
] {
let resolved = resolve_host_or_interface(literal, &resolver).unwrap();
assert_eq!(resolved.bind_ip(), ip(expected));
assert_eq!(resolved.advertise_ip(), resolved.bind_ip());
}
for literal in ["[::1", "::1]", "[not-an-ip]", "192.0.2.999"] {
let error = resolve_host_or_interface(literal, &resolver)
.unwrap_err()
.to_string();
assert!(error.contains("invalid IP literal"), "{error}");
}
for literal in [
"224.0.0.1",
"255.255.255.255",
"fe80::1",
"ff02::1",
"::ffff:224.0.0.1",
"[::ffff:255.255.255.255]",
] {
let error = resolve_host_or_interface(literal, &resolver).unwrap_err();
assert!(matches!(error, IpResolutionError::UnusableAddress(_)));
}
}
#[test]
fn wildcard_switches_bind_family_before_using_loopback() {
let mut resolver = StubResolver::new(NotFound, NotFound);
resolver.interfaces = interface("eth0", &["192.0.2.20"]);
let cases = [
("0.0.0.0", ip("0.0.0.0"), ip("192.0.2.20")),
("::", ip("0.0.0.0"), ip("192.0.2.20")),
("[::]", ip("0.0.0.0"), ip("192.0.2.20")),
("::ffff:0.0.0.0", ip("0.0.0.0"), ip("192.0.2.20")),
(" [::ffff:0.0.0.0] ", ip("0.0.0.0"), ip("192.0.2.20")),
];
for (literal, bind_ip, advertise_ip) in cases {
let resolved = resolve_host_or_interface(literal, &resolver).unwrap();
assert_eq!(resolved.bind_ip(), bind_ip);
assert_eq!(resolved.advertise_ip(), advertise_ip);
}
}
#[test]
fn wildcard_prefers_loopback_in_requested_family() {
let mut resolver = StubResolver::not_found();
resolver.interfaces = vec![("lo", ip("127.0.0.1")), ("lo", ip("::1"))];
for (literal, expected_bind, expected_advertise) in [
("0.0.0.0", ip("0.0.0.0"), ip("127.0.0.1")),
("::", ip("::"), ip("::1")),
("[::]", ip("::"), ip("::1")),
("::ffff:0.0.0.0", ip("0.0.0.0"), ip("127.0.0.1")),
] {
let resolved = resolve_host_or_interface(literal, &resolver).unwrap();
assert_eq!(resolved.bind_ip(), expected_bind);
assert_eq!(resolved.advertise_ip(), expected_advertise);
}
resolver.interfaces = vec![("lo", ip("127.0.0.1"))];
let resolved = resolve_host_or_interface("::", &resolver).unwrap();
assert_eq!(resolved.bind_ip(), ip("0.0.0.0"));
assert_eq!(resolved.advertise_ip(), ip("127.0.0.1"));
resolver.interfaces.clear();
let resolved = resolve_host_or_interface("::", &resolver).unwrap();
assert_eq!(resolved.bind_ip(), ip("::"));
assert_eq!(resolved.advertise_ip(), ip("::1"));
}
#[test]
fn interface_alias_preserves_order_and_accepts_ipv4_link_local() {
let mut resolver = StubResolver::not_found();
resolver.interfaces = interface("eth0:1", &["2001:db8::2", "169.254.10.5", "192.0.2.10"]);
for name in ["eth0:1", " eth0:1\t"] {
let resolved = resolve_host_or_interface(name, &resolver).unwrap();
assert_eq!(resolved.advertise_ip(), ip("169.254.10.5"));
}
}
#[test]
fn interface_errors_retain_context() {
let mut resolver = StubResolver::not_found();
resolver.interfaces = vec![("eth0", ip("fe80::1"))];
for (name, expected) in [
("missing", "interface not found: missing"),
("", "interface not found: "),
(" \t", "interface not found: "),
("eth0", "interface has no usable IP address: eth0"),
] {
let error = resolve_host_or_interface(name, &resolver)
.unwrap_err()
.to_string();
assert!(error.contains(expected), "{error}");
}
resolver.interface_error = Some(Platform("test-platform"));
let error = resolve_host_or_interface("eth0", &resolver)
.unwrap_err()
.to_string();
assert!(
error.contains("failed to enumerate network interfaces"),
"{error}"
);
assert!(error.contains("test-platform"), "{error}");
}
#[test]
fn loopback_only_host_preserves_ipv4_compatibility() {
let mut resolver = StubResolver::not_found();
resolver.interfaces = vec![
("lo", IpAddr::V4(Ipv4Addr::LOCALHOST)),
("lo", IpAddr::V6(Ipv6Addr::LOCALHOST)),
];
let resolved = resolve_local_host(&resolver).unwrap();
assert_eq!(resolved.advertise_ip(), DEFAULT_LOOPBACK);
assert!(resolved.used_loopback_fallback());
}
#[test]
fn bound_wildcard_advertisement_stays_in_bound_family() {
let mut resolver = StubResolver::not_found();
resolver.interfaces = vec![
("lo", ip("127.0.0.1")),
("lo", ip("::1")),
("eth0", ip("2001:db8::20")),
];
assert_eq!(
resolve_advertise_ip_for_bind(ip("0.0.0.0"), &resolver).unwrap(),
ip("127.0.0.1")
);
assert_eq!(
resolve_advertise_ip_for_bind(ip("::"), &resolver).unwrap(),
ip("2001:db8::20")
);
assert_eq!(
resolve_advertise_ip_for_bind(ip("::ffff:0.0.0.0"), &resolver).unwrap(),
ip("127.0.0.1")
);
assert_eq!(
resolve_advertise_ip_for_bind(ip("::ffff:127.0.0.1"), &resolver).unwrap(),
ip("127.0.0.1")
);
}
#[test]
fn ipv6_wildcard_advertisement_falls_back_to_ipv4() {
let mut resolver = StubResolver::not_found();
resolver.interfaces = vec![
("lo", ip("127.0.0.1")),
("lo", ip("::1")),
("eth0", ip("192.0.2.20")),
];
assert_eq!(
resolve_advertise_ip_for_bind(ip("::"), &resolver).unwrap(),
ip("192.0.2.20")
);
resolver.interfaces.push(("eth0", ip("2001:db8::20")));
assert_eq!(
resolve_advertise_ip_for_bind(ip("::"), &resolver).unwrap(),
ip("2001:db8::20")
);
resolver.interfaces.retain(|(name, _)| *name == "lo");
assert_eq!(
resolve_advertise_ip_for_bind(ip("::"), &resolver).unwrap(),
ip("::1")
);
}
#[test]
fn mapped_interface_addresses_use_ipv4_classification() {
let mut resolver = StubResolver::not_found();
resolver.interfaces = interface(
"eth0",
&[
"::ffff:0.0.0.0",
"::ffff:224.0.0.1",
"::ffff:255.255.255.255",
"::ffff:127.0.0.1",
"2001:db8::20",
],
);
assert_eq!(
resolve_local_host(&resolver).unwrap().advertise_ip(),
ip("2001:db8::20")
);
resolver.interfaces.push(("eth0", ip("::ffff:192.0.2.1")));
assert_eq!(
resolve_local_host(&resolver).unwrap().advertise_ip(),
ip("192.0.2.1")
);
assert_eq!(
resolve_host_or_interface("eth0", &resolver)
.unwrap()
.advertise_ip(),
ip("192.0.2.1")
);
}
#[test]
fn transient_fallbacks_are_not_cached_but_success_is() {
let cache = OnceLock::new();
let mut enumeration_error = StubResolver::not_found();
enumeration_error.interface_error = Some(Platform("transient"));
assert_eq!(
cached_local_ip_for_advertise(&cache, &enumeration_error),
"127.0.0.1"
);
assert!(cache.get().is_none());
let loopback = StubResolver::not_found();
assert_eq!(
cached_local_ip_for_advertise(&cache, &loopback),
"127.0.0.1"
);
assert!(cache.get().is_none());
let mut success = StubResolver::not_found();
success.interfaces.push(("eth0", ip("2001:db8::10")));
assert_eq!(
cached_local_ip_for_advertise(&cache, &success),
"[2001:db8::10]"
);
assert_eq!(cache.get(), Some(&ip("2001:db8::10")));
assert_eq!(
cached_local_ip_for_advertise(&cache, &loopback),
"[2001:db8::10]"
);
}
#[test]
fn legacy_resolver_implementation_remains_source_compatible() {
struct LegacyResolver;
impl IpResolver for LegacyResolver {
fn local_ip(&self) -> Result<IpAddr, Error> {
Err(Error::LocalIpAddressNotFound)
}
fn local_ipv6(&self) -> Result<IpAddr, Error> {
Err(Error::LocalIpAddressNotFound)
}
}
fn assert_resolver<T: IpResolver>() {}
assert_resolver::<LegacyResolver>();
}
#[test]
fn host_override_is_trimmed_and_empty_is_unset() {
assert_eq!(
host_override_from_lookup("TEST_HOST", |_| Some(OsString::from(" ib0 "))).unwrap(),
Some("ib0".to_string())
);
assert_eq!(
host_override_from_lookup("TEST_HOST", |_| Some(OsString::from(" \t"))).unwrap(),
None
);
assert_eq!(
host_override_from_lookup("TEST_HOST", |_| None).unwrap(),
None
);
}
#[cfg(unix)]
#[test]
fn non_unicode_host_override_is_rejected() {
use std::os::unix::ffi::OsStringExt;
let error =
host_override_from_lookup("TEST_HOST", |_| Some(OsString::from_vec(vec![0xff])))
.unwrap_err();
assert_eq!(error.to_string(), "TEST_HOST must contain valid Unicode");
}
}