Skip to main content

dynamo_runtime/utils/
ip_resolver.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Local IP address resolution for advertising endpoints.
5
6use local_ip_address::{Error, list_afinet_netifas, local_ip, local_ipv6};
7use std::ffi::OsString;
8use std::{
9    net::{IpAddr, Ipv4Addr, Ipv6Addr},
10    sync::OnceLock,
11};
12
13const DEFAULT_LOOPBACK: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
14static LOCAL_IP_FOR_ADVERTISE: OnceLock<IpAddr> = OnceLock::new();
15
16/// Read and normalize a host override from the environment.
17pub(crate) fn host_override_from_env(name: &str) -> anyhow::Result<Option<String>> {
18    host_override_from_lookup(name, |key| std::env::var_os(key))
19}
20
21fn host_override_from_lookup(
22    name: &str,
23    mut get_env: impl FnMut(&str) -> Option<OsString>,
24) -> anyhow::Result<Option<String>> {
25    let Some(value) = get_env(name) else {
26        return Ok(None);
27    };
28    let value = value
29        .into_string()
30        .map_err(|_| anyhow::anyhow!("{name} must contain valid Unicode"))?;
31    let value = value.trim();
32    Ok((!value.is_empty()).then(|| value.to_string()))
33}
34
35/// IP address operations used by the runtime.
36///
37/// This trait allows address resolution and interface enumeration to be
38/// controlled in tests.
39pub trait IpResolver {
40    fn local_ip(&self) -> Result<IpAddr, Error>;
41    fn local_ipv6(&self) -> Result<IpAddr, Error>;
42
43    fn list_afinet_netifas(&self) -> Result<Vec<(String, IpAddr)>, Error> {
44        list_afinet_netifas()
45    }
46
47    /// List addresses eligible for automatic selection.
48    ///
49    /// Custom resolvers can override this to exclude down interfaces. The
50    /// default trusts their existing interface inventory for compatibility.
51    fn list_up_afinet_netifas(&self) -> Result<Vec<(String, IpAddr)>, Error> {
52        self.list_afinet_netifas()
53    }
54}
55
56/// The system IP resolver.
57pub struct DefaultIpResolver;
58
59impl IpResolver for DefaultIpResolver {
60    fn local_ip(&self) -> Result<IpAddr, Error> {
61        local_ip()
62    }
63
64    fn local_ipv6(&self) -> Result<IpAddr, Error> {
65        local_ipv6()
66    }
67
68    #[cfg(unix)]
69    fn list_up_afinet_netifas(&self) -> Result<Vec<(String, IpAddr)>, Error> {
70        let interfaces = nix::ifaddrs::getifaddrs()
71            .map_err(|error| Error::StrategyError(format!("getifaddrs failed: {error}")))?;
72        Ok(up_ip_interfaces(interfaces))
73    }
74}
75
76#[cfg(unix)]
77fn up_ip_interfaces(
78    interfaces: impl IntoIterator<Item = nix::ifaddrs::InterfaceAddress>,
79) -> Vec<(String, IpAddr)> {
80    use nix::net::if_::InterfaceFlags;
81
82    interfaces
83        .into_iter()
84        .filter(|interface| interface.flags.contains(InterfaceFlags::IFF_UP))
85        .filter_map(|interface| {
86            let address = interface.address?;
87            let ip = if let Some(address) = address.as_sockaddr_in() {
88                IpAddr::V4(address.ip())
89            } else {
90                IpAddr::V6(address.as_sockaddr_in6()?.ip())
91            };
92            Some((interface.interface_name, ip))
93        })
94        .collect()
95}
96
97#[derive(Debug, thiserror::Error)]
98pub(crate) enum IpResolutionError {
99    #[error("failed to enumerate network interfaces: {0}")]
100    InterfaceEnumeration(#[source] Error),
101
102    #[error("interface not found: {0}")]
103    InterfaceNotFound(String),
104
105    #[error("interface has no usable IP address: {0}")]
106    NoUsableInterfaceAddress(String),
107
108    #[error("IP address is not usable: {0}")]
109    UnusableAddress(IpAddr),
110
111    #[error("invalid IP literal: {0}")]
112    InvalidLiteral(String),
113}
114
115#[derive(Clone, Copy, Debug, Eq, PartialEq)]
116pub(crate) struct ResolvedHost {
117    bind_ip: IpAddr,
118    advertise_ip: IpAddr,
119    used_loopback_fallback: bool,
120}
121
122impl ResolvedHost {
123    pub(crate) fn same_address(address: IpAddr) -> Self {
124        Self {
125            bind_ip: address,
126            advertise_ip: address,
127            used_loopback_fallback: false,
128        }
129    }
130
131    pub(crate) fn loopback_fallback(address: IpAddr) -> Self {
132        Self {
133            bind_ip: address,
134            advertise_ip: address,
135            used_loopback_fallback: true,
136        }
137    }
138
139    pub(crate) fn bind_ip(self) -> IpAddr {
140        self.bind_ip
141    }
142
143    pub(crate) fn advertise_ip(self) -> IpAddr {
144        self.advertise_ip
145    }
146
147    pub(crate) fn used_loopback_fallback(self) -> bool {
148        self.used_loopback_fallback
149    }
150}
151
152#[derive(Default)]
153struct AddressCandidates {
154    ipv4: Option<Ipv4Addr>,
155    ipv6: Option<Ipv6Addr>,
156    ipv4_loopback: Option<Ipv4Addr>,
157    ipv6_loopback: Option<Ipv6Addr>,
158}
159
160impl AddressCandidates {
161    fn consider(&mut self, address: IpAddr) {
162        let address = address.to_canonical();
163        if !is_usable(address) {
164            return;
165        }
166
167        match address {
168            IpAddr::V4(address) if address.is_loopback() => {
169                self.ipv4_loopback.get_or_insert(address);
170            }
171            IpAddr::V4(address) => {
172                self.ipv4.get_or_insert(address);
173            }
174            IpAddr::V6(address) if address.is_loopback() => {
175                self.ipv6_loopback.get_or_insert(address);
176            }
177            IpAddr::V6(address) => {
178                self.ipv6.get_or_insert(address);
179            }
180        }
181    }
182
183    fn preferred_non_loopback(&self) -> Option<IpAddr> {
184        self.ipv4
185            .map(IpAddr::V4)
186            .or_else(|| self.ipv6.map(IpAddr::V6))
187    }
188
189    fn preferred_loopback(&self) -> Option<IpAddr> {
190        self.ipv4_loopback
191            .map(IpAddr::V4)
192            .or_else(|| self.ipv6_loopback.map(IpAddr::V6))
193    }
194
195    fn non_loopback_for(&self, family: IpAddr) -> Option<IpAddr> {
196        match family {
197            IpAddr::V4(_) => self.ipv4.map(IpAddr::V4),
198            IpAddr::V6(_) => self.ipv6.map(IpAddr::V6),
199        }
200    }
201
202    fn loopback_for(&self, family: IpAddr) -> Option<IpAddr> {
203        match family {
204            IpAddr::V4(_) => self.ipv4_loopback.map(IpAddr::V4),
205            IpAddr::V6(_) => self.ipv6_loopback.map(IpAddr::V6),
206        }
207    }
208
209    fn other_non_loopback(&self, family: IpAddr) -> Option<IpAddr> {
210        match family {
211            IpAddr::V4(_) => self.ipv6.map(IpAddr::V6),
212            IpAddr::V6(_) => self.ipv4.map(IpAddr::V4),
213        }
214    }
215}
216
217fn local_candidates<R: IpResolver>(resolver: &R) -> Result<AddressCandidates, IpResolutionError> {
218    let interfaces = resolver
219        .list_up_afinet_netifas()
220        .map_err(IpResolutionError::InterfaceEnumeration)?;
221    let mut candidates = AddressCandidates::default();
222
223    // Prefer the source address the operating system routes outbound traffic from
224    // (on Linux, the preferred source of the route to an external address). Hosts
225    // often have node-local interfaces, such as a BMC Redfish host interface, that
226    // enumerate before the cluster interface. A routed address is used only if an
227    // up interface in this snapshot carries it; failed probes are ignored.
228    for routed in [resolver.local_ip(), resolver.local_ipv6()]
229        .into_iter()
230        .flatten()
231    {
232        let routed = routed.to_canonical();
233        if interfaces
234            .iter()
235            .any(|(_, address)| address.to_canonical() == routed)
236        {
237            candidates.consider(routed);
238        }
239    }
240
241    for (_, address) in interfaces {
242        candidates.consider(address);
243    }
244    Ok(candidates)
245}
246
247/// Resolve the preferred local bind and advertisement address from one
248/// interface snapshot. The system resolver excludes down interfaces on Unix.
249pub(crate) fn resolve_local_host<R: IpResolver>(
250    resolver: &R,
251) -> Result<ResolvedHost, IpResolutionError> {
252    let candidates = local_candidates(resolver)?;
253    if let Some(address) = candidates.preferred_non_loopback() {
254        return Ok(ResolvedHost::same_address(address));
255    }
256
257    Ok(ResolvedHost::loopback_fallback(
258        candidates.preferred_loopback().unwrap_or(DEFAULT_LOOPBACK),
259    ))
260}
261
262/// Resolve a configured host value as an IP literal or interface name.
263///
264/// Named dual-stack interfaces prefer the first usable IPv4 address in OS
265/// enumeration order. IPv6 is used when no usable IPv4 address exists.
266/// Surrounding whitespace is trimmed, and IPv4-mapped addresses use IPv4 rules.
267pub(crate) fn resolve_host_or_interface<R: IpResolver>(
268    host_or_interface: &str,
269    resolver: &R,
270) -> Result<ResolvedHost, IpResolutionError> {
271    let host_or_interface = host_or_interface.trim();
272    if let Some(address) = parse_ip_literal(host_or_interface)? {
273        if address.is_unspecified() {
274            return resolve_wildcard(address, &local_candidates(resolver)?);
275        }
276
277        validate_configured_address(address)?;
278        return Ok(ResolvedHost::same_address(address));
279    }
280
281    let interfaces = resolver
282        .list_afinet_netifas()
283        .map_err(IpResolutionError::InterfaceEnumeration)?;
284    let mut interface_found = false;
285    let mut candidates = AddressCandidates::default();
286
287    for (name, address) in interfaces {
288        if name != host_or_interface {
289            continue;
290        }
291
292        interface_found = true;
293        candidates.consider(address);
294    }
295
296    if !interface_found {
297        return Err(IpResolutionError::InterfaceNotFound(
298            host_or_interface.to_string(),
299        ));
300    }
301
302    candidates
303        .preferred_non_loopback()
304        .or_else(|| candidates.preferred_loopback())
305        .map(ResolvedHost::same_address)
306        .ok_or_else(|| IpResolutionError::NoUsableInterfaceAddress(host_or_interface.to_string()))
307}
308
309fn resolve_wildcard(
310    wildcard: IpAddr,
311    candidates: &AddressCandidates,
312) -> Result<ResolvedHost, IpResolutionError> {
313    if let Some(advertise_ip) = candidates.non_loopback_for(wildcard) {
314        return Ok(ResolvedHost {
315            bind_ip: wildcard,
316            advertise_ip,
317            used_loopback_fallback: false,
318        });
319    }
320
321    if let Some(advertise_ip) = candidates.other_non_loopback(wildcard) {
322        return Ok(ResolvedHost {
323            bind_ip: unspecified_for(advertise_ip),
324            advertise_ip,
325            used_loopback_fallback: false,
326        });
327    }
328
329    let advertise_ip = candidates
330        .loopback_for(wildcard)
331        .or_else(|| candidates.preferred_loopback())
332        .unwrap_or_else(|| loopback_for(wildcard));
333    Ok(ResolvedHost {
334        bind_ip: unspecified_for(advertise_ip),
335        advertise_ip,
336        used_loopback_fallback: true,
337    })
338}
339
340/// Resolve an advertisement address that is served by an existing listener.
341/// Wildcard listeners prefer their bound family. An IPv6 wildcard listener also
342/// accepts IPv4 by default, so it falls back to a non-loopback IPv4 address on
343/// hosts without one for IPv6. An IPv4 wildcard never advertises IPv6.
344pub(crate) fn resolve_advertise_ip_for_bind<R: IpResolver>(
345    bind_ip: IpAddr,
346    resolver: &R,
347) -> Result<IpAddr, IpResolutionError> {
348    let bind_ip = bind_ip.to_canonical();
349    if !bind_ip.is_unspecified() {
350        validate_configured_address(bind_ip)?;
351        return Ok(bind_ip);
352    }
353
354    let candidates = local_candidates(resolver)?;
355    let dual_stack_fallback = bind_ip
356        .is_ipv6()
357        .then(|| candidates.other_non_loopback(bind_ip))
358        .flatten();
359    Ok(candidates
360        .non_loopback_for(bind_ip)
361        .or(dual_stack_fallback)
362        .or_else(|| candidates.loopback_for(bind_ip))
363        .unwrap_or_else(|| loopback_for(bind_ip)))
364}
365
366fn unspecified_for(address: IpAddr) -> IpAddr {
367    match address {
368        IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
369        IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED),
370    }
371}
372
373fn loopback_for(address: IpAddr) -> IpAddr {
374    match address {
375        IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::LOCALHOST),
376        IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::LOCALHOST),
377    }
378}
379
380fn parse_ip_literal(value: &str) -> Result<Option<IpAddr>, IpResolutionError> {
381    let has_open_bracket = value.starts_with('[');
382    let has_close_bracket = value.ends_with(']');
383
384    if has_open_bracket || has_close_bracket {
385        if !(has_open_bracket && has_close_bracket) {
386            return Err(IpResolutionError::InvalidLiteral(value.to_string()));
387        }
388
389        let inner = &value[1..value.len() - 1];
390        return inner
391            .parse::<Ipv6Addr>()
392            .map(|address| Some(IpAddr::V6(address).to_canonical()))
393            .map_err(|_| IpResolutionError::InvalidLiteral(value.to_string()));
394    }
395
396    match value.parse::<IpAddr>() {
397        Ok(address) => Ok(Some(address.to_canonical())),
398        Err(_) if looks_like_ip_literal(value) => {
399            Err(IpResolutionError::InvalidLiteral(value.to_string()))
400        }
401        Err(_) => Ok(None),
402    }
403}
404
405fn looks_like_ip_literal(value: &str) -> bool {
406    value.bytes().filter(|byte| *byte == b':').count() >= 2
407        || (value.contains('.')
408            && value
409                .chars()
410                .all(|character| character.is_ascii_digit() || character == '.'))
411}
412
413fn validate_configured_address(address: IpAddr) -> Result<(), IpResolutionError> {
414    if is_usable(address) {
415        Ok(())
416    } else {
417        Err(IpResolutionError::UnusableAddress(address))
418    }
419}
420
421fn is_usable(address: IpAddr) -> bool {
422    match address {
423        IpAddr::V4(address) => {
424            !address.is_unspecified() && !address.is_multicast() && !address.is_broadcast()
425        }
426        IpAddr::V6(address) => {
427            !address.is_unspecified() && !address.is_multicast() && !address.is_unicast_link_local()
428        }
429    }
430}
431
432/// Resolve the local IP for advertising endpoints, with loopback fallback.
433///
434/// IPv6 addresses are bracketed (for example, `[::1]`) so the result is safe
435/// to interpolate into a `host:port` URL. Resolution is cached for the process
436/// lifetime only after a non-loopback address is found. Enumeration failures
437/// and loopback fallbacks are retried on the next call.
438pub fn local_ip_for_advertise() -> String {
439    cached_local_ip_for_advertise(&LOCAL_IP_FOR_ADVERTISE, &DefaultIpResolver)
440}
441
442/// TCP RPC host: `DYN_TCP_RPC_HOST` if set, otherwise the resolved local IP.
443#[deprecated(note = "DYN_TCP_RPC_HOST is resolved by the request-plane server at startup")]
444pub fn tcp_rpc_host_from_env() -> String {
445    std::env::var(crate::config::environment_names::request_plane::DYN_TCP_RPC_HOST)
446        .unwrap_or_else(|_| local_ip_for_advertise())
447}
448
449fn cached_local_ip_for_advertise<R: IpResolver>(cache: &OnceLock<IpAddr>, resolver: &R) -> String {
450    if let Some(address) = cache.get() {
451        return format_host(*address);
452    }
453
454    match resolve_local_host(resolver) {
455        Ok(resolved) if !resolved.used_loopback_fallback() => {
456            let address = resolved.advertise_ip();
457            if cache.set(address).is_err() {
458                return format_host(*cache.get().expect("advertisement cache was initialized"));
459            }
460            format_host(address)
461        }
462        Ok(resolved) => {
463            let loopback = resolved.advertise_ip();
464            tracing::warn!(
465                %loopback,
466                "No usable non-loopback IP address found; advertising loopback"
467            );
468            format_host(loopback)
469        }
470        Err(error) => {
471            tracing::warn!(
472                %error,
473                loopback = %DEFAULT_LOOPBACK,
474                "Failed to resolve a usable local IP address; advertising loopback"
475            );
476            format_host(DEFAULT_LOOPBACK)
477        }
478    }
479}
480
481fn format_host(address: IpAddr) -> String {
482    match address {
483        IpAddr::V6(_) => format!("[{address}]"),
484        IpAddr::V4(_) => address.to_string(),
485    }
486}
487
488#[cfg(test)]
489pub(crate) mod test_support {
490    use super::*;
491    use std::cell::Cell;
492
493    #[derive(Clone, Copy)]
494    pub(crate) enum ProbeOutcome {
495        Found(&'static str),
496        NotFound,
497        Strategy(&'static str),
498        Platform(&'static str),
499    }
500
501    impl ProbeOutcome {
502        fn result(self) -> Result<IpAddr, Error> {
503            match self {
504                Self::Found(address) => Ok(address.parse().expect("valid test address")),
505                Self::NotFound => Err(Error::LocalIpAddressNotFound),
506                Self::Strategy(message) => Err(Error::StrategyError(message.to_string())),
507                Self::Platform(platform) => Err(Error::PlatformNotSupported(platform.to_string())),
508            }
509        }
510
511        fn error(self) -> Error {
512            self.result().unwrap_err()
513        }
514    }
515
516    pub(crate) struct StubResolver {
517        pub(crate) ipv4: ProbeOutcome,
518        pub(crate) ipv6: ProbeOutcome,
519        pub(crate) interfaces: Vec<(&'static str, IpAddr)>,
520        pub(crate) down_interfaces: Vec<&'static str>,
521        pub(crate) interface_error: Option<ProbeOutcome>,
522        pub(crate) interface_calls: Cell<usize>,
523    }
524
525    impl StubResolver {
526        pub(crate) fn new(ipv4: ProbeOutcome, ipv6: ProbeOutcome) -> Self {
527            Self {
528                ipv4,
529                ipv6,
530                interfaces: vec![
531                    ("lo", IpAddr::V4(Ipv4Addr::LOCALHOST)),
532                    ("lo", IpAddr::V6(Ipv6Addr::LOCALHOST)),
533                ],
534                down_interfaces: Vec::new(),
535                interface_error: None,
536                interface_calls: Cell::new(0),
537            }
538        }
539
540        pub(crate) fn not_found() -> Self {
541            Self::new(ProbeOutcome::NotFound, ProbeOutcome::NotFound)
542        }
543    }
544
545    impl IpResolver for StubResolver {
546        fn local_ip(&self) -> Result<IpAddr, Error> {
547            self.ipv4.result()
548        }
549
550        fn local_ipv6(&self) -> Result<IpAddr, Error> {
551            self.ipv6.result()
552        }
553
554        fn list_afinet_netifas(&self) -> Result<Vec<(String, IpAddr)>, Error> {
555            self.interface_calls.set(self.interface_calls.get() + 1);
556            if let Some(error) = self.interface_error {
557                return Err(error.error());
558            }
559
560            Ok(self
561                .interfaces
562                .iter()
563                .map(|(name, address)| ((*name).to_string(), *address))
564                .collect())
565        }
566
567        fn list_up_afinet_netifas(&self) -> Result<Vec<(String, IpAddr)>, Error> {
568            Ok(self
569                .list_afinet_netifas()?
570                .into_iter()
571                .filter(|(name, _)| !self.down_interfaces.contains(&name.as_str()))
572                .collect())
573        }
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::test_support::{ProbeOutcome, StubResolver};
580    use super::*;
581    use ProbeOutcome::{Found, NotFound, Platform, Strategy};
582
583    fn ip(address: &str) -> IpAddr {
584        address.parse().unwrap()
585    }
586
587    fn interface(name: &'static str, addresses: &[&str]) -> Vec<(&'static str, IpAddr)> {
588        addresses
589            .iter()
590            .map(|address| (name, ip(address)))
591            .collect()
592    }
593
594    #[test]
595    fn ipv6_only_inventory_uses_global_ipv6_when_family_probes_fail() {
596        let mut resolver = StubResolver::new(Strategy("IPv4 failed"), NotFound);
597        resolver.interfaces = vec![
598            ("lo", ip("127.0.0.1")),
599            ("lo", ip("::1")),
600            ("eth0", ip("2001:db8::5")),
601        ];
602
603        let resolved = resolve_local_host(&resolver).unwrap();
604        assert_eq!(resolved.bind_ip(), ip("2001:db8::5"));
605        assert!(!resolved.used_loopback_fallback());
606        assert_eq!(resolver.interface_calls.get(), 1);
607    }
608
609    #[test]
610    fn routed_address_is_preferred_over_earlier_interfaces() {
611        let mut resolver = StubResolver::new(Found("10.3.10.72"), Found("2001:db8::72"));
612        resolver.interfaces = vec![
613            ("lo", ip("127.0.0.1")),
614            ("bmc_redfish0", ip("10.0.1.2")),
615            ("bmc_redfish0", ip("fd00::2")),
616            ("eth0", ip("10.3.10.72")),
617            ("eth0", ip("2001:db8::72")),
618        ];
619
620        assert_eq!(
621            resolve_local_host(&resolver).unwrap().advertise_ip(),
622            ip("10.3.10.72")
623        );
624        let wildcard = resolve_host_or_interface("0.0.0.0", &resolver).unwrap();
625        assert_eq!(wildcard.bind_ip(), ip("0.0.0.0"));
626        assert_eq!(wildcard.advertise_ip(), ip("10.3.10.72"));
627        assert_eq!(
628            resolve_advertise_ip_for_bind(ip("0.0.0.0"), &resolver).unwrap(),
629            ip("10.3.10.72")
630        );
631        assert_eq!(
632            resolve_advertise_ip_for_bind(ip("::"), &resolver).unwrap(),
633            ip("2001:db8::72")
634        );
635        assert_eq!(
636            resolve_host_or_interface("bmc_redfish0", &resolver)
637                .unwrap()
638                .advertise_ip(),
639            ip("10.0.1.2")
640        );
641    }
642
643    #[test]
644    fn routed_address_outside_up_snapshot_is_ignored() {
645        for routed in ["198.51.100.1", "192.0.2.99"] {
646            let mut resolver = StubResolver::new(Found(routed), NotFound);
647            resolver.interfaces = vec![
648                ("lo", ip("127.0.0.1")),
649                ("stale0", ip("198.51.100.1")),
650                ("eth0", ip("192.0.2.10")),
651            ];
652            resolver.down_interfaces.push("stale0");
653
654            assert_eq!(
655                resolve_local_host(&resolver).unwrap().advertise_ip(),
656                ip("192.0.2.10")
657            );
658        }
659    }
660
661    #[cfg(unix)]
662    #[test]
663    fn system_inventory_keeps_only_up_ip_addresses() {
664        use nix::{
665            ifaddrs::InterfaceAddress, net::if_::InterfaceFlags, sys::socket::SockaddrStorage,
666        };
667        use std::{net::SocketAddr, os::fd::AsRawFd, os::unix::net::UnixDatagram};
668
669        let socket = UnixDatagram::unbound().unwrap();
670        let unsupported =
671            nix::sys::socket::getsockname::<SockaddrStorage>(socket.as_raw_fd()).unwrap();
672        let up = InterfaceFlags::IFF_UP;
673        let entries = [
674            (
675                "stale0",
676                InterfaceFlags::empty(),
677                Some(SocketAddr::new(ip("198.51.100.1"), 0).into()),
678            ),
679            ("eth0", up, Some(SocketAddr::new(ip("192.0.2.1"), 0).into())),
680            (
681                "eth0",
682                up,
683                Some(SocketAddr::new(ip("2001:db8::1"), 0).into()),
684            ),
685            (
686                "lo",
687                up | InterfaceFlags::IFF_LOOPBACK,
688                Some(SocketAddr::new(ip("127.0.0.1"), 0).into()),
689            ),
690            ("unsupported", up, Some(unsupported)),
691            ("no-address", up, None),
692        ];
693        let interfaces = entries
694            .into_iter()
695            .map(|(name, flags, address)| InterfaceAddress {
696                interface_name: name.to_string(),
697                flags,
698                address,
699                netmask: None,
700                broadcast: None,
701                destination: None,
702            });
703
704        assert_eq!(
705            up_ip_interfaces(interfaces),
706            vec![
707                ("eth0".to_string(), ip("192.0.2.1")),
708                ("eth0".to_string(), ip("2001:db8::1")),
709                ("lo".to_string(), ip("127.0.0.1")),
710            ]
711        );
712    }
713
714    #[test]
715    fn automatic_selection_excludes_down_interfaces_but_explicit_lookup_keeps_them() {
716        for active in [Some("192.0.2.1"), Some("2001:db8::1"), None] {
717            let mut resolver = StubResolver::not_found();
718            resolver
719                .interfaces
720                .insert(0, ("stale0", ip("198.51.100.1")));
721            resolver.down_interfaces.push("stale0");
722            if let Some(address) = active {
723                resolver.interfaces.push(("eth0", ip(address)));
724            }
725            let expected = active.map(ip).unwrap_or(DEFAULT_LOOPBACK);
726            assert_eq!(
727                resolve_local_host(&resolver).unwrap().advertise_ip(),
728                expected
729            );
730            assert_eq!(
731                resolve_host_or_interface("0.0.0.0", &resolver)
732                    .unwrap()
733                    .advertise_ip(),
734                expected
735            );
736            assert_eq!(
737                resolve_advertise_ip_for_bind(unspecified_for(expected), &resolver).unwrap(),
738                expected
739            );
740            assert_eq!(
741                resolve_host_or_interface("stale0", &resolver)
742                    .unwrap()
743                    .advertise_ip(),
744                ip("198.51.100.1")
745            );
746        }
747    }
748
749    #[test]
750    fn configured_literals_are_parsed_and_validated() {
751        let resolver = StubResolver::not_found();
752
753        for (literal, expected) in [
754            ("192.0.2.10", "192.0.2.10"),
755            ("169.254.1.1", "169.254.1.1"),
756            ("2001:db8::2", "2001:db8::2"),
757            ("[2001:db8::2]", "2001:db8::2"),
758            ("fd00::2", "fd00::2"),
759            (" 127.0.0.1 ", "127.0.0.1"),
760            ("\t[::1] ", "::1"),
761            ("::ffff:192.0.2.10", "192.0.2.10"),
762            ("[::ffff:127.0.0.1]", "127.0.0.1"),
763        ] {
764            let resolved = resolve_host_or_interface(literal, &resolver).unwrap();
765            assert_eq!(resolved.bind_ip(), ip(expected));
766            assert_eq!(resolved.advertise_ip(), resolved.bind_ip());
767        }
768
769        for literal in ["[::1", "::1]", "[not-an-ip]", "192.0.2.999"] {
770            let error = resolve_host_or_interface(literal, &resolver)
771                .unwrap_err()
772                .to_string();
773            assert!(error.contains("invalid IP literal"), "{error}");
774        }
775
776        for literal in [
777            "224.0.0.1",
778            "255.255.255.255",
779            "fe80::1",
780            "ff02::1",
781            "::ffff:224.0.0.1",
782            "[::ffff:255.255.255.255]",
783        ] {
784            let error = resolve_host_or_interface(literal, &resolver).unwrap_err();
785            assert!(matches!(error, IpResolutionError::UnusableAddress(_)));
786        }
787    }
788
789    #[test]
790    fn wildcard_switches_bind_family_before_using_loopback() {
791        let mut resolver = StubResolver::new(NotFound, NotFound);
792        resolver.interfaces = interface("eth0", &["192.0.2.20"]);
793        let cases = [
794            ("0.0.0.0", ip("0.0.0.0"), ip("192.0.2.20")),
795            ("::", ip("0.0.0.0"), ip("192.0.2.20")),
796            ("[::]", ip("0.0.0.0"), ip("192.0.2.20")),
797            ("::ffff:0.0.0.0", ip("0.0.0.0"), ip("192.0.2.20")),
798            (" [::ffff:0.0.0.0] ", ip("0.0.0.0"), ip("192.0.2.20")),
799        ];
800
801        for (literal, bind_ip, advertise_ip) in cases {
802            let resolved = resolve_host_or_interface(literal, &resolver).unwrap();
803            assert_eq!(resolved.bind_ip(), bind_ip);
804            assert_eq!(resolved.advertise_ip(), advertise_ip);
805        }
806    }
807
808    #[test]
809    fn wildcard_prefers_loopback_in_requested_family() {
810        let mut resolver = StubResolver::not_found();
811        resolver.interfaces = vec![("lo", ip("127.0.0.1")), ("lo", ip("::1"))];
812
813        for (literal, expected_bind, expected_advertise) in [
814            ("0.0.0.0", ip("0.0.0.0"), ip("127.0.0.1")),
815            ("::", ip("::"), ip("::1")),
816            ("[::]", ip("::"), ip("::1")),
817            ("::ffff:0.0.0.0", ip("0.0.0.0"), ip("127.0.0.1")),
818        ] {
819            let resolved = resolve_host_or_interface(literal, &resolver).unwrap();
820            assert_eq!(resolved.bind_ip(), expected_bind);
821            assert_eq!(resolved.advertise_ip(), expected_advertise);
822        }
823
824        resolver.interfaces = vec![("lo", ip("127.0.0.1"))];
825        let resolved = resolve_host_or_interface("::", &resolver).unwrap();
826        assert_eq!(resolved.bind_ip(), ip("0.0.0.0"));
827        assert_eq!(resolved.advertise_ip(), ip("127.0.0.1"));
828
829        resolver.interfaces.clear();
830        let resolved = resolve_host_or_interface("::", &resolver).unwrap();
831        assert_eq!(resolved.bind_ip(), ip("::"));
832        assert_eq!(resolved.advertise_ip(), ip("::1"));
833    }
834
835    #[test]
836    fn interface_alias_preserves_order_and_accepts_ipv4_link_local() {
837        let mut resolver = StubResolver::not_found();
838        resolver.interfaces = interface("eth0:1", &["2001:db8::2", "169.254.10.5", "192.0.2.10"]);
839
840        for name in ["eth0:1", " eth0:1\t"] {
841            let resolved = resolve_host_or_interface(name, &resolver).unwrap();
842            assert_eq!(resolved.advertise_ip(), ip("169.254.10.5"));
843        }
844    }
845
846    #[test]
847    fn interface_errors_retain_context() {
848        let mut resolver = StubResolver::not_found();
849        resolver.interfaces = vec![("eth0", ip("fe80::1"))];
850
851        for (name, expected) in [
852            ("missing", "interface not found: missing"),
853            ("", "interface not found: "),
854            (" \t", "interface not found: "),
855            ("eth0", "interface has no usable IP address: eth0"),
856        ] {
857            let error = resolve_host_or_interface(name, &resolver)
858                .unwrap_err()
859                .to_string();
860            assert!(error.contains(expected), "{error}");
861        }
862
863        resolver.interface_error = Some(Platform("test-platform"));
864        let error = resolve_host_or_interface("eth0", &resolver)
865            .unwrap_err()
866            .to_string();
867        assert!(
868            error.contains("failed to enumerate network interfaces"),
869            "{error}"
870        );
871        assert!(error.contains("test-platform"), "{error}");
872    }
873
874    #[test]
875    fn loopback_only_host_preserves_ipv4_compatibility() {
876        let mut resolver = StubResolver::not_found();
877        resolver.interfaces = vec![
878            ("lo", IpAddr::V4(Ipv4Addr::LOCALHOST)),
879            ("lo", IpAddr::V6(Ipv6Addr::LOCALHOST)),
880        ];
881
882        let resolved = resolve_local_host(&resolver).unwrap();
883        assert_eq!(resolved.advertise_ip(), DEFAULT_LOOPBACK);
884        assert!(resolved.used_loopback_fallback());
885    }
886
887    #[test]
888    fn bound_wildcard_advertisement_stays_in_bound_family() {
889        let mut resolver = StubResolver::not_found();
890        resolver.interfaces = vec![
891            ("lo", ip("127.0.0.1")),
892            ("lo", ip("::1")),
893            ("eth0", ip("2001:db8::20")),
894        ];
895
896        assert_eq!(
897            resolve_advertise_ip_for_bind(ip("0.0.0.0"), &resolver).unwrap(),
898            ip("127.0.0.1")
899        );
900        assert_eq!(
901            resolve_advertise_ip_for_bind(ip("::"), &resolver).unwrap(),
902            ip("2001:db8::20")
903        );
904        assert_eq!(
905            resolve_advertise_ip_for_bind(ip("::ffff:0.0.0.0"), &resolver).unwrap(),
906            ip("127.0.0.1")
907        );
908        assert_eq!(
909            resolve_advertise_ip_for_bind(ip("::ffff:127.0.0.1"), &resolver).unwrap(),
910            ip("127.0.0.1")
911        );
912    }
913
914    #[test]
915    fn ipv6_wildcard_advertisement_falls_back_to_ipv4() {
916        let mut resolver = StubResolver::not_found();
917        resolver.interfaces = vec![
918            ("lo", ip("127.0.0.1")),
919            ("lo", ip("::1")),
920            ("eth0", ip("192.0.2.20")),
921        ];
922        assert_eq!(
923            resolve_advertise_ip_for_bind(ip("::"), &resolver).unwrap(),
924            ip("192.0.2.20")
925        );
926
927        resolver.interfaces.push(("eth0", ip("2001:db8::20")));
928        assert_eq!(
929            resolve_advertise_ip_for_bind(ip("::"), &resolver).unwrap(),
930            ip("2001:db8::20")
931        );
932
933        resolver.interfaces.retain(|(name, _)| *name == "lo");
934        assert_eq!(
935            resolve_advertise_ip_for_bind(ip("::"), &resolver).unwrap(),
936            ip("::1")
937        );
938    }
939
940    #[test]
941    fn mapped_interface_addresses_use_ipv4_classification() {
942        let mut resolver = StubResolver::not_found();
943        resolver.interfaces = interface(
944            "eth0",
945            &[
946                "::ffff:0.0.0.0",
947                "::ffff:224.0.0.1",
948                "::ffff:255.255.255.255",
949                "::ffff:127.0.0.1",
950                "2001:db8::20",
951            ],
952        );
953        assert_eq!(
954            resolve_local_host(&resolver).unwrap().advertise_ip(),
955            ip("2001:db8::20")
956        );
957        resolver.interfaces.push(("eth0", ip("::ffff:192.0.2.1")));
958        assert_eq!(
959            resolve_local_host(&resolver).unwrap().advertise_ip(),
960            ip("192.0.2.1")
961        );
962        assert_eq!(
963            resolve_host_or_interface("eth0", &resolver)
964                .unwrap()
965                .advertise_ip(),
966            ip("192.0.2.1")
967        );
968    }
969
970    #[test]
971    fn transient_fallbacks_are_not_cached_but_success_is() {
972        let cache = OnceLock::new();
973        let mut enumeration_error = StubResolver::not_found();
974        enumeration_error.interface_error = Some(Platform("transient"));
975        assert_eq!(
976            cached_local_ip_for_advertise(&cache, &enumeration_error),
977            "127.0.0.1"
978        );
979        assert!(cache.get().is_none());
980
981        let loopback = StubResolver::not_found();
982        assert_eq!(
983            cached_local_ip_for_advertise(&cache, &loopback),
984            "127.0.0.1"
985        );
986        assert!(cache.get().is_none());
987
988        let mut success = StubResolver::not_found();
989        success.interfaces.push(("eth0", ip("2001:db8::10")));
990        assert_eq!(
991            cached_local_ip_for_advertise(&cache, &success),
992            "[2001:db8::10]"
993        );
994        assert_eq!(cache.get(), Some(&ip("2001:db8::10")));
995
996        assert_eq!(
997            cached_local_ip_for_advertise(&cache, &loopback),
998            "[2001:db8::10]"
999        );
1000    }
1001
1002    #[test]
1003    fn legacy_resolver_implementation_remains_source_compatible() {
1004        struct LegacyResolver;
1005
1006        impl IpResolver for LegacyResolver {
1007            fn local_ip(&self) -> Result<IpAddr, Error> {
1008                Err(Error::LocalIpAddressNotFound)
1009            }
1010
1011            fn local_ipv6(&self) -> Result<IpAddr, Error> {
1012                Err(Error::LocalIpAddressNotFound)
1013            }
1014        }
1015
1016        fn assert_resolver<T: IpResolver>() {}
1017        assert_resolver::<LegacyResolver>();
1018    }
1019
1020    #[test]
1021    fn host_override_is_trimmed_and_empty_is_unset() {
1022        assert_eq!(
1023            host_override_from_lookup("TEST_HOST", |_| Some(OsString::from(" ib0 "))).unwrap(),
1024            Some("ib0".to_string())
1025        );
1026        assert_eq!(
1027            host_override_from_lookup("TEST_HOST", |_| Some(OsString::from(" \t"))).unwrap(),
1028            None
1029        );
1030        assert_eq!(
1031            host_override_from_lookup("TEST_HOST", |_| None).unwrap(),
1032            None
1033        );
1034    }
1035
1036    #[cfg(unix)]
1037    #[test]
1038    fn non_unicode_host_override_is_rejected() {
1039        use std::os::unix::ffi::OsStringExt;
1040
1041        let error =
1042            host_override_from_lookup("TEST_HOST", |_| Some(OsString::from_vec(vec![0xff])))
1043                .unwrap_err();
1044        assert_eq!(error.to_string(), "TEST_HOST must contain valid Unicode");
1045    }
1046}