Add network_tap_relay: tap-backed veth substitute, standalone

Second step of the tap+relay fallback for veth-less kernels (the real
target device supports tun/tap but not veth). Reuses the existing
bridge as the switching fabric -- provision_bridge()'s NAT/forwarding
setup needs no changes -- and only replaces how a container's
namespace gets connected to it:

- a host-side tap device, created wherever the network's bridge lives
  and enslaved to it, playing veth's host-side role
- a container-side tap device, created directly inside the container's
  namespace and named eth<N> from the start (no rename step needed)
- a relay process holding both fds open, copying raw Ethernet frames
  bidirectionally between them -- reproducing a veth pair's kernel
  wire via one userspace hop

Not wired into join_one_network() yet -- this commit only adds
create_tap_relay()/stop_tap_relay() and exercises them standalone via
a new self-test (throwaway bridge + throwaway namespace).

A real synchronization bug turned up while writing that self-test:
fork() returning to the parent doesn't mean the child has reached its
own unshare(CLONE_NEWNET) yet, so using its pid immediately raced and
created the container-side tap in the wrong (host) namespace. Fixed
by polling namespace_isolated() first, the same guard
network_join.cpp's wait_for_isolated_net_namespace() already uses for
a real session.

Verified twice as root via the doas rule: host-side tap gets created
and attached to the bridge, container-side tap gets created with the
right name inside the target namespace, and -- the biggest open
assumption from the design doc addendum -- both devices disappear on
their own once stop_tap_relay() stops the process, no explicit
`ip link del` needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
2026-08-30 16:06:32 +00:00
parent 08895297a9
commit 96efcf37e1
6 changed files with 578 additions and 19 deletions
+93 -2
View File
@@ -284,8 +284,34 @@ Source layout (all under `src/`):
(checked from this process, *after* the forked child that actually did the
`unshare()`/bind-mount has already exited — the actual claim being tested:
the namespace outlives its creating process), then gone again after
removal. Deliberately its own small file since more real tests are expected
here as more of the networking feature lands.
removal. Also exercises `network_tap_relay.{h,cpp}`'s (see below)
create/attach/teardown cycle, same root-only skip: a throwaway bridge
(`ip link add ... type bridge`, no `network_bridge.h` provisioning needed —
a tap device only cares that *some* bridge exists to attach to) stands in
for a real network's bridge, and a throwaway network namespace (a forked
child that `unshare(CLONE_NEWNET)`s then blocks in `pause()` until
signaled) stands in for a real `-r/--run` session's isolated one. **Real
race caught by testing, not assumed**: `fork()` returning to the parent
doesn't mean the child has actually reached its own `unshare(CLONE_NEWNET)`
call yet — the same race `network_join.cpp`'s own
`wait_for_isolated_net_namespace()` already guards against for a real
session; an earlier version of this test used the child's pid immediately
and the container-side tap device silently ended up created in the *host's*
namespace instead (confirmed: it wasn't visible via `nsenter` into the
child's namespace at all). Fixed the same way — polling (bounded, 1s,
20ms interval) `namespace_isolated()` (`sandbox_process.h`) until the
child's own net namespace actually differs from this process's before
trusting its pid. Confirms the host-side tap gets created and attached to
the bridge (`ip link show` output contains `master <bridge>`), the
container-side tap gets created with the requested name inside the target
namespace (checked via `nsenter --net=/proc/<pid>/ns/net -- ip link show`),
and — the biggest previously-unverified assumption from
`docs/networking-design.md`'s tap+relay addendum — **both devices actually
disappear on their own once `stop_tap_relay()` stops the relay process,
with no explicit `ip link del` needed** (neither was created with
`IFF_PERSIST`) — confirmed directly, twice, on this dev machine (root, via
the scoped `doas` rule). Deliberately its own small file since more real
tests are expected here as more of the networking feature lands.
- `env_spec.{h,cpp}``resolve_env_specs()` turns an ordered list of
`EnvSpec {is_file, value}` (see `cli_args.{h,cpp}` above) into a flat, ordered list of
`(key, value)` pairs. A literal (`--env`) is split at its *first* `=` (the
@@ -824,6 +850,71 @@ Source layout (all under `src/`):
its rule-removal attempted — visibly failing only for lack of root in this
particular rootless test — and its record file actually removed,
reported as `removed stale port-forward rules for 'faketest-999999'`).
- `network_tap_relay.{h,cpp}` — a tap-backed substitute for one veth pair,
used when `should_use_veth()` (`network_bridge.h`) is false: either the
running kernel doesn't support veth at all (the real target device's
kernel supports `tun`/`tap` but lacks `CONFIG_VETH`, commonly stripped
from mobile kernels — see `docs/networking-design.md`'s tap+relay
addendum), or the network was created with `--no-veth` (`cli_args.{h,cpp}`)
specifically to exercise this path on a veth-capable machine. **Why tap
can't just replace veth 1:1**: a veth pair is two real kernel netdevices,
switched between (or into a bridge) entirely by the kernel; a tap device
only has *one* kernel-side netdevice — the other "end" is a raw-Ethernet-
frame file descriptor only a userspace process can read/write, so there's
no second kernel endpoint to attach to a bridge (the same reason
`slirp4netns`/QEMU's own tap networking need a userspace process on the fd
side). `create_tap_relay(network, bridge, host_tap_name, container_ns_pid,
container_if_name)` reproduces a veth pair's role with two tap devices and
one relay process that copies bytes between them, **reusing the existing
bridge as the switching fabric** so `network_bridge.cpp`'s
`provision_bridge()`/NAT setup needs no changes at all: a host-side tap
device (created wherever `network`'s bridge lives — `network_bridge.h`'s
`wrap_for_network()` namespace, reached here via a direct `setns()` rather
than that function's `nsenter`-argv-wrapping, since this whole sequence
must keep running, and later hold onto live fds, across each namespace
switch, not just for the duration of one external command) gets enslaved
to `bridge` exactly like veth's host-side end does in
`network_join.cpp`'s `join_one_network()`; a container-side tap device
gets created directly inside the namespace named by `container_ns_pid`,
**named `container_if_name` (e.g. `eth0`) from the start** — no
peer-name-then-rename dance needed, unlike veth. `host_tap_name` is
caller-provided (not derived here) specifically so a future caller
(`join_one_network()`, once this is wired in) can reuse its own existing
fnv1a-based veth-naming scheme rather than this file growing a second,
drifting copy of that six-line hash. `open_tap()` (`.cpp`-local) creates
each device via a direct `open("/dev/net/tun")` + `ioctl(TUNSETIFF,
IFF_TAP | IFF_NO_PI)``IFF_NO_PI` so both ends agree on raw-frame
framing with no extra header, deliberately **not** `IFF_PERSIST`, so each
device is expected to disappear on its own once its one-and-only fd
closes, the same "no explicit teardown" property veth already has (see
`self_test.{h,cpp}` above for where this got verified). The relay child's
entire setup sequence — enter the network's own namespace first if
`intern` (`persistent_netns_path()`, `persistent_netns.h`), create+attach
the host-side tap, `setns()` into the container's namespace (the
already-open host-side fd stays valid across this switch — fds aren't
namespace-scoped, only their *creation* is, the same property
`wrap_for_root_namespace()`, `bwrap.cpp`, already relies on), create the
container-side tap — reports success/failure back to `create_tap_relay()`
over a `pipe2(O_CLOEXEC)` (same handshake shape `daemonize()`,
`daemonize.cpp`, already uses), then falls into an unbounded
`poll()`/`read()`/`write()` loop copying raw frames bidirectionally
between the two fds — this loop *is* the actual "veth wire," just
implemented once in userspace instead of by the kernel. No `SIGTERM`
handler is installed in the relay: default disposition (terminate) already
closes both fds on the way out, which is all `stop_tap_relay()`'s
"no explicit teardown" contract needs. `stop_tap_relay()` sends `SIGTERM`
and reaps the process. Once `create_tap_relay()` returns successfully,
`container_if_name` is a completely ordinary interface from the
container's own point of view — `join_one_network()`'s existing IP
assignment/route/DNAT-target-address code (unchanged, not yet wired to
call this) needs no changes at all. **Not yet wired into
`join_one_network()`/`run_container()`** — see `self_test.{h,cpp}` above
for how this file's own create/attach/teardown cycle was verified
end-to-end in isolation first; full traffic-plane verification (does
`-p`/inter-container connectivity actually work end-to-end through this
path) is deferred to the commit that does that wiring, where two real
`--no-veth`-joined containers pinging/curling each other is a more natural
and simpler proof than hand-rolled raw-socket test traffic.
- `session_cgroup.{h,cpp}` — gives `--kill` (`kill_session.{h,cpp}`, see
below) a reliable way to find every process a session ever started, however
deeply forked/daemonized/reparented, by putting it in a dedicated cgroup v2