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()`
+8 -7
View File
@@ -92,7 +92,7 @@ slocker-lite -V|--version
| `--list-volumes` | List all named volumes (see `-v/--volume`) with their host directory. |
| `--delete-volume <name>` | Remove a named volume from the config. The host directory is left untouched. |
| `--delete-volume-full <name>` | Like `--delete-volume`, but also recursively deletes the volume's host directory. |
| `-n, --network <name>` | Create/manage a persistent named network: requires exactly one of `--extern` (a real Linux bridge in the host's own namespace, with NAT/forwarding set up so containers on it reach the host's real network) or `--intern` (a bridge inside its own dedicated, routeless namespace, only reachable by other containers on the same network). `--subnet <cidr>` overrides the auto-allocated IPv4 range (`10.168.0.0/24`, incrementing per network); `--no-ipv6` disables (and `--subnet6 <cidr>` overrides) the auto-allocated IPv6 range, on by default. With `--run`, instead joins `<name>` to the container; repeatable, no membership limit (not wired up yet, see below). Root-only for now. See [`docs/networking-design.md`](docs/networking-design.md). |
| `-n, --network <name>` | Create/manage a persistent named network: requires exactly one of `--extern` (a real Linux bridge in the host's own namespace, with NAT/forwarding set up so containers on it reach the host's real network) or `--intern` (a bridge inside its own dedicated, routeless namespace, only reachable by other containers on the same network). `--subnet <cidr>` overrides the auto-allocated IPv4 range (`10.168.0.0/24`, incrementing per network); `--no-ipv6` disables (and `--subnet6 <cidr>` overrides) the auto-allocated IPv6 range, on by default. With `--run`, instead joins `<name>` to the container as its own `eth<N>` interface with an address from the network's subnet; repeatable, no membership limit. Root-only for now. See [`docs/networking-design.md`](docs/networking-design.md). |
| `--list-networks` | List all named networks (see `-n/--network`) with their kind, IPv4 subnet, and IPv6 subnet (or `(no ipv6)`). |
| `--delete-network <name>` | Remove a named network from the config. |
| `--list-processes` | List running `--run` sessions found by their pid files under `$XDG_STATE_HOME/slocker-lite/run/`, with their pid, container name, and status (`running` or `exited`). |
@@ -203,12 +203,13 @@ managed by `-v/--volume` (see above) rather than hand-edited — it's what
`-r/--run`'s own `-v` usage looks named volumes up in. The `networks` section
is likewise managed by `-n/--network` rather than hand-edited — see
[`docs/networking-design.md`](docs/networking-design.md) for the full
persistent-network feature design. `-n/--network` creating a network does
stand up its real bridge/iptables state (root-only) as of this, but joining
one from `-r/--run` isn't wired up yet, so `global.unshare-net` above is
still the only thing actually affecting a sandboxed container's own network
access. A missing config file is fine either way (nothing is overridden, and
one gets created the first time `-v/--volume`/`-n/--network` is used).
persistent-network feature design. `-n/--network` both creates a network
(standing up its real bridge/iptables state, root-only) and, combined with
`-r/--run`, joins a container to one or more of them with a real veth
interface and address on each. `-p` port forwarding isn't implemented yet
(a later commit), so nothing outside the container can reach in until then.
A missing config file is fine either way (nothing is overridden, and one
gets created the first time `-v/--volume`/`-n/--network` is used).
Run `-w/--write-config` to bootstrap a config file: it writes out every
supported option explicitly (filling in the current or default value for
+2 -1
View File
@@ -22,7 +22,8 @@ slocker_lite = executable('slocker-lite',
'src/bwrap.cpp', 'src/user_spec.cpp', 'src/config_file.cpp', 'src/volume_mount.cpp',
'src/pid_file.cpp', 'src/exec_session.cpp', 'src/env_spec.cpp', 'src/daemonize.cpp',
'src/sandbox_process.cpp', 'src/session_cgroup.cpp', 'src/kill_session.cpp',
'src/network_subnet.cpp', 'src/persistent_netns.cpp', 'src/network_bridge.cpp'],
'src/network_subnet.cpp', 'src/persistent_netns.cpp', 'src/network_bridge.cpp',
'src/network_join.cpp'],
include_directories : include_directories('.'),
dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep],
install : true)
+35 -4
View File
@@ -39,6 +39,7 @@
#include "exec_session.h"
#include "kill_session.h"
#include "network_bridge.h"
#include "network_join.h"
#include "network_subnet.h"
#include "oci_image.h"
#include "pid_file.h"
@@ -546,7 +547,8 @@ int run_container(const std::filesystem::path& image_tar,
const std::optional<std::string>& user, const std::optional<std::string>& group,
const std::optional<std::string>& hostname,
const std::vector<std::pair<std::string, std::string>>& volume_specs,
const std::vector<EnvSpec>& env_specs, bool daemonize_flag, const AppConfig& app_config) {
const std::vector<EnvSpec>& env_specs, bool daemonize_flag,
const std::vector<std::string>& network_specs, const AppConfig& app_config) {
// Only depends on image_tar, so this can run before mount_image() -- moved
// up here (rather than right before the run_bwrap() call, as before) so
// daemonize() below can use the real container name for the log file from
@@ -644,9 +646,38 @@ int run_container(const std::filesystem::path& image_tar,
app_config.unshare_uts.value_or(true), app_config.unshare_cgroup.value_or(true),
};
// Joining a network needs a real, isolated network namespace to attach a
// veth into -- can't be skipped/degraded the way e.g. --hostname is when
// its own namespace type isn't available, so this is checked up front
// (before ever starting bwrap) rather than discovered as a confusing
// failure partway through join_networks().
if (!network_specs.empty()) {
if (!namespace_config.net) {
spdlog::error("-n/--network requires network namespace isolation, but global.unshare-net is disabled");
ok = false;
} else {
auto supported = detect_bwrap_unshare_args();
if (std::find(supported.begin(), supported.end(), "--unshare-net") == supported.end()) {
spdlog::error("-n/--network requires network namespace isolation, which this kernel doesn't support");
ok = false;
}
}
}
std::function<void(pid_t)> on_bwrap_pid_known;
if (daemonize_flag) {
on_bwrap_pid_known = [&](pid_t pid) { report_daemon_started(container_name, pid); };
if (daemonize_flag || !network_specs.empty()) {
on_bwrap_pid_known = [&](pid_t pid) {
// Joining networks first: report_daemon_started() below is what
// unblocks the parent (-D/--daemonize) waiting on the pipe, so
// network setup should have already had its chance to run by
// then rather than racing an already-returned parent.
if (!network_specs.empty()) {
join_networks(pid, network_specs, app_config);
}
if (daemonize_flag) {
report_daemon_started(container_name, pid);
}
};
}
int exit_code = -1;
@@ -733,7 +764,7 @@ int dispatch_command(const ParsedArgs& args, const std::filesystem::path& config
}
return run_container(args.mode_arg, args.command, use_nsenter, args.user_flag, args.group_flag,
args.hostname_flag, args.volume_specs, args.env_specs, args.daemonize_flag,
config);
args.network_specs, config);
}
}
return 1; // unreachable
+6 -21
View File
@@ -31,11 +31,8 @@
namespace {
// Deterministic, portable 32-bit FNV-1a -- not std::hash<std::string>(),
// whose exact value is implementation-defined and isn't guaranteed stable
// across a rebuild with a different standard library, which would silently
// "orphan" an already-provisioned bridge (a rebuilt binary computing a
// different name for the same network could no longer find it).
// Deterministic, portable 32-bit FNV-1a -- see bridge_name() below for why
// not std::hash<std::string>().
uint32_t fnv1a(std::string_view s) {
uint32_t hash = 0x811c9dc5u;
for (unsigned char c : s) {
@@ -45,24 +42,10 @@ uint32_t fnv1a(std::string_view s) {
return hash;
}
// "slk" + 8 hex chars = 11 characters, comfortably under Linux's 15-character
// (IFNAMSIZ - 1) interface name limit regardless of how long `network_name`
// is. Not human-recognizable, but doesn't need to be -- the network's own
// identity lives in config.yaml by name; this only needs to be stable (the
// same network always maps to the same bridge, so re-provisioning finds the
// existing one) and collision-free at this project's expected scale (a
// handful of networks, not thousands).
} // namespace
std::string bridge_name(const std::string& network_name) { return fmt::format("slk{:08x}", fnv1a(network_name)); }
// For `extern` networks, commands run 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, no wrapping needed. For `intern`, every
// command is wrapped through nsenter into the network's own dedicated
// persistent namespace (persistent_netns.h) -- the same pattern
// wrap_for_root_namespace() (bwrap.cpp) already uses for reaching the
// rootless containers-storage mount's namespace, just targeting a persistent
// bind-mounted path instead of a live pid's /proc entry.
std::vector<std::string> wrap_for_network(const NetworkEntry& network, std::vector<std::string> argv) {
if (network.kind == NetworkKind::extern_) {
return argv;
@@ -73,6 +56,8 @@ std::vector<std::string> wrap_for_network(const NetworkEntry& network, std::vect
return wrapped;
}
namespace {
bool run_admin_command(const NetworkEntry& network, std::vector<std::string> argv, std::string_view what) {
auto result = run_process(wrap_for_network(network, std::move(argv)));
if (result.exit_code != 0) {
+23
View File
@@ -16,8 +16,31 @@
#pragma once
#include <string>
#include <vector>
#include "config_file.h"
// A stable, deterministic, <=15-character (Linux's IFNAMSIZ - 1 limit)
// interface name for `network_name`'s bridge: "slk" + 8 hex chars of a
// hand-rolled FNV-1a hash (not std::hash<std::string>(), whose value is
// implementation-defined and not guaranteed stable across a rebuild with a
// different standard library, which would silently orphan an
// already-provisioned bridge). Exported (not just this file's own internal
// helper) so network_join.h can attach a container's veth to the exact same
// bridge this file provisioned.
std::string bridge_name(const std::string& network_name);
// Wraps `argv` so it runs wherever `network`'s bridge actually lives: as-is
// for `extern` (the host's own root namespace -- this whole feature is
// root-only for now, so slocker-lite's own current namespace already is the
// right one); through `nsenter --net=<persistent path>` for `intern` (its
// own dedicated namespace, persistent_netns.h). Exported so network_join.h
// can run its own veth-setup commands (creating the pair, attaching the
// bridge-side end) in that same place, not just this file's own
// provisioning commands.
std::vector<std::string> wrap_for_network(const NetworkEntry& network, std::vector<std::string> argv);
// Checks that the external tools provisioning `network` needs are found in
// PATH, logging which are missing: always `ip`; `iptables`/`sysctl` (+
// `ip6tables` if `network.ipv6`) for `extern`; `nsenter` for `intern` (to
+260
View File
@@ -0,0 +1,260 @@
// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#include "network_join.h"
#include <algorithm>
#include <cerrno>
#include <cstdint>
#include <ctime>
#include <filesystem>
#include <optional>
#include <string>
#include <string_view>
#include <system_error>
#include <vector>
#include <fcntl.h>
#include <sys/file.h>
#include <unistd.h>
#include <fmt/core.h>
#include <spdlog/spdlog.h>
#include "network_bridge.h"
#include "network_subnet.h"
#include "pid_file.h"
#include "process.h"
#include "sandbox_process.h"
namespace {
// Same tiny algorithm as network_bridge.cpp's own (`.cpp`-local there too) --
// not worth exporting/sharing a six-line hash function across files.
uint32_t fnv1a(std::string_view s) {
uint32_t hash = 0x811c9dc5u;
for (unsigned char c : s) {
hash ^= c;
hash *= 0x01000193u;
}
return hash;
}
// Polls (nanosleep, EINTR-retry, matching this project's existing direct-POSIX
// style -- kill_session.cpp's poll_until() does the same) until
// resolve_namespace_pid(outer_pid) names a child that has actually entered
// its own net namespace, or timeout_ms elapses. While no such child exists
// yet, resolve_namespace_pid() falls back to returning outer_pid itself, so
// comparing a namespace to itself naturally keeps this looping rather than
// needing a separate "does a child exist yet" check.
std::optional<pid_t> wait_for_isolated_net_namespace(pid_t outer_pid, int timeout_ms) {
constexpr int interval_ms = 20;
for (int elapsed = 0; elapsed <= timeout_ms; elapsed += interval_ms) {
pid_t ns_pid = resolve_namespace_pid(outer_pid);
if (namespace_isolated(outer_pid, ns_pid, "net")) {
return ns_pid;
}
struct timespec ts {
0, static_cast<long>(interval_ms) * 1000000L
};
while (nanosleep(&ts, &ts) != 0 && errno == EINTR) {
}
}
return std::nullopt;
}
std::vector<std::string> wrap_in_container(pid_t ns_pid, std::vector<std::string> argv) {
std::vector<std::string> wrapped = {"nsenter", fmt::format("--net=/proc/{}/ns/net", ns_pid), "--"};
wrapped.insert(wrapped.end(), argv.begin(), argv.end());
return wrapped;
}
bool run(const std::vector<std::string>& argv, std::string_view what, std::string_view network_name) {
auto result = run_process(argv);
if (result.exit_code != 0) {
spdlog::error("failed to {} for network '{}' (exit code {})", what, network_name, result.exit_code);
return false;
}
return true;
}
// Address allocation can't be checked by inspecting live interface state the
// way session/cgroup liveness can: a container's actual assigned IP lives on
// its own `eth<N>` inside its own private namespace, invisible from the
// bridge's namespace (only the *host* side of each veth -- with no address
// of its own -- is visible there; confirmed by testing -- an earlier version
// of this function queried `ip -o addr show master <bridge>` and always saw
// nothing, so every container got the same address). Instead, each candidate
// address gets its own tiny lock file (mirroring pid_file.h's own
// SessionLock: an exclusive, non-blocking flock() held by an intentionally
// never-closed fd, so it's released automatically -- by the kernel -- the
// instant this process exits for any reason, including a crash, without
// needing an explicit release step or any cleanup sweep). Picking a free
// address is then just "find the first candidate whose lock file isn't
// already held."
std::filesystem::path address_lease_path(const NetworkEntry& network, bool ipv6, uint64_t n) {
return xdg_state_dir() / "net-leases" /
fmt::format("{}-{}-{}", sanitize_for_filename(network.name), ipv6 ? "v6" : "v4", n);
}
bool try_lock_address_lease(const std::filesystem::path& path) {
std::error_code ec;
std::filesystem::create_directories(path.parent_path(), ec);
if (ec) {
return false;
}
int fd = open(path.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0644);
if (fd < 0) {
return false;
}
if (flock(fd, LOCK_EX | LOCK_NB) != 0) {
close(fd);
return false;
}
// Deliberately never closed: the flock (and thus the lease) must survive
// exactly as long as this process does, released automatically on any
// exit -- the same reasoning create_session_lock() (pid_file.h) already
// relies on for its own SessionLock fd.
return true;
}
std::optional<std::string> pick_free_address(const NetworkEntry& network, bool ipv6) {
constexpr uint64_t max_n = 65534; // plenty for both a /24 (254 hosts) and a bounded slice of a /64
for (uint64_t n = 2; n <= max_n; ++n) {
auto candidate = ipv6 ? ipv6_host_address(network.subnet6, n) : ipv4_host_address(network.subnet, n);
if (!candidate) {
break; // n no longer fits the host portion -- subnet exhausted
}
if (try_lock_address_lease(address_lease_path(network, ipv6, n))) {
return candidate;
}
}
return std::nullopt;
}
bool join_one_network(pid_t ns_pid, const NetworkEntry& network, int if_index) {
if (!ensure_network_provisioned(network)) {
return false;
}
std::string bridge = bridge_name(network.name);
// Distinct prefixes ("vh"/"vp") so the two ends' names can't collide with
// each other while both still live in the same (bridge's) namespace,
// right after creation and before the peer end is moved away.
std::string suffix = fmt::format("{:08x}", fnv1a(fmt::format("{}-{}", network.name, ns_pid)));
std::string host_veth = "vh" + suffix;
std::string peer_veth = "vp" + suffix;
if (!run(wrap_for_network(network, {"ip", "link", "add", host_veth, "type", "veth", "peer", "name", peer_veth}),
"create veth pair", network.name)) {
return false;
}
if (!run(wrap_for_network(network, {"ip", "link", "set", host_veth, "master", bridge}), "attach veth to bridge",
network.name) ||
!run(wrap_for_network(network, {"ip", "link", "set", host_veth, "up"}), "bring host veth up",
network.name)) {
return false;
}
if (!run(wrap_for_network(network, {"ip", "link", "set", peer_veth, "netns", fmt::to_string(ns_pid)}),
"move veth into the container's namespace", network.name)) {
return false;
}
std::string container_if = fmt::format("eth{}", if_index);
if (!run(wrap_in_container(ns_pid, {"ip", "link", "set", peer_veth, "name", container_if}),
"rename the container's interface", network.name)) {
return false;
}
auto container_ip = pick_free_address(network, false);
if (!container_ip) {
spdlog::error("no free IPv4 address available on network '{}'", network.name);
return false;
}
if (!run(wrap_in_container(ns_pid, {"ip", "addr", "add", *container_ip, "dev", container_if}),
"assign the container's IPv4 address", network.name)) {
return false;
}
if (network.ipv6) {
auto container_ip6 = pick_free_address(network, true);
if (!container_ip6) {
spdlog::error("no free IPv6 address available on network '{}'", network.name);
return false;
}
if (!run(wrap_in_container(ns_pid, {"ip", "-6", "addr", "add", *container_ip6, "dev", container_if}),
"assign the container's IPv6 address", network.name)) {
return false;
}
}
if (!run(wrap_in_container(ns_pid, {"ip", "link", "set", container_if, "up"}), "bring the container's interface up",
network.name)) {
return false;
}
if (network.kind == NetworkKind::extern_) {
auto gateway = ipv4_gateway_address(network.subnet);
if (gateway) {
std::string gateway_ip = gateway->substr(0, gateway->find('/'));
// "replace", not "add": a container joining more than one extern
// network would otherwise fail on the second one ("File exists")
// -- whichever extern network is joined last ends up as the
// effective default route.
run(wrap_in_container(ns_pid, {"ip", "route", "replace", "default", "via", gateway_ip, "dev", container_if}),
"set the default IPv4 route", network.name);
}
if (network.ipv6) {
auto gateway6 = ipv6_gateway_address(network.subnet6);
if (gateway6) {
std::string gateway6_ip = gateway6->substr(0, gateway6->find('/'));
run(wrap_in_container(
ns_pid, {"ip", "-6", "route", "replace", "default", "via", gateway6_ip, "dev", container_if}),
"set the default IPv6 route", network.name);
}
}
}
return true;
}
} // namespace
bool join_networks(pid_t bwrap_outer_pid, const std::vector<std::string>& network_names, const AppConfig& config) {
auto ns_pid = wait_for_isolated_net_namespace(bwrap_outer_pid, 3000);
if (!ns_pid) {
spdlog::error("timed out waiting for the session's own network namespace; not joining any network");
return false;
}
bool all_ok = true;
int if_index = 0;
for (const auto& name : network_names) {
auto it = std::find_if(config.networks.begin(), config.networks.end(),
[&](const NetworkEntry& network) { return network.name == name; });
if (it == config.networks.end()) {
spdlog::error("no network named '{}' exists; not joining it", name);
all_ok = false;
continue;
}
if (!join_one_network(*ns_pid, *it, if_index)) {
spdlog::error("failed to join network '{}'", name);
all_ok = false;
}
++if_index;
}
return all_ok;
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#pragma once
#include <string>
#include <vector>
#include <sys/types.h>
#include "config_file.h"
// Joins a just-started -r/--run session (identified by bwrap_outer_pid, the
// same pid pid_file.h/session_cgroup.h already track) to each named network
// in `network_names`. Waits (bounded) for the session's own isolated network
// namespace to actually exist first -- bwrap's outer/tracked pid never
// itself enters the namespaces it creates for its clone()'d child (see
// sandbox_process.h's resolve_namespace_pid()), and that child may not even
// exist yet the instant this is called (run_bwrap()'s on_bwrap_pid_known
// fires right after fork(), before bwrap has done any of its own setup).
//
// For each network: ensures it's provisioned (network_bridge.h -- also
// covers post-reboot recreation), creates a veth pair where that network's
// bridge lives, attaches the bridge-side end, moves the container-side end
// into the session's own namespace, assigns it a free IP from the network's
// subnet, brings it up, and -- for an `extern` join -- makes it the
// namespace's default route (ip route replace, so a second extern join
// doesn't fail outright; whichever extern network is joined last ends up as
// the effective default).
//
// Best-effort per network: a failure joining one network is logged loudly
// but doesn't abort joining the rest, and never kills the already-running
// session -- network setup can only happen after bwrap's own namespace
// exists, i.e. after the sandboxed command may already be running. Returns
// true only if every requested network joined successfully.
bool join_networks(pid_t bwrap_outer_pid, const std::vector<std::string>& network_names, const AppConfig& config);
+30 -4
View File
@@ -98,13 +98,31 @@ void mask_to_network(std::vector<uint8_t>& addr, int prefix) {
}
}
std::optional<std::string> gateway_address(int af, const std::string& cidr) {
// General form backing both the gateway (n=1) and per-container (n>1)
// address functions: masks `cidr` to its network address, then adds `n` as a
// big-endian integer into the trailing (host-portion) bytes, carrying
// leftward same as ordinary multi-byte addition -- generic over address
// length (4 bytes for IPv4, 16 for IPv6), not two parallel implementations.
std::optional<std::string> host_address(int af, const std::string& cidr, uint64_t n) {
auto parsed = parse_cidr(af, cidr);
if (!parsed) {
return std::nullopt;
}
int addr_bits = static_cast<int>(parsed->addr.size()) * 8;
int host_bits = addr_bits - parsed->prefix;
if (host_bits < 64 && n >= (uint64_t{1} << host_bits)) {
return std::nullopt; // n doesn't fit in the host portion
}
mask_to_network(parsed->addr, parsed->prefix);
parsed->addr.back() = static_cast<uint8_t>(parsed->addr.back() | 1);
uint64_t carry = n;
for (auto it = parsed->addr.rbegin(); it != parsed->addr.rend() && carry != 0; ++it) {
uint64_t sum = static_cast<uint64_t>(*it) + (carry & 0xFF);
*it = static_cast<uint8_t>(sum & 0xFF);
carry = (carry >> 8) + (sum >> 8);
}
char buf[INET6_ADDRSTRLEN] = {};
if (!inet_ntop(af, parsed->addr.data(), buf, sizeof(buf))) {
@@ -177,6 +195,14 @@ std::optional<std::string> allocate_ipv6_subnet(const std::vector<NetworkEntry>&
return std::nullopt;
}
std::optional<std::string> ipv4_gateway_address(const std::string& cidr) { return gateway_address(AF_INET, cidr); }
std::optional<std::string> ipv4_gateway_address(const std::string& cidr) { return host_address(AF_INET, cidr, 1); }
std::optional<std::string> ipv6_gateway_address(const std::string& cidr) { return gateway_address(AF_INET6, cidr); }
std::optional<std::string> ipv6_gateway_address(const std::string& cidr) { return host_address(AF_INET6, cidr, 1); }
std::optional<std::string> ipv4_host_address(const std::string& cidr, uint64_t n) {
return host_address(AF_INET, cidr, n);
}
std::optional<std::string> ipv6_host_address(const std::string& cidr, uint64_t n) {
return host_address(AF_INET6, cidr, n);
}
+12
View File
@@ -16,6 +16,7 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
@@ -59,3 +60,14 @@ std::optional<std::string> ipv4_gateway_address(const std::string& cidr);
// Same idea for IPv6.
std::optional<std::string> ipv6_gateway_address(const std::string& cidr);
// The nth host address within `cidr` (n=1 is the same address
// ipv4_gateway_address() returns -- the ".1" gateway; n=2, 3, ... are for
// individual containers), masked to the network address first then offset by
// n, formatted with the same prefix length. nullopt if `cidr` doesn't parse,
// or if n doesn't fit in the address's host portion (e.g. n=300 against a
// /24, which only has 8 host bits).
std::optional<std::string> ipv4_host_address(const std::string& cidr, uint64_t n);
// Same idea for IPv6.
std::optional<std::string> ipv6_host_address(const std::string& cidr, uint64_t n);
+6 -3
View File
@@ -24,6 +24,7 @@
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
#include <system_error>
#include <fmt/core.h>
@@ -121,12 +122,14 @@ pid_t resolve_namespace_pid(pid_t pid) {
return pid;
}
bool pid_namespace_isolated(pid_t outer_pid, pid_t ns_pid) {
auto outer_ns = read_ns_link(fmt::format("/proc/{}/ns/pid", outer_pid));
auto inner_ns = read_ns_link(fmt::format("/proc/{}/ns/pid", ns_pid));
bool namespace_isolated(pid_t outer_pid, pid_t ns_pid, std::string_view ns_type) {
auto outer_ns = read_ns_link(fmt::format("/proc/{}/ns/{}", outer_pid, ns_type));
auto inner_ns = read_ns_link(fmt::format("/proc/{}/ns/{}", ns_pid, ns_type));
return outer_ns && inner_ns && *outer_ns != *inner_ns;
}
bool pid_namespace_isolated(pid_t outer_pid, pid_t ns_pid) { return namespace_isolated(outer_pid, ns_pid, "pid"); }
std::vector<pid_t> collect_descendant_pids(pid_t root) {
auto children_of = build_ppid_map();
+12
View File
@@ -18,6 +18,7 @@
#include <sys/types.h>
#include <string_view>
#include <vector>
// Finds the real sandboxed child of bwrap's own outer/tracked pid `pid` --
@@ -46,6 +47,17 @@ pid_t resolve_namespace_pid(pid_t pid);
// since a daemonized process there reparents to the *host's* pid 1 instead).
bool pid_namespace_isolated(pid_t outer_pid, pid_t ns_pid);
// General form of pid_namespace_isolated() above, parametrized over which
// /proc/<pid>/ns/<ns_type> entry to compare (e.g. "net", "uts", "ipc") --
// pid_namespace_isolated() is just namespace_isolated(outer_pid, ns_pid, "pid")
// (kept as its own function since kill_session.h already depends on that
// exact name/signature). Used by network_join.h to confirm the session's
// sandboxed child has actually entered its own network namespace (bwrap's
// clone() happens asynchronously after this project forks/execs it, so the
// child may not exist -- or may exist but not have finished unshare()'ing --
// yet) before attaching a veth to it.
bool namespace_isolated(pid_t outer_pid, pid_t ns_pid, std::string_view ns_type);
// Every transitive descendant of `root` (root included), found via a single
// /proc/<n>/stat pass matching ppid chains back to `root` -- the same
// mechanism resolve_namespace_pid()'s own fallback uses, generalized to