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:
1 parent
cc86680e05
commit
b2016af916
5 files changed
+655
-5
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user