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

55 KiB
Raw Blame History

覆盖网络 · 传输方案取舍:开放端口 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)
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 本机验收(可复现命令 + 实测)

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 连接状态机(显式七态:布尔说不出"正在握手"还是"正在退避")

  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 四个真机硬结论(都是本次实测踩出来的,下一棒别再踩)

  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 打分公式(可解释是这个模块存在的意义)

  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 可留,不占端口即无害)