Files
v2ray-agent/shell/docker_reality_en.sh
mack-a 7b151c9623 fix(sing-box): 将配置迁移至 1.14
fix(sing-box): 移除不需要的 Reality 目标
fix(reality): 为 Reality XHTTP 启用 ML-DSA-65
fix(reality): 设置兼容的最低客户端版本
2026-08-31 23:58:56 +08:00

2446 lines
88 KiB
Bash

#!/usr/bin/env bash
# docker_reality_en.sh — Docker-based VLESS-Reality standalone launcher (no domain required)
# Usage: bash shell/docker_reality_en.sh [options]
export LANG=en_US.UTF-8
# ---------------------------------------------------------------------------
# Output helper — style consistent with echoContent in install.sh
# ---------------------------------------------------------------------------
echoContent() {
case $1 in
"red")
printf "\033[31m%s\033[0m\n" "$2"
;;
"skyBlue")
printf "\033[1;36m%s\033[0m\n" "$2"
;;
"green")
printf "\033[32m%s\033[0m\n" "$2"
;;
"white")
printf "\033[37m%s\033[0m\n" "$2"
;;
"magenta")
printf "\033[35m%s\033[0m\n" "$2"
;;
"yellow")
printf "\033[33m%s\033[0m\n" "$2"
;;
esac
}
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
nonInteractive=0
dataDir="/etc/v2ray-agent/docker/"
installMode=""
port=""
xhttpPort=""
xhttpPath=""
serverName=""
privateKey=""
uuid=""
email=""
generateOnly=0
startOnly=0
skipSelfInstall=0
subscribePort=""
resolvedSubscribePort=""
# ---------------------------------------------------------------------------
# QA override: set V2RAY_AGENT_FORCE_NO_DOCKER=1 to skip Docker check in tests
# ---------------------------------------------------------------------------
forceNoDocker="${V2RAY_AGENT_FORCE_NO_DOCKER:-0}"
# ---------------------------------------------------------------------------
# showHelp — print usage and exit
# ---------------------------------------------------------------------------
showHelp() {
echoContent "skyBlue" "docker_reality_en.sh — Run VLESS-Reality via Docker (no domain required)"
echoContent "white" ""
echoContent "white" "Usage:"
echoContent "white" " bash shell/docker_reality_en.sh [options]"
echoContent "white" ""
echoContent "white" "Run modes:"
echoContent "white" " Default / interactive Detect install state first, then show install/reinstall/start menu options"
echoContent "white" " --non-interactive Non-interactive mode; exit with error if required values are missing"
echoContent "white" " --generate-only Generate config and summary files only, then exit"
echoContent "white" " --start-only Reuse existing config and only start/recreate the container"
echoContent "white" " --skip-self-install Skip script relocation and vasmad shortcut creation"
echoContent "white" ""
echoContent "white" "Options:"
echoContent "white" " --non-interactive Run non-interactively; all required values must be supplied via flags"
echoContent "white" " --data-dir <path> Persistent config/data directory (default: /etc/v2ray-agent/docker/)"
echoContent "white" " --install-mode <mode> Install mode: vision / xhttp / all"
echoContent "white" " --port <port> Vision or single-protocol external listen port (leave blank to pick randomly)"
echoContent "white" " --xhttp-port <port> XHTTP mode port (leave blank to pick randomly)"
echoContent "white" " --xhttp-path <path> XHTTP path base value (rendered as /<path>xHTTP)"
echoContent "white" " --subscribe-port <port> Subscription access port (leave blank to pick randomly)"
echoContent "white" " --server-name <sni> SNI / server name used for Reality handshake"
echoContent "white" " --private-key <key> X25519 private key (auto-generated if blank)"
echoContent "white" " --uuid <uuid> Client UUID (auto-generated if blank)"
echoContent "white" " --email <email> Contact email stored in the config"
echoContent "white" " --generate-only Generate config and keys then exit without starting Docker"
echoContent "white" " --start-only Skip config generation and start Docker with existing config"
echoContent "white" " --skip-self-install Skip script relocation and vasmad shortcut creation"
echoContent "white" " -h, --help Show this help and exit"
echoContent "white" ""
echoContent "white" "Notes:"
echoContent "white" " 1. Interactive mode checks both config.json and the v2ray-agent-docker container before showing a menu."
echoContent "white" " 2. If both are missing, it shows an install menu; if either exists, it shows user-management / reinstall / start-or-recreate options."
echoContent "white" " 3. Install mode selects Reality Vision, Reality XHTTP, or both; XHTTP requires an additional path input."
echoContent "white" " 4. Non-interactive mode requires all values explicitly; privateKey/uuid/email may still be auto-generated/derived."
echoContent "white" " 5. Data directory defaults to /etc/v2ray-agent/docker/; override with --data-dir for QA or custom paths."
echoContent "white" " 6. If the script is not running from /etc/v2ray-agent/docker_reality_en.sh, it will relocate itself and create the vasmad shortcut."
echoContent "white" ""
echoContent "yellow" "QA / Testing:"
echoContent "white" " V2RAY_AGENT_FORCE_NO_DOCKER=1 Skip Docker availability check (test environments)"
echoContent "white" " --data-dir /tmp/v2ray-agent/docker Example path override for isolated verification"
exit "${1:-0}"
}
# jsonEscape — escape a string for safe insertion into JSON.
jsonEscape() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
value="${value//$'\n'/\\n}"
value="${value//$'\r'/\\r}"
value="${value//$'\t'/\\t}"
printf '%s' "${value}"
}
# validateDataDirPath — reject obviously unsafe data-dir path forms (for root-run scenarios).
validateDataDirPath() {
local path="$1"
if [[ -z "${path}" || "${path}" != /* || "${path}" == *:* ]]; then
echoContent "red" "--data-dir must be an absolute path without colons"
exit 1
fi
if [[ -e "${path}" && -L "${path}" ]]; then
echoContent "red" "--data-dir must not be a symbolic link: ${path}"
exit 1
fi
}
# ---------------------------------------------------------------------------
# promptValue — interactive prompt; pressing Enter keeps the current value
# ---------------------------------------------------------------------------
promptValue() {
local promptMessage="$1"
local currentValue="$2"
local inputValue
printf '%b' "${promptMessage}" >/dev/tty
read -r inputValue </dev/tty
if [[ -n "${inputValue}" ]]; then
printf '%s' "${inputValue}"
else
printf '%s' "${currentValue}"
fi
}
# initRandomPath — generate a 4-character random path base value, close to install.sh style.
initRandomPath() {
local chars="abcdefghijklmnopqrstuvwxyz"
local randomPath=""
local _idx
for _idx in 1 2 3 4; do
randomPath+="${chars:RANDOM%${#chars}:1}"
done
printf '%s' "${randomPath}"
}
# normalizeInstallMode — normalise install mode, defaulting to vision for backward compatibility.
normalizeInstallMode() {
local rawMode="$1"
case "${rawMode}" in
"")
if [[ -n "${port}" && ( -n "${xhttpPort}" || -n "${xhttpPath}" ) ]]; then
resolvedInstallMode="all"
elif [[ -n "${xhttpPort}" || -n "${xhttpPath}" ]]; then
resolvedInstallMode="xhttp"
else
resolvedInstallMode="vision"
fi
;;
vision | xhttp | all)
resolvedInstallMode="${rawMode}"
;;
*)
echoContent "red" "Invalid install mode: ${rawMode} (must be vision / xhttp / all)"
exit 1
;;
esac
}
# normalizeXHTTPPath — normalise the XHTTP path base value; the xHTTP suffix is appended at render time.
normalizeXHTTPPath() {
local rawPath="$1"
local normalized="${rawPath#/}"
while [[ "${normalized}" == */ ]]; do
normalized="${normalized%/}"
done
if [[ "${normalized}" == *xHTTP ]]; then
normalized="${normalized%xHTTP}"
fi
if [[ -z "${normalized}" ]]; then
normalized="$(initRandomPath)"
fi
if [[ "${normalized}" == *ws ]]; then
echoContent "red" "XHTTP path base must not end with 'ws' — it conflicts with the legacy ws path convention in install.sh"
exit 1
fi
resolvedXHTTPPath="${normalized}"
}
# renderXHTTPPath — render the path base value into the /<path>xHTTP form used by install.sh.
renderXHTTPPath() {
printf '/%sxHTTP' "$1"
}
urlEncode() {
local input="$1"
local length=${#input}
local index char
for ((index = 0; index < length; index++)); do
char="${input:index:1}"
case "${char}" in
[a-zA-Z0-9.~_-]) printf '%s' "${char}" ;;
*) printf '%%%02X' "'${char}" ;;
esac
done
}
buildVisionSubscriptionLink() {
local displayAddress="$1"
local displayPort="$2"
local displayServerName="$3"
local displayPublicKey="$4"
local displayUUID="$5"
local displayEmail="$6"
local displayShortId="$7"
if [[ -z "${displayAddress}" || -z "${displayPort}" || -z "${displayServerName}" || -z "${displayPublicKey}" || -z "${displayUUID}" || -z "${displayEmail}" || -z "${displayShortId}" ]]; then
return 1
fi
printf 'vless://%s@%s:%s?encryption=none&security=reality&type=tcp&sni=%s&fp=chrome&pbk=%s&sid=%s&flow=xtls-rprx-vision#%s' \
"${displayUUID}" \
"${displayAddress}" \
"${displayPort}" \
"${displayServerName}" \
"${displayPublicKey}" \
"${displayShortId}" \
"${displayEmail}"
}
buildXHTTPSubscriptionLink() {
local displayAddress="$1"
local displayPort="$2"
local displayServerName="$3"
local displayPublicKey="$4"
local displayUUID="$5"
local displayEmail="$6"
local displayShortId="$7"
local displayPath="$8"
if [[ -z "${displayAddress}" || -z "${displayPort}" || -z "${displayServerName}" || -z "${displayPublicKey}" || -z "${displayUUID}" || -z "${displayEmail}" || -z "${displayShortId}" || -z "${displayPath}" ]]; then
return 1
fi
printf 'vless://%s@%s:%s?encryption=none&security=reality&type=xhttp&sni=%s&host=%s&fp=chrome&path=%s&pbk=%s&sid=%s#%s' \
"${displayUUID}" \
"${displayAddress}" \
"${displayPort}" \
"${displayServerName}" \
"${displayServerName}" \
"${displayPath}" \
"${displayPublicKey}" \
"${displayShortId}" \
"${displayEmail}"
}
buildXHTTPDefaultSubscriptionLink() {
local displayAddress="$1"
local displayPort="$2"
local displayServerName="$3"
local displayPublicKey="$4"
local displayUUID="$5"
local displayEmail="$6"
local displayShortId="$7"
local displayPath="$8"
if [[ -z "${displayAddress}" || -z "${displayPort}" || -z "${displayServerName}" || -z "${displayPublicKey}" || -z "${displayUUID}" || -z "${displayEmail}" || -z "${displayShortId}" || -z "${displayPath}" ]]; then
return 1
fi
printf 'vless://%s@%s:%s?encryption=none&security=reality&type=xhttp&sni=%s&fp=chrome&path=%s&pbk=%s&sid=%s#%s' \
"${displayUUID}" \
"${displayAddress}" \
"${displayPort}" \
"${displayServerName}" \
"${displayPath}" \
"${displayPublicKey}" \
"${displayShortId}" \
"${displayEmail}"
}
writeVisionClashMetaNode() {
local outputFile="$1"
local displayAddress="$2"
local displayPort="$3"
local displayServerName="$4"
local displayPublicKey="$5"
local displayUUID="$6"
local displayEmail="$7"
local displayShortId="$8"
if [[ -z "${displayAddress}" || -z "${displayPort}" || -z "${displayServerName}" || -z "${displayPublicKey}" || -z "${displayUUID}" || -z "${displayEmail}" || -z "${displayShortId}" ]]; then
return 1
fi
cat <<EOF >>"${outputFile}"
- name: "${displayEmail}"
type: vless
server: ${displayAddress}
port: ${displayPort}
uuid: ${displayUUID}
network: tcp
tls: true
udp: true
flow: xtls-rprx-vision
servername: ${displayServerName}
reality-opts:
public-key: ${displayPublicKey}
short-id: ${displayShortId}
client-fingerprint: chrome
EOF
}
writeXHTTPClashMetaNode() {
local outputFile="$1"
local displayAddress="$2"
local displayPort="$3"
local displayServerName="$4"
local displayPublicKey="$5"
local displayUUID="$6"
local displayEmail="$7"
local displayShortId="$8"
local displayPath="$9"
if [[ -z "${displayAddress}" || -z "${displayPort}" || -z "${displayServerName}" || -z "${displayPublicKey}" || -z "${displayUUID}" || -z "${displayEmail}" || -z "${displayShortId}" || -z "${displayPath}" ]]; then
return 1
fi
cat <<EOF >>"${outputFile}"
- name: "${displayEmail}"
type: vless
server: ${displayAddress}
port: ${displayPort}
uuid: ${displayUUID}
udp: true
tls: true
network: xhttp
client-fingerprint: chrome
alpn:
- h2
servername: ${displayServerName}
xhttp-opts:
path: ${displayPath}
host: ${displayServerName}
reality-opts:
public-key: ${displayPublicKey}
short-id: ${displayShortId}
EOF
}
writeClashMetaProfile() {
local outputFile="$1"
local providerURL="$2"
local providerName="$3"
cat <<EOF >"${outputFile}"
log-level: debug
mode: rule
ipv6: true
mixed-port: 7890
allow-lan: true
bind-address: "*"
find-process-mode: strict
external-controller: 0.0.0.0:9090
global-client-fingerprint: chrome
profile:
store-selected: true
store-fake-ip: true
dns:
enable: true
listen: 0.0.0.0:1053
ipv6: true
enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16
nameserver:
- https://1.1.1.1/dns-query
- https://8.8.8.8/dns-query
- 1.1.1.1
- 8.8.8.8
proxy-providers:
${providerName}:
type: http
path: ./${providerName}.yaml
url: ${providerURL}
interval: 3600
proxy: DIRECT
health-check:
enable: true
url: https://cp.cloudflare.com/generate_204
interval: 300
proxy-groups:
- name: Manual Select
type: select
use:
- ${providerName}
proxies: null
- name: Auto Select
type: url-test
url: http://www.gstatic.com/generate_204
interval: 36000
tolerance: 50
use:
- ${providerName}
proxies: null
- name: Global Proxy
type: select
use:
- ${providerName}
proxies:
- Manual Select
- Auto Select
- name: Final
type: select
use:
- ${providerName}
proxies:
- Global Proxy
- DIRECT
- Manual Select
- Auto Select
rules:
- GEOIP,LAN,DIRECT,no-resolve
- GEOSITE,private,DIRECT
- GEOSITE,cn,DIRECT
- GEOIP,CN,DIRECT
- MATCH,Final
EOF
}
initRandomSalt() {
local chars="abcdefghijklmnopqrtuxyz"
local randomSalt=""
local _idx
for _idx in 1 2 3 4 5 6 7 8 9 10; do
randomSalt+="${chars:RANDOM%${#chars}:1}"
done
printf '%s' "${randomSalt}"
}
md5Text() {
if command -v md5sum >/dev/null 2>&1; then
printf '%s' "$1" | md5sum | awk '{print $1}'
else
printf '%s' "$1" | md5 | awk '{print $NF}'
fi
}
base64FileSingleLine() {
if base64 --help 2>&1 | grep -q -- '-w'; then
base64 -w 0 "$1"
else
base64 <"$1" | tr -d '\n'
fi
}
generateSubscribeSalt() {
local saltFile="${dataDir%/}/subscribe_local/subscribeSalt"
if [[ -n "${persistedSubscribeSalt}" ]]; then
printf '%s' "${persistedSubscribeSalt}"
elif [[ -s "${saltFile}" ]]; then
cat "${saltFile}"
else
local newSalt
newSalt="$(initRandomSalt)"
mkdir -p "${dataDir%/}/subscribe_local"
printf '%s' "${newSalt}" >"${saltFile}"
printf '%s' "${newSalt}"
fi
}
startSubscribeContainer() {
local displayAddress="$1"
local containerName="v2ray-agent-docker-subscribe"
local nginxImage="nginx:alpine"
local activePort="${persistedSubscribePort:-${resolvedSubscribePort}}"
local nginxConfig="${dataDir%/}/subscribe-nginx.conf"
if [[ -z "${activePort}" ]]; then
parsePort ""
activePort="${resolvedPort}"
checkPortInUse "${activePort}"
fi
mkdir -p "${dataDir%/}/subscribe/default" "${dataDir%/}/subscribe/clashMeta" "${dataDir%/}/subscribe/clashMetaProfiles"
cat >"${nginxConfig}" <<'EOF'
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
location ~ ^/s/(default|clashMeta|clashMetaProfiles)/(.*) {
default_type 'text/plain; charset=utf-8';
alias /usr/share/nginx/html/subscribe/$1/$2;
}
location / {
return 404;
}
}
EOF
docker pull "${nginxImage}" >/dev/null 2>&1 || true
if docker ps -a --filter "name=^/${containerName}$" --format '{{.Names}}' | grep -q "^${containerName}$"; then
docker rm -f "${containerName}" >/dev/null 2>&1 || true
fi
if ! docker run -d \
--name "${containerName}" \
--restart unless-stopped \
-p "${activePort}:80" \
-v "${dataDir%/}:/usr/share/nginx/html:ro" \
-v "${nginxConfig}:/etc/nginx/conf.d/default.conf:ro" \
"${nginxImage}" >/dev/null; then
echoContent "red" "Failed to start subscription access container"
exit 1
fi
persistedSubscribePort="${activePort}"
echoContent "green" "Subscription access service started: http://${displayAddress}:${activePort}/s/default/<subscription-id>"
}
persistSubscribeState() {
local summaryFile="${dataDir%/}/client-summary.txt"
local tmpFile=""
[[ -f "${summaryFile}" ]] || return 0
tmpFile="$(mktemp)"
while IFS= read -r line; do
case "${line}" in
subscribePort:\ * | subscribeSalt:\ *) ;;
*) printf '%s\n' "${line}" >>"${tmpFile}" ;;
esac
done <"${summaryFile}"
printf 'subscribePort: %s\n' "${persistedSubscribePort}" >>"${tmpFile}"
printf 'subscribeSalt: %s\n' "${persistedSubscribeSalt}" >>"${tmpFile}"
mv "${tmpFile}" "${summaryFile}"
chmod 600 "${summaryFile}"
}
ensureSubscribeRuntimeValues() {
local inputPort=""
if [[ -z "${persistedSubscribePort}" ]]; then
inputPort="$(promptValue $'Enter subscription access port, [Enter] random: ' "")"
if ! parsePort "${inputPort}"; then
exit 1
fi
persistedSubscribePort="${resolvedPort}"
fi
if [[ -z "${persistedSubscribeSalt}" ]]; then
persistedSubscribeSalt="$(initRandomSalt)"
fi
persistSubscribeState
}
installModeHasVision() {
[[ "$1" == "vision" || "$1" == "all" ]]
}
installModeHasXHTTP() {
[[ "$1" == "xhttp" || "$1" == "all" ]]
}
# ---------------------------------------------------------------------------
# promptInteractiveValues — collect missing values in interactive mode
# ---------------------------------------------------------------------------
promptInteractiveValues() {
if [[ "${startOnly}" == "1" || "${nonInteractive}" == "1" ]]; then
return 0
fi
echoContent "skyBlue" ""
echoContent "skyBlue" "─── Configuration Input ─────────────────────────────────"
echoContent "white" "Docker is ready. Please enter each configuration value."
echoContent "white" "Press Enter to accept the default behaviour shown in brackets."
echoContent "skyBlue" "────────────────────────────────────────────────────────"
if [[ -z "${installMode}" ]]; then
echoContent "yellow" "1. Reality Vision"
echoContent "yellow" "2. Reality XHTTP"
echoContent "yellow" "3. Install both"
case "$(promptValue $'\n[Step 1/8] Install mode [1-3]: ' "")" in
1) installMode="vision" ;;
2) installMode="xhttp" ;;
3) installMode="all" ;;
*)
echoContent "red" "Invalid selection. Please enter 1 / 2 / 3"
exit 1
;;
esac
fi
normalizeInstallMode "${installMode}"
if installModeHasVision "${resolvedInstallMode}"; then
if [[ -z "${port}" ]]; then
port="$(promptValue $'\n[Step 2/8] Vision port [leave blank to pick randomly from 10000-30000]: ' "")"
if [[ -z "${port}" ]]; then
port=""
fi
fi
fi
if installModeHasXHTTP "${resolvedInstallMode}"; then
if [[ -z "${xhttpPort}" ]]; then
xhttpPort="$(promptValue $'\n[Step 3/8] XHTTP port [leave blank to pick randomly from 10000-30000]: ' "")"
if [[ -z "${xhttpPort}" ]]; then
xhttpPort=""
fi
fi
if [[ -z "${xhttpPath}" ]]; then
xhttpPath="$(promptValue $'\n[Step 4/8] XHTTP path [e.g. alone, leave blank to auto-generate]: ' "")"
fi
fi
if [[ -z "${serverName}" ]]; then
serverName="$(promptValue $'\n[Step 5/8] Server name [leave blank to pick a random Reality target]: ' "")"
fi
if [[ -z "${privateKey}" ]]; then
privateKey="$(promptValue $'\n[Step 6/8] Private key [leave blank to auto-generate]: ' "")"
fi
if [[ -z "${uuid}" ]]; then
uuid="$(promptValue $'\n[Step 7/8] UUID [leave blank to auto-generate]: ' "")"
fi
if [[ -z "${email}" ]]; then
email="$(promptValue $'\n[Step 8/8] Email base name [leave blank to derive from UUID]: ' "")"
fi
echoContent "white" ""
}
# ---------------------------------------------------------------------------
# parseCli — parse command-line arguments
# ---------------------------------------------------------------------------
parseCli() {
while [[ $# -gt 0 ]]; do
case "$1" in
--non-interactive)
nonInteractive=1
shift
;;
--data-dir)
if [[ $# -lt 2 || -z "${2:-}" || "${2:0:1}" == "-" || "${2}" == *:* || "${2}" != /* ]]; then
echoContent "red" "--data-dir requires a path value"
exit 1
fi
dataDir="$2"
validateDataDirPath "${dataDir}"
shift 2
;;
--install-mode)
if [[ $# -lt 2 || -z "${2:-}" || "${2:0:1}" == "-" ]]; then
echoContent "red" "--install-mode requires a value (vision / xhttp / all)"
exit 1
fi
installMode="$2"
shift 2
;;
--port)
if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then
echoContent "red" "--port requires a numeric value"
exit 1
fi
port="$2"
shift 2
;;
--xhttp-port)
if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then
echoContent "red" "--xhttp-port requires a numeric value"
exit 1
fi
xhttpPort="$2"
shift 2
;;
--xhttp-path)
if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then
echoContent "red" "--xhttp-path requires a value"
exit 1
fi
xhttpPath="$2"
shift 2
;;
--server-name)
if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then
echoContent "red" "--server-name requires a value"
exit 1
fi
serverName="$2"
shift 2
;;
--private-key)
if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then
echoContent "red" "--private-key requires a value"
exit 1
fi
privateKey="$2"
shift 2
;;
--uuid)
if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then
echoContent "red" "--uuid requires a value"
exit 1
fi
uuid="$2"
shift 2
;;
--email)
if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then
echoContent "red" "--email requires a value"
exit 1
fi
email="$2"
shift 2
;;
--subscribe-port)
if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then
echoContent "red" "--subscribe-port requires a numeric value"
exit 1
fi
subscribePort="$2"
shift 2
;;
--generate-only)
generateOnly=1
shift
;;
--start-only)
startOnly=1
shift
;;
--skip-self-install)
skipSelfInstall=1
shift
;;
-h | --help)
showHelp
;;
*)
echoContent "red" "Unknown option: $1"
showHelp 1
;;
esac
done
}
# ---------------------------------------------------------------------------
# checkEnvironment — require Linux and root/sudo
# ---------------------------------------------------------------------------
checkEnvironment() {
local osType
osType="$(uname -s 2>/dev/null || true)"
if [[ "${osType}" != "Linux" ]]; then
echoContent "red" "This script requires a Linux host (detected: ${osType})"
exit 1
fi
if [[ "$(id -u)" != "0" ]]; then
echoContent "red" "This script must be run as root or with sudo"
exit 1
fi
}
# selfInstallShortcut — mirror install.sh aliasInstall behavior for this standalone script and create the vasmad shortcut.
selfInstallShortcut() {
local targetScript="/etc/v2ray-agent/docker_reality_en.sh"
local currentScript=""
local shortcutCreated="false"
if [[ "${skipSelfInstall}" == "1" ]]; then
return 0
fi
currentScript=$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$0" 2>/dev/null || true)
if [[ -z "${currentScript}" ]]; then
currentScript="$0"
fi
mkdir -p /etc/v2ray-agent
if [[ "${currentScript}" != "${targetScript}" ]]; then
if ! mv "${currentScript}" "${targetScript}" 2>/dev/null; then
echoContent "yellow" "Unable to move the script automatically to ${targetScript}; continuing from the current path."
else
chmod 700 "${targetScript}"
currentScript="${targetScript}"
echoContent "green" "Script moved to ${targetScript}"
fi
else
chmod 700 "${targetScript}"
fi
if [[ -f "${targetScript}" ]]; then
if [[ -d "/usr/bin/" ]]; then
rm -f /usr/bin/vasmad
ln -s "${targetScript}" /usr/bin/vasmad
chmod 700 /usr/bin/vasmad
shortcutCreated="true"
fi
if [[ -d "/usr/sbin/" ]]; then
rm -f /usr/sbin/vasmad
ln -s "${targetScript}" /usr/sbin/vasmad
chmod 700 /usr/sbin/vasmad
shortcutCreated="true"
fi
fi
if [[ "${shortcutCreated}" == "true" ]]; then
echoContent "green" "Shortcut created successfully. Execute [vasmad] to reopen the script"
echoContent "yellow" "Launch command: vasmad"
fi
}
# ---------------------------------------------------------------------------
# installDocker — download and run the official Docker convenience install script
# ---------------------------------------------------------------------------
installDocker() {
echoContent "white" "Downloading Docker install script from https://get.docker.com ..."
local tmpScript
tmpScript="$(mktemp /tmp/get-docker-XXXXXX.sh)"
if ! curl -fsSL https://get.docker.com -o "${tmpScript}"; then
echoContent "red" "Failed to download Docker install script from https://get.docker.com"
rm -f "${tmpScript}"
return 1
fi
echoContent "white" "Running Docker install script..."
if ! sh "${tmpScript}"; then
echoContent "red" "Docker installation failed"
rm -f "${tmpScript}"
return 1
fi
rm -f "${tmpScript}"
echoContent "green" "Docker installed successfully"
return 0
}
# checkDocker — verify Docker is installed and running; prompt to install if missing
checkDocker() {
# QA override: V2RAY_AGENT_FORCE_NO_DOCKER=1 forces the Docker-missing branch
if [[ "${forceNoDocker}" == "1" ]]; then
echoContent "yellow" "[QA] V2RAY_AGENT_FORCE_NO_DOCKER=1: simulating Docker not installed"
_promptDockerInstall
return $?
fi
echoContent "white" "Checking Docker availability..."
if ! command -v docker >/dev/null 2>&1; then
echoContent "yellow" "Docker is not installed on this system"
_promptDockerInstall
return $?
fi
# Docker binary exists — verify the daemon is reachable
if ! docker info >/dev/null 2>&1; then
echoContent "red" "Docker is installed but the daemon is not running"
echoContent "white" "Please start the Docker daemon (e.g.: systemctl start docker) and retry"
exit 1
fi
echoContent "green" "Docker is available and running"
return 0
}
# _promptDockerInstall — ask the user whether to install Docker; exit cleanly on refusal
_promptDockerInstall() {
local answer
if [[ "${nonInteractive}" == "1" ]]; then
echoContent "red" "Docker is required but not installed. Use interactive mode, or set V2RAY_AGENT_FORCE_NO_DOCKER for QA only."
exit 1
fi
echoContent "yellow" "Docker is required to run this script."
answer="$(promptValue $'\033[33mInstall Docker now via https://get.docker.com? [y/N]: \033[0m' "")"
case "${answer}" in
[yY] | [yY][eE][sS])
if ! installDocker; then
echoContent "red" "Docker installation failed, exiting."
exit 1
fi
# Verify the daemon started after installation
if ! docker info >/dev/null 2>&1; then
echoContent "yellow" "Docker installed but daemon not yet running, starting..."
systemctl start docker 2>/dev/null || true
sleep 2
if ! docker info >/dev/null 2>&1; then
echoContent "red" "Docker daemon failed to start. Please start it manually and retry."
exit 1
fi
fi
echoContent "green" "Docker is ready"
;;
*)
echoContent "white" "Docker installation declined, exiting."
exit 0
;;
esac
}
# ---------------------------------------------------------------------------
# xrayImagePreflight — validate a config file using the official Xray image
# Usage: xrayImagePreflight <config-file-path>
# ---------------------------------------------------------------------------
xrayImagePreflight() {
local configFile="$1"
local xrayImage="ghcr.io/xtls/xray-core:26.5.9"
if [[ -z "${configFile}" ]]; then
echoContent "red" "xrayImagePreflight: config file path is required"
return 1
fi
if [[ ! -f "${configFile}" ]]; then
echoContent "red" "xrayImagePreflight: config file not found: ${configFile}"
return 1
fi
echoContent "white" "Pulling Xray image ${xrayImage} (if not cached)..."
docker pull "${xrayImage}" >/dev/null 2>&1 || true
echoContent "white" "Validating config: docker run --rm --user root -v \"${configFile}:/usr/local/etc/xray/config.json:ro\" ${xrayImage} run -test -c /usr/local/etc/xray/config.json"
if docker run --rm \
--user root \
-v "${configFile}:/usr/local/etc/xray/config.json:ro" \
"${xrayImage}" \
run -test -c /usr/local/etc/xray/config.json; then
echoContent "green" "Xray config validation passed"
return 0
else
echoContent "red" "Xray config validation failed"
return 1
fi
}
# ---------------------------------------------------------------------------
# Value generation helpers
# ---------------------------------------------------------------------------
# _realityDomainList — curated Reality target domain list, kept in sync with install.sh:9618
# Returns a comma-separated domain string (Xray-core list)
_realityDomainList() {
printf '%s' "download-installer.cdn.mozilla.net,addons.mozilla.org,s0.awsstatic.com,d1.awsstatic.com,images-na.ssl-images-amazon.com,m.media-amazon.com,player.live-video.net,one-piece.com,lol.secure.dyn.riotcdn.net,www.lovelive-anime.jp,academy.nvidia.com,dl.google.com,www.google-analytics.com,www.caltech.edu,www.calstatela.edu,www.suny.edu,www.suffolk.edu,www.python.org,vuejs-jp.org,vuejs.org,zh-hk.vuejs.org,react.dev,www.java.com,www.oracle.com,www.mysql.com,www.mongodb.com,redis.io,cname.vercel-dns.com,vercel-dns.com,www.swift.com,academy.nvidia.com,www.swift.com,www.cisco.com,www.asus.com,www.samsung.com,www.amd.com,www.umcg.nl,www.fom-international.com,www.u-can.co.jp,github.io"
}
# parsePort — validate and normalise a port value.
# Usage: parsePort <raw-port>
# Sets global: resolvedPort
# Returns 0 on success, 1 on invalid input.
parsePort() {
local raw="$1"
if [[ -z "${raw}" ]]; then
# Blank → pick randomly from 10000-30000 (aligned with install.sh:9711)
resolvedPort=$((RANDOM % 20001 + 10000))
echoContent "yellow" "No port provided — randomly selected: ${resolvedPort}"
return 0
fi
# Must be a positive integer
if ! [[ "${raw}" =~ ^[0-9]+$ ]]; then
echoContent "red" "Invalid port '${raw}': must be a positive integer"
return 1
fi
if [[ "${raw}" -lt 1 || "${raw}" -gt 65535 ]]; then
echoContent "red" "Invalid port '${raw}': must be in range 1-65535"
return 1
fi
resolvedPort="${raw}"
return 0
}
# checkPortInUse — exit if the port is occupied by a process other than the v2ray-agent-docker container
# (aligned with install.sh:1951-1957).
# On re-run the script does docker rm -f v2ray-agent-docker before recreating,
# so a port held only by that container is not a real conflict.
# Usage: checkPortInUse <port>
checkPortInUse() {
local p="$1"
if ! command -v lsof >/dev/null 2>&1; then
return 0
fi
# Collect PIDs listening on this port
local listenPids
listenPids=$(lsof -i "tcp:${p}" -sTCP:LISTEN -t 2>/dev/null || true)
[[ -z "${listenPids}" ]] && return 0
# Check whether the existing v2ray-agent-docker container holds the port.
# If the container exists, its published ports are managed by docker-proxy.
# If all listening PIDs belong to that container's docker-proxy processes, allow the port.
local containerExists=0
if docker ps -a --filter "name=^/v2ray-agent-docker$" --format '{{.Names}}' 2>/dev/null \
| grep -q "^v2ray-agent-docker$" || docker ps -a --filter "name=^/v2ray-agent-docker-subscribe$" --format '{{.Names}}' 2>/dev/null | grep -q "^v2ray-agent-docker-subscribe$"; then
containerExists=1
fi
if [[ "${containerExists}" == "1" ]]; then
# Verify all listening PIDs are docker-proxy processes (Docker's forwarder for published ports).
# If any PID is not docker-proxy, the port is held by an unrelated process — a real conflict.
local unrelatedPids=0
local pid
while IFS= read -r pid; do
[[ -z "${pid}" ]] && continue
local comm
comm=$(cat "/proc/${pid}/comm" 2>/dev/null || ps -p "${pid}" -o comm= 2>/dev/null || true)
if [[ "${comm}" != *"docker-proxy"* ]]; then
unrelatedPids=1
break
fi
done <<<"${listenPids}"
if [[ "${unrelatedPids}" == "0" ]]; then
# Port is held only by v2ray-agent-docker's docker-proxy — safe to continue
echoContent "yellow" "Port ${p} is held by the existing v2ray-agent-docker container (will be replaced)"
return 0
fi
fi
echoContent "red" "Port ${p} is already in use — please free it and retry"
lsof -i "tcp:${p}" -sTCP:LISTEN 2>/dev/null || true
exit 1
}
# parseServerName — validate and normalise serverName / host:port input.
# Usage: parseServerName <raw-server-name>
# Sets globals: resolvedServerName, resolvedTargetPort
# Returns 0 on success.
parseServerName() {
local raw="$1"
if [[ -z "${raw}" ]]; then
# Blank → pick randomly from the curated list (aligned with install.sh:9673-9678)
local domainList count randomIdx
domainList="$(_realityDomainList)"
count=$(printf '%s' "${domainList}" | awk -F',' '{print NF}')
randomIdx=$(( (RANDOM % count) + 1 ))
resolvedServerName=$(printf '%s' "${domainList}" | awk -F',' -v n="${randomIdx}" '{print $n}')
resolvedTargetPort=443
echoContent "yellow" "No serverName provided — randomly selected: ${resolvedServerName}:${resolvedTargetPort}"
return 0
fi
# Support host:port syntax (aligned with install.sh:9679-9682)
if printf '%s' "${raw}" | grep -q ":"; then
resolvedTargetPort=$(printf '%s' "${raw}" | awk -F: '{print $2}')
resolvedServerName=$(printf '%s' "${raw}" | awk -F: '{print $1}')
else
resolvedServerName="${raw}"
resolvedTargetPort=443
fi
return 0
}
# generatePrivateKey — generate an X25519 key pair via the pre-checked Xray Docker image.
# Usage: generatePrivateKey <xray-runtime-path>
# Sets globals: resolvedPrivateKey, resolvedPublicKey
# Returns 0 on success, 1 on failure.
generatePrivateKey() {
local xrayRuntime="$1"
local x25519Output
if [[ -z "${xrayRuntime}" ]]; then
echoContent "red" "generatePrivateKey: xray runtime path is required"
return 1
fi
# xrayRuntime may be a Docker invocation string or a host binary path
x25519Output=$(${xrayRuntime} x25519 2>/dev/null) || {
echoContent "red" "Failed to generate X25519 key pair via: ${xrayRuntime} x25519"
return 1
}
resolvedPrivateKey=$(printf '%s' "${x25519Output}" | awk -F': ' '/PrivateKey|Private key/ {print $2; exit}')
resolvedPublicKey=$(printf '%s' "${x25519Output}" | awk -F': ' '/Password|Public key/ {print $2; exit}')
if [[ -z "${resolvedPrivateKey}" ]]; then
echoContent "red" "X25519 key generation produced no PrivateKey output"
return 1
fi
echoContent "green" "Generated public key: ${resolvedPublicKey}"
return 0
}
# derivePublicKey — derive the public key from an existing private key via the Xray runtime.
# Usage: derivePublicKey <xray-runtime-path> <private-key>
# Sets global: resolvedPublicKey
# Returns 0 on success, 1 on failure.
derivePublicKey() {
local xrayRuntime="$1"
local privKey="$2"
local x25519Output
x25519Output=$(${xrayRuntime} x25519 -i "${privKey}" 2>/dev/null) || {
echoContent "red" "Failed to derive public key from the provided private key"
return 1
}
resolvedPublicKey=$(printf '%s' "${x25519Output}" | awk -F': ' '/Password|Public key/ {print $2; exit}')
if [[ -z "${resolvedPublicKey}" ]]; then
echoContent "red" "Provided private key is invalid — cannot derive public key"
return 1
fi
return 0
}
# generateUUID — generate a UUID via the pre-checked Xray Docker image.
# Usage: generateUUID <xray-runtime-path>
# Sets global: resolvedUUID
# Returns 0 on success, 1 on failure.
generateUUID() {
local xrayRuntime="$1"
local uuidOutput
if [[ -z "${xrayRuntime}" ]]; then
echoContent "red" "generateUUID: xray runtime path is required"
return 1
fi
uuidOutput=$(${xrayRuntime} uuid 2>/dev/null) || {
echoContent "red" "Failed to generate UUID via: ${xrayRuntime} uuid"
return 1
}
resolvedUUID=$(printf '%s' "${uuidOutput}" | tr -d '[:space:]')
if [[ -z "${resolvedUUID}" ]]; then
echoContent "red" "UUID generation produced empty output"
return 1
fi
echoContent "green" "Generated UUID: ${resolvedUUID}"
return 0
}
# deriveEmail — derive email base from the UUID prefix (aligned with install.sh:3842)
# Usage: deriveEmail <uuid>
# Sets global: resolvedEmail
deriveEmail() {
local uuidVal="$1"
# Take the part before the first '-'
resolvedEmail="${uuidVal%%-*}"
}
# _xrayDockerRuntime — return the docker-run command prefix for the Xray image.
# This is the pre-checked runtime path used in value generation (no host binary assumed).
_xrayDockerRuntime() {
printf '%s' "docker run --rm ghcr.io/xtls/xray-core:26.5.9"
}
# loadPersistedSummaryIfPresent — optionally read and validate client-summary.txt.
# Sets: persistedContainer, persistedInstallMode, persistedVisionPort, persistedXHTTPPort,
# persistedXHTTPPath, persistedServerName, persistedPublicKey, persistedUUID,
# persistedEmailBase, persistedVisionEmail, persistedXHTTPEmail,
# persistedShortId, persistedConfigPath
loadPersistedSummaryIfPresent() {
local summaryFile="${dataDir%/}/client-summary.txt"
persistedContainer=""
persistedInstallMode=""
persistedVisionPort=""
persistedXHTTPPort=""
persistedXHTTPPath=""
persistedServerName=""
persistedPublicKey=""
persistedUUID=""
persistedEmailBase=""
persistedVisionEmail=""
persistedXHTTPEmail=""
persistedShortId="6ba85179e30d4fc2"
persistedSubscribePort=""
persistedSubscribeSalt=""
persistedConfigPath=""
if [[ ! -f "${summaryFile}" ]]; then
return 0
fi
while IFS= read -r line; do
case "${line}" in
container:\ *) persistedContainer="${line#container: }" ;;
installMode:\ *) persistedInstallMode="${line#installMode: }" ;;
visionPort:\ *) persistedVisionPort="${line#visionPort: }" ;;
xhttpPort:\ *) persistedXHTTPPort="${line#xhttpPort: }" ;;
xhttpPath:\ *) persistedXHTTPPath="${line#xhttpPath: }" ;;
serverName:\ *) persistedServerName="${line#serverName: }" ;;
publicKey:\ *) persistedPublicKey="${line#publicKey: }" ;;
uuid:\ *) persistedUUID="${line#uuid: }" ;;
emailBase:\ *) persistedEmailBase="${line#emailBase: }" ;;
visionEmail:\ *) persistedVisionEmail="${line#visionEmail: }" ;;
xhttpEmail:\ *) persistedXHTTPEmail="${line#xhttpEmail: }" ;;
shortId:\ *) persistedShortId="${line#shortId: }" ;;
subscribePort:\ *) persistedSubscribePort="${line#subscribePort: }" ;;
subscribeSalt:\ *) persistedSubscribeSalt="${line#subscribeSalt: }" ;;
configPath:\ *) persistedConfigPath="${line#configPath: }" ;;
esac
done <"${summaryFile}"
persistedContainer="${persistedContainer#"${persistedContainer%%[![:space:]]*}"}"
persistedInstallMode="${persistedInstallMode#"${persistedInstallMode%%[![:space:]]*}"}"
persistedVisionPort="${persistedVisionPort#"${persistedVisionPort%%[![:space:]]*}"}"
persistedXHTTPPort="${persistedXHTTPPort#"${persistedXHTTPPort%%[![:space:]]*}"}"
persistedXHTTPPath="${persistedXHTTPPath#"${persistedXHTTPPath%%[![:space:]]*}"}"
persistedServerName="${persistedServerName#"${persistedServerName%%[![:space:]]*}"}"
persistedPublicKey="${persistedPublicKey#"${persistedPublicKey%%[![:space:]]*}"}"
persistedUUID="${persistedUUID#"${persistedUUID%%[![:space:]]*}"}"
persistedEmailBase="${persistedEmailBase#"${persistedEmailBase%%[![:space:]]*}"}"
persistedVisionEmail="${persistedVisionEmail#"${persistedVisionEmail%%[![:space:]]*}"}"
persistedXHTTPEmail="${persistedXHTTPEmail#"${persistedXHTTPEmail%%[![:space:]]*}"}"
persistedShortId="${persistedShortId#"${persistedShortId%%[![:space:]]*}"}"
persistedSubscribePort="${persistedSubscribePort#"${persistedSubscribePort%%[![:space:]]*}"}"
persistedSubscribeSalt="${persistedSubscribeSalt#"${persistedSubscribeSalt%%[![:space:]]*}"}"
persistedConfigPath="${persistedConfigPath#"${persistedConfigPath%%[![:space:]]*}"}"
if [[ -n "${persistedContainer}" && "${persistedContainer}" != "v2ray-agent-docker" ]]; then
echoContent "red" "Persisted summary container name mismatch: ${persistedContainer}"
exit 1
fi
if [[ -n "${persistedConfigPath}" && "${persistedConfigPath}" != "${dataDir%/}/config.json" ]]; then
echoContent "red" "Persisted summary configPath mismatch: ${persistedConfigPath}"
exit 1
fi
}
# loadPersistedStateFromConfig — restore protocol mode, ports, path, and display fields from config.json.
loadPersistedStateFromConfig() {
local configFile="${dataDir%/}/config.json"
local parsedLines=()
if [[ ! -f "${configFile}" ]]; then
echoContent "red" "Persisted config not found: ${configFile}"
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echoContent "red" "python3 is required to safely parse the persisted config.json"
exit 1
fi
loadPersistedSummaryIfPresent
mapfile -t parsedLines < <(python3 - <<'PY' "${configFile}"
import json, sys
path = sys.argv[1]
try:
with open(path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
except Exception:
sys.exit(1)
vision_port = ''
xhttp_port = ''
xhttp_path = ''
server_name = ''
public_key = ''
uuid = ''
vision_email = ''
xhttp_email = ''
for inbound in data.get('inbounds', []):
tag = inbound.get('tag')
port = inbound.get('port')
if tag == 'dokodemo-in-VLESSReality' and isinstance(port, int):
vision_port = str(port)
elif tag == 'VLESSRealityXHTTP' and isinstance(port, int):
xhttp_port = str(port)
xhttp_settings = (inbound.get('streamSettings') or {}).get('xhttpSettings') or {}
raw_path = xhttp_settings.get('path') or ''
if raw_path.startswith('/'):
raw_path = raw_path[1:]
if raw_path.endswith('xHTTP'):
raw_path = raw_path[:-5]
xhttp_path = raw_path.rstrip('/')
for inbound in data.get('inbounds', []):
if inbound.get('protocol') != 'vless':
continue
settings = inbound.get('settings') or {}
clients = settings.get('clients') or []
if not clients:
continue
stream_settings = inbound.get('streamSettings') or {}
reality_settings = stream_settings.get('realitySettings') or {}
server_names = reality_settings.get('serverNames') or []
if not server_name and server_names:
server_name = server_names[0]
if not public_key:
public_key = reality_settings.get('publicKey') or ''
if not uuid:
uuid = clients[0].get('id') or ''
network = stream_settings.get('network')
if network == 'tcp':
vision_email = clients[0].get('email') or ''
elif network == 'xhttp':
xhttp_email = clients[0].get('email') or ''
if vision_port and xhttp_port:
install_mode = 'all'
elif vision_port:
install_mode = 'vision'
elif xhttp_port:
install_mode = 'xhttp'
else:
sys.exit(1)
print(install_mode)
print(vision_port)
print(xhttp_port)
print(xhttp_path)
print(server_name)
print(public_key)
print(uuid)
print(vision_email)
print(xhttp_email)
PY
)
if [[ ${#parsedLines[@]} -ne 9 ]]; then
echoContent "red" "Persisted config format error: cannot read protocol state from ${configFile}"
exit 1
fi
persistedInstallMode="${parsedLines[0]}"
persistedVisionPort="${parsedLines[1]}"
persistedXHTTPPort="${parsedLines[2]}"
persistedXHTTPPath="${parsedLines[3]}"
persistedServerName="${parsedLines[4]}"
persistedPublicKey="${parsedLines[5]}"
persistedUUID="${parsedLines[6]}"
persistedVisionEmail="${parsedLines[7]}"
persistedXHTTPEmail="${parsedLines[8]}"
if [[ -z "${persistedEmailBase}" ]]; then
persistedEmailBase="${persistedUUID%%-*}"
fi
if installModeHasVision "${persistedInstallMode}" && [[ -z "${persistedVisionEmail}" ]]; then
persistedVisionEmail="${persistedEmailBase}-VLESS_TCP/TLS_Vision"
fi
if installModeHasXHTTP "${persistedInstallMode}" && [[ -z "${persistedXHTTPEmail}" ]]; then
persistedXHTTPEmail="${persistedEmailBase}-VLESS_Reality_XHTTP"
fi
case "${persistedInstallMode}" in
vision)
[[ -n "${persistedVisionPort}" ]] || { echoContent "red" "Persisted config missing Vision port"; exit 1; }
;;
xhttp)
[[ -n "${persistedXHTTPPort}" && -n "${persistedXHTTPPath}" ]] || { echoContent "red" "Persisted config missing XHTTP port or path"; exit 1; }
;;
all)
[[ -n "${persistedVisionPort}" && -n "${persistedXHTTPPort}" && -n "${persistedXHTTPPath}" ]] || { echoContent "red" "Persisted config missing fields for 'all' install mode"; exit 1; }
;;
*)
echoContent "red" "Invalid install mode in persisted config: ${persistedInstallMode}"
exit 1
;;
esac
}
# loadPersistedPort — restore protocol mode and ports from persisted config.
loadPersistedPort() {
loadPersistedStateFromConfig
}
# getPublicIP — try to resolve the current host's public IP for account display output.
getPublicIP() {
local currentIP=""
if command -v curl >/dev/null 2>&1; then
currentIP=$(curl -fsS -4 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null | grep '^ip=' | awk -F '=' '{print $2}')
if [[ -z "${currentIP}" ]]; then
currentIP=$(curl -fsS -6 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null | grep '^ip=' | awk -F '=' '{print $2}')
fi
if [[ -z "${currentIP}" ]]; then
currentIP=$(curl -fsS https://api.ipify.org 2>/dev/null || true)
fi
fi
if [[ -z "${currentIP}" ]]; then
currentIP=$(hostname -I 2>/dev/null | awk '{print $1}' || true)
fi
printf '%s' "${currentIP}"
}
# loadPersistedAccountInfo — restore account fields needed for display from summary (preferred) or config.json.
# Sets: persistedInstallMode, persistedVisionPort, persistedXHTTPPort, persistedXHTTPPath,
# persistedServerName, persistedPublicKey, persistedUUID, persistedEmailBase,
# persistedVisionEmail, persistedXHTTPEmail, persistedShortId
loadPersistedAccountInfo() {
local configFile="${dataDir%/}/config.json"
if [[ ! -f "${configFile}" ]]; then
echoContent "red" "Persisted config not found: ${configFile}"
exit 1
fi
# Prefer summary file (most complete field set)
loadPersistedSummaryIfPresent
# Fall back to config.json for any missing critical fields
if [[ -z "${persistedInstallMode}" || -z "${persistedServerName}" || -z "${persistedUUID}" || -z "${persistedPublicKey}" ]]; then
if ! command -v python3 >/dev/null 2>&1; then
echoContent "red" "python3 is required to safely parse the persisted config.json"
exit 1
fi
local parsedLines=()
mapfile -t parsedLines < <(python3 - <<'PY' "${configFile}"
import json, sys
path = sys.argv[1]
try:
with open(path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
except Exception:
sys.exit(1)
vision_port = ''
xhttp_port = ''
xhttp_path = ''
server_name = ''
public_key = ''
uuid = ''
vision_email = ''
xhttp_email = ''
for inbound in data.get('inbounds', []):
tag = inbound.get('tag')
port = inbound.get('port')
if tag == 'dokodemo-in-VLESSReality' and isinstance(port, int):
vision_port = str(port)
elif tag == 'VLESSRealityXHTTP' and isinstance(port, int):
xhttp_port = str(port)
xhttp_settings = (inbound.get('streamSettings') or {}).get('xhttpSettings') or {}
raw_path = xhttp_settings.get('path') or ''
if raw_path.startswith('/'):
raw_path = raw_path[1:]
if raw_path.endswith('xHTTP'):
raw_path = raw_path[:-5]
xhttp_path = raw_path.rstrip('/')
for inbound in data.get('inbounds', []):
if inbound.get('protocol') != 'vless':
continue
settings = inbound.get('settings') or {}
clients = settings.get('clients') or []
if not clients:
continue
stream_settings = inbound.get('streamSettings') or {}
reality_settings = stream_settings.get('realitySettings') or {}
server_names = reality_settings.get('serverNames') or []
if not server_name and server_names:
server_name = server_names[0]
if not public_key:
public_key = reality_settings.get('publicKey') or ''
if not uuid:
uuid = clients[0].get('id') or ''
network = stream_settings.get('network')
if network == 'tcp' and not vision_email:
vision_email = clients[0].get('email') or ''
elif network == 'xhttp' and not xhttp_email:
xhttp_email = clients[0].get('email') or ''
if vision_port and xhttp_port:
install_mode = 'all'
elif vision_port:
install_mode = 'vision'
elif xhttp_port:
install_mode = 'xhttp'
else:
sys.exit(1)
print(install_mode)
print(vision_port)
print(xhttp_port)
print(xhttp_path)
print(server_name)
print(public_key)
print(uuid)
print(vision_email)
print(xhttp_email)
PY
)
if [[ ${#parsedLines[@]} -ne 9 ]]; then
echoContent "red" "Persisted config format error: failed to read account info from ${configFile}"
exit 1
fi
[[ -z "${persistedInstallMode}" ]] && persistedInstallMode="${parsedLines[0]}"
[[ -z "${persistedVisionPort}" ]] && persistedVisionPort="${parsedLines[1]}"
[[ -z "${persistedXHTTPPort}" ]] && persistedXHTTPPort="${parsedLines[2]}"
[[ -z "${persistedXHTTPPath}" ]] && persistedXHTTPPath="${parsedLines[3]}"
[[ -z "${persistedServerName}" ]] && persistedServerName="${parsedLines[4]}"
[[ -z "${persistedPublicKey}" ]] && persistedPublicKey="${parsedLines[5]}"
[[ -z "${persistedUUID}" ]] && persistedUUID="${parsedLines[6]}"
[[ -z "${persistedVisionEmail}" ]] && persistedVisionEmail="${parsedLines[7]}"
[[ -z "${persistedXHTTPEmail}" ]] && persistedXHTTPEmail="${parsedLines[8]}"
fi
# Fill in any still-missing email fields
if [[ -z "${persistedEmailBase}" && -n "${persistedUUID}" ]]; then
persistedEmailBase="${persistedUUID%%-*}"
fi
if installModeHasVision "${persistedInstallMode}" && [[ -z "${persistedVisionEmail}" ]]; then
persistedVisionEmail="${persistedEmailBase}-VLESS_TCP/TLS_Vision"
fi
if installModeHasXHTTP "${persistedInstallMode}" && [[ -z "${persistedXHTTPEmail}" ]]; then
persistedXHTTPEmail="${persistedEmailBase}-VLESS_Reality_XHTTP"
fi
}
# hasPersistedConfig — check whether a persisted config already exists under dataDir.
hasPersistedConfig() {
local configFile="${dataDir%/}/config.json"
[[ -f "${configFile}" ]]
}
# hasManagedContainer — check whether the target Docker container exists (running or stopped).
hasManagedContainer() {
docker ps -a --filter "name=^/v2ray-agent-docker$" --format '{{.Names}}' 2>/dev/null | grep -q '^v2ray-agent-docker$'
}
# hasExistingInstallState — treat either config presence or container presence as existing/residual install state.
hasExistingInstallState() {
if hasPersistedConfig || hasManagedContainer; then
return 0
fi
return 1
}
userManageMenu() {
local action=""
if ! hasPersistedConfig; then
echoContent "red" "No existing config found. Cannot enter user management; reinstall first."
return 0
fi
echoContent "skyBlue" "─── User Management ─────────────────────────────────────"
echoContent "yellow" "1. View account"
echoContent "yellow" "2. View subscription"
echoContent "yellow" "3. Exit"
while true; do
action="$(promptValue $'Choose [1-3]: ' "")"
case "${action}" in
1)
showAccountInfo
exit 0
;;
2)
showSubscriptionInfo
exit 0
;;
3)
echoContent "white" "Exiting without changes."
exit 0
;;
*)
echoContent "red" "Invalid selection. Please enter 1-3."
;;
esac
done
}
# uninstallDockerReality — remove the standalone Docker Reality container, data, script shortcut, and installed script copy.
uninstallDockerReality() {
local answer=""
local installedScript="/etc/v2ray-agent/docker_reality_en.sh"
answer="$(promptValue $'Confirm uninstall of Docker Reality content? [y/N]: ' "")"
case "${answer}" in
[yY] | [yY][eE][sS])
;;
*)
echoContent "green" " ---> Uninstall cancelled"
return 0
;;
esac
if hasManagedContainer; then
docker rm -f v2ray-agent-docker >/dev/null 2>&1 || true
echoContent "green" " ---> Docker container removed"
fi
if docker ps -a --filter "name=^/v2ray-agent-docker-subscribe$" --format '{{.Names}}' 2>/dev/null | grep -q '^v2ray-agent-docker-subscribe$'; then
docker rm -f v2ray-agent-docker-subscribe >/dev/null 2>&1 || true
echoContent "green" " ---> Subscription access container removed"
fi
rm -rf "${dataDir%/}" >/dev/null 2>&1 || true
echoContent "green" " ---> Docker Reality data directory removed"
rm -rf /usr/bin/vasmad >/dev/null 2>&1 || true
rm -rf /usr/sbin/vasmad >/dev/null 2>&1 || true
echoContent "green" " ---> Shortcut removed"
if [[ -f "${installedScript}" ]]; then
rm -f "${installedScript}" >/dev/null 2>&1 || true
echoContent "green" " ---> Installed script removed"
fi
exit 0
}
# promptExistingInstallAction — offer an interactive menu based on combined config/container state.
promptExistingInstallAction() {
local action=""
local hasConfig=1
local hasContainer=1
if [[ "${nonInteractive}" == "1" || "${startOnly}" == "1" || "${generateOnly}" == "1" ]]; then
return 0
fi
if hasPersistedConfig; then
hasConfig=0
fi
if hasManagedContainer; then
hasContainer=0
fi
echoContent "skyBlue" ""
if [[ ${hasConfig} -ne 0 && ${hasContainer} -ne 0 ]]; then
echoContent "skyBlue" "─── No Existing Docker Reality Installation Detected ───────────────"
echoContent "white" "No config file or container was detected. Choose the next action:"
echoContent "yellow" "1. Install"
echoContent "yellow" "2. Exit"
while true; do
action="$(promptValue $'Choose [1-2]: ' "")"
case "${action}" in
1)
echoContent "yellow" "Install selected. The script will collect values and create the container."
return 0
;;
2)
echoContent "white" "Exiting without changes."
exit 0
;;
*)
echoContent "red" "Invalid selection. Please enter 1-2."
;;
esac
done
fi
echoContent "skyBlue" "─── Existing/Residual Docker Reality State Detected ───────────────"
if [[ ${hasConfig} -eq 0 && ${hasContainer} -eq 0 ]]; then
echoContent "white" "Detected both the config file and the container. Choose the next action:"
elif [[ ${hasConfig} -eq 0 ]]; then
echoContent "white" "Detected the config file but not the container. Choose the next action:"
else
echoContent "white" "Detected the container but not the config file. Choose the next action:"
fi
echoContent "yellow" "1. User management"
echoContent "yellow" "2. Reinstall"
echoContent "yellow" "3. Start/Recreate container"
echoContent "yellow" "4. Uninstall"
echoContent "yellow" "5. Exit"
while true; do
action="$(promptValue $'Choose [1-5]: ' "")"
case "${action}" in
1)
if hasPersistedConfig; then
userManageMenu
exit 0
else
echoContent "red" "No existing config was found, so user management cannot be opened. Please reinstall first."
fi
;;
2)
echoContent "yellow" "Reinstall selected. The script will collect new values and recreate the container."
return 0
;;
3)
if ! hasExistingInstallState; then
echoContent "red" "No installed state was detected, so the container cannot be started/recreated. Please install first."
continue
fi
if ! hasPersistedConfig; then
echoContent "red" "No existing config was found, so the container cannot be started/recreated. Please reinstall first."
continue
fi
startOnly=1
echoContent "yellow" "Using the existing config to start/recreate the container."
return 0
;;
4)
uninstallDockerReality
;;
5)
echoContent "white" "Exiting without changes."
exit 0
;;
*)
echoContent "red" "Invalid selection. Please enter 1-5."
;;
esac
done
}
# showVisionAccount — display the Vision account in a showAccounts-style layout.
# Args: $1=address $2=port $3=serverName $4=publicKey $5=uuid $6=email $7=shortId
showVisionAccount() {
local displayAddress="$1"
local displayPort="$2"
local displayServerName="$3"
local displayPublicKey="$4"
local displayUUID="$5"
local displayEmail="$6"
local displayShortId="$7"
local vlessLink qrData qrLink
if [[ -z "${displayAddress}" ]]; then
displayAddress="YOUR_SERVER_IP"
fi
vlessLink="$(buildVisionSubscriptionLink "${displayAddress}" "${displayPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayEmail}" "${displayShortId}")"
if [[ -z "${vlessLink}" ]]; then
echoContent "yellow" " ---> Vision account data is incomplete; skipping subscription link output"
return 0
fi
qrData="${vlessLink//:/%3A}"
qrData="${qrData//\//%2F}"
qrData="${qrData//@/%40}"
qrData="${qrData//\?/%3F}"
qrData="${qrData//&/%26}"
qrData="${qrData//#/%23}"
qrData="${qrData//=/%3D}"
qrLink="https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${qrData}"
echoContent "skyBlue" "============================= VLESS Reality Vision [Recommended] =============================="
echoContent "skyBlue" ""
echoContent "skyBlue" " ---> Account: ${displayEmail}"
echoContent "white" ""
echoContent "yellow" " ---> Universal Format (VLESS+reality+uTLS+Vision)"
echoContent "green" " ${vlessLink}"
echoContent "white" ""
echoContent "yellow" " ---> Formatted Plaintext (VLESS+reality+uTLS+Vision)"
echoContent "green" "Protocol: VLESS reality, Address: ${displayAddress}, publicKey: ${displayPublicKey}, shortId: ${displayShortId}, serverNames: ${displayServerName}, Port: ${displayPort}, UserID: ${displayUUID}, Transport: tcp, Account: ${displayEmail}"
echoContent "white" ""
echoContent "yellow" " ---> QR Code VLESS (VLESS+reality+uTLS+Vision)"
echoContent "green" " ${qrLink}"
}
# showXHTTPAccount — display the XHTTP account in a showAccounts-style layout.
# Args: $1=address $2=port $3=serverName $4=publicKey $5=uuid $6=email $7=shortId $8=renderedPath
showXHTTPAccount() {
local displayAddress="$1"
local displayPort="$2"
local displayServerName="$3"
local displayPublicKey="$4"
local displayUUID="$5"
local displayEmail="$6"
local displayShortId="$7"
local displayPath="$8"
local vlessLink qrLink
if [[ -z "${displayAddress}" ]]; then
displayAddress="YOUR_SERVER_IP"
fi
vlessLink="$(buildXHTTPSubscriptionLink "${displayAddress}" "${displayPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayEmail}" "${displayShortId}" "${displayPath}")"
if [[ -z "${vlessLink}" ]]; then
echoContent "yellow" " ---> XHTTP account data is incomplete; skipping subscription link output"
return 0
fi
qrLink="https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=vless%3A%2F%2F${displayUUID}%40${displayAddress}%3A${displayPort}%3Fencryption%3Dnone%26security%3Dreality%26type%3Dxhttp%26sni%3D${displayServerName}%26fp%3Dchrome%26path%3D${displayPath}%26host%3D${displayServerName}%26pbk%3D${displayPublicKey}%26sid%3D${displayShortId}%23${displayEmail}"
echoContent "skyBlue" "============================= VLESS Reality XHTTP =============================="
echoContent "skyBlue" ""
echoContent "skyBlue" " ---> Account: ${displayEmail}"
echoContent "white" ""
echoContent "yellow" " ---> Universal Format (VLESS+reality+xhttp)"
echoContent "green" " ${vlessLink}"
echoContent "white" ""
echoContent "yellow" " ---> Formatted Plaintext (VLESS+reality+xhttp)"
echoContent "green" "Protocol: VLESS reality, Address: ${displayAddress}, publicKey: ${displayPublicKey}, shortId: ${displayShortId}, serverNames: ${displayServerName}, Port: ${displayPort}, Path: ${displayPath}, SNI: ${displayServerName}, Host: ${displayServerName}, UserID: ${displayUUID}, Transport: xhttp, Account: ${displayEmail}"
echoContent "white" ""
echoContent "yellow" " ---> QR Code VLESS (VLESS+reality+xhttp)"
echoContent "green" " ${qrLink}"
}
# showEnglishSubscriptionSection — print the local subscription content and security note.
showEnglishSubscriptionSection() {
local displayAddress="$1"
local mode="$2"
local displayServerName="$3"
local displayPublicKey="$4"
local displayUUID="$5"
local displayVisionPort="$6"
local displayXHTTPPort="$7"
local displayXHTTPPath="$8"
local displayVisionEmail="$9"
local displayXHTTPEmail="${10}"
local displayShortId="${11}"
local visionLink=""
local xhttpLink=""
local renderedPath=""
local subscribeName=""
local subscribeSalt=""
local emailMd5=""
local defaultLocalFile=""
local defaultPublicFile=""
local clashLocalFile=""
local clashPublicFile=""
local clashProfileFile=""
local subscribeDomain=""
local subscribeURL=""
local clashProxyURL=""
local clashProfileURL=""
if [[ -z "${displayAddress}" ]]; then
displayAddress="YOUR_SERVER_IP"
fi
subscribeName="${displayVisionEmail:-${displayXHTTPEmail}}"
subscribeName="${subscribeName%%-*}"
if [[ -z "${subscribeName}" ]]; then
echoContent "yellow" " ---> Subscription account data is incomplete; skipping subscription file generation"
return 0
fi
mkdir -p "${dataDir%/}/subscribe_local/default" "${dataDir%/}/subscribe_local/clashMeta" "${dataDir%/}/subscribe/default" "${dataDir%/}/subscribe/clashMeta" "${dataDir%/}/subscribe/clashMetaProfiles"
defaultLocalFile="${dataDir%/}/subscribe_local/default/${subscribeName}"
clashLocalFile="${dataDir%/}/subscribe_local/clashMeta/${subscribeName}"
: >"${defaultLocalFile}"
: >"${clashLocalFile}"
if installModeHasVision "${mode}"; then
visionLink="$(buildVisionSubscriptionLink "${displayAddress}" "${displayVisionPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayVisionEmail}" "${displayShortId}")" || visionLink=""
if [[ -n "${visionLink}" ]]; then
printf '%s\n' "${visionLink}" >>"${defaultLocalFile}"
writeVisionClashMetaNode "${clashLocalFile}" "${displayAddress}" "${displayVisionPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayVisionEmail}" "${displayShortId}" || true
fi
fi
if installModeHasXHTTP "${mode}"; then
renderedPath="$(renderXHTTPPath "${displayXHTTPPath}")"
xhttpLink="$(buildXHTTPDefaultSubscriptionLink "${displayAddress}" "${displayXHTTPPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayXHTTPEmail}" "${displayShortId}" "${renderedPath}")" || xhttpLink=""
if [[ -n "${xhttpLink}" ]]; then
printf '%s\n' "${xhttpLink}" >>"${defaultLocalFile}"
writeXHTTPClashMetaNode "${clashLocalFile}" "${displayAddress}" "${displayXHTTPPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayXHTTPEmail}" "${displayShortId}" "${renderedPath}" || true
fi
fi
if [[ ! -s "${defaultLocalFile}" ]]; then
echoContent "yellow" " ---> Subscription account data is incomplete; no usable subscription content was generated"
return 0
fi
subscribeSalt="$(generateSubscribeSalt)"
emailMd5="$(md5Text "${subscribeName}${subscribeSalt}"$'\n')"
defaultPublicFile="${dataDir%/}/subscribe/default/${emailMd5}"
base64FileSingleLine "${defaultLocalFile}" >"${defaultPublicFile}"
startSubscribeContainer "${displayAddress}"
subscribeDomain="${displayAddress}:${persistedSubscribePort:-${resolvedSubscribePort}}"
subscribeURL="http://${subscribeDomain}/s/default/${emailMd5}"
if [[ -s "${clashLocalFile}" ]]; then
clashPublicFile="${dataDir%/}/subscribe/clashMeta/${emailMd5}"
clashProfileFile="${dataDir%/}/subscribe/clashMetaProfiles/${emailMd5}"
clashProxyURL="http://${subscribeDomain}/s/clashMeta/${emailMd5}"
clashProfileURL="http://${subscribeDomain}/s/clashMetaProfiles/${emailMd5}"
printf 'proxies:\n' >"${clashPublicFile}"
cat "${clashLocalFile}" >>"${clashPublicFile}"
writeClashMetaProfile "${clashProfileFile}" "${clashProxyURL}" "${subscribeSalt}_provider"
fi
echoContent "skyBlue" "============================= Default subscription =============================="
echoContent "white" "Plain HTTP subscription transport can expose UUID, Reality parameters, SNI, path, ports, and connection details."
echoContent "white" "The Docker edition serves this subscription through a local HTTP container; do not share the URL over untrusted networks."
echoContent "white" ""
echoContent "green" "email:${subscribeName}"
echoContent "yellow" "url:${subscribeURL}"
echoContent "yellow" "Online QR code:https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=$(urlEncode "${subscribeURL}")"
if [[ -n "${clashProfileURL}" ]]; then
echoContent "skyBlue" "\n----------clashMeta/Clash Verge subscription----------\n"
echoContent "yellow" "url:${clashProfileURL}"
echoContent "yellow" "Online QR code:https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=$(urlEncode "${clashProfileURL}")"
fi
}
# resolveValues — resolve all CLI-supplied or blank values into resolved* globals.
# Depends on: installMode, port, xhttpPort, xhttpPath, serverName, privateKey, uuid, email
# Sets: resolvedInstallMode, resolvedVisionPort, resolvedXHTTPPort, resolvedXHTTPPath,
# resolvedServerName, resolvedTargetPort, resolvedPrivateKey, resolvedPublicKey,
# resolvedUUID, resolvedEmailBase, resolvedVisionEmail, resolvedXHTTPEmail
# Returns 0 on success; exits on invalid input.
resolveValues() {
local xrayRuntime
xrayRuntime="$(_xrayDockerRuntime)"
# --- Install mode ---
normalizeInstallMode "${installMode}"
# resolvedInstallMode is set by normalizeInstallMode
# --- Vision port ---
if installModeHasVision "${resolvedInstallMode}"; then
if ! parsePort "${port}"; then
exit 1
fi
resolvedVisionPort="${resolvedPort}"
checkPortInUse "${resolvedVisionPort}"
fi
# --- XHTTP port and path ---
if installModeHasXHTTP "${resolvedInstallMode}"; then
if ! parsePort "${xhttpPort}"; then
exit 1
fi
resolvedXHTTPPort="${resolvedPort}"
# Ensure XHTTP port does not collide with Vision port in 'all' mode
if installModeHasVision "${resolvedInstallMode}" && [[ "${resolvedXHTTPPort}" == "${resolvedVisionPort}" ]]; then
echoContent "red" "XHTTP port (${resolvedXHTTPPort}) is the same as the Vision port — please use different ports"
exit 1
fi
checkPortInUse "${resolvedXHTTPPort}"
normalizeXHTTPPath "${xhttpPath}"
resolvedXHTTPPath="${resolvedXHTTPPath:-${resolvedXHTTPPath}}"
fi
if ! parsePort "${subscribePort}"; then
exit 1
fi
resolvedSubscribePort="${resolvedPort}"
if installModeHasVision "${resolvedInstallMode}" && [[ "${resolvedSubscribePort}" == "${resolvedVisionPort}" ]]; then
echoContent "red" "Subscription port (${resolvedSubscribePort}) is the same as the Vision port — please use different ports"
exit 1
fi
if installModeHasXHTTP "${resolvedInstallMode}" && [[ "${resolvedSubscribePort}" == "${resolvedXHTTPPort}" ]]; then
echoContent "red" "Subscription port (${resolvedSubscribePort}) is the same as the XHTTP port — please use different ports"
exit 1
fi
checkPortInUse "${resolvedSubscribePort}"
# --- Server name ---
parseServerName "${serverName}"
# --- Private key ---
if [[ -z "${privateKey}" ]]; then
if ! generatePrivateKey "${xrayRuntime}"; then
exit 1
fi
else
resolvedPrivateKey="${privateKey}"
if ! derivePublicKey "${xrayRuntime}" "${resolvedPrivateKey}"; then
echoContent "red" "Provided private key is invalid"
exit 1
fi
fi
# --- UUID ---
if [[ -z "${uuid}" ]]; then
if ! generateUUID "${xrayRuntime}"; then
exit 1
fi
else
resolvedUUID="${uuid}"
fi
# --- Email base and per-protocol emails ---
if [[ -z "${email}" ]]; then
deriveEmail "${resolvedUUID}"
resolvedEmailBase="${resolvedEmail}"
else
resolvedEmailBase="${email}"
resolvedEmail="${email}"
fi
resolvedVisionEmail="${resolvedEmailBase}-VLESS_TCP/TLS_Vision"
resolvedXHTTPEmail="${resolvedEmailBase}-VLESS_Reality_XHTTP"
if installModeHasVision "${resolvedInstallMode}"; then
echoContent "green" "Resolved Vision port: ${resolvedVisionPort}"
fi
if installModeHasXHTTP "${resolvedInstallMode}"; then
echoContent "green" "Resolved XHTTP port: ${resolvedXHTTPPort}"
echoContent "green" "Resolved XHTTP path: $(renderXHTTPPath "${resolvedXHTTPPath}")"
fi
echoContent "green" "Resolved server name: ${resolvedServerName}:${resolvedTargetPort}"
echoContent "green" "Resolved email base: ${resolvedEmailBase}"
}
# ---------------------------------------------------------------------------
# _buildVisionInbounds — emit the two Vision inbound JSON blocks (no trailing comma)
# Args: $1=visionPort $2=serverName $3=targetPort $4=privateKey $5=publicKey
# $6=uuid $7=visionEmail
# ---------------------------------------------------------------------------
_buildVisionInbounds() {
local vPort="$1" sName="$2" tPort="$3" privKey="$4" pubKey="$5"
local vuuid="$6" vEmail="$7"
cat <<VEOF
{
"tag": "dokodemo-in-VLESSReality",
"port": ${vPort},
"protocol": "dokodemo-door",
"settings": {
"address": "127.0.0.1",
"port": 45987,
"network": "tcp"
},
"sniffing": {
"enabled": true,
"destOverride": ["tls"],
"routeOnly": true
}
},
{
"listen": "127.0.0.1",
"port": 45987,
"protocol": "vless",
"tag": "VLESSRealityVision",
"settings": {
"clients": [
{
"id": "$(jsonEscape "${vuuid}")",
"flow": "xtls-rprx-vision",
"email": "$(jsonEscape "${vEmail}")"
}
],
"decryption": "none",
"fallbacks": []
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"show": false,
"target": "$(jsonEscape "${sName}:${tPort}")",
"xver": 0,
"serverNames": ["$(jsonEscape "${sName}")"],
"privateKey": "$(jsonEscape "${privKey}")",
"publicKey": "$(jsonEscape "${pubKey}")",
"minClientVer": "1.8.2",
"maxTimeDiff": 70000,
"shortIds": ["", "6ba85179e30d4fc2"]
}
},
"sniffing": {
"enabled": true,
"destOverride": ["http", "tls", "quic"],
"routeOnly": true
}
}
VEOF
}
# ---------------------------------------------------------------------------
# _buildXHTTPInbound — emit the XHTTP inbound JSON block (no trailing comma)
# Args: $1=xhttpPort $2=serverName $3=targetPort $4=privateKey $5=publicKey
# $6=uuid $7=xhttpEmail $8=renderedPath
# ---------------------------------------------------------------------------
_buildXHTTPInbound() {
local xPort="$1" sName="$2" tPort="$3" privKey="$4" pubKey="$5"
local xuuid="$6" xEmail="$7" xPath="$8"
cat <<XEOF
{
"port": ${xPort},
"listen": "0.0.0.0",
"protocol": "vless",
"tag": "VLESSRealityXHTTP",
"settings": {
"clients": [
{
"id": "$(jsonEscape "${xuuid}")",
"email": "$(jsonEscape "${xEmail}")"
}
],
"decryption": "none"
},
"streamSettings": {
"network": "xhttp",
"security": "reality",
"realitySettings": {
"show": false,
"target": "$(jsonEscape "${sName}:${tPort}")",
"xver": 0,
"serverNames": ["$(jsonEscape "${sName}")"],
"privateKey": "$(jsonEscape "${privKey}")",
"publicKey": "$(jsonEscape "${pubKey}")",
"minClientVer": "1.8.2",
"maxTimeDiff": 70000,
"shortIds": ["", "6ba85179e30d4fc2"]
},
"xhttpSettings": {
"host": "$(jsonEscape "${sName}")",
"path": "$(jsonEscape "${xPath}")",
"mode": "auto"
}
}
}
XEOF
}
# ---------------------------------------------------------------------------
# generateConfig — write the Xray Reality config and client summary to dataDir
# Supports installMode: vision / xhttp / all
# ---------------------------------------------------------------------------
generateConfig() {
# Strip trailing slash for unambiguous path concatenation
dataDir="${dataDir%/}"
echoContent "white" "Generating Reality config in ${dataDir} ..."
resolveValues
# Create the data directory; exit cleanly if the path cannot be created
if ! mkdir -p "${dataDir}" 2>/dev/null; then
echoContent "red" "Failed to create data directory: ${dataDir}"
echoContent "red" "Check that the parent path exists and is a directory, not a regular file"
exit 1
fi
local configFile="${dataDir}/config.json"
local summaryFile="${dataDir}/client-summary.txt"
# Remove stale directory remnants left by previous failed runs
if [[ -d "${configFile}" ]]; then
echoContent "yellow" "Removing stale directory at ${configFile} left by a previous failed run"
rm -rf "${configFile}" 2>/dev/null || { echoContent "red" "Cannot remove stale directory: ${configFile}"; exit 1; }
fi
if [[ -d "${summaryFile}" ]]; then
echoContent "yellow" "Removing stale directory at ${summaryFile} left by a previous failed run"
rm -rf "${summaryFile}" 2>/dev/null || { echoContent "red" "Cannot remove stale directory: ${summaryFile}"; exit 1; }
fi
local renderedXHTTPPath=""
if installModeHasXHTTP "${resolvedInstallMode}"; then
renderedXHTTPPath="$(renderXHTTPPath "${resolvedXHTTPPath}")"
fi
# --- Build inbounds array content ---
local inboundsContent=""
case "${resolvedInstallMode}" in
vision)
inboundsContent="$(_buildVisionInbounds \
"${resolvedVisionPort}" "${resolvedServerName}" "${resolvedTargetPort}" \
"${resolvedPrivateKey}" "${resolvedPublicKey}" \
"${resolvedUUID}" "${resolvedVisionEmail}")"
;;
xhttp)
inboundsContent="$(_buildXHTTPInbound \
"${resolvedXHTTPPort}" "${resolvedServerName}" "${resolvedTargetPort}" \
"${resolvedPrivateKey}" "${resolvedPublicKey}" \
"${resolvedUUID}" "${resolvedXHTTPEmail}" "${renderedXHTTPPath}")"
;;
all)
local visionPart xhttpPart
visionPart="$(_buildVisionInbounds \
"${resolvedVisionPort}" "${resolvedServerName}" "${resolvedTargetPort}" \
"${resolvedPrivateKey}" "${resolvedPublicKey}" \
"${resolvedUUID}" "${resolvedVisionEmail}")"
xhttpPart="$(_buildXHTTPInbound \
"${resolvedXHTTPPort}" "${resolvedServerName}" "${resolvedTargetPort}" \
"${resolvedPrivateKey}" "${resolvedPublicKey}" \
"${resolvedUUID}" "${resolvedXHTTPEmail}" "${renderedXHTTPPath}")"
inboundsContent="${visionPart},"$'\n'"${xhttpPart}"
;;
esac
# --- Build routing rules ---
# Vision mode needs dokodemo-door routing rules; XHTTP listens directly on the public port
local routingRules=""
if installModeHasVision "${resolvedInstallMode}"; then
routingRules=$(cat <<REOF
{
"inboundTag": ["dokodemo-in-VLESSReality"],
"domain": ["$(jsonEscape "${resolvedServerName}")"],
"outboundTag": "direct"
},
{
"inboundTag": ["dokodemo-in-VLESSReality"],
"outboundTag": "blackhole"
}
REOF
)
else
routingRules=$(cat <<REOF
{
"type": "field",
"outboundTag": "direct",
"network": "udp,tcp"
}
REOF
)
fi
local oldUmask
oldUmask="$(umask)"
umask 077
cat >"${configFile}" <<EOF
{
"inbounds": [
${inboundsContent}
],
"routing": {
"rules": [
${routingRules}
]
},
"outbounds": [
{
"tag": "direct",
"protocol": "freedom"
},
{
"tag": "blackhole",
"protocol": "blackhole"
}
]
}
EOF
umask "${oldUmask}"
# Apply strict permissions to the config file (contains private key)
chmod 600 "${configFile}"
# --- Write human-readable client summary ---
# Fields are strictly aligned with loadPersistedSummaryIfPresent parsing logic
{
printf 'container: v2ray-agent-docker\n'
printf 'installMode: %s\n' "${resolvedInstallMode}"
if installModeHasVision "${resolvedInstallMode}"; then
printf 'visionPort: %s\n' "${resolvedVisionPort}"
fi
if installModeHasXHTTP "${resolvedInstallMode}"; then
printf 'xhttpPort: %s\n' "${resolvedXHTTPPort}"
printf 'xhttpPath: %s\n' "${resolvedXHTTPPath}"
fi
printf 'serverName: %s\n' "${resolvedServerName}"
printf 'publicKey: %s\n' "${resolvedPublicKey}"
printf 'uuid: %s\n' "${resolvedUUID}"
printf 'emailBase: %s\n' "${resolvedEmailBase}"
if installModeHasVision "${resolvedInstallMode}"; then
printf 'visionEmail: %s\n' "${resolvedVisionEmail}"
fi
if installModeHasXHTTP "${resolvedInstallMode}"; then
printf 'xhttpEmail: %s\n' "${resolvedXHTTPEmail}"
fi
printf 'shortId: 6ba85179e30d4fc2\n'
printf 'subscribePort: %s\n' "${resolvedSubscribePort}"
printf 'subscribeSalt: %s\n' "$(initRandomSalt)"
printf 'configPath: %s\n' "${configFile}"
} >"${summaryFile}"
chmod 600 "${summaryFile}"
# Post-write validation: confirm both outputs are regular files
if [[ ! -f "${configFile}" ]]; then
echoContent "red" "Config write failed — ${configFile} is not a regular file after write"
exit 1
fi
if [[ ! -f "${summaryFile}" ]]; then
echoContent "red" "Summary write failed — ${summaryFile} is not a regular file after write"
exit 1
fi
echoContent "green" "Config written to ${configFile}"
echoContent "green" "Summary written to ${summaryFile}"
}
# startContainer — validate config, remove existing container, and recreate.
# Uses config under dataDir; called after generateConfig or directly in --start-only mode.
startContainer() {
local xrayImage="ghcr.io/xtls/xray-core:26.5.9"
local containerName="v2ray-agent-docker"
# Strip trailing slash for consistent path concatenation
dataDir="${dataDir%/}"
local configFile="${dataDir}/config.json"
# Verify the config file exists before attempting anything
if [[ ! -f "${configFile}" ]]; then
echoContent "red" "Config file not found: ${configFile}"
echoContent "red" "Run without --start-only to generate the config first"
exit 1
fi
# --- Validate config using directory mount ---
echoContent "white" "Pulling Xray image ${xrayImage} (if not cached)..."
docker pull "${xrayImage}" >/dev/null 2>&1 || true
echoContent "white" "Validating config with Xray image (directory mount)..."
if ! docker run --rm \
--user root \
-v "${dataDir}:/usr/local/etc/xray:ro" \
"${xrayImage}" \
run -test -c /usr/local/etc/xray/config.json; then
echoContent "red" "Xray config validation failed — container will not be started"
exit 1
fi
echoContent "green" "Xray config validation passed"
# --- Remove any existing container with the same name to avoid name conflicts ---
if docker ps -a --filter "name=^/${containerName}$" --format '{{.Names}}' | grep -q "^${containerName}$"; then
echoContent "yellow" "Removing existing container: ${containerName}"
docker rm -f "${containerName}" >/dev/null 2>&1 || true
fi
# --- Determine the port list to publish ---
# start-only mode restores protocol mode and ports from persisted summary/config
local activeInstallMode activeVisionPort activeXHTTPPort
if [[ "${startOnly}" == "1" ]]; then
loadPersistedPort
activeInstallMode="${persistedInstallMode}"
activeVisionPort="${persistedVisionPort}"
activeXHTTPPort="${persistedXHTTPPort}"
else
activeInstallMode="${resolvedInstallMode}"
activeVisionPort="${resolvedVisionPort}"
activeXHTTPPort="${resolvedXHTTPPort}"
fi
# Build the -p argument list
local portArgs=()
if installModeHasVision "${activeInstallMode}" && [[ -n "${activeVisionPort}" ]]; then
portArgs+=("-p" "${activeVisionPort}:${activeVisionPort}")
fi
if installModeHasXHTTP "${activeInstallMode}" && [[ -n "${activeXHTTPPort}" ]]; then
portArgs+=("-p" "${activeXHTTPPort}:${activeXHTTPPort}")
fi
if [[ ${#portArgs[@]} -eq 0 ]]; then
echoContent "red" "Cannot determine ports to publish — check the persisted config"
exit 1
fi
echoContent "white" "Starting container ${containerName} (ports: ${portArgs[*]})..."
if ! docker run -d \
--name "${containerName}" \
--restart unless-stopped \
--user root \
"${portArgs[@]}" \
-v "${dataDir}:/usr/local/etc/xray:ro" \
"${xrayImage}" \
run -c /usr/local/etc/xray/config.json; then
echoContent "red" "Failed to start container ${containerName}"
exit 1
fi
# --- Verify the container is actually running ---
local runningName
runningName=$(docker ps --filter "name=^/${containerName}$" --format '{{.Names}}' 2>/dev/null || true)
if [[ "${runningName}" != "${containerName}" ]]; then
echoContent "red" "Container ${containerName} failed to reach running state"
docker logs "${containerName}" 2>/dev/null || true
exit 1
fi
}
# showAccountInfo — print the current Reality account in a showAccounts-style layout.
# Displays only the protocol blocks that are actually installed.
showAccountInfo() {
local displayAddress=""
loadPersistedAccountInfo
displayAddress="$(getPublicIP)"
local shortId="${persistedShortId:-6ba85179e30d4fc2}"
local mode="${persistedInstallMode}"
if installModeHasVision "${mode}"; then
showVisionAccount \
"${displayAddress}" \
"${persistedVisionPort}" \
"${persistedServerName}" \
"${persistedPublicKey}" \
"${persistedUUID}" \
"${persistedVisionEmail}" \
"${shortId}"
fi
if installModeHasXHTTP "${mode}"; then
local renderedPath
renderedPath="$(renderXHTTPPath "${persistedXHTTPPath}")"
showXHTTPAccount \
"${displayAddress}" \
"${persistedXHTTPPort}" \
"${persistedServerName}" \
"${persistedPublicKey}" \
"${persistedUUID}" \
"${persistedXHTTPEmail}" \
"${shortId}" \
"${renderedPath}"
fi
}
showSubscriptionInfo() {
local displayAddress=""
local shortId="${persistedShortId:-6ba85179e30d4fc2}"
local mode=""
loadPersistedAccountInfo
ensureSubscribeRuntimeValues
displayAddress="$(getPublicIP)"
shortId="${persistedShortId:-6ba85179e30d4fc2}"
mode="${persistedInstallMode}"
showEnglishSubscriptionSection \
"${displayAddress}" \
"${mode}" \
"${persistedServerName}" \
"${persistedPublicKey}" \
"${persistedUUID}" \
"${persistedVisionPort}" \
"${persistedXHTTPPort}" \
"${persistedXHTTPPath}" \
"${persistedVisionEmail}" \
"${persistedXHTTPEmail}" \
"${shortId}"
}
showClientInfo() {
showAccountInfo
}
# ---------------------------------------------------------------------------
# main — top-level flow
# ---------------------------------------------------------------------------
main() {
parseCli "$@"
if [[ "${generateOnly}" == "1" && "${startOnly}" == "1" ]]; then
echoContent "red" "--generate-only and --start-only are mutually exclusive"
exit 1
fi
checkEnvironment
selfInstallShortcut
checkDocker
promptExistingInstallAction
promptInteractiveValues
if [[ "${startOnly}" != "1" ]]; then
generateConfig
fi
if [[ "${generateOnly}" != "1" ]]; then
startContainer
showClientInfo
fi
}
main "$@"