Files
vpsmgr/scripts/00-ip-ask.sh
T
bluebluesodaandCommandCodeBot 02e590c974 fix(install): make oneclick.sh work via bash <(curl ...)
When piped through /dev/fd the script's $0 is not a filesystem path, so
sourcing scripts/lib-download.sh failed ("... /dev/fd/scripts/lib-download.sh:
No such file or directory"). Inline dl_content() instead so the gitless
bootstrap can still retry the repo tarball download.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-24 13:21:26 +00:00

619 lines
25 KiB
Bash

#!/usr/bin/env bash
# 00-ip-ask.sh — install-time network asks: whether to enable IPv6
# 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
# global address if it has one); VPSMGR_IPV6_SUBNET env var used verbatim
# (validated); a previous ipv6_subnet in the config is kept on adoption;
# disabled on non-interactive installs without the env var.
# - 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 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] $*"; }
# NOTE: this script is `source`d by install.sh, so we must use `return` (not
# `exit`) at top level — `exit` would terminate the parent installer.
die(){ echo "[net] error: $*" >&2; return 1; }
# --- dependency: python3 (prefix/octet/overlap validation below). This script
# runs before 00-check.sh, so the check must live here. Ubuntu 24.04/26.04
# ship python3 by default, but install it on a minimal image if missing.
if ! command -v python3 >/dev/null 2>&1; then
log "python3 not found, installing..."
if apt-get update -qq 2>/dev/null \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 >/dev/null 2>&1 \
&& command -v python3 >/dev/null 2>&1; then
:
else
die "python3 required (apt install python3 failed)"
return 1
fi
fi
# --- IPv6 pass-through -------------------------------------------------------
#
# The installer offers THREE IPv6 outcomes, decided by probing the host with
# the (unchanged) check-ipv6-support.sh script:
#
# 1. prefix — the provider routes a whole prefix (verified from outside):
# containers get deterministic /112 blocks (classic mode).
# 2. pool — no routable whole prefix, but the host has multiple global
# addresses on its NIC (the "discrete whitelist" providers):
# containers each get one address from the pool the user
# confirms (the host keeps the first address for itself).
# 3. none — pure IPv4 (user cancels, or no global IPv6 at all).
#
# The chosen mode is exported as VPSMGR_IPV6_MODE (none|prefix|pool) and the
# pool (if any) as VPSMGR_IPV6_POOL; the config's FillAuto reads both.
# validate_prefix: exit 0 if arg is a global IPv6 CIDR (/80 or shorter) with
# an explicit prefix length.
validate_prefix(){
python3 - "$1" <<'PY'
import ipaddress, sys
p = sys.argv[1]
if "/" not in p:
sys.exit(1)
try:
n = ipaddress.IPv6Network(p, strict=False)
except Exception:
sys.exit(1)
if n.prefixlen > 80:
sys.exit(1)
a = n.network_address
if a.is_private or a.is_link_local or a.is_loopback or a.is_unspecified:
sys.exit(1)
PY
}
# run_detector — run the (unchanged) check-ipv6-support.sh and capture its
# verdict. Prints "prefix" when the whole prefix was verified reachable from
# outside, "noverify" when no external verification was possible (offline /
# rate-limited), "unverified" when the random test address got no reply (the
# prefix is NOT routed as a whole). The script's own output is streamed to the
# user so the installer keeps the same look; only the verdict line is parsed.
run_detector(){
local det
det="${ROOT:-$(dirname "$(readlink -f "$0")")}/check-ipv6-support.sh"
if [[ ! -x "$det" ]]; then
log "check-ipv6-support.sh not found at $det — skipping automatic detection"
echo "unverified"
return 0
fi
local out
out=$("$det" 2>&1 || true)
printf '%s\n' "$out" >&2
if grep -q "Pass-through:[[:space:]]*VERIFIED" <<< "$out"; then
echo "prefix"
elif grep -q "UNKNOWN (run again" <<< "$out"; then
echo "noverify"
else
echo "unverified"
fi
}
# ask_ipv6 — the mode-selection interview. Sets VPSMGR_IPV6_MODE and (for
# pool) VPSMGR_IPV6_POOL; leaves them empty for pure IPv4.
ask_ipv6(){
# --- env override: VPSMGR_IPV6_MODE=none|prefix|pool (with pool list) ---
if [[ -n "${VPSMGR_IPV6_MODE:-}" ]]; then
case "$VPSMGR_IPV6_MODE" in
none) log "IPv6 mode: none (from env)"; export VPSMGR_IPV6_MODE=none; return 0 ;;
prefix)
if [[ -z "${VPSMGR_IPV6_SUBNET:-}" ]]; then
die "VPSMGR_IPV6_MODE=prefix requires VPSMGR_IPV6_SUBNET (e.g. 2602:fada:6::/64)"
return 1
fi
log "IPv6 mode: prefix $VPSMGR_IPV6_SUBNET (from env)"; return 0 ;;
pool)
# Pool mode starts EMPTY by design (addresses are added later in the
# admin panel); an env-provided pool list is optional.
log "IPv6 mode: pool (from env)"
export VPSMGR_IPV6_MODE=pool
return 0 ;;
*) die "VPSMGR_IPV6_MODE must be none|prefix|pool (got '$VPSMGR_IPV6_MODE')"; return 1 ;;
esac
fi
# --- reinstall adoption: keep the previous mode ---
if [[ -f /etc/vpsmgr/config.yaml ]]; then
local EXISTING
EXISTING=$(grep -E '^\s+ipv6_mode:' /etc/vpsmgr/config.yaml 2>/dev/null | awk -F': ' '{print $2}' | tr -d '"')
if [[ -n "$EXISTING" ]]; then
log "existing config has ipv6_mode=$EXISTING — keeping it"
export VPSMGR_IPV6_MODE="$EXISTING"
if [[ "$EXISTING" == "pool" ]]; then
local POOL
POOL=$(grep -A200 '^\s+ipv6_pool:' /etc/vpsmgr/config.yaml 2>/dev/null | sed -n 's/^\s*-\s*//p' | tr '\n' ',' | sed 's/,$//')
[[ -n "$POOL" ]] && export VPSMGR_IPV6_POOL="$POOL"
fi
return 0
fi
# Older config without ipv6_mode: fall back to ipv6_subnet presence.
if grep -Eq '^\s+ipv6_subnet:' /etc/vpsmgr/config.yaml 2>/dev/null; then
local OLDSUB
OLDSUB=$(grep -E '^\s+ipv6_subnet:' /etc/vpsmgr/config.yaml 2>/dev/null | awk -F': ' '{print $2}' | tr -d '"')
if [[ -n "$OLDSUB" ]]; then
log "existing config has ipv6_subnet=$OLDSUB — keeping prefix mode"
export VPSMGR_IPV6_MODE=prefix VPSMGR_IPV6_SUBNET="$OLDSUB"
return 0
fi
fi
fi
# --- non-interactive with no env var: IPv6 stays disabled ---
if [[ ! -t 0 ]] && [[ -z "${FORCE_ASK:-}" ]]; then
log "non-interactive install, no VPSMGR_IPV6_MODE set — IPv6 disabled"
export VPSMGR_IPV6_MODE=none
return 0
fi
echo
echo "============================================================"
echo " IPv6 pass-through — BETA / 实验性功能"
echo "------------------------------------------------------------"
echo " Each container gets its own public IPv6 address (no NAT)."
echo " Requires either a routable prefix OR multiple global"
echo " addresses on this host's NIC."
echo " 每台小鸡将获得独立的公网 IPv6 地址(无 NAT)。"
echo " 需要可路由前缀,或本机网卡上的多个公网地址。"
echo " Default: DISABLED. Only enable if you understand the risks."
echo " 默认不启用,请确认理解后再开启。"
echo "============================================================"
echo
read -r -p "Enable IPv6 pass-through? 启用 IPv6 直通? [y/N] " ans
case "${ans,,}" in
y|yes) ;;
*) log "IPv6 pass-through disabled / 未启用"; export VPSMGR_IPV6_MODE=none; return 0 ;;
esac
# Run the detector (unchanged script) to learn the host's capability. The
# external reachability probe is powered by Globalping.
echo
echo "自动测试已开始,如果您不够了解IPv6,请在接下来的选项中一路默认"
echo "Auto test running, recommened you KEEP ALL Default Settings later on."
echo " (The Test Powered by Globalping.io )"
log "running check-ipv6-support.sh to probe this host..."
DET=$(run_detector)
log "detector verdict: $DET"
if [[ "$DET" == "prefix" ]]; then
# Whole prefix verified routed -> classic prefix mode. Default candidate
# from the host's own global address, same as before.
ask_prefix_mode
return 0
fi
# The whole prefix was NOT verified from outside. Two ways forward:
# - pool mode: the provider hands out discrete /128 addresses (a
# whitelist); the user adds them later in the admin UI (the NIC only
# shows the one address the provider assigned at boot, so there is
# nothing to auto-collect at install).
# - manual prefix: the user knows their provider routes a prefix and
# types it in; we trust their choice (the external probe may have
# failed for transient reasons, or the user has a routed subnet).
echo
echo "The provider's whole prefix was NOT verified as routable from"
echo "outside (this is typical for whitelist-style IPv6 providers)."
echo " [p] Pool mode — each container gets one address you add later"
echo " in the admin panel's IPv6 pool page"
echo " [m] Manual — you type the prefix you know is routed"
echo " [n] none — disable IPv6, proceed with IPv4 only"
read -r -p "Choose IPv6 mode? 选择 IPv6 模式? [P/m/n] " ans3
case "${ans3,,}" in
p|pool|"")
export VPSMGR_IPV6_MODE=pool VPSMGR_IPV6_POOL=""
log "IPv6 mode: pool (empty — add addresses later in the admin panel)"
return 0
;;
m|manual)
ask_prefix_mode
return 0
;;
*)
log "IPv6 disabled / 未启用 — proceeding with pure IPv4"
export VPSMGR_IPV6_MODE=none
return 0
;;
esac
}
# ask_prefix_mode — classic prefix mode: propose the host's own global
# address prefix (or let the user type one) and validate it. On success sets
# VPSMGR_IPV6_MODE=prefix + VPSMGR_IPV6_SUBNET.
ask_prefix_mode(){
local EXT_IF GLOBAL CAND
# ext iface: IPv4 default route first, then IPv6 default route (covers
# IPv6-only hosts / policy-routed boxes), then the first non-virtual UP link.
EXT_IF=$(ip route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')
if [[ -z "$EXT_IF" ]]; then
EXT_IF=$(ip -6 route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')
fi
if [[ -z "$EXT_IF" ]]; then
EXT_IF=$(ip -o link show up 2>/dev/null | grep -v -E 'lo|incusbr|virbr|docker|veth|warp|wg' | awk -F': ' '{print $2; exit}')
fi
GLOBAL=""
if [[ -n "$EXT_IF" ]]; then
GLOBAL=$(ip -6 -o addr show dev "$EXT_IF" scope global 2>/dev/null | awk '{print $4; exit}')
fi
CAND=""
if [[ -n "$GLOBAL" ]]; then
local GADDR GLEN
GADDR="${GLOBAL%%/*}"
GLEN="${GLOBAL##*/}"
GLEN="${GLEN:-64}"
CAND=$(python3 -c 'import ipaddress,sys
a=ipaddress.IPv6Address(sys.argv[1])
plen=int(sys.argv[2])
n=ipaddress.IPv6Network((int(a), plen), strict=False)
print(n.network_address)' "$GADDR" "$GLEN")
CAND="$CAND/$GLEN"
fi
local PREFIX
if [[ -n "$CAND" ]]; then
log "detected host global address: $GLOBAL"
read -r -p "Global prefix for containers — include the length (e.g. /64, /80) [default: $CAND]: " PREFIX
PREFIX="${PREFIX:-$CAND}"
else
read -r -p "Global prefix for containers — include the length (e.g. 2001:db8::/64; up to /80): " PREFIX
fi
PREFIX="${PREFIX%$'\r'}"
# Normalize to the canonical CIDR form (the length is mandatory — a bare
# address is rejected, never silently assumed to be /64).
local PREFIX_NORM
PREFIX_NORM=$(python3 - "$PREFIX" <<'PY'
import ipaddress, sys
p = sys.argv[1]
if "/" not in p:
sys.exit(1)
try:
print(ipaddress.IPv6Network(p, strict=False))
except Exception:
sys.exit(1)
PY
)
if validate_prefix "$PREFIX" && [[ -n "$PREFIX_NORM" ]]; then
export VPSMGR_IPV6_MODE=prefix VPSMGR_IPV6_SUBNET="$PREFIX_NORM"
log "IPv6 mode: prefix $PREFIX_NORM"
else
die "invalid prefix '$PREFIX' — must be a global IPv6 CIDR with an explicit length (e.g. 2602:fada:6::/64, or a /80 like 2406:da14:1dd2:a807:753a::/80)"
return 1
fi
return 0
}
# --- container subnet --------------------------------------------------------
# validate_octet: exit 0 if arg is an integer 1..254.
validate_octet(){
python3 - "$1" <<'PY'
import sys
o = sys.argv[1]
if not o.isdigit() or not (1 <= int(o) <= 254):
sys.exit(1)
PY
}
# 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
# to a block end <= b (never ends in ...00, never exceeds user's b); 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 + 1) // 100) * 100 - 1
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_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 ))
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
}
# overlaps_existing: exit 0 when 10.<octet>.0.0/24 does NOT overlap any existing
# IPv4 network on the host (routes or interface addresses). On overlap it prints
# the conflicting networks and exits 1.
overlaps_existing(){
python3 - "$1" <<'PY'
import ipaddress, subprocess, sys
net = ipaddress.ip_network("10.%s.0.0/24" % sys.argv[1], strict=False)
out = []
for cmd in (["ip", "-4", "route", "show"], ["ip", "-4", "-o", "addr", "show"]):
try:
r = subprocess.run(cmd, capture_output=True, text=True).stdout
except Exception:
continue
for line in r.splitlines():
for tok in line.split():
if "/" in tok:
try:
n = ipaddress.ip_network(tok, strict=False)
except Exception:
continue
if n.version == 4 and net.overlaps(n):
out.append(str(n))
seen = sorted(set(out))
if seen:
print(", ".join(seen))
sys.exit(1)
PY
}
# --- IPv4 inbound forwarding policy ------------------------------------------
# Always ON by default, never asked. A VPSMGR_V4_FORWARD env var (e.g. for a
# scripted IPv6-only box) is honored; on adoption the recorded config value is
# re-exported so 30-haproxy.sh keeps the existing policy. A fresh install
# leaves it unset → the config default (enabled) applies everywhere.
if [[ -n "${VPSMGR_V4_FORWARD:-}" ]]; then
case "$VPSMGR_V4_FORWARD" in
1|0|true|false|web-only) ;;
*) die "VPSMGR_V4_FORWARD must be 1/0/true/false/web-only (got '$VPSMGR_V4_FORWARD')"; return 1 ;;
esac
elif [[ -f /etc/vpsmgr/config.yaml ]]; then
V4_FWD=$(grep -E '^\s+v4_forward:' /etc/vpsmgr/config.yaml 2>/dev/null | awk -F': ' '{print $2}' | tr -d '"')
if [[ -n "$V4_FWD" ]]; then
export VPSMGR_V4_FORWARD="$V4_FWD"
log "existing config has v4_forward=$V4_FWD — keeping it"
fi
fi
# --- ask: container IPv4 subnet octet ----------------------------------------
ask_subnet(){
# If the env var is already set, just validate and use it.
if [[ -n "${VPSMGR_IPV4_SUBNET:-}" ]]; then
SUB="$VPSMGR_IPV4_SUBNET"
if [[ "$SUB" =~ ^10\.([0-9]+)\.0\.0/24$ ]] && validate_octet "${BASH_REMATCH[1]}"; then
log "container subnet: $SUB (from env)"
export VPSMGR_IPV4_SUBNET="$SUB"
return 0
fi
die "VPSMGR_IPV4_SUBNET='$SUB' must be 10.<n>.0.0/24 with n in 1..254 (e.g. 10.115.0.0/24)"
return 1
fi
# Reinstall after a non-purging uninstall: an existing config already holds
# the subnet — keep it instead of re-asking (a different answer would set the
# bridge to a prefix the config doesn't know and break container networking).
if [[ -f /etc/vpsmgr/config.yaml ]]; then
EXISTING=$(grep -E '^\s+subnet:' /etc/vpsmgr/config.yaml 2>/dev/null | awk -F': ' '{print $2}' | tr -d '"')
if [[ -n "$EXISTING" ]]; then
log "existing config has subnet=$EXISTING — keeping it"
export VPSMGR_IPV4_SUBNET="$EXISTING"
return 0
fi
fi
# Non-interactive with no env var: default subnet.
if [[ ! -t 0 ]] && [[ -z "${FORCE_ASK:-}" ]]; then
log "non-interactive install — using default subnet 10.115.0.0/24"
export VPSMGR_IPV4_SUBNET="10.115.0.0/24"
return 0
fi
# Interactive: only the second octet is tweakable, and it is fixed at install.
echo
echo "容器子网 10.<n>.0.0/24(网关 .1)— 仅第二段八位组可自定义,安装后不可更改。"
echo "Container subnet 10.<n>.0.0/24 (gateway .1) — only the second octet is tweakable; fixed after install."
read -r -p "第二段八位组 / Second octet (1-254) [default: 115]: " OCT
OCT="${OCT:-115}"
if ! validate_octet "$OCT"; then
die "'$OCT' is not an integer in 1..254"
return 1
fi
if CONFLICT=$(overlaps_existing "$OCT"); then
:
else
echo
log "warn: 10.$OCT.0.0/24 overlaps an existing host network ($CONFLICT)."
read -r -p " continue anyway? [y/N] " ANS
case "${ANS,,}" in
y|yes) ;;
*) die "aborted — pick another octet"; return 1 ;;
esac
fi
export VPSMGR_IPV4_SUBNET="10.$OCT.0.0/24"
log "container subnet: 10.$OCT.0.0/24"
}
# --- 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_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 user_ports instead of re-asking.
if [[ -f /etc/vpsmgr/config.yaml ]]; then
EXISTING=$(grep -E '^\s+user_ports:' /etc/vpsmgr/config.yaml 2>/dev/null | awk -F': ' '{print $2}' | tr -d '"')
if [[ -n "$EXISTING" ]]; then
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 user_ports='$EXISTING'"
return 1
fi
# 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 user ports 10000-29999"
export VPSMGR_USER_PORTS="10000-29999"
user_ports_summary "10000-29999" "200"
return 0
fi
# 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 "用户可用端口范围设置 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
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_user_ports || return 1