#!/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 基础值(最终写为 /xHTTP)" echoContent "white" " --subscribe-port <端口> 订阅访问端口(留空则随机)" echoContent "white" " --server-name Reality 握手所用的 SNI / 服务器名称" echoContent "white" " --private-key <密钥> X25519 私钥(留空则自动生成)" echoContent "white" " --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 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 <>"${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 <>"${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 <"${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 # 设置全局变量: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 <私钥> # 设置全局变量: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 # 设置全局变量: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 # 设置全局变量: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 </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 <"${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 "$@"