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:
+135
-20
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user