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}