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:
2026-08-22 09:41:32 +00:00
parent d4c0e8f0a1
commit 00482b6b42
6 changed files with 275 additions and 7 deletions
+147
View File
@@ -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);
}