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