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:
1 parent
02d55d2eec
commit
70eac5918f
12 files changed
+272
-66
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user