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:
1 parent
26015c1fe9
commit
02d55d2eec
5 files changed
+105
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
Reference in new issue
Block a user