From 69f79187c8abff6eeaae281d0261f0943b46988f Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:32:13 +0000 Subject: [PATCH] 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. --- configs/config.yaml.example | 20 +-- docs/architecture.md | 19 ++- docs/configuration.md | 67 +++++---- scripts/00-check.sh | 52 ++++--- scripts/00-ip-ask.sh | 249 +++++++++++++++++++++++--------- src/internal/admin/handlers.go | 10 +- src/internal/cfg/cfg.go | 253 ++++++++++++++++++++++++++------- src/internal/cfg/cfg_test.go | 197 +++++++++++++++++++++---- src/internal/cfg/registry.go | 16 ++- src/internal/db/db.go | 10 +- src/internal/db/user.go | 35 ++++- src/internal/db/user_test.go | 44 +++++- src/internal/mgr/mgr.go | 78 ++++++++-- src/internal/mgr/ports_test.go | 102 ++++++++++++- 14 files changed, 905 insertions(+), 247 deletions(-) diff --git a/configs/config.yaml.example b/configs/config.yaml.example index 42a111c..c5e699a 100644 --- a/configs/config.yaml.example +++ b/configs/config.yaml.example @@ -19,18 +19,18 @@ net: # Container subnet 10..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 diff --git a/docs/architecture.md b/docs/architecture.md index 4627050..8f43622 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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..0.(i+1)` (subnet `10..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 diff --git a/docs/configuration.md b/docs/configuration.md index fb5b231..bdb5bca 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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..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 `. -- **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 + `. +- **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**. diff --git a/scripts/00-check.sh b/scripts/00-check.sh index 3f49069..daefeac 100755 --- a/scripts/00-check.sh +++ b/scripts/00-check.sh @@ -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="" diff --git a/scripts/00-ip-ask.sh b/scripts/00-ip-ask.sh index 5c1eca2..8b62981 100644 --- a/scripts/00-ip-ask.sh +++ b/scripts/00-ip-ask.sh @@ -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..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..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 diff --git a/src/internal/admin/handlers.go b/src/internal/admin/handlers.go index ef60453..89b8a28 100644 --- a/src/internal/admin/handlers.go +++ b/src/internal/admin/handlers.go @@ -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) diff --git a/src/internal/cfg/cfg.go b/src/internal/cfg/cfg.go index 8b858a9..2978c7b 100644 --- a/src/internal/cfg/cfg.go +++ b/src/internal/cfg/cfg.go @@ -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 "-"): 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 - // ("-", 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 "-" 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 "-" 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 +// "-") 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 \"-\" (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 } diff --git a/src/internal/cfg/cfg_test.go b/src/internal/cfg/cfg_test.go index 6391748..bf370f4 100644 --- a/src/internal/cfg/cfg_test.go +++ b/src/internal/cfg/cfg_test.go @@ -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) } } diff --git a/src/internal/cfg/registry.go b/src/internal/cfg/registry.go index 0942021..540d56a 100644 --- a/src/internal/cfg/registry.go +++ b/src/internal/cfg/registry.go @@ -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, diff --git a/src/internal/db/db.go b/src/internal/db/db.go index 7bced4f..b9530b4 100644 --- a/src/internal/db/db.go +++ b/src/internal/db/db.go @@ -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. diff --git a/src/internal/db/user.go b/src/internal/db/user.go index 66525ef..34fb9c0 100644 --- a/src/internal/db/user.go +++ b/src/internal/db/user.go @@ -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 diff --git a/src/internal/db/user_test.go b/src/internal/db/user_test.go index 714e006..265df67 100644 --- a/src/internal/db/user_test.go +++ b/src/internal/db/user_test.go @@ -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") + } +} diff --git a/src/internal/mgr/mgr.go b/src/internal/mgr/mgr.go index 4243e4e..671da44 100644 --- a/src/internal/mgr/mgr.go +++ b/src/internal/mgr/mgr.go @@ -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) } diff --git a/src/internal/mgr/ports_test.go b/src/internal/mgr/ports_test.go index 4c01261..5b7d159 100644 --- a/src/internal/mgr/ports_test.go +++ b/src/internal/mgr/ports_test.go @@ -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) + } + } +}