Add bridge provisioning for networks (root-only)

Commit 3/6 of the network isolation feature (docs/networking-design.md).
network_bridge.{h,cpp}: ensure_network_provisioned() stands up a
network's real bridge -- idempotent (checks `ip link show` first), so
this doubles as the reboot-reconciliation mechanism, no separate code
path. extern's bridge lives in the host's own root namespace with
net.ipv4.ip_forward + an iptables MASQUERADE rule for the subnet (+
IPv6 equivalents if enabled); intern's bridge lives inside its own
dedicated persistent namespace (persistent_netns.h) with no forwarding
or NAT at all -- a structural isolation boundary, not just a missing
rule. Bridge names are a deterministic FNV-1a hash of the network name
(not std::hash, whose value isn't guaranteed stable across a rebuild),
kept under Linux's 15-char interface name limit.

network_subnet.{h,cpp} gains ipv4_gateway_address()/
ipv6_gateway_address() (mask a CIDR to its network address, +1 for the
bridge's own ".1"). create_network_command() now calls
ensure_network_provisioned() before persisting the config entry -- a
network that fails to provision isn't saved.

Verified end-to-end as root (via a scoped doas rule): a real extern
network's bridge/gateway IPs/forwarding/NAT rule, and a real intern
network's isolated bridge with neither, both came up correctly; test
networks removed via --delete-network afterward.
This commit is contained in:
2026-08-30 12:50:55 +00:00
parent db3a9d82c7
commit 24b8ddcce7
8 changed files with 380 additions and 26 deletions
+78 -14
View File
@@ -135,19 +135,28 @@ Source layout (all under `src/`):
`create_network_command()`/`list_networks_command()`/`delete_network_command()`
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`. This commit is
config-only: no bridge, namespace, or iptables state is created yet, only the
config entry (later commits in the design doc's sequence wire up the actual
host-side networking). `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()`/
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
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()`/
`ipv6_cidrs_overlap()`, `network_subnet.{h,cpp}`, see below); otherwise the
next free block is auto-allocated (`allocate_ipv4_subnet()`/
`allocate_ipv6_subnet()`). `list_networks_command()` reuses the same
`allocate_ipv6_subnet()`). Once a `subnet`/`subnet6` is resolved,
`create_network_command()` calls `ensure_network_provisioned()`
(`network_bridge.h`, see below) to actually stand up the network's
host-side state (bridge, sysctls, iptables rules for `extern`; a dedicated
persistent namespace + bridge for `intern`) — only once that succeeds is
the entry appended to `config.networks` and persisted; a network that
fails to provision isn't saved. `list_networks_command()` reuses the same
independently-per-column tab-alignment scheme as `list_processes_command()`
(name/kind/subnet each aligned, then the IPv6 subnet — or `"(no ipv6)"`
appended unaligned as the trailing column, nothing follows it).
`delete_network_command()` currently only removes the config entry, the
same as `delete_volume_command()`'s default (non-`-full`) behavior — it
does not tear down the network's live bridge/namespace/iptables state (no
`--delete-network-full` analog exists yet).
`write_config_command()` implements `-w/--write-config`: unlike
`create_volume_command()`/`delete_volume_command()`'s use of
`write_config_file()` (which only ever persists `AppConfig` fields that are
@@ -464,9 +473,9 @@ Source layout (all under `src/`):
left untouched and not reported.
- `persistent_netns.{h,cpp}` — generic, narrow infrastructure for keeping a
network namespace alive with no process in it, the way `ip netns add` does;
no `intern`/`extern` policy or bridge logic here (that's a later commit,
`network_bridge.{h,cpp}`, per `docs/networking-design.md`'s commit
sequence), and not yet wired into `-n/--network` at all. `persistent_netns_path()`
no `intern`/`extern` policy or bridge logic here (that's `network_bridge.{h,cpp}`,
see below, which is what actually calls `create_persistent_netns()` for an
`intern` network). `persistent_netns_path()`
resolves `xdg_state_dir() / "netns" / sanitize_for_filename(name)`
(`pid_file.h`, see above). `persistent_netns_exists()` checks whether that
path is actually a live bind-mounted namespace, not just a stale/never-
@@ -485,6 +494,54 @@ Source layout (all under `src/`):
host-state primitives (session locks, cgroups): logs and returns `false` on
any failure (already exists, fork/unshare/mount failure) rather than
throwing. `remove_persistent_netns()` unmounts then removes the file.
- `network_bridge.{h,cpp}` — stands up (or confirms already-standing) a
network's actual host-side state: `ensure_network_provisioned()` is
idempotent by design (checks `ip link show <bridge>` first and does nothing
further if it's already there) — this is deliberately also the reboot-
reconciliation mechanism, not a separate code path: nothing about a
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
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
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
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
`ipv6`, both idempotent global host sysctls, not per-bridge, so no separate
"already enabled" tracking is needed) and one `iptables`/`ip6tables`
`POSTROUTING`/`MASQUERADE` rule for the subnet (`! -o <bridge>`, the same
`docker0` shape, so bridge-local inter-container traffic isn't
unnecessarily NAT'd). `check_network_dependencies()` gates on the tool set
each kind actually needs (`ip` always; `iptables`/`sysctl` [+`ip6tables` if
`ipv6`] for `extern`; `nsenter` for `intern`) — same shape/spirit as
`commands.cpp`'s own `check_required_dependencies()`, kept separate since
the tool set here depends on the network's own kind/`ipv6` setting.
`create_network_command()` (`commands.cpp`, see above) calls
`ensure_network_provisioned()` after resolving subnets but *before*
persisting the config entry — a network that fails to provision isn't
saved, so a later join doesn't find a config entry for something that
doesn't actually exist on the host. **Verified end-to-end on this dev
machine (root, via a scoped `doas` rule)**: a real `extern` network's
bridge, gateway IPv4/IPv6 addresses, `ip_forward`, and `MASQUERADE` rules
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.
- `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
@@ -820,8 +877,8 @@ Source layout (all under `src/`):
`networks`, `ipv6` re-serialized as canonical `"true"`/`"false"` like the
`unshare-*` keys.
- `network_subnet.{h,cpp}` — pure CIDR arithmetic backing `-n/--network`'s
subnet allocation, no kernel/`ip`/`iptables` calls (those come in a later
commit per `docs/networking-design.md`'s sequence). `is_valid_ipv4_cidr()`/
subnet allocation and `network_bridge.{h,cpp}`'s (see below) gateway-address
computation; no kernel/`ip`/`iptables` calls of its own. `is_valid_ipv4_cidr()`/
`is_valid_ipv6_cidr()` and `ipv4_cidrs_overlap()`/`ipv6_cidrs_overlap()` all
build on one `.cpp`-local `parse_cidr()` (via `inet_pton()`, not hand-rolled
parsing) producing a plain byte-vector address (4 bytes for IPv4, 16 for
@@ -838,7 +895,14 @@ Source layout (all under `src/`):
since IPv6 hextets are hexadecimal, `n >= 10` renders as a valid but
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.
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.
- `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()`