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:
2026-08-30 18:38:57 +00:00
parent 745930aad6
commit 8a0d800478
5 changed files with 129 additions and 54 deletions
+67 -13
View File
@@ -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