feat(admin): expose the whole-/64 blocks in the panel

- admin → IPv6 (prefix mode): the extra-prefix editor, a capacity readout
  (total / kept for this host / assigned / free) and the assigned blocks with
  their owners — only the assigned ones, a /48 has 65536.
- create and batch forms carry a ticked-by-default "Assign a whole /64 (N
  left)" checkbox, disabled once the pool is empty; The quota dialog can hand
  one to an existing container and shows an existing block read-only, since it
  can never be taken back there (only deleting the account releases it).
- the user panel lists the block next to the IPv6 address, plainly — nothing
  advertises it as something to ask for.
- docs: the configuration.md key row/example/notes, a new ipv6.md section on
  what a container gets and the two wiring traps above, and an architecture.md
  pointer.
- check-ipv6-support.sh now probes a random address anywhere inside the
  candidate prefix instead of a host part of its first /64, so a delegation
  shorter than /64 is actually verified (the first /64 is usually the one the
  host already holds an address in, and proves nothing).

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-09-21 07:53:35 +00:00
1 parent a97dcb379e
commit 8c728096cc
12 files changed
+393 -21

No files matched your search

+16 -14
View File
@@ -265,20 +265,22 @@ if ! curl -sS --max-time 10 -o /dev/null https://api.globalping.io/v1/measuremen
fi
echo "== Phase 3: external verification (random address in $CAND_PREFIX) =="
# The random host-part must avoid: the provider gateway (::1), the host's own
# address, and all-zero / all-f suffix hextets (subnet-router / subnet-anycast).
HOST_SUFFIX=$(echo "$HOST_ADDR" | awk -F: '{print $NF}')
RAND=""
for i in $(seq 1 30); do
SUF=$(printf '%04x' "$((RANDOM%65521+1))") # 1..65520, avoids 0 and ffff
[[ "$SUF" == "$HOST_SUFFIX" ]] && continue
RAND="$SUF"
break
done
[[ -n "$RAND" ]] || die "could not pick a random host part"
# strip any "/len" from the prefix for address concatenation
PREFIX_BARE="${CAND_PREFIX%%/*}"
TEST_ADDR="$PREFIX_BARE$RAND"
# Probe a uniformly random address INSIDE the prefix, not a random host part of
# its first /64: for a delegation shorter than /64 (a /60, /56, /48) the whole
# point is that the provider routes all of it, and an address in the first /64
# proves nothing — that is usually the segment the host already holds an address
# in. Avoiding the network address and the last address also skips the two
# anycast forms (subnet-router ::0 and subnet anycast ...:ffff).
TEST_ADDR=$(python3 - "$CAND_PREFIX" <<'PY'
import ipaddress, random, sys
net = ipaddress.IPv6Network(sys.argv[1], strict=False)
addr = net.network_address + random.randrange(1, net.num_addresses)
if addr == net.broadcast_address:
addr -= 1
print(addr)
PY
)
[[ -n "$TEST_ADDR" ]] || die "could not pick a random address in $CAND_PREFIX"
TEST_CIDR="$TEST_ADDR/128"
log "picked random test address: $TEST_ADDR (inside $CAND_PREFIX)"
+6
View File
@@ -118,6 +118,12 @@ The panel daemon runs as the dedicated unprivileged `vps` system user
- **Port 25 is always blocked**: the ruleset's forward chain drops port 25
(TCP+UDP, both directions) for all forwarded traffic — permanent anti-spam,
only a full uninstall clears it.
- **Optional whole /64 per container**: on a `prefix`-mode host whose provider
delegates a prefix shorter than /64, `net.ipv6_extra_prefix` lets a container
own a whole /64 out of it alongside its /112 primary — the only block size
inside which SLAAC and sub-delegation work. OFF by default and invisible until
an operator sets that key; the /64 this host itself uses is never handed out.
See [ipv6.md](ipv6.md).
- Add/Del/Reinstall are serialized by a per-process mutex, and `mgr.Add` rolls
back the container, IPv6 route, nft rules and DB record on any post-launch
failure. The DB write is a **single transaction** (`db.CreateUserFull`):
+12
View File
@@ -47,6 +47,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) 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`) |
@@ -123,6 +124,11 @@ net:
ipv6_pool: [] # optional (pool mode): the /128 addresses containers are
# assigned from, e.g. "2001:db8:1::9c4" (bare global
# addresses, no prefix length)
ipv6_extra_prefix: "" # optional (prefix mode): an extra prefix a container can
# be given a whole /64 out of, e.g. "2001:1c00:b1b:7f0::/60"
# (/48, /56, /60 ...). Empty = off (the default; nothing is
# allocated and no panel UI appears). The /64 this host uses
# itself is never handed out. See docs/ipv6.md
incus:
image: "vpsmgr/debian-sshd"
@@ -310,6 +316,12 @@ goes away when the panel is uninstalled.
- `net.ipv6_mode` is fixed at install and immutable (like `net.subnet`):
`none` (pure IPv4), `prefix` (classic /112 blocks), `pool` (per-container
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.
- 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`.
+88
View File
@@ -262,3 +262,91 @@ Incus programs everything.
Pool mode works with either `v4_forward` setting. When the pool is exhausted
(or the admin picks "no IPv6"), containers are plain V4-only boxes — same as
the pre-IPv6 behavior.
## Whole /64 blocks from an extra prefix (optional)
A provider that delegates a prefix **shorter than /64** (a /60, /56 or /48) makes
one thing possible that a plain /64 host cannot do: hand a container a *whole
/64*. The /112 block a prefix-mode account normally owns has 16 host bits, so
nothing inside the container can use SLAAC (which needs exactly 64 bits) or be
delegated any further.
The feature is **off by default and stays off on every upgrade**: it exists only
while `net.ipv6_extra_prefix` is set (see [configuration.md](configuration.md)).
Nothing is allocated and no panel UI appears otherwise, and the installer never
asks for it — an operator turns it on deliberately.
### What a container gets
- Its `/112` primary address and everything around it, unchanged.
- A whole `/64` out of the extra prefix (e.g. `2001:1c00:b1b:7f0::/64`), bound
inside the container as `<block>::1/64` — with its real length, so the prefix
is on-link there, any address in it is usable, and the customer can carve
sub-prefixes out of it for internal networks. It is deliberately **not** a
local route: that would claim every address in the block for the container
itself and make exactly that delegation impossible.
- The host routes the block to the container, and one ndppd rule covers it (like
the `/112`s) for an upstream that resolves prefixes by NDP.
### How the route is wired
The block is declared on the container's own NIC — `ipv6.routes = <account's
/112>,<block>/64` — and `ipv6.address` is **removed** from that device. Both
halves are load-bearing:
- Incus builds `security.ipv6_filtering` from the addresses and routes a NIC
declares, and that filter drops everything else. A block that is not declared
is a block the bridge throws away: the container binds its addresses and gets
replies from the host, but every packet it sources from the block dies at the
veth.
- Incus programs a declared route as `via <ipv6.address>`. The kernel refuses
such a route when the gateway is itself covered by a *gateway* route — which
the account's own `/112` route is (`RTNETLINK answers: No route to host`) —
so the declared routes have to be direct (`dev <bridge>`), which is exactly
what omitting `ipv6.address` produces. Nothing is lost: the container binds
its primary address itself, from the provider script, so the device option was
only ever the DHCPv6 reservation and the routes' gateway.
Because the wiring lives in the container's own network configuration, Incus
restores it on every start — a container restart or a host reboot brings the
block back with no panel-side route plumbing. What the guest needs (the address
binding) is written by the provider script, so a power start, the boot unit and
`vps ipv6-reapply` re-apply it idempotently; `EnsureExtraBlockRoutes` also
re-declares the NIC of every account that owns a block, healing a container that
was recreated or downgraded out of band.
### The host's own /64 is never handed out
The pool excludes every `/64` the host itself uses: the one holding
`net.ipv6_subnet` (bridge address, gateway, every `/112`) and any `/64` the host
holds a global address in. Routing one of those to a container would collide
with the host's own on-link route and cut its upstream connectivity — which is
why an operator may safely point the key at a prefix that also contains the
host's own /64: it is carved out, not handed out.
### Capacity and degradation
`2^(64-len)` blocks, minus the reserved ones: a `/60` yields 15 or 16, a `/56`
255 or 256, a `/48` many thousands. Blocks are handed out lowest-first, so the
assigned set reads in order. An exhausted pool is **not** an error: the
container is created without a block, the create form greys its checkbox out,
and the label shows how many are left.
An assignment belongs to the account for life. Editing a container's quotas can
never take the block back — only deleting the account releases it, after which
the block is free for the next container.
### Where it shows up
- **Admin → IPv6** (prefix mode): the prefix editor, the capacity readout
(`total / kept for this host / assigned / free`) and the list of assigned
blocks with their owners (only the assigned ones — a /48 has 65536).
- **Admin → create user**: a ticked-by-default checkbox `Assign a whole /64
(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.
- **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.
+1
View File
@@ -74,6 +74,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/ipv6pool", s.requireAuth(s.handleIPv6Pool))
mux.HandleFunc("/ipv6pool-add", s.requireAuth(s.requirePost(s.handleIPv6PoolAdd)))
mux.HandleFunc("/ipv6pool-del", s.requireAuth(s.requirePost(s.handleIPv6PoolDel)))
mux.HandleFunc("/ipv6extra-set", s.requireAuth(s.requirePost(s.handleIPv6ExtraSet)))
mux.HandleFunc("/audit", s.requireAuth(s.handleAudit))
mux.HandleFunc("/audit/api", s.requireAuth(s.handleAuditAPI))
mux.HandleFunc("/user-add", s.requireAuth(s.requirePost(s.handleUserAdd)))
+90 -3
View File
@@ -52,6 +52,12 @@ type pageData struct {
PoolFree []string
PoolUsed int
PoolTotal int
// Whole /64 blocks (net.ipv6_extra_prefix, prefix mode only). ExtraConfigured
// is "the operator set an extra prefix", which is what makes the create
// form's checkbox render at all; ExtraFree is how many blocks are left, and
// 0 greys the checkbox out instead of hiding it.
ExtraConfigured bool
ExtraFree int
// AdminKeys is the operator's own SSH-key store (management panel only).
AdminKeys []sshKeyRow
// BatchID, when set, is a running/recent batch create the page should show
@@ -125,8 +131,12 @@ type userView struct {
DownGB string
BWTotal string // up+down this month, GB — used for table sorting
IPv6 string
Procs int64 // live process count (0 when stopped)
ProcsLimit string // per-container pids.max cap, e.g. "4096"
// IPv6Extra is the whole /64 the account owns from net.ipv6_extra_prefix
// ("" when none). Shown read-only in the quota dialog, where such a block
// can be assigned but never taken back.
IPv6Extra string
Procs int64 // live process count (0 when stopped)
ProcsLimit string // per-container pids.max cap, e.g. "4096"
// Quota validity: Expired locks the account to read-only (only the admin's
// extend/delete work). ExpiredDays is whole days past the deadline, floored
// (a same-day expiry shows 0). ExpiresShort is the UTC date for display.
@@ -174,6 +184,15 @@ func (s *Server) buildPageData(msg, errMsg string) pageData {
d.PoolUsed, d.PoolTotal = used, total
}
}
// Whole /64 blocks: prefix mode with an extra prefix configured. A prefix
// that holds no whole /64 (a /64 or longer) counts as not configured — there
// would be nothing to hand out.
if s.mgr.IPv6Mode() == cfg.IPv6ModePrefix {
if total, _, _, free, err := s.mgr.ExtraCapacity(); err == nil && total > 0 {
d.ExtraConfigured = true
d.ExtraFree = free
}
}
if keys, err := s.mgr.ListAdminKeys(); err == nil {
for _, k := range keys {
d.AdminKeys = append(d.AdminKeys, sshKeyRow{ID: k.ID, Name: k.Name, Key: k.Key, Active: k.Active})
@@ -261,6 +280,7 @@ func (s *Server) loadUsers(d *pageData) {
DownGB: st.DownGB,
BWTotal: st.BWTotal,
IPv6: st.IPv6,
IPv6Extra: st.IPv6Extra,
Procs: st.Procs,
ProcsLimit: lx.DefaultProcessesLimit,
ExpiresAt: u.ExpiresAt,
@@ -492,7 +512,11 @@ func (s *Server) handleUserAdd(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_invalid_days"))
return
}
res, err := s.mgr.Add(name, mgr.AddOptions{CPU: cpu, MemMB: memMB, DiskGB: diskGB, BandwidthGB: bandwidthGB, IPv6Addr: ipv6, AllowChild: true, Days: days})
// Whole-/64 checkbox (prefix mode with an extra prefix configured): the form
// ticks it by default, and it is disabled once the pool is empty. Allocation
// is best-effort either way — an exhausted pool silently yields no block.
allocExtra64 := strings.TrimSpace(r.FormValue("extra64")) != ""
res, err := s.mgr.Add(name, mgr.AddOptions{CPU: cpu, MemMB: memMB, DiskGB: diskGB, BandwidthGB: bandwidthGB, IPv6Addr: ipv6, AllowChild: true, Days: days, AllocateExtra64: allocExtra64})
if err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
@@ -581,6 +605,7 @@ func (s *Server) handleUserBatch(w http.ResponseWriter, r *http.Request) {
opt := mgr.AddOptions{
CPU: cpu, MemMB: memMB, DiskGB: diskGB, BandwidthGB: bandwidthGB,
AllowChild: true, Days: days, FromShare: share,
AllocateExtra64: strings.TrimSpace(r.FormValue("extra64")) != "",
}
_ = s.db.AddAuditLog("000", "user.batch_create")
// Background: each clone takes tens of seconds, so the request returns at
@@ -706,6 +731,17 @@ func (s *Server) handleUserQuota(w http.ResponseWriter, r *http.Request) {
}
_ = s.db.AddAuditLog("000+"+name, "ipv6.assign")
}
// A whole /64 can be handed to a container that has none, and only then: a
// block, once assigned, is the account's for life — this dialog can never
// take it back (deleting the account is the only way to release it). The
// manager call is idempotent, so a re-submit is harmless.
if strings.TrimSpace(r.FormValue("extra64")) != "" {
if _, err := s.mgr.AssignExtraBlock(name); err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog("000+"+name, "ipv6.extra64.assign")
}
_ = s.db.AddAuditLog("000+"+name, "quota.update")
s.redirect(w, r, s.p(""), s.t(r, "quota_updated", name))
}
@@ -1328,6 +1364,15 @@ type ipv6PoolPageData struct {
Used int
Free int
Addrs []mgr.PoolEntries
// Whole /64 blocks (prefix mode): the extra prefix the operator configured
// ("" = feature off), how its blocks are distributed, and who holds them.
// Only the ASSIGNED blocks are listed — a /48 would have 65536 of them.
ExtraPrefix string
ExtraTotal int
ExtraReserved int
ExtraUsed int
ExtraFree int
ExtraAssigned []mgr.ExtraEntry
}
func (s *Server) renderIPv6Pool(w http.ResponseWriter, r *http.Request, d ipv6PoolPageData) {
@@ -1370,9 +1415,51 @@ func (s *Server) handleIPv6Pool(w http.ResponseWriter, r *http.Request) {
} else {
d.Addrs = addrs
}
// Whole-/64 blocks live on the same page: prefix mode has no address pool,
// so this card is what that mode's page actually shows.
if d.Mode == cfg.IPv6ModePrefix {
d.ExtraPrefix = s.cfg.Net.IPv6ExtraPrefix
if t, reserved, u, free, err := s.mgr.ExtraCapacity(); err == nil {
d.ExtraTotal, d.ExtraReserved, d.ExtraUsed, d.ExtraFree = t, reserved, u, free
} else {
d.Err = d.Err + " " + err.Error()
}
d.ExtraAssigned = s.mgr.ExtraAssignments()
}
s.renderIPv6Pool(w, r, d)
}
// handleIPv6ExtraSet stores net.ipv6_extra_prefix: the optional prefix whole /64
// blocks are carved from. Empty turns the feature off. The value is taken as
// the operator enters it — they assert the prefix is theirs, so nothing checks
// that the provider really routes it — and only the basic shape (a global CIDR
// carrying a length) is validated, by the same registry entry the CLI uses.
func (s *Server) handleIPv6ExtraSet(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
f := cfg.FieldFor("net.ipv6_extra_prefix")
if f == nil {
s.redirect(w, r, s.p("/ipv6pool"), "error: unknown setting")
return
}
if err := f.Assign(s.cfg, r.FormValue("prefix")); err != nil {
s.redirect(w, r, s.p("/ipv6pool"), "error: "+err.Error())
return
}
if err := cfg.Save(s.cfg); err != nil {
s.redirect(w, r, s.p("/ipv6pool"), "error: "+err.Error())
return
}
// Best-effort host plumbing refresh so the change takes effect at once.
// Existing blocks keep working either way: their routes are restored by the
// same pass, and changing the prefix never renumbers a container.
_ = s.mgr.RewireAllIPv6()
_ = s.db.AddAuditLog("000", "ipv6.extra_prefix")
s.redirect(w, r, s.p("/ipv6pool"), s.t(r, "extra_prefix_saved"))
}
// handleIPv6PoolAdd batch-adds addresses from the multi-line textarea.
func (s *Server) handleIPv6PoolAdd(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
+1
View File
@@ -99,6 +99,7 @@ func tr(l, key string, args ...any) string {
"blocked_skipped_lines": {"第 %[1]v 行不是有效域名,已跳过;其余已保存", "line(s) %[1]v are not valid domains and were skipped; the rest were saved"},
"pool_added": {"已添加 %[1]v 个 IPv6 地址到池", "added %[1]v IPv6 address(es) to the pool"},
"pool_removed": {"已从池中移除 %[1]v", "removed %[1]v from the pool"},
"extra_prefix_saved": {"额外前缀已保存", "extra prefix saved"},
"batch_started": {"批量创建已在后台开始", "batch create started in the background"},
"batch_need_share": {"需要填写分享的快照 UUID", "a shared snapshot UUID is required"},
"color_updated": {"已设置 %[1]v 的专属颜色", "color set for %[1]v"},
+104
View File
@@ -0,0 +1,104 @@
package admin
import (
"fmt"
"net/http"
"strings"
"testing"
"vpsmgr/internal/cfg"
"vpsmgr/internal/db"
)
// TestExtraPrefixUI renders the pages that carry a whole-/64 control with
// net.ipv6_extra_prefix configured. Those template branches are dead on a host
// without an extra prefix — which is every default install and every test that
// does not set one — so a mistake there would only surface once an operator
// turned the feature on.
func TestExtraPrefixUI(t *testing.T) {
// newHost returns an admin server in prefix mode with extra configured, one
// account per given block, plus the handler, prefix and free-block count.
newHost := func(t *testing.T, extra string, blocks ...string) (http.Handler, string, int) {
t.Helper()
srv, d := newTestServer(t)
srv.cfg.Net.IPv6Subnet = "2602:fada:6::/64"
srv.cfg.Net.IPv6Mode = cfg.IPv6ModePrefix
srv.cfg.Net.IPv6ExtraPrefix = extra
setAdminPass(t, srv, "correct-horse-battery")
for i, b := range blocks {
if _, err := d.CreateUserFull(fmt.Sprintf("u%d", i), "h", fmt.Sprintf("10.42.0.%d", i+2),
i+1, 30001+i, 10000+i*100, 1, 1024, 10, 0, db.StatusReady, "", int64(i+1), b, ""); err != nil {
t.Fatal(err)
}
}
total, reserved, used, free, err := srv.mgr.ExtraCapacity()
if err != nil {
t.Fatal(err)
}
t.Logf("extra prefix %s: total=%d reserved=%d used=%d free=%d", extra, total, reserved, used, free)
return srv.Handler(), "/" + testAdminSecret, free
}
t.Run("blocks available", func(t *testing.T) {
h, prefix, free := newHost(t, "2001:db8:1234::/56", "2001:db8:1234::/64")
ck := adminLogin(t, h, prefix, "correct-horse-battery")
// The IPv6 page: the editor (pre-filled), the capacity line and the
// list of assigned blocks with their owner.
rr := doReq(t, h, http.MethodGet, prefix+"/ipv6pool", nil, ck)
if rr.Code != http.StatusOK {
t.Fatalf("GET /ipv6pool = %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{
"ipv6extra-set", // the save target
`value="2001:db8:1234::/56"`, // the configured prefix
"2001:db8:1234::/64", // an assigned block
"u0", // its owner
fmt.Sprintf("%d free", free), // the capacity readout
"cannot be taken back by editing quotas",
} {
if !strings.Contains(body, want) {
t.Errorf("IPv6 page missing %q", want)
}
}
// The overview: the create form offers a block, ticked by default.
rr = doReq(t, h, http.MethodGet, prefix, nil, ck)
if rr.Code != http.StatusOK {
t.Fatalf("GET / = %d: %s", rr.Code, rr.Body.String())
}
body = rr.Body.String()
label := fmt.Sprintf("Assign a whole /64 (%d left)", free)
if !strings.Contains(body, label) {
t.Errorf("create form missing %q", label)
}
if !strings.Contains(body, `name="extra64" value="1" style="width:auto;flex:none" checked`) {
t.Error("the create form's /64 checkbox is not ticked by default")
}
if strings.Contains(body, `name="extra64" value="1" style="width:auto;flex:none" disabled`) {
t.Error("the create form's /64 checkbox is disabled although blocks are free")
}
})
t.Run("exhausted", func(t *testing.T) {
// A /63 holds exactly two /64s; both are taken, so the pool is empty and
// the control has to grey out instead of disappearing.
h, prefix, free := newHost(t, "2001:db8:5678::/63", "2001:db8:5678::/64", "2001:db8:5678:1::/64")
if free != 0 {
t.Fatalf("free = %d, want an exhausted pool", free)
}
ck := adminLogin(t, h, prefix, "correct-horse-battery")
rr := doReq(t, h, http.MethodGet, prefix, nil, ck)
if rr.Code != http.StatusOK {
t.Fatalf("GET / = %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, "Assign a whole /64 (0 left)") {
t.Error("exhausted pool does not show a 0-left label")
}
if !strings.Contains(body, `name="extra64" value="1" style="width:auto;flex:none" disabled`) {
t.Error("exhausted pool does not disable the /64 checkbox")
}
})
}
@@ -67,8 +67,33 @@ form.inline { display: inline; }
{{if eq .Mode "prefix"}}
<div class="card">
<p class="muted">{{if eq .Lang "zh"}}当前为「前缀模式」:容器地址由前缀自动派生,不需要地址池。{{else}}This host is in prefix mode: container addresses are derived from the prefix, no pool is needed.{{end}}</p>
<h2>{{if eq .Lang "zh"}}整段 /64 分配{{else}}Whole /64 blocks{{end}}</h2>
<p class="muted">{{if eq .Lang "zh"}}前缀模式下容器地址由前缀自动派生,不需要地址池。这里可以额外填写一段更短的前缀(如 /60、/56、/48):填写后,新容器可以额外获得一整段 /64,在容器内部自行切分子网或运行 SLAAC。留空即关闭该功能,已分配的 /64 不受影响。{{else}}In prefix mode a container's address is derived from the prefix and no pool is needed. Optionally enter an extra, shorter prefix here (e.g. /60, /56, /48): a container can then be given a whole /64 of its own, to subnet internally or run SLAAC. Leave it empty to keep the feature off; already-assigned /64s are not affected.{{end}}</p>
<form action="{{.Prefix}}/ipv6extra-set" method="post">
<div class="field"><label>{{if eq .Lang "zh"}}额外前缀(CIDR){{else}}Extra prefix (CIDR){{end}}</label>
<input name="prefix" value="{{.ExtraPrefix}}" placeholder="2001:1c00:b1b:7f0::/60" spellcheck="false" autocomplete="off" style="width:100%;max-width:420px;padding:8px 10px;border:1px solid #d1d5db;border-radius:8px;font-size:13px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#fff;color:inherit">
</div>
<div style="margin-top:12px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<button type="submit" class="primary">{{if eq .Lang "zh"}}保存{{else}}Save{{end}}</button>
{{if .ExtraTotal}}<span class="muted">{{if eq .Lang "zh"}}共 {{.ExtraTotal}} 个 /64 · 宿主机保留 {{.ExtraReserved}} · 已分配 {{.ExtraUsed}} · 剩余 {{.ExtraFree}}{{else}}{{.ExtraTotal}} /64s · {{.ExtraReserved}} kept for this host · {{.ExtraUsed}} assigned · {{.ExtraFree}} free{{end}}</span>{{end}}
</div>
</form>
<p class="muted" style="margin-top:10px">{{if eq .Lang "zh"}}宿主机自己正在使用的 /64 永远不会分配给容器;已分配的 /64 不能通过改配额收回,删除该账号才会释放。{{else}}The /64 this host uses itself is never handed out, and an assigned /64 cannot be taken back by editing quotas — deleting the account releases it.{{end}}</p>
</div>
{{if .ExtraAssigned}}
<div class="card">
<h2>{{if eq .Lang "zh"}}已分配的 /64{{else}}Assigned /64s{{end}}</h2>
<table>
<thead><tr>
<th>{{if eq .Lang "zh"}}前缀{{else}}Prefix{{end}}</th>
<th>{{if eq .Lang "zh"}}所属用户{{else}}Owner{{end}}</th>
</tr></thead>
<tbody>
{{range .ExtraAssigned}}<tr><td><code>{{.Block}}</code></td><td>{{.User}}</td></tr>{{end}}
</tbody>
</table>
</div>
{{end}}
{{else if eq .Mode "pool"}}
<div class="card">
<h2>{{if eq .Lang "zh"}}添加地址{{else}}Add addresses{{end}}</h2>
@@ -244,6 +244,14 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
{{if .PoolTotal}}<small style="color:#6b7280">{{if eq .Lang "zh"}}池 {{.PoolUsed}}/{{.PoolTotal}} 已用{{else}}{{.PoolUsed}}/{{.PoolTotal}} used{{end}}</small>{{end}}
</div>
{{end}}
{{if .ExtraConfigured}}
<div class="field create-field ipv6-field"><label>{{if eq .Lang "zh"}}整段 /64{{else}}Whole /64{{end}}</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;{{if not .ExtraFree}}color:#9ca3af{{end}}">
<input type="checkbox" name="extra64" value="1" style="width:auto;flex:none"{{if .ExtraFree}} checked{{else}} disabled{{end}}>
<span>{{if eq .Lang "zh"}}分配 /64 前缀(剩余 {{.ExtraFree}} 个){{else}}Assign a whole /64 ({{.ExtraFree}} left){{end}}</span>
</label>
</div>
{{end}}
<button type="submit" class="primary neutral" id="cfBtn">{{if eq .Lang "zh"}}创建{{else}}Create{{end}}</button>
</form>
</div>
@@ -292,7 +300,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
{{else}}
<form action="{{$.Prefix}}/power" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><input type="hidden" name="action" value="start"><button class="ok">{{if eq $.Lang "zh"}}启动{{else}}Start{{end}}</button></form>
{{end}}
<button onclick="quota('{{.Name}}','{{.QuotaCPU}}','{{.QuotaMem}}','{{.QuotaDisk}}','{{.BandwidthGB}}','{{.ExpiresAt}}',false,{{if .IPv6}}true{{else}}false{{end}})">{{if eq $.Lang "zh"}}配额{{else}}Quota{{end}}</button>
<button onclick="quota('{{.Name}}','{{.QuotaCPU}}','{{.QuotaMem}}','{{.QuotaDisk}}','{{.BandwidthGB}}','{{.ExpiresAt}}',false,{{if .IPv6}}true{{else}}false{{end}},'{{.IPv6Extra}}')">{{if eq $.Lang "zh"}}配额{{else}}Quota{{end}}</button>
<form action="{{$.Prefix}}/login-as" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><button class="primary neutral"{{if .Color}} style="--ubtn:{{.Color}};color:#fff"{{end}} title="{{if eq $.Lang "zh"}}以该用户身份登录用户面板{{else}}log in to this user's panel{{end}}">{{if eq $.Lang "zh"}}登录面板{{else}}Login{{end}}</button></form>
<form action="{{$.Prefix}}/reset-panel-pass" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><button>{{if eq $.Lang "zh"}}重置密码{{else}}Reset pw{{end}}</button></form>
<button class="danger" onclick="del('{{.Name}}')">{{if eq $.Lang "zh"}}删除{{else}}Delete{{end}}</button>
@@ -434,6 +442,20 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
</select>
</div>
{{end}}
{{if .ExtraConfigured}}
<!-- A whole /64 can be given to a container that has none. Once given it
is the account's for life: this form can assign one, never release it. -->
<div class="field" id="quotaExtra64Wrap" style="display:none"><label>{{if eq .Lang "zh"}}分配 /64 前缀{{else}}Assign a whole /64{{end}}</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;{{if not .ExtraFree}}color:#9ca3af{{end}}">
<input type="checkbox" name="extra64" value="1" id="quotaExtra64" style="width:auto;flex:none"{{if not .ExtraFree}} disabled{{end}}>
<span>{{if eq .Lang "zh"}}分配 /64 前缀(剩余 {{.ExtraFree}} 个){{else}}Assign a whole /64 ({{.ExtraFree}} left){{end}}</span>
</label>
</div>
<div class="field" id="quotaExtra64Have" style="display:none"><label>{{if eq .Lang "zh"}}IPv6 段{{else}}IPv6 prefix{{end}}</label>
<code id="quotaExtra64Val"></code>
<div class="muted" style="margin-top:4px;font-size:12px">{{if eq .Lang "zh"}}已分配,只能通过删除账号释放{{else}}assigned; only deleting the account releases it{{end}}</div>
</div>
{{end}}
<button type="submit" class="primary" id="quotaSaveBtn">{{if eq .Lang "zh"}}保存{{else}}Save{{end}}</button>
</form>
<p id="quotaExpiredNote" style="display:none;color:#b91c1c;font-weight:600;margin:10px 0 0">{{if eq .Lang "zh"}}该账户已到期,仅可延期。{{else}}This account has expired — only extending is allowed.{{end}}</p>
@@ -544,6 +566,14 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
<div class="field"><label>{{if eq .Lang "zh"}}流量配额 GiB{{else}}Bandwidth GiB{{end}}</label><input name="bandwidth" type="number" min="0" step="1" value="0"></div>
<div class="field"><label>{{if eq .Lang "zh"}}有效期天数{{else}}Validity days{{end}}</label><input name="days" type="number" min="0" step="1" value="0"></div>
</div>
{{if .ExtraConfigured}}
<div class="field">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;{{if not .ExtraFree}}color:#9ca3af{{end}}">
<input type="checkbox" name="extra64" value="1" style="width:auto;flex:none"{{if .ExtraFree}} checked{{else}} disabled{{end}}>
<span>{{if eq .Lang "zh"}}为每个容器分配 /64 前缀(剩余 {{.ExtraFree}} 个){{else}}Assign a whole /64 to each container ({{.ExtraFree}} left){{end}}</span>
</label>
</div>
{{end}}
<div class="foot">
<button type="button" id="batchCancel">{{if eq .Lang "zh"}}取消{{else}}Cancel{{end}}</button>
<button type="submit" class="primary" id="batchSubmit">{{if eq .Lang "zh"}}开始创建{{else}}Start{{end}}</button>
@@ -610,7 +640,7 @@ function copyModal(){
if (navigator.clipboard) { navigator.clipboard.writeText(b.textContent).then(done, done); }
else { var ta=document.createElement('textarea'); ta.value=b.textContent; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); done(); }
}
function quota(name, cpu, mem, disk, bandwidth, expires, expired, hasIpv6){
function quota(name, cpu, mem, disk, bandwidth, expires, expired, hasIpv6, extra64){
document.getElementById('quotaName').textContent = name;
document.getElementById('quotaNameHidden').value = name;
document.getElementById('quotaBwName').value = name;
@@ -630,6 +660,17 @@ function quota(name, cpu, mem, disk, bandwidth, expires, expired, hasIpv6){
// A pool address can be given to a container that has none, and only then.
var v6wrap = document.getElementById('quotaIpv6Wrap');
if(v6wrap) v6wrap.style.display = (hasIpv6 || lock) ? 'none' : '';
// Whole /64: the checkbox is offered only while one is free and the container
// has none (a greyed-out, disabled checkbox is rendered when none is). A
// container that already owns one shows it read-only — this form can never
// take it back.
var e64have = document.getElementById('quotaExtra64Have');
if(e64have){
e64have.style.display = extra64 ? '' : 'none';
if(extra64) document.getElementById('quotaExtra64Val').textContent = extra64;
}
var e64wrap = document.getElementById('quotaExtra64Wrap');
if(e64wrap) e64wrap.style.display = (extra64 || lock) ? 'none' : '';
var note = document.getElementById('quotaExpiredNote'); if(note) note.style.display = lock ? 'block' : 'none';
document.getElementById('quotaExtend').value = 'none';
document.getElementById('quotaExtendDaysWrap').style.display = 'none';
+5
View File
@@ -201,6 +201,7 @@ type pageData struct {
DownGB string
IPv6 string // primary global address (the one to connect to)
IPv6Block string // the /112 block the container owns (informational)
IPv6Extra string // whole /64 handed out from the extra prefix ("" = none)
Snapshots []snapshotRow
SnapshotLimit int // configured per-container snapshot cap (for display)
// Snapshot share: the code others can use to install from a checkpoint, and
@@ -471,6 +472,10 @@ func (s *Server) buildData(u *db.User, msg, errMsg string) pageData {
if b, _ := s.mgr.IPv6Block(u.Name); b != nil {
d.IPv6Block = b.String()
}
// The whole /64 the account may own (net.ipv6_extra_prefix). Neutral
// presentation: it is simply shown, not advertised as something to
// ask for.
d.IPv6Extra = u.IPv6ExtraBlock
}
}
up, down := s.mgr.BandwidthFor(u.ID) // pure DB read
+1 -1
View File
@@ -299,7 +299,7 @@ footer a{color:var(--muted);text-decoration:underline}
<table>
{{if .V4Forward}}<tr><th>IPV4</th><td colspan="3"><code class="copyable">{{.PublicIP}}</code></td></tr>{{end}}
<tr><th>{{if eq .Lang "zh"}}SSH 端口{{else}}SSH port{{end}}</th><td colspan="3">{{if .V4Forward}}<span>{{if eq .Lang "zh"}}V4 端口{{else}}V4 port{{end}}: <code class="copyable" data-copy="ssh -p {{.SSHPort}} root@{{.PublicIP}}" title="ssh -p {{.SSHPort}} root@{{.PublicIP}}">{{.SSHPort}}</code></span>{{else}}<span>{{if eq .Lang "zh"}}V4 端口{{else}}V4 port{{end}}: <span class="pill down">{{if eq .Lang "zh"}}不支持{{else}}not available{{end}}</span></span>{{end}}{{if .IPv6}}<span class="muted"> &nbsp;|&nbsp; </span><span>{{if eq .Lang "zh"}}V6 端口{{else}}V6 port{{end}}: <code class="copyable" data-copy="ssh -p 22 root@{{.IPv6}}" title="ssh -p 22 root@{{.IPv6}}">22</code></span>{{end}}</td></tr>
{{if .IPv6}}<tr><th>IPv6</th><td colspan="3"><code class="copyable">{{.IPv6}}</code>{{if .IPv6Block}}<div class="muted" style="margin-top:4px">{{if eq .Lang "zh"}}地址块{{else}}Address block{{end}}: {{.IPv6Block}}</div>{{end}}</td></tr>{{end}}
{{if .IPv6}}<tr><th>IPv6</th><td colspan="3"><code class="copyable">{{.IPv6}}</code>{{if .IPv6Block}}<div class="muted" style="margin-top:4px">{{if eq .Lang "zh"}}地址块{{else}}Address block{{end}}: {{.IPv6Block}}</div>{{end}}{{if .IPv6Extra}}<div class="muted" style="margin-top:4px">{{if eq .Lang "zh"}}IPv6 段{{else}}IPv6 prefix{{end}}: <code class="copyable">{{.IPv6Extra}}</code></div>{{end}}</td></tr>{{end}}
</table>
</div>