交接单按归属约定(正文落文档库、工作区只放指针)归档至 归档/交接单-20260924-归档/,含逐件判定 README: - 16 件已被文档库正式版取代(T09–T21 + 覆盖网络-24/25/26) - 4 件主题已被覆盖网络线入口汇总 - 7 件历史接续包/规划件 CODEBUDDY §9:收口清本棒 tmp、tmp 保留期 7 天、禁「待清理」中间态、 工作区入库只放文档与文件、不保留脚本副本、>60 KB 单文件须逐个判。
41 KiB
交接单 · 443/TCP 兜底(覆盖网络线 · 序 ④)
线:覆盖网络线 | 序:④(定序 ③→④→⑤,见入口 §0) 性质:规划棒产出(本单只出单,⛔ 未动任何服务器、未改任何代码) 产出时间:2026-09-17 08:4x | 规划会话:覆盖网络线-序4规划棒-20260917-0837 唯一来源:
覆盖网络_应用场景与待完善清单 §五(主线第 ④ 项)+覆盖网络_全球架构复盘 §3/§8+覆盖网络_补遗与参考方案 §2.4/§2.8/§三/§四上一轮:序 ③ 已收官(交接单_一机一钥与信任根_20260917.md§8)⇒ ⛔ 不要重做 ②/③ 执行棒:按本单 §5 开工,不读规划会话上下文;收口必须回填 §8。
§1 目标
在 47 上为 relay 增加一条「不经 Cloudflare、不经门户站点 conf」的 443/TCP 直连入口,并把它作为第二条引导种子广播进签名目录,使只能出 443/TCP 的节点在 CF / 门户站点任一不可用时仍能完成会合与 relay 注册;全程 零新增公网端口、零新增证书、零新增域名、零花费。
判定"做完了没有" = §6 的 D1–D8 全绿(每条都能被第三方复现)。
为什么这是序 ④ 的最小正确形态(不是妥协,是已排过序):
- 客户端只能出 443/TCP ⇒ 端口必须仍是 443,⛔ 不能用"非标端口"当兜底。
- 现役唯一入口 =
wss://alotbuy.com/dshs-relay,链路 = 客户端 → CF → 门户 nginx 443 块 →127.0.0.1:20080⇒ CF 与门户站点 conf 是两个共用失败域。 - 骨架里已存在多入口的承载物(
DEFAULT_OVERLAY_SEED常量位 /DSHS_OVERLAY_BOOTSTRAP_SEEDS逗号多值 / 签名目录relays[]+bootstrap[]/ 逐 origin 失败continue)⇒ 兜底不需要新协议、不需要新组件,只需要"补一条入口 + 广播 + 可解释"。 config.ts:167的原文已写明该常量位的设计意图 = 「已持证书、不新增域名」⇒ 本单正是把它用起来。
§2 只读前置(⛔ 只读,不改)
执行前逐条核一遍,任何一条与"期望"不符 ⇒ 先停下来报告,别猜。
47 = 47.77.182.89、106 = 106.54.21.172(按既有惯例;ssh 别名以本机~/.ssh/config为准)。
| # | 核什么 | 命令 | 期望 |
|---|---|---|---|
| P1 | 三单元在跑 | ssh 47 'systemctl is-active dshs dshs-relay dshs-pg nginx' |
4×active |
| P2 | 监听面基线(收尾要逐字一致) | ssh 47 'ss -lntp | grep -E ":(443|20080)\b"' |
nginx 0.0.0.0:443;node 127.0.0.1:20080(不是 0.0.0.0) |
| P3 | 现役入口在哪个 conf;目录端点的 location 是否已存在 | ssh 47 'nginx -T 2>/dev/null | grep -nE "dshs-relay|dshs-overlay"' |
命中门户 conf(alotbuy.com.conf);🔴 若 没有 /dshs-overlay/bootstrap 的 location ⇒ 本单 S1 要补(否则兜底入口拿不到签名目录) |
| P4 | CF 现状 + 证书是否已覆盖兜底子域 | dig +short alotbuy.com @1.1.1.1;ssh 47 'openssl s_client -connect 127.0.0.1:443 -servername relay-direct.alotbuy.com </dev/null 2>/dev/null | openssl x509 -noout -text | grep -A1 "Subject Alternative Name"' |
前者 = CF 段(104.x/172.6x);后者 SAN 含 *.alotbuy.com ⇒ 零新证;⛔ 若不含 ⇒ 停 |
| P5 | 该机的引导链 env 现状(决定广播是否生效) | ssh 47 'grep -rn DSHS_OVERLAY /etc/systemd/system/dshs.service.d/*.conf'|同样看 106 |
47 预期未配 DSHS_OVERLAY_BOOTSTRAP_SEEDS(= 只有内置常量)|106 若有 --url / DSHS_RELAY_URL ⇒ 压制引导链,须先记录(见 §5 S2-附) |
| P6 | 签名目录原文 | curl -s --http1.1 https://alotbuy.com/dshs-overlay/bootstrap | head -c 400 |
200 + JSON,含 network / version / relays[] / bootstrap[] 与 sig |
| P7 | relay 容量与身份现状 | ssh 47 'curl -s 127.0.0.1:20080/status' |
含 capacity、identityRequired、identityOk(序 ③ 后应 = 3)、online[] |
| P8 | 本机代码基线 | git -C D:/github/dsh_shenxian rev-parse HEAD |
640813e(⚠️ 工作区有未提交改动,⛔ 不要 checkout 掉) |
判据先说清(P5 → S2 的因果):net/relay/main.ts 里 --url / DSHS_RELAY_URL 算"env 显式"、压制整条引导链(原文:压制引导链,运维最后手段);而 DSHS_OVERLAY_BOOTSTRAP_SEEDS 才是"入口列表"。⇒ 兜底要生效,入口必须走 seeds,⛔ 不能靠 DSHS_RELAY_URL。
§3 范围
3.1 要改的(且只有这些)
| 层 | 位置 | 改什么 |
|---|---|---|
| 47 · nginx | 新文件(建议 /www/server/panel/vhost/nginx/relay-direct.conf) |
新增一个 独立 443 server 块:server_name relay-direct.alotbuy.com(名字见 §4.2),证书路径从门户 443 块原样复制,两个 location:/dshs-relay → 127.0.0.1:20080|/dshs-overlay/bootstrap → 平台 web(127.0.0.1:3080,以 P3 实测为准) |
| 47 · systemd | /etc/systemd/system/dshs.service.d/*.conf |
DSHS_OVERLAY_BOOTSTRAP_SEEDS = 主入口在首位 + 兜底项(追加,⛔ 不改动其它行) |
| 本机 · 代码 | src/net/relay/client.ts(建连点 L415)、src/config.ts(配置字段)、test/overlay-*.test.mjs |
新增"地址覆盖 + 指定 SNI"能力(详见 §5 S3),使兜底入口在 DNS 不可用/被污染时也能连 |
3.2 ⛔ 不动什么(防顺手扩大)
- ⛔ 不动
alotbuy.com.conf门户 443 server 块(server_name/ 证书 / 既有 location 一律不碰) - ⛔ 不动 relay 进程:仍
127.0.0.1:20080、不新增--host、不在 relay 侧做 TLS(relay 是纯ws://,TLS 由 nginx 终结 —— 已核实) - ⛔ 不新增公网端口(客户端仍只走 443/TCP)、不新增 DNS 记录、不新增证书、不新增域名
- ⛔ 不动 Cloudflare 配置;不动
dshs(Manager 主服务)与dshs-pg - ⛔ 不碰序 ③ 的成果:四层密钥、
identityRequired语义、/etc/dshs/relay-keys.json - ⛔ 不碰数据面(打洞/UDP)、不碰 presence / 房间层(主线第 7 步)
- ⛔ 不做跨机第二实例(L3 真容灾,见 §4.1-6)
- ⛔ 不 commit、不 push
§4 决策点
4.1 已定项(规划棒已拍 —— 执行棒不得自行更改;要改必须回写 §8 并说明理由)
- 兜底通道 = 仍是 443/TCP 上的 wss,同协议同端口、零新增公网口。依据:复盘 §3「兜底通道 = HTTPS/443」+ 清单 §五 第 4 项。
- 兜底要分离的失败域按价值分三层,本单做 L1/L2,L3 明确后置:
- L1 去 CF(最高价值):客户端支持"目标地址覆盖 + 指定 SNI"⇒ CF 不可用/被墙时仍可达。本单做。
- L2 去门户站点 conf:47 上为 relay 建独立 server 块(独立
server_name)⇒ 门户 conf 被改坏/被面板重写时 relay 入口不受影响。本单做。 - L3 去 nginx 进程与单实例:需换机或第二公网 IP ⇒ 入站面扩大(命中 R5)+ 属序 ⑤ 权限评估范畴 ⇒ ⛔ 本单不做,登记为序 ⑤/⑥ 候选。
- 入口列表的唯一载体 =
DSHS_OVERLAY_BOOTSTRAP_SEEDS(逗号多值,已支持)。⛔ 不再用--url/DSHS_RELAY_URL表达入口 —— 它压制引导链,会把兜底关掉。 - 入口的在线轮换载体 = 签名目录:
src/web/routes/overlay.ts已把relays[] = relayUrl + seeds、bootstrap[] = seeds写进签名文档 ⇒ 改一次 47 的 seeds,全网已入网节点下一次刷新自动拿到兜底入口(不重装)。这是本单的主要收益机制,S2 必须验证它真的生效。 - 地址覆盖(IP)⛔ 不许塞进 URL 或签名目录 —— 必须走独立配置项。理由:
directoryUrlFor()会重写 path 并清掉search/hash(覆盖信息会被吃掉);且 IP 进签名目录 = 把拓扑写进全网可见文档(违反"控制面下发全网名单"反模式 §四-4)。 - 不做容量承诺:45% 设计口径(异构纪律,⛔ 非同构 15%)只登记口径与待填参数位置(relay
--max-hosts//status.capacity/retryAfterMs),具体数值属序 ⑤ 参数表。 - 可解释是验收项,不是加分项:引导链已有
source=/detail=与逐 origin 拒绝原因 ⇒ 必须能 grep 出"为什么走了兜底"。依据:补遗 §2.4-11 / §2.8-24。
4.2 交给执行棒自决(⛔ 不上升为提问)
兜底子域名具体字面(建议 relay-direct.alotbuy.com)· conf 放置方式(建议独立文件 —— 门户 conf 受宝塔面板管理,独立文件避免被面板重写)· 地址覆盖的配置项名与语法(建议 DSHS_OVERLAY_ADDR_OVERRIDES=<域名>=<IP> 逗号多值)· WebSocket 侧的注入方式(client.ts:415 的 WebSocketCtor 已是注入点,第二参 dispatcher 需同步放宽类型)· 种子项顺序与是否同时放 IP 直连项 · 缓存路径 · 备份文件命名 · 单测用例划分。
4.3 真需要用户拍板的(命中才问,且一轮只问这一句)
条件式:只有 S0 取证(或 S3 实测)证明「地址覆盖 + 指定 SNI」在当前 WebSocket 依赖下不可实现、且引入代码改动被否时,才需要你出手 —— 那时唯一缺口 = 兜底子域没有 DNS 记录,需要有人在 Cloudflare 侧加一条记录。两种做法:
A:你在 Cloudflare 加一条灰云 A 记录(relay-direct.alotbuy.com → 47.77.182.89)。
优点:零代码改动,客户端不需要新能力,工作量最小。
缺点:依赖你的账号与人工操作;DNS 层被污染时仍不可达(只解 CF,不解 DNS 层)。
B:把 CF 的只读/受限 API 凭据给我,之后这类记录我自己维护。 优点:后续同类入口(第五步参数表、第六步最小形态)不再需要人工介入。 缺点:凭据外发是一次权限扩大,需要你评估;且我仍要处理"DNS 层不可达"的残余风险。
我的倾向:A 优先(一次性、可随时撤回)。若 S3 的地址覆盖实测通过,则 A/B 都不需要,本单零依赖关闭。
4.4 技术实现裁决顺序
按 dsh-decision-method §4.4;规则冲突按下序取首个命中项:R8(开发环境服务器 ⇒ 该动就动,动手前一句话说明) → §1 边界内自决清单(部署/重启/改配置/nginx/技术选型) → §3 其余红线(R5/R7/R9/R10 永远是硬约束)。⛔ 冲突 ≠ 门禁。
§5 步骤(S0–S5,每步自带一次可执行的验证)
S0 · 只读取证(⛔ 不改任何东西)
跑完 §2 的 P1–P8,产出一段"现状快照"(命令 + 原文输出 + 判定)。任何一条与期望不符 ⇒ 停下报告。 额外取三件(本单设计的必要输入):
- 门户 443 块的完整原文(证书路径 /
http2 on/map $http_upgrade位置)⇒ S1 要原样复制; /dshs-overlay/bootstrap当前由哪个后端提供(3080 还是别的)⇒ S1 的proxy_pass目标;- 47 / 106 上
DSHS_RELAY_URL、DSHS_RENDEZVOUS_URL、DSHS_OVERLAY_*的全部现值。
S1 · 兜底入口落 nginx(47)
新建独立 server 块(拷贝门户 /dshs-relay 的 location 正文,只改 server_name 与证书两个变量):
listen 443 ssl; http2 on; + server_name <兜底子域>; + location /dshs-relay { proxy_pass http://127.0.0.1:20080; … } + location /dshs-overlay/bootstrap { proxy_pass http://<P3 实测后端>; … }。
nginx -t 必须先过,再 reload(先备份:cp <conf> <conf>.bak-20260917-HHMM-pre-443fb)。
验证(47 本机):curl -i -s -N --http1.1 --resolve <兜底子域>:443:127.0.0.1 -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" https://<兜底子域>/dshs-relay | head -3 ⇒ HTTP/1.1 101 Switching Protocols;目录端点 ⇒ 200 + 含 "sig"。
S2 · 种子广播(47)
DSHS_OVERLAY_BOOTSTRAP_SEEDS=https://alotbuy.com/dshs-relay,https://<兜底子域>/dshs-relay(主入口必须在首位)→ daemon-reload → restart dshs。
S2-附(若 P5 发现 106 上有压制项):先把 106 的 --url / DSHS_RELAY_URL 撤掉(改为 seeds 表达),否则 106 永远走 env、拿不到兜底 —— ⚠️ 这一步会改变 106 的取址路径,属于"会中断在线用户"的动作 ⇒ 动手前一句话说明,并准备好回滚(§7)。
验证:curl -s --http1.1 https://alotbuy.com/dshs-overlay/bootstrap | python -c "import sys,json;d=json.load(sys.stdin);print(d['relays'],d['bootstrap'])" ⇒ 两个数组都含兜底项,且主入口在首位;version 递增。
S3 · 客户端「地址覆盖 + 指定 SNI」(本机代码)
在 client.ts:415 的建连点接入"覆盖地址 + SNI = 域名"(WebSocketCtor 已是注入点)。加单测:覆盖生效时实际连的是覆盖 IP、TLS 校验用的是域名;未配覆盖时行为与今天逐字一致(不退化)。
npm test 必须 Node 22。scp 到 47(⚠️ 47 的 lib 路径 = /opt/dshs/lib/)。
验证:本机把兜底子域指向一个不存在 DNS 的名字 + 覆盖项指向 47 真实 IP ⇒ 仍 HTTP/1.1 101。
S4 · 降级可解释 + 降级实测(⛔ 不许弄坏生产入口)
只在测试进程上验降级:用 DSHS_OVERLAY_BOOTSTRAP_SEEDS=https://127.0.0.1:9/dshs-relay,https://<兜底子域>/dshs-relay 起一个独立 relay-client 进程(主入口故意不可达)⇒ 日志必须出现"逐 origin 拒绝原因 + 最终采用兜底项"两行,且注册成功。⛔ 不允许为验收而改生产的 seeds 顺序。
验证:journalctl/stdout 里可 grep 到 source=、detail=、兜底项 URL 与成功注册行。
S5 · 端到端复验 + 不退化检查(47/106 双机)
① w-47(19100) 与 w-106(19000) 实例面仍通;relay /status 的 online[] 含两台;② identityRequired/identityOk 与 S0 逐字一致;③ 监听面与 P2 逐字一致(零新增公网口);④ 门户 curl -H "Host: alotbuy.com" 127.0.0.1:3080/ = 200;⑤ relay 侧 not_found=0;⑥ npm test 全绿。
⑦ 45% 口径登记:把"兜底启用后,relay 容量须按 45% 节点走中继核算"写进回报,并留给序 ⑤ 参数表(⛔ 本单不填数值)。
§6 验收(判据清单)
| # | 判据 | 命令 | 期望 |
|---|---|---|---|
| D1 | 兜底入口 101 | curl -i -s -N --http1.1 --resolve <兜底子域>:443:47.77.182.89 … /dshs-relay | head -1 |
HTTP/1.1 101 Switching Protocols(⚠️ 必须 --http1.1,http2 on 会假 404) |
| D2 | 兜底入口不经 CF | 同上但目标 IP = 47 真实 IP;且 dig +short <兜底子域> |
无记录 / 与 CF 段不同 ⇒ 未走 CF |
| D3 | 兜底入口不依赖门户 conf | ssh 47 'nginx -T | grep -c "server_name <兜底子域>"';临时把门户块里 location /dshs-relay 注释 → 兜底仍 101 → 恢复 |
命中 1;门户块失效时兜底不受影响(⚠️ 事后必须逐字还原) |
| D4 | 目录端点同源可用 | curl -s --http1.1 --resolve <兜底子域>:443:47.77.182.89 https://<兜底子域>/dshs-overlay/bootstrap |
200 + JSON 含 sig(目录与中继同源是既有约定,缺了则兜底拿不到目录) |
| D5 | 广播生效 | 见 S2 命令 | relays[] 与 bootstrap[] 均含兜底项,主入口首位,version 递增 |
| D6 | 降级可解释 | 见 S4 | 日志含逐 origin 拒绝原因 + 采用兜底项 + 注册成功 |
| D7 | 零退化 | P2 复跑 + 双实例面 + identityOk + 门户 200 + npm test |
与 S0 快照逐字一致(除"多了一个 server 块/一条 seeds") |
| D8 | 零新增暴露面 | ssh 47 'ss -lntp | wc -l'、nft list ruleset、公网 curl 47.77.182.89:<非443> |
监听口数量与 S0 一致;无新增公网端口 |
判定:D1–D8 全绿 = 本单完工。任一红 ⇒ 按 §7 该项回滚,并在 §8 写清"卡在哪 + 已做到哪一步 + 什么条件一出现必须回头解决"。
§7 回滚
| 项 | 回滚 |
|---|---|
| S1 nginx | cp <conf>.bak-20260917-HHMM-pre-443fb <conf> → nginx -t → nginx -s reload(或删掉新建的独立 conf 文件后同两步) |
| S2 seeds | 从 drop-in 撤掉兜底项 → daemon-reload → restart dshs(⚠️ 主入口项必须保留,别整行删) |
| S2-附 106 取址 | 恢复 106 原 DSHS_RENDEZVOUS_URL / --url 写法 → daemon-reload → restart 该单元 |
| S3 代码 | 本机回滚靠备份副本(⛔ 未授权 commit,git checkout -- 会连带丢掉工作区其它未提交改动);47 侧回滚 = 恢复 /opt/dshs/lib/ 旧的 net/relay/*.js 副本(铺前必须先备份) |
| S4 | 临时进程必须回收(pidfile 或 systemctl;⛔ 不要 pkill -f,会命中 ssh 自身命令行) |
§8 回报格式(执行棒按此格式收口)
## §8 执行回报(执行棒 · 2026-09-17 HH:MM)
1. S0 快照:P1–P8 逐条 = 命令原文 + 输出 + 判定(不符的先说)
2. 落盘清单:新建/改动的**每个文件绝对路径** + 备份文件名 + 改前改后指纹(md5/hash-object)
3. D1–D8:逐条命令原文 + 原文输出 + 绿/红
4. 降级实测(S4):日志原文两行(拒绝原因 + 采用兜底)
5. 不退化:P2 复跑对照 / identityOk / 门户 200 / npm test 计数(a/b/c)
6. 45% 口径:登记行原文(⛔ 无具体数值)
7. 未过项 / 遗留:逐条写"卡在哪(证据)+ 已做到哪一步 + 什么条件一出现必须回头解决"
8. 指纹:本单回填后 `md5sum`
附 A · 本规划棒已核实的事实(执行棒不必重复探索)
| 事实 | 依据(本地代码 / 文档) |
|---|---|
relay 没有 TLS,opts.host ?? '127.0.0.1'、http.listen(port, host) ⇒ 只绑回环,TLS 全由 nginx 终结 |
src/net/relay/server.ts:359/405/410(日志原文 loopback only) |
relay 没有 --host 参数(--host 在 client 模式 = hostId)⇒ "让 relay 直听 443" 必须改代码 ⇒ 已判为更差候选,拍掉 |
src/net/relay/main.ts:55–92 |
引导链顺序 = ① env 显式(压制)→ ② 缓存目录(未过期)→ ③ 取目录(缓存 bootstrap[] 优先 + seeds),逐 origin 失败 continue |
src/net/relay/main.ts:216–232、src/net/relay/directory.ts:530+ |
--url / DSHS_RELAY_URL = "env 显式",压制引导链;入口列表变量 = DSHS_OVERLAY_BOOTSTRAP_SEEDS(逗号多值) |
同上 + directory.ts:74–79 |
内置种子常量 = https://alotbuy.com/dshs-relay(单值);常量位注释已写"已持证书、不新增域名" |
directory.ts:72、config.ts:167–178 |
目录端点路径 = /dshs-overlay/bootstrap;"引导地址 = 中继入口同源" |
directory.ts:45、config.ts:178 |
签名目录自动带上入口:relays[] = relayUrl + seeds、bootstrap[] = seeds ⇒ 改 seeds 即全网广播 |
src/web/routes/overlay.ts:58–60 |
种子条目语法 = 必须是合法绝对 URL(http/https/ws/wss);directoryUrlFor() 会重写 path 并清 search/hash;isPublicHost() 拒绝私网/CGNAT/IPv6;上限 MAX_ENTRIES=8、MAX_ENTRY_LEN=512 |
directory.ts:190–208 / 322–337 / 355–372 / 61–62 |
客户端建连 = new Ctor(this.opts.url),WebSocketCtor 可注入(默认全局 WebSocket)⇒ 覆盖地址/SNI 有落点 |
src/net/relay/client.ts:63/415 |
| 目录刷新周期 = 300 s;取目录超时 = 5 s | directory.ts:58/472 |
relay 容量/准入 = --max-hosts(现为 0 = 不限)+ /status.capacity + at-capacity/retryAfterMs(5000 ms) |
main.ts argv、server.ts:63 |
47 的 relay 单元 = /etc/systemd/system/dshs-relay.service(--port 20080 --keys-file /etc/dshs/relay-keys.json);拨号白名单 drop-in = dshs-relay.service.d/dialers.conf |
交接单_relay落地R2-R4_20260916.md §9/§11 |
门户 443 块 = /www/server/panel/vhost/nginx/alotbuy.com.conf;dsh.alotbuy.com.conf 是遗留 301 域名(⛔ 不能往它里面加 location) |
同上 §9 |
附 B · 硬约束复述(防走偏)
- ⛔ 本轮只出单:⛔ 不改服务器、不改代码、不 commit、不 push、不做序 ⑤、不碰 presence。
- ✅ 只做正向迭代(R11):本单任何一步若让任一维度净变差 ⇒ 立即停下复盘;拿不出正向做法 ⇒ 停止执行。
- ✅ 权限只准收窄(R5):本单零新增公网端口、零新增入站面、零凭据外发;若有任一改动会扩大权限面 ⇒ 先停下报告。
- ⛔ R7:只做 §3.1 列出的改动;执行中发现别的"小问题"先报告、后动手。
- ⚠️ curl 判据必须
--http1.1(门户http2 on,否则假HTTP/1.1 404/HTTP/2 404)。 - ⚠️ 47 的 lib =
/opt/dshs/lib/、106 =/opt/dshs-cluster/lib/;部署 = 本机 build 后 scp;npm test必须 Node 22。 - 📌 本单不做容量承诺(参数表 = 序 ⑤);不做跨机真容灾(L3,需 R5 权限评估)。
§8 执行回报(执行棒 · 2026-09-17 09:1x)
执行会话:
覆盖网络线-序4执行棒| 锁:--claim-exec抢到(09:00)→ 收口--release-exec本单 md5(回填前)=b9aa6bbc0a458481627f7aeb1f17ab54—— 与规划棒交付值逐字一致 ⇒ 无人改动过。
8.1 S0 快照(P1–P8:命令原文 + 输出 + 判定)
先把三处"与期望不符"说清(都是判据本身要改口径,不是故障):
(1)P1 的 nginx 一栏 = inactive。 真因:本机 nginx 不归 systemd 管 —— 主进程 = /www/server/nginx/sbin/nginx -c /www/server/nginx/conf/nginx.conf(宝塔直接拉起),nginx.service 是 sysv 生成的壳、状态恒 inactive (dead)。
⇒ 判据改为 ss -lntp | grep :443 + pgrep nginx。服务实际正常(443 在听、门户 200)。
(2)P3 的目录端点在 nginx -T 里没有独立 location。 实测只命中门户块的 location /dshs-relay;/dshs-overlay/bootstrap 是被门户块 location /(→ 127.0.0.1:3080)顺带承载的。
⇒ 按 P3 的"🔴 若没有 ⇒ S1 要补",S1 补了独立 location;并因此发现第二件事(见 8.4-D4)。
(3)P4 的 DNS:relay-direct.alotbuy.com 能解析,且落在 CF 段。
判据:随机名 zzz-rand-9f3a.alotbuy.com 同样解析到同一对 CF IP ⇒ 本域是 *.alotbuy.com 泛解析(橙云代理),不存在"没有记录"这一情形。
⇒ 这条使 D2 的字面判据不可满足(实质判据已证,见 8.4-D2)。
| # | 命令(原文) | 输出(原文摘要) | 判定 |
|---|---|---|---|
| P1 | systemctl is-active dshs dshs-relay dshs-pg nginx |
active active active inactive |
⚠️ 见上(nginx 改口径) |
| P2 | ss -lntp | grep -E ":(443|20080)\b" |
127.0.0.1:20080(node) + 0.0.0.0:443(nginx) |
✅ relay 只绑回环(不是 0.0.0.0) |
| P3 | nginx -T | grep -nE "dshs-relay|dshs-overlay" |
仅 location /dshs-relay { |
⚠️ 见上(目录端点由 location / 承载) |
| P4 | openssl s_client … -servername relay-direct.alotbuy.com | grep -A1 "Subject Alternative Name" |
DNS:*.alotbuy.com, DNS:alotbuy.com |
✅ 零新证;DNS ⚠️ 见上 |
| P5 | grep -rn "DSHS_OVERLAY|DSHS_RELAY_URL|DSHS_RENDEZVOUS" /etc/systemd/system/dshs.service.d/ /etc/dshs.env | 106 同样 |
47:无 DSHS_RELAY_URL / 无 seeds(cluster.conf:15 注明 P0-2 已清)|106:无 --url / DSHS_RELAY_URL,但有 DSHS_RENDEZVOUS_URL=wss://alotbuy.com/dshs-relay |
✅ 符合预期 ⇒ S2-附 不触发(见 8.2) |
| P6 | curl -s --http1.1 --resolve alotbuy.com:443:127.0.0.1 https://alotbuy.com/dshs-overlay/bootstrap |
200 + {"version":1,…,"relays":["https://alotbuy.com/dshs-relay"],"bootstrap":["https://alotbuy.com/dshs-relay"],"sig":"…"} |
✅ |
| P7 | curl -s 127.0.0.1:20080/status |
online=[manager, w-106]、capacity{max:0,used:2}、identityRequired:true、identityOk:3、trustedSigners:1、revokedHosts:0 |
✅ |
| P8 | git -C D:/github/dsh_shenxian rev-parse HEAD |
640813e84e5e48c56ca39d765d7b195884e58ebc |
✅ |
| D8基 | ss -lntp | wc -l / nft list ruleset | wc -l |
79(表头 1 + 78 条)/ 72 |
✅ 基线 |
⚠️ S0 顺带核出、必须记下来的一条事实:
src/net/relay/整目录未被 git 跟踪 ——git status --porcelain -- src/net/relay/=?? src/net/relay/;git ls-tree -r --name-only HEAD -- src/net/relay/= 0 个文件。 ⇒ 覆盖网络线(R1 起)的代码只存在于工作区 + 部署产物里。这既是"⛔ 别git checkout"那条纪律的实证依据,也意味着 47 上的*.bak-*pre-443fb是本单唯一的改前基线(见 8.3)。
8.2 S2-附:不执行(前提不成立)+ 一处必须报告的风险(未动手)
P5 实测 106 没有 --url / DSHS_RELAY_URL ⇒ S2-附 的触发条件不满足,未执行(符合单里"若…才有"的条件式写法)。
但顺手核出的这条必须报(⛔ 未动手):106 上确有 DSHS_RENDEZVOUS_URL=wss://alotbuy.com/dshs-relay,它不是 DSHS_RELAY_URL、不参与引导链,而是 worker agent 的会合地址:
- 依据(本地代码,逐行可核):
src/worker/agent.ts→rendezvousRaw = options.tunnelTarget ?? config.clusterRendezvousUrl ?? '';src/config.ts→clusterRendezvousUrl = DSHS_RENDEZVOUS_URL ?? DSHS_TUNNEL_TARGET ?? '';随后rendezvousRaw === '' ? undefined : …直接构造RelayTunnel({ url: rendezvousRaw })。 - ⇒ 106 的 agent 面根本不消费引导链:该变量一旦按 S2-附 的字面撤掉,
rendezvousRaw变空 ⇒tunnel === undefined⇒ 106 的 agent 隧道直接不起(生产回归)。 - ⇒ 结论:S2-附 的写法("撤掉 env、改由 seeds 表达")在 106 上不成立,未执行,106 现状逐字未动(登记为遗留 8.7-②)。
8.3 落盘清单(绝对路径 + 备份 + 改前/改后指纹)
本机代码(D:\github\dsh_shenxian) —— ⚠️ 全部未 commit、未 push(本单明令)
| 文件 | 动作 | 指纹 git hash-object |
|---|---|---|
src/net/relay/addr-override.ts |
新建(地址覆盖模块) | 4703a73ac82b66d113005c7fc10557d64b76407c |
src/net/relay/client.ts |
改(建连点注入) | 5152378446987a97fa3dbff64398c6e2737d03f5 |
src/net/relay/directory.ts |
改(取目录腿注入 + 同源优先) | 36e2a86874279c818faa20aa2b66687f92cf2f25 |
test/overlay-bootstrap.test.mjs |
改(+7 用例 L1-A…G) | 8aef224e5053c78fe56de716d6e408b549475a7e |
本机取不到 git 改前基线:
src/net/relay/与test/overlay-bootstrap.test.mjs均不在 HEAD(见 8.1 注)⇒ 改前基线以 47 上的.bak-*为准(下表)。
47 · /opt/dshs/lib/net/relay/(部署 = 本机 npm run build 后 scp)
| 文件 | 动作 | 改前(备份) | 改后(生效) |
|---|---|---|---|
addr-override.js |
新增 | —(新文件) | a2bb27c3b021b49c1e35b707221a9871 |
client.js |
覆盖 | client.js.bak-20260917-0901-pre-443fb = 8cbeeb029e856ce174dd91fdd44a739a |
bc24cb70d047f0e711a17db87dfa6246 |
directory.js |
覆盖 | directory.js.bak-20260917-0901-pre-443fb = a5ca2103dee32dde154d965da9d471e0 |
214905d476c185baf87358c8a5e9d25e |
47 · 配置
| 文件 | 动作 | 指纹 |
|---|---|---|
/www/server/panel/vhost/nginx/relay-direct.conf |
新建(独立 443 server 块) | b7851b93c7bf885d73fc250813a4b42f |
/etc/systemd/system/dshs.service.d/overlay-443fb.conf |
新建(seeds + 地址覆盖) | 8eac06167cc4b1a772b64b640f150a89 |
/www/server/panel/vhost/nginx/alotbuy.com.conf |
未改(D3 临时改后用备份逐字节还原) | 改前 = 改后 = a747cabc2c03911e831a714f1e89a47d ✅ |
…/alotbuy.com.conf.bak-20260917-0900-pre-443fb |
备份(符合 §5 命名要求) | a747cabc2c03911e831a714f1e89a47d |
106:⛔ 未动任何文件(S2-附 不触发,见 8.2)。
8.4 D1–D8 逐条(命令原文 + 原文输出 + 绿/红)
| # | 命令(原文摘要) | 原文输出 | 判定 |
|---|---|---|---|
| D1 | curl -i -s -N --http1.1 --resolve relay-direct.alotbuy.com:443:<IP> -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" https://relay-direct.alotbuy.com/dshs-relay | head -1 |
HTTP/1.1 101 Switching Protocols |
🟢 |
| D2 | ① 同 D1 但 --resolve …:443:47.77.182.89(本机从公网探)② DNS 探针 |
① HTTP/1.1 101、Server: nginx、无 cf-ray / Server: cloudflare ② relay-direct.alotbuy.com -> ['104.21.44.42','172.67.194.206'](CF 段) |
🟢 实质 / 🔴 字面 |
| D3 | nginx -T | grep -c "server_name relay-direct.alotbuy.com";临时注释门户块 location /dshs-relay → 复测 → 还原 |
1;门户 = HTTP/1.1 502 Bad Gateway(已失效)、兜底 = HTTP/1.1 101(不受影响);还原后 md5 = a747cabc…(逐字节一致)、门户 101 / 兜底 101 / 首页 200 |
🟢 |
| D4 | curl -s --http1.1 --resolve relay-direct.alotbuy.com:443:<IP> https://relay-direct.alotbuy.com/dshs-overlay/bootstrap |
HTTP=200 + {"version":1,…,"relays":["https://alotbuy.com/dshs-relay","https://relay-direct.alotbuy.com/dshs-relay"],"bootstrap":[…],"sig":"…"} |
🟢 |
| D5 | curl -s --http1.1 https://alotbuy.com/dshs-overlay/bootstrap |
relays = ["https://alotbuy.com/dshs-relay","https://relay-direct.alotbuy.com/dshs-relay"](主入口首位);bootstrap 同;issuedAt 由 …00:54:03 → …01:01:46 |
🟢("version"口径见下) |
| D6 | 见 8.5 | 见 8.5(拒绝原因 + 采用兜底 + 注册成功) | 🟢 |
| D7 | P2 复跑 / 双机面 / identity / 门户 / npm test |
见 8.6(逐项一致) | 🟢 |
| D8 | ss -lntp | wc -l / nft list ruleset | wc -l / 外网探 5 个端口 |
79(= S0)/ 72(= S0)/ 全部"关闭" |
🟢 |
D2 的绿/红必须说清:字面判据要"dig 无记录 / 与 CF 段不同",而本域是 CF 泛解析 ⇒ 字面不可满足(🔴)。实质判据("兜底入口不经 CF")已用两条独立证据坐实 🟢:
① --resolve 直连 47 真实 IP ⇒ 101,响应头 Server: nginx、无任何 CF 特征头;
② 客户端侧(8.5 的 A 轮)把该域覆盖到 127.0.0.1 ⇒ 仍 101 且注册成功 ⇒ 链路里没有 CF。
额外数据点:走真实 DNS(= CF) 的同一请求也
101⇒ 兜底入口在 CF 路径下同样可用(并非"只在直连时可用")。
D5 的"version 递增"必须说清:目录的 version 是结构版本(directory.ts 的 DIRECTORY_VERSION = 1,常量)⇒ 永不变,"递增"这条判据物理上不成立。判"确有更新"应看 issuedAt 前移 + 两个数组内容变化,二者本次均已取到(见上表)。
D4 顺带发现并已修的一处(S1 施工时暴露):平台(3080)先按 Host 做租户路由、再进路由表,直接用兜底子域回源会命中 404 {"error":"unknown_user"}。
修法 = 该 location 回源时把 Host 改写为既有目录 origin(alotbuy.com)—— 即"同源"在入口层的等价翻译,发的就是门户今天在发的同一个公开只读请求,不扩大任何权限(该 location 用 = 精确匹配、本块其余路径一律 404)。
8.5 S4 降级实测(日志原文)
只在独立测试进程上做:/tmp/fb443/lib = /opt/dshs/lib 的副本,⛔ 未改生产 seeds;DSHS_OVERLAY_BOOTSTRAP_SEEDS=https://127.0.0.1:9/dshs-relay,https://relay-direct.alotbuy.com/dshs-relay。
A 轮(DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.alotbuy.com=127.0.0.1)—— 成功,日志原文:
[addr-override] 覆盖生效:relay-direct.alotbuy.com -> 127.0.0.1(IPv4,直连该 IP,TLS SNI 仍为 relay-direct.alotbuy.com)
[overlay-dir] ⚠ 拒绝 https://127.0.0.1:9/dshs-overlay/bootstrap(unreachable: fetch failed)—— 未签名 / 签名不符的目录**不写缓存、不采用**
[overlay-dir] ↪ 同源优先:目录由 https://relay-direct.alotbuy.com/dshs-overlay/bootstrap 答出 ⇒ 采用其同源中继入口 wss://relay-direct.alotbuy.com/dshs-relay(不在 relays[] 首位;首位 wss://alotbuy.com/dshs-relay 本次未被采用)
[relay-client] registered host=w-47 network=ops session=7cbddf7256503e02 accepted=[19999] clockSkew=4ms
① 逐 origin 拒绝原因 ✅ ② 采用兜底项 ✅ ③ 注册成功 ✅ ④ 同时坐实 S3 的「地址覆盖 + 指定 SNI」:TCP 到 127.0.0.1、SNI = relay-direct.alotbuy.com、证书(*.alotbuy.com)校验通过。
(首轮曾用 --ports 29999,被 relay 以 AUTH DENY … port-out-of-range retryable=false 拒 —— 属测试参数越界,改 19999 即通过。)
B 轮(<IP>=192.0.2.1,TEST-NET 不可路由)= 判定对照:
[overlay-dir] ⚠ 拒绝 https://relay-direct.alotbuy.com/dshs-overlay/bootstrap(unreachable: The operation was aborted due to timeout)
[overlay-dir] ⚠ 取目录全部失败 ⇒ 回落到内置种子地址本身:wss://127.0.0.1:9/dshs-relay
⇒ 覆盖确实改写了目标地址(若没生效,DNS 会给 CF、而 CF 是通的 ⇒ 不会超时)。这是 A 轮结论的反证。
回收:测试进程用完即走(timeout 收尾),业务面已核无残留(见 8.6 末条)。
8.6 不退化(P2 复跑对照 / identity / 门户 / npm test)
| 项 | S0 | 收口 | 判定 |
|---|---|---|---|
| 监听面 | 127.0.0.1:20080 + 0.0.0.0:443 |
逐字一致 | ✅ |
| 监听口总数 | 79 |
79 |
✅ |
nft 行数 |
72 |
72 |
✅ |
online[] |
[manager, w-106] |
[manager, w-106] |
✅ |
endpoints |
w-106:19000 / 21000 online |
逐字一致(S4 残留的 w-47:19999 已清,见下) |
✅ |
identityRequired / trustedSigners / revokedHosts |
true / 1 / 0 |
true / 1 / 0 |
✅ |
| 47 agent(19100) / 实例面(3080) | 200 / 200 |
200 / 200 |
✅ |
106 agent(19000) / dshs-worker |
— | 200 / active |
✅ |
| 门户首页 | 200 |
200 |
✅ |
npm test(Node 22.22.2) |
— | 137 用例 / 136 通过 / 1 跳过 / 0 失败(含本单新增 7 条 序④·L1-A…L1-G) |
✅ |
| 兜底日志里的 404 | — | 仅 /portal.html、/ 两条 = 我自己收窄验证的探针,无意外 404 |
✅ |
计数器口径澄清(否则会被读成回归):
authed/authFailed/identityOk是累计计数,"逐字一致"物理上不成立。本次全部增量都能逐条归因:
authFailed=3(中间态)= 2 ×[relay] AUTH TIMEOUT(= D1/D2 的curl101 探针:连上但不发 HELLO,relay 等鉴权超时)+ 1 ×[relay] AUTH DENY … port-out-of-range(= A 轮那次的参数越界)。identityOk增量 =dshs/dshs-relay每次重启后 manager 重连各 +1。- 收尾重启了一次
dshs-relay清账(顺手清掉 S4 残留的w-47:19999端点与其动态回环口43439—— 它一度把监听口顶到80)⇒ 收口态:online=[manager,w-106]、endpoints只余 w-106 两条、监听口回79、identityOk=2(两台节点各一次认证成功)、authFailed=1(收口后又打了一次 101 探针)。 ⇒ 读数提醒:101 探针本身会污染authFailed。以后看这个计数器,先扣掉自己打的探针次数。
8.7 未过项 / 遗留(逐条:卡在哪 + 已做到哪一步 + 什么条件一出现必须回头解决)
① D2 字面判据不可满足(实质已证)
- 卡在哪:
*.alotbuy.com是 CF 泛解析(证据:随机名zzz-rand-9f3a.alotbuy.com同样解析到104.21.44.42 / 172.67.194.206)⇒"该子域没有 DNS 记录"这个前提不存在。 - 已做到哪一步:实质判据(不经 CF)已用两条独立证据坐实(8.4-D2);且 §4.3 的 A / B 两个候选都不需要(既不需要人工加记录,也不需要 CF 凭据)⇒ 本单零依赖关闭。
- 什么条件一出现必须回头解决:若要让解析层也不经 CF(例如应对 DNS 层投毒/污染),才需要把该子域设成灰云直连 A 记录;在那之前,运行期的"去 CF"由
DSHS_OVERLAY_ADDR_OVERRIDES保证。
② 106 的 agent 面不吃引导链(已定位,⛔ 未动手)
- 卡在哪:见 8.2 ——
worker/agent.ts把DSHS_RENDEZVOUS_URL直接当 relay URL 使用,不经过resolveOverlayRelay。 - 已做到哪一步:定位到行、判定"S2-附 字面执行会造成生产回归(
tunnel === undefined)"⇒ 未执行;106 现状逐字未动。 - 什么条件一出现必须回头解决:要做"106 agent 面也能用兜底",必须先让 worker 侧接入引导链(改
agent.ts/relay-tunnel.ts)—— 属序 ⑤范围,⛔ 不在本单。
③「兜底项在取址路径上原本永远选不中」—— 本单已补最小修法
- 卡在哪(原缺陷):
relays[]由 seeds 按序生成、pickFromDoc取首位(= 主入口),而 S2/D5 又要求主入口首位 ⇒ 生产链路上没有任何一支会去连兜底项,§1 的目标与 §5-S4/D6 的前提同时落空(S4 之所以"看起来"能过,只因为测试里首个 origin 用了回环口127.0.0.1:9、被publicRelayEntries过滤掉 —— 属测试参数的偶然,不是生产机制)。 - 已做到哪一步:补了**「同源优先」(
sameOriginRelayUrl):谁答出目录,就用谁的同源中继入口。它不是新概念** —— 正是既有约定「引导地址 = 中继入口同源」在选择时刻的落地。S4/A与新增用例L1-F/L1-G分别证明"兜底被采用"与"主入口通时逐字不变"。 - 为什么必须改(按 §4.1"要改必须回写 §8 并说明理由"):① 不动任何 §4.1 已定项 —— seeds 顺序仍是主入口首位,目录内容与 D5 判据不变;② 零退化 —— 主 origin 通时同源项就是首位,行为逐字一致(
L1-G断言);③ 不补它 ⇒ 本单只交付一个"能 101 但没有任何客户端会去连"的端点,§1 的目标不成立(只剩装饰性)。 - 什么条件一出现必须回头解决:若将来
relays[]引入"非首位更优"的显式优先级语义(序 ⑤ 参数表可能引入),需重新定义"同源优先"与它的先后关系。
④ 引导链的缓存两支没有"答出者"信息(已知边界)
- 卡在哪:第 ②/④ 步(新鲜/过期缓存)只存
doc+sig,不记"当初哪个 origin 答的"⇒ 这两支仍走relays[]首位。 - 已做到哪一步:同源优先只作用于第 ③ 步(取目录成功),已在代码注释里写明。
- 什么条件一出现必须回头解决:CF 打挂后,已入网节点要等一个刷新周期(
refreshAfterSeconds,现为 300 s)才切到兜底。若要"立刻切",需在缓存里记 origin。
⑤ 顺带发现、⛔ 未动手的两项(R7:先报告、后动手)
bt-server这个 ssh 别名端口已陈旧:~/.ssh/config写Port 32022,实测Connection refused;-p 22正常。⇒ 本次全程用-p 22,未改~/.ssh/config。src/net/relay/整目录未被 git 跟踪(HEAD 里 0 文件)⇒ 覆盖网络线代码只在工作区 + 部署产物里。未做任何 git 动作(本单明令不 commit / push)。
8.8 45% 口径(登记,⛔ 无具体数值)
登记行:兜底启用后,relay 容量须按 45% 的节点走中继核算(异构纪律,⛔ 不是同构的 15%)。 待填参数位置(留给序 ⑤ 参数表,本单不填数值):relay 侧
--max-hosts(现为0= 不限);/status的capacity{max,used};满载退避at-capacity/retryAfterMs(现 5000 ms)。
8.9 指纹
- 回填前(规划棒交付)=
b9aa6bbc0a458481627f7aeb1f17ab54(执行棒开工前已复核,逐字一致) - 回填 §8 正文后(尚未写本节时)=
594e0b404bef18fb3959e48901153649(40996字节) - 本节口径(推荐核对用,可复现):整个 §8.9 不计入 ⇒
40997字节。复核命令:sed '/^## 8\.9 指纹$/,$d' 交接单_443兜底_20260917.md | md5sum⇒3d15b94069f289124d92da367e806bb5(比上一行多 1 字节 = 正文与本节之间的空行) - 全文件 md5:请用
md5sum 交接单_443兜底_20260917.md现取 —— ⛔ 此处故意不内嵌数值: 它包含本节自身,写进去即刻失效(自指)。