18 Commits

Author SHA1 Message Date
ceamac 586c20f341 Add a man page, built optionally via scdoc
docs/slocker-lite.1.scd covers the full CLI (grouped like README.md's
flag table, including the DEBUGGING section for --mount/--umount/
--cleanup and the reassigned-short-option note), FILES, EXIT STATUS,
and SEE ALSO. meson.build builds it into slocker-lite.1 and installs it
under man1 only when scdoc is found on the host -- configuration still
succeeds without it, matching this project's existing "degrade
gracefully when an optional tool is missing" policy. Verified the
rendered page with `man --warnings` (clean, no troff warnings) and a
DESTDIR install.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-07 17:08:08 +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 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 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 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 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 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 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 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 f5f1e8522b Fix dropped section heading in networking design doc
The "Explicitly out of scope for now" heading was lost during the
previous edit that appended the commit-sequence section, leaving its
bullets orphaned under no heading.
2026-08-30 12:27:05 +00:00
ceamac e663e30221 Break the network isolation feature into a 6-commit sequence
Documentation only, no code: config schema + subnet/IPv6 allocation +
-n/--network CRUD, persistent netns primitives, bridge provisioning
(also doubles as reboot reconciliation), veth join at -r/--run time,
-p port forwarding, then a crash-orphan cleanup sweep. Each step is
independently buildable and manually verifiable, since the full
feature is too large for one commit.
2026-08-30 12:10:41 +00:00
ceamac f0aa913014 Add network isolation design doc
Captures the design discussion for persistent extern/intern networks,
-n/--network, and -p port forwarding: root-only Linux bridge+veth
(no slirp4netns needed once root is assumed), extern vs intern
differing only in where the bridge lives, iptables-based DNAT for -p,
and the subnet/IPv6/CLI/config-schema decisions. No code yet -- this
is the confirmed design to implement against.
2026-08-30 12:03:12 +00:00