Skip to main content

majordomus_cli/mesh/
address.rs

1//! Where this runtime can be reached: the `host:port` authorities it advertises and dials
2//! back. A server bound to a specific address advertises that address. A server bound to
3//! every interface (`0.0.0.0`) is reachable at each interface's address and at none
4//! called `0.0.0.0`, so it advertises its up, non-loopback IPv4 interface addresses —
5//! physical ones before container bridges, at most [`super::protocol::MAX_ENDPOINTS`].
6//! A server bound to loopback advertises loopback, which only this machine can dial; the
7//! mesh doctor says so.
8//!
9//! ```
10//! use majordomus_cli::mesh::address::advertised_endpoints;
11//! assert_eq!(advertised_endpoints("127.0.0.1:8742"), vec!["127.0.0.1:8742".to_string()]);
12//! assert!(advertised_endpoints("0.0.0.0:8742").iter().all(|e| !e.starts_with("0.0.0.0")));
13//! ```
14
15use std::net::{IpAddr, Ipv4Addr, SocketAddr};
16
17use super::protocol::MAX_ENDPOINTS;
18
19/// The endpoints a server bound at `bound` (`host:port`) advertises. A bind is what the
20/// socket was asked for; an endpoint is what another machine can dial, and the two differ
21/// exactly when the bind is unspecified — nobody can reach `0.0.0.0`, so that bind is
22/// expanded into the interfaces it actually listens on. A bind this cannot parse is
23/// passed through unchanged, because a name this process cannot resolve may still be one
24/// its peers can.
25///
26/// ```
27/// use majordomus_cli::mesh::address::advertised_endpoints;
28///
29/// assert_eq!(advertised_endpoints("127.0.0.1:8742"), ["127.0.0.1:8742"]);
30///
31/// // the unspecified bind never advertises itself, and keeps the port it was given
32/// let wide = advertised_endpoints("0.0.0.0:8742");
33/// assert!(wide.iter().all(|e| e.ends_with(":8742") && !e.starts_with("0.0.0.0")));
34/// assert!(!wide.is_empty(), "loopback stands in when no interface is up");
35///
36/// // a host this process cannot parse is still a host its peers may know
37/// assert_eq!(advertised_endpoints("gateway.local:8742"), ["gateway.local:8742"]);
38/// ```
39pub fn advertised_endpoints(bound: &str) -> Vec<String> {
40    let Ok(addr) = bound.parse::<SocketAddr>() else {
41        return vec![bound.to_string()];
42    };
43    if !addr.ip().is_unspecified() {
44        return vec![addr.to_string()];
45    }
46    let mut endpoints: Vec<String> = interfaces()
47        .into_iter()
48        .map(|(_, ip)| SocketAddr::new(IpAddr::V4(ip), addr.port()).to_string())
49        .collect();
50    endpoints.dedup();
51    endpoints.truncate(MAX_ENDPOINTS);
52    if endpoints.is_empty() {
53        endpoints.push(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), addr.port()).to_string());
54    }
55    endpoints
56}
57
58/// Whether every endpoint is a loopback address: reachable from this machine alone. A
59/// runtime in that state is not broken and is not on the mesh either, which is a
60/// distinction the doctor has to be able to draw before it blames the network for a
61/// cooperation that was never reachable. One routable endpoint is enough, so this asks of
62/// all of them rather than of any.
63///
64/// ```
65/// use majordomus_cli::mesh::address::loopback_only;
66///
67/// assert!(loopback_only(&["127.0.0.1:8742".into(), "localhost:8742".into()]));
68/// assert!(!loopback_only(&["127.0.0.1:8742".into(), "10.0.0.2:8742".into()]));
69/// ```
70pub fn loopback_only(endpoints: &[String]) -> bool {
71    endpoints.iter().all(|e| {
72        e.parse::<SocketAddr>()
73            .map(|a| a.ip().is_loopback())
74            .unwrap_or_else(|_| e.starts_with("localhost:"))
75    })
76}
77
78/// Rank an interface: physical and overlay interfaces first, container and VM bridges
79/// last — a remote runtime can dial the first, rarely the second.
80fn rank(name: &str) -> u8 {
81    const BRIDGES: &[&str] = &[
82        "docker", "br-", "virbr", "veth", "vmnet", "bridge", "lxc", "cni",
83    ];
84    u8::from(BRIDGES.iter().any(|b| name.starts_with(b)))
85}
86
87/// The up, non-loopback IPv4 interfaces of this machine, ranked, with their names. What is
88/// left out is what a remote runtime could not dial anyway: an interface that is down,
89/// loopback, unspecified, or link-local. The order is the advertising preference — a
90/// physical or overlay interface before a container or VM bridge — because only the first
91/// [`MAX_ENDPOINTS`] survive into an advertisement and a bridge address is the one a peer
92/// is least likely to reach.
93///
94/// ```
95/// use majordomus_cli::mesh::address::interfaces;
96///
97/// // whatever this machine has, every address here is one a peer could be told to dial
98/// for (name, ip) in interfaces() {
99///     assert!(!ip.is_loopback() && !ip.is_unspecified(), "{name} advertises {ip}");
100/// }
101/// ```
102#[cfg(unix)]
103pub fn interfaces() -> Vec<(String, Ipv4Addr)> {
104    let mut out: std::collections::BTreeMap<(u8, String, String), (String, Ipv4Addr)> =
105        std::collections::BTreeMap::new();
106    // SAFETY: getifaddrs hands back a linked list this function walks read-only and frees
107    // exactly once; every pointer is checked for null before it is read, and a sockaddr
108    // is reinterpreted as sockaddr_in only when its family says AF_INET.
109    unsafe {
110        let mut head: *mut libc::ifaddrs = std::ptr::null_mut();
111        if libc::getifaddrs(&mut head) != 0 {
112            return Vec::new();
113        }
114        let mut cursor = head;
115        while !cursor.is_null() {
116            let entry = &*cursor;
117            let flags = entry.ifa_flags as libc::c_int;
118            let address = entry.ifa_addr;
119            if flags & libc::IFF_UP != 0
120                && flags & libc::IFF_LOOPBACK == 0
121                && !address.is_null()
122                && libc::c_int::from((*address).sa_family) == libc::AF_INET
123            {
124                let sin = &*(address as *const libc::sockaddr_in);
125                let ip = Ipv4Addr::from(u32::from_be(sin.sin_addr.s_addr));
126                let name = if entry.ifa_name.is_null() {
127                    String::new()
128                } else {
129                    std::ffi::CStr::from_ptr(entry.ifa_name)
130                        .to_string_lossy()
131                        .into_owned()
132                };
133                if !ip.is_loopback() && !ip.is_unspecified() && !ip.is_link_local() {
134                    // Keyed by the declared preference of the interface, then by its name
135                    // and address: the map holds the order, so nothing here compares.
136                    out.insert((rank(&name), name.clone(), ip.to_string()), (name, ip));
137                }
138            }
139            cursor = entry.ifa_next;
140        }
141        libc::freeifaddrs(head);
142    }
143    out.into_values().collect()
144}
145
146/// No interface enumeration off Unix: `getifaddrs` is the only enumeration this crate
147/// carries, so on any other platform the bound address is all there is to advertise. The
148/// empty answer is not a failure — it makes a server bound to every interface fall back to
149/// advertising loopback, which is honest about what a peer can reach rather than guessing
150/// an address that may not exist.
151///
152/// ```
153/// use majordomus_cli::mesh::address::{advertised_endpoints, interfaces};
154///
155/// assert!(interfaces().is_empty(), "nothing is enumerated off Unix");
156/// assert_eq!(advertised_endpoints("0.0.0.0:8742"), ["127.0.0.1:8742"]);
157/// ```
158#[cfg(not(unix))]
159pub fn interfaces() -> Vec<(String, Ipv4Addr)> {
160    Vec::new()
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn a_specific_bind_advertises_itself_and_the_unspecified_bind_never_advertises_zero() {
169        assert_eq!(advertised_endpoints("192.168.1.5:9"), vec!["192.168.1.5:9"]);
170        let wide = advertised_endpoints("0.0.0.0:8742");
171        assert!(!wide.is_empty() && wide.len() <= MAX_ENDPOINTS);
172        assert!(wide
173            .iter()
174            .all(|e| e.ends_with(":8742") && !e.starts_with("0.0.0.0")));
175    }
176
177    #[test]
178    fn loopback_is_named_as_this_machine_only() {
179        assert!(loopback_only(&["127.0.0.1:1".into()]));
180        assert!(!loopback_only(&["127.0.0.1:1".into(), "10.0.0.2:1".into()]));
181    }
182
183    #[test]
184    fn bridges_rank_after_physical_interfaces() {
185        assert!(rank("docker0") > rank("eth0"));
186        assert!(rank("virbr0") > rank("tailscale0"));
187    }
188}