diff --git a/.gitignore b/.gitignore index 0375927..e27a26b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ fodder/.DS_Store GEMINI.md GEMINI.zh-CN.md AGENTS.md +.worktrees/ +docs +.sisyphus diff --git a/README.md b/README.md index 0a93458..8143eda 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Xray-core/sing-box 一键脚本快速安装 ## 快速开始 -### 安装 +### 安装脚本版 ``` wget -P /root -N --no-check-certificate "https://raw.githubusercontent.com/mack-a/v2ray-agent/master/install.sh" && chmod 700 /root/install.sh && /root/install.sh @@ -39,6 +39,21 @@ wget -P /root -N --no-check-certificate "https://raw.githubusercontent.com/mack- vasma ``` +### 安装Docker版 +``` +wget -P /root -N --no-check-certificate "https://raw.githubusercontent.com/mack-a/v2ray-agent/master/shell/docker_reality.sh" && chmod 700 /root/docker_reality.sh && /root/docker_reality.sh +``` + +### 使用 + +* [Docker Reality 使用说明](https://www.v2ray-agent.com/archives/019e1b57-92b3-70ab-8919-cdf8c0bb4fe9) + +安装后,运行以下命令可再次打开管理菜单: + +``` +vasmad +``` + ## 文档和指南 * [八合一脚本从入门到精通](https://www.v2ray-agent.com/archives/1710141233) diff --git a/install.sh b/install.sh index 8d77dd8..36bfe02 100644 --- a/install.sh +++ b/install.sh @@ -9968,7 +9968,7 @@ menu() { cd "$HOME" || exit echoContent red "\n==============================================================" echoContent green "作者:mack-a" - echoContent green "当前版本:v3.5.16" + echoContent green "当前版本:v3.5.17" echoContent green "Github:https://github.com/mack-a/v2ray-agent" echoContent green "描述:八合一共存脚本\c" showInstallStatus diff --git a/shell/docker_reality.sh b/shell/docker_reality.sh new file mode 100644 index 0000000..04feb70 --- /dev/null +++ b/shell/docker_reality.sh @@ -0,0 +1,1892 @@ +#!/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 + +# --------------------------------------------------------------------------- +# 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" " --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" +} + +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 + ;; + --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$"; 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" + 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: }" ;; + 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:]]*}"}" + 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 +} + +# 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 + + 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 + showClientInfo + 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="vless://${displayUUID}@${displayAddress}:${displayPort}?encryption=none&security=reality&type=tcp&sni=${displayServerName}&fp=chrome&pbk=${displayPublicKey}&sid=${displayShortId}&flow=xtls-rprx-vision#${displayEmail}" + 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="vless://${displayUUID}@${displayAddress}:${displayPort}?encryption=none&security=reality&type=xhttp&sni=${displayServerName}&host=${displayServerName}&fp=chrome&path=${displayPath}&pbk=${displayPublicKey}&sid=${displayShortId}#${displayEmail}" + 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}" +} + +# 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 + + # --- 服务器名称 --- + 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 + +} + +# showClientInfo — 以 showAccounts 风格打印当前 Reality 账号信息 +# 根据 installMode 只显示已安装协议的账号块 +showClientInfo() { + local displayAddress="" + + loadPersistedAccountInfo + displayAddress="$(getPublicIP)" + + local shortId="${persistedShortId:-6ba85179e30d4fc2}" + local mode="${persistedInstallMode}" + + if installModeHasVision "${mode}"; then + showVisionAccount \ + "${displayAddress}" \ + "${persistedVisionPort}" \ + "${persistedServerName}" \ + "${persistedPublicKey}" \ + "${persistedUUID}" \ + "${persistedVisionEmail}" \ + "${shortId}" + fi + + if installModeHasXHTTP "${mode}"; then + local renderedPath + renderedPath="$(renderXHTTPPath "${persistedXHTTPPath}")" + showXHTTPAccount \ + "${displayAddress}" \ + "${persistedXHTTPPort}" \ + "${persistedServerName}" \ + "${persistedPublicKey}" \ + "${persistedUUID}" \ + "${persistedXHTTPEmail}" \ + "${shortId}" \ + "${renderedPath}" + fi +} + +# --------------------------------------------------------------------------- +# 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 "$@" diff --git a/shell/docker_reality_en.sh b/shell/docker_reality_en.sh new file mode 100644 index 0000000..8c195d6 --- /dev/null +++ b/shell/docker_reality_en.sh @@ -0,0 +1,1870 @@ +#!/usr/bin/env bash +# docker_reality_en.sh — Docker-based VLESS-Reality standalone launcher (no domain required) +# Usage: bash shell/docker_reality_en.sh [options] +export LANG=en_US.UTF-8 + +# --------------------------------------------------------------------------- +# Output helper — style consistent with echoContent in install.sh +# --------------------------------------------------------------------------- +echoContent() { + case $1 in + "red") + printf "\033[31m%s\033[0m\n" "$2" + ;; + "skyBlue") + printf "\033[1;36m%s\033[0m\n" "$2" + ;; + "green") + printf "\033[32m%s\033[0m\n" "$2" + ;; + "white") + printf "\033[37m%s\033[0m\n" "$2" + ;; + "magenta") + printf "\033[35m%s\033[0m\n" "$2" + ;; + "yellow") + printf "\033[33m%s\033[0m\n" "$2" + ;; + esac +} + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- +nonInteractive=0 +dataDir="/etc/v2ray-agent/docker/" +installMode="" +port="" +xhttpPort="" +xhttpPath="" +serverName="" +privateKey="" +uuid="" +email="" +generateOnly=0 +startOnly=0 +skipSelfInstall=0 + +# --------------------------------------------------------------------------- +# QA override: set V2RAY_AGENT_FORCE_NO_DOCKER=1 to skip Docker check in tests +# --------------------------------------------------------------------------- +forceNoDocker="${V2RAY_AGENT_FORCE_NO_DOCKER:-0}" + +# --------------------------------------------------------------------------- +# showHelp — print usage and exit +# --------------------------------------------------------------------------- +showHelp() { + echoContent "skyBlue" "docker_reality_en.sh — Run VLESS-Reality via Docker (no domain required)" + echoContent "white" "" + echoContent "white" "Usage:" + echoContent "white" " bash shell/docker_reality_en.sh [options]" + echoContent "white" "" + echoContent "white" "Run modes:" + echoContent "white" " Default / interactive Detect install state first, then show install/reinstall/start menu options" + echoContent "white" " --non-interactive Non-interactive mode; exit with error if required values are missing" + echoContent "white" " --generate-only Generate config and summary files only, then exit" + echoContent "white" " --start-only Reuse existing config and only start/recreate the container" + echoContent "white" " --skip-self-install Skip script relocation and vasmad shortcut creation" + echoContent "white" "" + echoContent "white" "Options:" + echoContent "white" " --non-interactive Run non-interactively; all required values must be supplied via flags" + echoContent "white" " --data-dir Persistent config/data directory (default: /etc/v2ray-agent/docker/)" + echoContent "white" " --install-mode Install mode: vision / xhttp / all" + echoContent "white" " --port Vision or single-protocol external listen port (leave blank to pick randomly)" + echoContent "white" " --xhttp-port XHTTP mode port (leave blank to pick randomly)" + echoContent "white" " --xhttp-path XHTTP path base value (rendered as /xHTTP)" + echoContent "white" " --server-name SNI / server name used for Reality handshake" + echoContent "white" " --private-key X25519 private key (auto-generated if blank)" + echoContent "white" " --uuid Client UUID (auto-generated if blank)" + echoContent "white" " --email Contact email stored in the config" + echoContent "white" " --generate-only Generate config and keys then exit without starting Docker" + echoContent "white" " --start-only Skip config generation and start Docker with existing config" + echoContent "white" " --skip-self-install Skip script relocation and vasmad shortcut creation" + echoContent "white" " -h, --help Show this help and exit" + echoContent "white" "" + echoContent "white" "Notes:" + echoContent "white" " 1. Interactive mode checks both config.json and the v2ray-agent-docker container before showing a menu." + echoContent "white" " 2. If both are missing, it shows an install menu; if either exists, it shows view-account / reinstall / start-or-recreate options." + echoContent "white" " 3. Install mode selects Reality Vision, Reality XHTTP, or both; XHTTP requires an additional path input." + echoContent "white" " 4. Non-interactive mode requires all values explicitly; privateKey/uuid/email may still be auto-generated/derived." + echoContent "white" " 5. Data directory defaults to /etc/v2ray-agent/docker/; override with --data-dir for QA or custom paths." + echoContent "white" " 6. If the script is not running from /etc/v2ray-agent/docker_reality_en.sh, it will relocate itself and create the vasmad shortcut." + echoContent "white" "" + echoContent "yellow" "QA / Testing:" + echoContent "white" " V2RAY_AGENT_FORCE_NO_DOCKER=1 Skip Docker availability check (test environments)" + echoContent "white" " --data-dir /tmp/v2ray-agent/docker Example path override for isolated verification" + exit "${1:-0}" +} + +# jsonEscape — escape a string for safe insertion into JSON. +jsonEscape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/\\r}" + value="${value//$'\t'/\\t}" + printf '%s' "${value}" +} + +# validateDataDirPath — reject obviously unsafe data-dir path forms (for root-run scenarios). +validateDataDirPath() { + local path="$1" + if [[ -z "${path}" || "${path}" != /* || "${path}" == *:* ]]; then + echoContent "red" "--data-dir must be an absolute path without colons" + exit 1 + fi + + if [[ -e "${path}" && -L "${path}" ]]; then + echoContent "red" "--data-dir must not be a symbolic link: ${path}" + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# promptValue — interactive prompt; pressing Enter keeps the current value +# --------------------------------------------------------------------------- +promptValue() { + local promptMessage="$1" + local currentValue="$2" + local inputValue + + printf '%b' "${promptMessage}" >/dev/tty + read -r inputValue xHTTP form used by install.sh. +renderXHTTPPath() { + printf '/%sxHTTP' "$1" +} + +installModeHasVision() { + [[ "$1" == "vision" || "$1" == "all" ]] +} + +installModeHasXHTTP() { + [[ "$1" == "xhttp" || "$1" == "all" ]] +} + +# --------------------------------------------------------------------------- +# promptInteractiveValues — collect missing values in interactive mode +# --------------------------------------------------------------------------- +promptInteractiveValues() { + if [[ "${startOnly}" == "1" || "${nonInteractive}" == "1" ]]; then + return 0 + fi + + echoContent "skyBlue" "" + echoContent "skyBlue" "─── Configuration Input ─────────────────────────────────" + echoContent "white" "Docker is ready. Please enter each configuration value." + echoContent "white" "Press Enter to accept the default behaviour shown in brackets." + echoContent "skyBlue" "────────────────────────────────────────────────────────" + + if [[ -z "${installMode}" ]]; then + echoContent "yellow" "1. Reality Vision" + echoContent "yellow" "2. Reality XHTTP" + echoContent "yellow" "3. Install both" + case "$(promptValue $'\n[Step 1/8] Install mode [1-3]: ' "")" in + 1) installMode="vision" ;; + 2) installMode="xhttp" ;; + 3) installMode="all" ;; + *) + echoContent "red" "Invalid selection. Please enter 1 / 2 / 3" + exit 1 + ;; + esac + fi + + normalizeInstallMode "${installMode}" + + if installModeHasVision "${resolvedInstallMode}"; then + if [[ -z "${port}" ]]; then + port="$(promptValue $'\n[Step 2/8] Vision port [leave blank to pick randomly from 10000-30000]: ' "")" + if [[ -z "${port}" ]]; then + port="" + fi + fi + fi + + if installModeHasXHTTP "${resolvedInstallMode}"; then + if [[ -z "${xhttpPort}" ]]; then + xhttpPort="$(promptValue $'\n[Step 3/8] XHTTP port [leave blank to pick randomly from 10000-30000]: ' "")" + if [[ -z "${xhttpPort}" ]]; then + xhttpPort="" + fi + fi + if [[ -z "${xhttpPath}" ]]; then + xhttpPath="$(promptValue $'\n[Step 4/8] XHTTP path [e.g. alone, leave blank to auto-generate]: ' "")" + fi + fi + + if [[ -z "${serverName}" ]]; then + serverName="$(promptValue $'\n[Step 5/8] Server name [leave blank to pick a random Reality target]: ' "")" + fi + if [[ -z "${privateKey}" ]]; then + privateKey="$(promptValue $'\n[Step 6/8] Private key [leave blank to auto-generate]: ' "")" + fi + if [[ -z "${uuid}" ]]; then + uuid="$(promptValue $'\n[Step 7/8] UUID [leave blank to auto-generate]: ' "")" + fi + if [[ -z "${email}" ]]; then + email="$(promptValue $'\n[Step 8/8] Email base name [leave blank to derive from UUID]: ' "")" + fi + echoContent "white" "" +} + +# --------------------------------------------------------------------------- +# parseCli — parse command-line arguments +# --------------------------------------------------------------------------- +parseCli() { + while [[ $# -gt 0 ]]; do + case "$1" in + --non-interactive) + nonInteractive=1 + shift + ;; + --data-dir) + if [[ $# -lt 2 || -z "${2:-}" || "${2:0:1}" == "-" || "${2}" == *:* || "${2}" != /* ]]; then + echoContent "red" "--data-dir requires a path value" + exit 1 + fi + dataDir="$2" + validateDataDirPath "${dataDir}" + shift 2 + ;; + --install-mode) + if [[ $# -lt 2 || -z "${2:-}" || "${2:0:1}" == "-" ]]; then + echoContent "red" "--install-mode requires a value (vision / xhttp / all)" + exit 1 + fi + installMode="$2" + shift 2 + ;; + --port) + if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then + echoContent "red" "--port requires a numeric value" + exit 1 + fi + port="$2" + shift 2 + ;; + --xhttp-port) + if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then + echoContent "red" "--xhttp-port requires a numeric value" + exit 1 + fi + xhttpPort="$2" + shift 2 + ;; + --xhttp-path) + if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then + echoContent "red" "--xhttp-path requires a value" + exit 1 + fi + xhttpPath="$2" + shift 2 + ;; + --server-name) + if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then + echoContent "red" "--server-name requires a value" + exit 1 + fi + serverName="$2" + shift 2 + ;; + --private-key) + if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then + echoContent "red" "--private-key requires a value" + exit 1 + fi + privateKey="$2" + shift 2 + ;; + --uuid) + if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then + echoContent "red" "--uuid requires a value" + exit 1 + fi + uuid="$2" + shift 2 + ;; + --email) + if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then + echoContent "red" "--email requires a value" + exit 1 + fi + email="$2" + shift 2 + ;; + --generate-only) + generateOnly=1 + shift + ;; + --start-only) + startOnly=1 + shift + ;; + --skip-self-install) + skipSelfInstall=1 + shift + ;; + -h | --help) + showHelp + ;; + *) + echoContent "red" "Unknown option: $1" + showHelp 1 + ;; + esac + done + +} + +# --------------------------------------------------------------------------- +# checkEnvironment — require Linux and root/sudo +# --------------------------------------------------------------------------- +checkEnvironment() { + local osType + osType="$(uname -s 2>/dev/null || true)" + if [[ "${osType}" != "Linux" ]]; then + echoContent "red" "This script requires a Linux host (detected: ${osType})" + exit 1 + fi + + if [[ "$(id -u)" != "0" ]]; then + echoContent "red" "This script must be run as root or with sudo" + exit 1 + fi +} + +# selfInstallShortcut — mirror install.sh aliasInstall behavior for this standalone script and create the vasmad shortcut. +selfInstallShortcut() { + local targetScript="/etc/v2ray-agent/docker_reality_en.sh" + local currentScript="" + local shortcutCreated="false" + + if [[ "${skipSelfInstall}" == "1" ]]; then + return 0 + fi + + currentScript=$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$0" 2>/dev/null || true) + if [[ -z "${currentScript}" ]]; then + currentScript="$0" + fi + + mkdir -p /etc/v2ray-agent + + if [[ "${currentScript}" != "${targetScript}" ]]; then + if ! mv "${currentScript}" "${targetScript}" 2>/dev/null; then + echoContent "yellow" "Unable to move the script automatically to ${targetScript}; continuing from the current path." + else + chmod 700 "${targetScript}" + currentScript="${targetScript}" + echoContent "green" "Script moved to ${targetScript}" + fi + else + chmod 700 "${targetScript}" + fi + + if [[ -f "${targetScript}" ]]; then + if [[ -d "/usr/bin/" ]]; then + rm -f /usr/bin/vasmad + ln -s "${targetScript}" /usr/bin/vasmad + chmod 700 /usr/bin/vasmad + shortcutCreated="true" + fi + + if [[ -d "/usr/sbin/" ]]; then + rm -f /usr/sbin/vasmad + ln -s "${targetScript}" /usr/sbin/vasmad + chmod 700 /usr/sbin/vasmad + shortcutCreated="true" + fi + fi + + if [[ "${shortcutCreated}" == "true" ]]; then + echoContent "green" "Shortcut created successfully. Execute [vasmad] to reopen the script" + echoContent "yellow" "Launch command: vasmad" + fi +} + +# --------------------------------------------------------------------------- +# installDocker — download and run the official Docker convenience install script +# --------------------------------------------------------------------------- +installDocker() { + echoContent "white" "Downloading Docker install script from https://get.docker.com ..." + local tmpScript + tmpScript="$(mktemp /tmp/get-docker-XXXXXX.sh)" + if ! curl -fsSL https://get.docker.com -o "${tmpScript}"; then + echoContent "red" "Failed to download Docker install script from https://get.docker.com" + rm -f "${tmpScript}" + return 1 + fi + echoContent "white" "Running Docker install script..." + if ! sh "${tmpScript}"; then + echoContent "red" "Docker installation failed" + rm -f "${tmpScript}" + return 1 + fi + rm -f "${tmpScript}" + echoContent "green" "Docker installed successfully" + return 0 +} + +# checkDocker — verify Docker is installed and running; prompt to install if missing +checkDocker() { + # QA override: V2RAY_AGENT_FORCE_NO_DOCKER=1 forces the Docker-missing branch + if [[ "${forceNoDocker}" == "1" ]]; then + echoContent "yellow" "[QA] V2RAY_AGENT_FORCE_NO_DOCKER=1: simulating Docker not installed" + _promptDockerInstall + return $? + fi + + echoContent "white" "Checking Docker availability..." + + if ! command -v docker >/dev/null 2>&1; then + echoContent "yellow" "Docker is not installed on this system" + _promptDockerInstall + return $? + fi + + # Docker binary exists — verify the daemon is reachable + if ! docker info >/dev/null 2>&1; then + echoContent "red" "Docker is installed but the daemon is not running" + echoContent "white" "Please start the Docker daemon (e.g.: systemctl start docker) and retry" + exit 1 + fi + + echoContent "green" "Docker is available and running" + return 0 +} + +# _promptDockerInstall — ask the user whether to install Docker; exit cleanly on refusal +_promptDockerInstall() { + local answer + if [[ "${nonInteractive}" == "1" ]]; then + echoContent "red" "Docker is required but not installed. Use interactive mode, or set V2RAY_AGENT_FORCE_NO_DOCKER for QA only." + exit 1 + fi + + echoContent "yellow" "Docker is required to run this script." + answer="$(promptValue $'\033[33mInstall Docker now via https://get.docker.com? [y/N]: \033[0m' "")" + case "${answer}" in + [yY] | [yY][eE][sS]) + if ! installDocker; then + echoContent "red" "Docker installation failed, exiting." + exit 1 + fi + # Verify the daemon started after installation + if ! docker info >/dev/null 2>&1; then + echoContent "yellow" "Docker installed but daemon not yet running, starting..." + systemctl start docker 2>/dev/null || true + sleep 2 + if ! docker info >/dev/null 2>&1; then + echoContent "red" "Docker daemon failed to start. Please start it manually and retry." + exit 1 + fi + fi + echoContent "green" "Docker is ready" + ;; + *) + echoContent "white" "Docker installation declined, exiting." + exit 0 + ;; + esac +} + +# --------------------------------------------------------------------------- +# xrayImagePreflight — validate a config file using the official Xray image +# Usage: xrayImagePreflight +# --------------------------------------------------------------------------- +xrayImagePreflight() { + local configFile="$1" + local xrayImage="ghcr.io/xtls/xray-core:26.5.9" + + if [[ -z "${configFile}" ]]; then + echoContent "red" "xrayImagePreflight: config file path is required" + return 1 + fi + + if [[ ! -f "${configFile}" ]]; then + echoContent "red" "xrayImagePreflight: config file not found: ${configFile}" + return 1 + fi + + echoContent "white" "Pulling Xray image ${xrayImage} (if not cached)..." + docker pull "${xrayImage}" >/dev/null 2>&1 || true + + echoContent "white" "Validating config: docker run --rm --user root -v \"${configFile}:/usr/local/etc/xray/config.json:ro\" ${xrayImage} run -test -c /usr/local/etc/xray/config.json" + if docker run --rm \ + --user root \ + -v "${configFile}:/usr/local/etc/xray/config.json:ro" \ + "${xrayImage}" \ + run -test -c /usr/local/etc/xray/config.json; then + echoContent "green" "Xray config validation passed" + return 0 + else + echoContent "red" "Xray config validation failed" + return 1 + fi +} + +# --------------------------------------------------------------------------- +# Value generation helpers +# --------------------------------------------------------------------------- + +# _realityDomainList — curated Reality target domain list, kept in sync with install.sh:9618 +# Returns a comma-separated domain string (Xray-core list) +_realityDomainList() { + printf '%s' "download-installer.cdn.mozilla.net,addons.mozilla.org,s0.awsstatic.com,d1.awsstatic.com,images-na.ssl-images-amazon.com,m.media-amazon.com,player.live-video.net,one-piece.com,lol.secure.dyn.riotcdn.net,www.lovelive-anime.jp,academy.nvidia.com,dl.google.com,www.google-analytics.com,www.caltech.edu,www.calstatela.edu,www.suny.edu,www.suffolk.edu,www.python.org,vuejs-jp.org,vuejs.org,zh-hk.vuejs.org,react.dev,www.java.com,www.oracle.com,www.mysql.com,www.mongodb.com,redis.io,cname.vercel-dns.com,vercel-dns.com,www.swift.com,academy.nvidia.com,www.swift.com,www.cisco.com,www.asus.com,www.samsung.com,www.amd.com,www.umcg.nl,www.fom-international.com,www.u-can.co.jp,github.io" +} + +# parsePort — validate and normalise a port value. +# Usage: parsePort +# Sets global: resolvedPort +# Returns 0 on success, 1 on invalid input. +parsePort() { + local raw="$1" + if [[ -z "${raw}" ]]; then + # Blank → pick randomly from 10000-30000 (aligned with install.sh:9711) + resolvedPort=$((RANDOM % 20001 + 10000)) + echoContent "yellow" "No port provided — randomly selected: ${resolvedPort}" + return 0 + fi + # Must be a positive integer + if ! [[ "${raw}" =~ ^[0-9]+$ ]]; then + echoContent "red" "Invalid port '${raw}': must be a positive integer" + return 1 + fi + if [[ "${raw}" -lt 1 || "${raw}" -gt 65535 ]]; then + echoContent "red" "Invalid port '${raw}': must be in range 1-65535" + return 1 + fi + resolvedPort="${raw}" + return 0 +} + +# checkPortInUse — exit if the port is occupied by a process other than the v2ray-agent-docker container +# (aligned with install.sh:1951-1957). +# On re-run the script does docker rm -f v2ray-agent-docker before recreating, +# so a port held only by that container is not a real conflict. +# Usage: checkPortInUse +checkPortInUse() { + local p="$1" + if ! command -v lsof >/dev/null 2>&1; then + return 0 + fi + # Collect PIDs listening on this port + local listenPids + listenPids=$(lsof -i "tcp:${p}" -sTCP:LISTEN -t 2>/dev/null || true) + [[ -z "${listenPids}" ]] && return 0 + + # Check whether the existing v2ray-agent-docker container holds the port. + # If the container exists, its published ports are managed by docker-proxy. + # If all listening PIDs belong to that container's docker-proxy processes, allow the port. + local containerExists=0 + if docker ps -a --filter "name=^/v2ray-agent-docker$" --format '{{.Names}}' 2>/dev/null \ + | grep -q "^v2ray-agent-docker$"; then + containerExists=1 + fi + + if [[ "${containerExists}" == "1" ]]; then + # Verify all listening PIDs are docker-proxy processes (Docker's forwarder for published ports). + # If any PID is not docker-proxy, the port is held by an unrelated process — a real conflict. + local unrelatedPids=0 + local pid + while IFS= read -r pid; do + [[ -z "${pid}" ]] && continue + local comm + comm=$(cat "/proc/${pid}/comm" 2>/dev/null || ps -p "${pid}" -o comm= 2>/dev/null || true) + if [[ "${comm}" != *"docker-proxy"* ]]; then + unrelatedPids=1 + break + fi + done <<<"${listenPids}" + + if [[ "${unrelatedPids}" == "0" ]]; then + # Port is held only by v2ray-agent-docker's docker-proxy — safe to continue + echoContent "yellow" "Port ${p} is held by the existing v2ray-agent-docker container (will be replaced)" + return 0 + fi + fi + + echoContent "red" "Port ${p} is already in use — please free it and retry" + lsof -i "tcp:${p}" -sTCP:LISTEN 2>/dev/null || true + exit 1 +} + +# parseServerName — validate and normalise serverName / host:port input. +# Usage: parseServerName +# Sets globals: resolvedServerName, resolvedTargetPort +# Returns 0 on success. +parseServerName() { + local raw="$1" + if [[ -z "${raw}" ]]; then + # Blank → pick randomly from the curated list (aligned with install.sh:9673-9678) + local domainList count randomIdx + domainList="$(_realityDomainList)" + count=$(printf '%s' "${domainList}" | awk -F',' '{print NF}') + randomIdx=$(( (RANDOM % count) + 1 )) + resolvedServerName=$(printf '%s' "${domainList}" | awk -F',' -v n="${randomIdx}" '{print $n}') + resolvedTargetPort=443 + echoContent "yellow" "No serverName provided — randomly selected: ${resolvedServerName}:${resolvedTargetPort}" + return 0 + fi + # Support host:port syntax (aligned with install.sh:9679-9682) + if printf '%s' "${raw}" | grep -q ":"; then + resolvedTargetPort=$(printf '%s' "${raw}" | awk -F: '{print $2}') + resolvedServerName=$(printf '%s' "${raw}" | awk -F: '{print $1}') + else + resolvedServerName="${raw}" + resolvedTargetPort=443 + fi + return 0 +} + +# generatePrivateKey — generate an X25519 key pair via the pre-checked Xray Docker image. +# Usage: generatePrivateKey +# Sets globals: resolvedPrivateKey, resolvedPublicKey +# Returns 0 on success, 1 on failure. +generatePrivateKey() { + local xrayRuntime="$1" + local x25519Output + if [[ -z "${xrayRuntime}" ]]; then + echoContent "red" "generatePrivateKey: xray runtime path is required" + return 1 + fi + # xrayRuntime may be a Docker invocation string or a host binary path + x25519Output=$(${xrayRuntime} x25519 2>/dev/null) || { + echoContent "red" "Failed to generate X25519 key pair via: ${xrayRuntime} x25519" + return 1 + } + resolvedPrivateKey=$(printf '%s' "${x25519Output}" | awk -F': ' '/PrivateKey|Private key/ {print $2; exit}') + resolvedPublicKey=$(printf '%s' "${x25519Output}" | awk -F': ' '/Password|Public key/ {print $2; exit}') + if [[ -z "${resolvedPrivateKey}" ]]; then + echoContent "red" "X25519 key generation produced no PrivateKey output" + return 1 + fi + echoContent "green" "Generated public key: ${resolvedPublicKey}" + return 0 +} + +# derivePublicKey — derive the public key from an existing private key via the Xray runtime. +# Usage: derivePublicKey +# Sets global: resolvedPublicKey +# Returns 0 on success, 1 on failure. +derivePublicKey() { + local xrayRuntime="$1" + local privKey="$2" + local x25519Output + x25519Output=$(${xrayRuntime} x25519 -i "${privKey}" 2>/dev/null) || { + echoContent "red" "Failed to derive public key from the provided private key" + return 1 + } + resolvedPublicKey=$(printf '%s' "${x25519Output}" | awk -F': ' '/Password|Public key/ {print $2; exit}') + if [[ -z "${resolvedPublicKey}" ]]; then + echoContent "red" "Provided private key is invalid — cannot derive public key" + return 1 + fi + return 0 +} + +# generateUUID — generate a UUID via the pre-checked Xray Docker image. +# Usage: generateUUID +# Sets global: resolvedUUID +# Returns 0 on success, 1 on failure. +generateUUID() { + local xrayRuntime="$1" + local uuidOutput + if [[ -z "${xrayRuntime}" ]]; then + echoContent "red" "generateUUID: xray runtime path is required" + return 1 + fi + uuidOutput=$(${xrayRuntime} uuid 2>/dev/null) || { + echoContent "red" "Failed to generate UUID via: ${xrayRuntime} uuid" + return 1 + } + resolvedUUID=$(printf '%s' "${uuidOutput}" | tr -d '[:space:]') + if [[ -z "${resolvedUUID}" ]]; then + echoContent "red" "UUID generation produced empty output" + return 1 + fi + echoContent "green" "Generated UUID: ${resolvedUUID}" + return 0 +} + +# deriveEmail — derive email base from the UUID prefix (aligned with install.sh:3842) +# Usage: deriveEmail +# Sets global: resolvedEmail +deriveEmail() { + local uuidVal="$1" + # Take the part before the first '-' + resolvedEmail="${uuidVal%%-*}" +} + +# _xrayDockerRuntime — return the docker-run command prefix for the Xray image. +# This is the pre-checked runtime path used in value generation (no host binary assumed). +_xrayDockerRuntime() { + printf '%s' "docker run --rm ghcr.io/xtls/xray-core:26.5.9" +} + +# loadPersistedSummaryIfPresent — optionally read and validate client-summary.txt. +# Sets: persistedContainer, persistedInstallMode, persistedVisionPort, persistedXHTTPPort, +# persistedXHTTPPath, persistedServerName, persistedPublicKey, persistedUUID, +# persistedEmailBase, persistedVisionEmail, persistedXHTTPEmail, +# persistedShortId, persistedConfigPath +loadPersistedSummaryIfPresent() { + local summaryFile="${dataDir%/}/client-summary.txt" + + persistedContainer="" + persistedInstallMode="" + persistedVisionPort="" + persistedXHTTPPort="" + persistedXHTTPPath="" + persistedServerName="" + persistedPublicKey="" + persistedUUID="" + persistedEmailBase="" + persistedVisionEmail="" + persistedXHTTPEmail="" + persistedShortId="6ba85179e30d4fc2" + 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: }" ;; + 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:]]*}"}" + persistedConfigPath="${persistedConfigPath#"${persistedConfigPath%%[![:space:]]*}"}" + + if [[ -n "${persistedContainer}" && "${persistedContainer}" != "v2ray-agent-docker" ]]; then + echoContent "red" "Persisted summary container name mismatch: ${persistedContainer}" + exit 1 + fi + if [[ -n "${persistedConfigPath}" && "${persistedConfigPath}" != "${dataDir%/}/config.json" ]]; then + echoContent "red" "Persisted summary configPath mismatch: ${persistedConfigPath}" + exit 1 + fi +} + +# loadPersistedStateFromConfig — restore protocol mode, ports, path, and display fields from config.json. +loadPersistedStateFromConfig() { + local configFile="${dataDir%/}/config.json" + local parsedLines=() + + if [[ ! -f "${configFile}" ]]; then + echoContent "red" "Persisted config not found: ${configFile}" + exit 1 + fi + if ! command -v python3 >/dev/null 2>&1; then + echoContent "red" "python3 is required to safely parse the persisted config.json" + exit 1 + fi + + loadPersistedSummaryIfPresent + + mapfile -t parsedLines < <(python3 - <<'PY' "${configFile}" +import json, sys + +path = sys.argv[1] +try: + with open(path, 'r', encoding='utf-8') as fh: + data = json.load(fh) +except Exception: + sys.exit(1) + +vision_port = '' +xhttp_port = '' +xhttp_path = '' +server_name = '' +public_key = '' +uuid = '' +vision_email = '' +xhttp_email = '' + +for inbound in data.get('inbounds', []): + tag = inbound.get('tag') + port = inbound.get('port') + if tag == 'dokodemo-in-VLESSReality' and isinstance(port, int): + vision_port = str(port) + elif tag == 'VLESSRealityXHTTP' and isinstance(port, int): + xhttp_port = str(port) + xhttp_settings = (inbound.get('streamSettings') or {}).get('xhttpSettings') or {} + raw_path = xhttp_settings.get('path') or '' + if raw_path.startswith('/'): + raw_path = raw_path[1:] + if raw_path.endswith('xHTTP'): + raw_path = raw_path[:-5] + xhttp_path = raw_path.rstrip('/') + +for inbound in data.get('inbounds', []): + if inbound.get('protocol') != 'vless': + continue + settings = inbound.get('settings') or {} + clients = settings.get('clients') or [] + if not clients: + continue + stream_settings = inbound.get('streamSettings') or {} + reality_settings = stream_settings.get('realitySettings') or {} + server_names = reality_settings.get('serverNames') or [] + if not server_name and server_names: + server_name = server_names[0] + if not public_key: + public_key = reality_settings.get('publicKey') or '' + if not uuid: + uuid = clients[0].get('id') or '' + + network = stream_settings.get('network') + if network == 'tcp': + vision_email = clients[0].get('email') or '' + elif network == 'xhttp': + xhttp_email = clients[0].get('email') or '' + +if vision_port and xhttp_port: + install_mode = 'all' +elif vision_port: + install_mode = 'vision' +elif xhttp_port: + install_mode = 'xhttp' +else: + sys.exit(1) + +print(install_mode) +print(vision_port) +print(xhttp_port) +print(xhttp_path) +print(server_name) +print(public_key) +print(uuid) +print(vision_email) +print(xhttp_email) +PY +) + + if [[ ${#parsedLines[@]} -ne 9 ]]; then + echoContent "red" "Persisted config format error: cannot read protocol state from ${configFile}" + exit 1 + fi + + persistedInstallMode="${parsedLines[0]}" + persistedVisionPort="${parsedLines[1]}" + persistedXHTTPPort="${parsedLines[2]}" + persistedXHTTPPath="${parsedLines[3]}" + persistedServerName="${parsedLines[4]}" + persistedPublicKey="${parsedLines[5]}" + persistedUUID="${parsedLines[6]}" + persistedVisionEmail="${parsedLines[7]}" + persistedXHTTPEmail="${parsedLines[8]}" + + if [[ -z "${persistedEmailBase}" ]]; then + persistedEmailBase="${persistedUUID%%-*}" + fi + if installModeHasVision "${persistedInstallMode}" && [[ -z "${persistedVisionEmail}" ]]; then + persistedVisionEmail="${persistedEmailBase}-VLESS_TCP/TLS_Vision" + fi + if installModeHasXHTTP "${persistedInstallMode}" && [[ -z "${persistedXHTTPEmail}" ]]; then + persistedXHTTPEmail="${persistedEmailBase}-VLESS_Reality_XHTTP" + fi + + case "${persistedInstallMode}" in + vision) + [[ -n "${persistedVisionPort}" ]] || { echoContent "red" "Persisted config missing Vision port"; exit 1; } + ;; + xhttp) + [[ -n "${persistedXHTTPPort}" && -n "${persistedXHTTPPath}" ]] || { echoContent "red" "Persisted config missing XHTTP port or path"; exit 1; } + ;; + all) + [[ -n "${persistedVisionPort}" && -n "${persistedXHTTPPort}" && -n "${persistedXHTTPPath}" ]] || { echoContent "red" "Persisted config missing fields for 'all' install mode"; exit 1; } + ;; + *) + echoContent "red" "Invalid install mode in persisted config: ${persistedInstallMode}" + exit 1 + ;; + esac +} + +# loadPersistedPort — restore protocol mode and ports from persisted config. +loadPersistedPort() { + loadPersistedStateFromConfig +} + +# getPublicIP — try to resolve the current host's public IP for account display output. +getPublicIP() { + local currentIP="" + + if command -v curl >/dev/null 2>&1; then + currentIP=$(curl -fsS -4 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null | grep '^ip=' | awk -F '=' '{print $2}') + if [[ -z "${currentIP}" ]]; then + currentIP=$(curl -fsS -6 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null | grep '^ip=' | awk -F '=' '{print $2}') + fi + if [[ -z "${currentIP}" ]]; then + currentIP=$(curl -fsS https://api.ipify.org 2>/dev/null || true) + fi + fi + + if [[ -z "${currentIP}" ]]; then + currentIP=$(hostname -I 2>/dev/null | awk '{print $1}' || true) + fi + + printf '%s' "${currentIP}" +} + +# loadPersistedAccountInfo — restore account fields needed for display from summary (preferred) or config.json. +# Sets: persistedInstallMode, persistedVisionPort, persistedXHTTPPort, persistedXHTTPPath, +# persistedServerName, persistedPublicKey, persistedUUID, persistedEmailBase, +# persistedVisionEmail, persistedXHTTPEmail, persistedShortId +loadPersistedAccountInfo() { + local configFile="${dataDir%/}/config.json" + + if [[ ! -f "${configFile}" ]]; then + echoContent "red" "Persisted config not found: ${configFile}" + exit 1 + fi + + # Prefer summary file (most complete field set) + loadPersistedSummaryIfPresent + + # Fall back to config.json for any missing critical fields + if [[ -z "${persistedInstallMode}" || -z "${persistedServerName}" || -z "${persistedUUID}" || -z "${persistedPublicKey}" ]]; then + if ! command -v python3 >/dev/null 2>&1; then + echoContent "red" "python3 is required to safely parse the persisted config.json" + exit 1 + fi + + local parsedLines=() + mapfile -t parsedLines < <(python3 - <<'PY' "${configFile}" +import json, sys + +path = sys.argv[1] +try: + with open(path, 'r', encoding='utf-8') as fh: + data = json.load(fh) +except Exception: + sys.exit(1) + +vision_port = '' +xhttp_port = '' +xhttp_path = '' +server_name = '' +public_key = '' +uuid = '' +vision_email = '' +xhttp_email = '' + +for inbound in data.get('inbounds', []): + tag = inbound.get('tag') + port = inbound.get('port') + if tag == 'dokodemo-in-VLESSReality' and isinstance(port, int): + vision_port = str(port) + elif tag == 'VLESSRealityXHTTP' and isinstance(port, int): + xhttp_port = str(port) + xhttp_settings = (inbound.get('streamSettings') or {}).get('xhttpSettings') or {} + raw_path = xhttp_settings.get('path') or '' + if raw_path.startswith('/'): + raw_path = raw_path[1:] + if raw_path.endswith('xHTTP'): + raw_path = raw_path[:-5] + xhttp_path = raw_path.rstrip('/') + +for inbound in data.get('inbounds', []): + if inbound.get('protocol') != 'vless': + continue + settings = inbound.get('settings') or {} + clients = settings.get('clients') or [] + if not clients: + continue + stream_settings = inbound.get('streamSettings') or {} + reality_settings = stream_settings.get('realitySettings') or {} + server_names = reality_settings.get('serverNames') or [] + if not server_name and server_names: + server_name = server_names[0] + if not public_key: + public_key = reality_settings.get('publicKey') or '' + if not uuid: + uuid = clients[0].get('id') or '' + network = stream_settings.get('network') + if network == 'tcp' and not vision_email: + vision_email = clients[0].get('email') or '' + elif network == 'xhttp' and not xhttp_email: + xhttp_email = clients[0].get('email') or '' + +if vision_port and xhttp_port: + install_mode = 'all' +elif vision_port: + install_mode = 'vision' +elif xhttp_port: + install_mode = 'xhttp' +else: + sys.exit(1) + +print(install_mode) +print(vision_port) +print(xhttp_port) +print(xhttp_path) +print(server_name) +print(public_key) +print(uuid) +print(vision_email) +print(xhttp_email) +PY +) + + if [[ ${#parsedLines[@]} -ne 9 ]]; then + echoContent "red" "Persisted config format error: failed to read account info from ${configFile}" + exit 1 + fi + + [[ -z "${persistedInstallMode}" ]] && persistedInstallMode="${parsedLines[0]}" + [[ -z "${persistedVisionPort}" ]] && persistedVisionPort="${parsedLines[1]}" + [[ -z "${persistedXHTTPPort}" ]] && persistedXHTTPPort="${parsedLines[2]}" + [[ -z "${persistedXHTTPPath}" ]] && persistedXHTTPPath="${parsedLines[3]}" + [[ -z "${persistedServerName}" ]] && persistedServerName="${parsedLines[4]}" + [[ -z "${persistedPublicKey}" ]] && persistedPublicKey="${parsedLines[5]}" + [[ -z "${persistedUUID}" ]] && persistedUUID="${parsedLines[6]}" + [[ -z "${persistedVisionEmail}" ]] && persistedVisionEmail="${parsedLines[7]}" + [[ -z "${persistedXHTTPEmail}" ]] && persistedXHTTPEmail="${parsedLines[8]}" + fi + + # Fill in any still-missing email fields + if [[ -z "${persistedEmailBase}" && -n "${persistedUUID}" ]]; then + persistedEmailBase="${persistedUUID%%-*}" + fi + if installModeHasVision "${persistedInstallMode}" && [[ -z "${persistedVisionEmail}" ]]; then + persistedVisionEmail="${persistedEmailBase}-VLESS_TCP/TLS_Vision" + fi + if installModeHasXHTTP "${persistedInstallMode}" && [[ -z "${persistedXHTTPEmail}" ]]; then + persistedXHTTPEmail="${persistedEmailBase}-VLESS_Reality_XHTTP" + fi +} + +# hasPersistedConfig — check whether a persisted config already exists under dataDir. +hasPersistedConfig() { + local configFile="${dataDir%/}/config.json" + [[ -f "${configFile}" ]] +} + +# hasManagedContainer — check whether the target Docker container exists (running or stopped). +hasManagedContainer() { + docker ps -a --filter "name=^/v2ray-agent-docker$" --format '{{.Names}}' 2>/dev/null | grep -q '^v2ray-agent-docker$' +} + +# hasExistingInstallState — treat either config presence or container presence as existing/residual install state. +hasExistingInstallState() { + if hasPersistedConfig || hasManagedContainer; then + return 0 + fi + return 1 +} + +# uninstallDockerReality — remove the standalone Docker Reality container, data, script shortcut, and installed script copy. +uninstallDockerReality() { + local answer="" + local installedScript="/etc/v2ray-agent/docker_reality_en.sh" + + answer="$(promptValue $'Confirm uninstall of Docker Reality content? [y/N]: ' "")" + case "${answer}" in + [yY] | [yY][eE][sS]) + ;; + *) + echoContent "green" " ---> Uninstall cancelled" + return 0 + ;; + esac + + if hasManagedContainer; then + docker rm -f v2ray-agent-docker >/dev/null 2>&1 || true + echoContent "green" " ---> Docker container removed" + fi + + rm -rf "${dataDir%/}" >/dev/null 2>&1 || true + echoContent "green" " ---> Docker Reality data directory removed" + + rm -rf /usr/bin/vasmad >/dev/null 2>&1 || true + rm -rf /usr/sbin/vasmad >/dev/null 2>&1 || true + echoContent "green" " ---> Shortcut removed" + + if [[ -f "${installedScript}" ]]; then + rm -f "${installedScript}" >/dev/null 2>&1 || true + echoContent "green" " ---> Installed script removed" + fi + + exit 0 +} + +# promptExistingInstallAction — offer an interactive menu based on combined config/container state. +promptExistingInstallAction() { + local action="" + local hasConfig=1 + local hasContainer=1 + + if [[ "${nonInteractive}" == "1" || "${startOnly}" == "1" || "${generateOnly}" == "1" ]]; then + return 0 + fi + + if hasPersistedConfig; then + hasConfig=0 + fi + if hasManagedContainer; then + hasContainer=0 + fi + + echoContent "skyBlue" "" + if [[ ${hasConfig} -ne 0 && ${hasContainer} -ne 0 ]]; then + echoContent "skyBlue" "─── No Existing Docker Reality Installation Detected ───────────────" + echoContent "white" "No config file or container was detected. Choose the next action:" + echoContent "yellow" "1. Install" + echoContent "yellow" "2. Exit" + + while true; do + action="$(promptValue $'Choose [1-2]: ' "")" + case "${action}" in + 1) + echoContent "yellow" "Install selected. The script will collect values and create the container." + return 0 + ;; + 2) + echoContent "white" "Exiting without changes." + exit 0 + ;; + *) + echoContent "red" "Invalid selection. Please enter 1-2." + ;; + esac + done + fi + + echoContent "skyBlue" "─── Existing/Residual Docker Reality State Detected ───────────────" + if [[ ${hasConfig} -eq 0 && ${hasContainer} -eq 0 ]]; then + echoContent "white" "Detected both the config file and the container. Choose the next action:" + elif [[ ${hasConfig} -eq 0 ]]; then + echoContent "white" "Detected the config file but not the container. Choose the next action:" + else + echoContent "white" "Detected the container but not the config file. Choose the next action:" + fi + echoContent "yellow" "1. View account" + echoContent "yellow" "2. Reinstall" + echoContent "yellow" "3. Start/Recreate container" + echoContent "yellow" "4. Uninstall" + echoContent "yellow" "5. Exit" + + while true; do + action="$(promptValue $'Choose [1-5]: ' "")" + case "${action}" in + 1) + if hasPersistedConfig; then + showClientInfo + exit 0 + else + echoContent "red" "No existing config was found, so the account cannot be shown. Please reinstall first." + fi + ;; + 2) + echoContent "yellow" "Reinstall selected. The script will collect new values and recreate the container." + return 0 + ;; + 3) + if ! hasExistingInstallState; then + echoContent "red" "No installed state was detected, so the container cannot be started/recreated. Please install first." + continue + fi + if ! hasPersistedConfig; then + echoContent "red" "No existing config was found, so the container cannot be started/recreated. Please reinstall first." + continue + fi + startOnly=1 + echoContent "yellow" "Using the existing config to start/recreate the container." + return 0 + ;; + 4) + uninstallDockerReality + ;; + 5) + echoContent "white" "Exiting without changes." + exit 0 + ;; + *) + echoContent "red" "Invalid selection. Please enter 1-5." + ;; + esac + done +} + +# showVisionAccount — display the Vision account in a showAccounts-style layout. +# Args: $1=address $2=port $3=serverName $4=publicKey $5=uuid $6=email $7=shortId +showVisionAccount() { + local displayAddress="$1" + local displayPort="$2" + local displayServerName="$3" + local displayPublicKey="$4" + local displayUUID="$5" + local displayEmail="$6" + local displayShortId="$7" + local vlessLink qrData qrLink + + if [[ -z "${displayAddress}" ]]; then + displayAddress="YOUR_SERVER_IP" + fi + + vlessLink="vless://${displayUUID}@${displayAddress}:${displayPort}?encryption=none&security=reality&type=tcp&sni=${displayServerName}&fp=chrome&pbk=${displayPublicKey}&sid=${displayShortId}&flow=xtls-rprx-vision#${displayEmail}" + qrData="${vlessLink//:/%3A}" + qrData="${qrData//\//%2F}" + qrData="${qrData//@/%40}" + qrData="${qrData//\?/%3F}" + qrData="${qrData//&/%26}" + qrData="${qrData//#/%23}" + qrData="${qrData//=/%3D}" + qrLink="https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${qrData}" + + echoContent "skyBlue" "============================= VLESS Reality Vision [Recommended] ==============================" + echoContent "skyBlue" "" + echoContent "skyBlue" " ---> Account: ${displayEmail}" + echoContent "white" "" + echoContent "yellow" " ---> Universal Format (VLESS+reality+uTLS+Vision)" + echoContent "green" " ${vlessLink}" + echoContent "white" "" + echoContent "yellow" " ---> Formatted Plaintext (VLESS+reality+uTLS+Vision)" + echoContent "green" "Protocol: VLESS reality, Address: ${displayAddress}, publicKey: ${displayPublicKey}, shortId: ${displayShortId}, serverNames: ${displayServerName}, Port: ${displayPort}, UserID: ${displayUUID}, Transport: tcp, Account: ${displayEmail}" + echoContent "white" "" + echoContent "yellow" " ---> QR Code VLESS (VLESS+reality+uTLS+Vision)" + echoContent "green" " ${qrLink}" +} + +# showXHTTPAccount — display the XHTTP account in a showAccounts-style layout. +# Args: $1=address $2=port $3=serverName $4=publicKey $5=uuid $6=email $7=shortId $8=renderedPath +showXHTTPAccount() { + local displayAddress="$1" + local displayPort="$2" + local displayServerName="$3" + local displayPublicKey="$4" + local displayUUID="$5" + local displayEmail="$6" + local displayShortId="$7" + local displayPath="$8" + local vlessLink qrLink + + if [[ -z "${displayAddress}" ]]; then + displayAddress="YOUR_SERVER_IP" + fi + + vlessLink="vless://${displayUUID}@${displayAddress}:${displayPort}?encryption=none&security=reality&type=xhttp&sni=${displayServerName}&host=${displayServerName}&fp=chrome&path=${displayPath}&pbk=${displayPublicKey}&sid=${displayShortId}#${displayEmail}" + qrLink="https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=vless%3A%2F%2F${displayUUID}%40${displayAddress}%3A${displayPort}%3Fencryption%3Dnone%26security%3Dreality%26type%3Dxhttp%26sni%3D${displayServerName}%26fp%3Dchrome%26path%3D${displayPath}%26host%3D${displayServerName}%26pbk%3D${displayPublicKey}%26sid%3D${displayShortId}%23${displayEmail}" + + echoContent "skyBlue" "============================= VLESS Reality XHTTP ==============================" + echoContent "skyBlue" "" + echoContent "skyBlue" " ---> Account: ${displayEmail}" + echoContent "white" "" + echoContent "yellow" " ---> Universal Format (VLESS+reality+xhttp)" + echoContent "green" " ${vlessLink}" + echoContent "white" "" + echoContent "yellow" " ---> Formatted Plaintext (VLESS+reality+xhttp)" + echoContent "green" "Protocol: VLESS reality, Address: ${displayAddress}, publicKey: ${displayPublicKey}, shortId: ${displayShortId}, serverNames: ${displayServerName}, Port: ${displayPort}, Path: ${displayPath}, SNI: ${displayServerName}, Host: ${displayServerName}, UserID: ${displayUUID}, Transport: xhttp, Account: ${displayEmail}" + echoContent "white" "" + echoContent "yellow" " ---> QR Code VLESS (VLESS+reality+xhttp)" + echoContent "green" " ${qrLink}" +} + +# resolveValues — resolve all CLI-supplied or blank values into resolved* globals. +# Depends on: installMode, port, xhttpPort, xhttpPath, serverName, privateKey, uuid, email +# Sets: resolvedInstallMode, resolvedVisionPort, resolvedXHTTPPort, resolvedXHTTPPath, +# resolvedServerName, resolvedTargetPort, resolvedPrivateKey, resolvedPublicKey, +# resolvedUUID, resolvedEmailBase, resolvedVisionEmail, resolvedXHTTPEmail +# Returns 0 on success; exits on invalid input. +resolveValues() { + local xrayRuntime + xrayRuntime="$(_xrayDockerRuntime)" + + # --- Install mode --- + normalizeInstallMode "${installMode}" + # resolvedInstallMode is set by normalizeInstallMode + + # --- Vision port --- + if installModeHasVision "${resolvedInstallMode}"; then + if ! parsePort "${port}"; then + exit 1 + fi + resolvedVisionPort="${resolvedPort}" + checkPortInUse "${resolvedVisionPort}" + fi + + # --- XHTTP port and path --- + if installModeHasXHTTP "${resolvedInstallMode}"; then + if ! parsePort "${xhttpPort}"; then + exit 1 + fi + resolvedXHTTPPort="${resolvedPort}" + # Ensure XHTTP port does not collide with Vision port in 'all' mode + if installModeHasVision "${resolvedInstallMode}" && [[ "${resolvedXHTTPPort}" == "${resolvedVisionPort}" ]]; then + echoContent "red" "XHTTP port (${resolvedXHTTPPort}) is the same as the Vision port — please use different ports" + exit 1 + fi + checkPortInUse "${resolvedXHTTPPort}" + + normalizeXHTTPPath "${xhttpPath}" + resolvedXHTTPPath="${resolvedXHTTPPath:-${resolvedXHTTPPath}}" + fi + + # --- Server name --- + parseServerName "${serverName}" + + # --- Private key --- + if [[ -z "${privateKey}" ]]; then + if ! generatePrivateKey "${xrayRuntime}"; then + exit 1 + fi + else + resolvedPrivateKey="${privateKey}" + if ! derivePublicKey "${xrayRuntime}" "${resolvedPrivateKey}"; then + echoContent "red" "Provided private key is invalid" + exit 1 + fi + fi + + # --- UUID --- + if [[ -z "${uuid}" ]]; then + if ! generateUUID "${xrayRuntime}"; then + exit 1 + fi + else + resolvedUUID="${uuid}" + fi + + # --- Email base and per-protocol emails --- + if [[ -z "${email}" ]]; then + deriveEmail "${resolvedUUID}" + resolvedEmailBase="${resolvedEmail}" + else + resolvedEmailBase="${email}" + resolvedEmail="${email}" + fi + resolvedVisionEmail="${resolvedEmailBase}-VLESS_TCP/TLS_Vision" + resolvedXHTTPEmail="${resolvedEmailBase}-VLESS_Reality_XHTTP" + + if installModeHasVision "${resolvedInstallMode}"; then + echoContent "green" "Resolved Vision port: ${resolvedVisionPort}" + fi + if installModeHasXHTTP "${resolvedInstallMode}"; then + echoContent "green" "Resolved XHTTP port: ${resolvedXHTTPPort}" + echoContent "green" "Resolved XHTTP path: $(renderXHTTPPath "${resolvedXHTTPPath}")" + fi + echoContent "green" "Resolved server name: ${resolvedServerName}:${resolvedTargetPort}" + echoContent "green" "Resolved email base: ${resolvedEmailBase}" +} + +# --------------------------------------------------------------------------- +# _buildVisionInbounds — emit the two Vision inbound JSON blocks (no trailing comma) +# Args: $1=visionPort $2=serverName $3=targetPort $4=privateKey $5=publicKey +# $6=uuid $7=visionEmail +# --------------------------------------------------------------------------- +_buildVisionInbounds() { + local vPort="$1" sName="$2" tPort="$3" privKey="$4" pubKey="$5" + local vuuid="$6" vEmail="$7" + cat </dev/null; then + echoContent "red" "Failed to create data directory: ${dataDir}" + echoContent "red" "Check that the parent path exists and is a directory, not a regular file" + exit 1 + fi + + local configFile="${dataDir}/config.json" + local summaryFile="${dataDir}/client-summary.txt" + + # Remove stale directory remnants left by previous failed runs + if [[ -d "${configFile}" ]]; then + echoContent "yellow" "Removing stale directory at ${configFile} left by a previous failed run" + rm -rf "${configFile}" 2>/dev/null || { echoContent "red" "Cannot remove stale directory: ${configFile}"; exit 1; } + fi + if [[ -d "${summaryFile}" ]]; then + echoContent "yellow" "Removing stale directory at ${summaryFile} left by a previous failed run" + rm -rf "${summaryFile}" 2>/dev/null || { echoContent "red" "Cannot remove stale directory: ${summaryFile}"; exit 1; } + fi + + local renderedXHTTPPath="" + if installModeHasXHTTP "${resolvedInstallMode}"; then + renderedXHTTPPath="$(renderXHTTPPath "${resolvedXHTTPPath}")" + fi + + # --- Build inbounds array content --- + local inboundsContent="" + case "${resolvedInstallMode}" in + vision) + inboundsContent="$(_buildVisionInbounds \ + "${resolvedVisionPort}" "${resolvedServerName}" "${resolvedTargetPort}" \ + "${resolvedPrivateKey}" "${resolvedPublicKey}" \ + "${resolvedUUID}" "${resolvedVisionEmail}")" + ;; + xhttp) + inboundsContent="$(_buildXHTTPInbound \ + "${resolvedXHTTPPort}" "${resolvedServerName}" "${resolvedTargetPort}" \ + "${resolvedPrivateKey}" "${resolvedPublicKey}" \ + "${resolvedUUID}" "${resolvedXHTTPEmail}" "${renderedXHTTPPath}")" + ;; + all) + local visionPart xhttpPart + visionPart="$(_buildVisionInbounds \ + "${resolvedVisionPort}" "${resolvedServerName}" "${resolvedTargetPort}" \ + "${resolvedPrivateKey}" "${resolvedPublicKey}" \ + "${resolvedUUID}" "${resolvedVisionEmail}")" + xhttpPart="$(_buildXHTTPInbound \ + "${resolvedXHTTPPort}" "${resolvedServerName}" "${resolvedTargetPort}" \ + "${resolvedPrivateKey}" "${resolvedPublicKey}" \ + "${resolvedUUID}" "${resolvedXHTTPEmail}" "${renderedXHTTPPath}")" + inboundsContent="${visionPart},"$'\n'"${xhttpPart}" + ;; + esac + + # --- Build routing rules --- + # Vision mode needs dokodemo-door routing rules; XHTTP listens directly on the public port + local routingRules="" + if installModeHasVision "${resolvedInstallMode}"; then + routingRules=$(cat <"${configFile}" <"${summaryFile}" + chmod 600 "${summaryFile}" + + # Post-write validation: confirm both outputs are regular files + if [[ ! -f "${configFile}" ]]; then + echoContent "red" "Config write failed — ${configFile} is not a regular file after write" + exit 1 + fi + if [[ ! -f "${summaryFile}" ]]; then + echoContent "red" "Summary write failed — ${summaryFile} is not a regular file after write" + exit 1 + fi + + echoContent "green" "Config written to ${configFile}" + echoContent "green" "Summary written to ${summaryFile}" +} + +# startContainer — validate config, remove existing container, and recreate. +# Uses config under dataDir; called after generateConfig or directly in --start-only mode. +startContainer() { + local xrayImage="ghcr.io/xtls/xray-core:26.5.9" + local containerName="v2ray-agent-docker" + + # Strip trailing slash for consistent path concatenation + dataDir="${dataDir%/}" + + local configFile="${dataDir}/config.json" + + # Verify the config file exists before attempting anything + if [[ ! -f "${configFile}" ]]; then + echoContent "red" "Config file not found: ${configFile}" + echoContent "red" "Run without --start-only to generate the config first" + exit 1 + fi + + # --- Validate config using directory mount --- + echoContent "white" "Pulling Xray image ${xrayImage} (if not cached)..." + docker pull "${xrayImage}" >/dev/null 2>&1 || true + + echoContent "white" "Validating config with Xray image (directory mount)..." + if ! docker run --rm \ + --user root \ + -v "${dataDir}:/usr/local/etc/xray:ro" \ + "${xrayImage}" \ + run -test -c /usr/local/etc/xray/config.json; then + echoContent "red" "Xray config validation failed — container will not be started" + exit 1 + fi + echoContent "green" "Xray config validation passed" + + # --- Remove any existing container with the same name to avoid name conflicts --- + if docker ps -a --filter "name=^/${containerName}$" --format '{{.Names}}' | grep -q "^${containerName}$"; then + echoContent "yellow" "Removing existing container: ${containerName}" + docker rm -f "${containerName}" >/dev/null 2>&1 || true + fi + + # --- Determine the port list to publish --- + # start-only mode restores protocol mode and ports from persisted summary/config + local activeInstallMode activeVisionPort activeXHTTPPort + if [[ "${startOnly}" == "1" ]]; then + loadPersistedPort + activeInstallMode="${persistedInstallMode}" + activeVisionPort="${persistedVisionPort}" + activeXHTTPPort="${persistedXHTTPPort}" + else + activeInstallMode="${resolvedInstallMode}" + activeVisionPort="${resolvedVisionPort}" + activeXHTTPPort="${resolvedXHTTPPort}" + fi + + # Build the -p argument list + local portArgs=() + if installModeHasVision "${activeInstallMode}" && [[ -n "${activeVisionPort}" ]]; then + portArgs+=("-p" "${activeVisionPort}:${activeVisionPort}") + fi + if installModeHasXHTTP "${activeInstallMode}" && [[ -n "${activeXHTTPPort}" ]]; then + portArgs+=("-p" "${activeXHTTPPort}:${activeXHTTPPort}") + fi + + if [[ ${#portArgs[@]} -eq 0 ]]; then + echoContent "red" "Cannot determine ports to publish — check the persisted config" + exit 1 + fi + + echoContent "white" "Starting container ${containerName} (ports: ${portArgs[*]})..." + if ! docker run -d \ + --name "${containerName}" \ + --restart unless-stopped \ + --user root \ + "${portArgs[@]}" \ + -v "${dataDir}:/usr/local/etc/xray:ro" \ + "${xrayImage}" \ + run -c /usr/local/etc/xray/config.json; then + echoContent "red" "Failed to start container ${containerName}" + exit 1 + fi + + # --- Verify the container is actually running --- + local runningName + runningName=$(docker ps --filter "name=^/${containerName}$" --format '{{.Names}}' 2>/dev/null || true) + if [[ "${runningName}" != "${containerName}" ]]; then + echoContent "red" "Container ${containerName} failed to reach running state" + docker logs "${containerName}" 2>/dev/null || true + exit 1 + fi + +} + +# showClientInfo — print the current Reality account in a showAccounts-style layout. +# Displays only the protocol blocks that are actually installed. +showClientInfo() { + local displayAddress="" + + loadPersistedAccountInfo + displayAddress="$(getPublicIP)" + + local shortId="${persistedShortId:-6ba85179e30d4fc2}" + local mode="${persistedInstallMode}" + + if installModeHasVision "${mode}"; then + showVisionAccount \ + "${displayAddress}" \ + "${persistedVisionPort}" \ + "${persistedServerName}" \ + "${persistedPublicKey}" \ + "${persistedUUID}" \ + "${persistedVisionEmail}" \ + "${shortId}" + fi + + if installModeHasXHTTP "${mode}"; then + local renderedPath + renderedPath="$(renderXHTTPPath "${persistedXHTTPPath}")" + showXHTTPAccount \ + "${displayAddress}" \ + "${persistedXHTTPPort}" \ + "${persistedServerName}" \ + "${persistedPublicKey}" \ + "${persistedUUID}" \ + "${persistedXHTTPEmail}" \ + "${shortId}" \ + "${renderedPath}" + fi +} + +# --------------------------------------------------------------------------- +# main — top-level flow +# --------------------------------------------------------------------------- +main() { + parseCli "$@" + + if [[ "${generateOnly}" == "1" && "${startOnly}" == "1" ]]; then + echoContent "red" "--generate-only and --start-only are mutually exclusive" + exit 1 + fi + + checkEnvironment + selfInstallShortcut + checkDocker + promptExistingInstallAction + promptInteractiveValues + + if [[ "${startOnly}" != "1" ]]; then + generateConfig + fi + + if [[ "${generateOnly}" != "1" ]]; then + startContainer + showClientInfo + fi +} + +main "$@"