feat(dual-link-proxy): 一键安装脚本,空机器一条命令装完链路与面板

- 新增 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 仍落盘、验收空跑
This commit is contained in:
admin committed 2026-10-18 20:18:33 +08:00
1 parent cc86680e05
commit b2016af916
5 files changed
+655 -5

No files matched your search

+27 -2
View File
@@ -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`。
@@ -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 退出。(依据:不带参数的验收脚本会把「什么都没查」打印成「全部通过」,这种绿灯比红灯更危险。)
+77 -3
View File
@@ -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__":
@@ -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 <<WS
location ${path} {
if (\$http_upgrade = "") { return 404; }
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$http_host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:${port};
}
WS
)
fi
[ -n "$path" ] && [ "$DRY" = 1 ] && dim " [dry-run] 写入 ${f}(含 ${path} → 127.0.0.1:${port} 的 WS 反代)" && return 0
[ -z "$path" ] && [ "$DRY" = 1 ] && dim " [dry-run] 写入 ${f}(仅 ACME 验证目录 + 跳 443)" && return 0
[ "$DRY" = 1 ] || mkdir -p "$root"
backup_file "$f"
cat > "$f" <<CONF
server
{
listen 80;
server_name ${domain};
root ${root};
access_log ${LOG_DIR}/${domain}.log;
error_log ${LOG_DIR}/${domain}.error.log;
location ^~ /.well-known/acme-challenge/ {
default_type text/plain;
root ${root};
}
${ws}
location / { return 301 https://\$host\$request_uri; }
}
CONF
say " 写入 ${f}"
}
# write_https_vhost 域名 [路径] [端口] [basic-auth 文件]
write_https_vhost() {
local domain="$1" path="${2:-}" port="${3:-}" authfile="${4:-}"
local root="${WEBROOT_BASE}/${domain}" cert="${CERT_DIR}/${domain}" f="${VHOST_DIR}/${domain}.conf"
local block=""
if [ -n "$path" ]; then
block=$(cat <<BK
location ${path} {
if (\$http_upgrade = "") { return 404; }
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$http_host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:${port};
}
location / { try_files \$uri \$uri/ =404; }
BK
)
else
block=$(cat <<BK
location / {
auth_basic "panel";
auth_basic_user_file ${authfile};
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$http_connection;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 600s;
proxy_pass http://127.0.0.1:${PANEL_PORT};
}
BK
)
fi
if [ "$DRY" = 1 ]; then
if [ -n "$path" ]; then
dim " [dry-run] 写入 ${f}(443 + ${path} → 127.0.0.1:${port},证书 ${cert})"
else
dim " [dry-run] 写入 ${f}(443 + 面板反代 → 127.0.0.1:${PANEL_PORT},Basic Auth ${authfile})"
fi
return 0
fi
backup_file "$f"
cat > "$f" <<CONF
server
{
listen 80;
server_name ${domain};
root ${root};
location ^~ /.well-known/acme-challenge/ { default_type text/plain; root ${root}; }
location / { return 301 https://\$host\$request_uri; }
}
server
{
listen 443 ssl http2;
server_name ${domain};
root ${root};
access_log ${LOG_DIR}/${domain}.log;
error_log ${LOG_DIR}/${domain}.error.log;
ssl_certificate ${cert}/fullchain.pem;
ssl_certificate_key ${cert}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL_${domain//[.-]/_}:10m;
ssl_session_timeout 1d;
location ^~ /.well-known/acme-challenge/ { default_type text/plain; root ${root}; }
${block}
}
CONF
say " 写入 ${f}"
}
step_nginx_http() {
say ""
say "[4/6] 建 80 端 vhost 并签发证书"
write_http_vhost "$DOMAIN_A" "$PATH_A" "$PORT_A"
write_http_vhost "$DOMAIN_B" "$PATH_B" "$PORT_B"
if [ -n "$PANEL_DOMAIN" ]; then write_http_vhost "$PANEL_DOMAIN"; fi
nginx_apply
ok "80 已生效(ACME 验证目录就绪)"
if [ "$DRY" = 0 ] && [ ! -f /root/.acme.sh/acme.sh ]; then
run_sh "curl -fsSL https://get.acme.sh | sh -s email=${ACME_EMAIL:-admin@${DOMAIN_A}} >/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
+8
View File
@@ -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