Add -k/--kill <pid> to fully stop a running session
A plain `kill <tracked_bwrap_pid>` doesn't kill everything a container
started: on a kernel without pid namespace support, a service that
daemonizes (double-forks and detaches) before the entrypoint execs into its
main command reparents all the way to the *host's* own pid 1, completely
disconnected from the sandboxed session -- confirmed via a real session log
from the user's own Android target device, where php-fpm and caddy both
kept running as orphans after killing the tracked pid.
kill_session() (src/kill_session.{h,cpp}) picks between three
independently-named strategies per session, based on what's actually
available for it:
- kill_via_cgroup(): preferred when the session has a dedicated cgroup
(src/session_cgroup.{h,cpp}, set up at -r/--run time in run_bwrap()'s
on_start callback). Reaches every process the session ever started,
daemonized/reparented or not, regardless of pid namespace support.
- kill_via_pid_namespace(): used when --unshare-pid was genuinely in effect
for the session (src/sandbox_process.{h,cpp}, shared with exec_session.cpp,
which already needed resolve_namespace_pid()). Relies on the kernel's own
guarantee that killing a pid namespace's pid 1 tears down everything in it.
- kill_via_tracked_pid(): fallback, signals the tracked pid directly -- no
worse than today's manual kill. This is what the user's real target
device currently falls back to (no pid namespace support there).
Each strategy sends SIGTERM, waits up to a 10s grace period, then forces a
SIGKILL. Verified locally (rootless dev machine, which does support pid
namespaces): a daemonizing test session was fully cleaned up via
kill_via_pid_namespace(), including the forced-SIGKILL escalation path,
with no leftover processes, mounts, or layers.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
@@ -38,7 +38,12 @@ Source layout (all under `src/`):
|
||||
`Mode::exec` dispatch arm) `-e/--exec <pid>`'s own pid parsing/validation
|
||||
(`ParsedArgs::exec_pid`, a positive integer or a hard error) and its
|
||||
trailing-command requirement (`ParsedArgs::command`, required non-empty).
|
||||
Returns an exit code `main()` should return immediately (`0` for `-h`/`-V`,
|
||||
`-k/--kill <pid>` (`ParsedArgs::kill_pid`) shares that same positive-integer
|
||||
parsing/validation via a small extracted `parse_pid_arg()` helper (`.cpp`-local)
|
||||
rather than duplicating the `strtol` dance a second time — unlike `-e/--exec`,
|
||||
it takes no trailing command, so it's simply not added to the leftover-args
|
||||
exemption list (`Mode::run`/`Mode::exec` only). Returns an exit code `main()`
|
||||
should return immediately (`0` for `-h`/`-V`,
|
||||
`1` for any parse error) when set; `nullopt` means `out` is ready for
|
||||
`dispatch_command()`. `-D/--daemonize` (has a short form; `'D'` was free) is
|
||||
a plain boolean flag (`ParsedArgs::daemonize_flag`, set in its own `case
|
||||
@@ -257,7 +262,12 @@ Source layout (all under `src/`):
|
||||
same `on_start` timing — `-D/--daemonize` (`daemonize.{h,cpp}`, see below)
|
||||
hooks in here via `report_daemon_started()` to learn the real pid at the
|
||||
same instant everything else that needs it does, rather than needing its own
|
||||
separate pid-discovery mechanism.
|
||||
separate pid-discovery mechanism. That same `on_start` lambda also calls
|
||||
`create_session_cgroup()` (`session_cgroup.h`, see below), right alongside
|
||||
`create_session_lock()`, so `-k/--kill` can later find every process the
|
||||
session ever starts via its dedicated cgroup; `remove_session_cgroup()` is
|
||||
called from the same post-`run_process_foreground()` spot
|
||||
`release_session_lock()` already is.
|
||||
- `priv_drop_helper.cpp` → the separate `slocker-lite-priv-drop` binary (its own
|
||||
`executable()` target in `meson.build`, **built with `-static`**). Deliberately
|
||||
has zero dependencies on the rest of this project (no fmt/spdlog/etc.) and is
|
||||
@@ -325,7 +335,11 @@ Source layout (all under `src/`):
|
||||
- `pid_file.{h,cpp}` — tracks one running `-r/--run` session (a live `bwrap`
|
||||
process) as a locked pid file, so an outside process (or a later
|
||||
`slocker-lite` invocation) can tell whether it's still running.
|
||||
`session_pid_file_path()` resolves
|
||||
`sanitize_for_filename()` (anything outside `[A-Za-z0-9._-]` → `_`, falling
|
||||
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 `<name>-<pid>` naming rule for its own per-session cgroup
|
||||
directory without drifting from this file's own. `session_pid_file_path()` resolves
|
||||
`$XDG_STATE_HOME/slocker-lite/run/<container_name>-<pid>` (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
|
||||
@@ -369,6 +383,76 @@ 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.
|
||||
- `session_cgroup.{h,cpp}` — gives `-k/--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
|
||||
group from the moment it starts. `cgroup_v2_available()` checks for
|
||||
`/sys/fs/cgroup/cgroup.controllers` (the same signal systemd's own
|
||||
unified-hierarchy detection uses) — only cgroup v2 is supported; v1 (which
|
||||
splits per-controller into separate hierarchies with no unified
|
||||
`cgroup.procs` at the top) is deliberately out of scope, since this
|
||||
project's real target (Android) has used the unified v2 hierarchy by
|
||||
default since Android 12. `session_cgroup_path()` is deterministic —
|
||||
`/sys/fs/cgroup/slocker-lite/<name>-<pid>/`, reusing `pid_file.h`'s own
|
||||
`sanitize_for_filename()` — so no separate lookup state is needed anywhere.
|
||||
`create_session_cgroup()` is called from `run_bwrap()`'s `on_start` callback
|
||||
(`bwrap.cpp`, see below), the same spot `create_session_lock()` already
|
||||
fires from: `create_directories()`'s the leaf directory (also creating the
|
||||
`slocker-lite/` parent the first time — a plain grouping cgroup, no resource
|
||||
controllers are ever enabled on it via `cgroup.subtree_control`, so the "no
|
||||
internal processes" restriction that comes with actually delegating
|
||||
controllers never applies here) and writes the bwrap pid into its
|
||||
`cgroup.procs`. From that point on, every process bwrap (or anything it
|
||||
execs into) forks inherits this cgroup automatically, permanently —
|
||||
including anything that later daemonizes/double-forks and gets reparented,
|
||||
unlike pid-namespace child membership (only the processes `clone()` itself
|
||||
creates) or process-group membership (many daemonizing services explicitly
|
||||
`setpgid()`/`setsid()` away from it on purpose). Best-effort, mirroring
|
||||
`create_session_lock()`: returns `nullopt` (logging a warning, never fatal)
|
||||
if cgroup v2 isn't available, or the directory can't be created/written
|
||||
(no delegated subtree when running rootless, or an SELinux policy blocking
|
||||
cgroupfs writes even for a root-euid process, are both real, confirmed-by-
|
||||
testing causes on the two environments this project actually runs on).
|
||||
`remove_session_cgroup()` (called from the same post-`run_process_foreground()`
|
||||
spot `release_session_lock()` already is) only succeeds once the cgroup is
|
||||
empty — a straggler process still alive at normal exit (e.g. a daemonized
|
||||
process that outlived the session's own main command, a pre-existing
|
||||
exposure independent of this feature) leaves it in place with a warning, not
|
||||
a fatal error. `session_cgroup_pids()` reads `cgroup.procs` — this is the
|
||||
actual answer to "gather every process running inside the container":
|
||||
unlike anything derived from `/proc` parent-pid chains or pid namespaces,
|
||||
cgroup membership reliably includes every process the session ever started.
|
||||
`session_cgroup_supports_kill()`/`kill_session_cgroup()` wrap the
|
||||
`cgroup.kill` knob (Linux 5.14+): writing `"1"` to it atomically `SIGKILL`s
|
||||
every process currently in the cgroup in one step.
|
||||
- `sandbox_process.{h,cpp}` — process-tree/namespace-resolution utilities
|
||||
shared by `exec_session.{h,cpp}` and `kill_session.{h,cpp}` (see both
|
||||
below); pulled into their own file (rather than staying private to
|
||||
`exec_session.cpp`, where `resolve_namespace_pid()` originally lived) once
|
||||
`-k/--kill` needed the exact same "find the real sandboxed child" logic, to
|
||||
avoid a second, drifting copy. `resolve_namespace_pid()` is unchanged from
|
||||
its original `exec_session.cpp` form (see that entry for the full
|
||||
reasoning: bwrap's own outer/tracked pid never actually enters the
|
||||
pid/uts/ipc/cgroup namespaces it creates for its clone()'d child, only that
|
||||
child does). Two new utilities added alongside it for `kill_session()`:
|
||||
`pid_namespace_isolated(outer_pid, ns_pid)` compares
|
||||
`/proc/<outer_pid>/ns/pid` and `/proc/<ns_pid>/ns/pid`'s own `readlink()`
|
||||
targets directly — true only when bwrap's `clone()` actually created a
|
||||
separate pid namespace for its child (`--unshare-pid` was requested *and*
|
||||
the kernel supported it), the precondition for the kernel's own guarantee
|
||||
that killing a pid namespace's pid 1 forcibly tears down every remaining
|
||||
process in it. `collect_descendant_pids(root)` generalizes
|
||||
`resolve_namespace_pid()`'s own `/proc/<n>/stat` ppid-scanning fallback to
|
||||
collect a whole transitive tree (root included) instead of just one child,
|
||||
sharing the actual stat-parsing loop between both via a private
|
||||
`build_ppid_map()` (one `/proc` pass, used by both the single-child lookup
|
||||
and the full-tree collection). Reliable specifically when `root` is a
|
||||
genuinely isolated pid namespace's own pid 1: anything that reparents
|
||||
within it (e.g. a daemonizing service) is guaranteed by the kernel to land
|
||||
back on `root` itself, unlike on a kernel without pid namespace support,
|
||||
where it escapes to the *host's* real pid 1 instead (see
|
||||
`kill_session.{h,cpp}` below for exactly this scenario, confirmed on a real
|
||||
target device).
|
||||
- `daemonize.{h,cpp}` — implements `-D/--daemonize`'s fork/detach mechanics.
|
||||
`daemonize(container_name)` sets up a `pipe2(..., O_CLOEXEC)` pair (so it
|
||||
never leaks into `bwrap`/the sandboxed command, same reasoning as the pid
|
||||
@@ -433,22 +517,9 @@ Source layout (all under `src/`):
|
||||
itself never actually enters those namespaces — comparing
|
||||
`/proc/<outer_pid>/ns/{pid,uts,ipc,cgroup}` against this process's own showed
|
||||
them identical, while only `mnt`/`user` differed. `resolve_namespace_pid()`
|
||||
reads `/proc/<pid>/task/<pid>/children` (the direct-children list `procfs`
|
||||
exposes) to find that real inner process and joins *its* namespaces instead.
|
||||
**Real bug found via testing on a real target device, not assumed**: that
|
||||
file requires `CONFIG_CHECKPOINT_RESTORE`, which not every kernel enables —
|
||||
confirmed absent (not just unreadable — the file doesn't exist at all) on a
|
||||
real Android device, where `-e/--exec` then fell back to the outer `bwrap`
|
||||
pid itself and failed outright (`nsenter: no namespace specified`, since
|
||||
every namespace type either matched the outer process's own or couldn't be
|
||||
read at all). Fixed by adding `find_child_by_scanning_proc()`, a portable
|
||||
fallback used only when the children file is missing/empty: scans
|
||||
`/proc/<n>/stat` for any process whose ppid field equals `pid` — the same
|
||||
information `pstree` itself reads to build its tree, which is how the actual
|
||||
sandboxed child was located and confirmed correct on the same device via a
|
||||
manual `nsenter -t <child_pid> -a -- /bin/sh` before the fix was written.
|
||||
Picks the lowest matching pid if more than one child exists, for a
|
||||
deterministic result. For each of `{mnt→--mount, uts→--uts, ipc→--ipc, pid→--pid,
|
||||
(`sandbox_process.{h,cpp}` — moved out of this file once `-k/--kill`
|
||||
needed the exact same logic, see that entry) finds that real inner process
|
||||
so this can join *its* namespaces instead. 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_pid>/ns/<type>` and `/proc/self/ns/<type>` and only passes
|
||||
@@ -513,6 +584,85 @@ Source layout (all under `src/`):
|
||||
real `250:250` via the fetched `/etc/passwd` and then fails clearly (helper not
|
||||
bind-mounted, since the session itself had no declared user) rather than
|
||||
silently running as the wrong identity.
|
||||
- `kill_session.{h,cpp}` — implements `-k/--kill <pid>`, stopping a tracked,
|
||||
running `-r/--run` session and everything it started. `kill_session()`
|
||||
validates `pid` the same way `exec_in_session()` does (via `list_sessions()`,
|
||||
`pid_file.h`). **Real bug reported by the user against their own actual
|
||||
target device, confirmed via a captured session log**: a plain `kill
|
||||
<tracked_bwrap_pid>` doesn't kill everything a container started — their
|
||||
`/init` script `php-fpm --daemonize`s (double-forks, detaches) then `exec
|
||||
caddy ...`s (replaces itself); after killing the tracked pid, both `caddy`
|
||||
and the `php-fpm` master+workers kept running as orphans. **Root cause**: on
|
||||
that device, `bwrap`'s `--unshare-pid` isn't actually in effect at all —
|
||||
`detect_bwrap_unshare_args()` (`bwrap.cpp`) only requests `--unshare-xxx`
|
||||
flags the kernel actually supports, and that kernel doesn't support pid
|
||||
namespaces (independently confirmed elsewhere this session, see
|
||||
`exec_session.{h,cpp}`'s own `CONFIG_CHECKPOINT_RESTORE` bug above) — so
|
||||
`php-fpm --daemonize` reparents to the *host's own* pid 1, completely
|
||||
disconnected from the sandboxed session; the classic "kill a pid namespace's
|
||||
pid 1, the kernel guarantees the whole namespace collapses" trick simply
|
||||
doesn't apply there. Per the user's own explicit request (they want to
|
||||
choose the mechanism per host capability, and may need an even more basic
|
||||
one later for some hypothetical older device), `kill_session()` picks
|
||||
between three independently-named strategies, selected dynamically per
|
||||
session (not a single cached host-wide capability flag, since e.g. cgroup
|
||||
creation can fail for session-specific reasons like permissions even on a
|
||||
host that generally supports cgroups) — each runs its own complete
|
||||
`SIGTERM` → wait-up-to-`grace_period_seconds` (10s default, no CLI flag) →
|
||||
forced-`SIGKILL` escalation internally, with no cross-strategy
|
||||
fallback-after-failure chaining:
|
||||
1. `kill_via_cgroup()` — preferred whenever the session has a non-empty
|
||||
dedicated cgroup (`session_cgroup_pids()`, `session_cgroup.h`): `SIGTERM`
|
||||
to every pid currently in it, and, if forcing is needed, either the
|
||||
atomic `cgroup.kill` knob or a fresh re-read-and-`SIGKILL` sweep (fresh,
|
||||
not the original snapshot, since a process could have forked a new child
|
||||
after the graceful sweep but before dying). The **only** mechanism that
|
||||
reliably reaches every process regardless of pid namespace support.
|
||||
**Critical correctness point, caught during design review before this
|
||||
shipped**: the "is it stopped yet" poll must gate on the *cgroup being
|
||||
empty*, not `list_sessions()`'s running flag — that flag only reflects
|
||||
the pid file's flock, released the moment the tracked outer `bwrap` pid
|
||||
exits, and the `SIGTERM` sweep necessarily hits `bwrap` itself too (it's
|
||||
a cgroup member) — `bwrap` dies and gets reaped in well under a second,
|
||||
long before slower descendants (`caddy` shutting down gracefully,
|
||||
`php-fpm` finishing in-flight requests) actually exit. Gating on the pid
|
||||
file instead would make the poll resolve "done" almost immediately, the
|
||||
forced-kill step would never run, and the original bug would reproduce
|
||||
with unused machinery around it.
|
||||
2. `kill_via_pid_namespace()` — used when no cgroup exists for the session,
|
||||
but `resolve_namespace_pid()`/`pid_namespace_isolated()`
|
||||
(`sandbox_process.h`) confirm `--unshare-pid` was genuinely in effect for
|
||||
it. `SIGTERM`s `collect_descendant_pids(ns_pid)` (reliable here
|
||||
specifically because reparenting within a genuinely isolated pid
|
||||
namespace always lands back on that namespace's own pid 1); if forcing
|
||||
is needed, a single `SIGKILL` to `ns_pid` alone is *guaranteed* complete
|
||||
by the kernel itself, independent of whatever the graceful sweep missed.
|
||||
"Stopped" is simply `kill(ns_pid, 0)` failing with `ESRCH`. Verified
|
||||
end-to-end on this project's rootless dev machine (which does support
|
||||
pid namespaces, unlike the user's real target device): a daemonized
|
||||
busybox session running `sh -c 'sleep 300 & exec sleep 300'` (mirroring
|
||||
the daemonize-then-exec shape of the original bug) was fully cleaned up
|
||||
by `-k/--kill`, including the backgrounded child, with no leftover
|
||||
processes, mounts, or layers; a second run using `sh -c 'trap "" TERM;
|
||||
sleep 300'` (ignoring `SIGTERM` entirely) confirmed the forced-`SIGKILL`
|
||||
escalation path too, taking the full 10s grace period before the pid
|
||||
namespace's own collapse-on-kill guarantee cleaned it up regardless.
|
||||
3. `kill_via_tracked_pid()` — fallback when neither of the above applies:
|
||||
signals the tracked `bwrap` pid directly, `SIGTERM` then `SIGKILL`,
|
||||
polling `list_sessions()` for "stopped" since that's the only signal
|
||||
available without a cgroup or an isolated pid namespace to check
|
||||
directly. Exactly today's manual-`kill` behavior — least complete, but
|
||||
always available, and strictly no worse than before this feature
|
||||
existed. This is the path the user's own real target device actually
|
||||
takes today (no cgroup delegation confirmed working there yet; no pid
|
||||
namespace support at all) — a future, even more basic strategy (for some
|
||||
hypothetical still-more-limited device) would slot in here the same way,
|
||||
per the user's own explicit request to keep this extensible.
|
||||
|
||||
`poll_until()`/`sleep_ms()` (`.cpp`-local) use `nanosleep()` in an
|
||||
`EINTR`-retry loop — matching this project's existing direct-POSIX style
|
||||
(`process.cpp` already retries `waitpid()` the same way) — rather than
|
||||
`<thread>`/`<chrono>` (unused anywhere else in this project).
|
||||
- `config_file.{h,cpp}` — `load_config_file()` reads and parses (via libyaml's
|
||||
document API, `<yaml.h>`) the `global` and `volumes` sections of the local YAML
|
||||
config file located by `config_file_path()` (`$XDG_CONFIG_HOME/slocker-lite/config.yaml`,
|
||||
@@ -628,7 +778,7 @@ Build directory is `buildDir/` (already configured).
|
||||
(see `priv_drop_helper.cpp` in "Project state")
|
||||
- Run the executable: `./buildDir/slocker-lite -m <image.tar>` (see `--help` for the
|
||||
full flag list: `-m/--mount`, `-r/--run`, `-u/--umount`, `-c/--cleanup`,
|
||||
`-l/--list-images`, `-i/--inspect`, `-e/--exec`, `-n/--no-nsenter`, `-D/--daemonize`,
|
||||
`-l/--list-images`, `-i/--inspect`, `-e/--exec`, `-k/--kill`, `-n/--no-nsenter`, `-D/--daemonize`,
|
||||
`--user`, `--group`, `--hostname`, `--env`, `--env-file`, `-v/--volume`, `--list-volumes`, `--delete-volume`,
|
||||
`--delete-volume-full`, `--list-processes`, `--clean-processes`, `-t/--test`, `--log-level`,
|
||||
`-h/--help`, `-V/--version`)
|
||||
|
||||
@@ -59,6 +59,7 @@ slocker-lite -c|--cleanup <layer-id>
|
||||
slocker-lite -l|--list-images <directory>
|
||||
slocker-lite -i|--inspect <image.tar>
|
||||
slocker-lite -e|--exec <pid> [-- <command> [args...]]
|
||||
slocker-lite -k|--kill <pid>
|
||||
slocker-lite -v|--volume <name> <directory>
|
||||
slocker-lite --list-volumes
|
||||
slocker-lite --delete-volume <name>
|
||||
@@ -86,6 +87,7 @@ slocker-lite -V|--version
|
||||
| `-l, --list-images <dir>` | List OCI Image Layout tars (`*.tar`, `*.tar.*`) found directly in `<dir>`, with their `name:tag`. |
|
||||
| `-i, --inspect <image.tar>` | Print an image's declared user, exposed ports, env, volumes, and default command, without mounting or running it. |
|
||||
| `-e, --exec <pid>` | Join an already-running `--run` session (`<pid>` must be one `--list-processes` shows as `running`) and run a command inside its container. Pass `-- <command> [args...]` to specify it. |
|
||||
| `-k, --kill <pid>` | Stop a running `--run` session (`<pid>` must be one `--list-processes` shows as `running`): sends `SIGTERM`, waits up to 10s, then forces it with `SIGKILL`. Reaches every process the session started — including daemonized/reparented ones a plain `kill <pid>` would leave behind — via a dedicated cgroup when available, or the sandboxed pid namespace's own collapse-on-kill guarantee when not, falling back to signaling the tracked pid alone if neither applies. |
|
||||
| `-v, --volume <name> <dir>` | 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): `<name>` is an existing named volume, or, if it contains `/`, a host directory path (created if missing); `<dir>` 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 <name>` | Remove a named volume from the config. The host directory is left untouched. |
|
||||
@@ -212,6 +214,22 @@ 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.
|
||||
|
||||
`-k/--kill <pid>` stops a running session and everything it started — not just
|
||||
the tracked `bwrap` process. A plain `kill <pid>` can leave processes behind: a
|
||||
container whose entrypoint daemonizes a service (double-forks and detaches)
|
||||
before `exec`-ing its main command can end up with that service reparented
|
||||
somewhere `bwrap` dying never reaches, especially on a kernel without pid
|
||||
namespace support, where it reparents all the way to the *host's* own pid 1.
|
||||
`-k/--kill` picks between three mechanisms depending on what's actually
|
||||
available for that session: a dedicated cgroup (set up at `-r/--run` time,
|
||||
reliably includes every process the session ever started regardless of
|
||||
daemonizing or pid namespace support — the most complete option, when the
|
||||
kernel and permissions allow it), the sandboxed pid namespace's own
|
||||
collapse-on-kill guarantee (when `--unshare-pid` was genuinely in effect for
|
||||
that session), or, failing both, signaling the tracked process directly (no
|
||||
worse than today's manual `kill`). Either way it sends `SIGTERM` first,
|
||||
waits up to 10 seconds, then escalates to `SIGKILL`.
|
||||
|
||||
`-D/--daemonize` forks and detaches into the background by calling `setsid()`
|
||||
itself, rather than re-enabling `bwrap`'s own `--new-session` — that flag only
|
||||
detaches the deeply-nested sandboxed command, leaving `bwrap`/`nsenter` still
|
||||
|
||||
+2
-1
@@ -20,7 +20,8 @@ slocker_lite = executable('slocker-lite',
|
||||
['src/main.cpp', 'src/cli_args.cpp', 'src/commands.cpp', 'src/self_test.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/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'],
|
||||
include_directories : include_directories('.'),
|
||||
dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep],
|
||||
install : true)
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
#include "pid_file.h"
|
||||
#include "process.h"
|
||||
#include "session_cgroup.h"
|
||||
|
||||
// Locates the priv-drop helper installed next to this process's own binary (found
|
||||
// via /proc/self/exe), which holds whether run from buildDir/ or after a proper
|
||||
@@ -337,10 +338,17 @@ int run_bwrap(const std::string& root, const std::vector<std::string>& command,
|
||||
}
|
||||
|
||||
std::optional<SessionLock> session_lock;
|
||||
std::optional<SessionCgroup> session_cgroup;
|
||||
int exit_code = run_process_foreground(
|
||||
*argv,
|
||||
[&](pid_t pid) {
|
||||
session_lock = create_session_lock(container_name, pid);
|
||||
// Best-effort, like the session lock above -- lets kill_session()
|
||||
// (kill_session.cpp) reach every process this session ever starts,
|
||||
// including anything that later daemonizes/double-forks and gets
|
||||
// reparented, regardless of whether the kernel supports pid
|
||||
// namespaces at all. See session_cgroup.h.
|
||||
session_cgroup = create_session_cgroup(container_name, pid);
|
||||
if (on_bwrap_pid_known) {
|
||||
on_bwrap_pid_known(pid);
|
||||
}
|
||||
@@ -350,6 +358,9 @@ int run_bwrap(const std::string& root, const std::vector<std::string>& command,
|
||||
if (session_lock) {
|
||||
release_session_lock(*session_lock);
|
||||
}
|
||||
if (session_cgroup) {
|
||||
remove_session_cgroup(*session_cgroup);
|
||||
}
|
||||
|
||||
return exit_code;
|
||||
}
|
||||
|
||||
+39
-6
@@ -46,7 +46,7 @@ constexpr int env = 265;
|
||||
constexpr int env_file = 266;
|
||||
} // namespace options
|
||||
|
||||
constexpr std::array<struct option, 25> long_options = {{
|
||||
constexpr std::array<struct option, 26> long_options = {{
|
||||
{"help", no_argument, nullptr, 'h'},
|
||||
{"version", no_argument, nullptr, 'V'},
|
||||
{"test", no_argument, nullptr, 't'},
|
||||
@@ -66,6 +66,7 @@ constexpr std::array<struct option, 25> long_options = {{
|
||||
{"delete-volume-full", required_argument, nullptr, options::delete_volume_full},
|
||||
{"inspect", required_argument, nullptr, 'i'},
|
||||
{"exec", required_argument, nullptr, 'e'},
|
||||
{"kill", required_argument, nullptr, 'k'},
|
||||
{"hostname", required_argument, nullptr, options::hostname},
|
||||
{"list-processes", no_argument, nullptr, options::list_processes},
|
||||
{"clean-processes", no_argument, nullptr, options::clean_processes},
|
||||
@@ -83,6 +84,7 @@ void print_usage(const char* prog) {
|
||||
" {0} -l|--list-images <directory>\n"
|
||||
" {0} -i|--inspect <image.tar>\n"
|
||||
" {0} -e|--exec <pid> [-- <command> [args...]]\n"
|
||||
" {0} -k|--kill <pid>\n"
|
||||
" {0} -v|--volume <name> <directory>\n"
|
||||
" {0} --list-volumes\n"
|
||||
" {0} --delete-volume <name>\n"
|
||||
@@ -151,6 +153,16 @@ void print_usage(const char* prog) {
|
||||
" shown by --list-processes as \"running\") and run\n"
|
||||
" a command inside its container; pass\n"
|
||||
" -- <command> [args...] to specify it\n"
|
||||
" -k, --kill <pid> stop a running --run session (pid must be one\n"
|
||||
" shown by --list-processes as \"running\"): sends\n"
|
||||
" SIGTERM, waits up to 10s, then forces it with\n"
|
||||
" SIGKILL. Reaches every process the session\n"
|
||||
" started -- including daemonized/reparented ones\n"
|
||||
" a plain `kill <pid>` would leave behind -- via a\n"
|
||||
" dedicated cgroup when available, or the sandboxed\n"
|
||||
" pid namespace's own collapse-on-kill guarantee\n"
|
||||
" when not, falling back to signaling the tracked\n"
|
||||
" pid alone if neither applies\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"
|
||||
@@ -193,6 +205,17 @@ void print_version() {
|
||||
PACKAGE, VERSION);
|
||||
}
|
||||
|
||||
// Shared by -e/--exec's exec_pid and -k/--kill's kill_pid -- both are a bare
|
||||
// positive integer, nothing else.
|
||||
std::optional<pid_t> parse_pid_arg(const std::string& text) {
|
||||
char* end = nullptr;
|
||||
long parsed = std::strtol(text.c_str(), &end, 10);
|
||||
if (text.empty() || !end || *end != '\0' || parsed <= 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return static_cast<pid_t>(parsed);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool apply_log_level(std::string_view name) {
|
||||
@@ -211,7 +234,7 @@ bool apply_log_level(std::string_view name) {
|
||||
std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
|
||||
opterr = 0;
|
||||
int opt;
|
||||
while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:v:i:e:D", long_options.data(), nullptr)) != -1) {
|
||||
while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:v:i:e:Dk:", long_options.data(), nullptr)) != -1) {
|
||||
switch (opt) {
|
||||
case 'h':
|
||||
print_usage(argv[0]);
|
||||
@@ -227,6 +250,7 @@ std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
|
||||
case 'l':
|
||||
case 'i':
|
||||
case 'e':
|
||||
case 'k':
|
||||
case options::list_volumes:
|
||||
case options::delete_volume:
|
||||
case options::delete_volume_full:
|
||||
@@ -258,6 +282,9 @@ std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
|
||||
case 'e':
|
||||
requested = Mode::exec;
|
||||
break;
|
||||
case 'k':
|
||||
requested = Mode::kill;
|
||||
break;
|
||||
case options::list_volumes:
|
||||
requested = Mode::list_volumes;
|
||||
break;
|
||||
@@ -376,13 +403,19 @@ std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
|
||||
print_usage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
char* end = nullptr;
|
||||
long parsed = std::strtol(out.mode_arg.c_str(), &end, 10);
|
||||
if (out.mode_arg.empty() || !end || *end != '\0' || parsed <= 0) {
|
||||
out.exec_pid = parse_pid_arg(out.mode_arg);
|
||||
if (!out.exec_pid) {
|
||||
spdlog::error("invalid pid: {}", out.mode_arg);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (out.mode == Mode::kill) {
|
||||
out.kill_pid = parse_pid_arg(out.mode_arg);
|
||||
if (!out.kill_pid) {
|
||||
spdlog::error("invalid pid: {}", out.mode_arg);
|
||||
return 1;
|
||||
}
|
||||
out.exec_pid = static_cast<pid_t>(parsed);
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
|
||||
+4
-1
@@ -41,7 +41,8 @@ enum class Mode {
|
||||
inspect,
|
||||
list_processes,
|
||||
clean_processes,
|
||||
exec
|
||||
exec,
|
||||
kill
|
||||
};
|
||||
|
||||
// Everything parse_args() extracts from argv, ready to hand to
|
||||
@@ -61,6 +62,8 @@ struct ParsedArgs {
|
||||
std::vector<std::string> command;
|
||||
// Parsed and range-validated from mode_arg when mode == Mode::exec.
|
||||
std::optional<pid_t> exec_pid;
|
||||
// Parsed and range-validated from mode_arg when mode == Mode::kill.
|
||||
std::optional<pid_t> kill_pid;
|
||||
};
|
||||
|
||||
// Validates and applies a log-level name (trace/debug/info/warn/error/critical/off)
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
#include "daemonize.h"
|
||||
#include "env_spec.h"
|
||||
#include "exec_session.h"
|
||||
#include "kill_session.h"
|
||||
#include "oci_image.h"
|
||||
#include "pid_file.h"
|
||||
#include "process.h"
|
||||
@@ -526,6 +527,8 @@ int dispatch_command(const ParsedArgs& args, const std::filesystem::path& config
|
||||
return run_self_tests();
|
||||
case Mode::exec:
|
||||
return exec_in_session(*args.exec_pid, args.command, args.user_flag, args.group_flag);
|
||||
case Mode::kill:
|
||||
return kill_session(*args.kill_pid);
|
||||
case Mode::run: {
|
||||
// As root, containers-storage mount doesn't need to reexec into a private
|
||||
// user namespace to gain privilege, so the mount is already directly
|
||||
|
||||
+1
-84
@@ -18,7 +18,6 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
@@ -33,93 +32,11 @@
|
||||
#include "bwrap.h"
|
||||
#include "pid_file.h"
|
||||
#include "process.h"
|
||||
#include "sandbox_process.h"
|
||||
#include "user_spec.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// Fast path: /proc/<pid>/task/<pid>/children lists direct children with no
|
||||
// scanning needed. Requires CONFIG_CHECKPOINT_RESTORE, which not every kernel
|
||||
// enables -- absent (not just unreadable), confirmed by direct testing on a
|
||||
// real Android target, where the file simply doesn't exist.
|
||||
std::optional<pid_t> find_child_via_children_file(pid_t pid) {
|
||||
std::ifstream children(fmt::format("/proc/{}/task/{}/children", pid, pid));
|
||||
pid_t child = 0;
|
||||
if (children >> child && child > 0) {
|
||||
return child;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Portable fallback for kernels without the children file: scans /proc/<n>/stat
|
||||
// for any process whose ppid field (the first whitespace-separated field after
|
||||
// comm's closing ')' -- comm itself is parenthesized and may contain spaces or
|
||||
// parens, so it can't just be split on whitespace) equals `pid`. This is the
|
||||
// same information `pstree` itself reads to build its tree -- confirmed by
|
||||
// direct testing: `pstree -p <bwrap_pid>` found the real sandboxed child on a
|
||||
// device where the children file was missing. Picks the lowest matching pid if
|
||||
// more than one child exists, for a deterministic result.
|
||||
std::optional<pid_t> find_child_by_scanning_proc(pid_t pid) {
|
||||
std::error_code ec;
|
||||
std::optional<pid_t> found;
|
||||
for (const auto& entry : std::filesystem::directory_iterator("/proc", ec)) {
|
||||
if (ec) {
|
||||
break;
|
||||
}
|
||||
const std::string name = entry.path().filename().string();
|
||||
if (name.empty() || !std::all_of(name.begin(), name.end(),
|
||||
[](unsigned char c) { return std::isdigit(c) != 0; })) {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::ifstream stat_file(entry.path() / "stat");
|
||||
std::string line;
|
||||
if (!std::getline(stat_file, line)) {
|
||||
continue;
|
||||
}
|
||||
auto close_paren = line.rfind(')');
|
||||
if (close_paren == std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
std::istringstream rest(line.substr(close_paren + 1));
|
||||
std::string state;
|
||||
pid_t ppid = 0;
|
||||
if (!(rest >> state >> ppid) || ppid != pid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pid_t candidate = std::stoi(name);
|
||||
if (!found || candidate < *found) {
|
||||
found = candidate;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (auto child = find_child_via_children_file(pid)) {
|
||||
return *child;
|
||||
}
|
||||
if (auto child = find_child_by_scanning_proc(pid)) {
|
||||
spdlog::debug("pid {}: found sandboxed child {} by scanning /proc (no .../task/.../children file)",
|
||||
pid, *child);
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// 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 "kill_session.h"
|
||||
|
||||
#include <time.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <csignal>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include <fmt/core.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#include "pid_file.h"
|
||||
#include "sandbox_process.h"
|
||||
#include "session_cgroup.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// EINTR-retry loop, matching this project's existing direct-POSIX style
|
||||
// (process.cpp already retries waitpid() the same way) rather than pulling in
|
||||
// <thread>/<chrono> (unused anywhere else in this project) for a single
|
||||
// sleep.
|
||||
void sleep_ms(int ms) {
|
||||
struct timespec ts{ms / 1000, static_cast<long>(ms % 1000) * 1000000L};
|
||||
while (nanosleep(&ts, &ts) == -1 && errno == EINTR) {
|
||||
}
|
||||
}
|
||||
|
||||
// Polls `done` every `interval_ms` until it returns true or `timeout_ms`
|
||||
// elapses. Returns whatever `done` last reported.
|
||||
bool poll_until(const std::function<bool()>& done, int timeout_ms, int interval_ms) {
|
||||
for (int waited = 0; waited < timeout_ms; waited += interval_ms) {
|
||||
if (done()) {
|
||||
return true;
|
||||
}
|
||||
sleep_ms(interval_ms);
|
||||
}
|
||||
return done();
|
||||
}
|
||||
|
||||
// Strategy 1: preferred whenever the session has a non-empty dedicated
|
||||
// cgroup. Most complete -- reaches every process the session ever started,
|
||||
// daemonized/reparented or not, regardless of pid namespace support.
|
||||
bool kill_via_cgroup(const SessionCgroup& cgroup, int grace_period_seconds) {
|
||||
for (pid_t t : session_cgroup_pids(cgroup)) {
|
||||
kill(t, SIGTERM);
|
||||
}
|
||||
|
||||
auto is_stopped = [&] { return session_cgroup_pids(cgroup).empty(); };
|
||||
if (poll_until(is_stopped, grace_period_seconds * 1000, 200)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Re-read cgroup.procs fresh here (not the earlier SIGTERM-sweep
|
||||
// snapshot) -- a process that forked a new child after the sweep but
|
||||
// before dying would otherwise be missed entirely.
|
||||
if (!(session_cgroup_supports_kill(cgroup) && kill_session_cgroup(cgroup))) {
|
||||
for (pid_t t : session_cgroup_pids(cgroup)) {
|
||||
kill(t, SIGKILL);
|
||||
}
|
||||
}
|
||||
return poll_until(is_stopped, 2000, 200);
|
||||
}
|
||||
|
||||
// Strategy 2: used when no cgroup exists for the session, but --unshare-pid
|
||||
// was genuinely in effect (the sandboxed child is actually isolated in its
|
||||
// own pid namespace). Reliable because reparenting within an isolated pid
|
||||
// namespace always lands back on that namespace's own pid 1 (`ns_pid`
|
||||
// itself) -- collect_descendant_pids() still finds everything for the
|
||||
// graceful sweep, and the final SIGKILL is *guaranteed* complete by the
|
||||
// kernel itself (a pid namespace's pid 1 dying forcibly kills every
|
||||
// remaining process in it), independent of whatever the graceful sweep
|
||||
// missed.
|
||||
bool kill_via_pid_namespace(pid_t ns_pid, int grace_period_seconds) {
|
||||
for (pid_t t : collect_descendant_pids(ns_pid)) {
|
||||
kill(t, SIGTERM);
|
||||
}
|
||||
|
||||
auto is_stopped = [&] { return kill(ns_pid, 0) != 0; }; // ESRCH once gone
|
||||
if (poll_until(is_stopped, grace_period_seconds * 1000, 200)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
kill(ns_pid, SIGKILL);
|
||||
return poll_until(is_stopped, 2000, 200);
|
||||
}
|
||||
|
||||
// Strategy 3: fallback, always available, least complete -- exactly today's
|
||||
// manual `kill <tracked_pid>` behavior. The only "stopped" signal available
|
||||
// here is the pid file itself (list_sessions()), since there's neither a
|
||||
// cgroup nor an isolated pid namespace to check directly.
|
||||
bool kill_via_tracked_pid(pid_t pid, int grace_period_seconds) {
|
||||
kill(pid, SIGTERM);
|
||||
|
||||
auto is_stopped = [&] {
|
||||
auto sessions = list_sessions();
|
||||
return std::none_of(sessions.begin(), sessions.end(),
|
||||
[&](const SessionInfo& s) { return s.pid == pid && s.running; });
|
||||
};
|
||||
if (poll_until(is_stopped, grace_period_seconds * 1000, 200)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
kill(pid, SIGKILL);
|
||||
return poll_until(is_stopped, 2000, 200);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int kill_session(pid_t pid, int grace_period_seconds) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Strategy selection happens once, up front: whichever is picked runs its
|
||||
// own complete SIGTERM -> wait -> forced-kill escalation internally --
|
||||
// there's no cross-strategy fallback-after-failure chaining, since if the
|
||||
// best-available strategy's own forced step doesn't finish the job, a
|
||||
// weaker one wouldn't either.
|
||||
SessionCgroup cgroup{session_cgroup_path(it->container_name, pid)};
|
||||
bool stopped;
|
||||
std::string_view method;
|
||||
|
||||
if (std::filesystem::is_directory(cgroup.path) && !session_cgroup_pids(cgroup).empty()) {
|
||||
method = "cgroup";
|
||||
stopped = kill_via_cgroup(cgroup, grace_period_seconds);
|
||||
} else if (pid_t ns_pid = resolve_namespace_pid(pid); ns_pid != pid && pid_namespace_isolated(pid, ns_pid)) {
|
||||
method = "pid namespace";
|
||||
stopped = kill_via_pid_namespace(ns_pid, grace_period_seconds);
|
||||
} else {
|
||||
method = "tracked process only";
|
||||
stopped = kill_via_tracked_pid(pid, grace_period_seconds);
|
||||
}
|
||||
spdlog::debug("stopping session '{}' (pid {}) via {}: {}", it->container_name, pid, method, stopped);
|
||||
|
||||
if (stopped) {
|
||||
fmt::print("session '{}' (pid {}) stopped\n", it->container_name, pid);
|
||||
return 0;
|
||||
}
|
||||
spdlog::error("session '{}' (pid {}) could not be fully stopped", it->container_name, pid);
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// 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 <sys/types.h>
|
||||
|
||||
// Stops a tracked, running -r/--run session identified by `pid` (the same
|
||||
// tracked pid --list-processes/-e/--exec use) -- see CLAUDE.md's
|
||||
// kill_session.{h,cpp} entry for the three strategies this picks between,
|
||||
// depending on what's actually available for that session (a dedicated
|
||||
// cgroup, an isolated pid namespace, or neither). Sends SIGTERM first, waits
|
||||
// up to `grace_period_seconds` for the session to actually stop, then forces
|
||||
// it with SIGKILL if it's still there. Returns 0 once confirmed stopped, or 1
|
||||
// if `pid` isn't a tracked/running session (logged with spdlog::error) or it
|
||||
// still hadn't stopped a few seconds after the forced kill (also logged with
|
||||
// spdlog::error).
|
||||
int kill_session(pid_t pid, int grace_period_seconds = 10);
|
||||
+2
-2
@@ -29,8 +29,6 @@
|
||||
#include <fmt/core.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
namespace {
|
||||
|
||||
std::string sanitize_for_filename(std::string_view name) {
|
||||
std::string result;
|
||||
result.reserve(name.size());
|
||||
@@ -44,6 +42,8 @@ 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;
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
// Replaces every character outside [A-Za-z0-9._-] in `name` with '_' (image
|
||||
// names/tags can contain '/' (registry paths) or ':'), falling back to
|
||||
// "container" if that leaves nothing. Shared with session_cgroup.{h,cpp},
|
||||
// which needs the exact same <name>-<pid> naming rule for its own per-session
|
||||
// cgroup directory, so the two don't drift apart.
|
||||
std::string sanitize_for_filename(std::string_view name);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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 "sandbox_process.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
|
||||
#include <fmt/core.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
namespace {
|
||||
|
||||
// Single /proc pass building a ppid -> direct-children map, shared by
|
||||
// find_child_by_scanning_proc() and collect_descendant_pids() below so the
|
||||
// (identical) stat-parsing work only happens once per call site.
|
||||
std::map<pid_t, std::vector<pid_t>> build_ppid_map() {
|
||||
std::map<pid_t, std::vector<pid_t>> children_of;
|
||||
|
||||
std::error_code ec;
|
||||
for (const auto& entry : std::filesystem::directory_iterator("/proc", ec)) {
|
||||
if (ec) {
|
||||
break;
|
||||
}
|
||||
const std::string name = entry.path().filename().string();
|
||||
if (name.empty() || !std::all_of(name.begin(), name.end(),
|
||||
[](unsigned char c) { return std::isdigit(c) != 0; })) {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::ifstream stat_file(entry.path() / "stat");
|
||||
std::string line;
|
||||
if (!std::getline(stat_file, line)) {
|
||||
continue;
|
||||
}
|
||||
// comm field is parenthesized and can contain spaces/parens itself;
|
||||
// ppid is the first whitespace-separated field after the closing ')'.
|
||||
auto close_paren = line.rfind(')');
|
||||
if (close_paren == std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
std::istringstream rest(line.substr(close_paren + 1));
|
||||
std::string state;
|
||||
pid_t ppid = 0;
|
||||
if (!(rest >> state >> ppid)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
children_of[ppid].push_back(std::stoi(name));
|
||||
}
|
||||
return children_of;
|
||||
}
|
||||
|
||||
// Fast path: /proc/<pid>/task/<pid>/children lists direct children with no
|
||||
// scanning needed. Requires CONFIG_CHECKPOINT_RESTORE, which not every kernel
|
||||
// enables -- absent (not just unreadable), confirmed by direct testing on a
|
||||
// real Android target, where the file simply doesn't exist.
|
||||
std::optional<pid_t> find_child_via_children_file(pid_t pid) {
|
||||
std::ifstream children(fmt::format("/proc/{}/task/{}/children", pid, pid));
|
||||
pid_t child = 0;
|
||||
if (children >> child && child > 0) {
|
||||
return child;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Portable fallback for kernels without the children file: the same
|
||||
// information pstree itself reads to build its tree. Picks the lowest
|
||||
// matching pid if more than one child exists, for a deterministic result.
|
||||
std::optional<pid_t> find_child_by_scanning_proc(pid_t pid) {
|
||||
auto children_of = build_ppid_map();
|
||||
auto it = children_of.find(pid);
|
||||
if (it == children_of.end() || it->second.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return *std::min_element(it->second.begin(), it->second.end());
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
pid_t resolve_namespace_pid(pid_t pid) {
|
||||
if (auto child = find_child_via_children_file(pid)) {
|
||||
return *child;
|
||||
}
|
||||
if (auto child = find_child_by_scanning_proc(pid)) {
|
||||
spdlog::debug("pid {}: found sandboxed child {} by scanning /proc (no .../task/.../children file)",
|
||||
pid, *child);
|
||||
return *child;
|
||||
}
|
||||
spdlog::debug("could not determine pid {}'s sandboxed child process; joining its own namespaces only",
|
||||
pid);
|
||||
return pid;
|
||||
}
|
||||
|
||||
bool pid_namespace_isolated(pid_t outer_pid, pid_t ns_pid) {
|
||||
auto outer_ns = read_ns_link(fmt::format("/proc/{}/ns/pid", outer_pid));
|
||||
auto inner_ns = read_ns_link(fmt::format("/proc/{}/ns/pid", ns_pid));
|
||||
return outer_ns && inner_ns && *outer_ns != *inner_ns;
|
||||
}
|
||||
|
||||
std::vector<pid_t> collect_descendant_pids(pid_t root) {
|
||||
auto children_of = build_ppid_map();
|
||||
|
||||
std::vector<pid_t> result = {root};
|
||||
for (size_t i = 0; i < result.size(); ++i) {
|
||||
auto it = children_of.find(result[i]);
|
||||
if (it != children_of.end()) {
|
||||
result.insert(result.end(), it->second.begin(), it->second.end());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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 <sys/types.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
// Finds the real sandboxed child of bwrap's own outer/tracked pid `pid` --
|
||||
// bwrap sets up the mount/user namespaces itself, then clone()s the actual
|
||||
// sandboxed command into fresh pid/uts/ipc/cgroup namespaces, 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 the
|
||||
// caller's own, while only mnt/user differed for the outer process). Prefers
|
||||
// /proc/<pid>/task/<pid>/children (the direct-children list procfs exposes);
|
||||
// falls back to scanning /proc/<n>/stat for a process whose ppid is `pid` --
|
||||
// the same information pstree itself reads -- when that file doesn't exist
|
||||
// (requires CONFIG_CHECKPOINT_RESTORE, absent on some kernels, confirmed
|
||||
// missing on a real Android target). Falls back to `pid` itself (best-effort,
|
||||
// not fatal) if no child can be found either way. Shared by exec_in_session()
|
||||
// (exec_session.h) and kill_session() (kill_session.h).
|
||||
pid_t resolve_namespace_pid(pid_t pid);
|
||||
|
||||
// True if `ns_pid` is in a different pid namespace than `outer_pid` -- i.e.
|
||||
// bwrap's clone() actually created a separate pid namespace for its child
|
||||
// (--unshare-pid was requested by build_bwrap_args() and the kernel supported
|
||||
// it). This is the precondition for the kernel's own guarantee that killing a
|
||||
// pid namespace's pid 1 forcibly tears down every remaining process in it --
|
||||
// used by kill_session() (kill_session.h) to decide whether that mechanism
|
||||
// actually applies to a given session (on a kernel without pid namespace
|
||||
// support -- e.g. this project's real stock-Android target -- it never does,
|
||||
// since a daemonized process there reparents to the *host's* pid 1 instead).
|
||||
bool pid_namespace_isolated(pid_t outer_pid, pid_t ns_pid);
|
||||
|
||||
// Every transitive descendant of `root` (root included), found via a single
|
||||
// /proc/<n>/stat pass matching ppid chains back to `root` -- the same
|
||||
// mechanism resolve_namespace_pid()'s own fallback uses, generalized to
|
||||
// collect the whole tree instead of just one child. Reliable specifically
|
||||
// when `root` is a genuinely isolated pid namespace's own pid 1: anything
|
||||
// that reparents within it (e.g. a daemonizing service) is guaranteed by the
|
||||
// kernel to land back on `root` itself, so nothing escapes the tree the way
|
||||
// it would on a kernel without pid namespace support. Used by kill_session()
|
||||
// (kill_session.h) to send a best-effort graceful signal to everything in a
|
||||
// session's pid namespace, not just its own pid 1.
|
||||
std::vector<pid_t> collect_descendant_pids(pid_t root);
|
||||
@@ -0,0 +1,101 @@
|
||||
// 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 "session_cgroup.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
|
||||
#include <fmt/core.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#include "pid_file.h"
|
||||
|
||||
namespace {
|
||||
constexpr const char* cgroup_root = "/sys/fs/cgroup/slocker-lite";
|
||||
} // namespace
|
||||
|
||||
bool cgroup_v2_available() {
|
||||
std::error_code ec;
|
||||
return std::filesystem::exists("/sys/fs/cgroup/cgroup.controllers", ec);
|
||||
}
|
||||
|
||||
std::filesystem::path session_cgroup_path(std::string_view container_name, pid_t pid) {
|
||||
return std::filesystem::path(cgroup_root) / fmt::format("{}-{}", sanitize_for_filename(container_name), pid);
|
||||
}
|
||||
|
||||
std::optional<SessionCgroup> create_session_cgroup(std::string_view container_name, pid_t pid) {
|
||||
if (!cgroup_v2_available()) {
|
||||
spdlog::debug(
|
||||
"cgroup v2 not available; -k/--kill will fall back to a less complete method for this session");
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
auto path = session_cgroup_path(container_name, pid);
|
||||
|
||||
// create_directories() also creates cgroup_root itself the first time --
|
||||
// a plain grouping cgroup, no controllers ever enabled on it via
|
||||
// cgroup.subtree_control, so it never carries the "no internal processes"
|
||||
// restriction that applies once a parent actually delegates controllers.
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(path, ec);
|
||||
if (ec) {
|
||||
spdlog::warn("failed to create session cgroup {}: {} (no delegated subtree when running rootless, "
|
||||
"or an SELinux policy blocking cgroupfs writes, are both common causes)",
|
||||
path.string(), ec.message());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::ofstream procs(path / "cgroup.procs");
|
||||
if (!(procs << pid << "\n")) {
|
||||
spdlog::warn("failed to move pid {} into session cgroup {}", pid, path.string());
|
||||
std::filesystem::remove(path, ec);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return SessionCgroup{path};
|
||||
}
|
||||
|
||||
void remove_session_cgroup(const SessionCgroup& cgroup) {
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(cgroup.path, ec);
|
||||
if (ec) {
|
||||
spdlog::warn("failed to remove session cgroup {}: {} (a process it started may have outlived the "
|
||||
"session's own main command)",
|
||||
cgroup.path.string(), ec.message());
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<pid_t> session_cgroup_pids(const SessionCgroup& cgroup) {
|
||||
std::vector<pid_t> pids;
|
||||
std::ifstream procs(cgroup.path / "cgroup.procs");
|
||||
pid_t pid = 0;
|
||||
while (procs >> pid) {
|
||||
pids.push_back(pid);
|
||||
}
|
||||
return pids;
|
||||
}
|
||||
|
||||
bool session_cgroup_supports_kill(const SessionCgroup& cgroup) {
|
||||
std::error_code ec;
|
||||
return std::filesystem::exists(cgroup.path / "cgroup.kill", ec);
|
||||
}
|
||||
|
||||
bool kill_session_cgroup(const SessionCgroup& cgroup) {
|
||||
std::ofstream kill_file(cgroup.path / "cgroup.kill");
|
||||
return static_cast<bool>(kill_file << "1");
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// 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 <optional>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
// True if /sys/fs/cgroup/cgroup.controllers exists -- the standard signal
|
||||
// (systemd's own unified-hierarchy detection works the same way) that this is
|
||||
// an actual cgroup v2 unified hierarchy mount, not v1 (which splits per
|
||||
// controller into e.g. /sys/fs/cgroup/cpu, /sys/fs/cgroup/memory, with no
|
||||
// unified cgroup.controllers/cgroup.procs at the top) or nothing mounted at
|
||||
// all. Only cgroup v2 is supported here -- cgroup v1 is scoped out, since it
|
||||
// would require moving a pid into multiple separate per-controller
|
||||
// hierarchies for the same tracking purpose, and this project's real target
|
||||
// (Android) has used the unified v2 hierarchy by default since Android 12.
|
||||
bool cgroup_v2_available();
|
||||
|
||||
// Deterministic path -- /sys/fs/cgroup/slocker-lite/<sanitized container_name>-<pid>/,
|
||||
// reusing pid_file.h's own sanitize_for_filename() so this never drifts from
|
||||
// the exact same naming rule the pid file itself already uses. No separate
|
||||
// lookup state needed: kill_session() (kill_session.h) derives this directly
|
||||
// from the same (container_name, pid) list_sessions() already reports.
|
||||
std::filesystem::path session_cgroup_path(std::string_view container_name, pid_t pid);
|
||||
|
||||
struct SessionCgroup {
|
||||
std::filesystem::path path;
|
||||
};
|
||||
|
||||
// Creates the session's own dedicated cgroup (a plain grouping leaf -- no
|
||||
// resource controllers are ever enabled on it via cgroup.subtree_control, so
|
||||
// the "no internal processes" constraint that applies once a parent cgroup
|
||||
// delegates real controllers never comes into play here) and moves `pid` into
|
||||
// it by writing to its cgroup.procs. Every process `pid` (or anything it later
|
||||
// execs into) forks from this point on inherits this cgroup automatically,
|
||||
// permanently -- including anything that later daemonizes/double-forks and
|
||||
// gets reparented, unlike pid-namespace child membership or process-group
|
||||
// membership, neither of which survives that.
|
||||
//
|
||||
// Best-effort, mirroring create_session_lock() (pid_file.h): returns nullopt
|
||||
// (logging a warning, never fatal -- session tracking must never block or
|
||||
// fail -r/--run itself) if cgroup v2 isn't available, or the directory can't
|
||||
// be created/written (e.g. no delegated subtree when running rootless, or an
|
||||
// SELinux policy blocking cgroupfs writes even for a root-euid process).
|
||||
std::optional<SessionCgroup> create_session_cgroup(std::string_view container_name, pid_t pid);
|
||||
|
||||
// Removes the cgroup directory -- only possible once it's empty (no live
|
||||
// processes left in it). Best-effort, mirroring release_session_lock(): a
|
||||
// straggler process still alive (e.g. a natural, non-killed exit where a
|
||||
// daemonized process outlived the main sandboxed command -- a pre-existing
|
||||
// exposure independent of this feature) makes the underlying rmdir() fail
|
||||
// with ENOTEMPTY; just warn and leave the directory in place.
|
||||
void remove_session_cgroup(const SessionCgroup& cgroup);
|
||||
|
||||
// Every live pid currently in the cgroup (its cgroup.procs, one pid per
|
||||
// line), or empty if the cgroup doesn't exist or can't be read. This is what
|
||||
// actually answers "gather all the processes running inside the container":
|
||||
// unlike anything derived from /proc parent-pid chains or pid namespaces,
|
||||
// cgroup membership reliably includes every process the session ever
|
||||
// started, however deeply forked/daemonized/reparented.
|
||||
std::vector<pid_t> session_cgroup_pids(const SessionCgroup& cgroup);
|
||||
|
||||
// Whether this cgroup has a cgroup.kill knob (Linux 5.14+).
|
||||
bool session_cgroup_supports_kill(const SessionCgroup& cgroup);
|
||||
|
||||
// Writes "1" to the cgroup's own cgroup.kill, atomically SIGKILLing every
|
||||
// process currently in it. Returns false (not fatal -- callers fall back to
|
||||
// signaling session_cgroup_pids() directly) if cgroup.kill isn't present or
|
||||
// the write fails.
|
||||
bool kill_session_cgroup(const SessionCgroup& cgroup);
|
||||
Reference in New Issue
Block a user