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:
admin committed 2026-10-18 15:05:52 +08:00
1 parent 310a964a94
commit a9d2adaf72
9 files changed
+759

No files matched your search

@@ -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。)