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 <name> --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 <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-09-21 08:25:02 +00:00
1 parent 8c728096cc
commit b5a9ba940f
6 files changed
+46 -45

No files matched your search

+3 -2
View File
@@ -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 <name>` is
the CLI equivalent.
container that already owns one shows it read-only. `vps quota <name>
--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: <block>/64`), presented plainly — nothing advertises it as
something to ask for.
+13
View File
@@ -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:
+3 -3
View File
@@ -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)
}
+5 -3
View File
@@ -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)
+6 -3
View File
@@ -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
+16 -34
View File
@@ -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 <name>")
break
}
err = userExtra64(os.Args[2])
case "power":
if len(os.Args) != 4 {
err = fmt.Errorf("usage: vps power <name> start|stop|restart")
@@ -208,8 +202,7 @@ func usage() {
usage:
vps list [name] all users, or one user's detail
vps add <name> [--cpu 1] [--mem 1G] [--disk 10G] [--bandwidth 100] [--days 30] [--extra64]
vps quota <name> [--cpu 2] [--mem 2G] [--disk 20G] [--bandwidth 200] [--days 30] [--clear-expiry]
vps extra64 <name> assign a whole /64 block from net.ipv6_extra_prefix
vps quota <name> [--cpu 2] [--mem 2G] [--disk 20G] [--bandwidth 200] [--days 30] [--clear-expiry] [--extra64]
vps power <name> start|stop|restart
vps passwd <name> 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 <name> [--cpu 2] [--mem 2G] [--disk 20G] [--bandwidth 100] [--days 30] [--clear-expiry]")
return fmt.Errorf("usage: vps quota <name> [--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