feat(dual-link-proxy): 新增双链路代理搭建与运维技能(已脱敏)
把「在已跑 nginx 的 VPS 上从零搭起橙云 + 灰云双链路、并用 3x-ui 管多用户」 的全过程沉淀成技能:五阶段工作流、五条硬性前提、三条易踩反的判据、19 条避坑。 - references/01 前置取证与选型:泛解析不等通、端口取证、nginx 约束下的选型 - references/02 服务端落地:3x-ui 与 sing-box 两条路线,含包名与 2096 冲突 - references/03 nginx 与证书:80 → 签证书 → 443 的顺序、AEAD 套件、口令文件位置 - references/04 面板与多用户:3x-ui 三层模型、加人步骤、CSRF 登录法 - references/05 避坑清单:19 条,正向写做法、括号里附踩过的坑 - scripts/verify-links.sh 与 scripts/3xui-api.py:两个脚本均对真实环境跑通 正文已脱敏,域名/端口/口令/UUID/主机 全部改为占位符。
This commit is contained in:
1 parent
310a964a94
commit
a9d2adaf72
9 files changed
+759
No files matched your search
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: dual-link-proxy
|
||||
description: Deploy and operate a self-hosted proxy on a VPS that already runs nginx, giving one Cloudflare-fronted link plus one direct-DNS link so clients can switch when a path degrades. Covers nginx-side TLS termination, WebSocket reverse proxying to a loopback backend, Let's Encrypt issuance, 3x-ui panel based multi-user management, and the verification criteria for every hop. Use when building or maintaining nginx-fronted VLESS/WebSocket proxy links, adding or revoking users, checking link health, or diagnosing 101/200/401/403/404/520/526 symptoms.
|
||||
agent_created: true
|
||||
---
|
||||
|
||||
# 双链路代理:搭建与运维
|
||||
|
||||
## 用途
|
||||
|
||||
在一台已经跑着 nginx(通常是宝塔 / aaPanel)的 VPS 上,落地一套「两条链路 + 面板管多用户」的代理:
|
||||
|
||||
- 链路一挂在 Cloudflare 后面(橙云),抗干扰、隐藏源站。
|
||||
- 链路二用灰云 A 记录直连源站,路径短、握手快。
|
||||
- 两条互为备份,任一条被干扰就切另一条,客户端参数结构一致。
|
||||
- 后端只监听 127.0.0.1,公网只暴露 nginx 的 443。
|
||||
- 用户通过 3x-ui 面板增删,每人独立 UUID、独立流量统计、可设到期。
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 在已有 nginx 的机器上加代理,且用户明确「域名都走 nginx 转发」。
|
||||
- 用户要求「每人一个独立账号」并要网页管理。
|
||||
- 链路出现 101 不通、证书签不下来、面板打不开等故障需要定位。
|
||||
|
||||
## 硬性前提:先确认这四条,再动手
|
||||
|
||||
1. 确认域名是否真的回源到目标服务器,解析结果不算数,要看 HTTP 响应特征(见 `references/01-前置取证与选型.md`)。
|
||||
2. 确认目标端口空闲,且不与 nginx 的 80/443 冲突。
|
||||
3. 确认后端选型与「域名走 nginx」这一约束不冲突,Reality 与 nginx 转发互斥。
|
||||
4. 确认改动范围只新增文件,现有站点的 vhost 一行不动。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 阶段一 取证与选型
|
||||
|
||||
读 `references/01-前置取证与选型.md`。产出:链路数量与域名分配、后端选型、端口清单。
|
||||
|
||||
关键判据:云解析的泛记录常常存在但**不通**,必须用 HTTP 状态码区分「CF 自有 404」与「回源 520 / 200」。
|
||||
|
||||
### 阶段二 服务端落地
|
||||
|
||||
读 `references/02-服务端落地.md`。产出:后端在 127.0.0.1 的指定端口跑起来,入站就绪。
|
||||
|
||||
这一篇含 3x-ui 与纯 sing-box 两条路线,写明包名、版本、端口冲突处理。
|
||||
|
||||
### 阶段三 nginx 与证书
|
||||
|
||||
读 `references/03-nginx与证书.md`。产出:vhost 建好、证书签发成功、443 可用。
|
||||
|
||||
顺序不可颠倒:先建 80 的 vhost 并在其上完成 ACME 验证,再签证书,最后加 443。反过来必然失败。
|
||||
|
||||
### 阶段四 面板与多用户
|
||||
|
||||
读 `references/04-面板与多用户.md`。产出:面板可登录、入站建好、每个用户在两套入站里各有一个客户端。
|
||||
|
||||
面板的脚本化登录有前置条件,先读该文档再写自动化。
|
||||
|
||||
### 阶段五 验收
|
||||
|
||||
跑 `scripts/verify-links.sh` 或等效命令。四条判据缺一不可:
|
||||
|
||||
- 每条链路的 WebSocket 路径返回 `HTTP/1.1 101`。
|
||||
- 每个既有站点返回 `200`。
|
||||
- 面板带口令、带浏览器 UA 返回 `200`。
|
||||
- 后端服务 `active`,被替换的旧服务 `inactive` 且 `disabled`。
|
||||
|
||||
## 三条容易踩反的判据
|
||||
|
||||
- 验证带 basic auth 的站点要带浏览器 User-Agent。aaPanel 及同类面板对非浏览器 UA 一律返回 404,用 curl 默认 UA 判断「服务挂了」会得到假阳性。
|
||||
- 验证 WS 转发看 `101 Switching Protocols`,不看 `200`。经 CDN 时先有 `200 Connection Established`(CONNECT 隧道)再是 `101`。
|
||||
- 判断服务状态要把 `systemctl is-active` 与 `ss -tlnp` 的持有进程一起看。孤儿 worker 会继续持有监听端口,此时 systemd 显示 failed 而站点仍然可访问。
|
||||
|
||||
## 资源索引
|
||||
|
||||
- `references/01-前置取证与选型.md` —— DNS / 端口 / 选型的取证命令与判据。
|
||||
- `references/02-服务端落地.md` —— 3x-ui 与 sing-box 两条安装路线。
|
||||
- `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 登录与建入站的命令行封装。
|
||||
@@ -0,0 +1,65 @@
|
||||
# 01 前置取证与选型
|
||||
|
||||
## 一、DNS 取证:解析结果不等于能回源
|
||||
|
||||
泛解析(`*.example.com`)在 Cloudflare 上常常存在,但背后没有可用源站。此时域名「能解析」却完全不通,直接在上面签证书或配 vhost 会浪费大量时间。
|
||||
|
||||
判断方法:对目标域名发一次 HTTP 请求,看状态码与 `Server` 头。
|
||||
|
||||
- 返回 `404` 且 `Server: cloudflare` —— CF 自己回的 404,说明这条记录在 CF 上没有可用源站,**必须新增显式 A 记录**,泛解析顶不了。
|
||||
- 返回 `520` / `522` / `526` 且 `Server: cloudflare` —— CF 正在尝试回源但失败。`520` 通常是源站没有该 Host 的 vhost,`526` 是源站证书不被信任。这说明记录已经指向你的服务器,可以对症处理。
|
||||
- 返回 `200` —— 回源正常。
|
||||
|
||||
对照验证:在本机用 Host 头直接打源站,绕开 DNS。
|
||||
|
||||
```
|
||||
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: <域名>" http://<源站IP>/
|
||||
```
|
||||
|
||||
这条返回 `301` 或 `200` 说明 vhost 生效、域名已指过来;返回 `000` 说明连不通。
|
||||
|
||||
同时记录两条链路的解析目标:橙云会解析到 CF 的 `104.x` / `172.67.x` 段,灰云解析到源站 IP。两条链路的 `server_name` 用同一套配置结构,客户端只需换域名。
|
||||
|
||||
## 二、端口取证
|
||||
|
||||
在建任何入站之前先确认端口空闲,避免装好才发现撞车。
|
||||
|
||||
```
|
||||
ss -tln | grep -E ":<端口> "
|
||||
```
|
||||
|
||||
同时列出机器上现有的全部监听端口,作为选端口时的排除清单。常见冲突源:宝塔面板自己的 webserver、面板的订阅服务、旧代理容器的端口。
|
||||
|
||||
## 三、选型:先看约束,再看性能
|
||||
|
||||
### 约束优先
|
||||
|
||||
用户说「域名都走 nginx 转发」时,传输层只有一个合理选择:**VLESS + WebSocket + TLS,TLS 在 nginx 终止**。
|
||||
|
||||
原因是 Reality 自己做 TLS 握手,nginx 一旦转发就断,两者互斥;橙云前置更不可能透传裸 TLS。这条约束会直接决定方案,不需要在性能上纠结。
|
||||
|
||||
### 后端选型
|
||||
|
||||
两种,按用户是否需要网页管理来分:
|
||||
|
||||
1. 纯配置文件(sing-box)—— 加人靠改 JSON 加 `users` 数组,一人一个 UUID。零新增服务、零新增攻击面,适合只有自己或极少数人。
|
||||
2. 面板(3x-ui)—— 有网页界面,每个客户端独立 UUID、流量统计、到期时间、可单独停用。适合「要管理多个用户」。
|
||||
|
||||
用户的真实需求是「每人独立账号 + 网页管理」时选 2;只提单人用时选 1。
|
||||
|
||||
### 面板选型
|
||||
|
||||
在同类面板里优先选 3x-ui:用户量最大、界面可切中文、每个入站能挂多个客户端并各自计流量、CLI 与 API 都有明确入口。
|
||||
|
||||
sing-box 系的 S-UI 也可用,但它的登录接口字段约定不标准(请求体里的用户名会被读成空),脚本化成本高,且与 3x-ui 抢同一个订阅端口。
|
||||
|
||||
### 一条容易忽略的容量事实
|
||||
|
||||
先测服务器总带宽再谈优化。在本机用两条并发下载对比单条即可判断是「总带宽封顶」还是「单连接受限」:
|
||||
|
||||
- 两条并发合计 ≈ 单条 ⇒ 总带宽是天花板,换协议或换链路都不会更快。
|
||||
- 两条并发合计 ≈ 两倍 ⇒ 单连接受限,值得优化传输层。
|
||||
|
||||
## 四、产出物
|
||||
|
||||
阶段一结束时应该能明确写下:链路数量、每条链路的域名与云解析模式(橙/灰)、后端选型与版本、入站端口清单、需要新增的防火墙放行项。
|
||||
@@ -0,0 +1,78 @@
|
||||
# 02 服务端落地
|
||||
|
||||
后端只监听 `127.0.0.1`,公网流量一律由 nginx 转入。这一条决定了后面所有排查的方向。
|
||||
|
||||
## 路线一:3x-ui 面板(要管多用户时选它)
|
||||
|
||||
### 安装
|
||||
|
||||
1. 从 GitHub releases 取最新版本号:`curl -sI https://github.com/MHSanaei/3x-ui/releases/latest` 看 `location` 里的 tag。
|
||||
2. 下载发行包。包名固定为 `x-ui-linux-amd64.tar.gz`,**不带版本号**,写成 `x-ui-linux-amd64-v3.10.2.tar.gz` 会拿到 404。
|
||||
3. 解压后放进 `/usr/local/x-ui`,`chmod +x x-ui bin/xray-linux-amd64`。
|
||||
4. 包里没有 `x-ui.service`,只有 `x-ui.service.debian` / `.arch` / `.rhel`,按系统选一个复制成 `/etc/systemd/system/x-ui.service`。
|
||||
5. `systemctl daemon-reload && systemctl enable x-ui && systemctl restart x-ui`。
|
||||
|
||||
### 设端口与账号
|
||||
|
||||
```
|
||||
x-ui setting -port 2053 -username admin -password '<强密码>'
|
||||
x-ui setting -show # 复核
|
||||
```
|
||||
|
||||
### 两个必然遇到的冲突
|
||||
|
||||
- 3x-ui 除面板端口外还会起一个**订阅服务,默认占 2096**。2096 被占用时服务直接以 `status=1` 退出,日志里是 `listen tcp :2096: bind: address already in use`。装之前先确认 2096 空闲,或停掉占用它的服务。
|
||||
- 面板端口默认 2053,与 nginx 的 80/443 不冲突,可以保留。
|
||||
|
||||
### 建入站
|
||||
|
||||
面板里建,或用 `scripts/3xui-api.py`。参数与 vhost 一一对应:
|
||||
|
||||
- 协议 `vless`,监听 `127.0.0.1`,端口与 nginx 的 `proxy_pass` 目标一致。
|
||||
- 传输 `ws`,路径与 nginx 的 `location` 一致。
|
||||
- 安全 `none`,TLS 由 nginx 终止,入站再开 TLS 会导致双重加密、客户端连不上。
|
||||
- 每个入站至少要有一个客户端,客户端带 `id`(UUID)与 `email`(标识,流量统计以它为键)。
|
||||
|
||||
### 换后端时不停机的切法
|
||||
|
||||
新入站用新端口起,nginx 的 `proxy_pass` 改指新端口并 reload,验证两条链路都拿到 `101` 之后再停旧服务。旧服务的配置文件保留,回滚只需改回端口并启动。
|
||||
|
||||
## 路线二:纯 sing-box(单人用,不要面板)
|
||||
|
||||
```
|
||||
curl -fsSL -o sb.tar.gz https://github.com/SagerNet/sing-box/releases/download/v<版本>/sing-box-<版本>-linux-amd64.tar.gz
|
||||
tar -xzf sb.tar.gz && install -m 755 sing-box-<版本>-linux-amd64/sing-box /usr/local/bin/sing-box
|
||||
```
|
||||
|
||||
配置写在 `/etc/sing-box/config.json`,权限 600。每个入站是一段 `inbounds`,`users` 数组里一人一条:
|
||||
|
||||
```
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "vless-<名>",
|
||||
"listen": "127.0.0.1",
|
||||
"listen_port": <端口>,
|
||||
"users": [ { "name": "<标识>", "uuid": "<uuid>", "flow": "" } ],
|
||||
"transport": {
|
||||
"type": "ws",
|
||||
"path": "/<路径>",
|
||||
"max_early_data": 2048,
|
||||
"early_data_header_name": "Sec-WebSocket-Protocol"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
改完先 `sing-box check -c /etc/sing-box/config.json`,再 `systemctl restart sing-box`。
|
||||
|
||||
生成凭据:`sing-box generate uuid`。加用户时把 `name` 一并写上,将来做统计时它是键。
|
||||
|
||||
### sing-box 的能力边界
|
||||
|
||||
- 支持多用户:`users` 是必填数组,字段为 `name` / `uuid` / `flow`。
|
||||
- 不支持每用户配额、到期、带宽限速,入站配置层面没有这些字段。
|
||||
- 按用户统计流量要靠 `experimental.v2ray_api` 的 `stats.users`,但官方发行包默认**没有**编入 `with_v2ray_api`,需要自行带 build tag 重编。装完用 `sing-box version` 看 Tags 列表确认。
|
||||
|
||||
## 收尾
|
||||
|
||||
- 后端服务 `systemctl enable`,保证重启自动恢复。
|
||||
- 记录每个入站的 tag、端口、路径、UUID 到部署文档,后续排查全靠这份对照。
|
||||
@@ -0,0 +1,112 @@
|
||||
# 03 nginx 与证书
|
||||
|
||||
## 顺序:80 → 签证书 → 443
|
||||
|
||||
三个阶段不能颠倒。没证书就写 `listen 443 ssl`,`nginx -t` 直接报错;域名没有 vhost 就签证书,challenge 会打到默认 server 而 404。
|
||||
|
||||
### 阶段一:只开 80
|
||||
|
||||
```
|
||||
server
|
||||
{
|
||||
listen 80;
|
||||
server_name <域名>;
|
||||
root /www/wwwroot/<域名>;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
default_type text/plain;
|
||||
root /www/wwwroot/<域名>;
|
||||
}
|
||||
# 有代理路径时,这里先写好 location,签证书不受影响
|
||||
location /<路径> {
|
||||
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:<端口>;
|
||||
}
|
||||
location / { return 301 https://$host$request_uri; }
|
||||
}
|
||||
```
|
||||
|
||||
写完 `nginx -t` 再 `nginx -s reload`,然后用 `curl -H "Host: <域名>" http://<源站IP>/` 确认返回 `301`。
|
||||
|
||||
### 阶段二:签证书
|
||||
|
||||
```
|
||||
/root/.acme.sh/acme.sh --set-default-ca --server letsencrypt
|
||||
/root/.acme.sh/acme.sh --issue -d <域名> -w /www/wwwroot/<域名> --keylength ec-256
|
||||
```
|
||||
|
||||
`acme.sh` 安装时会把续期任务写进 root 的 crontab(每天多次检查),续期后靠 `--reloadcmd` 自动 reload nginx,所以 `--install-cert` 时把 reloadcmd 一起带上:
|
||||
|
||||
```
|
||||
/root/.acme.sh/acme.sh --install-cert -d <域名> --ecc \
|
||||
--key-file /www/server/panel/vhost/cert/<域名>/privkey.pem \
|
||||
--fullchain-file /www/server/panel/vhost/cert/<域名>/fullchain.pem \
|
||||
--reloadcmd "/www/server/nginx/sbin/nginx -s reload"
|
||||
```
|
||||
|
||||
域名走橙云时 ACME 的 HTTP-01 请求会经 CF 回源,只要 80 上有正确的 vhost 且没有强制 HTTPS 重定向,验证能通过。
|
||||
|
||||
### 阶段三:加 443
|
||||
|
||||
在同一个 server 块里补 `listen 443 ssl http2` 与证书路径。TLS 参数用纯 AEAD 套件:
|
||||
|
||||
```
|
||||
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<名>:10m;
|
||||
ssl_session_timeout 10m;
|
||||
```
|
||||
|
||||
省略 `ssl_ciphers` 或沿用宝塔模板的 `EECDH+AES128` 写法会把 CBC 套件一起放进白名单,TLS1.2 下可被降级到 CBC。改完用 `openssl s_client -tls1_2 -cipher 'ECDHE-ECDSA-AES128-SHA'` 验证,返回 `Cipher: 0000` 表示已拒绝。
|
||||
|
||||
## 隐藏代理路径的指纹
|
||||
|
||||
不带 `Upgrade` 头访问真实代理路径时,后端会返回 `400`,而错误路径返回 `404` —— 这个差异直接暴露了入口。在 location 首行加一行守卫即可让所有非 WebSocket 请求统一 404:
|
||||
|
||||
```
|
||||
if ($http_upgrade = "") { return 404; }
|
||||
```
|
||||
|
||||
## 加一层 basic auth
|
||||
|
||||
面板这类管理界面值得在外面再压一层口令,密码文件用 `openssl passwd -apr1` 生成:
|
||||
|
||||
```
|
||||
printf 'user:%s\n' "$(openssl passwd -apr1 '<口令>')" > /www/wwwroot/<域名>/.pass
|
||||
chown www:www /www/wwwroot/<域名>/.pass
|
||||
chmod 640 /www/wwwroot/<域名>/.pass
|
||||
```
|
||||
|
||||
在 location 里引用:
|
||||
|
||||
```
|
||||
auth_basic "restricted";
|
||||
auth_basic_user_file /www/wwwroot/<域名>/.pass;
|
||||
```
|
||||
|
||||
密码文件的存放位置决定成败:`/www/server/panel/vhost` 及其子目录是 `drw------- root root`,nginx worker 以 `www` 身份穿不进目录,会报 `open() ... failed (13: Permission denied)` 并对外返回 **500**(不是 401)。放 webroot 下并 chown 给 `www` 即可;由于该 location 全部反代到后端,文件不会被当静态资源暴露。
|
||||
|
||||
## 全量验证
|
||||
|
||||
```
|
||||
# 经域名的 TLS 与页面
|
||||
curl -s -o /dev/null -w "%{http_code}\n" https://<域名>/
|
||||
|
||||
# 直连源站验证 SNI 与 vhost
|
||||
curl -s -o /dev/null -w "%{http_code}\n" --resolve <域名>:443:<源站IP> https://<域名>/
|
||||
|
||||
# WS 转发(应看到 101;经 CDN 时先有 200 Connection Established)
|
||||
curl -s -i --http1.1 -H "Upgrade: websocket" -H "Connection: Upgrade" \
|
||||
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
|
||||
https://<域名>/<路径> | head -5
|
||||
```
|
||||
|
||||
带 basic auth 的站点要加 `-u user:pass` **并带浏览器 UA**(`-A "Mozilla/5.0 ... Chrome/131"`),否则 aaPanel 这类面板会一律返回 404,造成误判。
|
||||
@@ -0,0 +1,79 @@
|
||||
# 04 面板与多用户
|
||||
|
||||
## 3x-ui 的层级模型
|
||||
|
||||
三层,理解这三层就不会找错按钮:
|
||||
|
||||
1. **入站(inbound)** = 一个监听端口 + 一套传输配置,对应一条公网链路。两条链路就是两个入站。
|
||||
2. **客户端(client)** = 一个用户,挂在入站下面,带 UUID、email 标识、可选流量上限与到期时间。
|
||||
3. **统计(clientStats)** = 以客户端的 email 为键的流量记录。
|
||||
|
||||
由此推出两条结论:
|
||||
|
||||
- 加用户是在已有入站里加客户端,不是新建入站。一个入站能挂几十个客户端,共用同一端口与路径。
|
||||
- 一个人要两条链路,就要在两个入站里各加一次客户端,email 用同一个名字便于对应;UUID 可同可不同。
|
||||
|
||||
## 面板里加用户的点击路径
|
||||
|
||||
1. 左侧菜单进入入站列表,能看到各个入站的一行。
|
||||
2. 点该行的编辑按钮(铅笔图标)进入入站详情。
|
||||
3. 详情页的「客户端」区域右上角是「添加客户端」。
|
||||
4. 表单必填三项:email(标识,写姓名拼音)、UUID(点旁边的按钮自动生成)、可选的流量上限与到期时间。
|
||||
5. 保存后回到列表,客户端行上会出现二维码与复制链接按钮,把链接发给对方即可。
|
||||
|
||||
## 三条不要动的配置
|
||||
|
||||
入站的监听端口、WS 路径、TLS 设置 —— nginx 的 `proxy_pass` 与 `location` 是照着它们配的,改任何一项都会立刻断链路。要改必须同步改 nginx。
|
||||
|
||||
## 脚本化操作:CSRF 是唯一门槛
|
||||
|
||||
面板的登录接口不是裸 POST,缺 CSRF token 会返回 **403**,并且这个 token 不在 cookie 里,而在登录页 HTML 的 meta 标签里。
|
||||
|
||||
完整流程:
|
||||
|
||||
1. `GET /` 拿登录页,同时保存 cookie。
|
||||
2. 从登录页 HTML 抽 token:匹配 `name="csrf-token"` 所在 meta 的 `content` 值。
|
||||
3. `POST /login`,带 cookie、`Origin`、`Referer`、`X-CSRF-Token`、表单体 `username=...&password=...`。
|
||||
4. 成功返回 `{"success":true,"msg":"You have successfully logged into your account."}`。
|
||||
5. 之后所有 `/panel/api/*` 请求都带 cookie 与 `X-CSRF-Token`。
|
||||
|
||||
建入站的接口:`POST /panel/api/inbounds/add`,`Content-Type: application/json`。注意 `settings` / `streamSettings` / `sniffing` 三个字段要传 **JSON 字符串**,不是对象:
|
||||
|
||||
```
|
||||
{
|
||||
"remark": "<名>", "enable": true, "expiryTime": 0, "total": 0,
|
||||
"listen": "127.0.0.1", "port": <端口>, "protocol": "vless",
|
||||
"settings": "{\"clients\":[{\"id\":\"<uuid>\",\"flow\":\"\",\"email\":\"<标识>\"}],\"decryption\":\"none\",\"fallbacks\":[]}",
|
||||
"streamSettings": "{\"network\":\"ws\",\"security\":\"none\",\"wsSettings\":{\"path\":\"/<路径>\"}}",
|
||||
"sniffing": "{\"enabled\":true,\"destOverride\":[\"http\",\"tls\"]}"
|
||||
}
|
||||
```
|
||||
|
||||
读列表时 `settings` / `streamSettings` 有时返回 dict 有时返回字符串,解析要两种都兼容。
|
||||
|
||||
直接使用 `scripts/3xui-api.py` 即可,脚本已封装登录、建入站、列客户端三个动作。
|
||||
|
||||
## 流量统计的前提
|
||||
|
||||
客户端的 `clientStats[].enable` 必须为 true 才会记账。建入站时不带 `enable` 字段可能落成 false,建完复查一次;为 false 时通过 `POST /panel/api/inbounds/update/<id>` 补上 `clients[].enable: true`。
|
||||
|
||||
## 客户端链接
|
||||
|
||||
VLESS + WS + TLS 的分享链接形态:
|
||||
|
||||
```
|
||||
vless://<uuid>@<域名>:443?encryption=none&security=tls&sni=<域名>&alpn=http%2F1.1&type=ws&host=<域名>&path=%2F<路径>&fp=chrome#<名字>-<链路>
|
||||
```
|
||||
|
||||
- `alpn=http/1.1` 必须写,WS 走 HTTP/1.1。
|
||||
- 域名在橙云后面时,客户端的 `fp`(uTLS 指纹)实际不起作用,TLS 在 CDN 终止。
|
||||
- 路径里的 `/` 要 URL 编码成 `%2F`。
|
||||
|
||||
## 面板自身的暴露方式
|
||||
|
||||
两种,按是否需要外部访问选:
|
||||
|
||||
1. SSH 隧道(`ssh -N -L <本地端口>:127.0.0.1:<面板端口> <主机>`),面板不对外,最安全,但隧道会因网络空闲被重置。
|
||||
2. 独立子域名 + nginx 反代 + basic auth,长期可用。子域名建议用灰云直连,避免面板密码经过 CDN。
|
||||
|
||||
用子域名时,先在 Cloudflare 加一条显式 A 记录(泛解析通常不通),再按 `references/03-nginx与证书.md` 建 vhost,`proxy_read_timeout` 给到 3600s,否则面板的实时状态会因长连接超时断开。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 05 避坑清单
|
||||
|
||||
每条写在「要怎么做」,括号里是踩过的坑作为依据。
|
||||
|
||||
## 一、取证类
|
||||
|
||||
1. 判断域名是否真的回源,看 HTTP 状态码与 `Server` 头,把「CF 自有 404」与「回源 520」分开。(依据:泛解析记录存在但背后无源站,域名能解析却完全不通,直接在上面签证书或配 vhost 会白跑;补显式 A 记录后状态码立刻从 404 变 520。)
|
||||
|
||||
2. 验证带 basic auth 的站点时带浏览器 User-Agent。(依据:aaPanel 及同类面板对非浏览器 UA 一律返回 404,用 curl 默认 UA 测出 404 后误判「面板挂了」,连带杀掉面板进程、改端口、重启整机,全是无效动作。换 UA 后同一 URL 返回 200 与完整登录页。)
|
||||
|
||||
3. 判断服务是否健康,把 `systemctl is-active`、`ss -tlnp` 的持有进程、以及实际 HTTP 响应三者对齐。(依据:master 被 kill 后 worker 变成 `ppid=1` 的孤儿继续持有 80/443,此时 systemd 显示 failed 而站点仍返回 200。)
|
||||
|
||||
4. 测服务器总带宽时用两条并发对比单条。(依据:在服务器本机跑代理客户端测速,同一份 payload 会在网卡上走两遍,算出来的速率天然是一半,据此得出「CDN 砍掉一半吞吐」的错误结论。)
|
||||
|
||||
## 二、nginx 与证书类
|
||||
|
||||
5. 证书流程按 80 → 签证书 → 443 的顺序推进。(依据:没证书就写 `listen 443 ssl`,`nginx -t` 直接报错;域名没 vhost 就签证书,challenge 打到默认 server 得到 404。)
|
||||
|
||||
6. `auth_basic_user_file` 指向 webroot 下并 `chown www:www`。(依据:放 `/www/server/panel/vhost/nginx/` 下时该目录是 `drw------- root root`,nginx 穿不进去,对外返回 500 而不是 401,报错在 `open() ... Permission denied`。)
|
||||
|
||||
7. `ssl_ciphers` 显式写纯 AEAD 列表。(依据:沿用宝塔模板的 `EECDH+AES128` 写法会把 CBC 套件一起放进白名单,TLS1.2 下可被强制协商到 `ECDHE-ECDSA-AES128-SHA`。)
|
||||
|
||||
8. 代理路径的 location 首行加 `if ($http_upgrade = "") { return 404; }`。(依据:不带 Upgrade 头访问真实路径返回 400、错误路径返回 404,这个差异等于向扫描器指明入口。)
|
||||
|
||||
9. 按进程名批量处理之前,先 `ps -eo pid,ppid,cmd | grep` 把清单打出来看一遍。(依据:用 `awk '$2==1 && /nginx/'` 清孤儿时,把宝塔自己的 `webserver` 进程一起杀了 —— 它的进程标题里含 nginx、ppid 也是 1。)
|
||||
|
||||
## 三、服务端与面板类
|
||||
|
||||
10. 装 3x-ui 前先确认 2096 空闲。(依据:除面板端口外它还会起一个订阅服务默认占 2096,被占用时服务以 `status=1` 直接退出,日志是 `listen tcp :2096: bind: address already in use`。)
|
||||
|
||||
11. 3x-ui 的发行包名为 `x-ui-linux-amd64.tar.gz`,服务文件用 `x-ui.service.debian`。(依据:包名带版本号会 404;包里没有 `x-ui.service`,照原样复制会导致 unit 建不起来。)
|
||||
|
||||
12. 脚本化登录 3x-ui 时从登录页 meta 里取 CSRF token 并放进 `X-CSRF-Token` 头。(依据:缺这个头时 `POST /login` 返回 403,且 token 不在 cookie 里;补上后返回 `{"success":true}`。)
|
||||
|
||||
13. 建完入站复查 `clientStats[].enable` 是否为 true。(依据:为 false 时面板上看不到任何流量,而「看谁用了多少」正是用面板的主要理由。)
|
||||
|
||||
14. 换后端走旁路:新入站用新端口起,改 nginx 指向并验证 `101` 之后再停旧服务,旧配置保留。(依据:原地改会直接断链路,且失去回滚路径。)
|
||||
|
||||
## 四、系统维护类
|
||||
|
||||
15. 判断 Ubuntu Pro 是否已挂载,看 `pro status` 第一行的列名是 `AVAILABLE` 还是 `ENTITLED`。(依据:`AVAILABLE` 是未挂载格式,此时机器一条安全补丁都收不到,而 `apt upgrade` 只显示几个无关包,容易被误读成「已经是最新」。)
|
||||
|
||||
16. 挂载配对码要等用户登录后再生成,并用 `script -qefc` 抓输出。(依据:无 TTY 时 `pro attach` 不打印任何内容,日志是空的;配对码约 12 分钟过期,先发码后等登录必然作废。重跑前要先 `pgrep -f "pro attach"` 清残留并删 `/var/lib/ubuntu-advantage/lock`,否则报 `Operation in progress`。)
|
||||
|
||||
17. 判断 Livepatch 是否真的在起作用,看 `canonical-livepatch status --verbose` 里的 `patchState`。(依据:`pro status` 显示 enabled 时内核可能已超出覆盖窗口,状态是 warning,一个补丁都没打;`nothing-to-apply` 才表示新内核自带全部补丁。)
|
||||
|
||||
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。)
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""3x-ui 命令行封装:登录(含 CSRF token 处理)、建入站、列入站与客户端。
|
||||
|
||||
3x-ui 的登录接口缺 X-CSRF-Token 会返回 403,而 token 不在 cookie 里,
|
||||
而在登录页 HTML 的 <meta name="csrf-token" content="..."> 中。本脚本自动处理。
|
||||
|
||||
用法:
|
||||
python3 3xui-api.py --base http://127.0.0.1:2053 --user admin --password 'pw' list
|
||||
python3 3xui-api.py --base http://127.0.0.1:2053 --user admin --password 'pw' \
|
||||
add --remark <入站名> --port <端口> --path /<路径> --uuid <UUID>
|
||||
python3 3xui-api.py --base ... --user ... --password ... ping
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import http.cookiejar
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
def opener_with_cookies():
|
||||
jar = http.cookiejar.CookieJar()
|
||||
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
|
||||
|
||||
def http_get(op, url):
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with op.open(req, timeout=20) as r:
|
||||
return r.read().decode("utf-8", "replace")
|
||||
|
||||
|
||||
def http_post(op, url, form=None, js=None, headers=None):
|
||||
h = dict(headers or {})
|
||||
if js is not None:
|
||||
body = json.dumps(js).encode("utf-8")
|
||||
h["Content-Type"] = "application/json"
|
||||
else:
|
||||
body = urllib.parse.urlencode(form or {}).encode("utf-8")
|
||||
h["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
req = urllib.request.Request(url, data=body, headers=h, method="POST")
|
||||
with op.open(req, timeout=20) as r:
|
||||
return r.read().decode("utf-8", "replace")
|
||||
|
||||
|
||||
def extract_csrf(html):
|
||||
for pat in (
|
||||
r'name="csrf-token"[^>]*content="([^"]+)"',
|
||||
r'content="([^"]+)"[^>]*name="csrf-token"',
|
||||
r'csrf-token"\s+content="([^"]+)"',
|
||||
):
|
||||
m = re.search(pat, html)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def login(base, user, password):
|
||||
op = opener_with_cookies()
|
||||
html = http_get(op, base + "/")
|
||||
token = extract_csrf(html)
|
||||
if not token:
|
||||
sys.exit("登录页未找到 csrf-token,检查面板路径与版本")
|
||||
raw = http_post(
|
||||
op,
|
||||
base + "/login",
|
||||
form={"username": user, "password": password},
|
||||
headers={"Origin": base, "Referer": base + "/", "X-CSRF-Token": token},
|
||||
)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
sys.exit("登录返回非 JSON:%s" % raw[:200])
|
||||
if not data.get("success"):
|
||||
sys.exit("登录失败:%s" % data.get("msg"))
|
||||
return op, token
|
||||
|
||||
|
||||
def api_headers(token):
|
||||
return {"X-CSRF-Token": token, "Origin": "", "Referer": ""}
|
||||
|
||||
|
||||
def build_inbound(remark, port, path, uuid, listen="127.0.0.1", email=None):
|
||||
email = email or remark
|
||||
return {
|
||||
"up": 0,
|
||||
"down": 0,
|
||||
"total": 0,
|
||||
"remark": remark,
|
||||
"enable": True,
|
||||
"expiryTime": 0,
|
||||
"listen": listen,
|
||||
"port": int(port),
|
||||
"protocol": "vless",
|
||||
"settings": json.dumps(
|
||||
{
|
||||
"clients": [
|
||||
{"id": uuid, "flow": "", "email": email, "enable": True}
|
||||
],
|
||||
"decryption": "none",
|
||||
"fallbacks": [],
|
||||
}
|
||||
),
|
||||
"streamSettings": json.dumps(
|
||||
{"network": "ws", "security": "none", "wsSettings": {"path": path}}
|
||||
),
|
||||
"sniffing": json.dumps({"enabled": True, "destOverride": ["http", "tls"]}),
|
||||
"tag": remark,
|
||||
}
|
||||
|
||||
|
||||
def as_obj(v):
|
||||
if isinstance(v, (dict, list)):
|
||||
return v
|
||||
try:
|
||||
return json.loads(v or "{}")
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def cmd_ping(op, token, base):
|
||||
raw = http_get(op, base + "/panel/api/inbounds/list")
|
||||
data = json.loads(raw)
|
||||
print("登录有效,入站数 = %d" % len(data.get("obj") or []))
|
||||
|
||||
|
||||
def cmd_list(op, token, base):
|
||||
raw = http_get(op, base + "/panel/api/inbounds/list")
|
||||
data = json.loads(raw)
|
||||
items = data.get("obj") or []
|
||||
if not items:
|
||||
print("当前没有入站")
|
||||
return
|
||||
for ib in items:
|
||||
st = as_obj(ib.get("streamSettings"))
|
||||
se = as_obj(ib.get("settings"))
|
||||
path = (st.get("wsSettings") or {}).get("path")
|
||||
print(
|
||||
"入站 id=%s 备注=%s 协议=%s 监听=%s:%s WS=%s"
|
||||
% (ib.get("id"), ib.get("remark"), ib.get("protocol"),
|
||||
ib.get("listen"), ib.get("port"), path)
|
||||
)
|
||||
for c in se.get("clients") or []:
|
||||
print(" 客户端 标识=%s UUID=%s" % (c.get("email"), c.get("id")))
|
||||
for cs in ib.get("clientStats") or []:
|
||||
print(
|
||||
" 统计 标识=%s 上行=%s 下行=%s 限额=%s 启用=%s"
|
||||
% (cs.get("email"), cs.get("up"), cs.get("down"),
|
||||
cs.get("total"), cs.get("enable"))
|
||||
)
|
||||
|
||||
|
||||
def cmd_add(op, token, base, args):
|
||||
payload = build_inbound(
|
||||
args.remark, args.port, args.path, args.uuid, args.listen, args.email
|
||||
)
|
||||
raw = http_post(
|
||||
op,
|
||||
base + "/panel/api/inbounds/add",
|
||||
js=payload,
|
||||
headers={"X-CSRF-Token": token, "Origin": base,
|
||||
"Referer": base + "/panel/inbounds"},
|
||||
)
|
||||
data = json.loads(raw)
|
||||
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))
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="3x-ui 命令行封装")
|
||||
p.add_argument("--base", default="http://127.0.0.1:2053")
|
||||
p.add_argument("--user", default="admin")
|
||||
p.add_argument("--password", required=True)
|
||||
sub = p.add_subparsers(dest="cmd")
|
||||
|
||||
sub.add_parser("ping", help="验证登录是否可用")
|
||||
sub.add_parser("list", help="列出入站与客户端")
|
||||
|
||||
a = sub.add_parser("add", help="创建 VLESS+WS 入站")
|
||||
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("--listen", default="127.0.0.1")
|
||||
a.add_argument("--email", default=None)
|
||||
|
||||
args = p.parse_args()
|
||||
if not args.cmd:
|
||||
p.error("缺少子命令:ping / list / add")
|
||||
op, token = login(args.base, args.user, args.password)
|
||||
if args.cmd == "ping":
|
||||
cmd_ping(op, token, args.base)
|
||||
elif args.cmd == "list":
|
||||
cmd_list(op, token, args.base)
|
||||
elif args.cmd == "add":
|
||||
cmd_add(op, token, args.base, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# 双链路代理验收:链路 101、站点 200、面板 200(带浏览器 UA)
|
||||
#
|
||||
# 用法:
|
||||
# ./verify-links.sh \
|
||||
# --link <域名A>:/<路径> \
|
||||
# --link <域名B>:/<路径> \
|
||||
# --site <站点域名> \
|
||||
# --panel https://<面板域名>/ --panel-auth user:secret
|
||||
#
|
||||
# 退出码:全部通过为 0,任一失败为 1
|
||||
|
||||
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
|
||||
|
||||
check_link() {
|
||||
local spec="$1"
|
||||
local host="${spec%%:*}"
|
||||
local path="${spec#*:}"
|
||||
local out
|
||||
out=$(curl -s -i --http1.1 \
|
||||
-H "Upgrade: websocket" -H "Connection: Upgrade" \
|
||||
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: $WS_KEY" \
|
||||
--max-time 20 "https://${host}${path}" 2>/dev/null | grep -oE "HTTP/1.1 (101|4[0-9][0-9]|5[0-9][0-9])" | head -1)
|
||||
if echo "$out" | grep -q "101"; then
|
||||
echo " PASS link ${host}${path} ${out}"
|
||||
else
|
||||
echo " FAIL link ${host}${path} 期望 101,实际 ${out:-无响应}"
|
||||
FAIL=1
|
||||
fi
|
||||
}
|
||||
|
||||
check_site() {
|
||||
local host="$1"
|
||||
local code
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 20 "https://${host}/" 2>/dev/null)
|
||||
if [ "$code" = "200" ]; then
|
||||
echo " PASS site ${host} ${code}"
|
||||
else
|
||||
echo " FAIL site ${host} 期望 200,实际 ${code:-无响应}"
|
||||
FAIL=1
|
||||
fi
|
||||
}
|
||||
|
||||
check_panel() {
|
||||
local url="$1"
|
||||
local auth="$2"
|
||||
local code
|
||||
if [ -n "$auth" ]; then
|
||||
code=$(curl -s -L -o /dev/null -w "%{http_code}" -u "$auth" -A "$UA" --max-time 25 "$url" 2>/dev/null)
|
||||
else
|
||||
code=$(curl -s -L -o /dev/null -w "%{http_code}" -A "$UA" --max-time 25 "$url" 2>/dev/null)
|
||||
fi
|
||||
if [ "$code" = "200" ]; then
|
||||
echo " PASS panel ${url} ${code}"
|
||||
else
|
||||
echo " FAIL panel ${url} 期望 200,实际 ${code:-无响应}"
|
||||
echo " 提示:aaPanel 及同类面板对非浏览器 UA 返回 404,本脚本已带浏览器 UA"
|
||||
FAIL=1
|
||||
fi
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--link) check_link "$2"; shift 2 ;;
|
||||
--site) check_site "$2"; shift 2 ;;
|
||||
--panel) PANEL_URL="$2"; shift 2 ;;
|
||||
--panel-auth) PANEL_AUTH="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
sed -n '2,12p' "$0"; exit 0 ;;
|
||||
*) echo "未知参数:$1"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -n "${PANEL_URL:-}" ]; then
|
||||
check_panel "$PANEL_URL" "${PANEL_AUTH:-}"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo "全部通过"
|
||||
else
|
||||
echo "存在失败项,按上面的 FAIL 行定位"
|
||||
fi
|
||||
exit "$FAIL"
|
||||
Reference in new issue
Block a user