fix(ipv6): keep the bridge MAC stable and render one NDP rule per block

Every container pins the bridge's MAC as its static fe80::1 gateway
neighbor — the guest cannot resolve the gateway by NDP, because
security.ipv6_filtering drops its neighbor solicitation (it is sourced from
the guest link-local, which is outside the routed /112 the source filter
allows). Incus does not persist a managed bridge's MAC, so a host reboot
recreated the bridge with a new one and every guest pinned to the old MAC
kept its address but lost all IPv6; the boot-time reapply only heals a
container that is already running. bridge.hwaddr is now pinned to the MAC
in use, so the guest pins never go stale.

Also render one NDP rule per block: a reinstall passed the container's block
as `fresh` while its account row already existed, emitting the rule twice.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-10-07 07:49:47 +00:00
1 parent 26015c1fe9
commit 02d55d2eec
5 files changed
+105

No files matched your search

+16
View File
@@ -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
+12
View File
@@ -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 {
+12
View File
@@ -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)
+34
View File
@@ -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)
}
}
+31
View File
@@ -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:
//