feat(web-arm): IPv6 egress for podman containers via a reusable NAT66/ULA network #85

Closed
opened 2026-06-03 20:37:13 +02:00 by dominik.polakovics · 1 comment

This was generated by AI during triage.

What to build

web-arm now has working host outbound IPv6 (static /64 + default route on enp1s0,
ADR 0010 / #81). Its podman containers do not — they sit on the default podman
bridge, which is IPv4-only, so a container cannot reach a v6-only destination. This
builds the missing container-egress path as reusable, opt-in infrastructure (a new
container that needs a v6-only destination will be deployed onto it later).

Add a dual-stack podman bridge network any container can opt into:

  • A bridge network (e.g. v6egress) with a private IPv4 subnet and a ULA IPv6
    subnet (fd00::/8). netavark masquerades both to the host's sources, so the container
    reaches the v4 and v6 internet via NAT — the v6 source being the host GUA
    2a01:4f8:c012:43b::1.
  • Created idempotently via a systemd oneshot, the same way fw provisions per-workload
    podman networks (podman network exists … || podman network create …), ordered before
    the containers that attach to it.
  • Host-global IPv6 forwarding enabled (net.ipv6.conf.all.forwarding). Compatible with
    #81's static-address / accept_ra-off setup; does not disturb the host's own v6 route.
  • Containers opt in with --network=v6egress; collabora/rustdesk are left on the default
    v4 network, untouched.

NAT66 from a ULA is chosen over a routed GUA sub-prefix: egress-only (no inbound surface),
no Hetzner-side work, and it doesn't touch the host /64 from #81. Record in a new ADR 0011.

Acceptance criteria

  • A reusable dual-stack podman network (private v4 + ULA v6 /64) exists on web-arm,
    created idempotently, surviving reboot and redeploy
  • net.ipv6.conf.all.forwarding is enabled on web-arm
  • A throwaway container on the network reaches a v6-only destination over IPv6
    (curl -6/ping6 to a known global v6 address succeeds)
  • That container's external v6 egress source is 2a01:4f8:c012:43b::1 (NAT66 confirmed)
  • collabora and rustdesk-server are unchanged (default network, still working)
  • Host outbound IPv6 and outbound mail (IPv4-pinned, ADR 0010) are unaffected
  • docs/adr/0011-*.md added (NAT66/ULA over routed-prefix; podman, no backend flip;
    egress-only; no-rDNS/SPF caveat)
  • web-arm dry-build passes (pre-commit hook)

Blocked by

None — #81 (host IPv6) is merged. Can start immediately.

> *This was generated by AI during triage.* ## What to build web-arm now has working **host** outbound IPv6 (static /64 + default route on enp1s0, ADR 0010 / #81). Its **podman** containers do not — they sit on the default podman bridge, which is IPv4-only, so a container cannot reach a v6-only destination. This builds the missing container-egress path as reusable, opt-in infrastructure (a new container that needs a v6-only destination will be deployed onto it later). Add a **dual-stack podman bridge network** any container can opt into: - A bridge network (e.g. `v6egress`) with a private **IPv4** subnet and a **ULA IPv6** subnet (`fd00::/8`). netavark masquerades both to the host's sources, so the container reaches the v4 *and* v6 internet via NAT — the v6 source being the host GUA `2a01:4f8:c012:43b::1`. - Created idempotently via a systemd oneshot, the same way `fw` provisions per-workload podman networks (`podman network exists … || podman network create …`), ordered before the containers that attach to it. - Host-global IPv6 forwarding enabled (`net.ipv6.conf.all.forwarding`). Compatible with #81's static-address / accept_ra-off setup; does not disturb the host's own v6 route. - Containers opt in with `--network=v6egress`; collabora/rustdesk are left on the default v4 network, untouched. NAT66 from a ULA is chosen over a routed GUA sub-prefix: egress-only (no inbound surface), no Hetzner-side work, and it doesn't touch the host /64 from #81. Record in a new ADR 0011. ## Acceptance criteria - [ ] A reusable dual-stack podman network (private v4 + ULA v6 /64) exists on web-arm, created idempotently, surviving reboot and redeploy - [ ] `net.ipv6.conf.all.forwarding` is enabled on web-arm - [ ] A throwaway container on the network reaches a v6-only destination over IPv6 (`curl -6`/`ping6` to a known global v6 address succeeds) - [ ] That container's external v6 egress source is `2a01:4f8:c012:43b::1` (NAT66 confirmed) - [ ] collabora and rustdesk-server are unchanged (default network, still working) - [ ] Host outbound IPv6 and outbound mail (IPv4-pinned, ADR 0010) are unaffected - [ ] `docs/adr/0011-*.md` added (NAT66/ULA over routed-prefix; podman, no backend flip; egress-only; no-rDNS/SPF caveat) - [ ] web-arm dry-build passes (pre-commit hook) ## Blocked by None — #81 (host IPv6) is merged. Can start immediately.
Author
Owner

This was generated by AI during triage.

Agent Brief

Category: enhancement
Summary: Give web-arm's podman containers an opt-in IPv6 egress path — a reusable
dual-stack podman bridge network whose ULA v6 subnet netavark masquerades (NAT66) to the
host's global v6 source, so a container can reach v6-only destinations.

Current behavior:
web-arm (Hetzner Cloud CAX / ARM) has working host outbound IPv6 since #81 / ADR 0010 —
static 2a01:4f8:c012:43b::1/64 on enp1s0, default route via fe80::1. Its containers run
under podman: virtualisation.oci-containers sets no backend, so it resolves to the
nixos-25.11 default podman — NOT docker. (virtualisation.docker.enable is on, but only
for sa-core's direct docker run; the oci-containers units are podman-*.) The live
oci-containers are collabora and rustdesk-server, on the default podman bridge, which is
IPv4-only. There is no v6 networking.nat, no v6-enabled podman network, and
net.ipv6.conf.all.forwarding is unset. Net effect: a container cannot reach a v6-only host.

Desired behavior:
A new container (deployed later by the maintainer) must reach a v6-only destination. Build a
reusable, opt-in egress path, not a one-off:

  • A podman bridge network, dual-stack: a private IPv4 subnet (normal v4 egress) + a
    ULA IPv6 /64 (fd00::/8). netavark masquerades both to the host; the v6 egress source
    is the host GUA 2a01:4f8:c012:43b::1.
  • Ensured idempotently at boot, persists across redeploys.
  • Host-global IPv6 forwarding enabled.
  • A container opts in by attaching to the network; non-opted containers are unaffected.

Key interfaces / contracts (NixOS options — durable; ignore current file layout):

  • A systemd.services.<name> oneshot that idempotently ensures the podman network exists
    with both subnets, wants/after podman.service, ordered before the podman-*.service
    units that attach to it. Mirror the existing in-repo pattern for provisioning podman
    networks (see how host fw sets up per-workload networks for piped/invidious:
    podman network exists <net> || podman network create --ipv6 --subnet <v4> --subnet <v6> <net>).
  • boot.kernel.sysctl."net.ipv6.conf.all.forwarding" = true. Deployed-host contract:
    sysctl net.ipv6.conf.all.forwarding = 1, without perturbing #81's static v6 address/route
    (accept_ra stays off; addressing is static).
  • A consuming container is a virtualisation.oci-containers.containers.<name> with
    extraOptions = [ "--network=<net>" ]. (Real container comes later; validate with a
    throwaway one.)
  • New ADR docs/adr/0011-*.md (next free number at implementation time).

Literal values:

  • v6 egress source / NAT66 target: 2a01:4f8:c012:43b::1 — already on enp1s0 from #81; do not
    redeclare it.
  • ULA v6 subnet: any fd00::/8 /64, e.g. fdaa:bbcc:ddee::/64. NAT'd, so the value is free —
    just keep it stable and ULA.
  • Private v4 subnet for the network: any RFC1918 /24 not colliding with the default podman
    bridge (10.88.0.0/16), e.g. 10.89.0.0/24.
  • Suggested network name: v6egress.

Acceptance criteria:

  • Reusable dual-stack podman network (private v4 + ULA v6 /64) exists, created
    idempotently, surviving reboot and nixos-rebuild
  • net.ipv6.conf.all.forwarding = 1 on the deployed host
  • A throwaway container on the network reaches a v6-only destination over IPv6
    (curl -6/ping6 to a known global v6 address succeeds)
  • The container's external v6 egress source is 2a01:4f8:c012:43b::1 (e.g.
    curl -6 https://ifconfig.co from the container returns the host GUA)
  • collabora and rustdesk-server stay on the default network and keep working
  • Host outbound IPv6 (curl -6 from host) and outbound mail (IPv4 per ADR 0010 —
    postconf smtp_address_preference = ipv4) are unchanged
  • docs/adr/0011-*.md records: NAT66/ULA over routed GUA sub-prefix (which would need an
    NDP proxy or 2nd Hetzner /64 and would touch #81's on-link /64); podman kept (no backend
    flip); egress-only (no inbound surface); and that the NAT66 source GUA has no rDNS/SPF —
    any future container sending SMTP over this network inherits the exact deliverability
    problem ADR 0010's Postfix v4-pin exists to avoid
  • web-arm dry-build passes (pre-commit hook)

Out of scope:

  • Inbound IPv6 to containers (v6 port-publishing, AAAA) — egress only.
  • The maintainer's actual new container — delivered separately; this issue ships only the
    reusable network, validated with a throwaway container.
  • collabora / rustdesk-server — they don't opt in.
  • sa-core / the Docker daemon — its containers' v6 egress is a separate concern.
  • The latent oci-containers→podman vs virtualisation.docker.enable mismatch and the vestigial
    rustdesk-serverdocker group — pre-existing warts; do not flip oci-containers.backend
    or refactor them here.
  • Any Hetzner-side networking, routed GUA prefixes, or SOPS/secret changes (the ULA isn't a secret).
> *This was generated by AI during triage.* ## Agent Brief **Category:** enhancement **Summary:** Give web-arm's podman containers an opt-in IPv6 **egress** path — a reusable dual-stack podman bridge network whose ULA v6 subnet netavark masquerades (NAT66) to the host's global v6 source, so a container can reach v6-only destinations. **Current behavior:** web-arm (Hetzner Cloud CAX / ARM) has working host outbound IPv6 since #81 / ADR 0010 — static `2a01:4f8:c012:43b::1/64` on enp1s0, default route via `fe80::1`. Its containers run under **podman**: `virtualisation.oci-containers` sets no `backend`, so it resolves to the nixos-25.11 default `podman` — NOT docker. (`virtualisation.docker.enable` is on, but only for sa-core's direct `docker run`; the oci-containers units are `podman-*`.) The live oci-containers are `collabora` and `rustdesk-server`, on the default podman bridge, which is IPv4-only. There is no v6 `networking.nat`, no v6-enabled podman network, and `net.ipv6.conf.all.forwarding` is unset. Net effect: a container cannot reach a v6-only host. **Desired behavior:** A new container (deployed later by the maintainer) must reach a v6-only destination. Build a **reusable, opt-in** egress path, not a one-off: - A podman **bridge** network, dual-stack: a private IPv4 subnet (normal v4 egress) + a **ULA** IPv6 /64 (`fd00::/8`). netavark masquerades both to the host; the v6 egress source is the host GUA `2a01:4f8:c012:43b::1`. - Ensured idempotently at boot, persists across redeploys. - Host-global IPv6 forwarding enabled. - A container opts in by attaching to the network; non-opted containers are unaffected. **Key interfaces / contracts (NixOS options — durable; ignore current file layout):** - A `systemd.services.<name>` **oneshot** that idempotently ensures the podman network exists with both subnets, `wants`/`after` `podman.service`, ordered `before` the `podman-*.service` units that attach to it. Mirror the existing in-repo pattern for provisioning podman networks (see how host `fw` sets up per-workload networks for piped/invidious: `podman network exists <net> || podman network create --ipv6 --subnet <v4> --subnet <v6> <net>`). - `boot.kernel.sysctl."net.ipv6.conf.all.forwarding" = true`. Deployed-host contract: `sysctl net.ipv6.conf.all.forwarding` = 1, without perturbing #81's static v6 address/route (accept_ra stays off; addressing is static). - A consuming container is a `virtualisation.oci-containers.containers.<name>` with `extraOptions = [ "--network=<net>" ]`. (Real container comes later; validate with a throwaway one.) - New ADR `docs/adr/0011-*.md` (next free number at implementation time). **Literal values:** - v6 egress source / NAT66 target: `2a01:4f8:c012:43b::1` — already on enp1s0 from #81; do not redeclare it. - ULA v6 subnet: any `fd00::/8` /64, e.g. `fdaa:bbcc:ddee::/64`. NAT'd, so the value is free — just keep it stable and ULA. - Private v4 subnet for the network: any RFC1918 /24 not colliding with the default podman bridge (10.88.0.0/16), e.g. `10.89.0.0/24`. - Suggested network name: `v6egress`. **Acceptance criteria:** - [ ] Reusable dual-stack podman network (private v4 + ULA v6 /64) exists, created idempotently, surviving reboot and `nixos-rebuild` - [ ] `net.ipv6.conf.all.forwarding` = 1 on the deployed host - [ ] A throwaway container on the network reaches a v6-only destination over IPv6 (`curl -6`/`ping6` to a known global v6 address succeeds) - [ ] The container's external v6 egress source is `2a01:4f8:c012:43b::1` (e.g. `curl -6 https://ifconfig.co` from the container returns the host GUA) - [ ] `collabora` and `rustdesk-server` stay on the default network and keep working - [ ] Host outbound IPv6 (`curl -6` from host) and outbound mail (IPv4 per ADR 0010 — `postconf smtp_address_preference` = ipv4) are unchanged - [ ] `docs/adr/0011-*.md` records: NAT66/ULA over routed GUA sub-prefix (which would need an NDP proxy or 2nd Hetzner /64 and would touch #81's on-link /64); podman kept (no backend flip); egress-only (no inbound surface); and that the NAT66 source GUA has no rDNS/SPF — any future container sending SMTP over this network inherits the exact deliverability problem ADR 0010's Postfix v4-pin exists to avoid - [ ] web-arm dry-build passes (pre-commit hook) **Out of scope:** - **Inbound** IPv6 to containers (v6 port-publishing, AAAA) — egress only. - The maintainer's actual new container — delivered separately; this issue ships only the reusable network, validated with a throwaway container. - `collabora` / `rustdesk-server` — they don't opt in. - sa-core / the **Docker** daemon — its containers' v6 egress is a separate concern. - The latent oci-containers→podman vs `virtualisation.docker.enable` mismatch and the vestigial `rustdesk-server` ∈ `docker` group — pre-existing warts; do **not** flip `oci-containers.backend` or refactor them here. - Any Hetzner-side networking, routed GUA prefixes, or SOPS/secret changes (the ULA isn't a secret).
dominik.polakovics 2026-06-03 21:02:09 +02:00
Sign in to join this conversation.
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#85
No description provided.