feat(config): apply net.ipv6_extra_prefix immediately

- the key was ApplyNextAdd: the CLI rewrote config.yaml while the running panel
  kept its startup snapshot, so the admin's "assign a whole /64" checkbox only
  appeared after a manual restart
- it is now ApplyImmediate: the change rewires host IPv6, (re)installs the
  vps-ipv6 boot unit and restarts the panel, so the create form updates at once
- factor the boot-unit selection out of cmdInstall into syncIPv6BootUnit so
  install and the runtime apply share one implementation
- document the new apply semantics

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-09-27 14:47:16 +00:00
1 parent 5e87c9bed7
commit 2d3cbb9759
3 files changed
+90 -33

No files matched your search

+9 -5
View File
@@ -48,7 +48,7 @@ same table; `vps config list` shows the live values with this annotation.
| `net.ipv6_subnet` | operator | re-run `vps install` | global IPv6 prefix for pass-through, e.g. `2602:fada:6::/64`; empty = disabled (does not remove IPv6 state already applied, see note below) |
| `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 | next `vps add` / reinstall (existing blocks are reapplied by `vps install` / `vps ipv6-reapply`); admin-panel changes apply immediately | 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_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 |
| `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`) |
@@ -325,10 +325,14 @@ goes away when the panel is uninstalled.
address from `net.ipv6_pool`). Switching modes would renumber containers.
- `net.ipv6_extra_prefix` is **optional and off by default** — an upgraded host
keeps running exactly as before until an operator fills it in, and nothing in
the installer or the panel prompts for it. It only means anything in `prefix`
mode, is accepted as the operator enters it (no ownership check: they assert
the prefix is theirs), and never renumbers an existing container. A value that
holds no whole /64 (a /64 or longer) simply leaves the feature inert.
the installer or the panel prompts for it. It works in `prefix` mode
(alongside the /112) and in `none` mode (a standalone routed /64), is accepted
as the operator enters it (no ownership check: they assert the prefix is
theirs), and never renumbers an existing container. Changing it takes effect
immediately: host IPv6 plumbing (forwarding, `proxy_ndp`, NDP rules, block
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.
- 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`.
+2 -2
View File
@@ -389,8 +389,8 @@ var Fields = []Field{
}
return nil
}},
{"net.ipv6_extra_prefix", KindOperator, ApplyNextAdd,
"optional extra prefix — whole /64 blocks are carved from and routed to containers; works in prefix mode (alongside /112) and none mode (standalone routed /64). Empty = feature off. The /64 this host itself uses is never handed to a container",
{"net.ipv6_extra_prefix", KindOperator, ApplyImmediate,
"optional extra prefix — whole /64 blocks are carved from and routed to containers; works in prefix mode (alongside /112) and none mode (standalone routed /64). Empty = feature off. Applied immediately: host IPv6 plumbing is rewired and the panel restarted so the create form updates. The /64 this host itself uses is never handed to a container",
"2001:1c00:b1b:7f0::/60 (empty for off)",
func(c *Config) string {
if c.Net.IPv6ExtraPrefix == "" {
+79 -26
View File
@@ -409,32 +409,11 @@ func cmdInstall() error {
if err := writeUnit("vps-nft.service", nftUnit); err != nil {
return err
}
// IPv6 pass-through boot unit. Prefix mode runs the long-lived in-tree NDP
// responder (ipv6ProxyUnit); pool mode re-applies routes/proxy at boot via
// the oneshot ipv6Unit. Stop the old unit before replacing it so an upgrade
// from the previous oneshot/ndppd implementation actually starts the new
// responder, and (for the prefix responder) so a bad ext_if fails the
// install clearly instead of the service hot-looping under Restart=always.
_ = exec.Command("systemctl", "disable", "--now", "vps-ipv6.service").Run()
proxyUsable := true
if c.IPv6Enabled() || c.IPv6ExtraEnabled() {
unit := ipv6Unit
if c.IPv6ModeEffective() == cfg.IPv6ModePrefix || (c.IPv6ModeEffective() == cfg.IPv6ModeNone && c.IPv6ExtraEnabled()) {
unit = ipv6ProxyUnit
if _, err := ethernetMAC(c.Net.ExtIF); err != nil {
log.Printf("warning: prefix-mode/extra-prefix IPv6 needs an Ethernet ext_if (%s): %v — NDP responder not enabled; fix net.ext_if and re-run `vps install`", c.Net.ExtIF, err)
proxyUsable = false
}
}
if proxyUsable {
if err := writeUnit("vps-ipv6.service", unit); err != nil {
return err
}
} else {
_ = os.Remove("/etc/systemd/system/vps-ipv6.service")
}
} else {
_ = os.Remove("/etc/systemd/system/vps-ipv6.service")
// IPv6 pass-through boot unit: see syncIPv6BootUnit. It is started below,
// after daemon-reload.
proxyUsable, err := syncIPv6BootUnit(c)
if err != nil {
return err
}
// Prefix mode uses the vpsmgr raw NDP responder; a distro ndppd or npd6
// listener would emit competing (link-local-source) NAs, so disable both
@@ -548,6 +527,55 @@ func writeUnit(name, content string) error {
return os.Rename(tmp, p)
}
// syncIPv6BootUnit (re)installs vps-ipv6.service to match the current IPv6
// config. Prefix mode and none+extra run the long-lived in-tree NDP responder
// (ipv6ProxyUnit); any other IPv6 passes through the oneshot reapply
// (ipv6Unit); no IPv6 at all removes the unit. The old unit is stopped before
// it is replaced so an upgrade from the previous oneshot/ndppd implementation
// actually starts the new responder, and so a bad ext_if fails clearly instead
// of hot-looping under Restart=always. Returns whether the responder unit is
// usable (a non-Ethernet ext_if cannot answer NDP). Root-only: it writes into
// /etc/systemd/system, so a runtime caller must be root and should treat a
// failure as a warning (run `vps install`) rather than lose a saved config.
func syncIPv6BootUnit(c *cfg.Config) (proxyUsable bool, err error) {
_ = exec.Command("systemctl", "disable", "--now", "vps-ipv6.service").Run()
if !c.IPv6Enabled() && !c.IPv6ExtraEnabled() {
_ = os.Remove("/etc/systemd/system/vps-ipv6.service")
return false, nil
}
unit := ipv6Unit
if c.IPv6ModeEffective() == cfg.IPv6ModePrefix || (c.IPv6ModeEffective() == cfg.IPv6ModeNone && c.IPv6ExtraEnabled()) {
unit = ipv6ProxyUnit
if _, err := ethernetMAC(c.Net.ExtIF); err != nil {
log.Printf("warning: prefix-mode/extra-prefix IPv6 needs an Ethernet ext_if (%s): %v — NDP responder not enabled; fix net.ext_if and re-run `vps install`", c.Net.ExtIF, err)
_ = os.Remove("/etc/systemd/system/vps-ipv6.service")
return false, nil
}
}
if err := writeUnit("vps-ipv6.service", unit); err != nil {
return false, err
}
return true, nil
}
// applyIPv6BootUnit is syncIPv6BootUnit plus the systemd reload/enable steps,
// for callers outside `vps install` (a runtime config change).
func applyIPv6BootUnit(c *cfg.Config) error {
proxyUsable, err := syncIPv6BootUnit(c)
if err != nil {
return err
}
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
return fmt.Errorf("daemon-reload: %s", strings.TrimSpace(string(out)))
}
if (c.IPv6Enabled() || c.IPv6ExtraEnabled()) && proxyUsable {
if out, err := exec.Command("systemctl", "enable", "--now", "vps-ipv6.service").CombinedOutput(); err != nil {
return fmt.Errorf("enable vps-ipv6: %s", strings.TrimSpace(string(out)))
}
}
return nil
}
// ethernetMAC returns the 6-byte hardware address of iface by name, or an
// error when it is missing or has no Ethernet MAC (a tunnel, bare ifb, some
// cloud vtaps, bond without a slave, ...). Prefix-mode NDP needs a real L2 MAC
@@ -1069,6 +1097,29 @@ 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_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
// config from startup, so without the restart the create form would
// keep showing the old extra-prefix state (no /64 checkbox).
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)
if err := m.RewireAllIPv6(); err != nil {
fmt.Printf("warning: host IPv6 was not fully rewired: %v\n", err)
}
d.Close()
if err := applyIPv6BootUnit(c); err != nil {
fmt.Printf("warning: the IPv6 boot unit was not updated (%v) — run `vps install`\n", err)
}
if err := restartPanel(); err != nil {
fmt.Printf("warning: the panel was not restarted: %v\n", err)
}
fmt.Printf("%s updated and applied (host IPv6 rewired, panel restarted).\n", key)
return nil
}
if key == "snapshots.share" {
d, err := db.Open(c.Panel.DB)
if err != nil {
@@ -1195,6 +1246,8 @@ func confirmApply(c *cfg.Config, f *cfg.Field, key string) (bool, error) {
what = "starts/stops HAProxy immediately and changes whether new domains may be added"
case "incus.swap_ratio":
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"
default:
what = "applies immediately"
}