cbec986e781bb31a5a9dfa1db233272baaff7d49
100 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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).
|
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
bebf559039 |
Create tap devices persistently via ip tuntap add, drop retry logic
Real-device testing showed the previous retry-based fix (
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
ec09b96b56 |
Add -n/--network config CRUD: schema, subnet/IPv6 allocation, CLI
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.
|