diff --git a/docs/ipv6.md b/docs/ipv6.md index 1c0d12a..412760c 100644 --- a/docs/ipv6.md +++ b/docs/ipv6.md @@ -81,6 +81,15 @@ that the host can see is already taken: The conflicting prefix route Incus auto-creates on the bridge is deleted (eth0 keeps the authoritative route), and IPv6 forwarding is enabled. +`SetupIPv6Bridge` also **pins the bridge's MAC** (`bridge.hwaddr`) to the value +already in use. Incus assigns a managed bridge a random MAC and does not persist +it, so a host reboot recreates the bridge with a new one. That matters because +every container pins the bridge MAC as its static gateway neighbor (see below), +and a container that boots with the old MAC keeps its address but loses all +IPv6. Pinning makes the guest pins valid for the life of the host; the write is +idempotent, and because it stores the MAC already in use the bridge is never +re-created by the change. + ## Per-container wiring For each container: @@ -95,6 +104,13 @@ For each container: DHCPv6 would hand the recreated container a *dynamic* address instead, which falls outside the routed /112 and is dropped by `ipv6_filtering`. Binding the /128 directly makes IPv6 survive reinstalls. +- The gateway (`fe80::1`, the bridge) is **pinned as a static neighbor** in the + guest. The guest cannot resolve it by NDP: `security.ipv6_filtering` drops a + neighbor solicitation whose source is outside the routed /112, and that + solicitation is sourced from the guest's link-local. The pinned MAC is the + bridge's own, which is exactly why the bridge MAC is pinned (see the bridge + setup above) — a stale pin leaves the container with its address but no + working IPv6, which is the symptom to look for first. - DHCPv6 is turned off on Debian (`DHCP=ipv4` plus `[IPv6AcceptRA] DHCPv6Client=no` — the RA's Managed flag would otherwise start the DHCPv6 client regardless of `DHCP=`), and the RA is told to generate no SLAAC address, so the container diff --git a/src/internal/lx/lx.go b/src/internal/lx/lx.go index d723a5e..9cbf0b4 100644 --- a/src/internal/lx/lx.go +++ b/src/internal/lx/lx.go @@ -462,6 +462,18 @@ func (c *Client) State(name string) (string, error) { // ---- mutations ---- +// NetworkGet returns one config option of a managed Incus network, or "" when +// the option is unset. Used to read back bridge.hwaddr before pinning it. +func (c *Client) NetworkGet(network, key string) (string, error) { + var net struct { + Config map[string]string `json:"config"` + } + if err := c.get("/1.0/networks/"+url.PathEscape(network), &net); err != nil { + return "", err + } + return net.Config[key], nil +} + // NetworkSet sets one key=value config option on a managed Incus network // (e.g. incusbr0). Used for IPv6 pass-through bridge configuration. func (c *Client) NetworkSet(network, kv string) error { diff --git a/src/internal/mgr/ipv6.go b/src/internal/mgr/ipv6.go index 0e0a83f..a4c44eb 100644 --- a/src/internal/mgr/ipv6.go +++ b/src/internal/mgr/ipv6.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "sort" "strconv" "strings" @@ -178,6 +179,12 @@ func (m *Manager) SetupIPv6Bridge() error { return err } bridge := m.cfg.Incus.Bridge + // Pin the bridge MAC before touching anything else. Every container's guest + // config pins this MAC as its static fe80::1 gateway neighbor, and a changed + // MAC strands its IPv6 until the config is rewritten (see pinBridgeMAC). + if err := m.pinBridgeMAC(bridge); err != nil { + return fmt.Errorf("pin bridge MAC on %s: %w", bridge, err) + } for _, kv := range []string{ "ipv6.address=" + gw + "/" + strconv.Itoa(bridgeOnes), "ipv6.nat=false", @@ -413,6 +420,11 @@ func (m *Manager) ndppdConf(fresh, drop string) (string, error) { return "", nil } sort.Strings(blocks) + // A block can land here twice: the loop above already added every existing + // account's block, and `fresh` adds the same block again for a container + // whose account row already exists (a reinstall). Harmless for matching, but + // a duplicated rule is wrong to render. + blocks = slices.Compact(blocks) var b strings.Builder b.WriteString(cfg.GeneratedBanner) fmt.Fprintf(&b, "proxy %s {\n", ext) diff --git a/src/internal/mgr/ipv6_test.go b/src/internal/mgr/ipv6_test.go index 91b8e57..09eaad4 100644 --- a/src/internal/mgr/ipv6_test.go +++ b/src/internal/mgr/ipv6_test.go @@ -351,3 +351,37 @@ func TestPickIPv6IndexSkipsGatewayBlock(t *testing.T) { } } } + +// ndppdConf must render exactly one rule per block. A reinstall passes the +// container's block explicitly as `fresh` while its account row already exists, +// so the block is seen twice and would otherwise be emitted twice. +func TestNDPPDConfDeduplicatesRules(t *testing.T) { + m := ipv6TestManager(t, "2001:db8::/64", map[string]int64{"alice": legacyAlice}) + m.cfg.Net.ExtIF = "eth0" + block, err := m.IPv6Block("alice") + if err != nil || block == nil { + t.Fatalf("IPv6Block = %v, %v", block, err) + } + conf, err := m.ndppdConf(block.String(), "") + if err != nil { + t.Fatal(err) + } + if got := strings.Count(conf, "rule "+block.String()+" {"); got != 1 { + t.Errorf("want exactly 1 rule for %s, got %d:\n%s", block, got, conf) + } +} + +// A container created before its account row exists is passed as `fresh`; its +// rule must still be rendered. +func TestNDPPDConfEmitsFreshBlock(t *testing.T) { + m := ipv6TestManager(t, "2001:db8::/64", nil) + m.cfg.Net.ExtIF = "eth0" + const block = "2001:db8::2bd8:6c9:0/112" + conf, err := m.ndppdConf(block, "") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(conf, "rule "+block+" {") { + t.Errorf("fresh block missing from:\n%s", conf) + } +} diff --git a/src/internal/mgr/routed_ipv6.go b/src/internal/mgr/routed_ipv6.go index d5ef1fc..650c946 100644 --- a/src/internal/mgr/routed_ipv6.go +++ b/src/internal/mgr/routed_ipv6.go @@ -283,6 +283,37 @@ func (m *Manager) bridgeMAC() string { return mac.String() } +// pinBridgeMAC records the bridge's current MAC in bridge.hwaddr so it stays +// stable for the life of the host. +// +// Incus assigns a random MAC to a managed bridge and does not persist it, so a +// host reboot recreates the bridge with a new one. Every container's guest +// config pins that MAC as its static fe80::1 gateway neighbor — the guest +// cannot resolve the gateway by NDP, because security.ipv6_filtering drops its +// neighbor solicitation (the NS is sourced from the guest's link-local, which +// is outside the routed /112 that the per-NIC source filter allows). A +// container that boots with a stale pinned MAC therefore keeps its address but +// loses all IPv6, and the boot-time reapply only heals it once the container is +// running — a container that starts after the reapply pass comes up broken. +// Pinning the MAC removes the failure: the guest pins never go stale. +// +// Idempotent: it writes only when bridge.hwaddr is unset, and it writes the MAC +// already in use, so the bridge is never re-created by the change. +func (m *Manager) pinBridgeMAC(bridge string) error { + mac := m.bridgeMAC() + if mac == "" { + return nil + } + cur, err := m.lx.NetworkGet(bridge, "bridge.hwaddr") + if err != nil { + return err + } + if strings.TrimSpace(cur) != "" { + return nil + } + return m.lx.NetworkSet(bridge, "bridge.hwaddr="+mac) +} + // poolContainerScript renders the guest-side network config for a pool // container, on whichever stack the image ships: //