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