compose_state_file_path()/record_compose_services() write one line per
started service ("<service_name> <container_name> <pid>") to
$XDG_STATE_HOME/slocker-lite/compose/<sanitized-absolute-compose-path>,
keyed by the compose file's own resolved path -- the same state-file
naming pattern port_forward.h's/network_tap_relay.h's own crash-orphan
records already use, so a future -d/--down implementation can find exactly
which sessions a given -u/--up started. Overwrites any previous run's own
record for the same file, a known limitation until -d/--down itself exists
to keep the two in sync.
Verified manually end to end: -u against a 2-service, depends_on-linked
compose file mounts, provisions, starts both daemonized in order, and
writes a correct state file; --kill against the recorded pids leaves no
processes behind.
This completes the -u/--up sequence requested (collect images, mount all
before starting any, provision networks/volumes, start in dependency
order backgrounded, record pids). Not yet implemented: -d/--down itself
(still a stub), port-forwarding/--user/--group for compose services, and
re-verification of network provisioning specifically against real root
(the volume/single-network-free path was verified rootless; network
provisioning reuses already-proven create_network_command()/
ensure_network_provisioned() as-is).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
start_compose_services() topologically orders services by depends_on
(Kahn's algorithm -- load_compose_file() already guarantees the graph is
acyclic) and starts each one daemonized (as if -D/--daemonize had been
given), against its own already-mounted image, provisioned networks
(project-prefixed names resolved via the mapping from step 3), and
provisioned named volumes. A service whose dependency failed to start (or
was itself skipped) is skipped too, logged clearly, and never started
against a dependency that isn't actually running.
A service's session identity (pid-file/log/cgroup naming) is its explicit
container_name: if given, else "<project>_<service>"; its DNS hostname
(what sibling services resolve it by) is instead its explicit
container_name: or bare service name, never project-prefixed -- matching
real Compose's own service-name resolution. Port-forwarding and explicit
--user/--group aren't wired up for compose services yet.
Verified manually (rootless): a 2-service compose file with a depends_on
edge starts both daemonized, in order, with correct --hostname each;
--list-processes/`ps` confirm both genuinely running; --kill cleanly stops
both with no leftover processes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
run_container() (-r/--run) still decides container_name, handles the
-D/--daemonize fork (which must happen before mounting so the log file can
be named from its first line), and calls mount_image() itself. Everything
after that -- volume/env/user resolution, namespace policy, network/
port-forward/DNS setup, running bwrap, and unmount/cleanup -- moves into a
new, exported run_mounted_container(), taking an already-mounted image
instead of mounting its own. No behavior change for -r/--run itself; this
is prep for the compose orchestrator's own dependency-ordered starting
(next commit), which mounts every service's image up front and needs to
run each one against its own already-mounted image rather than a second
copy of ~200 lines of this logic.
Verified: meson test clean, [integration][net]~[root] clean (exercises
this exact path via test_rootless_run.cpp), and a direct manual `-r
images/busybox.tar -- echo` smoke test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Exports create_volume_command()/create_network_command() (pure refactor)
so the orchestrator can reuse -v/--volume's and -n/--network's own
provisioning logic directly. New provision_compose_networks_and_volumes()
creates every managed (non-external) network/volume the compose file
declares, project-name-prefixed (compose_project_name(), derived the same
way real Docker Compose derives its own default project name -- the
compose file's parent directory basename) so two different compose
projects can't collide in slocker-lite's single, flat networks/volumes
namespace. Finding one that already exists is deliberately not an error --
reused as-is, per the user's own explicit direction (e.g. left over from an
interrupted previous -u/--up run). external: true networks map to
themselves, unprefixed, since they're real pre-existing networks by
definition.
Verified manually (rootless): volume provisioning creates the
project-prefixed directory/config entry, and a second -u run against the
same compose file correctly reuses it without error. Network provisioning
reuses ensure_network_provisioned()/create_network_command() as-is (no new
logic there) but wasn't separately re-verified live, since it needs root.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
mount_compose_images() mounts every resolved image up front, deliberately
before starting any service -- mounting can be slow on some devices, so
this avoids leaving earlier services already running while a later one is
still mounting. On any failure partway through, unmounts+cleans up every
image already mounted in the same call before returning, so a failed
-u/--up never leaves a partial mount set behind.
Verified manually: single and multi-service mounts each produce distinct
layer IDs; the successful path leaves images mounted deliberately, since
the next step's dependency-ordered starting still needs them mounted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Exports MountedImage/mount_image() from commands.cpp's own anonymous
namespace (pure refactor, no behavior change) so the new orchestrator can
reuse them. New src/compose_orchestrator.{h,cpp} adds
resolve_compose_images(), matching each service's image: reference against
list_oci_images() -- the same function -l/--list-images already uses --
before anything is mounted or started, wired into -u/--up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Both take a required OCI images directory plus an optional compose file
name (defaulting to "compose.yaml", resolved relative to the cwd) via the
same manual two-token consumption -v/--volume already uses, just with the
second token optional. The stub commands aren't no-ops: they resolve the
images directory, load and validate the compose file through the existing
load_compose_file()/validate_compose_external_state(), and print a summary
-- confirming the CLI wiring and parser work end to end -- before logging
that actual orchestration isn't implemented yet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Separate from load_compose_file() (which stays pure YAML validation with no
host-state dependency): checks every env_file exists as a readable regular
file, and every network marked external: true already exists in the real
persistent.yaml. Fail-fast, same convention as load_compose_file()'s own
cross-validation. Bind-mount host directories are deliberately not checked
here, since resolve_volume_mount() already auto-creates a missing one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
- depends_on cycles (not just direct self-reference): a three-color DFS
over the dependency graph reports the actual cycle path.
- Duplicate host-port/protocol across (or within) services: two services
both publishing the same (host_port, protocol) would only ever leave one
reachable, even though both DNAT rules would get added later.
- container_name colliding with another service's own implicit name, not
just two explicit container_names matching each other.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
load_compose_file() (src/compose_file.{h,cpp}) parses and validates
services/networks/volumes -- unrecognized keys are silently ignored, but a
malformed value for a supported key is a hard parse error, since a Compose
file describes an actual deployment rather than being a version-spanning
settings file. Confirmed no dedicated C++ library for this exists, so it's
hand-written against the already-present libyaml dependency rather than
pulling in the official JSON Schema plus a validator library.
scalar_value()/find_in_mapping() move out of config_file.cpp's own
.cpp-local pair into a new shared src/yaml_util.{h,cpp} (plus a new
sequence_items(), for Compose's list-valued keys) so both files share one
YAML-traversal implementation instead of drifting copies.
tests/unit/test_compose_file.cpp covers every supported field/form and
validation error against small hand-written snippets -- the checked-in
test-compose/compose.yaml skeleton is reserved for later integration tests
once an orchestrator exists, not these unit tests.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
create_session_cgroup() used to be called from run_bwrap()'s on_start
callback, in the parent, concurrently with the just-forked child execing
into bwrap and bwrap then doing its own internal clone() of the sandboxed
target. Without --unshare-pid, bwrap has little enough setup work to do
that it could reliably win that race, cloning its target before the
parent's own write into cgroup.procs completed -- leaving that target, and
everything it later spawns, permanently outside the tracked cgroup, so the
post-exit sweep found nothing to reap. Found via the user's own request to
test "pid namespace off, cgroup on" as root: confirmed directly by
inspecting cgroup.procs mid-session, showing only bwrap's own pid.
Fixed by giving run_process_foreground() a new before_exec hook, invoked in
the child synchronously right before execvp() -- the child cannot proceed
to exec (and thus cannot trigger any of bwrap's own internal forking) until
this has already returned, closing the race structurally rather than by
timing luck. run_bwrap() now creates the session cgroup there instead of in
on_start; the parent side just reconstructs the deterministic path
unconditionally, since the downstream sweep/cleanup functions already
tolerate a nonexistent directory gracefully either way.
Also fixes a false positive found while verifying this: the regression
test's own process-matching did a substring search across a whole cmdline
blob, which matched an unrelated manual `pkill -f 'sleep 137'` diagnostic
command run by hand during the investigation. Tightened to an exact
argv[0]/argv[1] match.
Finally, the regression test now SKIP()s (instead of failing) when neither
a pid namespace nor a working session cgroup is available for the current
effective config -- a documented, known residual limitation, not a
regression -- checked directly via two new helpers rather than assumed from
e.g. geteuid().
run_self_tests() now takes the same effective AppConfig any other command
gets and stashes it into a new g_test_app_config global (tests/support/
fixtures.h) before Catch2 runs anything. test_rootless_run.cpp's own
run_in_fixture() reads a copy of it instead of a hardcoded default
AppConfig{}, so -c actually reaches that test's container creation.
Verified: a config with every unshare-*/with-* key set to false, used via
-c <file> -t -- "[integration][net]~[root]", flips all 3 of that file's
container-creating tests to failing -- including the nohup-straggler
regression test, since with neither a pid namespace nor a cgroup nothing
reaps the backgrounded process -- while [unit] and non-networking
[integration] tests are completely unaffected, as expected.
config.yaml now holds only the global section (log-level, unshare-*,
with-veth, with-ipv6); a new persistent.yaml holds volumes/networks.
load_config_file()/write_config_file() are replaced by
load_global_config()/load_persistent_config()/write_global_config()/
write_persistent_config(), each touching only their own file.
-c/--config-file <path> lets one invocation use an alternate file for the
global section only -- persistent.yaml is always the one fixed path,
regardless of -c, so an experiment can never affect real volumes/networks
(a -c file's own volumes/networks, if any, are simply never read either).
A -c path that doesn't exist is a hard error, unlike the default path's
existing missing-file leniency.
migrate_legacy_config_if_needed() moves volumes/networks out of an
old-format config.yaml into persistent.yaml on first run after upgrading,
always against the fixed default paths regardless of -c. A name collision
aborts the migration for that run (touching neither file) rather than
risking data loss.
Required reordering main() to parse CLI args before loading config (so
-c's value is known first) -- ParsedArgs::log_level_flag_given tracks
whether --log-level was already given so the config file's own log-level
doesn't clobber it despite the reversed call order.
Now that -r/--run and -x/--exec cover normal use, --mount/--umount/--cleanup
are debug-only escape hatches not worth a short letter. Reassigned their
long_options codes to long-option-only constants (options::mount/umount/
cleanup) and dropped m:/u:/c: from getopt_long's own short-options string.
Updated the fixture smoke test (tests/run_test.py) and docs, which invoked
-m/-u/-c directly.
Replaces --no-ipv6/--no-veth (plain flags) with --with-ipv6/--with-veth,
each taking an explicit true/false value (e.g. --with-veth=false), parsed
via the same parse_bool_flag() the config file itself already uses (now
exported from config_file.h so cli_args.cpp can reuse it).
create_network_command() now resolves ipv6/veth as CLI flag -> config's own
global.with-ipv6/global.with-veth -> true, so a host that always wants the
tap+relay fallback (or no IPv6) can set it once in the config instead of
passing the flag on every network creation. -w/--write-config fills in both
new keys like the existing six unshare-* bools.
A container process that daemonizes/double-forks and setsid()'s away can
escape bwrap's own pid tree and outlive the session, whether it ends via a
normal exit, a forwarded SIGINT/SIGTERM, or -D/--daemonize -- --kill's own
cgroup-based sweep already reaches such a straggler for a still-running
session, but nothing ran that sweep automatically once the session itself
ended.
Export kill_via_cgroup() (previously kill_session.cpp-local) and call it
from run_bwrap(), right after run_process_foreground() returns and before
the session cgroup is removed, whenever anything is still left in it -- runs
unconditionally regardless of why bwrap exited, and covers -D for free since
it re-enters this same run_bwrap() call from within the daemonized child.
One file per source area, exercising the pure/isolated parsing and CIDR-
arithmetic functions already exposed via headers with no side effects --
parse_port_forward_spec() (protocol suffix parsing/validation, network
resolution left to add_port_forward()), resolve_env_specs() (literal and
--env-file parsing, ordering, error cases -- a small local RAII ScratchFile
helper writes the --env-file fixtures under /tmp), network_subnet.h's CIDR
validation/overlap/allocation/address-arithmetic functions, and parse_args()
itself against synthetic argv's.
Two real bugs found running parse_args() repeatedly in one process (never
possible before -- a real invocation only ever calls it once), not
assumed:
1. getopt_long's scanning position (`optind`) is process-global and never
reset, so a second parse_args() call would silently resume scanning
wherever the first one left off. Fixing this alone (optind = 1) wasn't
enough on its own, either --
2. -h/-V return out of the getopt_long loop early (their own `return 0`
case), before a call ever completes its scan and lets getopt_long null
out its own private `nextchar` pointer -- the *next* parse_args() call
then resumed scanning through that stale pointer into the *previous*
call's already-destroyed argv strings, misparsing its own fresh argv.
glibc documents `optind = 0` (not 1) as the "fully reinitialize private
state before rescanning a new argv" signal; switching to it fixed this
for good, confirmed by 3 repeated runs each in both random and
deterministic (--order lex) Catch2 ordering with zero flakiness either
way.
Neither bug could ever have surfaced in real usage (parse_args() is only
ever called once per process from main()) -- purely a testability gap the
new unit tests exposed, now fixed at the source rather than worked around
in the test file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
-t now accepts trailing args (mirroring -r/-x's own trailing-command
capture, ParsedArgs::test_args), forwarded unmodified to Catch2. Bare -t
runs every registered TEST_CASE (currently none); a tag expression after
'--' selects a subset once real tests land, e.g. -t -- "[unit]". The '--'
matters since Catch2's own -r/--reporter and -c/--section collide with
slocker-lite's -r/--run and -c/--cleanup.
Guarded by config.h's ENABLE_TESTS macro (from Meson's existing
enable_tests option, already linking catch2_dep into the binary but never
actually used until now) -- a -Denable_tests=false build prints a clear
message instead of failing to link.
The 3 hand-rolled root-only self-tests this replaced (persistent-netns,
tap-relay, dns-resolver) are being ported to proper tagged TEST_CASEs in
a follow-up commit, not lost.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
run_process_foreground() previously collapsed "child killed by a signal"
into the exact same exit_code == -1 sentinel as "fork() itself failed",
triggering commands.cpp's "failed to run bwrap" spdlog::error for the
ordinary case of Ctrl-C (SIGINT, forwarded to bwrap by this project's own
forward_signal_to_foreground_child handler) or `kill`/--kill (SIGTERM)
ending a foreground -r/--run session -- both ways this project deliberately
supports stopping one cleanly, not failures.
Now distinguishes WIFSIGNALED from a real fork() failure, returning
128+signal (the same convention a shell itself uses for $? after a
signal-killed job) instead of -1. SIGINT/SIGTERM specifically log at debug
(invisible at the default log level) rather than warn; any other signal
still warns, since that's a genuine, unexpected crash. commands.cpp's own
`exit_code < 0` check is now accurate -- it only ever fires on a genuine
fork() failure.
Verified directly (rootless, this dev machine): SIGINT and SIGTERM against
a running foreground session both now exit 130/143 respectively with no
error or warning logged at the default level (only debug), unmount/cleanup
still ran either way; a genuine failure (nonexistent command inside the
sandbox) still warns and exits 1, unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Extends the spec syntax with an optional /tcp|udp suffix
([<network>:]<host-port>:<container-port>[/proto]), defaulting to tcp so
every existing -p spec keeps working unchanged. PortForwardSpec and
ActivePortForward carry the resolved PortForwardProtocol; add/remove_port_forward()
use it to pick iptables' own -p tcp/-p udp for both the DNAT and FORWARD
ACCEPT rules. The port-forward state file gains a 4th field for the
protocol; clean_stale_port_forwards() parses per-line rather than chaining
extraction operators, so a pre-UDP 3-field record still gets its rule
removed instead of silently short-circuiting.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Root cause of two real bugs reported from the real target device: the
per-session DNS resolver failing to start ("dnsmasq: failed to open
pidfile .local/state/slocker-lite/dns-resolvers/... No such file or
directory" -- a relative path, not absolute), and, earlier, two
slocker-lite invocations from different working directories silently
ending up with two completely disjoint config.yaml/state trees.
xdg_state_dir()/config_file_path() built
`std::filesystem::path(home ? home : "") / ".local" / "state"` whenever
$HOME wasn't available to the exact invoking process -- which resolves
to a relative path ("./.local/state"), not an absolute one, with no
error. Harmless as long as every reader/writer shared the same process
and cwd, but broke outright once dnsmasq (network_dns.cpp), a genuinely
separate process, tried to open a --pid-file built from that same
relative path.
Fixed two ways: a new resolve_home_dir() (pid_file.{h,cpp}) falls back
to the passwd database entry for the current uid when $HOME itself is
unset, the same fallback well-behaved tools like su/sshd already use;
and both xdg_state_dir() and config_file_path() now make their own
final return value absolute (std::filesystem::absolute()) regardless of
which piece was relative, covering a relative
$XDG_STATE_HOME/$XDG_CONFIG_HOME override too, not just an unset $HOME.
Verified locally: with $HOME unset entirely, -w now correctly resolves
to the real home directory via the passwd fallback instead of a
cwd-relative path; with XDG_CONFIG_HOME set to a relative value, the
result is still a proper absolute path (resolved against cwd), not a
bare relative one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Containers on a shared -n <network> can now resolve each other by the
name given via --hostname on any network they share, plus
host.containers.internal (podman's convention) resolving to the first
extern network's own gateway, if any.
One dnsmasq instance per session, not per network: with one instance per
network instead, a container joined to two networks would list two
nameserver lines in resolv.conf, and standard stub resolvers don't fall
through to the next nameserver on NXDOMAIN, only on timeout -- a name
that exists only on the second network would silently fail to resolve.
Running one instance per session, entered into the container's own
network namespace and bound to 127.0.0.1:53, configured (via dnsmasq's
own repeatable --hostsdir) to watch every network that specific
container joined, avoids the problem entirely.
Three real bugs found via direct testing while building this, not
assumed:
- dnsmasq only writes --pid-file while actually daemonizing;
-d/--no-daemon suppresses it, so the startup-confirmation poll needs
to read the real daemon pid back from the file rather than assume the
forked/exec'd pid is it.
- dnsmasq drops root privileges to an unprivileged user by default,
which then couldn't read $XDG_STATE_HOME (under /root, mode 0700) at
all -- fixed with an explicit --user=root --group=root (tracked as a
security follow-up in TODO.md: run it as a low-privilege user instead
and relocate the files it needs).
- an AAAA query for a name with only an A record came back REFUSED
(breaking any getaddrinfo()-based tool, e.g. ping, that queries both
types together) unless --filter-AAAA is given; host.containers.internal
additionally needed to be served via a plain --addn-hosts file rather
than dnsmasq's own --address=/name/ip option, which stayed REFUSED for
AAAA even with --filter-AAAA.
Best-effort throughout: gated on dnsmasq actually being found in PATH,
with a new --no-dns opt-out. Verified end-to-end both on this dev
machine and on the real Android target device: two containers on a
shared network resolve each other (including self-resolution) and can
ping by name; a container joined to both an intern and an extern
network resolves both its intern peer and host.containers.internal
simultaneously (the specific scenario the per-network-instance design
would have broken).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
A server listening on an extern network wasn't reachable via -p at all,
from the host or from a real outside client -- confirmed by direct
testing: since extern's bridge moved into a private namespace (the
earlier connectivity fix), host root had no route to the container
subnet whatsoever (ip route get fell through to the LAN default gateway
instead), so -p's own DNAT rule, which targets the container's real IP
directly, had nowhere to route the rewritten packet.
Fixed with two pieces in ensure_uplink_provisioned(), both confirmed
necessary by direct testing -- the same "route alone isn't enough on
Android" lesson the uplink's own outbound/return-path ip rules already
learned: a host-root route to the container subnet through the uplink,
plus a matching ip rule routing traffic to that subnet into main (without
it, Android's own lower-priority-number policy routing -- a generic
fwmark 0/0x10000 catch-all among them -- intercepts the packet into an
unrelated table before rule evaluation ever reaches main, so the route
alone is silently never consulted).
Also fixes a related robustness bug found while testing this: a failed
ensure_uplink_provisioned() only stopped the tap relay, leaving every
already-added ip rule/iptables piece (deterministic, hash-derived names)
live on the host -- a first failed attempt then made every later attempt
for the same network name fail identically and permanently, until a
manual fix or a full device reboot. Fixed by recording the relay's pid to
the uplink state file as soon as it's known, so a failure can roll back
via the same teardown_uplink_state() a real --delete-network-full would
use, instead of a partial, drifting copy of its cleanup logic.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Joining a container to two or more networks in one -r/--run left every
network after the first permanently unreachable, from the very start of
the session, regardless of extern/intern -- confirmed via strace -f on
the real target device.
create_tap_relay() returns, and its relay starts polling, the instant the
container-side tap device is *created*; join_one_network() (a separate
process) still has its own ip addr add/ip link set <if> up steps left to
run afterward for that same device. For the first network joined this
race is narrow enough that no frame ever arrives first; for the second
(and any later) network, something reliably delivers a frame before the
interface is up, and the previous code treated any write() failure as
fatal -- exiting for good on that single EIO, breaking the network for
the rest of the session.
Fixed by retrying specifically on EIO/ENETDOWN (both mean "not up yet",
a startup race, not a torn-down namespace) with a short bounded backoff
(up to 50 * 20ms = 1s) instead of exiting immediately.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
extern networks had no connectivity at all on the real Android target
device -- confirmed by direct on-device testing (see
docs/networking-design.md's own writeup) that this wasn't a code bug in
the tap+relay mechanism itself, but the bridge's own location: extern's
bridge lived directly in the host's root network namespace, while
intern's already lived in its own dedicated persistent namespace and
always worked correctly. Relocating extern's bridge into the same kind
of private namespace fixed gateway reachability (both IPv4 and IPv6)
immediately, most likely because Android's own netd-managed
iptables/routing policy applies only to the root namespace and never
touches a network that's genuinely isolated in its own.
Doing that alone loses outside connectivity by construction -- a
genuinely isolated namespace has no uplink at all. Restoring it needed a
second, narrow mechanism: a point-to-point tap+relay link (reusing
network_tap_relay.h's existing primitive with a new
attach_host_side_to_bridge=false mode -- a plain routed link, not another
bridge port) between the private namespace and the host's root namespace,
on its own small deterministic transit subnet, with NAT applied only in
host root.
Three further pieces, each found and confirmed by direct real-device
testing (not assumed), were independently necessary for that uplink to
actually carry traffic -- dropping any one reproduces the original "no
connectivity to anything" symptom:
1. The iptables FORWARD accept rule for the uplink must be *inserted at
the front* of the chain (`-I FORWARD 1`), not appended. Android's own
FORWARD chain unconditionally jumps through several subordinate
chains first, one of which (tetherctrl_FORWARD, its tethering
control chain) contains an unconditional DROP with no match criteria
at all -- an appended rule is structurally unreachable, since DROP is
already a terminal verdict long before a packet gets that far.
2. An outbound `ip rule`, since a genuinely *forwarded* packet doesn't
get the same routing treatment an interactive command does on this
device: every `ip rule` landing in a table with a real route requires
`iif lo` (locally-generated traffic only), so forwarded traffic falls
through to a generic catch-all landing in a routeless table and never
even reaches the FORWARD chain. Fixed by discovering, dynamically
(via the same `ip route get` trick, not hardcoded), whichever table
the host is actually using for its own real traffic right now, and
routing the uplink's own traffic into it.
3. A **return-path** `ip rule`, mirroring #2 for the reverse direction --
confirmed via live /proc/net/nf_conntrack inspection during a real
request that the outbound leg was already fully working (a genuine,
tracked reply, not just a locally-generated packet succeeding), but
the reply -- arriving back on the real interface and correctly
de-MASQUERADEd to the transit-subnet address by conntrack -- still had
nowhere to go: same "falls into a routeless table" failure, just for
the destination address on the way back in.
IPv6 outside connectivity remains local-only (same-bridge reachability),
same as intern's IPv6 side already was -- deliberately, not a bug:
confirmed on the real device that neither ip6tables nor nftables can even
create an IPv6 NAT table on that kernel at all ("Not supported"), and the
device's own global IPv6 prefix rotates every ~10 minutes, too short-lived
to build stable addressing on top of via the alternative (NDP proxying).
Verified end-to-end on the real target device, from a clean state:
gateway IPv4 0% loss, gateway IPv6 0% loss, and a real outside destination
(8.8.8.8) 0% loss (3/3 replies) through a container on a freshly created
extern network. Also verified on this dev machine: self-test, a plain
veth-capable join, the --no-veth tap+relay fallback, and intern (still
unaffected -- no uplink, "Network unreachable" for outside as intended).
self_test.cpp's own tap-relay test needed a small matching update: it
constructs its own throwaway extern NetworkEntry and calls
create_tap_relay() directly, which now unconditionally enters the
network's persistent namespace first (both kinds, matching
wrap_for_network()'s own change) -- the test now provisions one for its
own throwaway network the same way a real network would be.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Real-device testing showed the previous retry-based fix (ad30f93) was
insufficient: a bare ioctl(TUNSETIFF)-created tap device (no IFF_PERSIST)
could work for one command and then vanish for the next on that kernel,
not just be slow to appear. Both tap devices are now created ahead of
time via an external `ip tuntap add dev <name> mode tap` before being
attached to via open_tap(), making them genuine persistent netdevices
with no tie to any fd/process lifetime -- the same technique QEMU/libvirt
use. stop_tap_relay() now explicitly `ip link del`s the host-side device
since it no longer disappears on its own; the crash-orphan sweep records
each relay's network kind/name too so it can do the same for orphans.
All retry logic (network_join.cpp's run_with_retry(), self_test.cpp's
wait_for_container_device_visible()) is removed as no longer needed.
self_test.cpp's post-teardown assertions updated to match: the host-side
device is now expected gone after stop_tap_relay(), while the
container-side device is expected to persist (it only goes away once its
own namespace is torn down, not merely because the relay stopped).
Verified end-to-end on the dev machine with --no-veth forcing the
fallback: eth0 stayed visible and usable across repeated commands with
no disappearance, and both gateway and outside (8.8.8.8) ping succeeded
at 0% loss.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
A user's log from the real target device showed nsenter'd `ip addr
add ... dev eth0` failing with "Cannot find device" immediately after
network_tap_relay.h's relay had already created it -- confirmed by
hand that retrying the whole session a few times eventually worked.
join_one_network() now retries (bounded, ~500ms, quiet until final
success/give-up) the three steps that touch the just-created container
interface -- IPv4 address, IPv6 address, bringing it up -- via a new
run_with_retry() instead of plain run().
A tempting first fix was investigated and ruled out by direct A/B
testing, not just reasoned about: having the relay itself self-verify
the device is visible (a same-process check, immediately after
creating it, before ever reporting success) was tried first, in
relay_child_main(). It made things categorically worse: the
container-side device became permanently invisible to every external
nsenter afterward, 100% reproducibly (confirmed with a 10-second retry
budget -- never once became visible), on a mechanism that had
otherwise worked correctly and instantly, zero retries needed, on
every real session tested earlier the same day -- including a
from-scratch self-test reproduction that had passed reliably many
times before this one change, and immediately went back to passing
once it was reverted. Root cause not fully understood (something about
forking a subprocess that inherits the tap fd -- deliberately not
O_CLOEXEC -- while still holding it open, immediately after device
creation, appears to corrupt the device's external visibility on this
kernel specifically), but the fix is unambiguous: never add an
internal, same-process/fd-holding self-check to the relay; only the
external, separate-process retry is safe.
network_tap_relay.cpp ends up completely unchanged -- the actual fix
lives entirely in network_join.cpp's own retry. self_test.cpp's own
container-visibility check needed the same external retry treatment,
for the same underlying reason.
Verified as root via the doas rule: three separate real --no-veth
sessions all succeeded getting eth0 on the first attempt (no retries
triggered), and the self-test passes reliably across repeated runs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
The old fd00:168:0::/48 base was never actually generated via RFC
4193's randomization procedure -- just a memorable placeholder chosen
to visibly pair with the IPv4 10.168.x.x scheme. Replaced with
fdf0:f243:f06f::/48, a real randomly-generated ULA prefix.
That /48 consumes all three "identity" hextets a ULA prefix has room
for, leaving only the subnet-id (4th) hextet -- the same one the
per-network auto-allocation index already lived in -- with nowhere
left to also place a fixed "168" marker without colliding with either
the random prefix or the index itself. Per the user's own choice
(offered two options): the per-network index is now offset by a
constant 168 instead of matching IPv4's index number-for-number, so
the first auto-allocated network's IPv6 block is
fdf0:f243:f06f:168::/64 (paired with 10.168.0.0/24), second is
...:169::/64 (paired with 10.168.1.0/24), and so on -- deterministic
and still visibly project-stamped, just via a constant offset instead
of an identical digit.
network_subnet.cpp's new ipv6_ula_prefix48/ipv6_subnet_id_base
constants hold the new prefix and offset.
Verified as root via the doas rule: two freshly created extern
networks got fdf0:f243:f06f:168::/64 and fdf0:f243:f06f:169::/64
exactly as expected, correctly paired with their IPv4 subnets.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
The real target device's ip6tables build has no MASQUERADE target,
breaking extern network provisioning whenever IPv6 was enabled. Rather
than work around that gap, dropped the ip6tables MASQUERADE rule
entirely, unconditionally, on both the veth and tap+relay paths --
it was never correct IPv6 design to begin with. The fd00::/8 ULA
addresses this project auto-allocates (network_subnet.h) are
non-globally-routable by design (RFC 4193, the IPv6 equivalent of
RFC1918 private space); NAT66 for them isn't how IPv6 is meant to get
outside access -- that's supposed to come from a properly delegated,
globally-routable prefix (DHCPv6-PD), which this project doesn't do.
Dropping NAT66 is the honest design, not a workaround.
extern's IPv6 side now behaves exactly like intern's already did: real
same-bridge reachability between containers, no path to the actual
internet. IPv4 is unaffected -- extern still gets full NAT'd outside
access there. IPv6 forwarding stays enabled (harmless, global,
available for other uses later); only the ip6tables MASQUERADE call
and the ip6tables dependency check that gated it were removed
(network_bridge.{h,cpp}'s provision_bridge()/teardown_network_state()/
check_network_dependencies()) -- one less required tool on the target
device too.
Verified as root via the doas rule: creating and fully deleting an
extern network with IPv6 enabled no longer invokes ip6tables at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
--delete-network only ever removed the config entry, leaving the
bridge/iptables/persistent-namespace state behind. Since
ensure_network_provisioned() treats "bridge exists" as "already fully
provisioned" and skips re-adding anything, a network recreated with
the same name after a plain --delete-network silently never got a
fresh MASQUERADE rule if the old one had been removed separately by
hand -- the bridge itself was still there the whole time.
teardown_network_state() (network_bridge.{h,cpp}) is the reverse of
ensure_network_provisioned(): for extern, removes the MASQUERADE
rule(s) then deletes the bridge; for intern, removes the whole
persistent namespace in one step (destroys the bridge inside it too,
no separate ip link del needed). Deliberately leaves the IPv4/IPv6
forwarding sysctls alone -- those are global host state shared across
every extern network, not per-network. Each step is best-effort
(teardown_step(), logging a warning not an error on failure) since a
step "failing" because that piece was already gone by hand is the
expected case this exists to handle, not a reason to abort --
delete_network_command() doesn't gate the config removal on any of
this succeeding, unlike delete_volume_command()'s own -full variant.
--delete-network-full wired into cli_args.{h,cpp} the same way
--delete-volume-full is.
Verified as root via the doas rule: an extern network's bridge and
MASQUERADE rule were both confirmed gone after --delete-network-full,
and recreating a network with the same name went through
provision_bridge() fresh instead of short-circuiting on a stale
bridge_exists() check -- fixing exactly the gap reported (a manually
removed MASQUERADE rule never came back on delete+recreate). An intern
network's persistent namespace was likewise confirmed fully removed
and recreatable without conflict.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
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