ceamac 96efcf37e1 Add network_tap_relay: tap-backed veth substitute, standalone
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
2026-08-30 16:06:32 +00:00

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 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

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.

S
Description
Run a docker image with bubblewrap on low end devices
Readme 772 KiB
Languages
C++ 97.9%
Python 1%
Meson 0.7%
C 0.3%