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
This commit is contained in:
2026-09-03 12:19:26 +00:00
parent 6216e2bced
commit e7ef5428d0
2 changed files with 121 additions and 0 deletions
+73
View File
@@ -649,6 +649,79 @@ complete trace that correctly captured the real bug.
networks joined simultaneously in one session, all gateways reachable at 0%
packet loss, clean teardown, no leftover state.
## Resolved: `-p/--port-forward` had no connectivity at all on `extern`
**Reported after the fixes above had already shipped**: a server listening
on an `extern` network wasn't reachable via `-p` at all — not from the host,
and not from a real outside client — even though that same network's own
gateway and outside connectivity (the uplink fix earlier in this document)
worked correctly. `intern` was never affected (`-p` only ever targets
`extern` networks by design, since an `intern` network's bridge has no path
from the host at all).
**Root cause**: moving `extern`'s bridge into a private namespace (the fix
above) restored gateway reachability but, as a direct side effect, also left
host root with no route to the container subnet whatsoever — confirmed
directly: `ip route get <container-ip>` from host root fell through to
whatever the host's own default route happened to be (the real LAN
gateway), not the container's actual namespace. `-p`'s own `DNAT` rule
(`port_forward.cpp`) is added in host root and targets the container's real
IP directly, so the rewritten packet had nowhere to go — the connection
simply timed out, both from `curl <real LAN IP>:<port>` on the host itself
and from a separate machine on the same LAN.
**Fix, two independently necessary pieces** — confirmed by testing, the same
"a route alone isn't enough on Android" lesson the uplink's own
outbound/return-path `ip rule`s (above) already learned, just for the
container subnet instead of the uplink's own transit subnet:
1. A host-root route to the container subnet, through the uplink's own
netns-side address (`ip route add <container-subnet> via <uplink-netns-
addr> dev <uplink-root-tap>`). From there, no second NAT stage is
needed: the private namespace already forwards arriving traffic to the
bridge on its own (a fresh namespace's `FORWARD` policy is `ACCEPT` by
default, and `ip_forward` is already enabled), and the reply's return
path is already covered by the private namespace's own default route
back out through this same uplink, plus host root's own conntrack
correctly reversing the original `DNAT` on the way back out.
2. A matching `ip rule add priority 100 to <container-subnet> lookup main`.
Without this, the route added in step 1 is silently never consulted at
all: confirmed via `ip rule show` on the real device that Android's own
policy routing has no default "lookup main" rule anywhere in its list (it
ends in a catch-all `unreachable` before priority 32766, where that
default would normally live) — a generic `fwmark 0/0x10000 lookup 99`
rule (matching any untouched/forwarded packet, arriving on a real
interface rather than locally generated) intercepts the packet first and
routes it into an unrelated table with no route to the container subnet,
long before rule evaluation would ever reach `main`.
**A related robustness bug found while testing this, not the original bug
itself**: reproduced directly during testing when two concurrent test
invocations collided (an incidental accident of testing on a live device,
not a deliberate scenario) — a first failed `ensure_uplink_provisioned()`
call only stopped the tap relay, leaving every `ip rule`/`iptables` piece it
had already added (all deterministic, hash-derived names tied to the
network name) live on the host. That leftover state then made every
subsequent attempt to create a network with the *same name* fail
identically and permanently — `ip rule add` returning `File exists` against
a rule nothing had ever removed — with no way to recover short of 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 (before any of the steps that can
fail), so a failure can call the exact same `teardown_uplink_state()` a real
`--delete-network-full` would use to roll back everything already added,
instead of a second, partial copy of that cleanup logic.
**Verified end-to-end on the real target device**: a busybox `httpd` on a
freshly (re-)created `extern` network, reached via `-p 18080:80` — a `curl`
from the device's own shell against its real LAN IP got a genuine HTTP
response, and, separately, a `curl` from a completely different machine on
the same LAN got the same result. Reproduced reliably across repeated fresh
network creations, including after a full device reboot with no leftover
state. `curl 127.0.0.1:<port>` (from the host, against loopback
specifically) still doesn't work — that's the pre-existing, already-
documented NAT-hairpinning limitation above (see "Port forwarding (`-p`)"),
unrelated to this fix and out of scope here.
## IPv6: no NAT (MASQUERADE), by design
**Trigger**: the real target device's `ip6tables` build doesn't support a