diff --git a/CLAUDE.md b/CLAUDE.md index d1f5211..45e89a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,10 +196,17 @@ Source layout (all under `src/`): - `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" - below). Currently an empty placeholder (previously reported - `detect_bwrap_unshare_args()`'s output — `bwrap.{h,cpp}` — unplugged since - that's kernel-capability diagnostics, not a test); deliberately its own - small file since real tests are expected here soon. + below; previously reported `detect_bwrap_unshare_args()`'s output — + `bwrap.{h,cpp}` — unplugged since that's kernel-capability diagnostics, not + a test). Currently exercises `persistent_netns.{h,cpp}`'s (see below) + 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 `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 @@ -406,7 +413,12 @@ Source layout (all under `src/`): back to `"container"` if that leaves nothing) is exported here (not just `.cpp`-local) specifically so `session_cgroup.{h,cpp}` (see below) can reuse the exact same `-` 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/-` (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 @@ -450,6 +462,29 @@ Source layout (all under `src/`): 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 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 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 diff --git a/meson.build b/meson.build index bf5332f..a10766c 100644 --- a/meson.build +++ b/meson.build @@ -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/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/network_subnet.cpp', 'src/persistent_netns.cpp'], include_directories : include_directories('.'), dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep], install : true) diff --git a/src/persistent_netns.cpp b/src/persistent_netns.cpp new file mode 100644 index 0000000..287ab4f --- /dev/null +++ b/src/persistent_netns.cpp @@ -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 +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include + +#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; +} diff --git a/src/persistent_netns.h b/src/persistent_netns.h new file mode 100644 index 0000000..10393cd --- /dev/null +++ b/src/persistent_netns.h @@ -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 +#include + +// 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/ (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); diff --git a/src/pid_file.cpp b/src/pid_file.cpp index db71820..8b72a42 100644 --- a/src/pid_file.cpp +++ b/src/pid_file.cpp @@ -42,8 +42,6 @@ std::string sanitize_for_filename(std::string_view name) { return result.empty() ? "container" : result; } -namespace { - std::filesystem::path xdg_state_dir() { const char* xdg_state_home = std::getenv("XDG_STATE_HOME"); std::filesystem::path state_home; @@ -56,6 +54,8 @@ std::filesystem::path xdg_state_dir() { return state_home / "slocker-lite"; } +namespace { + std::filesystem::path session_run_dir() { return xdg_state_dir() / "run"; } diff --git a/src/pid_file.h b/src/pid_file.h index d94afee..809bf08 100644 --- a/src/pid_file.h +++ b/src/pid_file.h @@ -31,6 +31,12 @@ // cgroup directory, so the two don't drift apart. 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 // 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 diff --git a/src/self_test.cpp b/src/self_test.cpp index 4ed38ff..00bb7f4 100644 --- a/src/self_test.cpp +++ b/src/self_test.cpp @@ -16,6 +16,52 @@ #include "self_test.h" +#include + +#include +#include + +#include "persistent_netns.h" + 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; } diff --git a/src/self_test.h b/src/self_test.h index d6b465a..deca4e3 100644 --- a/src/self_test.h +++ b/src/self_test.h @@ -17,6 +17,8 @@ #pragma once // 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 -// placeholder -- real tests are expected here soon. +// from the Meson-driven fixture smoke test under tests/). Currently exercises +// 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();