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:
unshare(CLONE_NEWUSER | CLONE_NEWNET | CLONE_NEWPID), then the identityuid_map/gid_mapwrites 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_NEWNETalone needsCAP_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 primitivesealeduses, and AppArmor’s unprivileged-userns restriction can refuse it the same way.lois 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.- One route is installed over netlink ([
add_local_default_route]):local default dev lo. Alocal-type default route makes every address locally deliverable, so aconnect()to any address is looped back to a socket in this namespace with the original destination intact — recoverable withgetsockname(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.confnames, 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. - 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_RIGHTSmessage, followed by the/proc/self/ns/user,/proc/self/ns/netand/proc/self/ns/pid_for_childrendescriptors 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 getsEADDRINUSE.
§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§
- Egress
Namespace Failure - A creation of the capsule network namespace that failed, with what its refusal names.
Enums§
- Creation
Step - One step of the namespace’s construction, as the creator reports it when that step fails.
- Egress
Namespace Blocker - Which remediation an
EgressNamespaceFailurecalls for. - Namespace
Attempt Error - 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$!), orNonewhen no live process matches. - host_
pid_ in_ workdir - The host pid of the live process whose working directory is
workdirand whose pid inside its PID namespace isnamespace_pid, for a session run by amurthis process did not start in-process. - host_
pids_ in_ sessions - Every host pid
host_pid_in_sessioncould answer: one per session ofruntime_pidthat has a live process numberednamespace_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.