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:
1 parent
3fdb946baa
commit
69f79187c8
14 files changed
+905
-247
No files matched your search
+10
-10
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user