From b5a9ba940f7a96b2a3c20447f3ec3a165463baec Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:25:02 +0000 Subject: [PATCH] polish(ipv6): keep the plain NIC shape, fold the /64 into vps quota - a container without a whole /64 now gets exactly the NIC it has always had (ipv6.address + the /112 in ipv6.routes, the /112 route via that address). Only a /64 owner uses the routes-only form, so an upgraded host is unchanged for everything that does not touch the new key. - drop the `vps extra64` command: `vps quota --extra64` assigns a block instead, next to the quotas it belongs with (the panel's quota dialog is the other entry point). Nothing else about quota changed. - docs updated for the merged flag. Co-authored-by: CommandCodeBot --- docs/ipv6.md | 5 ++-- src/internal/mgr/ipv6extra.go | 13 +++++++++ src/internal/mgr/mgr.go | 6 ++--- src/internal/mgr/share.go | 8 +++--- src/internal/mgr/transfer.go | 9 ++++--- src/main.go | 50 +++++++++++------------------------ 6 files changed, 46 insertions(+), 45 deletions(-) diff --git a/docs/ipv6.md b/docs/ipv6.md index 445a38d..2b64f6c 100644 --- a/docs/ipv6.md +++ b/docs/ipv6.md @@ -345,8 +345,9 @@ the block is free for the next container. (N left)`, disabled once the pool is empty; the batch dialog has the same one. `vps add --extra64` is the CLI equivalent. - **Admin → quota**: `Assign a whole /64` for a container that has none; a - container that already owns one shows it read-only. `vps extra64 ` is - the CLI equivalent. + container that already owns one shows it read-only. `vps quota + --extra64` is the CLI equivalent (the same dialog's other fields keep + working). - **User panel**: the block is listed next to the container's IPv6 address (`IPv6 prefix: /64`), presented plainly — nothing advertises it as something to ask for. diff --git a/src/internal/mgr/ipv6extra.go b/src/internal/mgr/ipv6extra.go index 8f2a293..a29d78e 100644 --- a/src/internal/mgr/ipv6extra.go +++ b/src/internal/mgr/ipv6extra.go @@ -250,6 +250,19 @@ func blockRoutes(block112, extra string) string { } } +// deviceIPv6Addr is the ipv6.address a container's NIC should declare: the +// account's primary address — unless it owns a whole /64, in which case none. +// A declared address turns every declared route into a via-address one, and the +// kernel refuses that form for a block the account's own /112 route covers (see +// applyExtraRoutes). A container without a block therefore keeps exactly the NIC +// shape it has always had, on a host that never configured the feature or not. +func deviceIPv6Addr(primary, extra string) string { + if extra != "" { + return "" + } + return primary +} + // applyExtraRoutes rewrites a container's eth0 IPv6 wiring so its whole /64 is // routed to it: ipv6.routes carries the /112 AND the block, and ipv6.address is // REMOVED. Both halves matter, and both were found the hard way: diff --git a/src/internal/mgr/mgr.go b/src/internal/mgr/mgr.go index 8b83eaa..662e044 100644 --- a/src/internal/mgr/mgr.go +++ b/src/internal/mgr/mgr.go @@ -530,7 +530,7 @@ func (m *Manager) Add(name string, opt AddOptions) (*Result, error) { cloned := cloneOwner != "" if cloned { if err := m.lx.CloneFromSnapshot(cloneOwner, cloneSnap, name, - m.cfg.Incus.Pool, m.cfg.Incus.Bridge, ip, "", blockStr, poolAddr, + m.cfg.Incus.Pool, m.cfg.Incus.Bridge, ip, deviceIPv6Addr(ipv6, extraBlock), blockStr, poolAddr, m.cfg.Net.ExtIF, opt.CPU, opt.MemMB, opt.DiskGB); err != nil { return nil, fmt.Errorf("clone shared checkpoint: %w", err) } @@ -544,7 +544,7 @@ func (m *Manager) Add(name string, opt AddOptions) (*Result, error) { if err := m.lx.EnsureImage(image); err != nil { return nil, fmt.Errorf("ensure image %s: %w", image, err) } - if err := m.lx.Launch(m.cfg.Incus.Pool, m.cfg.Incus.Bridge, name, image, ip, "", blockStr, poolAddr, m.cfg.Net.ExtIF, opt.CPU, opt.MemMB, opt.DiskGB); err != nil { + if err := m.lx.Launch(m.cfg.Incus.Pool, m.cfg.Incus.Bridge, name, image, ip, deviceIPv6Addr(ipv6, extraBlock), blockStr, poolAddr, m.cfg.Net.ExtIF, opt.CPU, opt.MemMB, opt.DiskGB); err != nil { return nil, fmt.Errorf("launch container: %w", err) } } @@ -1526,7 +1526,7 @@ func (m *Manager) Reinstall(name, image string) (string, error) { // account owns one (it keeps it across a reinstall). blockStr = blockRoutes(blockStr, u.IPv6ExtraBlock) } - if err := m.lx.Launch(m.cfg.Incus.Pool, m.cfg.Incus.Bridge, u.Name, image, u.IP, "", blockStr, u.IPv6Address, m.cfg.Net.ExtIF, u.CPU, u.MemMB, u.DiskGB); err != nil { + if err := m.lx.Launch(m.cfg.Incus.Pool, m.cfg.Incus.Bridge, u.Name, image, u.IP, deviceIPv6Addr(ipv6, u.IPv6ExtraBlock), blockStr, u.IPv6Address, m.cfg.Net.ExtIF, u.CPU, u.MemMB, u.DiskGB); err != nil { rollback() return "", fmt.Errorf("recreate container: %w", err) } diff --git a/src/internal/mgr/share.go b/src/internal/mgr/share.go index a4b2279..b3d455c 100644 --- a/src/internal/mgr/share.go +++ b/src/internal/mgr/share.go @@ -252,17 +252,19 @@ func (m *Manager) ReinstallFromShare(name, code string, runInit bool) (string, e m.db.UpdateUserStatus(u.ID, db.StatusFailed) return "", err } - blockStr := "" + blockStr, addr := "", "" if m.cfg.IPv6ModeEffective() != cfg.IPv6ModePool { + primary, _ := m.IPv6Addr(u.Name) if block, _ := m.IPv6Block(u.Name); block != nil { blockStr = block.String() } // The account keeps its whole /64 across the reinstall, so the clone - // gets the same declared routes. + // gets the same declared routes (and the same NIC shape). blockStr = blockRoutes(blockStr, u.IPv6ExtraBlock) + addr = deviceIPv6Addr(primary, u.IPv6ExtraBlock) } if err := m.lx.CloneFromSnapshot(owner.Name, snap, tmp, - m.cfg.Incus.Pool, m.cfg.Incus.Bridge, u.IP, "", blockStr, u.IPv6Address, + m.cfg.Incus.Pool, m.cfg.Incus.Bridge, u.IP, addr, blockStr, u.IPv6Address, m.cfg.Net.ExtIF, u.CPU, u.MemMB, u.DiskGB); err != nil { m.db.UpdateUserStatus(u.ID, db.StatusFailed) return "", fmt.Errorf("clone shared checkpoint: %w", err) diff --git a/src/internal/mgr/transfer.go b/src/internal/mgr/transfer.go index 7b4473e..c80638c 100644 --- a/src/internal/mgr/transfer.go +++ b/src/internal/mgr/transfer.go @@ -835,21 +835,24 @@ func freshNICAddrs() (map[string]string, error) { // following the disk here. func (m *Manager) instanceConfig(u *db.User) (map[string]string, map[string]lx.Device) { poolMode := m.cfg.IPv6ModeEffective() == cfg.IPv6ModePool - block, poolAddr := "", "" + block, poolAddr, addr := "", "", "" if poolMode { // Only pool mode keeps the address on the routed NIC, and only when // the user actually has one. poolAddr = u.IPv6Address } else { + primary, _ := m.IPv6Addr(u.Name) if b, _ := m.IPv6Block(u.Name); b != nil { block = b.String() } // The imported account never carries a whole /64 (the block is - // host-local, like the /112 index), so only the /112 is declared. + // host-local, like the /112 index), so this normally keeps the plain + // /112-only shape. block = blockRoutes(block, u.IPv6ExtraBlock) + addr = deviceIPv6Addr(primary, u.IPv6ExtraBlock) } return m.lx.InstanceSpec(m.cfg.Incus.Pool, m.cfg.Incus.Bridge, u.IP, - "", block, poolAddr, m.cfg.Net.ExtIF, u.CPU, u.MemMB, u.DiskGB) + addr, block, poolAddr, m.cfg.Net.ExtIF, u.CPU, u.MemMB, u.DiskGB) } // requireStopped refuses to export a container that is not stopped. The check diff --git a/src/main.go b/src/main.go index cfbb0c8..44f677e 100644 --- a/src/main.go +++ b/src/main.go @@ -151,12 +151,6 @@ func main() { } case "quota": err = userQuota(os.Args[2:]) - case "extra64": - if len(os.Args) != 3 { - err = fmt.Errorf("usage: vps extra64 ") - break - } - err = userExtra64(os.Args[2]) case "power": if len(os.Args) != 4 { err = fmt.Errorf("usage: vps power start|stop|restart") @@ -208,8 +202,7 @@ func usage() { usage: vps list [name] all users, or one user's detail vps add [--cpu 1] [--mem 1G] [--disk 10G] [--bandwidth 100] [--days 30] [--extra64] - vps quota [--cpu 2] [--mem 2G] [--disk 20G] [--bandwidth 200] [--days 30] [--clear-expiry] - vps extra64 assign a whole /64 block from net.ipv6_extra_prefix + vps quota [--cpu 2] [--mem 2G] [--disk 20G] [--bandwidth 200] [--days 30] [--clear-expiry] [--extra64] vps power start|stop|restart vps passwd reissue user panel password (shown once) vps admin-passwd reset admin panel password (shown once) @@ -1403,32 +1396,9 @@ func userDel(name string) error { return nil } -// userExtra64 hands an existing container a whole /64 out of -// net.ipv6_extra_prefix (the same operation as the admin panel's quota dialog). -// Like there, it can only assign: a block stays with the account until the -// account is deleted. -func userExtra64(name string) error { - c, err := cfg.Load() - if err != nil { - return err - } - d, err := db.Open(c.Panel.DB) - if err != nil { - return err - } - defer d.Close() - m := mgr.New(c, d) - block, err := m.AssignExtraBlock(name) - if err != nil { - return err - } - fmt.Printf("user %s: whole /64 %s\n", name, block) - return nil -} - func userQuota(args []string) error { if len(args) < 1 { - return fmt.Errorf("usage: vps quota [--cpu 2] [--mem 2G] [--disk 20G] [--bandwidth 100] [--days 30] [--clear-expiry]") + return fmt.Errorf("usage: vps quota [--cpu 2] [--mem 2G] [--disk 20G] [--bandwidth 100] [--days 30] [--clear-expiry] [--extra64]") } name := args[0] fs := flag.NewFlagSet("update", flag.ContinueOnError) @@ -1437,12 +1407,14 @@ func userQuota(args []string) error { var memS, diskS, bandwidthS string var days int var clearExpiry bool + var extra64 bool fs.StringVar(&cpuS, "cpu", "", "") fs.StringVar(&memS, "mem", "", "") fs.StringVar(&diskS, "disk", "", "") fs.StringVar(&bandwidthS, "bandwidth", "", "") // GiB/month, 0 = unlimited fs.IntVar(&days, "days", 0, "") // extend validity by N days fs.BoolVar(&clearExpiry, "clear-expiry", false, "") + fs.BoolVar(&extra64, "extra64", false, "") // assign a whole /64 from net.ipv6_extra_prefix if err := fs.Parse(args[1:]); err != nil { return err } @@ -1528,12 +1500,12 @@ func userQuota(args []string) error { bandwidthChanged := setBandwidth || bandwidthGB != u.BandwidthQuotaGB setDays, clearExpiry := provided["days"], provided["clear-expiry"] expiryChanged := setDays || clearExpiry - if cpu == u.CPU && mem == u.MemMB && disk == u.DiskGB && !bandwidthChanged && !expiryChanged { + if cpu == u.CPU && mem == u.MemMB && disk == u.DiskGB && !bandwidthChanged && !expiryChanged && !extra64 { if inter.IsTTY() { fmt.Println("no changes, exiting") return nil } - return fmt.Errorf("nothing to update: pass at least one of --cpu/--mem/--disk/--bandwidth/--days/--clear-expiry") + return fmt.Errorf("nothing to update: pass at least one of --cpu/--mem/--disk/--bandwidth/--days/--clear-expiry/--extra64") } if disk < u.DiskGB { return fmt.Errorf("disk can only grow: current %d GiB, cannot shrink to %d GiB", u.DiskGB, disk) @@ -1561,6 +1533,16 @@ func userQuota(args []string) error { return err } } + // --extra64 hands the container a whole /64 out of net.ipv6_extra_prefix + // (the same operation as the admin panel's quota dialog). Like there it can + // only assign: a block stays with the account until the account is deleted. + // Nothing to do when the host hands no blocks out, so a failed assignment + // (no prefix configured, pool exhausted) is reported rather than ignored. + if extra64 { + if _, err := m.AssignExtraBlock(name); err != nil { + return err + } + } fresh, err := d.GetUserByName(name) if err != nil { return err