Second step of the tap+relay fallback for veth-less kernels (the real target device supports tun/tap but not veth). Reuses the existing bridge as the switching fabric -- provision_bridge()'s NAT/forwarding setup needs no changes -- and only replaces how a container's namespace gets connected to it: - a host-side tap device, created wherever the network's bridge lives and enslaved to it, playing veth's host-side role - a container-side tap device, created directly inside the container's namespace and named eth<N> from the start (no rename step needed) - a relay process holding both fds open, copying raw Ethernet frames bidirectionally between them -- reproducing a veth pair's kernel wire via one userspace hop Not wired into join_one_network() yet -- this commit only adds create_tap_relay()/stop_tap_relay() and exercises them standalone via a new self-test (throwaway bridge + throwaway namespace). A real synchronization bug turned up while writing that self-test: fork() returning to the parent doesn't mean the child has reached its own unshare(CLONE_NEWNET) yet, so using its pid immediately raced and created the container-side tap in the wrong (host) namespace. Fixed by polling namespace_isolated() first, the same guard network_join.cpp's wait_for_isolated_net_namespace() already uses for a real session. Verified twice as root via the doas rule: host-side tap gets created and attached to the bridge, container-side tap gets created with the right name inside the target namespace, and -- the biggest open assumption from the design doc addendum -- both devices disappear on their own once stop_tap_relay() stops the process, no explicit `ip link del` needed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
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").
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 real Linux bridge in the host's own namespace, with NAT/forwarding set up so containers on it reach the host's real network) 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. |
--list-networks |
List all named networks (see -n/--network) with their kind, IPv4 subnet, and IPv6 subnet (or (no ipv6)). |
--delete-network <name> |
Remove a named network from the config. |
-p, --port-forward [<network>:]<host-port>:<container-port> |
With --run, forward a TCP port from the host into the container. <network> is optional, defaulting to the container's sole --extern network (an error if it joined more than one without specifying). Repeatable. Reachable via the host's real, externally-facing IP; localhost/loopback access has a known NAT-hairpinning limitation (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). |
-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
# 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:
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: fd00:168:0:0::/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 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.