Files
dsh_shenxian/dsh-server-docs/04-调整方案/113-覆盖网络-传输方案取舍-开放端口与自研relay.md
T
admin 04776af4b1 docs: 工作区根项目文档批量入仓(04-调整方案 113–128 / 交接单 T09–T21 / ops / archive)
起因:用户 2026-09-17 明确「所有文档都要同步,都放在开发仓库 docs 对应文件夹下」。
判据:工作区根 *.md 中在仓库(git ls-files --quotepath=false)搜不到的那些。

入仓 33 份(一律复制,工作区根原件保留不动,避免引用断链):
- 04-调整方案/113–128(16 份 · 原子占号后落盘):覆盖网络 传输方案取舍 / 应用场景与待完善清单 /
  插件化vs改内核 / 问题逐条推演 / 参数表与观测口径;会合中继拆分取证与改造方案;
  集群化改造方案 Manager-Worker;跨节点迁移与节点自举;项目代码分层范式与迭代风险评估;
  搬运与共享重建方案 guest w47→w106;方案规划方法提炼;文档无效信息审计报告;
  会话接续机制复盘与修复;会话接续规范;dsh 客户端化部署方案;dsh 桌面客户端开发方案
- 交接单/archive/交接单-已完成/T09–T21(13 份 · 覆盖网络线已完成单归档)
- ops/(2 份运行态指针:接续入口 / 接续包 · 覆盖网络线)
- archive/(2 份临时与内部简报)

已排除(无需重复入仓):覆盖网络线 10 份方案正文已入档案 103–112(文件名不同)。

登记:INDEX.md §二 新增 04-113–128 共 16 行 + §四 追加 T09–T21 说明 + 机器摘要行刷新
(⛔ 未跑 docs-index-stats.py --write:该脚本会按 \r\n 归一化全文件行尾,故改为字节级单行替换);
README.md 追加 1 条入仓指针;docs-manifest.json 复跑 scripts/docs-manifest.py 刷新。

验收:工作区根 47 份 .md —— 同名已入仓 8 / 本次内容一致 29 / 已知改名映射 10 / 未入仓 0;
git status 待提交清单只含本次新增与登记 3 件(未涉 src/ 与 relay 代码面)。
2026-09-17 18:24:19 +08:00

601 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 覆盖网络 · 传输方案取舍:开放端口 vs 自研 relay(2026-09-16)
> **缘起**(用户原话):「我在 106 腾讯云和服务器上开放端口是否可以解决这个问题,开放端口安全性能否得到保障,现在 47 和 106 的连接方案也是走的 ssh 是临时的方案」
---
## 0. 结论先行
- **开放端口能解决"谁连谁",但不是更优解** —— 它把今天「**worker 只拨出、不开入站**」的形态,换成「每台 worker 都要暴露一个公网入口」⇒ **暴露面从 O(1) 变 O(N)**。
- **SSH 隧道确实只是"第一个可替换实现",不是终态**。但替代它的正解是 **自研 relay + worker 出向单端口长连接**(**不需要开放任何入站端口**),而不是靠开放端口。
- 安全性的决定因素不是"开不开端口",而是 **"谁是服务端"**:**worker 永远只做出向连接**(今天已经是这个形状),攻击面最小。
---
## 1. 现在到底怎么连的(实测事实)
| 方向 | 事实 |
|---|---|
| 106 → 47 | `ssh -M -N -f -R <port>:127.0.0.1:<port> root@47:32022` —— **106 主动拨出**(只需出向) |
| 47 → 106 | 走 47 的 `127.0.0.1:<port>`(隧道落点),**47 从不主动连 106** |
| 谁开了入站端口 | **只有 47**:`0.0.0.0:32022`(sshd)、`80/443`(nginx)、`888`、`8765`、`58888`。**106 为平台开了 0 个入站口** |
⇒ **今天的形态已经是"最小暴露"**:**新增一台 worker 完全不需要碰云安全组**。这是"worker 拨出"这个方向带来的核心收益,容易被忽略。
---
## 2. 「在 106 开放端口」得到什么、失去什么
**得到**:47 可直连 106 的 agent(19000),少一跳本地转发,延迟略低;不必维护隧道。
**失去(这才是重点)**:
1. **每台 worker 一个公网入口** ⇒ N 台机器 N 套安全组/防火墙配置,接入成本与出错面**随台数线性上升**;跨云(阿里云 47 + 腾讯云 106)还要两端同时配。
2. **agent 的认证是单密钥共享**(`x-dshs-agent-token`)⇒ 该通道被突破/密钥泄露 = **能指挥那台 worker 起停任意用户实例**。今天这条通道**只能在 47 的 loopback 上被触达**(隧道落点),开放后变公网可达 —— 属**权限扩大**(R5 的反面)。
3. 47 侧要为每台 worker 维护"我怎么连它"的地址(= `dsh_hosts.endpoint` 今天在做的事),worker 换 IP / 换云就要改控制面数据。
---
## 3. 若真要开放端口,安全性能保障吗
能保障到**"可控"**,但有前提,且**每一项都是新增的运维负担**:
- 云安全组**按源 IP 白名单**(106 侧只放 47 的公网 IP)—— 前提是 47 有固定公网 IP(目前是);
- 该端口**只跑 agent API**;认证从"共享 token"升级为 **mTLS + 定期轮换**;
- **单端口复用**(不要每实例一个端口)、限速、审计日志、失败即封。
⚠️ 即便如此,**仍不如"worker 只拨出"**:白名单一旦写错、或 47 的 IP 变更,就退化成"一个公网可达的 agent 入口"。
⇒ **可保障,但代价是把安全从「架构保证」降级为「配置保证」。**
---
## 4. 摆脱 SSH 的正解(按代价排序)
| 方案 | 需新开入站端口? | 说明 |
|---|---|---|
| **① 自研 relay:worker 拨出 + 单端口 TLS 长连接 + 多路复用** | ❌ **不需要** | 复用今天"worker 主动拨出"的形状,只把 sshd 换成自研 relay 进程;relay 侧维护 `(hostId, 实例端口) → 连接/流` 映射表。**这就是原方案 S5 的位置**(S5 原只写"443/TCP 兜底",可与本项合并做——那个"单端口"serves both) |
| ② 中继独立成单元(P4) | ⚠️ 需在 47 新开 **32023** | 只换绑定关系、不换协议;**当前卡在 R5 待授权** |
| ③ WireGuard / TURN 打洞 | ❌(但依赖 UDP 出向) | 异构网络(企业/校园网常封 UDP)不可靠 ⇒ 仍须保留 443 兜底,复杂度高 |
| ④ 每台 worker 开公网端口直连 | ✅ 需要 | 最省事、**安全面最大**,不推荐 |
---
## 5. 与既有决定的关系(不冲突)
- 与「**覆盖网络按异构设计**(中继 45% / **必须补 443/TCP 兜底**)」一致:那个兜底通道**就是 ① 的单端口**,正好一次做掉。
- 与「**单机自用也要互联**」一致:**租户维度收窄 ≠ 网络维度收窄** ⇒ "少开端口"是约束,不是可选项。
---
## 6. 我选了什么(可推翻)
**维持「worker 只拨出、不开任何入站端口」**;把"摆脱 SSH"的正确落点定为 **① 自研 relay + 单端口出向长连接**(不需要新开任何公网口),而不是在 106 上开端口。
**连带影响**:**P4(47 新开 32023)优先级下调** —— 它只是"换绑定"的过渡步,安全性上**不优于今天**(甚至新增暴露面)。
**剩下的两选一(这是 P4 那个 R5 门禁的实质)**:
- 选「不开」⇒ 我把 **P4 与 ① 合并**成一条"自研 relay(worker 只拨出)"的路线来做,**全程不新增公网端口**;
- 选「开」⇒ 我按原 P4 执行(32023 + 独立单元 + 非 root 账号),作为过渡。
---
# 7. 成熟方案调研与选型(2026-09-16 17:2x 追加 · 回答"自研 relay 有没有成熟方案 / SSH 做这个事专家会不会认为不安全")
## 7.1 先纠正一个前提:**不需要自研**
"worker 主动拨出、被中央服务反向暴露"这个形状**有成熟件,且被大量生产环境使用**。按适配度排序:
| 方案 | 形态 | 成熟度(检索实测) | 与本项目的适配 |
|---|---|---|---|
| **frp**(fatedier/frp) | frps 公网 + frpc 拨出;**单连接多路复用**;TCP/UDP | ⭐ **最成熟**:**v0.70.0(2026-07-11)**、**~10.6 万 star**、v0.50 起 **TLS 默认开**、静态 token + OIDC、`allowPorts` 白名单、dashboard | ✅ **首选**。语义几乎一对一:一个 frpc = 一台 worker,`[[proxies]]` = 每个实例端口,取代今天的 `ssh -R` |
| **rathole**(rapiz1/rathole) | 同上,Rust | 活跃;**<500 KiB** 单文件、**Noise_NK** 每服务 token、**热重载**、TCP+UDP;**无 dashboard**,生态远小于 frp | ✅ **备选**(资源极紧 / 想要最小二进制时)。基准自称吞吐 2–5× frp ⚠️ **厂商自测,别当结论** |
| **Headscale + Tailscale DERP** / **Nebula** | 完整 mesh overlay(会合 + 中继 + ACL) | 成熟(Slack 的 Nebula 已在 `瓶颈落地方案` 里被引用过) | ⚠️ **层级不同**:它自带"会合 + 身份 + ACL",会与我们的 Manager/租约/`via` 语义重叠 ⇒ 引入成本高,属**远期** |
| **chisel** | HTTP/WebSocket 隧道 | 成熟,常用于内网穿透 | ⚠️ 优势只在"网络**只**放 80/443";我们用 frp over 443 也能达到,故不必 |
| **Cloudflare Tunnel** | cloudflared 拨出,CF 边缘接 | 非常成熟 | ❌ **数据面交给 CF**,与"自建覆盖网络"目标冲突(且跨境链路受其调度) |
| **WireGuard(hub 模式)** | 各点 `PersistentKeepalive` 拨出到 hub | 成熟 | ⚠️ 需 UDP 出向;异构网络(企业/校园网)常封 ⇒ 仍要 443 兜底,复杂度高 |
⚠️ **性能数字别照抄**:检索到的 "FRP 920 Mbps / SSH 650 Mbps" 之类来自二手博客,**未经我们实测** ⇒ 只作方向参考("专用 relay 优于 SSH 转发"这个**方向**可信,具体倍数不可信)。
## 7.2 再看 SSH:专家会认为不安全吗?——**一半对、一半是误解**
**是误解的部分**:SSH 反向隧道**是"无公网 IP / 无入站"场景的标准做法之一**,2026 年多份运维指南仍把它列为"Solution 1",前提是**按规矩加固**:
- ✅ 中继上用**专用非特权账号**(不要 root);
- ✅ `sshd_config` 里下 `PermitOpen` 只放必要端口;
- ✅ 对该通道**限速 + 审计**。
**说得对的部分**(这些才是真批评,且我们**今天全都中**):
1. **凭据模型**:现在隧道以 **`root@47`** 登录(已加 `restrict,port-forwarding`,拿不到 shell,这点已收窄);但仍是**通用 sshd 上的登录凭据**,审计粒度粗、与运维 SSH 混在一条 `authorized_keys` 里。
2. **运营耦合**:中继是**宝塔面板也在用的那个 sshd** ⇒ 面板改 SSH 配置 / 重启 sshd 会波及整张覆盖网络。
3. **静默失败**:我们**已经实测撞到一次**——`-R` 失败时 `tunnel.forward()` 的返回值无人检查 ⇒ 撞号后静默不转发、拨到别人实例。SSH 的 `-O forward` 语义天生不擅长这种"要精确知道成功没有"的编排。
4. **多中继/负载均衡**:SSH 没有原生"多中继选主/轮询/健康检查"概念;frp/rathole 有。
5. **审计观感**:*"SSH 一般当命令行用"* 这个看法在评审场合确实常见 —— 它不是技术缺陷,但**是真实存在的沟通/合规成本**。
⇒ **结论**:SSH 隧道**不是"不安全",而是"不该长期做数据面"**。它适合当**第一个可替换实现**(已扮演好这个角色),不适合当终态。
## 7.3 由此重新拍 P4:**不做 SSH 版中继,直接换成熟 relay**
**理由**:P4(独立 sshd 单元)想拿到的两样东西 —— ① 与宝塔 sshd 解耦 ② 甩掉 root 凭据 —— **在 frp/rathole 方案里同样拿到**,而且顺带解决"专家观感"和 S5 的 443 兜底。⇒ **P4 与"换 relay"两步合一步,不先做 P4。**
**并且:换 relay 也**不必**新增公网端口。**
frp/rathole **同样需要一个公网入口**(frps 默认 `bindPort 7000`)。但可以让它**复用 47 已有的 443**:
```
worker(frpc) ──TLS/SNI──▶ 47:443 (nginx stream + ssl_preread)
└─ 按 SNI 分流到 127.0.0.1:7000 (frps)
└─ 暴露各实例端口
```
⇒ **零新增公网监听口**(R5 不触发),**同时把 S5「443/TCP 兜底」做掉**(既有决定里本来就要补的那条)。这也是为什么"换 relay"比"新开 32023"更划算。
## 7.4 修正后的路线(我选了什么 · 可推翻)
1. **⛔ 不做 P4(SSH 版中继单元)**;**不新开任何公网端口**。
2. **relay 采用 frp(首选)/ rathole(备选)**,形态 = "worker 拨出、relay 复用 443"。
3. 工作时序(每步可独立验收、可独立回滚,延续 P1–P3 的交付方式):
- **R1** 47 上装 frps(仅监听 `127.0.0.1:7000`,不碰公网口)+ 证书/SNI;本机与 47 之间先跑通"一条隧道"。
- **R2** nginx `stream` + `ssl_preread` 把 443 按 SNI 分流给它 ⇒ 从此 **443 就是兜底通道**(S5 达成)。
- **R3** 106 的 worker 侧把 sshd 隧道换成 frpc(env 切换,双路径并存 ⇒ 零代码回滚)。
- **R4** 观察一轮后下线 sshd 隧道路径 + 收回 47 上那条 `authorized_keys`。
4. **回滚**:每步都靠"env 切回 SSH 路径"回退,ssh 路径**在 R4 之前不删**。
---
# 8. 选型复核:**撤销"首选 frp"**(2026-09-16 17:3x,因用户质疑"项目久 ≠ 最好")
> 用户原话:「frp 要好好判断只是项目时间比较久,性能和安全性还真不一定是最好」
> **先认账**:§7.1 我把 frp 排第一,依据是 star 数、发布频率、生态 —— **那是"省心度"排序,不是"安全性/性能"排序**。这是**选型轴用错了**,本节修正。
## 8.1 frp 的实际安全面(检索实测,非推测)
| 项 | 事实 | 对我们的意味 |
|---|---|---|
| **CVE-2026-40910 / GHSA-26gq-p25f-99cp** | **认证机制绕过 + 未授权远程 DoS**,**影响 frp ≥ 0.53.0** | 有在野/可利用描述,且影响**近几年的所有版本** ⇒ 「项目久」反而意味着**被盯得久、漏洞历史长** |
| **dashboard 默认 `admin:admin`、口令明文存配置** | 官方引导必须 `webServer.addr = "127.0.0.1"` | 绑回环是**必须**,不是可选 |
| **`proxyBindAddr` 默认跟 `bindAddr`** | 官方原文:这是"**大多数指南遗漏的配置**";不设则 frp 为代理开的监听器**绑到公网** | 🔴 **致命**:不设它 = 我们"零新增公网口"的目标当场破掉 |
| **服务端默认不强制 TLS** | 需 `transport.tls.force = true` 才拒绝明文控制连接(客户端 v0.50 起默认 TLS) | 又一项"不设就静默降级" |
| **`auth.token` 是单一静态共享令牌,frpc 明文存** | 官方原文:没有它 frps 会接受任何找到 7000 端口的客户端 | ⇒ **一台 worker 失陷 = 可申请任意端口**(`allowPorts` 又是 opt-in 默认关) |
| 7000 / 7500 端口被持续扫描 | 公网 frps 是明确标靶 | 需 nft/安全组 + 非默认端口 |
⇒ **结论:frp 的"默认姿态不安全"** —— 上面 5 项**每一项都必须手工关/设**,少设一项就可能**静默**破掉我们的安全目标。这正是"老项目"的另一面:**默认值停留在历史约定上,安全靠运维纪律补**。
(正面:官方文档把正确姿势写得很清楚 —— `proxyBindAddr=127.0.0.1` + nginx 前置 443 + 非特权用户 + systemd 加固 `NoNewPrivileges/ProtectSystem/ProtectHome`;且修得快,**v0.71.0 = 2026-08-14**,约一月一版。)
## 8.2 按**我们的**判据重排(这才是该用的轴)
**判据(按重要性)**:① 认证/授权模型(身份 > 每服务密钥 > 共享 token)② 默认拒绝还是默认放行 ③ 能否做到零新增入站 ④ 单 worker 失陷的爆炸半径 ⑤ 可观测性 ⑥ 生态与排障。
| 方案 | ① 认证模型 | ② 默认姿态 | ③ 零入站 | ④ 爆炸半径 | ⑤ 可观测 | ⑥ 生态 |
|---|---|---|---|---|---|---|
| **OpenZiti** | ✅ **证书身份 + 服务策略**(最强) | ✅ 默认拒绝 | ✅ | ✅ 最小(逐服务授权) | 中 | 中(CNCF) |
| **Nebula** | ✅ **CA 签发身份**(Slack 在生产用) | ✅ 默认拒绝 | ✅(lighthouse 拨出) | ✅ 小 | 中 | 中 |
| **rathole** | ✅ **Noise_NK 双向认证 + 每服务 token 必填** | 🟡 无 token 不通 | ✅ | 🟡 中 | ❌ 无 dashboard | ❌ 小(单维护者) |
| **frp** | 🔴 **单一静态 token**(明文存) | 🔴 **默认放行**(5 项需手设) | 🟡 需 `proxyBindAddr` 才成立 | 🔴 **大**(可申请任意端口) | ✅ dashboard+metrics | ✅ 最好 |
| **konnectivity**(k8s apiserver-network-proxy) | ✅ 客户端证书 | ✅ | ✅ | ✅ 小 | 中 | 🟡(K8s 专用) |
⇒ **在我们最在意的 ①②④ 三条上,frp 都是最差一档**;它只在 ⑤⑥ 领先。
## 8.3 性能:**这条轴我们根本不该用于选型**
- 覆盖网络的**第一瓶颈是 presence,不是带宽**(既有推演结论);我们的量级是"**每 worker 几十个 HTTP 会话**",不是 1 万并发、不是线速。
- ⇒ **拿 frp/rathole 的吞吐 benchmark 选型 = 优化错误的轴**(且那些数字多为厂商/二手自测)。
- 真要测,该测的是**链路本身**(本机 ↔ 47 / 106 的 **RTT / jitter / 带宽**)—— 那**正是接续包里"未完成项 3"**。任何 relay 的性能上限由链路决定,不由实现决定。
## 8.4 修正后的路线(取代 §7.4 的第 2、3 条)
1. **不锁定 frp**;**不在这轮引入任何第三方 relay**。
2. **先做 R0(判据 + 画像)**:
- R0-a 把上面 6 条判据写成**一张打分表**(放本文件,作为后续任何 relay 决策的判据);
- R0-b **测链路画像**(本机↔47↔106 的 RTT / jitter / 带宽)—— 它同时是"未完成项 3",**一次做掉两件事**。
3. **R1 起再选实现**:按 R0 的打分表定;**优先考虑身份型(OpenZiti / Nebula)**,frp 只在"要立刻省心、且 8.1 那 5 项配置能一项不落地全部做完"时才选。
4. **🔑 关键(也是可以不急的根本原因)**:**P1–P3 已经把接口抽出来了** —— `Reachability.via` + `Rendezvous` 注册表 ⇒ **换实现是 env 级切换、零代码回滚**。**可选性已经买好了,选型错了不致命**,所以**不必现在一次选对,更不该为了"选对"付一次引入成本**。
5. ⚠️ **一个必须承认的权衡**:把 relay 换成第三方,是**用一个我们不完全掌控的攻击面(frp 的 CVE + 默认值)去替换一个已收窄、且有系统级补丁渠道的面(sshd + `restrict,port-forwarding`)**。**这不是无条件升级** ⇒ 没有明确的痛点(比如真要上多中继)之前,**维持现状也是合理选项**。
## §9 · R0-b 链路画像实测(2026-09-16 18:4x)
> 目的:给 relay 选型提供**链路事实**(性能不作选型轴,但"能不能直连 / 要不要打洞"必须实测)。
> 全程**只读**:未装软件、未开端口、未改配置;测量脚本 `/tmp/r0b.sh`,一次跑完。
### 9.1 原始实测(命令原文 + 数字)
| 路径 | 样本 / 丢包 | RTT avg | jitter(stddev) | TCP 吞吐 |
|---|---|---|---|---|
| 本机 → 47 `47.77.182.89` | 14/20 · **30%** | 189.6 ms | 0.49 ms | **12.7 Mbit/s**(1.6 MiB/s) |
| 本机 → 106 `106.54.21.172` | 20/20 · 0% | 34.8 ms | 0.54 ms | 未取到(无 106 凭据) |
| 47 → 106 | 18/20 · **10%** | 149.8 ms(mdev 0.414) | 0.41 ms | **未取到**(ssh `Permission denied`,rc=255) |
```bash
ping -n 20 47.77.182.89 # Windows ping;RTT 由 '=NNms' 提取,stddev 自算
ping -n 20 106.54.21.172
dd if=/dev/zero bs=1M count=50 | ssh [email protected] 'cat >/dev/null' # 实测 50MiB / 31403 ms
ssh [email protected] 'ping -c 20 106.54.21.172' # 20 transmitted, 18 received, 10% loss
curl -s -m 6 -o /dev/null -w '%{http_code}|connect=%{time_connect}s' http://47.77.182.89:19100/
curl -s -m 6 -o /dev/null -w '%{http_code}|connect=%{time_connect}s' http://106.54.21.172:19000/
```
### 9.2 公网可达性 / 入站面(判"打洞"可行性)
- 本机 → `47:19100`:`curl_rc=28`(连接超时,`connect=0.000000s`)⇒ **不可直连**
- 本机 → `106:19000`:`curl_rc=28` ⇒ **不可直连**
- 47 实际监听(`ss -lntp`):`127.0.0.1:3080`、`127.0.0.1:15432`、`127.0.0.1:19000`、`127.0.0.1:19100`,仅 `0.0.0.0:22`
- 106 监听面:**未取到**(本机 ssh 到 106 报 `Permission denied (publickey,gssapi-keyex,gssapi-with-mic)`)
### 9.3 三条结论
1. **零入站 = 实测成立**:47 侧 agent / 控制面端口**全部绑回环**,唯一公网入站是 `22(sshd)` ⇒ 「打洞 / 直连」路径**实测不可行**,relay 只能靠**唯一拨出长连接**(与本线既定设计一致)。
2. **47 链路质量是本轮最大异常**:30% / 10% 丢包,而 RTT 抖动仅 0.5 ms 级 ⇒ 属**丢包型**而非**拥塞型**恶化;吞吐 12.7 Mbit/s 已含丢包拖累 ⇒ 选型时**丢包恢复能力(Noise / QUIC 级)应重于带宽**。
3. **跨云一跳代价**:47 ↔ 106 实测 149.8 ms / 10% 丢包 ⇒ 多跳中继必须把这一跳计入「45% 设计 / 55% 余量」的**余量侧**。
### 9.4 未完成取证(诚实标注,非"已解决")
- 106 侧监听面 + 本机↔106 吞吐:**缺 106 的 ssh 凭据**(本机与 47 两处均 `Permission denied (publickey)`)⇒ 需先确认 `manager-ssh` 通道实际用的是哪个账号 / 密钥;**本轮未改任何配置**。
- 吞吐只测了"经 SSH 隧道的加密吞吐",非裸 TCP ⇒ 结论按**下界**理解。
- 丢包为 ICMP 采样,若对端有 ICMP 限速则可能高估;但 47 的 SSH 吞吐(1.6 MiB/s)与之相互印证。
---
# 10. R0-a 判据打分表 + **relay 定案**(2026-09-16 18:5x)
> 回答用户提问:「继续执行 让方案落地,**自建 relay 的方案定了吗**」
> **直接答案**:此前**没有定**(§8.4 明确写"不锁定 frp、本轮不引入第三方、先做 R0");**现在定了 —— 自研 relay,传输走 WebSocket over 现有 nginx 443,零新增公网口**。理由见 10.2,缺点见 10.6。
## 10.1 R0-a:六条判据·可打分版(**后续任何 relay 决策都用这张表**)
权重(= 8.2 的重要性排序):① 认证/授权模型 **3**|② 默认拒绝 vs 默认放行 **3**|③ 能否零新增入站 **2**|④ 单 worker 失陷爆炸半径 **3**|⑤ 可观测 **1**|⑥ 生态/排障 **2**(总权重 14,满分 70)。评分 1–5(5 = 最好)。
| 方案 | ① 认证(×3) | ② 默认姿态(×3) | ③ 零入站(×2) | ④ 爆炸半径(×3) | ⑤ 可观测(×1) | ⑥ 生态(×2) | **加权分/70** |
|---|---|---|---|---|---|---|---|
| **自研 relay** | 5 | 5 | 5 | 5 | 2 | 1 | **59(84%)** |
| **OpenZiti** | 5 | 5 | 5 | 5 | 3 | 3 | **64(91%)** |
| **Nebula** | 5 | 5 | 4 | 4 | 3 | 3 | **59(84%)** |
| **rathole** | 4 | 3 | 5 | 3 | 1 | 1 | 43(61%) |
| **chisel** | 2 | 3 | 5 | 2 | 1 | 2 | 34(49%) |
| **frp** | 1 | 1 | 3 | 1 | 5 | 5 | 30(43%) |
⚠️ **纯看这张表会选 OpenZiti —— 但表里缺了「架构契合度」这条否决项**(见 10.2 第 3 点)。**分数不是终点,否决项优先。**
## 10.2 定案:**自研 relay**(不是"造轮子偏好",是约束共同推出的唯一解)
1. **入口约束把成熟件全部逼到墙角**(10.3 已实测):我们唯一愿意接受的入口是 **WebSocket over 现有 443**(零新增监听口、且天然满足 S5「443/TCP 兜底」、对"只放 443 的异构网络"最鲁棒)。
- **rathole 不支持 WebSocket** ⇒ 只能退回 `nginx stream + ssl_preread`,而 443 现由 **http 层**监听 ⇒ 必须把 443 从 http 挪进 stream = **动门户主入口**(结构级改动)。
- **OpenZiti / Nebula** 是完整 overlay(自带会合 + 身份 + ACL),会**与已完成的 `Rendezvous` / `via` 语义重叠** ⇒ §7.1 已判定"层级不对、属远期"。**这是否决项,不是扣分项。**
2. **认证模型 + 静默失败**:chisel/frp 走 WS 但都是**共享 auth / 静态 token**(①垫底);而 SSH 的病根之一正是**静默失败**(`-R` 撞号时无人检查返回值)⇒ 我们需要"**注册必须被 ACK / 端口占用必须报错**"这种精确语义,成熟件里没有现成的。
3. ⇒ **同时满足「WS over 443」+「每服务密钥(非共享 token)」+「精确 ACK」的成熟件不存在** ⇒ 自研。
4. **代价可控的关键**:真正的功能面很小 —— 一条出向长连接 + `(hostId, port) → conn` 映射表 + 字节双向泵 + 心跳/重连/ACK。**不自造密码学**(复用 Node 内建 `tls` / `net` / `crypto`),协议帧 = 长度前缀 + JSON 头。
5. **可选性已买好**(§8.4 第 4 条):`Reachability.via` + `Rendezvous` 已抽 ⇒ 自研 relay = **新增一个 `via='relay'` 实现**,零删除、env 级回滚。**选错不致命,所以现在定案的风险是可控的。**
## 10.3 入口取证(本轮只读实测 —— 决定"零新增公网口"能不能成立)
| 项 | 实测 | 对落地的影响 |
|---|---|---|
| nginx | `nginx/1.28.3`,含 `--with-stream` + `--with-stream_ssl_preread_module` | 方案 `stream + ssl_preread` **技术可行**(但见 10.2 第 1 点:要动 443) |
| stream 段 | 已存在,且 `include /www/server/panel/vhost/nginx/tcp/*.conf` | 有现成落点(宝塔 TCP 转发目录) |
| 443 现状 | `listen 443 ssl default_server;`(**http 层**,nginx pid 521250/521249/85711) | 走 stream 分流必须把 443 挪走 ⇒ **动门户** ⇒ 故选 WS 方案绕过 |
| **本机防火墙** | `nft: chain INPUT policy accept` + `iptables -P INPUT ACCEPT` | 🔴 **47 没有本机防火墙**,暴露面**全靠云安全组** ⇒ **任何绑 `0.0.0.0` 的新监听会立刻公网可达** ⇒ 「零新增公网口」是**硬约束**,不是洁癖 |
| 公网监听面 | `22`+`32022`(**同一 sshd 进程 pid 1017**);`80/443/888`(nginx);`58888`(BT-Panel);`8765`(python3) | 32022 = 覆盖网络专用口(同为 sshd) |
| 回环面 | `127.0.0.1:19000`(**sshd**)、`19100`(w-47 agent)、`20000`(w-47 实例)、`3080`(门户)、`15432`(PG) | 见 10.4 勘误 |
| 出向 | `https://github.com → http=200 t=0.084s`,`gh-proxy/ghfast → 200` | 取第三方件无障碍(**但本定案不取**) |
| 已装 relay | `rathole/frpc/frps/chisel/ziti/wg` 全部 `(none)` | 干净起点 |
| 现有隧道进程 | 47 上 `ps \| grep ssh` **为空** | 隧道由 **106 侧**发起(106 拨出) |
## 10.4 一处勘误(推翻 §9.4 的"阻塞"判断)
`127.0.0.1:19000`(及 `[::1]:19000`)的属主是 **`sshd`(pid 720417)**,**不是 dsh agent** ⇒ 它是 **106 侧 `ssh -R` 的落点**。
⇒ `via='manager-ssh'` 的语义 = "**106 主动拨出、经 Manager 的 sshd 落点可达**",**不是** "Manager 主动连 106"。
⇒ **§9.4 记的"缺 106 凭据 = 阻塞"应修正为"不是阻塞"**:设计上 47 就不需要能 ssh 到 106(实测 `47→106 ssh Permission denied` 属**正常**)。
⇒ 106 侧操作的通道 = **宝塔面板**(本机已挂 106 的宝塔 MCP:`mcp__baota-mcp-106.54.21.172`),而不是 ssh。
## 10.5 R1–R4 落地步骤(每步可独立验收、可独立回滚,延续 P1–P3 的交付方式)
| 步 | 动作 | 验收(可核对) | 回滚 |
|---|---|---|---|
| **R1** | 47 上 relay 服务端只监听 **`127.0.0.1:20080`**(**仅回环**,不新增公网口);本机 ↔ 47 先用**临时 `ssh -L`** 引出来跑通一条隧道(**不碰 443、不改 nginx**) | `curl -s localhost:<映射口>` 取到目标内容;relay `/status` 显示 1 条注册 + 端口已占用 | 停进程即回零 |
| **R2** | nginx 某站点 443 加 `location /dshs-relay/`(`proxy_http_version 1.1` + `Upgrade`/`Connection` 头 → `127.0.0.1:20080`);先 `nginx -t`,**备份站点 conf**,再 reload | `wss://<域名>/dshs-relay/` 握手 **HTTP 101**;`ss -lntp` **无新增 0.0.0.0 监听** | 删 location + reload(秒级) |
| **R3** | worker 侧 relay client 拨出(106 经宝塔部署);`dsh_hosts.via='relay'`(env 级);**SSH 路径并存不删** | `via='relay'` 的 worker 实例可正常被门户访问;`/status` 显示该 worker 心跳 | `via` 切回 `manager-ssh`/`local`(零代码) |
| **R4** | 观察一轮 → 下线 sshd 隧道 + 收 47 上那条 `authorized_keys`;**回收 32022** | sshd 隧道进程消失、实例仍可用;32022 从 `0.0.0.0` 监听面消失(**净减一个暴露口**) | 重建隧道(回 R3 前状态) |
**R2 是唯一动门户的一步**(其余全为新增/回环),且 `nginx -t` + 备份 + reload 三重保护;**R2 之前 SSH 路径全程在跑**。
## 10.6 我选了什么(可推翻)+ 诚实的缺点
**选了**:自研 relay;**WS over 443**;**每 worker 一密钥**(非共享 token);注册/占用**必须 ACK**;先只服务 w-47 / w-106 两个 worker;**R4 之前 SSH 路径不删**。
**缺点(必须承认,不是"没有缺点")**:
1. **无第三方审计**:自研数据面没有别人的眼睛看过 ⇒ 缓解 = 协议极简、复用 Node 内建密码学、**不开新端口**(暴露面不增)、单点可 `kill` 回退。
2. **长期自维护**:bug 自己修、没有上游 → 缓解 = 代码量刻意压小(≤500 行目标)、`via` 可秒切回 SSH。
3. **可观测要自己补**:无现成 dashboard ⇒ 缓解 = 先做 `/status`(注册数/心跳/字节数)+ journald。
4. **相对成熟件的功能天花板低**:多中继选主、UDP、热重载都要自己加 ⇒ 缓解 = 本阶段不需要(第一瓶颈是 presence,不是带宽/Multi-relay)。
## 10.7 R1 前置取证(本轮实测,**执行会话直接照用,不必重新探索**)
**代码仓 / 接线点**
- 代码仓 = `D:/github/dsh_shenxian`(`src/` + `dsh-server-docs/` 同一仓);**relay 代码落 `src/net/relay/`**(与 `src/net/reachability.ts` / `rendezvous.ts` 同层)。
- 既有 `src/net/` 仅 211 行:`reachability.ts`(99) + `rendezvous.ts`(112)。**`via` 词表 = `src/net/reachability.ts`**(`VIA_LOCAL='local'` / `VIA_MANAGER_SSH='manager-ssh'`)。
- **接线的唯一一处**:`src/web/server.ts:270` → `new RendezvousRegistry([ LocalRendezvous, ManagerSshRendezvous ])`。R1 新增 `RelayRendezvous` ⇒ 只在此数组加一项 + 注册 `id='relay:<id>'`。
- ✅ **代码里已预留 `relay:<id>` 语义**:`rendezvous.ts:64`「S4 把"接收 worker 反拨"搬进独立单元 `dshs-relay.service` 后,本类应被 `relay:<id>` 实现替换」;`src/web/routes/admin.ts:188` 注释「未来的 `relay:<id>`」。⇒ **本定案与既有架构同构,不需要发明任何命名。**
**依赖与运行(实测,决定 R1 怎么写)**
| 项 | 实测 | 结论 |
|---|---|---|
| Node | `v22.22.2`,`"type":"module"`(ESM) | 与全仓一致,relay 用 ESM |
| 依赖 | 仅 `@fastify/rate-limit, @fastify/static, better-sqlite3, fastify, pg` —— **无 `ws`** | **R1 需二选一**(见下) |
| 内建 `WebSocket` | `typeof globalThis.WebSocket === 'function'`(**仅客户端**,服务端无) | **relay client 零依赖**;server 端需 WS 实现 |
| `.ts` 直跑 | 自检**未通过**(错误栈未展开)⇒ 不赌 type stripping | 走既有链路 `npm run build` → `node lib/…` |
| 门禁 | `npm run verify` 含 `test/reachability.test.mjs` + `scripts/check-layering.mjs`(**分层 ①入口→②领域→③能力→④基础**) | **R1 必须让 verify 全绿**;`src/net/*` 属 ③能力层,⛔ relay 不得反向依赖 ①② |
**R1 的依赖二选一(我的倾向:②)**
1. 给主 `package.json` 加 `ws` —— 优点:直接用成熟帧实现;缺点:**给整个平台新增一个生产依赖**(与"relay 只是可选单元"的定位不符)。
2. **服务端手写最小 WS 帧编解码**(~120 行:握手 SHA1+GUID、二进制帧、ping/pong/close,不做分片与 permessage-deflate)—— 优点:**零新增依赖**、relay 可独立成单元、协议面小到可审;缺点:自写帧层有 bug 风险 ⇒ 必须配 `test/` 断言(握手 + 回环字节数一致)。
**入口(R2)前置条件 —— ✅ 已全部具备,不必改 nginx 结构**
- `map $http_upgrade $connection_upgrade` **已在** `nginx.conf:321-322`。
- `proxy_http_version 1.1` + `Upgrade` / `Connection $connection_upgrade` 模式**已在 5 处**(`nginx -T` 行 378-380 / 403-405 / 499-501 / 553-555 / 568…)⇒ 加 location 是**照抄既有模式**,不是新写法。
- 443 `default_server` 在 `/www/server/panel/vhost/nginx/0.catchall-443.conf:4`;门户站点 = `alotbuy.com.conf` / `dsh.alotbuy.com.conf`;另有 `0.websocket.conf` 已存在。
- 备份惯例已成熟:`dsh.alotbuy.com.conf.bak-20260910-2300-pre-buffering` 之类 ⇒ R2 备份按同格式命名。
- `/www/server/panel/vhost/nginx/tcp/` **为空**(宝塔 TCP 转发目录已 include 但无内容)⇒ 走 stream 的备用落点可用。
---
# 11. R1 落地与验收(2026-09-16 19:3x)—— 自研 relay 最小闭环 **已跑通(本机 + 跨机)**
## 11.1 交付物
**新增** `src/net/relay/`(代码仓 `D:/github/dsh_shenxian`,**未 commit**):
| 文件 | 职责 | 关键点 |
|---|---|---|
| `wire.ts` | 最小 WebSocket **服务端**帧(RFC 6455 子集)+ mux 帧 | 手写因为**无 `ws` 依赖**且 Node 内建只有客户端;握手 SHA-1 走 `node:crypto`(**不自造密码学**) |
| `server.ts` | relay 服务端(只绑回环) | 认证 / 端点 / 多路复用 / 背压 / `/status` |
| `client.ts` | worker 侧拨出端 | 内建 `WebSocket`;**白名单二次校验**;指数退避 + 抖动 |
| `keys.ts` | 密钥装载 | 强制 **64 位 hex**,短密钥直接拒 |
| `rendezvous.ts` | `RelayRendezvous`(`via='relay'`) | 独有增益 = **实时在线态**(心跳驱动,非 DB 快照) |
| `main.ts` | 单元入口(`--client` 可切客户端) | R1 阶段**不读 `config.ts`**,只认 `DSHS_RELAY_*` / argv(避免与其它会话的改动冲突) |
| `index.ts` | barrel | — |
**改动**(小到可以逐字核对):
- `src/net/reachability.ts`:**只加** `export const VIA_RELAY = 'relay'`(`via` 词表的单一来源仍在原处)。
- `package.json`:`verify` / `test` 的测试列表加 `test/relay.test.mjs`。
- `test/relay.test.mjs`(新增):7 项,**真起服务、真握手、真泵字节**(不 mock 传输层)。
⇒ **既有 `.ts` 逻辑文件改动 = 0**(relay 全部落在新目录)⇒ 回滚 = 整目录丢弃。
## 11.2 本机验收(可复现命令 + 实测)
```bash
npm run build # → 0 错误
node scripts/check-layering.mjs # → 现存违规 5 条(全在基线内);✅ 无新增违规(relay 落层③能力层)
node --test test/relay.test.mjs test/reachability.test.mjs # → tests 17 / pass 17 / fail 0
```
| 用例 | 断言 |
|---|---|
| T1 | mux 编解码往返(含 `streamId=0xffffffff` 边界与空负载);<5 字节判协议错误 |
| T2 | 密钥只接受 64 位 hex,`deadbeef` 直接拒 |
| **T3** | **端到端**:Manager 连回环口 ⇒ 字节真过 ⇒ 回显一致;**并发 3 条流同时工作**(多路复用真在复用);`authFailed=0 / dropped=0` |
| T4 | 错密钥 ⇒ 不 `up` + `authFailed≥1`(**不静默**) |
| T5 | **同 nonce 二次注册 ⇒ 重放被拒**(第二条连接拿不到 `HELLO_ACK`) |
| T6 | 声明实例区间外端口 ⇒ 拒绝注册 + **不留回环监听** |
| T7 | 未注册端口**不存在**回环监听(默认拒绝,不是默认放行) |
## 11.3 跨机验收(47 ⇄ 本机)—— 命令原文 + 实测输出
拓扑:**47 = relay 服务端(只绑 `127.0.0.1:20080`)**;**本机 = worker 侧 client(经临时 `ssh -L` 拨出)**;验证 = **在 47 上 curl relay 分配的回环口 ⇒ 必须打到本机端口**。
| 步 | 命令(原文) | 实测输出(原文摘录) |
|---|---|---|
| 部署 | `scp -r lib/net/relay root@47:/tmp/dshs-relay-r1/net/` + `printf '{"type":"module"}' > package.json` | 远端 7 个 `.js`;`/opt/dshs/lib` 下**无** relay 目录 ⇒ **零覆盖** |
| 起服务 | `nohup node net/relay/main.js --port 20080 --keys-file keys.json --base 19800 --span 200` | `[relay] listening ws://127.0.0.1:20080/dshs-relay (loopback only) instance-ports=19800..19999`;`LISTEN 127.0.0.1:20080 users:(("node",pid=724533))` |
| 引出来 | `ssh -f -N -L 20080:127.0.0.1:20080 root@47` | 经隧道读到远端 `/status` ✅ |
| 拨出 | 本机 `--client --url ws://127.0.0.1:20080/dshs-relay --host local-r1 --ports 19876` | `[relay-client] registered host=local-r1 session=3cbd8fc7425ca326 accepted=[19876]` |
| 服务端视角 | 47 `curl 127.0.0.1:20080/status` | `online: ["local-r1(session=3cbd8fc7425ca326 ports=19876 …)"]`;`endpoints: [{port:19876, localPort:41811, online:true}]`;`authed=1 / authFailed=0` |
| **关键验证** | 47 `curl 127.0.0.1:41811/hello-from-47`(+ `/second-call` + `/third`) | **`echo:/hello-from-47\|served-by=User-2026QYRQXO\|at=127.0.0.1:19876`**(三次全部命中;`served-by` = **本机主机名** ⇒ 确实到了本机) |
| **暴露面** | 47 `ss -lntp \| grep 20080`;本机 `curl http://47.77.182.89:20080/status` | 只 `127.0.0.1:20080`;公网 **`curl_rc=000` 不可达** |
| 回收 | kill client/echo/隧道 + 远端 kill 后 `rm -rf /tmp/dshs-relay-r1` | 20080 已释放、无残留进程、临时目录已清 |
## 11.4 三条结论
1. **数据面闭环成立,且零新增公网口**:worker 只拨出、relay 只绑回环、Manager 连回环口 ⇒ `Reachability.address` 与 sshd 版**同形**,调用方零改动。
2. **安全姿态实测有效**(不是纸面):越界端口被拒(本轮实测触发过:`19876` 不在 `20000..20999` 时被正确拒绝)、nonce 重放被拒、错密钥被拒、公网不可达、**每 worker 一密钥**(非共享 token)。
3. **R2 是唯一动门户的一步**(nginx 443 加一个 `location /dshs-relay`);R2 之前 SSH 路径全程在跑,且 R1 的部署方式**不碰 `/opt/dshs/lib`**。
## 11.5 本轮踩到的三个坑(**下一棒直接照用,别再踩**)
1. 🔴 **远端 `pkill -f "<pattern>"` 会杀掉自己**:只要 pattern 串出现在 ssh 自身命令行里(例如 `relay/main.js`),`pkill -f` 就匹配到那个 `bash -c` 进程 ⇒ **自杀**,后续命令全不执行(本轮因此**空跑两次**,第二次即使用 `[r]elay` 字符类也无效,因为**启动命令里也含该字面量**)⇒ ✅ **用 pidfile(`echo $! > server.pid`)**,不要用 `pkill -f`。
2. 🔴 **`/tmp` 下跑 ESM 产物必须先放 `package.json {"type":"module"}`**:否则 node 按 CJS 解析 ⇒ 启动即 `SyntaxError`,而日志被上面那个坑挡住 ⇒ 表现为"服务起不来但没有任何报错"。
3. ⚠️ **端口必须落在 `--base/--span` 区间内**:client 声明区间外端口会被服务端拒绝 —— 这是**爆炸半径校验在正常工作**,不是 bug(本轮实测触发)。
---
# 12. 网络模块韧性 —— 节点启停 / 网络变化 / 网络中断 / 网络异常 / 时钟漂移(R1.5,2026-09-16 20:xx)
> 回答用户提问:「**网络模块都要考虑 服务器等公网 IP 节点启停,网络变化,网络中断,网络异常,如何重连和恢复**」
> 结论先说:**五类场景各有对应的机制与可观测字段,且都能被实测断言**(单测 15/15 + 47 真机验收全绿)。
> **不做**做不到的事:断链必然终止在途流,**不假装**能流级恢复(见 12.3)。
## 12.1 连接状态机(**显式七态**:布尔说不出"正在握手"还是"正在退避")
```text
idle ──start()──▶ connecting ──ws open──▶ handshaking ──HELLO_ACK──▶ up
▲ │ │ │
│ └── 任一步失败 / 超时 ────┴─────────────────────┘
│ ▼
└──stop()──▶ stopped ◀──stop()── { backoff | queued }
│ (延迟到点 / 地址变化 / 对端优雅告别 / 位子空出)
└──────────────────────────────▶ connecting
```
`queued` 与 `backoff` **分开**是刻意的:前者是"没位子"(不是故障,不该按故障退避),后者是"链路有问题"。
## 12.2 五类场景 × 处置 × 恢复时间(**数字是实测/默认值,都可调**)
| 场景 | 现象 | 处置 | 恢复时间(默认) | 可观测字段 |
|---|---|---|---|---|
| **节点启停**(计划内) | 对端发 `BYE` / `close 1001` | **不消耗退避**,进入**快速重试时窗**(`gracefulBurstMs` 默认 15s,间隔 `gracefulRetryMs` 300ms);同时 `BYE` 让服务端**秒级**标离线(不等 45s 心跳) | **窗口内立即恢复**(实测 <2s,含 400ms 重启) | `restarts` / `state=backoff`+短 `nextRetryMs` |
| **节点启停**(崩溃,无告辞) | 连接突然断 | 指数退避 `1s→2s→4s…` 封顶 `reconnectMaxMs`(30s)**±25% 抖动**,**永不放弃** | ≤30s,典型 ≤2s | `attempts` / `nextRetryMs` |
| **网络变化**(IP 变 / 网卡上下) | 本机地址快照变化 | 巡检(`netWatchMs` 5s)发现即**取消剩余退避、立即重拨**(旧退避的前提已失效) | 立即 | `networkChanges` |
| **网络中断**(长时间断网) | 连不上 / 连上无帧 | 同退避序列;**不设"重试 N 次后沉默"** | 恢复联网后 ≤30s | `attempts` |
| **网络异常**(半开 / 静默黑洞) | TCP 不报错但再无数据 | **半开巡检**:`2.5×hbSec`(默认 37.5s)内**没有任何帧** ⇒ 主动断开重连(不等 OS 的 TCP 超时,那要几百秒);服务端侧同样 45s idle 判死 | ≤37.5s 判死 + 立即重连 | `lastFrameAgeMs` |
| **满载**(位子不够) | 服务端回 `at-capacity` | **排队**(`queued` 态),按服务端 `retryAfterMs` 复盘;位子一空即注册 | 按 `retryAfterMs`(默认 5s) | `queueWaits` / `queuedMs` |
| **时钟漂移** | `HELLO` 被拒 `clock-skew` | 用拒绝帧里的 `serverTime` **本地校正**后立即重试(含 `HELLO_ACK` 也回传 `serverTime`,首连即可对齐) | 1 次握手(~300ms) | `clockSkewMs` |
**为什么时钟漂移必须专门处理**:`HELLO` 的 MAC 里含时间戳、窗口 ±60s ⇒ 一台时钟漂了 10 分钟的机器
**永远无法注册**(每次都 `bad-mac`/`clock-skew`),且现象是"连上又被踢"的无限循环 —— 这是 Replay 防护的必然代价,必须用"把服务端时间告诉它"来还债。实测 T10 覆盖。
## 12.3 恢复语义分层(**明确哪层做、哪层不做**)
| 层 | 谁负责 | 恢复方式 |
|---|---|---|
| ① **连接恢复** | relay client | 重连(本节的退避 / 时窗 / 半开巡检) |
| ② **注册恢复** | relay client | 重连成功后**自动重新注册**(`ports` 声明不变,服务端重建回环监听) |
| ③ **路由恢复** | Manager / `RelayRendezvous` | `online()` 实时判在线、`localPortOf()` **不猜**:离线一律 `undefined`,让上层回退别的实现,而不是往死地址上打 |
| ④ **流级恢复** | **不做** | 断链**必然**终止在途流(多路复用帧没有重放日志)。**不缝合半开流**:流级重试交给上层(HTTP 幂等请求 / 实例侧自身重连)。**假装能做 = 制造"看起来恢复了其实数据烂了"** |
## 12.4 实测证据(可核对)
**单测**(`node --test test/relay.test.mjs`,15/15 通过;全量 `npm run verify` = 80 tests / 79 pass / 0 fail,1 skip):
| 用例 | 断言 |
|---|---|
| T8 | 优雅停机(`BYE`+`close 1001`)⇒ 对端**立刻**进短间隔时窗(`nextRetryMs ≤ 1000`,不退避到 60s)⇒ 重启窗口内**自动恢复**(`reconnect #1`、新实例 `isOnline`) |
| T9 | 客户端优雅停机 ⇒ 服务端**1.5s 内**标离线(心跳超时被设为 60s ⇒ 这只可能来自 `BYE`),且不再给出回环口 |
| T10 | 注入 +600s 时钟漂移 ⇒ 首次被拒 → **自愈**后注册成功;`clockSkewMs` 反映真实漂移 |
| T11 | 半开(握手成功但**永不回帧**)⇒ 无帧超阈值即主动重连(`lastError` 含 `half-open`),且**确实重拨** |
| T12 | 网络变化(快照变化)⇒ **取消剩余 60s 退避、立即重拨** |
| T13 | 容量准入:`maxHosts=1` 时第二个 host 收到 `HELLO_ERR{reason:'at-capacity', retryable:true, retryAfterMs, capacity}`、**不留回环监听**;**已在册 host 重连仍被接受** |
| T14 | 客户端满载 ⇒ 进 `queued`、按 `retryAfterMs` 重试、`attempts` 不累计;位子空出即注册成功 |
| T15 | 选点判据:满载是**唯一硬门**;速度 + 负载打分;负载能压过速度;手动指定优先且**不被静默改选**;近期失败降权 |
**47 真机验收**(`/tmp/dshs-r15`,**不碰 `/opt/dshs`**;命令原文与输出摘录):
```bash
# 部署(只传 lib/net/relay 产物)+ 起 relay(--max-hosts 1,只绑回环)
node lib/net/relay/main.js --port 20080 --keys-file keys.json --base 19800 --span 200 --max-hosts 1
# 两个 worker 拨出(同机两个进程 ⇒ 等价于两台机器,都是"只拨出")
node lib/net/relay/main.js --client --url ws://127.0.0.1:20080/dshs-relay --host local-r1 --keys-file keys.json --ports 19876
node lib/net/relay/main.js --client --url ws://127.0.0.1:20080/dshs-relay --host local-r2 --keys-file keys.json --ports 19877
curl -s http://127.0.0.1:20080/status
# 重启:kill $(cat server.pid) → 1s → 同端口重起 → 观察 A
```
实测输出(摘录):
```text
[relay-client] registered host=local-r1 session=873ca9a30e3bdb14 accepted=[19876] clockSkew=6ms
[relay-client] down (at-capacity (queued #1, waited 0ms)); attempt #0 [queued], retry in 5000ms
"capacity":{"max":1,"used":1,"free":0}
"online":["local-r1(session=873ca9a30e3bdb14ports=19876streams=0hbAge=4886msin=0Bout=0B)"]
-- 重启 --
旧进程是否已退出: 已退出 端口释放: 0
server2 首行: [relay] listening ws://127.0.0.1:20080/dshs-relay (loopback only) ...
[relay-client] peer BYE: server restarting ⇒ fast reconnect
[relay-client] down (peer bye: server restarting (was up)); attempt #0 [graceful, burst window 15000ms], retry in 300ms
[relay-client] registered host=local-r1 session=5071fa3f8c2d5761 accepted=[19876] clockSkew=2ms (reconnect #1)
新实例 online: "online":["local-r1(session=5071fa3f8c2d5761...)"]
残留回环监听: 0
```
## 12.5 四个真机硬结论(**都是本次实测踩出来的,下一棒别再踩**)
1. 🔴 **重试定时器绝对不能 `unref()`**:断链后 WebSocket 句柄已消失,若连"重试计划"也是 unref 的,事件循环就空了 ⇒ **进程静默退出**(节点"人间蒸发":不重连、不报错、日志停在最后一行,`/status` 里再也等不到它)。**本文件其余定时器 unref 是对的,唯独重试定时器不行。**(单测发现不了 —— 只有在真机上才暴露,这也是"本机通过 ≠ 交付"的又一实证)
2. 🔴 **停机必须"可控"**:`stop()` 里若只 `close()`,空闲 keep-alive 连接会把进程挂住 ⇒ 旧进程迟迟不退 ⇒ 新进程 `EADDRINUSE` ⇒ **客户端只能一直撞那个正在 `draining` 的旧实例**(表现为"重启后再也连不上")。修法 = `http.closeAllConnections()` + `main.ts` 里 2s 硬兜底退出(等价 systemd `TimeoutStopSec`)+ `shutdownGraceMs`(300ms)通知窗口。
3. 🔴 **优雅重连要用"时窗"而不是"次数"**:重启耗时不可预测(实测一次 `stop()` 自身就要 1.8s),固定 4 次会在"差一点点"处失败并把退避直接拉到 60s ⇒ 计划内重启被放大成长时间中断。
4. 🔴 **满载是唯一的硬门,且已在册节点重连永远优先**:容量检查写成 `sessions.has(hostId)` 放行 —— 否则平台自己重启一次,节点就会被自己的满载规则挡在门外。
5. ⚠️ **临时验收进程必须回收**:本次在 47 上发现**上一轮验收残留的 relay 进程**(`node lib/net/relay/main.js --port 20080`,已运行 8 分钟,占着 20080)⇒ 导致新 relay `EADDRINUSE`、客户端连上旧实例报 `bad-mac`。**已回收**。⇒ 验收脚本必须用 pidfile 收尾(本次已改为 `kill $(cat *.pid)` + 结束前计数校验 `残留回环监听: 0`)。
---
# 13. 节点准入与选点 —— 自动(速度 + 负载) / 手动 / 满载排队(R1.5,2026-09-16 20:xx)
> 回答用户提问:「**如何自动选择和判断适合的节点加入(速度和负载),支持手动选择(要考虑负载满的时候不能加入或排队等待)**」
## 13.1 判据分层(**只有一处判据**,避免三套不一致)
| 层 | 位置 | 职责 | 硬门? |
|---|---|---|---|
| **准入(Admission)** | `RelayServer` | 容量上限(`maxHosts`):满了**拒绝新节点加入**,回 `at-capacity` + `retryAfterMs`;**已在册 host 重连优先** | ✅ **唯一硬门** |
| **选点(Placement)** | `src/net/relay/placement.ts`(纯函数、可单测) | 在**有资格**的候选里按**速度 + 负载**排序选一个;支持手动指定 | ❌ 只排序 |
`placement.ts` **不连网、不开端口**:它只把"可观测画像"变成"一次可解释的选择"。relay 只负责把画像喂进来(`rttMs` 来自心跳 PONG,`capacity` 来自注册数)。
## 13.2 打分公式(**可解释**是这个模块存在的意义)
```text
speed = 100 / (1 + rttMs / rttHalfMs) # rtt 0→100;50ms→50;200ms→20(rttHalfMs 默认 50)
load = 100 × (1 − used / max) # 容量未知 ⇒ 按 0.5 中性(不猜它空)
score = weight × (0.55·speed + 0.45·load) − 15 × recentFailures
硬门:capacity.free === 0 ⇒ score = −∞(**不可选**,只能排队或换节点)
```
- **速度略重(0.55)**:实测里"能不能连上"由 RTT 决定;且第一瓶颈是 **presence**,不是带宽。
- **满载不参与打分**,直接出局 —— 这正是用户要的"负载满的时候不能加入"。
- `recentFailures` **只降权不排除**:唯一可用但常失败的节点,也好过没有节点。
- **手动指定优先**(`manualId`):给了它就**只考虑它**;它满了 ⇒ `queued`(排队)或 `rejected`(`allowQueue:false`),**绝不静默改选别的节点**。
## 13.3 三种出口都有明确语义
| 情况 | `outcome` | 含义 |
|---|---|---|
| 手动指定且未满 | `chosen` | 尊重显式选择 |
| 手动指定但已满 | `queued` / `rejected` | 排队等待 / 拒绝加入(**不偷偷换**) |
| 自动且有空位 | `chosen` | 按速度 + 负载打分取最高 |
| 自动但全满 | `queued` / `rejected` | 排队等位 / 直接拒绝(`allowQueue:false`) |
| 候选为空 / 手动 id 不存在 | `rejected` | **明确拒绝并说明原因**,不抛异常让上层去猜 |
## 13.4 与既有集群化落点逻辑的关系
既有约定「存量锚 `w-47` 粘性优先、新用户按容量落 `w-106`」在本模块里的表达就是:
**粘性 = `manual`/高 `weight`**,**按容量 = `capacity` 打分**。⇒ 门户/Manager 已有的落点选择**不需要改判据**,
只要把画象喂给 `chooseNode()` 即可(同一套判据,不会出现"门户算一套、relay 算另一套")。
## 13.5 落地状态
| 项 | 状态 |
|---|---|
| 代码 | `src/net/relay/placement.ts`(新增)、`server.ts` 容量准入、`client.ts` 排队语义、`main.ts` `--max-hosts` / `DSHS_RELAY_MAX_HOSTS` |
| 单测 | T13 / T14 / T15 ✅ |
| 真机 | 47 上 `--max-hosts 1`:A 注册成功、**B 被拒并排队**、服务端 `capacity:{max:1,used:1,free:0}` ✅ |
| 未做 | Manager/门户侧接 `chooseNode()`(属 R3 集成);relay 集群的多中继选主(本阶段不需要) |
---
## 14. R2 落地:relay 常驻 47 + 经 nginx 443 暴露 wss(2026-09-16 21:0x,**已验收**)
### 14.1 落地形态(实测)
| 项 | 值 |
|---|---|
| 服务端产物 | 47 `/opt/dsh-relay/lib/net/relay/*` + **`lib/net/reachability.js`**(⚠️ `rendezvous.js` import 它)+ `/opt/dsh-relay/package.json` = `{"type":"module"}` |
| 单元 | `/etc/systemd/system/dshs-relay.service`(`enabled` + `active`;`Restart=always`) |
| ExecStart | `/usr/local/bin/node /opt/dsh-relay/lib/net/relay/main.js --port 20080 --keys-file /etc/dshs/relay-keys.json --base 20000 --span 1000 --max-hosts 0` |
| 密钥 | `/etc/dshs/relay-keys.json`(`600`;**每 worker 一密钥** `w-47` / `w-106`,各 64 hex) |
| 入口 | `alotbuy.com.conf` 443 server 块内**只加一个** `location /dshs-relay` → `proxy_pass http://127.0.0.1:20080`(复用 `nginx.conf:321` 的 `map $http_upgrade $connection_upgrade`) |
### 14.2 验收证据(命令原文级)
⚠️ **本轮两处取证纠正(⛔ 勿沿用旧结论)**
1. 🔴 **门户 443 块在 `alotbuy.com.conf`,不在 `dsh.alotbuy.com.conf`** —— 后者是**遗留 301 跳转域名**(`return 301 https://alotbuy.com$request_uri`,`server_name dsh.alotbuy.com *.dsh.alotbuy.com`)。R2 交接单里写的那个"候选"是错的;也**不能**把 location 加进 301 块。
2. 🔴 **`dsh.alotbuy.com` 在 Cloudflare 后面**(`104.21.44.42` / `172.67.194.206`)⇒ 验收 URL 用 **`https://alotbuy.com/dshs-relay`**;且 443 块是 `listen 443 ssl; http2 on;` ⇒ **curl 默认协商 h2,经典 `Upgrade` 握手必失败(实测 `HTTP/2 404`)**,判据命令**必须带 `--http1.1`**。
| 判据 | 命令 | 实测 |
|---|---|---|
| 服务在跑 | `systemctl is-active dshs-relay` | `active`;`is-enabled` = `enabled` |
| 只绑回环 | `ss -lntp \| grep 20080` | `127.0.0.1:20080`(**不是** `0.0.0.0`) |
| 状态面 | `curl -s 127.0.0.1:20080/status` | 含 `"capacity":{"max":0,"used":0}` |
| **零新增公网口** | 本机 `curl -m 5 http://47.77.182.89:20080/status` | `http_code=000`、`curl_rc=28`(不可达) |
| 启动日志 | `journalctl -u dshs-relay` | `[relay] listening ws://127.0.0.1:20080/dshs-relay (loopback only) instance-ports=20000..20999` |
| nginx 语法 | `nginx -t` | `syntax is ok` + `test is successful` → `reload` |
| **origin 直连 101** | `curl -k -i --http1.1 --resolve alotbuy.com:443:127.0.0.1 -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' https://alotbuy.com/dshs-relay` | `HTTP/1.1 101 Switching Protocols` |
| **经 Cloudflare 101** | 同上去掉 `--resolve` | `HTTP/1.1 101` + `Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=` |
| 404 来源可辨 | 普通 GET `https://alotbuy.com/dshs-relay` | body = **relay 自身文案** `dshs relay: WebSocket upgrade only, at /dshs-relay` ⇒ 证明 location 真打到 20080(而非门户 3080) |
| 门户未受损 | 47 本机 `curl --resolve alotbuy.com:443:127.0.0.1 https://alotbuy.com/portal.html` | `code=200 size=50726`;`/api/dsh/status` → `401 size=24`(正常未授权) |
| 监听面零变化 | `ss -lntp \| awk '{print $4}' \| sort -u` | **改前改后逐字一致**(无新增 `0.0.0.0` / `*`) |
| **重启韧性** | `systemctl restart dshs-relay` 后复测 | `active` + `20080` 回归 + nginx 路径仍 `101` |
| **端到端** | 47 上 `node lib/net/relay/main.js --client --url wss://alotbuy.com/dshs-relay --host w-47 --keys-file /etc/dshs/relay-keys.json --ports 20099` | client:`registered host=w-47 session=4afc68c53de7991f accepted=[20099] clockSkew=6ms`;服务端 `/status`:`online=[w-47(…ports=20099…)]`、`endpoints=[{hostId:w-47,port:20099,localPort:42067,online:true}]`;journal:`AUTH OK host=w-47 session=4afc68c53de7991f ports=[20099]` |
| 优雅停机 | 对上述 client 发 `SIGTERM` | client `EXITED`;relay journal `session 4afc68c53de7991f (w-47) dropped: connection closed`;`online=[]`、`used=0`;**残留进程 0**、**残留回环口 0** |
### 14.3 本轮三条硬结论(下一棒必读)
1. 🔑 **`--base/--span` 的语义 = "允许 worker 声明的实例端口区间"(准入校验)**,见 `server.js:348`(越界 → `port-out-of-range`);**⛔ 不是 relay 本地回环口的区间** —— 本地口是 `server.js:558` 的 `listen(0)`,由 **OS 动态分配**。⚠️ R1.5 记忆里"relay 本地端口区间隔离"的表述**不准确,已勘误**:实测 `localPort=42067`(落在 OS 临时段)**是设计使然、不是 bug**;真正的隔离由「**每个 `(hostId,port)` 一个独立 `node:net` 监听 + 一条独立到对面的 socket**」保证 —— 本就不存在共享的端口编号空间。
2. 🔴 **"占着实例端口的不一定是残留进程"** —— 47 上 `127.0.0.1:20000` 的占用者 `pid 720541`,实测是**在线用户实例**(`node /usr/local/bin/dsh --profile web --host 127.0.0.1 --port 20000`,`cwd=/var/lib/dshs/users/cce6d1cd-b376-4304-80f0-0e1c58c9ffde/ws`,已跑 4h41m)⇒ 判别法 = **`tr '\0' ' ' < /proc/<pid>/cmdline` + `ls -l /proc/<pid>/cwd`**(回环口 → uid ≠ 0 即用户实例)。⛔ 别按端口号猜、⛔ 别 `pkill -f`。
3. 🟡 **`http2 on` 与"经典 WebSocket 握手"在 curl 侧互斥** ⇒ 判据命令**必须 `--http1.1`**;Cloudflare 对 WS 请求会自行降级 HTTP/1.1 回源,故**真实用户路径不受影响**(实测 101)。
### 14.4 回滚(两步各自秒级,SSH 路径从头到尾没动)
- **R2-b**:`cp /www/server/panel/vhost/nginx/alotbuy.com.conf.bak-20260916-2059-pre-relay /www/server/panel/vhost/nginx/alotbuy.com.conf` → `nginx -t` → `nginx -s reload`
- **R2-a**:`systemctl disable --now dshs-relay` → `rm -f /etc/systemd/system/dshs-relay.service` → `systemctl daemon-reload`(`/opt/dsh-relay` 可留,不占端口即无害)