Commit Graph

101 Commits

Author SHA1 Message Date
ceamac a22024fcf8 Add regression test: a nohup-backgrounded process doesn't survive a session
Reproduces the user's reported real-world shape end to end with a real
busybox container ("nohup sleep 137 & exit"), verifying via the host's own
/proc that the backgrounded process is actually gone afterward -- checked
by cmdline substring, not pid, since a pid seen inside an isolated pid
namespace doesn't correspond to the same-numbered host pid. A bounded 2s
poll guards against the pid namespace's own kernel collapse-on-pid-1-exit
timing (which already covers this case for free on this dev machine).

Complements test_session_cleanup.cpp's existing [integration][root] test,
which exercises kill_via_cgroup() directly -- this one instead proves the
outward, visible contract holds through the real -r/--run path.
2026-09-05 09:50:49 +00:00
ceamac cbec986e78 Split config.yaml into global+persistent files; add -c/--config-file
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.
2026-09-05 09:32:36 +00:00
ceamac d2d631117d Drop -m/-u/-c short options for --mount/--umount/--cleanup
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.
2026-09-05 08:43:58 +00:00
ceamac cdf9dcd210 Add global.with-veth/with-ipv6 config defaults for -n/--network creation
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.
2026-09-05 08:36:12 +00:00
ceamac 1f52bc5f6f Add regression test for the session-straggler sweep; resolve TODO entry
test_session_cleanup.cpp exercises kill_via_cgroup() directly against two
plain forked processes (one setsid()-ing away from the other before it
exits), confirming a reparented straggler is actually reaped -- reproducing
the real escape shape (no pid namespace support at all) through a full
mount/bwrap session isn't possible from the CLI on a single run, since
--unshare-pid is a config-file-only setting, not a flag.

Also resolves TODO.md's SIGINT/SIGTERM entry and extends the relevant
CLAUDE.md sections (bwrap.{h,cpp}, session_cgroup.{h,cpp}, kill_session.{h,cpp})
with the fix's rationale and its known residual limitation (a kernel with
neither cgroup v2 nor pid namespace support still can't be reached
automatically).
2026-09-05 07:20:51 +00:00
ceamac aea4d90463 Automatically reap session stragglers after run_bwrap() exits
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.
2026-09-05 07:18:41 +00:00
ceamac cadea945e4 Fix [integration][net] rootless tests to not assume kernel capability
Found via real-device testing (the actual Android target), not assumed:
both tests hardcoded assumptions that don't hold on every kernel.

1. "a fresh network namespace has only loopback" assumed exactly 3 lines
   of /proc/net/dev (2-line header + one "lo" entry) -- the real target
   device's kernel auto-creates several harmless placeholder tunnel
   interfaces (sit0, ip6tnl0, ip_vti0, ip6_vti0) in *every* fresh network
   namespace, alongside loopback. The namespace is still genuinely
   isolated (confirmed: none of the *host's* real interfaces leak in) --
   the test's assumption was just wrong for this kernel. Replaced the
   exact-count check with a readlink-based /proc/self/ns/net identity
   comparison (proves genuine isolation regardless of kernel config) plus
   a simple "loopback is present" check, dropping the brittle count
   assertion entirely.

2. "pid/uts/ipc namespaces differ from this process's own" assumed all
   three are always readable via /proc/self/ns/<type>. The real target
   device has neither PID nor IPC namespace support *as a kernel feature
   at all* -- confirmed directly: even this test process's own `readlink
   /proc/self/ns/pid`, run completely outside any container, fails
   outright there. This matches this project's own already-documented
   standing lesson (neither CONFIG_CHECKPOINT_RESTORE nor pid namespace
   support on this target). Comparing against a namespace type the kernel
   doesn't expose at all wouldn't prove anything either way.

Both tests now build their expectations from detect_bwrap_unshare_args()
(bwrap.h) -- this host's own live kernel-capability probe, the exact same
one build_bwrap_args() itself already gates on -- rather than assuming a
fixed set of namespace types is always available. "user" is deliberately
excluded from the generic per-type check: build_bwrap_args() never
requests --unshare-user when running as root, so asserting on it would be
wrong specifically when these tests are run as root (as they are on the
real device).

Verified: passes repeatably on this dev machine (all 6 namespace types
supported, 8 assertions/2 test cases either way -- same coverage as
before, just derived instead of hardcoded), full combined suite and
meson test both still clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-04 15:09:08 +00:00
ceamac 0c3a4bae99 TODO: Ctrl-C/SIGTERM on a foreground session may leave orphans
forward_signal_to_foreground_child() (process.cpp) does a plain kill() on
only the one tracked bwrap pid -- unlike --kill's kill_session(), which
picks a strategy (cgroup, pid-namespace, or tracked-pid) to reach every
process the session started. Anything inside the sandbox that
daemonizes/double-forks into a new session escapes the simple forward and
can be left running after Ctrl-C, even though --kill against the same
session would reach it. Reported by the user during real-device testing;
not yet reproduced with a specific repro, just the architectural gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-04 14:46:39 +00:00
ceamac b602de8e7b Document the new Catch2 test suite
README.md: new "Testing" section -- the 4 category/tag-expression table,
what meson test covers vs. what stays manual, and tests/setup-tests.py.

CLAUDE.md: rewrote the self_test.{h,cpp} entry to describe its new role
(pure Catch2 Session::run() plumbing, ENABLE_TESTS-guarded) instead of the
hand-rolled tests it used to contain directly, and added a full
per-file breakdown of the new tests/unit, tests/integration, and
tests/support infrastructure -- including the real bugs found building it
(the two parse_args()/getopt_long state-reset bugs, the ScratchXdgDirs
mixed-iterator UB, the Catch2-inherited-SIGTERM-handler artifact, the
missing /sys mount and spdlog-writes-to-stdout findings), all in the same
narrative depth this file already uses throughout. Also updated the
"Build & test commands" flag list and meson test description.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-04 11:23:24 +00:00
ceamac 865862762d Wire [unit] and safe [integration] tests into meson test
Two new test() entries alongside the existing fixture smoke test:
'unit-tests' (-t -- "[unit]") and 'integration-tests'
(-t -- "[integration]~[net]") -- both safe to run unprivileged with no
network setup, so meson test -C buildDir now catches regressions in those
categories automatically. [net] and [root] tests stay manual-only
(developer-run on a real/root-capable machine), matching how this
project's own self-tests were never part of meson test either. Only
registered when enable_tests is on, matching test_sources' own guard --
verified a -Denable_tests=false build still runs cleanly with just the
original smoke test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-04 11:23:24 +00:00
ceamac 80cc49d898 Add [integration][net] rootless container-run tests
tests/integration/test_rootless_run.cpp runs a real busybox image through
the exact real -r/--run dispatch path (dispatch_command(), commands.h) --
mount, resolve, run_bwrap, unmount, cleanup, in-process rather than via a
subprocess -- and confirms bwrap's *default* sandboxing (no -n/-p at all)
is genuinely isolating: a fresh network namespace with nothing but
loopback, and pid/uts/ipc namespaces that differ from this test process's
own. No root needed, same as a plain `-r image.tar -- <command>` already
isn't.

New tests/support helpers: CapturedStdout (RAII, redirects this process's
own fd 1 -- and anything a forked/exec'd child inherits from it -- to a
throwaway temp file for its lifetime) so the sandboxed command's own
output can actually be asserted on.

Two real, non-obvious findings from getting this working, not assumed:
1. bwrap's own sandbox mounts --proc /proc and --dev /dev, but *not*
   /sys -- confirmed directly (`ls /sys/class/net` inside the sandbox:
   "No such file or directory", reproduced identically via the real CLI,
   not just this test). Switched the loopback-only check to
   /proc/net/dev instead (two header lines + one "<iface>: ..." line per
   interface), which correctly shows only "lo".
2. spdlog's default sink writes to stdout, not stderr, same as the plain
   "mounted image at: ..." success line (see CLAUDE.md) -- so a naive
   capture-and-line-split mixed slocker-lite's own status/log output in
   with the sandboxed command's real output. Fixed by having the
   sandboxed command bracket its own output between two unique markers
   and extracting only what's strictly between them.

Verified: both tests pass repeatably, 15 stress-test runs of the full
combined [unit]+[integration] suite with zero failures, plus a full run
as root.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-04 11:23:24 +00:00
ceamac dd66886de8 Add tests/setup-tests.py: fetch a real busybox fixture for [net] tests
Idempotent: does nothing if images/busybox.tar already exists (your own
unofficial build, or a previous fetch). Otherwise tries skopeo, then
podman, then docker, in that order -- skopeo/podman both reliably produce
a genuine OCI Image Layout tar; a plain `docker save` only does if the
containerd image store happens to be enabled, so the result is verified
(oci-layout/index.json actually present at the tar root) regardless of
which tool produced it, falling through to the next option otherwise.
Clear instructions + nonzero exit if none of the three are available and
no fixture already exists.

Verified the no-op (already-present) path and the no-tools-available error
path directly (temporarily moved the existing images/busybox.tar aside and
back) -- this dev machine has none of skopeo/podman/docker installed, so
the actual fetch path itself is unverified here; the format-verification
step (looks_like_oci_layout()) is what protects against a `docker save`
that produced the legacy Docker tar format on some other machine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-04 11:23:24 +00:00
ceamac dad4e75392 Add test support helpers + [integration] config/bwrap chain tests
tests/support/fixtures.{h,cpp}: find_busybox_fixture() (images/busybox.tar
relative to cwd, this project's own established manual-testing convention
-- nullopt if absent, so [net] tests can SKIP() rather than fail) and
ScratchXdgDirs, an RAII helper pointing XDG_CONFIG_HOME/XDG_STATE_HOME at a
fresh throwaway mkdtemp() directory for its lifetime, restoring the
previous environment and removing the directory on destruction -- so
integration tests that actually exercise config_file_path()/xdg_state_dir()
never touch the real developer's own config/state.

tests/integration/test_config_bwrap_chain.cpp: the user's own example --
write a config file, load it back, resolve a NamespaceConfig from it the
same way run_container() (commands.cpp) does, and confirm build_bwrap_args()'s
resulting argv actually reflects it (disabled unshare-net/unshare-uts never
requested; an all-default config matches the live host's own
detect_bwrap_unshare_args() probe exactly). Neither test mounts/runs
anything or needs privilege.

Real bug found via ~10-30 repeated combined [unit]+[integration] runs, not
assumed: ScratchXdgDirs's constructor built its mkdtemp() template vector
from two *separate* temporary std::string objects (`.begin()` off one,
`.end()` off the other) -- mixing iterators from different containers is
undefined behavior, here manifesting as an intermittent, heap-address-
dependent `std::length_error: cannot create std::vector larger than
max_size()` inside whichever test happened to run adjacent to it. Fixed by
using a single named string instance for both ends of the range; confirmed
clean across 30 repeated combined runs afterward, plus a full run as root.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-04 11:23:24 +00:00
ceamac 42f9d36edf Add [unit] tests: port_forward, env_spec, network_subnet, cli_args
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
2026-09-04 11:23:24 +00:00
ceamac ec1ce505d2 Port the 3 root network self-tests to tagged Catch2 TEST_CASEs
persistent-netns, tap-relay, and dns-resolver (formerly hand-rolled
bool-returning functions in self_test.cpp, called unconditionally by the
old ad hoc run_self_tests()) move to tests/integration/test_root_networking.cpp
as TEST_CASEs tagged [integration][root][net] -- SKIP() (not a whole-suite
skip) when not root, or when dnsmasq isn't installed for the DNS one, so
e.g. -t -- "[unit]" on a rootless machine is unaffected. Every assertion
uses CHECK, not REQUIRE: these tests manage real host-side namespaces,
bridges, and tap devices that must not leak just because an earlier
assertion failed, so execution always falls through to the same
unconditional cleanup at the end (guarded only by simple pid/bool checks
to skip meaningless dependent steps).

Real bug found running the tap-relay test under Catch2, not assumed:
Catch2 installs its own fatal-signal handler around a running TEST_CASE,
which create_tap_relay()'s own forked relay child inherits -- so the
relay's ordinary shutdown SIGTERM (sent by stop_tap_relay()) got caught by
that *inherited* handler in the child instead of terminating it via the
default disposition the relay's own design relies on, producing a
spurious "FAILED ... due to a fatal error condition: SIGTERM" report
interleaved into the real output (confirmed cosmetic only -- exit code
and assertion count were correct either way, just confusing). Fixed by
resetting SIGTERM to SIG_DFL for the narrow window around the
create_tap_relay() call and restoring it right after -- only the
disposition at fork time is inherited, so nothing about how long the
relay then keeps running matters. No production code changed for this;
it's purely an artifact of forking network primitives from within a
Catch2-instrumented process.

meson.build: the new tests/integration/*.cpp sources are only added to
slocker-lite's own source list when enable_tests is true (mirroring
config.h's ENABLE_TESTS runtime guard, added last commit), and src/ is
added to the target's own include_directories so test sources under
tests/ can #include project headers the same way src/*.cpp already does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-04 11:23:24 +00:00
ceamac 48fd221d74 Wire -t/--test to Catch2's own argv-driven Session::run()
-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
2026-09-04 11:23:24 +00:00
ceamac cdc4b75309 Don't log Ctrl-C/SIGTERM on a foreground session as an error
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
2026-09-04 10:47:03 +00:00
ceamac 5080895043 Document UDP port-forward support
CLAUDE.md's port_forward.{h,cpp} entry, README.md's -p table row, and
docs/networking-design.md's syntax line all updated for the new
[/tcp|udp] suffix. Includes the local dev-machine (root, via the scoped
doas rule) verification detail: TCP unaffected, UDP confirmed end-to-end
(a raw datagram sent to the forwarded host port was read back inside the
container via -x/--exec), same port pair coexisting on both protocols,
invalid-protocol parse errors, clean teardown, and --clean-processes
sweeping both the old 3-field and new 4-field state-file formats. Real
Android iptables/tetherctrl_FORWARD confirmation for UDP is still open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-04 06:48:20 +00:00
ceamac ae8715473c Add UDP support to -p/--port-forward
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
2026-09-04 06:48:14 +00:00
ceamac b3990094b9 Confirm root cause: HOME set but not exported in the invoking shell
The user identified the exact trigger: declare -p HOME showed
`declare -- HOME="/root"` (no -x), meaning HOME was a plain shell
variable, never exported, so slocker-lite's own getenv("HOME") saw
nothing -- identical to HOME being fully unset from a child process's
point of view. An earlier full environment dump had looked like it
already had HOME correctly set and ruled this out; it didn't, since
that dump listed all shell variables (declare -p style), not strictly
the exported environment a child process actually receives.

Reproduced directly on the real device (env -u HOME bash -c 'HOME=/root;
declare -p HOME; slocker-lite -w') and confirmed the existing fix
(resolve_home_dir()'s passwd-database fallback) already handles it
correctly -- resolves to /root/.config/slocker-lite/config.yaml, not a
cwd-relative path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-03 18:40:33 +00:00
ceamac cc3beba011 Update TODO: cwd-relative path fix shipped, exact trigger still open
The previous entry's fix (xdg_state_dir()/config_file_path() now
absolute-by-construction, plus a passwd-database $HOME fallback) has
landed, but the user's own environment dump after hitting the dnsmasq
symptom showed $HOME correctly set to /root with nothing that should
have produced a relative path under the old code either -- so the exact
mechanism that triggered it originally is still unconfirmed, even though
the fix should cover it defensively regardless of cause.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-03 18:37:15 +00:00
ceamac adeceac1f8 Fix config/state paths silently resolving relative to cwd
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
2026-09-03 18:36:11 +00:00
ceamac 0745d30c97 Document the per-session DNS resolution feature
Records the design decision (one dnsmasq instance per session, not per
network -- avoids the NXDOMAIN-fallthrough problem a per-network design
would have hit for multi-network containers) and the three real bugs
found while building it (dnsmasq's --pid-file needing daemonize mode,
its default privilege drop breaking $XDG_STATE_HOME access, and REFUSED
AAAA answers breaking getaddrinfo()-based tools), matching the level of
detail already recorded for the other networking features in this
document and in CLAUDE.md's own file-by-file reference.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-03 17:36:55 +00:00
ceamac a70634f1d7 TODO: investigate cwd-relative config/state path fallback
Found on the real device: two slocker-lite invocations from different
working directories ended up with completely separate config.yaml/state
trees (one under /root, one under /root/src/slocker-lite), both
auto-allocating the same 10.168.0.0/24 subnet and independently mutating
host-level ip/iptables state with no awareness of each other. Looks like
$HOME being unset/empty in some invocations, causing the $HOME-relative
fallback to silently resolve relative to cwd instead -- not yet confirmed,
needs a real repro before deciding on a fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-03 17:34:40 +00:00
ceamac 4147a387b8 Add TODO: run the DNS resolver as a low-privilege user
Tracks the security follow-up for network_dns.cpp's --user=root
--group=root workaround -- dnsmasq's own default privilege drop broke
reading state under /root (mode 0700), so it's kept at root entirely for
now. Not urgent (networking here is already root-only throughout), but
worth revisiting later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-03 17:34:40 +00:00
ceamac bd3bc27fc4 Add per-session DNS resolution via dnsmasq
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
2026-09-03 17:34:36 +00:00
ceamac e7ef5428d0 Document the -p/--port-forward extern-connectivity fix
Records the root-cause investigation and fix for -p/--port-forward not
reaching a server on an extern network (host root had no route to the
container subnet, and the route alone wasn't consulted without a matching
ip rule -- Android's policy routing has no default "lookup main" rule),
plus the related uplink-rollback-on-failure robustness bug found while
testing it, matching the level of detail already recorded for the other
networking fixes in this document.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-03 12:19:26 +00:00
ceamac 6216e2bced Fix -p/--port-forward unreachable on extern networks
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
2026-09-03 12:18:33 +00:00
ceamac 37ab2fcb1e Document the multi-network join race and its fix
Records the strace-based root-cause investigation for the bug where
joining 2+ networks in one -r/--run left every network after the first
permanently unreachable, and the retry-on-EIO/ENETDOWN fix applied in
network_tap_relay.cpp, matching the level of detail already recorded for
the extern-connectivity investigation above it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-31 17:00:25 +00:00
ceamac 244a814e85 Retry tap writes on EIO/ENETDOWN: fixes multi-network join race
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
2026-08-31 16:59:37 +00:00
ceamac 743e899c3d Document extern's private-namespace + uplink architecture
Updates docs/networking-design.md, CLAUDE.md, and README.md to describe
the current, corrected extern network architecture (see the previous
commit) instead of the superseded host-root-bridge design: both extern
and intern now provision their bridge inside a dedicated private
namespace, and extern additionally gets a point-to-point uplink out to
host root, with the three real-device-confirmed pieces (FORWARD insert
ordering, outbound ip rule, return-path ip rule) that make it actually
carry traffic. docs/networking-design.md gets the full incident writeup,
including exactly how each piece was diagnosed on the real device.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-31 12:23:09 +00:00
ceamac fd3f25b0b2 Fix extern network connectivity: private namespace + uplink to host root
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
2026-08-31 12:19:48 +00:00
ceamac bebf559039 Create tap devices persistently via ip tuntap add, drop retry logic
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
2026-08-30 19:59:14 +00:00
ceamac ad30f93f77 Retry container-side tap device setup, external process only
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
2026-08-30 19:26:39 +00:00
ceamac cf166ed3b4 Use a genuinely random ULA /48 for auto-allocated IPv6 subnets
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
2026-08-30 18:49:15 +00:00
ceamac 8a0d800478 Drop IPv6 NAT (MASQUERADE): never correct IPv6 practice, unsupported
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
2026-08-30 18:38:57 +00:00
ceamac 745930aad6 Resolve gateway/outside reachability gap: stale test state, not a bug
Retested the tap+relay fallback on a clean host after the user cleared
out accumulated leftover bridges and iptables rules from many earlier
rounds of manual testing (left behind because --delete-network never
tore down live host state before --delete-network-full existed).

With a clean host: a fresh --no-veth extern network's gateway IP and a
real external host both answered ICMP with 0% packet loss, and a raw
TCP connect (nc) to an external host completed cleanly. A separate
wget segfault against the same host was confirmed to be an unrelated
busybox bug (reproduces identically regardless of join mechanism), not
a networking issue. Inter-container connectivity was reconfirmed
working at the same time.

This closes out the previously-reported "gateway/outside reachability
unconfirmed" gap in docs/networking-design.md and CLAUDE.md -- both
peer-to-peer and gateway/outside connectivity through the tap+relay
fallback are now confirmed working on this dev machine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-30 18:31:21 +00:00
ceamac 534136339f Add --delete-network-full to fully tear down a network's live state
--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
2026-08-30 17:59:52 +00:00
ceamac 69a18924b4 Add bridge name column to --list-networks
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
2026-08-30 17:15:31 +00:00
ceamac 53b859b7bf Document the tap+relay veth fallback in the design doc
Adds a dedicated section to docs/networking-design.md covering the
tap+relay fallback for veth-less kernels: the trigger (real target
device supports tun/tap but not veth), why tap can't 1:1 replace veth,
the confirmed design (two tap devices + a relay reusing the existing
bridge, replacing veth's earlier N-way-switch-daemon sketch once
bridge support was confirmed available), the four-commit
implementation sequence with what testing actually found (the fd-leak
deadlock, the reverted cgroup fix), and an honest writeup of the
unresolved gateway/outside-reachability gap.

README.md's -n/--network row now also flags that gap directly, next
to the existing NAT-hairpinning limitation note for -p.

This closes out the tap+relay fallback work for now: peer-to-peer
connectivity through it is solid and dev-verified; gateway/outside
reachability needs re-verification 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
2026-08-30 16:36:43 +00:00
ceamac fd234124e1 Add crash-orphan sweep for stale tap-relay processes
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
2026-08-30 16:35:26 +00:00
ceamac f323dab3da Wire tap+relay fallback into join_one_network()
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
2026-08-30 16:31:27 +00:00
ceamac 96efcf37e1 Add network_tap_relay: tap-backed veth substitute, standalone
Second step of the tap+relay fallback for veth-less kernels (the real
target device supports tun/tap but not veth). Reuses the existing
bridge as the switching fabric -- provision_bridge()'s NAT/forwarding
setup needs no changes -- and only replaces how a container's
namespace gets connected to it:

- a host-side tap device, created wherever the network's bridge lives
  and enslaved to it, playing veth's host-side role
- a container-side tap device, created directly inside the container's
  namespace and named eth<N> from the start (no rename step needed)
- a relay process holding both fds open, copying raw Ethernet frames
  bidirectionally between them -- reproducing a veth pair's kernel
  wire via one userspace hop

Not wired into join_one_network() yet -- this commit only adds
create_tap_relay()/stop_tap_relay() and exercises them standalone via
a new self-test (throwaway bridge + throwaway namespace).

A real synchronization bug turned up while writing that self-test:
fork() returning to the parent doesn't mean the child has reached its
own unshare(CLONE_NEWNET) yet, so using its pid immediately raced and
created the container-side tap in the wrong (host) namespace. Fixed
by polling namespace_isolated() first, the same guard
network_join.cpp's wait_for_isolated_net_namespace() already uses for
a real session.

Verified twice as root via the doas rule: host-side tap gets created
and attached to the bridge, container-side tap gets created with the
right name inside the target namespace, and -- the biggest open
assumption from the design doc addendum -- both devices disappear on
their own once stop_tap_relay() stops the process, no explicit
`ip link del` needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-30 16:06:32 +00:00
ceamac 08895297a9 Add veth capability probe + --no-veth network flag
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
2026-08-30 15:59:40 +00:00
ceamac a9cd3bf643 Fix -x/--exec to also join a session's network namespace
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.
2026-08-30 13:39:25 +00:00
ceamac 7605831269 Add crash-orphan sweep for stale -p port-forward rules
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.
2026-08-30 13:33:47 +00:00
ceamac 5c87cac430 Add -p/--port-forward: iptables DNAT into extern-joined containers
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.
2026-08-30 13:28:23 +00:00
ceamac 8cc967e748 Join -r/--run containers to networks: veth creation, IPs, routes
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.
2026-08-30 13:11:28 +00:00
ceamac 24b8ddcce7 Add bridge provisioning for networks (root-only)
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.
2026-08-30 12:50:55 +00:00
ceamac db3a9d82c7 Add persistent network namespace primitives
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.
2026-08-30 12:43:12 +00:00