ExSandbox.Egress.NetnsSocket (ExSandbox v1.2.0)

Copy Markdown View Source

Sockets created inside a sandbox's network namespace and owned by this BEAM.

The constraint this removes

An nft redirect is DNAT to the local machine as the redirecting namespace sees it, so it can only ever land on a socket in that namespace. The BEAM runs in the host namespace and no option to :gen_tcp.listen/2 changes a socket's namespace. That is the whole reason this project ran a separate helper process entered via nsenter, and the reason Egress.Pool's own listener was dead code: it bound the host namespace, and nothing redirected there.

setns(2) with CLONE_NEWNET affects only the calling thread. So the descriptor can cross the boundary even though the process cannot: enter the namespace on a dedicated thread, create the socket there, hand back the fd, and adopt it with {:fd, Fd}. See c_src/netns_nif.c for why each job gets a thread of its own and why running one on a scheduler would be a defect.

What this is not

It is not an enforcement point and it holds no policy. It returns descriptors. ExSandbox.Egress.Decision.decide/3 remains the single implementation of "may this sandbox reach this destination".

⚠️ Absence is refusal, not a fallback

The NIF is built only on Linux, and only when a C compiler was present. When it is missing every function here returns {:error, :unsupported} and the caller must refuse. There is deliberately no host-namespace fallback: a listener that binds the host instead is precisely the silent no-op this module exists to replace, and it would read as a working enforcement point while enforcing nothing.

Summary

Types

Why a descriptor could not be produced. The atom names the syscall that refused, so :setns (namespace gone, or no CAP_SYS_ADMIN) is distinguishable from :setsockopt_mark (no CAP_NET_ADMIN); the integer is errno.

Path to a network namespace, e.g. /var/run/netns/sandbox-7.

Functions

Whether namespace-local sockets are possible on this host.

A listening socket bound to port on INADDR_ANY inside netns.

An unconnected socket inside netns, carrying SO_MARK set to mark.

A bound UDP socket inside netns, for the DNS leg.

Types

failure()

@type failure() :: {:error, atom(), integer()} | {:error, :unsupported}

Why a descriptor could not be produced. The atom names the syscall that refused, so :setns (namespace gone, or no CAP_SYS_ADMIN) is distinguishable from :setsockopt_mark (no CAP_NET_ADMIN); the integer is errno.

netns()

@type netns() :: String.t()

Path to a network namespace, e.g. /var/run/netns/sandbox-7.

Functions

available?()

@spec available?() :: boolean()

Whether namespace-local sockets are possible on this host.

Recorded at load time rather than probed by calling, and read rather than derived from :os.type/0: a Linux host whose build had no C compiler is indistinguishable from macOS from here, and both must refuse.

listen(netns, port)

@spec listen(netns(), :inet.port_number()) :: {:ok, non_neg_integer()} | failure()

A listening socket bound to port on INADDR_ANY inside netns.

Bound to all addresses rather than loopback because the redirect rewrites the destination to the namespace's own primary address; measured, a connection arrived from 172.19.0.4, so a loopback listener is never reached.

Adopt the descriptor with :gen_tcp.listen(0, [..., {:fd, fd}]). The port argument to :gen_tcp.listen/2 is ignored when {:fd, _} is given -- the bind already happened here.

socket(netns, mark)

@spec socket(netns(), non_neg_integer()) :: {:ok, non_neg_integer()} | failure()

An unconnected socket inside netns, carrying SO_MARK set to mark.

⚠️ The mark is written, read back, and compared inside the NIF before the descriptor is returned, because setting it from Elixir fails open: with CAP_NET_ADMIN dropped, :inet.setopts/2 reports :ok while the kernel refused and the option reads back as zero. The symptom of a lost mark is a permitted destination timing out, which every denial-focused check scores as green. There is no return path from the NIF that yields an unmarked socket.

Adopt with :gen_tcp.connect(address, port, [..., {:fd, fd}], timeout), which connects through the given descriptor and therefore out of netns.

udp(netns, port)

@spec udp(netns(), :inet.port_number()) :: {:ok, non_neg_integer()} | failure()

A bound UDP socket inside netns, for the DNS leg.

Bound to INADDR_ANY on port for the same measured reason listen/2 is: the redirect DNATs the tenant's port 53 traffic to the resolver's address:port, which is not loopback. Adopt with :gen_udp.open(0, [..., {:fd, fd}]).