From 00482b6b422e3bdee318d534bb11f715e113fd37 Mon Sep 17 00:00:00 2001 From: Viorel Munteanu Date: Sat, 22 Aug 2026 09:41:32 +0000 Subject: [PATCH] Add -e/--exec to join a running -r/--run session Validates the given pid against the same tracked-session liveness check --list-processes/--clean-processes already use, then joins its namespaces via nsenter and runs a command there in the foreground. Two things discovered only by testing against a live session, not assumed up front: - The tracked pid is bwrap's own outer process. It sets up the mount/user namespaces itself, then clone()s the actual sandboxed command into fresh pid/uts/ipc/cgroup namespaces -- clone()'s namespace flags only ever affect the new child, never the caller, so the outer process itself never enters those namespaces at all. exec_in_session() resolves that real child via /proc//task//children and joins its namespaces instead, falling back to the outer pid if that can't be read. - Rather than nsenter -a (which would hit a known "Invalid argument" failure re-entering an identical namespace -- this project already worked around exactly that once, for the containers-storage mount path), each namespace type is only joined if /proc//ns/ actually differs from this process's own. nsenter also needs --preserve-credentials, or it tries to setuid/setgid/setgroups to the target's identity, which fails outright against the setgroups-denied unprivileged user namespace bwrap creates whenever -r/--run isn't root. Verified end-to-end: joined shell gets the container's own hostname, process tree (ps shows only container processes), and root filesystem; untracked/stale pids error out cleanly without touching nsenter; Ctrl-C during the joined command doesn't disturb the original session; no leftover mounts after either exits. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz --- CLAUDE.md | 48 +++++++++++++- README.md | 16 +++++ meson.build | 2 +- src/exec_session.cpp | 147 +++++++++++++++++++++++++++++++++++++++++++ src/exec_session.h | 34 ++++++++++ src/main.cpp | 35 +++++++++-- 6 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 src/exec_session.cpp create mode 100644 src/exec_session.h diff --git a/CLAUDE.md b/CLAUDE.md index 105bec3..1ad2cae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,14 @@ Source layout (all under `src/`): (`pid_file.{h,cpp}`) and prints one `removed stale pid file for '' (pid )` line per file actually removed — nothing is printed for sessions still running, and an empty result (nothing stale) is silent success, same - convention as the rest of this file's list/delete commands. + convention as the rest of this file's list/delete commands. `-e/--exec ` + (has a short form, unlike the rest of the process-tracking flags) dispatches + straight to `exec_in_session()` (`exec_session.{h,cpp}`, see below): `pid` + lands in `mode_arg` (parsed as a positive integer, erroring out otherwise) the + same way `-r`'s image path does, and the trailing command + (`argv[optind:]`, required — errors out if empty) is collected the same way + `-r`'s own command is, sharing that mode's exemption from the "no leftover + positional args" check. `inspect_image_command()` implements `-i/--inspect `: prints every `OciImageConfig` field (user/group, exposed ports, env, volumes, default command) without mounting or running the image — extend it @@ -222,6 +229,43 @@ 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. +- `exec_session.{h,cpp}` — implements `-e/--exec `: joins an already-running + `-r/--run` session's namespaces via `nsenter` and runs a command inside it in + the foreground. `exec_in_session()` first confirms `pid` is a tracked, running + session via `list_sessions()` (`pid_file.h`) — same liveness check + `--list-processes`/`--clean-processes` already use, no new logic needed there. + **Key discovery, confirmed by direct testing, not assumed**: `pid` (the one + `run_process_foreground()` captured and pid-file-tracked when `-r` launched + `bwrap`) is bwrap's own *outer* process — it sets up the mount and user + namespaces itself, then `clone()`s the actual sandboxed command into fresh + pid/uts/ipc/cgroup namespaces, and `clone()`'s namespace-creation flags only + ever affect the newly created child, never the caller. So the outer process + itself never actually enters those namespaces — comparing + `/proc//ns/{pid,uts,ipc,cgroup}` against this process's own showed + them identical, while only `mnt`/`user` differed. `resolve_namespace_pid()` + reads `/proc//task//children` (the direct-children list `procfs` + exposes) to find that real inner process and joins *its* namespaces instead — + falls back to `pid` itself (best-effort, not fatal) if that file can't be + read. For each of `{mnt→--mount, uts→--uts, ipc→--ipc, pid→--pid, + cgroup→--cgroup, user→--user}` (`net` deliberately excluded — this project + never isolates networking, see `bwrap.cpp` below), `readlink()`s both + `/proc//ns/` and `/proc/self/ns/` and only passes + nsenter's corresponding `--type=/proc//ns/` flag when they + differ — an identical-namespace re-entry attempt can fail outright + (`setns()`'s own `EINVAL` restriction on re-entering a namespace you're + already in), so skipping is deliberate, not just an optimization. `mnt` is the + one type where a *read* failure (permission denied, or the process vanished) + is treated as fatal, since without it "joining the container" is meaningless; + every other type just degrades to a skip. Always appends + `--preserve-credentials`: without it, `nsenter --user` also tries to + `setuid()`/`setgid()`/`setgroups()` to the target's identity within the new + user namespace, which fails outright (`setgroups failed: Operation not + permitted`) against the `setgroups`-denied unprivileged user namespace bwrap + creates whenever `-r/--run` isn't root — confirmed by hitting this exact + failure during manual testing before adding the flag. Runs the final + `nsenter ... -- ` via the existing `run_process_foreground()` + (`process.h`) — same inherited stdio and SIGINT/SIGTERM forwarding as every + other foreground external command, no new process-running logic needed. - `config_file.{h,cpp}` — `load_config_file()` reads and parses (via libyaml's document API, ``) the `global` and `volumes` sections of the local YAML config file located by `config_file_path()` (`$XDG_CONFIG_HOME/slocker-lite/config.yaml`, @@ -298,7 +342,7 @@ Build directory is `buildDir/` (already configured). (see `priv_drop_helper.cpp` in "Project state") - Run the executable: `./buildDir/slocker-lite -m ` (see `--help` for the full flag list: `-m/--mount`, `-r/--run`, `-u/--umount`, `-c/--cleanup`, - `-l/--list-images`, `-i/--inspect`, `-n/--no-nsenter`, `--user`, `--group`, + `-l/--list-images`, `-i/--inspect`, `-e/--exec`, `-n/--no-nsenter`, `--user`, `--group`, `--hostname`, `-v/--volume`, `--list-volumes`, `--delete-volume`, `--delete-volume-full`, `--list-processes`, `--clean-processes`, `-t/--test`, `--log-level`, `-h/--help`, `-V/--version`) diff --git a/README.md b/README.md index a692085..e1a6414 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ slocker-lite -u|--umount slocker-lite -c|--cleanup slocker-lite -l|--list-images slocker-lite -i|--inspect +slocker-lite -e|--exec [-- [args...]] slocker-lite -v|--volume slocker-lite --list-volumes slocker-lite --delete-volume @@ -81,6 +82,7 @@ slocker-lite -V|--version | `--hostname ` | With `--run`, set the sandbox's hostname. Only takes effect if the running kernel supports `--unshare-uts`; ignored with a warning otherwise. | | `-l, --list-images ` | List OCI Image Layout tars (`*.tar`, `*.tar.*`) found directly in ``, with their `name:tag`. | | `-i, --inspect ` | Print an image's declared user, exposed ports, env, volumes, and default command, without mounting or running it. | +| `-e, --exec ` | Join an already-running `--run` session (`` must be one `--list-processes` shows as `running`) and run a command inside its container. Pass `-- [args...]` to specify it. | | `-v, --volume ` | Create a named volume mapped to a host directory (created if missing), recorded in the config file's `volumes` section. Fails if the name or directory is already used by an existing volume. Volume names can't contain `/`. With `--run`, instead mounts a volume into the sandbox (repeatable): `` is an existing named volume, or, if it contains `/`, a host directory path (created if missing); `` is the absolute path inside the container to mount it at. If the host directory is empty and the image already has content there, that content is copied in first, preserving numeric ownership/permissions/links and, where the host filesystem supports them, extended attributes/ACLs (skipped with a warning otherwise). | | `--list-volumes` | List all named volumes (see `-v/--volume`) with their host directory. | | `--delete-volume ` | Remove a named volume from the config. The host directory is left untouched. | @@ -116,6 +118,12 @@ sudo ./buildDir/slocker-lite -r myimage.tar --user git # Inspect an image's declared config without mounting or running it ./buildDir/slocker-lite -i myimage.tar +# Start a busybox container in the background, then get a shell inside it from +# another terminal (find its pid with --list-processes) +./buildDir/slocker-lite -r busybox.tar & +./buildDir/slocker-lite --list-processes +./buildDir/slocker-lite -e 12345 -- /bin/sh + # Create a named volume backed by a host directory ./buildDir/slocker-lite -v mydata ~/slocker-volumes/mydata @@ -187,6 +195,14 @@ when its own session ends, but `--clean-processes` removes any stale ones left behind (e.g. after a crash) using that same check, atomically per file, so it never removes one that's still genuinely running. +`-e/--exec ` joins a running session's namespaces with `nsenter` and runs a +command there. Because `bwrap` itself sets up the sandbox's mount/user namespaces +and then hands the actual sandboxed command off to a child process in fresh +pid/uts/ipc/cgroup namespaces, `-e` resolves that real child first (via `/proc`) +rather than joining the outer `bwrap` process's own namespaces, so the joined +command sees the container's process tree and hostname too, not just its +filesystem. + See `CLAUDE.md` for the full architecture writeup (file-by-file breakdown, the reasoning behind each of the above, and known gaps). diff --git a/meson.build b/meson.build index 074fb15..317c4c7 100644 --- a/meson.build +++ b/meson.build @@ -19,7 +19,7 @@ configure_file(output : 'config.h', configuration : conf_data) slocker_lite = executable('slocker-lite', ['src/main.cpp', 'src/process.cpp', 'src/oci_image.cpp', 'src/containers_storage.cpp', 'src/bwrap.cpp', 'src/user_spec.cpp', 'src/config_file.cpp', 'src/volume_mount.cpp', - 'src/pid_file.cpp'], + 'src/pid_file.cpp', 'src/exec_session.cpp'], include_directories : include_directories('.'), dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep], install : true) diff --git a/src/exec_session.cpp b/src/exec_session.cpp new file mode 100644 index 0000000..a9345bc --- /dev/null +++ b/src/exec_session.cpp @@ -0,0 +1,147 @@ +// 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 "exec_session.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "pid_file.h" +#include "process.h" + +namespace { + +// bwrap's own outer process (the one tracked in the session pid file) sets up the +// mount/user namespaces itself, then clone()s the actual sandboxed command into +// fresh pid/uts/ipc/cgroup/net namespaces -- clone()'s namespace-creation flags +// only ever affect the newly created child, never the caller, so the outer +// process itself never actually enters those namespaces (confirmed by direct +// testing: /proc//ns/{pid,uts,ipc,cgroup,net} all matched this +// process's own, while only mnt/user differed). The real sandboxed command is +// that direct child, which is what actually needs to be nsenter-target for a +// faithful join. Returns `pid` itself (best-effort fallback, not fatal) if the +// child can't be determined -- callers still get a mount/user-namespace join out +// of that, just not the rest. +pid_t resolve_namespace_pid(pid_t pid) { + std::ifstream children(fmt::format("/proc/{}/task/{}/children", pid, pid)); + pid_t child = 0; + if (children >> child && child > 0) { + return child; + } + spdlog::debug("could not determine pid {}'s sandboxed child process; joining its own namespaces only", + pid); + return pid; +} + +struct JoinableNamespace { + const char* proc_name; // matches /proc//ns/ + const char* nsenter_flag; + bool required; // fatal (not just skipped) if it can't be read +}; + +// net is deliberately excluded: this project never isolates networking either +// (see build_bwrap_args() dropping --unshare-net), so there's nothing meaningful +// to join there. +constexpr std::array kJoinableNamespaces = {{ + {"mnt", "--mount", true}, + {"uts", "--uts", false}, + {"ipc", "--ipc", false}, + {"pid", "--pid", false}, + {"cgroup", "--cgroup", false}, + {"user", "--user", false}, +}}; + +std::optional read_ns_link(const std::filesystem::path& path) { + std::error_code ec; + auto target = std::filesystem::read_symlink(path, ec); + if (ec) { + return std::nullopt; + } + return target.string(); +} + +} // namespace + +int exec_in_session(pid_t pid, const std::vector& command) { + if (pid <= 0) { + spdlog::error("invalid pid: {}", pid); + return 1; + } + + auto sessions = list_sessions(); + auto it = std::find_if(sessions.begin(), sessions.end(), + [&](const SessionInfo& session) { return session.pid == pid; }); + if (it == sessions.end()) { + spdlog::error("no tracked slocker-lite session with pid {}", pid); + return 1; + } + if (!it->running) { + spdlog::error("session '{}' (pid {}) is no longer running", it->container_name, pid); + return 1; + } + + pid_t ns_pid = resolve_namespace_pid(pid); + + std::vector argv = {"nsenter"}; + for (const auto& ns : kJoinableNamespaces) { + auto target_ns = read_ns_link(fmt::format("/proc/{}/ns/{}", ns_pid, ns.proc_name)); + if (!target_ns) { + if (ns.required) { + spdlog::error( + "could not access pid {}'s {} namespace (permission denied, or it has since exited)", + ns_pid, ns.proc_name); + return 1; + } + spdlog::debug("skipping {} namespace for pid {}: not accessible", ns.proc_name, ns_pid); + continue; + } + + auto own_ns = read_ns_link(fmt::format("/proc/self/ns/{}", ns.proc_name)); + if (own_ns && *own_ns == *target_ns) { + // Already in the same namespace -- nsenter can fail ("Invalid + // argument") if asked to re-enter it, so skip rather than risk that. + continue; + } + + argv.push_back(fmt::format("{}=/proc/{}/ns/{}", ns.nsenter_flag, ns_pid, ns.proc_name)); + } + + if (!find_in_path("nsenter")) { + spdlog::error("nsenter not found in PATH"); + return 1; + } + + // Without this, nsenter --user tries to setgroups()/setuid()/setgid() to the + // target's identity within the new user namespace, which fails outright + // ("setgroups failed: Operation not permitted") when that namespace has + // setgroups denied -- the kernel-enforced default for any unprivileged user + // namespace, which is exactly what bwrap creates when run_container() isn't + // root. We don't want nsenter changing our credentials anyway -- just join + // the namespaces and keep running as whatever this process already is. + argv.push_back("--preserve-credentials"); + + argv.push_back("--"); + argv.insert(argv.end(), command.begin(), command.end()); + + return run_process_foreground(argv); +} diff --git a/src/exec_session.h b/src/exec_session.h new file mode 100644 index 0000000..078fab7 --- /dev/null +++ b/src/exec_session.h @@ -0,0 +1,34 @@ +// 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 + +#include + +// Validates that `pid` is a currently-tracked, running -r/--run session (see +// list_sessions() in pid_file.h) and, if so, joins its namespaces via nsenter and +// runs `command` inside it in the foreground, inheriting stdio (see +// run_process_foreground() in process.h). `pid` is bwrap's own outer/tracked +// process, but the sandboxed command actually runs in a *child* of it (bwrap sets +// up the mount/user namespaces itself, then clone()s the real command into fresh +// pid/uts/ipc/cgroup namespaces -- the parent never enters those itself), so this +// resolves that child internally before building nsenter's argv. Returns the exit +// code, or 1 if `pid` isn't a tracked/running session (logged with +// spdlog::error), or -1 if nsenter itself couldn't be launched. +int exec_in_session(pid_t pid, const std::vector& command); diff --git a/src/main.cpp b/src/main.cpp index 3804679..b4acae7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -34,6 +34,7 @@ #include "config.h" #include "config_file.h" #include "containers_storage.h" +#include "exec_session.h" #include "oci_image.h" #include "pid_file.h" #include "process.h" @@ -58,7 +59,8 @@ enum class Mode { kDeleteVolumeFull, kInspect, kListProcesses, - kCleanProcesses + kCleanProcesses, + kExec }; // --log-level/--user/--group/--list-volumes/--delete-volume[-full]/--hostname/ @@ -76,7 +78,7 @@ constexpr int kHostnameOpt = 262; constexpr int kListProcessesOpt = 263; constexpr int kCleanProcessesOpt = 264; -constexpr std::array kLongOptions = {{ +constexpr std::array kLongOptions = {{ {"help", no_argument, nullptr, 'h'}, {"version", no_argument, nullptr, 'V'}, {"test", no_argument, nullptr, 't'}, @@ -94,6 +96,7 @@ constexpr std::array kLongOptions = {{ {"delete-volume", required_argument, nullptr, kDeleteVolumeOpt}, {"delete-volume-full", required_argument, nullptr, kDeleteVolumeFullOpt}, {"inspect", required_argument, nullptr, 'i'}, + {"exec", required_argument, nullptr, 'e'}, {"hostname", required_argument, nullptr, kHostnameOpt}, {"list-processes", no_argument, nullptr, kListProcessesOpt}, {"clean-processes", no_argument, nullptr, kCleanProcessesOpt}, @@ -108,6 +111,7 @@ void print_usage(const char* prog) { " {0} -c|--cleanup \n" " {0} -l|--list-images \n" " {0} -i|--inspect \n" + " {0} -e|--exec [-- [args...]]\n" " {0} -v|--volume \n" " {0} --list-volumes\n" " {0} --delete-volume \n" @@ -152,6 +156,10 @@ void print_usage(const char* prog) { " -i, --inspect print an image's declared user, exposed ports,\n" " env, volumes, and default command, without\n" " mounting or running it\n" + " -e, --exec join a running --run session (pid must be one\n" + " shown by --list-processes as \"running\") and run\n" + " a command inside its container; pass\n" + " -- [args...] to specify it\n" " -v, --volume create a named volume mapped to a host directory\n" " (created if missing), recorded in the config\n" " file's volumes section. With --run, instead\n" @@ -624,7 +632,7 @@ int main(int argc, char* argv[]) { opterr = 0; int opt; - while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:v:i:", kLongOptions.data(), nullptr)) != -1) { + while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:v:i:e:", kLongOptions.data(), nullptr)) != -1) { switch (opt) { case 'h': print_usage(argv[0]); @@ -639,6 +647,7 @@ int main(int argc, char* argv[]) { case 'c': case 'l': case 'i': + case 'e': case kListVolumesOpt: case kDeleteVolumeOpt: case kDeleteVolumeFullOpt: @@ -667,6 +676,9 @@ int main(int argc, char* argv[]) { case 'i': requested = Mode::kInspect; break; + case 'e': + requested = Mode::kExec; + break; case kListVolumesOpt: requested = Mode::kListVolumes; break; @@ -756,7 +768,7 @@ int main(int argc, char* argv[]) { print_usage(argv[0]); return 1; } - if (mode != Mode::kRun && optind != argc) { + if (mode != Mode::kRun && mode != Mode::kExec && optind != argc) { print_usage(argv[0]); return 1; } @@ -797,6 +809,21 @@ int main(int argc, char* argv[]) { if (mode == Mode::kCleanProcesses) { return clean_processes_command(); } + if (mode == Mode::kExec) { + std::vector command(argv + optind, argv + argc); + if (command.empty()) { + spdlog::error("--exec requires a command to run"); + print_usage(argv[0]); + return 1; + } + char* end = nullptr; + long parsed = std::strtol(mode_arg.c_str(), &end, 10); + if (mode_arg.empty() || !end || *end != '\0' || parsed <= 0) { + spdlog::error("invalid pid: {}", mode_arg); + return 1; + } + return exec_in_session(static_cast(parsed), command); + } if (mode == Mode::kRun) { std::vector command(argv + optind, argv + argc); // As root, containers-storage mount doesn't need to reexec into a private