Join -r/--run containers to networks: veth creation, IPs, routes

Commit 4/6 of the network isolation feature (docs/networking-design.md).
network_join.{h,cpp}: join_networks() waits (bounded, polling) for the
session's own isolated net namespace to exist -- bwrap's outer pid
never enters it, and the on_bwrap_pid_known callback fires before
bwrap has even started its own setup -- then per network: ensures it's
provisioned, creates a veth pair where the bridge lives, attaches the
bridge side, moves the container side into the session's namespace as
eth<N>, assigns it a free address, brings it up, and (extern only)
replaces the default route.

sandbox_process.{h,cpp}: generalized pid_namespace_isolated() into
namespace_isolated(outer_pid, ns_pid, ns_type) so this can reuse it for
"net" instead of "pid".

network_subnet.{h,cpp}: gateway-address logic generalized into
host_address(af, cidr, n) shared by the existing gateway functions
(n=1) and new ipv4/ipv6_host_address() (n=2, 3, ... for containers).

network_bridge.{h,cpp}: bridge_name()/wrap_for_network() exported so
network_join.cpp can attach to the exact bridge/namespace
network_bridge.cpp provisioned.

commands.cpp: run_container() validates network namespace isolation is
actually available before ever starting bwrap (can't be degraded the
way --hostname is), then joins networks from on_bwrap_pid_known,
before the -D/--daemonize report is sent.

Real bug caught by testing, fixed before landing: address allocation
first tried to detect in-use IPs via `ip addr show master <bridge>`,
but a container's address lives on its own interface inside its own
private namespace, invisible from the bridge's namespace -- two
concurrent containers on the same network both got 10.168.0.2. Fixed
with a flock-based per-address lease file (same technique pid_file.h's
SessionLock already uses), verified with two containers running
simultaneously getting distinct addresses.

Known, documented limitation: a very short-lived sandboxed command can
exit before the namespace-wait polling catches up (bwrap execs
straight into the target with no hook point in between namespace
creation and exec); real long-running networked services are
unaffected.

Verified end-to-end as root (via a scoped doas rule): two containers
on the same intern network got distinct addresses and could ping each
other; an intern-joined container could not reach the outside; an
extern-joined container reached the real internet through NAT; a
container joining both simultaneously got two working interfaces.
This commit is contained in:
2026-08-30 13:11:28 +00:00
parent 24b8ddcce7
commit 8cc967e748
12 changed files with 567 additions and 63 deletions
+124 -23
View File
@@ -136,8 +136,9 @@ Source layout (all under `src/`):
are the direct network equivalents (`Mode::network`/`Mode::list_networks`/
`Mode::delete_network`) — see `docs/networking-design.md` for the full feature
design and `config_file.{h,cpp}` below for `NetworkEntry`. Joining a network
from `-r/--run` isn't wired up yet (a later commit in the design doc's
sequence). `create_network_command()` rejects a duplicate name first, then
from `-r/--run` (repeatable `-n <name>`, `ParsedArgs::network_specs`, see
`cli_args.{h,cpp}` above) is handled by `run_container()`, further below,
via `network_join.{h,cpp}` (see below). `create_network_command()` rejects a duplicate name first, then
resolves `subnet`/`subnet6`: an explicit `--subnet`/`--subnet6` is validated
(`is_valid_ipv4_cidr()`/`is_valid_ipv6_cidr()`) and checked for overlap
against every existing network's subnet (`ipv4_cidrs_overlap()`/
@@ -201,7 +202,21 @@ Source layout (all under `src/`):
`run_container()`'s existing body completely unchanged, including the
unmount/cleanup that already runs after `run_bwrap()` returns (no separate
watcher/reaper — the daemonized child *is* what runs the whole session,
start to finish).
start to finish). `network_specs` (repeatable `-n`, `cli_args.h`) is
validated up front, before `run_bwrap()` is ever called: joining a network
needs a real, isolated network namespace to attach a veth into (unlike
`--hostname`, this can't just be skipped/degraded when unavailable), so if
any networks were requested, `run_container()` checks both that
`namespace_config.net` is actually enabled (`global.unshare-net`,
`config_file.h`) and that `detect_bwrap_unshare_args()` (`bwrap.h`) reports
the running kernel actually supports `--unshare-net` — either failing sets
`ok = false` with a clear error, the same pattern as a failed `--user`
resolution. If validation passed, `on_bwrap_pid_known` (already used for
`-D/--daemonize`'s `report_daemon_started()`) additionally calls
`join_networks(pid, network_specs, app_config)` (`network_join.h`, see
below) — networks are joined *before* the daemonize report is sent, so a
`-D`-daemonized caller doesn't get control back until network setup has
already had its chance to run.
- `self_test.{h,cpp}``run_self_tests()` implements `-t/--test`, this
project's own built-in self-test mode (distinct from the Meson-driven
fixture smoke test under `tests/`, described in "Build & test commands"
@@ -425,9 +440,10 @@ Source layout (all under `src/`):
directory without drifting from this file's own. `xdg_state_dir()`
(`$XDG_STATE_HOME/slocker-lite`, or the `$HOME/.local/state/...` fallback)
is likewise exported (moved out of this file's own anonymous namespace) so
`persistent_netns.{h,cpp}` (see below) can resolve its own subdirectory
under the same state root without a second, drifting copy of this
resolution logic. `session_pid_file_path()` resolves
`persistent_netns.{h,cpp}` (see below) and `network_join.cpp`'s own
per-address lease files (`xdg_state_dir() / "net-leases"`) can resolve
their own subdirectories under the same state root without a second,
drifting copy of this resolution logic. `session_pid_file_path()` resolves
`$XDG_STATE_HOME/slocker-lite/run/<container_name>-<pid>` (falling back to
`$HOME/.local/state/...` when `XDG_STATE_HOME` is unset/empty — same
resolution pattern as `config_file_path()` below, for state instead of
@@ -502,24 +518,28 @@ Source layout (all under `src/`):
network's live state (bridge, veths, iptables rules; the persistent
namespace itself for `intern`) survives a reboot except its `config.yaml`
entry, so calling this again after one just recreates whatever's missing.
`bridge_name()` derives a stable, `.cpp`-local interface name from the
network's own name via a hand-rolled 32-bit FNV-1a (`"slk" + 8 hex chars`,
11 characters, comfortably under Linux's `IFNAMSIZ - 1` = 15-character
limit regardless of how long the network name is) — deliberately not
`std::hash<std::string>`, whose exact value is implementation-defined and
not guaranteed stable across a rebuild with a different standard library,
which would silently orphan an already-provisioned bridge a rebuilt binary
`bridge_name()` derives a stable interface name from the network's own name
via a hand-rolled 32-bit FNV-1a (`"slk" + 8 hex chars`, 11 characters,
comfortably under Linux's `IFNAMSIZ - 1` = 15-character limit regardless of
how long the network name is) — deliberately not `std::hash<std::string>`,
whose exact value is implementation-defined and not guaranteed stable
across a rebuild with a different standard library, which would silently
orphan an already-provisioned bridge a rebuilt binary
can no longer find by the name it now computes. For `extern`, every command
runs directly (the bridge lives in the host's own root namespace, and this
whole feature is root-only for now — `docs/networking-design.md` — so
`slocker-lite`'s own current namespace already is the right one). For
`intern`, every command is wrapped through `nsenter --net=<persistent path>`
(`wrap_for_network()`, `.cpp`-local) into the network's own dedicated
(`wrap_for_network()`) into the network's own dedicated
namespace (`persistent_netns.h`, created here first if it doesn't exist
yet) — the same pattern `wrap_for_root_namespace()` (`bwrap.cpp`) already
uses for the rootless `containers-storage` mount's namespace, just
targeting a persistent bind-mounted path instead of a live pid's `/proc`
entry. `provision_bridge()` (`.cpp`-local): creates the bridge, assigns it
entry. `bridge_name()`/`wrap_for_network()` are both exported (not just
this file's own internal helpers) specifically so `network_join.{h,cpp}`
(see below) can attach a container's veth to the exact same bridge, in the
exact same place, this file provisioned it in. `provision_bridge()`
(`.cpp`-local): creates the bridge, assigns it
the gateway address from `network_subnet.h`'s `ipv4_gateway_address()`
(and `ipv6_gateway_address()` if `network.ipv6`), brings it up, then —
`extern` only — `sysctl -w net.ipv4.ip_forward=1` (+ the IPv6 equivalent if
@@ -542,6 +562,74 @@ Source layout (all under `src/`):
all came up correctly; a real `intern` network's bridge came up inside its
own dedicated namespace with neither forwarding nor a NAT rule, confirming
the structural (not merely policy) isolation the design calls for.
- `network_join.{h,cpp}` — joins a just-started `-r/--run` session to each
network named in `-n`. `join_networks()` first waits (bounded, 3s,
20ms-interval `nanosleep()` polling — `wait_for_isolated_net_namespace()`,
`.cpp`-local) for `resolve_namespace_pid()` (`sandbox_process.h`) to name a
child whose net namespace is actually isolated
(`namespace_isolated(outer_pid, ns_pid, "net")`) — necessary because
`run_bwrap()`'s `on_bwrap_pid_known` fires right after `fork()`, before
`bwrap` has done any of its own namespace setup, so that child may not even
exist yet the instant this is called; while it doesn't,
`resolve_namespace_pid()` falls back to returning `outer_pid` itself, so
comparing a namespace to itself naturally keeps the loop going without a
separate "does a child exist yet" check. **Known limitation, not solved
here**: for a very short-lived sandboxed command, the whole session can
exit before this poll ever catches up (confirmed by testing: `-n <net> --
echo hi` reliably timed out) — `bwrap` execs straight into the target
command with no hook point in between namespace creation and exec, so
there's no way for this project to guarantee network setup completes
before a near-instant command already has too. Real (long-running)
networked services are unaffected — confirmed by testing (see below).
For each named network: looked up in `config.networks` (an unknown name is
a per-network error, not fatal to the others); `ensure_network_provisioned()`
(`network_bridge.h`) covers post-reboot recreation; a veth pair is created
wherever that network's bridge lives (`wrap_for_network()`, reused from
`network_bridge.h`), the bridge-side end attached and brought up, the
container-side end moved into the session's own namespace (`ip link set
... netns <ns_pid>`) and renamed `eth<N>` (`N` = the network's position in
the `-n` list, so multiple joins each get a distinct interface). **Address
allocation, `pick_free_address()`, needed a real fix during testing, not
just design**: an interface's actual assigned IP lives inside its own
private per-container namespace, invisible from the bridge's own namespace
— an earlier version queried `ip -o addr show master <bridge>` (only the
*host* side of each veth, with no address of its own, is visible there) and
always saw nothing, so two concurrently-running containers on the same
network were both handed the identical address (confirmed by testing:
`10.168.0.2` twice). Fixed by giving each candidate address its own tiny
lock file under `xdg_state_dir() / "net-leases"` (`pid_file.h`) and holding
an exclusive, non-blocking `flock()` on it via an intentionally
never-`close()`d fd — the same technique `pid_file.h`'s own `SessionLock`
uses for session liveness, released automatically by the kernel the
instant this process exits for any reason, no explicit release step or
cleanup sweep needed. Picking a free address is then just "the first
candidate (`network_subnet.h`'s `ipv4_host_address()`/`ipv6_host_address()`,
`n = 2, 3, ...`) whose lock file isn't already held." For an `extern` join,
`ip route replace default via <gateway> dev eth<N>` (`replace`, not `add`,
so a container joining a *second* extern network doesn't fail outright with
"File exists" — whichever extern network is joined last ends up as the
effective default route; `intern` gets no default route at all, matching
the design's "no route out exists" intent — the connected route for the
local subnet is already automatic once an address is assigned, no explicit
route command needed for same-bridge reachability regardless of kind).
Every step failure is logged specifically (which command, which network)
and best-effort: `join_networks()` returns `true` only if every requested
network joined, but a failure never kills the already-running session
(network setup can only happen after `bwrap`'s own namespace exists, i.e.
potentially after the sandboxed command is already running). Veth teardown
needs no explicit code: the kernel destroys an entire veth pair (both
ends, including the one still attached to the bridge) the instant *either*
end's owning namespace is destroyed, so a session's veths disappear on
their own once its namespace does — only the bridge/iptables/persistent-
namespace state is deliberately left behind (`network_bridge.h`'s
reboot-reconciliation design). **Verified end-to-end on this dev machine
(root, via a scoped `doas` rule)**: two concurrently-running containers on
the same `intern` network got distinct addresses and could ping each
other; an `intern`-joined container could not reach the outside
(`Network unreachable`); an `extern`-joined container reached the real
internet through the bridge's NAT; a container joining both an `intern`
and an `extern` network simultaneously got two working interfaces
(`eth0`/`eth1`) with neither one breaking the other.
- `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
@@ -600,7 +688,13 @@ Source layout (all under `src/`):
separate pid namespace for its child (`--unshare-pid` was requested *and*
the kernel supported it), the precondition for the kernel's own guarantee
that killing a pid namespace's pid 1 forcibly tears down every remaining
process in it. `collect_descendant_pids(root)` generalizes
process in it. Generalized into `namespace_isolated(outer_pid, ns_pid,
ns_type)` (parametrized over which `/proc/<pid>/ns/<ns_type>` entry to
compare) once `network_join.{h,cpp}` (see below) needed the exact same
check for `"net"` instead of `"pid"`
`pid_namespace_isolated()` is now just `namespace_isolated(outer_pid,
ns_pid, "pid")`, kept as its own function since `kill_session.h` already
depends on that exact name/signature. `collect_descendant_pids(root)` generalizes
`resolve_namespace_pid()`'s own `/proc/<n>/stat` ppid-scanning fallback to
collect a whole transitive tree (root included) instead of just one child,
sharing the actual stat-parsing loop between both via a private
@@ -896,13 +990,20 @@ Source layout (all under `src/`):
numerically-different-from-`n` address (e.g. `n=15` becomes `...:15::/64`,
which is hex `0x15` = 21) — purely cosmetic, allocation correctness doesn't
depend on the two matching numerically. `ipv4_gateway_address()`/
`ipv6_gateway_address()` (`network_bridge.cpp`'s `provision_bridge()`)
return a network's bridge gateway address within a CIDR: masked down to its
network address first (a shared `.cpp`-local `mask_to_network()`, in case
the CIDR given — e.g. a manual `--subnet`/`--subnet6` — wasn't already a
canonical network address), then `| 1` in the last bit for the `.1`
convention this project's bridges use, reusing the same `parse_cidr()` as
the validation/overlap functions above.
`ipv6_gateway_address()` (`network_bridge.cpp`'s `provision_bridge()`) and
`ipv4_host_address()`/`ipv6_host_address()` (`network_join.cpp`'s
`pick_free_address()`, see below — `n = 2, 3, ...` for individual
containers) are all thin wrappers around one shared `.cpp`-local
`host_address(af, cidr, n)`: masks `cidr` down to its network address first
(`mask_to_network()`, in case it — e.g. a manual `--subnet`/`--subnet6`
wasn't already a canonical network address), then adds `n` as a big-endian
integer into the trailing host-portion bytes with proper carry propagation
(generic over address length, so the same code handles both IPv4's 4 bytes
and IPv6's 16 without two parallel implementations), rejecting `n` outright
if it doesn't fit the address's host-bit width. The gateway functions are
just `host_address(af, cidr, 1)` — the `.1` convention this project's
bridges use. Reuses the same `parse_cidr()` as the validation/overlap
functions above.
- `volume_mount.{h,cpp}``is_valid_volume_name()` (no `/`, checked by both
`create_volume_command()` and to tell a `-v` spec's name/path apart) and
`resolve_volume_mount()`, called once per `-v` occurrence from `run_container()`