Skip to main content

Module network_namespace

Module network_namespace 

Source
Expand description

The capsule’s own network namespace: the structural half of the mechanism that replaced the seccomp-notify connect/sendto supervisor.

[crate::egress_proxy] decides which destinations a subprocess may reach. This module is what makes that decision the only way out — it puts the subprocess tree somewhere with no route off the host, so an unlisted destination is not “denied” by a filter that has to be consulted, it is simply unreachable.

§One namespace per session

A session has exactly one network namespace, [SessionNetns], created the first time one of its commands is spawned and shared by every command after that. A server one shell call starts on 127.0.0.1:8080 is therefore reachable from the session’s next call, and from nothing outside the session: two sessions are two mur processes, each with its own SessionNetns, and no interface hands one across a process boundary. A plan’s shell steps run in the namespace of the session that submitted the plan.

The namespace is built once by a dedicated creator child, forked for nothing else, which hands the parent the namespace’s listening sockets and a descriptor for each of its three namespaces — user, network and PID — in one SCM_RIGHTS message and then exits. The network namespace outlives the creator because the runtime holds those descriptors; the PID namespace outlives it because of its holder, pid 1 of that namespace, which the creator forks and which lives exactly as long as the runtime holds its lifeline. Every command then enters the namespaces with [join_session_netns] — setns(2) into the user namespace, then into the network namespace — and setns(2) into the PID namespace followed by a fork, all in its own pre_exec window, where it is single-threaded and still unfiltered (sandbox::linux_enforce’s relay). The runtime’s own threads never call setns or unshare. The namespaces are released when the session ends: [SessionNetns::end] stops the session’s egress proxy, closes the descriptors and closes the holder’s lifeline, after ShellEnforcement::end_session has killed the session’s cgroup scope; the holder’s exit then kills whatever is left in the PID namespace.

§Whether a host can create one

Answered by creating one: crate::namespace_probe runs the same creator a session does, with an empty allowlist, and drops what it hands over. A staged session’s staging and launch read the answer off that session’s one crate::sandbox::HostProbe, and a session’s first command gets the same refusal from its own creation ([SessionNetns::establish]). AppArmor’s unprivileged-userns restriction and the profile confining mur are read only to explain a failure: a container whose own profile permits the namespace is not refused because the host’s restriction reads 1.

§The syscall sequence

Everything happens in the creator child, before any capsule code runs, and the join happens in each command’s forked child before the seccomp filter (which denies unshare, setns and socket(AF_NETLINK) outright) and before crate::sealed’s composed root:

  1. unshare(CLONE_NEWUSER | CLONE_NEWNET | CLONE_NEWPID), then the identity uid_map/gid_map writes that make the child the owner of all three, then a fork: the forked process is pid 1 of the new PID namespace, the holder, and keeps only its lifeline (see [SessionNetns::end]). The user namespace is not incidental: CLONE_NEWNET alone needs CAP_NET_ADMIN, which an unprivileged process does not have, and creating it inside a user namespace it owns is what supplies that capability there and nowhere else. This is the same primitive sealed uses, and AppArmor’s unprivileged-userns restriction can refuse it the same way.
  2. lo is brought up ([bring_loopback_up]). It is the namespace’s only interface: there is no veth pair, no bridge and no NAT, so nothing in the namespace has a path to the host’s network or to the outside world.
  3. One route is installed over netlink ([add_local_default_route]): local default dev lo. A local-type default route makes every address locally deliverable, so a connect() to any address is looped back to a socket in this namespace with the original destination intact — recoverable with getsockname(2). That is the entire interception mechanism, and it is why no netfilter rule is needed anywhere. It also means a lookup reaches the resolver whatever address /etc/resolv.conf names, and a connection to a port nothing is bound to is refused immediately rather than stalling through the kernel’s full SYN-retry schedule against a blackhole.
  4. The listening sockets are created here, inside the namespace — one TCP listener per port the allowlist implies, then one wildcard UDP :53 resolver socket — and handed back to the parent in a single SCM_RIGHTS message, followed by the /proc/self/ns/user, /proc/self/ns/net and /proc/self/ns/pid_for_children descriptors later commands join through. A socket belongs to the namespace it was created in, not the one it is used from, which is the whole reason this inversion works: the runtime serves them while staying in the host’s namespace, with real internet access for the upstream half. The listeners are held for the whole session, so a server a command starts on one of those ports gets EADDRINUSE.

§Why this shape rather than the obvious ones

No veth pair. Wiring a namespace to the host with veth requires CAP_NET_ADMIN in the host’s network namespace, which means real root. This design needs no host privilege at all, which is what lets mur install per-user.

No proxy process inside the namespace, and a holder that forks nothing. A multithreaded process cannot enter a user namespace: setns(user_fd, CLONE_NEWUSER) from the runtime fails (EINVAL, or EPERM from one of its threads). A freshly forked child is single-threaded, and the euid that owns the session’s user namespace holds every capability in it, so the same setns from a command’s pre_exec window succeeds, followed by setns(net_fd, CLONE_NEWNET). Passing the listening sockets out is what lets the proxy serve the namespace from the host’s side with no privilege at all.

A long-lived single-threaded holder that forked each command on request was the alternative. It would make every command its child rather than the runtime’s, so every Child the shell path keys on would need a proxy layer, and it would re-implement std::process::Command’s spawn over a request protocol. Joining in pre_exec changes one step of the existing spawn instead. The PID namespace’s holder is not that process: it is only pid 1, forks nothing, runs no capsule code and holds no descriptor but its lifeline. A command still enters the namespace in its own pre_exec window, through a relay: the process Child refers to stays outside the namespace and waits for the command it forks into it.

No iptables/nft/TPROXY. Nothing here needs a netfilter rule, so there is no dependency on netfilter modules being present, no iptables binary to shell out to, and no rule set that can drift out of step with the manifest.

§What this does not isolate

AF_UNIX is untouched. CLONE_NEWNET does not mediate unix sockets in any way that matters here — a pathname socket is reached through the filesystem, not the network stack. That is exactly why the register-level socket(2) domain filter (denied_socket_domains in [crate::sandbox]) is untouched here and remains the thing that refuses /var/run/docker.sock.

Structs§

EgressNamespaceFailure
A creation of the capsule network namespace that failed, with what its refusal names.

Enums§

CreationStep
One step of the namespace’s construction, as the creator reports it when that step fails.
EgressNamespaceBlocker
Which remediation an EgressNamespaceFailure calls for.
NamespaceAttemptError
Why an attempt to create a session’s network namespace produced no namespace.

Functions§

check_egress_namespace
Refuses the launch when a capsule that can spawn subprocesses is on a host that cannot give them a network namespace.
detect_egress_namespace_failure
Whether this host can give a capsule its own network namespace, answered by creating one.
host_pid_in_session
The host pid of the process a session’s command knows as namespace_pid (its $$ or $!), or None when no live process matches.
host_pid_in_workdir
The host pid of the live process whose working directory is workdir and whose pid inside its PID namespace is namespace_pid, for a session run by a mur this process did not start in-process.
host_pids_in_sessions
Every host pid host_pid_in_session could answer: one per session of runtime_pid that has a live process numbered namespace_pid, for a test that holds several sessions at once.
skip_without_egress_namespace
Whether a test that needs a capsule network namespace must stand down on this host, printing the failure when it must.