diff --git a/install.sh b/install.sh index 71532e8..d9adf54 100644 --- a/install.sh +++ b/install.sh @@ -9978,7 +9978,7 @@ menu() { cd "$HOME" || exit echoContent red "\n==============================================================" echoContent green "作者:mack-a" - echoContent green "当前版本:v3.5.18" + echoContent green "当前版本:v3.5.19" 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 index 04feb70..c22627f 100644 --- a/shell/docker_reality.sh +++ b/shell/docker_reality.sh @@ -45,6 +45,8 @@ email="" generateOnly=0 startOnly=0 skipSelfInstall=0 +subscribePort="" +resolvedSubscribePort="" # --------------------------------------------------------------------------- # QA 覆盖:设置 V2RAY_AGENT_FORCE_NO_DOCKER=1 可在测试中跳过 Docker 检查 @@ -74,6 +76,7 @@ showHelp() { echoContent "white" " --port <端口> Vision 或单协议模式的外部监听端口(留空则随机)" echoContent "white" " --xhttp-port <端口> XHTTP 模式端口(留空则随机)" echoContent "white" " --xhttp-path <路径> XHTTP path 基础值(最终写为 /xHTTP)" + echoContent "white" " --subscribe-port <端口> 订阅访问端口(留空则随机)" echoContent "white" " --server-name Reality 握手所用的 SNI / 服务器名称" echoContent "white" " --private-key <密钥> X25519 私钥(留空则自动生成)" echoContent "white" " --uuid 客户端 UUID(留空则自动生成)" @@ -85,7 +88,7 @@ showHelp() { echoContent "white" "" echoContent "white" "操作说明:" echoContent "white" " 1. 交互模式会同时检测 config.json 与 v2ray-agent-docker 容器是否存在,再显示对应菜单。" - echoContent "white" " 2. 若两者都不存在,则显示安装菜单;若任一存在,则显示查看账号/重新安装/启动或重建菜单。" + 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 或自定义路径。" @@ -199,6 +202,371 @@ renderXHTTPPath() { printf '/%sxHTTP' "$1" } +urlEncode() { + local input="$1" + local length=${#input} + local index char + + for ((index = 0; index < length; index++)); do + char="${input:index:1}" + case "${char}" in + [a-zA-Z0-9.~_-]) printf '%s' "${char}" ;; + *) printf '%%%02X' "'${char}" ;; + esac + done +} + +# buildVisionSubscriptionLink — 生成 Vision 订阅 URI,空字段时返回失败。 +buildVisionSubscriptionLink() { + local displayAddress="$1" + local displayPort="$2" + local displayServerName="$3" + local displayPublicKey="$4" + local displayUUID="$5" + local displayEmail="$6" + local displayShortId="$7" + + if [[ -z "${displayAddress}" || -z "${displayPort}" || -z "${displayServerName}" || -z "${displayPublicKey}" || -z "${displayUUID}" || -z "${displayEmail}" || -z "${displayShortId}" ]]; then + return 1 + fi + + printf 'vless://%s@%s:%s?encryption=none&security=reality&type=tcp&sni=%s&fp=chrome&pbk=%s&sid=%s&flow=xtls-rprx-vision#%s' \ + "${displayUUID}" \ + "${displayAddress}" \ + "${displayPort}" \ + "${displayServerName}" \ + "${displayPublicKey}" \ + "${displayShortId}" \ + "${displayEmail}" +} + +# buildXHTTPSubscriptionLink — 生成 XHTTP 订阅 URI,空字段时返回失败。 +buildXHTTPSubscriptionLink() { + local displayAddress="$1" + local displayPort="$2" + local displayServerName="$3" + local displayPublicKey="$4" + local displayUUID="$5" + local displayEmail="$6" + local displayShortId="$7" + local displayPath="$8" + + if [[ -z "${displayAddress}" || -z "${displayPort}" || -z "${displayServerName}" || -z "${displayPublicKey}" || -z "${displayUUID}" || -z "${displayEmail}" || -z "${displayShortId}" || -z "${displayPath}" ]]; then + return 1 + fi + + printf 'vless://%s@%s:%s?encryption=none&security=reality&type=xhttp&sni=%s&host=%s&fp=chrome&path=%s&pbk=%s&sid=%s#%s' \ + "${displayUUID}" \ + "${displayAddress}" \ + "${displayPort}" \ + "${displayServerName}" \ + "${displayServerName}" \ + "${displayPath}" \ + "${displayPublicKey}" \ + "${displayShortId}" \ + "${displayEmail}" +} + +buildXHTTPDefaultSubscriptionLink() { + local displayAddress="$1" + local displayPort="$2" + local displayServerName="$3" + local displayPublicKey="$4" + local displayUUID="$5" + local displayEmail="$6" + local displayShortId="$7" + local displayPath="$8" + + if [[ -z "${displayAddress}" || -z "${displayPort}" || -z "${displayServerName}" || -z "${displayPublicKey}" || -z "${displayUUID}" || -z "${displayEmail}" || -z "${displayShortId}" || -z "${displayPath}" ]]; then + return 1 + fi + + printf 'vless://%s@%s:%s?encryption=none&security=reality&type=xhttp&sni=%s&fp=chrome&path=%s&pbk=%s&sid=%s#%s' \ + "${displayUUID}" \ + "${displayAddress}" \ + "${displayPort}" \ + "${displayServerName}" \ + "${displayPath}" \ + "${displayPublicKey}" \ + "${displayShortId}" \ + "${displayEmail}" +} + +writeVisionClashMetaNode() { + local outputFile="$1" + local displayAddress="$2" + local displayPort="$3" + local displayServerName="$4" + local displayPublicKey="$5" + local displayUUID="$6" + local displayEmail="$7" + local displayShortId="$8" + + if [[ -z "${displayAddress}" || -z "${displayPort}" || -z "${displayServerName}" || -z "${displayPublicKey}" || -z "${displayUUID}" || -z "${displayEmail}" || -z "${displayShortId}" ]]; then + return 1 + fi + + cat <>"${outputFile}" + - name: "${displayEmail}" + type: vless + server: ${displayAddress} + port: ${displayPort} + uuid: ${displayUUID} + network: tcp + tls: true + udp: true + flow: xtls-rprx-vision + servername: ${displayServerName} + reality-opts: + public-key: ${displayPublicKey} + short-id: ${displayShortId} + client-fingerprint: chrome +EOF +} + +writeXHTTPClashMetaNode() { + local outputFile="$1" + local displayAddress="$2" + local displayPort="$3" + local displayServerName="$4" + local displayPublicKey="$5" + local displayUUID="$6" + local displayEmail="$7" + local displayShortId="$8" + local displayPath="$9" + + if [[ -z "${displayAddress}" || -z "${displayPort}" || -z "${displayServerName}" || -z "${displayPublicKey}" || -z "${displayUUID}" || -z "${displayEmail}" || -z "${displayShortId}" || -z "${displayPath}" ]]; then + return 1 + fi + + cat <>"${outputFile}" + - name: "${displayEmail}" + type: vless + server: ${displayAddress} + port: ${displayPort} + uuid: ${displayUUID} + udp: true + tls: true + network: xhttp + client-fingerprint: chrome + alpn: + - h2 + servername: ${displayServerName} + xhttp-opts: + path: ${displayPath} + host: ${displayServerName} + reality-opts: + public-key: ${displayPublicKey} + short-id: ${displayShortId} +EOF +} + +writeClashMetaProfile() { + local outputFile="$1" + local providerURL="$2" + local providerName="$3" + + cat <"${outputFile}" +log-level: debug +mode: rule +ipv6: true +mixed-port: 7890 +allow-lan: true +bind-address: "*" +find-process-mode: strict +external-controller: 0.0.0.0:9090 +global-client-fingerprint: chrome + +profile: + store-selected: true + store-fake-ip: true + +dns: + enable: true + listen: 0.0.0.0:1053 + ipv6: true + enhanced-mode: fake-ip + fake-ip-range: 198.18.0.1/16 + nameserver: + - https://1.1.1.1/dns-query + - https://8.8.8.8/dns-query + - 1.1.1.1 + - 8.8.8.8 + +proxy-providers: + ${providerName}: + type: http + path: ./${providerName}.yaml + url: ${providerURL} + interval: 3600 + proxy: DIRECT + health-check: + enable: true + url: https://cp.cloudflare.com/generate_204 + interval: 300 + +proxy-groups: + - name: 手动切换 + type: select + use: + - ${providerName} + proxies: null + - name: 自动选择 + type: url-test + url: http://www.gstatic.com/generate_204 + interval: 36000 + tolerance: 50 + use: + - ${providerName} + proxies: null + - name: 全球代理 + type: select + use: + - ${providerName} + proxies: + - 手动切换 + - 自动选择 + - name: 漏网之鱼 + type: select + use: + - ${providerName} + proxies: + - 全球代理 + - DIRECT + - 手动切换 + - 自动选择 + +rules: + - GEOIP,LAN,DIRECT,no-resolve + - GEOSITE,private,DIRECT + - GEOSITE,cn,DIRECT + - GEOIP,CN,DIRECT + - MATCH,漏网之鱼 +EOF +} + +initRandomSalt() { + local chars="abcdefghijklmnopqrtuxyz" + local randomSalt="" + local _idx + for _idx in 1 2 3 4 5 6 7 8 9 10; do + randomSalt+="${chars:RANDOM%${#chars}:1}" + done + printf '%s' "${randomSalt}" +} + +md5Text() { + if command -v md5sum >/dev/null 2>&1; then + printf '%s' "$1" | md5sum | awk '{print $1}' + else + printf '%s' "$1" | md5 | awk '{print $NF}' + fi +} + +base64FileSingleLine() { + if base64 --help 2>&1 | grep -q -- '-w'; then + base64 -w 0 "$1" + else + base64 <"$1" | tr -d '\n' + fi +} + +generateSubscribeSalt() { + local saltFile="${dataDir%/}/subscribe_local/subscribeSalt" + if [[ -n "${persistedSubscribeSalt}" ]]; then + printf '%s' "${persistedSubscribeSalt}" + elif [[ -s "${saltFile}" ]]; then + cat "${saltFile}" + else + local newSalt + newSalt="$(initRandomSalt)" + mkdir -p "${dataDir%/}/subscribe_local" + printf '%s' "${newSalt}" >"${saltFile}" + printf '%s' "${newSalt}" + fi +} + +startSubscribeContainer() { + local displayAddress="$1" + local containerName="v2ray-agent-docker-subscribe" + local nginxImage="nginx:alpine" + local activePort="${persistedSubscribePort:-${resolvedSubscribePort}}" + local nginxConfig="${dataDir%/}/subscribe-nginx.conf" + + if [[ -z "${activePort}" ]]; then + parsePort "" + activePort="${resolvedPort}" + checkPortInUse "${activePort}" + fi + + mkdir -p "${dataDir%/}/subscribe/default" "${dataDir%/}/subscribe/clashMeta" "${dataDir%/}/subscribe/clashMetaProfiles" + cat >"${nginxConfig}" <<'EOF' +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + location ~ ^/s/(default|clashMeta|clashMetaProfiles)/(.*) { + default_type 'text/plain; charset=utf-8'; + alias /usr/share/nginx/html/subscribe/$1/$2; + } + location / { + return 404; + } +} +EOF + + docker pull "${nginxImage}" >/dev/null 2>&1 || true + if docker ps -a --filter "name=^/${containerName}$" --format '{{.Names}}' | grep -q "^${containerName}$"; then + docker rm -f "${containerName}" >/dev/null 2>&1 || true + fi + if ! docker run -d \ + --name "${containerName}" \ + --restart unless-stopped \ + -p "${activePort}:80" \ + -v "${dataDir%/}:/usr/share/nginx/html:ro" \ + -v "${nginxConfig}:/etc/nginx/conf.d/default.conf:ro" \ + "${nginxImage}" >/dev/null; then + echoContent "red" "启动订阅访问容器失败" + exit 1 + fi + persistedSubscribePort="${activePort}" + echoContent "green" "订阅访问服务已启动:http://${displayAddress}:${activePort}/s/default/<订阅ID>" +} + +persistSubscribeState() { + local summaryFile="${dataDir%/}/client-summary.txt" + local tmpFile="" + + [[ -f "${summaryFile}" ]] || return 0 + tmpFile="$(mktemp)" + while IFS= read -r line; do + case "${line}" in + subscribePort:\ * | subscribeSalt:\ *) ;; + *) printf '%s\n' "${line}" >>"${tmpFile}" ;; + esac + done <"${summaryFile}" + printf 'subscribePort: %s\n' "${persistedSubscribePort}" >>"${tmpFile}" + printf 'subscribeSalt: %s\n' "${persistedSubscribeSalt}" >>"${tmpFile}" + mv "${tmpFile}" "${summaryFile}" + chmod 600 "${summaryFile}" +} + +ensureSubscribeRuntimeValues() { + local inputPort="" + + if [[ -z "${persistedSubscribePort}" ]]; then + inputPort="$(promptValue $'请输入订阅访问端口,[回车]随机:' "")" + if ! parsePort "${inputPort}"; then + exit 1 + fi + persistedSubscribePort="${resolvedPort}" + fi + if [[ -z "${persistedSubscribeSalt}" ]]; then + persistedSubscribeSalt="$(initRandomSalt)" + fi + persistSubscribeState +} + installModeHasVision() { [[ "$1" == "vision" || "$1" == "all" ]] } @@ -356,6 +724,14 @@ parseCli() { email="$2" shift 2 ;; + --subscribe-port) + if [[ $# -lt 2 || (-n "${2:-}" && "${2:0:1}" == "-") ]]; then + echoContent "red" "--subscribe-port 需要一个数字值" + exit 1 + fi + subscribePort="$2" + shift 2 + ;; --generate-only) generateOnly=1 shift @@ -624,7 +1000,7 @@ checkPortInUse() { # 若所有监听 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 + 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 @@ -784,6 +1160,8 @@ loadPersistedSummaryIfPresent() { persistedVisionEmail="" persistedXHTTPEmail="" persistedShortId="6ba85179e30d4fc2" + persistedSubscribePort="" + persistedSubscribeSalt="" persistedConfigPath="" if [[ ! -f "${summaryFile}" ]]; then @@ -804,6 +1182,8 @@ loadPersistedSummaryIfPresent() { 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}" @@ -820,6 +1200,8 @@ loadPersistedSummaryIfPresent() { 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 @@ -1153,6 +1535,41 @@ hasExistingInstallState() { return 1 } +userManageMenu() { + local action="" + + if ! hasPersistedConfig; then + echoContent "red" "未找到现有配置,无法进入用户管理。请先重新安装。" + return 0 + fi + + echoContent "skyBlue" "─── 用户管理 ───────────────────────────────────────────" + echoContent "yellow" "1. 查看账号" + echoContent "yellow" "2. 查看订阅" + echoContent "yellow" "3. 退出" + + while true; do + action="$(promptValue $'请选择 [1-3]:' "")" + case "${action}" in + 1) + showAccountInfo + exit 0 + ;; + 2) + showSubscriptionInfo + exit 0 + ;; + 3) + echoContent "white" "已退出,不做任何修改。" + exit 0 + ;; + *) + echoContent "red" "无效选择,请输入 1-3。" + ;; + esac + done +} + # uninstallDockerReality — 卸载 Docker Reality 独立脚本生成的容器、配置与快捷方式。 uninstallDockerReality() { local answer="" @@ -1172,6 +1589,10 @@ uninstallDockerReality() { docker rm -f v2ray-agent-docker >/dev/null 2>&1 || true echoContent "green" " ---> 删除 Docker 容器完成" fi + if docker ps -a --filter "name=^/v2ray-agent-docker-subscribe$" --format '{{.Names}}' 2>/dev/null | grep -q '^v2ray-agent-docker-subscribe$'; then + docker rm -f v2ray-agent-docker-subscribe >/dev/null 2>&1 || true + echoContent "green" " ---> 删除订阅访问容器完成" + fi rm -rf "${dataDir%/}" >/dev/null 2>&1 || true echoContent "green" " ---> 删除 Docker Reality 配置目录完成" @@ -1243,7 +1664,7 @@ promptExistingInstallAction() { echoContent "white" "已检测到容器,但未检测到配置文件。请选择下一步操作:" fi - echoContent "yellow" "1. 查看账号" + echoContent "yellow" "1. 用户管理" echoContent "yellow" "2. 重新安装" echoContent "yellow" "3. 启动/重建容器" echoContent "yellow" "4. 卸载" @@ -1254,10 +1675,10 @@ promptExistingInstallAction() { case "${action}" in 1) if hasPersistedConfig; then - showClientInfo + userManageMenu exit 0 else - echoContent "red" "未找到现有配置,无法查看账号。请先重新安装。" + echoContent "red" "未找到现有配置,无法进入用户管理。请先重新安装。" fi ;; 2) @@ -1307,7 +1728,11 @@ showVisionAccount() { 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}" + vlessLink="$(buildVisionSubscriptionLink "${displayAddress}" "${displayPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayEmail}" "${displayShortId}")" + if [[ -z "${vlessLink}" ]]; then + echoContent "yellow" " ---> Vision 账号信息不完整,已跳过订阅链接输出" + return 0 + fi qrData="${vlessLink//:/%3A}" qrData="${qrData//\//%2F}" qrData="${qrData//@/%40}" @@ -1348,7 +1773,11 @@ showXHTTPAccount() { 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}" + vlessLink="$(buildXHTTPSubscriptionLink "${displayAddress}" "${displayPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayEmail}" "${displayShortId}" "${displayPath}")" + if [[ -z "${vlessLink}" ]]; then + echoContent "yellow" " ---> XHTTP 账号信息不完整,已跳过订阅链接输出" + return 0 + fi qrLink="https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=vless%3A%2F%2F${displayUUID}%40${displayAddress}%3A${displayPort}%3Fencryption%3Dnone%26security%3Dreality%26type%3Dxhttp%26sni%3D${displayServerName}%26fp%3Dchrome%26path%3D${displayPath}%26host%3D${displayServerName}%26pbk%3D${displayPublicKey}%26sid%3D${displayShortId}%23${displayEmail}" echoContent "skyBlue" "============================= VLESS reality_xhttp ==============================" @@ -1365,6 +1794,108 @@ showXHTTPAccount() { echoContent "green" " ${qrLink}" } +# showChineseSubscriptionSection — 输出本地中文订阅内容与风险提示。 +showChineseSubscriptionSection() { + local displayAddress="$1" + local mode="$2" + local displayServerName="$3" + local displayPublicKey="$4" + local displayUUID="$5" + local displayVisionPort="$6" + local displayXHTTPPort="$7" + local displayXHTTPPath="$8" + local displayVisionEmail="$9" + local displayXHTTPEmail="${10}" + local displayShortId="${11}" + local visionLink="" + local xhttpLink="" + local renderedPath="" + local subscribeName="" + local subscribeSalt="" + local emailMd5="" + local defaultLocalFile="" + local defaultPublicFile="" + local clashLocalFile="" + local clashPublicFile="" + local clashProfileFile="" + local subscribeDomain="" + local subscribeURL="" + local clashProxyURL="" + local clashProfileURL="" + + if [[ -z "${displayAddress}" ]]; then + displayAddress="YOUR_SERVER_IP" + fi + + subscribeName="${displayVisionEmail:-${displayXHTTPEmail}}" + subscribeName="${subscribeName%%-*}" + if [[ -z "${subscribeName}" ]]; then + echoContent "yellow" " ---> 订阅账号信息不完整,已跳过订阅文件生成" + return 0 + fi + + mkdir -p "${dataDir%/}/subscribe_local/default" "${dataDir%/}/subscribe_local/clashMeta" "${dataDir%/}/subscribe/default" "${dataDir%/}/subscribe/clashMeta" "${dataDir%/}/subscribe/clashMetaProfiles" + defaultLocalFile="${dataDir%/}/subscribe_local/default/${subscribeName}" + clashLocalFile="${dataDir%/}/subscribe_local/clashMeta/${subscribeName}" + : >"${defaultLocalFile}" + : >"${clashLocalFile}" + + if installModeHasVision "${mode}"; then + visionLink="$(buildVisionSubscriptionLink "${displayAddress}" "${displayVisionPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayVisionEmail}" "${displayShortId}")" || visionLink="" + if [[ -n "${visionLink}" ]]; then + printf '%s\n' "${visionLink}" >>"${defaultLocalFile}" + writeVisionClashMetaNode "${clashLocalFile}" "${displayAddress}" "${displayVisionPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayVisionEmail}" "${displayShortId}" || true + fi + fi + + if installModeHasXHTTP "${mode}"; then + renderedPath="$(renderXHTTPPath "${displayXHTTPPath}")" + xhttpLink="$(buildXHTTPDefaultSubscriptionLink "${displayAddress}" "${displayXHTTPPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayXHTTPEmail}" "${displayShortId}" "${renderedPath}")" || xhttpLink="" + if [[ -n "${xhttpLink}" ]]; then + printf '%s\n' "${xhttpLink}" >>"${defaultLocalFile}" + writeXHTTPClashMetaNode "${clashLocalFile}" "${displayAddress}" "${displayXHTTPPort}" "${displayServerName}" "${displayPublicKey}" "${displayUUID}" "${displayXHTTPEmail}" "${displayShortId}" "${renderedPath}" || true + fi + fi + + if [[ ! -s "${defaultLocalFile}" ]]; then + echoContent "yellow" " ---> 订阅账号信息不完整,未生成可用订阅内容" + return 0 + fi + + subscribeSalt="$(generateSubscribeSalt)" + emailMd5="$(md5Text "${subscribeName}${subscribeSalt}"$'\n')" + defaultPublicFile="${dataDir%/}/subscribe/default/${emailMd5}" + base64FileSingleLine "${defaultLocalFile}" >"${defaultPublicFile}" + + startSubscribeContainer "${displayAddress}" + subscribeDomain="${displayAddress}:${persistedSubscribePort:-${resolvedSubscribePort}}" + subscribeURL="http://${subscribeDomain}/s/default/${emailMd5}" + if [[ -s "${clashLocalFile}" ]]; then + clashPublicFile="${dataDir%/}/subscribe/clashMeta/${emailMd5}" + clashProfileFile="${dataDir%/}/subscribe/clashMetaProfiles/${emailMd5}" + clashProxyURL="http://${subscribeDomain}/s/clashMeta/${emailMd5}" + clashProfileURL="http://${subscribeDomain}/s/clashMetaProfiles/${emailMd5}" + printf 'proxies:\n' >"${clashPublicFile}" + cat "${clashLocalFile}" >>"${clashPublicFile}" + writeClashMetaProfile "${clashProfileFile}" "${clashProxyURL}" "${subscribeSalt}_provider" + fi + + echoContent "skyBlue" "============================= 默认订阅 ==============================" + echoContent "white" "HTTP 明文订阅有风险,可能暴露 订阅信息。" + echoContent "white" "当前 Docker 版通过本机订阅容器提供 HTTP 访问链接,请勿在不可信网络中传播。" + echoContent "white" "" + echoContent "green" "email: ${subscribeName}" + echoContent "yellow" "url: ${subscribeURL}" + echoContent "yellow" "在线二维码: https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=$(urlEncode "${subscribeURL}")" + if [[ -n "${clashProfileURL}" ]]; then + echo + echoContent "skyBlue" "--------------Clash Verge(mihomo)订阅--------------" + echo + echoContent "yellow" "url: ${clashProfileURL}" + echoContent "yellow" "在线二维码: https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=$(urlEncode "${clashProfileURL}")" + fi +} + # resolveValues — 将所有 CLI 提供或留空的值解析为 resolved* 全局变量。 # 依赖:installMode/resolvedInstallMode、port、xhttpPort、xhttpPath、 # serverName、privateKey、uuid、email(来自 parseCli 的全局变量) @@ -1407,6 +1938,20 @@ resolveValues() { resolvedXHTTPPath="${resolvedXHTTPPath:-${resolvedXHTTPPath}}" fi + if ! parsePort "${subscribePort}"; then + exit 1 + fi + resolvedSubscribePort="${resolvedPort}" + if installModeHasVision "${resolvedInstallMode}" && [[ "${resolvedSubscribePort}" == "${resolvedVisionPort}" ]]; then + echoContent "red" "订阅端口(${resolvedSubscribePort})与 Vision 端口相同,请使用不同端口" + exit 1 + fi + if installModeHasXHTTP "${resolvedInstallMode}" && [[ "${resolvedSubscribePort}" == "${resolvedXHTTPPort}" ]]; then + echoContent "red" "订阅端口(${resolvedSubscribePort})与 XHTTP 端口相同,请使用不同端口" + exit 1 + fi + checkPortInUse "${resolvedSubscribePort}" + # --- 服务器名称 --- parseServerName "${serverName}" @@ -1716,6 +2261,8 @@ EOF printf 'xhttpEmail: %s\n' "${resolvedXHTTPEmail}" fi printf 'shortId: 6ba85179e30d4fc2\n' + printf 'subscribePort: %s\n' "${resolvedSubscribePort}" + printf 'subscribeSalt: %s\n' "$(initRandomSalt)" printf 'configPath: %s\n' "${configFile}" } >"${summaryFile}" chmod 600 "${summaryFile}" @@ -1825,16 +2372,17 @@ startContainer() { } -# showClientInfo — 以 showAccounts 风格打印当前 Reality 账号信息 +# showAccountInfo — 以 showAccounts 风格打印当前 Reality 账号信息 # 根据 installMode 只显示已安装协议的账号块 -showClientInfo() { +showAccountInfo() { local displayAddress="" + local mode="" + local shortId="" loadPersistedAccountInfo displayAddress="$(getPublicIP)" - - local shortId="${persistedShortId:-6ba85179e30d4fc2}" - local mode="${persistedInstallMode}" + mode="${persistedInstallMode}" + shortId="${persistedShortId:-6ba85179e30d4fc2}" if installModeHasVision "${mode}"; then showVisionAccount \ @@ -1860,6 +2408,36 @@ showClientInfo() { "${shortId}" \ "${renderedPath}" fi + +} + +showSubscriptionInfo() { + local displayAddress="" + local mode="" + local shortId="" + + loadPersistedAccountInfo + ensureSubscribeRuntimeValues + displayAddress="$(getPublicIP)" + mode="${persistedInstallMode}" + shortId="${persistedShortId:-6ba85179e30d4fc2}" + + showChineseSubscriptionSection \ + "${displayAddress}" \ + "${mode}" \ + "${persistedServerName}" \ + "${persistedPublicKey}" \ + "${persistedUUID}" \ + "${persistedVisionPort}" \ + "${persistedXHTTPPort}" \ + "${persistedXHTTPPath}" \ + "${persistedVisionEmail}" \ + "${persistedXHTTPEmail}" \ + "${shortId}" +} + +showClientInfo() { + showAccountInfo } # --------------------------------------------------------------------------- diff --git a/shell/docker_reality_en.sh b/shell/docker_reality_en.sh index 8c195d6..bb7b3e0 100644 --- a/shell/docker_reality_en.sh +++ b/shell/docker_reality_en.sh @@ -45,6 +45,8 @@ email="" generateOnly=0 startOnly=0 skipSelfInstall=0 +subscribePort="" +resolvedSubscribePort="" # --------------------------------------------------------------------------- # QA override: set V2RAY_AGENT_FORCE_NO_DOCKER=1 to skip Docker check in tests @@ -74,6 +76,7 @@ showHelp() { 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)" @@ -85,7 +88,7 @@ showHelp() { 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" " 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." @@ -199,6 +202,369 @@ 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" ]] } @@ -357,6 +723,14 @@ parseCli() { 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 @@ -625,7 +999,7 @@ checkPortInUse() { # 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 + | 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 @@ -789,6 +1163,8 @@ loadPersistedSummaryIfPresent() { persistedVisionEmail="" persistedXHTTPEmail="" persistedShortId="6ba85179e30d4fc2" + persistedSubscribePort="" + persistedSubscribeSalt="" persistedConfigPath="" if [[ ! -f "${summaryFile}" ]]; then @@ -809,6 +1185,8 @@ loadPersistedSummaryIfPresent() { 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}" @@ -825,6 +1203,8 @@ loadPersistedSummaryIfPresent() { 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 @@ -1146,6 +1526,41 @@ hasExistingInstallState() { 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="" @@ -1165,6 +1580,10 @@ uninstallDockerReality() { 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" @@ -1231,7 +1650,7 @@ promptExistingInstallAction() { else echoContent "white" "Detected the container but not the config file. Choose the next action:" fi - echoContent "yellow" "1. View account" + echoContent "yellow" "1. User management" echoContent "yellow" "2. Reinstall" echoContent "yellow" "3. Start/Recreate container" echoContent "yellow" "4. Uninstall" @@ -1242,10 +1661,10 @@ promptExistingInstallAction() { case "${action}" in 1) if hasPersistedConfig; then - showClientInfo + userManageMenu exit 0 else - echoContent "red" "No existing config was found, so the account cannot be shown. Please reinstall first." + echoContent "red" "No existing config was found, so user management cannot be opened. Please reinstall first." fi ;; 2) @@ -1295,7 +1714,11 @@ showVisionAccount() { 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}" + 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}" @@ -1336,7 +1759,11 @@ showXHTTPAccount() { 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}" + 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 ==============================" @@ -1353,6 +1780,106 @@ showXHTTPAccount() { 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, @@ -1393,6 +1920,20 @@ resolveValues() { 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}" @@ -1694,6 +2235,8 @@ EOF printf 'xhttpEmail: %s\n' "${resolvedXHTTPEmail}" fi printf 'shortId: 6ba85179e30d4fc2\n' + printf 'subscribePort: %s\n' "${resolvedSubscribePort}" + printf 'subscribeSalt: %s\n' "$(initRandomSalt)" printf 'configPath: %s\n' "${configFile}" } >"${summaryFile}" chmod 600 "${summaryFile}" @@ -1803,9 +2346,9 @@ startContainer() { } -# showClientInfo — print the current Reality account in a showAccounts-style layout. +# showAccountInfo — print the current Reality account in a showAccounts-style layout. # Displays only the protocol blocks that are actually installed. -showClientInfo() { +showAccountInfo() { local displayAddress="" loadPersistedAccountInfo @@ -1838,6 +2381,36 @@ showClientInfo() { "${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 } # ---------------------------------------------------------------------------