#!/usr/bin/env bash # 双链路代理一键安装(交互确认 / 可用 --yes 全默认) # # 一台空机器跑完即得:3x-ui 面板 + 两条 VLESS+WS 入站 + nginx 反代 + Let's Encrypt 证书 # 支持:Ubuntu/Debian 系 VPS,nginx 是宝塔/aaPanel 或系统自带均可 # # 用法: # ./install-dual-link.sh # 交互确认 # ./install-dual-link.sh --dry-run # 只打印将执行的动作,不改任何东西 # ./install-dual-link.sh --yes --domain-a a.example.com --domain-b b.example.com # ./install-dual-link.sh --yes --domain-a a.x.com --domain-b b.x.com --panel-domain p.x.com # # 约定:本脚本只接 nginx 转发这一种架构(Reality 与 nginx 转发互斥)。 set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" API_PY="${HERE}/3xui-api.py" # ─── 参数默认值 ──────────────────────────────────────────────────────────── DOMAIN_A="" # 橙云链路(走 Cloudflare 代理) DOMAIN_B="" # 灰云链路(A 记录直连回源) PATH_A="/linkA" # 链路 A 的 WebSocket 路径 PATH_B="/linkB" REMARK_A="linkA" # 入站备注名(也是客户端里链路的名字) REMARK_B="linkB" PORT_A=15004 # 后端入站端口(只绑 127.0.0.1) PORT_B=15005 PANEL_PORT=2053 # 3x-ui 面板端口(只绑 127.0.0.1) PANEL_DOMAIN="" # 可选:给面板一个子域,nginx 反代 + 一层 Basic Auth PANEL_PASSWORD="" # 留空则随机生成;机器上已有 3x-ui 时必须显式给 OWNER_EMAIL="owner" # 首个账号标识 ACME_EMAIL="" # Let's Encrypt 注册邮箱,留空则用 admin@<域名A> DRY=0 ASSUME=0 # ─── 输出工具 ────────────────────────────────────────────────────────────── c_ok="\033[32m"; c_warn="\033[33m"; c_err="\033[31m"; c_dim="\033[2m"; c_end="\033[0m" say() { printf "%b\n" "$*"; } ok() { printf "${c_ok} ✓${c_end} %s\n" "$*"; } warn() { printf "${c_warn} !${c_end} %s\n" "$*"; } die() { printf "${c_err} ✗ %s${c_end}\n" "$*" >&2; exit 1; } dim() { printf "${c_dim}%s${c_end}\n" "$*"; } hr() { printf "${c_dim}%s${c_end}\n" "────────────────────────────────────────────────────"; } run_sh() { if [ "$DRY" = 1 ]; then printf "${c_dim} [dry-run] %s${c_end}\n" "$1"; return 0; fi bash -c "$1" } ask() { # ask 变量名 "提示" 默认值 local __var="$1" __prompt="$2" __def="${3:-}" __ans="" if [ -n "$__def" ]; then __prompt="$__prompt [$__def]"; fi if [ "$ASSUME" = 1 ] || [ ! -t 0 ]; then printf -v "$__var" '%s' "$__def"; return 0 fi read -r -p " ${__prompt}: " __ans || true [ -z "$__ans" ] && __ans="$__def" printf -v "$__var" '%s' "$__ans" } usage() { sed -n '2,15p' "$0"; exit 0; } # ─── 环境探测 ────────────────────────────────────────────────────────────── NGINX_BIN=""; VHOST_DIR=""; CERT_DIR=""; WEBROOT_BASE=""; WEB_USER=""; LOG_DIR="" detect_env() { if [ -x /www/server/nginx/sbin/nginx ]; then NGINX_BIN=/www/server/nginx/sbin/nginx VHOST_DIR=/www/server/panel/vhost/nginx CERT_DIR=/www/server/panel/vhost/cert WEBROOT_BASE=/www/wwwroot LOG_DIR=/www/wwwlogs WEB_USER="www" ok "检测到宝塔/aaPanel 的 nginx(${NGINX_BIN})" else NGINX_BIN="$(command -v nginx || true)" [ -z "$NGINX_BIN" ] && die "没找到 nginx,请先安装 nginx" VHOST_DIR=/etc/nginx/conf.d CERT_DIR=/etc/nginx/certs WEBROOT_BASE=/var/www LOG_DIR=/var/log/nginx WEB_USER="www-data" ok "检测到系统 nginx(${NGINX_BIN})" fi id -u "$WEB_USER" >/dev/null 2>&1 || die "nginx 的运行用户 ${WEB_USER} 不存在" if [ "$DRY" = 1 ]; then dim " [dry-run] mkdir -p ${VHOST_DIR} ${CERT_DIR} ${WEBROOT_BASE} ${LOG_DIR}" else mkdir -p "$VHOST_DIR" "$CERT_DIR" "$WEBROOT_BASE" "$LOG_DIR"; fi } port_in_use() { # 只看该端口有没有 LISTEN;用 ss 自带的端口过滤器,避免列对齐导致的误判 local out out="$(ss -tlnH "sport = :$1" 2>/dev/null)" [ -n "$out" ] } need_free() { # 需要是空的:新后端/面板端口 if port_in_use "$1"; then die "端口 $1 已被占用(ss -tln | grep :$1),换一个再跑"; fi ok "端口 $1 空闲" } need_nginx() { # 需要被 nginx 占着:80 / 443 if port_in_use "$1"; then ok "端口 $1 由 nginx 监听(预期)" else warn "端口 $1 没有监听——nginx 没起来?证书签发会失败"; fi } my_ip() { curl -s --max-time 8 https://api.ipify.org 2>/dev/null || echo ""; } backup_file() { [ -f "$1" ] || return 0 local b="$1.bak-$(date +%Y%m%d-%H%M%S)" if [ "$DRY" = 1 ]; then dim " [dry-run] cp -a $1 $b"; return 0; fi cp -a "$1" "$b" && warn "原配置已备份 → $b" } nginx_apply() { if [ "$DRY" = 1 ]; then dim " [dry-run] ${NGINX_BIN} -t && ${NGINX_BIN} -s reload"; return 0; fi ${NGINX_BIN} -t >/tmp/nginx-t.log 2>&1 || { tail -5 /tmp/nginx-t.log; die "nginx 配置检查未通过"; } ${NGINX_BIN} -s reload >/dev/null 2>&1 || die "nginx reload 失败" ok "nginx 已重载" } # ─── 步骤 1:前置检查 ────────────────────────────────────────────────────── step_preflight() { say "" say "[1/6] 前置检查" [ "$(id -u)" = 0 ] || die "需要 root 权限执行" detect_env command -v python3 >/dev/null 2>&1 || die "需要 python3(调面板接口靠它)" [ -f "$API_PY" ] || die "找不到 ${API_PY},请与 3xui-api.py 放在同一目录" local pubip; pubip="$(my_ip)" if [ -n "$pubip" ]; then ok "本机公网出口 IP:${pubip}"; else warn "取不到公网 IP,DNS 校验跳过"; fi local d resolved for d in "$DOMAIN_A" "$DOMAIN_B" $PANEL_DOMAIN; do [ -z "$d" ] && continue resolved="$(getent hosts "$d" | awk '{print $1}' | head -1)" if [ -z "$resolved" ]; then warn "${d} 解析不到 —— 先加 DNS 记录再跑,否则证书签发必然失败" elif [ -n "$pubip" ] && [ "$resolved" != "$pubip" ]; then warn "${d} → ${resolved}(与出口 IP 不同:走 CDN 属正常,直连链路必须指向本机)" else ok "${d} → ${resolved}" fi done need_nginx 80 need_nginx 443 need_free "$PORT_A" need_free "$PORT_B" if [ -x /usr/local/x-ui/x-ui ] && port_in_use "$PANEL_PORT"; then warn "面板端口 ${PANEL_PORT} 已被现有 3x-ui 占用 —— 走复用安装(需 --panel-password)" else need_free "$PANEL_PORT" fi [ -f /root/.acme.sh/acme.sh ] || warn "acme.sh 未安装,第 4 步会自动装" } # ─── 步骤 2:装后端 3x-ui ────────────────────────────────────────────────── step_backend() { say "" say "[2/6] 安装后端 3x-ui(面板本体)" if [ -x /usr/local/x-ui/x-ui ]; then [ -z "$PANEL_PASSWORD" ] && die "本机已有 3x-ui。为免覆盖现有账号,请显式给出现有面板密码: ./install-dual-link.sh --panel-password '<现有密码>' ..." warn "复用已存在的 /usr/local/x-ui(不重新下载)" else local ver ver="$(curl -sI --max-time 20 https://github.com/MHSanaei/3x-ui/releases/latest 2>/dev/null \ | grep -i '^location:' | sed 's#.*/tag/##' | tr -d '\r')" [ -z "$ver" ] && die "取不到 3x-ui 最新版本号(检查出网)" ok "最新版本 ${ver}" run_sh "cd /tmp && rm -rf x-ui xui.tar.gz && \ curl -fsSL -o xui.tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${ver}/x-ui-linux-amd64.tar.gz && \ tar xzf xui.tar.gz && rm -rf /usr/local/x-ui && mv /tmp/x-ui /usr/local/x-ui && \ chmod +x /usr/local/x-ui/x-ui /usr/local/x-ui/bin/xray-linux-amd64" run_sh "cp /usr/local/x-ui/x-ui.service.debian /etc/systemd/system/x-ui.service" fi if [ -z "$PANEL_PASSWORD" ]; then PANEL_PASSWORD="$(head -c 18 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 16)" fi # 面板只绑本地:要么经 nginx 子域进来,要么 SSH 隧道进来 run_sh "/usr/local/x-ui/x-ui setting -port ${PANEL_PORT} -username admin \ -password '${PANEL_PASSWORD}' -listenIP 127.0.0.1" run_sh "systemctl daemon-reload && systemctl enable x-ui >/dev/null 2>&1 && systemctl restart x-ui" if [ "$DRY" = 0 ]; then local i for i in 1 2 3 4 5 6 7 8 9 10; do port_in_use "$PANEL_PORT" && break sleep 1 done port_in_use "$PANEL_PORT" || die "面板没起来,看 journalctl -u x-ui -n 40" ok "面板已在 127.0.0.1:${PANEL_PORT} 监听" fi } # ─── 步骤 3:建两条入站 ──────────────────────────────────────────────────── api() { python3 "$API_PY" --base "http://127.0.0.1:${PANEL_PORT}" --password "$PANEL_PASSWORD" "$@"; } step_inbounds() { say "" say "[3/6] 创建两条 VLESS+WS 入站(各绑 127.0.0.1)" run_sh "python3 '${API_PY}' --base 'http://127.0.0.1:${PANEL_PORT}' --password '${PANEL_PASSWORD}' \ add --remark ${REMARK_A} --port ${PORT_A} --path '${PATH_A}' --email '${OWNER_EMAIL}'" run_sh "python3 '${API_PY}' --base 'http://127.0.0.1:${PANEL_PORT}' --password '${PANEL_PASSWORD}' \ add --remark ${REMARK_B} --port ${PORT_B} --path '${PATH_B}' --email '${OWNER_EMAIL}'" ok "${REMARK_A} → 127.0.0.1:${PORT_A}${PATH_A} ${REMARK_B} → 127.0.0.1:${PORT_B}${PATH_B}" if [ "$DRY" = 0 ]; then api list | sed 's/^/ /' fi } # ─── 步骤 4/5:nginx + 证书 ──────────────────────────────────────────────── # write_http_vhost 域名 [路径] [端口] —— 有路径就给 WS,没路径就只做 ACME + 跳转 write_http_vhost() { local domain="$1" path="${2:-}" port="${3:-}" local root="${WEBROOT_BASE}/${domain}" f="${VHOST_DIR}/${domain}.conf" local ws="" if [ -n "$path" ]; then ws=$(cat < "$f" < "$f" </dev/null 2>&1" run_sh "/root/.acme.sh/acme.sh --set-default-ca --server letsencrypt >/dev/null 2>&1" ok "acme.sh 已安装" fi [ "$DRY" = 1 ] && dim " [dry-run] acme.sh --issue -w ${WEBROOT_BASE}/<域名> -d <域名> --keylength ec-256" local d for d in "$DOMAIN_A" "$DOMAIN_B" $PANEL_DOMAIN; do [ -z "$d" ] && continue run_sh "/root/.acme.sh/acme.sh --issue -d ${d} -w ${WEBROOT_BASE}/${d} --keylength ec-256 >/tmp/acme-${d}.log 2>&1" if [ "$DRY" = 0 ] && [ ! -f "/root/.acme.sh/${d}_ecc/${d}.cer" ]; then tail -6 "/tmp/acme-${d}.log"; die "${d} 证书签发失败(80 端口要能回源到本机 80)" fi run_sh "/root/.acme.sh/acme.sh --install-cert -d ${d} --ecc \ --key-file ${CERT_DIR}/${d}/privkey.pem \ --fullchain-file ${CERT_DIR}/${d}/fullchain.pem \ --reloadcmd '${NGINX_BIN} -s reload' >/dev/null 2>&1" ok "${d} 证书就绪 → ${CERT_DIR}/${d}" done } step_nginx_https() { say "" say "[5/6] 切到 443" write_https_vhost "$DOMAIN_A" "$PATH_A" "$PORT_A" write_https_vhost "$DOMAIN_B" "$PATH_B" "$PORT_B" if [ -n "$PANEL_DOMAIN" ]; then local root="${WEBROOT_BASE}/${PANEL_DOMAIN}" passfile passfile="${root}/.panel-pass" local bpw; bpw="$(head -c 12 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 10)" if [ "$DRY" = 1 ]; then dim " [dry-run] printf 'ops:%s\\n' \"\$(openssl passwd -apr1 '******')\" > ${passfile}" else mkdir -p "$root" printf 'ops:%s\n' "$(openssl passwd -apr1 "$bpw")" > "$passfile" chown "$WEB_USER":"$WEB_USER" "$passfile" chmod 640 "$passfile" fi PANEL_BASIC_USER="ops"; PANEL_BASIC_PASS="$bpw" write_https_vhost "$PANEL_DOMAIN" "" "" "$passfile" ok "面板子域 ${PANEL_DOMAIN} 已配好(Basic Auth 用户 ops / ${bpw})" fi nginx_apply ok "443 已生效" } # ─── 步骤 6:验收 ────────────────────────────────────────────────────────── code_of() { # code_of URL [额外 curl 参数...] local url="$1"; shift curl -s -i --http1.1 --max-time 20 "$@" "$url" 2>/dev/null | grep -oE 'HTTP/1.1 [0-9]{3}' | head -1 } step_verify() { say "" say "[6/6] 验收" if [ "$DRY" = 1 ]; then dim " [dry-run] 跳过实测"; return 0; fi local d p c for d in "$DOMAIN_A" "$DOMAIN_B"; do if [ "$d" = "$DOMAIN_A" ]; then p="$PATH_A"; else p="$PATH_B"; fi c="$(code_of "https://${d}${p}" -H 'Upgrade: websocket' -H 'Connection: Upgrade' \ -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==')" case "$c" in *101*) ok "${d}${p} → ${c}(WebSocket 已通)" ;; *) warn "${d}${p} → ${c:-无响应}(期望 101,看 ${VHOST_DIR}/${d}.conf 与 nginx error log)" ;; esac c="$(code_of "https://${d}${p}")" [ "$c" = "HTTP/1.1 404" ] && ok "${d}${p} 不带 Upgrade 时返回 404(探针伪装,符合预期)" \ || warn "${d}${p} 不带 Upgrade 返回 ${c:-无响应}(期望 404)" done if [ -n "$PANEL_DOMAIN" ]; then c="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "https://${PANEL_DOMAIN}/" 2>/dev/null)" [ "$c" = "401" ] && ok "面板子域未认证返回 401(Basic Auth 生效)" \ || warn "面板子域返回 ${c:-无响应},期望 401" c="$(code_of "https://${PANEL_DOMAIN}/" -u "ops:${PANEL_BASIC_PASS}")" [ "$c" = "HTTP/1.1 200" ] && ok "面板子域带认证返回 200" || warn "面板子域带认证返回 ${c:-无响应}" fi } # ─── 收尾 ────────────────────────────────────────────────────────────────── step_summary() { say "" say "─── 装好了 ──────────────────────────────────────────────" if [ -n "$PANEL_DOMAIN" ]; then say "面板: https://${PANEL_DOMAIN}/ 外层 ${PANEL_BASIC_USER} / ${PANEL_BASIC_PASS} 内层 admin / ${PANEL_PASSWORD}" else say "面板: ssh -L 2053:127.0.0.1:${PANEL_PORT} root@<本机IP> → 本地开 http://127.0.0.1:2053" say " 账号 admin / ${PANEL_PASSWORD}" fi say "入站: ${REMARK_A} 127.0.0.1:${PORT_A}${PATH_A}(→ ${DOMAIN_A}) ${REMARK_B} 127.0.0.1:${PORT_B}${PATH_B}(→ ${DOMAIN_B})" say "" if [ "$DRY" = 1 ]; then dim " [dry-run] 真实链接在装完后用下面命令取:" dim " python3 ${API_PY} --password '<面板密码>' link --inbound ${REMARK_A} --email ${OWNER_EMAIL} --domain ${DOMAIN_A}" hr; return 0 fi say "你的两条链接(可直接导入客户端):" api link --inbound "$REMARK_A" --email "$OWNER_EMAIL" --domain "$DOMAIN_A" --fragment "${REMARK_A}-橙云" 2>/dev/null \ | sed 's/^/ A /' api link --inbound "$REMARK_B" --email "$OWNER_EMAIL" --domain "$DOMAIN_B" --fragment "${REMARK_B}-灰云" 2>/dev/null \ | sed 's/^/ B /' say "" say "给新用户开号:python3 ${API_PY} --password '<面板密码>' add-user --email <名字>" say "取某人某条链路的链接:… link --inbound ${REMARK_A} --email <名字> --domain ${DOMAIN_A}" say "销号: python3 ${API_PY} --password '<面板密码>' del-user --email <名字>" hr } # ─── 主流程 ──────────────────────────────────────────────────────────────── main() { while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY=1; shift ;; --yes|-y) ASSUME=1; shift ;; --domain-a) DOMAIN_A="$2"; shift 2 ;; --domain-b) DOMAIN_B="$2"; shift 2 ;; --path-a) PATH_A="$2"; shift 2 ;; --path-b) PATH_B="$2"; shift 2 ;; --remark-a) REMARK_A="$2"; shift 2 ;; --remark-b) REMARK_B="$2"; shift 2 ;; --port-a) PORT_A="$2"; shift 2 ;; --port-b) PORT_B="$2"; shift 2 ;; --panel-port) PANEL_PORT="$2"; shift 2 ;; --panel-domain) PANEL_DOMAIN="$2"; shift 2 ;; --panel-password) PANEL_PASSWORD="$2"; shift 2 ;; --owner) OWNER_EMAIL="$2"; shift 2 ;; --acme-email) ACME_EMAIL="$2"; shift 2 ;; -h|--help) usage ;; *) die "未知参数:$1(--help 看用法)" ;; esac done say "双链路代理安装向导(Ctrl+C 退出)" say "本脚本只做 nginx 转发 + 3x-ui 面板这一种架构。" [ -z "$DOMAIN_A" ] && ask DOMAIN_A "橙云链路域名(走 CF 代理)" "" [ -z "$DOMAIN_B" ] && ask DOMAIN_B "灰云链路域名(A 记录直连)" "" ask PATH_A "链路 A 的 WebSocket 路径" "$PATH_A" ask PATH_B "链路 B 的 WebSocket 路径" "$PATH_B" ask PORT_A "链路 A 的后端本地端口" "$PORT_A" ask PORT_B "链路 B 的后端本地端口" "$PORT_B" ask PANEL_PORT "3x-ui 面板端口(只绑 127.0.0.1)" "$PANEL_PORT" ask PANEL_DOMAIN "面板子域名(留空=只走 SSH 隧道)" "$PANEL_DOMAIN" ask OWNER_EMAIL "首个账号标识(你自己)" "$OWNER_EMAIL" say "" say "将执行:" say " ${DOMAIN_A} → 127.0.0.1:${PORT_A}${PATH_A}" say " ${DOMAIN_B} → 127.0.0.1:${PORT_B}${PATH_B}" say " 面板 127.0.0.1:${PANEL_PORT}$([ -n "$PANEL_DOMAIN" ] && echo " → https://${PANEL_DOMAIN}"),首账号 ${OWNER_EMAIL}" [ "$DRY" = 1 ] && say " (dry-run:只打印不执行)" if [ "$ASSUME" = 0 ] && [ -t 0 ]; then local go; read -r -p " 确认开始?[y/N] " go || true [ "$go" = y ] || [ "$go" = Y ] || { say "已取消"; exit 0; } fi step_preflight step_backend step_inbounds step_nginx_http step_nginx_https step_verify step_summary } if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@"; fi