config.yaml now holds only the global section (log-level, unshare-*, with-veth, with-ipv6); a new persistent.yaml holds volumes/networks. load_config_file()/write_config_file() are replaced by load_global_config()/load_persistent_config()/write_global_config()/ write_persistent_config(), each touching only their own file. -c/--config-file <path> lets one invocation use an alternate file for the global section only -- persistent.yaml is always the one fixed path, regardless of -c, so an experiment can never affect real volumes/networks (a -c file's own volumes/networks, if any, are simply never read either). A -c path that doesn't exist is a hard error, unlike the default path's existing missing-file leniency. migrate_legacy_config_if_needed() moves volumes/networks out of an old-format config.yaml into persistent.yaml on first run after upgrading, always against the fixed default paths regardless of -c. A name collision aborts the migration for that run (touching neither file) rather than risking data loss. Required reordering main() to parse CLI args before loading config (so -c's value is known first) -- ParsedArgs::log_level_flag_given tracks whether --log-level was already given so the config file's own log-level doesn't clobber it despite the reversed call order.
25 KiB
slocker-lite
slocker-lite mounts an OCI Image Layout
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 vendoredsubprojects/spdlog.wrap)catch2, only if theenable_testsMeson option is on (default: on)
Runtime:
containers-storagefuse-overlayfsbwrap(bubblewrap)nsenter(only needed for non-root runs — see "How it works" below)
Build
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
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 would otherwise collide with
slocker-lite's own -r/--run):
| 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 --mount <image.tar>
slocker-lite -r|--run <image.tar> [-v <name-or-dir> <container-path>]... [-- <command> [args...]]
slocker-lite --umount <layer-id>
slocker-lite --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 [-c|--config-file <path>] -w|--write-config
slocker-lite -t|--test [-- <catch-command-line-options>]
slocker-lite -h|--help
slocker-lite -V|--version
| Flag | Description |
|---|---|
--mount <image.tar> |
Validate and mount an OCI Image Layout tar. Debug-only — see -r/--run/-x/--exec for the normal way to actually use an image. |
-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. |
--umount <layer-id> |
Unmount a previously mounted layer (the ID printed by --mount/--run, or from containers-storage layers). Debug-only — -r/--run already unmounts on its own when done. |
--cleanup <layer-id> |
Delete a layer and its ancestor chain from local storage (unmount it first). Debug-only, same reason as --umount above. |
--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); --with-ipv6=false disables (and --subnet6 <cidr> overrides) the auto-allocated IPv6 range. --with-veth=false 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). Both default to the config file's own global.with-ipv6/global.with-veth (enabled if unset either way — see -w/--write-config below) when not given explicitly. 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 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). |
--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). |
-c, --config-file <path> |
Use <path> instead of the default config.yaml for the global section only (log-level, unshare-*, with-veth, with-ipv6) — volumes/networks always come from the separate, always-fixed persistent.yaml (see Configuration below), never affected by this. Errors if <path> doesn't exist (no silent fall-back to defaults, unlike the default path). Combine with -w to bootstrap a config file at a custom location. Always available, regardless of command. |
-w, --write-config |
Write a complete global config file (creating it, and its parent directory, if missing), filling in every global option's current or default value. Useful to bootstrap one for hand-editing. Never touches volumes/networks. Prints the file's full path. |
-t, --test [-- <catch-command-line-options>] |
Run the built-in Catch2 test suite (see Testing above). |
--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
# 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
# Bootstrap a config file at a custom location (global section only --
# volumes/networks are unaffected, see Configuration below)
./buildDir/slocker-lite -c ~/alt-slocker.yaml -w
# Use that alternate file's global settings for one run
./buildDir/slocker-lite -c ~/alt-slocker.yaml -r myimage.tar
Configuration
Persistent settings are split across two local YAML files, both under
$XDG_CONFIG_HOME/slocker-lite/ (falling back to
$HOME/.config/slocker-lite/ if XDG_CONFIG_HOME isn't set):
config.yaml— theglobalsection only (log verbosity, bwrap namespace policy, network-creation defaults). This is the file-c/--config-filecan point elsewhere for a single invocation.persistent.yaml— thevolumesandnetworkssections: real, provisioned host state (named volumes, bridges/namespaces). Always this one fixed file —-c/--config-filenever affects it, so an experiment with an alternate global config can never corrupt or shadow real volumes/networks.
# config.yaml
global:
log-level: debug
unshare-user: on
unshare-ipc: on
unshare-pid: on
unshare-net: on
unshare-uts: on
unshare-cgroup: on
with-veth: on
with-ipv6: on
# persistent.yaml
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, whether the config came from the default
config.yaml or a -c override). 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. global.with-veth/global.with-ipv6 (same accepted values,
same "on unless set otherwise" default) give -n/--network's own creation-time
--with-veth/--with-ipv6 a persistent default, so e.g. a dev machine that's
always used to exercise the tap+relay fallback can set with-veth: off once
instead of passing --with-veth=false on every network creation — an explicit
--with-veth/--with-ipv6 on the command line still overrides it for that one
call. No other long options belong in config.yaml (one-shot
commands like --mount/--run/--user don't).
persistent.yaml's 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. Its networks section is likewise managed by -n/--network
rather than hand-edited — see 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. Missing config files are
fine either way (nothing is overridden, and each file gets created the first
time something needs to write to it).
-c/--config-file <path> uses <path> instead of the default
config.yaml for the global section only, for that one invocation —
persistent.yaml is always read from its one fixed location regardless, and
if <path> itself happens to contain volumes/networks sections (e.g. an
old-format file reused by mistake), those are simply ignored, never read or
migrated. Unlike the default config.yaml (missing = defaults, not an
error), a -c-given path that doesn't exist is a hard error, since pointing
at a specific file is a deliberate choice.
Migrating from an older single-file config.yaml: versions before this
split kept volumes/networks directly in config.yaml alongside global.
The first time a newer build runs against such a file, it automatically
moves any volumes/networks it finds there into persistent.yaml and
rewrites config.yaml down to just its global section — logged at info
level (migrated N volume(s) and M network(s) from ... to ...). This only
ever inspects the default config.yaml, never a -c-given file. A name
collision against an already-existing persistent.yaml entry aborts the
migration for that run (neither file is touched) with a warning identifying
the conflict, rather than silently dropping or overwriting anything.
Run -w/--write-config to bootstrap a config.yaml (or, combined with -c,
an alternate global file at a custom location): it writes out every
supported global option explicitly (filling in the current or default
value for anything not already set — so the first 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. It never touches
persistent.yaml. 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.