Commit 1/6 of the network isolation feature (docs/networking-design.md):
config-only, no host-side effects yet. Adds NetworkEntry {name, kind,
subnet, ipv6, subnet6} and a networks config-file section parallel to
volumes; network_subnet.{h,cpp} for CIDR validation, overlap checking,
and auto-allocation (10.168.<n>.0/24 / fd00:168:0:<n>::/64, paired,
--subnet/--subnet6 overrides); -n/--network create/list/delete CLI,
dual-purpose like -v/--volume (alone creates, repeatable with -r to
join -- joining isn't wired up yet, just accepted).
-n was already taken by --no-nsenter; moved that to long-option-only
(--no-nsenter), matching --kill's "rare flag, no real loss" precedent,
since --network will be far more heavily used.
The "Explicitly out of scope for now" heading was lost during the
previous edit that appended the commit-sequence section, leaving its
bullets orphaned under no heading.
Documentation only, no code: config schema + subnet/IPv6 allocation +
-n/--network CRUD, persistent netns primitives, bridge provisioning
(also doubles as reboot reconciliation), veth join at -r/--run time,
-p port forwarding, then a crash-orphan cleanup sweep. Each step is
independently buildable and manually verifiable, since the full
feature is too large for one commit.
Captures the design discussion for persistent extern/intern networks,
-n/--network, and -p port forwarding: root-only Linux bridge+veth
(no slirp4netns needed once root is assumed), extern vs intern
differing only in where the bridge lives, iptables-based DNAT for -p,
and the subnet/IPv6/CLI/config-schema decisions. No code yet -- this
is the confirmed design to implement against.
Writes every supported config option explicitly -- the six
unshare-* bools defaulted via value_or(true), log-level captured
from the actually active spdlog level (so an explicit --log-level
passed alongside -w is persisted too) -- creating the file and its
parent directory if missing, and prints its full path. Existing
values (including volumes) are preserved untouched.
Also fixes a stale README row left over from unplugging the
namespace probe out of -t/--test.
detect_bwrap_unshare_args()'s output was diagnostic/capability info,
not an actual test; run_self_tests() reverts to an empty placeholder
for real tests to land in later.
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.
Per the user's request: --kill is destructive enough (stops every process
a session started) that typo-prone brevity isn't worth it -- dropping the
short option makes it harder to invoke by mistake.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
A plain `kill <tracked_bwrap_pid>` doesn't kill everything a container
started: on a kernel without pid namespace support, a service that
daemonizes (double-forks and detaches) before the entrypoint execs into its
main command reparents all the way to the *host's* own pid 1, completely
disconnected from the sandboxed session -- confirmed via a real session log
from the user's own Android target device, where php-fpm and caddy both
kept running as orphans after killing the tracked pid.
kill_session() (src/kill_session.{h,cpp}) picks between three
independently-named strategies per session, based on what's actually
available for it:
- kill_via_cgroup(): preferred when the session has a dedicated cgroup
(src/session_cgroup.{h,cpp}, set up at -r/--run time in run_bwrap()'s
on_start callback). Reaches every process the session ever started,
daemonized/reparented or not, regardless of pid namespace support.
- kill_via_pid_namespace(): used when --unshare-pid was genuinely in effect
for the session (src/sandbox_process.{h,cpp}, shared with exec_session.cpp,
which already needed resolve_namespace_pid()). Relies on the kernel's own
guarantee that killing a pid namespace's pid 1 tears down everything in it.
- kill_via_tracked_pid(): fallback, signals the tracked pid directly -- no
worse than today's manual kill. This is what the user's real target
device currently falls back to (no pid namespace support there).
Each strategy sends SIGTERM, waits up to a 10s grace period, then forces a
SIGKILL. Verified locally (rootless dev machine, which does support pid
namespaces): a daemonizing test session was fully cleaned up via
kill_via_pid_namespace(), including the forced-SIGKILL escalation path,
with no leftover processes, mounts, or layers.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Previously -e/--exec always ran its command as the host's own invoking
credentials, ignoring whatever uid/gid the session's own -r/--run resolved
it to. Now it defaults to whatever the session's sandboxed command is
already running as (read from /proc/<pid>/status), and --user/--group can
override that, resolved against the session's own /etc/passwd/group
(fetched via nsenter, since the mount namespace isn't otherwise reachable).
Either way it's applied by reusing the already bind-mounted
slocker-lite-priv-drop helper from the original -r/--run, not a second copy.
Refactored resolve_user_and_group() (user_spec.{h,cpp}) to take passwd/group
*content* instead of a filesystem path, so both callers -- run_container()
(local file read) and exec_in_session() (nsenter + cat) -- can share it.
Exported priv_drop::path/helper_name and find_priv_drop_helper() from
bwrap.h so exec_session.cpp can reuse the same helper.
Caught and fixed a second bug during testing on a rootless dev machine:
under -r/--run's own --unshare-user, "root inside the container" is a uid
mapping, not a real privilege drop, so /proc/<pid>/status's uid/gid (read
from outside that namespace) is the host-mapped id, not the container's
own view -- defaulting to a priv-drop there was wrong and failed outright.
Fixed by skipping the default-identity lookup whenever --exec is already
joining a differing user namespace, since nsenter --preserve-credentials
alone already reproduces the container's view via that same kernel mapping.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
/proc/<pid>/task/<pid>/children doesn't exist on every kernel (confirmed
missing on a real Android target), so resolve_namespace_pid() fell back to
the outer bwrap pid itself and nsenter ended up with no namespace flags at
all. Add a portable fallback that scans /proc/<n>/stat for the child whose
ppid matches, the same information pstree uses to build its tree.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
resolve_volume_mount() was supposed to initialize a fresh host directory
from the image's own content at the mounted container path, but only ever
did anything when that image-side directory was non-empty. Two bugs
followed: 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 plain create_directories() defaults instead, and even the
non-empty case never reconciled the directory's own attributes -- only
each copied entry's.
Fixed by splitting into two independent steps in the embedded cp script:
copy contents only when non-empty (as before), then always reconcile the
host directory's own mode/ownership/timestamps/xattrs via
`cp -a --attributes-only -T`. -T/--no-target-directory turned out to be
required -- caught by direct testing: without it, cp nests the image
directory *into* the already-existing host directory instead of
reconciling its attributes, which silently produced a spurious nested
copy and left the host directory's own attributes untouched.
Verified end-to-end against images/gitea.tar's real declared
/etc/gitea and /var/lib/gitea volumes under a real rootless mount: the
resulting host directories' mode/ownership now match the image's own
declared values, with no leftover mounts/layers afterward.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
main.cpp had grown to ~910 lines holding CLI parsing, every command
implementation, and the -t/--test handler all in one file. Split it:
- cli_args.{h,cpp}: getopt_long parsing and all post-loop validation
(parse_args()), producing a ParsedArgs the rest of the program consumes.
- commands.{h,cpp}: every command implementation plus dispatch_command(),
an exhaustive switch over Mode with no default -- so -Wswitch (this
project's warning_level=3) now catches a future Mode value added without
a matching dispatch case, instead of silently falling through. Confirmed
by temporarily adding an unhandled enumerator and observing the warning.
- self_test.{h,cpp}: -t/--test's own file, ahead of real tests landing here.
Also fixes Mode::mount, which previously had no explicit dispatch check at
all -- it ran only because it was whatever fell off the end of main()'s
if/else chain when nothing else matched. It's now mount_command(), a real
case in dispatch_command() like every other mode.
main.cpp itself shrinks to ~30 lines: load config, apply its log level,
parse_args(), dispatch_command().
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Ahead of docker-compose multi-container support, audited the three pieces
of mutable global state (g_mount_program, g_foreground_child_pid,
g_report_fd/g_log_path) for concurrency safety. Decided each container's
session will run in its own forked OS process, the same model
-D/--daemonize already uses, rather than one process managing multiple
containers without forking. Under that model none of the globals need to
change, since "one OS process" already equals "one running container" --
recorded as a load-bearing constraint for the compose orchestrator.
No code changes; documentation only.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Drop the k Hungarian-notation prefix throughout src/. Enum class values
(Mode::, OciPortProtocol::) are already qualified by the enum's own name,
so plain snake_case enumerators are enough. Free-standing constants also
move to snake_case; related ones are grouped under a named namespace
instead of relying on a shared prefix (main.cpp's getopt long-option
codes -> namespace options, bwrap.cpp's priv-drop path/binary name ->
namespace priv_drop). kMountProgram, which was actually mutable global
state rather than a true constant, is renamed to g_mount_program to
match this codebase's existing g_ convention for that kind of state.
Also dedupes the three identical kTabWidth local constants in main.cpp
into one shared tab_width.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Single-fork daemonize: the child calls setsid() itself rather than
re-enabling bwrap's own --new-session, which was previously removed
(and stays that way) because it only detaches the deeply-nested
sandboxed command, leaving bwrap/nsenter/slocker-lite itself still
attached to the original session -- not real daemonization. Calling
setsid() in slocker-lite's own forked child, before it execs into
nsenter/bwrap, detaches the whole chain at once, since exec() never
changes session membership -- confirmed via ps -o sid,pgid,tty against
a running daemonized session.
The child also ignores SIGHUP (confirmed to survive exec() into bwrap,
unlike a real handler, which exec() resets) and redirects stdin to
/dev/null and stdout/stderr to a log file under
$XDG_STATE_HOME/slocker-lite/logs/ (session_log_file_path(), new
sibling to the existing session_pid_file_path() in pid_file.{h,cpp}).
The original process blocks briefly on a pipe until the child reports
the real bwrap pid (or exits without doing so), then prints it and
exits -- keeping "pid" meaning the same thing everywhere in this
codebase (the same one --list-processes/-e/--exec already use), rather
than introducing a separate daemon-supervisor pid. run_bwrap() gained
an on_bwrap_pid_known callback (bwrap.{h,cpp}) for this, invoked
alongside the existing session-lock creation at the same instant.
The daemonized child is what runs run_container()'s entire existing
body afterward, including the unmount/cleanup that already ran once
bwrap exits -- no separate watcher/reaper process.
Testing caught a real bug before this was correct: the log file gets
renamed from its initial (daemon-pid-named) filename to the final
<container_name>-<bwrap-pid>.log once the real pid is known, but the
parent had already been told the pre-rename path and was never updated
-- fixed by re-reporting the path over the same pipe after the rename.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Both flags are repeatable and share a single ordered EnvSpec list
(env_spec.{h,cpp}) so a later --env or --env-file always overrides an
earlier one for the same name, regardless of which flag set it.
--env-file reads one VAR=VALUE per line, skipping blank lines and
#-comments. resolve_env_specs()'s result is appended after
build_sandbox_env()'s own PATH/HOME/PWD/TERM defaults, letting an
explicit --env override any of them too.
Testing this surfaced a real bug in the environment-at-exec-time
mechanism from the previous change (dropping bwrap's own --clearenv):
since bwrap/nsenter are now exec'd with the same explicitly-built
environment the sandbox sees, a --env PATH=... override broke
execvp()'s ability to even locate bwrap/nsenter themselves (bare-name
PATH lookup happens in the child, using the already-overridden PATH).
Fixed by resolving both to absolute paths via find_in_path(), called
from this process's own unmodified environment before fork() --
confirmed by testing that only the sandboxed command's own lookup is
now affected by a PATH override, not bwrap/nsenter.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Moves responsibility for the sandboxed command's environment out of
bwrap's own --clearenv/--setenv flags and into the process-launching
code itself: build_sandbox_env() (bwrap.cpp) constructs the exact
environment (PATH, HOME, PWD, TERM if present), and run_bwrap() passes
it to run_process_foreground()'s new optional env override, which
replaces the forked child's entire environment via clearenv()/setenv()
(plain POSIX, not the GNU-only execvpe(), since the target platform
includes musl) right before exec. bwrap, nsenter (when interposed),
and slocker-lite-priv-drop all just forward whatever environment
they're launched with, so controlling it once at the outermost exec
is sufficient.
Also adds PWD=/ to the constructed environment: per bwrap's own man
page, --clearenv never actually unset PWD in the first place (bwrap
manages it separately, alongside --chdir), so the old --clearenv/
--setenv sequence was leaving it unset by omission rather than by
choice. Hardcoded to "/" to match --chdir's own value.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
bwrap now runs its own small reaper as pid 1 inside the sandbox
instead of execing the target command directly into that role.
-e/--exec is unaffected: it resolves the real namespace-holding child
dynamically via /proc rather than assuming any particular process
identity, so it still joins the right namespaces with the extra
reaper process in the tree.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Validates the given pid against the same tracked-session liveness
check --list-processes/--clean-processes already use, then joins its
namespaces via nsenter and runs a command there in the foreground.
Two things discovered only by testing against a live session, not
assumed up front:
- The tracked pid is bwrap's own outer process. It sets up the
mount/user namespaces itself, then clone()s the actual sandboxed
command into fresh pid/uts/ipc/cgroup namespaces -- clone()'s
namespace flags only ever affect the new child, never the caller,
so the outer process itself never enters those namespaces at all.
exec_in_session() resolves that real child via
/proc/<pid>/task/<pid>/children and joins its namespaces instead,
falling back to the outer pid if that can't be read.
- Rather than nsenter -a (which would hit a known "Invalid argument"
failure re-entering an identical namespace -- this project already
worked around exactly that once, for the containers-storage mount
path), each namespace type is only joined if
/proc/<pid>/ns/<type> actually differs from this process's own.
nsenter also needs --preserve-credentials, or it tries to
setuid/setgid/setgroups to the target's identity, which fails
outright against the setgroups-denied unprivileged user namespace
bwrap creates whenever -r/--run isn't root.
Verified end-to-end: joined shell gets the container's own hostname,
process tree (ps shows only container processes), and root
filesystem; untracked/stale pids error out cleanly without touching
nsenter; Ctrl-C during the joined command doesn't disturb the
original session; no leftover mounts after either exits.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Scans the same $XDG_STATE_HOME/slocker-lite/run/ directory as
--list-processes and removes every pid file that's genuinely stale.
The liveness check and the removal happen atomically per file (the
same non-blocking flock() used to test it is held across the
remove() call itself), rather than reusing a separate earlier scan,
closing the race window where a new session could start in between.
Only files actually removed are reported, one line each; sessions
still running are left untouched and silently skipped.
Refactored list_sessions() and the new clean_stale_sessions() to
share a private open_session_file() helper for the open/read-pid/
recover-name step they both need.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Lists every session pid file found under
$XDG_STATE_HOME/slocker-lite/run/, showing pid, container name, and
status (running or exited). Status is determined the same way any
external tool could check it: a non-blocking exclusive flock() on the
file that succeeds means it's actually stale (nothing holds it), so
the session is reported as exited in that case; the lock is always
released again immediately, never left held by the check itself.
list_sessions() (pid_file.{h,cpp}) reads the real pid from each file's
own contents rather than parsing it out of the filename, which would
be ambiguous for container names that themselves contain '-'.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Each bwrap session is now recorded under
$XDG_STATE_HOME/slocker-lite/run/<container-name>-<pid> (falling back
to $HOME/.local/state/...), holding an exclusive advisory flock() for
as long as it's running -- so any tool can tell a stale leftover file
apart from a live session by attempting the same non-blocking flock().
The file is removed once the run ends, on every exit path including a
forwarded Ctrl-C.
run_process_foreground() gained an optional on_start(pid) callback,
fired right after fork() succeeds -- the only point the real bwrap pid
is knowable, since exec() (including nsenter handing off to bwrap)
never changes it. run_bwrap() uses this to create/release the session
lock. The container name comes from read_image_ref(), promoted from a
list_oci_images()-only helper to public API in oci_image.h so
run_container() can reuse the same name/tag derivation for a single
image tar.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Long-option only. Threaded through run_container() into
build_bwrap_args(), which passes it as bwrap's own --hostname only
when --unshare-uts is actually among the flags being given to bwrap
(bwrap itself refuses --hostname without it) -- otherwise logs a
warning and leaves the hostname alone, since a stock Android kernel
in degraded mode may not support a UTS namespace at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
ResolvedUser now carries home, looked up from the image's own
/etc/passwd entry for the final resolved uid (falling back to /root
for uid 0 or / otherwise when there's no matching row). bwrap's HOME
now uses this whenever a user override applies (--user/--group or an
image-declared default user); the plain /root default is kept only
when no override applies at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Volume names now reject '/', which lets a -v spec used with -r be told
apart as either an existing named volume or a host directory path. -v
becomes repeatable with -r, each mounting a volume at an absolute
container path; if the host directory is empty and the image already
has content there, it's copied in first (preserving numeric
ownership/permissions/links/xattrs-ACLs, degrading gracefully with a
warning if the host filesystem doesn't support xattrs). The
existence-check and copy run through the same nsenter-wrapped
namespace bwrap itself needs, since a rootless containers-storage
mount's content isn't otherwise visible to this process at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Prints user/group, exposed ports, env, volumes, and default command
from OciImageConfig without mounting or running the image. Extend
inspect_image_command() whenever OciImageConfig gains a new field.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
--delete-volume <name> removes a volume's config entry only.
--delete-volume-full <name> also recursively deletes its host
directory -- fails before touching the config if that deletion errors,
so a failed directory delete never silently drops the config entry;
warns instead of failing if the directory was already gone.
Both are long-only, following the --list-volumes precedent, and slot
into the existing multi-mode getopt dispatch the same way -v/--volume
already does.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Long-only (no free short letter left), lists each volume's name and
host directory, tab-aligned the same way -l/--list-images already
aligns name:tag output -- list_volumes_command() reuses that scheme.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Creates the host directory if missing (warning if it already exists,
another if it's non-empty) and records name -> directory in the config
file's new "volumes" section. Fails if the name or directory is already
used by an existing volume.
This is a distinct concept from OciImageConfig::volumes (an image's own
declared mount points, still unconsumed) -- a user-defined volume, meant
to be referenced by name once -r/--run starts actually mounting volumes.
config_file.{h,cpp} gains write_config_file(), symmetric to the existing
load_config_file(), built on libyaml's document-building/emitter API.
Rewrites the whole file each time; global.log-level round-trips
untouched alongside the new volumes section.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Reads $XDG_CONFIG_HOME/slocker-lite/config.yaml (falling back to
$HOME/.config/slocker-lite/config.yaml), organized into sections. Only
the "global" section's log-level is supported for now -- other options
are one-shot flags, not standing preferences. An explicit --log-level
on the command line always overrides the config file, the same way
SPDLOG_LEVEL already does.
Uses libyaml directly (yaml_dep was already declared in meson.build but
unused). A missing config file isn't an error; unknown sections/keys
are ignored for forward-compatibility; malformed YAML syntax is a hard
error.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
When --user isn't given, run the sandboxed process as whatever user the
image's own config.User declares (already parsed into OciImageConfig::user/
group), instead of always defaulting to root. An explicit --user/--group on
the command line still takes precedence.
read_oci_image_config() is now called unconditionally in run_container()
(it was previously gated behind "no command given") and shared for both the
default command and the default user/group, rather than growing another
special-case guard.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
--user/--group are now handled as separate strings (ResolvedUser), so
parse config.User's "<user>[:<group>]" the same way instead of leaving
callers to split the combined spec themselves.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
bwrap --uid/--gid require --unshare-user, which is never requested
when running as root (since last session's fix), and user namespaces
aren't a near-term option anyway -- the actual Android target doesn't
support them.
--user <name-or-uid> / --group <name-or-gid> work around this:
user_spec.cpp resolves them against the *mounted image's own*
/etc/passwd and /etc/group (names like "git" only mean anything inside
that image's user database), and bwrap.cpp bind-mounts a small helper
into the sandbox to do the actual privilege drop before exec'ing the
real command, since bwrap itself can't switch uid/gid without a user
namespace.
The helper has to be a separate, statically-linked binary
(priv_drop_helper.cpp -> slocker-lite-priv-drop, built with -static)
rather than slocker_lite's own binary: bind-mounting a dynamically
linked executable into an arbitrary container image fails ("error
while loading shared libraries") since that image's own /lib won't
have slocker_lite's dependencies. find_priv_drop_helper() locates it
next to slocker_lite's own binary; run_bwrap() fails fast if it's
missing rather than silently running as root.
Only works without a user namespace (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 doing nothing silently.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Without any network setup (slirp4netns or similar), unsharing the
network namespace just leaves the sandbox with no network at all,
which isn't useful yet. build_bwrap_args() now skips --unshare-net
when assembling the real bwrap invocation; re-add once network
isolation is implemented.
detect_bwrap_unshare_args() itself is unchanged and still probes/
reports net namespace kernel support (e.g. via -t/--test), since
that's capability detection, not policy -- same pattern as the
--new-session removal.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Root already has full privilege without a new user namespace. Creating
one anyway (bwrap's default single-mapping uid/gid setup, no
--uid/--gid/subuid ranges) forces the kernel's unprivileged-userns
setgroups() restriction: every supplementary group outside that one
mapping collapses to the overflow gid (65534/"nobody"), and
setgroups() calls inside the sandbox then fail.
Reported by the user running -r images/gitea.tar as root: `id` showed
groups=0(root),65534(nobody) repeated once per real supplementary
group, and `su git` failed with "can't set groups: Operation not
permitted". detect_bwrap_unshare_args() now skips --unshare-user (and
stops combining the other probes with CLONE_NEWUSER) whenever
geteuid() == 0 -- confirmed fixed by the user on a root-capable
machine. The non-root path is unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously, Ctrl-C or `kill` during -r's bwrap run left slocker_lite
killed outright (default signal disposition), skipping
run_container()'s unmount/cleanup entirely and leaving the layer
imported and/or mounted as garbage.
run_process_foreground() now installs a handler around its waitpid()
that forwards the signal to the running child (kill(), async-signal-
safe) and keeps waiting (EINTR-retry loop) instead of dying. Once the
child actually exits, control returns normally so the existing
unmount/cleanup runs as usual.
Verified with a backgrounded `-r ... -- /bin/sleep 30`, sending both
SIGINT and SIGTERM to the outer slocker_lite PID: process tree exits
cleanly, unmount+delete-layer both run, containers-storage layers
returns to baseline.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
read_oci_image_config() (src/oci_image.cpp) reads the image config
blob referenced by the manifest and extracts User, ExposedPorts (as
OciExposedPort{port, OciPortProtocol}, parsed from keys like
"3000/tcp"), Env, Volumes, and the effective default command
(Entrypoint ++ Cmd). Refactored the oci-layout/index.json/manifest
loading read_oci_layers() already did into a shared read_oci_manifest()
helper, since this is the first time a second "loud" (spdlog::error
on failure) caller needs the identical validation.
-r/--run now uses the image's own default command when none is given
on the command line, falling back to /bin/sh only if the image sets
neither Entrypoint nor Cmd. User/ExposedPorts/Env/Volumes are captured
but not applied anywhere yet -- that lines up with volumes/networking
still being deferred.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pad each name:tag with enough tabs (not spaces) to reach one 8-column
tab stop past the longest entry in the list, so filenames line up
regardless of how much shorter other names/tags are.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
list_oci_images() (src/oci_image.cpp) scans a directory non-recursively
for *.tar/*.tar.* files and, for each one that's a valid OCI Image
Layout archive, derives a name:tag from its index.json manifest
annotations -- io.containerd.image.name if present (a full reference),
else org.opencontainers.image.ref.name (conventionally just a bare
tag for skopeo/podman-produced archives). Falls back to the archive's
filename (.tar and any compression suffix stripped) for the name and
"latest" for the tag. Files that aren't OCI archives are skipped
quietly, since a directory scan is expected to hit unrelated tars.
-l/--list-images <directory> wires this into the CLI, printing
"name:tag<TAB>filename" per image. This is also why --log-level lost
its short form last session: -l needed to be free for this.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
As a regular (non-root) user, most namespace types can only be
unshared together with a fresh user namespace, not in isolation --
the user namespace is what supplies the needed capabilities. Probing
each type on its own under-reported support: on this dev machine only
--unshare-user came back as supported, when in fact ipc/pid/net/uts/
cgroup were all usable once combined with it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
It detaches the sandboxed process from the controlling terminal, so
an interactive /bin/sh fails with "can't access tty; job control
turned off." Re-add it once background/daemonized runs land, where
detaching from the terminal is actually the point.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-r/--run mounts an image, runs a command under bwrap in the
foreground (default /bin/sh, overridable via -- <command> [args...]),
then unmounts and cleans up when it exits. -c/--cleanup deletes a
layer and its ancestor chain from local storage (containers-storage
delete-layer, walking parents via `layer --json`), since -u only ever
unmounted.
bwrap needs to see the merged mount from inside the private namespace
containers-storage mount creates when running rootless; run_bwrap()
locates the live fuse-overlayfs process and runs bwrap via nsenter
into its namespaces. When running as root no such namespace exists
(containers-storage doesn't need to reexec for privilege), so nsenter
fails with EINVAL; detect geteuid() == 0 and skip it automatically
there. -n/--no-nsenter forces it off manually for any other case.
process.cpp gains run_process_foreground() (inherited stdio, for the
interactive bwrap run) and the relocated find_in_path(), now shared
with bwrap.cpp's nsenter lookup.
Also: meson test only ran -m, leaking a layer on every run; it now
runs tests/run_test.py, which drives mount -> umount -> cleanup and
fails if any step does.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add detect_bwrap_unshare_args(), which probes the running kernel for
which namespace types (user, ipc, pid, net, uts, cgroup) can actually
be unshared and returns the matching bwrap --unshare-xxx flags. Each
probe forks a throwaway child to call unshare() so the calling
process's own namespaces are never touched. Needed because the target
device (Android, stock kernel) only supports a subset of namespace
types, so bwrap must be invoked with just the flags it can honor.
Temporarily wired into -t/--test so it can be exercised on-device
ahead of the real bwrap invocation; will be removed once that lands.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace the implicit single-argument invocation with getopt_long-based
flags: -m/--mount (existing mount flow, now explicit), -u/--umount
(unmounts a layer via containers-storage), -t/--test (stub),
-l/--log-level (runtime spdlog level), -h/--help, -V/--version.
--mount now also prints the top layer's ID so it can be passed to
--umount.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>