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