Add persistent network namespace primitives

Commit 2/6 of the network isolation feature (docs/networking-design.md).
persistent_netns.{h,cpp}: create/verify/remove a network namespace kept
alive with no process in it, the way `ip netns add` does (fork a child,
unshare(CLONE_NEWNET), bind-mount its /proc/self/ns/net onto a
persistent path, exit -- the bind mount keeps it alive). Root-only
(CAP_SYS_ADMIN for the bind mount), best-effort like this project's
other host-state primitives. Not wired into -n/--network yet.

xdg_state_dir() (pid_file.cpp) moved out of its anonymous namespace so
this file can reuse the same $XDG_STATE_HOME resolution rather than a
second, drifting copy.

-t/--test now exercises the create/verify/remove cycle (skipped with a
message, not a failure, when not root) -- confirmed working via doas.
This commit is contained in:
2026-08-30 12:43:12 +00:00
parent ec09b96b56
commit db3a9d82c7
8 changed files with 276 additions and 10 deletions
+40 -5
View File
@@ -196,10 +196,17 @@ Source layout (all under `src/`):
- `self_test.{h,cpp}``run_self_tests()` implements `-t/--test`, this - `self_test.{h,cpp}``run_self_tests()` implements `-t/--test`, this
project's own built-in self-test mode (distinct from the Meson-driven project's own built-in self-test mode (distinct from the Meson-driven
fixture smoke test under `tests/`, described in "Build & test commands" fixture smoke test under `tests/`, described in "Build & test commands"
below). Currently an empty placeholder (previously reported below; previously reported `detect_bwrap_unshare_args()`'s output —
`detect_bwrap_unshare_args()`'s output — `bwrap.{h,cpp}` — unplugged since `bwrap.{h,cpp}` — unplugged since that's kernel-capability diagnostics, not
that's kernel-capability diagnostics, not a test); deliberately its own a test). Currently exercises `persistent_netns.{h,cpp}`'s (see below)
small file since real tests are expected here soon. create/verify/remove cycle: skipped with a message (not a failure) when not
root, since `create_persistent_netns()` requires it for the bind mount.
Confirms the namespace is missing before creation, exists right after
(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.
- `env_spec.{h,cpp}``resolve_env_specs()` turns an ordered list of - `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 `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 `(key, value)` pairs. A literal (`--env`) is split at its *first* `=` (the
@@ -406,7 +413,12 @@ Source layout (all under `src/`):
back to `"container"` if that leaves nothing) is exported here (not just back to `"container"` if that leaves nothing) is exported here (not just
`.cpp`-local) specifically so `session_cgroup.{h,cpp}` (see below) can reuse `.cpp`-local) specifically so `session_cgroup.{h,cpp}` (see below) can reuse
the exact same `<name>-<pid>` naming rule for its own per-session cgroup the exact same `<name>-<pid>` naming rule for its own per-session cgroup
directory without drifting from this file's own. `session_pid_file_path()` resolves 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
`$XDG_STATE_HOME/slocker-lite/run/<container_name>-<pid>` (falling back to `$XDG_STATE_HOME/slocker-lite/run/<container_name>-<pid>` (falling back to
`$HOME/.local/state/...` when `XDG_STATE_HOME` is unset/empty — same `$HOME/.local/state/...` when `XDG_STATE_HOME` is unset/empty — same
resolution pattern as `config_file_path()` below, for state instead of resolution pattern as `config_file_path()` below, for state instead of
@@ -450,6 +462,29 @@ Source layout (all under `src/`):
a check and a later removal. Only files it actually removes are reported back a check and a later removal. Only files it actually removes are reported back
(as `SessionInfo`s with `running=false`); still-locked (running) files are (as `SessionInfo`s with `running=false`); still-locked (running) files are
left untouched and not reported. 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()`
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-
mounted file: `stat()`s the path and its parent directory and compares
`st_dev` — a genuine bind mount always has a different device number than
its parent, the same "is this a mountpoint" technique used elsewhere. Never
needs root itself (just `stat()`). `create_persistent_netns()` forks a
child (never touches the caller's own network namespace — `unshare(2)`
affects only the calling process) that `unshare(CLONE_NEWNET)`s its own
fresh namespace, bind-mounts its `/proc/self/ns/net` onto the target path,
then exits immediately — the bind mount itself is what keeps the namespace
alive from then on, independent of the now-exited child, exactly `ip netns
add`'s own technique. Requires `CAP_SYS_ADMIN` (root) for the bind mount,
matching this feature's current root-only scope (see
`docs/networking-design.md`) — best-effort like this project's other
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.
- `session_cgroup.{h,cpp}` — gives `--kill` (`kill_session.{h,cpp}`, see - `session_cgroup.{h,cpp}` — gives `--kill` (`kill_session.{h,cpp}`, see
below) a reliable way to find every process a session ever started, however 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 deeply forked/daemonized/reparented, by putting it in a dedicated cgroup v2
+1 -1
View File
@@ -22,7 +22,7 @@ slocker_lite = executable('slocker-lite',
'src/bwrap.cpp', 'src/user_spec.cpp', 'src/config_file.cpp', 'src/volume_mount.cpp', '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/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/sandbox_process.cpp', 'src/session_cgroup.cpp', 'src/kill_session.cpp',
'src/network_subnet.cpp'], 'src/network_subnet.cpp', 'src/persistent_netns.cpp'],
include_directories : include_directories('.'), include_directories : include_directories('.'),
dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep], dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep],
install : true) install : true)
+122
View File
@@ -0,0 +1,122 @@
// 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 "persistent_netns.h"
#include <sched.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <system_error>
#include <fcntl.h>
#include <spdlog/spdlog.h>
#include "pid_file.h"
std::filesystem::path persistent_netns_path(std::string_view name) {
return xdg_state_dir() / "netns" / sanitize_for_filename(name);
}
bool persistent_netns_exists(std::string_view name) {
auto path = persistent_netns_path(name);
struct stat file_stat {};
struct stat parent_stat {};
if (stat(path.c_str(), &file_stat) != 0 || stat(path.parent_path().c_str(), &parent_stat) != 0) {
return false;
}
return file_stat.st_dev != parent_stat.st_dev;
}
bool create_persistent_netns(std::string_view name) {
if (persistent_netns_exists(name)) {
spdlog::error("persistent network namespace '{}' already exists", name);
return false;
}
auto path = persistent_netns_path(name);
std::error_code ec;
std::filesystem::create_directories(path.parent_path(), ec);
if (ec) {
spdlog::warn("failed to create directory {}: {}", path.parent_path().string(), ec.message());
return false;
}
// A plain regular file for the bind mount to land on -- created (or, if a
// stale never-mounted leftover from a previous failed attempt, simply
// reused) before forking, so the child has a stable target to bind onto.
int fd = open(path.c_str(), O_CREAT | O_RDONLY | O_CLOEXEC, 0644);
if (fd < 0) {
spdlog::warn("failed to create {}: {}", path.string(), strerror(errno));
return false;
}
close(fd);
pid_t pid = fork();
if (pid < 0) {
spdlog::warn("failed to fork while creating persistent network namespace '{}': {}", name, strerror(errno));
return false;
}
if (pid == 0) {
// unshare(2) affects only the calling process -- doing this in a
// forked child, never the caller itself, means slocker-lite's own
// network namespace is never touched by creating one of these.
if (unshare(CLONE_NEWNET) != 0) {
_exit(1);
}
// The bind mount is what actually keeps the namespace alive after
// this child exits -- exactly the technique `ip netns add` itself
// uses (bind-mounting /proc/self/ns/net onto a persistent path).
if (mount("/proc/self/ns/net", path.c_str(), nullptr, MS_BIND, nullptr) != 0) {
_exit(1);
}
_exit(0);
}
int status = 0;
waitpid(pid, &status, 0);
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
spdlog::warn(
"failed to create persistent network namespace '{}' (unshare/bind-mount failed -- requires root)",
name);
return false;
}
return true;
}
bool remove_persistent_netns(std::string_view name) {
auto path = persistent_netns_path(name);
if (umount2(path.c_str(), 0) != 0) {
spdlog::warn("failed to unmount persistent network namespace '{}' at {}: {}", name, path.string(),
strerror(errno));
return false;
}
std::error_code ec;
std::filesystem::remove(path, ec);
if (ec) {
spdlog::warn("failed to remove {}: {}", path.string(), ec.message());
return false;
}
return true;
}
+55
View File
@@ -0,0 +1,55 @@
// 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 <filesystem>
#include <string_view>
// Generic infrastructure for keeping a network namespace alive with no
// process in it, the way `ip netns add` does -- narrow, reusable primitives
// only; no `intern`/`extern` policy or bridge logic here (that's
// network_bridge.{h,cpp}, a later commit -- see docs/networking-design.md's
// commit sequence). Not yet wired into -n/--network.
// $XDG_STATE_HOME/slocker-lite/netns/<sanitized-name> (see pid_file.h's
// xdg_state_dir()/sanitize_for_filename(), reused here rather than a second,
// drifting copy of the same resolution/sanitization rules).
std::filesystem::path persistent_netns_path(std::string_view name);
// True if persistent_netns_path(name) exists and is actually a live
// bind-mounted namespace (its st_dev differs from its parent directory's --
// the same "is this a mountpoint" check used elsewhere in this project's
// spirit of trusting stat() over guessing from a path alone), not just a
// stale, never-mounted, or already-unmounted leftover file.
bool persistent_netns_exists(std::string_view name);
// Creates a brand-new persistent network namespace at
// persistent_netns_path(name): forks a child that unshare(CLONE_NEWNET)s its
// own network namespace, bind-mounts its /proc/self/ns/net onto the target
// path, then exits immediately -- the bind mount itself keeps the namespace
// alive from then on, independent of the (now-exited) child, exactly like
// `ip netns add`. Requires CAP_SYS_ADMIN (root) for the bind mount, matching
// this feature's current root-only scope. Best-effort like this project's
// other host-state primitives (session locks, cgroups): logs and returns
// false on any failure (already exists, can't fork/unshare/mount) rather
// than throwing.
bool create_persistent_netns(std::string_view name);
// Removes the persistent network namespace at persistent_netns_path(name)
// (unmounts it, then removes the now-plain file). Returns false (logging a
// warning) if it doesn't exist or can't be removed.
bool remove_persistent_netns(std::string_view name);
+2 -2
View File
@@ -42,8 +42,6 @@ std::string sanitize_for_filename(std::string_view name) {
return result.empty() ? "container" : result; return result.empty() ? "container" : result;
} }
namespace {
std::filesystem::path xdg_state_dir() { std::filesystem::path xdg_state_dir() {
const char* xdg_state_home = std::getenv("XDG_STATE_HOME"); const char* xdg_state_home = std::getenv("XDG_STATE_HOME");
std::filesystem::path state_home; std::filesystem::path state_home;
@@ -56,6 +54,8 @@ std::filesystem::path xdg_state_dir() {
return state_home / "slocker-lite"; return state_home / "slocker-lite";
} }
namespace {
std::filesystem::path session_run_dir() { std::filesystem::path session_run_dir() {
return xdg_state_dir() / "run"; return xdg_state_dir() / "run";
} }
+6
View File
@@ -31,6 +31,12 @@
// cgroup directory, so the two don't drift apart. // cgroup directory, so the two don't drift apart.
std::string sanitize_for_filename(std::string_view name); std::string sanitize_for_filename(std::string_view name);
// $XDG_STATE_HOME/slocker-lite, or $HOME/.local/state/slocker-lite if
// XDG_STATE_HOME is unset/empty. Exported (not just this file's own internal
// helper) so persistent_netns.{h,cpp} can resolve its own subdirectory under
// the same state root without a second, drifting copy of this logic.
std::filesystem::path xdg_state_dir();
// Tracks one running -r/--run session (a live bwrap process) as a PID file with an // Tracks one running -r/--run session (a live bwrap process) as a PID file with an
// advisory flock() held for as long as this process is running it -- so a *different* // advisory flock() held for as long as this process is running it -- so a *different*
// process can tell a stale leftover file apart from a genuinely still-running // process can tell a stale leftover file apart from a genuinely still-running
+46
View File
@@ -16,6 +16,52 @@
#include "self_test.h" #include "self_test.h"
#include <unistd.h>
#include <fmt/core.h>
#include <spdlog/spdlog.h>
#include "persistent_netns.h"
int run_self_tests() { int run_self_tests() {
constexpr std::string_view test_netns_name = "selftest";
// create_persistent_netns() bind-mounts, which requires root -- report
// and skip rather than treating a rootless dev machine as a failure.
if (geteuid() != 0) {
fmt::print("skipping persistent network namespace test (requires root)\n");
return 0;
}
// Clean up a leftover from a previous interrupted run, if any, before
// starting -- create_persistent_netns() refuses to overwrite an existing
// live namespace.
if (persistent_netns_exists(test_netns_name)) {
remove_persistent_netns(test_netns_name);
}
if (!create_persistent_netns(test_netns_name)) {
spdlog::error("self-test: failed to create persistent network namespace");
return 1;
}
// Checked from this (parent) process, after the child that actually did
// the unshare()/bind-mount has already exited -- this is exactly what
// confirms the namespace outlives its creating process, the whole point
// of the bind-mount technique.
if (!persistent_netns_exists(test_netns_name)) {
spdlog::error("self-test: persistent network namespace missing right after creating it");
remove_persistent_netns(test_netns_name);
return 1;
}
if (!remove_persistent_netns(test_netns_name)) {
spdlog::error("self-test: failed to remove persistent network namespace");
return 1;
}
if (persistent_netns_exists(test_netns_name)) {
spdlog::error("self-test: persistent network namespace still exists after removing it");
return 1;
}
fmt::print("persistent network namespace create/verify/remove: OK\n");
return 0; return 0;
} }
+4 -2
View File
@@ -17,6 +17,8 @@
#pragma once #pragma once
// Implements -t/--test, this project's own built-in self-test mode (distinct // Implements -t/--test, this project's own built-in self-test mode (distinct
// from the Meson-driven fixture smoke test under tests/). Currently an empty // from the Meson-driven fixture smoke test under tests/). Currently exercises
// placeholder -- real tests are expected here soon. // persistent_netns.{h,cpp}'s create/verify/remove cycle (root-only, skipped
// with a message otherwise -- see docs/networking-design.md); more real tests
// are expected here as more of that feature lands.
int run_self_tests(); int run_self_tests();