kumo_mac_address/
lib.rs

1//! Resolves the 6 byte MAC address that identifies this node.
2//!
3//! The MAC becomes part of the node identity embedded in v1 UUIDs (spool ids,
4//! node ids) and is reported in machine info. It must be distinct between
5//! hosts. The naive "first interface the OS lists" choice is unreliable: on
6//! cloud and containerized hosts it frequently selects a virtual device (a
7//! container bridge, a veth pair) whose address is identical across otherwise
8//! separate machines, which then produces colliding node identities.
9//!
10//! To avoid that, resolution accepts explicit operator overrides and, failing
11//! those, prefers a physical interface over a virtual one. One cached value is
12//! shared by every consumer so the id embedded in spool ids and the address
13//! shown in machine info always agree.
14
15use std::sync::LazyLock;
16
17use mac_address::mac_address_by_name;
18use nix::ifaddrs::getifaddrs;
19
20/// Environment variable naming a literal MAC address to use verbatim, in the
21/// usual colon or hyphen separated hex form. Each host must set a distinct
22/// value. A shared value recreates the cross-host collisions that this
23/// resolution logic exists to prevent.
24const ENV_MAC_ADDRESS: &str = "KUMO_MAC_ADDRESS";
25
26/// Environment variable naming the network interface (such as `eth0` or `ens5`)
27/// whose MAC address identifies this node.
28const ENV_MAC_INTERFACE: &str = "KUMO_MAC_INTERFACE";
29
30/// Interface names for virtual devices, matched exactly. The loopback device
31/// is the only single-instance virtual device we skip by name, and matching it
32/// by prefix would also catch real interfaces an operator named `lom0` or
33/// similar.
34const VIRTUAL_INTERFACE_NAMES: &[&str] = &["lo", "lo0"];
35
36/// Interface name prefixes for virtual device families whose MAC is often
37/// identical across otherwise distinct hosts. These come in numbered instances
38/// (`docker0`, `veth1234`), and a prefix is the most reliable way to catch
39/// every one. Automatic selection skips these in favor of a physical interface.
40const VIRTUAL_INTERFACE_PREFIXES: &[&str] = &[
41    "br-", "cali", "cni", "docker", "dummy", "flannel", "gre", "ifb", "ip6tnl", "kube", "nlmon",
42    "sit", "tap", "tun", "veth", "virbr", "vmbr", "vnet", "wg", "zt",
43];
44
45static MAC: LazyLock<[u8; 6]> = LazyLock::new(resolve);
46
47/// Returns the MAC address that identifies this node. The value is resolved
48/// once on first use and cached for the lifetime of the process. Any
49/// environment overrides must be set before this is first called.
50pub fn get_mac_address() -> &'static [u8; 6] {
51    &MAC
52}
53
54/// Work through the override, physical-interface, and gethostid sources in
55/// preference order, returning the first that yields a usable address.
56fn resolve() -> [u8; 6] {
57    if let Some(mac) = from_env_literal() {
58        tracing::info!("using node MAC {} from {ENV_MAC_ADDRESS}", format_mac(&mac));
59        return mac;
60    }
61
62    if let Some((mac, name)) = from_env_interface() {
63        tracing::info!(
64            "using node MAC {} from interface {name} named by {ENV_MAC_INTERFACE}",
65            format_mac(&mac)
66        );
67        return mac;
68    }
69
70    if let Some((mac, name)) = first_physical_interface() {
71        tracing::info!("using node MAC {} from interface {name}", format_mac(&mac));
72        return mac;
73    }
74
75    let mac = from_host_id();
76    tracing::warn!(
77        "no usable network interface MAC found; derived node MAC {} from gethostid()",
78        format_mac(&mac)
79    );
80    mac
81}
82
83fn from_env_literal() -> Option<[u8; 6]> {
84    let value = std::env::var(ENV_MAC_ADDRESS).ok()?;
85    let value = value.trim();
86    if value.is_empty() {
87        return None;
88    }
89    match parse_mac(value) {
90        Some(mac) if mac.iter().all(|b| *b == 0) => {
91            tracing::error!("{ENV_MAC_ADDRESS}=`{value}` is a zero MAC address; ignoring it");
92            None
93        }
94        Some(mac) => Some(mac),
95        None => {
96            tracing::error!("{ENV_MAC_ADDRESS}=`{value}` is not a valid MAC address; ignoring it");
97            None
98        }
99    }
100}
101
102fn from_env_interface() -> Option<([u8; 6], String)> {
103    let name = std::env::var(ENV_MAC_INTERFACE).ok()?;
104    let name = name.trim().to_string();
105    if name.is_empty() {
106        return None;
107    }
108    match mac_address_by_name(&name) {
109        Ok(Some(addr)) => {
110            let bytes = addr.bytes();
111            if bytes.iter().all(|b| *b == 0) {
112                tracing::error!(
113                    "interface {name} from {ENV_MAC_INTERFACE} has a zero MAC address; ignoring it"
114                );
115                None
116            } else {
117                Some((bytes, name))
118            }
119        }
120        Ok(None) => {
121            tracing::error!("interface {name} from {ENV_MAC_INTERFACE} was not found; ignoring it");
122            None
123        }
124        Err(err) => {
125            tracing::error!(
126                "failed to read MAC for interface {name} from {ENV_MAC_INTERFACE}: {err:#}; \
127                 ignoring it"
128            );
129            None
130        }
131    }
132}
133
134/// Returns the first physical interface's MAC together with its name, skipping
135/// zero addresses and virtual devices. When every candidate is virtual it
136/// returns the first non-zero one anyway, since a stable virtual address is
137/// still preferable to the gethostid fallback.
138///
139/// The name and address come from the same `getifaddrs` entry. A separate
140/// by-MAC name lookup would misattribute the name when several interfaces
141/// share a MAC (bonding, macvlan).
142fn first_physical_interface() -> Option<([u8; 6], String)> {
143    let mut first_usable = None;
144    for iface in getifaddrs().ok()? {
145        let Some(bytes) = iface.address.and_then(|a| a.as_link_addr()?.addr()) else {
146            continue;
147        };
148        if bytes.iter().all(|b| *b == 0) {
149            continue;
150        }
151        let name = iface.interface_name;
152        if first_usable.is_none() {
153            first_usable = Some((bytes, name.clone()));
154        }
155        if is_virtual_interface(&name) {
156            continue;
157        }
158        return Some((bytes, name));
159    }
160    first_usable
161}
162
163fn is_virtual_interface(name: &str) -> bool {
164    VIRTUAL_INTERFACE_NAMES.contains(&name)
165        || VIRTUAL_INTERFACE_PREFIXES
166            .iter()
167            .any(|prefix| name.starts_with(prefix))
168}
169
170/// Derives a 6 byte value from `gethostid()` as a last resort when no interface
171/// offers a usable MAC. This is not guaranteed unique between hosts, but is
172/// preferable to random bytes because it is stable across restarts.
173fn from_host_id() -> [u8; 6] {
174    let host_id = unsafe { libc::gethostid() }.to_le_bytes();
175    [
176        host_id[0], host_id[1], host_id[2], host_id[3], host_id[4], host_id[5],
177    ]
178}
179
180/// Parses a MAC address as six 2-digit hex groups separated consistently by
181/// `:` or `-`, or as 12 bare hex digits. Mixed separators are rejected rather
182/// than normalized, since a hand-typed value with an inconsistent separator is
183/// more likely a typo than an intended address.
184fn parse_mac(input: &str) -> Option<[u8; 6]> {
185    let groups: Vec<&str> = if input.contains(':') {
186        input.split(':').collect()
187    } else if input.contains('-') {
188        input.split('-').collect()
189    } else {
190        if input.len() != 12 {
191            return None;
192        }
193        return parse_hex_bytes(input);
194    };
195    if groups.len() != 6 || groups.iter().any(|g| g.len() != 2) {
196        return None;
197    }
198    parse_hex_bytes(&groups.concat())
199}
200
201fn parse_hex_bytes(hex: &str) -> Option<[u8; 6]> {
202    if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
203        return None;
204    }
205    let mut out = [0u8; 6];
206    for (i, byte) in out.iter_mut().enumerate() {
207        *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
208    }
209    Some(out)
210}
211
212fn format_mac(mac: &[u8; 6]) -> String {
213    format!(
214        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
215        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
216    )
217}
218
219#[cfg(test)]
220mod test {
221    use super::*;
222
223    #[test]
224    fn parse_colon_separated() {
225        assert_eq!(
226            parse_mac("02:1a:2b:3c:4d:5e"),
227            Some([0x02, 0x1a, 0x2b, 0x3c, 0x4d, 0x5e])
228        );
229    }
230
231    #[test]
232    fn parse_hyphen_separated() {
233        assert_eq!(
234            parse_mac("02-1A-2B-3C-4D-5E"),
235            Some([0x02, 0x1a, 0x2b, 0x3c, 0x4d, 0x5e])
236        );
237    }
238
239    #[test]
240    fn parse_bare_hex() {
241        assert_eq!(
242            parse_mac("021a2b3c4d5e"),
243            Some([0x02, 0x1a, 0x2b, 0x3c, 0x4d, 0x5e])
244        );
245    }
246
247    #[test]
248    fn parse_rejects_bad_input() {
249        assert_eq!(parse_mac("nope"), None);
250        assert_eq!(parse_mac("02:1a:2b:3c:4d"), None);
251        assert_eq!(parse_mac("02:1a:2b:3c:4d:5e:6f"), None);
252        assert_eq!(parse_mac("gg:1a:2b:3c:4d:5e"), None);
253    }
254
255    #[test]
256    fn parse_rejects_mixed_separators() {
257        assert_eq!(parse_mac("02:1a-2b:3c-4d:5e"), None);
258        assert_eq!(parse_mac("021a2b-3c4d5e"), None);
259    }
260
261    #[test]
262    fn virtual_interfaces_match() {
263        assert!(is_virtual_interface("lo"));
264        assert!(is_virtual_interface("lo0"));
265        assert!(is_virtual_interface("docker0"));
266        assert!(is_virtual_interface("veth1234"));
267        assert!(is_virtual_interface("br-abcdef"));
268        assert!(is_virtual_interface("dummy0"));
269        assert!(is_virtual_interface("gre0"));
270        assert!(is_virtual_interface("sit0"));
271        assert!(!is_virtual_interface("lom0"));
272        assert!(!is_virtual_interface("eth0"));
273        assert!(!is_virtual_interface("ens5"));
274        assert!(!is_virtual_interface("bond0"));
275    }
276}