Skip to main content

capsule_runtime/
network_namespace.rs

1//! The capsule's own network namespace: the structural half of the mechanism that replaced the
2//! seccomp-notify `connect`/`sendto` supervisor.
3//!
4//! [`crate::egress_proxy`] decides *which* destinations a native subprocess may reach. This module
5//! is what makes that decision the only way out — it puts the subprocess tree somewhere with no
6//! route off the host, so an unlisted destination is not "denied" by a filter that has to be
7//! consulted, it is simply unreachable.
8//!
9//! ## The syscall sequence
10//!
11//! Everything happens in the forked child's `pre_exec` window, before the seccomp filter (which
12//! denies `unshare`, `setns` and `socket(AF_NETLINK)` outright) and before [`crate::sealed`]'s
13//! composed root:
14//!
15//!   1. `unshare(CLONE_NEWUSER | CLONE_NEWNET)`, then the identity `uid_map`/`gid_map` writes that
16//!      make the child the owner of both. The user namespace is not incidental: `CLONE_NEWNET`
17//!      alone needs `CAP_NET_ADMIN`, which an unprivileged process does not have, and creating it
18//!      inside a user namespace it owns is what supplies that capability *there and nowhere else*.
19//!      This is the same primitive `sealed` uses and the same AppArmor restriction governs it —
20//!      see [`EgressNamespaceBlocker`].
21//!   2. `lo` is brought up ([`bring_loopback_up`]). It is the namespace's only interface: there is
22//!      no veth pair, no bridge and no NAT, so nothing in the namespace has a path to the host's
23//!      network or to the outside world.
24//!   3. One route is installed over netlink ([`add_local_default_route`]): `local default dev lo`.
25//!      A `local`-type default route makes every address *locally deliverable*, so a `connect()`
26//!      to any address is looped back to a socket in this namespace with the original destination
27//!      intact — recoverable with `getsockname(2)`. That is the entire interception mechanism,
28//!      and it is why no netfilter rule is needed anywhere. It also means a lookup reaches the
29//!      resolver whatever address `/etc/resolv.conf` names, and a connection to a port nothing is
30//!      bound to is refused immediately rather than stalling through the kernel's full SYN-retry
31//!      schedule against a blackhole.
32//!   4. The listening sockets are created here, *inside* the namespace — one TCP listener per
33//!      port the allowlist implies, then one wildcard UDP :53 resolver socket — and handed back
34//!      to the parent in a single `SCM_RIGHTS` message. A socket belongs to the namespace it was
35//!      **created** in, not the one it is used from, which is the whole reason this inversion
36//!      works: the runtime serves them while staying in the host's namespace, with real internet
37//!      access for the upstream half.
38//!
39//! ## Why this shape rather than the obvious ones
40//!
41//! *No veth pair.* Wiring a namespace to the host with veth requires `CAP_NET_ADMIN` in the
42//! **host's** network namespace, which means real root. This design needs no host privilege at
43//! all, which is what lets `mur` install per-user.
44//!
45//! *No proxy process inside the namespace.* `setns(2)` into the namespace from a thread of the
46//! runtime is not possible: it requires `CAP_SYS_ADMIN` in the caller's *own* user namespace, and
47//! an unprivileged multithreaded process cannot get it (`unshare(CLONE_NEWUSER)` requires a
48//! single-threaded process). Passing the listening sockets *out* inverts the problem and needs no
49//! privilege whatsoever. This was established empirically, not assumed — the `setns` route was
50//! tried first and returned `EPERM`.
51//!
52//! *No `iptables`/`nft`/`TPROXY`.* Nothing here needs a netfilter rule, so there is no dependency
53//! on netfilter modules being present, no `iptables` binary to shell out to, and no rule set that
54//! can drift out of step with the manifest.
55//!
56//! ## What this does not isolate
57//!
58//! `AF_UNIX` is untouched. `CLONE_NEWNET` does not mediate unix sockets in any way that matters
59//! here — a pathname socket is reached through the filesystem, not the network stack. That is
60//! exactly why the register-level `socket(2)` domain filter (`denied_socket_domains` in
61//! [`crate::sandbox`]) is untouched here and remains the thing that refuses
62//! `/var/run/docker.sock`.
63
64/// One TCP listener per allowlisted port, plus the single wildcard UDP :53 resolver socket.
65///
66/// Bounds the one `SCM_RIGHTS` message the child sends, and therefore the fixed stack buffers the
67/// `pre_exec` window builds it in.
68pub(crate) const MAX_NAMESPACE_SOCKETS: usize = crate::egress_proxy::MAX_EGRESS_TCP_PORTS + 1;
69
70// ---------------------------------------------------------------- blockers
71
72/// Why this host cannot give a capsule its own network namespace.
73///
74/// Exactly two variants, because they are the two things an operator can act on and their
75/// remediations do not overlap: one is a permission this host withholds from the `mur` binary,
76/// the other is a kernel that does not implement the mechanism at all. Collapsing them would send
77/// half the readers to the wrong fix.
78///
79/// Deliberately **not** an `EnforcementTier` variant and deliberately not tied to
80/// `ContainmentClass`: a network namespace is required for every Linux capsule that can spawn a
81/// subprocess, at every containment class, so hanging it off the class ladder would make
82/// `advisory` capsules silently exempt from this boundary.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
84#[serde(rename_all = "snake_case")]
85pub enum EgressNamespaceBlocker {
86    /// The kernel implements unprivileged user namespaces, but this host withholds them from this
87    /// binary: AppArmor's `restrict_unprivileged_userns` is on and the shipped profile is not
88    /// confining `mur`, or `unshare` was refused outright (the container case), or the namespace
89    /// was created and could not then be owned or configured.
90    CapabilityGrantMissing,
91    /// The kernel does not provide the mechanism at all — `CONFIG_USER_NS=n`, or
92    /// `user.max_user_namespaces=0`.
93    KernelSupportMissing,
94}
95
96impl EgressNamespaceBlocker {
97    /// Every variant, so a caller reasoning about refusal text cannot silently miss one. Same
98    /// convention as `SealedBlocker::ALL`, which was added there after a hand-maintained list
99    /// drifted the moment a variant appeared.
100    pub const ALL: &'static [EgressNamespaceBlocker] = &[
101        EgressNamespaceBlocker::CapabilityGrantMissing,
102        EgressNamespaceBlocker::KernelSupportMissing,
103    ];
104
105    /// One sentence naming the missing mechanism, plus the exact remediation. Rendered into
106    /// [`crate::errors::RuntimeError::EgressNamespaceUnavailable`], so this is the text an
107    /// operator sees under `E-CAP-005`.
108    #[must_use]
109    pub fn reason(self) -> String {
110        match self {
111            EgressNamespaceBlocker::CapabilityGrantMissing => format!(
112                "this host refused unshare(CLONE_NEWUSER | CLONE_NEWNET) to the mur binary. On an \
113                 AppArmor host (Ubuntu 23.10+ and derivatives) this is the unprivileged-userns \
114                 restriction: install and load the profile shipped with mur, `sudo install -m 644 \
115                 packaging/apparmor/{name} {path} && sudo apparmor_parser -r {path}` (or re-run \
116                 the mur installer as root), then re-run. Inside a container it is a missing \
117                 capability: add `--cap-add SYS_ADMIN` to the container invocation, or create the \
118                 network namespace outside the container and run mur inside it. The runtime will \
119                 not fall back to the retired seccomp connect/sendto interception — that \
120                 mechanism was removed as unsound, not demoted to a fallback.",
121                name = crate::sealed::SEALED_APPARMOR_PROFILE_NAME,
122                path = crate::sealed::SEALED_APPARMOR_PROFILE_PATH,
123            ),
124            EgressNamespaceBlocker::KernelSupportMissing => {
125                "this kernel does not provide unprivileged user namespaces, which a capsule's \
126                 network namespace has to be created inside (CONFIG_USER_NS=n, or \
127                 user.max_user_namespaces=0). Raise `sudo sysctl -w \
128                 user.max_user_namespaces=10000` if the sysctl is merely zeroed, otherwise run on \
129                 a kernel built with CONFIG_USER_NS=y. The runtime will not fall back to the \
130                 retired seccomp connect/sendto interception — that mechanism was removed as \
131                 unsound, not demoted to a fallback."
132                    .to_string()
133            }
134        }
135    }
136}
137
138// ---------------------------------------------------------------- probe
139
140/// What a real `unshare(CLONE_NEWUSER | CLONE_NEWNET)` attempt in a forked child did.
141///
142/// Richer than the two-valued [`EgressNamespaceBlocker`] on purpose: the probe records the step
143/// that failed, and the mapping to a blocker is then an explicit, reviewable, unit-tested
144/// decision rather than something the probe pre-decided in the dark.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
146pub(crate) enum EgressNamespaceProbe {
147    /// The namespace was created, owned, and configured — `lo` came up and the local route
148    /// installed. Everything the real launch does, rehearsed.
149    Ok,
150    /// `unshare` was refused (`EPERM`).
151    Denied,
152    /// The namespace was created but writing the identity `uid_map`/`gid_map` was refused.
153    MapDenied,
154    /// Created and owned, but configuring it failed. Kept apart from `Denied` because it is the
155    /// signature of a confinement that permits namespace creation and then withholds
156    /// `CAP_NET_ADMIN` inside it.
157    ConfigDenied,
158    /// The kernel does not implement it, or the probe could not run at all.
159    #[default]
160    Unsupported,
161}
162
163/// Everything the host probe learned, kept separate from the decision that uses it so
164/// [`egress_namespace_blocker`] stays pure.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
166pub(crate) struct EgressNamespaceSupport {
167    /// AppArmor is not standing between this binary and an unprivileged user namespace.
168    ///
169    /// Answered by `sealed`'s own probe of exactly the same question: the restriction is on
170    /// `CLONE_NEWUSER`, which both mechanisms need, so asking it twice with two implementations
171    /// could only ever produce two answers that disagree about one host.
172    pub(crate) apparmor_permits_userns: bool,
173    pub(crate) namespace: EgressNamespaceProbe,
174}
175
176/// Which of the two blockers to name, given the probe. Pure, and unit-testable on any OS.
177///
178/// `None` means this host can give a capsule its own network namespace.
179pub(crate) fn egress_namespace_blocker(
180    is_linux: bool,
181    support: EgressNamespaceSupport,
182) -> Option<EgressNamespaceBlocker> {
183    // Not Linux: there is no network namespace here, and there was no seccomp interception
184    // either. A non-Linux host resolves to `EnforcementTier::EnvironmentOnly`, installs no kernel
185    // subprocess sandbox at all, and says so loudly (`W_SEC_001`). Refusing here would turn every
186    // macOS run into an error about a mechanism macOS has never had.
187    if !is_linux {
188        return None;
189    }
190    // AppArmor before the namespace outcome, for the reason `sealed_blocker` documents: when the
191    // restriction is on and our profile is absent, the `unshare` failure is a *consequence*, and
192    // blaming the kernel would send an Ubuntu desktop user somewhere useless.
193    if !support.apparmor_permits_userns {
194        return Some(EgressNamespaceBlocker::CapabilityGrantMissing);
195    }
196    match support.namespace {
197        EgressNamespaceProbe::Ok => None,
198        EgressNamespaceProbe::Denied
199        | EgressNamespaceProbe::MapDenied
200        | EgressNamespaceProbe::ConfigDenied => {
201            Some(EgressNamespaceBlocker::CapabilityGrantMissing)
202        }
203        EgressNamespaceProbe::Unsupported => Some(EgressNamespaceBlocker::KernelSupportMissing),
204    }
205}
206
207/// Which blocker, if any, stands between this host and a capsule network namespace.
208///
209/// Reads a process-cached probe, so the refusal an operator sees at staging time and the
210/// mechanism a launch actually installs can never disagree about the host.
211#[cfg(target_os = "linux")]
212#[must_use]
213pub fn detect_egress_namespace_blocker() -> Option<EgressNamespaceBlocker> {
214    static SUPPORT: std::sync::OnceLock<EgressNamespaceSupport> = std::sync::OnceLock::new();
215    let support = *SUPPORT.get_or_init(linux::probe_egress_namespace);
216    egress_namespace_blocker(true, support)
217}
218
219#[cfg(not(target_os = "linux"))]
220#[must_use]
221pub fn detect_egress_namespace_blocker() -> Option<EgressNamespaceBlocker> {
222    egress_namespace_blocker(false, EgressNamespaceSupport::default())
223}
224
225/// Whether a test that needs a capsule network namespace must stand down on this host, printing
226/// the blocker when it must.
227///
228/// Test support, not a runtime code path. Asked through [`detect_egress_namespace_blocker`], the
229/// same probe `stage_session` consults, so the gate and the staging refusal cannot reach different
230/// conclusions about the host.
231///
232/// Narrower than [`crate::skip_without_host_support`], which also requires a delegated cgroup
233/// scope: a test driving `execute_shell` directly needs the namespace and nothing else, and
234/// standing down on the cgroup half would skip it on hosts where it runs perfectly well.
235pub fn skip_without_egress_namespace(test_name: &str) -> bool {
236    match detect_egress_namespace_blocker() {
237        Some(blocker) => {
238            eprintln!("[SKIP-HOST] {test_name}: {}", blocker.reason());
239            true
240        }
241        None => false,
242    }
243}
244
245/// Refuses the launch when a capsule that can spawn native subprocesses is on a host that cannot
246/// give them a network namespace.
247///
248/// Pure — the host answer is passed in — so the decision is testable on any OS at any tier.
249///
250/// `can_spawn_subprocess` gates the whole check rather than
251/// `capabilities.network.allow` being non-empty, and the asymmetry is deliberate: an empty
252/// allowlist means *no* egress, and without the namespace to deliver that, the capsule would get
253/// unrestricted egress instead. Refusing only capsules that asked for some network would let
254/// exactly the capsules that asked for none run with the most.
255pub fn check_egress_namespace(
256    can_spawn_subprocess: bool,
257    blocker: Option<EgressNamespaceBlocker>,
258) -> Result<(), crate::errors::RuntimeError> {
259    if !can_spawn_subprocess {
260        return Ok(());
261    }
262    match blocker {
263        None => Ok(()),
264        Some(blocker) => Err(crate::errors::RuntimeError::EgressNamespaceUnavailable {
265            blocker,
266            reason: blocker.reason(),
267        }),
268    }
269}
270
271// ---------------------------------------------------------------- the plan
272
273/// The fully-resolved, allocation-free description of the namespace the forked child will build.
274///
275/// Precomputed in the **parent**, before `fork()`, for the reason [`crate::sealed`] documents at
276/// length: the `pre_exec` window permits only async-signal-safe work, and allocating there can
277/// deadlock on an allocator lock a different thread of the parent held at `fork()` time. The
278/// child reads this and issues syscalls; it computes nothing and allocates nothing.
279#[derive(Debug, Clone)]
280pub(crate) struct CapsuleNetnsPlan {
281    /// TCP ports to bind and listen on inside the namespace, ascending — exactly
282    /// `egress_proxy::egress_listen_ports` for this capsule's allowlist.
283    ///
284    /// May be empty, which is a capsule with no TCP egress at all. The UDP :53 socket is bound
285    /// regardless, so even that capsule gets `REFUSED` *answers* to its lookups rather than the
286    /// multi-second resolver stall a dropped packet produces.
287    pub(crate) tcp_ports: Vec<u16>,
288    /// The real uid/gid, read in the parent.
289    ///
290    /// Load-bearing ordering, exactly as in `sealed::probe_namespace`: inside a fresh user
291    /// namespace with no map written yet, `getuid()` reports the overflow id (65534), and writing
292    /// that into `uid_map` is refused on every host.
293    pub(crate) uid: u32,
294    pub(crate) gid: u32,
295}
296
297impl CapsuleNetnsPlan {
298    /// Resolves the plan for one subprocess launch.
299    #[cfg(target_os = "linux")]
300    #[allow(unsafe_code)]
301    pub(crate) fn resolve(tcp_ports: Vec<u16>) -> Self {
302        // SAFETY: `getuid`/`getgid` take no arguments, dereference nothing and cannot fail.
303        let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
304        Self {
305            tcp_ports,
306            uid,
307            gid,
308        }
309    }
310
311    /// How many descriptors the child will send: one per TCP port, plus the resolver socket.
312    pub(crate) fn socket_count(&self) -> usize {
313        self.tcp_ports.len() + 1
314    }
315}
316
317#[cfg(target_os = "linux")]
318pub(crate) use linux::{create_capsule_netns, receive_namespace_sockets};
319
320#[cfg(target_os = "linux")]
321#[allow(unsafe_code)]
322mod linux {
323    use std::io;
324    use std::os::fd::{FromRawFd, OwnedFd, RawFd};
325
326    use super::{CapsuleNetnsPlan, EgressNamespaceProbe, EgressNamespaceSupport};
327
328    // ------------------------------------------------------------ netlink constants
329    //
330    // Spelled out here rather than taken from `libc`, whose rtnetlink coverage varies by target
331    // and libc flavour. These are uapi ABI constants — fixed for the lifetime of the interface —
332    // and a wrong one fails loudly at the netlink ACK rather than silently.
333
334    const RTM_NEWROUTE: u16 = 24;
335    const NLM_F_REQUEST: u16 = 0x001;
336    const NLM_F_ACK: u16 = 0x004;
337    const NLM_F_EXCL: u16 = 0x200;
338    const NLM_F_CREATE: u16 = 0x400;
339    const NLMSG_ERROR: u16 = 2;
340    /// Table 255, `local` — the table whose routes cause *input* delivery. A default route here
341    /// is what makes every address locally deliverable.
342    const RT_TABLE_LOCAL: u8 = 255;
343    const RTPROT_BOOT: u8 = 3;
344    const RT_SCOPE_HOST: u8 = 254;
345    const RTN_LOCAL: u8 = 2;
346    const RTA_OIF: u16 = 4;
347    /// `lo` is always interface index 1 in a freshly created network namespace: it is the only
348    /// interface there, and the kernel creates it first.
349    const LOOPBACK_IFINDEX: u32 = 1;
350
351    const NLMSGHDR_LEN: usize = 16;
352    const RTMSG_LEN: usize = 12;
353    const RTATTR_OIF_LEN: usize = 8;
354    const ROUTE_REQUEST_LEN: usize = NLMSGHDR_LEN + RTMSG_LEN + RTATTR_OIF_LEN;
355
356    // ------------------------------------------------------------ probe
357
358    /// Rehearses, in a forked child, exactly what a real launch does: create the namespace, own
359    /// it, bring `lo` up and install the local route.
360    ///
361    /// Forking rather than testing in-process is required, not defensive — `unshare(CLONE_NEWUSER)`
362    /// is irreversible for the calling process, and putting the whole runtime into a user
363    /// namespace as a side effect of a capability check is the "probe that changes the thing it
364    /// measures" mistake `sealed::probe_namespace` documents avoiding.
365    pub(super) fn probe_egress_namespace() -> EgressNamespaceSupport {
366        EgressNamespaceSupport {
367            apparmor_permits_userns: crate::sealed::apparmor_permits_userns(),
368            namespace: probe_namespace(),
369        }
370    }
371
372    // Exit codes the probe child reports, so the parent learns the failing *step* with no IPC
373    // beyond `waitpid`. Same convention as `sealed::probe_namespace`.
374    const PROBE_OK: i32 = 0;
375    const PROBE_UNSHARE_DENIED: i32 = 1;
376    const PROBE_UNSHARE_UNSUPPORTED: i32 = 2;
377    const PROBE_MAP_DENIED: i32 = 3;
378    const PROBE_CONFIG_DENIED: i32 = 4;
379
380    fn probe_namespace() -> EgressNamespaceProbe {
381        // Read before the fork, and therefore before `unshare`. See `CapsuleNetnsPlan::uid`.
382        // SAFETY: `getuid`/`getgid` take no arguments and cannot fail.
383        let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
384
385        // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the child
386        // touches nothing but async-signal-safe primitives. The child below calls `unshare`,
387        // `prctl`, the `open`/`write`/`close` triples that write the identity maps, `socket`,
388        // `ioctl`, `sendto`/`recv` and `_exit` — every one async-signal-safe, with no allocation,
389        // no locks and no stdio.
390        let pid = unsafe { libc::fork() };
391        if pid < 0 {
392            return EgressNamespaceProbe::Unsupported;
393        }
394        if pid == 0 {
395            // SAFETY: forked-child context, syscalls only; every branch ends in `_exit`.
396            unsafe {
397                if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET) != 0 {
398                    let errno = *libc::__errno_location();
399                    libc::_exit(match errno {
400                        libc::EPERM => PROBE_UNSHARE_DENIED,
401                        _ => PROBE_UNSHARE_UNSUPPORTED,
402                    });
403                }
404                if write_identity_maps(uid, gid).is_err() {
405                    libc::_exit(PROBE_MAP_DENIED);
406                }
407                if bring_loopback_up().is_err() || add_local_default_route().is_err() {
408                    libc::_exit(PROBE_CONFIG_DENIED);
409                }
410                libc::_exit(PROBE_OK);
411            }
412        }
413
414        let mut status: libc::c_int = 0;
415        // SAFETY: `pid` is the child just forked; `status` is a live local.
416        let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
417        if waited != pid || !libc::WIFEXITED(status) {
418            return EgressNamespaceProbe::Unsupported;
419        }
420        match libc::WEXITSTATUS(status) {
421            PROBE_OK => EgressNamespaceProbe::Ok,
422            PROBE_UNSHARE_DENIED => EgressNamespaceProbe::Denied,
423            PROBE_MAP_DENIED => EgressNamespaceProbe::MapDenied,
424            PROBE_CONFIG_DENIED => EgressNamespaceProbe::ConfigDenied,
425            _ => EgressNamespaceProbe::Unsupported,
426        }
427    }
428
429    // ------------------------------------------------------------ child-side construction
430
431    /// Builds the capsule's network namespace and hands its listening sockets to the parent.
432    ///
433    /// Runs in the forked child's `pre_exec` window, **before** the seccomp filter (which denies
434    /// `unshare` and `socket(AF_NETLINK)`) and before `sealed`'s composed root. The ordering
435    /// against `sealed` is load-bearing in a second way worth stating outright: `sealed` goes on
436    /// to `unshare(CLONE_NEWUSER | CLONE_NEWNS)` a *nested* user namespace, and a process in a
437    /// descendant user namespace holds no capabilities in the ancestor that owns this network
438    /// namespace. A `sealed` capsule therefore cannot reconfigure the namespace confining it —
439    /// verified by observing `SIOCSIFFLAGS` return `EPERM` from inside the nested namespace,
440    /// rather than assumed from the capability rules.
441    ///
442    /// # Safety
443    /// Post-`fork()`, pre-`execve` child context only. Every call below is async-signal-safe and
444    /// nothing here allocates: `plan` was fully resolved in the parent.
445    pub(crate) unsafe fn create_capsule_netns(
446        plan: &CapsuleNetnsPlan,
447        sock_fd: RawFd,
448    ) -> io::Result<()> {
449        // SAFETY: forked-child context; the caller's `unsafe` contract covers this whole body.
450        unsafe {
451            if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET) != 0 {
452                return Err(io::Error::other(
453                    "egress-netns: unshare(CLONE_NEWUSER | CLONE_NEWNET) failed",
454                ));
455            }
456            write_identity_maps(plan.uid, plan.gid)
457                .map_err(|()| io::Error::other("egress-netns: writing uid_map/gid_map failed"))?;
458            bring_loopback_up()
459                .map_err(|()| io::Error::other("egress-netns: bringing lo up failed"))?;
460            add_local_default_route().map_err(|()| {
461                io::Error::other("egress-netns: installing the local default route failed")
462            })?;
463
464            // One TCP listener per allowlisted port, then the resolver socket last — the order
465            // the parent's `start_egress_proxy` splits them back apart in.
466            let mut fds = [-1 as RawFd; super::MAX_NAMESPACE_SOCKETS];
467            let mut count = 0usize;
468
469            for port in &plan.tcp_ports {
470                let fd = bind_tcp_listener(*port);
471                if fd < 0 {
472                    close_all(&fds[..count]);
473                    return Err(io::Error::other(
474                        "egress-netns: binding a TCP listener inside the namespace failed",
475                    ));
476                }
477                fds[count] = fd;
478                count += 1;
479            }
480
481            let resolver = bind_dns_socket();
482            if resolver < 0 {
483                close_all(&fds[..count]);
484                return Err(io::Error::other(
485                    "egress-netns: binding UDP 53 inside the namespace failed",
486                ));
487            }
488            fds[count] = resolver;
489            count += 1;
490
491            let sent = send_fds(sock_fd, &fds[..count]);
492            close_all(&fds[..count]);
493            sent.map_err(|()| {
494                io::Error::other(
495                    "egress-netns: handing the namespace sockets to the runtime failed",
496                )
497            })
498        }
499    }
500
501    /// Writes `setgroups=deny` and the identity `uid_map`/`gid_map`, reopening the dumpable
502    /// window around them.
503    ///
504    /// The dumpable dance is not optional and is `sealed`'s hardest-won lesson, reused here
505    /// rather than rediscovered: `mur` marks itself non-dumpable at startup, the flag is inherited
506    /// across `fork()`, and the kernel reassigns every `/proc/<pid>/*` entry of a non-dumpable
507    /// task to root — including `uid_map`. Without this the namespace is created and can never be
508    /// owned, and the failure reads as a host id-mapping policy problem on a host whose policy is
509    /// perfectly fine.
510    ///
511    /// # Safety
512    /// Post-`fork()`, pre-exec child context only.
513    unsafe fn write_identity_maps(uid: u32, gid: u32) -> Result<(), ()> {
514        // SAFETY: caller's contract; every call below is async-signal-safe.
515        unsafe {
516            let previous = crate::sealed::make_dumpable_for_map_writes();
517            let _ = crate::sealed::write_decimal_map(c"/proc/self/setgroups", None, 0);
518            let mapped = crate::sealed::write_decimal_map(c"/proc/self/uid_map", Some(uid), uid)
519                .is_ok()
520                && crate::sealed::write_decimal_map(c"/proc/self/gid_map", Some(gid), gid).is_ok();
521            crate::sealed::restore_dumpable(previous);
522            if mapped {
523                Ok(())
524            } else {
525                Err(())
526            }
527        }
528    }
529
530    /// `ip link set lo up`, via the legacy `ifreq` ioctl rather than netlink.
531    ///
532    /// The ioctl is a fixed-size struct with no message framing, so it is markedly simpler to do
533    /// allocation-free inside `pre_exec` than the equivalent `RTM_NEWLINK`. It is not deprecated
534    /// for this purpose, and every distribution's `ifconfig` still uses it.
535    ///
536    /// # Safety
537    /// Post-`fork()`, pre-exec child context only.
538    unsafe fn bring_loopback_up() -> Result<(), ()> {
539        // SAFETY: `ifr` is a live, zeroed `ifreq` whose name field is set to "lo"; both ioctls
540        // read and write only within it, and the socket is closed on every path.
541        unsafe {
542            let sock = libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0);
543            if sock < 0 {
544                return Err(());
545            }
546            let mut ifr: libc::ifreq = std::mem::zeroed();
547            ifr.ifr_name[0] = b'l' as libc::c_char;
548            ifr.ifr_name[1] = b'o' as libc::c_char;
549            if libc::ioctl(sock, libc::SIOCGIFFLAGS, &mut ifr) < 0 {
550                libc::close(sock);
551                return Err(());
552            }
553            ifr.ifr_ifru.ifru_flags |= (libc::IFF_UP | libc::IFF_RUNNING) as libc::c_short;
554            let rc = libc::ioctl(sock, libc::SIOCSIFFLAGS, &ifr);
555            libc::close(sock);
556            if rc < 0 {
557                return Err(());
558            }
559            Ok(())
560        }
561    }
562
563    /// `ip route add local default dev lo`, over raw netlink.
564    ///
565    /// A route of type `RTN_LOCAL` in table `local` tells the kernel that the destination is one
566    /// of *its own* addresses, so a `connect()` to any address is looped back and delivered to a
567    /// listening socket in this namespace — with the original destination preserved and
568    /// recoverable by `getsockname(2)`. That is the whole interception mechanism: no netfilter, no
569    /// `iptables` binary, and no rule set that can drift out of step with the manifest. A port
570    /// nothing is bound to is refused immediately by the namespace instead of stalling through the
571    /// kernel's full SYN-retry schedule against a blackhole.
572    ///
573    /// IPv4 only, deliberately. A capsule's IPv6 destinations have no route at all and fail with
574    /// `ENETUNREACH`, and the proxy reaches every upstream over the host's own stack, so nothing
575    /// a capsule can legitimately ask for depends on IPv6 inside the namespace.
576    ///
577    /// # Safety
578    /// Post-`fork()`, pre-exec child context only. The request is built in a fixed stack buffer.
579    unsafe fn add_local_default_route() -> Result<(), ()> {
580        // SAFETY: `request` and `address` are live, correctly-sized stack locals; the socket is
581        // closed on every path.
582        unsafe {
583            let mut request = [0u8; ROUTE_REQUEST_LEN];
584            request[0..4].copy_from_slice(&(ROUTE_REQUEST_LEN as u32).to_ne_bytes());
585            request[4..6].copy_from_slice(&RTM_NEWROUTE.to_ne_bytes());
586            request[6..8].copy_from_slice(
587                &(NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL).to_ne_bytes(),
588            );
589            request[8..12].copy_from_slice(&1u32.to_ne_bytes()); // sequence
590            request[12..16].copy_from_slice(&0u32.to_ne_bytes()); // pid: the kernel assigns
591
592            let rtm = NLMSGHDR_LEN;
593            request[rtm] = libc::AF_INET as u8;
594            request[rtm + 1] = 0; // dst_len 0 — a default route
595            request[rtm + 2] = 0; // src_len
596            request[rtm + 3] = 0; // tos
597            request[rtm + 4] = RT_TABLE_LOCAL;
598            request[rtm + 5] = RTPROT_BOOT;
599            request[rtm + 6] = RT_SCOPE_HOST;
600            request[rtm + 7] = RTN_LOCAL;
601            request[rtm + 8..rtm + 12].copy_from_slice(&0u32.to_ne_bytes()); // rtm_flags
602
603            let attr = rtm + RTMSG_LEN;
604            request[attr..attr + 2].copy_from_slice(&(RTATTR_OIF_LEN as u16).to_ne_bytes());
605            request[attr + 2..attr + 4].copy_from_slice(&RTA_OIF.to_ne_bytes());
606            request[attr + 4..attr + 8].copy_from_slice(&LOOPBACK_IFINDEX.to_ne_bytes());
607
608            let sock = libc::socket(
609                libc::AF_NETLINK,
610                libc::SOCK_RAW | libc::SOCK_CLOEXEC,
611                libc::NETLINK_ROUTE,
612            );
613            if sock < 0 {
614                return Err(());
615            }
616            let mut address: libc::sockaddr_nl = std::mem::zeroed();
617            address.nl_family = libc::AF_NETLINK as u16;
618            let sent = libc::sendto(
619                sock,
620                request.as_ptr().cast(),
621                ROUTE_REQUEST_LEN,
622                0,
623                std::ptr::addr_of!(address).cast(),
624                std::mem::size_of::<libc::sockaddr_nl>() as libc::socklen_t,
625            );
626            if sent < 0 {
627                libc::close(sock);
628                return Err(());
629            }
630
631            // Reading the ACK is not optional: netlink reports the real outcome here, and a
632            // request that was accepted for delivery but rejected by the kernel would otherwise
633            // look like success and leave the capsule with a namespace it cannot resolve through.
634            let mut response = [0u8; 256];
635            let received = libc::recv(sock, response.as_mut_ptr().cast(), response.len(), 0);
636            libc::close(sock);
637            if received < (NLMSGHDR_LEN + 4) as isize {
638                return Err(());
639            }
640            if u16::from_ne_bytes([response[4], response[5]]) == NLMSG_ERROR {
641                let code = i32::from_ne_bytes([
642                    response[NLMSGHDR_LEN],
643                    response[NLMSGHDR_LEN + 1],
644                    response[NLMSGHDR_LEN + 2],
645                    response[NLMSGHDR_LEN + 3],
646                ]);
647                // Netlink spells a plain ACK as an error message carrying code 0.
648                if code != 0 {
649                    return Err(());
650                }
651            }
652            Ok(())
653        }
654    }
655
656    /// A listening TCP socket on `0.0.0.0:port` inside the namespace, or `-1`.
657    ///
658    /// The wildcard address is what pairs with the local default route: every address is locally
659    /// deliverable, so this one socket receives connections addressed to *any* host on this port,
660    /// and `getsockname(2)` on each accepted connection recovers which one the capsule dialled.
661    ///
662    /// # Safety
663    /// Post-`fork()`, pre-exec child context only.
664    unsafe fn bind_tcp_listener(port: u16) -> RawFd {
665        // SAFETY: `address` is a live, zeroed `sockaddr_in`; every failure path closes the fd.
666        unsafe {
667            let fd = libc::socket(libc::AF_INET, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0);
668            if fd < 0 {
669                return -1;
670            }
671            let mut address: libc::sockaddr_in = std::mem::zeroed();
672            address.sin_family = libc::AF_INET as libc::sa_family_t;
673            address.sin_port = port.to_be();
674            address.sin_addr.s_addr = libc::INADDR_ANY.to_be();
675            if libc::bind(
676                fd,
677                std::ptr::addr_of!(address).cast(),
678                std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
679            ) != 0
680                || libc::listen(fd, 128) != 0
681            {
682                libc::close(fd);
683                return -1;
684            }
685            fd
686        }
687    }
688
689    /// A UDP socket bound to `0.0.0.0:53` inside the namespace with `IP_PKTINFO` enabled, or
690    /// `-1`.
691    ///
692    /// The wildcard bind pairs with the local default route: whatever address `/etc/resolv.conf`
693    /// names, the query is delivered to this one socket. `IP_PKTINFO` is then required rather
694    /// than a nicety — it is how the proxy recovers the address the query was *sent to*, so its
695    /// reply can carry that same address as the source. A resolver discards an answer that
696    /// appears to come from a server it never asked, and the symptom of getting this wrong is
697    /// "DNS silently does not work".
698    ///
699    /// # Safety
700    /// Post-`fork()`, pre-exec child context only.
701    unsafe fn bind_dns_socket() -> RawFd {
702        // SAFETY: as `bind_tcp_listener`.
703        unsafe {
704            let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0);
705            if fd < 0 {
706                return -1;
707            }
708            let enable: libc::c_int = 1;
709            if libc::setsockopt(
710                fd,
711                libc::IPPROTO_IP,
712                libc::IP_PKTINFO,
713                std::ptr::addr_of!(enable).cast(),
714                std::mem::size_of::<libc::c_int>() as libc::socklen_t,
715            ) != 0
716            {
717                libc::close(fd);
718                return -1;
719            }
720            let mut address: libc::sockaddr_in = std::mem::zeroed();
721            address.sin_family = libc::AF_INET as libc::sa_family_t;
722            address.sin_port = super::DNS_PORT_BE;
723            address.sin_addr.s_addr = libc::INADDR_ANY.to_be();
724            if libc::bind(
725                fd,
726                std::ptr::addr_of!(address).cast(),
727                std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
728            ) != 0
729            {
730                libc::close(fd);
731                return -1;
732            }
733            fd
734        }
735    }
736
737    /// # Safety
738    /// Post-`fork()`, pre-exec child context only.
739    unsafe fn close_all(fds: &[RawFd]) {
740        for fd in fds {
741            if *fd >= 0 {
742                // SAFETY: each entry was returned by a successful `socket` call above.
743                unsafe { libc::close(*fd) };
744            }
745        }
746    }
747
748    /// Enough room for [`super::MAX_NAMESPACE_SOCKETS`] descriptors plus the `cmsghdr`.
749    const CMSG_BUFFER_LEN: usize = 128;
750
751    // Not just a comment: `send_fds`/`receive_namespace_sockets` size `msg_controllen` from
752    // `libc::CMSG_SPACE(descriptor_bytes)`, independently of `CMSG_BUFFER_LEN`, so nothing before
753    // this diff stopped a future bump of `MAX_NAMESPACE_SOCKETS` (via
754    // `egress_proxy::MAX_EGRESS_TCP_PORTS`) from silently making that computed length exceed the
755    // fixed-size `CmsgBuffer` below — the CMSG macros would then write past its end. Pinned here
756    // at compile time instead of trusted to stay true.
757    const _: () = assert!(
758        // SAFETY: `CMSG_SPACE` is pure arithmetic on its argument (see libc's definition); it is
759        // marked `unsafe` only for macro-generation consistency with pointer-taking neighbours
760        // like `CMSG_DATA`, not because this call touches memory.
761        unsafe {
762            libc::CMSG_SPACE((super::MAX_NAMESPACE_SOCKETS * std::mem::size_of::<RawFd>()) as u32)
763                as usize
764        } <= CMSG_BUFFER_LEN,
765        "CMSG_BUFFER_LEN is too small to hold MAX_NAMESPACE_SOCKETS descriptors plus the cmsghdr; \
766         raise it to match"
767    );
768
769    /// A `cmsghdr` buffer with the alignment the CMSG macros require.
770    ///
771    /// A bare `[u8; N]` is only 1-byte aligned and `CMSG_FIRSTHDR` casts it straight to
772    /// `*mut cmsghdr`: on a debug build that is a misaligned-pointer panic, and on a release build
773    /// it is undefined behaviour. Found the hard way while prototyping this hand-off.
774    #[repr(C, align(8))]
775    struct CmsgBuffer([u8; CMSG_BUFFER_LEN]);
776
777    /// Sends every namespace socket to the parent in one `SCM_RIGHTS` message.
778    ///
779    /// One message rather than one per fd, so the parent's receive is a single unambiguous read:
780    /// a partial hand-off would leave the proxy serving the endpoint but not the resolver, which
781    /// presents as "DNS mysteriously does not work" rather than as a failure.
782    ///
783    /// # Safety
784    /// Post-`fork()`, pre-exec child context only; `sendmsg` is async-signal-safe and every
785    /// buffer is a stack local.
786    unsafe fn send_fds(sock_fd: RawFd, fds: &[RawFd]) -> Result<(), ()> {
787        // SAFETY: all buffers are live locals outliving the single `sendmsg`; the control buffer
788        // is 8-byte aligned, which the CMSG macros require.
789        unsafe {
790            let mut payload = [0u8; 1];
791            let mut iov = libc::iovec {
792                iov_base: payload.as_mut_ptr().cast(),
793                iov_len: payload.len(),
794            };
795            let mut control = CmsgBuffer([0u8; CMSG_BUFFER_LEN]);
796            let bytes = std::mem::size_of_val(fds) as u32;
797
798            let mut msg: libc::msghdr = std::mem::zeroed();
799            msg.msg_iov = &mut iov;
800            msg.msg_iovlen = 1;
801            msg.msg_control = control.0.as_mut_ptr().cast();
802            msg.msg_controllen = libc::CMSG_SPACE(bytes) as _;
803
804            let cmsg = libc::CMSG_FIRSTHDR(&msg);
805            if cmsg.is_null() {
806                return Err(());
807            }
808            (*cmsg).cmsg_level = libc::SOL_SOCKET;
809            (*cmsg).cmsg_type = libc::SCM_RIGHTS;
810            (*cmsg).cmsg_len = libc::CMSG_LEN(bytes) as _;
811            std::ptr::copy_nonoverlapping(
812                fds.as_ptr().cast::<u8>(),
813                libc::CMSG_DATA(cmsg),
814                bytes as usize,
815            );
816
817            if libc::sendmsg(sock_fd, &msg, 0) < 0 {
818                return Err(());
819            }
820            Ok(())
821        }
822    }
823
824    /// Parent-side counterpart of [`send_fds`]: receives the namespace's listening sockets.
825    ///
826    /// Returns them in the order the child sent: one TCP listener per allowlisted port, then the
827    /// resolver socket. A count that does not match the plan is an error rather than a
828    /// best-effort start, because a proxy missing one of its sockets is a capsule whose network
829    /// half-works in a way nothing downstream would report.
830    pub(crate) fn receive_namespace_sockets(
831        sock_fd: RawFd,
832        expected: usize,
833    ) -> io::Result<Vec<OwnedFd>> {
834        // SAFETY: `sock_fd` is a valid connected unix socket; all buffers are live locals sized
835        // for the largest message the child can send, and the control buffer is 8-byte aligned.
836        unsafe {
837            let mut payload = [0u8; 1];
838            let mut iov = libc::iovec {
839                iov_base: payload.as_mut_ptr().cast(),
840                iov_len: payload.len(),
841            };
842            let mut control = CmsgBuffer([0u8; CMSG_BUFFER_LEN]);
843            let mut msg: libc::msghdr = std::mem::zeroed();
844            msg.msg_iov = &mut iov;
845            msg.msg_iovlen = 1;
846            msg.msg_control = control.0.as_mut_ptr().cast();
847            msg.msg_controllen = control.0.len() as _;
848
849            if libc::recvmsg(sock_fd, &mut msg, libc::MSG_CMSG_CLOEXEC) < 0 {
850                return Err(io::Error::last_os_error());
851            }
852            let cmsg = libc::CMSG_FIRSTHDR(&msg);
853            if cmsg.is_null()
854                || (*cmsg).cmsg_level != libc::SOL_SOCKET
855                || (*cmsg).cmsg_type != libc::SCM_RIGHTS
856            {
857                return Err(io::Error::other(
858                    "egress-netns: the namespace sockets did not arrive as an SCM_RIGHTS message",
859                ));
860            }
861            let bytes = (*cmsg).cmsg_len - libc::CMSG_LEN(0) as usize;
862            let received = bytes / std::mem::size_of::<RawFd>();
863            let data = libc::CMSG_DATA(cmsg).cast::<RawFd>();
864            // Wrapped in `OwnedFd` before the count is checked, so a mismatched message closes
865            // its descriptors on the error path instead of leaking them.
866            let mut sockets = Vec::with_capacity(received);
867            for index in 0..received {
868                sockets.push(OwnedFd::from_raw_fd(std::ptr::read_unaligned(
869                    data.add(index),
870                )));
871            }
872            if received != expected {
873                return Err(io::Error::other(
874                    "egress-netns: the runtime received a different number of namespace sockets \
875                     than the launch plan asked for",
876                ));
877            }
878            Ok(sockets)
879        }
880    }
881}
882
883/// [`crate::egress_proxy::EGRESS_DNS_PORT`] in network byte order, so the `pre_exec` window does
884/// no arithmetic the parent could have done for it.
885#[cfg(target_os = "linux")]
886const DNS_PORT_BE: u16 = crate::egress_proxy::EGRESS_DNS_PORT.to_be();
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891
892    fn support(apparmor: bool, namespace: EgressNamespaceProbe) -> EgressNamespaceSupport {
893        EgressNamespaceSupport {
894            apparmor_permits_userns: apparmor,
895            namespace,
896        }
897    }
898
899    // ---- `egress_namespace_blocker` ------------------------------------------------------
900
901    #[test]
902    fn a_working_namespace_blocks_nothing() {
903        assert_eq!(
904            egress_namespace_blocker(true, support(true, EgressNamespaceProbe::Ok)),
905            None
906        );
907    }
908
909    #[test]
910    fn apparmor_is_blamed_before_the_namespace_outcome() {
911        // The `unshare` failure is a *consequence* of the restriction, so naming the kernel here
912        // would send an Ubuntu desktop user to entirely the wrong fix.
913        assert_eq!(
914            egress_namespace_blocker(true, support(false, EgressNamespaceProbe::Unsupported)),
915            Some(EgressNamespaceBlocker::CapabilityGrantMissing)
916        );
917    }
918
919    #[test]
920    fn every_refusal_of_a_supported_mechanism_is_a_capability_grant_problem() {
921        for probe in [
922            EgressNamespaceProbe::Denied,
923            EgressNamespaceProbe::MapDenied,
924            EgressNamespaceProbe::ConfigDenied,
925        ] {
926            assert_eq!(
927                egress_namespace_blocker(true, support(true, probe)),
928                Some(EgressNamespaceBlocker::CapabilityGrantMissing),
929                "{probe:?}"
930            );
931        }
932    }
933
934    #[test]
935    fn a_kernel_without_the_mechanism_is_named_separately() {
936        assert_eq!(
937            egress_namespace_blocker(true, support(true, EgressNamespaceProbe::Unsupported)),
938            Some(EgressNamespaceBlocker::KernelSupportMissing)
939        );
940    }
941
942    #[test]
943    fn non_linux_is_never_refused_for_a_mechanism_it_never_had() {
944        for probe in [
945            EgressNamespaceProbe::Ok,
946            EgressNamespaceProbe::Denied,
947            EgressNamespaceProbe::Unsupported,
948        ] {
949            assert_eq!(egress_namespace_blocker(false, support(false, probe)), None);
950        }
951    }
952
953    // ---- refusal text --------------------------------------------------------------------
954
955    #[test]
956    fn every_blocker_refuses_the_retired_fallback_in_so_many_words() {
957        for blocker in EgressNamespaceBlocker::ALL {
958            let reason = blocker.reason();
959            assert!(
960                reason.contains("will not fall back"),
961                "{blocker:?} must state that the retired seccomp path is not a fallback"
962            );
963        }
964    }
965
966    #[test]
967    fn the_capability_grant_refusal_names_both_remediations() {
968        let reason = EgressNamespaceBlocker::CapabilityGrantMissing.reason();
969        assert!(reason.contains("apparmor_parser"), "names the AppArmor fix");
970        assert!(
971            reason.contains("--cap-add SYS_ADMIN"),
972            "names the container fix"
973        );
974    }
975
976    #[test]
977    fn the_kernel_refusal_names_the_sysctl_and_the_config_symbol() {
978        let reason = EgressNamespaceBlocker::KernelSupportMissing.reason();
979        assert!(reason.contains("user.max_user_namespaces"));
980        assert!(reason.contains("CONFIG_USER_NS"));
981    }
982
983    // ---- the socket plan --------------------------------------------------------------------
984
985    #[test]
986    fn the_socket_count_is_one_per_port_plus_the_resolver() {
987        let plan = CapsuleNetnsPlan {
988            tcp_ports: vec![80, 443],
989            uid: 1000,
990            gid: 1000,
991        };
992        assert_eq!(plan.socket_count(), 3);
993    }
994
995    #[test]
996    fn a_capsule_with_no_tcp_egress_still_binds_the_resolver() {
997        // Load-bearing: with nothing bound on 53 a lookup would be a dropped packet, and the
998        // capsule would stall through glibc's whole retry schedule instead of being told no.
999        let plan = CapsuleNetnsPlan {
1000            tcp_ports: Vec::new(),
1001            uid: 1000,
1002            gid: 1000,
1003        };
1004        assert_eq!(plan.socket_count(), 1);
1005    }
1006
1007    #[test]
1008    fn the_widest_allowlist_still_fits_one_scm_rights_message() {
1009        let plan = CapsuleNetnsPlan {
1010            tcp_ports: (0..crate::egress_proxy::MAX_EGRESS_TCP_PORTS as u16).collect(),
1011            uid: 1000,
1012            gid: 1000,
1013        };
1014        assert!(plan.socket_count() <= MAX_NAMESPACE_SOCKETS);
1015    }
1016}