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
+135 -20
View File
@@ -54,27 +54,36 @@ already (for `mnt`/`user`) and is directly reusable for `net`.
Both `extern` and `intern` networks use the same core mechanism — a Linux
bridge, with one veth pair per joined container (container-side end moved
into that container's own, still separately-`--unshare-net`'ed namespace; the
bridge-side end attached to the network's bridge). **The only structural
difference is where the bridge lives:**
bridge-side end attached to the network's bridge). **Both kinds' bridges live
inside their own dedicated, free-standing namespace** (persistent the way
`ip netns add` keeps a namespace alive with no process in it — bind-mounting
its `ns/net` file to a path that outlives any one process). This wasn't
always true for `extern` — see "Resolved: extern had no connectivity at all
on the real device" below for why it changed, and why that change was
necessary in the first place.
- **extern**: the bridge lives directly in the *host's own* root network
namespace, so it already has a path outside via the host's real routing.
Needs `net.ipv4.ip_forward=1` plus one iptables `MASQUERADE` (SNAT) rule
for the bridge's subnet — the same pattern `docker0` uses. If IPv6 is
enabled for the network, only the IPv6 forwarding sysctl is set —
deliberately **no** `ip6tables` MASQUERADE rule: the ULA (`fd00::/8`)
addresses this project allocates are non-globally-routable by design (RFC
4193), so NAT66 for them isn't correct IPv6 practice regardless (also
confirmed not universally supported: the real target device's `ip6tables`
build lacks a `MASQUERADE` target entirely). `extern`'s IPv6 side is thus
same-bridge reachability only, exactly what `intern`'s IPv6 side already
is — see the "Resolved" IPv6 section further below.
- **intern**: the bridge lives inside its own dedicated, free-standing
namespace (persistent the way `ip netns add` keeps a namespace alive with
no process in it — bind-mounting its `ns/net` file to a path that outlives
any one process). No route out exists at all: a real structural isolation
boundary, not merely "extern but without a NAT rule" (which would still be
reachable from the host itself).
- **intern**: no route out exists at all beyond the private namespace: a
real structural isolation boundary, not merely "extern but without a NAT
rule" (which would still be reachable from the host itself).
- **extern**: needs `net.ipv4.ip_forward=1` plus one iptables `MASQUERADE`
(SNAT) rule for the bridge's subnet, both applied *inside* that private
namespace — the same pattern `docker0` uses, just relocated. Since a
namespace genuinely isolated this way has no path outside on its own by
construction, it additionally gets an **uplink**: a second, narrower
point-to-point link out to the host's own root namespace, with NAT and
routing set up there instead. See "Resolved: extern had no connectivity at
all on the real device" below for the uplink's own mechanism and the three
distinct host-root-side pieces (a specifically-ordered iptables rule, and
two `ip rule`s) it needed to actually carry traffic. If IPv6 is enabled for
the network, only the IPv6 forwarding sysctl is set — deliberately **no**
`ip6tables` MASQUERADE rule, and no IPv6 equivalent of the uplink either:
the ULA (`fd00::/8`) addresses this project allocates are
non-globally-routable by design (RFC 4193), so NAT66 for them isn't
correct IPv6 practice regardless, and confirmed on the real target device
that neither `ip6tables` nor `nftables` can even create an IPv6 NAT table
on that kernel at all. `extern`'s IPv6 side is thus same-bridge
reachability only, exactly what `intern`'s IPv6 side already is — see the
"Resolved" IPv6 sections further below.
Each container always keeps its own private net namespace, so its own
loopback and interface set never clash with another container's. Joining N
@@ -462,6 +471,112 @@ stale-state accumulation can't recur — always prefer it over
`--delete-network` when a network won't be recreated with the same name, or
when testing repeatedly against the same name during development.
### Resolved: `extern` had no connectivity at all on the real device
**A second, deeper problem — genuinely device-specific, not stale state.**
After the dev-machine investigation above, the user confirmed `eth0`
creation itself was reliable on the real Android target device — but a
plain `extern` network there had no connectivity at all, gateway included,
in either IPv4 or IPv6, even after a full device reboot ruled out stale
state (unlike the dev-machine case above, `intern` on the same device worked
correctly the whole time — a genuine, reproducible structural difference).
**Root cause, found by isolating the actual differences between `extern` and
`intern` provisioning one at a time on a disposable branch**, rather than
guessing: `extern`'s bridge lived directly in the host's own root network
namespace, while `intern`'s always lived inside its own dedicated persistent
one. Relocating `extern`'s bridge into the same kind of private namespace
fixed gateway reachability immediately, both IPv4 and IPv6 — the "Mechanism"
section above already describes the resulting (current) architecture. The
underlying cause is almost certainly Android's own `netd`-managed
iptables/routing policy, which applies only in the root namespace and never
touches a genuinely isolated one — consistent with everything found in the
steps below, all of which turned out to be root-namespace-specific policy,
not anything wrong with the bridge/NAT/forwarding mechanism itself.
**That fix alone loses outside connectivity by construction** — a namespace
this isolated has no path to the real network at all. Restoring it needed an
**uplink**: a second, narrower 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 `169.254.0.0/16` transit subnet, with NAT applied
only in host root.
Three further, independently necessary pieces were needed for that uplink to
actually carry traffic — each found by direct real-device testing (SSH
access to the device, live inspection of `iptables -L -n -v`, `ip rule
show`, and `/proc/net/nf_conntrack`), not assumed, and each confirmed
necessary by dropping it and reproducing the exact original symptom:
1. **The iptables `FORWARD` accept rule needed `-I FORWARD 1` (insert at the
front), not `-A` (append).** A first attempt appended the rule and saw no
improvement; `iptables -L FORWARD -n -v`, captured live during a test,
showed why: Android's own `FORWARD` chain unconditionally jumps through
several of its own subordinate chains before reaching anything else, and
one of them — `tetherctrl_FORWARD`, its tethering-control chain — contains
a single unconditional `DROP` with no match criteria at all. Every
forwarded packet reaches it and dies there regardless of interface; an
appended rule sits after that point and is structurally unreachable,
since `DROP` is already a terminal verdict. Inserting at the very front
pre-empts the whole chain of subordinate jumps.
2. **An outbound `ip rule`.** Even with #1 fixed, packets still went
nowhere — `iptables -L FORWARD -n -v` showed 0 packets ever reaching the
chain at all for a real destination. `ip rule show`, captured live,
explained it: every rule landing in a table with a real, working route
requires `iif lo` (locally-generated traffic only — exactly why an
interactive `ip route get 8.8.8.8` always looked fine on its own, since
that lookup itself has `iif lo`). A genuinely *forwarded* packet — arriving
via the uplink's own host-root-side tap, not generated locally — instead
falls through to a generic `fwmark 0/0x10000` catch-all landing in an
unrelated, routeless table, and is dropped before a forwarding decision is
even made. Fixed by discovering, dynamically (`ip route get 8.8.8.8`,
parsing out the `table <N>` it names — not hardcoded, since the table
number is specific to whichever real network, WiFi or cellular, is
currently active), whichever table the host is actually using for its own
real traffic right now, and adding `ip rule add priority 100 iif
<uplink-tap> lookup <table>` — the explicit low priority matters too: a
first attempt without one was silently placed *after* the same
`fwmark 0/0x10000` catch-all and never actually consulted, the identical
append-vs-insert mistake as #1, just in `ip rule` instead of `iptables`.
3. **A return-path `ip rule`, mirroring #2 for the reverse direction.** Even
with #1 and #2 both fixed, outside connectivity still didn't work. Live
inspection of `/proc/net/nf_conntrack` during a real request settled it:
the outbound leg was already fully working — a genuine, tracked reply
from the real destination, not merely a locally-generated packet
succeeding (no `[UNREPLIED]` marker). But the reply, arriving back on
whichever real interface is currently active and correctly
de-MASQUERADEd by conntrack back to the uplink's own transit-subnet
address, still had nowhere to go: `ip route get <transit-addr> from
<remote> iif <real-interface>` returned "Network unreachable" — the exact
same "falls into a routeless table" failure as #2, just for a packet
whose *destination* (not source) is now the transit subnet, arriving on a
real interface instead of the uplink's own. Fixed with `ip rule add
priority 100 to <transit-subnet> lookup main` — routing by destination
into the plain `main` table, which already has the directly-connected
route to this subnet, regardless of which real interface a reply happens
to arrive on.
**Verified completely end-to-end on the real 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 through the veth path and, separately, through the
`--no-veth` tap+relay fallback.
**IPv6 outside connectivity was investigated separately and found not
practically achievable on this device, so it stays local-only by deliberate
decision** (see "IPv6: no NAT (MASQUERADE), by design" below for the
existing, still-valid reasoning against NAT66 specifically) — checked
directly on the real device: `nft add table ip6 ...` fails outright
("Not supported" / "cache initialization failed"), so neither `ip6tables`
nor `nftables` can NAT IPv6 on this kernel at all, ruling out a MASQUERADE-
based fix analogous to the IPv4 uplink entirely. The NAT-free alternative
(NDP proxying real addresses carved out of the device's own global prefix)
was also ruled out: that prefix rotates every ~596 seconds (under 10
minutes) on the network tested against, for both the privacy address and the
normally long-lived EUI64 address, too short-lived to build a stable
addressing scheme on top of.
## IPv6: no NAT (MASQUERADE), by design
**Trigger**: the real target device's `ip6tables` build doesn't support a