Files
slocker-lite/README.md
T
ceamac b602de8e7b Document the new Catch2 test suite
README.md: new "Testing" section -- the 4 category/tag-expression table,
what meson test covers vs. what stays manual, and tests/setup-tests.py.

CLAUDE.md: rewrote the self_test.{h,cpp} entry to describe its new role
(pure Catch2 Session::run() plumbing, ENABLE_TESTS-guarded) instead of the
hand-rolled tests it used to contain directly, and added a full
per-file breakdown of the new tests/unit, tests/integration, and
tests/support infrastructure -- including the real bugs found building it
(the two parse_args()/getopt_long state-reset bugs, the ScratchXdgDirs
mixed-iterator UB, the Catch2-inherited-SIGTERM-handler artifact, the
missing /sys mount and spdlog-writes-to-stdout findings), all in the same
narrative depth this file already uses throughout. Also updated the
"Build & test commands" flag list and meson test description.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-04 11:23:24 +00:00

329 lines
21 KiB
Markdown

# slocker-lite
`slocker-lite` mounts an [OCI Image Layout](https://github.com/opencontainers/image-spec/blob/main/image-layout.md)
tar (the format produced by `skopeo`, `podman save --format oci-archive`, or a modern
`docker save`) and runs a sandboxed command against it — without `podman`, `docker`,
or kernel overlayfs.
## Why
The target environment is Android with a stock kernel: `podman`/`docker` don't run
there (missing namespace support), and there's no kernel overlayfs. `slocker-lite`
works around both: it imports image layers into `containers-storage` and mounts them
with `fuse-overlayfs` (userspace, no kernel overlayfs needed), then sandboxes the run
with `bwrap` in "degraded mode" — using only whichever `--unshare-xxx` namespaces the
running kernel actually supports, instead of requiring the full set.
## Status
Early-stage. Mounting, running, and dropping privileges to a specific user/group all
work. Named volumes (`-v/--volume`) can be created and are persisted in the config
file, and can be mounted into `-r/--run` (repeatably), along with ad hoc host
directories. Image-declared networking (`ExposedPorts`/`Env` from the image config,
and the image's own separately-declared `Volumes`) are parsed but not yet applied,
and there's no background/daemonized run mode yet.
## Requirements
Build-time:
- Meson + a C++20 compiler
- `fmt`, `libarchive`, `nlohmann_json`, `yaml-0.1` (libyaml)
- `spdlog` (uses the system package if found, otherwise fetched automatically via
the vendored `subprojects/spdlog.wrap`)
- `catch2`, only if the `enable_tests` Meson option is on (default: on)
Runtime:
- `containers-storage`
- `fuse-overlayfs`
- `bwrap` (bubblewrap)
- `nsenter` (only needed for non-root runs — see "How it works" below)
## Build
```sh
meson setup buildDir
meson compile -C buildDir
meson test -C buildDir
```
This also builds `buildDir/slocker-lite-priv-drop`, a small statically-linked helper
that `-r --user`/`--group` needs at runtime (see "How it works").
## Testing
`meson test -C buildDir` above runs three checks: the original fixture mount/unmount
smoke test, plus two of the four test categories `-t/--test` provides (see below) --
`meson test` only ever runs what's safe unprivileged with no network setup; the other
two categories are for a developer to run by hand on a real machine.
`-t`/`--test` runs this project's own built-in [Catch2](https://github.com/catchorg/Catch2)
test suite directly: `slocker-lite -t [-- <catch-command-line-options>]`. A bare `-t`
runs everything Catch2 finds; select a category with a tag expression (note the `--`
before it, needed since Catch2's own `-r/--reporter` and `-c/--section` would otherwise
collide with slocker-lite's own `-r/--run` and `-c/--cleanup`):
| Category | What it needs | Invocation |
|---|---|---|
| 1. Unit | Nothing | `slocker-lite -t -- "[unit]"` |
| 2. Integration | Nothing (no network, no root) | `slocker-lite -t -- "[integration]~[net]"` |
| 3. Integration, networked | A real image (see below), no root | `slocker-lite -t -- "[integration][net]~[root]"` |
| 4. Integration, root | Root | `slocker-lite -t -- "[integration][root]"` |
Append `~[slow]` to any of these to skip notably slow cases. A `[root]`-tagged test
`SKIP()`s cleanly (not a failure) when not actually run as root, and a
`[net]`-tagged one does the same when it needs a real container image it can't find
(see next) -- so a bare `-t` degrades gracefully rather than failing outright on a
plain rootless dev machine with no fixture set up.
Categories 3 and 4 need a real, runnable OCI image (something with an actual
`/bin/sh`, unlike the minimal single-file fixture the plain `meson test` smoke test
uses) at `images/busybox.tar`. Run `tests/setup-tests.py` to fetch one automatically
(via `skopeo`, `podman`, or `docker`, whichever is available -- does nothing if
`images/busybox.tar` already exists, e.g. your own build); or just drop your own
busybox-like image there directly.
## Usage
```
slocker-lite -m|--mount <image.tar>
slocker-lite -r|--run <image.tar> [-v <name-or-dir> <container-path>]... [-- <command> [args...]]
slocker-lite -u|--umount <layer-id>
slocker-lite -c|--cleanup <layer-id>
slocker-lite -l|--list-images <directory>
slocker-lite -i|--inspect <image.tar>
slocker-lite -x|--exec <pid> [-- <command> [args...]]
slocker-lite --kill <pid>
slocker-lite -v|--volume <name> <directory>
slocker-lite --list-volumes
slocker-lite --delete-volume <name>
slocker-lite --delete-volume-full <name>
slocker-lite --list-processes
slocker-lite --clean-processes
slocker-lite -t|--test
slocker-lite -h|--help
slocker-lite -V|--version
```
| Flag | Description |
| --- | --- |
| `-m, --mount <image.tar>` | Validate and mount an OCI Image Layout tar. |
| `-r, --run <image.tar>` | Mount, run `bwrap` in the foreground, then unmount and clean up on exit. Defaults to the image's own `Entrypoint`/`Cmd` (or `/bin/sh` if neither is set); pass `-- <command> [args...]` to override. |
| `-u, --umount <layer-id>` | Unmount a previously mounted layer (the ID printed by `--mount`/`--run`, or from `containers-storage layers`). |
| `-c, --cleanup <layer-id>` | Delete a layer and its ancestor chain from local storage (unmount it first). |
| `--no-nsenter` | With `--run`, bind the mount directly instead of `nsenter`-ing into `fuse-overlayfs`'s namespace. Automatic when running as root; use this to force it off otherwise. |
| `-D, --daemonize` | With `--run`, fork into the background: detaches from the controlling terminal (`setsid()`), ignores `SIGHUP`, and redirects stdin from `/dev/null` and stdout/stderr to a log file under `$XDG_STATE_HOME/slocker-lite/logs/`. Prints the session's pid and log path, then returns — the same pid `--list-processes`/`-x/--exec` use. |
| `--user <user>` | With `--run`, run the command as this user (name or numeric uid) instead of the image's own declared user (or root, if it declares none). Resolved against the image's own `/etc/passwd`. Only takes effect when `--run` executes as root. |
| `--group <group>` | With `--user`, use this group (name or numeric gid) instead of the user's primary group. |
| `--hostname <name>` | With `--run`, set the sandbox's hostname. Only takes effect if the running kernel supports `--unshare-uts`; ignored with a warning otherwise. |
| `--env VAR=VALUE` | With `--run`, set an environment variable in the sandbox (overrides the default `PATH`/`HOME`/`PWD`/`TERM` if given the same name). Repeatable; combined with `--env-file` in command-line order, each later one winning over an earlier one for the same name. |
| `--env-file <file>` | With `--run`, load environment variables from `<file>` — one `VAR=VALUE` per line; blank lines and `#`-comments are skipped. Repeatable. |
| `-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. |
| `-x, --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. |
| `--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. |
| `--delete-volume-full <name>` | Like `--delete-volume`, but also recursively deletes the volume's host directory. |
| `-n, --network <name>` | Create/manage a persistent named network: requires exactly one of `--extern` (a bridge in its own dedicated namespace, with an uplink out to the host's real network so containers reach it via IPv4 NAT/forwarding — IPv6, if enabled, is same-bridge reachability only, no NAT: its ULA addresses are non-globally-routable by design, and this kernel's IPv6 stack has no NAT support at all regardless) or `--intern` (a bridge inside its own dedicated, routeless namespace, only reachable by other containers on the same network). `--subnet <cidr>` overrides the auto-allocated IPv4 range (`10.168.0.0/24`, incrementing per network); `--no-ipv6` disables (and `--subnet6 <cidr>` overrides) the auto-allocated IPv6 range, on by default. `--no-veth` forces the tap+relay join fallback even on a kernel that supports veth (useful for testing that path; it's otherwise chosen automatically whenever the running kernel lacks veth support). With `--run`, instead joins `<name>` to the container as its own `eth<N>` interface with an address from the network's subnet; repeatable, no membership limit. Root-only for now. See [`docs/networking-design.md`](docs/networking-design.md) for the full design, including the tap+relay veth fallback. |
| `--list-networks` | List all named networks (see `-n/--network`) with their kind, IPv4 subnet, bridge name, and IPv6 subnet (or `(no ipv6)`). |
| `--delete-network <name>` | Remove a named network from the config. Its live bridge/namespace/iptables state is left untouched. |
| `--delete-network-full <name>` | Like `--delete-network`, but also tears down its live state (the IPv4 MASQUERADE rule and the bridge itself for `extern`, or the whole persistent namespace for `intern`), so a network with the same name can be created fresh afterward. |
| `-p, --port-forward [<network>:]<host-port>:<container-port>[/tcp\|udp]` | With `--run`, forward a port from the host into the container -- TCP by default, or UDP with an explicit `/udp` suffix. `<network>` is optional, defaulting to the container's sole `--extern` network (an error if it joined more than one without specifying). Repeatable, including the same port pair once per protocol. Reachable via the host's real, externally-facing IP; `localhost`/loopback access has a known NAT-hairpinning limitation, for both protocols (see [`docs/networking-design.md`](docs/networking-design.md)). |
| `--list-processes` | List running `--run` sessions found by their pid files under `$XDG_STATE_HOME/slocker-lite/run/`, with their pid, container name, and status (`running` or `exited`). |
| `--clean-processes` | Remove stale pid files (see `--list-processes`), and any `-p`/`--port-forward` iptables rules, left behind by sessions that are no longer running (e.g. after a crash). |
| `-w, --write-config` | Write a complete config file (creating it, and its parent directory, if missing), filling in every option's current or default value. Useful to bootstrap one for hand-editing. Prints the config file's full path. |
| `-t, --test` | Run the (currently empty) self-test placeholder. |
| `--log-level <level>` | Set log verbosity (`trace`, `debug`, `info`, `warn`, `error`, `critical`, `off`). |
| `-h, --help` | Print usage and exit. |
| `-V, --version` | Print version information and exit. |
### Examples
```sh
# Mount an image and inspect it (prints the merged mount path)
./buildDir/slocker-lite -m myimage.tar
# Mount, run the image's default command, then unmount and clean up
./buildDir/slocker-lite -r myimage.tar
# Run a specific command instead
./buildDir/slocker-lite -r myimage.tar -- /bin/sh -c 'echo hello'
# Run as a specific user (as root only)
sudo ./buildDir/slocker-lite -r myimage.tar --user git
# Run with a custom hostname inside the sandbox
./buildDir/slocker-lite -r myimage.tar --hostname mybox
# Run with extra environment variables, from flags and/or a file
./buildDir/slocker-lite -r myimage.tar --env FOO=bar --env-file ./app.env
# Run in the background; prints its pid and log file, then returns
./buildDir/slocker-lite -r myimage.tar -D
# List every OCI image tar in a directory
./buildDir/slocker-lite -l ./images
# Inspect an image's declared config without mounting or running it
./buildDir/slocker-lite -i myimage.tar
# Start a busybox container in the background, then get a shell inside it from
# another terminal (find its pid with --list-processes)
./buildDir/slocker-lite -r busybox.tar &
./buildDir/slocker-lite --list-processes
./buildDir/slocker-lite -e 12345 -- /bin/sh
# Create a named volume backed by a host directory
./buildDir/slocker-lite -v mydata ~/slocker-volumes/mydata
# Run, mounting that named volume plus an ad hoc host directory
./buildDir/slocker-lite -r myimage.tar -v mydata /data -v ~/scratch /scratch
# List all named volumes
./buildDir/slocker-lite --list-volumes
# Remove a named volume (keeps its host directory)
./buildDir/slocker-lite --delete-volume mydata
# Remove a named volume and delete its host directory too
./buildDir/slocker-lite --delete-volume-full mydata
# List currently running (and any leftover, exited) --run sessions
./buildDir/slocker-lite --list-processes
# Remove any leftover, stale pid files
./buildDir/slocker-lite --clean-processes
```
## Configuration
Persistent settings can be kept in a local YAML config file at
`$XDG_CONFIG_HOME/slocker-lite/config.yaml` (falling back to
`$HOME/.config/slocker-lite/config.yaml` if `XDG_CONFIG_HOME` isn't set). The file is
organized into sections:
```yaml
global:
log-level: debug
unshare-user: on
unshare-ipc: on
unshare-pid: on
unshare-net: on
unshare-uts: on
unshare-cgroup: on
volumes:
mydata: /home/user/slocker-volumes/mydata
networks:
mynet:
kind: extern
subnet: 10.168.0.0/24
ipv6: true
subnet6: fdf0:f243:f06f:168::/64
veth: true
```
`global.log-level` sets the default log verbosity (an explicit `--log-level` on
the command line always overrides it). The six `global.unshare-<type>` keys
control whether `-r/--run` requests the matching bwrap `--unshare-xxx` flag
(only namespace types the running kernel actually supports are ever affected
either way) — each accepts `1`/`on`/`yes`/`true` or `0`/`off`/`no`/`false`,
case-insensitively, and defaults to `on` (enabled) when unset, so the block
above is also the default with nothing configured. **Note on `unshare-net`**:
with no `slirp4netns`-style setup yet implemented, leaving it enabled (the
default) means a sandboxed container currently has no network access at all —
set `unshare-net: off` if you need the sandbox to see the host's network in
the meantime. No other long options belong in a config file (one-shot
commands like `--mount`/`--run`/`--user` don't). The `volumes` section is
managed by `-v/--volume` (see above) rather than hand-edited — it's what
`-r/--run`'s own `-v` usage looks named volumes up in. The `networks` section
is likewise managed by `-n/--network` rather than hand-edited — see
[`docs/networking-design.md`](docs/networking-design.md) for the full
persistent-network feature design. `-n/--network` both creates a network
(standing up its real bridge/iptables state, root-only) and, combined with
`-r/--run`, joins a container to one or more of them with a real veth
interface and address on each; `-p/--port-forward` then forwards a host port
into a container on one of its `--extern` networks. A missing config file is
fine either way (nothing is overridden, and one gets created the first time
`-v/--volume`/`-n/--network` is used).
Run `-w/--write-config` to bootstrap a config file: it writes out every
supported option explicitly (filling in the current or default value for
anything not already set — so the block above is exactly what a fresh `-w`
produces), creating the file and its parent directory if they don't exist
yet, and prints the file's full path. Combine it with other flags to seed
specific values, e.g. `slocker-lite --log-level debug -w` writes
`log-level: debug`.
## How it works
Image layers are imported into `containers-storage` (parent-chained) and the
resulting top layer is mounted via `fuse-overlayfs`; `-r/--run` then sandboxes the
requested command with `bwrap`. Because `containers-storage mount` runs rootless by
re-execing into a private user+mount namespace, `-r/--run` normally has to `nsenter`
into that namespace to reach the mount — except when running as root, where the mount
is already directly visible and `--unshare-user` is skipped entirely (a fresh user
namespace isn't needed for root's own privilege, and forces an unrelated
supplementary-group bug in that case). Running as root also unlocks `--user`/
`--group`: since `bwrap --uid`/`--gid` require a user namespace that isn't available
there, `slocker-lite` instead bind-mounts a separate, statically-linked helper
(`slocker-lite-priv-drop`) into the sandbox and routes the command through it to drop
privileges before exec.
While a `-r/--run` session is active, its `bwrap` process is tracked as a locked
PID file under `$XDG_STATE_HOME/slocker-lite/run/` (falling back to
`$HOME/.local/state/...`), named after the image and its PID so the same image can
be run concurrently without collisions. The file is removed automatically once the
run ends; any tool can check whether a session is still alive by attempting the
same exclusive, non-blocking `flock()` on its file. `--list-processes` does
exactly that for every pid file it finds, reporting each one's pid, container
name, and `running`/`exited` status. Normally the file is removed automatically
when its own session ends, but `--clean-processes` removes any stale ones left
behind (e.g. after a crash) using that same check, atomically per file, so it
never removes one that's still genuinely running.
`-x/--exec <pid>` joins a running session's namespaces with `nsenter` and runs a
command there. Because `bwrap` itself sets up the sandbox's mount/user namespaces
and then hands the actual sandboxed command off to a child process in fresh
pid/uts/ipc/cgroup namespaces, `-e` resolves that real child first (via `/proc`)
rather than joining the outer `bwrap` process's own namespaces, so the joined
command sees the container's process tree and hostname too, not just its
filesystem.
`--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.
`--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
attached to the original session. Calling `setsid()` in `slocker-lite`'s own
forked child, before it execs into `nsenter`/`bwrap`, detaches the whole chain
at once (`exec()` never changes session membership), and correctly scopes
`bwrap`'s own `--die-with-parent` to that child. The child ignores `SIGHUP` and
redirects output to a log file before doing anything else; the original,
still-foreground process waits only long enough to learn the real session pid
(the same one `--list-processes`/`-x/--exec` use) before printing it and
returning — the detached child is what runs the entire session afterward,
including the same unmount/cleanup that always ran once the sandboxed command
exits.
See `CLAUDE.md` for the full architecture writeup (file-by-file breakdown, the
reasoning behind each of the above, and known gaps).
## License
GPL-2.0-or-later. See [`COPYING`](COPYING).