list_networks_command() now includes each network's bridge name
(recomputed via bridge_name(), not stored -- it's already a pure
deterministic function of the network name), tab-aligned the same way
as the existing name/kind/subnet columns, positioned before the
trailing unaligned IPv6 column. Useful for correlating a network entry
with its live host-side state (`ip link show <bridge>`,
`iptables -t nat -L`) without recomputing the hash by hand.
Verified as root via the doas rule against a fresh extern and intern
network.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Direct tap+relay analog of the existing -p port-forward sweep: unlike
a veth pair or a session's own bridge/persistent-namespace state, a
relay process is host-global state with no automatic teardown if
slocker-lite crashes before reaching its own stop_tap_relay() calls
(bwrap still dies immediately via --die-with-parent, so only the relay
can actually leak).
tap_relay_state_path() uses the same xdg_state_dir()/
sanitize_for_filename() naming scheme as session_pid_file_path()/
port_forward_state_path(), so clean_stale_tap_relays() can
cross-reference filenames against list_sessions() the same way
clean_stale_port_forwards() already does. record_tap_relays()/
remove_tap_relay_record() are wired into run_container() the same
two-places-split as their port-forward counterparts. Wired into
clean_processes_command() (--clean-processes) alongside the two
existing sweeps.
Verified via a controlled scratch test, the same shape the original
port-forward sweep used: a plain rootless daemonized session (no
network join needed for the sweep logic itself) gave a real, live pid,
alongside a hand-written matching record and a fabricated stale one in
the same rootless state dir -- the matching record was left untouched,
the fabricated one was correctly identified as stale and removed
(kill() on its nonexistent pid failing harmlessly with ESRCH), and
killing the real session then correctly swept its own now-stale record
on a second run.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
join_one_network() now branches on should_use_veth(network)
(network_bridge.h): the existing veth-pair path when the kernel
supports veth and the network wasn't created with --no-veth, or
create_tap_relay() (network_tap_relay.h) otherwise -- both produce the
same postcondition (a ready eth<N> in the container's namespace)
before the unchanged IP-assignment/route code runs. JoinedNetwork
gains an optional `relay` field; run_container() (commands.cpp)
collects these into active_relays (mirroring active_port_forwards) and
calls stop_tap_relay() for each after run_bwrap() returns.
Two real bugs caught by testing while verifying this end-to-end:
1. The relay child, unlike every other forked child in this project,
never exec()s, so O_CLOEXEC on fds created before its fork (like
daemonize.cpp's own report pipe) never takes effect -- it only
closes fds across exec(), not across a fork that never execs. The
relay inherited a live copy of that pipe's write end and kept it
open forever, so `-D` combined with `-n <no-veth network>` hung
indefinitely (the daemonize handshake's read-until-EOF never saw
EOF). Fixed with close_inherited_fds(), scanning /proc/self/fd and
closing everything except stdio and the relay's own report pipe, as
the first thing relay_child_main() does.
2. A first-attempt companion fix -- adding the relay's pid to the
session's own cgroup so --kill would reach it directly -- was tried
and reverted: remove_session_cgroup() runs inside run_bwrap(),
before run_container() gets to call stop_tap_relay(), so the cgroup
was still non-empty at removal time and every such session left a
stray cgroup directory behind (EBUSY, confirmed by testing). The
ordinary flow already stops the relay correctly (killing bwrap lets
run_container() reach its own cleanup), so this wasn't worth the
added complexity.
Verified end-to-end as root via the doas rule, using a --no-veth
extern network on this dev machine specifically to exercise the
fallback: two containers joined the same network, got distinct
addresses via two tap devices + relays (no veth at all), and pinged
each other with 0% packet loss, repeatably.
Known gap, confirmed by testing, not yet root-caused: neither
container could reach the network's own gateway IP (ICMP or TCP),
despite ARP resolving correctly -- ruling out an L2/relay framing
problem. The identical bridge/subnet/host reached via veth instead
works perfectly, ruling out every environment-level explanation that
would affect both paths equally. rp_filter=0 (host-tap, bridge, and
`all` scope) was tried and confirmed not to fix it. Diagnosing further
needs host tools (tcpdump, direct iptables/sysctl inspection) this
session's doas access doesn't permit. Peer-to-peer connectivity (an
intern network's whole purpose) is solid; gateway/outside reachability
through this fallback needs re-verification, ideally on the actual
veth-less target device, before being relied on.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
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
Prep work for a tap+userspace-relay fallback: the real target device
supports tun/tap but not veth (CONFIG_VETH stripped from its kernel),
so -n/--network joins there can't use the veth-pair mechanism as-is.
probe_veth_support() (network_bridge.{h,cpp}) detects kernel veth
support the same way bwrap.cpp's kernel_supports_namespace() probes
namespace types: fork, unshare(CLONE_NEWNET) into a throwaway
namespace, try `ip link add ... type veth ...` there. should_use_veth()
combines that with a new per-network NetworkEntry::veth policy flag
(default true, config_file.h), mirroring how namespace_policy_enabled()
already combines kernel capability with policy for --unshare-xxx.
--no-veth at network-creation time (-n <name> --extern|--intern
--no-veth) sets veth: false, forcing the not-yet-built tap+relay
fallback even on a veth-capable kernel like this dev machine -- lets
that path be exercised here without the actual veth-less hardware.
Verified as root via the doas rule: probe_veth_support() returns true
on this dev machine (a real veth pair is created successfully), and
--no-veth correctly persists veth: false while should_use_veth() still
returns false regardless of kernel support.
The fallback itself (network_tap_relay.{h,cpp}) isn't wired in yet --
a --no-veth network simply has no way to join a container until that
lands.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
net was deliberately excluded from exec_session.cpp's joinable_namespaces
list, written back when this project never isolated networking at
all. Now that -r/--run sometimes does (whenever -n/--network was
used), -x/--exec'ing into such a session saw the host's own network
stack instead of the container's -- confirmed directly: it showed the
host's unrelated listening ports and couldn't reach the container's
own service on 127.0.0.1.
Fixed by joining net the same way -x/--exec already joins
mnt/uts/ipc/pid/cgroup/user when they differ from the caller's own --
not required, so a session with no isolated net namespace (never
joined any network) is unaffected, the entry is just skipped like any
other identical-to-ours namespace.
Verified as root (via a scoped doas rule): execing into a session
joined to an extern network now correctly shows its own eth0 and
reaches its own service on 127.0.0.1; execing into a plain session
with no -n is unaffected.
Commit 6/6 (final) of the network isolation feature
(docs/networking-design.md). Landed narrower in scope than originally
planned once the actual orphan surface was worked out: veths need no
sweep at all (the kernel tears down an entire pair once either end's
namespace is destroyed, so nothing survives a crash), and bridges/
persistent namespaces are deliberately meant to always outlive any one
session (the whole point of the reboot-reconciliation design already
built in commit 3). Only -p's iptables rules are host-global state
with no automatic teardown, so that's the entire sweep.
port_forward.{h,cpp}: record_port_forwards()/remove_port_forward_record()
persist a session's active mappings to $XDG_STATE_HOME/slocker-lite/
port-forwards/<container_name>-<pid> -- the exact same naming scheme
as session_pid_file_path() (pid_file.h), so clean_stale_port_forwards()
can cross-reference filenames directly against list_sessions()'s own
liveness check rather than re-deriving it. --clean-processes
(commands.cpp) now also runs this sweep alongside its existing
stale-pid-file one.
Also fixed a real gap in commands.cpp caught while wiring this up:
on_bwrap_pid_known was only set when daemonize_flag ||
!network_specs.empty(), so a bare "-p ... " with no -n or -D would
silently never even attempt to run -- no error, nothing logged, the
whole flag just quietly did nothing.
Verified via a controlled scratch test rather than a literal kill -9
on a root-owned slocker-lite process (not achievable through this
session's scoped doas rule, which only permits running slocker-lite
itself): a fabricated stale port-forward record was correctly
detected, its rule-removal attempted, and its file cleaned up, while a
record matching a real running session was left completely untouched.
Commit 5/6 of the network isolation feature (docs/networking-design.md).
port_forward.{h,cpp}: parse_port_forward_spec() parses
"[<network>:]<host-port>:<container-port>"; add_port_forward()
resolves the network (by name, or the container's sole extern network)
against join_networks()'s result and adds the DNAT/FORWARD rules;
remove_port_forward() undoes them. join_networks() (network_join.{h,cpp})
now returns the joined networks with their assigned IPs (was a bare
bool) so port-forward setup knows where to send traffic. -p requires
-r, is repeatable, network names may no longer contain ':' (needed to
keep the spec syntax unambiguous -- is_valid_network_name(),
network_subnet.h).
Two real corrections from testing, not assumed:
- The DNAT rule needs both nat PREROUTING and nat OUTPUT -- PREROUTING
never sees locally-generated packets (e.g. curl run on the same
host), only OUTPUT does. PREROUTING-only left the host's own real IP
connection-refused despite the container being directly reachable.
- curl localhost:<port> still doesn't work even with both chains --
a separate problem, NAT hairpinning: the container sees an inbound
packet claiming a loopback source arriving on a non-loopback
interface and drops it as martian. A net.ipv4.conf.*.route_localnet
sysctl was tried and confirmed not to fix this alone, then removed
rather than left in as dead code. Not solved here (would need scoped
source masquerading or a userland proxy); curl <host's real IP> is
the actually-relevant, verified-working path for real clients.
Also surfaced (unrelated to -p, found while testing it, not fixed
here): -x/--exec doesn't join the net namespace -- written when this
project never isolated networking at all -- so it currently sees the
host's own network stack instead of a network-isolated session's own.
Verified end-to-end as root (via a scoped doas rule): a container
serving HTTP on an extern network with -p 8080:80 was reachable via
curl <host's real IP>:8080; the rule was confirmed gone after the
session was killed.
Commit 4/6 of the network isolation feature (docs/networking-design.md).
network_join.{h,cpp}: join_networks() waits (bounded, polling) for the
session's own isolated net namespace to exist -- bwrap's outer pid
never enters it, and the on_bwrap_pid_known callback fires before
bwrap has even started its own setup -- then per network: ensures it's
provisioned, creates a veth pair where the bridge lives, attaches the
bridge side, moves the container side into the session's namespace as
eth<N>, assigns it a free address, brings it up, and (extern only)
replaces the default route.
sandbox_process.{h,cpp}: generalized pid_namespace_isolated() into
namespace_isolated(outer_pid, ns_pid, ns_type) so this can reuse it for
"net" instead of "pid".
network_subnet.{h,cpp}: gateway-address logic generalized into
host_address(af, cidr, n) shared by the existing gateway functions
(n=1) and new ipv4/ipv6_host_address() (n=2, 3, ... for containers).
network_bridge.{h,cpp}: bridge_name()/wrap_for_network() exported so
network_join.cpp can attach to the exact bridge/namespace
network_bridge.cpp provisioned.
commands.cpp: run_container() validates network namespace isolation is
actually available before ever starting bwrap (can't be degraded the
way --hostname is), then joins networks from on_bwrap_pid_known,
before the -D/--daemonize report is sent.
Real bug caught by testing, fixed before landing: address allocation
first tried to detect in-use IPs via `ip addr show master <bridge>`,
but a container's address lives on its own interface inside its own
private namespace, invisible from the bridge's namespace -- two
concurrent containers on the same network both got 10.168.0.2. Fixed
with a flock-based per-address lease file (same technique pid_file.h's
SessionLock already uses), verified with two containers running
simultaneously getting distinct addresses.
Known, documented limitation: a very short-lived sandboxed command can
exit before the namespace-wait polling catches up (bwrap execs
straight into the target with no hook point in between namespace
creation and exec); real long-running networked services are
unaffected.
Verified end-to-end as root (via a scoped doas rule): two containers
on the same intern network got distinct addresses and could ping each
other; an intern-joined container could not reach the outside; an
extern-joined container reached the real internet through NAT; a
container joining both simultaneously got two working interfaces.
Commit 3/6 of the network isolation feature (docs/networking-design.md).
network_bridge.{h,cpp}: ensure_network_provisioned() stands up a
network's real bridge -- idempotent (checks `ip link show` first), so
this doubles as the reboot-reconciliation mechanism, no separate code
path. extern's bridge lives in the host's own root namespace with
net.ipv4.ip_forward + an iptables MASQUERADE rule for the subnet (+
IPv6 equivalents if enabled); intern's bridge lives inside its own
dedicated persistent namespace (persistent_netns.h) with no forwarding
or NAT at all -- a structural isolation boundary, not just a missing
rule. Bridge names are a deterministic FNV-1a hash of the network name
(not std::hash, whose value isn't guaranteed stable across a rebuild),
kept under Linux's 15-char interface name limit.
network_subnet.{h,cpp} gains ipv4_gateway_address()/
ipv6_gateway_address() (mask a CIDR to its network address, +1 for the
bridge's own ".1"). create_network_command() now calls
ensure_network_provisioned() before persisting the config entry -- a
network that fails to provision isn't saved.
Verified end-to-end as root (via a scoped doas rule): a real extern
network's bridge/gateway IPs/forwarding/NAT rule, and a real intern
network's isolated bridge with neither, both came up correctly; test
networks removed via --delete-network afterward.
Commit 2/6 of the network isolation feature (docs/networking-design.md).
persistent_netns.{h,cpp}: create/verify/remove a network namespace kept
alive with no process in it, the way `ip netns add` does (fork a child,
unshare(CLONE_NEWNET), bind-mount its /proc/self/ns/net onto a
persistent path, exit -- the bind mount keeps it alive). Root-only
(CAP_SYS_ADMIN for the bind mount), best-effort like this project's
other host-state primitives. Not wired into -n/--network yet.
xdg_state_dir() (pid_file.cpp) moved out of its anonymous namespace so
this file can reuse the same $XDG_STATE_HOME resolution rather than a
second, drifting copy.
-t/--test now exercises the create/verify/remove cycle (skipped with a
message, not a failure, when not root) -- confirmed working via doas.
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.
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
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
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>
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>
-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>
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>