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:
@@ -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()`
|
||||
|
||||
Reference in New Issue
Block a user