From 8d2d31041fab3b03efec0032bc74f8089b51945c Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:07:13 +0000 Subject: [PATCH] docs(ipv6): pin the pre-responder ndppd upgrade path A host can sit on a pre-1.6 release (distro ndppd, old oneshot vps-ipv6.service) for a long time before upgrading, so document and lock down how install.sh converges it: - the old ndppd/npd6 daemons are disabled and stopped, the unit is replaced by the in-tree responder, and the legacy per-address kernel proxy_ndp entries are swept on ipv6-reapply; - /etc/vpsmgr/ndppd.conf is read AS-IS, so the nested `iface ` block and its indentation written by the old panel must keep parsing. Adds TestLoadRulesAcceptsPreResponderFile to a regression guard, since every existing container would lose inbound IPv6 if that format ever diverged; - the old sudoers grants disappear with the rewritten whitelist, and the distro ndppd package is left installed but inert. Co-authored-by: CommandCodeBot --- docs/ipv6-ndp-responder.md | 25 +++++++++++++++++++ src/internal/ndp/proxy_test.go | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/docs/ipv6-ndp-responder.md b/docs/ipv6-ndp-responder.md index 40824e2..246db77 100644 --- a/docs/ipv6-ndp-responder.md +++ b/docs/ipv6-ndp-responder.md @@ -152,6 +152,31 @@ route. So the new code **relies on** the isolation layer's behaviour and coexists with it — one closes the neighbour-spoofing hole, the other makes strict upstreams able to reach the block through the existing design. +## Upgrading a host that still runs the distro ndppd + +An operator can sit on an old (pre-responder) release for a long time, so +`install.sh` has to converge such a host in one run. Everything below is +idempotent and needs no manual step beyond re-running the installer: + +| Left behind by the old release | What the upgrade does | +|---|---| +| `ndppd.service` enabled and running | `vps install` disables and stops it (`systemctl disable --now ndppd.service`), and `npd6.service` with it — either would emit competing link-local-source NAs | +| `vps-ipv6.service` = the old oneshot unit | stopped, then rewritten as the responder unit (`ExecStart=/usr/local/bin/vps ipv6-proxy`, `ExecStartPre=… ipv6-reapply`) and re-enabled | +| `/etc/vpsmgr/ndppd.conf` in the old `rule { iface }` form | read as-is. The filename and format were deliberately kept; `loadRules` takes the CIDR from the `rule` line and ignores the nested block, so no regeneration is needed (`TestLoadRulesAcceptsPreResponderFile`) | +| `/etc/ndppd.conf` symlink → `/etc/vpsmgr/ndppd.conf` | harmless and left alone: the responder opens the real `/etc/vpsmgr/ndppd.conf` directly | +| Per-address kernel `proxy_ndp` entries and `/128` routes from the older scheme | swept on `ipv6-reapply` by `cleanLegacyKernelProxy` (limited to addresses inside the configured prefix) | +| Old sudoers grants (`service ndppd restart`, `pkill -x ndppd`, `ln -sf /etc/ndppd.conf`, …) | the whitelist is rewritten from scratch, so those grants disappear. The old panel never needs them again — the binary is replaced in the same run, and an old binary that somehow ran would simply fail on them | +| The distro `ndppd` package (installed by the old `install.sh`) | left installed but inactive — it is harmless while disabled, and leaving it keeps the upgrade offline-friendly. `uninstall.sh` stops and disables its service and removes its config files, but does not purge the package | + +The rule file being read rather than regenerated is the one place where this +upgrade is load-bearing: if the format ever diverged, every existing container +would lose inbound IPv6 the moment the responder took over. Keep the parser +tolerant of the legacy layout. + +Note that this is unlike the Traefik → HAProxy migration, where the old +per-domain files ARE discarded: those are a derived artifact of the database, +while `ndppd.conf` is the live input of a running process. + ## Notes on a few deliberate decisions - **File name kept as `ndppd.conf`.** The format (bare `rule {}`) is diff --git a/src/internal/ndp/proxy_test.go b/src/internal/ndp/proxy_test.go index 5f0a52c..8991661 100644 --- a/src/internal/ndp/proxy_test.go +++ b/src/internal/ndp/proxy_test.go @@ -58,6 +58,50 @@ func TestFilterRules(t *testing.T) { } } +// TestLoadRulesAcceptsPreResponderFile pins the UPGRADE path from a pre-1.6 +// (Traefik-era, distro-ndppd) install: that panel rendered each rule with the +// bridge as a nested `iface` argument and three-space indentation — +// +// rule 2001:db8:1::/112 { +// iface incusbr0 +// } +// +// The distro daemon is gone now, but the FILE is not regenerated during an +// upgrade, so the in-tree responder has to read exactly this on the first boot +// after `install.sh`. If the rule indentation or the nested block ever became +// significant, every existing container would lose inbound IPv6 the moment the +// new responder took over. +func TestLoadRulesAcceptsPreResponderFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ndppd.conf") + legacy := "proxy eth0 {\n" + + " rule 2001:db8:1::/112 {\n" + + " iface incusbr0\n" + + " }\n" + + " rule 2001:db8:2::/112 {\n" + + " iface incusbr0\n" + + " }\n" + + "}\n" + if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + rules, err := loadRules(path) + if err != nil { + t.Fatalf("legacy ndppd file rejected: %v", err) + } + if len(rules) != 2 { + t.Fatalf("got %d rules, want 2", len(rules)) + } + for _, addr := range []string{"2001:db8:1::1", "2001:db8:1::9999", "2001:db8:2::5"} { + if !matches(net.ParseIP(addr), rules) { + t.Errorf("%s did not match any legacy rule", addr) + } + } + if matches(net.ParseIP("2001:db8:3::1"), rules) { + t.Error("address outside every legacy rule matched") + } +} + func mustCIDR(t *testing.T, s string) *net.IPNet { t.Helper() _, n, err := net.ParseCIDR(s)