#!/usr/bin/env bash # docker_reality_en.sh — Docker-based VLESS-Reality standalone launcher (no domain required) # Usage: bash shell/docker_reality_en.sh [options] export LANG=en_US.UTF-8 # --------------------------------------------------------------------------- # Output helper — style consistent with echoContent in install.sh # --------------------------------------------------------------------------- echoContent() { case $1 in "red") printf "\033[31m%s\033[0m\n" "$2" ;; "skyBlue") printf "\033[1;36m%s\033[0m\n" "$2" ;; "green") printf "\033[32m%s\033[0m\n" "$2" ;; "white") printf "\033[37m%s\033[0m\n" "$2" ;; "magenta") printf "\033[35m%s\033[0m\n" "$2" ;; "yellow") printf "\033[33m%s\033[0m\n" "$2" ;; esac } # --------------------------------------------------------------------------- # Defaults # --------------------------------------------------------------------------- nonInteractive=0 dataDir="/etc/v2ray-agent/docker/" installMode="" port="" xhttpPort="" xhttpPath="" serverName="" privateKey="" uuid="" email="" generateOnly=0 startOnly=0 skipSelfInstall=0 subscribePort="" resolvedSubscribePort="" # --------------------------------------------------------------------------- # QA override: set V2RAY_AGENT_FORCE_NO_DOCKER=1 to skip Docker check in tests # --------------------------------------------------------------------------- forceNoDocker="${V2RAY_AGENT_FORCE_NO_DOCKER:-0}" # --------------------------------------------------------------------------- # showHelp — print usage and exit # --------------------------------------------------------------------------- showHelp() { echoContent "skyBlue" "docker_reality_en.sh — Run VLESS-Reality via Docker (no domain required)" echoContent "white" "" echoContent "white" "Usage:" echoContent "white" " bash shell/docker_reality_en.sh [options]" echoContent "white" "" echoContent "white" "Run modes:" echoContent "white" " Default / interactive Detect install state first, then show install/reinstall/start menu options" echoContent "white" " --non-interactive Non-interactive mode; exit with error if required values are missing" echoContent "white" " --generate-only Generate config and summary files only, then exit" echoContent "white" " --start-only Reuse existing config and only start/recreate the container" echoContent "white" " --skip-self-install Skip script relocation and vasmad shortcut creation" echoContent "white" "" echoContent "white" "Options:" echoContent "white" " --non-interactive Run non-interactively; all required values must be supplied via flags" echoContent "white" " --data-dir 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" " --subscribe-port Subscription access port (leave blank to pick randomly)" 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 user-management / reinstall / start-or-recreate options." echoContent "white" " 3. Install mode selects Reality Vision, Reality XHTTP, or both; XHTTP requires an additional path input." echoContent "white" " 4. Non-interactive mode requires all values explicitly; privateKey/uuid/email may still be auto-generated/derived." echoContent "white" " 5. Data directory defaults to /etc/v2ray-agent/docker/; override with --data-dir for QA or custom paths." echoContent "white" " 6. If the script is not running from /etc/v2ray-agent/docker_reality_en.sh, it will relocate itself and create the vasmad shortcut." echoContent "white" "" echoContent "yellow" "QA / Testing:" echoContent "white" " V2RAY_AGENT_FORCE_NO_DOCKER=1 Skip Docker availability check (test environments)" echoContent "white" " --data-dir /tmp/v2ray-agent/docker Example path override for isolated verification" exit "${1:-0}" } # jsonEscape — escape a string for safe insertion into JSON. jsonEscape() { local value="$1" value="${value//\\/\\\\}" value="${value//\"/\\\"}" value="${value//$'\n'/\\n}" value="${value//$'\r'/\\r}" value="${value//$'\t'/\\t}" printf '%s' "${value}" } # validateDataDirPath — reject obviously unsafe data-dir path forms (for root-run scenarios). validateDataDirPath() { local path="$1" if [[ -z "${path}" || "${path}" != /* || "${path}" == *:* ]]; then echoContent "red" "--data-dir must be an absolute path without colons" exit 1 fi if [[ -e "${path}" && -L "${path}" ]]; then echoContent "red" "--data-dir must not be a symbolic link: ${path}" exit 1 fi } # --------------------------------------------------------------------------- # promptValue — interactive prompt; pressing Enter keeps the current value # --------------------------------------------------------------------------- promptValue() { local promptMessage="$1" local currentValue="$2" local inputValue printf '%b' "${promptMessage}" >/dev/tty read -r inputValue xHTTP form used by install.sh. renderXHTTPPath() { printf '/%sxHTTP' "$1" } urlEncode() { local input="$1" local length=${#input} local index char for ((index = 0; index < length; index++)); do char="${input:index:1}" case "${char}" in [a-zA-Z0-9.~_-]) printf '%s' "${char}" ;; *) printf '%%%02X' "'${char}" ;; esac done } buildVisionSubscriptionLink() { local displayAddress="$1" local displayPort="$2" local displayServerName="$3" local displayPublicKey="$4" local displayUUID="$5" local displayEmail="$6" local displayShortId="$7" if [[ -z "${displayAddress}" || -z "${displayPort}" || -z "${displayServerName}" || -z "${displayPublicKey}" || -z "${displayUUID}" || -z "${displayEmail}" || -z "${displayShortId}" ]]; then return 1 fi printf 'vless://%s@%s:%s?encryption=none&security=reality&type=tcp&sni=%s&fp=chrome&pbk=%s&sid=%s&flow=xtls-rprx-vision#%s' \ "${displayUUID}" \ "${displayAddress}" \ "${displayPort}" \ "${displayServerName}" \ "${displayPublicKey}" \ "${displayShortId}" \ "${displayEmail}" } buildXHTTPSubscriptionLink() { local displayAddress="$1" local displayPort="$2" local displayServerName="$3" local displayPublicKey="$4" local displayUUID="$5" local displayEmail="$6" local displayShortId="$7" local displayPath="$8" if [[ -z "${displayAddress}" || -z "${displayPort}" || -z "${displayServerName}" || -z "${displayPublicKey}" || -z "${displayUUID}" || -z "${displayEmail}" || -z "${displayShortId}" || -z "${displayPath}" ]]; then return 1 fi printf 'vless://%s@%s:%s?encryption=none&security=reality&type=xhttp&sni=%s&host=%s&fp=chrome&path=%s&pbk=%s&sid=%s#%s' \ "${displayUUID}" \ "${displayAddress}" \ "${displayPort}" \ "${displayServerName}" \ "${displayServerName}" \ "${displayPath}" \ "${displayPublicKey}" \ "${displayShortId}" \ "${displayEmail}" } buildXHTTPDefaultSubscriptionLink() { local displayAddress="$1" local displayPort="$2" local displayServerName="$3" local displayPublicKey="$4" local displayUUID="$5" local displayEmail="$6" local displayShortId="$7" local displayPath="$8" if [[ -z "${displayAddress}" || -z "${displayPort}" || -z "${displayServerName}" || -z "${displayPublicKey}" || -z "${displayUUID}" || -z "${displayEmail}" || -z "${displayShortId}" || -z "${displayPath}" ]]; then return 1 fi printf 'vless://%s@%s:%s?encryption=none&security=reality&type=xhttp&sni=%s&fp=chrome&path=%s&pbk=%s&sid=%s#%s' \ "${displayUUID}" \ "${displayAddress}" \ "${displayPort}" \ "${displayServerName}" \ "${displayPath}" \ "${displayPublicKey}" \ "${displayShortId}" \ "${displayEmail}" } writeVisionClashMetaNode() { local outputFile="$1" local displayAddress="$2" local displayPort="$3" local displayServerName="$4" local displayPublicKey="$5" local displayUUID="$6" local displayEmail="$7" local displayShortId="$8" if [[ -z "${displayAddress}" || -z "${displayPort}" || -z "${displayServerName}" || -z "${displayPublicKey}" || -z "${displayUUID}" || -z "${displayEmail}" || -z "${displayShortId}" ]]; then return 1 fi cat <>"${outputFile}" - name: "${displayEmail}" type: vless server: ${displayAddress} port: ${displayPort} uuid: ${displayUUID} network: tcp tls: true udp: true flow: xtls-rprx-vision servername: ${displayServerName} reality-opts: public-key: ${displayPublicKey} short-id: ${displayShortId} client-fingerprint: chrome EOF } writeXHTTPClashMetaNode() { local outputFile="$1" local displayAddress="$2" local displayPort="$3" local displayServerName="$4" local displayPublicKey="$5" local displayUUID="$6" local displayEmail="$7" local displayShortId="$8" local displayPath="$9" if [[ -z "${displayAddress}" || -z "${displayPort}" || -z "${displayServerName}" || -z "${displayPublicKey}" || -z "${displayUUID}" || -z "${displayEmail}" || -z "${displayShortId}" || -z "${displayPath}" ]]; then return 1 fi cat <>"${outputFile}" - name: "${displayEmail}" type: vless server: ${displayAddress} port: ${displayPort} uuid: ${displayUUID} udp: true tls: true network: xhttp client-fingerprint: chrome alpn: - h2 servername: ${displayServerName} xhttp-opts: path: ${displayPath} host: ${displayServerName} reality-opts: public-key: ${displayPublicKey} short-id: ${displayShortId} EOF } writeClashMetaProfile() { local outputFile="$1" local providerURL="$2" local providerName="$3" cat <"${outputFile}" log-level: debug mode: rule ipv6: true mixed-port: 7890 allow-lan: true bind-address: "*" find-process-mode: strict external-controller: 0.0.0.0:9090 global-client-fingerprint: chrome profile: store-selected: true store-fake-ip: true dns: enable: true listen: 0.0.0.0:1053 ipv6: true enhanced-mode: fake-ip fake-ip-range: 198.18.0.1/16 nameserver: - https://1.1.1.1/dns-query - https://8.8.8.8/dns-query - 1.1.1.1 - 8.8.8.8 proxy-providers: ${providerName}: type: http path: ./${providerName}.yaml url: ${providerURL} interval: 3600 proxy: DIRECT health-check: enable: true url: https://cp.cloudflare.com/generate_204 interval: 300 proxy-groups: - name: Manual Select type: select use: - ${providerName} proxies: null - name: Auto Select type: url-test url: http://www.gstatic.com/generate_204 interval: 36000 tolerance: 50 use: - ${providerName} proxies: null - name: Global Proxy type: select use: - ${providerName} proxies: - Manual Select - Auto Select - name: Final type: select use: - ${providerName} proxies: - Global Proxy - DIRECT - Manual Select - Auto Select rules: - GEOIP,LAN,DIRECT,no-resolve - GEOSITE,private,DIRECT - GEOSITE,cn,DIRECT - GEOIP,CN,DIRECT - MATCH,Final EOF } initRandomSalt() { local chars="abcdefghijklmnopqrtuxyz" local randomSalt="" local _idx for _idx in 1 2 3 4 5 6 7 8 9 10; do randomSalt+="${chars:RANDOM%${#chars}:1}" done printf '%s' "${randomSalt}" } md5Text() { if command -v md5sum >/dev/null 2>&1; then printf '%s' "$1" | md5sum | awk '{print $1}' else printf '%s' "$1" | md5 | awk '{print $NF}' fi } base64FileSingleLine() { if base64 --help 2>&1 | grep -q -- '-w'; then base64 -w 0 "$1" else base64 <"$1" | tr -d '\n' fi } generateSubscribeSalt() { local saltFile="${dataDir%/}/subscribe_local/subscribeSalt" if [[ -n "${persistedSubscribeSalt}" ]]; then printf '%s' "${persistedSubscribeSalt}" elif [[ -s "${saltFile}" ]]; then cat "${saltFile}" else local newSalt newSalt="$(initRandomSalt)" mkdir -p "${dataDir%/}/subscribe_local" printf '%s' "${newSalt}" >"${saltFile}" printf '%s' "${newSalt}" fi } startSubscribeContainer() { local displayAddress="$1" local containerName="v2ray-agent-docker-subscribe" local nginxImage="nginx:alpine" local activePort="${persistedSubscribePort:-${resolvedSubscribePort}}" local nginxConfig="${dataDir%/}/subscribe-nginx.conf" if [[ -z "${activePort}" ]]; then parsePort "" activePort="${resolvedPort}" checkPortInUse "${activePort}" fi mkdir -p "${dataDir%/}/subscribe/default" "${dataDir%/}/subscribe/clashMeta" "${dataDir%/}/subscribe/clashMetaProfiles" cat >"${nginxConfig}" <<'EOF' server { listen 80; server_name _; root /usr/share/nginx/html; location ~ ^/s/(default|clashMeta|clashMetaProfiles)/(.*) { default_type 'text/plain; charset=utf-8'; alias /usr/share/nginx/html/subscribe/$1/$2; } location / { return 404; } } EOF docker pull "${nginxImage}" >/dev/null 2>&1 || true if docker ps -a --filter "name=^/${containerName}$" --format '{{.Names}}' | grep -q "^${containerName}$"; then docker rm -f "${containerName}" >/dev/null 2>&1 || true fi if ! docker run -d \ --name "${containerName}" \ --restart unless-stopped \ -p "${activePort}:80" \ -v "${dataDir%/}:/usr/share/nginx/html:ro" \ -v "${nginxConfig}:/etc/nginx/conf.d/default.conf:ro" \ "${nginxImage}" >/dev/null; then echoContent "red" "Failed to start subscription access container" exit 1 fi persistedSubscribePort="${activePort}" echoContent "green" "Subscription access service started: http://${displayAddress}:${activePort}/s/default/" } persistSubscribeState() { local summaryFile="${dataDir%/}/client-summary.txt" local tmpFile="" [[ -f "${summaryFile}" ]] || return 0 tmpFile="$(mktemp)" while IFS= read -r line; do case "${line}" in subscribePort:\ * | subscribeSalt:\ *) ;; *) printf '%s\n' "${line}" >>"${tmpFile}" ;; esac done <"${summaryFile}" printf 'subscribePort: %s\n' "${persistedSubscribePort}" >>"${tmpFile}" printf 'subscribeSalt: %s\n' "${persistedSubscribeSalt}" >>"${tmpFile}" mv "${tmpFile}" "${summaryFile}" chmod 600 "${summaryFile}" } ensureSubscribeRuntimeValues() { local inputPort="" if [[ -z "${persistedSubscribePort}" ]]; then inputPort="$(promptValue $'Enter subscription access port, [Enter] random: ' "")" if ! parsePort "${inputPort}"; then exit 1 fi persistedSubscribePort="${resolvedPort}" fi if [[ -z "${persistedSubscribeSalt}" ]]; then persistedSubscribeSalt="$(initRandomSalt)" fi persistSubscribeState } installModeHasVision() { [[ "$1" == "vision" || "$1" == "all" ]] } installModeHasXHTTP() { [[ "$1" == "xhttp" || "$1" == "all" ]] } # --------------------------------------------------------------------------- # promptInteractiveValues — collect missing values in interactive mode # --------------------------------------------------------------------------- promptInteractiveValues() { if [[ "${startOnly}" == "1" || "${nonInteractive}" == "1" ]]; then return 0 fi echoContent "skyBlue" "" echoContent "skyBlue" "─── Configuration Input ─────────────────────────────────" echoContent "white" "Docker is ready. Please enter each configuration value." echoContent "white" "Press Enter to accept the default behaviour shown in brackets." echoContent "skyBlue" "────────────────────────────────────────────────────────" if [[ -z "${installMode}" ]]; then echoContent "yellow" "1. Reality Vision" echoContent "yellow" "2. Reality XHTTP" echoContent "yellow" "3. Install both" case "$(promptValue $'\n[Step 1/8] Install mode [1-3]: ' "")" in 1) installMode="vision" ;; 2) installMode="xhttp" ;; 3) installMode="all" ;; *) echoContent "red" "Invalid selection. Please enter 1 / 2 / 3" exit 1 ;; esac fi normalizeInstallMode "${installMode}" if installModeHasVision "${resolvedInstallMode}"; then if [[ -z "${port}" ]]; then port="$(promptValue $'\n[Step 2/8] Vision port [leave blank to pick randomly from 10000-30000]: ' "")" if [[ -z "${port}" ]]; then port="" fi fi fi if installModeHasXHTTP "${resolvedInstallMode}"; then if [[ -z "${xhttpPort}" ]]; then xhttpPort="$(promptValue $'\n[Step 3/8] XHTTP port [leave blank to pick randomly from 10000-30000]: ' "")" if [[ -z "${xhttpPort}" ]]; then xhttpPort="" fi fi if [[ -z "${xhttpPath}" ]]; then xhttpPath="$(promptValue $'\n[Step 4/8] XHTTP path [e.g. alone, leave blank to auto-generate]: ' "")" fi fi if [[ -z "${serverName}" ]]; then serverName="$(promptValue $'\n[Step 5/8] Server name [leave blank to pick a random Reality target]: ' "")" fi if [[ -z "${privateKey}" ]]; then privateKey="$(promptValue $'\n[Step 6/8] Private key [leave blank to auto-generate]: ' "")" fi if [[ -z "${uuid}" ]]; then uuid="$(promptValue $'\n[Step 7/8] UUID [leave blank to auto-generate]: ' "")" fi if [[ -z "${email}" ]]; then email="$(promptValue $'\n[Step 8/8] Email base name [leave blank to derive from UUID]: ' "")" fi echoContent "white" "" } # --------------------------------------------------------------------------- # parseCli — parse command-line arguments # --------------------------------------------------------------------------- parseCli() { while [[ $# -gt 0 ]]; do case "$1" in --non-interactive) nonInteractive=1 shift ;; --data-dir) if [[ $# -lt 2 || -z "${2:-}" || "${2:0:1}" == "-" || "${2}" == *:* || "${2}" != /* ]]; then echoContent "red" "--data-dir requires a path value" exit 1 fi dataDir="$2" validateDataDirPath "${dataDir}" shift 2 ;; --install-mode) if [[ $# -lt 2 || -z "${2:-}" || "${2:0:1}" == "-" ]]; then echoContent "red" "--install-mode requires a value (vision / xhttp / all)" exit 1 fi installMode="$2" shift 2 ;; --port) if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then echoContent "red" "--port requires a numeric value" exit 1 fi port="$2" shift 2 ;; --xhttp-port) if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then echoContent "red" "--xhttp-port requires a numeric value" exit 1 fi xhttpPort="$2" shift 2 ;; --xhttp-path) if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then echoContent "red" "--xhttp-path requires a value" exit 1 fi xhttpPath="$2" shift 2 ;; --server-name) if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then echoContent "red" "--server-name requires a value" exit 1 fi serverName="$2" shift 2 ;; --private-key) if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then echoContent "red" "--private-key requires a value" exit 1 fi privateKey="$2" shift 2 ;; --uuid) if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then echoContent "red" "--uuid requires a value" exit 1 fi uuid="$2" shift 2 ;; --email) if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then echoContent "red" "--email requires a value" exit 1 fi email="$2" shift 2 ;; --subscribe-port) if [[ $# -lt 2 || ( -n "${2:-}" && "${2:0:1}" == "-" ) ]]; then echoContent "red" "--subscribe-port requires a numeric value" exit 1 fi subscribePort="$2" shift 2 ;; --generate-only) generateOnly=1 shift ;; --start-only) startOnly=1 shift ;; --skip-self-install) skipSelfInstall=1 shift ;; -h | --help) showHelp ;; *) echoContent "red" "Unknown option: $1" showHelp 1 ;; esac done } # --------------------------------------------------------------------------- # checkEnvironment — require Linux and root/sudo # --------------------------------------------------------------------------- checkEnvironment() { local osType osType="$(uname -s 2>/dev/null || true)" if [[ "${osType}" != "Linux" ]]; then echoContent "red" "This script requires a Linux host (detected: ${osType})" exit 1 fi if [[ "$(id -u)" != "0" ]]; then echoContent "red" "This script must be run as root or with sudo" exit 1 fi } # selfInstallShortcut — mirror install.sh aliasInstall behavior for this standalone script and create the vasmad shortcut. selfInstallShortcut() { local targetScript="/etc/v2ray-agent/docker_reality_en.sh" local currentScript="" local shortcutCreated="false" if [[ "${skipSelfInstall}" == "1" ]]; then return 0 fi currentScript=$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$0" 2>/dev/null || true) if [[ -z "${currentScript}" ]]; then currentScript="$0" fi mkdir -p /etc/v2ray-agent if [[ "${currentScript}" != "${targetScript}" ]]; then if ! mv "${currentScript}" "${targetScript}" 2>/dev/null; then echoContent "yellow" "Unable to move the script automatically to ${targetScript}; continuing from the current path." else chmod 700 "${targetScript}" currentScript="${targetScript}" echoContent "green" "Script moved to ${targetScript}" fi else chmod 700 "${targetScript}" fi if [[ -f "${targetScript}" ]]; then if [[ -d "/usr/bin/" ]]; then rm -f /usr/bin/vasmad ln -s "${targetScript}" /usr/bin/vasmad chmod 700 /usr/bin/vasmad shortcutCreated="true" fi if [[ -d "/usr/sbin/" ]]; then rm -f /usr/sbin/vasmad ln -s "${targetScript}" /usr/sbin/vasmad chmod 700 /usr/sbin/vasmad shortcutCreated="true" fi fi if [[ "${shortcutCreated}" == "true" ]]; then echoContent "green" "Shortcut created successfully. Execute [vasmad] to reopen the script" echoContent "yellow" "Launch command: vasmad" fi } # --------------------------------------------------------------------------- # installDocker — download and run the official Docker convenience install script # --------------------------------------------------------------------------- installDocker() { echoContent "white" "Downloading Docker install script from https://get.docker.com ..." local tmpScript tmpScript="$(mktemp /tmp/get-docker-XXXXXX.sh)" if ! curl -fsSL https://get.docker.com -o "${tmpScript}"; then echoContent "red" "Failed to download Docker install script from https://get.docker.com" rm -f "${tmpScript}" return 1 fi echoContent "white" "Running Docker install script..." if ! sh "${tmpScript}"; then echoContent "red" "Docker installation failed" rm -f "${tmpScript}" return 1 fi rm -f "${tmpScript}" echoContent "green" "Docker installed successfully" return 0 } # checkDocker — verify Docker is installed and running; prompt to install if missing checkDocker() { # QA override: V2RAY_AGENT_FORCE_NO_DOCKER=1 forces the Docker-missing branch if [[ "${forceNoDocker}" == "1" ]]; then echoContent "yellow" "[QA] V2RAY_AGENT_FORCE_NO_DOCKER=1: simulating Docker not installed" _promptDockerInstall return $? fi echoContent "white" "Checking Docker availability..." if ! command -v docker >/dev/null 2>&1; then echoContent "yellow" "Docker is not installed on this system" _promptDockerInstall return $? fi # Docker binary exists — verify the daemon is reachable if ! docker info >/dev/null 2>&1; then echoContent "red" "Docker is installed but the daemon is not running" echoContent "white" "Please start the Docker daemon (e.g.: systemctl start docker) and retry" exit 1 fi echoContent "green" "Docker is available and running" return 0 } # _promptDockerInstall — ask the user whether to install Docker; exit cleanly on refusal _promptDockerInstall() { local answer if [[ "${nonInteractive}" == "1" ]]; then echoContent "red" "Docker is required but not installed. Use interactive mode, or set V2RAY_AGENT_FORCE_NO_DOCKER for QA only." exit 1 fi echoContent "yellow" "Docker is required to run this script." answer="$(promptValue $'\033[33mInstall Docker now via https://get.docker.com? [y/N]: \033[0m' "")" case "${answer}" in [yY] | [yY][eE][sS]) if ! installDocker; then echoContent "red" "Docker installation failed, exiting." exit 1 fi # Verify the daemon started after installation if ! docker info >/dev/null 2>&1; then echoContent "yellow" "Docker installed but daemon not yet running, starting..." systemctl start docker 2>/dev/null || true sleep 2 if ! docker info >/dev/null 2>&1; then echoContent "red" "Docker daemon failed to start. Please start it manually and retry." exit 1 fi fi echoContent "green" "Docker is ready" ;; *) echoContent "white" "Docker installation declined, exiting." exit 0 ;; esac } # --------------------------------------------------------------------------- # xrayImagePreflight — validate a config file using the official Xray image # Usage: xrayImagePreflight # --------------------------------------------------------------------------- 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$" || docker ps -a --filter "name=^/v2ray-agent-docker-subscribe$" --format '{{.Names}}' 2>/dev/null | grep -q "^v2ray-agent-docker-subscribe$"; then containerExists=1 fi if [[ "${containerExists}" == "1" ]]; then # Verify all listening PIDs are docker-proxy processes (Docker's forwarder for published ports). # If any PID is not docker-proxy, the port is held by an unrelated process — a real conflict. local unrelatedPids=0 local pid while IFS= read -r pid; do [[ -z "${pid}" ]] && continue local comm comm=$(cat "/proc/${pid}/comm" 2>/dev/null || ps -p "${pid}" -o comm= 2>/dev/null || true) if [[ "${comm}" != *"docker-proxy"* ]]; then unrelatedPids=1 break fi done <<<"${listenPids}" if [[ "${unrelatedPids}" == "0" ]]; then # Port is held only by v2ray-agent-docker's docker-proxy — safe to continue echoContent "yellow" "Port ${p} is held by the existing v2ray-agent-docker container (will be replaced)" return 0 fi fi echoContent "red" "Port ${p} is already in use — please free it and retry" lsof -i "tcp:${p}" -sTCP:LISTEN 2>/dev/null || true exit 1 } # parseServerName — validate and normalise serverName / host:port input. # Usage: parseServerName # 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" persistedSubscribePort="" persistedSubscribeSalt="" persistedConfigPath="" if [[ ! -f "${summaryFile}" ]]; then return 0 fi while IFS= read -r line; do case "${line}" in container:\ *) persistedContainer="${line#container: }" ;; installMode:\ *) persistedInstallMode="${line#installMode: }" ;; visionPort:\ *) persistedVisionPort="${line#visionPort: }" ;; xhttpPort:\ *) persistedXHTTPPort="${line#xhttpPort: }" ;; xhttpPath:\ *) persistedXHTTPPath="${line#xhttpPath: }" ;; serverName:\ *) persistedServerName="${line#serverName: }" ;; publicKey:\ *) persistedPublicKey="${line#publicKey: }" ;; uuid:\ *) persistedUUID="${line#uuid: }" ;; emailBase:\ *) persistedEmailBase="${line#emailBase: }" ;; visionEmail:\ *) persistedVisionEmail="${line#visionEmail: }" ;; xhttpEmail:\ *) persistedXHTTPEmail="${line#xhttpEmail: }" ;; shortId:\ *) persistedShortId="${line#shortId: }" ;; subscribePort:\ *) persistedSubscribePort="${line#subscribePort: }" ;; subscribeSalt:\ *) persistedSubscribeSalt="${line#subscribeSalt: }" ;; configPath:\ *) persistedConfigPath="${line#configPath: }" ;; esac done <"${summaryFile}" persistedContainer="${persistedContainer#"${persistedContainer%%[![:space:]]*}"}" persistedInstallMode="${persistedInstallMode#"${persistedInstallMode%%[![:space:]]*}"}" persistedVisionPort="${persistedVisionPort#"${persistedVisionPort%%[![:space:]]*}"}" persistedXHTTPPort="${persistedXHTTPPort#"${persistedXHTTPPort%%[![:space:]]*}"}" persistedXHTTPPath="${persistedXHTTPPath#"${persistedXHTTPPath%%[![:space:]]*}"}" persistedServerName="${persistedServerName#"${persistedServerName%%[![:space:]]*}"}" persistedPublicKey="${persistedPublicKey#"${persistedPublicKey%%[![:space:]]*}"}" persistedUUID="${persistedUUID#"${persistedUUID%%[![:space:]]*}"}" persistedEmailBase="${persistedEmailBase#"${persistedEmailBase%%[![:space:]]*}"}" persistedVisionEmail="${persistedVisionEmail#"${persistedVisionEmail%%[![:space:]]*}"}" persistedXHTTPEmail="${persistedXHTTPEmail#"${persistedXHTTPEmail%%[![:space:]]*}"}" persistedShortId="${persistedShortId#"${persistedShortId%%[![:space:]]*}"}" persistedSubscribePort="${persistedSubscribePort#"${persistedSubscribePort%%[![:space:]]*}"}" persistedSubscribeSalt="${persistedSubscribeSalt#"${persistedSubscribeSalt%%[![:space:]]*}"}" persistedConfigPath="${persistedConfigPath#"${persistedConfigPath%%[![:space:]]*}"}" if [[ -n "${persistedContainer}" && "${persistedContainer}" != "v2ray-agent-docker" ]]; then echoContent "red" "Persisted summary container name mismatch: ${persistedContainer}" exit 1 fi if [[ -n "${persistedConfigPath}" && "${persistedConfigPath}" != "${dataDir%/}/config.json" ]]; then echoContent "red" "Persisted summary configPath mismatch: ${persistedConfigPath}" exit 1 fi } # loadPersistedStateFromConfig — restore protocol mode, ports, path, and display fields from config.json. loadPersistedStateFromConfig() { local configFile="${dataDir%/}/config.json" local parsedLines=() if [[ ! -f "${configFile}" ]]; then echoContent "red" "Persisted config not found: ${configFile}" exit 1 fi if ! command -v python3 >/dev/null 2>&1; then echoContent "red" "python3 is required to safely parse the persisted config.json" exit 1 fi loadPersistedSummaryIfPresent mapfile -t parsedLines < <(python3 - <<'PY' "${configFile}" import json, sys path = sys.argv[1] try: with open(path, 'r', encoding='utf-8') as fh: data = json.load(fh) except Exception: sys.exit(1) vision_port = '' xhttp_port = '' xhttp_path = '' server_name = '' public_key = '' uuid = '' vision_email = '' xhttp_email = '' for inbound in data.get('inbounds', []): tag = inbound.get('tag') port = inbound.get('port') if tag == 'dokodemo-in-VLESSReality' and isinstance(port, int): vision_port = str(port) elif tag == 'VLESSRealityXHTTP' and isinstance(port, int): xhttp_port = str(port) xhttp_settings = (inbound.get('streamSettings') or {}).get('xhttpSettings') or {} raw_path = xhttp_settings.get('path') or '' if raw_path.startswith('/'): raw_path = raw_path[1:] if raw_path.endswith('xHTTP'): raw_path = raw_path[:-5] xhttp_path = raw_path.rstrip('/') for inbound in data.get('inbounds', []): if inbound.get('protocol') != 'vless': continue settings = inbound.get('settings') or {} clients = settings.get('clients') or [] if not clients: continue stream_settings = inbound.get('streamSettings') or {} reality_settings = stream_settings.get('realitySettings') or {} server_names = reality_settings.get('serverNames') or [] if not server_name and server_names: server_name = server_names[0] if not public_key: public_key = reality_settings.get('publicKey') or '' if not uuid: uuid = clients[0].get('id') or '' network = stream_settings.get('network') if network == 'tcp': vision_email = clients[0].get('email') or '' elif network == 'xhttp': xhttp_email = clients[0].get('email') or '' if vision_port and xhttp_port: install_mode = 'all' elif vision_port: install_mode = 'vision' elif xhttp_port: install_mode = 'xhttp' else: sys.exit(1) print(install_mode) print(vision_port) print(xhttp_port) print(xhttp_path) print(server_name) print(public_key) print(uuid) print(vision_email) print(xhttp_email) PY ) if [[ ${#parsedLines[@]} -ne 9 ]]; then echoContent "red" "Persisted config format error: cannot read protocol state from ${configFile}" exit 1 fi persistedInstallMode="${parsedLines[0]}" persistedVisionPort="${parsedLines[1]}" persistedXHTTPPort="${parsedLines[2]}" persistedXHTTPPath="${parsedLines[3]}" persistedServerName="${parsedLines[4]}" persistedPublicKey="${parsedLines[5]}" persistedUUID="${parsedLines[6]}" persistedVisionEmail="${parsedLines[7]}" persistedXHTTPEmail="${parsedLines[8]}" if [[ -z "${persistedEmailBase}" ]]; then persistedEmailBase="${persistedUUID%%-*}" fi if installModeHasVision "${persistedInstallMode}" && [[ -z "${persistedVisionEmail}" ]]; then persistedVisionEmail="${persistedEmailBase}-VLESS_TCP/TLS_Vision" fi if installModeHasXHTTP "${persistedInstallMode}" && [[ -z "${persistedXHTTPEmail}" ]]; then persistedXHTTPEmail="${persistedEmailBase}-VLESS_Reality_XHTTP" fi case "${persistedInstallMode}" in vision) [[ -n "${persistedVisionPort}" ]] || { echoContent "red" "Persisted config missing Vision port"; exit 1; } ;; xhttp) [[ -n "${persistedXHTTPPort}" && -n "${persistedXHTTPPath}" ]] || { echoContent "red" "Persisted config missing XHTTP port or path"; exit 1; } ;; all) [[ -n "${persistedVisionPort}" && -n "${persistedXHTTPPort}" && -n "${persistedXHTTPPath}" ]] || { echoContent "red" "Persisted config missing fields for 'all' install mode"; exit 1; } ;; *) echoContent "red" "Invalid install mode in persisted config: ${persistedInstallMode}" exit 1 ;; esac } # loadPersistedPort — restore protocol mode and ports from persisted config. loadPersistedPort() { loadPersistedStateFromConfig } # getPublicIP — try to resolve the current host's public IP for account display output. getPublicIP() { local currentIP="" if command -v curl >/dev/null 2>&1; then currentIP=$(curl -fsS -4 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null | grep '^ip=' | awk -F '=' '{print $2}') if [[ -z "${currentIP}" ]]; then currentIP=$(curl -fsS -6 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null | grep '^ip=' | awk -F '=' '{print $2}') fi if [[ -z "${currentIP}" ]]; then currentIP=$(curl -fsS https://api.ipify.org 2>/dev/null || true) fi fi if [[ -z "${currentIP}" ]]; then currentIP=$(hostname -I 2>/dev/null | awk '{print $1}' || true) fi printf '%s' "${currentIP}" } # loadPersistedAccountInfo — restore account fields needed for display from summary (preferred) or config.json. # Sets: persistedInstallMode, persistedVisionPort, persistedXHTTPPort, persistedXHTTPPath, # persistedServerName, persistedPublicKey, persistedUUID, persistedEmailBase, # persistedVisionEmail, persistedXHTTPEmail, persistedShortId loadPersistedAccountInfo() { local configFile="${dataDir%/}/config.json" if [[ ! -f "${configFile}" ]]; then echoContent "red" "Persisted config not found: ${configFile}" exit 1 fi # Prefer summary file (most complete field set) loadPersistedSummaryIfPresent # Fall back to config.json for any missing critical fields if [[ -z "${persistedInstallMode}" || -z "${persistedServerName}" || -z "${persistedUUID}" || -z "${persistedPublicKey}" ]]; then if ! command -v python3 >/dev/null 2>&1; then echoContent "red" "python3 is required to safely parse the persisted config.json" exit 1 fi local parsedLines=() mapfile -t parsedLines < <(python3 - <<'PY' "${configFile}" import json, sys path = sys.argv[1] try: with open(path, 'r', encoding='utf-8') as fh: data = json.load(fh) except Exception: sys.exit(1) vision_port = '' xhttp_port = '' xhttp_path = '' server_name = '' public_key = '' uuid = '' vision_email = '' xhttp_email = '' for inbound in data.get('inbounds', []): tag = inbound.get('tag') port = inbound.get('port') if tag == 'dokodemo-in-VLESSReality' and isinstance(port, int): vision_port = str(port) elif tag == 'VLESSRealityXHTTP' and isinstance(port, int): xhttp_port = str(port) xhttp_settings = (inbound.get('streamSettings') or {}).get('xhttpSettings') or {} raw_path = xhttp_settings.get('path') or '' if raw_path.startswith('/'): raw_path = raw_path[1:] if raw_path.endswith('xHTTP'): raw_path = raw_path[:-5] xhttp_path = raw_path.rstrip('/') for inbound in data.get('inbounds', []): if inbound.get('protocol') != 'vless': continue settings = inbound.get('settings') or {} clients = settings.get('clients') or [] if not clients: continue stream_settings = inbound.get('streamSettings') or {} reality_settings = stream_settings.get('realitySettings') or {} server_names = reality_settings.get('serverNames') or [] if not server_name and server_names: server_name = server_names[0] if not public_key: public_key = reality_settings.get('publicKey') or '' if not uuid: uuid = clients[0].get('id') or '' network = stream_settings.get('network') if network == 'tcp' and not vision_email: vision_email = clients[0].get('email') or '' elif network == 'xhttp' and not xhttp_email: xhttp_email = clients[0].get('email') or '' if vision_port and xhttp_port: install_mode = 'all' elif vision_port: install_mode = 'vision' elif xhttp_port: install_mode = 'xhttp' else: sys.exit(1) print(install_mode) print(vision_port) print(xhttp_port) print(xhttp_path) print(server_name) print(public_key) print(uuid) print(vision_email) print(xhttp_email) PY ) if [[ ${#parsedLines[@]} -ne 9 ]]; then echoContent "red" "Persisted config format error: failed to read account info from ${configFile}" exit 1 fi [[ -z "${persistedInstallMode}" ]] && persistedInstallMode="${parsedLines[0]}" [[ -z "${persistedVisionPort}" ]] && persistedVisionPort="${parsedLines[1]}" [[ -z "${persistedXHTTPPort}" ]] && persistedXHTTPPort="${parsedLines[2]}" [[ -z "${persistedXHTTPPath}" ]] && persistedXHTTPPath="${parsedLines[3]}" [[ -z "${persistedServerName}" ]] && persistedServerName="${parsedLines[4]}" [[ -z "${persistedPublicKey}" ]] && persistedPublicKey="${parsedLines[5]}" [[ -z "${persistedUUID}" ]] && persistedUUID="${parsedLines[6]}" [[ -z "${persistedVisionEmail}" ]] && persistedVisionEmail="${parsedLines[7]}" [[ -z "${persistedXHTTPEmail}" ]] && persistedXHTTPEmail="${parsedLines[8]}" fi # Fill in any still-missing email fields if [[ -z "${persistedEmailBase}" && -n "${persistedUUID}" ]]; then persistedEmailBase="${persistedUUID%%-*}" fi if installModeHasVision "${persistedInstallMode}" && [[ -z "${persistedVisionEmail}" ]]; then persistedVisionEmail="${persistedEmailBase}-VLESS_TCP/TLS_Vision" fi if installModeHasXHTTP "${persistedInstallMode}" && [[ -z "${persistedXHTTPEmail}" ]]; then persistedXHTTPEmail="${persistedEmailBase}-VLESS_Reality_XHTTP" fi } # hasPersistedConfig — check whether a persisted config already exists under dataDir. hasPersistedConfig() { local configFile="${dataDir%/}/config.json" [[ -f "${configFile}" ]] } # hasManagedContainer — check whether the target Docker container exists (running or stopped). hasManagedContainer() { docker ps -a --filter "name=^/v2ray-agent-docker$" --format '{{.Names}}' 2>/dev/null | grep -q '^v2ray-agent-docker$' } # hasExistingInstallState — treat either config presence or container presence as existing/residual install state. hasExistingInstallState() { if hasPersistedConfig || hasManagedContainer; then return 0 fi return 1 } userManageMenu() { local action="" if ! hasPersistedConfig; then echoContent "red" "No existing config found. Cannot enter user management; reinstall first." return 0 fi echoContent "skyBlue" "─── User Management ─────────────────────────────────────" echoContent "yellow" "1. View account" echoContent "yellow" "2. View subscription" echoContent "yellow" "3. Exit" while true; do action="$(promptValue $'Choose [1-3]: ' "")" case "${action}" in 1) showAccountInfo exit 0 ;; 2) showSubscriptionInfo exit 0 ;; 3) echoContent "white" "Exiting without changes." exit 0 ;; *) echoContent "red" "Invalid selection. Please enter 1-3." ;; esac done } # uninstallDockerReality — remove the standalone Docker Reality container, data, script shortcut, and installed script copy. uninstallDockerReality() { local answer="" local installedScript="/etc/v2ray-agent/docker_reality_en.sh" answer="$(promptValue $'Confirm uninstall of Docker Reality content? [y/N]: ' "")" case "${answer}" in [yY] | [yY][eE][sS]) ;; *) echoContent "green" " ---> Uninstall cancelled" return 0 ;; esac if hasManagedContainer; then docker rm -f v2ray-agent-docker >/dev/null 2>&1 || true echoContent "green" " ---> Docker container removed" fi if docker ps -a --filter "name=^/v2ray-agent-docker-subscribe$" --format '{{.Names}}' 2>/dev/null | grep -q '^v2ray-agent-docker-subscribe$'; then docker rm -f v2ray-agent-docker-subscribe >/dev/null 2>&1 || true echoContent "green" " ---> Subscription access container removed" fi rm -rf "${dataDir%/}" >/dev/null 2>&1 || true echoContent "green" " ---> Docker Reality data directory removed" rm -rf /usr/bin/vasmad >/dev/null 2>&1 || true rm -rf /usr/sbin/vasmad >/dev/null 2>&1 || true echoContent "green" " ---> Shortcut removed" if [[ -f "${installedScript}" ]]; then rm -f "${installedScript}" >/dev/null 2>&1 || true echoContent "green" " ---> Installed script removed" fi exit 0 } # promptExistingInstallAction — offer an interactive menu based on combined config/container state. promptExistingInstallAction() { local action="" local hasConfig=1 local hasContainer=1 if [[ "${nonInteractive}" == "1" || "${startOnly}" == "1" || "${generateOnly}" == "1" ]]; then return 0 fi if hasPersistedConfig; then hasConfig=0 fi if hasManagedContainer; then hasContainer=0 fi echoContent "skyBlue" "" if [[ ${hasConfig} -ne 0 && ${hasContainer} -ne 0 ]]; then echoContent "skyBlue" "─── No Existing Docker Reality Installation Detected ───────────────" echoContent "white" "No config file or container was detected. Choose the next action:" echoContent "yellow" "1. Install" echoContent "yellow" "2. Exit" while true; do action="$(promptValue $'Choose [1-2]: ' "")" case "${action}" in 1) echoContent "yellow" "Install selected. The script will collect values and create the container." return 0 ;; 2) echoContent "white" "Exiting without changes." exit 0 ;; *) echoContent "red" "Invalid selection. Please enter 1-2." ;; esac done fi echoContent "skyBlue" "─── Existing/Residual Docker Reality State Detected ───────────────" if [[ ${hasConfig} -eq 0 && ${hasContainer} -eq 0 ]]; then echoContent "white" "Detected both the config file and the container. Choose the next action:" elif [[ ${hasConfig} -eq 0 ]]; then echoContent "white" "Detected the config file but not the container. Choose the next action:" else echoContent "white" "Detected the container but not the config file. Choose the next action:" fi echoContent "yellow" "1. User management" echoContent "yellow" "2. Reinstall" echoContent "yellow" "3. Start/Recreate container" echoContent "yellow" "4. Uninstall" echoContent "yellow" "5. Exit" while true; do action="$(promptValue $'Choose [1-5]: ' "")" case "${action}" in 1) if hasPersistedConfig; then userManageMenu exit 0 else echoContent "red" "No existing config was found, so user management cannot be opened. Please reinstall first." fi ;; 2) echoContent "yellow" "Reinstall selected. The script will collect new values and recreate the container." return 0 ;; 3) if ! hasExistingInstallState; then echoContent "red" "No installed state was detected, so the container cannot be started/recreated. Please install first." continue fi if ! hasPersistedConfig; then echoContent "red" "No existing config was found, so the container cannot be started/recreated. Please reinstall first." continue fi startOnly=1 echoContent "yellow" "Using the existing config to start/recreate the container." return 0 ;; 4) uninstallDockerReality ;; 5) echoContent "white" "Exiting without changes." exit 0 ;; *) echoContent "red" "Invalid selection. Please enter 1-5." ;; esac done } # showVisionAccount — display the Vision account in a showAccounts-style layout. # Args: $1=address $2=port $3=serverName $4=publicKey $5=uuid $6=email $7=shortId showVisionAccount() { local displayAddress="$1" local displayPort="$2" local displayServerName="$3" local displayPublicKey="$4" local displayUUID="$5" local displayEmail="$6" local displayShortId="$7" local vlessLink qrData qrLink if [[ -z "${displayAddress}" ]]; then displayAddress="YOUR_SERVER_IP" fi vlessLink="$(buildVisionSubscriptionLink "${displayAddress}" "${displayPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayEmail}" "${displayShortId}")" if [[ -z "${vlessLink}" ]]; then echoContent "yellow" " ---> Vision account data is incomplete; skipping subscription link output" return 0 fi qrData="${vlessLink//:/%3A}" qrData="${qrData//\//%2F}" qrData="${qrData//@/%40}" qrData="${qrData//\?/%3F}" qrData="${qrData//&/%26}" qrData="${qrData//#/%23}" qrData="${qrData//=/%3D}" qrLink="https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${qrData}" echoContent "skyBlue" "============================= VLESS Reality Vision [Recommended] ==============================" echoContent "skyBlue" "" echoContent "skyBlue" " ---> Account: ${displayEmail}" echoContent "white" "" echoContent "yellow" " ---> Universal Format (VLESS+reality+uTLS+Vision)" echoContent "green" " ${vlessLink}" echoContent "white" "" echoContent "yellow" " ---> Formatted Plaintext (VLESS+reality+uTLS+Vision)" echoContent "green" "Protocol: VLESS reality, Address: ${displayAddress}, publicKey: ${displayPublicKey}, shortId: ${displayShortId}, serverNames: ${displayServerName}, Port: ${displayPort}, UserID: ${displayUUID}, Transport: tcp, Account: ${displayEmail}" echoContent "white" "" echoContent "yellow" " ---> QR Code VLESS (VLESS+reality+uTLS+Vision)" echoContent "green" " ${qrLink}" } # showXHTTPAccount — display the XHTTP account in a showAccounts-style layout. # Args: $1=address $2=port $3=serverName $4=publicKey $5=uuid $6=email $7=shortId $8=renderedPath showXHTTPAccount() { local displayAddress="$1" local displayPort="$2" local displayServerName="$3" local displayPublicKey="$4" local displayUUID="$5" local displayEmail="$6" local displayShortId="$7" local displayPath="$8" local vlessLink qrLink if [[ -z "${displayAddress}" ]]; then displayAddress="YOUR_SERVER_IP" fi vlessLink="$(buildXHTTPSubscriptionLink "${displayAddress}" "${displayPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayEmail}" "${displayShortId}" "${displayPath}")" if [[ -z "${vlessLink}" ]]; then echoContent "yellow" " ---> XHTTP account data is incomplete; skipping subscription link output" return 0 fi qrLink="https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=vless%3A%2F%2F${displayUUID}%40${displayAddress}%3A${displayPort}%3Fencryption%3Dnone%26security%3Dreality%26type%3Dxhttp%26sni%3D${displayServerName}%26fp%3Dchrome%26path%3D${displayPath}%26host%3D${displayServerName}%26pbk%3D${displayPublicKey}%26sid%3D${displayShortId}%23${displayEmail}" echoContent "skyBlue" "============================= VLESS Reality XHTTP ==============================" echoContent "skyBlue" "" echoContent "skyBlue" " ---> Account: ${displayEmail}" echoContent "white" "" echoContent "yellow" " ---> Universal Format (VLESS+reality+xhttp)" echoContent "green" " ${vlessLink}" echoContent "white" "" echoContent "yellow" " ---> Formatted Plaintext (VLESS+reality+xhttp)" echoContent "green" "Protocol: VLESS reality, Address: ${displayAddress}, publicKey: ${displayPublicKey}, shortId: ${displayShortId}, serverNames: ${displayServerName}, Port: ${displayPort}, Path: ${displayPath}, SNI: ${displayServerName}, Host: ${displayServerName}, UserID: ${displayUUID}, Transport: xhttp, Account: ${displayEmail}" echoContent "white" "" echoContent "yellow" " ---> QR Code VLESS (VLESS+reality+xhttp)" echoContent "green" " ${qrLink}" } # showEnglishSubscriptionSection — print the local subscription content and security note. showEnglishSubscriptionSection() { local displayAddress="$1" local mode="$2" local displayServerName="$3" local displayPublicKey="$4" local displayUUID="$5" local displayVisionPort="$6" local displayXHTTPPort="$7" local displayXHTTPPath="$8" local displayVisionEmail="$9" local displayXHTTPEmail="${10}" local displayShortId="${11}" local visionLink="" local xhttpLink="" local renderedPath="" local subscribeName="" local subscribeSalt="" local emailMd5="" local defaultLocalFile="" local defaultPublicFile="" local clashLocalFile="" local clashPublicFile="" local clashProfileFile="" local subscribeDomain="" local subscribeURL="" local clashProxyURL="" local clashProfileURL="" if [[ -z "${displayAddress}" ]]; then displayAddress="YOUR_SERVER_IP" fi subscribeName="${displayVisionEmail:-${displayXHTTPEmail}}" subscribeName="${subscribeName%%-*}" if [[ -z "${subscribeName}" ]]; then echoContent "yellow" " ---> Subscription account data is incomplete; skipping subscription file generation" return 0 fi mkdir -p "${dataDir%/}/subscribe_local/default" "${dataDir%/}/subscribe_local/clashMeta" "${dataDir%/}/subscribe/default" "${dataDir%/}/subscribe/clashMeta" "${dataDir%/}/subscribe/clashMetaProfiles" defaultLocalFile="${dataDir%/}/subscribe_local/default/${subscribeName}" clashLocalFile="${dataDir%/}/subscribe_local/clashMeta/${subscribeName}" : >"${defaultLocalFile}" : >"${clashLocalFile}" if installModeHasVision "${mode}"; then visionLink="$(buildVisionSubscriptionLink "${displayAddress}" "${displayVisionPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayVisionEmail}" "${displayShortId}")" || visionLink="" if [[ -n "${visionLink}" ]]; then printf '%s\n' "${visionLink}" >>"${defaultLocalFile}" writeVisionClashMetaNode "${clashLocalFile}" "${displayAddress}" "${displayVisionPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayVisionEmail}" "${displayShortId}" || true fi fi if installModeHasXHTTP "${mode}"; then renderedPath="$(renderXHTTPPath "${displayXHTTPPath}")" xhttpLink="$(buildXHTTPDefaultSubscriptionLink "${displayAddress}" "${displayXHTTPPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayXHTTPEmail}" "${displayShortId}" "${renderedPath}")" || xhttpLink="" if [[ -n "${xhttpLink}" ]]; then printf '%s\n' "${xhttpLink}" >>"${defaultLocalFile}" writeXHTTPClashMetaNode "${clashLocalFile}" "${displayAddress}" "${displayXHTTPPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayXHTTPEmail}" "${displayShortId}" "${renderedPath}" || true fi fi if [[ ! -s "${defaultLocalFile}" ]]; then echoContent "yellow" " ---> Subscription account data is incomplete; no usable subscription content was generated" return 0 fi subscribeSalt="$(generateSubscribeSalt)" emailMd5="$(md5Text "${subscribeName}${subscribeSalt}"$'\n')" defaultPublicFile="${dataDir%/}/subscribe/default/${emailMd5}" base64FileSingleLine "${defaultLocalFile}" >"${defaultPublicFile}" startSubscribeContainer "${displayAddress}" subscribeDomain="${displayAddress}:${persistedSubscribePort:-${resolvedSubscribePort}}" subscribeURL="http://${subscribeDomain}/s/default/${emailMd5}" if [[ -s "${clashLocalFile}" ]]; then clashPublicFile="${dataDir%/}/subscribe/clashMeta/${emailMd5}" clashProfileFile="${dataDir%/}/subscribe/clashMetaProfiles/${emailMd5}" clashProxyURL="http://${subscribeDomain}/s/clashMeta/${emailMd5}" clashProfileURL="http://${subscribeDomain}/s/clashMetaProfiles/${emailMd5}" printf 'proxies:\n' >"${clashPublicFile}" cat "${clashLocalFile}" >>"${clashPublicFile}" writeClashMetaProfile "${clashProfileFile}" "${clashProxyURL}" "${subscribeSalt}_provider" fi echoContent "skyBlue" "============================= Default subscription ==============================" echoContent "white" "Plain HTTP subscription transport can expose UUID, Reality parameters, SNI, path, ports, and connection details." echoContent "white" "The Docker edition serves this subscription through a local HTTP container; do not share the URL over untrusted networks." echoContent "white" "" echoContent "green" "email:${subscribeName}" echoContent "yellow" "url:${subscribeURL}" echoContent "yellow" "Online QR code:https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=$(urlEncode "${subscribeURL}")" if [[ -n "${clashProfileURL}" ]]; then echoContent "skyBlue" "\n----------clashMeta/Clash Verge subscription----------\n" echoContent "yellow" "url:${clashProfileURL}" echoContent "yellow" "Online QR code:https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=$(urlEncode "${clashProfileURL}")" fi } # resolveValues — resolve all CLI-supplied or blank values into resolved* globals. # Depends on: installMode, port, xhttpPort, xhttpPath, serverName, privateKey, uuid, email # Sets: resolvedInstallMode, resolvedVisionPort, resolvedXHTTPPort, resolvedXHTTPPath, # resolvedServerName, resolvedTargetPort, resolvedPrivateKey, resolvedPublicKey, # resolvedUUID, resolvedEmailBase, resolvedVisionEmail, resolvedXHTTPEmail # Returns 0 on success; exits on invalid input. resolveValues() { local xrayRuntime xrayRuntime="$(_xrayDockerRuntime)" # --- Install mode --- normalizeInstallMode "${installMode}" # resolvedInstallMode is set by normalizeInstallMode # --- Vision port --- if installModeHasVision "${resolvedInstallMode}"; then if ! parsePort "${port}"; then exit 1 fi resolvedVisionPort="${resolvedPort}" checkPortInUse "${resolvedVisionPort}" fi # --- XHTTP port and path --- if installModeHasXHTTP "${resolvedInstallMode}"; then if ! parsePort "${xhttpPort}"; then exit 1 fi resolvedXHTTPPort="${resolvedPort}" # Ensure XHTTP port does not collide with Vision port in 'all' mode if installModeHasVision "${resolvedInstallMode}" && [[ "${resolvedXHTTPPort}" == "${resolvedVisionPort}" ]]; then echoContent "red" "XHTTP port (${resolvedXHTTPPort}) is the same as the Vision port — please use different ports" exit 1 fi checkPortInUse "${resolvedXHTTPPort}" normalizeXHTTPPath "${xhttpPath}" resolvedXHTTPPath="${resolvedXHTTPPath:-${resolvedXHTTPPath}}" fi if ! parsePort "${subscribePort}"; then exit 1 fi resolvedSubscribePort="${resolvedPort}" if installModeHasVision "${resolvedInstallMode}" && [[ "${resolvedSubscribePort}" == "${resolvedVisionPort}" ]]; then echoContent "red" "Subscription port (${resolvedSubscribePort}) is the same as the Vision port — please use different ports" exit 1 fi if installModeHasXHTTP "${resolvedInstallMode}" && [[ "${resolvedSubscribePort}" == "${resolvedXHTTPPort}" ]]; then echoContent "red" "Subscription port (${resolvedSubscribePort}) is the same as the XHTTP port — please use different ports" exit 1 fi checkPortInUse "${resolvedSubscribePort}" # --- Server name --- parseServerName "${serverName}" # --- Private key --- if [[ -z "${privateKey}" ]]; then if ! generatePrivateKey "${xrayRuntime}"; then exit 1 fi else resolvedPrivateKey="${privateKey}" if ! derivePublicKey "${xrayRuntime}" "${resolvedPrivateKey}"; then echoContent "red" "Provided private key is invalid" exit 1 fi fi # --- UUID --- if [[ -z "${uuid}" ]]; then if ! generateUUID "${xrayRuntime}"; then exit 1 fi else resolvedUUID="${uuid}" fi # --- Email base and per-protocol emails --- if [[ -z "${email}" ]]; then deriveEmail "${resolvedUUID}" resolvedEmailBase="${resolvedEmail}" else resolvedEmailBase="${email}" resolvedEmail="${email}" fi resolvedVisionEmail="${resolvedEmailBase}-VLESS_TCP/TLS_Vision" resolvedXHTTPEmail="${resolvedEmailBase}-VLESS_Reality_XHTTP" if installModeHasVision "${resolvedInstallMode}"; then echoContent "green" "Resolved Vision port: ${resolvedVisionPort}" fi if installModeHasXHTTP "${resolvedInstallMode}"; then echoContent "green" "Resolved XHTTP port: ${resolvedXHTTPPort}" echoContent "green" "Resolved XHTTP path: $(renderXHTTPPath "${resolvedXHTTPPath}")" fi echoContent "green" "Resolved server name: ${resolvedServerName}:${resolvedTargetPort}" echoContent "green" "Resolved email base: ${resolvedEmailBase}" } # --------------------------------------------------------------------------- # _buildVisionInbounds — emit the two Vision inbound JSON blocks (no trailing comma) # Args: $1=visionPort $2=serverName $3=targetPort $4=privateKey $5=publicKey # $6=uuid $7=visionEmail # --------------------------------------------------------------------------- _buildVisionInbounds() { local vPort="$1" sName="$2" tPort="$3" privKey="$4" pubKey="$5" local vuuid="$6" vEmail="$7" cat </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 } # showAccountInfo — print the current Reality account in a showAccounts-style layout. # Displays only the protocol blocks that are actually installed. showAccountInfo() { local displayAddress="" loadPersistedAccountInfo displayAddress="$(getPublicIP)" local shortId="${persistedShortId:-6ba85179e30d4fc2}" local mode="${persistedInstallMode}" if installModeHasVision "${mode}"; then showVisionAccount \ "${displayAddress}" \ "${persistedVisionPort}" \ "${persistedServerName}" \ "${persistedPublicKey}" \ "${persistedUUID}" \ "${persistedVisionEmail}" \ "${shortId}" fi if installModeHasXHTTP "${mode}"; then local renderedPath renderedPath="$(renderXHTTPPath "${persistedXHTTPPath}")" showXHTTPAccount \ "${displayAddress}" \ "${persistedXHTTPPort}" \ "${persistedServerName}" \ "${persistedPublicKey}" \ "${persistedUUID}" \ "${persistedXHTTPEmail}" \ "${shortId}" \ "${renderedPath}" fi } showSubscriptionInfo() { local displayAddress="" local shortId="${persistedShortId:-6ba85179e30d4fc2}" local mode="" loadPersistedAccountInfo ensureSubscribeRuntimeValues displayAddress="$(getPublicIP)" shortId="${persistedShortId:-6ba85179e30d4fc2}" mode="${persistedInstallMode}" showEnglishSubscriptionSection \ "${displayAddress}" \ "${mode}" \ "${persistedServerName}" \ "${persistedPublicKey}" \ "${persistedUUID}" \ "${persistedVisionPort}" \ "${persistedXHTTPPort}" \ "${persistedXHTTPPath}" \ "${persistedVisionEmail}" \ "${persistedXHTTPEmail}" \ "${shortId}" } showClientInfo() { showAccountInfo } # --------------------------------------------------------------------------- # main — top-level flow # --------------------------------------------------------------------------- main() { parseCli "$@" if [[ "${generateOnly}" == "1" && "${startOnly}" == "1" ]]; then echoContent "red" "--generate-only and --start-only are mutually exclusive" exit 1 fi checkEnvironment selfInstallShortcut checkDocker promptExistingInstallAction promptInteractiveValues if [[ "${startOnly}" != "1" ]]; then generateConfig fi if [[ "${generateOnly}" != "1" ]]; then startContainer showClientInfo fi } main "$@"