起因:用户 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 代码面)。
55 KiB
覆盖网络 · 传输方案取舍:开放端口 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),少一跳本地转发,延迟略低;不必维护隧道。
失去(这才是重点):
- 每台 worker 一个公网入口 ⇒ N 台机器 N 套安全组/防火墙配置,接入成本与出错面随台数线性上升;跨云(阿里云 47 + 腾讯云 106)还要两端同时配。
- agent 的认证是单密钥共享(
x-dshs-agent-token)⇒ 该通道被突破/密钥泄露 = 能指挥那台 worker 起停任意用户实例。今天这条通道只能在 47 的 loopback 上被触达(隧道落点),开放后变公网可达 —— 属权限扩大(R5 的反面)。 - 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只放必要端口; - ✅ 对该通道限速 + 审计。
说得对的部分(这些才是真批评,且我们今天全都中):
- 凭据模型:现在隧道以
root@47登录(已加restrict,port-forwarding,拿不到 shell,这点已收窄);但仍是通用 sshd 上的登录凭据,审计粒度粗、与运维 SSH 混在一条authorized_keys里。 - 运营耦合:中继是宝塔面板也在用的那个 sshd ⇒ 面板改 SSH 配置 / 重启 sshd 会波及整张覆盖网络。
- 静默失败:我们已经实测撞到一次——
-R失败时tunnel.forward()的返回值无人检查 ⇒ 撞号后静默不转发、拨到别人实例。SSH 的-O forward语义天生不擅长这种"要精确知道成功没有"的编排。 - 多中继/负载均衡:SSH 没有原生"多中继选主/轮询/健康检查"概念;frp/rathole 有。
- 审计观感:"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 修正后的路线(我选了什么 · 可推翻)
- ⛔ 不做 P4(SSH 版中继单元);不新开任何公网端口。
- relay 采用 frp(首选)/ rathole(备选),形态 = "worker 拨出、relay 复用 443"。
- 工作时序(每步可独立验收、可独立回滚,延续 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。
- R1 47 上装 frps(仅监听
- 回滚:每步都靠"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 条)
- 不锁定 frp;不在这轮引入任何第三方 relay。
- 先做 R0(判据 + 画像):
- R0-a 把上面 6 条判据写成一张打分表(放本文件,作为后续任何 relay 决策的判据);
- R0-b 测链路画像(本机↔47↔106 的 RTT / jitter / 带宽)—— 它同时是"未完成项 3",一次做掉两件事。
- R1 起再选实现:按 R0 的打分表定;优先考虑身份型(OpenZiti / Nebula),frp 只在"要立刻省心、且 8.1 那 5 项配置能一项不落地全部做完"时才选。
- 🔑 关键(也是可以不急的根本原因):P1–P3 已经把接口抽出来了 ——
Reachability.via+Rendezvous注册表 ⇒ 换实现是 env 级切换、零代码回滚。可选性已经买好了,选型错了不致命,所以不必现在一次选对,更不该为了"选对"付一次引入成本。 - ⚠️ 一个必须承认的权衡:把 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) |
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 三条结论
- 零入站 = 实测成立:47 侧 agent / 控制面端口全部绑回环,唯一公网入站是
22(sshd)⇒ 「打洞 / 直连」路径实测不可行,relay 只能靠唯一拨出长连接(与本线既定设计一致)。 - 47 链路质量是本轮最大异常:30% / 10% 丢包,而 RTT 抖动仅 0.5 ms 级 ⇒ 属丢包型而非拥塞型恶化;吞吐 12.7 Mbit/s 已含丢包拖累 ⇒ 选型时丢包恢复能力(Noise / QUIC 级)应重于带宽。
- 跨云一跳代价: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(不是"造轮子偏好",是约束共同推出的唯一解)
- 入口约束把成熟件全部逼到墙角(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 已判定"层级不对、属远期"。这是否决项,不是扣分项。
- rathole 不支持 WebSocket ⇒ 只能退回
- 认证模型 + 静默失败:chisel/frp 走 WS 但都是共享 auth / 静态 token(①垫底);而 SSH 的病根之一正是静默失败(
-R撞号时无人检查返回值)⇒ 我们需要"注册必须被 ACK / 端口占用必须报错"这种精确语义,成熟件里没有现成的。 - ⇒ 同时满足「WS over 443」+「每服务密钥(非共享 token)」+「精确 ACK」的成熟件不存在 ⇒ 自研。
- 代价可控的关键:真正的功能面很小 —— 一条出向长连接 +
(hostId, port) → conn映射表 + 字节双向泵 + 心跳/重连/ACK。不自造密码学(复用 Node 内建tls/net/crypto),协议帧 = 长度前缀 + JSON 头。 - 可选性已买好(§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 路径不删。
缺点(必须承认,不是"没有缺点"):
- 无第三方审计:自研数据面没有别人的眼睛看过 ⇒ 缓解 = 协议极简、复用 Node 内建密码学、不开新端口(暴露面不增)、单点可
kill回退。 - 长期自维护:bug 自己修、没有上游 → 缓解 = 代码量刻意压小(≤500 行目标)、
via可秒切回 SSH。 - 可观测要自己补:无现成 dashboard ⇒ 缓解 = 先做
/status(注册数/心跳/字节数)+ journald。 - 相对成熟件的功能天花板低:多中继选主、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 的依赖二选一(我的倾向:②)
- 给主
package.json加ws—— 优点:直接用成熟帧实现;缺点:给整个平台新增一个生产依赖(与"relay 只是可选单元"的定位不符)。 - 服务端手写最小 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 本机验收(可复现命令 + 实测)
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 三条结论
- 数据面闭环成立,且零新增公网口:worker 只拨出、relay 只绑回环、Manager 连回环口 ⇒
Reachability.address与 sshd 版同形,调用方零改动。 - 安全姿态实测有效(不是纸面):越界端口被拒(本轮实测触发过:
19876不在20000..20999时被正确拒绝)、nonce 重放被拒、错密钥被拒、公网不可达、每 worker 一密钥(非共享 token)。 - R2 是唯一动门户的一步(nginx 443 加一个
location /dshs-relay);R2 之前 SSH 路径全程在跑,且 R1 的部署方式不碰/opt/dshs/lib。
11.5 本轮踩到的三个坑(下一棒直接照用,别再踩)
- 🔴 远端
pkill -f "<pattern>"会杀掉自己:只要 pattern 串出现在 ssh 自身命令行里(例如relay/main.js),pkill -f就匹配到那个bash -c进程 ⇒ 自杀,后续命令全不执行(本轮因此空跑两次,第二次即使用[r]elay字符类也无效,因为启动命令里也含该字面量)⇒ ✅ 用 pidfile(echo $! > server.pid),不要用pkill -f。 - 🔴
/tmp下跑 ESM 产物必须先放package.json {"type":"module"}:否则 node 按 CJS 解析 ⇒ 启动即SyntaxError,而日志被上面那个坑挡住 ⇒ 表现为"服务起不来但没有任何报错"。 - ⚠️ 端口必须落在
--base/--span区间内:client 声明区间外端口会被服务端拒绝 —— 这是爆炸半径校验在正常工作,不是 bug(本轮实测触发)。
12. 网络模块韧性 —— 节点启停 / 网络变化 / 网络中断 / 网络异常 / 时钟漂移(R1.5,2026-09-16 20:xx)
回答用户提问:「网络模块都要考虑 服务器等公网 IP 节点启停,网络变化,网络中断,网络异常,如何重连和恢复」 结论先说:五类场景各有对应的机制与可观测字段,且都能被实测断言(单测 15/15 + 47 真机验收全绿)。 不做做不到的事:断链必然终止在途流,不假装能流级恢复(见 12.3)。
12.1 连接状态机(显式七态:布尔说不出"正在握手"还是"正在退避")
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;命令原文与输出摘录):
# 部署(只传 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
实测输出(摘录):
[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 四个真机硬结论(都是本次实测踩出来的,下一棒别再踩)
- 🔴 重试定时器绝对不能
unref():断链后 WebSocket 句柄已消失,若连"重试计划"也是 unref 的,事件循环就空了 ⇒ 进程静默退出(节点"人间蒸发":不重连、不报错、日志停在最后一行,/status里再也等不到它)。本文件其余定时器 unref 是对的,唯独重试定时器不行。(单测发现不了 —— 只有在真机上才暴露,这也是"本机通过 ≠ 交付"的又一实证) - 🔴 停机必须"可控":
stop()里若只close(),空闲 keep-alive 连接会把进程挂住 ⇒ 旧进程迟迟不退 ⇒ 新进程EADDRINUSE⇒ 客户端只能一直撞那个正在draining的旧实例(表现为"重启后再也连不上")。修法 =http.closeAllConnections()+main.ts里 2s 硬兜底退出(等价 systemdTimeoutStopSec)+shutdownGraceMs(300ms)通知窗口。 - 🔴 优雅重连要用"时窗"而不是"次数":重启耗时不可预测(实测一次
stop()自身就要 1.8s),固定 4 次会在"差一点点"处失败并把退避直接拉到 60s ⇒ 计划内重启被放大成长时间中断。 - 🔴 满载是唯一的硬门,且已在册节点重连永远优先:容量检查写成
sessions.has(hostId)放行 —— 否则平台自己重启一次,节点就会被自己的满载规则挡在门外。 - ⚠️ 临时验收进程必须回收:本次在 47 上发现上一轮验收残留的 relay 进程(
node lib/net/relay/main.js --port 20080,已运行 8 分钟,占着 20080)⇒ 导致新 relayEADDRINUSE、客户端连上旧实例报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 打分公式(可解释是这个模块存在的意义)
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 验收证据(命令原文级)
⚠️ 本轮两处取证纠正(⛔ 勿沿用旧结论)
- 🔴 门户 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 块。 - 🔴
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 本轮三条硬结论(下一棒必读)
- 🔑
--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」保证 —— 本就不存在共享的端口编号空间。 - 🔴 "占着实例端口的不一定是残留进程" —— 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。 - 🟡
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可留,不占端口即无害)