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/<pid>/task/<pid>/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/<pid>/ns/<type> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
@@ -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 <algorithm>
|
||||
#include <array>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <system_error>
|
||||
|
||||
#include <fmt/core.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#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/<outer_pid>/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/<pid>/ns/<proc_name>
|
||||
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<JoinableNamespace, 6> kJoinableNamespaces = {{
|
||||
{"mnt", "--mount", true},
|
||||
{"uts", "--uts", false},
|
||||
{"ipc", "--ipc", false},
|
||||
{"pid", "--pid", false},
|
||||
{"cgroup", "--cgroup", false},
|
||||
{"user", "--user", false},
|
||||
}};
|
||||
|
||||
std::optional<std::string> 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<std::string>& 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<std::string> 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);
|
||||
}
|
||||
@@ -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 <string>
|
||||
#include <vector>
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
// 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<std::string>& command);
|
||||
+31
-4
@@ -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<struct option, 21> kLongOptions = {{
|
||||
constexpr std::array<struct option, 22> kLongOptions = {{
|
||||
{"help", no_argument, nullptr, 'h'},
|
||||
{"version", no_argument, nullptr, 'V'},
|
||||
{"test", no_argument, nullptr, 't'},
|
||||
@@ -94,6 +96,7 @@ constexpr std::array<struct option, 21> 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 <layer-id>\n"
|
||||
" {0} -l|--list-images <directory>\n"
|
||||
" {0} -i|--inspect <image.tar>\n"
|
||||
" {0} -e|--exec <pid> [-- <command> [args...]]\n"
|
||||
" {0} -v|--volume <name> <directory>\n"
|
||||
" {0} --list-volumes\n"
|
||||
" {0} --delete-volume <name>\n"
|
||||
@@ -152,6 +156,10 @@ void print_usage(const char* prog) {
|
||||
" -i, --inspect <image.tar> print an image's declared user, exposed ports,\n"
|
||||
" env, volumes, and default command, without\n"
|
||||
" mounting or running it\n"
|
||||
" -e, --exec <pid> 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"
|
||||
" -- <command> [args...] to specify it\n"
|
||||
" -v, --volume <name> <dir> 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<std::string> 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<pid_t>(parsed), command);
|
||||
}
|
||||
if (mode == Mode::kRun) {
|
||||
std::vector<std::string> command(argv + optind, argv + argc);
|
||||
// As root, containers-storage mount doesn't need to reexec into a private
|
||||
|
||||
Reference in New Issue
Block a user