Drop IPv6 NAT (MASQUERADE): never correct IPv6 practice, unsupported
The real target device's ip6tables build has no MASQUERADE target,
breaking extern network provisioning whenever IPv6 was enabled. Rather
than work around that gap, dropped the ip6tables MASQUERADE rule
entirely, unconditionally, on both the veth and tap+relay paths --
it was never correct IPv6 design to begin with. The fd00::/8 ULA
addresses this project auto-allocates (network_subnet.h) are
non-globally-routable by design (RFC 4193, the IPv6 equivalent of
RFC1918 private space); NAT66 for them isn't how IPv6 is meant to get
outside access -- that's supposed to come from a properly delegated,
globally-routable prefix (DHCPv6-PD), which this project doesn't do.
Dropping NAT66 is the honest design, not a workaround.
extern's IPv6 side now behaves exactly like intern's already did: real
same-bridge reachability between containers, no path to the actual
internet. IPv4 is unaffected -- extern still gets full NAT'd outside
access there. IPv6 forwarding stays enabled (harmless, global,
available for other uses later); only the ip6tables MASQUERADE call
and the ip6tables dependency check that gated it were removed
(network_bridge.{h,cpp}'s provision_bridge()/teardown_network_state()/
check_network_dependencies()) -- one less required tool on the target
device too.
Verified as root via the doas rule: creating and fully deleting an
extern network with IPv6 enabled no longer invokes ip6tables at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
+67
-13
@@ -59,9 +59,16 @@ difference is where the bridge lives:**
|
||||
|
||||
- **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` (and the IPv6 forwarding sysctl, if IPv6 is
|
||||
enabled for that network) plus one iptables `MASQUERADE` (SNAT) rule for
|
||||
the bridge's subnet — the same pattern `docker0` uses.
|
||||
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
|
||||
@@ -100,8 +107,9 @@ than one extern network and didn't disambiguate.
|
||||
incrementing base (proposed: `fd00:168:0:1::/64`, `fd00:168:0:2::/64`, ...
|
||||
— mirroring the `168` from the v4 base so the two are visibly paired), with
|
||||
a `--subnet6 <cidr>` override. `--no-ipv6` at creation time skips both the
|
||||
v6 allocation and any `ip6tables`/IPv6-forwarding setup for that network
|
||||
entirely.
|
||||
v6 allocation and the IPv6-forwarding sysctl for that network entirely
|
||||
(there's no `ip6tables` setup to skip — see "Mechanism" above for why one
|
||||
is never added at all).
|
||||
|
||||
## CLI surface
|
||||
|
||||
@@ -146,11 +154,14 @@ after boot just works).
|
||||
|
||||
## Tooling
|
||||
|
||||
Shell out to `iptables`/`ip6tables` (via `process.h`'s existing
|
||||
`run_process()`, matching how `containers-storage`/`bwrap`/`fuse-overlayfs`
|
||||
are already invoked) for `MASQUERADE`/`DNAT`/`FORWARD` rules, and to `ip` for
|
||||
bridge/veth/namespace management. Both need an on-device availability check
|
||||
the same way `check_required_dependencies()` already gates on
|
||||
Shell out to `iptables` (via `process.h`'s existing `run_process()`, matching
|
||||
how `containers-storage`/`bwrap`/`fuse-overlayfs` are already invoked) for
|
||||
`MASQUERADE`/`DNAT`/`FORWARD` rules, and to `ip` for bridge/veth/namespace
|
||||
management. `ip6tables` is deliberately never used at all (see "Mechanism"
|
||||
above) — one fewer required tool on the real target device, whose
|
||||
`ip6tables` build turned out not to support `MASQUERADE` anyway. Both `ip`
|
||||
and `iptables` need an on-device availability check the same way
|
||||
`check_required_dependencies()` already gates on
|
||||
`containers-storage`/`bwrap`.
|
||||
|
||||
**iptables only, for now** — confirmed available on the real target device;
|
||||
@@ -225,7 +236,12 @@ pass.
|
||||
- `extern`: bridge in the *host's own* root namespace; assign it the
|
||||
gateway IP from the network's subnet; `net.ipv4.ip_forward=1` (+ IPv6
|
||||
forwarding sysctl if `ipv6`); one iptables `MASQUERADE` rule for the
|
||||
subnet (`ip6tables` too, if `ipv6`).
|
||||
subnet. **Landed without the `ip6tables` MASQUERADE rule this bullet
|
||||
originally called for** (see "Resolved" IPv6 section further below,
|
||||
and "Mechanism" above): ULA addresses are non-globally-routable by
|
||||
design, so NAT66 for them was never correct IPv6 practice, and the
|
||||
real target device's `ip6tables` build doesn't support `MASQUERADE`
|
||||
at all regardless.
|
||||
- `intern`: bridge inside its own dedicated `persistent_netns.h`
|
||||
namespace (commit 2); gateway IP assigned; no forwarding, no NAT rule
|
||||
— no route out at all.
|
||||
@@ -233,8 +249,8 @@ pass.
|
||||
creating a network actually stands up its bridge immediately) — later
|
||||
commits also call it lazily before a join, covering the reboot case.
|
||||
- `check_required_dependencies()`-style availability check added for `ip`/
|
||||
`iptables` (and `ip6tables` when needed), alongside the existing
|
||||
`containers-storage`/`bwrap` check.
|
||||
`iptables`, alongside the existing `containers-storage`/`bwrap` check
|
||||
(no `ip6tables` check, for the same reason it's never called).
|
||||
- Verify: `-n mynet --extern` produces a real bridge with the expected
|
||||
gateway IP, `ip_forward` enabled, and a matching `MASQUERADE` rule
|
||||
(`ip link show`, `iptables -t nat -L`); an `intern` network's bridge
|
||||
@@ -435,6 +451,44 @@ 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.
|
||||
|
||||
## IPv6: no NAT (MASQUERADE), by design
|
||||
|
||||
**Trigger**: the real target device's `ip6tables` build doesn't support a
|
||||
`MASQUERADE` target at all, so `provision_bridge()`'s original IPv6
|
||||
MASQUERADE rule (added in commit 3 of the implementation plan above) simply
|
||||
fails there.
|
||||
|
||||
**This isn't worked around — it's dropped entirely, on both the veth and
|
||||
tap+relay paths, unconditionally, because it was never correct IPv6 design
|
||||
to begin with.** The `fd00::/8` addresses `network_subnet.h` auto-allocates
|
||||
are ULA (Unique Local Address, RFC 4193) — deliberately **non-globally-
|
||||
routable**, the IPv6 equivalent of RFC1918 private space (`10.0.0.0/8`,
|
||||
etc.). NAT66 (masquerading a ULA source to a real global address) is
|
||||
possible in principle and some consumer routers do offer it, but it's
|
||||
explicitly discouraged: one of IPv6's own core design goals was eliminating
|
||||
the *need* for NAT via its vastly larger address space — the "correct" way
|
||||
for a network to get real outside IPv6 access is a properly delegated,
|
||||
globally-routable prefix (via DHCPv6-PD from an upstream router), not NAT
|
||||
on a private range. This project doesn't do prefix delegation (a
|
||||
materially bigger feature, not currently planned), so attempting NAT66 here
|
||||
would only ever have been a workaround for that gap, not a real solution —
|
||||
dropping it is the more honest design, not a compromise forced by the
|
||||
missing `ip6tables` target.
|
||||
|
||||
**Net effect**: `extern` networks' IPv6 side now behaves exactly like
|
||||
`intern`'s already did — real same-bridge reachability between containers
|
||||
over their ULA addresses, no path to the actual internet. IPv4 is
|
||||
unaffected; `extern` still gets full NAT'd outside access there. IPv6
|
||||
forwarding (`net.ipv6.conf.all.forwarding=1`) is still enabled for `extern`
|
||||
(harmless, global, symmetric with the IPv4 case, and available if a host
|
||||
administrator wants to wire up real inter-network IPv6 routing some other
|
||||
way later) — only the `ip6tables` MASQUERADE call itself, and the
|
||||
`ip6tables` dependency check that gated it, were removed
|
||||
(`network_bridge.{h,cpp}`'s `provision_bridge()`/`teardown_network_state()`/
|
||||
`check_network_dependencies()`). `--no-ipv6` is unaffected by this — it
|
||||
still means "skip IPv6 addressing entirely," an orthogonal decision from
|
||||
whether NAT is ever attempted for the addresses that are assigned.
|
||||
|
||||
## Explicitly out of scope for now
|
||||
|
||||
- **Rootless networking.** An earlier draft of this design considered a
|
||||
|
||||
Reference in New Issue
Block a user