Files
v2ray-agent/shell/docker_reality.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

2473 lines
88 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# docker_reality.sh — 基于 Docker 的 VLESS-Reality 独立启动器(无需域名)
# 用法:bash shell/docker_reality.sh [选项]
export LANG=en_US.UTF-8
# ---------------------------------------------------------------------------
# 输出辅助函数 — 与 install.sh 中的 echoContent 风格保持一致
# ---------------------------------------------------------------------------
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
}
# ---------------------------------------------------------------------------
# 默认值
# ---------------------------------------------------------------------------
nonInteractive=0
dataDir="/etc/v2ray-agent/docker/"
installMode=""
port=""
xhttpPort=""
xhttpPath=""
serverName=""
privateKey=""
uuid=""
email=""
generateOnly=0
startOnly=0
skipSelfInstall=0
subscribePort=""
resolvedSubscribePort=""
# ---------------------------------------------------------------------------
# QA 覆盖:设置 V2RAY_AGENT_FORCE_NO_DOCKER=1 可在测试中跳过 Docker 检查
# ---------------------------------------------------------------------------
forceNoDocker="${V2RAY_AGENT_FORCE_NO_DOCKER:-0}"
# ---------------------------------------------------------------------------
# showHelp — 打印用法说明并退出
# ---------------------------------------------------------------------------
showHelp() {
echoContent "skyBlue" "docker_reality.sh — 通过 Docker 运行 VLESS-Reality(无需域名)"
echoContent "white" ""
echoContent "white" "用法:"
echoContent "white" " bash shell/docker_reality.sh [选项]"
echoContent "white" ""
echoContent "white" "运行模式:"
echoContent "white" " 默认 / 交互模式 先检测安装状态,再显示安装/重装/启动相关菜单"
echoContent "white" " --non-interactive 非交互模式;若必填值缺失则确定性地报错退出"
echoContent "white" " --generate-only 仅生成配置和摘要文件,然后退出"
echoContent "white" " --start-only 复用已有配置,仅启动/重建容器"
echoContent "white" " --skip-self-install 跳过脚本迁移与 vasmad 快捷方式创建"
echoContent "white" ""
echoContent "white" "选项:"
echoContent "white" " --non-interactive 非交互运行;所有必填值须通过参数提供"
echoContent "white" " --data-dir <路径> 持久化配置/数据目录(默认:/etc/v2ray-agent/docker/"
echoContent "white" " --install-mode <模式> 安装模式:vision / xhttp / all"
echoContent "white" " --port <端口> Vision 或单协议模式的外部监听端口(留空则随机)"
echoContent "white" " --xhttp-port <端口> XHTTP 模式端口(留空则随机)"
echoContent "white" " --xhttp-path <路径> XHTTP path 基础值(最终写为 /<path>xHTTP"
echoContent "white" " --subscribe-port <端口> 订阅访问端口(留空则随机)"
echoContent "white" " --server-name <sni> Reality 握手所用的 SNI / 服务器名称"
echoContent "white" " --private-key <密钥> X25519 私钥(留空则自动生成)"
echoContent "white" " --uuid <uuid> 客户端 UUID(留空则自动生成)"
echoContent "white" " --email <邮箱> 存储在配置中的联系邮箱"
echoContent "white" " --generate-only 生成配置和密钥后退出,不启动 Docker"
echoContent "white" " --start-only 跳过配置生成,直接用已有配置启动 Docker"
echoContent "white" " --skip-self-install 跳过脚本迁移与 vasmad 快捷方式创建"
echoContent "white" " -h, --help 显示本帮助信息并退出"
echoContent "white" ""
echoContent "white" "操作说明:"
echoContent "white" " 1. 交互模式会同时检测 config.json 与 v2ray-agent-docker 容器是否存在,再显示对应菜单。"
echoContent "white" " 2. 若两者都不存在,则显示安装菜单;若任一存在,则显示用户管理/重新安装/启动或重建菜单。"
echoContent "white" " 3. 安装时可选 Reality Vision、Reality XHTTP 或全部安装;XHTTP 会额外使用 path 输入。"
echoContent "white" " 4. 非交互模式需要显式提供所有值,但 privateKey/uuid/email 留空时可自动生成/推导。"
echoContent "white" " 5. 运行数据目录默认为 /etc/v2ray-agent/docker/;可用 --data-dir 覆盖以用于 QA 或自定义路径。"
echoContent "white" " 6. 若脚本不是从 /etc/v2ray-agent/docker_reality.sh 运行,则会迁移脚本并创建 vasmad 快捷方式。"
echoContent "white" ""
echoContent "yellow" "QA / 测试:"
echoContent "white" " V2RAY_AGENT_FORCE_NO_DOCKER=1 跳过 Docker 可用性检查(测试环境)"
echoContent "white" " --data-dir /tmp/v2ray-agent/docker 隔离验证的路径覆盖示例"
exit "${1:-0}"
}
# jsonEscape — 对字符串进行转义,以便安全地插入 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 — 拒绝明显不安全的 data-dir 路径形式(用于 root 运行场景)。
validateDataDirPath() {
local path="$1"
if [[ -z "${path}" || "${path}" != /* || "${path}" == *:* ]]; then
echoContent "red" "--data-dir 必须是不含冒号的绝对路径"
exit 1
fi
if [[ -e "${path}" && -L "${path}" ]]; then
echoContent "red" "--data-dir 不能是符号链接:${path}"
exit 1
fi
}
# ---------------------------------------------------------------------------
# promptValue — 交互式输入提示,允许直接回车保留空值
# ---------------------------------------------------------------------------
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 — 生成与 install.sh 风格接近的 4 位随机 path 基础值。
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 — 规范安装模式,默认保持对旧 Vision 行为的兼容。
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" "安装模式无效:${rawMode}(必须是 vision / xhttp / all"
exit 1
;;
esac
}
# normalizeXHTTPPath — 规范 XHTTP path 基础值,最终渲染时自动追加 xHTTP 后缀。
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 基础值结尾不可用 ws,否则会和 install.sh 的旧分流路径约定冲突"
exit 1
fi
resolvedXHTTPPath="${normalized}"
}
# renderXHTTPPath — 将 path 基础值渲染为 install.sh 使用的 /<path>xHTTP 形式。
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 — 生成 Vision 订阅 URI,空字段时返回失败。
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 — 生成 XHTTP 订阅 URI,空字段时返回失败。
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: 手动切换
type: select
use:
- ${providerName}
proxies: null
- name: 自动选择
type: url-test
url: http://www.gstatic.com/generate_204
interval: 36000
tolerance: 50
use:
- ${providerName}
proxies: null
- name: 全球代理
type: select
use:
- ${providerName}
proxies:
- 手动切换
- 自动选择
- name: 漏网之鱼
type: select
use:
- ${providerName}
proxies:
- 全球代理
- DIRECT
- 手动切换
- 自动选择
rules:
- GEOIP,LAN,DIRECT,no-resolve
- GEOSITE,private,DIRECT
- GEOSITE,cn,DIRECT
- GEOIP,CN,DIRECT
- MATCH,漏网之鱼
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" "启动订阅访问容器失败"
exit 1
fi
persistedSubscribePort="${activePort}"
echoContent "green" "订阅访问服务已启动:http://${displayAddress}:${activePort}/s/default/<订阅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 $'请输入订阅访问端口,[回车]随机:' "")"
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 — 在交互模式下收集缺失的值
# ---------------------------------------------------------------------------
promptInteractiveValues() {
if [[ "${startOnly}" == "1" || "${nonInteractive}" == "1" ]]; then
return 0
fi
echoContent "skyBlue" ""
echoContent "skyBlue" "─── 配置输入 ───────────────────────────────────────────"
echoContent "white" "Docker 已就绪。请依次输入各项配置值。"
echoContent "white" "直接回车则接受括号内的默认行为(自动生成或随机选取)。"
echoContent "skyBlue" "────────────────────────────────────────────────────────"
if [[ -z "${installMode}" ]]; then
echoContent "yellow" "1. Reality Vision"
echoContent "yellow" "2. Reality XHTTP"
echoContent "yellow" "3. 全部安装"
case "$(promptValue $'\n[步骤 1/8] 安装模式 [1-3]' "")" in
1) installMode="vision" ;;
2) installMode="xhttp" ;;
3) installMode="all" ;;
*)
echoContent "red" "无效选择,请输入 1 / 2 / 3"
exit 1
;;
esac
fi
normalizeInstallMode "${installMode}"
if installModeHasVision "${resolvedInstallMode}"; then
if [[ -z "${port}" ]]; then
port="$(promptValue $'\n[步骤 2/8] Vision 端口 [留空则随机选取 10000-30000]' "")"
if [[ -z "${port}" ]]; then
port=""
fi
fi
fi
if installModeHasXHTTP "${resolvedInstallMode}"; then
if [[ -z "${xhttpPort}" ]]; then
xhttpPort="$(promptValue $'\n[步骤 3/8] XHTTP 端口 [留空则随机选取 10000-30000]' "")"
if [[ -z "${xhttpPort}" ]]; then
xhttpPort=""
fi
fi
if [[ -z "${xhttpPath}" ]]; then
xhttpPath="$(promptValue $'\n[步骤 4/8] XHTTP path [例: alone,留空则随机生成]' "")"
fi
fi
if [[ -z "${serverName}" ]]; then
serverName="$(promptValue $'\n[步骤 5/8] 服务器名称 [留空则随机选取 Reality 目标]' "")"
fi
if [[ -z "${privateKey}" ]]; then
privateKey="$(promptValue $'\n[步骤 6/8] 私钥 [留空则自动生成]:' "")"
fi
if [[ -z "${uuid}" ]]; then
uuid="$(promptValue $'\n[步骤 7/8] UUID [留空则自动生成]' "")"
fi
if [[ -z "${email}" ]]; then
email="$(promptValue $'\n[步骤 8/8] 邮箱基础名称 [留空则从 UUID 推导]:' "")"
fi
echoContent "white" ""
}
# ---------------------------------------------------------------------------
# parseCli — 解析命令行参数
# ---------------------------------------------------------------------------
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 需要一个路径值"
exit 1
fi
dataDir="$2"
validateDataDirPath "${dataDir}"
shift 2
;;
--install-mode)
if [[ $# -lt 2 || -z "${2:-}" || "${2:0:1}" == "-" ]]; then
echoContent "red" "--install-mode 需要一个值(vision / xhttp / all"
exit 1
fi
installMode="$2"
shift 2
;;
--port)
if [[ $# -lt 2 || (-n "${2:-}" && "${2:0:1}" == "-") ]]; then
echoContent "red" "--port 需要一个数字值"
exit 1
fi
port="$2"
shift 2
;;
--xhttp-port)
if [[ $# -lt 2 || (-n "${2:-}" && "${2:0:1}" == "-") ]]; then
echoContent "red" "--xhttp-port 需要一个数字值"
exit 1
fi
xhttpPort="$2"
shift 2
;;
--xhttp-path)
if [[ $# -lt 2 || (-n "${2:-}" && "${2:0:1}" == "-") ]]; then
echoContent "red" "--xhttp-path 需要一个值"
exit 1
fi
xhttpPath="$2"
shift 2
;;
--server-name)
if [[ $# -lt 2 || (-n "${2:-}" && "${2:0:1}" == "-") ]]; then
echoContent "red" "--server-name 需要一个值"
exit 1
fi
serverName="$2"
shift 2
;;
--private-key)
if [[ $# -lt 2 || (-n "${2:-}" && "${2:0:1}" == "-") ]]; then
echoContent "red" "--private-key 需要一个值"
exit 1
fi
privateKey="$2"
shift 2
;;
--uuid)
if [[ $# -lt 2 || (-n "${2:-}" && "${2:0:1}" == "-") ]]; then
echoContent "red" "--uuid 需要一个值"
exit 1
fi
uuid="$2"
shift 2
;;
--email)
if [[ $# -lt 2 || (-n "${2:-}" && "${2:0:1}" == "-") ]]; then
echoContent "red" "--email 需要一个值"
exit 1
fi
email="$2"
shift 2
;;
--subscribe-port)
if [[ $# -lt 2 || (-n "${2:-}" && "${2:0:1}" == "-") ]]; then
echoContent "red" "--subscribe-port 需要一个数字值"
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" "未知选项:$1"
showHelp 1
;;
esac
done
}
# ---------------------------------------------------------------------------
# checkEnvironment — 强制要求 Linux 系统及 root/sudo 权限
# ---------------------------------------------------------------------------
checkEnvironment() {
local osType
osType="$(uname -s 2>/dev/null || true)"
if [[ "${osType}" != "Linux" ]]; then
echoContent "red" "本脚本需要 Linux 主机(检测到:${osType}"
exit 1
fi
if [[ "$(id -u)" != "0" ]]; then
echoContent "red" "本脚本必须以 root 或 sudo 方式运行"
exit 1
fi
}
# selfInstallShortcut — 参考 install.sh 的 aliasInstall 逻辑,为本脚本创建稳定位置与 vasmad 快捷方式。
selfInstallShortcut() {
local targetScript="/etc/v2ray-agent/docker_reality.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" "无法自动移动脚本到 ${targetScript},继续使用当前路径运行。"
else
chmod 700 "${targetScript}"
currentScript="${targetScript}"
echoContent "green" "脚本已移动到 ${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" "快捷方式创建成功,可执行 [vasmad] 重新打开脚本"
echoContent "yellow" "启动方式:vasmad"
fi
}
# ---------------------------------------------------------------------------
# installDocker — 下载并运行 Docker 官方便捷安装脚本
# ---------------------------------------------------------------------------
installDocker() {
echoContent "white" "正在从 https://get.docker.com 下载 Docker 安装脚本..."
local tmpScript
tmpScript="$(mktemp /tmp/get-docker-XXXXXX.sh)"
if ! curl -fsSL https://get.docker.com -o "${tmpScript}"; then
echoContent "red" "从 https://get.docker.com 下载 Docker 安装脚本失败"
rm -f "${tmpScript}"
return 1
fi
echoContent "white" "正在运行 Docker 安装脚本..."
if ! sh "${tmpScript}"; then
echoContent "red" "Docker 安装失败"
rm -f "${tmpScript}"
return 1
fi
rm -f "${tmpScript}"
echoContent "green" "Docker 安装成功"
return 0
}
# checkDocker — 验证 Docker 是否已安装并正在运行;若未安装则提示安装
checkDocker() {
# QA 覆盖:V2RAY_AGENT_FORCE_NO_DOCKER=1 强制进入 Docker 缺失分支
if [[ "${forceNoDocker}" == "1" ]]; then
echoContent "yellow" "[QA] V2RAY_AGENT_FORCE_NO_DOCKER=1:模拟 Docker 未安装"
_promptDockerInstall
return $?
fi
echoContent "white" "正在检查 Docker 可用性..."
if ! command -v docker >/dev/null 2>&1; then
echoContent "yellow" "本系统未安装 Docker"
_promptDockerInstall
return $?
fi
# Docker 二进制存在 — 验证守护进程是否可达
if ! docker info >/dev/null 2>&1; then
echoContent "red" "Docker 已安装但守护进程未运行"
echoContent "white" "请启动 Docker 守护进程(例如:systemctl start docker)后重试"
exit 1
fi
echoContent "green" "Docker 可用且正在运行"
return 0
}
# _promptDockerInstall — 询问用户是否安装 Docker;拒绝时干净退出
_promptDockerInstall() {
local answer
if [[ "${nonInteractive}" == "1" ]]; then
echoContent "red" "Docker 是必需的但未安装。请使用交互模式,或仅在 QA 中设置 V2RAY_AGENT_FORCE_NO_DOCKER。"
exit 1
fi
echoContent "yellow" "运行本脚本需要 Docker。"
answer="$(promptValue $'\033[33m是否通过 https://get.docker.com 立即安装 Docker[y/N]\033[0m' "")"
case "${answer}" in
[yY] | [yY][eE][sS])
if ! installDocker; then
echoContent "red" "Docker 安装失败,正在退出。"
exit 1
fi
# 安装后验证守护进程是否已启动
if ! docker info >/dev/null 2>&1; then
echoContent "yellow" "Docker 已安装但守护进程尚未运行,正在启动..."
systemctl start docker 2>/dev/null || true
sleep 2
if ! docker info >/dev/null 2>&1; then
echoContent "red" "Docker 守护进程未能启动,请手动启动后重试。"
exit 1
fi
fi
echoContent "green" "Docker 已就绪"
;;
*)
echoContent "white" "已拒绝安装 Docker,正在退出。"
exit 0
;;
esac
}
# ---------------------------------------------------------------------------
# xrayImagePreflight — 使用官方 Xray 镜像验证配置文件
# 用法:xrayImagePreflight <配置文件路径>
# ---------------------------------------------------------------------------
xrayImagePreflight() {
local configFile="$1"
local xrayImage="ghcr.io/xtls/xray-core:26.5.9"
if [[ -z "${configFile}" ]]; then
echoContent "red" "xrayImagePreflight:需要提供配置文件路径"
return 1
fi
if [[ ! -f "${configFile}" ]]; then
echoContent "red" "xrayImagePreflight:配置文件未找到:${configFile}"
return 1
fi
echoContent "white" "正在拉取 Xray 镜像 ${xrayImage}(如未缓存)..."
docker pull "${xrayImage}" >/dev/null 2>&1 || true
echoContent "white" "正在验证配置: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 配置验证通过"
return 0
else
echoContent "red" "Xray 配置验证失败"
return 1
fi
}
# ---------------------------------------------------------------------------
# 值生成辅助函数(任务 2
# ---------------------------------------------------------------------------
# _realityDomainList — 精选的 Reality 目标域名列表,与 install.sh:9618 保持同步
# 返回逗号分隔的域名字符串(Xray-core 列表)
_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 — 验证并规范化端口值。
# 用法:parsePort <原始端口>
# 设置全局变量:resolvedPort
# 成功返回 0,输入无效返回 1。
parsePort() {
local raw="$1"
if [[ -z "${raw}" ]]; then
# 留空 → 随机选取 10000-30000(与 install.sh:9711 保持一致)
resolvedPort=$((RANDOM % 20001 + 10000))
echoContent "yellow" "未提供端口 — 随机选取:${resolvedPort}"
return 0
fi
# 必须是正整数
if ! [[ "${raw}" =~ ^[0-9]+$ ]]; then
echoContent "red" "端口无效 '${raw}':必须是正整数"
return 1
fi
if [[ "${raw}" -lt 1 || "${raw}" -gt 65535 ]]; then
echoContent "red" "端口无效 '${raw}':必须在 1-65535 范围内"
return 1
fi
resolvedPort="${raw}"
return 0
}
# checkPortInUse — 若端口已被 v2ray-agent-docker 容器以外的进程占用则退出
# (与 install.sh:1951-1957 保持一致)。
# 重新运行时脚本会在重建前执行 docker rm -f v2ray-agent-docker
# 因此仅由该容器占用的端口不构成真正的冲突。
# 用法:checkPortInUse <端口>
checkPortInUse() {
local p="$1"
if ! command -v lsof >/dev/null 2>&1; then
return 0
fi
# 收集监听该端口的 PID
local listenPids
listenPids=$(lsof -i "tcp:${p}" -sTCP:LISTEN -t 2>/dev/null || true)
[[ -z "${listenPids}" ]] && return 0
# 检查现有 v2ray-agent-docker 容器是否占用该端口。
# 若容器存在,其发布端口由 docker-proxy 管理。
# 若所有监听 PID 均属于该容器的 docker-proxy 进程,则允许使用该端口。
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
# 验证所有监听 PID 是否均为 docker-proxy 进程(Docker 为发布端口生成的转发器)。
# 若有任何 PID 不是 docker-proxy,则该端口被无关进程占用 — 这是真正的冲突。
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
# 端口仅由 v2ray-agent-docker 的 docker-proxy 占用 — 可以继续
echoContent "yellow" "端口 ${p} 由现有 v2ray-agent-docker 容器占用(将被替换)"
return 0
fi
fi
echoContent "red" "端口 ${p} 已被占用 — 请释放后重试"
lsof -i "tcp:${p}" -sTCP:LISTEN 2>/dev/null || true
exit 1
}
# parseServerName — 验证并规范化 serverName / host:port 输入。
# 用法:parseServerName <原始服务器名称>
# 设置全局变量:resolvedServerName、resolvedTargetPort
# 成功返回 0。
parseServerName() {
local raw="$1"
if [[ -z "${raw}" ]]; then
# 留空 → 从精选列表随机选取(与 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" "未提供 serverName — 随机选取:${resolvedServerName}:${resolvedTargetPort}"
return 0
fi
# 支持 host:port 语法(与 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 — 通过预检的 Xray Docker 镜像生成 X25519 密钥对。
# 用法:generatePrivateKey <xray运行时路径>
# 设置全局变量:resolvedPrivateKey、resolvedPublicKey
# 成功返回 0,失败返回 1。
generatePrivateKey() {
local xrayRuntime="$1"
local x25519Output
if [[ -z "${xrayRuntime}" ]]; then
echoContent "red" "generatePrivateKey:需要提供 xray 运行时路径"
return 1
fi
# xrayRuntime 可以是 Docker 调用字符串或宿主机二进制路径
x25519Output=$(${xrayRuntime} x25519 2>/dev/null) || {
echoContent "red" "通过以下命令生成 X25519 密钥对失败:${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 密钥生成未产生 PrivateKey 输出"
return 1
fi
echoContent "green" "已生成公钥: ${resolvedPublicKey}"
return 0
}
# derivePublicKey — 通过 Xray 运行时从已有私钥推导公钥。
# 用法:derivePublicKey <xray运行时路径> <私钥>
# 设置全局变量:resolvedPublicKey
# 成功返回 0,失败返回 1。
derivePublicKey() {
local xrayRuntime="$1"
local privKey="$2"
local x25519Output
x25519Output=$(${xrayRuntime} x25519 -i "${privKey}" 2>/dev/null) || {
echoContent "red" "从提供的私钥推导公钥失败"
return 1
}
resolvedPublicKey=$(printf '%s' "${x25519Output}" | awk -F': ' '/Password|Public key/ {print $2; exit}')
if [[ -z "${resolvedPublicKey}" ]]; then
echoContent "red" "提供的私钥无效 — 无法推导公钥"
return 1
fi
return 0
}
# generateUUID — 通过预检的 Xray Docker 镜像生成 UUID。
# 用法:generateUUID <xray运行时路径>
# 设置全局变量:resolvedUUID
# 成功返回 0,失败返回 1。
generateUUID() {
local xrayRuntime="$1"
local uuidOutput
if [[ -z "${xrayRuntime}" ]]; then
echoContent "red" "generateUUID:需要提供 xray 运行时路径"
return 1
fi
uuidOutput=$(${xrayRuntime} uuid 2>/dev/null) || {
echoContent "red" "通过以下命令生成 UUID 失败:${xrayRuntime} uuid"
return 1
}
resolvedUUID=$(printf '%s' "${uuidOutput}" | tr -d '[:space:]')
if [[ -z "${resolvedUUID}" ]]; then
echoContent "red" "UUID 生成产生了空输出"
return 1
fi
echoContent "green" "已生成 UUID${resolvedUUID}"
return 0
}
# deriveEmail — 从 UUID 前缀推导邮箱(与 install.sh:3842 保持一致)
# 用法:deriveEmail <uuid>
# 设置全局变量:resolvedEmail
deriveEmail() {
local uuidVal="$1"
# 取第一个 '-' 之前的部分
resolvedEmail="${uuidVal%%-*}"
}
# _xrayDockerRuntime — 返回 Xray 镜像的 docker-run 命令前缀。
# 这是任务 3 中预检的运行时路径(不假设宿主机有二进制文件)。
_xrayDockerRuntime() {
printf '%s' "docker run --rm ghcr.io/xtls/xray-core:26.5.9"
}
# loadPersistedSummaryIfPresent — 可选地读取并验证 client-summary.txt。
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" "持久化摘要容器名称不匹配:${persistedContainer}"
exit 1
fi
if [[ -n "${persistedConfigPath}" && "${persistedConfigPath}" != "${dataDir%/}/config.json" ]]; then
echoContent "red" "持久化摘要 configPath 不匹配:${persistedConfigPath}"
exit 1
fi
}
# loadPersistedStateFromConfig — 从 config.json 恢复协议模式、端口、path 与展示字段。
loadPersistedStateFromConfig() {
local configFile="${dataDir%/}/config.json"
local parsedLines=()
if [[ ! -f "${configFile}" ]]; then
echoContent "red" "未找到持久化配置:${configFile}"
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echoContent "red" "需要 python3 才能安全解析持久化的 config.json"
exit 1
fi
loadPersistedSummaryIfPresent
mapfile -t parsedLines < <(
python3 - "${configFile}" <<'PY'
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" "持久化配置格式错误:无法从 ${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" "持久化配置未包含 Vision 端口"
exit 1
}
;;
xhttp)
[[ -n "${persistedXHTTPPort}" && -n "${persistedXHTTPPath}" ]] || {
echoContent "red" "持久化配置未包含 XHTTP 端口或 path"
exit 1
}
;;
all)
[[ -n "${persistedVisionPort}" && -n "${persistedXHTTPPort}" && -n "${persistedXHTTPPath}" ]] || {
echoContent "red" "持久化配置未完整包含全部安装字段"
exit 1
}
;;
*)
echoContent "red" "安装模式无效:${persistedInstallMode}"
exit 1
;;
esac
}
# loadPersistedPort — 从持久化配置恢复协议模式与端口。
loadPersistedPort() {
loadPersistedStateFromConfig
}
# getPublicIP — 尝试获取当前主机的公网 IP,用于展示 Reality 连接信息。
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 — 从持久化摘要(优先)或 config.json 恢复展示所需的账号信息。
# 设置:persistedInstallMode、persistedVisionPort、persistedXHTTPPort、
# persistedXHTTPPath、persistedServerName、persistedPublicKey、
# persistedUUID、persistedEmailBase、persistedVisionEmail、
# persistedXHTTPEmail、persistedShortId
loadPersistedAccountInfo() {
local configFile="${dataDir%/}/config.json"
if [[ ! -f "${configFile}" ]]; then
echoContent "red" "未找到持久化配置:${configFile}"
exit 1
fi
# 优先从摘要文件恢复(字段最完整)
loadPersistedSummaryIfPresent
# 若摘要文件缺失关键字段,则从 config.json 补充
if [[ -z "${persistedInstallMode}" || -z "${persistedServerName}" || -z "${persistedUUID}" || -z "${persistedPublicKey}" ]]; then
if ! command -v python3 >/dev/null 2>&1; then
echoContent "red" "需要 python3 才能安全解析持久化的 config.json"
exit 1
fi
local parsedLines=()
mapfile -t parsedLines < <(
python3 - "${configFile}" <<'PY'
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" "持久化配置格式错误:无法从 ${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
# 补全邮箱字段
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 — 判断 dataDir 下是否已有持久化配置。
hasPersistedConfig() {
local configFile="${dataDir%/}/config.json"
[[ -f "${configFile}" ]]
}
# hasManagedContainer — 判断目标 Docker 容器是否存在(运行中或已停止均算存在)。
hasManagedContainer() {
docker ps -a --filter "name=^/v2ray-agent-docker$" --format '{{.Names}}' 2>/dev/null | grep -q '^v2ray-agent-docker$'
}
# hasExistingInstallState — 只要配置文件或容器任意一个存在,就视为已安装/残留状态。
hasExistingInstallState() {
if hasPersistedConfig || hasManagedContainer; then
return 0
fi
return 1
}
userManageMenu() {
local action=""
if ! hasPersistedConfig; then
echoContent "red" "未找到现有配置,无法进入用户管理。请先重新安装。"
return 0
fi
echoContent "skyBlue" "─── 用户管理 ───────────────────────────────────────────"
echoContent "yellow" "1. 查看账号"
echoContent "yellow" "2. 查看订阅"
echoContent "yellow" "3. 退出"
while true; do
action="$(promptValue $'请选择 [1-3]' "")"
case "${action}" in
1)
showAccountInfo
exit 0
;;
2)
showSubscriptionInfo
exit 0
;;
3)
echoContent "white" "已退出,不做任何修改。"
exit 0
;;
*)
echoContent "red" "无效选择,请输入 1-3。"
;;
esac
done
}
# uninstallDockerReality — 卸载 Docker Reality 独立脚本生成的容器、配置与快捷方式。
uninstallDockerReality() {
local answer=""
local installedScript="/etc/v2ray-agent/docker_reality.sh"
answer="$(promptValue $'是否确认卸载 Docker Reality 安装内容?[y/N]' "")"
case "${answer}" in
[yY] | [yY][eE][sS]) ;;
*)
echoContent "green" " ---> 放弃卸载"
return 0
;;
esac
if hasManagedContainer; then
docker rm -f v2ray-agent-docker >/dev/null 2>&1 || true
echoContent "green" " ---> 删除 Docker 容器完成"
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" " ---> 删除订阅访问容器完成"
fi
rm -rf "${dataDir%/}" >/dev/null 2>&1 || true
echoContent "green" " ---> 删除 Docker Reality 配置目录完成"
rm -rf /usr/bin/vasmad >/dev/null 2>&1 || true
rm -rf /usr/sbin/vasmad >/dev/null 2>&1 || true
echoContent "green" " ---> 卸载快捷方式完成"
if [[ -f "${installedScript}" ]]; then
rm -f "${installedScript}" >/dev/null 2>&1 || true
echoContent "green" " ---> 删除脚本本体完成"
fi
exit 0
}
# promptExistingInstallAction — 根据配置文件与容器的组合状态提供交互菜单。
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" ""
echoContent green "作者:mack-a"
echoContent green "当前版本:v0.0.1"
echoContent green "Githubhttps://github.com/mack-a/v2ray-agent"
echoContent green "描述:八合一docker版"
if [[ ${hasConfig} -ne 0 && ${hasContainer} -ne 0 ]]; then
echoContent "skyBlue" "─── 未检测到现有 Docker Reality 安装 ─────────────────────"
echoContent "white" "未检测到配置文件与容器,请选择下一步操作:"
echoContent "yellow" "1. 安装"
echoContent "yellow" "2. 退出"
while true; do
action="$(promptValue $'请选择 [1-2]' "")"
case "${action}" in
1)
echoContent "yellow" "已选择安装,将收集配置并创建容器。"
return 0
;;
2)
echoContent "white" "已退出,不做任何修改。"
exit 0
;;
*)
echoContent "red" "无效选择,请输入 1-2。"
;;
esac
done
fi
echoContent "skyBlue" "─── 检测到已有 Docker Reality 安装/残留状态 ──────────────"
if [[ ${hasConfig} -eq 0 && ${hasContainer} -eq 0 ]]; then
echoContent "white" "已检测到配置文件与容器。请选择下一步操作:"
elif [[ ${hasConfig} -eq 0 ]]; then
echoContent "white" "已检测到配置文件,但未检测到容器。请选择下一步操作:"
else
echoContent "white" "已检测到容器,但未检测到配置文件。请选择下一步操作:"
fi
echoContent "yellow" "1. 用户管理"
echoContent "yellow" "2. 重新安装"
echoContent "yellow" "3. 启动/重建容器"
echoContent "yellow" "4. 卸载"
echoContent "yellow" "5. 退出"
while true; do
action="$(promptValue $'请选择 [1-5]' "")"
case "${action}" in
1)
if hasPersistedConfig; then
userManageMenu
exit 0
else
echoContent "red" "未找到现有配置,无法进入用户管理。请先重新安装。"
fi
;;
2)
echoContent "yellow" "已选择重新安装,将重新收集配置并重建容器。"
return 0
;;
3)
if ! hasExistingInstallState; then
echoContent "red" "未检测到已安装状态,无法启动/重建容器。请先安装。"
continue
fi
if ! hasPersistedConfig; then
echoContent "red" "未找到现有配置,无法启动/重建容器。请先重新安装。"
continue
fi
startOnly=1
echoContent "yellow" "已选择使用已有配置启动/重建容器。"
return 0
;;
4)
uninstallDockerReality
;;
5)
echoContent "white" "已退出,不做任何修改。"
exit 0
;;
*)
echoContent "red" "无效选择,请输入 1-5。"
;;
esac
done
}
# showVisionAccount — 以 install.sh showAccounts 风格展示 Vision 账号。
# 参数:$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 账号信息不完整,已跳过订阅链接输出"
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 [推荐] =============================="
echoContent "skyBlue" ""
echoContent "skyBlue" " ---> 账号:${displayEmail}"
echoContent "white" ""
echoContent "yellow" " ---> 通用格式(VLESS+reality+uTLS+Vision)"
echoContent "green" " ${vlessLink}"
echoContent "white" ""
echoContent "yellow" " ---> 格式化明文(VLESS+reality+uTLS+Vision)"
echoContent "green" "协议类型:VLESS reality,地址:${displayAddress}publicKey:${displayPublicKey}shortId: ${displayShortId}serverNames${displayServerName},端口:${displayPort},用户ID:${displayUUID},传输方式:tcp,账户名:${displayEmail}"
echoContent "white" ""
echoContent "yellow" " ---> 二维码 VLESS(VLESS+reality+uTLS+Vision)"
echoContent "green" " ${qrLink}"
}
# showXHTTPAccount — 以 install.sh showAccounts 风格展示 XHTTP 账号。
# 参数:$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 账号信息不完整,已跳过订阅链接输出"
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" " ---> 账号:${displayEmail}"
echoContent "white" ""
echoContent "yellow" " ---> 通用格式(VLESS+reality+xhttp)"
echoContent "green" " ${vlessLink}"
echoContent "white" ""
echoContent "yellow" " ---> 格式化明文(VLESS+reality+xhttp)"
echoContent "green" "协议类型:VLESS reality,地址:${displayAddress}publicKey:${displayPublicKey}shortId: ${displayShortId}serverNames${displayServerName},端口:${displayPort},路径:${displayPath}SNI:${displayServerName},伪装域名:${displayServerName},用户ID:${displayUUID},传输方式:xhttp,账户名:${displayEmail}"
echoContent "white" ""
echoContent "yellow" " ---> 二维码 VLESS(VLESS+reality+xhttp)"
echoContent "green" " ${qrLink}"
}
# showChineseSubscriptionSection — 输出本地中文订阅内容与风险提示。
showChineseSubscriptionSection() {
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" " ---> 订阅账号信息不完整,已跳过订阅文件生成"
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" " ---> 订阅账号信息不完整,未生成可用订阅内容"
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" "============================= 默认订阅 =============================="
echoContent "white" "HTTP 明文订阅有风险,可能暴露 订阅信息。"
echoContent "white" "当前 Docker 版通过本机订阅容器提供 HTTP 访问链接,请勿在不可信网络中传播。"
echoContent "white" ""
echoContent "green" "email: ${subscribeName}"
echoContent "yellow" "url: ${subscribeURL}"
echoContent "yellow" "在线二维码: https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=$(urlEncode "${subscribeURL}")"
if [[ -n "${clashProfileURL}" ]]; then
echo
echoContent "skyBlue" "--------------Clash Verge(mihomo)订阅--------------"
echo
echoContent "yellow" "url: ${clashProfileURL}"
echoContent "yellow" "在线二维码: https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=$(urlEncode "${clashProfileURL}")"
fi
}
# resolveValues — 将所有 CLI 提供或留空的值解析为 resolved* 全局变量。
# 依赖:installMode/resolvedInstallMode、port、xhttpPort、xhttpPath、
# serverName、privateKey、uuid、email(来自 parseCli 的全局变量)
# 设置:resolvedInstallMode、resolvedVisionPort、resolvedXHTTPPort、
# resolvedXHTTPPath、resolvedServerName、resolvedTargetPort、
# resolvedPrivateKey、resolvedPublicKey、resolvedUUID、
# resolvedEmailBase、resolvedVisionEmail、resolvedXHTTPEmail
# 成功返回 0,输入无效时退出。
resolveValues() {
local xrayRuntime
xrayRuntime="$(_xrayDockerRuntime)"
# --- 安装模式 ---
normalizeInstallMode "${installMode}"
# resolvedInstallMode 已由 normalizeInstallMode 设置
# --- Vision 端口 ---
if installModeHasVision "${resolvedInstallMode}"; then
if ! parsePort "${port}"; then
exit 1
fi
resolvedVisionPort="${resolvedPort}"
checkPortInUse "${resolvedVisionPort}"
fi
# --- XHTTP 端口与 path ---
if installModeHasXHTTP "${resolvedInstallMode}"; then
if ! parsePort "${xhttpPort}"; then
exit 1
fi
resolvedXHTTPPort="${resolvedPort}"
# 确保 XHTTP 端口与 Vision 端口不冲突(all 模式)
if installModeHasVision "${resolvedInstallMode}" && [[ "${resolvedXHTTPPort}" == "${resolvedVisionPort}" ]]; then
echoContent "red" "XHTTP 端口(${resolvedXHTTPPort})与 Vision 端口相同,请使用不同端口"
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" "订阅端口(${resolvedSubscribePort})与 Vision 端口相同,请使用不同端口"
exit 1
fi
if installModeHasXHTTP "${resolvedInstallMode}" && [[ "${resolvedSubscribePort}" == "${resolvedXHTTPPort}" ]]; then
echoContent "red" "订阅端口(${resolvedSubscribePort})与 XHTTP 端口相同,请使用不同端口"
exit 1
fi
checkPortInUse "${resolvedSubscribePort}"
# --- 服务器名称 ---
parseServerName "${serverName}"
# --- 私钥 ---
if [[ -z "${privateKey}" ]]; then
if ! generatePrivateKey "${xrayRuntime}"; then
exit 1
fi
else
resolvedPrivateKey="${privateKey}"
if ! derivePublicKey "${xrayRuntime}" "${resolvedPrivateKey}"; then
echoContent "red" "提供的私钥无效"
exit 1
fi
fi
# --- UUID ---
if [[ -z "${uuid}" ]]; then
if ! generateUUID "${xrayRuntime}"; then
exit 1
fi
else
resolvedUUID="${uuid}"
fi
# --- 邮箱基础名称与各协议邮箱 ---
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" "已解析 Vision 端口: ${resolvedVisionPort}"
fi
if installModeHasXHTTP "${resolvedInstallMode}"; then
echoContent "green" "已解析 XHTTP 端口: ${resolvedXHTTPPort}"
echoContent "green" "已解析 XHTTP path $(renderXHTTPPath "${resolvedXHTTPPath}")"
fi
echoContent "green" "已解析服务器名称: ${resolvedServerName}:${resolvedTargetPort}"
echoContent "green" "已解析邮箱基础名: ${resolvedEmailBase}"
}
# ---------------------------------------------------------------------------
# _buildVisionInbounds — 输出 Vision 协议的两个 inbound JSON 块(不含尾随逗号)
# 参数:$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 — 输出 XHTTP 协议的 inbound JSON 块(不含尾随逗号)
# 参数:$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 — 将 Xray Reality 配置和客户端摘要写入 dataDir
# 支持 installMode: vision / xhttp / all
# ---------------------------------------------------------------------------
generateConfig() {
# 去除末尾斜杠,使路径拼接无歧义
dataDir="${dataDir%/}"
echoContent "white" "正在 ${dataDir} 中生成 Reality 配置..."
resolveValues
# 创建数据目录;若路径无法创建则干净退出
if ! mkdir -p "${dataDir}" 2>/dev/null; then
echoContent "red" "创建数据目录失败:${dataDir}"
echoContent "red" "请检查父路径是否存在且为目录而非普通文件"
exit 1
fi
local configFile="${dataDir}/config.json"
local summaryFile="${dataDir}/client-summary.txt"
# 清除由之前失败运行留下的陈旧目录残留
if [[ -d "${configFile}" ]]; then
echoContent "yellow" "正在清除 ${configFile} 处由之前失败运行留下的陈旧目录"
rm -rf "${configFile}" 2>/dev/null || {
echoContent "red" "无法清除陈旧目录:${configFile}"
exit 1
}
fi
if [[ -d "${summaryFile}" ]]; then
echoContent "yellow" "正在清除 ${summaryFile} 处由之前失败运行留下的陈旧目录"
rm -rf "${summaryFile}" 2>/dev/null || {
echoContent "red" "无法清除陈旧目录:${summaryFile}"
exit 1
}
fi
local renderedXHTTPPath=""
if installModeHasXHTTP "${resolvedInstallMode}"; then
renderedXHTTPPath="$(renderXHTTPPath "${resolvedXHTTPPath}")"
fi
# --- 构建 inbounds 数组内容 ---
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
# --- 构建路由规则 ---
# Vision 模式需要 dokodemo-door 路由规则;XHTTP 直接监听公网端口无需转发
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}"
# 对配置文件设置严格权限(包含私钥)
chmod 600 "${configFile}"
# --- 写入人类可读的客户端摘要 ---
# 字段与 loadPersistedSummaryIfPresent 解析逻辑严格对应
{
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}"
# 写后验证:确认两个输出均为普通文件
if [[ ! -f "${configFile}" ]]; then
echoContent "red" "配置写入失败 — 写入后 ${configFile} 不是普通文件"
exit 1
fi
if [[ ! -f "${summaryFile}" ]]; then
echoContent "red" "摘要写入失败 — 写入后 ${summaryFile} 不是普通文件"
exit 1
fi
echoContent "green" "配置已写入 ${configFile}"
echoContent "green" "摘要已写入 ${summaryFile}"
}
# startContainer — 验证配置,删除已有容器,并重建(任务 5)
# 使用 dataDir 下的配置;在 generateConfig 之后或直接在 --start-only 模式下调用。
startContainer() {
local xrayImage="ghcr.io/xtls/xray-core:26.5.9"
local containerName="v2ray-agent-docker"
# 去除末尾斜杠,使路径拼接一致
dataDir="${dataDir%/}"
local configFile="${dataDir}/config.json"
# 在尝试任何操作前验证配置文件是否存在
if [[ ! -f "${configFile}" ]]; then
echoContent "red" "配置文件未找到:${configFile}"
echoContent "red" "请不带 --start-only 运行以先生成配置"
exit 1
fi
# --- 使用目录挂载验证配置 ---
echoContent "white" "正在拉取 Xray 镜像 ${xrayImage}"
docker pull "${xrayImage}" >/dev/null 2>&1 || true
echoContent "white" "正在使用 Xray 镜像验证配置(目录挂载方式)..."
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 配置验证失败 — 容器将不会启动"
exit 1
fi
echoContent "green" "Xray 配置验证通过"
# --- 删除同名的已有容器以避免名称冲突 ---
if docker ps -a --filter "name=^/${containerName}$" --format '{{.Names}}' | grep -q "^${containerName}$"; then
echoContent "yellow" "正在删除已有容器:${containerName}"
docker rm -f "${containerName}" >/dev/null 2>&1 || true
fi
# --- 确定需要发布的端口列表 ---
# start-only 模式从持久化摘要/配置中恢复协议模式与端口
local activeInstallMode activeVisionPort activeXHTTPPort
if [[ "${startOnly}" == "1" ]]; then
loadPersistedPort
activeInstallMode="${persistedInstallMode}"
activeVisionPort="${persistedVisionPort}"
activeXHTTPPort="${persistedXHTTPPort}"
else
activeInstallMode="${resolvedInstallMode}"
activeVisionPort="${resolvedVisionPort}"
activeXHTTPPort="${resolvedXHTTPPort}"
fi
# 构建 -p 参数列表
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" "无法确定要发布的端口 — 请检查持久化配置"
exit 1
fi
echoContent "white" "正在启动容器 ${containerName}(端口:${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" "启动容器 ${containerName} 失败"
exit 1
fi
# --- 验证容器是否确实处于运行状态 ---
local runningName
runningName=$(docker ps --filter "name=^/${containerName}$" --format '{{.Names}}' 2>/dev/null || true)
if [[ "${runningName}" != "${containerName}" ]]; then
echoContent "red" "容器 ${containerName} 未能进入运行状态"
docker logs "${containerName}" 2>/dev/null || true
exit 1
fi
}
# showAccountInfo — 以 showAccounts 风格打印当前 Reality 账号信息
# 根据 installMode 只显示已安装协议的账号块
showAccountInfo() {
local displayAddress=""
local mode=""
local shortId=""
loadPersistedAccountInfo
displayAddress="$(getPublicIP)"
mode="${persistedInstallMode}"
shortId="${persistedShortId:-6ba85179e30d4fc2}"
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 mode=""
local shortId=""
loadPersistedAccountInfo
ensureSubscribeRuntimeValues
displayAddress="$(getPublicIP)"
mode="${persistedInstallMode}"
shortId="${persistedShortId:-6ba85179e30d4fc2}"
showChineseSubscriptionSection \
"${displayAddress}" \
"${mode}" \
"${persistedServerName}" \
"${persistedPublicKey}" \
"${persistedUUID}" \
"${persistedVisionPort}" \
"${persistedXHTTPPort}" \
"${persistedXHTTPPath}" \
"${persistedVisionEmail}" \
"${persistedXHTTPEmail}" \
"${shortId}"
}
showClientInfo() {
showAccountInfo
}
# ---------------------------------------------------------------------------
# main — 顶层流程
# ---------------------------------------------------------------------------
main() {
parseCli "$@"
if [[ "${generateOnly}" == "1" && "${startOnly}" == "1" ]]; then
echoContent "red" "--generate-only 与 --start-only 互斥,不能同时使用"
exit 1
fi
checkEnvironment
selfInstallShortcut
checkDocker
promptExistingInstallAction
promptInteractiveValues
if [[ "${startOnly}" != "1" ]]; then
generateConfig
fi
if [[ "${generateOnly}" != "1" ]]; then
startContainer
showClientInfo
fi
}
main "$@"