From 0adc50a4dbcfe76d3ab575d6e5ce8ef06cf4660c Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:10:31 +0000 Subject: [PATCH] fix(pool): RHEL containers need nmcli IPv6 config, not just networkd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit poolContainerScript only rendered Debian systemd-networkd files (/etc/systemd/network/eth0.network binding the public /128). RHEL-family images use NetworkManager, which ignores networkd config, so a pool-mode Alma/Rocky container never bound its routed-NIC /128 and had no public IPv6 — while Debian worked. Verified on a whitelist-provider host: the nmcli path (eth0 = manual IPv6, /128, fe80::1 gateway, IPv4 disabled; eth1 = DHCPv4) brings up the /128, the default route and v4/v6/DNS connectivity, and the connection survives reboots. - routed_ipv6.go: poolContainerScript branches on the guest stack — RHEL/NetworkManager gets a static nmcli connection matched by DEVICE (name-grep fallback), Debian keeps the networkd files. Idempotent and self-healing on reinstall/ipv6-reapply. - ipv6_test.go: TestPoolContainerScript asserts both branches render the /128, fe80::1 gateway and DHCPv4 markers. --- src/internal/mgr/ipv6_test.go | 36 ++++++++++++++++++++++++ src/internal/mgr/routed_ipv6.go | 50 ++++++++++++++++++++++++--------- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/src/internal/mgr/ipv6_test.go b/src/internal/mgr/ipv6_test.go index f7fc2a0..85e10ff 100644 --- a/src/internal/mgr/ipv6_test.go +++ b/src/internal/mgr/ipv6_test.go @@ -233,3 +233,39 @@ func TestCheckIPv6BlockCollision(t *testing.T) { t.Errorf("nil block should be a no-op: %v", err) } } + +// The pool container script must configure BOTH guest stacks: Debian gets the +// systemd-networkd files binding the public /128 + fe80::1 route on eth0 and +// DHCPv4 on eth1; RHEL-family images (NetworkManager) get the same layout as a +// static nmcli connection — the networkd files alone would be ignored there, +// leaving the routed NIC without its /128 and the container with no public IPv6. +func TestPoolContainerScript(t *testing.T) { + c := cfg.Default() + m := &Manager{cfg: c} + addr := "2a03:b0c0:2:f0:0:1:dbc2:1006" + script, err := m.poolContainerScript(addr) + if err != nil { + t.Fatal(err) + } + // Both stacks: the branch selection and the address/gateway/DHCP markers. + for _, want := range []string{ + "nmcli", // RHEL path present + "ipv6.method manual", // RHEL: NM owns the IPv6 stack + "ipv6.addresses " + addr + "/128", // public /128 bound on eth0 + "ipv6.gateway fe80::1", // routed-NIC gateway + "ipv4.method disabled", // v4 lives on eth1 only + "systemd-networkd", // Debian path present + "Address=" + addr + "/128", // Debian: static bind + "Gateway=fe80::1", // Debian: default route + "DHCP=ipv4", // eth1 DHCPv4 on both stacks + } { + if !strings.Contains(script, want) { + t.Errorf("script missing %q:\n%s", want, script) + } + } + // An empty address is a no-op (V4-only container) — the empty check lives in + // ipv6ContainerScriptFor, the pool script's only caller. + if s, _ := m.ipv6ContainerScriptFor("", ""); s != "" { + t.Errorf("expected empty script for empty address, got %q", s) + } +} diff --git a/src/internal/mgr/routed_ipv6.go b/src/internal/mgr/routed_ipv6.go index 1935a70..642986b 100644 --- a/src/internal/mgr/routed_ipv6.go +++ b/src/internal/mgr/routed_ipv6.go @@ -127,21 +127,45 @@ fi return script, nil } -// poolContainerScript renders the Debian systemd-networkd config for a pool -// container: eth0 (routed NIC) statically binds the public /128 with the -// fe80::1 gateway, eth1 (bridged NIC) runs DHCPv4 on the private bridge. -// eth0 also gets public IPv6 nameservers: the routed NIC has no DHCPv6/RA DNS -// source, and systemd-resolved's stub (127.0.0.53) cannot resolve v6-only -// names without an upstream v6 DNS. Idempotent and self-healing. +// poolContainerScript renders the guest-side network config for a pool +// container, on whichever stack the image ships: +// +// - eth0 — routed NIC: the public /128 bound statically with fe80::1 (Incus's +// routed-NIC gateway) as the default route, no DHCPv6/RA. +// - eth1 — bridged NIC on the private bridge: DHCPv4 for the shared IPv4. +// +// RHEL-family images use NetworkManager, which ignores the systemd-networkd +// files Debian relies on — so there the eth0 connection is declared a STATIC +// IPv6 profile via nmcli (manual method + /128 + gateway fe80::1 + no IPv4) +// and eth1 keeps DHCPv4. Debian gets the networkd files, which also carry the +// public IPv6 nameservers (the routed NIC has no DHCPv6/RA DNS source, and +// systemd-resolved's 127.0.0.53 stub cannot resolve v6-only names without an +// upstream v6 DNS). Both paths are idempotent and self-healing on reapply; the +// nmcli profile survives reboots, so the /128 comes back on every boot. func (m *Manager) poolContainerScript(ipv6 string) (string, error) { return fmt.Sprintf(`set -e for i in $(seq 1 40); do [ -S /run/systemd/private ] && break sleep 0.5 done -mkdir -p /etc/systemd/network -# eth0: public routed /128, static, no DHCP (v4 lives on eth1). -cat > /etc/systemd/network/eth0.network <<'EOF' +if command -v nmcli >/dev/null 2>&1 && ! systemctl is-active systemd-networkd >/dev/null 2>&1; then + # RHEL-family (NetworkManager): the routed eth0 carries only the public /128 + # (v4 lives on eth1), so its connection is manual IPv6 + disabled IPv4 with + # the fe80::1 gateway. eth1 keeps DHCPv4. Match the connection by DEVICE + # (names vary between distros) and fall back to a name grep. + CONN0=$(nmcli -t -f NAME,DEVICE con show 2>/dev/null | awk -F: '$2 == "eth0" {print $1; exit}') + [ -z "$CONN0" ] && CONN0=$(nmcli -t -f NAME con show 2>/dev/null | grep -i eth0 | head -1) + [ -n "$CONN0" ] && nmcli con mod "$CONN0" ipv6.method manual ipv6.addresses %s/128 ipv6.gateway fe80::1 ipv4.method disabled 2>/dev/null || true + [ -n "$CONN0" ] && nmcli con up "$CONN0" >/dev/null 2>&1 || true + CONN1=$(nmcli -t -f NAME,DEVICE con show 2>/dev/null | awk -F: '$2 == "eth1" {print $1; exit}') + [ -z "$CONN1" ] && CONN1=$(nmcli -t -f NAME con show 2>/dev/null | grep -i eth1 | head -1) + [ -n "$CONN1" ] && nmcli con mod "$CONN1" ipv4.method auto 2>/dev/null || true + [ -n "$CONN1" ] && nmcli con up "$CONN1" >/dev/null 2>&1 || true +else + # Debian (systemd-networkd): eth0 binds the public /128 statically with the + # public IPv6 nameservers; eth1 runs DHCPv4 on the private bridge. + mkdir -p /etc/systemd/network + cat > /etc/systemd/network/eth0.network <<'EOF' [Match] Name=eth0 @@ -158,16 +182,16 @@ Address=%s/128 Destination=::/0 Gateway=fe80::1 EOF -# eth1: private bridge, DHCPv4. -cat > /etc/systemd/network/eth1.network <<'EOF' + cat > /etc/systemd/network/eth1.network <<'EOF' [Match] Name=eth1 [Network] DHCP=ipv4 EOF -systemctl restart systemd-networkd || true -`, ipv6), nil + systemctl restart systemd-networkd || true +fi +`, ipv6, ipv6), nil } // ConfigureContainerIPv6 applies the host-routed IPv6 setup to one container