c70abb56a9
Adds six global.unshare-{user,ipc,pid,net,uts,cgroup} config-file keys
(1/on/yes/true or 0/off/no/false, case-insensitive, default on) that
gate whether -r/--run requests each bwrap --unshare-xxx flag when the
kernel also supports it. Replaces the previous hardcoded skip of
--unshare-net, which is now policy-driven like the other five types
and defaults to enabled -- preparation for real network namespace
isolation (slirp4netns) next, on this same branch.
848 lines
62 KiB
Markdown
848 lines
62 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
## Project state
|
|
|
|
`slocker-lite` (C++20, built with Meson) mounts an OCI Image Layout tar (`oci-layout` +
|
|
`index.json` + `blobs/sha256/*`, as produced by `skopeo`/`podman save --format
|
|
oci-archive`/modern `docker save`) using `containers-storage` and `fuse-overlayfs`, then
|
|
(via `-r/--run`) runs a sandboxed command against it with `bwrap`. The real deployment
|
|
target is Android with a stock kernel, where `podman`/`docker` don't run (missing
|
|
namespace support) and there's no kernel overlayfs (hence `fuse-overlayfs`); `bwrap` is
|
|
invoked in "degraded mode" using only whichever `--unshare-xxx` namespaces the running
|
|
kernel actually supports. See `README.md` for the human-facing overview (build/usage/
|
|
status); this file stays the dense, file-by-file reference. Still early-stage.
|
|
|
|
Source layout (all under `src/`):
|
|
- `main.cpp` — the CLI entry point only, and deliberately tiny (~30 lines):
|
|
loads the config file, applies its `global.log-level` (`apply_log_level()`,
|
|
`cli_args.h`) before CLI parsing so an explicit `--log-level` can still
|
|
override it afterward, calls `parse_args()` (`cli_args.h`) and returns its
|
|
exit code immediately if it gives one (covers `-h`/`-V` and every parse
|
|
error), otherwise calls `dispatch_command()` (`commands.h`) and returns its
|
|
result. All of the actual option-parsing and command logic that used to live
|
|
here moved out into `cli_args.{h,cpp}`/`commands.{h,cpp}`/`self_test.{h,cpp}`
|
|
(see below) specifically to keep this file from re-growing into a dumping
|
|
ground as more commands (docker-compose support, etc.) get added.
|
|
- `cli_args.{h,cpp}` — command-line parsing only, nothing else. `Mode` (the
|
|
enum of every CLI action) and `ParsedArgs` (everything `parse_args()`
|
|
extracts from `argv`) live in the header since `commands.h`'s
|
|
`dispatch_command()` consumes them; `print_usage()`/`print_version()`, the
|
|
`options` namespace of getopt long-option codes, and the `long_options`
|
|
array itself are `.cpp`-local. `parse_args(argc, argv, out)` runs the
|
|
`getopt_long` loop plus all of the post-loop validation that used to live at
|
|
the top of `main()`: mode/`--volume` interaction (`-v` alone vs. combined
|
|
with `-r`, see below), `--group` requires `--user`, no leftover positional
|
|
args outside `-r`/`-e`, and (moved here from what used to be inline in the
|
|
`Mode::exec` dispatch arm) `-x/--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).
|
|
`--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 `-x/--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
|
|
'D':`, same pattern as `-n/--no-nsenter`). `--hostname <name>`/`--env
|
|
VAR=VALUE`/`--env-file <file>` (all long-option only, `--env`/`--env-file`
|
|
both repeatable) are collected here into `ParsedArgs::hostname_flag`/
|
|
`env_specs` — `--env` pushes `{false, optarg}`, `--env-file` pushes `{true,
|
|
optarg}` into the *same* ordered `std::vector<EnvSpec>` (not two separate
|
|
lists), preserving their exact relative command-line order across both
|
|
flags, since `resolve_env_specs()` (`env_spec.{h,cpp}`, see below) needs
|
|
that order to let a later one override an earlier one for the same variable
|
|
name — actually resolving them happens later, in `commands.cpp`'s
|
|
`run_container()`. `-v/--volume` is dual-purpose: used alone it's a
|
|
standalone `Mode::volume` request; combined with `-r/--run` it's repeatable
|
|
and requests a volume mount instead (resolved later by
|
|
`resolve_volume_mount()`, `volume_mount.{h,cpp}`, see below). Since `-v`
|
|
must be repeatable with `-r` but each occurrence still takes two
|
|
space-separated tokens, the getopt loop doesn't let `'v'` set `Mode`
|
|
directly: it accumulates `(spec, path)` pairs into `ParsedArgs::volume_specs`
|
|
(consuming the second token manually, with a guard against swallowing the
|
|
next flag if there isn't one), and only *after* the loop decides whether
|
|
that means one standalone `Mode::volume` call or, together with `-r`,
|
|
passes `volume_specs` through unresolved for `dispatch_command()`/
|
|
`run_container()` to handle.
|
|
- `commands.{h,cpp}` — every command's implementation, plus the dispatcher.
|
|
`dispatch_command(args, config_path, config)` (the only externally-linked
|
|
function; everything else in this file is `.cpp`-local) is a `switch
|
|
(args.mode)` with **one explicit `case` per `Mode` enumerator and no
|
|
`default:`**, so `-Wswitch` (this project builds at `warning_level=3`)
|
|
forces a compile warning/error if a future `Mode` value is ever added
|
|
without a matching dispatch case, instead of silently falling through to
|
|
the wrong command — confirmed by testing (temporarily adding an unhandled
|
|
enumerator triggered exactly the expected `-Wswitch` warning). `Mode::mount`
|
|
has its own explicit case (`mount_command()`) for the same reason: it used
|
|
to be handled only by *falling off the end* of a long `if`/`else` chain in
|
|
`main()` with no explicit check at all — the very kind of implicit,
|
|
easy-to-silently-break behavior this dispatcher redesign exists to close
|
|
off, especially with more `Mode` values (docker-compose support) expected
|
|
soon. `list_processes_command()` implements `--list-processes` (long-option
|
|
only): calls `list_sessions()` (`pid_file.{h,cpp}`, see below) and prints
|
|
one tab-aligned `pid`, `container name`, `running`/`exited` row per entry
|
|
(same two-column tab-alignment scheme as `list_images_command()`/
|
|
`list_volumes_command()`, extended to a third column), no header row, silent
|
|
success on an empty list. `clean_processes_command()` implements
|
|
`--clean-processes` (also long-option only): calls `clean_stale_sessions()`
|
|
(`pid_file.{h,cpp}`) and prints one `removed stale pid file for '<name>' (pid
|
|
<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. `Mode::exec`'s
|
|
dispatch case is a one-line call to `exec_in_session(*args.exec_pid,
|
|
args.command)` (`exec_session.{h,cpp}`, see below) — the pid/command
|
|
parsing and validation now happens in `cli_args.cpp`'s `parse_args()`
|
|
instead (see above).
|
|
`inspect_image_command()` implements `-i/--inspect
|
|
<image.tar>`: prints every `OciImageConfig` field (user/group, exposed ports, env,
|
|
volumes, default command) without mounting or running the image — extend it
|
|
whenever `OciImageConfig` gains a new field (see `oci_image.{h,cpp}` below).
|
|
`run_container()` unconditionally calls `read_oci_image_config()` and reuses the
|
|
result for two independent defaults: the command to run (`Entrypoint ++ Cmd`) when
|
|
none is given on the command line, and, when `--user` wasn't given, the sandboxed
|
|
process's user/group (`config.User`, split into `OciImageConfig::user`/`group`) —
|
|
an explicit `--user`/`--group` on the command line always takes precedence.
|
|
`create_volume_command()` implements `-v/--volume <name> <directory>`;
|
|
`list_volumes_command()` implements `--list-volumes` (same tab-alignment scheme as
|
|
`list_images_command()`, reused as-is); `delete_volume_command()` implements both
|
|
`--delete-volume <name>` (config entry only) and `--delete-volume-full <name>`
|
|
(also `std::filesystem::remove_all()`s the host directory — errors out before
|
|
touching the config if that fails, warns instead of failing if the directory was
|
|
already gone) — see `config_file.{h,cpp}` below for what a "volume" means here (a
|
|
distinct concept from `OciImageConfig::volumes`). `dispatch_command()`'s
|
|
`Mode::volume`/`Mode::delete_volume`/`Mode::delete_volume_full` cases call these.
|
|
`run_container()` (the `Mode::run` dispatch case) resolves each `-v` spec
|
|
(erroring out, `ok = false`, same as a failed `--user` resolution — `bwrap`
|
|
is skipped but unmount/cleanup still runs) into a `ResolvedVolumeMount`,
|
|
rejecting a duplicate or non-absolute container path first, and passes the
|
|
resolved list to `run_bwrap()`. `--hostname <name>` is likewise threaded
|
|
straight through `run_container()` into `run_bwrap()`/`build_bwrap_args()`
|
|
(`bwrap.{h,cpp}`) — see there for how/when it actually takes effect.
|
|
`run_container()` also derives a `container_name` for the session-tracking
|
|
pid file (see `pid_file.{h,cpp}` below): `read_image_ref()`
|
|
(`oci_image.{h,cpp}`) applied to the single image tar being run, formatted
|
|
as `name:tag`, falling back to the tar's own filename stem if
|
|
`read_image_ref()` can't determine one — passed through to `run_bwrap()`
|
|
alongside everything else. `--env`/`--env-file`'s already-ordered
|
|
`env_specs` (see `cli_args.{h,cpp}` above) are resolved here, once, via
|
|
`resolve_env_specs()` (`env_spec.{h,cpp}`, see below) — same `ok =
|
|
false`-on-failure pattern as volume/user resolution — and the resolved list
|
|
is passed to `run_bwrap()` as `extra_env`. `-D/--daemonize`'s
|
|
`daemonize_flag` is also consumed here: `run_container()` computes
|
|
`container_name` *before* `mount_image()` (needed so `daemonize()` below can
|
|
use the real container name for the log file from its very first line, not
|
|
just after a later rename) and, if daemonizing, calls
|
|
`daemonize(container_name)` (`daemonize.{h,cpp}`, see below) immediately
|
|
after: a returned value means this is the original (parent) process (or a
|
|
hard daemonize failure) — print it and `return` right away; `nullopt` means
|
|
this is the now-detached child, which falls through into the rest of
|
|
`run_container()`'s existing body completely unchanged, including the
|
|
unmount/cleanup that already runs after `run_bwrap()` returns (no separate
|
|
watcher/reaper — the daemonized child *is* what runs the whole session,
|
|
start to finish).
|
|
- `self_test.{h,cpp}` — `run_self_tests()` implements `-t/--test`, this
|
|
project's own built-in self-test mode (distinct from the Meson-driven
|
|
fixture smoke test under `tests/`, described in "Build & test commands"
|
|
below). Currently just reports `detect_bwrap_unshare_args()`'s output
|
|
(`bwrap.{h,cpp}`); deliberately its own small file since real tests are
|
|
expected here soon.
|
|
- `env_spec.{h,cpp}` — `resolve_env_specs()` turns an ordered list of
|
|
`EnvSpec {is_file, value}` (see `cli_args.{h,cpp}` above) into a flat, ordered list of
|
|
`(key, value)` pairs. A literal (`--env`) is split at its *first* `=` (the
|
|
value may itself contain `=`; the key must be non-empty). A file (`--env-file`)
|
|
is read line by line: blank/whitespace-only lines and lines whose first
|
|
non-whitespace character is `#` are skipped (comments), with a trailing `\r`
|
|
stripped first for CRLF files; every other line is parsed the same way as a
|
|
literal. Logs a specific error and returns `nullopt` on the first hard failure
|
|
(malformed line, empty key, or an unreadable file) — deliberately stops at the
|
|
first line, not "skip and warn", since an env file with a typo should fail
|
|
loudly rather than silently omit a variable a container might depend on.
|
|
`build_sandbox_env()` (`bwrap.cpp`, see below) appends the resolved list after
|
|
its own built-in `PATH`/`HOME`/`PWD`/`TERM` — no deduplication needed there,
|
|
since `run_process_foreground()`'s own `setenv(..., 1)` loop already lets the
|
|
later occurrence in iteration order win for a repeated key, so an explicit
|
|
`--env PATH=...` still overrides the default.
|
|
- `oci_image.{h,cpp}` — validates/parses the OCI Image Layout tar (libarchive +
|
|
nlohmann_json) and extracts layer blobs. `list_oci_images()` scans a directory
|
|
(non-recursively) for `*.tar`/`*.tar.*` files and, for each valid OCI archive,
|
|
derives an image name/tag via `read_image_ref()` from its `index.json` manifest
|
|
annotations (`io.containerd.image.name` preferred, else
|
|
`org.opencontainers.image.ref.name`), falling back to the archive's filename and
|
|
`"latest"` respectively. `read_image_ref()` is public (not just an internal
|
|
helper of `list_oci_images()`) precisely so `run_container()` (`commands.cpp`) can
|
|
reuse the exact same logic to name a *single* image tar's session pid file (see
|
|
`pid_file.{h,cpp}` below) instead of duplicating it.
|
|
`read_oci_image_config()` reads the image config blob referenced by the manifest
|
|
and extracts `User` (split on `:` into `OciImageConfig::user`/`group`),
|
|
`ExposedPorts`, `Env`, `Volumes`, and the effective default command
|
|
(`Entrypoint ++ Cmd`). `user`/`group` and the default command are consumed by
|
|
`-r/--run`, and every field is displayed by `-i/--inspect` (see `commands.{h,cpp}` above)
|
|
— `ExposedPorts`/`Env`/`Volumes` are otherwise still just captured for when
|
|
networking/volumes are implemented.
|
|
- `containers_storage.{h,cpp}` — wraps the `containers-storage` CLI (`import-layer`,
|
|
`mount`, `unmount`, `layer --json`, `delete-layer`), forcing `fuse-overlayfs` as the
|
|
overlay `mount_program`. `cleanup_layer_chain()` walks a layer's parent chain
|
|
(children before parents) deleting each one.
|
|
- `bwrap.{h,cpp}` — `detect_bwrap_unshare_args()` probes the kernel (via a forked
|
|
`unshare(2)` per namespace type) for which `--unshare-xxx` flags `bwrap` can actually
|
|
use; `build_bwrap_args()`/`run_bwrap()` assemble and run the sandboxed command.
|
|
`build_bwrap_args()` also takes a `std::vector<ResolvedVolumeMount>` (see
|
|
`volume_mount.h` below) and appends one writable `--bind <host_directory>
|
|
<container_path>` per entry. `wrap_for_root_namespace()` is `run_bwrap()`'s own
|
|
nsenter-wrapping logic pulled out into a reusable, exported function — it's also
|
|
what `volume_mount.cpp`'s copy-into-an-empty-volume step uses to reach the
|
|
image's content when running rootless (see below); `run_bwrap()` itself now just
|
|
calls it once on the assembled `bwrap` argv.
|
|
`build_bwrap_args()`/`run_bwrap()` also take a `NamespaceConfig` (`bwrap.h`) — one
|
|
plain `bool` field per `namespace_probes` entry (`user`/`ipc`/`pid`/`net`/`uts`/
|
|
`cgroup`, default `true`), resolved by `run_container()` (`commands.cpp`) from
|
|
`AppConfig`'s six `global.unshare-*` keys (`config_file.h`, see above) once, up
|
|
front — `bwrap.{h,cpp}` itself never touches `AppConfig`/YAML, only this
|
|
already-resolved struct. For each flag `detect_bwrap_unshare_args()` finds the
|
|
kernel supports, `build_bwrap_args()` additionally requires the matching
|
|
`NamespaceConfig` field to be `true` (looked up via a `.cpp`-local
|
|
`namespace_policy_enabled()` if-chain over `namespace_probes`' `name`s) before
|
|
actually passing it to `bwrap` — kernel support and policy are separate gates,
|
|
both must allow a type. This replaced an earlier hardcoded special case that
|
|
always dropped `--unshare-net` regardless of policy or kernel support (without
|
|
any network setup, e.g. `slirp4netns`, unsharing it just left the sandbox with no
|
|
network at all) — `net` now goes through the exact same policy path as every
|
|
other type, defaulting to enabled like the rest. **This is a deliberate,
|
|
user-acknowledged transitional behavior change**: as of this, a plain `-r/--run`
|
|
with no config file override gets a real network namespace and thus no network
|
|
access at all, until `slirp4netns` integration (the next task on this same
|
|
branch) actually sets one up; `global.unshare-net: off` restores the prior
|
|
no-isolation behavior in the meantime. `detect_bwrap_unshare_args()` itself is
|
|
untouched by any of this — still an unfiltered kernel-capability probe, so
|
|
`-t/--test`'s diagnostic report continues to reflect raw kernel support, not policy.
|
|
`build_bwrap_args()`/`run_bwrap()` also take an optional `hostname` (from
|
|
`--hostname`, long-option only): passed through as bwrap's own `--hostname` only
|
|
when `--unshare-uts` is actually among the flags `bwrap` is being given (bwrap
|
|
itself refuses `--hostname` without it) — otherwise logs a warning and leaves the
|
|
sandbox's hostname alone, since a stock Android kernel in degraded mode may not
|
|
support a UTS namespace at all.
|
|
Never requests `--unshare-user` when running as root: root doesn't need a fresh
|
|
user namespace for privilege, and bwrap's own single-mapping uid/gid setup for one
|
|
triggers the kernel's unprivileged-userns setgroups() restriction, which showed up
|
|
as every other supplementary group collapsing to the overflow gid ("nobody") in
|
|
`id`, and `su` inside the sandbox failing with "can't set groups: Operation not
|
|
permitted". Because of that, bwrap's own `--uid`/`--gid` (which require
|
|
`--unshare-user`) aren't usable when running as root either — `--user`/`--group`
|
|
work around this: when set, `build_bwrap_args()`/`run_bwrap()` bind-mount the
|
|
separate `slocker-lite-priv-drop` helper (see below) into the sandbox at a fixed
|
|
hidden path and route the real command through it as
|
|
`<uid>:<gid> -- <command...>`. This only actually works without a user namespace
|
|
(i.e. running as root) — under `--unshare-user`, the sandbox's uid map has only
|
|
one valid entry, so the helper's own `setuid()` fails cleanly there instead of
|
|
silently doing nothing. `run_bwrap()` fails fast (returns -1) if the helper can't
|
|
be found next to this binary when `--user` was requested, rather than silently
|
|
running the command as root. `run_bwrap()` also takes a `container_name` and
|
|
tracks the running session with it: it passes a lambda as
|
|
`run_process_foreground()`'s new `on_start` callback (see `process.{h,cpp}`
|
|
below) that calls `create_session_lock(container_name, pid)` (`pid_file.{h,cpp}`,
|
|
see below) the instant the real `bwrap` pid is known, then calls
|
|
`release_session_lock()` once `run_process_foreground()` returns (covering
|
|
every exit path — normal, nonzero, or a forwarded-signal exit — since that call
|
|
always blocks until the child has actually exited).
|
|
`build_bwrap_args()` no longer passes `--clearenv`/`--setenv` to `bwrap` itself;
|
|
instead, `build_sandbox_env()` builds the sandboxed command's exact environment
|
|
(`PATH`, `HOME`, `PWD` — hardcoded to `"/"`, matching `--chdir`'s own value; note
|
|
per bwrap's own man page `--clearenv` never actually unset `PWD` in the first
|
|
place, so this isn't a straight port of a prior `--setenv` — and `TERM`, only
|
|
if the host process has one, followed by `extra_env` — the resolved
|
|
`--env`/`--env-file` list from `resolve_env_specs()` (`env_spec.h`), appended
|
|
last so it can override the built-in defaults for the same key) and
|
|
`run_bwrap()` passes it straight to `run_process_foreground()`'s own `env`
|
|
override (see `process.{h,cpp}` below). This works because `bwrap` (and
|
|
`nsenter`, when interposed via `wrap_for_root_namespace()`) doesn't alter its
|
|
own inherited environment unless told to, and neither does
|
|
`slocker-lite-priv-drop` (just `setgroups()`/`setgid()`/`setuid()`/`execvp()`,
|
|
no env manipulation) — so controlling it once, at the outermost exec, is
|
|
sufficient for it to reach the final sandboxed command unchanged. **Because
|
|
that outermost exec now uses this same explicitly-built environment**,
|
|
`build_bwrap_args()`/`wrap_for_root_namespace()` resolve `bwrap`'s and
|
|
`nsenter`'s own argv[0] to an absolute path via `find_in_path()` (called from
|
|
this process's own, unmodified environment, before `fork()`) instead of
|
|
leaving them as bare names — confirmed by direct testing: `--env PATH=...`
|
|
used to break `execvp()`'s ability to even *locate* `bwrap`/`nsenter`
|
|
(bare-name lookup happens in the child, using the already-overridden PATH),
|
|
not just what the sandboxed command itself sees. With the fix, only the
|
|
*sandboxed command's own* lookup is affected by a `--env PATH=...` override
|
|
(as expected — same as overriding `PATH` in any real shell before running a
|
|
bare command name), and `bwrap`/`nsenter` are always found regardless.
|
|
`run_bwrap()` also takes an optional `on_bwrap_pid_known` callback, invoked
|
|
alongside (not instead of) the session-lock-creation lambda, at the exact
|
|
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. That same `on_start` lambda also calls
|
|
`create_session_cgroup()` (`session_cgroup.h`, see below), right alongside
|
|
`create_session_lock()`, so `--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
|
|
fully statically linked: it gets bind-mounted *into the container image's own
|
|
filesystem*, which won't have `slocker-lite`'s own shared library dependencies —
|
|
a dynamically linked binary bind-mounted that way fails outright ("error while
|
|
loading shared libraries"), which is exactly what happened before this was split
|
|
out (the original approach bind-mounted `slocker-lite`'s own — dynamically linked
|
|
— binary via `/proc/self/exe` and reexeced it; kept only as a lesson, not as
|
|
working code). Usage: `slocker-lite-priv-drop <uid>:<gid> -- <command> [args...]`;
|
|
does `setgroups(0,…)` → `setgid()` → `setuid()` → `execvp()`, in that order
|
|
(dropping the group needs `CAP_SETGID`, which is lost once `setuid()` drops root).
|
|
`find_priv_drop_helper()` and the `priv_drop::path`/`priv_drop::helper_name`
|
|
constants live in `bwrap.h` (not just internal to `bwrap.cpp`) specifically so
|
|
`exec_in_session()` (`exec_session.cpp`, see below) can reuse the exact same
|
|
already-bind-mounted helper for `-x/--exec`'s own `--user`/`--group` support,
|
|
instead of a second copy needing to be bind-mounted for it (which wouldn't even
|
|
be possible — `-x/--exec` joins an *already-running* session's mount namespace,
|
|
it doesn't get to add bind mounts to it). `find_priv_drop_helper()` itself only
|
|
checks this binary's own host-side existence; it says nothing about whether a
|
|
given session actually has it bind-mounted (only true when that session's
|
|
`-r/--run` resolved a user in the first place).
|
|
- `user_spec.{h,cpp}` — `resolve_user_and_group()` resolves a user/group spec (each
|
|
a name or numeric id) against the *container's own* `/etc/passwd`/`/etc/group`
|
|
**content** (not the host's, and not a path — callers own reading it, since the
|
|
two current callers get that content two different ways: `run_container()`
|
|
reads it directly off the merged mount path, while `exec_in_session()` fetches
|
|
it over `nsenter`, since a running session's mount namespace isn't otherwise
|
|
reachable from this process — see below). `nullopt` content for either file
|
|
means "unreadable/absent"; a numeric user with no group still resolves fine
|
|
without it (defaults gid to the same numeric value as the uid) but a named one
|
|
doesn't. `ResolvedUser` (`bwrap.h`) also carries `home`, looked up by the final
|
|
resolved uid's `/etc/passwd` entry (field 5) regardless of whether `user` was
|
|
given as a name or a number; falls back to `"/root"` for uid 0 or `"/"`
|
|
otherwise when there's no matching row. `build_sandbox_env()` (`bwrap.cpp`)
|
|
sets the sandboxed process's `HOME` from this — `"/root"` only when no user
|
|
override applies at all (no `--user`, no image-declared `config.User`).
|
|
`run_container()` (`commands.cpp`) calls `resolve_user_and_group()` with either
|
|
the explicit `--user`/`--group` flags, or, when `--user` wasn't given, the
|
|
image's own declared `config.User` (`OciImageConfig::user`/`group`) — so a
|
|
container defaults to running as whatever user the image itself declares, not
|
|
root, unless the image declares none.
|
|
- `process.{h,cpp}` — argv-based subprocess helpers (fork/execvp, no shell):
|
|
`run_process()` captures stdout (used for `containers-storage` calls),
|
|
`run_process_foreground()` inherits all of stdio (used for the interactive `bwrap`
|
|
run). Also `find_in_path()`, a shared `$PATH` lookup. `run_process_foreground()`
|
|
installs a SIGINT/SIGTERM handler around its `waitpid()` that forwards the signal
|
|
to the running child and keeps waiting instead of letting the default disposition
|
|
kill `slocker-lite` itself — without this, Ctrl-C (or `kill`) during `-r`'s `bwrap`
|
|
run would skip `run_container()`'s unmount/cleanup entirely, leaving the layer
|
|
imported and/or mounted. `run_process_foreground()` also takes an optional
|
|
`on_start` callback, invoked with the child's real pid right after `fork()`
|
|
succeeds (before the signal handlers go up and it blocks in `waitpid()`) — the
|
|
only point where that pid is knowable, and still accurate even when `argv`
|
|
itself execs into something else first (e.g. `nsenter` handing off to the final
|
|
command via its own in-place `execvp()` — a pid never changes across `exec()`).
|
|
`run_bwrap()` (`bwrap.cpp`) is the one caller that uses it, for session pid-file
|
|
tracking (see `pid_file.{h,cpp}` below). `run_process_foreground()` also takes
|
|
an optional `env` (list of key/value pairs): when set, the forked child
|
|
replaces its entire environment via `clearenv()`/`setenv()` (plain POSIX, not
|
|
the GNU-only `execvpe()` — the target platform includes musl) before `execvp()`,
|
|
instead of inheriting this process's own. `nullopt` (the default) leaves the
|
|
child's environment untouched. `run_bwrap()` is again the one caller that uses
|
|
this, via `build_sandbox_env()` (`bwrap.cpp`) — see there.
|
|
- `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.
|
|
`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
|
|
config), sanitizing `container_name` first (anything outside `[A-Za-z0-9._-]`
|
|
→ `_`, since an image name/tag can contain `/` or `:`). `session_log_file_path()`
|
|
is a sibling resolving to `$XDG_STATE_HOME/slocker-lite/logs/<container_name>-<pid>.log`
|
|
instead — same sanitization, same `$XDG_STATE_HOME`/`$HOME` fallback, just a
|
|
different subdirectory and a `.log` extension — used by `daemonize.{h,cpp}`
|
|
(see below) for `-D/--daemonize`'s log file. `create_session_lock()`
|
|
creates the file (`O_CREAT|O_WRONLY|O_TRUNC|O_CLOEXEC`, mode 0644 — `O_CLOEXEC`
|
|
matters: this fd must never leak into the sandboxed command's own fd table),
|
|
writes the pid as text, and takes an exclusive, non-blocking `flock()` on it —
|
|
held only by that fd, so its lifetime tracks `slocker-lite`'s own process
|
|
lifetime (released automatically on any exit, including a crash), which lines
|
|
up with `bwrap` itself being invoked with `--die-with-parent`. Any external
|
|
tool can check liveness the same way: attempt the same exclusive non-blocking
|
|
`flock()` on the file — success means nothing holds it anymore (stale, safe to
|
|
remove), `EWOULDBLOCK` means a live process still does. `release_session_lock()`
|
|
closes the fd (releasing the flock immediately) and removes the file. Every
|
|
failure path here (can't create the directory/file, can't lock, can't remove)
|
|
is a `spdlog::warn`, never fatal — session tracking is best-effort and must
|
|
never block or fail `-r/--run` itself. `list_sessions()` implements
|
|
`--list-processes` (`commands.cpp`'s `list_processes_command()`): scans the same
|
|
`run/` directory and reports one `SessionInfo {pid, container_name, running}`
|
|
per readable pid file. `pid` is read from the file's own contents, not parsed
|
|
from the filename (ambiguous for names that themselves contain `-`);
|
|
`container_name` is then recovered by stripping that exact `-<pid>` suffix
|
|
back off the filename. `running` reuses the same liveness check any external
|
|
tool would do — a non-blocking exclusive `flock()` that succeeds means the
|
|
file is actually stale, so `running` is false in that case; the lock is always
|
|
released again immediately either way, never left held by the check itself. A
|
|
file that can't be opened or doesn't parse as a pid (e.g. removed mid-scan) is
|
|
silently skipped, not reported as an error — scanning a live directory is
|
|
inherently racy. Both `list_sessions()` and `clean_stale_sessions()` (the
|
|
latter implements `--clean-processes`) share a private `open_session_file()`
|
|
helper for the open/read-pid/recover-name step. `clean_stale_sessions()`
|
|
doesn't just remove whatever a separate `list_sessions()` call reported as not
|
|
running — it re-takes the same non-blocking `flock()` used to test liveness
|
|
and holds it across the `remove()` call itself, per file, so the stale check
|
|
and the removal stay atomic against a new session starting in the gap between
|
|
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 `--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
|
|
`--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
|
|
file's own `O_CLOEXEC`) and `fork()`s. The **child** calls `setsid()` —
|
|
deliberately here, not via re-adding bwrap's own `--new-session` (removed
|
|
earlier, see the `build_bwrap_args()` comment): `--new-session` only calls
|
|
`setsid()` for the deeply-nested sandboxed command *inside* bwrap's own
|
|
namespace setup, leaving the outer `bwrap`/`nsenter`/`slocker-lite` processes
|
|
still attached to the *original* session and still receiving its signals
|
|
(e.g. a `SIGHUP` when the controlling terminal closes) — not real
|
|
daemonization. Calling `setsid()` in our own forked child, *before* it execs
|
|
into `nsenter`/`bwrap`, detaches the entire chain at once, since `exec()`
|
|
never changes session membership — confirmed by direct testing
|
|
(`ps -o pid,sid,pgid,tty`): the daemon child becomes its own session leader
|
|
with no controlling tty, and `bwrap` (a later descendant) shares that same
|
|
session, also with no tty. The child also `sigaction()`s `SIGHUP` to
|
|
`SIG_IGN` (survives the later `exec()` into `nsenter`/`bwrap`, unlike a real
|
|
handler, which `exec()` resets to default — confirmed by sending `SIGHUP`
|
|
directly to a running daemonized `bwrap` pid and it staying alive), then
|
|
redirects stdin to `/dev/null` and stdout/stderr to a log file at
|
|
`session_log_file_path(container_name, getpid())` (`pid_file.h`) — named
|
|
after its *own* pid since the real session pid (`bwrap`'s) isn't known yet.
|
|
If the log directory/file can't be set up at all, that's a **hard** failure
|
|
here (`_exit(1)`), not best-effort — silently losing the very output
|
|
`--daemonize` was asked to capture would defeat the point of the flag. The
|
|
child reports `"LOG <path>\n"` over the pipe immediately (so the parent can
|
|
show a useful location even on failure) and returns `nullopt` to its caller
|
|
(`run_container()`, `commands.cpp`), which then falls through into the rest of
|
|
that function's existing body completely unchanged — **the daemonized child
|
|
is what runs the whole rest of `run_container()`, including the unmount/
|
|
cleanup that already existed after `run_bwrap()` returns; no separate
|
|
watcher/reaper process exists**. The **parent** blocks reading the pipe until
|
|
EOF, returning the accumulated `"LOG "`/`"PID "` lines as a `DaemonizeResult`
|
|
— the caller then prints it and exits immediately without running any
|
|
session logic itself. `report_daemon_started(container_name, pid)` (called
|
|
from `run_bwrap()`'s new `on_bwrap_pid_known` callback — see `bwrap.{h,cpp}`
|
|
below — the instant the real `bwrap` pid is known) renames the pid-named log
|
|
file to `<container_name>-<pid>.log`, re-reports the *updated* `"LOG "` line
|
|
(a real bug caught by testing: the parent's first `"LOG "` line names the
|
|
pre-rename, daemon-pid-named path — without a second one, the parent would
|
|
print a stale filename that doesn't match where the file actually ends up),
|
|
then `"PID <pid>\n"` and closes its own end of the pipe — must happen here,
|
|
explicitly, rather than waiting for the pipe to close naturally at the end of
|
|
the (potentially very long) daemon's lifetime, or the parent would block for
|
|
as long as the session runs instead of returning promptly. The pipe's write
|
|
fd and the current log path are tracked as private file-scope state in
|
|
`daemonize.cpp` (matching `process.cpp`'s own `g_foreground_child_pid`
|
|
pattern for "there's only ever one of these per process" runtime state),
|
|
since `report_daemon_started()` is called later, from a different function,
|
|
not threaded explicitly through every call in between.
|
|
- `exec_session.{h,cpp}` — implements `-x/--exec <pid>`: 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/<outer_pid>/ns/{pid,uts,ipc,cgroup}` against this process's own showed
|
|
them identical, while only `mnt`/`user` differed. `resolve_namespace_pid()`
|
|
(`sandbox_process.{h,cpp}` — moved out of this file once `--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
|
|
nsenter's corresponding `--type=/proc/<ns_pid>/ns/<type>` 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 ... -- <command>` 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.
|
|
`exec_in_session()` also takes optional `--user`/`--group` (mirroring `-r/--run`'s
|
|
own): given, they resolve against the session's own `/etc/passwd`/`/etc/group`
|
|
(fetched via `cat` run through the same `nsenter` join, since this process can't
|
|
otherwise see into that namespace, then handed to `resolve_user_and_group()` —
|
|
`user_spec.h`, see below); if unset, defaults to whatever uid/gid the session's
|
|
own sandboxed command is *already* running as (read from `/proc/<ns_pid>/status`),
|
|
rather than root/the caller — fixing a real bug (reported after this project's
|
|
own `-x/--exec` and priv-drop features had both shipped separately): without this,
|
|
`-x/--exec` always ran as whatever the *host* invocation was, ignoring any
|
|
`--user`/`--group` the session itself was started with. Either way, the resolved
|
|
identity is applied by running `command` through the session's already
|
|
bind-mounted `slocker-lite-priv-drop` helper (`priv_drop::path`, `bwrap.h`) —
|
|
reused as-is, not bind-mounted again (`-x/--exec` can't add bind mounts to an
|
|
already-running session's namespace anyway). Skipped entirely when the resolved
|
|
uid *and* gid are both 0: a session that was never given a resolvable user at
|
|
`-r/--run` time never got the helper bind-mounted at all, and dropping to 0:0
|
|
would be a no-op regardless; a missing helper for a genuinely non-root
|
|
resolution instead surfaces as `nsenter`'s own "No such file or directory" once
|
|
it tries to exec `priv_drop::path`, diagnostic enough on its own. **Second real
|
|
bug, caught by direct testing on a rootless dev machine before this shipped**:
|
|
when the session's own `-r/--run` used `--unshare-user` (i.e. ran rootless —
|
|
see the root-vs-rootless paragraph below), "root inside the container" is
|
|
achieved purely through the kernel's own uid mapping for that namespace, not a
|
|
real privilege drop — so `/proc/<ns_pid>/status`'s uid/gid, read from *outside*
|
|
that namespace, shows the host-mapped id (e.g. `1000`), not the
|
|
container-relative one (`0`). Treating that as "needs a priv-drop to 1000" is
|
|
wrong two ways: the helper is typically never bind-mounted for a session with
|
|
no resolved `--user`, and even when it is, `setuid()` fails outright under the
|
|
single-entry uid map an unprivileged user namespace gets (confirmed directly:
|
|
`failed to drop privileges to 0:0: Operation not permitted`). Fixed by tracking
|
|
whether the `user` namespace type was actually one of the ones joined (it only
|
|
is when it differs from this process's own, i.e. exactly when `-r/--run` used
|
|
`--unshare-user`) and, when so, leaving the default identity unresolved (no
|
|
priv-drop) for that case — joining that same user namespace with
|
|
`--preserve-credentials` (already done regardless) already reproduces the
|
|
container's own view correctly via that same kernel mapping, with nothing
|
|
further needed. Verified end-to-end on this same rootless dev machine: a
|
|
daemonized `-r --run` busybox session with no declared user, `--exec`'d with no
|
|
`--user`, now correctly shows `uid=0(root)` (previously would have attempted,
|
|
and failed, a priv-drop to the host-mapped uid); an explicit `--exec --user 0`
|
|
against the same session correctly resolves to `0:0` and skips the priv-drop
|
|
step; `--exec --user portage` against it correctly resolves the name to its
|
|
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 `--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 `--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`,
|
|
falling back to `$HOME/.config/slocker-lite/config.yaml`). Supported `global` keys:
|
|
`log-level`, and six `unshare-<type>` keys (`unshare-user`/`unshare-ipc`/
|
|
`unshare-pid`/`unshare-net`/`unshare-uts`/`unshare-cgroup`, one per
|
|
`bwrap.cpp`'s own `namespace_probes` entry) controlling whether `-r/--run`
|
|
requests each of bwrap's `--unshare-xxx` flags — every other long option is a
|
|
one-shot flag, not a setting, so it doesn't belong in a persistent config file.
|
|
Each `unshare-*` key accepts `"1"`/`"on"`/`"yes"`/`"true"` (enabled) or
|
|
`"0"`/`"off"`/`"no"`/`"false"` (disabled), case-insensitively (`.cpp`-local
|
|
`parse_bool_flag()`); an unset key defaults to enabled, and an unrecognized
|
|
value logs a `spdlog::warn` and is treated as unset (default enabled) rather
|
|
than failing the whole config load — consistent with this file's existing
|
|
forward-compatible/ignore-malformed-entries policy (only malformed *YAML
|
|
syntax* is a hard error). A missing file returns a default-constructed
|
|
(empty) `AppConfig`, not an error; unknown sections/keys (and malformed
|
|
individual volume entries) are likewise ignored for forward-compatibility.
|
|
`main()` applies `config->log_level` (via the existing `apply_log_level()`)
|
|
right after `spdlog::cfg::load_env_levels()` and before parsing CLI options,
|
|
so an explicit `--log-level` on the command line always overwrites it
|
|
afterward — same precedence pattern already used for `SPDLOG_LEVEL`.
|
|
`write_config_file()` writes the whole file back out (via libyaml's
|
|
document-building/emitter API, symmetric to the read side) — used by
|
|
`-v/--volume` (`create_volume_command()`, `commands.cpp`) to persist a new
|
|
`VolumeEntry {name, directory}` into the `volumes` section, preserving
|
|
`global` (including any set `unshare-*` keys, re-serialized as canonical
|
|
`"true"`/`"false"`) untouched. **`VolumeEntry`/the `volumes` section is a
|
|
distinct concept from `OciImageConfig::volumes`**: this is a user-defined
|
|
`name -> host directory` mapping created via `-v/--volume`, not an image's
|
|
own declared mount points (still unconsumed, see `oci_image.{h,cpp}` above).
|
|
`AppConfig`/`config.volumes` is looked up by name in `resolve_volume_mount()`
|
|
(`volume_mount.{h,cpp}`, see below), which is how `-r/--run`'s own `-v` usage
|
|
finds a named volume's host directory. `run_container()` (`commands.cpp`)
|
|
resolves the six `unshare-*` fields (each `value_or(true)`) into a
|
|
`NamespaceConfig` (`bwrap.h`, see below) once, up front, and passes it to
|
|
`run_bwrap()` — `bwrap.{h,cpp}` itself has no dependency on this file or on
|
|
YAML parsing at all, only on the already-resolved, defaults-applied struct.
|
|
- `volume_mount.{h,cpp}` — `is_valid_volume_name()` (no `/`, checked by both
|
|
`create_volume_command()` and to tell a `-v` spec's name/path apart) and
|
|
`resolve_volume_mount()`, called once per `-v` occurrence from `run_container()`
|
|
when running with `-r`. A spec with no `/` is looked up in `config.volumes` by
|
|
name (error if unknown); one with `/` is treated as a host directory path and
|
|
`create_directories()`'d if missing. If the resulting host directory is empty,
|
|
`initialize_volume_directory()` reconciles it against the image's own directory
|
|
at the given container path: if that image directory is non-empty, its contents
|
|
are copied in first; then, **whether or not there was content to copy**, the
|
|
host directory's own mode/ownership/timestamps (and xattrs/ACLs where
|
|
supported) are always set to match the image directory's own — **real bug
|
|
fixed by the user, not assumed**: an earlier version only ever copied when the
|
|
image directory was non-empty, so an image declaring an *empty* directory with
|
|
specific ownership/permissions (e.g. a data directory owned by a non-root
|
|
uid/gid) got a host directory with default `create_directories()` permissions
|
|
instead, and even the non-empty case never reconciled the directory's *own*
|
|
attributes (only each copied entry's). The existence check, content copy, and
|
|
attribute reconciliation all run as a single `sh -c` invocation wrapped through
|
|
`wrap_for_root_namespace()` (`bwrap.h`) — **not** a plain `std::filesystem`
|
|
check — because a rootless `containers-storage mount`'s content isn't visible
|
|
to this process at all without `nsenter`, the same constraint `run_bwrap()`
|
|
itself works around (see the root-vs-rootless paragraph below). `cp -a
|
|
--preserve=mode,ownership,timestamps,links[,xattr] --attributes-only -T` does
|
|
the attribute-reconciliation step; whether `,xattr` is included is decided by
|
|
a direct `setxattr()`/`removexattr()` probe on the host directory (no new
|
|
library dependency — Linux POSIX ACLs are themselves stored as xattrs, so this
|
|
one probe stands in for both, logging a single `spdlog::warn` if unsupported).
|
|
`-T`/`--no-target-directory` is required on that second `cp` — confirmed by
|
|
direct testing: without it, since the host directory already exists, plain
|
|
`cp SRC DST` copies `SRC` *into* `DST` as a nested `DST/basename(SRC)`
|
|
subdirectory instead of reconciling `DST`'s own attributes, which is exactly
|
|
the bug this fix closes. A nonzero `cp` exit is only ever a warning, never
|
|
fatal — often just an ownership-preservation shortfall when not running as
|
|
root. Verified end-to-end against `images/gitea.tar`'s real declared
|
|
`/etc/gitea`/`/var/lib/gitea` volumes under a real rootless mount: the
|
|
resulting host directories' mode/ownership matched the image's own declared
|
|
values in both cases, and no mounts/layers were left behind afterward.
|
|
|
|
Errors are logged via `spdlog::error`; every external command is also traced at debug
|
|
level in `run_process()`/`run_process_foreground()` (`src/process.cpp`) — visible via
|
|
`SPDLOG_LEVEL=debug`, since spdlog's default level is `info` — and a failed external
|
|
command additionally logs a `spdlog::warn`, which is visible by default (no env var
|
|
needed). The final "mounted image at: ..." success line is direct stdout program
|
|
output, not a log.
|
|
|
|
Because `containers-storage mount` runs rootless, it reexecs itself into a private
|
|
user+mount namespace to gain the privilege it needs for the overlay mount — which
|
|
leaves the result invisible to a plain shell or child process outside that namespace.
|
|
Confirmed `containers-storage unshare` does **not** rejoin an already-running mount's
|
|
namespace; only `nsenter` targeting the live `fuse-overlayfs` daemon's PID does.
|
|
`-r/--run` handles this automatically by locating that PID and running `bwrap` via
|
|
`nsenter` into its namespaces (`wrap_for_root_namespace()`, `src/bwrap.h`) — reused
|
|
as-is by `volume_mount.cpp`'s copy-into-an-empty-volume step, since that also needs
|
|
to read image content that's otherwise invisible outside the same namespace.
|
|
**Running as root sidesteps all of this**: no privilege
|
|
reexec is needed, so the mount is already directly visible in the current namespace,
|
|
and `nsenter --user=...` into it then fails ("reassociate to namespace 'ns/user'
|
|
failed: Invalid argument") since the caller is already in that same user namespace.
|
|
`-r/--run` detects `geteuid() == 0` and skips `nsenter` automatically in that case;
|
|
`-n/--no-nsenter` forces it off manually for any other situation where the mount turns
|
|
out to already be directly visible.
|
|
|
|
**Mutable global state and multi-container support:** an audit ahead of planned
|
|
docker-compose support (running multiple containers at once) found exactly three
|
|
pieces of mutable global/file-scope state in `src/`: `g_mount_program`
|
|
(`containers_storage.{h,cpp}`, the resolved `fuse-overlayfs` path — genuinely
|
|
process-wide, invariant across containers), `g_foreground_child_pid`
|
|
(`process.cpp`, plus `run_process_foreground()`'s process-wide `SIGINT`/`SIGTERM`
|
|
handler installation — tracks one foreground child at a time), and
|
|
`g_report_fd`/`g_log_path` (`daemonize.cpp`, one in-flight `-D/--daemonize`
|
|
handshake's report-pipe fd and log path). **Decision, confirmed by the user:**
|
|
multi-container/compose support will run each container's session in its own
|
|
forked OS process — the same model `-D/--daemonize` already uses — rather than
|
|
one process managing multiple containers concurrently without forking. Under
|
|
that model, "one OS process" and "one running container" stay the same thing
|
|
they already are today, so **none of these globals need to become per-container
|
|
state** — each forked child only ever tracks/signals one foreground child and
|
|
handles one daemonize handshake, exactly as today. This is a load-bearing
|
|
constraint for however the compose orchestrator ends up implemented: it must
|
|
fork (not thread, not run an in-process event loop over N containers) one child
|
|
per service, each child reusing `run_container()`'s existing single-container
|
|
code path unchanged.
|
|
|
|
## Build & test commands
|
|
|
|
Build directory is `buildDir/` (already configured).
|
|
|
|
- Configure (only needed if `buildDir/` is missing or deleted): `meson setup buildDir`
|
|
- Build: `meson compile -C buildDir` (or `ninja -C buildDir`) — also builds
|
|
`buildDir/slocker-lite-priv-drop`, the statically-linked helper `-r --user` needs
|
|
(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`, `-x/--exec`, `--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`)
|
|
- Run tests: `meson test -C buildDir`
|
|
|
|
## Code style
|
|
|
|
- Null-pointer checks: prefer `if (!ptr)` / `if (ptr)` over `if (ptr == nullptr)` / `if (ptr != nullptr)`.
|
|
- Constants: no `k` Hungarian-notation prefix. `enum class` values are already qualified by
|
|
the enum's own name (e.g. `Mode::run`, `OciPortProtocol::tcp`), so plain snake_case
|
|
enumerators are enough on their own. Free-standing constants also use plain snake_case;
|
|
when several are conceptually related, group them under a named `namespace` instead of
|
|
relying on a shared prefix to imply the grouping (e.g. `cli_args.cpp`'s `getopt_long` long-option
|
|
codes live in `namespace options { constexpr int log_level = ...; }`, and `bwrap.cpp`'s
|
|
priv-drop-helper path/binary-name pair live in `namespace priv_drop { ... }`) — nest the named
|
|
namespace inside the file's existing anonymous namespace where one is already present, so
|
|
internal linkage is unchanged. A `kXxx`-named identifier that turns out not to actually be
|
|
`const` (mutable global/static state) instead follows this codebase's existing `g_` prefix
|
|
convention (e.g. `containers_storage.cpp`'s `g_mount_program`, matching `process.cpp`'s
|
|
`g_foreground_child_pid` and `daemonize.cpp`'s `g_report_fd`/`g_log_path`).
|
|
|
|
## Licensing
|
|
|
|
- Every `.c`/`.cpp`/`.h` file under `src/` must start with the GPLv2-or-later copyright
|
|
header (see any existing file under `src/` for the exact text).
|
|
- After adding a new source file under `src/`, run `./add-license.sh` from the repo
|
|
root to prepend the header (it reads `copyright-header` and inserts it via `sed`,
|
|
skipping files that already have it, so it's safe to re-run at any time).
|
|
|
|
## Build configuration notes
|
|
|
|
- `meson.build` sets `warning_level=3` and `cpp_std=c++20` — keep new code warning-clean under `-Wall -Wextra -Wpedantic`-equivalent settings.
|
|
- The single Meson `test()` target runs `slocker-lite` against a fixture OCI image tar generated at build time by `tests/gen_fixture.py` (a `custom_target`) and checks its exit code (no test framework is wired in yet).
|