From 70eac5918fa436c1bdc9953924df148f5a46f896 Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:19:40 +0000 Subject: [PATCH] feat(ipv6): opt-in permanent pin of the upstream gateway neighbour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add net.ipv6_pin_gateway (off by default): make the HOST's upstream link-local default gateway a permanent neighbour entry, so the kernel stops probing it. Why: on a host whose provider router answers neighbour solicitations unreliably, the host's neighbour for its default nexthop drops to INCOMPLETE, and the host then answers every forwarded packet — every container's IPv6 included — with "address unreachable" until it recovers. The container keeps its address and can ping the bridge, but nothing off-link works, intermittently; the host's own IPv6 drops at the same moments. Captured on-bridge: the host (the bridge's global address) returning ICMP6 destination-unreachable for the container's packets, correlated with the gateway neighbour going REACHABLE -> DELAY -> PROBE -> INCOMPLETE. Applied by RewireAllIPv6, so `vps ipv6-reapply` (the boot unit) and `vps install` re-apply it, and `vps config set net.ipv6_pin_gateway true` applies it immediately. It only touches the host's own link-local default gateway, and only while that gateway is resolved: the MAC is read from the live neighbour table by the new `vps ip6 neigh-pin` root helper (`neigh-unpin` drops the entry again when the key is turned off). Off by default because a permanent entry is never re-resolved — a gateway that later moves to another MAC would be missed. Also drop the bridge-MAC pin from 02d55d2 (and lx.NetworkGet with it): measured on a live host, Incus keeps a managed bridge's MAC stable across two reboots even with bridge.hwaddr unset and no stored MAC, so that pin guarded a failure that does not occur. Co-authored-by: CommandCodeBot --- docs/configuration.md | 17 ++++++++ docs/ipv6.md | 46 +++++++++++++------- src/internal/cfg/cfg.go | 9 ++++ src/internal/cfg/registry.go | 12 ++++++ src/internal/lx/lx.go | 12 ------ src/internal/mgr/ipv6.go | 74 ++++++++++++++++++++++++++++++--- src/internal/mgr/ipv6_test.go | 24 +++++++++++ src/internal/mgr/routed_ipv6.go | 31 -------------- src/internal/su/su.go | 3 +- src/main.go | 18 ++++++++ src/main_ip6.go | 66 ++++++++++++++++++++++++++++- src/main_ip6_test.go | 26 ++++++++++++ 12 files changed, 272 insertions(+), 66 deletions(-) create mode 100644 src/main_ip6_test.go diff --git a/docs/configuration.md b/docs/configuration.md index b988e6c..d6c6f94 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -49,6 +49,7 @@ same table; `vps config list` shows the live values with this annotation. | `net.ipv6_mode` | **fixed at install** | — | IPv6 allocation mode: `none` / `prefix` (/112 blocks) / `pool` (per-container address) | | `net.ipv6_pool` | operator | `vps config set` needs `--apply`; admin-panel changes apply immediately | pool-mode address list (bare global addresses or `/128`); editable via the admin panel's IPv6 Pool page | | `net.ipv6_extra_prefix` | operator | applied immediately (rewires host IPv6 + restarts the panel); admin-panel changes apply immediately too | optional extra prefix (prefix mode alongside /112, or IPv4-only none mode as standalone routed /64) that a container can be given a whole /64 out of, e.g. `2001:1c00:b1b:7f0::/60`; empty = feature off (the default, including on every upgrade). Accepted as-is — the /64 this host itself uses is never handed out | +| `net.ipv6_pin_gateway` | operator | **applied immediately** (host IPv6 rewired; no panel restart) | pin the host's upstream link-local default gateway as a **permanent** neighbour, working around a provider router that answers neighbour solicitations unreliably (the host's default route otherwise drops to `INCOMPLETE` and every container loses IPv6 with it). **Off by default**: a permanent entry is never re-resolved, so a gateway that moves to another MAC is then missed | | `incus.image` | operator | next `vps add` / reinstall | container image alias | | `incus.image_fallback` | operator | next `vps add` / reinstall | fallback remote image | | `incus.pool` | **fixed at install** | — | storage pool (backend selected at install via `VPSMGR_STORAGE=zfs\|btrfs\|dir`) | @@ -130,6 +131,11 @@ net: # (/48, /56, /60 ...). Works in prefix mode or IPv4-only # (none) mode. Empty = off (the default). The /64 this host # uses itself is never handed out. See docs/ipv6.md + ipv6_pin_gateway: false # optional: pin the host's upstream link-local default + # gateway as a PERMANENT neighbour, working around a + # provider router that answers NDP unreliably. Off by + # default — a permanent entry is never re-resolved. + # See docs/ipv6.md incus: image: "vpsmgr/debian-sshd" @@ -333,6 +339,17 @@ goes away when the panel is uninstalled. routes) is rewired, the boot unit is refreshed and the panel is restarted so the create form updates. A value that holds no whole /64 (a /64 or longer) simply leaves the feature inert. +- `net.ipv6_pin_gateway` is **optional and off by default**. It makes the host's + own upstream default gateway — only when that gateway is a link-local address + — a permanent neighbour entry, so the kernel stops probing it. That works + around providers whose router answers neighbour solicitations unreliably: the + host's default nexthop otherwise falls to `INCOMPLETE`, and every forwarded + packet, every container's IPv6 included, is answered with *address + unreachable* until it recovers. It is re-applied by `vps ipv6-reapply` and + `vps install`, touches only that one gateway, and does nothing while the + gateway is unresolved. Turning the key off drops the permanent entry again. + The trade-off is why the default is off: a permanent entry is never + re-resolved, so a gateway that later moves to a different MAC is not picked up. - Clearing `net.ipv6_subnet` (empty) only stops vpsmgr from applying IPv6 on the next `vps install`; it does not remove IPv6 already in place (bridge address, ndppd, routes). Full IPv6 cleanup happens in `uninstall.sh`. diff --git a/docs/ipv6.md b/docs/ipv6.md index 412760c..9a605a2 100644 --- a/docs/ipv6.md +++ b/docs/ipv6.md @@ -81,15 +81,6 @@ 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: @@ -105,12 +96,10 @@ For each container: 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. + guest — the bridge's MAC is baked in at provisioning time. 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. - 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 @@ -131,6 +120,33 @@ rules survive reboots. `vps ipv6-reapply` also re-applies the per-container routed-IPv6 config (self-healing: containers created before the host-routed scheme, or whose networkd config was corrupted, are repaired on every boot). +## Upstream gateway neighbour pin (`net.ipv6_pin_gateway`, optional) + +Off by default. A container's IPv6 rides on the host's own IPv6: the host reaches +the internet through a default route whose nexthop is the provider's link-local +gateway, and a container's packets are forwarded through it. Some providers' +routers answer neighbour solicitations unreliably, so the host's neighbour entry +for that gateway drops to `INCOMPLETE` — and until it recovers, the host answers +every forwarded packet, the containers' included, with *address unreachable*. + +The symptom is distinctive: the container keeps its address, can ping the +bridge, but **nothing off-link works, intermittently** (and the host's own IPv6 +drops at the same moments). No container-side config fixes it, because the +container is not at fault. + +`net.ipv6_pin_gateway: true` makes that gateway's neighbour entry **permanent**, +so the kernel never probes it again and the failure cannot occur. +`RewireAllIPv6` applies it — so it is re-applied at boot by +`vps-ipv6.service` and by `vps install` — and it only ever touches the host's own +**link-local default gateway**, and only while that gateway is resolved (the MAC +is read from the live neighbour table; an unresolved gateway is left alone). +Turning the key off again drops the permanent entry on the next pass. + +The catch, and the reason it is opt-in: a permanent entry is never re-resolved, +so a gateway that later moves to a **different MAC** would be missed and the +host's upstream would black-hole silently. On a provider that behaves, the +default (off) is the right setting. + ## Installer flow - `00-ip-ask.sh` — the install-time network asks: whether to enable IPv6 diff --git a/src/internal/cfg/cfg.go b/src/internal/cfg/cfg.go index c86b40b..11c0f83 100644 --- a/src/internal/cfg/cfg.go +++ b/src/internal/cfg/cfg.go @@ -315,6 +315,15 @@ type NetCfg struct { // may hand us only a slice of what the provider gave them and asserts it is // theirs. The /64 the host itself uses is never handed to a container. IPv6ExtraPrefix string `yaml:"ipv6_extra_prefix,omitempty"` + // IPv6PinGateway pins the HOST's upstream default gateway as a permanent + // neighbour on the external interface. Some providers' routers answer + // neighbour solicitations unreliably, so the host's default nexthop drops to + // INCOMPLETE and every forwarded packet — every container's IPv6 included — + // is answered with "address unreachable" until it recovers. A permanent + // entry skips the probe entirely. OFF by default, and deliberately so: a + // permanent entry is never re-resolved, so a gateway that moves to another + // MAC afterwards is missed. Only for hosts that actually see the flapping. + IPv6PinGateway bool `yaml:"ipv6_pin_gateway,omitempty"` } // IPv6 mode values. diff --git a/src/internal/cfg/registry.go b/src/internal/cfg/registry.go index ca343eb..e18b225 100644 --- a/src/internal/cfg/registry.go +++ b/src/internal/cfg/registry.go @@ -412,6 +412,18 @@ var Fields = []Field{ } return nil }}, + {"net.ipv6_pin_gateway", KindOperator, ApplyImmediate, + "pin the HOST's upstream link-local default gateway as a permanent neighbour, so the host's default route stops losing it to a failed neighbour probe — some provider routers answer neighbour solicitations unreliably, and then the host, and with it every container, loses IPv6 until it recovers. Off by default: a permanent entry is never re-resolved, so a gateway that moves to another MAC is not picked up on its own", + "true or false", + getStr(func(c *Config) string { return strconv.FormatBool(c.Net.IPv6PinGateway) }), + func(c *Config, v string) error { + b, ok := parseBool(v) + if !ok { + return fmt.Errorf("net.ipv6_pin_gateway must be true/false or 1/0") + } + c.Net.IPv6PinGateway = b + return nil + }}, {"incus.image", KindOperator, ApplyNextAdd, "container image alias used on add/reinstall", "vpsmgr/debian-sshd", getStr(func(c *Config) string { return c.Incus.Image }), diff --git a/src/internal/lx/lx.go b/src/internal/lx/lx.go index 9cbf0b4..d723a5e 100644 --- a/src/internal/lx/lx.go +++ b/src/internal/lx/lx.go @@ -462,18 +462,6 @@ 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 a4c44eb..5af93c9 100644 --- a/src/internal/mgr/ipv6.go +++ b/src/internal/mgr/ipv6.go @@ -179,12 +179,6 @@ 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", @@ -356,6 +350,69 @@ func (m *Manager) enableProxyNDP() error { return err } +// pinUpstreamGateway pins (or clears) the HOST's upstream default-gateway +// neighbour permanently when net.ipv6_pin_gateway is set. +// +// Some providers' routers answer neighbour solicitations unreliably: the host's +// default nexthop drops to INCOMPLETE, and every forwarded packet — every +// container's IPv6 included — is answered with "address unreachable" until it +// recovers. A permanent entry removes the probe entirely. Off by default, +// because a permanent entry is never re-resolved: a gateway that later moves to +// another MAC would be missed. Only the host's own link-local upstream gateway +// is touched, and only while it is resolved (the neigh-pin helper reads the MAC +// from the live table and does nothing when it is absent). +func (m *Manager) pinUpstreamGateway() error { + gw, dev, ok := m.upstreamLinkLocalGateway() + if !ok { + return nil + } + if m.cfg.Net.IPv6PinGateway { + _, err := su.IP6("neigh-pin", gw, dev) + return err + } + // Feature off: clear a permanent entry an earlier run may have left, so + // turning the key off actually reverts the host. + _, err := su.IP6("neigh-unpin", gw, dev) + return err +} + +// upstreamLinkLocalGateway returns the host's default IPv6 gateway when it is a +// link-local address — the only case net.ipv6_pin_gateway applies to — together +// with the interface it is reached over. +func (m *Manager) upstreamLinkLocalGateway() (addr, dev string, ok bool) { + out, err := exec.Command("ip", "-6", "route", "show", "default").CombinedOutput() + if err != nil { + return "", "", false + } + return parseUpstreamLinkLocalGateway(string(out)) +} + +// parseUpstreamLinkLocalGateway picks the first link-local default gateway out +// of `ip -6 route show default`, with the interface it is reached over. Pure, +// so the choice is tested without a live route table. +func parseUpstreamLinkLocalGateway(out string) (addr, dev string, ok bool) { + for _, line := range strings.Split(out, "\n") { + f := strings.Fields(line) + var gw, d string + for i, tok := range f { + if i+1 >= len(f) { + break + } + switch tok { + case "via": + gw = f[i+1] + case "dev": + d = f[i+1] + } + } + ip := net.ParseIP(gw) + if ip != nil && ip.To4() == nil && ip.IsLinkLocalUnicast() && d != "" { + return ip.String(), d, true + } + } + return "", "", false +} + // ndppdConfPath is where vpsmgr renders the ndppd rules. It lives inside // /etc/vpsmgr (the panel's own writable dir) instead of /etc/ndppd.conf in the // root-owned area of the filesystem — the panel daemon generates this file, so @@ -561,6 +618,11 @@ func (m *Manager) cleanLegacyKernelProxy() { // is just "pool addresses are not bound on the external interface + proxy_ndp // / forwarding on" (the routed NICs program their own per-address routes). func (m *Manager) RewireAllIPv6() error { + // The upstream gateway pin is host-level and mode-independent, so it runs + // for every mode (including a plain V4-only host that opted in). + if err := m.pinUpstreamGateway(); err != nil { + return fmt.Errorf("pin upstream gateway neighbor: %w", err) + } if m.cfg.IPv6ModeEffective() == cfg.IPv6ModePool { return m.RewireAllIPv6Pool() } diff --git a/src/internal/mgr/ipv6_test.go b/src/internal/mgr/ipv6_test.go index 09eaad4..8f5acde 100644 --- a/src/internal/mgr/ipv6_test.go +++ b/src/internal/mgr/ipv6_test.go @@ -385,3 +385,27 @@ func TestNDPPDConfEmitsFreshBlock(t *testing.T) { t.Errorf("fresh block missing from:\n%s", conf) } } + +// Only a link-local default gateway qualifies for net.ipv6_pin_gateway: a +// global gateway is resolved by routing (nothing to pin), and IPv4 default +// routes are irrelevant. +func TestParseUpstreamLinkLocalGateway(t *testing.T) { + cases := []struct { + name string + out string + wantGW string + wantDev string + wantOK bool + }{ + {"link-local", "default via fe80::1 dev eth0 proto static metric 1024 onlink pref medium\n", "fe80::1", "eth0", true}, + {"global", "default via 2001:db8::1 dev eth0 proto static metric 1024 pref medium\n", "", "", false}, + {"ipv4", "default via 198.51.100.1 dev eth0 proto static metric 1024 pref medium\n", "", "", false}, + {"none", "", "", "", false}, + } + for _, tc := range cases { + gw, dev, ok := parseUpstreamLinkLocalGateway(tc.out) + if gw != tc.wantGW || dev != tc.wantDev || ok != tc.wantOK { + t.Errorf("%s: got (%q,%q,%v), want (%q,%q,%v)", tc.name, gw, dev, ok, tc.wantGW, tc.wantDev, tc.wantOK) + } + } +} diff --git a/src/internal/mgr/routed_ipv6.go b/src/internal/mgr/routed_ipv6.go index 650c946..d5ef1fc 100644 --- a/src/internal/mgr/routed_ipv6.go +++ b/src/internal/mgr/routed_ipv6.go @@ -283,37 +283,6 @@ 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: // diff --git a/src/internal/su/su.go b/src/internal/su/su.go index df23bb7..4d37360 100644 --- a/src/internal/su/su.go +++ b/src/internal/su/su.go @@ -32,7 +32,8 @@ func Run(args ...string) (string, error) { // argument — an IPv6 address/CIDR and a legal interface name — before touching // the network, so the panel never gets bare `ip -6 ... *` rights (which would // let it run ip with arbitrary arguments). op is one of route-add, addr-add, -// route-del, neigh-del-proxy; val is the address/CIDR; dev is the interface. +// route-del, neigh-del-proxy, neigh-add-proxy, neigh-pin, neigh-unpin; val is +// the address/CIDR; dev is the interface. func IP6(op, val, dev string) (string, error) { return Run("/usr/local/bin/vps", "ip6", op, val, dev) } diff --git a/src/main.go b/src/main.go index e8ef965..7358db2 100644 --- a/src/main.go +++ b/src/main.go @@ -1097,6 +1097,22 @@ func configSet(args []string) error { case cfg.ApplyImmediate: // Config-owned runtime settings the panel reads live: write the DB mirror // (the channel to the long-running panel) and apply now. No restart. + if key == "net.ipv6_pin_gateway" { + // Host-level neighbour plumbing only: rewire (the pin is applied by + // RewireAllIPv6), no panel restart needed. + d, err := db.Open(c.Panel.DB) + if err != nil { + return fmt.Errorf("config saved, but applying it failed: %w", err) + } + m := mgr.New(c, d) + err = m.RewireAllIPv6() + d.Close() + if err != nil { + return fmt.Errorf("config saved, but rewiring host IPv6 failed: %w", err) + } + fmt.Printf("%s updated and applied (host IPv6 rewired).\n", key) + return nil + } if key == "net.ipv6_extra_prefix" { // Host wiring first (forwarding, proxy_ndp, NDP rules, block routes), // then make the boot unit match, then restart the panel: it holds its @@ -1248,6 +1264,8 @@ func confirmApply(c *cfg.Config, f *cfg.Field, key string) (bool, error) { what = "re-applies the swap allowance of every existing container (no restart)" case "net.ipv6_extra_prefix": what = "rewires host IPv6 plumbing (forwarding / proxy_ndp / NDP rules), (re)installs the IPv6 boot unit and restarts the panel" + case "net.ipv6_pin_gateway": + what = "pins (or, when turned off, clears) the host's upstream gateway neighbour as a PERMANENT entry — a permanent entry is never re-resolved, so a gateway that moves to another MAC is then missed" default: what = "applies immediately" } diff --git a/src/main_ip6.go b/src/main_ip6.go index 38931ef..2540e1d 100644 --- a/src/main_ip6.go +++ b/src/main_ip6.go @@ -28,13 +28,15 @@ import ( // route-del ip -6 route del /128 dev // neigh-del-proxy ip -6 neigh del proxy dev // neigh-add-proxy ip -6 neigh add proxy dev +// neigh-pin make 's current lladdr permanent on +// neigh-unpin drop a PERMANENT neighbour entry for // proxy-ndp-on sysctl -w net.ipv6.conf..proxy_ndp=1 // // Every non-op argument must be a valid IPv6 address/CIDR and a valid Linux // interface name — nothing else can reach `ip`. func cmdIP6(args []string) error { if len(args) != 3 { - return fmt.Errorf("usage: vps ip6 ") + return fmt.Errorf("usage: vps ip6 ") } op, val, dev := args[0], args[1], args[2] @@ -111,11 +113,73 @@ func cmdIP6(args []string) error { return fmt.Errorf("neigh-add-proxy: %q is not an IPv6 address", val) } return runIP("-6", "neigh", "add", "proxy", val, "dev", dev) + case "neigh-pin": + // Make the upstream default gateway's neighbour entry permanent, so its + // MAC is never re-probed. The MAC comes from the current neighbour + // table; when the gateway is not resolved right now we leave the table + // alone rather than pin a guessed MAC. + ip := net.ParseIP(val) + if ip == nil || ip.To4() != nil { + return fmt.Errorf("neigh-pin: %q is not an IPv6 address", val) + } + _, lladdr, err := neighborEntry(dev, val) + if err != nil { + return err + } + if lladdr == "" { + return nil + } + return runIP("-6", "neigh", "replace", val, "lladdr", lladdr, "nud", "permanent", "dev", dev) + case "neigh-unpin": + // Drop a PERMANENT entry only — a normal kernel-learned one is left for + // the kernel to manage. + ip := net.ParseIP(val) + if ip == nil || ip.To4() != nil { + return fmt.Errorf("neigh-unpin: %q is not an IPv6 address", val) + } + state, _, err := neighborEntry(dev, val) + if err != nil { + return err + } + if state != "PERMANENT" { + return nil + } + return runIP("-6", "neigh", "del", val, "dev", dev) default: return fmt.Errorf("unknown ip6 operation %q", op) } } +// neighborEntry reports the state ("PERMANENT", "REACHABLE", ...) and lladdr of +// the neighbour entry for addr on dev, as `ip -6 neigh show` prints them. Both +// are "" when no entry exists yet. +func neighborEntry(dev, addr string) (state, lladdr string, err error) { + out, err := exec.Command("/sbin/ip", "-6", "neigh", "show", "dev", dev, "to", addr).CombinedOutput() + if err != nil { + return "", "", fmt.Errorf("ip -6 neigh show %s: %s", addr, strings.TrimSpace(string(out))) + } + state, lladdr = parseNeighborEntry(string(out)) + return state, lladdr, nil +} + +// parseNeighborEntry extracts the state and lladdr from `ip -6 neigh show` +// output; both are "" when the entry carries none. Pure, so it is tested +// without a live neighbour table. +func parseNeighborEntry(out string) (state, lladdr string) { + fields := strings.Fields(out) + for i, f := range fields { + switch f { + case "lladdr": + if i+1 < len(fields) { + lladdr = fields[i+1] + } + case "PERMANENT", "REACHABLE", "STALE", "DELAY", "PROBE", "INCOMPLETE", "FAILED", "NOARP": + state = f + } + } + return state, lladdr +} + // runIP executes the ip command (this process is already root via sudo). func runIP(args ...string) error { out, err := exec.Command("/sbin/ip", args...).CombinedOutput() diff --git a/src/main_ip6_test.go b/src/main_ip6_test.go new file mode 100644 index 0000000..d0bd6e1 --- /dev/null +++ b/src/main_ip6_test.go @@ -0,0 +1,26 @@ +package main + +import "testing" + +// parseNeighborEntry feeds the neigh-pin/neigh-unpin helpers: the MAC must be +// read correctly (a wrong one would pin the gateway to a black hole) and an +// unresolved entry must yield an empty lladdr so the helper leaves it alone. +func TestParseNeighborEntry(t *testing.T) { + cases := []struct { + name string + out string + wantState string + wantLladdr string + }{ + {"reachable", "fe80::1 dev eth0 lladdr 00:00:5e:60:02:07 router REACHABLE \n", "REACHABLE", "00:00:5e:60:02:07"}, + {"permanent", "fe80::1 dev eth0 lladdr 00:00:5e:60:02:07 PERMANENT \n", "PERMANENT", "00:00:5e:60:02:07"}, + {"incomplete", "fe80::1 dev eth0 router INCOMPLETE \n", "INCOMPLETE", ""}, + {"absent", "", "", ""}, + } + for _, tc := range cases { + state, lladdr := parseNeighborEntry(tc.out) + if state != tc.wantState || lladdr != tc.wantLladdr { + t.Errorf("%s: got (%q,%q), want (%q,%q)", tc.name, state, lladdr, tc.wantState, tc.wantLladdr) + } + } +}