feat(ports): replace net.slot_range with net.user_ports; probe SSH ports at add time

net.slot_range required operators to reason about IPv4 last-octet slots to
control the host's reserved port span. Replace it with net.user_ports, a
direct comma-separated set of user-port ranges (default 10000-29999) that a
new container's whole-hundred block is drawn from, auto-aligned to whole
hundreds and merged. Only-new-users effect; existing containers keep their
ports.

- cfg: ParseUserPorts/CanonicalUserPorts/UserPortRanges/UserPortCount/
  UserPortBlockStarts; legacy slot_range is migrated once on Load and the
  old key stays in the file, ignored, no longer editable.
- db: migration v13 adds a UNIQUE index on users.start_port (port blocks are
  no longer derived from idx, so uniqueness must be enforced directly);
  UsedStartPorts() feeds the allocator.
- mgr: port blocks are allocated independently of the IPv4 idx via
  allocUserPortBlock; admin capacity readout follows UserPortCount.
- installer: 00-ip-ask.sh asks for port ranges (default = no confirm, a
  changed value echoes capacity and asks Y/n) and converts legacy slot_range;
  00-check.sh scans only the effective user-port ranges.
- ssh: allocSSHPort now probes the host (bind test) before picking a port,
  reassigning on conflict; the install no longer hard-fails on an occupied
  30000-31999 port.
This commit is contained in:
bluebluesoda committed 2026-09-04 08:32:13 +00:00
1 parent 3fdb946baa
commit 69f79187c8
14 files changed
+905 -247

No files matched your search

+10 -10
View File
@@ -19,18 +19,18 @@ net:
# Container subnet 10.<n>.0.0/24, chosen ONCE at install (second octet n,
# default 115). Immutable: changing it after install breaks existing
# containers. The port scheme is fixed too: each container gets a random SSH
# port from 30000-31999 plus a whole-hundred block of 100 user ports
# (UserPortBase+(idx-1)*100 .. +99, idx being the container slot).
# port from 30000-31999 plus a whole-hundred block of 100 user ports.
subnet: "10.115.0.0/24"
gateway: "10.115.0.1"
# Slot range (read the v4 last octet a new container may take; the container
# index idx is octet-1, gateway takes .1). Default 2-201 = 200 containers;
# shrinkable to any sub-range of 2-201 (e.g. 6-201) to keep the higher
# user ports (and fewer slots total) on the host. Affects ONLY new
# containers — existing ones keep their slots/ports. Since the user-port
# span the host reserves follows this range, an install's port-occupancy
# check scans only that span (SSH 30000-31999 stays reserved regardless).
slot_range: "2-201"
# User-port ranges a NEW container's 100-port block is drawn from
# (comma-separated inclusive ranges; auto-aligned to whole hundreds).
# Default 10000-29999 = 200 containers. Use e.g. "10000-20000, 25000-30000"
# to skip a middle span the host needs for other services. Affects ONLY new
# containers — existing ones keep their ports. Since the user-port span the
# host reserves follows these ranges, an install's port-occupancy check scans
# only them. SSH ports (30000-31999) are NOT checked at install — each is
# probed at `vps add` time, and a busy port is simply skipped.
user_ports: "10000-29999"
v4_forward: true # IPv4 inbound policy. false (only meaningful with
# ipv6_subnet set) = containers are IPv6-only: no
# SSH/port-block DNAT and traefik/domains disabled
+12 -7
View File
@@ -94,12 +94,16 @@ The panel daemon runs as the dedicated unprivileged `vps` system user
- User `i` (the container's slot, `idx = v4-last-octet - 1`): container IP
`10.<n>.0.(i+1)` (subnet `10.<n>.0.0/24`, second octet chosen at install,
default 115), a random SSH port in `30000-31999` (DNAT to container 22),
and a whole-hundred block of 100 user ports `10000 + (i-1)*100 .. +99`
(DNAT to the container, TCP+UDP). The `i` a **new** container may take is
bounded by `net.slot_range` (an inclusive pair of v4 last octets, default
`2-201` = idx `1..200` = 200 containers); shrinking it reduces capacity and
the user-port span a host reserves, and affects **new containers only** —
existing ones keep their slot/ports even outside the narrowed range.
and a whole-hundred block of 100 user ports (DNAT to the container, TCP+UDP).
The block a **new** container takes is drawn from `net.user_ports` (default
`10000-29999` = 200 blocks = 200 containers), a comma-separated set of
inclusive port ranges auto-aligned to whole hundreds; narrowing the ranges
reduces capacity and the user-port span a host reserves, and affects **new
containers only** — existing ones keep their port blocks even outside the
current ranges. The IP slot `idx` (1..200) and the port block are allocated
independently: `NextFreeIdx` picks the IPv4, `allocUserPortBlock` picks a
free 100-port block inside the configured ranges (start_port UNIQUE is the
cross-process backstop).
- **IPv4 inbound policy (`v4_forward`)**: the SSH/port-block DNAT above only
exists while `net.v4_forward` is true. When false (IPv6-only box), no DNAT
rules are written and traefik is stopped (domains kept but not served); the
@@ -454,7 +458,8 @@ returns an error when any container could not be repaired.
containers, the storage pool and the Incus package.
- `install.sh` detects an existing `/etc/vpsmgr/config.yaml` and adopts it
(users/domains survive). `00-ip-ask.sh` reuses a previously configured
`ipv6_subnet` / `subnet` / `slot_range` instead of re-asking.
`ipv6_subnet` / `subnet` / `user_ports` instead of re-asking; a legacy
`slot_range` is converted to `user_ports` once and the new key persisted.
- `install.sh --local-build` prints the current git branch and waits 10 s
(Ctrl-C to abort) before starting, and always rebuilds rather than reusing
an installed stable binary. `install.sh --update` explicitly re-downloads
+40 -27
View File
@@ -37,7 +37,7 @@ same table; `vps config list` shows the live values with this annotation.
| `panel.admin_pass_hash` | managed elsewhere | — | bcrypt hash of the admin password; stored in the **DB**, set via `vps admin-passwd` / web UI |
| `net.subnet` | **fixed at install** | — | container subnet `10.<n>.0.0/24`; changing breaks existing containers |
| `net.gateway` | **fixed at install** | — | bridge gateway (derived from subnet) |
| `net.slot_range` | operator | next `vps add` / reinstall | container slot range (read the v4 last octet a new container may take), e.g. `2-201` = 200 containers; shrinkable to any sub-range of `2-201`; affects **new containers only** — existing ones keep their slot/ports |
| `net.user_ports` | operator | next `vps add` / reinstall | user-port ranges a **new** container's 100-port block is drawn from; comma-separated inclusive ranges (e.g. `10000-29999` = 200 containers, or `10000-20000, 25000-30000`); auto-aligned to whole hundreds; affects **new containers only** — existing ones keep their ports |
| `net.v4_forward` | runtime toggle | **applied immediately** | false = IPv6-only containers (no SSH/port DNAT, traefik disabled, NAT4 outbound kept) |
| `net.traefik` | runtime toggle | **applied immediately** | false = stop and disable Traefik; existing domains are retained, but new domains cannot be added |
| `net.ext_if` | operator | re-run `vps install` | external NIC (auto-detected from default route) |
@@ -125,44 +125,57 @@ snapshots:
## Port scheme
The port layout per container is fixed at install and not individually tunable,
but its **span** follows the container slot range (`net.slot_range`):
but the **span** new containers may use follows `net.user_ports`:
- **Panel port**: random free port in `2000-9999`, chosen on a fresh install
and stored in `panel.listen`. Change it in the config only if you know why.
- **SSH port**: each container gets one random port in `30000-31999` (TCP, DNAT
to container `:22`). Independent of `idx`; always reserved regardless of the
slot range. Shown as `ssh -p <port>`.
- **User ports**: each container owns a whole-hundred block of 100 ports,
assigned deterministically (`UserPortBase+(idx-1)*100` .. `+99`, where
`idx = v4-last-octet - 1`), DNAT to the container (TCP and UDP). Displayed
compactly as e.g. `107xx`.
to container `:22`). Independent of the user-port ranges. Not reserved at
install — each port is probed at `vps add` time, and a candidate already
listened on by another process is skipped for another. Shown as `ssh -p
<port>`.
- **User ports**: each container owns a whole-hundred block of 100 ports, drawn
at create time from the configured `net.user_ports` ranges, DNAT to the
container (TCP and UDP). Displayed compactly as e.g. `107xx`.
### Slot range (`net.slot_range`)
### User-port ranges (`net.user_ports`)
The slot range bounds which `idx` a **new** container may take, and therefore
how much of the user-port span (`10000-29999`) a host reserves. It is an
inclusive pair of v4 last octets, e.g. `2-201` (= idx `1..200`). Set it at
install (`00-ip-ask.sh` asks right after the subnet octet) or change it later:
`net.user_ports` names the port ranges a **new** container's 100-port block may
be drawn from, e.g. `10000-29999` (full) or `10000-20000, 25000-30000` (leave a
gap for the host). Set it at install (`00-ip-ask.sh` asks right after the
subnet octet) or change it later:
```sh
vps config set net.slot_range 6-201
vps config set net.user_ports 10000-20000, 25000-30000
```
Rules:
- The value must be `A-B` with each end an integer in `2..201` and `A <= B` —
i.e. always a **sub-range of the default**, so it can only increase the lower
edge and/or decrease the upper edge ("shrink"). Re-expanding within `2..201`
is allowed.
- Capacity follows the range: the default `2-201` allows **200** containers;
`6-201` allows **196**, `20-100` allows **81**, and so on.
- A shrunken install reserves far fewer host user ports, so its port-occupancy
check scans only the range's span (`10000+(lo-2)*100` .. `10000+(hi-2)*100+99`)
— not the whole `10000-29999`. `80/443` and SSH `30000-31999` are reserved
**regardless** of the range.
- It affects **new containers only**: shrinking never renumbers or removes an
existing container (one that falls outside the narrowed range keeps its
slot/ports). The admin panel's capacity readout reflects the range.
- Comma-separated inclusive ranges `A-B` (integers, `A <= B`). Ends may extend
outside `10000-29999` — only the overlap with the usable domain counts, and a
fully-outside range contributes nothing.
- Values are **auto-aligned to whole hundreds**: the low end rounds up to a
block start and the high end rounds down then extends to `+99`, so the
effective range always ends in `...99`, never `...00`. For example
`10001-29998` becomes `10100-29999`.
- At least one whole 100-port block must remain (the value is rejected
otherwise) — a range that cannot host even one container is an error.
- Capacity follows the ranges: `10000-29999` allows **200** containers;
`10000-20000, 25000-30000` allows **151**; and so on. The interactive prompt
echoes the computed capacity for confirmation.
- A narrowed set reserves far fewer host user ports, so an install's
port-occupancy check scans only the configured ranges — not the whole
`10000-29999`. `80/443` is reserved **regardless**; SSH ports (`30000-31999`)
are **not** checked at install — each is probed at `vps add` time and the
allocator reassigns on conflict (a live listener on the candidate is skipped).
- It affects **new containers only**: editing the ranges never renumbers or
removes an existing container (one whose block falls outside the current
ranges keeps it). The admin panel's capacity readout reflects the ranges.
> Legacy: configs written before `net.user_ports` used `net.slot_range` (a
> range of v4 last octets, e.g. `2-201`). On upgrade the installer converts it
> once to the equivalent `user_ports` value and writes the new key; the old key
> stays in the file but is ignored and is no longer an editable config key.
## IPv4 inbound policy (`v4_forward`)
`net.v4_forward` controls whether containers receive **shared IPv4 inbound**.
+34 -18
View File
@@ -236,8 +236,15 @@ fi
# NOT fail — it proceeds with Traefik installed but DISABLED (net.traefik
# false, not started/autostarted), so a host that already serves 80/443 is
# still usable; the operator can re-enable later via `vps config set
# net.traefik true`. The user-port span (from net.slot_range) and the SSH
# range (30000-31999) stay hard failures.
# net.traefik true`. The user-port ranges (from net.user_ports, exported by
# 00-ip-ask.sh as VPSMGR_USER_PORTS; default 10000-29999) stay hard failures.
#
# SSH ports (30000-31999) are deliberately NOT checked here: they are allocated
# per-container at `vps add` time with a "probe the port, reassign on conflict"
# loop (mgr.allocSSHPort binds the candidate to detect a live listener), so an
# occupied port no longer blocks the install — the host simply skips it. This
# keeps the install usable on machines that happen to run something in that
# range.
#
# 00-check runs as a separate bash process, so its env cannot reach the rest of
# the installer. The decision therefore goes through a root-only marker file
@@ -247,25 +254,34 @@ rm -f /etc/vpsmgr/.install-traefik-off 2>/dev/null || true
if [[ "${VPSMGR_DISABLE_V4FORWARD:-0}" == "1" ]]; then
log "v4 forwarding disabled by installer — skipping reserved port checks"
else
# The user-port block only spans the configured slot range (net.slot_range,
# exported by 00-ip-ask.sh as VPSMGR_SLOT_RANGE; default 2-201): each slot
# idx (= octet-1) owns [10000+(idx-1)*100, +99]. So a shrunken range reserves
# far fewer host ports, and only those must be free — checking the whole
# 10000-29999 regardless of the range would block a small-footprint install
# that never uses the upper ports. SSH (30000-31999) stays reserved no matter
# the range (SSH ports are drawn independently of idx).
SLOT_RANGE="${VPSMGR_SLOT_RANGE:-2-201}"
RANGE_LO="${SLOT_RANGE%%-*}"
RANGE_HI="${SLOT_RANGE##*-}"
USER_LO_PORT=$(( 10000 + (RANGE_LO - 2) * 100 ))
USER_HI_PORT=$(( 10000 + (RANGE_HI - 2) * 100 + 99 ))
port_hard_reserved(){
# Only the ports the configured user-port ranges actually reserve must be
# free — checking the whole 10000-29999 regardless of the ranges would block a
# small-footprint install that never uses the upper ports. VPSMGR_USER_PORTS is
# the comma-separated (already whole-hundred aligned) ranges exported by
# 00-ip-ask.sh; default 10000-29999. SSH ports (30000-31999) are NOT checked
# here — they are probed per-container at add time (see allocSSHPort).
USER_PORTS="${VPSMGR_USER_PORTS:-10000-29999}"
port_in_user_ranges(){
local p="$1"
(( p >= USER_LO_PORT && p <= USER_HI_PORT )) && return 0
(( p >= 30000 && p <= 31999 )) && return 0
local r lo hi
while IFS=',' read -ra RS; do
for r in "${RS[@]}"; do
r="${r// /}"
[[ -z "$r" ]] && continue
lo="${r%%-*}"; hi="${r##*-}"
if (( p >= lo && p <= hi )); then
return 0
fi
done
done <<<"$USER_PORTS"
return 1
}
log "checking reserved ports (80/443, $USER_LO_PORT-$USER_HI_PORT, 30000-31999)..."
port_hard_reserved(){
local p="$1"
port_in_user_ranges "$p"
return $?
}
log "checking reserved ports (80/443, $USER_PORTS)..."
CONFLICTS=""
PORT_80_443_TAKEN=0
PORT_80_443_PROC=""
+180 -69
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env bash
# 00-ip-ask.sh — install-time network asks: whether to enable IPv6
# pass-through (with the global prefix) and the container IPv4 subnet octet
# (10.<n>.0.0/24, default n=115). Both are fixed after install. IPv4 inbound
# forwarding is always ON by default and never asked — toggle it later with
# `vps config set net.v4_forward true|false`.
# pass-through (with the global prefix), the container IPv4 subnet octet
# (10.<n>.0.0/24, default n=115), and the user-port ranges new containers may
# use (10000-29999 by default). Subnet and port ranges are fixed after install.
# IPv4 inbound forwarding is always ON by default and never asked — toggle it
# later with `vps config set net.v4_forward true|false`.
#
# Behavior:
# - IPv6: interactive asks y/N then the prefix (default = the host's own
@@ -13,10 +14,16 @@
# - Subnet: interactive asks the second octet (default 115);
# VPSMGR_IPV4_SUBNET env var used verbatim (validated); a previous subnet
# in the config is kept on adoption; default 10.115.0.0/24 otherwise.
# - User ports: interactive asks one or more comma-separated port ranges
# (default 10000-29999, no confirm on the default; a changed value echoes
# the capacity and asks Y/n); VPSMGR_USER_PORTS env var used verbatim
# (validated); a previous user_ports in the config is kept on adoption; a
# legacy slot_range is converted once and the new key persisted.
#
# Writes nothing itself; exports VPSMGR_IPV6_SUBNET / VPSMGR_IPV4_SUBNET for
# the rest of the install, and re-exports an adopted VPSMGR_V4_FORWARD so the
# later steps keep the existing policy.
# Writes nothing itself except the one-time legacy slot_range→user_ports
# migration; exports VPSMGR_IPV6_SUBNET / VPSMGR_IPV4_SUBNET / VPSMGR_USER_PORTS
# for the rest of the install, and re-exports an adopted VPSMGR_V4_FORWARD so
# the later steps keep the existing policy.
set -uo pipefail
log(){ echo "[net] $*"; }
@@ -304,29 +311,97 @@ if not o.isdigit() or not (1 <= int(o) <= 254):
PY
}
# validate_range: exit 0 if arg is "A-B" with both ends integers within
# 2..201 (the default slot range) and A <= B. This enforces the "only shrink
# towards the defaults" rule: any sub-range of 2-201 is allowed, expanding
# beyond either edge is rejected.
validate_range(){
local s="$1"
[[ "$s" =~ ^[0-9]+-[0-9]+$ ]] || return 1
local lo="${s%%-*}" hi="${s##*-}"
(( lo >= 2 && lo <= 201 && hi >= 2 && hi <= 201 && lo <= hi )) || return 1
return 0
# normalize_user_ports: echoes "canonical" (whole-hundred aligned, merged,
# comma-separated) and the container capacity on one line ("canon|cap"). Exit 0
# only when the value yields at least one usable 100-port block. Mirrors
# cfg.ParseUserPorts: low end rounds up to a block start, high end rounds down
# then +99 (never ends in ...00); ranges outside 10000-29999 are clamped, and
# fully-outside ranges contribute nothing.
normalize_user_ports(){
python3 - "$1" <<'PY'
import sys
def norm(s):
out = []
for tok in s.split(','):
tok = tok.strip()
if not tok:
continue
if '-' not in tok:
raise ValueError(tok)
a, b = tok.split('-', 1)
a, b = int(a), int(b)
if a > b:
raise ValueError(tok)
a = max(a, 10000)
b = min(b, 29999)
if a > b:
continue
lo = ((a + 99) // 100) * 100
hi = (b // 100) * 100 + 99
if lo > hi:
continue
out.append([lo, hi])
if not out:
raise ValueError("no usable range")
out.sort()
merged = [out[0]]
for lo, hi in out[1:]:
if lo <= merged[-1][1] + 1:
merged[-1][1] = max(merged[-1][1], hi)
else:
merged.append([lo, hi])
canon = ', '.join('%d-%d' % (lo, hi) for lo, hi in merged)
cap = sum((hi - lo + 1) // 100 for lo, hi in merged)
return canon, cap
try:
canon, cap = norm(sys.argv[1])
except Exception:
sys.exit(1)
print("%s|%d" % (canon, cap))
PY
}
# slot_range_summary: one-line hint echoing what the chosen slot range means —
# the container capacity it allows and the user-port span it reserves (each
# slot idx=octet-1 owns [10000+(idx-1)*100, +99]). SSH ports are separate.
slot_range_summary(){
local r="$1" lo hi cap plo phi
lo="${r%%-*}"; hi="${r##*-}"
cap=$(( hi - lo + 1 ))
# slot_to_ports: convert a LEGACY slot_range ("lo-hi", inclusive v4 last
# octets) into the equivalent user-port range string. Only used to migrate old
# configs at install; the old key is left in the file but ignored from then on.
slot_to_ports(){
local s="$1" lo hi plo phi
lo="${s%%-*}"; hi="${s##*-}"
plo=$(( 10000 + (lo - 2) * 100 ))
phi=$(( 10000 + (hi - 2) * 100 + 99 ))
log "换算: 容器总数量上限 ${cap} 台; 可用用户端口范围 ${plo}-${phi} (SSH 端口 30000-31999 独立分配)"
log "capacity: up to ${cap} containers; usable user ports ${plo}-${phi} (SSH 30000-31999 separate)"
printf '%d-%d' "$plo" "$phi"
}
# user_ports_summary: one-line hint echoing what the chosen user-port ranges
# mean — the container capacity they allow (each 100-port block = one
# container). SSH ports (30000-31999) are separate.
user_ports_summary(){
local canon="$1" cap="$2"
log "换算: 容器总数量上限 ${cap} 台; 可用用户端口 ${canon} (SSH 端口 30000-31999 独立分配)"
log "capacity: up to ${cap} containers; usable user ports ${canon} (SSH 30000-31999 separate)"
}
# write_user_ports: add/update the net.user_ports key in an existing config so
# a legacy slot_range migration is persisted to the file (the old slot_range
# key is left in place, unused). No-op when the config does not exist yet.
write_user_ports(){
[[ -f /etc/vpsmgr/config.yaml ]] || return 0
python3 - "$1" <<'PY'
import sys, os
try:
import yaml
except ImportError:
sys.exit(0)
p = "/etc/vpsmgr/config.yaml"
try:
with open(p) as f:
cfg = yaml.safe_load(f) or {}
cfg.setdefault("net", {})["user_ports"] = sys.argv[1]
with open(p, "w") as f:
yaml.safe_dump(cfg, f, default_flow_style=False, sort_keys=False)
except Exception:
sys.exit(0)
PY
}
@@ -436,72 +511,108 @@ ask_subnet(){
log "container subnet: 10.$OCT.0.0/24"
}
# --- ask: container slot range (IPv4 last octet) -------------------------------
# Bounds the idx a NEW container may take, which in turn bounds the user-port
# block the host reserves (only-new-users effect; existing containers keep
# their slots/ports). Default 2-201 = 200 slots. Operator-editable afterwards
# via `vps config set net.slot_range`.
ask_range(){
# env override: VPSMGR_SLOT_RANGE
if [[ -n "${VPSMGR_SLOT_RANGE:-}" ]]; then
if validate_range "$VPSMGR_SLOT_RANGE"; then
log "container slot range: $VPSMGR_SLOT_RANGE (from env)"
export VPSMGR_SLOT_RANGE
slot_range_summary "$VPSMGR_SLOT_RANGE"
# --- ask: container user-port ranges -------------------------------------------
# The port ranges a NEW container's 100-port block may be drawn from. Directly
# configurable (no slot/octet concept): multiple comma-separated ranges are
# allowed, values are auto-aligned to whole hundreds, and only-new-users effect
# (existing containers keep their ports). Default 10000-29999 = 200 containers.
# Env override: VPSMGR_USER_PORTS. Operator-editable afterwards via
# `vps config set net.user_ports`.
ask_user_ports(){
local RES CANON CAP
# env override: VPSMGR_USER_PORTS
if [[ -n "${VPSMGR_USER_PORTS:-}" ]]; then
if RES=$(normalize_user_ports "$VPSMGR_USER_PORTS"); then
CANON="${RES%%|*}"; CAP="${RES##*|}"
log "user port ranges: $CANON (from env)"
export VPSMGR_USER_PORTS="$CANON"
user_ports_summary "$CANON" "$CAP"
return 0
fi
die "VPSMGR_SLOT_RANGE='$VPSMGR_SLOT_RANGE' must be 'A-B' with each end in 2..201 and A<=B (e.g. 2-201, 6-201)"
die "VPSMGR_USER_PORTS='$VPSMGR_USER_PORTS' must be one or more 'A-B' ranges overlapping 10000-29999 (e.g. 10000-29999, 10000-20000, 25000-30000)"
return 1
fi
# Adoption: keep the recorded slot_range instead of re-asking.
# Adoption: keep the recorded user_ports instead of re-asking.
if [[ -f /etc/vpsmgr/config.yaml ]]; then
EXISTING=$(grep -E '^\s+slot_range:' /etc/vpsmgr/config.yaml 2>/dev/null | awk -F': ' '{print $2}' | tr -d '"')
EXISTING=$(grep -E '^\s+user_ports:' /etc/vpsmgr/config.yaml 2>/dev/null | awk -F': ' '{print $2}' | tr -d '"')
if [[ -n "$EXISTING" ]]; then
if validate_range "$EXISTING"; then
log "existing config has slot_range=$EXISTING — keeping it"
export VPSMGR_SLOT_RANGE="$EXISTING"
slot_range_summary "$EXISTING"
if RES=$(normalize_user_ports "$EXISTING"); then
CANON="${RES%%|*}"; CAP="${RES##*|}"
log "existing config has user_ports=$EXISTING — keeping it (normalized $CANON)"
export VPSMGR_USER_PORTS="$CANON"
user_ports_summary "$CANON" "$CAP"
return 0
fi
die "existing config has an invalid slot_range='$EXISTING'"
die "existing config has an invalid user_ports='$EXISTING'"
return 1
fi
# Older config WITHOUT a slot_range key (pre-slot-range version): this box
# is being upgraded. Leave it at the default (full 2-201, which the panel
# applies anyway) and DO NOT prompt — a one-click upgrade of an existing
# panel must not be interrupted by a new question. Existing users keep
# their slots/ports untouched.
log "existing config has no slot_range (older version) — keeping default 2-201 (upgrade not interrupted)"
export VPSMGR_SLOT_RANGE="2-201"
slot_range_summary "2-201"
# Legacy config with ONLY slot_range: convert it once and persist the new
# key. The old slot_range key stays in the file (ignored from now on).
LEGACY=$(grep -E '^\s+slot_range:' /etc/vpsmgr/config.yaml 2>/dev/null | awk -F': ' '{print $2}' | tr -d '"')
if [[ -n "$LEGACY" ]]; then
CONVERTED=$(slot_to_ports "$LEGACY")
if RES=$(normalize_user_ports "$CONVERTED"); then
CANON="${RES%%|*}"; CAP="${RES##*|}"
log "existing config has legacy slot_range=$LEGACY — converted to user_ports=$CANON"
write_user_ports "$CANON"
export VPSMGR_USER_PORTS="$CANON"
user_ports_summary "$CANON" "$CAP"
return 0
fi
die "existing config has an invalid legacy slot_range='$LEGACY'"
return 1
fi
# Older config WITHOUT either key (pre-slot-range version): this box is
# being upgraded. Leave it at the default (full 10000-29999) and DO NOT
# prompt — a one-click upgrade of an existing panel must not be interrupted
# by a new question. Existing users keep their ports untouched.
log "existing config has no user_ports (older version) — keeping default 10000-29999 (upgrade not interrupted)"
export VPSMGR_USER_PORTS="10000-29999"
user_ports_summary "10000-29999" "200"
return 0
fi
# Non-interactive with no env var: default range (full capacity).
if [[ ! -t 0 ]] && [[ -z "${FORCE_ASK:-}" ]]; then
log "non-interactive install — using default slot range 2-201"
export VPSMGR_SLOT_RANGE="2-201"
slot_range_summary "2-201"
log "non-interactive install — using default user ports 10000-29999"
export VPSMGR_USER_PORTS="10000-29999"
user_ports_summary "10000-29999" "200"
return 0
fi
# Interactive: only shrinkable within the default range, affects new containers.
# Interactive: defaults are used without further confirmation; a changed
# value echoes the capacity it implies and asks for a Y/n confirm (default
# Y). Affects new containers only.
echo
echo "容器槽位范围设置(即容器 v4 末段)Container slot range (== IPv4 last octet)"
echo "2-201将上限200容器,占用宿主机1w-3w端口。102-201将上限100容器,占用2w-3w端口"
echo "2-201 = 200 containers(port 10000-29999); shrink to e.g. 102-201 to avoid 10000-19999 ports be used)"
read -r -p "范围 / Range A-B [default: 2-201]: " RANGE
RANGE="${RANGE:-2-201}"
if ! validate_range "$RANGE"; then
die "'$RANGE' invalid — must be 'A-B' with each end in 2..201 and A<=B (only shrink towards the defaults)"
echo "用户可用端口范围设置 User port ranges"
echo "每个容器占用一个整百的100端口块; 支持多个区间用逗号分隔; 自动按整百对齐。"
echo "10000-29999 = 最多 200 台容器; 例如 10000-20000, 25000-30000 可跳过中间段"
echo "10000-29999 = up to 200 containers; e.g. 10000-20000, 25000-30000 to leave a gap"
read -r -p "端口范围 / Port ranges [default: 10000-29999]: " RANGE
RANGE="${RANGE:-10000-29999}"
if [[ "$RANGE" == "10000-29999" ]]; then
log "using default user ports 10000-29999 (max 200 containers)"
export VPSMGR_USER_PORTS="10000-29999"
user_ports_summary "10000-29999" "200"
return 0
fi
if ! RES=$(normalize_user_ports "$RANGE"); then
die "'$RANGE' invalid — must be one or more 'A-B' ranges overlapping 10000-29999 with at least one whole 100-port block"
return 1
fi
export VPSMGR_SLOT_RANGE="$RANGE"
log "container slot range: $RANGE"
slot_range_summary "$RANGE"
CANON="${RES%%|*}"; CAP="${RES##*|}"
log "输入换算: 有效用户端口 ${CANON} — 容器总数量上限 ${CAP} 台"
log "normalized: $CANON — up to ${CAP} containers"
read -r -p "确认以上端口范围?Confirm these port ranges [Y/n]: " ANS
case "${ANS,,}" in
n|no) die "aborted — pick another port range"; return 1 ;;
esac
export VPSMGR_USER_PORTS="$CANON"
log "user port ranges: $CANON"
user_ports_summary "$CANON" "$CAP"
}
ask_ipv6 || return 1
ask_subnet || return 1
ask_range || return 1
ask_user_ports || return 1
+5 -5
View File
@@ -182,11 +182,11 @@ func (s *Server) loadUsers(d *pageData) {
}
d.Users = vs
d.UserCount = len(vs)
// Capacity follows the configured slot range, not a fixed 200: a shrunken
// net.slot_range lowers the ceiling, and existing users that fall outside
// it (from a wider earlier setting) can push the count past 100% — shown
// honestly as over-capacity.
d.MaxUsers = s.cfg.SlotCount()
// Capacity follows the configured user-port block count, not a fixed 200: a
// narrowed net.user_ports lowers the ceiling, and existing users that fall
// outside it (from a wider earlier setting) can push the count past 100% —
// shown honestly as over-capacity.
d.MaxUsers = s.cfg.UserPortCount()
if d.MaxUsers > 0 {
d.CapacityPct = d.UserCount * 100 / d.MaxUsers
// With only a handful of containers the true percentage (e.g. 1/200)
+200 -53
View File
@@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -54,27 +55,28 @@ const (
// Port scheme (fixed at install, immutable): each container gets one
// random SSH port from SSHPortBase..SSHPortBase+SSHPortCount-1, plus a
// whole-hundred block of PortsPerUser user ports starting at
// UserPortBase+(idx-1)*PortsPerUser. 10000..29999 holds exactly
// 200 blocks x 100 ports = 20000, so the range is fully packed — that is
// the total when the slot range is left at its maximum (2-201).
// whole-hundred block of PortsPerUser user ports. The block a NEW
// container takes is drawn from the configured net.user_ports ranges
// (default 10000-29999 = 200 blocks x 100 ports = 20000, fully packed).
// Existing containers keep their start_port regardless of later range
// edits — only new users are affected.
SSHPortBase = 30000
SSHPortCount = 2000
UserPortBase = 10000
PortsPerUser = 100
MaxUsers = 200 // absolute slot ceiling; the effective ceiling is net.slot_range
MaxUsers = 200 // absolute container ceiling (IPv4 /24 host bits); port blocks are the real limiter
// Slot range (net.slot_range "<lo>-<hi>"): the inclusive range of a new
// container's IPv4 last octet (== its slot number). The container index
// (idx, stored on the DB user) is octet-1, the gateway taking .1, so the
// default 2-201 maps to idx 1..200 = 200 slots. The range bounds the idx
// a new user may take, which in turn bounds the user-port span a host
// occupies (UserPortBase+(idx-1)*PortsPerUser). Only ever shrinkable to a
// sub-range of [DefaultSlotMin, DefaultSlotMax]; it affects ONLY new
// users — existing users keep their idx/ports untouched.
DefaultSlotRange = "2-201"
DefaultSlotMin = 2
DefaultSlotMax = 201
// User port domain. A container's block is always a whole hundred inside
// UserPortBase..UserPortMax; net.user_ports may name any sub-range(s) and
// is auto-aligned to whole hundreds (low end rounds up, high end rounds
// down then +99 so the last block ends in ...99, never ...00).
UserPortMax = UserPortBase + MaxUsers*PortsPerUser - 1 // 29999
DefaultUserPorts = "10000-29999"
// Legacy slot range bounds, kept only to migrate pre-user_ports configs
// (slot_range "lo-hi" of v4 last octets). See LegacySlotRangeToUserPorts.
DefaultSlotMin = 2
DefaultSlotMax = 201
// MaxInitScriptBytes caps a user's custom init script (run inside the
// container after a reinstall). Bounds the DB row and the panel payload.
@@ -144,12 +146,19 @@ type NetCfg struct {
// IPv4 forwarding. When false, Traefik is stopped and not enabled at boot;
// existing domain records are retained but new domains cannot be added.
Traefik bool `yaml:"traefik"`
// SlotRange is the inclusive range of a new container's IPv4 last octet
// ("<lo>-<hi>", default 2-201). The container's idx is octet-1, so this
// bounds which idx a new user is drawn from — and therefore how much of
// the user-port block 10000-29999 the host reserves. Only-new-users
// effect: existing containers never renumber. See DefaultSlotMin/Max.
SlotRange string `yaml:"slot_range"`
// UserPorts is the comma-separated set of inclusive user-port ranges a
// NEW container's whole-hundred block may be drawn from (e.g.
// "10000-29999", or "10000-20000, 25000-30000" for discontiguous spans).
// Values are auto-aligned to whole hundreds and merged; only-new-users
// effect: existing containers never change their ports. The default is
// the full 10000-29999. Absent in an older config (carrying only the
// legacy slot_range) it is migrated on Load.
UserPorts string `yaml:"user_ports"`
// SlotRange is the LEGACY pre-user_ports field (inclusive v4 last octet
// range, "lo-hi"). Read-only for migration: when user_ports is empty a
// set slot_range is converted to user_ports and the config rewritten. It
// is no longer editable — the registry entry was removed.
SlotRange string `yaml:"slot_range,omitempty"`
// IPv6Subnet is the global prefix handed out to containers (e.g.
// "2602:fada:6::/64", or a /80 slice the provider assigned the host).
// Empty means IPv6 pass-through is disabled.
@@ -231,7 +240,7 @@ type SnapshotsCfg struct {
func Default() *Config {
c := &Config{}
c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3}
c.Net = NetCfg{Subnet: DefaultSubnet, Gateway: DefaultGateway, V4Forward: true, Traefik: true, SlotRange: DefaultSlotRange}
c.Net = NetCfg{Subnet: DefaultSubnet, Gateway: DefaultGateway, V4Forward: true, Traefik: true, UserPorts: DefaultUserPorts}
c.Incus = IncusCfg{Image: DefaultImage, ImageFallback: DefaultImageFB, Pool: DefaultPool, Bridge: DefaultBridge, Socket: DefaultSocket, SwapRatio: DefaultSwapRatio}
c.Snapshots = SnapshotsCfg{Limit: 1}
return c
@@ -258,11 +267,19 @@ func (c *Config) TraefikDir() string {
}
// SubnetIP returns the IP portion of the subnet CIDR.
// ParseSlotRange parses "<lo>-<hi>" into inclusive v4-last-octet bounds.
// Rules (strict): exactly one '-' separator, both ends non-empty integers,
// each within [DefaultSlotMin, DefaultSlotMax] (2..201), and lo <= hi. This
// mirrors the "only shrink towards the edges of the default range" rule: any
// sub-range of [2, 201] is valid; expanding beyond either edge is rejected.
// PortRange is an inclusive, whole-hundred-aligned user-port range. Lo and Hi
// are both within [UserPortBase, UserPortMax]; Lo%100==0 and Hi%100==99, so the
// range always spans a whole number of 100-port blocks.
type PortRange struct {
Lo, Hi int
}
// ParseSlotRange parses a LEGACY slot range "<lo>-<hi>" into inclusive
// v4-last-octet bounds. Rules (strict): exactly one '-' separator, both ends
// non-empty integers, each within [DefaultSlotMin, DefaultSlotMax] (2..201),
// and lo <= hi. Kept for migrating pre-user_ports configs; no longer exposed
// as an editable config key.
func ParseSlotRange(s string) (lo, hi int, err error) {
s = strings.TrimSpace(s)
parts := strings.Split(s, "-")
@@ -283,30 +300,135 @@ func ParseSlotRange(s string) (lo, hi int, err error) {
return a, b, nil
}
// SlotRangeBounds returns the configured inclusive v4-last-octet bounds of the
// container slot range, falling back to the default 2-201 if it does not parse
// (a validated config never hits this; guard against hand-edits).
func (c *Config) SlotRangeBounds() (lo, hi int) {
lo, hi, err := ParseSlotRange(c.Net.SlotRange)
// LegacySlotRangeToUserPorts converts a legacy slot_range ("lo-hi", inclusive
// v4 last octets) into the equivalent user-port range string. Slot idx
// (= octet-1) owned [UserPortBase+(idx-1)*PortsPerUser, +99], so lo octet maps
// to port 10000+(lo-2)*100 and hi octet to 10000+(hi-2)*100+99. The result is
// already whole-hundred aligned.
func LegacySlotRangeToUserPorts(slotRange string) (string, error) {
lo, hi, err := ParseSlotRange(slotRange)
if err != nil {
return DefaultSlotMin, DefaultSlotMax
return "", err
}
return lo, hi
plo := UserPortBase + (lo-DefaultSlotMin)*PortsPerUser
phi := UserPortBase + (hi-DefaultSlotMin)*PortsPerUser + PortsPerUser - 1
return fmt.Sprintf("%d-%d", plo, phi), nil
}
// SlotCount returns how many container slots the configured range allows (
// == max concurrently addressable new users within the range).
func (c *Config) SlotCount() int {
lo, hi := c.SlotRangeBounds()
return hi - lo + 1
// ParseUserPorts parses a comma-separated list of inclusive port ranges (each
// "<lo>-<hi>") into normalized, merged, whole-hundred-aligned ranges. Rules:
// - Lo/hi may extend outside [UserPortBase, UserPortMax] — only the overlap
// with the usable domain counts (a range fully outside contributes nothing).
// - Each range is aligned inward to whole hundreds: the low end rounds UP to
// a block start, the high end rounds DOWN to a block start then extends to
// +99 (so the effective range always ends in ...99, never ...00). A range
// narrower than one block is dropped.
// - Overlapping/adjacent ranges are merged, so capacity is never double-counted.
// - At least one usable 100-port block must remain, or the value is rejected
// (a range that cannot host even one container is an error).
func ParseUserPorts(s string) ([]PortRange, error) {
var out []PortRange
for _, tok := range strings.Split(s, ",") {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
parts := strings.Split(tok, "-")
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return nil, fmt.Errorf("invalid user-port range %q: must be \"<lo>-<hi>\" (e.g. 10000-29999)", tok)
}
a, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
b, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
if err1 != nil || err2 != nil {
return nil, fmt.Errorf("invalid user-port range %q: both ends must be integers", tok)
}
if a > b {
return nil, fmt.Errorf("invalid user-port range %q: lo (%d) must be <= hi (%d)", tok, a, b)
}
// Clamp to the usable domain; a range with no overlap contributes 0.
if a < UserPortBase {
a = UserPortBase
}
if b > UserPortMax {
b = UserPortMax
}
if a > b {
continue
}
// Align inward: lo up to a block start, hi down to a block start then +99.
lo := ((a + PortsPerUser - 1) / PortsPerUser) * PortsPerUser
hi := (b/PortsPerUser)*PortsPerUser + PortsPerUser - 1
if lo > hi {
continue // narrower than one block
}
out = append(out, PortRange{lo, hi})
}
if len(out) == 0 {
return nil, fmt.Errorf("no usable user-port range %q: must overlap %d-%d with at least one whole 100-port block", s, UserPortBase, UserPortMax)
}
sort.Slice(out, func(i, j int) bool { return out[i].Lo < out[j].Lo })
merged := out[:1]
for _, r := range out[1:] {
last := &merged[len(merged)-1]
if r.Lo <= last.Hi+1 { // overlap or adjacent → merge
if r.Hi > last.Hi {
last.Hi = r.Hi
}
} else {
merged = append(merged, r)
}
}
return merged, nil
}
// SlotIdxBounds returns the inclusive idx (octet-1) bounds derived from the
// configured slot range. idx is the stored per-user index used for
// ContainerIP (ip[3] = idx+1) and the user-port block.
func (c *Config) SlotIdxBounds() (idxMin, idxMax int) {
lo, hi := c.SlotRangeBounds()
return lo - 1, hi - 1
// CanonicalUserPorts renders parsed ranges as their canonical comma-separated
// string form ("10000-20099, 25000-29999"). Returns the raw input unchanged if
// it does not parse.
func CanonicalUserPorts(s string) string {
rs, err := ParseUserPorts(s)
if err != nil {
return strings.TrimSpace(s)
}
parts := make([]string, len(rs))
for i, r := range rs {
parts[i] = fmt.Sprintf("%d-%d", r.Lo, r.Hi)
}
return strings.Join(parts, ", ")
}
// UserPortRanges returns the configured effective user-port ranges, falling
// back to the default full range if the configured value does not parse (a
// validated config never hits this; guard against hand-edits).
func (c *Config) UserPortRanges() []PortRange {
rs, err := ParseUserPorts(c.Net.UserPorts)
if err != nil {
rs, _ = ParseUserPorts(DefaultUserPorts)
}
return rs
}
// UserPortCount returns how many whole-hundred port blocks the configured
// ranges allow (== the container capacity a NEW user may still take; existing
// users outside the ranges keep their blocks and are excluded from the
// free-list window).
func (c *Config) UserPortCount() int {
n := 0
for _, r := range c.UserPortRanges() {
n += (r.Hi - r.Lo + 1) / PortsPerUser
}
return n
}
// UserPortBlockStarts returns every whole-hundred block start inside the
// configured ranges, for the port-block allocator.
func (c *Config) UserPortBlockStarts() []int {
var starts []int
for _, r := range c.UserPortRanges() {
for s := r.Lo; s <= r.Hi; s += PortsPerUser {
starts = append(starts, s)
}
}
return starts
}
func (c *Config) SubnetIP() string {
@@ -326,12 +448,33 @@ func Load() (*Config, error) {
if err := yaml.Unmarshal(b, c); err != nil {
return nil, err
}
// Legacy migration: a pre-user_ports config carries only slot_range. It
// must run here (not in FillAuto) because Default() pre-fills user_ports
// with the full range, making "unset" indistinguishable from "defaulted" —
// detect the key's actual presence in the file instead.
if !hasYAMLKey(b, "user_ports") && c.Net.SlotRange != "" {
if ports, err := LegacySlotRangeToUserPorts(c.Net.SlotRange); err == nil {
c.Net.UserPorts = ports
}
}
if err := c.FillAuto(); err != nil {
return nil, err
}
return c, nil
}
// hasYAMLKey reports whether the config file carries a `key:` line. Used to
// tell "key absent (older config)" from "key present with the default value".
func hasYAMLKey(b []byte, key string) bool {
for _, line := range strings.Split(string(b), "\n") {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, key+":") {
return true
}
}
return false
}
func Save(c *Config) error {
p := Path()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
@@ -411,14 +554,18 @@ func (c *Config) FillAuto() error {
if v := os.Getenv("VPSMGR_TRAEFIK"); v != "" {
c.Net.Traefik = v == "1" || strings.EqualFold(v, "true")
}
// VPSMGR_SLOT_RANGE carries the container slot range chosen at install
// (e.g. 2-201); strictly validated so a bad value fails the install
// loudly instead of silently defaulting.
if v := os.Getenv("VPSMGR_SLOT_RANGE"); v != "" {
if _, _, err := ParseSlotRange(v); err != nil {
return fmt.Errorf("VPSMGR_SLOT_RANGE %q: %w", v, err)
// Legacy migration happens in Load (see hasYAMLKey), NOT here: Default() has
// already pre-filled user_ports with the full range by the time FillAuto
// runs, so "unset" is indistinguishable from "defaulted" at this point.
// VPSMGR_USER_PORTS carries the container user-port ranges chosen at
// install (e.g. "10000-29999" or "10000-20000, 25000-30000"); strictly
// validated so a bad value fails the install loudly instead of silently
// defaulting.
if v := os.Getenv("VPSMGR_USER_PORTS"); v != "" {
if _, err := ParseUserPorts(v); err != nil {
return fmt.Errorf("VPSMGR_USER_PORTS %q: %w", v, err)
}
c.Net.SlotRange = v
c.Net.UserPorts = CanonicalUserPorts(v)
}
return nil
}
+168 -29
View File
@@ -1,6 +1,7 @@
package cfg
import (
"os"
"strings"
"testing"
)
@@ -201,19 +202,17 @@ func TestFillAutoV4ForwardEnv(t *testing.T) {
}
}
func TestSlotRange(t *testing.T) {
// Valid: any sub-range of [2,201] with lo <= hi, including the default.
func TestParseSlotRangeLegacy(t *testing.T) {
// Legacy parser kept only for migrating pre-user_ports configs.
cases := []struct {
in string
lo, hi int
slots, min, max int // min/max = idx bounds (octet-1)
in string
lo, hi int
}{
{"2-201", 2, 201, 200, 1, 200},
{"2-201", 2, 201, 200, 1, 200},
{"6-201", 6, 201, 196, 5, 200},
{"2-100", 2, 100, 99, 1, 99},
{"20-100", 20, 100, 81, 19, 99},
{"201-201", 201, 201, 1, 200, 200},
{"2-201", 2, 201},
{"6-201", 6, 201},
{"2-100", 2, 100},
{"20-100", 20, 100},
{"201-201", 201, 201},
}
for _, c := range cases {
lo, hi, err := ParseSlotRange(c.in)
@@ -224,19 +223,7 @@ func TestSlotRange(t *testing.T) {
if lo != c.lo || hi != c.hi {
t.Errorf("ParseSlotRange(%q) = %d-%d, want %d-%d", c.in, lo, hi, c.lo, c.hi)
}
cfg := Default()
cfg.Net.SlotRange = c.in
if n := cfg.SlotCount(); n != c.slots {
t.Errorf("SlotCount(%q) = %d, want %d", c.in, n, c.slots)
}
iMin, iMax := cfg.SlotIdxBounds()
if iMin != c.min || iMax != c.max {
t.Errorf("SlotIdxBounds(%q) = %d..%d, want %d..%d", c.in, iMin, iMax, c.min, c.max)
}
}
// Invalid: expanding beyond the default edges, lo > hi, malformed, non-int.
// (2-200 and 200-201 ARE valid sub-ranges; expanding past the edges is not.)
bad := []string{
"", " ", "1-201", "2-202", "2-300", "0-200", "3-2", "201-2",
"x-y", "2--201", "2-201-", "-2-201", "2.5-201", "201-200", "2-201 extra",
@@ -249,22 +236,174 @@ func TestSlotRange(t *testing.T) {
}
}
func TestFillAutoSlotRangeEnv(t *testing.T) {
func TestLegacySlotRangeToUserPorts(t *testing.T) {
cases := []struct {
slot string
want string
}{
{"2-201", "10000-29999"}, // full range
{"2-100", "10000-19899"}, // 99 blocks
{"102-201", "20000-29999"},
{"201-201", "29900-29999"}, // single block
{"6-201", "10400-29999"},
}
for _, c := range cases {
got, err := LegacySlotRangeToUserPorts(c.slot)
if err != nil {
t.Errorf("LegacySlotRangeToUserPorts(%q): %v", c.slot, err)
continue
}
if got != c.want {
t.Errorf("LegacySlotRangeToUserPorts(%q) = %q, want %q", c.slot, got, c.want)
}
}
}
func TestParseUserPorts(t *testing.T) {
cases := []struct {
in string
count int
canon string // canonical form; "" = must be rejected
}{
{"10000-29999", 200, "10000-29999"}, // default full range
{"10000-20000", 101, "10000-20099"}, // hi rounds down then +99 (never ends in 00)
{"10001-29998", 199, "10100-29999"}, // inward-aligned both ends
{"10001-10099", 0, ""}, // narrower than one block
{"10000-10099", 1, "10000-10099"}, // exactly one block
{"10000-20000, 25000-30000", 151, "10000-20099, 25000-29999"}, // discontiguous
{"10000-15000, 14000-20000", 101, "10000-20099"}, // overlapping → merged
{"5000-15000", 51, "10000-15099"}, // lo below domain clamps to 10000
{"20000-40000", 100, "20000-29999"}, // hi above domain clamps to 29999
{"10000-10000", 1, "10000-10099"}, // single port still yields its block
{"5000-6000", 0, ""}, // fully outside domain
{"10000-20000, 21000-21099", 102, "10000-20099, 21000-21099"}, // adjacent not merged (gap 100)
}
for _, c := range cases {
rs, err := ParseUserPorts(c.in)
if c.canon == "" {
if err == nil {
t.Errorf("ParseUserPorts(%q): expected error, got %v", c.in, rs)
}
continue
}
if err != nil {
t.Errorf("ParseUserPorts(%q): unexpected error: %v", c.in, err)
continue
}
n := 0
for _, r := range rs {
n += (r.Hi - r.Lo + 1) / 100
}
if n != c.count {
t.Errorf("ParseUserPorts(%q) block count = %d, want %d", c.in, n, c.count)
}
if got := CanonicalUserPorts(c.in); got != c.canon {
t.Errorf("CanonicalUserPorts(%q) = %q, want %q", c.in, got, c.canon)
}
// Every returned range must be whole-hundred aligned.
for _, r := range rs {
if r.Lo%100 != 0 || (r.Hi+1)%100 != 0 {
t.Errorf("ParseUserPorts(%q) range %d-%d not whole-hundred aligned", c.in, r.Lo, r.Hi)
}
if r.Lo < UserPortBase || r.Hi > UserPortMax {
t.Errorf("ParseUserPorts(%q) range %d-%d outside domain", c.in, r.Lo, r.Hi)
}
}
}
bad := []string{
"", " ", "x-y", "10000-20000 extra", "10000", "10000-20000-30000",
"20000-10000", // lo > hi
"10000-x",
}
for _, s := range bad {
if _, err := ParseUserPorts(s); err == nil {
t.Errorf("ParseUserPorts(%q): expected error", s)
}
}
}
func TestUserPortCount(t *testing.T) {
cfg := Default()
if n := cfg.UserPortCount(); n != 200 {
t.Errorf("default UserPortCount() = %d, want 200", n)
}
cfg.Net.UserPorts = "10000-20000, 25000-30000"
if n := cfg.UserPortCount(); n != 151 {
t.Errorf("UserPortCount() = %d, want 151", n)
}
cfg.Net.UserPorts = "garbage"
if n := cfg.UserPortCount(); n != 200 {
t.Errorf("garbage UserPortCount() = %d, want fallback 200", n)
}
}
func TestFillAutoUserPortsEnv(t *testing.T) {
c := Default()
c.Net.ExtIF = "eth0"
c.Panel.PublicIP = "203.0.113.10"
t.Setenv("VPSMGR_SLOT_RANGE", "6-201")
t.Setenv("VPSMGR_USER_PORTS", "10000-20000, 25000-30000")
if err := c.FillAuto(); err != nil {
t.Fatalf("FillAuto: %v", err)
}
if c.Net.SlotRange != "6-201" {
t.Errorf("slot_range = %q, want 6-201", c.Net.SlotRange)
if c.Net.UserPorts != "10000-20099, 25000-29999" {
t.Errorf("user_ports = %q, want canonical 10000-20099, 25000-29999", c.Net.UserPorts)
}
// A bad env value must fail loudly, not silently default.
t.Setenv("VPSMGR_SLOT_RANGE", "2-300")
t.Setenv("VPSMGR_USER_PORTS", "5000-6000")
if err := Default().FillAuto(); err == nil {
t.Error("FillAuto with VPSMGR_SLOT_RANGE=2-300 should fail")
t.Error("FillAuto with VPSMGR_USER_PORTS=5000-6000 should fail (no usable block)")
}
}
func TestLoadLegacySlotRangeMigration(t *testing.T) {
// A config written before net.user_ports carries only slot_range. Loading
// it must convert it to user_ports once (the migration reads the raw file
// because Default() pre-fills user_ports with the full range).
write := func(body string) string {
dir := t.TempDir()
p := dir + "/config.yaml"
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("VPSMGR_CONFIG", p)
return p
}
write(`panel:
listen: ":8443"
net:
subnet: "10.115.0.0/24"
slot_range: "6-201"
v4_forward: true
`)
// ExtIF/public_ip detection must be overridable so FillAuto doesn't touch
// the network. Set env for a stable public IP and eth0.
t.Setenv("VPSMGR_V4_FORWARD", "1")
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Net.UserPorts != "10400-29999" {
t.Errorf("legacy slot_range migration = %q, want 10400-29999", c.Net.UserPorts)
}
// A modern config with user_ports must NOT be touched by a legacy key.
write(`panel:
listen: ":8443"
net:
subnet: "10.115.0.0/24"
user_ports: "10000-20000, 25000-30000"
slot_range: "6-201"
v4_forward: true
`)
c2, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c2.Net.UserPorts != "10000-20000, 25000-30000" {
t.Errorf("user_ports should win over slot_range; got %q", c2.Net.UserPorts)
}
}
+10 -6
View File
@@ -248,16 +248,20 @@ var Fields = []Field{
c.Net.V4Forward = b
return nil
}},
{"net.slot_range", KindOperator, ApplyNextAdd,
"container slot range (== IPv4 last octet) a new user may take, e.g. 2-201 (up to 200 containers); shrinkable to any sub-range of 2-201; affects ONLY new users, never renumbers existing containers",
"2-201 (e.g. 6-201 to free ports on this host)",
getStr(func(c *Config) string { return c.Net.SlotRange }),
{"net.user_ports", KindOperator, ApplyNextAdd,
"user-port ranges a NEW container's 100-port block is drawn from; comma-separated inclusive ranges (e.g. 10000-29999, or 10000-20000, 25000-30000 for discontiguous spans); auto-aligned to whole hundreds; affects ONLY new users, never changes existing containers' ports",
"10000-29999 (e.g. 10000-20000, 25000-30000)",
getStr(func(c *Config) string { return c.Net.UserPorts }),
func(c *Config, v string) error {
lo, hi, err := ParseSlotRange(v)
rs, err := ParseUserPorts(v)
if err != nil {
return err
}
c.Net.SlotRange = fmt.Sprintf("%d-%d", lo, hi)
parts := make([]string, len(rs))
for i, r := range rs {
parts[i] = fmt.Sprintf("%d-%d", r.Lo, r.Hi)
}
c.Net.UserPorts = strings.Join(parts, ", ")
return nil
}},
{"net.traefik", KindRuntime, ApplyImmediate,
+9 -1
View File
@@ -55,7 +55,7 @@ func (d *DB) Close() error { return d.sql.Close() }
// schemaVersion is the current schema version. Every migration in
// migrations must be applied in order; Open refuses to start on a database
// whose version is newer than this binary understands (downgrade protection).
const schemaVersion = 12
const schemaVersion = 13
// migrations are applied in order, each inside its own transaction. v1 is the
// original schema (baseline); later versions only add/alter, never drop.
@@ -235,6 +235,14 @@ var migrations = []struct {
{12, []string{
`ALTER TABLE users ADD COLUMN color TEXT NOT NULL DEFAULT ''`,
}},
// v13: port blocks are no longer derived from idx (net.user_ports
// decoupled them), so start_port uniqueness must be enforced directly — a
// cross-process race (CLI + panel daemon) could otherwise hand the same
// block to two users. Existing rows are already unique (they came from the
// old idx derivation), so the index applies cleanly.
{13, []string{
`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_start_port ON users(start_port)`,
}},
}
// sqliteStr quotes a string literal for SQLite by doubling any single quote.
+28 -7
View File
@@ -100,12 +100,11 @@ func (d *DB) CreateUserFull(name, passHash, ip string, idx, sshPort, startPort,
}
// NextFreeIdx returns a random unused index in [idxMin, idxMax] (currently the
// configured slot range, idx = v4-last-octet - 1), so a new user gets a random
// slot (and therefore IP + port block) instead of always the smallest free
// one. Cross-process races on the pick are caught by the users.idx UNIQUE
// constraint; within one process Add is serialized by opMu. Existing users
// whose idx falls outside the current range (from an earlier, wider setting)
// keep their slots — they are simply excluded from the free-list window here.
// full 1..cfg.MaxUsers IPv4 slot range, idx = v4-last-octet - 1), so a new
// user gets a random slot (and therefore IP) instead of always the smallest
// free one. Cross-process races on the pick are caught by the users.idx UNIQUE
// constraint; within one process Add is serialized by opMu. The user-port
// block is allocated separately (net.user_ports) via UsedStartPorts.
func (d *DB) NextFreeIdx(idxMin, idxMax int) (int, error) {
rows, err := d.sql.Query(`SELECT idx FROM users`)
if err != nil {
@@ -130,7 +129,7 @@ func (d *DB) NextFreeIdx(idxMin, idxMax int) (int, error) {
}
}
if len(free) == 0 {
return 0, fmt.Errorf("slot range reach (%d..%d)", idxMin, idxMax)
return 0, fmt.Errorf("IPv4 slot pool exhausted (%d..%d): every address in the container subnet is assigned", idxMin, idxMax)
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(free))))
if err != nil {
@@ -158,6 +157,28 @@ func (d *DB) UsedSSHPorts() (map[int]bool, error) {
return used, rows.Err()
}
// UsedStartPorts returns the set of start_port (user-port block start) values
// already assigned, so the manager can pick a free block inside the configured
// net.user_ports ranges. start_port is NOT derived from idx anymore (ports are
// allocated independently from the configured ranges), so this set is the
// source of truth for "which blocks are taken".
func (d *DB) UsedStartPorts() (map[int]bool, error) {
rows, err := d.sql.Query(`SELECT start_port FROM users`)
if err != nil {
return nil, err
}
defer rows.Close()
used := map[int]bool{}
for rows.Next() {
var p int
if err := rows.Scan(&p); err != nil {
return nil, err
}
used[p] = true
}
return used, rows.Err()
}
func scanUser(row *sql.Row) (*User, error) {
u := &User{}
var ipv6 sql.NullString
+43 -1
View File
@@ -102,7 +102,7 @@ func TestNextFreeIdxExhausted(t *testing.T) {
defer d.Close()
for i := 1; i <= cfg.MaxUsers; i++ {
if _, err := d.CreateUser(fmt.Sprintf("u%d", i), "h", "10.115.0.2", i, 30000+i, 10000, 1, 1024, 10); err != nil {
if _, err := d.CreateUser(fmt.Sprintf("u%d", i), "h", "10.115.0.2", i, 30000+i, 10000+(i-1)*100, 1, 1024, 10); err != nil {
t.Fatal(err)
}
}
@@ -110,3 +110,45 @@ func TestNextFreeIdxExhausted(t *testing.T) {
t.Fatal("NextFreeIdx on a full pool: expected error, got nil")
}
}
// TestUsedStartPorts verifies UsedStartPorts returns exactly the start_port
// values already assigned — the source of truth for the port-block allocator
// (which is now independent of idx).
func TestUsedStartPorts(t *testing.T) {
d, err := Open(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer d.Close()
if _, err := d.CreateUser("alice", "h", "10.115.0.2", 1, 30001, 10000, 1, 1024, 10); err != nil {
t.Fatal(err)
}
if _, err := d.CreateUser("bob", "h", "10.115.0.3", 2, 30002, 15000, 1, 1024, 10); err != nil {
t.Fatal(err)
}
used, err := d.UsedStartPorts()
if err != nil {
t.Fatal(err)
}
if len(used) != 2 || !used[10000] || !used[15000] {
t.Errorf("UsedStartPorts = %v, want {10000,15000}", used)
}
}
// TestStartPortUnique verifies the v13 UNIQUE index on start_port refuses a
// duplicate block even when idx differs (the port-block allocator's backstop).
func TestStartPortUnique(t *testing.T) {
d, err := Open(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer d.Close()
if _, err := d.CreateUser("alice", "h", "10.115.0.2", 1, 30001, 10000, 1, 1024, 10); err != nil {
t.Fatal(err)
}
if _, err := d.CreateUser("bob", "h", "10.115.0.3", 2, 30002, 10000, 1, 1024, 10); err == nil {
t.Fatal("duplicate start_port with a different idx: expected UNIQUE violation, got nil")
}
}
+65 -13
View File
@@ -42,8 +42,8 @@ type Manager struct {
// opMu serializes Add/Del/Reinstall within this process (the panels share
// one Manager), so two simultaneous creates cannot race for the same
// index/IP. Cross-process CLI races are still caught by the users.idx
// UNIQUE constraint plus Add's rollback.
// index/IP. Cross-process CLI races are still caught by the users.idx and
// users.start_port UNIQUE constraints plus Add's rollback.
opMu sync.Mutex
// throttled tracks which containers currently carry a bandwidth throttle
@@ -141,10 +141,12 @@ func ContainerIP(subnet string, idx int) (string, error) {
return ip.String(), nil
}
// allocSSHPort picks a random free port from the SSH range. It tries random
// picks first, then falls back to the lowest free one; the ssh_port UNIQUE
// constraint in the DB is the backstop against a cross-process race (the
// in-process opMu already serializes adds).
// allocSSHPort picks a random free port from the SSH range. Two occupancy
// sets are checked: the DB (ports already handed to other users) and the host
// itself (a live listener on the port — our DNAT would otherwise silently
// hijack its traffic). It tries random picks first, then falls back to the
// lowest free one; the ssh_port UNIQUE constraint in the DB is the backstop
// against a cross-process race (the in-process opMu already serializes adds).
func (m *Manager) allocSSHPort() (int, error) {
used, err := m.db.UsedSSHPorts()
if err != nil {
@@ -153,24 +155,68 @@ func (m *Manager) allocSSHPort() (int, error) {
if len(used) >= cfg.SSHPortCount {
return 0, errors.New("no free ssh port (pool exhausted)")
}
for i := 0; i < 32; i++ {
avail := func(p int) bool { return !used[p] && sshPortFree(p) }
for i := 0; i < 64; i++ {
n, err := rand.Int(rand.Reader, big.NewInt(cfg.SSHPortCount))
if err != nil {
break
}
p := cfg.SSHPortBase + int(n.Int64())
if !used[p] {
if avail(p) {
return p, nil
}
}
for p := cfg.SSHPortBase; p < cfg.SSHPortBase+cfg.SSHPortCount; p++ {
if !used[p] {
if avail(p) {
return p, nil
}
}
return 0, errors.New("no free ssh port")
}
// sshPortFree probes whether a port can be bound on all interfaces. A bind
// failure means a live process already listens there — skipping it keeps the
// container's DNAT from silently shadowing another service. The probe bind is
// released immediately and is never part of the ruleset.
func sshPortFree(p int) bool {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", p))
if err != nil {
return false
}
ln.Close()
return true
}
// allocUserPortBlock picks a random free whole-hundred block start from the
// configured user-port ranges (net.user_ports), excluding blocks already held
// by existing users. Existing users outside the current ranges keep their
// blocks — they are simply not in the candidate set here. The start_port
// UNIQUE constraint is the backstop against a cross-process race.
func (m *Manager) allocUserPortBlock() (int, error) {
starts := m.cfg.UserPortBlockStarts()
if len(starts) == 0 {
return 0, errors.New("no user-port blocks configured (net.user_ports)")
}
used, err := m.db.UsedStartPorts()
if err != nil {
return 0, err
}
free := make([]int, 0, len(starts))
for _, s := range starts {
if !used[s] {
free = append(free, s)
}
}
if len(free) == 0 {
return 0, errors.New("user-port blocks exhausted (all blocks in net.user_ports are taken)")
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(free))))
if err != nil {
return 0, err
}
return free[n.Int64()], nil
}
// PoolUsage returns the used ratio (0..1) of the storage pool as reported by
// Incus. The error is returned to the caller instead of being swallowed: a
// capacity check that cannot read the pool must fail closed (refuse to create),
@@ -309,8 +355,10 @@ func (m *Manager) Add(name string, opt AddOptions) (*Result, error) {
if err != nil {
return nil, err
}
idMin, idMax := m.cfg.SlotIdxBounds()
idx, err := m.db.NextFreeIdx(idMin, idMax)
// IPv4 slot: a random unused idx in 1..MaxUsers (the /24 host range). The
// port block is allocated independently from the configured user-port
// ranges (allocUserPortBlock) — the two are no longer coupled.
idx, err := m.db.NextFreeIdx(1, cfg.MaxUsers)
if err != nil {
return nil, err
}
@@ -333,7 +381,10 @@ func (m *Manager) Add(name string, opt AddOptions) (*Result, error) {
if err != nil {
return nil, err
}
startPort := cfg.UserPortBase + (idx-1)*cfg.PortsPerUser
startPort, err := m.allocUserPortBlock()
if err != nil {
return nil, err
}
// IPv6 assignment depends on the mode:
// - prefix: deterministic /112-derived address (existing behavior)
// - pool: explicit choice, first free pool address, or "" when the
@@ -600,7 +651,8 @@ func (m *Manager) Del(name string) error {
}
// If the container cannot actually be removed, keep the DB record and let
// the admin retry. Deleting the row anyway would orphan the container and
// let NextFreeIdx reuse its IP/ports for a new user — a bridge IP conflict.
// let NextFreeIdx reuse its IP (and a new block its ports) for a new user
// — a bridge IP conflict.
if err := m.lx.Delete(name); err != nil {
return fmt.Errorf("delete container: %w", err)
}
+101 -1
View File
@@ -1,6 +1,13 @@
package mgr
import "testing"
import (
"net"
"path/filepath"
"testing"
"vpsmgr/internal/cfg"
"vpsmgr/internal/db"
)
func TestUserPorts(t *testing.T) {
cases := []struct {
@@ -70,3 +77,96 @@ func TestContainerIP(t *testing.T) {
}
}
}
// TestAllocUserPortBlock verifies the port-block allocator draws only from the
// configured ranges and never hands out an already-taken block. It is
// independent of the IPv4 idx (the two are decoupled now).
func TestAllocUserPortBlock(t *testing.T) {
c := cfg.Default()
c.Net.UserPorts = "10000-20099, 25000-29999" // 101 + 50 = 151 blocks
d, err := db.Open(filepath.Join(t.TempDir(), "u.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
m := &Manager{cfg: c, db: d}
// Occupy one block in each range, then ensure picks stay inside ranges and
// never collide with the used ones.
if _, err := d.CreateUser("a", "h", "10.115.0.2", 1, 30001, 10000, 1, 1024, 10); err != nil {
t.Fatal(err)
}
if _, err := d.CreateUser("b", "h", "10.115.0.3", 2, 30002, 25000, 1, 1024, 10); err != nil {
t.Fatal(err)
}
used, err := d.UsedStartPorts()
if err != nil {
t.Fatal(err)
}
seen := map[int]bool{}
for i := 0; i < 40; i++ {
p, err := m.allocUserPortBlock()
if err != nil {
t.Fatal(err)
}
if p%100 != 0 {
t.Fatalf("allocUserPortBlock = %d, not whole-hundred", p)
}
if used[p] {
t.Fatalf("allocUserPortBlock = %d, already used", p)
}
inRange := (p >= 10000 && p <= 20099) || (p >= 25000 && p <= 29999)
if !inRange {
t.Fatalf("allocUserPortBlock = %d, outside configured ranges", p)
}
seen[p] = true
}
if len(seen) < 5 {
t.Fatalf("allocUserPortBlock not random: only %d distinct picks", len(seen))
}
// A fully packed range must report exhaustion.
small := cfg.Default()
small.Net.UserPorts = "10000-10099" // exactly one block
m2 := &Manager{cfg: small, db: d}
if _, err := m2.allocUserPortBlock(); err == nil {
t.Fatal("allocUserPortBlock on a full single-block range: expected error")
}
}
// TestAllocSSHPortSkipsHostListener verifies allocSSHPort refuses a candidate
// that is already listened on by another process (the "probe, else reassign"
// behavior), even when the DB says the port is free.
func TestAllocSSHPortSkipsHostListener(t *testing.T) {
c := cfg.Default()
d, err := db.Open(filepath.Join(t.TempDir(), "s.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
m := &Manager{cfg: c, db: d}
// Occupy one port in the SSH range with a real listener. The test must
// pick a port that stays free afterwards (no DB record references it).
ln, err := net.Listen("tcp", ":0")
if err != nil {
t.Fatal(err)
}
taken := ln.Addr().(*net.TCPAddr).Port
t.Cleanup(func() { ln.Close() })
// The allocator must never hand out the occupied port, and must find a
// free one.
for i := 0; i < 25; i++ {
p, err := m.allocSSHPort()
if err != nil {
t.Fatal(err)
}
if p == taken {
t.Fatalf("allocSSHPort returned the occupied port %d", taken)
}
if p < cfg.SSHPortBase || p >= cfg.SSHPortBase+cfg.SSHPortCount {
t.Fatalf("allocSSHPort = %d, outside SSH range", p)
}
}
}