feat(web-arm): opt-in IPv6 egress for podman containers via NAT66/ULA network #86

Merged
dominik.polakovics merged 1 commit from afk/85 into main 2026-06-03 21:02:08 +02:00

What

web-arm gained host outbound IPv6 in #81 / ADR 0010, but its podman containers sit on the IPv4-only default bridge and cannot reach a v6-only destination. This adds a reusable, opt-in dual-stack podman bridge network so any container can get IPv6 egress.

  • New module hosts/web-arm/modules/v6egress.nix:
    • init-v6egress-network systemd oneshot creates the network idempotently (podman network exists … || podman network create --ipv6 --subnet 10.89.0.0/24 --subnet fdaa:bbcc:ddee::/64 v6egress), mirroring how fw provisions per-workload podman networks.
    • netavark masquerades both subnets to the host; container v6 is NAT66'd to the host GUA 2a01:4f8:c012:43b::1 and leaves over the #81 default route.
    • boot.kernel.sysctl."net.ipv6.conf.all.forwarding" = true so the host routes container v6 out enp1s0.
  • Imported in hosts/web-arm/configuration.nix.
  • docs/adr/0011-podman-ipv6-egress-via-nat66.md records the decision.

Egress-only, opt-in via --network=v6egress. collabora and rustdesk-server stay on the default bridge, untouched. The real container that needs this is delivered separately; this ships only the reusable network.

Why NAT66/ULA (not a routed GUA prefix)

Egress-only (no inbound surface), no Hetzner-side work, and it doesn't touch the host /64 from #81. A routed GUA sub-prefix would need an NDP proxy or a second Hetzner /64 and would re-home the on-link host /64. Podman is kept (no oci-containers.backend flip). See ADR 0011.

Verification

  • web-arm dry-build passes (pre-commit hook: :: web-arm OK).
  • Post-deploy (needs the live host — eval can't check reachability):
    • podman run --rm --network=v6egress docker.io/curlimages/curl -6 -s https://ifconfig.co2a01:4f8:c012:43b::1 (NAT66 source confirmed)
    • podman run --rm --network=v6egress docker.io/curlimages/curl -6 -sS -o /dev/null -w '%{http_code}\n' 'https://[2606:4700:4700::1111]' (or ping6 a global v6) → reaches a v6-only target
    • confirm collabora/rustdesk-server still serve; host curl -6 and postconf smtp_address_preference (=ipv4) unchanged

⚠️ Deliverability caveat (in ADR 0011 + module header): the NAT66 source GUA has no rDNS/SPF, so a future container sending SMTP over this network must pin its own outbound mail to IPv4 — the same constraint ADR 0010 documents for the host.

Closes #85

## What web-arm gained host outbound IPv6 in #81 / ADR 0010, but its **podman** containers sit on the IPv4-only default bridge and cannot reach a v6-only destination. This adds a **reusable, opt-in dual-stack podman bridge network** so any container can get IPv6 egress. - New module `hosts/web-arm/modules/v6egress.nix`: - `init-v6egress-network` systemd oneshot creates the network idempotently (`podman network exists … || podman network create --ipv6 --subnet 10.89.0.0/24 --subnet fdaa:bbcc:ddee::/64 v6egress`), mirroring how `fw` provisions per-workload podman networks. - netavark masquerades both subnets to the host; container v6 is **NAT66'd to the host GUA `2a01:4f8:c012:43b::1`** and leaves over the #81 default route. - `boot.kernel.sysctl."net.ipv6.conf.all.forwarding" = true` so the host routes container v6 out `enp1s0`. - Imported in `hosts/web-arm/configuration.nix`. - `docs/adr/0011-podman-ipv6-egress-via-nat66.md` records the decision. Egress-only, opt-in via `--network=v6egress`. `collabora` and `rustdesk-server` stay on the default bridge, untouched. The real container that needs this is delivered separately; this ships only the reusable network. ## Why NAT66/ULA (not a routed GUA prefix) Egress-only (no inbound surface), no Hetzner-side work, and it doesn't touch the host `/64` from #81. A routed GUA sub-prefix would need an NDP proxy or a second Hetzner `/64` and would re-home the on-link host `/64`. Podman is kept (no `oci-containers.backend` flip). See ADR 0011. ## Verification - ✅ web-arm dry-build passes (pre-commit hook: `:: web-arm OK`). - ⏳ Post-deploy (needs the live host — eval can't check reachability): - `podman run --rm --network=v6egress docker.io/curlimages/curl -6 -s https://ifconfig.co` → `2a01:4f8:c012:43b::1` (NAT66 source confirmed) - `podman run --rm --network=v6egress docker.io/curlimages/curl -6 -sS -o /dev/null -w '%{http_code}\n' 'https://[2606:4700:4700::1111]'` (or `ping6` a global v6) → reaches a v6-only target - confirm `collabora`/`rustdesk-server` still serve; host `curl -6` and `postconf smtp_address_preference` (=`ipv4`) unchanged > ⚠️ Deliverability caveat (in ADR 0011 + module header): the NAT66 source GUA has no rDNS/SPF, so a future container sending SMTP over this network must pin its own outbound mail to IPv4 — the same constraint ADR 0010 documents for the host. Closes #85
Add a reusable dual-stack podman bridge network (v6egress): private v4 10.89.0.0/24 + ULA v6 fdaa:bbcc:ddee::/64, both masqueraded by netavark. Container v6 is NAT66'd to the host GUA 2a01:4f8:c012:43b::1 and leaves over the #81 default route. The network is created idempotently by an init-v6egress-network oneshot (mirrors how fw provisions per-workload podman networks), and net.ipv6.conf.all.forwarding is enabled so the host routes container v6 out enp1s0.

Egress-only and opt-in via --network=v6egress; collabora and rustdesk-server stay on the default bridge, untouched. Records the decision (NAT66/ULA over a routed GUA sub-prefix; no backend flip; no rDNS/SPF deliverability caveat) in ADR 0011.

Closes #85
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/nixos!86
No description provided.