mirror of
https://github.com/mack-a/v2ray-agent.git
synced 2026-09-02 13:56:37 +00:00
fix(sing-box): 移除不需要的 Reality 目标 fix(reality): 为 Reality XHTTP 启用 ML-DSA-65 fix(reality): 设置兼容的最低客户端版本
2473 lines
88 KiB
Bash
2473 lines
88 KiB
Bash
#!/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 "Github:https://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 "$@"
|