chore(工作区): 纳入版本控制基线(回收 411 MB 过程产物)

回收 411 MB(470 M → 58.8 M),全部经回收站,可恢复:
- 待清理/(146.2 M,含 relay 分片 128 M 与 42 项过程目录)
- tmp/(32.4 M,按接续棒命名的过程临时区)
- .workbuddy/tmp/(39.5 M)
- 4 份 workbuddy.db 冗余副本(101 M,09-23 事故的坏副本 / 抢救产物)
- tmp/im16/gw/centrifugo 二进制(63.9 M,可重下)+ 缓存残留

入库范围:常驻规则(CODEBUDDY.md / README.md / state.py)、在途接续入口与
接续包、docs/、交付物/、交接单/、归档/、scripts/、.codebuddy/、
.workbuddy/memory/;共 398 件,其中 >60 KB 的 26 件全为文档。

排除(.gitignore):tmp/、待清理/、运行态日志与缓存、*.db 与 DB 备份整目录、
打包二进制(*.tar.gz / *.tgz)、记忆修复前备份。
This commit is contained in:
admin committed 2026-09-24 07:51:03 +08:00
commit ce8e6ceed9
396 files changed
+66045

No files matched your search

@@ -0,0 +1,600 @@
# 覆盖网络 · 传输方案取舍:开放端口 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` 可留,不占端口即无害)