From b2016af91612663f532a969e523e2e12a0b62374 Mon Sep 17 00:00:00 2001 From: maogeigei Date: Sun, 18 Oct 2026 20:18:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(dual-link-proxy):=20=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E8=84=9A=E6=9C=AC=EF=BC=8C=E7=A9=BA=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E4=B8=80=E6=9D=A1=E5=91=BD=E4=BB=A4=E8=A3=85=E5=AE=8C?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E4=B8=8E=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/install-dual-link.sh:装 3x-ui → 建两条 VLESS+WS 入站(绑 127.0.0.1) → 80 vhost 完成 ACME → 签证书 → 切 443 → 可选面板子域 + Basic Auth → 实测 101 并打印链接; 支持 --dry-run 干跑、--yes 无人值守、已有 3x-ui 时复用而不重置密码 - 3xui-api.py 补 uuid / link / del-inbound 三个子命令,add 的 --uuid 改为可选 - verify-links.sh 统计实际检查项,空跑以退出码 2 报错,避免假通过 - 避坑清单补三条脚本类实测:ss 端口判定的字段误用、dry-run 仍落盘、验收空跑 --- dual-link-proxy/SKILL.md | 29 +- dual-link-proxy/references/05-避坑清单.md | 8 + dual-link-proxy/scripts/3xui-api.py | 80 ++- dual-link-proxy/scripts/install-dual-link.sh | 535 +++++++++++++++++++ dual-link-proxy/scripts/verify-links.sh | 8 + 5 files changed, 655 insertions(+), 5 deletions(-) create mode 100644 dual-link-proxy/scripts/install-dual-link.sh diff --git a/dual-link-proxy/SKILL.md b/dual-link-proxy/SKILL.md index 110efcb..30b7d5f 100644 --- a/dual-link-proxy/SKILL.md +++ b/dual-link-proxy/SKILL.md @@ -31,6 +31,30 @@ agent_created: true ## 工作流 +### 快速通道:一条命令装完(推荐) + +新机器(Ubuntu/Debian + 已有 nginx + 域名已解析)直接跑: + +```bash +scripts/install-dual-link.sh --dry-run # 先干跑,看它打算做什么,不改任何东西 +scripts/install-dual-link.sh # 交互确认后真装 +scripts/install-dual-link.sh --yes \ + --domain-a gao.example.com --domain-b mao.example.com --panel-domain sing.example.com +``` + +它一次做完:装 3x-ui → 调面板接口建两条 VLESS+WS 入站(只绑 127.0.0.1)→ 建 80 vhost 完成 ACME → 签证书 → 切 443 → 可选给面板配子域 + Basic Auth → 实测 101 并打印两条可直接导入的链接。 + +装完会用到的后续命令: + +```bash +python3 scripts/3xui-api.py --password '<面板密码>' list # 看入站/账号/流量 +python3 scripts/3xui-api.py --password '<面板密码>' add-user --email alice # 一次给两条链路开同一个人 +python3 scripts/3xui-api.py --password '<面板密码>' link --inbound linkA --email alice --domain gao.example.com +python3 scripts/3xui-api.py --password '<面板密码>' del-user --email alice +``` + +脚本是「只接 nginx 转发」这一种架构;下列阶段一~五是把脚本内部每一步拆开讲,出问题或要偏离默认值时按阶段查。 + ### 阶段一 取证与选型 读 `references/01-前置取证与选型.md`。产出:链路数量与域名分配、后端选型、端口清单。 @@ -77,5 +101,6 @@ agent_created: true - `references/03-nginx与证书.md` —— vhost 模板、acme.sh 流程、TLS 套件收紧。 - `references/04-面板与多用户.md` —— 3x-ui 层级模型、加人步骤、脚本登录的 CSRF 处理。 - `references/05-避坑清单.md` —— 实际踩过的坑与对应正向做法。 -- `scripts/verify-links.sh` —— 一次验证链路、站点、面板。 -- `scripts/3xui-api.py` —— 3x-ui 登录与建入站的命令行封装。 +- `scripts/install-dual-link.sh` —— 一键安装:3x-ui + 两条入站 + nginx 反代 + 证书 + 面板子域 + 验收。支持 `--dry-run`。 +- `scripts/verify-links.sh` —— 一次验证链路、站点、面板(**必须带 `--link` / `--site` / `--panel`,不带任何检查项会以退出码 2 报错而不是假通过**)。 +- `scripts/3xui-api.py` —— 3x-ui 命令行封装:`ping` / `list` / `add` / `del-inbound` / `add-user` / `del-user` / `uuid` / `link`。 diff --git a/dual-link-proxy/references/05-避坑清单.md b/dual-link-proxy/references/05-避坑清单.md index 7f26719..c50d0a8 100644 --- a/dual-link-proxy/references/05-避坑清单.md +++ b/dual-link-proxy/references/05-避坑清单.md @@ -47,3 +47,11 @@ 18. 升内核要显式装 `linux-generic`。(依据:`apt-get upgrade` 把内核三个元包 `kept back`,跑完 `uname -r` 仍是旧内核;`apt install linux-generic` 才会装上新的 image 并在重启后生效。) 19. 用 ufw 限制 Docker 发布的端口要走 `DOCKER-USER` 链。(依据:`ufw status` 里没有放行规则,端口照样对外可达,因为 Docker 直接写 iptables 的 NAT 链绕过 ufw。) + +## 五、写自动化脚本类 + +20. 判断端口是否占用用 `ss -tlnH "sport = :<端口>"`,判空即可。(依据:`ss -tln | awk '$4 ~ ":80 "'` 这种写法永远匹配不到——`$4` 是 `0.0.0.0:80`,末尾没有空格,于是「占用」和「空闲」共用一个返回值,脚本会把正在监听的端口当成空的往下走。) + +21. `--dry-run` 分支里凡是 `cat > 目标文件`、`mkdir`、`cp` 都要先判断 DRY 再执行。(依据:只把命令包进 `run()` 的脚本,往往漏掉 heredoc 写文件这一步,干跑一次就把线上 vhost 覆盖了。) + +22. 验收脚本要统计「实际跑了几个检查项」,为 0 时以非 0 退出。(依据:不带参数的验收脚本会把「什么都没查」打印成「全部通过」,这种绿灯比红灯更危险。) diff --git a/dual-link-proxy/scripts/3xui-api.py b/dual-link-proxy/scripts/3xui-api.py index acc2f43..3788144 100644 --- a/dual-link-proxy/scripts/3xui-api.py +++ b/dual-link-proxy/scripts/3xui-api.py @@ -84,6 +84,7 @@ def api_headers(token): def build_inbound(remark, port, path, uuid, listen="127.0.0.1", email=None): email = email or remark + uuid = uuid or str(uuidlib.uuid4()) return { "up": 0, "down": 0, @@ -167,8 +168,10 @@ def cmd_add(op, token, base, args): if not data.get("success"): sys.exit("建入站失败:%s" % data.get("msg")) obj = data.get("obj") or {} - print("已创建 id=%s 备注=%s 端口=%s 路径=%s" - % (obj.get("id"), args.remark, args.port, args.path)) + # 服务端可能自行生成 UUID,回读一次保证打印的就是实际的 + ib, uid = find_inbound_uuid(fetch_inbounds(op, base), args.remark, args.email or args.remark) + print("已创建 id=%s 备注=%s 端口=%s 路径=%s UUID=%s" + % (obj.get("id") or (ib or {}).get("id"), args.remark, args.port, args.path, uid or "(回读失败)")) def fetch_inbounds(op, base): @@ -233,6 +236,57 @@ def cmd_add_user(op, token, base, args): print("已挂到入站:" + "、".join(names)) +def find_inbound_uuid(items, remark, email): + for ib in items: + if remark and ib.get("remark") != remark: + continue + for c in (as_obj(ib.get("settings")).get("clients") or []): + if c.get("email") == email: + return ib, (c.get("id") or "") + return None, "" + + +def cmd_uuid(op, token, base, args): + ib, uid = find_inbound_uuid(fetch_inbounds(op, base), args.inbound, args.email) + if not ib or not uid: + sys.exit("没找到 %s 在入站 %s 里的客户端" % (args.email, args.inbound)) + print(uid) + + +def cmd_link(op, token, base, args): + ib, uid = find_inbound_uuid(fetch_inbounds(op, base), args.inbound, args.email) + if not ib or not uid: + sys.exit("没找到 %s 在入站 %s 里的客户端" % (args.email, args.inbound)) + path = (as_obj(ib.get("streamSettings")).get("wsSettings") or {}).get("path") or "/" + query = urllib.parse.urlencode( + [ + ("encryption", "none"), + ("security", "tls"), + ("sni", args.domain), + ("alpn", "http/1.1"), + ("type", "ws"), + ("host", args.domain), + ("path", path), + ("fp", "chrome"), + ], + quote_via=urllib.parse.quote, + ) + frag = args.fragment or ib.get("remark") or "link" + print("vless://%s@%s:%s?%s#%s" % (uid, args.domain, args.port, query, frag)) + + +def cmd_del_inbound(op, token, base, args): + items = [ib for ib in fetch_inbounds(op, base) if ib.get("remark") == args.inbound] + if not items: + sys.exit("没有备注为 %s 的入站" % args.inbound) + for ib in items: + data = api_post(op, token, base, "/panel/api/inbounds/del/%s" % ib.get("id"), {}, + referer="/panel/inbounds") + if not data.get("success"): + sys.exit("删除入站 %s 失败:%s" % (ib.get("remark"), data.get("msg"))) + print("已删除入站 id=%s 备注=%s 端口=%s" % (ib.get("id"), ib.get("remark"), ib.get("port"))) + + def cmd_del_user(op, token, base, args): data = api_post( op, token, base, @@ -258,10 +312,13 @@ def main(): a.add_argument("--remark", required=True) a.add_argument("--port", required=True, type=int) a.add_argument("--path", required=True) - a.add_argument("--uuid", required=True) + a.add_argument("--uuid", default=None, help="不填则自动生成") a.add_argument("--listen", default="127.0.0.1") a.add_argument("--email", default=None) + u = sub.add_parser("del-inbound", help="按备注删掉整条入站") + u.add_argument("--inbound", required=True) + u = sub.add_parser("add-user", help="给所有入站加同一个用户,一人两条链路") u.add_argument("--email", required=True) u.add_argument("--uuid", default=None) @@ -271,6 +328,17 @@ def main(): d.add_argument("--email", required=True) d.add_argument("--inbound", default=None) + g = sub.add_parser("uuid", help="打印某入站某账号的真实 UUID") + g.add_argument("--inbound", required=True) + g.add_argument("--email", required=True) + + k = sub.add_parser("link", help="直接拼出可导入客户端的 vless 链接") + k.add_argument("--inbound", required=True, help="入站备注名") + k.add_argument("--email", required=True, help="账号标识") + k.add_argument("--domain", required=True) + k.add_argument("--port", default="443") + k.add_argument("--fragment", default=None, help="客户端里显示的名字") + args = p.parse_args() if not args.cmd: p.error("缺少子命令:ping / list / add") @@ -285,6 +353,12 @@ def main(): cmd_add_user(op, token, args.base, args) elif args.cmd == "del-user": cmd_del_user(op, token, args.base, args) + elif args.cmd == "del-inbound": + cmd_del_inbound(op, token, args.base, args) + elif args.cmd == "uuid": + cmd_uuid(op, token, args.base, args) + elif args.cmd == "link": + cmd_link(op, token, args.base, args) if __name__ == "__main__": diff --git a/dual-link-proxy/scripts/install-dual-link.sh b/dual-link-proxy/scripts/install-dual-link.sh new file mode 100644 index 0000000..30c28a4 --- /dev/null +++ b/dual-link-proxy/scripts/install-dual-link.sh @@ -0,0 +1,535 @@ +#!/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 diff --git a/dual-link-proxy/scripts/verify-links.sh b/dual-link-proxy/scripts/verify-links.sh index f8c568c..2909d11 100644 --- a/dual-link-proxy/scripts/verify-links.sh +++ b/dual-link-proxy/scripts/verify-links.sh @@ -14,8 +14,10 @@ set -u UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" WS_KEY="dGhlIHNhbXBsZSBub25jZQ==" FAIL=0 +CHECKS=0 check_link() { + CHECKS=$((CHECKS+1)) local spec="$1" local host="${spec%%:*}" local path="${spec#*:}" @@ -33,6 +35,7 @@ check_link() { } check_site() { + CHECKS=$((CHECKS+1)) local host="$1" local code code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 20 "https://${host}/" 2>/dev/null) @@ -45,6 +48,7 @@ check_site() { } check_panel() { + CHECKS=$((CHECKS+1)) local url="$1" local auth="$2" local code @@ -79,6 +83,10 @@ if [ -n "${PANEL_URL:-}" ]; then fi echo +if [ "$CHECKS" -eq 0 ]; then + echo "没有给任何检查项(--link / --site / --panel),这不叫通过" + exit 2 +fi if [ "$FAIL" -eq 0 ]; then echo "全部通过" else