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:
1 parent
a97dcb379e
commit
8c728096cc
12 files changed
+393
-21
No files matched your search
+16
-14
@@ -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)"
|
||||
|
||||
@@ -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`):
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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)))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"> | </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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user