Skip to main content

microsandbox_agentd/
network.rs

1//! Guest-side network configuration from `MSB_NET*` environment variables.
2//!
3//! Configures the guest network interface using ioctls and netlink, following
4//! the parameters from host.
5
6use std::net::{Ipv4Addr, Ipv6Addr};
7
8use crate::config::NetConfig;
9use crate::error::AgentdResult;
10
11//--------------------------------------------------------------------------------------------------
12// Functions
13//--------------------------------------------------------------------------------------------------
14
15/// Set the guest hostname and provision `/etc/hosts`. Each argument is
16/// optional; omitted pieces are skipped.
17///
18/// # Arguments
19///
20/// * `hostname` - Guest hostname. When set, calls `sethostname()` and writes
21///   `/etc/hostname`.
22/// * `host_alias` - DNS name the guest uses to reach the sandbox host
23///   (typically `host.microsandbox.internal`). Written to `/etc/hosts`
24///   alongside whichever gateway IPs are present.
25/// * `gateway_ipv4` - Gateway IPv4 the alias points at.
26/// * `gateway_ipv6` - Gateway IPv6 the alias points at.
27///
28/// # Errors
29///
30/// Returns [`AgentdError::Init`][crate::error::AgentdError::Init] when `/etc`
31/// cannot be created, `/etc/hosts` or `/etc/hostname` cannot be written, or
32/// `sethostname(2)` fails.
33pub(crate) fn apply_hostname(
34    hostname: Option<&str>,
35    host_alias: Option<&str>,
36    gateway_ipv4: Option<Ipv4Addr>,
37    gateway_ipv6: Option<Ipv6Addr>,
38) -> AgentdResult<()> {
39    linux::write_hosts_file(hostname, host_alias, gateway_ipv4, gateway_ipv6)?;
40
41    if let Some(name) = hostname {
42        linux::set_hostname(name)?;
43    }
44
45    Ok(())
46}
47
48/// Apply the guest-side network configuration.
49///
50/// Always provisions loopback first so the guest has a working `lo` interface
51/// even when the sandbox was booted with networking disabled. When `cfg.net`
52/// is `None`, nothing further is configured.
53///
54/// # Arguments
55///
56/// * `cfg` - Parsed `MSB_NET*` specs bundled by
57///   [`BootParams::network`][crate::config::BootParams::network]: interface
58///   name/MAC/MTU plus optional IPv4 and IPv6 addressing.
59///
60/// # Errors
61///
62/// Returns [`AgentdError::Init`][crate::error::AgentdError::Init] when bringing
63/// up `lo` fails, or any of the interface ioctls / netlink messages for the
64/// main interface fail.
65pub(crate) fn apply_network_config(cfg: NetConfig<'_>) -> AgentdResult<()> {
66    linux::configure_loopback()?;
67
68    let Some(net) = cfg.net else {
69        return Ok(());
70    };
71
72    linux::configure_interface(net, cfg.ipv4, cfg.ipv6)
73}
74
75/// Render the `/etc/hosts` contents.
76///
77/// # Arguments
78///
79/// * `hostname` - Guest hostname; when `Some`, appended as an alias on the
80///   `127.0.0.1` and `::1` lines.
81/// * `host_alias` - Name like `host.microsandbox.internal`; when `Some` and a
82///   gateway IP is set for the matching family, emits `<gw>\t<alias>` lines.
83/// * `gateway_ipv4` - IPv4 the alias resolves to. The IPv4 alias line is
84///   skipped when `None` (or when `host_alias` is `None`).
85/// * `gateway_ipv6` - IPv6 the alias resolves to. The IPv6 alias line is
86///   skipped when `None` (or when `host_alias` is `None`).
87fn hosts_file_contents(
88    hostname: Option<&str>,
89    host_alias: Option<&str>,
90    gateway_ipv4: Option<Ipv4Addr>,
91    gateway_ipv6: Option<Ipv6Addr>,
92) -> String {
93    let mut s = String::new();
94
95    // Localhost entries — always include hostname aliases when set.
96    if let Some(name) = hostname {
97        s.push_str(&format!("127.0.0.1\tlocalhost {name}\n"));
98        s.push_str(&format!(
99            "::1\tlocalhost ip6-localhost ip6-loopback {name}\n"
100        ));
101    } else {
102        s.push_str("127.0.0.1\tlocalhost\n");
103        s.push_str("::1\tlocalhost ip6-localhost ip6-loopback\n");
104    }
105
106    // `<host_alias>` → gateway IP mapping. Emits both address families
107    // so v4-only and v6-only resolvers find the alias.
108    if let Some(alias) = host_alias {
109        if let Some(gw_v4) = gateway_ipv4 {
110            s.push_str(&format!("{gw_v4}\t{alias}\n"));
111        }
112        if let Some(gw_v6) = gateway_ipv6 {
113            s.push_str(&format!("{gw_v6}\t{alias}\n"));
114        }
115    }
116
117    s.push_str("fe00::\tip6-localnet\n");
118    s.push_str("ff00::\tip6-mcastprefix\n");
119    s.push_str("ff02::1\tip6-allnodes\n");
120    s.push_str("ff02::2\tip6-allrouters\n");
121
122    s
123}
124
125//--------------------------------------------------------------------------------------------------
126// Modules
127//--------------------------------------------------------------------------------------------------
128
129mod linux {
130    use std::net::{Ipv4Addr, Ipv6Addr};
131    use std::{fs, io, mem, ptr};
132
133    use nix::unistd;
134
135    use crate::config::{NetIpv4Spec, NetIpv6Spec, NetSpec};
136    use crate::error::{AgentdError, AgentdResult};
137
138    //----------------------------------------------------------------------------------------------
139    // Types
140    //----------------------------------------------------------------------------------------------
141
142    // Alpine's musl-target libc crate does not expose the Linux netlink
143    // ifaddrmsg/rtmsg definitions, so we define the kernel-layout structs we
144    // need locally and continue using libc only for constants and syscalls.
145    #[repr(C)]
146    struct IfAddrMsg {
147        ifa_family: u8,
148        ifa_prefixlen: u8,
149        ifa_flags: u8,
150        ifa_scope: u8,
151        ifa_index: u32,
152    }
153
154    #[repr(C)]
155    struct RtMsg {
156        rtm_family: u8,
157        rtm_dst_len: u8,
158        rtm_src_len: u8,
159        rtm_tos: u8,
160        rtm_table: u8,
161        rtm_protocol: u8,
162        rtm_scope: u8,
163        rtm_type: u8,
164        rtm_flags: u32,
165    }
166
167    /// Configures the guest network interface using ioctls and netlink.
168    ///
169    /// Operations (in order):
170    /// 1. Set MAC address via `ioctl(SIOCSIFHWADDR)`
171    /// 2. Set MTU via `ioctl(SIOCSIFMTU)`
172    /// 3. Assign IPv4 address via netlink `RTM_NEWADDR`
173    /// 4. Assign IPv6 address via netlink `RTM_NEWADDR`
174    /// 5. Bring interface up via `ioctl(SIOCSIFFLAGS)` with `IFF_UP`
175    /// 6. Add IPv4 default route via netlink `RTM_NEWROUTE`
176    /// 7. Add IPv6 default route via netlink `RTM_NEWROUTE`
177    /// 8. Write `/etc/resolv.conf`
178    pub fn configure_interface(
179        net: &NetSpec,
180        ipv4: Option<&NetIpv4Spec>,
181        ipv6: Option<&NetIpv6Spec>,
182    ) -> AgentdResult<()> {
183        let ifindex = get_ifindex(&net.iface)?;
184
185        set_mac_address(&net.iface, &net.mac)?;
186        set_mtu(&net.iface, net.mtu)?;
187
188        if let Some(v4) = ipv4 {
189            add_address_v4(ifindex, v4.address, v4.prefix_len)?;
190        }
191        if let Some(v6) = ipv6 {
192            add_address_v6(ifindex, v6.address, v6.prefix_len)?;
193        }
194
195        bring_interface_up(&net.iface)?;
196
197        if let Some(v4) = ipv4 {
198            add_default_route_v4(v4.gateway)?;
199        }
200        if let Some(v6) = ipv6 {
201            add_default_route_v6(v6.gateway)?;
202        }
203
204        write_resolv_conf(ipv4.and_then(|v| v.dns), ipv6.and_then(|v| v.dns))?;
205
206        Ok(())
207    }
208
209    /// Brings up the loopback interface and makes sure localhost addresses exist.
210    pub fn configure_loopback() -> AgentdResult<()> {
211        let ifindex = get_ifindex("lo")?;
212
213        bring_interface_up("lo")?;
214        add_address_v4_if_missing(ifindex, Ipv4Addr::LOCALHOST, 8)?;
215        add_address_v6_if_missing(ifindex, Ipv6Addr::LOCALHOST, 128)?;
216
217        Ok(())
218    }
219
220    // ── ioctl helpers ──────────────────────────────────────────────────
221
222    /// Gets the interface index for a given interface name.
223    fn get_ifindex(ifname: &str) -> AgentdResult<u32> {
224        unsafe {
225            let mut ifr: libc::ifreq = mem::zeroed();
226            copy_ifname(&mut ifr, ifname)?;
227
228            let sock = socket_fd()?;
229            if libc::ioctl(sock, libc::SIOCGIFINDEX as _, &mut ifr) < 0 {
230                libc::close(sock);
231                return Err(AgentdError::Init(format!(
232                    "SIOCGIFINDEX failed for {ifname}: {}",
233                    io::Error::last_os_error()
234                )));
235            }
236            libc::close(sock);
237
238            Ok(ifr.ifr_ifru.ifru_ifindex as u32)
239        }
240    }
241
242    /// Sets the MAC address on an interface.
243    fn set_mac_address(ifname: &str, mac: &[u8; 6]) -> AgentdResult<()> {
244        unsafe {
245            let mut ifr: libc::ifreq = mem::zeroed();
246            copy_ifname(&mut ifr, ifname)?;
247
248            ifr.ifr_ifru.ifru_hwaddr.sa_family = libc::ARPHRD_ETHER;
249            ifr.ifr_ifru.ifru_hwaddr.sa_data[..6].copy_from_slice(&mac.map(|b| b as libc::c_char));
250
251            let sock = socket_fd()?;
252            if libc::ioctl(sock, libc::SIOCSIFHWADDR as _, &ifr) < 0 {
253                libc::close(sock);
254                return Err(AgentdError::Init(format!(
255                    "SIOCSIFHWADDR failed for {ifname}: {}",
256                    io::Error::last_os_error()
257                )));
258            }
259            libc::close(sock);
260        }
261        Ok(())
262    }
263
264    /// Sets the MTU on an interface.
265    fn set_mtu(ifname: &str, mtu: u16) -> AgentdResult<()> {
266        unsafe {
267            let mut ifr: libc::ifreq = mem::zeroed();
268            copy_ifname(&mut ifr, ifname)?;
269            ifr.ifr_ifru.ifru_mtu = mtu as libc::c_int;
270
271            let sock = socket_fd()?;
272            if libc::ioctl(sock, libc::SIOCSIFMTU as _, &ifr) < 0 {
273                libc::close(sock);
274                return Err(AgentdError::Init(format!(
275                    "SIOCSIFMTU failed for {ifname}: {}",
276                    io::Error::last_os_error()
277                )));
278            }
279            libc::close(sock);
280        }
281        Ok(())
282    }
283
284    /// Brings an interface up.
285    fn bring_interface_up(ifname: &str) -> AgentdResult<()> {
286        unsafe {
287            let mut ifr: libc::ifreq = mem::zeroed();
288            copy_ifname(&mut ifr, ifname)?;
289
290            let sock = socket_fd()?;
291
292            // Get current flags.
293            if libc::ioctl(sock, libc::SIOCGIFFLAGS as _, &mut ifr) < 0 {
294                libc::close(sock);
295                return Err(AgentdError::Init(format!(
296                    "SIOCGIFFLAGS failed for {ifname}: {}",
297                    io::Error::last_os_error()
298                )));
299            }
300
301            // Set IFF_UP.
302            ifr.ifr_ifru.ifru_flags |= libc::IFF_UP as libc::c_short;
303
304            if libc::ioctl(sock, libc::SIOCSIFFLAGS as _, &ifr) < 0 {
305                libc::close(sock);
306                return Err(AgentdError::Init(format!(
307                    "SIOCSIFFLAGS (UP) failed for {ifname}: {}",
308                    io::Error::last_os_error()
309                )));
310            }
311            libc::close(sock);
312        }
313        Ok(())
314    }
315
316    // ── netlink helpers ────────────────────────────────────────────────
317
318    /// Adds an IPv4 address to an interface via netlink RTM_NEWADDR.
319    fn add_address_v4(ifindex: u32, addr: Ipv4Addr, prefix_len: u8) -> AgentdResult<()> {
320        let addr_bytes = addr.octets();
321        netlink_newaddr(ifindex, libc::AF_INET as u8, prefix_len, &addr_bytes).map_err(|e| {
322            AgentdError::Init(format!(
323                "failed to add IPv4 address {addr}/{prefix_len}: {e}"
324            ))
325        })
326    }
327
328    /// Adds an IPv6 address to an interface via netlink RTM_NEWADDR.
329    fn add_address_v6(ifindex: u32, addr: Ipv6Addr, prefix_len: u8) -> AgentdResult<()> {
330        let addr_bytes = addr.octets();
331        netlink_newaddr(ifindex, libc::AF_INET6 as u8, prefix_len, &addr_bytes).map_err(|e| {
332            AgentdError::Init(format!(
333                "failed to add IPv6 address {addr}/{prefix_len}: {e}"
334            ))
335        })
336    }
337
338    /// Adds an IPv4 address unless it already exists.
339    fn add_address_v4_if_missing(ifindex: u32, addr: Ipv4Addr, prefix_len: u8) -> AgentdResult<()> {
340        let addr_bytes = addr.octets();
341        match netlink_newaddr(ifindex, libc::AF_INET as u8, prefix_len, &addr_bytes) {
342            Ok(()) => Ok(()),
343            Err(e) if e.raw_os_error() == Some(libc::EEXIST) => Ok(()),
344            Err(e) => Err(AgentdError::Init(format!(
345                "failed to add IPv4 address {addr}/{prefix_len}: {e}"
346            ))),
347        }
348    }
349
350    /// Adds an IPv6 address unless it already exists.
351    fn add_address_v6_if_missing(ifindex: u32, addr: Ipv6Addr, prefix_len: u8) -> AgentdResult<()> {
352        let addr_bytes = addr.octets();
353        match netlink_newaddr(ifindex, libc::AF_INET6 as u8, prefix_len, &addr_bytes) {
354            Ok(()) => Ok(()),
355            Err(e) if e.raw_os_error() == Some(libc::EEXIST) => Ok(()),
356            Err(e) => Err(AgentdError::Init(format!(
357                "failed to add IPv6 address {addr}/{prefix_len}: {e}"
358            ))),
359        }
360    }
361
362    /// Adds an IPv4 default route via netlink RTM_NEWROUTE.
363    fn add_default_route_v4(gateway: Ipv4Addr) -> AgentdResult<()> {
364        let gw_bytes = gateway.octets();
365        netlink_newroute(libc::AF_INET as u8, &gw_bytes).map_err(|e| {
366            AgentdError::Init(format!(
367                "failed to add IPv4 default route via {gateway}: {e}"
368            ))
369        })
370    }
371
372    /// Adds an IPv6 default route via netlink RTM_NEWROUTE.
373    fn add_default_route_v6(gateway: Ipv6Addr) -> AgentdResult<()> {
374        let gw_bytes = gateway.octets();
375        netlink_newroute(libc::AF_INET6 as u8, &gw_bytes).map_err(|e| {
376            AgentdError::Init(format!(
377                "failed to add IPv6 default route via {gateway}: {e}"
378            ))
379        })
380    }
381
382    /// Sends a netlink RTM_NEWADDR message.
383    ///
384    /// For IPv4: emits both `IFA_ADDRESS` and `IFA_LOCAL` (kernel expects both).
385    /// For IPv6: emits only `IFA_ADDRESS` (no `IFA_LOCAL` semantics for IPv6).
386    fn netlink_newaddr(ifindex: u32, family: u8, prefix_len: u8, addr: &[u8]) -> io::Result<()> {
387        let addr_len = addr.len();
388        let is_ipv4 = family == libc::AF_INET as u8;
389
390        // IPv4 needs two RTAs (IFA_ADDRESS + IFA_LOCAL), IPv6 needs one (IFA_ADDRESS).
391        let num_rtas = if is_ipv4 { 2 } else { 1 };
392        let rtas_len = rta_space(addr_len) * num_rtas;
393        let msg_len = NLMSG_HDRLEN + IFADDRMSG_LEN + rtas_len;
394        let mut buf = vec![0u8; nlmsg_align(msg_len)];
395
396        // nlmsghdr
397        let nlh = buf.as_mut_ptr().cast::<libc::nlmsghdr>();
398        unsafe {
399            (*nlh).nlmsg_len = msg_len as u32;
400            (*nlh).nlmsg_type = libc::RTM_NEWADDR;
401            (*nlh).nlmsg_flags =
402                (libc::NLM_F_REQUEST | libc::NLM_F_ACK | libc::NLM_F_CREATE | libc::NLM_F_EXCL)
403                    as u16;
404            (*nlh).nlmsg_seq = 1;
405        }
406
407        // ifaddrmsg
408        let ifa = unsafe { buf.as_mut_ptr().add(NLMSG_HDRLEN).cast::<IfAddrMsg>() };
409        unsafe {
410            (*ifa).ifa_family = family;
411            (*ifa).ifa_prefixlen = prefix_len;
412            (*ifa).ifa_flags = if is_ipv4 { 0 } else { libc::IFA_F_NODAD as u8 };
413            (*ifa).ifa_index = ifindex;
414            (*ifa).ifa_scope = libc::RT_SCOPE_UNIVERSE;
415        }
416
417        // RTA attributes
418        let mut rta_offset = NLMSG_HDRLEN + IFADDRMSG_LEN;
419        write_rta(&mut buf[rta_offset..], libc::IFA_ADDRESS, addr);
420        rta_offset += rta_space(addr_len);
421
422        if is_ipv4 {
423            write_rta(&mut buf[rta_offset..], libc::IFA_LOCAL, addr);
424        }
425
426        netlink_send(&buf)
427    }
428
429    /// Sends a netlink RTM_NEWROUTE message for a default route.
430    fn netlink_newroute(family: u8, gateway: &[u8]) -> io::Result<()> {
431        let gw_len = gateway.len();
432
433        // nlmsghdr + rtmsg + RTA_GATEWAY(rta_header + addr)
434        let rta_len = rta_space(gw_len);
435        let msg_len = NLMSG_HDRLEN + RTMSG_LEN + rta_len;
436        let mut buf = vec![0u8; nlmsg_align(msg_len)];
437
438        // nlmsghdr
439        let nlh = buf.as_mut_ptr().cast::<libc::nlmsghdr>();
440        unsafe {
441            (*nlh).nlmsg_len = msg_len as u32;
442            (*nlh).nlmsg_type = libc::RTM_NEWROUTE;
443            (*nlh).nlmsg_flags =
444                (libc::NLM_F_REQUEST | libc::NLM_F_ACK | libc::NLM_F_CREATE | libc::NLM_F_EXCL)
445                    as u16;
446            (*nlh).nlmsg_seq = 2;
447        }
448
449        // rtmsg
450        let rtm = unsafe { buf.as_mut_ptr().add(NLMSG_HDRLEN).cast::<RtMsg>() };
451        unsafe {
452            (*rtm).rtm_family = family;
453            (*rtm).rtm_dst_len = 0; // default route
454            (*rtm).rtm_src_len = 0;
455            (*rtm).rtm_tos = 0;
456            (*rtm).rtm_table = libc::RT_TABLE_MAIN;
457            (*rtm).rtm_protocol = libc::RTPROT_BOOT;
458            (*rtm).rtm_scope = libc::RT_SCOPE_UNIVERSE;
459            (*rtm).rtm_type = libc::RTN_UNICAST;
460            (*rtm).rtm_flags = 0;
461        }
462
463        // RTA_GATEWAY attribute
464        let rta_offset = NLMSG_HDRLEN + RTMSG_LEN;
465        write_rta(&mut buf[rta_offset..], libc::RTA_GATEWAY, gateway);
466
467        netlink_send(&buf)
468    }
469
470    /// Opens a netlink socket, sends a message, and waits for the ACK.
471    fn netlink_send(msg: &[u8]) -> io::Result<()> {
472        unsafe {
473            let sock = libc::socket(libc::AF_NETLINK, libc::SOCK_DGRAM, libc::NETLINK_ROUTE);
474            if sock < 0 {
475                return Err(io::Error::last_os_error());
476            }
477
478            // Bind to kernel.
479            let mut sa: libc::sockaddr_nl = mem::zeroed();
480            sa.nl_family = libc::AF_NETLINK as u16;
481            if libc::bind(
482                sock,
483                (&sa as *const libc::sockaddr_nl).cast(),
484                mem::size_of::<libc::sockaddr_nl>() as u32,
485            ) < 0
486            {
487                libc::close(sock);
488                return Err(io::Error::last_os_error());
489            }
490
491            // Send.
492            if libc::send(sock, msg.as_ptr().cast(), msg.len(), 0) < 0 {
493                libc::close(sock);
494                return Err(io::Error::last_os_error());
495            }
496
497            // Read ACK.
498            let mut ack_buf = [0u8; 1024];
499            let n = libc::recv(sock, ack_buf.as_mut_ptr().cast(), ack_buf.len(), 0);
500            libc::close(sock);
501
502            if n < 0 {
503                return Err(io::Error::last_os_error());
504            }
505
506            // Check for error in the ACK (using from_ne_bytes to avoid
507            // unaligned pointer dereference on the stack buffer).
508            if (n as usize) >= NLMSG_HDRLEN + 4 {
509                let nlh = ack_buf.as_ptr().cast::<libc::nlmsghdr>();
510                if (*nlh).nlmsg_type == libc::NLMSG_ERROR as u16 {
511                    let err = i32::from_ne_bytes(
512                        ack_buf[NLMSG_HDRLEN..NLMSG_HDRLEN + 4].try_into().unwrap(),
513                    );
514                    if err < 0 {
515                        return Err(io::Error::from_raw_os_error(-err));
516                    }
517                }
518            }
519
520            Ok(())
521        }
522    }
523
524    // ── hostname + hosts + resolv.conf ──────────────────────────────────
525
526    /// Sets the kernel hostname via `sethostname()` and writes `/etc/hostname`.
527    pub fn set_hostname(name: &str) -> AgentdResult<()> {
528        unistd::sethostname(name)
529            .map_err(|e| AgentdError::Init(format!("sethostname({name}): {e}")))?;
530
531        fs::create_dir_all("/etc")
532            .map_err(|e| AgentdError::Init(format!("failed to create /etc: {e}")))?;
533        fs::write("/etc/hostname", format!("{name}\n"))
534            .map_err(|e| AgentdError::Init(format!("failed to write /etc/hostname: {e}")))?;
535
536        Ok(())
537    }
538
539    /// Writes `/etc/hosts` with localhost aliases and an optional hostname entry.
540    pub fn write_hosts_file(
541        hostname: Option<&str>,
542        host_alias: Option<&str>,
543        gateway_ipv4: Option<Ipv4Addr>,
544        gateway_ipv6: Option<Ipv6Addr>,
545    ) -> AgentdResult<()> {
546        fs::create_dir_all("/etc")
547            .map_err(|e| AgentdError::Init(format!("failed to create /etc: {e}")))?;
548        fs::write(
549            "/etc/hosts",
550            super::hosts_file_contents(hostname, host_alias, gateway_ipv4, gateway_ipv6),
551        )
552        .map_err(|e| AgentdError::Init(format!("failed to write /etc/hosts: {e}")))?;
553        Ok(())
554    }
555
556    /// Writes `/etc/resolv.conf` with the configured DNS servers.
557    fn write_resolv_conf(dns_v4: Option<Ipv4Addr>, dns_v6: Option<Ipv6Addr>) -> AgentdResult<()> {
558        if dns_v4.is_none() && dns_v6.is_none() {
559            return Ok(());
560        }
561
562        let mut content = String::new();
563        if let Some(dns) = dns_v4 {
564            content.push_str(&format!("nameserver {dns}\n"));
565        }
566        if let Some(dns) = dns_v6 {
567            content.push_str(&format!("nameserver {dns}\n"));
568        }
569
570        fs::write("/etc/resolv.conf", &content)
571            .map_err(|e| AgentdError::Init(format!("failed to write /etc/resolv.conf: {e}")))?;
572
573        Ok(())
574    }
575
576    // ── low-level helpers ──────────────────────────────────────────────
577
578    /// Creates a UDP socket for ioctl operations.
579    fn socket_fd() -> AgentdResult<libc::c_int> {
580        let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) };
581        if fd < 0 {
582            return Err(AgentdError::Init(format!(
583                "failed to create socket: {}",
584                io::Error::last_os_error()
585            )));
586        }
587        Ok(fd)
588    }
589
590    /// Copies an interface name into an ifreq struct.
591    fn copy_ifname(ifr: &mut libc::ifreq, ifname: &str) -> AgentdResult<()> {
592        let bytes = ifname.as_bytes();
593        if bytes.len() >= libc::IFNAMSIZ {
594            return Err(AgentdError::Init(format!(
595                "interface name too long: {ifname}"
596            )));
597        }
598        unsafe {
599            ptr::copy_nonoverlapping(
600                bytes.as_ptr(),
601                ifr.ifr_name.as_mut_ptr().cast(),
602                bytes.len(),
603            );
604        }
605        Ok(())
606    }
607
608    // ── netlink constants and helpers ──────────────────────────────────
609
610    const NLMSG_HDRLEN: usize = 16;
611    const IFADDRMSG_LEN: usize = 8;
612    const RTMSG_LEN: usize = 12;
613    const RTA_HDRLEN: usize = 4;
614
615    // Compile-time assertions: catch layout mismatches across platforms.
616    const _: () = assert!(mem::size_of::<libc::nlmsghdr>() == NLMSG_HDRLEN);
617    const _: () = assert!(mem::size_of::<IfAddrMsg>() == IFADDRMSG_LEN);
618    const _: () = assert!(mem::size_of::<RtMsg>() == RTMSG_LEN);
619
620    fn nlmsg_align(len: usize) -> usize {
621        (len + 3) & !3
622    }
623
624    fn rta_space(data_len: usize) -> usize {
625        nlmsg_align(RTA_HDRLEN + data_len)
626    }
627
628    /// Writes an rtattr (type + data) into the buffer.
629    fn write_rta(buf: &mut [u8], rta_type: u16, data: &[u8]) {
630        let rta_len = (RTA_HDRLEN + data.len()) as u16;
631        buf[0..2].copy_from_slice(&rta_len.to_ne_bytes());
632        buf[2..4].copy_from_slice(&rta_type.to_ne_bytes());
633        buf[RTA_HDRLEN..RTA_HDRLEN + data.len()].copy_from_slice(data);
634    }
635}
636
637//--------------------------------------------------------------------------------------------------
638// Tests
639//--------------------------------------------------------------------------------------------------
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    #[test]
646    fn test_hosts_file_without_hostname() {
647        assert_eq!(
648            hosts_file_contents(None, None, None, None),
649            concat!(
650                "127.0.0.1\tlocalhost\n",
651                "::1\tlocalhost ip6-localhost ip6-loopback\n",
652                "fe00::\tip6-localnet\n",
653                "ff00::\tip6-mcastprefix\n",
654                "ff02::1\tip6-allnodes\n",
655                "ff02::2\tip6-allrouters\n",
656            )
657        );
658    }
659
660    #[test]
661    fn test_hosts_file_with_hostname() {
662        assert_eq!(
663            hosts_file_contents(Some("worker-01"), None, None, None),
664            concat!(
665                "127.0.0.1\tlocalhost worker-01\n",
666                "::1\tlocalhost ip6-localhost ip6-loopback worker-01\n",
667                "fe00::\tip6-localnet\n",
668                "ff00::\tip6-mcastprefix\n",
669                "ff02::1\tip6-allnodes\n",
670                "ff02::2\tip6-allrouters\n",
671            )
672        );
673    }
674
675    #[test]
676    fn test_hosts_file_with_host_alias_both_families() {
677        assert_eq!(
678            hosts_file_contents(
679                Some("worker-01"),
680                Some("host.microsandbox.internal"),
681                Some(Ipv4Addr::new(100, 96, 0, 1)),
682                Some("fd42:6d73:62::1".parse().unwrap()),
683            ),
684            concat!(
685                "127.0.0.1\tlocalhost worker-01\n",
686                "::1\tlocalhost ip6-localhost ip6-loopback worker-01\n",
687                "100.96.0.1\thost.microsandbox.internal\n",
688                "fd42:6d73:62::1\thost.microsandbox.internal\n",
689                "fe00::\tip6-localnet\n",
690                "ff00::\tip6-mcastprefix\n",
691                "ff02::1\tip6-allnodes\n",
692                "ff02::2\tip6-allrouters\n",
693            )
694        );
695    }
696
697    #[test]
698    fn test_hosts_file_with_host_alias_v4_only() {
699        let out = hosts_file_contents(
700            None,
701            Some("host.microsandbox.internal"),
702            Some(Ipv4Addr::new(100, 96, 0, 1)),
703            None,
704        );
705        assert!(out.contains("100.96.0.1\thost.microsandbox.internal\n"));
706        assert!(!out.contains("fd42"));
707    }
708
709    #[test]
710    fn test_hosts_file_omits_alias_when_name_missing() {
711        let out = hosts_file_contents(
712            None,
713            None,
714            Some(Ipv4Addr::new(100, 96, 0, 1)),
715            Some("fd42:6d73:62::1".parse().unwrap()),
716        );
717        assert!(!out.contains("host.microsandbox.internal"));
718        assert!(!out.contains("100.96.0.1"));
719        assert!(!out.contains("fd42"));
720    }
721}