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
This commit is contained in:
2026-08-31 12:23:09 +00:00
parent fd3f25b0b2
commit 743e899c3d
3 changed files with 272 additions and 69 deletions
+136 -48
View File
@@ -315,9 +315,14 @@ Source layout (all under `src/`):
the namespace outlives its creating process), then gone again after
removal. Also exercises `network_tap_relay.{h,cpp}`'s (see below)
create/attach/teardown cycle, same root-only skip: a throwaway bridge
(`ip link add ... type bridge`, no `network_bridge.h` provisioning needed —
a tap device only cares that *some* bridge exists to attach to) stands in
for a real network's bridge, and a throwaway network namespace (a forked
(`ip link add ... type bridge` inside a throwaway persistent namespace
of its own — `create_tap_relay()` now always enters a network's
persistent namespace first, both kinds, see `network_bridge.{h,cpp}`'s
own "Resolved: `extern` had no connectivity" entry, so this test needs
one too even though `network_bridge.h`'s own bridge *provisioning* logic
still isn't otherwise exercised here — a tap device only cares that
*some* bridge exists to attach to) stands in for a real network's bridge,
and a throwaway network namespace (a forked
child that `unshare(CLONE_NEWNET)`s then blocks in `pause()` until
signaled) stands in for a real `-r/--run` session's isolated one. **Real
race caught by testing, not assumed**: `fork()` returning to the parent
@@ -648,17 +653,19 @@ Source layout (all under `src/`):
whose exact value is implementation-defined and not guaranteed stable
across a rebuild with a different standard library, which would silently
orphan an already-provisioned bridge a rebuilt binary
can no longer find by the name it now computes. For `extern`, every command
runs directly (the bridge lives in the host's own root namespace, and this
whole feature is root-only for now — `docs/networking-design.md` — so
`slocker-lite`'s own current namespace already is the right one). For
`intern`, every command is wrapped through `nsenter --net=<persistent path>`
(`wrap_for_network()`) into the network's own dedicated
namespace (`persistent_netns.h`, created here first if it doesn't exist
yet) — the same pattern `wrap_for_root_namespace()` (`bwrap.cpp`) already
uses for the rootless `containers-storage` mount's namespace, just
targeting a persistent bind-mounted path instead of a live pid's `/proc`
entry. `bridge_name()`/`wrap_for_network()` are both exported (not just
can no longer find by the name it now computes. Every command, both kinds,
is wrapped through `nsenter --net=<persistent path>` (`wrap_for_network()`)
into the network's own dedicated namespace (`persistent_netns.h`, created
here first if it doesn't exist yet) — the same pattern
`wrap_for_root_namespace()` (`bwrap.cpp`) already uses for the rootless
`containers-storage` mount's namespace, just targeting a persistent
bind-mounted path instead of a live pid's `/proc` entry. **`extern` used
to run every command directly, unwrapped, since its bridge used to live in
the host's own root namespace** — see this entry's own "Resolved: `extern`
had no connectivity at all on the real device" paragraph further below for
why that changed: confirmed by direct on-device testing to be the actual
cause of a real, reproducible bug, not just an implementation choice.
`bridge_name()`/`wrap_for_network()` are both exported (not just
this file's own internal helpers) specifically so `network_join.{h,cpp}`
(see below) can attach a container's veth to the exact same bridge, in the
exact same place, this file provisioned it in. `provision_bridge()`
@@ -677,42 +684,51 @@ Source layout (all under `src/`):
with (also confirmed not universally supported on the real target device —
its `ip6tables` build lacks a `MASQUERADE` target entirely). `extern`'s
IPv6 side is thus same-bridge reachability only, exactly what `intern`'s
IPv6 already was. `check_network_dependencies()` gates on the tool set
each kind actually needs (`ip` always; `iptables`/`sysctl` for `extern`
no `ip6tables`, even when `ipv6`, since none is ever called; `nsenter` for
`intern`) — same shape/spirit as `commands.cpp`'s own
`check_required_dependencies()`, kept separate since the tool set here
depends on the network's own kind/`ipv6` setting.
IPv6 already was — and confirmed on the real target device, not just
theorized, that this is the only option regardless: neither `ip6tables`
nor `nftables` can even create an IPv6 NAT table on that kernel at all
("Not supported"). `check_network_dependencies()` gates on the tool set
each kind actually needs (`ip`/`nsenter` always, both now reaching their
bridge through a private namespace; `iptables`/`sysctl` additionally for
`extern` — no `ip6tables`, even when `ipv6`, since none is ever called) —
same shape/spirit as `commands.cpp`'s own `check_required_dependencies()`,
kept separate since the tool set here depends on the network's own
kind/`ipv6` setting.
`create_network_command()` (`commands.cpp`, see above) calls
`ensure_network_provisioned()` after resolving subnets but *before*
persisting the config entry — a network that fails to provision isn't
saved, so a later join doesn't find a config entry for something that
doesn't actually exist on the host. **Verified end-to-end on this dev
machine (root, via a scoped `doas` rule)**: a real `extern` network's
bridge, gateway IPv4/IPv6 addresses, `ip_forward`, and `MASQUERADE` rules
all came up correctly; a real `intern` network's bridge came up inside its
own dedicated namespace with neither forwarding nor a NAT rule, confirming
the structural (not merely policy) isolation the design calls for.
doesn't actually exist on the host. **Historical verification note**: an
earlier verification pass (before the "Resolved" paragraph below) confirmed
a real `extern` network's bridge/gateway/`ip_forward`/`MASQUERADE` rules
all coming up correctly *directly in the host's root namespace* — that
description is now superseded; see below for the current, corrected
architecture and why it changed. `ensure_network_provisioned()` no longer
short-circuits on `bridge_exists()` alone either: the uplink step below
(`extern` only) now runs on every call, even when the bridge already
existed, via its own separate idempotency check.
`teardown_network_state()` is the reverse: `--delete-network-full`
(`commands.cpp`'s `delete_network_command()`) calls it to tear down
exactly what `ensure_network_provisioned()` stood up. For `extern`:
removes the `iptables` MASQUERADE rule (no `ip6tables` counterpart, since
none is ever added — see `ensure_network_provisioned()`'s own comment
above), then deletes the bridge itself — via a new `.cpp`-local
`teardown_step()`, the same shape as `run_admin_command()` but logging a
*warning*, not an error, on failure, since a step failing because that
piece was already gone by hand is the expected, common case this exists
to handle (exactly `ensure_network_provisioned()`'s own existence-check
caveat above — this is precisely how a manually-removed MASQUERADE rule
can go from "won't come back on recreate" to "cleanly torn down and
recreated" once `--delete-network-full` exists at all). For `intern`:
removes the whole persistent namespace (`persistent_netns.h`) in one
step, which destroys everything inside it — the bridge included — with
no separate `ip link del` needed. Deliberately never touches the
exactly what `ensure_network_provisioned()` stood up. For `extern`: first
tears down the uplink (`teardown_uplink_state()`, see below), then removes
the `iptables` MASQUERADE rule for the container subnet (no `ip6tables`
counterpart, since none is ever added — see `ensure_network_provisioned()`'s
own comment above) — via a `.cpp`-local `teardown_step()`, the same shape
as `run_admin_command()` but logging a *warning*, not an error, on failure,
since a step failing because that piece was already gone by hand is the
expected, common case this exists to handle (exactly
`ensure_network_provisioned()`'s own existence-check caveat above — this is
precisely how a manually-removed MASQUERADE rule can go from "won't come
back on recreate" to "cleanly torn down and recreated" once
`--delete-network-full` exists at all). **Both kinds** then remove the
whole persistent namespace (`persistent_netns.h`) in one step, which
destroys everything left inside it — the bridge, and for `extern` the
uplink's own private-namespace-side tap device, both included — with no
separate `ip link del` needed for those. Deliberately never touches the
IPv4/IPv6 forwarding sysctls `provision_bridge()` enables for `extern`
those are global host state shared across every `extern` network, not
per-network, so turning them off here could break others still relying
on them. **Verified end-to-end on this dev machine (root, via the scoped
per-network, so turning them off here could break others still relying on
them. **Verified end-to-end on this dev machine (root, via the scoped
`doas` rule)**: an `extern` network's bridge and MASQUERADE rule were
both confirmed gone after `--delete-network-full` (`ip link show`
reporting "Device does not exist"), and recreating a network with the
@@ -723,6 +739,66 @@ Source layout (all under `src/`):
`--delete-network` + recreate, since the old bridge was silently still
there); an `intern` network's persistent namespace was similarly
confirmed fully removed and recreatable without conflict.
**Resolved: `extern` had no connectivity at all on the real device.**
Full incident writeup in `docs/networking-design.md`'s own section of the
same name — this entry just covers the resulting code. Root cause: the
bridge lived directly in the host's own root namespace, and (almost
certainly) Android's own `netd`-managed iptables/routing policy applies
only there, never to a genuinely isolated namespace — exactly why `intern`
was unaffected the whole time. `wrap_for_network()` no longer special-cases
`extern` (see its own entry above) — this one change alone fixed gateway
reachability, both IPv4 and IPv6, but also removes `extern`'s only path
outside by construction, restored by a new **uplink**: a second,
point-to-point tap+relay link (reusing `network_tap_relay.h`, see its own
entry below for the new `attach_host_side_to_bridge=false` mode this
needed) between the network's private namespace and the host's root
namespace, on its own small deterministic `169.254.0.0/16` transit subnet
(`uplink_transit_addresses()`/`uplink_transit_subnet()`, `.cpp`-local,
same `fnv1a()`-based derivation `bridge_name()` already uses).
`ensure_uplink_provisioned()` (`.cpp`-local, called from
`ensure_network_provisioned()` for `extern` only): idempotent via its own
`uplink_provisioned()` device-existence check; creates the relay
(`uph<hash>` in host root, `upn<hash>` in the private namespace), assigns
each end an address, sets the private namespace's own default route via
the uplink, enables `ip_forward`, and adds a `MASQUERADE` rule in host
root for the transit subnet — plus three more pieces, each independently
required and each found by real on-device testing, not assumed (full
detail, including exactly how each was diagnosed, in
`docs/networking-design.md`'s own section): an `iptables -I FORWARD 1`
accept rule (inserted at the front — Android's own `tetherctrl_FORWARD`
chain unconditionally drops everything reaching it, so an appended rule is
structurally unreachable), an outbound `ip rule` routing the uplink's own
traffic into whichever table `discover_default_table()` (`.cpp`-local,
parses `table <N>` out of `ip route get 8.8.8.8`'s own output — not
hardcoded, adapts to whichever real network is currently active) names,
and a **return-path** `ip rule` routing traffic *to* the transit subnet
into the plain `main` table regardless of which real interface a reply
arrives on. `TapRelayHandle` gained `root_side_tap_name`
(`network_tap_relay.h`) since, unlike a real container join's own
container-side tap (torn down for free once that session's namespace
goes away), the uplink's host-root-side tap never disappears on its own —
`stop_tap_relay()` removes it explicitly when set. The relay's own pid is
recorded to `$XDG_STATE_HOME/slocker-lite/network-uplinks/<network>`
(`uplink_state_path()`) since it must outlive the single
`ensure_network_provisioned()` call that created it, potentially spanning
many separate `slocker-lite` invocations before `teardown_uplink_state()`
(`.cpp`-local, the reverse of every step above, best-effort throughout)
eventually stops it. **Verified completely 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, both via the veth path
and via `--no-veth`'s tap+relay fallback; also verified on this dev
machine (self-test, a plain veth join, `--no-veth`, and `intern` — still
unaffected, no uplink, `ip route`'s own "Network unreachable" for outside
as intended). IPv6 outside connectivity was investigated separately and
found not practically fixable on this device (confirmed `nft add table
ip6 ...` itself fails — no IPv6 NAT support in this kernel at all, via
either `ip6tables` or `nftables`; the alternative, NDP-proxying real
addresses out of the device's own global prefix, was ruled out too, since
that prefix rotates every ~10 minutes on the network tested against) — so
it stays local-only by deliberate decision, matching what `intern`'s IPv6
side already was.
`probe_veth_support()` (added for the tap+relay fallback, see
`docs/networking-design.md`'s addendum and `network_join.{h,cpp}` below):
the real target device supports `tun`/`tap` but not `veth`
@@ -1048,9 +1124,11 @@ Source layout (all under `src/`):
persistently" paragraph further down for why that assumption turned out
to be wrong on the real target device, and `docs/networking-design.md`'s
matching section for the full incident writeup. The relay child's
entire setup sequence — enter the network's own namespace first if
`intern` (`persistent_netns_path()`, `persistent_netns.h`), create+attach
the host-side tap, `setns()` into the container's namespace (the
entire setup sequence — enter the network's own namespace first, always
now (`persistent_netns_path()`, `persistent_netns.h` — used to be `intern`
only; see `network_bridge.{h,cpp}`'s own "Resolved: `extern` had no
connectivity" entry above for why `extern` needs this too now), create+
attach the host-side tap, `setns()` into the container's namespace (the
already-open host-side fd stays valid across this switch — fds aren't
namespace-scoped, only their *creation* is, the same property
`wrap_for_root_namespace()`, `bwrap.cpp`, already relies on), create the
@@ -1064,13 +1142,23 @@ Source layout (all under `src/`):
closes both fds on the way out. `stop_tap_relay()` sends `SIGTERM`, reaps
the process, then explicitly `ip link del`s the host-side device
(`wrap_for_network(handle.network, ...)`, reaching wherever it lives —
host root for `extern`, the network's own persistent namespace for
`intern` — hence `TapRelayHandle` carrying its own `NetworkEntry`) — now
the network's own persistent namespace, both kinds — hence
`TapRelayHandle` carrying its own `NetworkEntry`) — now
required since the device is persistent and no longer disappears just
because the relay's fd closed (see below). The container-side device
needs no matching step: it lives inside the container's own network
namespace, which the kernel already tears down (every interface inside
it, persistent or not, along with it) once the session itself ends.
it, persistent or not, along with it) once the session itself ends
**except** the uplink's own second tap (`network_bridge.cpp`'s
`ensure_uplink_provisioned()`), which lives directly in the host's root
namespace instead and never goes away on its own; `TapRelayHandle` gained
`root_side_tap_name` for exactly this case, and `stop_tap_relay()` removes
it too (unwrapped, always host root by construction) when set. Likewise
`create_tap_relay()` gained an `attach_host_side_to_bridge` parameter
(default `true`, no change for either existing call site) — `false` skips
the `master <bridge>` step entirely and just brings the host-side tap up
plain, used by the uplink since it's deliberately a point-to-point routed
link, not another bridge port.
Once `create_tap_relay()` returns successfully,
`container_if_name` is a completely ordinary interface from the
container's own point of view — `join_one_network()`'s existing IP