feat(ipv6): opt-in permanent pin of the upstream gateway neighbour

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 <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-10-07 13:19:40 +00:00
1 parent 02d55d2eec
commit 70eac5918f
12 files changed
+272 -66

No files matched your search

+17
View File
@@ -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`.
+31 -15
View File
@@ -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
+9
View File
@@ -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.
+12
View File
@@ -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 }),
-12
View File
@@ -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 {
+68 -6
View File
@@ -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()
}
+24
View File
@@ -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)
}
}
}
-31
View File
@@ -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:
//
+2 -1
View File
@@ -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)
}
+18
View File
@@ -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"
}
+65 -1
View File
@@ -28,13 +28,15 @@ import (
// route-del <ipv6-addr> <iface> ip -6 route del <addr>/128 dev <iface>
// neigh-del-proxy <ipv6-addr> <iface> ip -6 neigh del proxy <addr> dev <iface>
// neigh-add-proxy <ipv6-addr> <iface> ip -6 neigh add proxy <addr> dev <iface>
// neigh-pin <ipv6-addr> <iface> make <addr>'s current lladdr permanent on <iface>
// neigh-unpin <ipv6-addr> <iface> drop a PERMANENT neighbour entry for <addr>
// proxy-ndp-on <iface> <iface> sysctl -w net.ipv6.conf.<iface>.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 <route-add|addr-add|addr-del|route-del|neigh-del-proxy|neigh-add-proxy|proxy-ndp-on> <addr-or-cidr> <iface>")
return fmt.Errorf("usage: vps ip6 <route-add|addr-add|addr-del|route-del|neigh-del-proxy|neigh-add-proxy|neigh-pin|neigh-unpin|proxy-ndp-on> <addr-or-cidr> <iface>")
}
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()
+26
View File
@@ -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)
}
}
}