diff --git a/.gitignore b/.gitignore index 97f5e03..ea10052 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,10 @@ # ⚠️ 它目录内的 `env/`、`logs/` 仍被下面那几条本机配置规则挡着 —— 那部分照旧各机器自建。 !/video-capture/ +# 🔴 2026-10-18 加(用户当轮令「脱敏后进仓」):双链路代理的搭建与运维技能。 +# 正文已脱敏(域名/IP/UUID/口令一律用占位符),本机的真实参数留在工作区的部署文档里。 +!/dual-link-proxy/ + # 🔴 不入库:运行期产物与备份 # 判据(记忆铁律):技能包=**可复制的资产**;⛔ 运行日志/缓存/备份/本机部署副本一律不入库。 diff --git a/dual-link-proxy/SKILL.md b/dual-link-proxy/SKILL.md new file mode 100644 index 0000000..110efcb --- /dev/null +++ b/dual-link-proxy/SKILL.md @@ -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 登录与建入站的命令行封装。 diff --git a/dual-link-proxy/references/01-前置取证与选型.md b/dual-link-proxy/references/01-前置取证与选型.md new file mode 100644 index 0000000..b96fa7b --- /dev/null +++ b/dual-link-proxy/references/01-前置取证与选型.md @@ -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 抢同一个订阅端口。 + +### 一条容易忽略的容量事实 + +先测服务器总带宽再谈优化。在本机用两条并发下载对比单条即可判断是「总带宽封顶」还是「单连接受限」: + +- 两条并发合计 ≈ 单条 ⇒ 总带宽是天花板,换协议或换链路都不会更快。 +- 两条并发合计 ≈ 两倍 ⇒ 单连接受限,值得优化传输层。 + +## 四、产出物 + +阶段一结束时应该能明确写下:链路数量、每条链路的域名与云解析模式(橙/灰)、后端选型与版本、入站端口清单、需要新增的防火墙放行项。 diff --git a/dual-link-proxy/references/02-服务端落地.md b/dual-link-proxy/references/02-服务端落地.md new file mode 100644 index 0000000..21c2d7a --- /dev/null +++ b/dual-link-proxy/references/02-服务端落地.md @@ -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": "", "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 到部署文档,后续排查全靠这份对照。 diff --git a/dual-link-proxy/references/03-nginx与证书.md b/dual-link-proxy/references/03-nginx与证书.md new file mode 100644 index 0000000..e40b2b9 --- /dev/null +++ b/dual-link-proxy/references/03-nginx与证书.md @@ -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,造成误判。 diff --git a/dual-link-proxy/references/04-面板与多用户.md b/dual-link-proxy/references/04-面板与多用户.md new file mode 100644 index 0000000..a76b076 --- /dev/null +++ b/dual-link-proxy/references/04-面板与多用户.md @@ -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\":\"\",\"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/` 补上 `clients[].enable: true`。 + +## 客户端链接 + +VLESS + WS + TLS 的分享链接形态: + +``` +vless://@<域名>: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,否则面板的实时状态会因长连接超时断开。 diff --git a/dual-link-proxy/references/05-避坑清单.md b/dual-link-proxy/references/05-避坑清单.md new file mode 100644 index 0000000..7f26719 --- /dev/null +++ b/dual-link-proxy/references/05-避坑清单.md @@ -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。) diff --git a/dual-link-proxy/scripts/3xui-api.py b/dual-link-proxy/scripts/3xui-api.py new file mode 100644 index 0000000..dde2ffc --- /dev/null +++ b/dual-link-proxy/scripts/3xui-api.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""3x-ui 命令行封装:登录(含 CSRF token 处理)、建入站、列入站与客户端。 + +3x-ui 的登录接口缺 X-CSRF-Token 会返回 403,而 token 不在 cookie 里, +而在登录页 HTML 的 中。本脚本自动处理。 + +用法: + 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 + 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() diff --git a/dual-link-proxy/scripts/verify-links.sh b/dual-link-proxy/scripts/verify-links.sh new file mode 100644 index 0000000..f8c568c --- /dev/null +++ b/dual-link-proxy/scripts/verify-links.sh @@ -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"