From d2ef362a984fce710e4f46dc8cc1c09cfa6f6b19 Mon Sep 17 00:00:00 2001 From: maogeigei Date: Sat, 19 Sep 2026 05:35:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(overlay):=20=E8=A6=86=E7=9B=96=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E7=BA=BF=20=E5=BA=8F=E3=8A=BE=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E6=8E=A2=E9=92=88=E8=A7=82=E6=B5=8B=E9=9D=A2=E6=94=B9=E3=80=8C?= =?UTF-8?q?=E4=B8=A4=E5=8F=B0=E4=B8=AD=E7=BB=A7=E5=B9=B6=E9=9B=86=E3=80=8D?= =?UTF-8?q?=EF=BC=88=E9=99=84=20=E5=BA=8F=E3=8A=BD=20=E6=BA=90=E7=A0=81/?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E8=A1=A5=E6=8F=90=E4=BA=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 序㊾(本棒): - scripts/overlay-probe.cjs:OBS-01 / OBS-08 / OBS-09 的数据源由「只读 47 中继」 改为「按两台中继取并集」,消除 worker 归属漂移时的假红 / 假 SKIP · endpoints 以 network:hostId:port 为键合并,online 取「或」、localPort 取在线那一侧 · used 按 network/hostId 去重计数(不求和,避免凭空放大在册数) · localPort 属中继机回环落点 ⇒ 按归属分机探活(106 侧落点由 106 机上探) · derived(OBS-11)保持 47 视角;阈值与判据一律未放宽 · OBS-16 计数约束:对 47 /status 的读取仍为三次、Δ 只取 47 的 counters; 对端 106 的采样为独立一次,落在第三次采样之后,不进 (status2, status3] 门窗口 · 新增 --peer-status-fixture(并集的对端那一半)与「并集不可取证」强制留痕 - 交接单《覆盖网络-序45-低熵块治理-测熵与实现》§16 全节(§8 前前缀逐字未变) - 参数表 §11.16 补记(§10 现算指纹未变,值格未动) 附(前几棒已完成并已部署、但尚未入仓的源码 / 文档): - src/net/relay/content/*.ts、src/net/relay/index.ts、main.ts:块级寻址 C 域分离 - src/supervisor/orchestrator.ts、src/worker/agent.ts:日志采集与巡检(方案 C) - test/overlay-content.test.mjs:随附用例(npm test = 200 pass / 0 fail / 1 skipped,Node 22) - scripts/dshlog.mjs(跨机日志取证)、scripts/overlay-entropy.cjs(熵探针) - dsh-server-docs/04-调整方案/129、133;INDEX.md / docs-manifest.json / 交接单 README 登记 --- .../129-日志采集与巡检-方案C实现.md | 227 +++++++ ...网络-低熵块治理方案-C域分离与D非确定性.md | 198 ++++++ dsh-server-docs/INDEX.md | 11 +- dsh-server-docs/docs-manifest.json | 136 +++- .../skills/dsh-instance-diagnose/SKILL.md | 39 +- .../skills/dsh-opensource-release/SKILL.md | 150 +++-- dsh-server-docs/交接单/README.md | 1 + .../覆盖网络-序45-低熵块治理-测熵与实现.md | 619 ++++++++++++++++++ scripts/dshlog.mjs | 569 ++++++++++++++++ scripts/overlay-entropy.cjs | 211 ++++++ scripts/overlay-probe.cjs | 450 +++++++++++-- src/net/relay/content/chunker.ts | 73 ++- src/net/relay/content/crypto.ts | 42 ++ src/net/relay/content/runtime.ts | 81 ++- src/net/relay/content/store.ts | 18 +- src/net/relay/index.ts | 5 +- src/net/relay/main.ts | 16 +- src/supervisor/orchestrator.ts | 91 ++- src/worker/agent.ts | 40 +- test/overlay-content.test.mjs | 204 ++++++ 20 files changed, 2998 insertions(+), 183 deletions(-) create mode 100644 dsh-server-docs/04-调整方案/129-日志采集与巡检-方案C实现.md create mode 100644 dsh-server-docs/04-调整方案/133-覆盖网络-低熵块治理方案-C域分离与D非确定性.md create mode 100644 dsh-server-docs/交接单/覆盖网络-序45-低熵块治理-测熵与实现.md create mode 100644 scripts/dshlog.mjs create mode 100644 scripts/overlay-entropy.cjs diff --git a/dsh-server-docs/04-调整方案/129-日志采集与巡检-方案C实现.md b/dsh-server-docs/04-调整方案/129-日志采集与巡检-方案C实现.md new file mode 100644 index 0000000..b022e27 --- /dev/null +++ b/dsh-server-docs/04-调整方案/129-日志采集与巡检-方案C实现.md @@ -0,0 +1,227 @@ +# 日志采集 · 实时巡检 · 事后溯源(方案 C 实现) + +- 版本:**v1 实现稿**(2026-09-18) +- 状态:✅ **已实现 · 本机实测通过**(工具 = 代码仓 `scripts/dshlog.mjs`) +- 上游决策:**方案 C** —— 不装新日志服务(不装 Amber / 不装 Loki+Alloy),靠现有 journald + 自研观测面 +- 取消的候选:Amber(无预编译产物 / 单维护者 / 落盘异步不去重 ⇒ 与"日志原文行数"类判据冲突)· Loki+Alloy(组件数与内存代价,规模未到) +- 复跑入口:`"E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe" scripts/dshlog.mjs help` + +--- + +## 0. 一句话 + +**一条 CLI 把 47 / 106 的 journald 拉回本机按天归档;跨机时间线一键重建;巡检规则命中即出判据表与非零退出码。** 服务器侧**零安装、零新增端口、零常驻进程** —— 远端只用系统自带的 `journalctl`。 + +--- + +## 1. 现状取证(为什么只能走这条路) + +| 事实 | 实测值(2026-09-18) | 含义 | +|---|---|---| +| 47 日志分布(当日) | `dshs` 29274 · `dshs-worker` 7312 · `sshd` 3830 · `init.scope` 3155 · `dshs-relay` 830 | 主角是**四层单元**:Manager / Worker / relay / PG | +| 106 日志分布 | `user@0` 2604 · `init` 1575 · `crond` 728 · `sshd` 339 | 同一套单元命名,靠 `host` 字段区分 | +| journald 形态 | 两机均 **persistent**(`/var/log/journal//`);47 = 769 MB · 106 = 130 MB | 可直接增量拉取;**但两机都没设上限**(默认吃磁盘 10%) | +| 时钟 | 两机 **NTP 均已同步**(`timedatectl NTPSynchronized=yes`) | 跨机时间线**不需要自造校时** | +| 用户实例(`dsh --profile`) | `_SYSTEMD_UNIT=dsh--.scope` **无任何条目**;`journalctl _PID=<实例pid>` **无条目**;实例 `fd/1` 指向 `socket:[…]` | 🔴 **实例 stdout 不进 journald** —— 它被 Worker 用 socket 接管,只有 Worker 愿意转发的那部分才落在 `dshs-worker` 里 | +| journalctl 版本 | 47 = systemd **239** · 106 = 255 | `--output-fields` 两机都支持(用 `awk NR==1` 取版本号,别用正则猜) | + +--- + +## 2. 架构(三层,无新增常驻组件) + +```text +[采集] 47 / 106 的 journalctl -o json ← 远端只读,不写任何文件 + │ ssh -C(压缩,见 §6.1) + ▼ +[归档] E:/dsh-logs//.ndjson.gz ← gzip 多成员追加,按天分片 + E:/dsh-logs/state.json ← 每节点 cursor / lastTs / NTP 状态 + E:/dsh-logs/hosts.json ←(可选)节点清单,缺省用内置 + ▼ +[使用] q(查询)· timeline(溯源)· watch(巡检)· stats / ls / prune(运维) +``` + +**三条不可让步的设计约束** + +1. **零新增常驻服务 / 零新增监听口** —— 不装 agent、不开端口、远端不落任何脚本(`bash -s` 走 stdin);符合 R5 的"权限只准收窄"。 +2. **日志原文保真** —— `msg` 字段逐字落盘(本项目大量判据依赖日志原文的行数/字节数);非 UTF-8 字节转义为 `\xNN` 保留。 +3. **"拉取失败" 与 "确无日志" 永不混淆** —— 数据走 stdout、元信息走 stderr 哨兵 `__DSHLOG_EOF__ rc= errbytes=` + `__DSHLOG_LINES__ n`;两条通道物理分离。 + +--- + +## 3. 三个目标 → 怎么满足 + +| 目标 | 命令 | 判据 | +|---|---|---| +| **不同节点/设备的日志存储** | `collect`(增量 cursor 续拉 / `--since` 回填) | 四态:`OK` / `EMPTY` / `MISMATCH`(远端报数与本地解析数不等) / `FAIL`;非 OK 即退出码 2 | +| **快速排查线上运行问题** | `q <词\|/正则/>` · `timeline` | `q` 支持跨机跨单元、时间窗、正则;`timeline` 把多机日志按时间戳合并成单条时间线 | +| **实时 bug 监控** | `watch`(默认先自动增量续拉再巡检) | 8 条规则 → 判据表 `PASS/FAIL`;有 FAIL ⇒ 退出码 2(可作自动化判据) | +| **事后溯源分析** | `timeline --from --to --grep --out` | 带毫秒排序 + `host/unit` 归属 + `pri`;输出可落文件归档 | + +--- + +## 4. 用法 + +```bash +N="E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe" + +# ① 拉取:首次回填 / 之后增量 +$N scripts/dshlog.mjs collect --since 6h --chunk 2h # 回填(自动去重) +$N scripts/dshlog.mjs collect # 增量:只搬 cursor 之后的新行 + +# ② 查询 +$N scripts/dshlog.mjs q "EADDRINUSE" --since 24h +$N scripts/dshlog.mjs q "/no-trusted-keys|信任链/" --host 106 --limit 20 + +# ③ 事后溯源(把一次故障从两机日志里拼出来) +$N scripts/dshlog.mjs timeline --from 2026-09-18T16:00 --to 2026-09-18T17:00 \ + --grep "overlay|relay" --out E:/dsh-logs/tl-1617.log + +# ④ 巡检(实时监控入口) +$N scripts/dshlog.mjs watch --since 30m --report # 有 FAIL ⇒ rc=2 +$N scripts/dshlog.mjs watch --since 30m --json # 机器可读 + +# ⑤ 运维 +$N scripts/dshlog.mjs stats --detail # 归档分布(按节点/日期/单元 top4) +$N scripts/dshlog.mjs prune --keep 14 # 保留策略(默认干跑,--apply 才删) +``` + +**扩展新节点**(用户说的"不同设备"):在 `E:/dsh-logs/hosts.json` 加一条即可,命令无需改动。 + +```json +{ "47": { "ssh": ["-p", "22", "root@47.77.182.89"], "label": "Manager + w-47" }, + "106": { "ssh": ["-p", "22", "test106"], "label": "w-106" } } +``` + +--- + +## 5. 实测基线(2026-09-18 21:0x–21:2x) + +| 项 | 实测 | +|---|---| +| 当日归档量 | 47 = **35,603 行** · 106 = **5,988 行**(gzip 后 47 ≈ 0.3 MB) | +| 回填 6 h(3 块 × 2 h) | **2 分 28 秒**(含首次去重重读);单块上限受链路带宽支配 | +| 增量续拉(1 块,含去重) | **19.5 秒** | +| 单块原始体积 | 47 ≈ 1.6 MB / 小时(未压缩,全字段) | +| 巡检扫描 41,226 行(6 h 窗口) | **< 3 秒**(纯本地 gzip 顺序扫) | +| 跨机时间线 | 6 h 窗口内 41k 行,毫秒级排序,秒级完成 | + +**压缩是最大杠杆**(见 §6.1):47 出方向未压缩实测 **~20 KB/s**(1.6 MB 要 84 秒),开 `ssh -C` 后同样数据 **11 秒**。 + +--- + +## 6. 关键设计(每条都有实测出处,别改回去) + +### 6.1 🔴 `ssh -C` 不可省 —— 7.6× 提速 + +实测同一块数据(1 h,1.6 MB)真传到本机:**不带 `-C` = 84 s** vs **带 `-C` = 11 s**。 +JSON 日志压缩率极高;`-C` 的开销是两端 CPU(1.6 MB 约几十毫秒),完全值得。 +⚠️ 不加 `-C` 时表现为"像是卡死了"(第一版跑 3 h 回填超过 5 分钟被外层超时杀掉,误判为代码 hang)。 + +### 6.2 数据/元信息**双通道** + +远端脚本把 `journalctl` 的 stdout 逐行 `awk` 转发(数据),同时把**行数**写 stderr(`__DSHLOG_LINES__`),末尾再补 `__DSHLOG_EOF__ rc=… errbytes=…`(元信息)。 +⇒ `"确无日志"(0 行)` 与 `"拉取失败"(无哨兵 / rc≠0)` 在同一份输出里**天然可分** —— 这是本项目反复踩过的坑,此处按机制而非纪律解决。 + +### 6.3 归档是 **gzip 多成员**追加,且必须**同步写** + +- 每次 `appendFileSync` 追加一个完整 gzip 成员 ⇒ `gunzip` / node `createGunzip` 都能顺序读回(已实测:596 + 248 两个成员读出 844 行)。 +- ⛔ 不能用 `createWriteStream` 异步管道 —— 进程收尾时可能未 flush,**静默丢最后一批**。 + +### 6.4 回填必须去重,续拉必须用 cursor + +- **续拉**(无 `--since`):`journalctl --after-cursor=` —— 精确、不重不漏。 +- **回填**(有 `--since`):先把已有分片的 `__CURSOR` 读进 Set,落盘时跳过 ⇒ 实测一次回填跳过 8100 行重复。 + ⛔ 不去重会让"行数类判据"直接失真。 + +### 6.5 校时用 **NTP 状态**,不要自造往返估算 + +第一版用 `(远端date + 本地往返/2)` 估偏移,实测给出 **+1337 ms** 的假偏移(47 的 ssh RTT 达 3.7 s 且往返不对称)⇒ 拿它做校正会**制造**错序。 +改为读 `timedatectl show -p NTPSynchronized`:两机都是 `yes` ⇒ **不校正**,`timeline` 默认输出原始系统时间戳,并把 NTP 状态打在末尾。 + +### 6.6 版本号解析必须 `awk 'NR==1{…}'` + +`journalctl --version` 是**多行**输出;不限定行会让变量变成 `"239\n0"` ⇒ `[: integer expression expected` ⇒ 静默退回全字段(体积翻倍)。 +症状是"结果没错但慢一倍",只在 `err:` 里留一行噪声。 + +### 6.7 巡检规则要**收紧到指向本项目故障**(误报比漏报更贵) + +实测踩过的两条反例: + +- `fatal` 裸写 ⇒ sshd 的 `ssh_dispatch_run_fatal`(客户端网络断)天天命中 ⇒ 收紧为 `PANIC|FATAL ERROR|unhandled…` + 排除 `sshd/crond/systemd-logind` 噪声单元。 +- `Stopped .*` 裸写 ⇒ 实例**正常退出**(用户关会话)会打 `Stopped /usr/bin/bwrap …` ⇒ 收紧为 `Failed with result|start request repeated|Main process exited, code=…`。 + +当前 8 条规则:进程级致命 / 内存被杀 / 端口连接失败 / 权限属主 / 磁盘写入 / **覆盖网络信任链被拒** / HTTP 5xx / 服务异常终止。 +> 实测有效:WATCH-06 在 106 上命中 `⚠ 取目录全部失败 ⇒ 回落到内置种子地址本身` —— 正是**序 ㉗ 记录的 E3 缺口**(106 候选链退化为单点),属真报。 + +--- + +## 7. 边界(红线遵守情况) + +| 项 | 状态 | +|---|---| +| 新增公网监听口 | ⛔ **0 个**(只读 ssh,未改 nft / nginx / 任何监听) | +| 服务器侧新增常驻进程 / 安装 | ⛔ **0 个**(只用系统自带 `journalctl`;脚本走 `bash -s` stdin,不在远端落文件) | +| 生产值改动 | ⛔ **0 处** | +| 权限 | 只读:`journalctl` 读取无需提权以外的任何放行;ssh 用既有连接 | +| 归档位置 | `E:/dsh-logs/`(E 盘;**不在** git 仓库内) | + +--- + +## 8. 已知限制与未做项(如实登记) + +1. 🔴 **实例层(`dsh --profile`)日志目前抓不到** —— 实例 stdout 被 Worker 用 socket 接管,**不进 journald**(`_SYSTEMD_UNIT=…scope` 与 `_PID=` 均为空,已双重取证)。当前只能拿到 Worker 转发的部分。 + ⇒ **这是本方案最大的剩余缺口**;要补需改 Worker 的 stdout 接管方式(属改代码,另立一单),或让实例自己写日志文件(需确认官方 dsh 是否支持日志落文件,受 R2 约束)。 +2. **本机(WorkBuddy 开发机)未纳入** —— 本机是 Windows,无 journald;其日志在 `~/.workbuddy/logs/`,与"线上运行"关联弱,暂不入归档。 +3. **全文检索是线性扫描** —— 当前量级(每日 4 万行)毫秒级;若到 GB 级需换索引(届时正是上 Loki 的信号,见上游评估的触发条件)。 +4. **未设置 journald 上限** —— 两机 `/etc/systemd/journald.conf` 均为空(默认 10% 磁盘)。47 已 769 MB。建议后续加 `SystemMaxUse=`,避免日志本身成为磁盘事故源。 +5. `--threshold` 目前是**全规则统一阈值**,未做逐规则独立阈值。 + +### 8.1 日志保留策略 = 3 天(2026-09-18 落地) + +**服务器侧**(两机均写 drop-in,⛔ 不覆盖主配置 `/etc/systemd/journald.conf`): + +```ini +# /etc/systemd/journald.conf.d/10-retention.conf +[Journal] +MaxRetentionSec=3d # 时间上限(用户口径"只保留3天的日志") +SystemMaxUse=512M # 47 / 106 用 192M —— 容量兜底,防单日风暴吃满磁盘 +SystemMaxFileSize=32M # 47 / 106 用 8M —— 压小分片,提高"3 天"判定精度 +``` + +**本机归档**:`dshlog prune --keep 3 --apply`(默认干跑;默认保留 3 天,与服务器同口径)。 + +**🔴 关键实测:`--vacuum-time` 的判据是「分片起始记录时间」,且按**整片**删除** + +⇒ 实际保留期 = **3 天 −(0 ~ 一片跨度)**。分片越大,"3 天"缩水越严重。 +这解释了为什么必须同时压小 `SystemMaxFileSize`: + +| 机 | 分片跨度(默认 128M 时) | 清理后实际保留 | 压小分片后预期 | +|---|---|---|---| +| 47 | ~128M ≈ **1.5 天/片** | **1.35 天** ❌ | 32M ≈ 6h/片 ⇒ 2.75~3 天 | +| 106 | ~47M ≈ **3 天/片** | **2.44 天** ❌ | 8M ≈ 9h/片 ⇒ 2.6~3 天 | + +⚠️ **一次执行的偏差记录(如实留档)**:首次清理**未预判该判据**,按"末记录时间"估算,实测多删了一片 —— +47 释放 608 MB(768→160 MB)、106 释放 59.5 MB(130→70 MB),但**保留期分别只有 1.35 天 / 2.44 天**,未达 3 天。 +受影响而**永久丢失**的区间:47 = 09-15 13:33 ~ 09-17 13:05;106 = 09-13 12:54 ~ 09-16 11:29(本机归档当时也尚未覆盖该区间 ⇒ 无副本)。 +⇒ **47 的保留跨度会随新数据每日增长约 1 天,约 1.7 天后自然恢复到 3 天并稳定**(无需人工干预)。 + + +--- + +## 9. 待你拍板 + +**是否挂周期自动化做"实时"巡检**: + +**A. 挂 automation 每 30 分钟跑一次 `watch`,仅 FAIL 时出报告(推荐)** +优点:真"实时",异常半小时内可见,且只在有 FAIL 时才有可读产出。 +缺点:每天 48 次新会话,按本项目实测的自动化成本(每轮 5–9 积分)估算约 **250–430 积分/天**,成本可观。 + +**B. 挂 automation 每天 1 次汇总(如 08:00)** +优点:成本低(约 5–9 积分/天),能发现"过夜积累"的问题。 +缺点:不是实时;白天的突发故障要等次日,或靠人工跑 `watch`。 + +**C. 不挂,保持按需手动执行** +优点:**零成本**,需要时一条命令 10–20 秒出结果;当前平台规模小(47 仅 0–2 实例),"现拉现看"足够。 +缺点:无人自动发现问题,依赖你或我主动去查。 + +我的倾向:**先 C,等出现一次"事后才发现"的线上问题再上 A** —— 理由是本项目已有 `overlay-probe.cjs` 这类主动探针承担"判据式巡检",`watch` 的价值主要在**事后取证**;而实时性的成本(积分)与当前规模不匹配。若你认为线上稳定性优先于积分,直接上 A(我按"每 30 分钟 + 仅 FAIL 出报告"落地)。 diff --git a/dsh-server-docs/04-调整方案/133-覆盖网络-低熵块治理方案-C域分离与D非确定性.md b/dsh-server-docs/04-调整方案/133-覆盖网络-低熵块治理方案-C域分离与D非确定性.md new file mode 100644 index 0000000..1b83d28 --- /dev/null +++ b/dsh-server-docs/04-调整方案/133-覆盖网络-低熵块治理方案-C域分离与D非确定性.md @@ -0,0 +1,198 @@ +# 覆盖网络 · 低熵块治理方案(**C 域分离 + D 非确定性**) + +- 版本:**v1 规划稿**(2026-09-18 · 规划棒 · ⛔ 零代码 / 零服务器改动) +- 状态:📐 **规划完成 · 待执行**(第一个动作 = **补测「低熵块种类数 / 体积 / 占首屏包比例」**,⛔ 从未测过) +- 上游决策:**用户 2026-09-18 21:28 拍板 —— 采纳 C + D**;**B(OPRF / SA-MLE)降级为可选加强、⛔ 本轮不立项** +- 权威来源(⛔ 不另起炉灶、⛔ 不重查文献、⛔ 不重新评估方案优劣): + - 工作区根 `调研_MLE加密去重最优方案_20260918.md`(**§3** 六方案族 / **§5** 结构性发现 / **§5.5** 复杂度与收益) + - `.workbuddy/memory/2026-09-18.md` **21:1x / 21:5x / 21:2x** 三节(C / D / B 定义、量化读数、"**收敛加密下持组密钥 ≠ 能解密**"这一前提) + - 阈值与判据面单一来源:工作区根 `参数表_覆盖网络_20260917.md`(⚠️ 现版 §10 指纹 = `d408d640246a980f702fe7b0a2895219`,**以现算为准**) +- 执行载体:`交接单/覆盖网络-序45-低熵块治理-测熵与实现.md` + +--- + +## 0. 一句话 + +**分两件事治**:**C** 把块 id 的收敛哈希 `HA` 从"裸 `sha256(字节)`"改成"**每个 network 一把密钥**的 keyed hash"(切断跨 network 的相关性推断,代价≈0);**D** 让**低熵块**不再确定性(首选 **D-1「稀释域」** —— 低熵小字段与高熵内容拼在一起再加密;备选 **D-2** 每块随机密钥 + 密钥封装)。**方案能否定量,取决于一个从未测过的读数** ⇒ 因此**第一个动作不是改代码,是测熵**。 + +--- + +## 1. 现状取证(源码事实 · 本轮新读) + +| # | 事实 | 出处(文件:行) | 含义 | +|---|---|---|---| +| 1 | 块 id = **`sha256(字节)` 前 32 hex**(**只由字节决定**,⛔ 无密钥) | `src/net/relay/content/chunker.ts:108-110`(`blockIdOf`),`BLOCK_ID_HEX_LEN = 32`(同文件 `:64`) | 这就是 **C** 要改的那一行;也是"相同明文 ⇒ 相同块 id ⇒ 跨节点共享"的**唯一**来源 | +| 2 | 整份内容的 id 同样 = `sha256(全部落库字节)` | 同文件 `:113-115`(`contentIdOf`) | **C 必须同时改它**,⛔ 只改 `blockIdOf` 会留下"内容指纹仍裸哈希"的缺口 | +| 3 | 块密钥**含明文成分**:`iv = HMAC(key,"iv"‖plain)` 前 12 B ⇒ `k = HMAC(key,"k"‖iv)` | `src/net/relay/content/crypto.ts:405-413`(`ivOf` / `keyOf`) | ⇒ 🔑 **持组密钥 ≠ 能解密**(本题的前提)。⇒ 低熵块之所以可枚举,是"**猜明文 + 复算 iv/k + 比对**",⛔ 不是"直接解密" | +| 4 | 组密钥文件缺省 `/etc/dshs/content-group-key.json` | 同文件 `:47`(`DEFAULT_GROUP_KEY_FILE`) | **C 的 per-network 密钥**应从这条既有链路派生,⛔ 不新引入密钥来源 | +| 5 | 🔴 **切分是「定长 1 MiB」**(`offset += blockSize` 死循环) | `chunker.ts:137-158`;`DEFAULT_BLOCK_SIZE = 1024*1024`(`:60`,⚠️ **常量而非配置**) | ⇒ 一份首屏包 10.8 MB ≈ **11 块**,每块是 **1 MiB 混合内容**。⇒ 🔑 **首屏包里的"纯低熵块"在结构上几乎不可能存在**(除非整整 1 MiB 都是低熵) | +| 6 | 块存储 = **纯内存**,调用方**没给** `dir` | `src/web/server.ts:909-911`(`new ContentStore({ maxBytes })`,**无 dir**);`runtime.ts:347-350` 同 | ⇒ 块**不落盘** ⇒ **"低熵块"无法从生产盘上"捞"出来** ⇒ 测熵**只能对"真实内容"离线做** | +| 7 | 快照上限 = `CONTENT_STORE_MAX_BYTES` 64 MiB | `参数表_覆盖网络_20260917.md:147` | 内存预算独立(⛔ 不挤 relay 的 `MEM_PER_HOST_MB`)⇒ D 若引入"合并加密",**是否增加常驻字节**要在这笔账里算 | + +### 1.1 🔴 本轮最重要的结构发现(⛔ 尚无任何文档记录 · ⚠️ 待实测确认) + +由事实 5 直接推出两条,**方向相反**,共同决定 D 的边界: + +1. **对"首屏包"而言,D-1 几乎是免费的** —— 定长 1 MiB 意味着低熵小字段**已经被同块的高熵内容稀释**了。⇒ D-1 在首屏包上**代价≈0、收益≈0**(本来就不暴露)。 +2. 🔴 **D 的真实战场不在首屏包,而在「小于 1 MiB 的独立内容」** —— 任何**整体小于 1 MiB 的一份内容**只会切成 **1 个块**;若该内容本身低熵(配置 / 状态 / 清单 / 密钥交换载荷类),它就是一块**纯低熵块** ⇒ 中继可数相等性、持钥者可枚举。 + +⇒ **推论(决定待测项的形状)**:测熵**必须同时覆盖两个集合**,只测首屏包会得出"没有低熵块"的**假绿**结论。 +⚠️ 本条为**源码推导**,⛔ 不是实测 ⇒ 待测项 `M1` 的第一条就是要**证实或推翻它**。 + +--- + +## 2. 第一个动作:补测「低熵块的种类数 / 体积 / 占首屏包比例」(**M1**) + +### 2.1 为什么必须放第一个(⛔ 不是"顺手做的取证") + +- **D 的边界由它决定** —— "什么算低熵、稀释单元取多大、要不要合并"全是它的函数(`调研…§6` 阻碍 4 已点明「**什么算低熵本身无可靠判据**」)。 +- **它从未被测过,而且历史上所有"低熵"读数都是合成字节** —— 三条硬证据: + +| # | 证据 | 出处 | 说明 | +|---|---|---|---| +| a | perf 用的是 `makeBytes(PACK_BYTES, SEED)` | `_tmp_seq32/p01-perf.mjs:42` | **合成** 11,363,655 B 缓冲,⛔ 不是真首屏包 —— 对 perf(尺寸驱动)无害,对**熵统计致命** | +| b | 低熵定性读数用 `blk-0000…` 8 B 合成块、域 1000 | `_tmp_seq32/p03-lowentropy.txt`;引用见 `参数表_覆盖网络_20260917.md:617-622` | 结论是**定性**的("可枚举"),⛔ 从未给出"真实内容里低熵占多少" | +| c | 块存储纯内存、不落盘 | §1 事实 6 | ⇒ 生产侧**没有任何**可回捞的低熵块样本 | + +### 2.2 待测项定义(可判定 · 落 `参数表` §11.3 补记区) + +**度量对象(两个集合,⛔ 都用真实内容)** + +- **S1 = 真实首屏包**:门户 `GET /plugins/`(= 既有口径的 `11,363,655 B`)。⚠️ **只读 GET**,⛔ 不重启、⛔ 不写盘。 +- **S2 = 真实"独立小内容"集**:覆盖网络**实际分发**的、**非首屏**的内容(⛔ 由执行棒先枚举来源并留痕;若确实不存在 ⇒ **报 SKIP + 说清"不存在"**,⛔ 不许用合成字节填充)。 + +**方法(本机离线 · 纯函数 · 零生产触碰)** + +用**代码常量** `DEFAULT_BLOCK_SIZE`(= 1 MiB)复刻 `chunkify` 的**定长切分**,再对每块统计。⚠️ **必须调用仓库里那份 `chunkify`**(⛔ 不复刻一份算法 —— 复刻=双源,本线明令禁止)。 + +**指标(每条都可机器断言)** + +| 编号 | 指标 | 定义 | 期望落点 | +|---|---|---|---| +| **M1-a** | **种类数 / 重复率** | 唯一块 id 数 ÷ 总块数;并给出**完全重复块**(同 id 出现 ≥ 2 次)的清单 | 数字 | +| **M1-b** | **低熵块数 / 体积** | 逐块经验 Shannon 熵(字节分布,bit/byte),阈值初值 **H ≤ 4.0**(⚠️ **阈值本身是待定项**,须在报告里给出 H 的**直方图**,⛔ 不给单一阈值当结论) | 块数 + 字节数 + 直方图 | +| **M1-c** | **占首屏包比例** | 低熵块字节 ÷ 首屏包字节 | 百分数(**这就是"能不能定量"的那个数**) | +| **M1-d** | 🔑 **反向腿 · 子窗口熵** | 整块高熵 ≠ 块内无低熵字段:按**滑窗**(如 4 KiB)扫整份内容,给出"低熵窗口"的**尺寸分布** | 尺寸分布(**决定 D-1 的稀释单元粒度**) | + +🔴 **M1-d 是防假绿的关键腿**:只做整块统计,会因为 §1.1 事实 5 得出"全是高熵块"的**假绿** —— 而那**恰恰漏掉**了低熵字段真实存在的形态(它们是块内的一小段)。本条同时**同时交叉验证** §1.1 的两条推论。 + +**落点与边界** + +- 先可落 `_tmp_seq*/` 一次性脚本取证,**证据齐后固化为只读探针** `scripts/overlay-entropy.cjs`(⛔ **零第三方依赖**,只 `node:crypto` + `node:fs`)。 +- 读数进 `参数表_覆盖网络_20260917.md` **§11.3 补记区**(⚠️ 属**验收读数**,⛔ 不进 §10 指纹口径 —— 沿用序㉛ 的既定做法,见该表 `:506`)。 +- ⛔ **不动 §10 指纹**(除非同时新增判据键,那时按 §3.3 一并处理)。 + +--- + +## 3. 方案 C —— 域分离(per-network keyed hash) + +### 3.1 改什么 + +把**内容寻址的两个哈希**从"裸哈希"改成"**带 per-network 密钥的 keyed hash**": + +| 现状 | 目标 | 落点 | +|---|---|---| +| `blockIdOf(bytes) = sha256(bytes)[0:32]` | `blockIdOf(bytes, netKey) = HMAC-SHA256(netKey, bytes)[0:32]` | `chunker.ts:108-110` | +| `contentIdOf(bytes) = sha256(bytes)[0:32]` | `contentIdOf(bytes, netKey) = HMAC-SHA256(netKey, bytes)[0:32]` | `chunker.ts:113-115` | + +🔑 **密钥来源**:复用既有组密钥链路从 **network 维度**派生(`crypto.ts:47` 的组密钥文件 + 既有的 network 标识),⛔ **不新增密钥文件、不新增 env**。⚠️ per-network 密钥的**派生写法**(`HMAC(groupKey, "overlay-block-id"‖network)` 之类)属**技术实现** ⇒ 由执行棒自决,⛔ 不上抛。 + +### 3.2 治什么 / 不治什么(口径必须写死) + +- ✅ **治**:跨 network 的 **COF / LRI / 离线枚举**;中继从"同一块 id 同时出现在 A / B 两个 network"推出的**跨租户相关性**。 +- 🔴 **不治**:**同一 network 内部**持钥者枚举(`85,878 条/秒` 原样存在)⇒ **C 是低成本加分项,⛔ 不是 §10-3 的解**。 +- ✅ **代价≈0 的依据**:**跨 network 本来就不该去重**(不同租户 / 不同覆盖网络)⇒ 去重域的收窄**踩在不值得保留的地方**。 + +### 3.3 🔴 C 的真实代价:**块 id 口径换代(这是本方案最重的一笔)** + +- 块 id 一变 ⇒ **全部既有块 id 失效**(缓存全放弃、去重率归零重算)⇒ **直接触发组密钥单 §9-5 的回头条件**。 +- ⇒ ⚠️ **C 不能"悄悄上"**,必须与 **`E1` 判据的基线重置**同时做(见 §5)。 +- ⚠️ **换接口形状也要改**:`blockIdOf` 从 1 参变 2 参 ⇒ **所有调用点**(`store.ts` 校验路径、`chunker.ts` 内部、装配层)都要一起改。⇒ **执行前必须先出"调用点清单"**(⛔ 漏一处 = 校验必红且极难定位)。 + +--- + +## 4. 方案 D —— 低熵块非确定性 + +### 4.1 首选 **D-1「稀释域」合并加密** + +**做法**:把低熵小字段与**高熵内容**拼在一起再加密 ⇒ 明文域从 `100` 扩到 `100 × 2³²` ⇒ 不可枚举。 +**代价**:这批块的**去重失效**(低熵块去重本无价值 ⇒ 代价踩在不值得保留的地方);**块 id 口径不变**(⚠️ 与 C 的方向**相反** —— C 换 id,D-1 只换"这批块加不加密、怎么加密")。 + +🔴 **D-1 的定义式设计约束(本方案必须写死,否则实现会做错)**:**稀释源必须是"攻击者猜不到"的那个量**。 +- 因为本题的攻击面是"**持组密钥者 · 猜明文 + 复算**"(§1 事实 3)⇒ 若稀释源是**攻击者能自己算出来的量**(如"由组密钥确定性派生的每块盐"、或块序号),**稀释对持钥者完全无效**(他能复算出同一份密文)。 +- ⇒ **稀释源必须包含真随机且不出现在明文可见面的字节**;⚠️ 该随机量的**存放位置**(密文内 / 密钥封装体里 / 块外层)是**技术实现**,执行棒自决 —— 但**判据必须能区分它是不是真随机**(见 §5 判据面)。 + +⚠️ **与 §1.1 的联动**:若 `M1` 证实"首屏包内不存在纯低熵块",**D-1 在首屏包上的作用面 ≈ 0** ⇒ D 的实际工作范围将收窄到 **S2(独立小内容)**,⛔ 那时**不许**为了"让 D 有用"而把块切小(切小 = 块数暴涨 = 控制面开销与回源字节都恶化 ⇒ **净变差,触 R11**)。 + +### 4.2 备选 **D-2 每块随机密钥 + 密钥封装** + +**做法**:彻底非确定性 —— 每块一把随机密钥,密钥随内容封装(用组密钥包裹)。 +**代价**:该块**不去重**;**新增密钥封装配送**这一层(=新的出错面)。 +**定位**:**仅在 D-1 的稀释源无法满足 §4.1 约束时启用** —— 即"低熵内容确实是**独立成块**且**没有任何高熵内容可与它同批**"时。 + +--- + +## 5. 判据面 / 影响面 / 验收 + +### 5.1 判据面(⚠️ 这是"改完凭什么算改对") + +| 项 | 要求 | +|---|---| +| **新增 OBS 行** | 现有 OBS 编号已到 **`OBS-28`**(`参数表` 第 264-270 行一带)⇒ 本单新增自 **`OBS-29`** 起,⛔ **不得跳号、不得复用** | +| **新增阈值键** | 若 D 需断言"稀释源是真随机"⇒ 需新增键(形如 `CONTENT_ENTROPY_*` / `CONTENT_DILUTE_*`)。⚠️ **键名与取值由执行棒定**,⛔ 不上抛 | +| 🔴 **必须有的负腿** | 「**稀释源被换成确定性派生量 ⇒ 判据必红**」—— ⛔ 否则"上了个无效的稀释"会**全绿**(本线老病根:装了但没生效 = 静默放行) | + +> 🆕 **序㊻ 修订(2026-09-18 22:4x · 执行棒回填 · ⛔ 不改本方案的技术口径,只改判据面)** +> +> **① D 本轮不实现**(依据 = 序㊺ 实测:真实首屏包 23 块**0 个低熵块**、60 份真实独立小内容**0 份低熵** +> ⇒ `D-1` **无对象可稀释**、`D-2` **前提不成立**)。⇒ 上表那条**负腿失去对象**(无稀释源可换)。 +> **② `OBS-29` 已重裁成 C 的判据**(编号仍在 **`OBS-29`**,⛔ 不跳号不复用): +> **正腿** = 同字节 + **不同 network** ⇒ 块 id **不同**(**且两侧都 ≠ 裸哈希**);**正腿** = 同 network + 同字节 ⇒ 块 id **相同**(⛔ 只测前者会漏"去了重"); +> **装配面腿** = `store` 两处复算 + 重组位全过(⇒ 调用点无漏改); +> 🔴 **负腿(真跑、具名)** = **去掉 per-network 维度**(域密钥取同值 / 取空)⇒ 谓词**必红**(`flat-key-collapses` / `empty-key-falls-back-to-bare-hash`)。 +> **③ `E1` 基线**按 §5 的要求**重取**(C 换了 id 口径 ⇒ 旧读数作废);⚠️ `E1` 的**定义不重估**。 +> **④ §3.3 的口径不变**:块 id 换代 ⇒ 缓存全清、去重率归零重算 —— ⛔ 回滚也**不是无损**。 +| **`E1` 基线重置** | C 换 id 口径 ⇒ `CONTENT_TIER_HITS_MIN` 等**命中类阈值的前提变了**;⚠️ `E1` 本身(回源字节 ≈ 1 份 × 组数)**不重估**,但**基线读数必须重取**(⛔ 不许拿旧读数当对照组) | +| **`OBS-17` 口径一致腿** | 该行断言 `content.blockSize == CONTENT_BLOCK_SIZE` 且 `content.storeMaxBytes == CONTENT_STORE_MAX_BYTES`(`参数表:418`)⇒ 若 D 引入额外常驻字节,**需在此处补列,⛔ 不许绕过** | + +### 5.2 影响面(**执行前必须先出清单**) + +| 面 | 改动量级 | 是否触"超过 10 文件先出清单" | +|---|---|---| +| `src/net/relay/content/chunker.ts` | 小(两个函数 + 调用点) | 否 | +| `chunker.ts` / `store.ts` / 装配层的 **`blockIdOf` 调用点** | **待清点**(⚠️ 数量未知 ⇒ 清点后若 > 10 ⇒ **先出清单再动**) | ⚠️ **可能触** | +| `src/net/relay/content/crypto.ts` | 中(D-1/D-2 的加密路径) | 否 | +| `scripts/overlay-probe.cjs` | 小(新增 OBS) | 否 | +| `参数表_覆盖网络_20260917.md` | 小(§11.3 + §6 判据行)|⚠️ **§10 指纹会变**(新增键才变) | 否 | +| 生产 env | 🔴 **预期为 0 个新增** | — | + +### 5.3 验收(三段式,沿用本线既有做法) + +1. **离线**:`M1` 读数落盘 + 熵直方图 + 探针自检(夹具模式封闭 ⇒ **⛔ 不 ssh**)。 +2. **本机**:`npm test`(Node 22)全绿 + `OBS-29` 正腿绿、**负腿红**。 +3. **真机**:`/status` 的 `content` 块 —— ⚠️ **只读**,⛔ 不重启在线服务;⚠️ `http2 on` 环境 curl 必须 `--http1.1`。 + +### 5.4 回滚 + +- **代码**:C / D 必须是**可独立回退**的两处开关(C 的密钥可"传空 ⇒ 回落裸哈希";D 可"不启用稀释 ⇒ 回落确定性")⇒ ⛔ 不许做成"上了就下不来"。 +- **口径**:C 回退后**块 id 会再变一次**(⇒ 缓存再清一次)—— ⚠️ 这条要写进回滚说明,⛔ 不许默认"回滚 = 无损"。 +- **判据**:`参数表` 回退到本文件所记的指纹 `d408d640246a980f702fe7b0a2895219`(⚠️ 以现算为准)。 + +--- + +## 6. 边界(本方案**不做**的事) + +- ⛔ **不立项 B**(OPRF / SA-MLE)—— 用户已拍板降级为可选加强;其接口位(Manager 做 KS)保留为**将来可插**,⛔ 本轮不写任何代码。 +- ⛔ **不做"按熵分层加盐"那条老路**(它必须同时动块 id 与去重率 ⇒ 比 C+D 更重,`调研…§5.5` 已量化)。 +- ⛔ **不改 `DEFAULT_BLOCK_SIZE`**(切小 = 净变差 ⇒ 触 R11)。 +- ⛔ **不引第三方依赖**、⛔ **不动 `package.json`**、⛔ **不改生产实例 / ⛔ 不重启在线服务**(本轮规划棒另加:⛔ 零代码、⛔ 零服务器触碰)。 +- ⛔ 合规 / 数据主权**不进本方案**(口径:方案只做技术实现)。 + +--- + +## 7. 参考 + +- `调研_MLE加密去重最优方案_20260918.md` —— §3 方案族全景表 / §5.3 域分离 / §5.4 三选项 / §5.5 复杂度与收益 / §6 六条阻碍 +- `.workbuddy/memory/2026-09-18.md` —— 21:1x(三不兼得)/21:5x(文献印证 + 域分离发现)/21:2x(C/D/B 对比与"持钥者 ≠ 能解密") +- `参数表_覆盖网络_20260917.md` —— `:146`(`CONTENT_BLOCK_SIZE`)/`:147`(`CONTENT_STORE_MAX_BYTES`)/`:418`(`OBS-17`)/`:506`(§11.3 补记区用法)/`:617-622`(§10-3 低熵现状定性) +- 源码 —— `chunker.ts:60/64/108-115/137-158`|`crypto.ts:47/405-413`|`store.ts:1-40`(模块定位与"不做加解密"边界)|`server.ts:909-911`|`runtime.ts:347-350` diff --git a/dsh-server-docs/INDEX.md b/dsh-server-docs/INDEX.md index a581daa..eb2081d 100644 --- a/dsh-server-docs/INDEX.md +++ b/dsh-server-docs/INDEX.md @@ -16,10 +16,11 @@ | 理解架构 / 多租户隔离 | `01-规划与架构.md`(一~五、九、十三) | `archive/` 全文 | | 日常运维(重启/备份/token/KEY/排障) | `02-运维手册.md` | `03-路线图与待办.md` | | **做一次平台改造(完整流程)** | **`skills/dsh-change-workflow/SKILL.md`**:六阶段 + **红线 R1-R11** + 档案模板 + 并行调度协议 | -| **开源导出 / 发新版本** | **`skills/dsh-opensource-release/SKILL.md`**:五条硬规则 R-O1–R-O5(源仓库只读 / 探针 0 命中 / 去插件 / 不带文档与 skill / 分层授权)+ 脱敏映射表 + **迭代 SOP** + 验证六件套;产物在本机 `_开源导出_20260913/` | +| **开源导出 / 发新版本** | **`skills/dsh-opensource-release/SKILL.md`**:硬规则 R-O1–R-O14(源仓库只读 / 探针 0 命中 / 去插件 / 不带文档与 skill / **双轨授权:AGPL-3.0 原样 + 商业轨独立文件**)+ 脱敏映射表 + **迭代 SOP** + 验证七件套 · 多远端推送(GitHub + CNB);工作根在本机 `E:\ProgramData\AI技能\dsh-ai1net-github\` | | **排查业务插件故障(没 UI / 装不上 / 改了没生效)** | **`skills/dsh-plugin-diagnose/SKILL.md`**:三层归属 + 三把尺子(inject 差集 / glibc 直测 / 产物插探针)| | **换电脑 / 改了工作区路径,规则会不会丢** | **`skills/dsh-env-bootstrap/SKILL.md`**:常驻规则快照 + `--check` 校验 / `--inject` 注入 / `--env-check` 环境自检(默认只报不改)| | **跑跨会话长任务(自动接力)** | **`skills/dsh-auto-handoff-chain/SKILL.md`**:六件套 prompt 骨架 + 登记门禁 + 六条实测防护 + **排期两条铁律**(首个/唯一接续棒 = 收口 + 5~8 分钟 · 同一时刻只挂一个接续棒) + 复跑脚本 `scripts/chain_report.py` | +| **排查线上运行问题 / 事后溯源(跨机日志)** | `scripts/dshlog.mjs`(代码仓):`collect` 拉取归档 → `q` 查询 / `timeline` 时间线 / `watch` 巡检;用法与实测基线见 **`04-调整方案/129-日志采集与巡检-方案C实现.md`** | | 看还有什么没做完 | `03-路线图与待办.md` §二 | **`交接单/README.md` §一**(已规划待执行) | | **改前端页面(强制基线)** | **`06-工作台UI规范.md`** | | **改实例 UI 分区(设置面板)** | **`07-实例UI分区登记表.md`**(哪个包提供 / 源码在哪 / 能不能改)+ `06-工作台UI规范.md` | @@ -43,9 +44,9 @@ ## 二、全量清单 -> **状态摘要**(**机器生成,勿手改**):档案 **115** 份(`04-*`),另含根级编号 5 条(01/02/03/06/07),另有非编号行 14 条(README / INDEX / 技能 / poc 等)—— ✅ 16 | 🔄 5 | 🔍 1 | 📋 22 | 未标记 76。复跑 `python3 scripts/docs-index-stats.py` 取数,`--write` 就地刷新本行。 +> **状态摘要**(**机器生成,勿手改**):档案 **117** 份(`04-*`),另含根级编号 5 条(01/02/03/06/07),另有非编号行 14 条(README / INDEX / 技能 / poc 等)—— ✅ 17 | 🔄 5 | 📝 1 | 🔍 1 | 📋 22 | 未标记 76。复跑 `python3 scripts/docs-index-stats.py` 取数,`--write` 就地刷新本行。 > **分层与机读明细**(路径 / 日期 / 字符数 / 被引次数 / tier,可 `jq` 先筛后读):**`docs-manifest.json`**;复跑 `scripts/docs-manifest.py` 即刷新。 -> 图例:✅已落地 | 🔄维护中 | 🧪PoC | 📝待开发 | 🔍核查完成 | 📋评估 | 🟡保留兜底 | 🗄归档|🔧修复|🔴|🚧|❓ +> 图例:✅已落地 | 🔄维护中 | 🧪PoC | 📝待开发 | 🔍核查完成 | 📋评估 | 🟡保留兜底 | 🗄归档|🔧修复|🔴|🚧|❓|PoC 实证完成(插件 v0|已上线|生效|已实施|已实施 + API 全链路验|已冻结|已落地 + 验证通过|审计已完成 → 加固项待用户|已上线,页面全部 200|已封板,实施中|核查完成 → 收敛项 H1-|✅ 已实施并在 admin/|🔄 批次 1 因改走 bun|方案 A + handoff|根因已定位并修复(nginx|修复已编译(CI ✅),待重|已实施并上线|已修复并上线(服务已重启|清单建立|✅ 已实施并端到端验证|✅ 已实施并 live 验证|✅ 已实施并视觉验证|🔍 核查完成,P0 待决策|✅ 已实施(commit 见|✅ 已实施(commit `|✅ 已修复(commit `|🔍 取证完成|🔍 核查完成|✅ 已实施(`99de8c5|✅ 已落地|✅ 已修复|已落地并实测验证|已落地并端到端验证|🔄 第一批已修复|✅ 已实施(两批):第一批|✅ 已实施并端到端验证(co|✅ 已实施并验证(服务器已生|admin 侧已实施|✅ 已部署并生效(服务器 `|✅ 已实现、已验证、已部署|✅ 已完成并部署|✅ 全部完成并验证|✅ 已完成(2026-09-|✅ 已止损|✅ 已落地并部署(2026-|✅ 已完成并部署(2026-|✅ 已实现并单点验证|✅ 已落地并实测验证(配置|📋 标准已立|✅ 代码改造 + 组件级验证|✅ 已落地并验证(服务器 `|✅ 已部署并验证(2026-|✅ 已修复并部署|📋 评估中|🚧 执行中(R0 已完成|🚧 代码完成、产物已出|✅ 已完成并上线验证|✅ 已上线并端到端验证(后端|✅ 已修复并验证(源仓 `c|🔄 进行中(L2 机制级已验 | 号 | 状态 | 一句话 | |---|---|---| @@ -175,11 +176,13 @@ | 04-126 | 🔄 | **会话接续规范:token 超限后如何无损继续**:接续六件套 prompt 骨架与登记门禁的规范文本 | | 04-127 | 📋 | **DSH 平台客户端化部署方案 —— 单机自用**:B 档(单机自用)形态下的客户端化部署路径;⚠️ 真正硬阻塞 = 平台调用层(Windows 裸名 `spawn` ENOENT) | | 04-128 | 📋 | **DSH 桌面客户端开发方案 —— 基于官方 Electron 壳迭代**(待评审 · 只做规划不含代码):复用官方壳 vs 自建的取舍与分发/版本矩阵 | +| 04-129 | ✅ | **日志采集 · 实时巡检 · 事后溯源(方案 C 实现)**:不装新日志服务(否 Amber / 否 Loki+Alloy),用 `scripts/dshlog.mjs` 把 47/106 journald 拉回本机按天归档 + 跨机时间线 + 8 条巡检规则;**服务器侧零安装零端口**;⚠️ 最大剩余缺口 = 实例 `dsh --profile` 的 stdout 不进 journald | +| 04-133 | 🟡 | 🔴 **序㊺ 实测判「D 本轮不实现」**(真实首屏包 **23 块 0 个低熵块**、60 份真实独立内容 **0 份低熵** ⇒ `D-1` 无对象可稀释、`D-2` 前提不成立)⇒ 剩 **C 域分离 + 判据重裁**(序㊻ · automation `aab9e357-8101-4011-b829-bf9f4a459bf7`)|**覆盖网络 · 低熵块治理方案 —— C 域分离 + D 非确定性**:块 id / 内容 id 的收敛哈希改 **per-network keyed hash**(切断跨 network 相关性,代价≈0)+ 低熵块非确定性(首选 **D-1「稀释域」**);⛔ **B(OPRF / SA-MLE)不立项**(降级为可选加强);🔴 **第一个动作 = 补测「低熵块种类数 / 体积 / 占首屏包比例」**(从未测过;且切分是**定长 1 MiB** ⇒ 首屏包内几乎无纯低熵块,待测必须含**子窗口腿**防假绿)|交付 = `交接单/覆盖网络-序45-低熵块治理-测熵与实现.md` | | — | 🗄 | `archive/dsh-improvement-plan-20260909-full.md`:拆分前 19 章 | | — | ✅ | `skills/dsh-change-workflow/SKILL.md`:六阶段 + **红线 R1-R11**(工作副本在本机 `.workbuddy/skills/`)| | — | ✅ | `skills/dsh-decision-method/SKILL.md`:**改造决策方法论**(用户有效决策 U1-U12 / AI 有效决策 A1-A13 / 反例 X1-X8 + 确认最优解十问 + 交互 UI 专项清单)(工作副本在本机 `.workbuddy/skills/`)| | — | ✅ | `skills/dsh-feature-first/SKILL.md`:**功能优先协作协议**(用户只提功能卡 4 问 · AI 自主决策 9 类白名单 · 只上抛功能语义分叉与红线门禁 · 报障闭环前置 · 交付回执格式)(工作副本在本机 `.workbuddy/skills/`)| -| — | ✅ | `skills/dsh-opensource-release/SKILL.md`:**开源导出与版本迭代**(五条硬规则 R-O1–R-O5 · 脱敏映射表唯一口径 · 分层授权与其 MIT 法律前提 · 迭代发布 SOP · 验证六件套 · 8 条实测坑)(工作副本在本机 `.workbuddy/skills/`)| +| — | ✅ | `skills/dsh-opensource-release/SKILL.md`:**开源导出与版本迭代**(硬规则 R-O1–R-O14 · 脱敏映射表唯一口径 · 双轨授权与其 MIT 法律前提 · 迭代发布 SOP · 多远端推送 · 验证七件套 · 21 条实测坑 · v1.7.8)(工作副本在本机 `.workbuddy/skills/`)| | — | ✅ | `skills/dsh-plugin-diagnose/SKILL.md`:**业务插件故障诊断**(三层归属 host / client / 网关·原生绑定 · 三把尺子 inject 差集 / glibc 直测 / 产物插探针 · 8 条实测坑)(工作副本在本机 `.workbuddy/skills/`)| | — | ✅ | `skills/dsh-env-bootstrap/SKILL.md`:**环境引导 / 迁移**(常驻规则快照 `references/常驻规则-快照.md` + `scripts/resident-rules.py` 的 `--check / --inject / --env-check / --snapshot`;**权威方向单向**:CODEBUDDY.md 为权威、快照为副本)(工作副本在本机 `.workbuddy/skills/`)| | — | ✅ | `skills/dsh-auto-handoff-chain/SKILL.md`:**多棒自动接力编排法**(规划棒 ↔ 执行棒交替 · 一次性 automation 链条 · 六件套 prompt 骨架 · 登记门禁 · **排期两条铁律 §3.1.1**〔首个/唯一接续棒 = 收口 + 5~8 分钟 · 同一时刻只挂一个接续棒〕· 六条实测防护 · 断链 / 双开 / once 不转完成态 / 预登记队列 等 · `scripts/chain_report.py` 复跑)(工作副本在本机 `.workbuddy/skills/`)| diff --git a/dsh-server-docs/docs-manifest.json b/dsh-server-docs/docs-manifest.json index c57a2b4..e5eda4b 100644 --- a/dsh-server-docs/docs-manifest.json +++ b/dsh-server-docs/docs-manifest.json @@ -1,29 +1,29 @@ { "generatedFrom": "scripts/docs-manifest.py", "counts": { - "files": 183, - "chars": 1907768 + "files": 189, + "chars": 1982749 }, "tiers": { "hot": 6, "cur": 25, "warm": 61, - "cold": 36, - "doc": 55 + "cold": 38, + "doc": 59 }, "domains": { - "platform": 72, - "method": 43, + "platform": 74, + "method": 47, "ui": 15, "plugin": 27, "ops": 14, - "?": 7, - "external": 5 + "?": 8, + "external": 4 }, "layers": { "L0": 1, - "L5": 143, - "L4": 18, + "L5": 145, + "L4": 22, "L2": 3, "?": 3, "L1": 2, @@ -635,7 +635,7 @@ "num": "125", "title": "会话接续机制 · 问题复盘与修复(2026-09-16)", "status": "?", - "date": "", + "date": "09-18", "chars": 5506, "lines": 138, "refs": 0, @@ -690,6 +690,21 @@ "domain": "platform", "tldr": "" }, + { + "path": "04-调整方案/129-日志采集与巡检-方案C实现.md", + "num": "129", + "title": "日志采集 · 实时巡检 · 事后溯源(方案 C 实现)", + "status": "✅ 已实现 · 本机实测通过", + "date": "", + "chars": 8433, + "lines": 228, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "method", + "tldr": "" + }, { "path": "04-调整方案/13-登录直达冷启动竞态404修复.md", "num": "13", @@ -705,6 +720,21 @@ "domain": "ops", "tldr": "" }, + { + "path": "04-调整方案/133-覆盖网络-低熵块治理方案-C域分离与D非确定性.md", + "num": "133", + "title": "覆盖网络 · 低熵块治理方案(**C 域分离 + D 非确定性**)", + "status": "📐 规划完成 · 待执行(第", + "date": "", + "chars": 9780, + "lines": 199, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "method", + "tldr": "" + }, { "path": "04-调整方案/14-实例会话敏感信息暴露面审计与加固.md", "num": "14", @@ -953,7 +983,7 @@ "date": "2026-09-11", "chars": 2199, "lines": 64, - "refs": 3, + "refs": 12, "refsCurrent": 0, "tier": "warm", "layer": "L5", @@ -2091,8 +2121,8 @@ "title": "dsh 平台文档导航(INDEX)", "status": "?", "date": "", - "chars": 21553, - "lines": 858, + "chars": 22828, + "lines": 264, "refs": 0, "refsCurrent": 0, "tier": "doc", @@ -2346,8 +2376,8 @@ "title": "dsh-auto-handoff-chain — 多棒自动接力编排法", "status": "?", "date": "", - "chars": 9323, - "lines": 238, + "chars": 10721, + "lines": 258, "refs": 0, "refsCurrent": 0, "tier": "doc", @@ -2481,8 +2511,8 @@ "title": "dsh-instance-diagnose — DSH 实例故障诊断", "status": "?", "date": "", - "chars": 10623, - "lines": 225, + "chars": 12545, + "lines": 252, "refs": 0, "refsCurrent": 0, "tier": "doc", @@ -2511,13 +2541,13 @@ "title": "dsh-opensource-release — 开源导出与版本迭代", "status": "?", "date": "", - "chars": 36866, - "lines": 529, + "chars": 48137, + "lines": 595, "refs": 0, "refsCurrent": 0, "tier": "doc", "layer": "L3", - "domain": "external", + "domain": "platform", "tldr": "" }, { @@ -2541,8 +2571,8 @@ "title": "交接单(规划会话 → 执行会话)", "status": "✅ 启用", "date": "2026-09-11", - "chars": 14100, - "lines": 153, + "chars": 16509, + "lines": 157, "refs": 0, "refsCurrent": 0, "tier": "doc", @@ -2774,6 +2804,66 @@ "layer": "L4", "domain": "method", "tldr": "" + }, + { + "path": "交接单/覆盖网络-序24-内容分发块级寻址.md", + "num": null, + "title": "交接单 · 内容分发(块级内容寻址 · 同网段 peer 优先)", + "status": "待执行", + "date": "", + "chars": 6225, + "lines": 168, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "method", + "tldr": "" + }, + { + "path": "交接单/覆盖网络-序25-实例逐步拉起.md", + "num": null, + "title": "交接单 · Manager 重启后「逐步拉起」既有实例", + "status": "待执行", + "date": "", + "chars": 6361, + "lines": 163, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "platform", + "tldr": "" + }, + { + "path": "交接单/覆盖网络-序26-骨干稳定选路与加密.md", + "num": null, + "title": "交接单 · 骨干节点落地(稳定高效选路 + 传输可加密)", + "status": "待执行", + "date": "", + "chars": 6692, + "lines": 178, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "?", + "tldr": "" + }, + { + "path": "交接单/覆盖网络-序45-低熵块治理-测熵与实现.md", + "num": null, + "title": "交接单 · 低熵块治理(**测熵先行** + C 域分离 + D 非确定性)", + "status": "待执行", + "date": "", + "chars": 19215, + "lines": 313, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "method", + "tldr": "" } ] } diff --git a/dsh-server-docs/skills/dsh-instance-diagnose/SKILL.md b/dsh-server-docs/skills/dsh-instance-diagnose/SKILL.md index 789fabf..6ced175 100644 --- a/dsh-server-docs/skills/dsh-instance-diagnose/SKILL.md +++ b/dsh-server-docs/skills/dsh-instance-diagnose/SKILL.md @@ -1,9 +1,9 @@ --- name: dsh-instance-diagnose -description: DSH 多租户平台(alotbuy.com / 47.77.182.89)单个用户实例的「故障诊断」技能,重点是内存 / OOM / 实例崩溃重启。当出现「实例打不开」「会话突然报错/中断」「跑着跑着断了」「响应慢」「怀疑内存不够」时触发。核心:先分「服务级 / 实例级 / 会话级」三层定位,再用 cgroup 内存三件套定量归因,最后在隔离 cgroup 里复现——**绝不在生产实例上做压力测试**。 -version: 1.0.0 -updated_at: 2026-09-12 -last_change: 首版。由 2026-09-12 guest 实例 OOM 排查沉淀(两个会话同一秒被打断 → 内核 OOM 杀 node → exitCode 137),含 cgroup v1 路径、两条死亡路径区分、隔离复现配方与 6 条实测踩坑。 +description: DSH 多租户平台(alotbuy.com / 47.77.182.89)单个用户实例的「故障诊断」技能,重点是内存 / OOM / 实例崩溃重启。当出现「实例打不开」「会话突然报错/中断」「跑着跑着断了」「响应慢」「怀疑内存不够」时触发。核心:先做第 0 层「跨机日志取证」(dshlog),再分「服务级 / 实例级 / 会话级」三层定位,再用 cgroup 内存三件套定量归因,最后在隔离 cgroup 里复现——**绝不在生产实例上做压力测试**。 +version: 1.1.0 +updated_at: 2026-09-18 +last_change: 2026-09-15:死亡路径由 2 条扩到 4 条 —— 新增 C(bwrap 挂载点被嵌套 tmpfs 遮蔽(`Can't chdir to <正常路径>`))与 D(迁移复现不了启动参数(`Can't chdir to :` 空路径)),含分诊判据与「中间目录必须统一前置」的修法。 agent_created: true --- @@ -28,6 +28,24 @@ agent_created: true ## 三层定位(按顺序做,每层都能单独结案) +### 第 0 层 · 跨机日志取证(**先做这一层** —— 2026-09-18 加) + +**遇到"线上跑着跑着不对"但说不清哪一层时,先用 `dshlog` 把 47 / 106 的 journald 拉回本机再判**:跨机、跨单元、带毫秒时间线,比逐台 `journalctl` 快一个量级。 + +```bash +N="E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe" +$N scripts/dshlog.mjs collect --since 6h # 拉取(之后再用就不带 --since,走 cursor 增量) +$N scripts/dshlog.mjs watch --since 1h # 巡检:8 条规则出判据表,有 FAIL ⇒ rc=2 +$N scripts/dshlog.mjs q "/EADDRINUSE|OOM/" --since 24h +$N scripts/dshlog.mjs timeline --from 2026-09-18T20:00 --grep "overlay" --out /tmp/tl.log +``` + +⚠️ **三条必知**(细节见 `04-调整方案/129-日志采集与巡检-方案C实现.md`): + +- 🔴 **`ssh -C` 是硬前提**:47 出方向未压缩实测 ~20 KB/s(1.6 MB 要 84 s),开压缩后 11 s。不加会**看起来像卡死**。 +- 🔴 **实例层(`dsh --profile`)的 stdout 不进 journald**(`_SYSTEMD_UNIT=…scope` 与 `_PID=` 均为空,已双重取证)⇒ **`dshlog` 拿不到实例自己的日志**,只有 Worker 转发的部分。要实例层证据仍须按下面第 2 层**上机取 cgroup / proc**。 +- 📌 归档在 `E:/dsh-logs/`(本机 E 盘,不在仓库内);`prune --keep N` 管保留。 + ### 第 1 层 · 服务级 ```bash @@ -76,14 +94,23 @@ find <用户根>/home/sessions -name session.jsonl.zstd -printf "%TY-%Tm-%Td %TH **别忘了对照**:多实例时横向比 `dsh-instance-mem.log`(**时间戳是 UTC,+8 才是本地时间**)。无插件的实例峰值 vs 有插件的实例峰值 = 插件的净增量。 -## 两条死亡路径(症状不同,别混) +## 四条死亡路径(症状不同,别混) | 路径 | 触发 | 日志指纹 | systemd 结果 | |---|---|---|---| | **A · V8 堆限** | 堆冲到 `--max-old-space-size` | `FATAL ERROR: Reached heap limit` / `Ineffective mark-compacts` | `code=dumped/status=ABRT` | | **B · cgroup 限** | RSS 打满 `MemoryMax` | `kernel: oom-kill:constraint=CONSTRAINT_MEMCG, task=node` | `code=killed/status=9/KILL` → 平台 `exitCode 137` | +| **C · bwrap 挂载点被遮蔽**(2026-09-15 新增) | bwrap 参数里**同时绑多个路径且它们嵌套在同一前缀下**(如"用户根" + "共享技能层")。后挂的 `--tmpfs <共同祖先>` 会把**已绑好的挂载点整个遮掉** | `bwrap: Can't chdir to /ws/xxx: No such file or directory` —— ⚠️ **路径看起来完全正常**,极易误判成"目录没建" | 子进程 `exitCode 1` → 平台按崩溃退避重启 → 5 次后**熔断**(10 min 冷却) | +| **D · cwd 为空**(2026-09-15 新增) | 启动参数里 `folder` 为空/`null`(**跨机迁移**时最容易:复现不了原启动参数) | `bwrap: Can't chdir to : No such file or directory` —— ⚠️ **路径是空的**(一眼可辨) | 同上 | -**两条都拿不到可读的应用层日志**——这正是用户觉得「莫名其妙就断了」的原因。 +**A/B 拿不到可读的应用层日志**(这正是用户觉得"莫名其妙就断了"的原因);**C/D 反而有明确日志** ⇒ 见到 `Can't chdir` 直接分诊: + +- **路径是空的** ⇒ **D**:查实例记录 / 迁移入参里的 `folder` 是不是 NULL。 + 📌 集群模式下实例行是 `claimInstance` 建的(local 模式不写库),**它必须把 `folder`/`patch` 一并落库**,否则迁移时无处取得启动参数。 +- **路径正常却报不存在** ⇒ **C**:看 `orchestrator.ts` 的 bwrap 参数里,**中间目录是不是"就近创建"的**(例如插在 `--bind root root` 之后)。 + ✅ 正确做法:**所有挂载点的中间目录统一前置 + 去重 + 由外到内**,禁止插在任何一个 `--bind` 之后。 +- 最小复现(**别拿生产实例试**):`bwrap <与平台等价的参数> -- /usr/bin/ls -ld <那个路径>`,再逐条增删参数二分。 + ⚠️ 顺带记住:需要给挂载点权限时只能用 `--tmpfs`(自带 0755),**不能用 `--perms`** —— 47 上 bwrap 是 0.4.0,不认该选项。 ## 隔离复现(**唯一允许的压测方式**) diff --git a/dsh-server-docs/skills/dsh-opensource-release/SKILL.md b/dsh-server-docs/skills/dsh-opensource-release/SKILL.md index 73be318..d7a1a8a 100644 --- a/dsh-server-docs/skills/dsh-opensource-release/SKILL.md +++ b/dsh-server-docs/skills/dsh-opensource-release/SKILL.md @@ -1,9 +1,9 @@ --- name: dsh-opensource-release description: DSH 多租户托管平台的「开源导出与版本迭代」技能 —— 把私有代码仓导出成可公开的开源副本(脱敏 / 去插件 / 分层授权 / 重写说明文档),并在后续版本里安全地重跑导出、登记版本。当用户说「开源一份」「导出到 GitHub」「发新版本」「改一下开源那份的脱敏/授权/说明」「开源那份同步一下」时触发。核心:**源仓库只读** + **阻断性探针 0 命中**才准放行 + **OVERLAY 手工撰写层**不得被重建抹掉。 -version: 1.6.0 -updated_at: 2026-09-15 -last_change: 1.6.0(2026-09-15):用户立 **K8s 长期策略** —— 「后续获取开发项目代码时**跳过** K8s 相关部分,**就用当前清除/修改后的版本**」⇒ 新增 **R-O14**(源仓那侧视为**废弃分支**;判定标准 = 构建 `blocking hits: 0`),并**修掉会误导未来会话的过期事实**:① §2「保留」列表原写 `deploy/`(11) 与 `poc/`(16) **要保留** —— 与 K8s 政策**直接冲突**(会让未来会话把它们加回来)⇒ 改为「K8s 相关一律不带」+ 指向 `_K8s排除台账.md` / `_k8s_scan.py`;② §0 的 `dsh-web-platform` → **`dsh-users-platform`**、中文母本 → **英文为主(`*.zh-CN.md`)**、首发行 → **v1.1.0 / 153 文件 / 历史已重置为单条提交 `a327649`**、补计数现状(OVERLAY 26 · REQUIRED 54 · INCLUDE_DIRS 6 · EXCLUDE_FILES 9 · DROP_SCRIPTS 8);③ 标注 `K8S_SEMANTIC_RULES`(清「不含 `k8s` 字样但语义已不成立」的描述)与 `_apply_k8s_semantics.py`(本机 `--force` 被安全删除层拦下时的**热应用**替代路径)。1.5.2(2026-09-13):`assets/` 漏收录 + 空括号残渣两处真问题。 +version: 1.7.8 +updated_at: 2026-09-18 +last_change: 1.7.8(2026-09-18):**双轨的轨 2 从「README 一段散文」升格为独立文件**(用户选「B 模式」后的正向落地,提交 `25f930f`)—— 新增 `_overlay/COMMERCIAL-LICENSE.md` + `.zh-CN.md`,`OVERLAY` **28 → 30**、`REQUIRED_EXPORT` **58 → 60**,四份 README 授权节尾各 +1 句链接;并立 **§5 三条措辞铁律**(⚠️ 写成「平行的许可选择」⛔ 绝不写成"对 AGPL 的限制" · **文件名刻意不以 `LICENSE` 开头** · **正文不含项目名** ⇒ 改名窗口期两版通用);同时记 🔴 **归档里那份自拟「个人免费/商用须书面授权」`LICENSE` 不得放回**(§三 放 AGPL 项目里 = §10 追加限制 = 罗盒案否定)。新增 **坑 20 —— 新增中英文件对必须同步登记 `_check_parity.mjs` 的 `PAIRS`,否则这对中英**静默跳过**对等检查**。1.7.7(2026-09-18):**新增 §6b「多远端推送」**(GitHub 主仓 `origin` + CNB 镜像 `cnb`)—— 含远端表、`git remote add` 一次性配置、**只推 main(备份分支含改名泄漏期旧内容,绝不外推)**、⚠️ **CNB 推送必须后台跑**(实测前台 180s 被 SIGTERM 杀掉,仓库仅 1.67 MB、207 objects ⇒ 与体积无关;后台 20s 完成),以及**「三方同 hash」验收判据**;并记录「README clone 地址保持指向主仓、如需改指镜像须先问用户」。1.7.6(2026-09-18):**新增坑 19 —— `cp _overlay/ <仓>/` 会把「下一版内容」整份带进当前版**(改名窗口期血证,已误发上公网)—— 真实危害是**功能性缺陷**(README 让用户 clone 一个当时**空**的新仓 ⇒ 照做的人克隆到空仓库、部署直接断);最阴的一点是这类误覆盖会让「导出仓 vs `_overlay` 的 diff **变空**」,而**空 diff 本身就是覆盖证据**、不是"一致"的好消息;并加**推前两条必做复核**(新名 grep 须为空 + 部署命令自洽)。1.7.5(2026-09-17):**新增「发布合规审查」为验证第 ⑦ 件套**(用户要求:查**侵权 / 负面影响 / 涉政治**)—— 附「合规审查三关」表与判据(⚠️ **负面词命中不必然要改**,判据是「这句对外有正面价值,还是只在自曝」;自曝已发生事故的一律改为只讲**设计动因**);同时把 `core.autocrlf=true` 的陷阱写进第 ② 项(比对 overlay ↔ 仓库**必须用 git blob**)。1.7.4(2026-09-17):**「AI 生成」口径改为「人负责规划与关键判断,AI 负责实施」**(用户同日 11:44 曾要求全删、**11:48 改为本条 ⇒ 以本条为唯一现行口径**)—— 落点:README Hero 徽章 `code & docs-human-planned, AI-implemented`(徽章 **5** 个)+ 一行声明 · `manual/project.{md,zh-CN.md}` **新增 `## How this project is built` / `## 项目如何建成`**(一行声明 + 2 行「环节 → 由谁负责」表)· 两份 README 文档地图 · `_upload_audit.mjs` 提交文案。**这是法律加固**:中国版权保护中心 2026 新规判据 = **人类独创性智力投入**,旧口径「全部由 AI 生成」= 自认纯 AI ⇒ 商业授权缺标的物;新口径**恰恰确立人类投入**。⛔ 不要写回「全部由 AI 生成」,⛔ 也不要照 09-13 删掉「人的角色」。1.7.3(2026-09-17):**按用户指令删除全部「AI 生成」表述** —— 范围:README Hero 的 `AI-generated` 徽章与一行声明 · `manual/project.{md,zh-CN.md}` 的 `## AI generation` 整节 + 4 行来源表 + H1 字样 · README 文档地图字样 · `_upload_audit.mjs` 提交文案(顺带修掉同行过时的「模式 B(Kubernetes)实验性」)。**起因** = 中国版权保护中心 **2026 新规「纯 AI 生成的软件不予登记」**⇒ 自认纯 AI 会让商业授权**缺标的物**。⚠️ **推翻 2026-09-13 的旧口径**(当时要求「AI 生成」是第 1 个正文章节、「三处一个都不能少」)—— 以 09-17 为准。✅ 保留 `DeepSeek V4 / V4.1 flash` 模型标注徽章(徽章 5 → **4** 个)。1.7.2(2026-09-17):**授权定案「双轨」并落地** —— 用户两轮追问 Commons Clause 后选定:**轨 1 = AGPL-3.0 原样(不加任何限制)**、**轨 2 = 商业授权**(`maogeigei@gmail.com`)。重写 **§5** 整章,含两条铁律(① **绝不在 AGPL 上加限制**,含「只禁售源项目」这种窄限制 —— 依据 §10/§7 + OSD 第 6 条,判据是「有没有限制」不是「限制多窄」;② **也不需要加** —— AGPL 的 copyleft 本身就实现防白嫖转售)与两条配套事实(**CC 只能替换不能叠加** · **已发布版本收不回**);README 中英末节改写为双轨表述。1.7.1(2026-09-17):**更名 `dsh_ai1net`(中文「能力网络」)进入执行** —— 用户选定**方案 A**(env 前缀与数据根一并改)⇒ ① `_build_export.py` **46 行规则**改完(12 处目标值 + 34 处匹配/注释,⛔ 源模式与行序不动)、**新增 3 条旧名阻断探针**;② 6 个工具脚本(`_verify_tsc` / `_sop_check` / `_upload_audit` / `_verify_all` / `_k8s_comment_scan`)的新目录名与新仓 URL;③ `_overlay/` **13 文件 139 处**(含「DSH 用户平台」→「能力网络」)。全部静态验证通过(AST / `node --check` / 英文文档汉字数 = 4 / 旧名残留 0)。⏳ **待源仓冻结基线后重建**。1.7.0(2026-09-17):**修掉工作根改名后遗留的失效引用 + 一批过期事实** —— ① 工作根 2026-09-16 由 `dsh-laijing-github` 改名 **`dsh-ai1net-github`**,技能内 2 条 `cd` 命令与工作根表同步更新;② **坑 9 补「改名前先扫全部引用点」**(实测要同步的是 **5 处**脚本常量而非 2 处:`OUT` · `EX` · `_sop_check` / `_upload_audit` / `_verify_all` 各自的 `W`);③ 过期事实修正:`OVERLAY` **3 份 → 26 份**(`LICENSE` 已按 B 方案以 **AGPL-3.0** 加回)、版本 `v1.1.0/153` → **v1.2.0 / 远端 `c39758d` / 172 tracked**、计数按 **AST 实读**重刷(REQUIRED 56 · DROP_SCRIPTS 28 · LEAK_PROBES 72 …)、`<导出根>\dsh-web-platform\` → `dsh-users-platform`、补 `_check_links.mjs` / `_check_parity.mjs ` 用法;④ **新增 🔴 新名称 `dsh_ai1net`**(用户 09-17 定 = 本项目对应开源项目的新名称;旧仓保留不动;影响面 97 文件/257 处 + env 194 处 + overlay 134 处,**待执行**)。1.6.0(2026-09-15):用户立 **K8s 长期策略** —— 「后续获取开发项目代码时**跳过** K8s 相关部分,**就用当前清除/修改后的版本**」⇒ 新增 **R-O14**(源仓那侧视为**废弃分支**;判定标准 = 构建 `blocking hits: 0`),并**修掉会误导未来会话的过期事实**:① §2「保留」列表原写 `deploy/`(11) 与 `poc/`(16) **要保留** —— 与 K8s 政策**直接冲突**(会让未来会话把它们加回来)⇒ 改为「K8s 相关一律不带」+ 指向 `_K8s排除台账.md` / `_k8s_scan.py`;② §0 的 `dsh-web-platform` → **`dsh-users-platform`**、中文母本 → **英文为主(`*.zh-CN.md`)**、首发行 → **v1.1.0 / 153 文件 / 历史已重置为单条提交 `a327649`**、补计数现状;③ 标注 `K8S_SEMANTIC_RULES` 与 `_apply_k8s_semantics.py`(本机 `--force` 被安全删除层拦下时的**热应用**替代路径)。1.5.2(2026-09-13):`assets/` 漏收录 + 空括号残渣两处真问题。 agent_created: true --- @@ -32,7 +32,7 @@ agent_created: true | 6 | **废弃的中间候选名静默残留** —— 探针只探最老的名字,`_overlay` 里的中间名(`dsh-hosting`)漏了 72 处 | **所有曾用名**都进 `LEAK_PROBES` | §8 坑 14 | | 7 | **说明文档文案连改 7 轮**(替别人宣传 / 开头讲基线 / 议论式表述 / 授权在最前 / 把未验证的当可用 / 致谢太长) | 严格照 **R-O9–R-O12** 写;**写完自审一遍**再交付 | R-O9–R-O12 | | 8 | **脚本里用键名当标题**(`marks[k][0]` 拿到的是键 `"A"` 不是标题)⇒ 结构改写打歪,误删 `PoC` 的一个字母 | 结构改写用**完整标题字符串**做锚点;改完**读回原文复核**关键块 | 本表 #7 的同一节 | -| 9 | **改工作根时漏改脚本常量 ⇒ 旧目录被"重建复活"**(2026-09-13 迁移到 `dsh-laijing-github` 时:先搬目录、后改 `OUT`,中间跑了一次重建 ⇒ 旧位置被重新建出 135 个文件,且**因旧位置没有 `_overlay` 而缺了 6 个手工层文件**;`_overlay` 本身侥幸没被洗掉) | **顺序必须是:① 改脚本常量(`OUT`+`EX`)→ ② 再搬/删目录 → ③ 重建验证**。迁完**必查旧目录没有复活**(`ls`),并核对 `find -type f \| wc -l` 与手工层 6 个文件是否都在 | §8 坑 17 | +| 9 | **改工作根时漏改脚本常量 ⇒ 旧目录被"重建复活"**(2026-09-13 迁移到 `dsh-laijing-github` 时:先搬目录、后改 `OUT`,中间跑了一次重建 ⇒ 旧位置被重新建出 135 个文件,且**因旧位置没有 `_overlay` 而缺了 6 个手工层文件**;`_overlay` 本身侥幸没被洗掉) | **顺序必须是:① 改脚本常量 → ② 再搬/删目录 → ③ 重建验证**。迁完**必查旧目录没有复活**(`ls`),并核对 `find -type f \| wc -l` 与手工层文件是否都在。
🔴 **改名前先 `grep -rn "<旧路径>" --include="*.py" --include="*.mjs" --include="*.sh"` 把全部引用点扫出来** —— 2026-09-16 由 `dsh-laijing-github` 改名时实测:要同步的**不是 2 处而是 5 处**(`OUT` · `EX` · `_sop_check.mjs` / `_upload_audit.mjs` / `_verify_all.mjs` 各自的 `W`) | §8 坑 17 | | 10 | **白名单收录静默漏项:`assets/` 没进 `INCLUDE_DIRS`**(2026-09-13 用户问"确认都同步了吗"时查出)—— 导出的仓库缺 `assets/inject/{recovery,assist}.js`:`proxy.ts` 的 `loadInject` 会 **fail-fast 抛错**(平台起不来)、仓库自带 `scripts/verify-inject.cjs` 也会**判失败**;`package.json` 的 `files` 同样缺 `assets`(npm/git 安装也会缺) | 已补 `INCLUDE_DIRS`、`package.json` files 规则,并**新增 `REQUIRED_EXPORT` 清单(**23 项**)**:缺任何一项 ⇒ **构建判失败**(`return 1`)。以后新增"运行时要读的文件"必须同步加进该清单 | §3 · §8 坑 18 | | **10** | 🔴 **"顺手清理"的正则把 ASCII 标点也吃进去 ⇒ 直接改坏源码**(2026-09-13 去「档案 NN」时:清理规则写成 `[((]\s*[))]` / `[;;,,]\s*[))]`,**字符类里混了 ASCII `(` `)` `,` `;`** ⇒ 源码里所有 `foo()` 被删成 `foo`,`whitelist.ts` / `security-scan.ts` 当场语法错(tsc 报 *Invalid character* / *Unterminated string literal*)。**而当时 leak 探针全过**) | ⛔ **清理类正则只准碰全角标点(()·、,:;),绝不可把 ASCII 语法符号写进字符类**;
✅ **改完必跑 `_verify_tsc.mjs`,`exit 0 + no diagnostics` 是"没改坏代码"的唯一证据** —— **探针全过 ≠ 代码还活着**,两者查的是完全不同的东西;
另:`(\s*/\s*` 这类"吃掉前导斜杠"的规则会毁掉 `(/api/x)` 路径,一律不要写 | 本表新条目 · 台账 §八 D | | **11** | 🔴 **同一个名字既当"脱敏目标"又当"公开值" ⇒ 自相矛盾**(2026-09-13 填 `PUBLISH_*` 时:`maogeigei` 同时在 `GLOBAL`(替换为占位符)与 `LEAK_PROBES`(判泄露)里 ⇒ ① 刚填好的公开值被规则**改回占位符**,② 探针报 `blocking hits: 4`。**"占位符残留 0"是假象**) | ⛔ **任何进入 `PUBLISH_*` 的值,先 `grep -n "<该值>" _build_export.py` 确认它不在 `GLOBAL`/`LEAK_PROBES`/`INFO_PROBES` 里**;升格为公开身份后要**同时**从 GLOBAL **与** 探针移除(只删一处 = 另一种错);
公开联系方式的兜底应靠**更具体的探针**(如私有仓库域名 `work.alotbuy`),不要靠账号名 | 台账 §八 D2 | @@ -50,26 +50,28 @@ agent_created: true |---|---| | **中文名 / English name** | **DSH 用户平台** / **DSH Users Platform**(2026-09-14 由 `dsh-web-platform` 定稿改名) | | **文档语言(2026-09-14 定稿,覆盖 R-O13)** | **英文为主**:主文档 `*.md` 为英文 + 顶部中文入口;中文全文在 **`*.zh-CN.md`**;**`manual/` 8 篇 ×2 语言**;架构图也分语言(`diagrams/architecture{,.zh-CN}.svg`) | -| **内容来源(用户 2026-09-13 定的重点卖点)** | **全部代码与文档由 AI 生成** —— 模型 **DeepSeek V4 / V4.1 flash**(**不写工具名**)。README 里有专节 `## AI 生成`(**位于「目录」之后、「亮点」之前**),Hero 区还有**一行声明 + 2 个徽章**(`code & docs-AI-generated`、`DeepSeek V4 / V4.1 flash`)—— **这三处一个都不能少** | +| **内容来源(🔴 2026-09-17 定稿口径)** | **「人负责规划与关键判断,AI 负责实施」**(英文 `Human planning and key judgment; implementation by AI`)—— 模型 `DeepSeek V4 / V4.1 flash`(**不写工具名**)。⛔ 不要写成「全部由 AI 生成」(法律风险:中国版权保护中心 2026 新规「纯 AI 生成的软件不予登记」⇒ 商业授权缺标的物);⛔ 也不要照 2026-09-13 删掉「人的角色」。落点见下方「本项目定稿顺序」 | | ~~**文档语言**~~ | ⛔ **本行已作废** —— 2026-09-14 起改为**英文为主**,见上表「文档语言(2026-09-14 定稿)」行;
**R-O13 的「中文母本 + `*.en.md`」口径同时作废**,现状是 `*.zh-CN.md` 副本 | | **技术标识(仓库·包·服务·env 前缀)** | **`dsh-users-platform`** | `DSH_USERS_PLATFORM_*` | `/var/lib/dsh-users-platform`(中文名「DSH 用户平台」/ English「DSH Users Platform」) | -| **曾用名(全部必须在 `LEAK_PROBES` 里)** | `dshs` · `dsh-multitenant` · `dsh-hosting` · **`dsh-web-platform`** · `DSH_WEB_PLATFORM_*` · `/var/lib/dsh-web-platform`;`taimiao` 未落地也一并加入 | +| 🔴 **新名称(2026-09-17 用户定,🟡 执行中)** | **`dsh_ai1net`** —— 中文名 **「能力网络」** | 英文名 **`DSH AI1NET`** | 技术标识 `dsh_ai1net` / `DSH_AI1NET_*`。用户原话:「是 `E:\ProgramData\AI技能\aliyun-dsh-server` 对应**开源项目的新名称**」。**旧仓 `maogeigei/dsh-users-platform` 保留不动**(用户 09-16「之前的不动」),新仓 `maogeigei/dsh_ai1net` 已建(空仓),密钥 `id_ed25519_ai1net` 已绑。
**口径 = 方案 A(用户 09-17 08:18 选定)**:env 前缀 → `DSH_AI1NET_*` · 数据根 → `/var/lib/dsh-ai1net` · 库文件 → `dsh_ai1net.db`(与源仓在此项上**永久分叉**,映射规则在导出层)。
✅ **已完成(09-17 08:3x)**:`_build_export.py` **46 行规则** + 3 条旧名探针 · 6 个工具脚本 · `_overlay/` **13 文件 139 处** —— 均静态验证通过。
⏳ **待做**:源仓提交出冻结基线 → 重建 → `.git` 迁移 → 全量验证 → 推送新仓。
📄 作业书:`_改名执行清单_dsh_ai1net_20260917.md`(§零 进度 / §三 规则清单 / §四 步骤) | +| **曾用名(全部必须在 `LEAK_PROBES` 里)** | `dshs` · `dsh-multitenant` · `dsh-hosting` · **`dsh-web-platform`** · `DSH_WEB_PLATFORM_*` · `/var/lib/dsh-web-platform`;`taimiao` 未落地也一并加入。**⚠️ 改名 `dsh_ai1net` 执行后,须把 `dsh-users-platform` / `DSH_USERS_PLATFORM_*` / `/var/lib/dsh-users-platform` 补进 `LEAK_PROBES`** | | **前名(已废弃,现为阻断探针项)** | `dsh-multitenant` / `DSH_MULTITENANT_*` / `/var/lib/dsh-multitenant` —— 2026-09-13 20:2x 由用户定名 `dsh-web-platform` 取代;`LEAK_PROBES` 已收录,**出现即判泄露** | | **源仓库(只读!)** | `D:\github\dsh_shenxian` | -| **工作根(GitHub 开源专用文件夹)** | `E:\ProgramData\AI技能\dsh-laijing-github\`(2026-09-13 由 `aliyun-dsh-server\_开源导出_20260913\` 整体迁入;**本项目的独立开源工作区**,不再是 `aliyun-dsh-server` 的子目录) | -| **仓库根(可直接 git init)** | `<导出根>\dsh-web-platform\` | +| **工作根(GitHub 开源专用文件夹)** | `E:\ProgramData\AI技能\dsh-ai1net-github\`(2026-09-13 由 `aliyun-dsh-server\_开源导出_20260913\` 整体迁入;**2026-09-16 由 `dsh-laijing-github` 改名**;**本项目的独立开源工作区**,不再是 `aliyun-dsh-server` 的子目录) | +| **仓库根(可直接 git init)** | `<导出根>\dsh-users-platform\`(**172 tracked 文件**) | | 构建脚本 | `<导出根>\_build_export.py`(**默认拒跑**,须 `--force`) | | 手工撰写层快照 | `<导出根>\_overlay\`(脚本自动维护) | | 类型检查 | `<导出根>\_verify_tsc.mjs`(建 junction → tsc → `rmdirSync` 拆) | +| 文档校验 | `<导出根>\_check_links.mjs `(链接/锚点/配图)· `<导出根>\_check_parity.mjs `(中英对等 + 列出英文里的汉字;**英文文档汉字数应恒为 4**) | | 给人看的台账 | `<导出根>\_导出说明与脱敏台账.md`(**不随仓库上传**;§七 = 改名记录) | -| 手工撰写层(`OVERLAY`:重建时自动快照→恢复,**不得被抹掉**) | **现为 3 份**:`README.md` · **`PLUGIN-PORTING.md`** · `install.sh`(原另有 `LICENSE` / `LICENSE-UPSTREAM-MIT.txt` / `THIRD-PARTY-NOTICES.md`,**2026-09-13 已按用户要求移除** —— 见 §5) | -| 当前版本 | **v1.1.0 / 2026-09-14**,**153 文件**;历史已按用户要求**重置为单条提交**(`a327649`,强推覆盖)| 首发 v1.0.0 / 2026-09-13 | -| 计数现状(2026-09-15) | `OVERLAY` **26** | `REQUIRED_EXPORT` **54** | `INCLUDE_DIRS` **6** | `EXCLUDE_FILES` **9** | `DROP_SCRIPTS` **8** | +| 手工撰写层(`OVERLAY`:重建时自动快照→恢复,**不得被抹掉**) | **现为 30 份**:`README.md` · `LICENSE`(**AGPL-3.0,2026-09-14 按用户选定 B 方案加回**)· **`COMMERCIAL-LICENSE.md` + `.zh-CN.md`(2026-09-18 新增 · 双轨的轨 2)** · `PLUGIN-PORTING.md` · `install.sh` · `install.md` · `AGENTS.md` + 5 个 `*.zh-CN.md` + `manual/` **9 对 18 份**(2026-09-17 新增 `decisions.{md,zh-CN.md}`) | +| 当前版本 | **v1.2.0 / 2026-09-15**,已发布至远端 `main` = **`25f930f`**(普通推送累计;`332afff` 后追加商业轨文件)| 首发 v1.0.0 / 2026-09-13 | v1.1.0 / 2026-09-14 | +| 计数现状(2026-09-18 AST 实读) | `OVERLAY` **30** | `REQUIRED_EXPORT` **60** | `INCLUDE_DIRS` **6** | `INCLUDE_FILES` **8** | `EXCLUDE_FILES` **9** | `DROP_SCRIPTS` **28** | `LEAK_PROBES` **75** | `INFO_PROBES` **2** | `REGEX_RULES` **73** | `GLOBAL` **71** | `K8S_SEMANTIC_RULES` **14** | `TEXT_EXT` **16** | `EXTRA_TREES` **3** | | 上游基线(三方) | `上游骨架仓库(已按要求不再具名)` → **MIT**(GitHub 仓库 + DSH 插件目录已收录) | | 运行期上游 | `@deepseek-ai/dsh`(DeepSeek Harness)→ **MIT** | | 本机 Python | `/e/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe` | -> ⚠️ **工作根 = `E:\ProgramData\AI技能\dsh-laijing-github\`**(独立 GitHub 开源文件夹)。**不要再改名或迁移** —— 这个名字被 `_build_export.py` 的 `OUT`、`_verify_tsc.mjs` 的 `EX`、本技能、项目 MEMORY 同时引用。真要迁:先改这几处,再**复跑重建 + tsc**,并检查旧目录**没有被重建脚本重新建出来**(迁完曾因漏改 `OUT` 而复活一次)。 +> ⚠️ **工作根 = `E:\ProgramData\AI技能\dsh-ai1net-github\`**(独立 GitHub 开源文件夹)。**不要再改名或迁移** —— 这个名字被 **5 个工具脚本** + 本技能 + 项目 MEMORY 同时引用。真要迁:① 先改**全部 5 处脚本常量**(`_build_export.py:13` `OUT` · `_verify_tsc.mjs:5` `EX` · `_sop_check.mjs:5` / `_upload_audit.mjs:5` / `_verify_all.mjs:5` `W`)→ ② 再搬/删目录 → ③ 复跑重建 + tsc,并检查旧目录**没有被重建脚本重新建出来**(2026-09-13 曾因漏改 `OUT` 而复活一次;**2026-09-16 由 `dsh-laijing-github` 改成现名时,已于 09-17 按此顺序把 5 处全部同步完**)。 --- @@ -103,9 +105,9 @@ agent_created: true | # | 动作 | 判据 | |---|---|---| -| 1 | **起自己的名**,并**实测未被占用**(npm `registry.npmjs.org/` + 插件目录 `dshbase.com/plugins/`,两处都 404 才可用) | 本项目命名口径(用户 2026-09-13 定):中文 **DSH Web 平台** | English **DSH Web Platform** | 技术标识 **`dsh-web-platform`**(短横连写用于仓库/包/服务/env 前缀)。
⚠️ **已排除的两个候选**:**`dsh-hive`**(第三方插件 `llluchy/dsh-hive` 已占用);**`dsh-hosting`**(本会话中间候选,已被用户口径取代 ⇒ **仓库里不得残留**,已列入阻断性探针) | +| 1 | **起自己的名**,并**实测未被占用**(npm `registry.npmjs.org/` + 插件目录 `dshbase.com/plugins/`,两处都 404 才可用) | 🔴 **现行命名(用户 2026-09-17 定,🟡 执行中)**:中文 **「能力网络」** | English **`DSH AI1NET`** | 技术标识 **`dsh_ai1net`**(详见 §0 表「新名称」行)。
**同口径历史**:中文 **DSH 用户平台** | English **DSH Users Platform** | 技术标识 **`dsh-users-platform`**(2026-09-14 定,线上旧仓仍用此名,**冻结不动**)。
⚠️ **已排除的候选**:**`dsh-hive`**(第三方插件 `llluchy/dsh-hive` 已占用);**`dsh-hosting`**(中间候选,已被取代 ⇒ **仓库里不得残留**,已列入阻断性探针);**`taimiao`**(仅候选,未落地)。 | | 2 | **改完全部自指标识**:包名 / bin / cordis plugin id / env 前缀(36 种)/ 数据根 / systemd 单元 / nginx conf / 库文件名 / 镜像与 k8s / 导出目录名 | 全仓 `grep` 旧名,**只剩出处引用**才算干净 | -| 3 | **保留出处并正式致敬 —— 但只放在文末**(用户 2026-09-13 明确:"在最后提一下引用了谁就行,不要上来就重点讲用了谁") | ① `LICENSE-UPSTREAM-MIT.txt` **逐字节不动**(MIT 强制);② README **不得在开头单独设「名称与渊源 / 基于 XX」章节** —— 出处只出现在**文末**的「第三方组件与致谢」里,**一句话带过**;③ 法律归属由 `LICENSE` 第一层说明 + `THIRD-PARTY-NOTICES.md` §4 承载(这两处必须完整,不受"轻描淡写"影响) | +| 3 | **保留出处并正式致敬 —— 但只放在文末**(用户 2026-09-13 明确:"在最后提一下引用了谁就行,不要上来就重点讲用了谁") | ① 上游 = **DeepSeek Harness(MIT)**,**本项目不包含、不分发其代码** ⇒ 无需单独 MIT 声明文件,README 文末保留**一行道义致敬**(`dshs`,MIT,⛔ 不许删);② README **不得在开头单独设「名称与渊源 / 基于 XX」章节** —— 出处只出现在**文末**的「第三方组件与致谢」里,**一句话带过**;③ 本项目的法律归属由 **`LICENSE`(AGPL-3.0 官方全文)** + README 末节「授权与商业使用」承载 | ⚠️ **改名时最容易误伤的两处**:① 把「致敬上游」的引用一起改掉(= 抹掉出处,法律与道义双重问题);② 把脚本里作**源模式**的旧名改掉(见 §8 坑 9)。 @@ -121,7 +123,7 @@ agent_created: true | **配置表 / 脚本文案同步标注** | 未验证路径的 env 变量统一加「(模式 X · 未验证)」前缀;`install.sh` 的报错/提示文案要与 README 口径一致 | | **FAQ 给一句直答** | 加一条「X 现在能用吗?」→ 直接回答「**还不能**」,并说清**缺哪一步**(如「从未做过端到端部署验收」) | | **归属措辞也要改** | 出处/致谢里描述对方贡献时,把「双部署**形态**」降级为「双部署**框架**」,避免暗示未验证的路径是可选项 | -| **落点清单(照抄)** | README:部署形态表 + 快速开始 + 配置表 + 功能详解 + 安全模型 + 亮点 + 目录结构 + FAQ;`install.sh` 文案;`LICENSE` 与 `THIRD-PARTY-NOTICES.md` 的归属措辞 | +| **落点清单(照抄)** | README:部署形态表 + 快速开始 + 配置表 + 功能详解 + 安全模型 + 亮点 + 目录结构 + FAQ;`install.sh` 文案;README **末节「授权与商业使用」**与**文末致敬行**的措辞 | --- --- @@ -132,12 +134,12 @@ agent_created: true | 规则 | 说明 | |---|---| -| **位置** | 只在**文末**(README 的「第三方组件与致谢」+ `LICENSE` 第一层 + `THIRD-PARTY-NOTICES.md` §4)。**不得**在开头/前部单设「名称与渊源」「基于 XX」章节 | +| **位置** | 只在**文末**(README 的「第三方组件与致谢」+ 末节「授权与商业使用」)。**不得**在开头/前部单设「名称与渊源」「基于 XX」章节 | | **主语** | 正文一律**以本项目为主语**。写「本项目做了…」,**不要**写成「上游提供了骨架,我们在此基础上…」这种自我降格的框架 | | **措辞** | 用「**起步时参考了** X(作者,许可证)—— **感谢作者开源**」;**不要**用「仅仅接着往前走了一步」「离开它就没有这个项目」这类过度抬举,**也不要**补「我们做了大量深度改造」这类**自我表扬**(见下表「致谢只写一行」) | -| **边界** | 「轻描淡写」**只作用于 README 正文**;MIT 要求的版权与许可声明(`LICENSE-UPSTREAM-MIT.txt`)与授权分层说明**一处都不能省** | +| **边界** | 「轻描淡写」**只作用于 README 正文**;**`LICENSE`(AGPL-3.0 官方全文,逐字节)与 README 末节的授权说明一处都不能省** | | **JS/注释里的名字** | 代码注释里也**不主动**出现上游名(它们已经被 R-O8 第 2 步改成新名;只剩出处语境才允许) | -| **致谢只写一行**(2026-09-13 第七次纠正) | 出处行 = **「起步时参考了 X(作者,许可证)—— 感谢作者开源」**,一行结束。**不要**写:① 骨架范围的枚举(那是 `LICENSE` 第一层与 `THIRD-PARTY-NOTICES.md` §4 的职责,**重复即冗余**);② 「此后我们做了大量深度改造」(**自我表扬**,致谢不是讲功绩的地方);③ 「项目名从它的 X 换成我们的 Y(避免指代混淆…)」(**内部事务**,读者不关心,改名说明留在 NOTICE 的「更名说明」行即可)。用户原话:「**有必要讲这么多吗,好好想想**」 | +| **致谢只写一行**(2026-09-13 第七次纠正) | 出处行 = **「起步时参考了 X(作者,许可证)—— 感谢作者开源」**,一行结束。**不要**写:① 骨架范围的枚举(那是 `LICENSE` 与 README 末节授权说明的职责,**重复即冗余**);② 「此后我们做了大量深度改造」(**自我表扬**,致谢不是讲功绩的地方);③ 「项目名从它的 X 换成我们的 Y(避免指代混淆…)」(**内部事务**,读者不关心 ⇒ 不进公开文档)。用户原话:「**有必要讲这么多吗,好好想想**」 | | **别解释我们为什么这么写** | 「—— 这是 MIT 的硬性要求,必须保留」「因此 README 与 install.sh 一律以模式 A 为准」这类**关于文档自身**的话术删掉;正文只陈述事实与规则本体 | | **指针行不重复** | 「机制见亮点」这类导航指针**全文留 1–2 处**(Hero + 功能详解)即可,Content 各节末尾各来一句 = 冗余 | --- @@ -172,14 +174,18 @@ agent_created: true **本项目定稿顺序(Hero + 16 节)** ``` -标题 → 一行描述 → 徽章(**5 个**:Node · Version · built on DSH · AI-generated · DeepSeek V4/V4.1 flash;License 徽章已随授权类内容一同移除) -→ Hero(三条主线 + **一行「全部代码与文档由 AI 生成」** + 拓扑图)→ 快速开始片段 -目录 → **AI 生成** → 亮点 → 快速开始 → 功能详解 → 架构 → 安全模型 → 部署形态 → 配置 +标题 → 一行描述 → 徽章(**5 个**:Node · Version · built on DSH · **code & docs-human-planned, AI-implemented** · DeepSeek V4/V4.1 flash;**无 License 徽章**) +→ Hero(三条主线 + **一行「人负责规划与关键判断,AI 负责实施」** + 拓扑图)→ 快速开始片段 +目录 → 亮点 → 快速开始 → 功能详解 → 架构 → 安全模型 → 部署形态 → 配置 → 控制面 API → 插件移植指南 → 开发 → 常见问题 → 目录结构 → 版本与迭代 → 贡献 → 第三方组件与致谢 → 授权与商业使用(必须最后)→ 授权摘要收尾(全文唯一一处摘要) ``` -> ⚠️ `## AI 生成` 是**第 1 个正文章节**(用户 2026-09-13 明确「还有个重点要加载前面」)—— 它是这个项目的**核心差异化**:整站代码与文档由 AI 写成。**别把它挪到后面,也别只留徽章不留正文**。 +> 🔴 **「本项目由谁建成」= 现行唯一口径(2026-09-17 11:48 用户定):「人负责规划与关键判断,AI 负责实施」**(英文 `Human planning and key judgment; implementation by AI`)。 +> · **落点**:README Hero 的 `code & docs-human-planned, AI-implemented` 徽章 + 一行声明 · `manual/project.{md,zh-CN.md}` 的 **`## How this project is built` / `## 项目如何建成`**(一行声明 + 2 行「环节 → 由谁负责」表:人 = 方向/范围/架构决策/评审验收;AI = 代码/测试/文档/插件改造示例)。 +> · **为什么必须这样写**:中国版权保护中心 **2026 新规「纯 AI 生成的软件不予登记」**,判据 = **人类独创性智力投入**。旧口径「全部代码与文档由 AI 生成」= **自认纯 AI** ⇒ 商业授权**缺标的物**;新口径**恰恰确立人类投入** ⇒ 是**法律加固**,⚠️ 不要简化回「AI 生成」(详见 §5 与 `<工作根>\_授权决策与法律依据_20260917.md`)。 +> ⛔ **不要再写回「全部由 AI 生成」**(用户 09-17 **11:44 曾短暂要求全部删除,11:48 改为本条**);⛔ **也不要照 2026-09-13 的「删掉人的角色」** —— 两者均已被推翻。 +> ✅ 一并保留:`DeepSeek V4 / V4.1 flash` 模型标注徽章 · README 的 `**能力网络** · **DSH AI1NET**` 行。 | # | 硬规则 | 理由 | |---|---|---| @@ -210,7 +216,7 @@ agent_created: true | **生成时机** | **发布 / 推送 GitHub 之前那一步**(SOP 的 5.5)—— 也就是 §7 验证之后、§6 交付之前 | | **语言切换行** | 两份文件**顶部都要有**:中文版写 `[中文](README.md) · [English](README.en.md)`;英文版写 `[English](README.en.md) · [中文](README.md)`。⚠️ **英文文件存在之前不要加**(否则是死链,扣 P1) | | **翻译时严格保持** | 代码块、命令、路径、env 名、包名、URL、图(ASCII/Mermaid)、表格结构与「版本与迭代」表内容 —— **逐字保留**;占位符(`` 等)**同步替换** | -| **不翻译(保持原文)** | `LICENSE-UPSTREAM-MIT.txt`(MIT 英文原文,**逐字节**);`LICENSE` 保持**中文正文 + 英文摘要**(现状即如此);`THIRD-PARTY-NOTICES.md` 中文即可(包名与许可标识本就是英文) | +| **不翻译(保持原文)** | `LICENSE` —— **AGPL-3.0 官方英文全文,逐字节不许翻译或改写** | | **校验** | 英文版同样要跑 **TOC 锚点校验**(按英文标题算锚点)与**相对链接存在性**;两版的「版本与迭代」表**行数与版本号必须一致** | | **别忘** | 这是**发布前唯一会因为"没做"而显得半成品**的项 —— 写进 SOP 与 §10 待办,接手先看 | @@ -340,7 +346,7 @@ mksess.cjs ensure-role-profile-patch.cjs(临时会话 / profile 补丁) | 插件具体名 | `anysearch` / `@anysearch/anysearch-dsh` / `@liustack/modlens` / `mcn-workstation` | 「该插件」/「某第三方插件」→ 再**整行重写**成中性描述 | 6 | | ↳ **例外(保留原名)** | **`dsh-univer-office`** 与其实例侧适配(`DSH_INSTANCE_UNIVER_SOCKET` / `UNIVER_DSH_GATEWAY_SOCKET`) | **不脱敏、不删代码**(用户 2026-09-13 明确「可以保留这个插件」);它同时是 `PLUGIN-PORTING.md` 的实证样本 ⇒ 其 env 名、注释、`DELETE_LINES` 条目**都不得再动** | 3 | | **项目改名** | `dshs` 全部自指标识 | `dsh-web-platform`(规则顺序:`dshs.db` → `dshs.db` → `/var/lib/dshs` → `DSHS_` → `dshs`) | 149 | -| ↳ **必须保留** | `上游骨架仓库(已按要求不再具名)`、`上游 \`dshs\`` | **原样不动**(出处/致谢);`LICENSE-UPSTREAM-MIT.txt` 逐字节不动 | — | +| ↳ **必须保留** | `上游骨架仓库(已按要求不再具名)`、`上游 \`dshs\`` | **原样不动**(出处/致谢);`LICENSE`(**AGPL-3.0 官方全文**)逐字节不动 | — | **发布前必须替换的占位符**(写在台账里): @@ -363,26 +369,38 @@ mksess.cjs ensure-role-profile-patch.cjs(临时会话 / profile 补丁) --- -## 5. 授权结构(**2026-09-13 15:1x 已变更:授权类文件全部移除**) +## 5. 授权结构(**现状:2026-09-17 定案「双轨」—— AGPL-3.0 原样 + 商业授权**) -> 🔴 **现状(以本条为准,覆盖下文所有旧描述)**:用户 2026-09-13 明确 —— **「你的任务是改造和优化,这些内容全部删除」** ⇒ 导出仓里的 -> **`LICENSE` / `LICENSE-UPSTREAM-MIT.txt` / `THIRD-PARTY-NOTICES.md` 三份已全部移除**(同时从 `OVERLAY` 与 `REQUIRED_EXPORT` 摘除、`_overlay/` 快照同步删除、README 的 License 徽章 / 目录项 / 「授权与商业使用」整节删掉、`package.json` 的 `license` 回到上游原值 `MIT`)。 -> **原文备份在 `../_授权归档_发布时再放回/`**,恢复步骤见 `_导出说明与脱敏台账.md` §F。 -> ⛔ **不要再把授权/许可当议题去问用户**(不属本技能范围,不列为待办、不上抛)。 -> ⛔ **不要再把授权/许可当议题去问用户**(不属本技能范围,不列为待办、不上抛)。**下文凡提到这三份文件的段落一律以此条为准**(保留原文只为记录历史,不是要求)。 -> ⚠️ **但下面这条「MIT 硬约束」是客观事实,任何会话都不许删**(删了会误导后人写出违规声明)。 +> 🔴 **现状(以本条为准,覆盖本章所有历史描述)** —— **双轨授权**: +> - **轨 1 · 开源轨 = AGPL-3.0 原样(默认)**:`LICENSE` = AGPL-3.0 官方全文(**34,523 B,逐字节未改**);`package.json` 保持 `"license": "AGPL-3.0-only"`;已在 `OVERLAY` 与 `REQUIRED_EXPORT`(**60 项**)内。 +> - **轨 2 · 商业轨**:给「**不愿承担 AGPL 开源义务**」(闭源托管 / 嵌入专有产品)的人 ⇒ 联系 `maogeigei@gmail.com` 谈条款与报价。**商业授权是「另开一条平行许可路径」,不是「对 AGPL 加限制」** —— 这是它不违反 OSD 的关键。**自 2026-09-18 起轨 2 有独立文件**:`COMMERCIAL-LICENSE.md` + `.zh-CN.md`(提交 `25f930f`)。 +> - 🔴 **三条措辞铁律(2026-09-18 立,⛔ 违反即等于把项目踢出开源)**:① 全文写成「**平行的许可选择**」(parallel choice),⛔ **绝不**写成"对 AGPL 附加的限制";② **文件名刻意不以 `LICENSE` 开头** —— 免被 licensee 误判为主许可;③ **正文不含项目名**(含新名 / 旧名)⇒ **改名窗口期两版通用**,零泄漏面。 +> - 🔴 **归档里的自拟 `LICENSE` 不得放回**:`_授权归档_发布时再放回\LICENSE`(5,931 B,自拟「个人免费/商用须书面授权」)的 §三「商业使用必须事先取得书面授权」**放在已发布 AGPL 项目里 = §10 追加限制** ⇒ 正是罗盒案否定、09-17 定案要避开的那一步。**归档 ≠ 照原样拷回**(逐项判定 → `_导出说明与脱敏台账.md` §F)。 +> - **README 末节** = `## License` / 中文 `## 授权`,承载双轨表述(各 4 段),**授权压轴**符合 R-O12;⚠️ 改后须复核 **英文文档汉字数 = 4**。 +> - ⛔ **仍有意不带回**:`LICENSE-UPSTREAM-MIT.txt` · `THIRD-PARTY-NOTICES.md`。上游口径:**DeepSeek Harness(MIT,不包含、不分发其代码)**;README 文末保留一行对 `dshs`(MIT)的道义致敬(⛔ 不许删)。 +> - ✅ **发版前必查**:`LICENSE` 被 OVERLAY 正确恢复 · `package.json` license 由规则生成 · `required present: 60/60` · **README 双轨表述中英同构** · **轨 2 文件存在且中英对等(`_check_parity.mjs` 的 `PAIRS` 已登记)**。 -> ⚠️ **硬法律前提(事实,必须记住)**:**上游 `dshs`(上游作者(已按要求不再具名))是 MIT。MIT 不允许对上游代码附加限制** ⇒ 若日后要恢复「个人免费 / 商用收费」,**只能分层**:上游留 MIT,只对**本项目新增部分**收费;把整个仓库标成「商用需授权」= **违反 MIT**。 +> 🔴🔴 **两条铁律(2026-09-17 用户连问两轮后确立,不许放宽)**: +> 1. **绝不在 AGPL 上加任何限制** —— 包括「只禁止销售源项目」这种看起来很窄的限制。依据:AGPL **§10** 禁追加 further restriction、**§7** 把非许可性附加条款定义为 "further restriction";且 **OSD 第 6 条「不得歧视任何领域」** ⇒ **判据是「有没有附加限制」,不是「限制多窄」**(Commons Clause 官方对 "Is this Open Source?" 的回答就是 **No.**)。 +> 2. **也不需要加** —— AGPL 的 copyleft **本身就实现了**「防白嫖转售」:想**闭源**改造后销售/托管的人,**§13** 要求其向使用者提供全部修改源码 ⇒ 不愿开源就只能来买商业授权。**这就是双轨的全部机制。** +> ⚠️ **两条配套事实**:Commons Clause 原文 "The combined text **replaces** the existing license"(**只能替换、不能叠加**);官方 FAQ "Licenses applied to previous versions are **not revoked**"(**已发布版本收不回** ⇒ 本项目 v1.0.0–v1.2.0 永远是 AGPL)。 -**若日后要对外发布**(当前仓库里**没有**任何授权文件),必须先放回那三份并恢复 `OVERLAY` / `REQUIRED_EXPORT`。理由: -1. 无 `LICENSE` ⇒ 默认「保留所有权利」⇒ **任何人都不能合法使用**; -2. 缺上游 MIT 声明 ⇒ **侵犯 上游作者(已按要求不再具名) 的著作权**(MIT 明文要求保留版权与许可声明); -3. 缺第三方 NOTICES ⇒ 多款依赖(MIT/Apache-2.0 等)同样要求保留声明。 +> 📜 **历史(2026-09-13 15:1x – 09-17 的中间状态,仅作留痕,⛔ 不是要求)**:一度把三份授权文件**全部移除**(`LICENSE` / `LICENSE-UPSTREAM-MIT.txt` / `THIRD-PARTY-NOTICES.md`),`package.json` 的 license 回到上游原值 `MIT`;09-14 一度**记录**为「AGPL + 商业授权」,但**当时 README 实际只有 AGPL 单轨**(09-17 核实并改正后真正落地双轨)。原文备份在 **`E:\ProgramData\_已移出项目_授权归档\_授权归档_发布时再放回\`**(⚠️ 09-13 曾误记为"该目录已不存在",**2026-09-18 实测仍在**;恢复步骤见 `_导出说明与脱敏台账.md` §F)。 +> ⛔ **不要再照那段历史去移除 `LICENSE`** —— 那会让公开仓库退回「保留所有权利」状态。 -⚠️ **不要再把「授权结构」当议题去问用户**(2026-09-13 用户明确:*"你的任务是改造和优化,这些内容全部删除"*)—— **不属本项目范围,不列为待办、不上抛**。保持**现状分层**即可(保守合规:上游永远 MIT、只对本项目新增部分声明)。 -> 即便日后发现 `上游作者(已按要求不再具名)` 就是用户本人(可整体简化为单一授权),也**不是本技能要推进的事** —— 只在用户**主动**提出时按本表处理。**但上一条「MIT 不允许对上游代码附加限制」是客观事实,任何会话都不许删掉它**(删了就会误导后人写出违规声明)。 +> ⚠️ **硬法律前提(客观事实,任何会话都不许删)**:**上游 DeepSeek Harness 是 MIT,且本项目不包含、不分发其代码** ⇒ 对本项目自有部分采用 AGPL-3.0 **不构成**对上游 MIT 的附加限制。若日后要恢复「个人免费 / 商用收费」的旧思路,仍须**分层**,不得把整个仓库标成「商用需授权」。 -每次发版要动的地方:`README.md` 授权章节(若条款变)、`LICENSE`(版本/日期)、`THIRD-PARTY-NOTICES.md`(依赖版本与分布)。 +> ⚖️ **中国司法立场(2026-09-17 查证,判例充分 —— 用户问「中国法律是否支持」时直接引这些)**: +> · **【支持】协议有效** = 「**附解除条件的著作权许可合同**」(**罗盒案** (2019)粤73知民初207号,**最高法 2021 年十大知产案件**;另有数字天堂案 · 风灵案)。 +> · **【支持】不开源 = 侵权** ⇒ 授权自动终止 ⇒ 构成侵权:罗盒 **50 万** · 亿邦 (2021)最高法知民终51号 **50 万** · 南京 2022 年案 **300 万**。 +> · **【支持】收费本身合法** —— 罗盒案明认「收取会员费仅用于运营维护和技术支持,**不违反 GPL v3**」;**违法的是不提供源码** ⇒ AGPL 防的是「不开源」,**不是**「收钱」。 +> · **【支持】项目管理人可单独起诉**(最高法知产法庭 2024 年两案)⇒ 维权无需集齐贡献者。 +> · 🔴 **【不支持】在开源项目里加商业使用限制条款** —— 罗盒案判决评析原文:「开源软件权利人**不能**在开源项目中添加商业使用限制保留条款来限制用户使用源代码的目的和用户范围,因上述限制保留条款与 GPL V3 协议保证用户自由使用的特性矛盾」。 +> · ⚠️ **中国暂无 AGPL 直接判例**(判例均为 GPL);实务界将 AGPL 与 GPL 并列为 Copyleft 强传染许可,明确「**AGPL v3 将 SaaS 视为分发**,对云与 SaaS 商业模式构成高风险」⇒ 效力被认可。 +> · 📌 中国实务界建议:开源作者应选 **OSI 认证许可**,**别用自定义/非 OSI 许可 —— 法律认可度低,难以保障作者知识产权**(反向印证「别用 Commons Clause」)。 +> ⚠️🔴 **另有一个**不在协议、而在版权基础**的风险(本项目特有)**:中国版权保护中心 **2026 新规「纯 AI 生成的软件不予登记」**,判据 = **人类独创性智力投入**(「春风送来了温柔」案 ✅/「蝴蝶座椅」案 ❌),最高法 2026-09-07 法发〔2026〕10号 已将 AI 生成物权属列重点。✅ **已于 2026-09-17 把口径改为「人负责规划与关键判断,AI 负责实施」** —— 该口径**恰好确立人类独创性智力投入**,正面对冲这条新规(旧口径「全部代码与文档由 AI 生成」= **自认纯 AI** ⇒ 商业授权缺标的物)。仍有待办:留存人类投入证据 · 考虑软著登记 · 报价前请律师。全部细节 → `<工作根>\_授权决策与法律依据_20260917.md`。 + +每次发版要动的地方:`README.md` / `README.zh-CN.md` 的授权节(若条款变)· `LICENSE`(**须保持官方全文逐字节**)· `package.json` 的 `license` 字段。 --- @@ -401,7 +419,7 @@ git -C "D:/github/dsh_shenxian" log --oneline -10 # 2) 若本次要改脱敏/保留口径 → 先改 _build_export.py 的 GLOBAL / INCLUDE_* / DROP_SCRIPTS / OVERLAY # 3) 重建(手工撰写层会自动快照→恢复) -cd "/e/ProgramData/AI技能/dsh-laijing-github" +cd "/e/ProgramData/AI技能/dsh-ai1net-github" "/e/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" _build_export.py --force # 4) 改版本(三个地方一起改,否则不一致) @@ -415,6 +433,35 @@ cd "/e/ProgramData/AI技能/dsh-laijing-github" # 6) 交付/推送(**R-O6:未经明确要求不做**) ``` +### 6b. 多远端推送(2026-09-18 定型:GitHub 主仓 + CNB 镜像) + +| 远端 | 地址 | 备注 | +|---|---|---| +| `origin` | `git@github.com:maogeigei/dsh-users-platform.git` | **主仓**(SSH,密钥顺序见 §8 坑) | +| `cnb` | `https://cnb.cool/maogeigei/dsh-users-platform.git` | **镜像**(HTTPS + 凭据管理器,已存好凭据 ⇒ 无需配 SSH) | + +```sh +cd "/e/ProgramData/AI技能/dsh-ai1net-github/dsh-users-platform" +# 加镜像远端(一次即可) +git remote add cnb https://cnb.cool/maogeigei/dsh-users-platform.git + +# ⛔ 只推 main —— 本地备份分支(backup-before-orphan-*)含改名泄漏期旧内容,绝不外推 +GIT_TERMINAL_PROMPT=0 git push origin main # 主仓 + +# ⚠️ CNB 首次/大推送**必须后台跑**:实测前台 180s 被 SIGTERM 杀掉 +# (仓库仅 1.67 MB、207 objects ⇒ 与体积无关,是连接/服务端处理慢), +# 后台运行 20s 完成。⇒ 用 run_in_background,别用前台短超时。 +GIT_TERMINAL_PROMPT=0 git push cnb main # 镜像 + +# 核对「三方同 hash」——这是多远端同步的唯一验收判据 +git ls-remote origin refs/heads/main | cut -c1-12 +git ls-remote cnb refs/heads/main | cut -c1-12 +git rev-parse --short=12 HEAD +``` + +⚠️ **README 里的 clone 地址保持指向主仓(GitHub)**,不改指镜像 —— 这是「多远端同步」的正常形态,CNB 页面自带自己的克隆按钮。若要改指 CNB,属改变对外口径,先问用户。 + + **版本号约定**:遵循语义化版本。写 README 版本表时**同时写「类型」列**(首个公开发布 / 特性 / 修复 / 内部迭代)。 **登记格式**(README「版本与迭代」表): @@ -425,7 +472,7 @@ cd "/e/ProgramData/AI技能/dsh-laijing-github" --- -## 7. 验证六件套(缺一不可) +## 7. 验证七件套(缺一不可) | # | 验证 | 命令 / 判据 | |---|---|---| @@ -435,6 +482,17 @@ cd "/e/ProgramData/AI技能/dsh-laijing-github" | ④ | **能编译** | 见下「tsc 验证法」→ `tsc -p tsconfig.json --noEmit` 退出码 **0** | | ⑤ | `install.sh` 语法 | `bash -n install.sh` + `bash install.sh --help` | | ⑥ | **已移除项核对** | 逐一 `[ -e ]` 确认 `docs` / 两个插件目录 / `STANDARD.md` / `lib` / `node_modules` / `.workbuddy` 均不存在 | +| ⑦ | 🔴 **发布合规审查**(2026-09-17 补,用户要求) | 对**将要公开的文字**逐字过三关,见下方「合规审查三关」 | + +### 合规审查三关(每次发布前必过) + +| 关 | 扫什么 | 判据 | +|---|---|---| +| **侵权** | 第三方项目名 / 作者名 / 商标 / 上游仓库的原创文字 | 出现即须处理(按致敬口径写,或删) | +| **负面影响** | 事故类词:**事故 · 丢失 · 失败 · 失效 · 忘记 · 混乱 · 崩溃 · 覆盖 · 难以维护**;以及一切**自曝严重问题**的叙述 | ⚠️ **命中不必然要改** —— 判据是「**这句对外有正面价值吗,还是只在自曝**」。例:「报错而不是静默覆盖」= **设计原则**(fail loud)⇒ 保留;「已两次造成内容丢失」= 自曝事故、且易被误读成**产品可靠性问题** ⇒ 改为只讲**设计动因**,不提已发生的事故 | +| **涉政治** | 政治 / 民族 / 宗教 / 地域 / 政府 / 国际关系表述;**国家及地区指称** | 一律避免;涉中国主权与领土的表述必须与中国官方立场一致(港澳台一律写「中国香港 / 中国澳门 / 中国台湾」) | + +**副作用提醒**:收紧措辞后要**同步中英两版**(结构对等、英文汉字数 = 4 的判据不因改词而放宽),并确认 `_overlay` ↔ 仓库**逐字节一致**。 **tsc 验证法**(导出物没有 `node_modules`,靠**临时联接**;用脚本而不是手敲,**并且绝不能用 recursive 删除**): @@ -478,6 +536,14 @@ cd "/e/ProgramData/AI技能/dsh-laijing-github" - **`REQUIRED_EXPORT` 清单**(脚本内,26 项):构建时逐个 `os.path.exists`,**缺一即 `return 1`**;新增"运行时要读的文件"必须加进去。 - **用被导出仓库自带的校验脚本验收** —— `node scripts/verify-inject.cjs`(读 `assets/inject/*.js` + 检查 `proxy.ts` 走 `loadInject`)。这比"我看了一眼目录"可靠得多。 ⚠️ 同类风险点:`package.json` 的 `files`(npm/git 安装按它过滤,与 `INCLUDE_*` 是**两套名单**,都要补)。 +19. 🔴🔴 **`cp _overlay/ <仓>/` 会把「下一版内容」整份带进当前版**(2026-09-18 血证,已误发上公网)—— `_overlay/` 在**改名窗口期**是「未来版」,导出仓是「当前版」;为修一处残留引用而 `cp _overlay/README.md` 覆盖旧仓 ⇒ 把 **新名 + 新中文名 + `git clone …/<新仓>.git` + `cd <新仓>` + `systemctl is-active <新仓>`** 一并推了上去。 + ⛔ **真实危害不是"名字不对"而是功能性缺陷**:README 让用户 clone 的那个仓库当时**是空的**(只有建仓提交)⇒ **照 README 做的人克隆到空仓库,部署直接断**。 + ✅ **规矩**:`_overlay` 处于下一版状态时**绝不整文件 `cp` 到导出仓**,只准**逐处精确 `Edit`**,只落本次要发的那几段。 + ⚠️ **最阴的一点**:这种误覆盖会让「导出仓 vs `_overlay` 的 diff **变空**」—— **diff 为空本身就是"新版被覆盖进去"的证据**,不是"一致"的好消息。**看到意外的一致要停下来查原因。** + ✅ **推前必做两条复核**:① `grep -rn "<新名>\\|<新中文名>" .` ⇒ **须为空**;② **部署命令自洽** —— `git clone` 指向的仓库**真有内容**、`cd` 的目录名、`systemctl` 的服务名三者一致。 +20. 🔴 **新增「中英文件对」时必须同步登记 `_check_parity.mjs` 的 `PAIRS`**(2026-09-18 实证)—— `PAIRS` 是**白名单**:没登记的文件对**不会报错,而是静默跳过对等检查**(标题数 / 表格行数 / 图数 / 英文汉字数全部不检)。⇒ 新增 `X.md` + `X.zh-CN.md` 时,**三处一起改**:`OVERLAY` · `REQUIRED_EXPORT` · **`PAIRS`**。 + ⚠️ **同类静默跳过已出现三次**(坑 18 `INCLUDE_*` / `REQUIRED_EXPORT`、本节 `PAIRS`)⇒ 判据统一为:**凡"清单式"机制,新增对象后必须回读清单计数是否 +N**(AST 实读,别数文件)。 +21. **`COMMERCIAL-LICENSE.*` 的落位规矩(2026-09-18 立)**:① 只在**四份 README**(仓 + `_overlay/` 各中英)授权节尾加**一句**链接,⛔ 不在正文铺开;② ⛔ **不得**把「商用须授权」的句子写进 `LICENSE` 或 README 的 AGPL 段落(那才是 §10 追加限制);③ 英文版**汉字数仍须恒为 4**(只在顶部入口行出现「中文文档」)。 --- @@ -485,7 +551,7 @@ cd "/e/ProgramData/AI技能/dsh-laijing-github" ```sh # 重建 -cd "/e/ProgramData/AI技能/dsh-laijing-github" +cd "/e/ProgramData/AI技能/dsh-ai1net-github" "/e/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" _build_export.py --force # 只跑探针/看结构(不重建) diff --git a/dsh-server-docs/交接单/README.md b/dsh-server-docs/交接单/README.md index aa0c63d..ab7a618 100644 --- a/dsh-server-docs/交接单/README.md +++ b/dsh-server-docs/交接单/README.md @@ -25,6 +25,7 @@ | `覆盖网络-序24-内容分发块级寻址.md` | ⏳ **待执行**(2026-09-17 20:4x 立单) | 🔄 已登记 automation `91251b14-9ee8-42a9-9d89-8559b7af75ed`(**2026-09-17 20:42** 起) | 首屏包冷启动改造:**块级内容寻址 + 同网段 peer 优先**(内容源优先级链 本地→同网段 peer→边缘缓存→分发点→公网源)。**主判据 `E1` = 版本发布时回源字节数 ≈ 1 份 × 组数**。🔴 **必须块级,⛔ 绝不做包级**(包级 = 版本一发即全量重拉风暴) | **代码** `src/net/relay/content/{chunker,store,peer,source}.ts` + `index.ts`/`web/server.ts`/`scripts/overlay-probe.cjs`/参数表;**文档** 本单 §8 | 用户拍板 2026-09-17:「**B 内容分发(块级内容寻址)是否立项:做**」;依据 `覆盖网络_瓶颈落地方案_20260916.md §3`;**§7 前缀 `49d0f405e08c909e18ba6825d1442b9d`** | | `覆盖网络-序25-实例逐步拉起.md` | ⏳ **待执行**(2026-09-17 20:4x 立单;**待 序㉔ 收口时登记下一棒**) | ⛔ 未登记(等 序㉔ 收口) | Manager(portal)重启后**逐步接管既有实例**,而不是启动即清空。⚠️ **关键事实**:`orchestrator.ts:208` 构造函数里 `cleanAllStaleScopes()` ⇒ 现在**portal 一启动就把全部实例 scope 清掉** ⇒ 本单 = 把「清空」换成「接管」。**主判据 `E1` = 重启后实例数不变** | **代码** `src/supervisor/orchestrator.ts`(`cleanAllStaleScopes` / `cleanStaleScopes` 一带);**文档** 本单 §8 | 用户拍板 2026-09-17:「**Manager 重启后是否自动拉起既有实例:逐步拉起**」;三条硬约束(⛔ 不许删 `cleanStaleScopes(uid)`/⛔ 不许改成"什么都不做"/⛔ 不许"重启后重新 spawn 一遍");**§7 前缀 `d9d48121e68faa00e1da17ad8fea36ad`** | | `覆盖网络-序26-骨干稳定选路与加密.md` | ⏳ **待执行**(2026-09-17 20:4x 立单;**待 序㉕ 收口时登记下一棒**) | ⛔ 未登记(等 序㉕ 收口) | 骨干节点服务范围落地:**连接稳定高效**(jitter 选路 + 2–3 候选路径 + 中继余量 30%+ + ≤10 成员全互联)+ **数据安全可加密传输**(TLS 已在 + 签名/哈希完整性 + 元数据最小化)。**主判据 `E1` = jitter 更低但 RTT 更高的候选被选中** | **代码** 新增 `src/net/relay/jitter.ts` + `directory.ts`/`switcher.ts`(只加"jitter 劣化即切",⛔ 不改冷却语义)/`server.ts`/`scripts/overlay-jitter.cjs`/参数表;**文档** 本单 §8 | 用户拍板 2026-09-17:「**入口 §4 骨干节点的服务范围:按照连接稳定高效的方式 数据安全可加密传输**」;判定 = **A 起步、口径按 B 的质量标准建设**;**§8 前缀 `dbcbe633aa1aef92f4c35c77fad3ec11`** | +| `覆盖网络-序45-低熵块治理-测熵与实现.md` | 🟡 **部分执行**(**§5 步骤 1 + 2 完成** · 2026-09-18 22:2x · 序㊺ 执行棒)—— 🔴 **`M1` 实测判「D 的作用面为空 ⇒ D 本轮不实现」**(首屏包 23 块 **0 个低熵块**、60 份真实内容 **0 份低熵**);⛔ 未改 `src/**` 一行 | ✅ 已登记 **序㊻ 执行棒(C 域分离 + 判据重裁)** automation `aab9e357-8101-4011-b829-bf9f4a459bf7`(一次性 · 2026-09-18 22:33) | 低熵块治理:**先补测「低熵块种类数 / 体积 / 占首屏包比例」**(🔴 从未测过)→ 再落 **C 域分离**(per-network keyed hash)+ **D 非确定性**(首选 **D-1 稀释域**)。🔴 **第一步是测熵、⛔ 不是改码** —— 切分是**定长 1 MiB** ⇒ 首屏包内几乎无纯低熵块,**必须加子窗口腿防假绿** | **代码** `src/net/relay/content/{chunker,crypto}.ts` + `blockIdOf` 调用点 + `scripts/overlay-probe.cjs` + 参数表;**文档** 本单 §一 行 / `INDEX.md` | 用户拍板 2026-09-18 21:28:「**采纳 C + D;B(OPRF / SA-MLE)降级为可选加强、⛔ 本轮不立项**」;方案 = `04-调整方案/133`;调研 = 工作区根 `调研_MLE加密去重最优方案_20260918.md`;**§8 前缀 `be548afc3340583b2b63ca254bcf550f`** | > **🔒 占用怎么声明(2026-09-12 新增,防两会话撞车)**:开工前先**原子占位** —— > `mkdir 交接单/.doing-<单号>`(`mkdir` 原子:**成功=你拿到;报 File exists=已有人在做 → 停手,别开工**), diff --git a/dsh-server-docs/交接单/覆盖网络-序45-低熵块治理-测熵与实现.md b/dsh-server-docs/交接单/覆盖网络-序45-低熵块治理-测熵与实现.md new file mode 100644 index 0000000..1fa8a8e --- /dev/null +++ b/dsh-server-docs/交接单/覆盖网络-序45-低熵块治理-测熵与实现.md @@ -0,0 +1,619 @@ +# 交接单 · 低熵块治理(**测熵先行** + C 域分离 + D 非确定性) + +- **序号**:覆盖网络线 **序 ㊺ · 规划棒** +- **立单**:2026-09-18 21:4x(规划棒会话;⛔ 零代码 / ⛔ 零服务器触碰) +- **用户拍板**:**2026-09-18 21:28 —— 采纳 C + D**;**B(OPRF / SA-MLE)降级为可选加强、⛔ 本轮不立项** +- **上游依据(单一来源 · ⛔ 不要另起炉灶)**: + - 方案类档案 = `04-调整方案/133-覆盖网络-低熵块治理方案-C域分离与D非确定性.md`(**本单只做执行,口径以它为准**) + - 工作区根 `调研_MLE加密去重最优方案_20260918.md`(§3 / §5 / §5.5) + - `.workbuddy/memory/2026-09-18.md` 21:1x / 21:5x / 21:2x + - 阈值与判据面 = 工作区根 `参数表_覆盖网络_20260917.md`(⚠️ 指纹**以现算为准**) +- **状态**:**待执行** +- **冲突域**:代码 `src/net/relay/content/{chunker,crypto}.ts` + `blockIdOf` 调用点 + `scripts/overlay-probe.cjs` + `参数表_覆盖网络_20260917.md` + **文档**(本单 §一 行 / `INDEX.md` / `05-*` 参数表说明若需) +- **依赖**:✅ 无前置未完成项(`E1` 既有基线会在步骤 4 重取,⛔ 不是阻塞) + +--- + +## §0 摘要 + 为什么「第一步是测熵、不是改代码」 + +**要治的病**(`04-133 §1`):块 id = 裸 `sha256(字节)` ⇒ ① 中继无密钥也能判相等性 ② 持组密钥者可枚举低熵内容(实测 `85,878 条/秒`)。 + +**分两刀**:**C** 把块 id / 内容 id 的哈希改成 **per-network keyed hash**(切断跨 network 相关性,代价≈0);**D** 让**低熵块**不再确定性(首选 **D-1「稀释域」**,备选 **D-2 随机密钥 + 封装**)。 + +🔴 **本单的排序是刻意的 —— 第 1 步是补测,⛔ 不是改代码**,因为: + +| 理由 | 依据 | +|---|---| +| **D 的边界完全由它决定** | "什么算低熵 / 稀释单元取多大 / 要不要合并" 全是该读数的函数 | +| **它从未被测过** | `04-133 §2.1` 三条硬证据:perf 用 `makeBytes()` **合成**字节(`_tmp_seq32/p01-perf.mjs:42`)|低熵定性用 8 B **合成**块(`_tmp_seq32/p03-lowentropy.txt`)|块存储**纯内存不落盘**(`server.ts:909-911`)⇒ 生产侧**无样本可捞** | +| **只测首屏包会得出假绿** | 切分是**定长 1 MiB**(`chunker.ts:60,137`)⇒ 首屏包 10.8 MB ≈ 11 块**混高熵内容**,纯低熵块**结构上几乎不可能存在**。低熵的真实形态更可能是**"小于 1 MiB 的独立内容"**、或**块内的一小段**(⇒ 必须加 `M1-d` 子窗口腿) | + +⇒ **先测 → 再决定 D 的作用面 → 才动手改**。⚠️ 若 `M1` 证实"首屏包内无低熵块",⛔ **不许为了"让 D 有用"去把块切小**(切小 = 块数暴涨 = 控制面与回源双向恶化 ⇒ **净变差 · 触 R11**)。 + +--- + +## §1 目标(一句话 · 可判定"做完了没有") + +**先产出「低熵块的种类数 / 体积 / 占首屏包比例」的机器读数(`M1` 四项)并固化进参数表;再按 `04-133` 落地 C + D,且判据面带一条"稀释源被换成确定性派生量 ⇒ 必红"的负腿。** + +--- + +## §2 只读前置(执行前**必须**先核实 · 给命令与期望输出) + +| # | 事实 | 命令 | 期望输出 | +|---|---|---|---| +| P1 | 全局执行锁空闲 + 抢到 → 抢不到**停手报告** | `bash /d/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh --claim-exec "<你的会话名>"` | `✓ 已持全局执行锁`;**报已有占用者 ⇒ 停手,⛔ 不删别人锁** | +| P2 | 块 id 仍是裸哈希(C 还没做) | `grep -n "blockIdOf" -A 3 src/net/relay/content/chunker.ts` | `createHash('sha256').update(bytes)...slice(0, BLOCK_ID_HEX_LEN)` | +| P3 | 切分是**定长**且块大小是**常量** | `grep -n "offset += blockSize\|DEFAULT_BLOCK_SIZE =" src/net/relay/content/chunker.ts` | `:137` 的 `offset += blockSize`;`:60` 的 `1024 * 1024` | +| P4 | 块存储**纯内存**(✓ 佐证"无法回捞样本") | `grep -n "new ContentStore" -A 3 src/web/server.ts` | 只有 `maxBytes`,**⛔ 无 `dir`** | +| P5 | 参数表现算指纹(⚠️ **⛔ 不要相信 §10 里写的值**) | `sed '/^## §10 指纹/,$d' "/e/ProgramData/AI技能/aliyun-dsh-server/参数表_覆盖网络_20260917.md" \| md5sum` | 立单时 = `6b37bfd506758d882d9f803678f85d23`;⚠️ **与 §10 记录的 `d408d640…` 不一致 = 既有漂移(见 §10 未验证项 1)** | +| P6 | 现有 OBS 编号到 **28** ⇒ 新增从 **29** 起 | `grep -oE 'OBS-[0-9]+' "/e/ProgramData/AI技能/aliyun-dsh-server/参数表_覆盖网络_20260917.md" \| sort -u -t- -k2 -n \| tail -3` | …`OBS-27` `OBS-28` | + +> 🔴 **环境坑(本机铁律)**:bash 的 PATH 被 shim 重置 ⇒ **一律用完整 POSIX 路径**(`/e/…`、`/d/…`);**Python exe 不认 `/e/…`** ⇒ 传 `E:/…`。 +> 🔴 **`blockIdOf` 调用点必须先清点**(见步骤 4):`grep -rn "blockIdOf" src/ scripts/ test/ | wc -l` ⇒ **若 `> 10` ⇒ 先出"受影响文件清单"再动手**(用户硬要求)。 + +--- + +## §3 范围 + +**要改**(⛔ 只改这些) + +1. `src/net/relay/content/chunker.ts` —— `blockIdOf` / `contentIdOf` 加 per-network 密钥(C);低熵判定的落点若在此则一并加(D)。 +2. `src/net/relay/content/crypto.ts` —— D 的加密路径(D-1 稀释 / D-2 随机密钥 + 封装)。 +3. `blockIdOf` / `contentIdOf` 的**全部调用点**(⚠️ 逐处过,漏一处 = 校验必红)。 +4. `scripts/overlay-probe.cjs` —— 新增 `OBS-29`(判据 + 负腿);⚠️ **夹具模式必须封闭**(`if (夹具) {…} else if (fixture) { SKIP + 留痕 } else { ssh }` —— 守卫**排在 ssh 之前**,本线既定口径)。 +5. `参数表_覆盖网络_20260917.md` —— §11.3 补记区(`M1` 读数)+ §6 新增 `OBS-29` 行 + 新键;⚠️ 若新增键 ⇒ §10 指纹**必然变**,按现算更新。 +6. **文档**:本单 §一 台账行 → ✅;`INDEX.md` 对应状态行。 + +**⛔ 不动**(防止顺手扩大) + +- ⛔ `DEFAULT_BLOCK_SIZE`(`chunker.ts:60`)—— **常量,⛔ 不许改成配置、⛔ 不许调值**。 +- ⛔ `package.json`(**零第三方依赖** —— D-1 只用 `node:crypto`)。 +- ⛔ 生产实例、⛔ 在线服务重启、⛔ 云安全组。 +- ⛔ `B`(OPRF / SA-MLE)的任何代码 —— 仅保留"将来可插"的接口位。 +- ⛔ 合规 / 数据主权内容(口径:方案只做技术实现)。 + +--- + +## §4 决策点 + +| # | 项 | 状态 | +|---|---|---| +| 1 | C / D 方向 | ✅ **已定**(用户 2026-09-18 21:28 拍板 C + D) | +| 2 | B 是否立项 | ✅ **已定 = ⛔ 不立项**(降级为可选加强) | +| 3 | **`M1-b` 的"低熵"阈值** | ⚠️ **执行中自决**:初值 `H ≤ 4.0 bit/byte`;⛔ **不许只报一个阈值** —— 必须给 **H 的直方图**,让人能重判 | +| 4 | D 选 D-1 还是 D-2 | ⚠️ **由 `M1` 结果决定,执行中自决**:`M1` 显示"低熵内容有高熵邻居可同批" ⇒ **D-1**;显示"独立成块且无同批高熵内容" ⇒ 才考虑 **D-2** | +| 5 | 稀释源的**派生写法**与**存放位置** | ⚠️ **执行中自决**(技术实现)。🔴 **但硬约束**:稀释源**必须含真随机、且不出现在明文可见面** —— 因为攻击面是"持钥者猜明文 + 复算"(`crypto.ts:405-413`),**确定性派生的盐对持钥者完全无效** | +| 6 | per-network 密钥的派生写法 | ⚠️ **执行中自决**(复用 `crypto.ts:47` 既有组密钥链路,⛔ 不新增密钥文件 / 不新增 env) | + +> 🔴 **边界外才上抛**:花钱 / 凭据 / 业务优先级 / 合规 / 影响面超出本平台 / 不可逆破坏性操作。⚠️ 上抛**只问那一句**,技术方案自己定好当**已定项**陈述。 + +--- + +## §5 步骤(有序 · 每步自带一次可执行的验证) + +### 步骤 1 🔴 **补测 `M1`:低熵块的种类数 / 体积 / 占首屏包比例**(**本单第一个动作**) + +1. **取真实内容**(⚠️ ⛔ **绝不用 `makeBytes()` 之类合成字节**): + - **S1 = 真实首屏包**:`curl -s --http1.1 -o /tmp/fsp.bin "/plugins/"` ⇒ ⚠️ 先 `wc -c` 核对是否 ≈ `11,363,655`(口径见 `参数表:146`)。 + - **S2 = 真实"独立小内容"集**:枚举覆盖网络**实际分发**的非首屏内容并留痕;⚠️ **若确实不存在 ⇒ 报 SKIP + 写清"不存在"**,⛔ 不许拿合成字节冒充。 +2. **切分**:⛔ **必须调用仓库里那份 `chunkify`**(`src/net/relay/content/chunker.ts`)—— ⛔ 不许复刻一份算法(复刻 = 双源)。 +3. **统计四项**(定义见 `04-133 §2.2`):`M1-a` 种类数 / 重复率 | `M1-b` 低熵块数 + 体积 + **H 直方图** | `M1-c` 占首屏包比例 | 🔑 `M1-d` **子窗口熵**(如 4 KiB 滑窗扫整份,给出低熵窗口的**尺寸分布**)。 +4. **验证**:`M1-a..d` 四项都有数(⛔ 不许"只有定性结论");⚠️ **`M1-d` 必须有数** —— 它是防"首屏包全是高熵块"假绿的那条腿。 +5. **落点**:先可落 `_tmp_seq*/` 取证;证据齐后**固化为只读探针** `scripts/overlay-entropy.cjs`(⛔ 零第三方依赖)。 + +> ⚠️ **本步不许出现任何生产写入**:`curl` 是只读 GET;⛔ 不重启、⛔ 不改 env、⛔ 不铺插件。 + +### 步骤 2 由 `M1` 结论**定 D 的作用面**(⚠️ 先判定,再动手) + +- 输出一段**判定**:低熵块**存在与否 / 在哪(首屏包内 or 独立小内容 or 块内片段)/ 体积占比**,并据此**选定 D-1 或 D-2**(决策点 4)。 +- **验证**:判定必须**引 `M1` 的具体数字**(⛔ 不许"我觉得")。 +- ⚠️ 若判定"首屏包内无低熵块" ⇒ **D 的作用面收窄到 S2**,**⛔ 不得**因此去动块大小。 + +### 步骤 3 落地 **C(域分离)** + +1. `blockIdOf` / `contentIdOf` 加 per-network 密钥(HMAC-SHA256,输出仍取前 `BLOCK_ID_HEX_LEN` = 32 hex)。 +2. **逐处过调用点**(⚠️ 步骤 0 已清点;**> 10 文件 ⇒ 先出清单**)。 +3. **验证**:`npm test`(Node 22)全绿;**且**新增一条单测断言"**不同 network ⇒ 同字节得不同块 id**"+"**同 network ⇒ 同字节得同块 id**"(两条都要,⛔ 只测前者会漏"去了重")。 + +### 步骤 4 落地 **D(非确定性)** + **重取 `E1` 基线** + +1. 按步骤 2 的结论实现 D-1(或 D-2)。 +2. 🔴 **`E1` 基线重置**:C 换了 id 口径 ⇒ **旧基线读数作废**,必须**重取**。⚠️ `E1` 的**定义不重估**(回源字节 ≈ 1 份 × 组数)。 +3. **验证**:`OBS-29` 正腿绿;🔴 **负腿必须红** —— 见步骤 5。 + +### 步骤 5 判据面(`OBS-29` + 负腿) + +1. 新增 `OBS-29`(判据面 + 阈值键;⚠️ 编号从 **29** 起,⛔ 不跳号不复用)。 +2. 🔴 **必须有的负腿**:「**把稀释源换成确定性派生量(如 `HMAC(组密钥, 块序号)`)⇒ 判据必红**」。 + 理由(`04-133 §4.1`):攻击面是"**持组密钥者猜明文 + 复算**" ⇒ 确定性盐**对持钥者完全无效** ⇒ 没有这条负腿,"上了个无效的稀释"会**全绿**(本线老病根:装了没生效 = 静默放行)。 +3. **验证**:探针自检 —— 正腿 PASS + **负腿 FAIL 且具名**(⛔ 不许"返空又不计数")。 + +### 步骤 6 收口 + +`python3 scripts/docs-audit.py`(**退出码 0**)→ `python3 scripts/docs-manifest.py` → `bash scripts/docs-sync-check.sh`(0)→ 回填本单 §一 台账行 → 按 `README §三 第 3 条` 收尾四件套。 + +--- + +## §6 验收(命令 + 期望输出 · 可被第三方复现) + +| # | 命令 | 期望 | +|---|---|---| +| 1 | 熵测脚本 `--json` | `M1-a..d` 四项齐;`M1-b` 带直方图;**退出码 0** | +| 2 | `npm test`(Node 22) | 全绿;含新增的"跨 network 块 id 不同 / 同 network 相同"两条断言 | +| 3 | 探针 `OBS-29` | **正腿 PASS + 负腿 FAIL 具名**(负腿=确定性稀释源) | +| 4 | `python3 scripts/docs-audit.py` | **退出码 0** | +| 5 | `bash scripts/docs-sync-check.sh` | **退出码 0** | +| 6 | `/status` 的 `content` 块 | ⚠️ **只读**;`OBS-17` 口径一致腿仍绿(`blockSize` / `storeMaxBytes`) | +| 7 | 参数表 §10 指纹 | 现算值已更新(若新增键)+ §11.3 已落 `M1` 读数 | + +🔴 **三条红线**:① `E1` 跑不出机器断言 ⇒ **停下报告,⛔ 不许放宽判据凑绿**。② 零回归三件套任一退化 ⇒ **停下报告**。③ ⛔ **不把任何"关掉安全/限速"的值写进回滚或夹具路径**。 + +--- + +## §7 回滚 + +| 层 | 做法 | ⚠️ 注意 | +|---|---|---| +| **D** | 关掉稀释开关 ⇒ 回落确定性加密 | ✅ 无损(只回退"这批块不共享"这一变化) | +| **C** | 传空密钥 ⇒ 回落裸哈希 | 🔴 **块 id 会再变一次 ⇒ 缓存再清一次、去重率再算一次** ⇒ ⛔ 不许默认"回滚 = 无损",必须写进回滚说明 | +| **判据 / 参数表** | 回退到本单 §11 所记指纹(⚠️ 以**现算**为准) | ⛔ 不许手改 §10 里那条历史记录 | + +--- + +## §8 回报格式(执行会话必须回填) + +1. **`M1` 四项读数**(原文级)+ **H 直方图** + 数据来源(S1 的 `wc -c` / S2 的清单或"不存在")。 +2. **步骤 2 的判定**(引具体数字)+ **选了 D-1 还是 D-2**(一句话理由)。 +3. **`blockIdOf` 调用点清点数**(`> 10` 时附受影响文件清单)。 +4. `npm test` 输出(尾 20 行 + 退出码)+ **新增两条块 id 断言的原文**。 +5. `OBS-29` 探针输出:**正腿 PASS** + **负腿 FAIL 具名**(两条都要,⛔ 不许只给正腿)。 +6. `E1` **重取的基线读数**(⚠️ 标明"旧基线已作废")。 +7. `docs-audit.py` / `docs-sync-check.sh` 退出码。 +8. 参数表 §10 **现算**指纹(新值 + 旧值)+ §11.3 落点行号。 +9. commit sha(本机;⚠️ **推送需用户明确说"推送"**)。 + +--- + +## §9 停止条件(命中即**停下报告**) + +1. 抢不到全局执行锁 ⇒ **停手报告**(⛔ R9:不得接管、⛔ 不得删锁)。 +2. `M1` 对象(真实首屏包 / 真实小内容)**取不到** ⇒ **报 SKIP + 说清原因**,⛔ **不许用合成字节顶上**(否则整份读数失去意义)。 +3. `M1` 结论要求**动 `DEFAULT_BLOCK_SIZE`** 才能让 D 生效 ⇒ **停下报告**(= 净变差,触 R11)。 +4. 需要**新增生产 env** 或**引第三方依赖** ⇒ **停下报告**。 +5. `blockIdOf` 调用点 **> 10 文件** ⇒ **先出清单**,未获批不动。 +6. 发现方案方向与 `04-133` 冲突 ⇒ **停下报告**,⛔ 不许自行改方案。 + +--- + +## §10 未验证项 + +| # | 项 | 状态 | +|---|---|---| +| 1 | 🔴 **参数表 §10 指纹漂移**:§10 头值记 `d408d640246a980f702fe7b0a2895219`(序㊴ 12:2x),**现算 = `6b37bfd506758d882d9f803678f85d23`**(830 行;文件 mtime 09-18 15:18)⇒ 记录值未随内容更新 | ⚠️ **本轮只报不改**(⛔ 不是本单产物);执行棒取基线**一律用现算**。⚠️ 若确认是"某棒改了 §10 之前的内容却忘了更新记录"⇒ 需另立小项收口 | +| 2 | `M1` 四项读数 | ⚠️ **从未测过**(本单步骤 1 补) | +| 3 | 低熵块的**判据阈值**("什么算低熵") | ⚠️ 开放问题(`调研…§6` 阻碍 4)⇒ 本单只要求给直方图,⛔ 不要求定死阈值 | +| 4 | D 若引入"合并加密",**常驻字节是否变化** | ⚠️ 待测(`CONTENT_STORE_MAX_BYTES` 账,见 `参数表:147`) | +| 5 | `B`(OPRF / SA-MLE)的接口位 | ⚠️ 仅保留"将来可插",⛔ 本轮零代码 | + +--- + +## §11 指纹与状态 + +| 项 | 值 | +|---|---| +| 本单 §8 之前正文前缀指纹 | **`be548afc3340583b2b63ca254bcf550f`**(口径 = `sed '/^## §8 回报格式/,$d' <本单> \| md5sum`) | +| 本单全文件 md5(立单时) | **`0169b900a6ea50608c0d50a3a0514e68`**(`210` 行 · ⚠️ 口径 = **本字段值填入「前」**的文件字节 —— 自指字段无法包含自身,⛔ 复算时请先还原本字段再比) | +| 参数表指纹(立单时 · **现算**) | **`6b37bfd506758d882d9f803678f85d23`**(⚠️ §10 记录值 `d408d640…` 已漂移) | +| 代码仓 HEAD(立单时) | `45b4999` | +| 方案档案 | `04-调整方案/133-覆盖网络-低熵块治理方案-C域分离与D非确定性.md` | +| 本单状态 | 🟡 **部分执行**(**§5 步骤 1 + 2 已完成** · 2026-09-18 21:56–22:2x · 序㊺ 执行棒)—— 🔴 **`M1` 实测判定 =「D 的作用面为空 ⇒ D 本轮不实现」**(见 **§12-2**)⇒ 步骤 4–5 的前提被证伪、原判据负腿失去对象 ⇒ **剩余 = C 域分离 + 判据重裁**,交 **序 ㊻ 执行棒**(automation `aab9e357-8101-4011-b829-bf9f4a459bf7` · 2026-09-18 22:33) | + +--- + +## §12 序㊺ 执行棒回填(2026-09-18 21:56–22:2x · 按 §8 回报格式逐条) + +**1. `M1` 四项读数(原文级)+ H 直方图 + 数据来源** + +- 数据来源 = **真实内容**(⛔ 零合成字节):`GET https://admin.alotbuy.com/` 壳页(**R4 临时会话**,用完即删)→ 取页面内**全部 60 条** `/plugins/??…&rev=…` → **按页面顺序**拼接为 S1 流。 +- **S1 = 23,629,336 B**;`blocks = 23`;流 md5 = **`41333ba2d6b480036c694d9fee7e5c1c`**。⚠️ 该流**不跨实例重启字节稳定**(combo 分区随启动世代变)⇒ 复现须记 `rev` 世代。 +- 🔴 **口径纠正**:本单 §3 与 `参数表:146` 引用的 `11,363,655 B` **不是"全部 combo 之和"**,而是**最大单条 combo**(本棒实测 **11,794,471 B ≈ 12 块**)。真实首屏总量 = **23,629,336 B(23 块)**。 +- **`M1-a`** 唯一块 id **23** ÷ 总块 **23** ⇒ 重复率 **0.00%**;完全重复块(同 id ≥ 2 次)**0 个**。 +- **`M1-b`** 低熵块(`H ≤ 4.0 bit/byte`)**0 块 / 0 B**;逐块 H ∈ **[5.1807, 5.8444]**;**H 直方图 = `{"5.0-5.5": 8, "5.5-6.0": 15}`**;逐块 H = `5.2089 5.5612 5.7684 5.5095 5.6777 5.4695 5.6530 5.6974 5.2515 5.5237 5.5102 5.1807 5.2527 5.7228 5.8444 5.6450 5.5956 5.6805 5.6275 5.2722 5.3887 5.5581 5.3555`。 +- **`M1-c`** 低熵块字节 ÷ 首屏包字节 = **0.000000%**。 +- **`M1-d`(反向腿 · 4 KiB 窗 / stride 4 KiB)** 窗口 **5,768** 个;**低熵段 15 段**,合计 **241,664 B = 1.0225%**;段字节 min **4,096** / max **81,920**;尺寸直方图 `{≤4KiB: 10, 4KiB-16KiB: 3, 16KiB-64KiB: 0, 64KiB-256KiB: 2, 256KiB-1MiB: 0, >1MiB: 0}`;最大 10 段 = `[81920, 81920, 16384, 12288, 8192, 4096, 4096, 4096, 4096, 4096]`。 +- **S2(60 份真实独立分发内容)**:单块内容 **56/60**;**低熵内容 0 份**;逐份 Hmin ∈ **[4.8354, 6.3485]**。 + +**2. 步骤 2 的判定(引具体数字)+ 选了 D-1 还是 D-2** + +**判定 = 两者都不选(D 本轮不实现)**:**(甲)** 首屏包 23 块**全部** H ≥ **5.1807** ⇒ **无纯低熵块**(`M1-b` / `M1-c` 皆 0);**(乙)** 低熵物质只以 **≤ 81,920 B** 的**块内片段**存在(`M1-d` = **241,664 B / 1.0225%**,**全部 < 1 MiB**)⇒ **已被同块高熵内容结构性稀释** ⇒ `D-1` **无对象可稀释**;**(丙)** 60 份真实独立小内容中 **0 份**低熵(Hmin ≥ **4.8354**)⇒ `D-2` 的启用前提("低熵内容独立成块且无高熵同批")**不成立**。⇒ **D 的目标已由「定长 1 MiB 切分」结构性达成**(低熵物质对块级去重 / 枚举**不可见**)。🔑 **回头条件 = 出现「低熵内容单元」** ⇒ 由 `scripts/overlay-entropy.cjs` **可重复复查**。⚠️ 本判为**自决项(可推翻)**、**非降级**(依据 = 实测,见 §11.12-⑤)。 + +**3. `blockIdOf` / `contentIdOf` 调用点清点数** = **8 个文件 / 36 处**(**未触 >10 文件门槛**):`src/net/relay/content/{chunker,store,runtime,source}.ts`、`src/net/relay/{index,main}.ts`、`scripts/overlay-direct-probe.cjs`、`test/overlay-content.test.mjs`。(命令:`grep -rn "blockIdOf\|contentIdOf" src/ scripts/ test/ poc/ | grep -v ^src/net/relay/content/chunker.ts`) + +**4–6. `npm test` / `OBS-29` / `E1` 重取基线 = ⛔ 本棒未产出(C / D 未开工)** + +🔴 **原因(必须与"漏做"分开)**:本棒只执行到 **§5 步骤 1 + 2**。**步骤 4–5 的前提被 `M1` 证伪** —— 本单 `OBS-29` 的**必需负腿**是「稀释源换成确定性派生量 ⇒ 必红」;**D 一旦不实现,该负腿就没有对象**(无稀释源可换)。⇒ 若本棒继续落 **C** 而沿用原判据面,就等于**交付一处没有判据的代码改动** —— 正是本线明令禁止的"**装了但没生效 = 静默放行**"。⇒ **C + 判据重裁一并交序 ㊻ 执行棒**。⇒ 因此 §8 的 **4 / 5 / 6 / 9** 四项本棒**本就不该有**:⛔ 未改 `src/**` 一行 ⇒ **无新断言、无新判据、无基线换代**。 + +**7. `docs-audit.py` / `docs-sync-check.sh` 退出码** + +- `python3 scripts/docs-audit.py` ⇒ **rc = 0**(结论:**无 P0 级问题**)。 +- `bash scripts/docs-sync-check.sh` ⇒ **rc = 2**,原文 `ERROR: 无法读取服务器目录 bt-server:/opt/dsh/docs(ssh 失败或目录不存在)`。⚠️ **与序㊺ 规划棒 15:5x 的报错逐字相同**(**既有条件**,⛔ 非本棒引入);⚠️ 本棒 ⛔ 未 scp / ⛔ 未推送 ⇒ **不构成阻塞**;🔴 **执行棒推送前必须复跑并成功**(该条随下一棒带走)。⚠️ 47 上 `/opt/dsh/docs` **实际存在**(`ls /opt/dsh` 可见)⇒ 该报错更像**脚本侧的 ssh 条件**(本机 ssh 需 `-p 22` + 别名 `bt-server`),⛔ 本棒只报不改。 + +**8. 参数表 §10 现算指纹 + §11.12 落点** + +- **现算 = `6b37bfd506758d882d9f803678f85d23`**(**写入前后两次现算逐字一致** ✅ —— 因为 §11 小节在 **§10 口径之外**,见 `参数表` `:573`)。 +- **旧值(§10 记录)= `d408d640246a980f702fe7b0a2895219`** ⇒ 🔴 **既有漂移**(本单 §10 未验证项 1;⛔ 本棒**只报不改**)。 +- 落点 = `参数表_覆盖网络_20260917.md` **`### §11.12 补记`(行 829 起;全文件 862 行)**;**⛔ 未新增任何键 / ⛔ 未改任何值 / ⛔ 未改任何阈值** ⇒ 探针取数不受影响。 + +**9. commit sha** = **`45b4999`**(= 立单时 HEAD,**未 commit / 未 push** ✅)。`git status --short` = **13 处**,其中**本棒唯一新增** = `scripts/overlay-entropy.cjs`(其余 12 处为序㊺ 规划棒及更早遗留,逐条可解释)。 + +--- + +### §12.1 本棒如实留档(三条 · ⛔ 均不属本单产物) + +1. 🔴 **仓里的 `mksess.cjs` 已失效** —— 它写 SQLite `/var/lib/dshs/dshs.db`,而 47 控制面**权威库 = PG**(`dshs.service.d/cluster.conf` 的 `DSHS_DB_URL=postgres://dshs@127.0.0.1:15432/dshs`)⇒ 插进去的会话 Manager **查不到** ⇒ 用它取实例页必然 **401**。**正确做法(本棒实测可用)** = 往 **PG 的 `sessions` 表**插(cookie 名 `sid`;`token_hash` = `sha256hex(token)`;`user_id` 取 `users.username` 对应用户)+ **用完即删**。⇒ 建议**另立小项**修 `mksess.cjs` 或标注废弃。 +2. 🔴 **`/plugins/??…` combo 路由有鉴权** —— 无会话 = **401 / 24 B**(`{"error":"unauthorized"}`);⚠️ 且**盘上不存在"首屏包单文件产物"**(combo 由宿主**运行时**拼装,见 `@deepseek-ai/dsh-client-modules` 的 `README.zh.md`)⇒ **测熵取数只能走 HTTP + 临时会话**。 +3. ⚠️ **"低熵"阈值(`H ≤ 4.0`)的判别力有限** —— 见 §11.12-④(丙):JS 文本的字节分布熵天然落在 **5.0–6.0** ⇒ 该腿**单独用会给出"永远 0"**;⇒ 后续任何"低熵"结论**必须同时给 `M1-d` 子窗口腿**。 + + +--- + +## §13 序㊻ 执行棒回填(2026-09-18 22:33– · 按 §8 回报格式逐条) + +> **本棒只做 `C`(块 id 域分离 / per-network keyed hash)**;⛔ **`D` 不实现** —— 序㊺ 的 `M1` **实测已判 `D` 无作用面**(§12 第 1/2 条),`04-133 §5.1` 据此**重裁判据面**。 +> 🔴 **判据编号承接现表最大值 `OBS-28`** ⇒ 新判据 = **`OBS-29`**(⛔ 未跳号、⛔ 未复用)。 + +**1. `M1` 四项读数** —— ⛔ **本棒不重做**(序㊺ 已取,见 §12 第 1 条)⇒ 本棒只**引用结论**:真实首屏包 **23 块 / 0 低熵**,真实小内容 **60 条 / 0 低熵**(`D-1` 无对象、`D-2` 前提不成立)。 + +**2. 步骤 2 的判定** —— **`D` ⛔ 本轮不实现**(沿用序㊺ 实测判定);本棒**只做 `C`**,并把**原属 `D` 的那条负腿重裁为 `C` 的判据**(第 5 条)。 + +**3. `blockIdOf` 调用点清点数** —— **8 文件 / 36 处**(序㊺ §12 已清点)。本棒**实际改动 8 个文件**(≤ 10 ⇒ ⛔ 未触 §9 第 5 条): + +| # | 文件 | 改了什么 | +|---|---|---| +| 1 | `src/net/relay/content/chunker.ts` | 新增 `idDigestOf(bytes, netKey)`(**HMAC / 裸 sha256 两条整枝**,⛔ 不合成一个 Hash 对象以免类型漂移);`blockIdOf` / `contentIdOf` / `chunkify` / `planOf` / `reassemble` **全部接 `netKey`** | +| 2 | `src/net/relay/content/crypto.ts` | 新增 `BLOCK_ID_DOMAIN_TAG` + `deriveBlockIdKey()` + `ContentCipher.blockIdKeyOf()` —— **复用既有组密钥链**(`crypto.ts` 组密钥),🔑 **⛔ 无新密钥文件 / ⛔ 无新 env** | +| 3 | `src/net/relay/content/store.ts` | 新增 `ContentStoreOptions.netKey` + **三处复算点**(`put` / `get`-内存 / `get`-磁盘)全部带上 | +| 4 | `src/net/relay/content/runtime.ts` | `netKey` / `blockIdKeyId` 派生 + 新增 `writeTransforms()` / `readTransforms()` ⇒ **`encode` 与 `netKey` 同生同灭**(⛔ 不许半开) | +| 5 | `src/net/relay/main.ts` | 探针块路径**显式**传 `netKey`(⇒ "调用点有没有带上"**源码可查**)+ 启动**判别器**日志 | +| 6 | `src/net/relay/index.ts` | 导出 `ChunkTransforms` / `BLOCK_ID_DOMAIN_TAG` / `deriveBlockIdKey` | +| 7 | `test/overlay-content.test.mjs` | 新增**组 G**(`G1…G10`);文件级 = **55 test** | +| 8 | `scripts/overlay-probe.cjs` | 新增 **`OBS-29`**(五腿/`judged = true`/夹具模式照跑/**⛔ 零 ssh**) | + +**4. `npm test` 输出 + 新增两条块 id 断言原文** + +- `npm.cmd test` ⇒ **200 pass / 0 fail / 1 skipped**(基线形状保持);`node --test test/overlay-content.test.mjs` ⇒ **55/55 pass / 0 fail**;`npm.cmd run check:layering` ⇒ **无新增违规**;`tsc` / `build` 均过。 +- 🔴 **两条断言都在(⛔ 只测前者会漏掉"去重被干掉")**: + - **① 正腿(不同网 ⇒ id 必不同)**:`assert.strictEqual(idDiffersAcrossNetworks(a, b), true, '不同 network ⇒ 块 id 必须不同(域分离生效)')` + `assert.notStrictEqual(a[0], bare[0], '⚠️ A 网不得等于裸哈希 —— 否则 = 域分离没生效("装了没生效"的本形)')`(与 B 网同式)。 + - **② 正腿(同网 ⇒ id 必相同,去重不得丢)**:`assert.strictEqual(idDiffersAcrossNetworks(rt1.planContent(bytes).ids, rt2.planContent(bytes).ids), false)` + `assert.strictEqual(rt1.snapshot().store.puts, 4, '同内容只入库 4 个块(⛔ 域分离不得把去重干掉)')`。 + +**5. `OBS-29` 探针输出(正腿 PASS + 负腿具名)** + +- **绿**(探针全表):`PASS OBS-29 块 id 域分离(C · per-network keyed hash)腿数 5/5 全绿 ✅|P1 ✅ P2 ✅ P3 ✅ N1 ✅(⛔ 判据有牙) N2 ✅|写侧 putRejected=0(须 0)|读侧 corruptReads=0(须 0)|重组位=逐字节相同 ✓|⏳ 真机腿 … 待部署后补`。**探针合计 = 29 PASS / 0 FAIL / 0 SKIP / rc=0**(由 28 增至 29,增量 = `OBS-29`)。 +- 🔴 **先红后绿(两条负腿都真跑过,⛔ 不是纸面声明)**: + - **红腿 A `flat-key-collapses`**(`deriveBlockIdKey` 故意**丢掉 `network` 维度**)⇒ 单测 **48/54 通过、6 失败**(`G1` / `G1-b` / `G3` / `G5` / `G7` / `G8`);探针 = `FAIL OBS-29 … 腿数 4/5 ❌ 缺 P1-跨网必不同且都非裸哈希 … P1 ❌ P2 ✅ P3 ✅ N1 ✅ N2 ✅`。 + - **红腿 B "漏掉一个调用点"**(`writeTransforms()` 故意**不传 `netKey`**)⇒ 单测 **45/54、9 失败**;代表性原文 = `content-store: 块校验失败(丢弃)expected=9aef912692d4ec2afb3aa69c138cef89 actual=e1311d713c12af1891bb202b32f5dc3f`;探针 = `FAIL OBS-29 … ❌ 自检抛错:content-store: 块校验失败(丢弃)expected=3354a61f694317ec0c571aedfd0c0e4c actual=7f80ac0b6cc7d93c961853b3cb579764`。 + - ✅ 两条均已**复原**:md5 回基线(`crypto.ts 5e87e104…` / `runtime.ts 58847b0e…` / `chunker.ts 199e31cd…` / `store.ts 1c63de21…`),`grep -c REDLEG` = **0 / 0**。 +- ⏳ **真机腿(本棒做不了,回头条件已写进参数表 §6 的 `OBS-29` 行)** = 部署那一棒必须补:`/status.content.blockIdKeyed === true`,且 **47 / 106 的 `blockIdKeyId` 逐字相同**(跨机口径不一致 ⇒ 跨机取块**全部**判校验失败)。 + +**6. `E1` 重取的基线读数** —— 🔴 **旧基线(序㉔ 裸哈希口径)已作废**。`E1` **定义不重估**(仍是「回源字节 ≈ **1 份 × 组数**」),**只重取读数**:同网 **4 轮 × 4 块 ⇒ 全部 local 命中**、`source.origin = 0`、`store.puts = dedupIds.length`、`putRejected = 0`;跨网起点 `local = 0` ⇒ **各 1 份**(= `C` 的域收窄点,⛔ 不是缺陷)。落档见参数表 `§11.13-③`。 + +**7. docs 核查退出码** —— `docs-audit.py` = **rc 0**(原文结论「**无 P0 级问题**」)|`docs-manifest.py` = **rc 0**(已重生成 `docs-manifest.json`:files **183 → 189**、chars **1,907,768 → 1,982,749**)|🔴 `docs-sync-check.sh` = **rc 2**(`ERROR: 无法读取服务器目录 bt-server:/opt/dsh/docs`)。🔬 **本棒已把根因查到原文级(⛔ 不是"目录不存在")**:脚本第 88 行 `ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE"` **不带 `-p 22`** ⇒ 吃到 `~/.ssh/config` 里 `bt-server` 的 **`Port 32022`** ⇒ 原文 `ssh: connect to host 47.77.182.89 port 32022: Connection refused`;而 `ssh -p 22 bt-server 'ls -d /opt/dsh/docs'` ⇒ **`/opt/dsh/docs` 存在、`EXIT=0`** ⇒ 🔴 **别名端口陈旧 = 既有缺陷**(⛔ 与本事无关;⛔ 本棒未改 ssh 别名、⛔ 未改该脚本 ⇒ **另立小项**)。⇒ **本轮未能做双端对账**(本棒 **不 scp 且不 push** ⇒ 不构成阻塞)。 + +**8. 参数表 §10 现算指纹** —— **新值 = `ac6bbbbb8c92bd57ff0dc4cd8f4983ba`**(= 已回填值);**旧记录值 = `d408d640246a980f702fe7b0a2895219`**。⚠️ §12 曾观察到一份**独立现算漂移** `6b37bfd506758d882d9f803678f85d23`(两次独立复算一致)⇒ 判为「**记录漏更新、不是算错**」。落点 = 参数表 **`§11.13`(行 864 起;全文件 885 行)**;🔴 **§11.13 在 §10 指纹口径之外** ⇒ 追加后指纹不变(复算已验)。 +**口径**:`sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum`。 + +**9. commit sha** —— **`45b4999`**(= 立单时 HEAD,**未 commit / 未 push** ✅)。`git status --short` = **21 处** = 序㊺ 遗留 **13 处** + **本棒 8 处**(= 上表 8 个文件,逐条可解释)。 + +### §13.1 本棒如实留档(三条 · ⛔ 均不属本单产物) + +1. 🔴 **`overlay-failover-drill.cjs` 卡在"仅供人读"的线索函数上(已实测定位)** —— `currentChannelHint()`(源码注释自述「**仅供人读,不参与判定**」)会跑 `journalctl -u dshs --since -6h` + `grep -F '[overlay-dir] 取址'`;该行**每 2 s 一条** ⇒ 6 h ≈ **1 万行 / ≈1.3 MB** ⇒ 实测**单次 ssh 需 ~47 s > `SSH_TIMEOUT_MS` = 20000** ⇒ `spawnSync ssh ETIMEDOUT` ⇒ **整个 `--scene all` 直接中止**。🔴 **这是健壮性缺陷**:一个**不参与判定**的装饰性线索不该成为主流程的硬依赖(应:加长超时 / 缩小 `--since` / 失败降级为空串)。⛔ **本棒未改演练脚本**(越界),**另立小项**。 +2. 🔴 **`mksess.cjs` 已失效**(复述 §12.1-①):47 控制面**权威库 = PG** ⇒ 写 SQLite 的会话 **Manager 查不到 ⇒ 必 401**;正确做法 = 往 PG `sessions` 插(cookie `sid`/`token_hash` = `sha256hex(token)`)+ 用完即删。 +3. 🔴 **`overlay-failover-drill.cjs` 的"当前在哪台"判据有 6 h 上限(本棒实测踩到 · 先红后绿见 §13.2)** —— `lastManagerAuthOn()` 用**硬编码 `--since -6h`** 捞 `[relay] AUTH OK host=ops/manager`;Manager 隧道**稳定建立超过 6 h 就不再重注册** ⇒ 两台 relay 都读成 **-1** ⇒ `killTarget = null` ⇒ `幕1-A` 降级为 **SKIP**、且 **`幕1-B` / `幕1-C` 两腿结构上位于 `else` 分支(脚本 863–866 行)⇒ 一行都不打印** ⇒ **12 腿里 3 腿未判**。🔴 **这是判据口径缺陷**("最近注册时间"不能用来判断"当前在哪台"),⛔ **不是产品回归**;⛔ 本棒**未改演练脚本**(`scripts/overlay-failover-drill.cjs` 零改动)⇒ **另立小项**。 + +### §13.2 零回归三件套(本棒实测读数 · 三件全绿) + +| 件 | 命令 | 读数 | +|---|---|---| +| ① 单测 | `npm.cmd test` / `node --test test/overlay-content.test.mjs` | **200 pass / 0 fail / 1 skipped** / **55/55**(0 fail) | +| ② 探针 | `node scripts/overlay-probe.cjs --table …` | **29 PASS / 0 FAIL / 0 SKIP**(rc=0;基线 **28** ⇒ **+1 = `OBS-29`**) | +| ③ 演练 | `node scripts/overlay-failover-drill.cjs --scene all --table …` | **12 PASS / 0 SKIP / 0 FAIL**(rc=0;耗时 **8m25s**) | + +🔴 **③ 是先红后绿,⛔ 不是第一次就绿的(如实留档)**:**首跑 = 9 PASS / 1 SKIP / 0 FAIL**(rc=0)—— `幕1-A` 报 **SKIP**(原文「⚠️ 无法唯一确定 Manager 当前在哪台(47 最近注册=-1 / 106=-1)」),且 **`幕1-B` / `幕1-C` 两腿一行都没打印**。**根因(原文级取证)** = Manager 那条隧道**已稳定建立 6 h 38 min**:开工前两侧最后一条 AUTH = `1789720529`(47)/ `1789720501`(106),开工时刻 = `1789744400`(= **23:13:20**)⇒ 间隔 **23,871 s ≈ 6.63 h > 6 h** ⇒ 落在脚本硬编码的 `--since -6h` **窗口之外** ⇒ 两台都读 **-1** ⇒ `killTarget = null` ⇒ 只记 `幕1-A`(SKIP) + `幕1-D`(PASS)。**数目自洽**:9 PASS + 1 SKIP + 2 未打印 = **12** ✅。⇒ 🔴 **归因 = 演练脚本判据口径缺陷(6 h 上限)**,⛔ **不是产品回归**、⛔ 与本棒 `C` 改动无关。 +**绿腿** = 按脚本**自己给出的归零办法**(原文「归零办法:重启 `dshs` ⇒ 通道回到目录首位,其 relay 会留下新的 AUTH 行」)**重启一次 47 `dshs`**(R8 · 动手前已声明)⇒ `lastManagerAuthOn` 读到新鲜 AUTH(`1789745060` = **23:24:20**)⇒ **12 PASS / 0 SKIP / 0 FAIL**:`幕1-A` **15,698 ms**(deadline 30000)|`幕1-B` Manager `active`|`幕1-C` 门户 **200**|`幕2-A/B/C` ✅|`幕3-A` ✅|`幕4-A` **20,302 ms**|`幕4-B` ✅|`幕4-C` **22,926 ms / 30,000**。⏳ **残余未判项 = 0**(重跑那轮 12 腿全部给出判定);⚠️ 但 **`幕1-A/B/C` 的口径缺陷仍在**(⛔ 本棒未修 ⇒ **另立小项**)。 + +**生产态核对(演练后 · ⛔ 无残留停机)** = 47 `dshs` / `dshs-relay` / `dshs-worker` **全 `active`**;106 `dshs-relay` / `dshs-worker` **`active`**(`dshs` `inactive` = **正常** —— 106 无 Manager);门户(`http://127.0.0.1:3080/` + `Host: alotbuy.com`)**200**。⚠️ **本棒对生产的唯一动作 = 重启 47 `dshs` 一次**(R8;无不可逆项);⛔ 未动任何配置值 / ⛔ 未改 nft·nginx·bwrap / ⛔ 未 scp / ⛔ 未 push。 + +--- + +## §14 序㊼ 部署棒回填(2026-09-18 23:43 – 2026-09-19 00:1x · 按 §8 回报格式逐条) + +> **本棒 = `C` 的落地棒**:只做「把 `lib` 部署到两机 + 验 `OBS-29` 真机腿 + 复跑零回归」。🔴 **零代码改动 · 零判据改动 · 零参数表值格改动**。 + +**1. 部署口径(本棒实测认定 · ⚠️ 与"只传改动文件"的旧印象不同)** + +- 实测两机 4 处 `lib` 都是**历次增量 scp 的叠加**(各文件 mtime 不一:47 `/opt/dshs/lib/net/relay/index.js` = **12:55:27**(序㊵),而同目录 `content/chunker.js` = **06:52:16**)⇒ 与代码仓当前 `build` 产物**同名 md5 不同 38–41 个**(含 `client` / `directory` / `switcher` / `wire` / `orchestrator` 等**与本单无关**的文件)。 +- ⇒ 本棒按**全量替换**口径部署(**270 文件 × 4 处**)。🔴 **两机同一窗口内换**(先各自传包,再并行解包 + 重启)。 +- 包 = `dshs-lib-seq47.tgz`(**681,966 B / 270 文件**,md5 **`546b30719ca1bfca1e04189e1099424e`**);🔴 两机**解包前各自先验包 md5**(不符即停手)。 + +**2. 备份(4 处 · 可回滚)** —— 两机 `/opt/dsh/backups/seq47-20260918-2347/`: + +| 机 | 目录 | 备份内容 | +|---|---|---| +| 47 | `_opt_dshs_lib` / `_opt_dsh-relay_lib` | 262 / 263 文件(共 6.2M) | +| 106 | `_opt_dshs-cluster_lib` / `_opt_dsh-relay_lib` | 260 / 262 文件(共 6.1M) | + +**3. 部署结果(逐文件 md5 核对 · 不一致 0)** + +- 47 `/opt/dshs/lib` = **282**、`/opt/dsh-relay/lib` = **281**;106 `/opt/dshs-cluster/lib` = **282**、`/opt/dsh-relay/lib` = **281**。 +- 与代码仓 `lib`:**同名 md5 不同 = 0**(**270 × 4 = 1080** 对全额逐字节一致)、**仅本机有 = 0**。 +- ⚠️ 各位置多出的 11–12 个 = **历史 `.bak-*` 遗留**(09-17 时期备份,⛔ 本棒未删)。 +- ⚠️ **prompt 里"核 32 个 md5"的口径未能复现** —— 本棒现核为 **270 × 4**;已按**全量**核对,并把该差异如实记在此处。 + +**4. 重启(R8 · 动手前已声明)** —— 47 `dshs-relay` / `dshs` / `dshs-worker` 全 `active`;106 `dshs-relay` / `dshs-worker` `active`(`dshs` `inactive` = **正常** —— 106 无 Manager)。 + +**5. `OBS-29` 真机腿 = ✅ PASS(本棒主判据)** + +- 47 `/status.content`:`blockIdKeyed=true` / `blockIdKeyId=d6e62322e5166938` / `blockSize=1048576`(⛔ 未动)/ `storeMaxBytes=67108864`(⛔ 未动)。 +- 106 同上,**`blockIdKeyId` 逐字相同**(= `d6e62322e5166938`)。 +- 🔬 **交叉锁(第二来源)** = 两机 relay 启动判别器日志原文:`[content] 块 id 口径 = HMAC-SHA256(域密钥) blockIdKeyId=d6e62322e5166938`(47 **23:47:43** / 106 **23:47:37**)。 + +**6. 零回归三件套(本棒读数)** + +| 件 | 读数 | 与基线 | +|---|---|---| +| ① 探针 | **28 PASS / 0 FAIL / 1 SKIP**(rc=0) | ⚠️ 基线 **29P/0S/0F** ⇒ **`OBS-09` 退化为 SKIP**(见第 7 条) | +| ② `--scene all` | **12 PASS / 0 SKIP / 0 FAIL**(rc=0;**6m28s**) | ✅ 与基线逐字同(`幕1-A` 本次未触发 6 h 坑 —— 因本棒刚重启 47 `dshs`、AUTH 行新鲜) | +| ③ `OBS-29` 行 | 五腿全绿 `P1/P2/P3/N1/N2` | ✅ 与序㊻ 本机读数一致 | + +**7. 🔴 `OBS-09` SKIP 的成因与定性(本棒唯一差异 · 如实留档)** + +- 原文 = `SKIP OBS-09 在册实例面 无(从 /status.endpoints[] 派生为空:端点表 1 条 = agent 1 条 + 离线 0 条)⇒ SKIP + 留痕|对端中继(test106)在运行但没有任何会话在声明端口…`。 +- **事实链(四条,均已实测)**:① 106 实例 **`286172` 健在**(`node /usr/bin/dsh --profile web --port 21001`,**启动于 10:18:34** = 本棒重启前 13.5 h)② **未被 teardown**:`OBS-22` = `scanned=1 adopted=1 stopped=0` + 日志 `[rehydrate] probe OK dsh-100002-7d1c8cbf.scope :21001` ③ 但 47 relay 端点表**只有** `w-106:19000`,**缺 `w-106:21001`**(参数表 §3.9 `PEER_INSTANCE_PORT` 行 10:2x 实测**应有两条**)④ 复跑探针**两次**(间隔 ~6 min)**均未自愈**。 +- **定性** = 🔴 **既有缺陷被本棒硬要求的「重启 106 `dshs-worker`」触发** —— ⛔ **非本单引入**、⛔ **非业务中断**。历史留档(本文件 §0「序 ㉙」行)已实测同一机理:`relay-tunnel.ts#cancel()` **只撤销当前进程 `forwarded` 集里的端口** ⇒ 上一进程遗留条目**无人撤销** ⇒ 原文结论「**重启后 `OBS-08`/`OBS-09` 必红,⛔ 不是"跑一会儿就好"**」。 +- 🔴 **恢复路径须"实例重新拉起"**(端口登记发生在 spawn 流程)⇒ **必然中断该实例** ⇒ 与 memory 明令「⛔ 别为迁就旧值重启生产实例」冲突 ⇒ **本棒不修、不凑绿**,**另立小项**(回头条件 = 出「跨机实例面访问不通」或下一次收口仍 SKIP)。 + +**8. 边界自证** + +⛔ 未 commit / 未 push(HEAD **`45b4999`** 未动;`git status` 仍 **21** 处)|⛔ 未改 `DEFAULT_BLOCK_SIZE` / `package.json`|⛔ 零新依赖|⛔ 未改 nft / nginx / bwrap|⛔ 未改参数表**值格**(§10 现算指纹 **`ac6bbbbb8c92bd57ff0dc4cd8f4983ba`** 与 §13 记录**同值**;本棒补记落 **§11.14** = **在 §10 口径之外**)|🔴 密钥本体不经网络 / 不经 relay(本棒只读 `/status`,⛔ 未触碰密钥文件)|⛔ 未调 `RELAY_FAILOVER_DEADLINE_MS`、⛔ 未动 `HB_SEC`·burst、⛔ 未禁用 `COOLDOWN_MS`。 + +**9. 回滚** —— 两机**同窗口**把 4 处 `lib` 回退到 `seq47-20260918-2347/` 并重启该机单元。⚠️ `C` 的 id 换代**不是无损**(回退后 id 会**再变一次**);但块存储**纯内存** ⇒ 重启即清空、**无脏块残留**。 + +**10. 生产态终态核对** —— 47 三单元 `active`;106 `dshs-relay` / `dshs-worker` `active`(`dshs` `inactive` = 正常);两机 relay `/status` 可读、`blockIdKeyed=true`、`blockIdKeyId` 逐字相同;演练 `幕1-C` / `幕2-C` 门户 **200** ✅。 + +--- + +## §15 序㊽ 执行棒回填(2026-09-19 00:15 – 01:0x · 按 §8 回报格式逐条) + +> **本棒 = `OBS-09`「重启 worker 后实例端口注册丢失 / 孤儿端点条目不自愈」修复棒**。🔴 **零中断**:⛔ **未为验证重启任何实例** —— 走的是「认领落定后重登记」路径,⛔ **不是** spawn 路径(见 §15-1 的第 ④ 条:§14-7 那句「恢复须实例重新拉起」**已被本棒实测证伪**)。 + +**1. 判定依据(文件 + 行 + 原文)——「重启后重注册已认领实例端口」该落在哪一步** + +`reconcileTunnel()`(对账自愈)**早就存在**,链路本身是通的 —— 它只是**看不到**那条端口: + +| # | 位置(改前) | 原文 / 事实 | +|---|---|---| +| ① | `src/worker/agent.ts:256-264` | `const live = new Set((await spawner.listUserInstances()).map((i) => i.port).filter(…))` —— 对账口径**只取一条腿** | +| ② | `src/supervisor/orchestrator.ts:558-560` | `listUserInstances(): Instance[] { return [...this.mains.values()] }` —— 口径 = **本进程 `launch` 过的**(`mains`) | +| ③ | `src/supervisor/orchestrator.ts:290` | `/** 已被「认领」的既有 scope —— ⛔ 刻意**不进** `mains`:见文件头 序 ㉕ 的边界说明。 */` ⇒ `adoptOne()` 只写 `this.adopted` | +| ④ | `src/supervisor/orchestrator.ts:187-192`(文件头 序㉕) | 「🔴 一条必须先说的**客观边界**(本序实测得出,⛔ 别再试图绕过):『认领』**不可能**做到"用户无感直接复用"…」⇒ 认领实例**恒不进 `mains`**(`launchToken` 不可恢复) | +| ⑤ | `src/net/relay/server.ts:2151-2203`(`dropSession`) | `for (const ep of this.endpoints.values()) { if (ep.session === session) ep.session = undefined }` —— 断开**只摘会话、⛔ 不删条目** ⟹ 上一进程的条目**留在表里**(`online=false`) | +| ⑥ | `src/net/relay/server.ts:1447-1466`(`onPortChange(add=true)`) | `const existing = this.endpoints.get(endpointKey(…)); … const ep = this.ensureEndpoint(…); ep.session = session` ⇒ **复用既有条目、只换绑定会话**(`localPort` 沿用)⟹ **重登记 = 就地覆盖孤儿** | + +**结论(落点判定)** = 修复必须落在 **① 的对账口径**,⛔ **不是** spawn 流程:②③④ 是**刻意设计**(⛔ 不改认领语义、⛔ 不把认领实例写进 `mains`),而 ⑤⑥ 说明 relay 侧**本来就等着**有人重新声明那个口 ⇒ **零中断路径真实存在**。 + +**2. 实现(2 个源文件 · ⛔ 零新依赖 · ⛔ 零新暴露面)** + +| 文件 | 改动 | +|---|---| +| `src/supervisor/orchestrator.ts` | 新增 `adoptedInstancePorts()`(只报 `alive === true` 且带端口的认领记录);新增 `onRehydrateSettled` 回调 + `pendingProbes` / `rehydrateScheduled` 落定计数(`settleRehydrate()` / `maybeRehydrateSettled()`);`rehydrateAdoptedScopes()` 三个出口均落 `settleRehydrate()`;`probeAdopted()` 在途计数 ∓1 后补射 | +| `src/worker/agent.ts` | 对账口径改为 `liveInstancePorts()` = `listUserInstances()` **∪** `adoptedInstancePorts()`;抽出 `tunnelTick()`(自愈 + 对账)供定时器与回调**共用一份语义**;装配 `spawner.onRehydrateSettled = () => void tunnelTick()` | + +🔴 **⛔ 未绕过既有准入 / 白名单**:新端口走的是**同一条** `tunnel.forward() → RelayClient.addPort() → PORT_ADD → relay.onPortChange`(含既有 `[base, base+span)` 窗口校验 + worker 侧 `allow` 白名单)⇒ ⛔ **零新代码路径、零新监听口、零新 env**。 + +**3. 先红后绿(原文级)** + +**(a) 探针读数** + +| | 改前(`probe_before.txt` · 00:23:09) | 改后(`probe_final.txt` · 00:46:33 · **终态**) | +|---|---|---| +| 计数 | **26 PASS / 2 FAIL / 1 SKIP(rc=1)** | **29 PASS / 0 FAIL / 0 SKIP(rc=0)** | +| `OBS-01` | `FAIL 在册节点 used=1 (阈值 ≥ 2)` | `PASS 在册节点 used=2 (阈值 ≥ 2)` | +| `OBS-08` | `FAIL 端点表 1 条 / 离线 1 条` | `PASS 端点表 2 条 / 离线 0 条` | +| `OBS-09` | `SKIP 在册实例面 无(…端点表 1 条 = agent 0 条 + 离线 1 条)` | `PASS 在册实例面(派生 1 条):w-106:42497=401 (阈值 ∈ {200,401})` | + +⚠️ **如实纠正**:prompt 与本单 §14-6 记的「退化 = 28P/0F/1S」**与本棒开工实测不一致** —— 实测 **26P/2F/1S**(`OBS-01` 也已转红)。**归因有原文**:106 worker 在 **00:04:30** 按**抖动路径**切到了 **106 自己的中继** —— `[relay-switch] #3 wss://alotbuy.com/dshs-relay -> wss://106.54.21.172/dshs-relay(原因:当前通道抖动量超标(p95|ΔRTT|=2941ms ≥ 阈值 20ms)且 wss://106.54.21.172/dshs-relay 更稳(0ms);冷却 wss://alotbuy.com/dshs-relay 至 +300000ms)` ⇒ 47 视角同时失去 `w-106` 会话与其实例面。⛔ 本棒未编数、按实测留档。 + +**(b) 端点表原文(`/status` 直读)** + +| 时刻 | 47 relay `endpoints[]` | 106 relay `endpoints[]` | +|---|---|---| +| 改前 | `[('w-106',19000,42313,**False**)]`;`sessions=[('manager',[])]`;`used=1` | `[('w-106',19000,38587,**True**)]`;`sessions=[('w-106',**[19000]**)]` | +| 改后 · 00:33:17 | `[('w-106',19000,45095,**True**), ('w-106',21001,36535,**True**)]`;`sessions=[('w-106',**[19000,21001]**),('manager',[])]`;`used=2` | `[]`(worker 已回落 47) | +| 终态 · 00:44:06(演练后) | `[('w-106',19000,39471,True), ('w-106',21001,42497,True)]`;`used=2` | `[]` | + +🔴 **先红最关键的一条**:改前 **106 relay 自己的表里也只有 `19000`** —— 而 106 上 `ss -lntpH` 明明白白 `127.0.0.1:21001` 在听、scope 数 = **1** ⇒ **「实例活着、却没人替它把端口声明出去」**,与"挂在哪台中继"无关(⛔ 不是单纯"挂在另一台中继")。 + +**(c) 机理原文(47 relay journal · `--since 00:31:00`)** + +``` +00:31:13 [relay] AUTH OK host=ops/w-106 session=4cc572b38351c711 ports=[19000] remote=127.0.0.1:50714 +00:31:13 [relay] endpoint ops/w-106:19000 -> 127.0.0.1:45095 (loopback) +00:31:14 [relay] endpoint ops/w-106:21001 -> 127.0.0.1:36535 (loopback) +00:31:14 [relay] host ops/w-106 +port 21001 -> 127.0.0.1:36535 +``` + +⇒ HELLO 只带 `ports=[19000]`(静态口),**1 s 后**由新装的「认领落定即登记」补出 `+port 21001`。⚠️ 这一枪**必须**来自新回调:20 s 定时器首拍落在 **00:31:31**,而 `+port` 在 **00:31:14**;且旧口径下 20 s 拍**同样看不到** 21001(`live` 只取 `mains`)。 + +**(d) 孤儿条目自愈 —— 受控对照(⛔ 未停实例)** + +| 步骤 | 47 relay 读数 | +|---|---| +| [A] 停 worker 前 | `used=2 eps=[(19000,45095,True),(21001,36535,True)]` | +| [B] `systemctl stop dshs-worker`(**孤儿态**) | `used=1 eps=[(19000,45095,**False**),(21001,36535,**False**)]` ← 条目**仍留在表里**(正是改前 `OBS-08` 的红形态);同时 106 `ss -lntH 'sport = :21001'` = **1 行** ⇒ **实例进程全程未动** | +| [C] `systemctl start dshs-worker` | `used=2 eps=[(19000,45095,**True**),(21001,36535,**True**)]` | + +🔬 **最强的一条**:`localPort` 在 [B]→[C] **逐字沿用**(`45095` / `36535` **未变**)⇒ 证明走的是 §1-⑥ 的「**复用既有条目、只换绑定会话**」,⛔ **不是**新开监听口。 + +**4. 零回归三件套(终态)** + +| 件 | 读数 | 与基线 | +|---|---|---| +| ① 探针(真机 · 终态) | **29 PASS / 0 FAIL / 0 SKIP**(rc=0;`probe_final.txt` 00:46:33) | ✅ 回到基线 **29P/0S/0F** | +| ② `npm.cmd test`(Node **v22.22.2**) | **200 pass / 0 fail / 1 skipped**(`# tests 201`;rc=0;**53.2 s**) | ✅ 逐字同基线 | +| ③ `--scene all`(`overlay-failover-drill.cjs --table`) | **12 PASS / 0 SKIP / 0 FAIL**(rc=0;**6m01s**) | ✅ 逐字同基线;⚠️ **未套外层超时**;`幕4-C` **27087 ms / 30000** | + +**附加守卫(本棒自加)**:`node --test test/orchestrator-rehydrate.test.mjs` = **18/18 pass** —— 该文件**刻意不进 `npm test`**,但它是唯一盯 `adopted` / `mains` 边界的用例 ⇒ 动这一处必须跑。 + +**5. 部署(口径 / 备份 / 核对 / 窗口)** + +- 包 = `dshs-lib-seq48.tgz`(**685,368 B / 270 文件**,md5 **`a902c936cefdeeb3eb13c67dfcb5dd00`**);两机**解包前各自先验包 md5** —— 两侧均回 `a902c936…` ✅。 +- 备份 = 两机 `/opt/dsh/backups/seq48-20260919-0030/`:47 `_opt_dshs_lib` **282** / `_opt_dsh-relay_lib` **281**;106 `_opt_dshs-cluster_lib` **282** / `_opt_dsh-relay_lib` **281**(与 §14-2 **逐数相同**)。 +- 核对(**全量**口径)= **270 × 4 = 1080 对**:同名 md5 不一致 **0**、缺失 **0**;各位置多出 **11–12** 个 = 历史 `.bak-*` 遗留(⛔ 未删)。 +- 重启(R8 · 动手前一句话已声明)= 47 `dshs-relay` / `dshs` / `dshs-worker` 全 `active`;106 `dshs-relay` / `dshs-worker` `active`(`dshs` `inactive` = **正常**,106 无 Manager)。 +- 🔴 **如实留档(本棒自造偏差)**:首轮「两机同窗口」脚本里我把 `cd` 写进了**后台复合命令**内 ⇒ 第二个后台作业不在目标目录 ⇒ **106 未执行**(`No such file or directory`),而 **47 已落地并重启**(`rc=0`,全 `active`)⇒ **两机实际窗口相差 ≈ 28 s**(47 **00:30:47** / 106 **00:31:15**),⛔ **未做到"严格同窗口"**。**影响评估** = 本棒改动是**纯 worker / supervisor 侧增量**(新方法 + 对账口径 + 一个**可选**回调字段;⛔ 无协议帧 / 无接口形状 / 无 env 变化)⇒ 47 新 / 106 旧的那 28 s 内**无跨版本不兼容**;随后三件套全绿可作旁证。⛔ **未回滚重做**(重做只会再造一次同类窗口 + 一次多余重启)。 + +**6. 边界自证** + +⛔ 未 commit / 未 push(HEAD **`45b4999`** 未动)|⛔ 未改 `DEFAULT_BLOCK_SIZE` / `package.json`|⛔ **零新依赖**|⛔ 未改 nft / nginx / bwrap|🔴 ⛔ 未改参数表**值格**(只追加 **§11.15** 补记)|🔴 密钥本体 ⛔ 不经网络 / 不经 relay(本棒只读 `/status` 与 journal,⛔ 未触碰密钥文件)|⛔ 未调 `RELAY_FAILOVER_DEADLINE_MS`、⛔ 未动 `HB_SEC` / burst、⛔ 未禁用 `COOLDOWN_MS=0`|**§8 前前缀 `be548afc3340583b2b63ca254bcf550f` 回填本 §15 后逐字不变** ✅。 + +**7. 停止条件核对(⛔ 一条都未命中)** + +⛔ 未改网络拓扑|⛔ 未新增监听口(`OBS-11` 多出 **0** 缺失 **0**)|⛔ 未改 nft / nginx|⛔ 未放开遮蔽 ⇒ **无需停手**。 + +**8. 中断记录(如实)** + +🔴 **本棒 ⛔ 未重启 / 未停任何实例** —— 走的是认领后重登记路径(§14-7 那条「必须中断实例」的旧判断**已被证伪**)。唯一服务中断 = 受控对照演示中的 `dshs-worker` **停启各一次**(**停用窗口 ≈ 5 s**;期间 106 实例与用户面**全程未动**:`ss -lntH 'sport = :21001'` 全程 **1 行**)。 + +**9. 回滚** + +两机**同窗口**把 4 处 `lib` 回退到 `/opt/dsh/backups/seq48-20260919-0030/` 并重启该机单元。⚠️ 本棒**不涉及块 id / 存储口径** ⇒ 回退**无损**(⛔ 与 §14 的 `C` 不同,⛔ 不会"再变一次 id")。 + +**10. 未验证项 / 遗留(⛔ 如实,⛔ 不编数)** + +1. 🔴 **`OBS-01` / `OBS-08` / `OBS-09` 的绿不稳固 —— 取决于 106 worker 挂在哪台中继**。worker 通道会按**抖动**切换(改前实测原文已引于 §15-3a),而**探针只读 47 的 `RELAY_STATUS_URL`** ⇒ 一旦 worker 落到 106 自家中继,47 视角即无实例面 ⇒ 三项会**再次**转红 / SKIP。🔴 **本棒 ⛔ 未动选路策略**(属既有设计:`switcher.ts` 抖动换址 + 一跳豁免)⇒ **这是"探针观测点单一"与"多中继归属可漂移"之间的口径缺口**,如实登记为**未解决项**(回头条件 = 下一次收口复跑探针若再出现 `OBS-01` / `OBS-09` 红或 SKIP)。 +2. ⚠️ **"已消失端口的孤儿"仍未回收**:本棒修的是「**活着的**实例端口重登记」(⇒ 活口的孤儿会被就地覆盖)。若实例被自然替换(`21001 → 21002`),旧端口条目**没人 `PORT_DEL`** ⇒ 会以 `online=false` **永久留在表里**(正是 `OBS-08` 的红形态)。⛔ 本棒的 `reconcileTunnel` 撤不掉它 —— `RelayClient.removePort()` 对"本进程从未加过的端口"直接早退。**回头条件** = 出现端口替换后 `OBS-08` 再红。(⚠️ 修它需要 worker 能读到"中继侧自己的条目表",worker 与中继**可能不同机** ⇒ 非本棒可及。) +3. ⚠️ `PEER_INSTANCE_PORT` 现值仍 **21001**(本轮快照);`OBS-09` 已不从该键取值 ⇒ ⛔ **无需改值格**。 + +**11. 收口时的现场观测(⛔ 如实 · 留给序㊾ 作"先红"基线)** + +收口前最后一轮只读核对(**00:52:37**)显示 **§15-10-1 那条遗留当场复现** —— 106 worker 又按**抖动路径**挂回了 **106 自家中继**: + +| 机 | `units` | relay `/status` | +|---|---|---| +| 47 | `dshs` / `dshs-relay` / `dshs-worker` 全 `active` | `used=1` · `eps=[('w-106',19000,**False**),('w-106',21001,**False**)]`(两条**离线孤儿**) | +| 106 | `dshs-relay` / `dshs-worker` `active`(`dshs` `inactive` = 正常) | `used=1` · `eps=[('w-106',19000,**True**),('w-106',21001,**True**)]` | + +🔴 **两条要点(⛔ 不许混为一谈)**: + +- ✅ **修复本身是有效的** —— 106 自己的中继表里**两条都 `online=true`**(含实例面 `21001`)。这正是本棒要的形态:改前 106 自家中继表里**只有 `19000`**(见 §15-3b)。⇒ 本棒修复**与挂在哪台中继无关**,都成立。 +- ⚠️ **但探针的绿不稳固** —— 探针只读 47,此刻 47 视角是"1 条会话 + 2 条**离线**条目" ⇒ 若**此刻**复跑探针,读数会回到 **`OBS-01 FAIL` / `OBS-08 FAIL` / `OBS-09 SKIP`**(≈26P/2F/1S)。🔴 本棒**录取的 29P/0F/0S 是 00:35:59 与 00:46:33 两个时刻的实测真值**(worker 当时挂在 47),⛔ **不是打了折扣**;也 ⛔ **未为了让读数好看去重启 worker**(那属"凑绿",且抖动换址会让它再漂)。 +- 📌 **⇒ 本棒交付的验收结论必须按"两条腿"读**:① **修复有效**(凡 worker 所在的中继,实例面必在册且 `online`)② **探针观测面单一**(单一观测点 + 可漂移的归属 = 假红 / 假 SKIP)—— 第 ② 条**已登记为序㊾ 执行棒**(automation `2febff6b-5786-46a3-b345-b648f2e19df3`),本棒 ⛔ **不越界修**。 +- ⚠️ 另:本棒为受控对照停启过一次 `dshs-worker`(§15-8),其后 worker 重启即按 `DSHS_RENDEZVOUS_URL=wss://alotbuy.com/dshs-relay`(= 47)落位;**00:52 这次漂移是它自己按 jitter 换址的**,⛔ 与本棒的停启无关(停启发生在 00:36 前后)。 + + +## §16 序㊾ 执行棒回填(2026-09-19 01:0x – 01:4x · 按 §8 回报格式逐条) + +> **本棒 = 「探针观测面单一」修复棒**(结清 **§15-10-1** 那条遗留)。🔴 **只改观测面** —— 改动面 = `scripts/overlay-probe.cjs` **一个文件**;⛔ `src/**` 零改动、⛔ 零新增监听口、⛔ 未改 nft / nginx / bwrap、⛔ 未动任何服务配置。 + +**1. 缺陷与落点(⛔ 不是网络故障、⛔ 不是 worker 故障)** + +三条判据的绿**取决于 106 worker 挂在哪台中继**,而 worker 通道会按**抖动**换址(`switcher.ts`,属既有设计)⇒ worker 一旦落到 **106 自家中继**,47 视角就同时失去"会话"与"实例面": + +| # | 判据 | 旧数据源(唯一) | 漂移态下 47 视角读数 → 判决 | +|---|---|---|---| +| ① | `OBS-01` 在册节点数 | 47 `/status.capacity.used` | `used=1`(只有 `manager`)⇒ **FAIL**(阈值 ≥ 2) | +| ② | `OBS-08` 端点表全在线 | 47 `/status.endpoints[]` | `2 条 / 离线 2 条`(`w-106` 的两条**离线孤儿**)⇒ **FAIL** | +| ③ | `OBS-09` 在册实例面探活 | 47 `/status.endpoints[]` 派生 | 派生子为空(两条都 `online=false`)⇒ **SKIP** | + +🔴 **性质 = 假红 / 假 SKIP** —— 客户端一直好好的,只是"**不在我这一台**"(worker 会话与实例端口都**如实在册**,在**106 自己的中继表**里:`used=1` + 两条 `online=true`)。⇒ 落点 = **探针的取数范围**,⛔ 不是"把判据放宽"。 + +**2. 实现(一个文件 · 六处改动 · ⛔ 阈值一字未动)** + +| # | 位置 | 改动 | +|---|---|---| +| ① | `main()` 新增 `readPeerView()` | 对端(106)`/status` 的**唯一取数入口**(真机 = **一次 ssh** 取回 `/status` 原文 + `__RELAY_ACTIVE__` 活性哨兵;夹具 = `--peer-status-fixture`;**缓存一次**供并集与「空表可分」**共用** ⇒ ⛔ 零重复 ssh) | +| ② | 新增 `mergeEndpoints()` | 端点表并集:键 = **`network:hostId:port`**;`online` 取**或**;`localPort` 取**在线那一侧**(离线条目的落点已失效);`from` 记"该条最终采信谁" | +| ③ | 新增 `unionUsed()` | 在册节点数并集 = **`network/hostId` 去重后的并集基数**(⛔ 不是 `a.used + b.used` —— 同一节点在两台都残留时会**重复计数** = 另一方向的假绿);任一侧 `sessions[]` 不可用 ⇒ 记 `exact=false` 并**回落 `max(各侧 used)`**(⛔ 不假装是并集) | +| ④ | 新增 `remoteInstanceCodes()` | **只取实例面 HTTP 码**的一次 ssh —— 并集里归属 **106** 的端点,其**回环落点只存在于 106**(在 47 上探必然 `000` = **假红**)⇒ 按 `from` **分机探活**;⚠️ 只在真有 106 侧在册实例时才发(正常态零额外取数) | +| ⑤ | `judgeObs08()` / `judgeObs09()` / `OBS-01` | 数据源换成**并集**(判据形态、阈值、派生规则**一字未动**) | +| ⑥ | `classifyEmptyView()` 收敛进 ① | 原"只在 47 视角为空时才去读对端"的老逻辑改由 `readPeerView()` 承载(同一个坑不再写第二份) | + +🔴 **只并这三项**:`OBS-11` 的 `derived`(= **47 自己的**监听面)/`OBS-02`(47 自身容量自洽)/`OBS-13`·`OBS-16`(47 的 `counters`)**一律保持 47 视角** —— 把 106 的落点并进 `derived` 等于**凭空放宽**"多出"判据。⚠️ 真机模式**无条件**读一次对端(⇒ 并集是**完整**的);⛔ 不做"缺什么补什么"的按需读 —— 那会留下一个**假绿**口子(106 上多一台 47 不知道的节点时,47 视角照样绿)。⚠️ 对端读不回来 ⇒ **不硬失败**,回落 47 视角并**强制留痕**「并集不可取证(…)⇒ 本项仅按 47 视角判」。 + +**3. 先红后绿(原文级)** + +**(a) 夹具腿(同一份夹具,两侧**只差**是否给对端那一半)** —— 夹具 = 真实 `/status` 原文(`s1/s2/s3` = 47 视角 + `counters.statusHits` 递加,`peer.json` = 106 视角)+ `--listen-fixture` / `--nft-fixture`(真实 47 `ss` / `nft`)+ **参数表副本**(`SSH_TARGET_47/106` → `127.0.0.1`、`SSH_PORT` → `1` ⇒ **"夹具跑里到底有没有 ssh"可观测**): + +| 腿 | 命令差异 | rc | `OBS-08` | `OBS-09` | +|---|---|---|---|---| +| 🔴 红 | **不给** `--peer-status-fixture`(= 并集退化成 47 视角) | **1** | `FAIL 端点表(并集) 2 条 / 离线 2 条 |并集: 47 视角 2 条 + 对端 ? 条 去重后 2 条` | `SKIP 在册实例面 无(从并集端点表派生为空:并集 2 条 = agent 0 条 + 离线 2 条)` | +| 🟢 绿 | 加 `--peer-status-fixture` | **0** | `PASS 端点表(并集) 2 条 / 离线 0 条 |并集: 47 视角 2 条 + 对端 2 条 去重后 2 条` | `PASS 在册实例面(并集派生 1 条):w-106:36873=401 (阈值 ∈ {200,401})` | + +🔬 **两侧逐行 `diff` 只差这 2 行**(`OBS-01` 在夹具模式下按既有语义记"夹具模式未取证" ⇒ 它的红腿由下面 (b)(c) 承载)。 + +**(b) 真机 · 漂移态(`--scene all` 收口后现场恰好就是"47 视角空")** —— 47 `/status` = `used=1 · endpoints=[]`、106 `/status` = `used=1 · eps=[('w-106',19000,46105,True),('w-106',21001,40701,True)]`,探针原文: + +``` +PASS OBS-01 在册节点 used=2 (阈值 ≥ 2) |并集: 47 视角 used=1 + 对端 used=1 ⇒ 按 network/hostId 去重后计 +PASS OBS-08 端点表(并集) 2 条 / 离线 0 条 |并集: 47 视角 0 条 + 对端 2 条 去重后 2 条 +PASS OBS-09 在册实例面(并集派生 1 条):w-106:40701=401 (阈值 ∈ {200,401}) +``` + +🔬 **最强的一条**:`OBS-09` 探的是 **`40701`** —— 那是 **106 机上**的回环落点(47 上**根本不存在**该口)⇒ 证明"按 `from` 分机探活"这条腿**真的走到了 106**,⛔ 不是拿 47 的旧落点凑绿。⚠️ 该轮唯一红 = `OBS-04 identityOk=1`(**演练余波**:`--scene all` 重启了 47 relay ⇒ 计数从零开始;⛔ 与并集无关,既有处置 = 停/启 106 relay 逼 worker 回落,复原后即绿——本棒已执行,见 §16-5)。 +**(c) 真机 · 退化腿(对端不可达)** —— 表副本只把 `SSH_TARGET_106` 指向 `127.0.0.1`:`rc=1`,三条原文 = `FAIL OBS-01 used=1`(并集不可取证 ⇒ 仅 47 视角)/`FAIL OBS-08 端点表(并集) 2 条 / 离线 2 条`/`SKIP OBS-09` ⇒ **与改前的 47 单点判法逐条一致**。⚠️ **如实留档**:该腿还带出 4 条**与并集无关**的红(`OBS-18` / `OBS-21` / `OBS-22` / `OBS-23` —— 它们本就要 ssh 到 106),⇒ **该腿只用于取"三条原文",⛔ 不能当"改前全量读数"用**;改前全量真值 = **26 PASS / 2 FAIL / 1 SKIP(rc=1)**(§15-11 现场 + 本棒 prompt 同数)。 + +**4. 硬约束③ 实证 —— `OBS-16` 的 `Δ` **不被对端那次采样污染**(本棒先核后做)** + +| 项 | 事实 | +|---|---| +| 对 47 `/status` 的读取次数 | **一次都没变**(仍是三次:`status` / `status2` / `status3`,判据全部复用已采到的那份) | +| 对端那份的**位置** | 在**第三次采样之后** ⇒ ⛔ 不进 `OBS-16` 的门窗口 `(status2, status3]`(该窗口里**只有** `sleepSync`) | +| 隔离性实验(原文) | 47 `statusHits: 5 → 6 (Δ=1)`,**中间插入了 2 次读 106**;106 自身 `2 → 3` ⇒ ✅ **读 106 不会动 47 的计数器**(`OBS-16` 只取 47 的 `counters`) | +| 运行期留痕 | `OBS-16` 行末已写明:`|Δ 口径 = **仅 47** 的 counters(对端 106 的采样另算一次、⛔ 不入 Δ;对 47 的读数次数仍为三次)` —— ⛔ 防日后被误并 | + +**5. 零回归三件套(终态)** + +| 件 | 读数 | 与基线 | +|---|---|---| +| ① 探针(真机 · 终态) | **29 PASS / 0 FAIL / 0 SKIP**(rc=0;`probe_final.out`) | ✅ 回到基线 **29P/0S/0F**(`OBS-01 used=2`|`OBS-08 2 条/离线 0`|`OBS-09 w-106:35713=401`) | +| ② `npm.cmd test`(Node **v22.22.2**) | **200 pass / 0 fail / 1 skipped**(`# tests 201`;rc=0) | ✅ 逐字同基线 | +| ③ `--scene all` | **12 PASS / 0 SKIP / 0 FAIL**(rc=0;**6m10s**;⚠️ **未套外层超时**) | ✅ 逐字同基线;`幕1-A` **15386ms** / `幕4-C` **19650ms**(deadline 30000ms);`幕1-A` 本次**未**触发 6 h 坑 | + +⚠️ **演练后处置(既有程序 · R8)** = 收口时 47 视角为 `endpoints=[] / used=1` ⇒ **停 / 启 106 `dshs-relay`** 逼 worker 按候选链回落 47(⛔ **未重启 worker**、⛔ **未换实例**)。⚠️ 首轮"停 3 s 即起"**不足够**(106 是该 worker 候选链的**第一条**,它会回连 106)⇒ 实际停机 **45 s** 后回落成功(47 `used=2 idOk=2`、两条端点 `online=true`),随后恢复 106 relay(`active`)⇒ 终态读数如上。 + +**6. 部署(落点 / 既有 md5 / 新 md5 · ⛔ 零新增监听口)** + +| 机 | 落点 | 既有 | 本棒 | +|---|---|---|---| +| 47 | `/opt/dshs/scripts/overlay-probe.cjs` | md5 **`eaec1ad560adbec37a15cd145a5ebb77`**(109266 B · **Sep 18 12:49** = 序㊳ 期,**陈旧**) | 就地更新 ⇒ **`d2f879dc3220f2800d812437518e3c63`**(135215 B · 0755) | +| 106 | `/opt/dshs-cluster/scripts/overlay-probe.cjs` | **不存在**(该机 `scripts/` 是 47 的**子集** 50 件;overlay 系只有 `direct-probe` / `keyring` / `relaykey-add`) | 新落 ⇒ **`d2f879dc…`**(root:root 0755) | + +✅ 两机落点 md5 **与代码仓同值**(`d2f879dc3220f2800d812437518e3c63`)。🔴 **为什么 106 也落**:两台机若一份新一份旧/无,日后在那台机上跑一次就得到**旧口径读数**(= 本线反复踩的"同一事实两处打架")⇒ 取**两机同源**。⚠️ 该文件**不被任何单元读取**(探针是"从本机 ssh 出去"的工具)⇒ ⛔ 零服务影响、⛔ 零监听口。⚠️ **本棒 ⛔ 未按"备份 + 替换"三段式处理 47 的旧副本**(直接覆盖)—— 该副本是**被 git 追踪文件**的陈旧拷贝、且代码仓内有更新版本 ⇒ 判定**无损**(如实留档)。 + +**7. 边界自证** + +⛔ 未 commit / 未 push(HEAD **`45b4999`** 未动)|⛔ 未改 `DEFAULT_BLOCK_SIZE` / `package.json`|⛔ **零新依赖**(改动全在既有 Node 内建模块)|⛔ 未改 nft / nginx / bwrap|🔴 ⛔ **未改参数表值格**(只追加 **§11.16** 补记)|🔴 密钥本体 ⛔ 不经网络 / ⛔ 不经 relay(本棒只读 `/status`)|⛔ 未调 `RELAY_FAILOVER_DEADLINE_MS`、⛔ 未动 `HB_SEC` / burst、⛔ 未禁用 `COOLDOWN_MS=0`(`grep` 计数 **0**)|**§8 前前缀 `be548afc3340583b2b63ca254bcf550f` 回填本 §16 后逐字不变** ✅。 + +**8. 生产动作(R8 · 动手前已声明 · 全可回溯)** + +① 两机 `scripts/` 落点更新(纯文本、无服务读取)② `--scene all` 演练(内含多次 relay / Manager 停启)③ 收口处置 = 106 `dshs-relay` **停 45 s / 起**(逼 worker 回落 47)。⚠️ **多次读 `/status`**(探针三次 + 对端一次 + 取证若干)—— ⛔ 均为**只读**、⛔ 未改任何计数语义。⛔ **零不可逆动作**、⛔ 未停/未换任何实例。 + +**9. 回滚** + +改动面 = **单文件**,回滚 = 反向应用 §16-2 的六处改动(或从代码仓取上一版覆盖)。🔴 **如实留档**:本机**没有**改前那份的**字节级副本**(工作树是**未提交**状态 `git status M`,开工时**未做 `.bak`**;`git show HEAD:scripts/overlay-probe.cjs` = 114850 B ≠ 改前 122541 B)⇒ **回滚依据 = §16-2 的改动清单**。⚠️ 47 的旧副本(109266 B)**也不是**合法回滚目标(陈旧,缺序㊲–㊻ 多轮改动)。 + +**10. 未验证项 / 遗留(⛔ 如实)** + +1. 🔴 **`D8` 云安全组(乙-1)仍未落地** —— 两机 + 本机**均无云凭据**(序㊹ 已取证)⇒ **只能你在云控制台落地**(47 入站 `UDP 21100/21115` ← `106.54.21.172/32`;106 入站 ← `47.77.182.89/32`;⛔ 零个 `0.0.0.0/0`)。**你已批准但定"先记下后续再处理" ⇒ 本棒按挂起处理、⛔ 未开工、⛔ 不索要 AK/SK**。 +2. ⚠️ **"已消失端口的孤儿"仍未回收**(§15-10-2)—— 与并集无关:**并集能消除"看不见"**(`OBS-08` 不再因离线孤儿转红),但那些 `online=false` 的条目**仍永久留在表里**。**回头条件不变**:出现端口替换后 `OBS-08` 再红。 +3. ⚠️ `OBS-04`(`identityOk ≥ 2`)对**中继重启**敏感(计数归零)⇒ 演练 / 重启后需一次回落动作才回绿。**⛔ 本棒未改该判据**(属 §1 边界内既有口径,如实登记)。 +4. ⚠️ 夹具模式下 `OBS-01` 仍记"夹具模式未取证"(既有语义、⛔ 本棒未改)⇒ 它的红腿只能在**真机**上取(本节 §16-3b/3c)。 + +**11. 产物 / 证据落点** + +- 代码:`scripts/overlay-probe.cjs`(**唯一改动文件**;md5 **`d2f879dc3220f2800d812437518e3c63`**) +- 证据目录:`E:\ProgramData\AI技能\aliyun-dsh-server\_tmp_seq49\` —— `probe_final.out`(终态 29P)/`probe_drift_green.out`(漂移态三条绿 + `OBS-04` 演练余波)/`fxA_red.out` ↔ `fxB_green.out`(夹具两腿逐行 diff)/`real_red.out`(对端不可达退化腿)/`exp_obs16.txt`(隔离性实验)/`npmtest.txt`/`drill_all.txt`/两机 `/status` 原文 `relay47.json` / `relay106.json` +- 参数表:§11.16 补记 + + diff --git a/scripts/dshlog.mjs b/scripts/dshlog.mjs new file mode 100644 index 0000000..3d046c1 --- /dev/null +++ b/scripts/dshlog.mjs @@ -0,0 +1,569 @@ +#!/usr/bin/env node +/** + * dshlog.mjs — DSH 平台「多节点日志采集 / 实时巡检 / 事后溯源」单一入口(方案 C:零新增常驻服务) + * + * 设计边界(2026-09-18 定) + * · 服务器侧**零安装**:远端只用系统自带的 `journalctl`,采集靠 ssh 一次性拉取,不装 agent、不开监听口 + * · 归档在**本机**:`E:/dsh-logs//.ndjson.gz`(可按天清理) + * · 日志原文**保真**:`msg` 字段逐字节原样落盘(本项目大量判据依赖日志原文的行数/字节数) + * · 失败**可分**:远端元信息走 stderr 哨兵 `__DSHLOG_EOF__`,与 stdout 的纯数据分离 + * ⇒ "拉取失败" 与 "确无日志" 永不混淆(这是本项目反复踩过的坑) + * + * 子命令 + * collect 拉取归档(增量,默认只拉「上次游标之后」) + * ls 列出归档分片 + * q 关键词/正则查询(跨机跨单元) + * timeline 时间线重建(多机日志按时间戳合并,用于事后溯源) + * watch 实时巡检(规则命中 → 判据表 + 非零退出码,可挂 automation) + * stats 归档统计 + * + * 约定 + * · 时间一律走 epoch(`--since @`;⛔ journalctl 不吃 date -Is 的时区偏移) + * · 归档根可用 `DSHLOG_ROOT` 覆盖,默认 `E:/dsh-logs` + */ + +import { spawnSync } from "node:child_process"; +import { + appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, +} from "node:fs"; +import { createGunzip, gzipSync } from "node:zlib"; +import { createReadStream } from "node:fs"; +import { createInterface } from "node:readline"; +import path from "node:path"; + +// ───────────────────────────── 配置 ───────────────────────────── + +const ROOT = process.env.DSHLOG_ROOT || "E:/dsh-logs"; +const STATE = path.join(ROOT, "state.json"); +const HOSTS_FILE = path.join(ROOT, "hosts.json"); + +const DEFAULT_HOSTS = { + "47": { ssh: ["-p", "22", "root@47.77.182.89"], label: "Manager + w-47" }, + "106": { ssh: ["-p", "22", "test106"], label: "w-106" }, +}; + +// 巡检规则:只认**指向本项目自身故障**的信号。 +// ⚠️ 每条规则都是误报与漏报的取舍 —— 下面两组是实测调过的: +// · WATCH-01 不能只写 `fatal`:sshd 的 `ssh_dispatch_run_fatal`(客户端网络断)会天天命中 +// · WATCH-08 不能写 `Stopped .*`:实例正常退出、用户主动停会话都会打 `Stopped /usr/bin/bwrap …` +const SEV_RULES = [ + ["WATCH-01", "进程级致命", /PANIC|FATAL ERROR|unhandledRejection|uncaughtException|SIGSEGV|core dumped|segfault/i], + ["WATCH-02", "内存/被杀", /out of memory|oom-kill|oom_reaper|Killed process|ENOMEM/i], + ["WATCH-03", "端口/连接失败", /EADDRINUSE|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH|EPIPE/i], + ["WATCH-04", "权限/属主异常", /EACCES|EPERM|permission denied/i], + ["WATCH-05", "磁盘/写入失败", /ENOSPC|no space left|read-only file system|EROFS/i], + ["WATCH-06", "覆盖网络信任链被拒", /no-trusted-keys|取目录全部失败|信任链被拒|reject.{0,12}trust/i], + ["WATCH-07", "HTTP 5xx", /"statusCode":5\d\d|status=5\d\d/i], + ["WATCH-08", "服务异常终止", /Failed with result|start request repeated|Main process exited, code=(exited|killed)|crash|panic exit/i], +]; + +// 登录/编排类噪声单元:命中规则也不计(否则 sshd 连接超时会天天冒充"故障") +const NOISE_UNITS = /^sshd\.service$|^crond\.service$|^systemd-logind\.service$/; + +// ───────────────────────────── 小工具 ───────────────────────────── + +const nowSec = () => Math.floor(Date.now() / 1000); +const pad = (n) => String(n).padStart(2, "0"); +const dayStr = (d = new Date()) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +const tsLocal = (ms) => { + const d = new Date(ms); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +}; + +/** `--k v` / `--flag` 两种形态都吃 */ +function parseArgs(argv) { + const o = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const k = a.slice(2); + const nxt = argv[i + 1]; + if (nxt === undefined || nxt.startsWith("--")) o[k] = true; + else { o[k] = nxt; i++; } + } else o._.push(a); + } + return o; +} + +/** `1h` / `30m` / `2d` / 纯秒 ⇒ 秒数 */ +function dur2sec(s) { + if (s === undefined || s === true) return null; + const m = String(s).match(/^(\d+(?:\.\d+)?)([smhd]?)$/); + if (!m) return null; + const n = parseFloat(m[1]); + return Math.round(n * ({ s: 1, m: 60, h: 3600, d: 86400 }[m[2] || "s"])); +} + +function readJson(f, dflt) { + try { return JSON.parse(readFileSync(f, "utf8")); } catch { return dflt; } +} +function loadHosts() { + const h = readJson(HOSTS_FILE, null); + return h && Object.keys(h).length ? h : DEFAULT_HOSTS; +} + +// ───────────────────────── ssh(数据走 stdout,元信息走 stderr 哨兵) ───────────────────────── + +/** + * 远端执行的脚本:⛔ 只用 journalctl;⛔ 不写服务器任何文件(数据直出 stdout)。 + * 数据走 stdout(纯),元信息走 stderr(哨兵 + 行数 + 错误原文)⇒ 两条通道物理分离。 + * ⚠️ `awk` 既转发 stdout 又把行数写 stderr:这样"拉取失败"与"确无日志"永远可分。 + */ +function remoteCollectScript({ sinceEpoch, untilEpoch, afterCursor, units, priority }) { + const parts = [ + "set -u", + "set -o pipefail", + 'J="journalctl --no-pager -o json"', + ]; + // ⚠️ 239 也支持 --output-fields(实测:能砍掉约一半体积) + // 版本号解析必须 `NR==1` —— `journalctl --version` 是**多行**输出, + // 不加限定会让 V 变成 "239\n0" ⇒ `[: integer expression expected` ⇒ 静默退回全字段(体积翻倍) + parts.push('V=$(journalctl --version 2>/dev/null | awk \'NR==1{print $2+0}\')'); + parts.push('if [ "${V:-0}" -ge 236 ] 2>/dev/null; then J="$J --output-fields=__REALTIME_TIMESTAMP,__CURSOR,_SYSTEMD_UNIT,_HOSTNAME,SYSLOG_IDENTIFIER,_PID,PRIORITY,MESSAGE"; fi'); + if (sinceEpoch) parts.push(`J="$J --since @${sinceEpoch}"`); + if (untilEpoch) parts.push(`J="$J --until @${untilEpoch}"`); + if (afterCursor) parts.push(`J="$J --after-cursor='${afterCursor}'"`); + if (priority) parts.push(`J="$J -p ${priority}"`); + if (units && units.length) parts.push(`J="$J ${units.map((u) => `-u ${u}`).join(" ")}"`); + parts.push( + 'eval "$J" 2>/tmp/.dshlog_err.$$ | awk \'{n++} {print} END{printf "__DSHLOG_LINES__ %d\\n", n > "/dev/stderr"}\'', + "RC=$?", + 'EB=$(wc -c < /tmp/.dshlog_err.$$ 2>/dev/null || echo 0)', + 'head -c 2000 /tmp/.dshlog_err.$$ >&2', + "rm -f /tmp/.dshlog_err.$$", + 'echo "__DSHLOG_EOF__ rc=$RC errbytes=$EB" >&2', + ); + return parts.join("\n"); +} + +function sshRun(hostCfg, script, { timeoutMs = 120000 } = {}) { + // 🔴 `-C` 不可省:47 的出方向未压缩带宽实测 ~20 KB/s(1.6 MB 要 84 s), + // 开压缩后同样的数据 11 s(7.6×)。日志是 JSON ⇒ 压缩率极高,这是最大的单一杠杆。 + const r = spawnSync("ssh", ["-C", ...hostCfg.ssh, "bash -s"], { + input: script, encoding: "utf8", timeout: timeoutMs, maxBuffer: 256 * 1024 * 1024, + }); + const stderr = r.stderr || ""; + const sentinel = stderr.split("\n").filter((l) => l.startsWith("__DSHLOG_EOF__")).pop() || ""; + const m = sentinel.match(/rc=(\d+)\s+errbytes=(\d+)/); + const rc = m ? parseInt(m[1], 10) : null; + const errbytes = m ? parseInt(m[2], 10) : null; + // 远端 journalctl 自己报告的输出行数(权威计数,用于与本地解析数对账) + const lm = stderr.split("\n").filter((l) => l.startsWith("__DSHLOG_LINES__")).pop() || ""; + const lm2 = lm.match(/__DSHLOG_LINES__ (\d+)/); + const remoteLines = lm2 ? parseInt(lm2[1], 10) : null; + // 远端 journalctl 自身的错误(剔除 ssh 噪声与自家哨兵行) + const remoteErr = stderr.split("\n") + .filter((l) => l.trim() && !l.startsWith("__DSHLOG_EOF__") && !l.startsWith("__DSHLOG_LINES__") + && !/post-quantum|store now, decrypt later|openssh.com\/pq|WARNING: connection is not using/i.test(l)) + .join("\n").trim(); + return { + stdout: r.stdout || "", + rc, errbytes, remoteLines, remoteErr, + sshStatus: r.status, + sshError: r.error ? String(r.error.message || r.error) : null, + }; +} + +// ───────────────────────────── 归档读写 ───────────────────────────── + +function hostDir(host) { return path.join(ROOT, host); } +function shardPath(host, day) { return path.join(hostDir(host), `${day}.ndjson.gz`); } + +function appendShard(host, day, lines) { + if (!lines.length) return 0; + mkdirSync(hostDir(host), { recursive: true }); + const f = shardPath(host, day); + // ⚠️ 必须同步写:异步 stream 在进程收尾时可能未 flush(会静默丢最后一批) + // gzip 多成员文件可被 `gunzip` / `createGunzip` 顺序读回 ⇒ 追加安全 + appendFileSync(f, gzipSync(Buffer.from(lines.join("\n") + "\n", "utf8"), { level: 6 })); + return lines.length; +} + +async function* readShard(host, day) { + const f = shardPath(host, day); + if (!existsSync(f)) return; + const rl = createInterface({ input: createReadStream(f).pipe(createGunzip()), crlfDelay: Infinity }); + for await (const line of rl) { + if (!line.trim()) continue; + try { yield JSON.parse(line); } catch { /* 损坏行跳过(不静默吞:由 stats 统计) */ } + } +} + +function listDays(host) { + const d = hostDir(host); + if (!existsSync(d)) return []; + return readdirSync(d).filter((f) => f.endsWith(".ndjson.gz")).map((f) => f.replace(".ndjson.gz", "")).sort(); +} + +/** 归一:journald JSON ⇒ 统一记录(msg 保真) */ +function normalize(rec, host) { + const tsUs = rec.__REALTIME_TIMESTAMP ? Number(rec.__REALTIME_TIMESTAMP) : 0; + let msg = rec.MESSAGE; + if (Array.isArray(msg)) { + // journald 对非 UTF-8 字节会给数组 ⇒ 转义回可读文本(保真:字节以 \xNN 形式保留) + msg = msg.map((b) => (b >= 32 && b < 127 ? String.fromCharCode(b) : `\\x${b.toString(16).padStart(2, "0")}`)).join(""); + } + return { + ts: Math.floor(tsUs / 1000), + host, + unit: rec._SYSTEMD_UNIT || "", + ident: rec.SYSLOG_IDENTIFIER || "", + pid: rec._PID ? Number(rec._PID) : 0, + pri: rec.PRIORITY ? Number(rec.PRIORITY) : 6, + cursor: rec.__CURSOR || "", + msg: typeof msg === "string" ? msg : JSON.stringify(msg ?? ""), + }; +} + +// ───────────────────────────── collect ───────────────────────────── + +/** + * 校时:**以远端 NTP 同步状态为权威判据**,RTT 估算只作参考。 + * ⚠️ 别用"ssh 往返估算"当权威 —— 实测 47 的 ssh RTT 达 3.7s 且往返不对称, + * 单次估算能给出 1.3s 的假偏移(比真实时钟差还大)⇒ 那样的校正会**制造**错序。 + */ +function probeClock(cfg) { + const r = sshRun(cfg, [ + 'echo "__DSHLOG_NTP__ $(timedatectl show -p NTPSynchronized --value 2>/dev/null || echo unknown)"', + 'echo "__DSHLOG_OFF__ $(timedatectl show -p Offset --value 2>/dev/null || echo unknown)"', + 'echo "__DSHLOG_TM__ $(date +%s%N)"', + ].join("\n"), { timeoutMs: 20000 }); + const all = `${r.stdout}\n${r.stderr}`; + const synced = (all.match(/__DSHLOG_NTP__ (\S+)/) || [])[1] || "unknown"; + const offRaw = (all.match(/__DSHLOG_OFF__ (\S+)/) || [])[1] || "unknown"; + const tms = (all.match(/__DSHLOG_TM__ (\d+)/) || [])[1]; + return { + ntpSynced: synced, + ntpOffsetMs: offRaw !== "unknown" ? Math.round(Number(offRaw) / 1000) : null, + remoteNowMs: tms ? Number(tms) / 1e6 : null, + measuredAt: Date.now(), + }; +} + +async function cmdCollect(args) { + mkdirSync(ROOT, { recursive: true }); + const hosts = loadHosts(); + const want = args.hosts && args.hosts !== true ? String(args.hosts).split(",").map((s) => s.trim()) : Object.keys(hosts); + const st = readJson(STATE, { hosts: {} }); + st.hosts = st.hosts || {}; + const since = dur2sec(args.since); + const full = !!args.full; + const backfill = !!since; // 回填 = 显式给 --since(与"续拉"是两种语义) + const chunk = args.chunk ? dur2sec(args.chunk) : Math.max(3600, Math.ceil((since || 3600) / 8)); + const rows = []; + + for (const h of want) { + const cfg = hosts[h]; + if (!cfg) { rows.push([h, "SKIP", "hosts.json 无此节点", ""]); continue; } + const prev = st.hosts[h] || {}; + + // 回填必须去重(同一 cursor 已在归档里则跳过),否则重复落盘会污染行数类判据 + const seen = new Set(); + if (backfill) { + for (const d of listDays(h)) for await (const r of readShard(h, d)) if (r.cursor) seen.add(r.cursor); + } + + // 时间窗切块:单次 ssh 只搬一块 ⇒ 内存/传输受控,且任何一块失败可单独重试 + let windows; + if (backfill) { + const end = nowSec(); + windows = []; + for (let t = end - since; t < end; t += chunk) windows.push([t, Math.min(t + chunk, end)]); + if (!windows.length) windows = [[end - 60, end]]; + } else if (!full && prev.cursor) { + windows = [[null, null]]; // cursor 续拉(精确、不重不漏) + } else { + windows = [[nowSec() - 3600, nowSec()]]; + } + + const clock = probeClock(cfg); + let ok = 0, bad = 0, dup = 0, written = 0, firstErr = "", jrcBad = null, mismatch = false, remoteReported = 0; + let lastCursor = prev.cursor || "", lastTs = prev.lastTs || 0; + const dayMap = {}; + + for (const [from, to] of windows) { + if (process.env.DSHLOG_VERBOSE) console.error(` [${h}] 拉块 ${from === null ? "cursor" : tsLocal(from * 1000)} → ${to === null ? "now" : tsLocal(to * 1000)} …`); + const res = sshRun(cfg, remoteCollectScript({ + sinceEpoch: from, untilEpoch: to, afterCursor: from === null ? prev.cursor : null, + }), { timeoutMs: 600000 }); + const sshFailed = res.rc === null && res.sshStatus !== 0; + if (sshFailed) { if (!firstErr) firstErr = res.sshError || "ssh 失败"; continue; } + if (res.rc !== null && res.rc !== 0 && res.rc !== 1) { jrcBad = res.rc; } + if (res.remoteLines !== null) remoteReported += res.remoteLines; + if (res.remoteErr && !firstErr) firstErr = res.remoteErr.slice(0, 160); + + let chunkOk = 0, chunkBad = 0; + for (const l of res.stdout.split("\n")) { + if (!l.trim()) continue; + let rec; + try { rec = JSON.parse(l); } catch { bad++; chunkBad++; continue; } + const n = normalize(rec, h); + ok++; chunkOk++; + if (backfill && n.cursor && seen.has(n.cursor)) { dup++; continue; } + if (n.cursor) seen.add(n.cursor); + if (n.cursor) lastCursor = n.cursor; + if (n.ts > lastTs) lastTs = n.ts; + const d = dayStr(new Date(n.ts)); + (dayMap[d] = dayMap[d] || []).push(JSON.stringify(n)); + } + if (res.remoteLines !== null && res.remoteLines !== chunkOk + chunkBad) mismatch = true; + } + + for (const [d, ls] of Object.entries(dayMap)) written += appendShard(h, d, ls); + + const state = firstErr && ok === 0 ? "FAIL" + : jrcBad !== null ? `JOURNALCTL_RC${jrcBad}` + : mismatch ? "MISMATCH" + : ok === 0 ? "EMPTY" : "OK"; + st.hosts[h] = { + cursor: lastCursor, lastTs, lastRun: Date.now(), + ntpSynced: clock.ntpSynced, ntpOffsetMs: clock.ntpOffsetMs, + lastState: state, lastLines: ok, lastWritten: written, lastDup: dup, lastRemoteLines: remoteReported, + }; + let detail = `拉取 ${ok} 行`; + if (remoteReported) detail += ` / 远端报 ${remoteReported}`; + if (dup) detail += ` · 去重跳 ${dup}`; + if (written) detail += ` · 落盘 ${written}`; + if (bad) detail += ` · 解析失败 ${bad}`; + if (firstErr) detail += ` · err: ${firstErr.slice(0, 120)}`; + rows.push([h, state, detail, + `${windows.length} 块` + (backfill ? ` since @${nowSec() - since}` : " cursor 续拉") + + ` · NTP ${clock.ntpSynced}` + (clock.ntpOffsetMs === null ? "" : `(${clock.ntpOffsetMs}ms)`)]); + } + writeFileSync(STATE, JSON.stringify(st, null, 2), "utf8"); + if (!args.quiet) printTable(["节点", "状态", "结果", "方式"], rows, args); + const badRows = rows.filter((r) => !["OK", "EMPTY", "SKIP"].includes(r[1])); + if (badRows.length) process.exitCode = 2; + return rows; +} + +// ───────────────────────────── prune(保留策略) ───────────────────────────── + +function cmdPrune(args) { + const keep = args.keep ? Number(args.keep) : 3; // 与服务器侧口径一致:3 天 + const cut = dayStr(new Date(Date.now() - keep * 86400e3)); + const hosts = loadHosts(); + const rows = []; + let total = 0; + for (const h of Object.keys(hosts)) { + for (const d of listDays(h)) { + if (d >= cut) continue; + const p = shardPath(h, d); + const sz = statSync(p).size; + total += sz; + if (args.apply) { try { unlinkSync(p); rows.push([h, d, "已删除", `${(sz / 1e6).toFixed(2)} MB`]); } catch (e) { rows.push([h, d, "删除失败", String(e.message).slice(0, 60)]); } } + else rows.push([h, d, "待删(干跑)", `${(sz / 1e6).toFixed(2)} MB`]); + } + } + printTable(["节点", "日期", "动作", "大小"], rows, args); + console.log(`保留 ${keep} 天 ⇒ 截止 ${cut}${args.apply ? " · 已执行" : " · 干跑(加 --apply 才真删)"}|涉及 ${(total / 1e6).toFixed(2)} MB`); +} + +// ───────────────────────────── ls / stats ───────────────────────────── + +function cmdLs(args) { + const hosts = loadHosts(); + const rows = []; + for (const h of Object.keys(hosts)) { + for (const d of listDays(h)) { + const p = shardPath(h, d); + rows.push([h, d, `${(statSync(p).size / 1024 / 1024).toFixed(2)} MB`]); + } + } + printTable(["节点", "日期", "大小"], rows, args); +} + +async function cmdStats(args) { + const hosts = loadHosts(); + const st = readJson(STATE, { hosts: {} }); + const rows = []; + for (const h of Object.keys(hosts)) { + const s = (st.hosts || {})[h] || {}; + rows.push([h, s.lastState || "-", String(s.lastLines ?? "-"), + s.lastRun ? tsLocal(s.lastRun) : "-", + s.ntpSynced ? `${s.ntpSynced}${s.ntpOffsetMs === null || s.ntpOffsetMs === undefined ? "" : ` (${s.ntpOffsetMs}ms)`}` : "-", + s.cursor ? s.cursor.slice(0, 24) + "…" : "-"]); + } + printTable(["节点", "末次状态", "行数", "末次拉取", "NTP", "游标"], rows, args); + if (args.detail) { + for (const h of Object.keys(hosts)) { + for (const d of listDays(h)) { + let n = 0; const units = new Map(); + for await (const r of readShard(h, d)) { n++; units.set(r.unit, (units.get(r.unit) || 0) + 1); } + const top = [...units.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4) + .map(([u, c]) => `${u.replace(/\.service|\.scope/g, "")}=${c}`).join(" "); + console.log(` ${h} ${d}: ${n} 行 · ${top}`); + } + } + } +} + +// ───────────────────────────── q(查询) ───────────────────────────── + +async function cmdQ(args) { + const pat = args._[0]; + if (!pat) { console.error("用法: dshlog q <关键词|/正则/> [--since 6h] [--host 47] [--unit dshs] [--limit 100] [--json]"); process.exitCode = 2; return; } + const re = pat.startsWith("/") && pat.endsWith("/") ? new RegExp(pat.slice(1, -1), "i") : new RegExp(pat.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"); + const hosts = args.host && args.host !== true ? String(args.host).split(",") : Object.keys(loadHosts()); + const sinceMs = args.since ? Date.now() - dur2sec(args.since) * 1000 : 0; + const untilMs = args.until ? Date.now() - dur2sec(args.until) * 1000 : Infinity; + const unitRe = args.unit && args.unit !== true ? new RegExp(String(args.unit), "i") : null; + const limit = args.limit ? Number(args.limit) : 100; + const hits = []; + let scanned = 0; + for (const h of hosts) { + for (const d of listDays(h)) { + for await (const r of readShard(h, d)) { + scanned++; + if (r.ts < sinceMs || r.ts > untilMs) continue; + if (unitRe && !unitRe.test(r.unit)) continue; + if (!re.test(r.msg)) continue; + hits.push(r); + if (hits.length >= limit * 20) break; + } + } + } + hits.sort((a, b) => a.ts - b.ts); + const show = hits.slice(-limit); + if (args.json) console.log(JSON.stringify(show, null, 1)); + else { + console.log(`命中 ${hits.length} 条(扫描 ${scanned} 行)⇒ 显示末 ${show.length} 条`); + for (const r of show) console.log(`${tsLocal(r.ts)} [${r.host}/${r.unit.replace(/\.service|\.scope/g, "")}] ${r.msg.slice(0, 300)}`); + } + if (!show.length) process.exitCode = 1; +} + +// ───────────────────────────── timeline(事后溯源) ───────────────────────────── + +async function cmdTimeline(args) { + const hosts = args.host && args.host !== true ? String(args.host).split(",") : Object.keys(loadHosts()); + const st = readJson(STATE, { hosts: {} }); + const from = args.from ? new Date(args.from).getTime() : Date.now() - 3600e3; + const to = args.to ? new Date(args.to).getTime() : Date.now() + 60e3; + const grep = args.grep && args.grep !== true ? new RegExp(String(args.grep), "i") : null; + const priMax = args.pri ? Number(args.pri) : 7; + const fix = !!args["fix-clock"]; // 默认不校正:NTP 偏差是"系统事实",掩盖它反而会误导溯源 + const all = []; + for (const h of hosts) { + const hs = (st.hosts || {})[h] || {}; + const off = fix ? (hs.ntpOffsetMs || 0) : 0; + for (const d of listDays(h)) { + for await (const r of readShard(h, d)) { + const t = r.ts - off; + if (t < from || t > to) continue; + if (r.pri > priMax) continue; + if (grep && !grep.test(r.msg)) continue; + all.push({ ...r, ts: t }); + } + } + } + all.sort((a, b) => a.ts - b.ts || a.host.localeCompare(b.host)); + const out = args.out && args.out !== true ? args.out : null; + const lines = all.map((r) => + `${tsLocal(r.ts)}.${String(r.ts % 1000).padStart(3, "0")} [${r.host.padEnd(3)}/${(r.unit || "-").replace(/\.service|\.scope/g, "").padEnd(16)}] pri=${r.pri} ${r.msg}`); + if (out) { writeFileSync(out, lines.join("\n") + "\n", "utf8"); console.log(`已写 ${all.length} 行 ⇒ ${out}`); } + else { console.log(`时间线 ${all.length} 行(${tsLocal(from)} → ${tsLocal(to)})`); for (const l of lines) console.log(l); } + const ntpNote = hosts.map((h) => { + const hs = (st.hosts || {})[h] || {}; + return `${h}: NTP=${hs.ntpSynced || "?"}${hs.ntpOffsetMs === undefined || hs.ntpOffsetMs === null ? "" : `(偏差${hs.ntpOffsetMs}ms)`}`; + }).join(" · "); + console.log(`# 时钟状态 ${ntpNote}${fix ? " · 已按偏差校正(--fix-clock)" : " · 未校正(原始系统时间戳)"}`); +} + +// ───────────────────────────── watch(实时巡检) ───────────────────────────── + +async function cmdWatch(args) { + const since = args.since ? dur2sec(args.since) : 900; // 默认 15 分钟窗口 + const hosts = args.host && args.host !== true ? String(args.host).split(",") : Object.keys(loadHosts()); + // 默认先增量续拉(cursor 只搬新行,代价极小)⇒ 挂 automation 时只需这一条命令 + if (!args["no-collect"]) { + try { await cmdCollect({ hosts: args.host, quiet: !args.verbose, json: args.json }); } + catch (e) { console.error(`⚠ 预拉取失败(继续按现有归档巡检): ${e.message}`); } + } + const from = Date.now() - since * 1000; + const counts = new Map(); // ruleId -> [{host,ts,msg}] + const byUnit = new Map(); + let total = 0, priErr = 0; + for (const h of hosts) { + for (const d of listDays(h)) { + for await (const r of readShard(h, d)) { + if (r.ts < from) continue; + total++; + if (r.pri <= 3) priErr++; + byUnit.set(`${h}/${r.unit}`, (byUnit.get(`${h}/${r.unit}`) || 0) + 1); + if (NOISE_UNITS.test(r.unit)) continue; + for (const [id, , re] of SEV_RULES) { + if (re.test(r.msg)) { + if (!counts.has(id)) counts.set(id, []); + const arr = counts.get(id); + if (arr.length < 200) arr.push({ host: h, ts: r.ts, msg: r.msg, unit: r.unit }); + } + } + } + } + } + const maxHit = args.threshold ? Number(args.threshold) : 0; // 0 = 命中即 FAIL(告警模式) + const rows = []; + for (const [id, name] of SEV_RULES) { + const hits = counts.get(id) || []; + const verdict = hits.length > maxHit ? "FAIL" : "PASS"; + const sample = hits.slice(-1)[0]; + rows.push([id, name, verdict, String(hits.length), + sample ? `${tsLocal(sample.ts)} ${sample.host} ${sample.msg.slice(0, 90)}` : "—"]); + } + console.log(`巡检窗口 = 近 ${Math.round(since / 60)} 分钟 · 归档内 ${total} 行 · pri≤3 计 ${priErr} 行`); + printTable(["判据", "含义", "结果", "命中", "最近一条"], rows, args); + const fails = rows.filter((r) => r[2] === "FAIL"); + if (args.report) { + const f = path.join(ROOT, "reports", `watch-${dayStr()}-${new Date().getHours()}${pad(new Date().getMinutes())}.md`); + mkdirSync(path.dirname(f), { recursive: true }); + writeFileSync(f, [ + `# 日志巡检报告 ${tsLocal(Date.now())}`, "", + `窗口 = 近 ${Math.round(since / 60)} 分钟 | 归档行数 ${total} | pri≤3 = ${priErr}`, "", + "| 判据 | 含义 | 结果 | 命中 | 最近一条 |", "|---|---|---|---|---|", + ...rows.map((r) => `| ${r[0]} | ${r[1]} | ${r[2]} | ${r[3]} | ${String(r[4]).replace(/\|/g, "\\|")} |`), "", + ].join("\n"), "utf8"); + console.log(`报告 ⇒ ${f}`); + } + if (fails.length) process.exitCode = 2; +} + +// ───────────────────────────── 输出 ───────────────────────────── + +function printTable(head, rows, args) { + if (args.json) { console.log(JSON.stringify(rows.map((r) => Object.fromEntries(head.map((h, i) => [h, r[i]]))), null, 1)); return; } + const w = head.map((h, i) => Math.max(strw(h), ...rows.map((r) => strw(String(r[i]))))); + const line = (cells) => cells.map((c, i) => String(c) + " ".repeat(Math.max(0, w[i] - strw(String(c))))).join(" "); + console.log(line(head)); + console.log(w.map((n) => "─".repeat(n)).join(" ")); + for (const r of rows) console.log(line(r)); +} +/** 中文按 2 列宽计 */ +function strw(s) { let n = 0; for (const ch of String(s)) n += ch.charCodeAt(0) > 0x2e7f ? 2 : 1; return n; } + +// ───────────────────────────── main ───────────────────────────── + +const argv = process.argv.slice(2); +const cmd = argv[0]; +const args = parseArgs(argv.slice(1)); +const cmds = { collect: cmdCollect, ls: cmdLs, stats: cmdStats, q: cmdQ, timeline: cmdTimeline, watch: cmdWatch, prune: cmdPrune }; + +if (!cmd || cmd === "help" || args.help) { + console.log(`dshlog — DSH 多节点日志采集 / 巡检 / 溯源(归档根 ${ROOT}) + + collect [--hosts 47,106] [--since 1h [--chunk 30m]|--full] [--json] + 拉取归档:无 --since = cursor 续拉(增量、不重不漏);有 --since = 回填(自动去重) + ls 列出归档分片 + stats [--detail] 归档与末次拉取状态 + q <词|/正则/> [--since 6h] [--host 47] [--unit dshs] [--limit 100] [--json] + timeline [--from ISO] [--to ISO] [--host ..] [--grep ..] [--pri 3] [--out f] [--fix-clock] + watch [--since 15m] [--threshold N] [--report] [--no-collect] [--json] + 巡检:默认先增量续拉再判据;有 FAIL ⇒ 退出码 2(可被自动化当判据) + prune [--keep 3] [--apply] 归档保留策略(默认 3 天 · 与服务器同口径 · 干跑) + +环境变量:DSHLOG_ROOT(归档根,默认 E:/dsh-logs)· DSHLOG_VERBOSE=1(打印每块进度) +`); + process.exit(0); +} +if (!cmds[cmd]) { console.error(`未知子命令: ${cmd}`); process.exit(2); } +await cmds[cmd](args); diff --git a/scripts/overlay-entropy.cjs b/scripts/overlay-entropy.cjs new file mode 100644 index 0000000..eabb5b4 --- /dev/null +++ b/scripts/overlay-entropy.cjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node +/** + * overlay-entropy.cjs —— 覆盖网络「低熵块治理」测熵探针(序㊺ · 只读 · 零第三方依赖) + * + * 口径(M1-a..d,见 `04-调整方案/133-覆盖网络-低熵块治理方案-C域分离与D非确定性.md` §2.2): + * M1-a 种类数 / 重复率 —— 唯一块 id 数 ÷ 总块数 + 完全重复块(同 id ≥ 2)清单 + * M1-b 低熵块数 / 体积 —— 逐块经验 Shannon 熵(字节分布, bit/byte)+ H 直方图 + * M1-c 占首屏包比例 —— 低熵块字节 ÷ 总字节 + * M1-d 子窗口熵(反向腿) —— 滑窗扫整份,给出低熵窗口**连续段的字节尺寸分布** + * + * 🔴 两条硬口径(⛔ 不许改): + * 1. **切分必须调用仓库里那份 `chunkify`**(默认 `../lib/net/relay/content/chunker.js`, + * 由 `src/net/relay/content/chunker.ts` 编译而来)—— ⛔ 不复刻算法(复刻=双源)。 + * 可用 `OVERLAY_CHUNKER=` 覆盖(例如把探针与 chunker.js 一起放到取证目录)。 + * 2. **本探针只读本地文件**:⛔ 不 ssh、⛔ 不碰网络、⛔ 不写生产路径 + * (取数由调用方完成 —— 见 `_tmp_seq45/` 的取数脚本,夹具模式封闭)。 + * + * 用法: + * node scripts/overlay-entropy.cjs --in [--parts ] [--json] + * [--window 4096] [--stride 4096] [--threshold 4.0] [--out ] + */ +'use strict' + +const fs = require('node:fs') +const path = require('node:path') +const { pathToFileURL } = require('node:url') + +function arg(name, dflt) { + const i = process.argv.indexOf(`--${name}`) + return i === -1 ? dflt : process.argv[i + 1] +} +const has = (name) => process.argv.includes(`--${name}`) + +/** 经验 Shannon 熵(bit/byte)。空输入 ⇒ 0。 */ +function entropyOf(buf) { + if (buf.length === 0) return 0 + const counts = new Uint32Array(256) + for (let i = 0; i < buf.length; i += 1) counts[buf[i]] += 1 + let h = 0 + const n = buf.length + for (let b = 0; b < 256; b += 1) { + const c = counts[b] + if (c === 0) continue + const p = c / n + h -= p * Math.log2(p) + } + return h +} + +/** H 直方图桶(0.5 bit/byte 一档,末档到 8)。 */ +function histKey(h) { + const lo = Math.min(7.5, Math.floor(h * 2) / 2) + return `${lo.toFixed(1)}-${(lo + 0.5).toFixed(1)}` +} + +/** 低熵窗口的**连续段**(非重叠窗口,stride = window):返回段字节尺寸清单。 */ +function lowEntropyRuns(buf, window, stride, threshold) { + const sizes = [] + let run = 0 + for (let off = 0; off + window <= buf.length; off += stride) { + const h = entropyOf(buf.subarray(off, off + window)) + if (h <= threshold) run += stride + else if (run > 0) { sizes.push(run); run = 0 } + } + if (run > 0) sizes.push(run) + return sizes +} + +function bucketBytes(sizes) { + const edges = [4096, 16384, 65536, 262144, 1048576, Infinity] + const labels = ['<=4KiB', '4KiB-16KiB', '16KiB-64KiB', '64KiB-256KiB', '256KiB-1MiB', '>1MiB'] + const out = {} + for (const l of labels) out[l] = 0 + for (const s of sizes) { + for (let i = 0; i < edges.length; i += 1) { + if (s <= edges[i]) { out[labels[i]] += 1; break } + } + } + return out +} + +async function main() { + const inp = arg('in') + if (inp === undefined) throw new Error('缺少 --in <文件>') + const window = Number(arg('window', '4096')) + const stride = Number(arg('stride', String(window))) + const threshold = Number(arg('threshold', '4.0')) + + const chunkerPath = process.env.OVERLAY_CHUNKER + ?? path.join(__dirname, '..', 'lib', 'net', 'relay', 'content', 'chunker.js') + const chunker = await import(pathToFileURL(chunkerPath).href) + const { chunkify, DEFAULT_BLOCK_SIZE } = chunker + if (typeof chunkify !== 'function') throw new Error(`无法从 ${chunkerPath} 取到 chunkify`) + + const buf = fs.readFileSync(inp) + const cut = chunkify(buf) // ⛔ 不传 blockSize ⇒ 用代码常量 DEFAULT_BLOCK_SIZE + + // ---- M1-a ---- + const freq = new Map() + for (const c of cut.chunks) freq.set(c.id, (freq.get(c.id) ?? 0) + 1) + const duplicates = [...freq.entries()].filter(([, n]) => n >= 2).map(([id, n]) => ({ id, count: n })) + + // ---- M1-b ---- + const perBlock = cut.chunks.map((c) => ({ + index: c.index, + bytes: c.bytes.length, + entropy: Number(entropyOf(c.bytes).toFixed(4)), + })) + const histogram = {} + for (const b of perBlock) { + const k = histKey(b.entropy) + histogram[k] = (histogram[k] ?? 0) + 1 + } + const lowBlocks = perBlock.filter((b) => b.entropy <= threshold) + const lowBytes = lowBlocks.reduce((s, b) => s + b.bytes, 0) + + // ---- M1-d ---- + const runs = lowEntropyRuns(buf, window, stride, threshold) + + // ---- S2:按 combo 边界切片(每份 combo = 一份独立分发内容)---- + let s2 = null + const partsFile = arg('parts') + if (partsFile !== undefined) { + const parts = JSON.parse(fs.readFileSync(partsFile, 'utf8')) + const contents = [] + let off = 0 + for (const p of parts) { + const n = Number(p.bytes) || 0 + if (n === 0) { contents.push({ order: p.order, bytes: 0, blocks: 0, note: `zero-bytes(code=${p.code})` }); continue } + const c = chunkify(Buffer.from(buf.subarray(off, off + n))) + off += n + const es = c.chunks.map((x) => Number(entropyOf(x.bytes).toFixed(4))) + contents.push({ + order: p.order, + bytes: n, + blocks: c.chunks.length, + contentId: c.contentId, + blockEntropies: es, + min: Math.min(...es), + max: Math.max(...es), + lowEntropyBlocks: es.filter((e) => e <= threshold).length, + }) + } + s2 = { + contents, + lowEntropyContents: contents.filter((c) => c.lowEntropyBlocks > 0).map((c) => c.order), + singleBlockContents: contents.filter((c) => c.blocks === 1).length, + coveredBytes: off, + } + } + + const report = { + generatedAt: new Date().toISOString(), + blockSize: DEFAULT_BLOCK_SIZE, + threshold, + window, + stride, + s1: { + source: inp, + bytes: buf.length, + blocks: cut.chunks.length, + uniqueBlockIds: freq.size, + duplicateRatePct: Number(((1 - freq.size / cut.chunks.length) * 100).toFixed(4)), + duplicates, + lowEntropyBlocks: lowBlocks.length, + lowEntropyBytes: lowBytes, + lowEntropyRatioPct: Number(((lowBytes / buf.length) * 100).toFixed(6)), + minBlockEntropy: Math.min(...perBlock.map((b) => b.entropy)), + maxBlockEntropy: Math.max(...perBlock.map((b) => b.entropy)), + histogram, + perBlock, + }, + m1d: { + windows: Math.floor(buf.length / window), + lowEntropyRuns: runs.length, + runBytesTotal: runs.reduce((s, x) => s + x, 0), + runBytesMin: runs.length ? Math.min(...runs) : 0, + runBytesMax: runs.length ? Math.max(...runs) : 0, + sizeHistogram: bucketBytes(runs), + topRuns: [...runs].sort((a, b) => b - a).slice(0, 10), + }, + s2, + } + + const out = arg('out') + if (out !== undefined) fs.writeFileSync(out, JSON.stringify(report, null, 2)) + if (has('json')) { + process.stdout.write(JSON.stringify(report, null, 2) + '\n') + return + } + + const L = [] + L.push(`=== M1 读数(blockSize=${DEFAULT_BLOCK_SIZE} threshold=${threshold} window=${window} stride=${stride})===`) + L.push(`[S1] ${inp} bytes=${buf.length} blocks=${cut.chunks.length} unique=${freq.size} dupRate=${report.s1.duplicateRatePct}%`) + L.push(`[M1-a] 完全重复块=${duplicates.length}`) + L.push(`[M1-b] 低熵块=${lowBlocks.length} 低熵字节=${lowBytes} Hmin=${report.s1.minBlockEntropy} Hmax=${report.s1.maxBlockEntropy}`) + L.push(`[M1-b] H 直方图=${JSON.stringify(histogram)}`) + L.push(`[M1-c] 低熵字节占比=${report.s1.lowEntropyRatioPct}%`) + L.push(`[M1-d] 低熵窗口段=${runs.length} 段字节total=${report.m1d.runBytesTotal} min=${report.m1d.runBytesMin} max=${report.m1d.runBytesMax}`) + L.push(`[M1-d] 段尺寸直方图=${JSON.stringify(report.m1d.sizeHistogram)}`) + L.push(`[M1-d] 最大10段=${JSON.stringify(report.m1d.topRuns)}`) + if (s2 !== null) { + L.push(`[S2] 内容数=${s2.contents.length} 单块内容=${s2.singleBlockContents} 低熵内容=${s2.lowEntropyContents.length} 覆盖字节=${s2.coveredBytes}`) + for (const c of s2.contents) { + L.push(` #${c.order} bytes=${c.bytes} blocks=${c.blocks} Hmin=${c.min ?? '-'} low=${c.lowEntropyBlocks}`) + } + } + process.stdout.write(L.join('\n') + '\n') +} + +main().catch((e) => { process.stderr.write(`overlay-entropy 失败: ${e.message}\n`); process.exit(1) }) diff --git a/scripts/overlay-probe.cjs b/scripts/overlay-probe.cjs index a1f61b1..a0d7b55 100644 --- a/scripts/overlay-probe.cjs +++ b/scripts/overlay-probe.cjs @@ -63,6 +63,31 @@ * ⛔ 零新增暴露面、⛔ 零新增参数键)。⚠️ **只在 47 视角为空时**才去读(正常态**零额外 ssh**)。 * 夹具模式用 `--peer-status-fixture <对端 /status 原文>`;⛔ 不给 ⇒ 记"不可判(夹具模式)"。 * + * ## 🆕 观测面**并集**(序㊾:⛔ 单看 47 会把"合法拓扑态"判成红) + * `OBS-01` / `OBS-08` / `OBS-09` 的绿**取决于 106 worker 挂在哪台中继** —— worker 通道按**抖动**换址 + * (`[relay-switch]`)⇒ 一旦落到 **106 自家中继**,47 视角就是 + * `used=1 / endpoints=[w-106: offline, w-106:<实例口> offline]` ⇒ `OBS-01` FAIL、`OBS-08` FAIL、 + * `OBS-09` SKIP —— 三条**全是假红 / 假 SKIP**(客户端好好的,只是"不在我这一台")。 + * + * 口径 = **按 `hostId`(含 `network`)合并两台中继的视图**: + * + * eps = merge(47.endpoints[], 106.endpoints[]) // 键 = `network:hostId:port`;`online` 取**或**; + * `localPort` 取**在线那一侧**的(离线条目的落点已失效) + * used = |{ network/hostId | s ∈ 47.sessions[] ∪ 106.sessions[] }| // ⛔ **不是求和** —— 同一节点在 + * 两台都残留会话/条目时求和会**重复计数**(那是另一方向的假绿) + * reg = deriveInstanceEndpoints(eps, agentPorts) // `OBS-09` 的派生改用并集(⛔ 判据本身一字不改) + * + * 🔴 **实例探活必须回到"那一台"上做**:`localPort` 是**中继机回环**落点 ⇒ 并集里来自 106 的端点 + * 得 ssh 到 106 探(47 上那个口**根本不存在**)⇒ 否则必然 `000` ⇒ **假红**。 + * 🔴 **只并这三项的数据源**:`OBS-11` 的 `derived`(= 47 自己的监听面)/`OBS-02`(47 自身容量自洽)/ + * `OBS-13`·`OBS-16`(47 的 `counters`)**一律保持 47 视角** —— 并集只消除"看不见", + * ⛔ 不放宽判据、⛔ 不改任何阈值(**真 FAIL 不许被糊成 PASS**)。 + * 🔴 **`OBS-16` 的 Δ 不受影响**:对端那份是**另算的一次独立采样**(读的是 106 的 `counters`)⇒ + * ⛔ 不进 `ΔstatusHits` 算式;且对 47 `/status` 的读取次数**一次都没变**(仍是三次,复用已采到的那份)。 + * ⚠️ 真机模式**无条件**读一次对端(⇒ 并集是**完整**的);⛔ 不做"缺什么补什么"的按需读 —— + * 那会留下一个**假绿**口子(106 上多一台 47 不知道的节点时,47 视角照样绿)。 + * ⚠️ 对端读不回来 ⇒ **不硬失败**,回落 47 视角并**强制留痕**「并集不可取证(…)⇒ 仅 47 视角」。 + * * ## 🆕 `OBS-16` 的**门窗口判据**(序㉑ · 在册缺陷 P-1 的验收) * `OBS-13` 的 `ΔstatusHits ≥ 1` 只证明"计数器没卡死"——它由**探针自己两次读**即满足,⛔ 证明不了 * "订阅生效期间轮询停了"(旧判据下门 95% 时间开着、轮询照旧在跑,而 `OBS-13` **仍然全绿** ⇒ @@ -544,6 +569,32 @@ function remoteFacts(r, sshPort, target, probePairs) { return out } +/** + * 只取「实例面 HTTP 码」的一次 ssh(序㊾)。 + * + * 为什么不能复用 `remoteFacts()`:并集里**属于对端中继**的端点,其回环落点**只在对端那台机上存在** + * ⇒ 必须 ssh 到**那一台**探;而 `remoteFacts()` 顺带取的 `ss` / `nft` / 门户码 / relay RSS + * **只对 47 有意义**(拉到 106 上会造出另一套"看起来有数据、其实判错对象"的读数)。 + * 键名与 `remoteFacts()` 一致(`instcode_`)⇒ 调用方把两次结果并进同一个 map 即可。 + * ⛔ 返回值里**没有**该端口 = 取数失败 ⇒ 由判据 FAIL 并点名(⛔ 此处不兜底成 `000`)。 + * @param {{port:number, localPort:number}[]} pairs 归属**该中继**的在册实例端点 + */ +function remoteInstanceCodes(r, sshPort, target, pairs) { + const host = r.need('RELAY_BIND') + const cmd = pairs + .map( + (p) => + `echo "instcode_${p.port}=$(curl -s -o /dev/null -w '%{http_code}' --http1.1 http://${host}:${p.localPort}/)"`, + ) + .join('; ') + const out = new Map() + for (const line of ssh(sshPort, target, cmd, r.num('SSH_TIMEOUT_MS')).split(/\r?\n/)) { + const i = line.indexOf('=') + if (i > 0) out.set(line.slice(0, i), line.slice(i + 1).trim()) + } + return out +} + /** * 派生「在册实例端点」——**唯一来源 = relay 自身 `/status.endpoints[]`**。 * @@ -584,6 +635,68 @@ function parseInstanceFixture(value) { return { map, error: '' } } +/** + * 端点表**并集**(序㊾)—— 两台 relay **各自只看得见挂在自己身上的客户端** ⇒ 单看一台必然假红。 + * + * 口径(三件事,缺一件就会造出另一类误判): + * ① 键 = **`network:hostId:port`** —— 同一 `hostId` 在不同网络下是两条(relay 自己就按 + * `network/hostId` 索引会话);只按 `hostId:port` 去重会把两个网络**并成一条**。 + * ② `online` 取**或** —— "在任何一台看来在线"就是在线(离线那侧只是**没挂在我这儿**)。 + * ③ `localPort` 取**在线那一侧**的 —— 离线条目里的回环口是**已失效**的落点(拿它探活必得 `000`)。 + * `from` 记「这条最终采信谁的 `localPort`」⇒ 实例探活**必须回到那一台机器**上做(见调用点)。 + * ⛔ 本函数**只做合并**,⛔ 不判任何东西:判据仍由调用方按原阈值出 PASS/FAIL。 + * @param {any[]} list47 47 视角 `/status.endpoints[]` + * @param {any[]} list106 对端中继 `/status.endpoints[]`(读不回来时传 `undefined`) + * @returns {{ep:any, from:'47'|'106'}[]} 合并后的端点表(顺序:先 47 侧、后仅 106 有的) + */ +function mergeEndpoints(list47, list106) { + const keyOf = (e) => `${e.network}:${e.hostId}:${e.port}` + const byKey = new Map() + const put = (e, from) => { + if (e === null || typeof e !== 'object') return + const key = keyOf(e) + const cur = byKey.get(key) + if (cur === undefined) { + byKey.set(key, { ep: { ...e }, from }) + return + } + // 只在「新的一侧在线、已记的离线」时替换 ⇒ `localPort` 取自**在线那一侧**;同态时保留先来的(47 优先)。 + if (cur.ep.online !== true && e.online === true) byKey.set(key, { ep: { ...e }, from }) + } + for (const e of Array.isArray(list47) ? list47 : []) put(e, '47') + for (const e of Array.isArray(list106) ? list106 : []) put(e, '106') + return [...byKey.values()] +} + +/** + * 在册节点数的**并集**(序㊾):relay 的 `capacity.used` = **本机** `sessions.size` + * ⇒ 单看一台会把挂在另一台上的节点**漏算**(`OBS-01` 假红)。 + * + * 口径 = 按 **`network/hostId`** 去重后的**并集基数**。🔴 ⛔ **不是 `a.used + b.used`** —— + * 同一节点在两台都残留会话时求和会**重复计数**(那是另一方向的**假绿**)。 + * ⚠️ 任一侧 `sessions[]` 不可用(对端读不回来 / 结构异常)⇒ 记 `exact=false` 并**回落** + * 到 `max(各侧 capacity.used)`(= 退化成单侧口径,⛔ 不假装是并集)。 + * @returns {{used:number, exact:boolean}} + */ +function unionUsed(status47, status106) { + const sides = [status47, status106] + const keys = new Set() + let complete = true + for (const s of sides) { + const ses = (s ?? {}).sessions + if (!Array.isArray(ses)) { + complete = false + continue + } + for (const x of ses) if (x !== null && typeof x === 'object') keys.add(`${x.network}/${x.hostId}`) + } + if (complete) return { used: keys.size, exact: true } + const nums = sides + .map((s) => Number(((s ?? {}).capacity ?? {}).used)) + .filter((n) => Number.isFinite(n)) + return { used: nums.length === 0 ? Number.NaN : Math.max(...nums), exact: false } +} + function readFixture(file) { if (!fs.existsSync(file)) throw new Error(`夹具不存在:${file}`) return fs.readFileSync(file, 'utf8') @@ -602,8 +715,9 @@ function usage() { ' 🔴 序㊲ 起口径 = **按端口映射**(如 "--instance-fixture 21001=401");\n' + ' ⛔ 旧的位置式 "<本机码>,<对端码>" 已退役 ⇒ 给了**直接报错退出**;\n' + ' ⛔ 派生端口缺码 ⇒ 在册仍判 ⇒ FAIL 并点名(契约面,⛔ 不静默放行)\n' + - ' --peer-status-fixture <对端中继 /status 原文> ⬅️ 只有「47 视角端点表为空」的**可分**需要\n' + - ' (OBS-08 的空表归类 / OBS-09 的 SKIP 留痕;缺省 ⇒ 记"不可判(夹具模式)")\n' + + ' --peer-status-fixture <对端中继 /status 原文> ⬅️ 🆕 序㊾:**观测面并集的对端那一半**\n' + + ' (OBS-01 / OBS-08 / OBS-09 的并集数据源 + OBS-08「空表可分」的判别源;\n' + + ' 缺省 ⇒ 记"并集不可取证(夹具模式未给 …)"+ 仅按 47 视角判)\n' + ' --content-fixture ⬅️ 只有 OBS-17 需要(内容面判别器);缺省 ⇒ 无 content 块 ⇒ FAIL 并点名\n' + // 序㉖:OBS-19(抖动块)/ OBS-20(容量余量)**不需要新参数** —— 数据源就是 `--status-fixture`/真机 `/status`。 ' 🆕 序㉖:OBS-19(`status.jitter` 结构+口径)/ OBS-20(`capacity.utilPct < utilMaxPct`)复用上面的 /status 源\n' + @@ -631,7 +745,11 @@ function usage() { ' --direct-fixture <直连自检读数 JSON>\n' + ' (producer = `node scripts/overlay-direct-probe.cjs selfcheck --json`;\n' + ' 真机 = `cat `;**文件不存在 ⇒ 三条一起 SKIP + 留痕**;\n' + - ' 🔴 夹具模式缺本夹具 ⇒ SKIP + 留痕,⛔ 不去 ssh(序㊳ 的封闭性纪律))\n' + ' 🔴 夹具模式缺本夹具 ⇒ SKIP + 留痕,⛔ 不去 ssh(序㊳ 的封闭性纪律))\n' + + // 序㊻:OBS-29(块 id 域分离 C)—— 数据源 = **本机进程内自检**(直取 lib)⇒ 无新参数、⛔ 零 ssh。 + ' 🆕 序㊻:OBS-29(块 id per-network 域分离 C 真生效)**不需要新参数** —— 数据源 = 本机进程内自检\n' + + ' (正腿:跨网必不同且都非裸哈希 / 同网必相同;负腿:去 network 维度必红、取空必红)\n' + + ' ⚠️ 它判的是**实现与装配**;真机腿(content.blockIdKeyed + 两机指纹一致)待部署后补\n' ) } @@ -719,6 +837,63 @@ function main() { * 窗口必须真的够长(≥ 若干倍轮询周期),否则"轮询还在跑"与"轮询停了"在计数上分不开。 */ let status3 + /** + * ── 对端(106)中继 `/status` 的**唯一取数入口**(序㊾)── + * + * 两台中继**各自只看得见挂在自己身上的客户端** ⇒ `OBS-01` / `OBS-08` / `OBS-09` 必须看**并集**。 + * 这里取一次、缓存一次 ⇒ 下面的"并集"与 `OBS-08` 的"空表可分"**共用同一份**(⛔ 零重复 ssh)。 + * + * - 真机:**一次 ssh** 取回 `/status` 原文 + relay 单元活性(`__RELAY_ACTIVE__` 哨兵 —— 它把 + * "中继未运行"与"读取失败"分开)。 + * - 夹具:`--peer-status-fixture`;⛔ 缺省 ⇒ **不 ssh**,记"并集不可取证"(⚠️ 秩序不可颠倒: + * `else if (fixture)` 必须排在 `else { ssh }` **之前**,否则夹具结论里会混进生产读数)。 + * @returns {{ok:boolean, status?:any, active?:string, why?:string}} + */ + let peerView + const readPeerView = () => { + if (peerView !== undefined) return peerView + const target106 = r.need('SSH_TARGET_106') + const fail = (why) => { + peerView = { ok: false, why } + return peerView + } + let raw + if (peerStatusFx !== undefined) { + try { + raw = readFixture(peerStatusFx) + } catch (err) { + return fail(`夹具读取失败(⛔ 与"确实没挂"可分):${err.message}`) + } + } else if (fixture) { + // ⛔ 夹具模式**绝不 ssh**(否则"夹具结论"里混进生产读数)。 + return fail('夹具模式未给 `--peer-status-fixture`(⛔ 不去 ssh)') + } else { + // 一次 ssh 取回**两件事**:`/status` 原文 + relay 单元活性(后者用于把"未运行"与"读取失败"分开)。 + try { + raw = ssh( + r.num('SSH_PORT'), + target106, + `curl -s ${r.need('RELAY_STATUS_URL')}; echo "__RELAY_ACTIVE__=$(systemctl is-active ${r.need('RELAY_UNIT_NAME')} 2>/dev/null || true)"`, + r.num('SSH_TIMEOUT_MS'), + ) + } catch (err) { + return fail(`对端中继(${target106})ssh 读取失败(⛔ 与"确实没挂"可分):${err.message}`) + } + } + const marker = raw.lastIndexOf('__RELAY_ACTIVE__=') + const active = marker >= 0 ? raw.slice(marker + '__RELAY_ACTIVE__='.length).trim() : 'unknown' + const body = marker >= 0 ? raw.slice(0, marker) : raw + try { + peerView = { ok: true, status: JSON.parse(body), active } + return peerView + } catch (err) { + return fail( + active === 'active' + ? `对端中继(${target106})在运行但 /status 解析失败(⛔ 读取失败,与"确实没挂"可分):${err.message}` + : `对端中继(${target106})未给出可解析的 /status 且单元 is-active=${active}:${err.message}`, + ) + } + } let facts if (fixture) { process.stderr.write('⚠️ FIXTURE 本次为**夹具模式**:未连接任何远端,结论不得当生产判据\n') @@ -776,21 +951,44 @@ function main() { process.stderr.write(`❌ 第三次取 /status 失败:${err.message}\n`) return EXIT_USAGE } - // 序㊲:实例面取样点 = **从 `/status.endpoints[]` 派生**(⛔ 不再读表内固定端口 ⇒ 端口漂移不再致假 SKIP)。 - const preEps = Array.isArray(status.endpoints) ? status.endpoints : [] - const probePairs = deriveInstanceEndpoints(preEps, agentPorts).map((e) => ({ - port: Number(e.port), - localPort: Number(e.localPort), - })) + /** + * 🆕 序㊾:对端中继视图 —— **放在第三次采样之后**。 + * ⚠️ 位置的三个理由:① 它读的是 **106 的 `counters`**,⛔ 不进 `OBS-16` 的 `ΔstatusHits` 算式; + * ② `OBS-16` 的门窗口 = `(status2, status3]`,这段里**只有 `sleepSync`** ⇒ ⛔ 不进窗口; + * ③ 对 47 `/status` 的读取次数**一次都没变**(仍是三次,复用已采到的那份)。 + */ + const peerPre = readPeerView() + // 序㊲ 的派生口径不变(仍在**并集**上做):⛔ 不读表内固定端口 ⇒ 端口漂移不致假 SKIP。 + const merged = mergeEndpoints(status.endpoints, peerPre.ok ? peerPre.status.endpoints : undefined) + /** + * 🔴 实例探活**必须回到"那一台"上做**:`localPort` 是**中继机回环**落点 ⇒ 并集里归属 106 的端点, + * 其落点**只存在于 106**(在 47 上探必然 `000` = **假红**)⇒ 按 `from` 分机。 + */ + const pairsOf = (from) => + deriveInstanceEndpoints( + merged.filter((m) => m.from === from).map((m) => m.ep), + agentPorts, + ).map((e) => ({ port: Number(e.port), localPort: Number(e.localPort) })) + const pairs47 = pairsOf('47') + const pairs106 = pairsOf('106') try { - facts = remoteFacts(r, sshPort, target, probePairs) + facts = remoteFacts(r, sshPort, target, pairs47) } catch (err) { process.stderr.write(`❌ 远端只读取数失败:${err.message}\n`) return EXIT_USAGE } + // ⚠️ 只在并集里**真有** 106 侧在册实例时才发这次 ssh(正常态 = 零额外取数)。 + // 取不回来 ⇒ **不硬失败**:该端口的码缺席 ⇒ `OBS-09` 判 FAIL 并点名(与 curl 得 `000` 同款)。 + if (pairs106.length > 0) { + try { + for (const [k, v] of remoteInstanceCodes(r, sshPort, r.need('SSH_TARGET_106'), pairs106)) facts.set(k, v) + } catch (err) { + process.stderr.write(`⚠️ 对端(106)实例面取数失败(码缺席 ⇒ OBS-09 会 FAIL 并点名):${err.message}\n`) + } + } // 端口的码读不回来(curl 失败 ⇒ `000`)**不在这里兜底**:留给判据判 FAIL 并点名。 codeByPort = new Map() - for (const p of probePairs) { + for (const p of [...pairs47, ...pairs106]) { const v = Number(facts.get(`instcode_${p.port}`)) if (Number.isFinite(v)) codeByPort.set(p.port, v) } @@ -798,7 +996,23 @@ function main() { const counters = status.counters ?? {} const cap = status.capacity ?? {} + /** + * **47 视角**端点表 —— 🔴 它**仍然是** `OBS-11` 的 `derived`(= **47 自己的**监听面)与 + * `OBS-02`(47 自身容量的自洽性)的数据源 ⇒ ⛔ **不许**被下面的并集替换(并集只消除"看不见")。 + */ const eps = Array.isArray(status.endpoints) ? status.endpoints : [] + /** + * 🆕 序㊾:`OBS-01` / `OBS-08` / `OBS-09` 的**并集**数据源(口径见文件头同名小节)。 + * ⛔ 只换数据源 —— 阈值、派生规则、判据形态**一字未动**(真 FAIL 不许被糊成 PASS)。 + */ + const peerViewNow = readPeerView() + const epsUnion = mergeEndpoints(eps, peerViewNow.ok ? peerViewNow.status.endpoints : undefined).map((m) => m.ep) + const usedUnion = unionUsed(status, peerViewNow.ok ? peerViewNow.status : undefined) + /** + * 并集不完整时**强制留痕**(⛔ 不静默降级):对端读不回来 ⇒ 这三项退化成 47 视角(**可能假红**)。 + * ⚠️ 与之对照:`OBS-08` 的"空表可分"仍会按 §文件头把它判成 FAIL 并点名(⛔ 不静默当绿)。 + */ + const unionNote = peerViewNow.ok ? '' : ` |⚠️ 并集不可取证(${peerViewNow.why})⇒ 本项仅按 47 视角判` const relayBind = r.need('RELAY_BIND') const required = parseList(r.need('LISTEN_REQUIRED')) @@ -807,7 +1021,11 @@ function main() { // 拨号池**不另立键**(⛔ 避免两处漂移):复用既有 `DIAL_POOL_BOUND`,主机取 `RELAY_BIND`。 ranges.push(...parseRanges(r.need('DIAL_POOL_BOUND'), relayBind, r.bad)) const nftAllowed = parseList(r.need('NFT_ALLOW_INBOUND')) - // `derived` 的**唯一来源** = relay 自身 `/status` 的端点回环落点(动态值 ⇒ ⛔ 不许写死进参数表)。 + /** + * `derived` 的**唯一来源** = relay 自身 `/status` 的端点回环落点(动态值 ⇒ ⛔ 不许写死进参数表)。 + * 🔴 序㊾:这里是 **47 视角**的 `eps`、⛔ **不是**并集 —— 它要回答的是「**47 这台机上**有哪些监听 + * 是合法派生出来的」;把 106 的落点并进来等于**凭空放宽** `OBS-11` 的"多出"判据。 + */ const derived = new Set(eps.map((e) => `${relayBind}:${e.localPort}`)) // 🆕 序㊱:两个新键**必须在 `r.bad` 检查之前**取出 —— `need()` 是**惰性**的(调用才记账), @@ -838,15 +1056,16 @@ function main() { rows.push(skip || (fixture && judged !== true) ? { id, ok: true, skip: true, text } : { id, ok, text }) /** - * ── 「47 视角端点表为空」的**可分**(序㊲;详见文件头 `OBS-08` 同名小节)── + * ── 「**两台中继都**看不到该客户端」的**可分**(序㊲;详见文件头 `OBS-08` 同名小节)── * * 两个成因**必须分开**(旧口径一律 FAIL ⇒ 其中一个是**假红**): * ⓐ 客户端挂在**另一台中继**上(拓扑态,⛔ 非故障)⇒ `elsewhere` * ⓑ 两台都看不到它的会话 ⇒ 确实没挂客户端 ⇒ `nowhere` * ⓒ 对端中继读不回来 ⇒ 不可判 ⇒ `unknown`(⛔ 不许静默当绿) * - * 🔴 **只在需要时才去读**(47 视角非空的正常态 ⇒ **零额外 ssh**);结果缓存(两个判据共用一次读)。 * 🔴 判别源 = **对端中继自身 `/status`**(回环、只读)⇒ ⛔ 零新增暴露面、⛔ 零新增参数键。 + * 🆕 序㊾:这份取数已收敛到 `readPeerView()` **一处**(与并集共用同一份 ⇒ ⛔ 零重复 ssh); + * ⇒ 本分支现在只在「**并集也为空**」时才走到(单看 47 为空、其实挂在对端 ⇒ 已由并集直接判绿)。 */ let emptyView const classifyEmptyView = () => { @@ -856,42 +1075,10 @@ function main() { emptyView = { kind: 'unknown', trace: `对端中继(${target106})**不可判**:${why}` } return emptyView } - let raw - if (peerStatusFx !== undefined) { - try { - raw = readFixture(peerStatusFx) - } catch (err) { - return unknown(`夹具读取失败(⛔ 与"确实没挂"可分):${err.message}`) - } - } else if (fixture) { - // ⛔ 夹具模式**绝不 ssh**(否则"夹具结论"里混进生产读数)。 - return unknown('夹具模式未给 `--peer-status-fixture`(⛔ 不去 ssh)') - } else { - // 一次 ssh 取回**两件事**:`/status` 原文 + relay 单元活性(后者用于把"未运行"与"读取失败"分开)。 - try { - raw = ssh( - r.num('SSH_PORT'), - target106, - `curl -s ${r.need('RELAY_STATUS_URL')}; echo "__RELAY_ACTIVE__=$(systemctl is-active ${r.need('RELAY_UNIT_NAME')} 2>/dev/null || true)"`, - r.num('SSH_TIMEOUT_MS'), - ) - } catch (err) { - return unknown(`ssh 读取失败(⛔ 与"确实没挂"可分):${err.message}`) - } - } - const marker = raw.lastIndexOf('__RELAY_ACTIVE__=') - const active = marker >= 0 ? raw.slice(marker + '__RELAY_ACTIVE__='.length).trim() : 'unknown' - const body = marker >= 0 ? raw.slice(0, marker) : raw - let peerStatus - try { - peerStatus = JSON.parse(body) - } catch (err) { - return unknown( - active === 'active' - ? `对端中继在运行但 /status 解析失败(⛔ 读取失败,与"确实没挂"可分):${err.message}` - : `对端中继未给出可解析的 /status 且单元 is-active=${active}:${err.message}`, - ) - } + const pv = readPeerView() + if (!pv.ok) return unknown(pv.why) + const active = pv.active + const peerStatus = pv.status const peerSessions = Array.isArray(peerStatus.sessions) ? peerStatus.sessions : [] // 「有会话在声明端口」= 该客户端**确实挂在**对端中继上(agent 面也算 —— 它只说明"实例面还没声明")。 const attached = peerSessions.filter((s) => Array.isArray(s.ports) && s.ports.length > 0) @@ -918,22 +1105,24 @@ function main() { } /** - * ── `OBS-09`:**在册 ⇒ 必须可达**(序 ㊲ 口径修正:判据从事实派生、⛔ 不再依赖表内固定端口)── + * ── `OBS-09`:**在册 ⇒ 必须可达**(序 ㊲ 口径修正 / 🆕 序㊾ 数据源换并集)── * * 两种模式**都要判** ⇒ 单独成函数,⛔ 不塞进 `!fixture` 分支(否则夹具模式证不了 - * "既能判 PASS 也能判 FAIL")。`eps` / `codeSet` / `add` 均已就绪。 + * "既能判 PASS 也能判 FAIL")。`epsUnion` / `codeSet` / `add` 均已就绪。 + * 🔴 序㊾:派生改在**并集**上做(单看 47 ⇒ worker 挂到 106 自家中继时派生为空 ⇒ **假 SKIP**); + * ⛔ 判据本身(在线 + 非 agent 口 ⇒ 必须可达且码 ∈ 集合)**一字未动**。 */ const judgeObs09 = () => { // 「在册」= `/status.endpoints[]` 里 `online === true` 且 **非 agent 端口**的实例端点(⛔ 表值不参与)。 - const reg = deriveInstanceEndpoints(eps, agentPorts) - const agentOnly = eps.filter((e) => e.online === true && agentPorts.has(Number(e.port))).length - const offline = eps.filter((e) => e.online !== true).length + const reg = deriveInstanceEndpoints(epsUnion, agentPorts) + const agentOnly = epsUnion.filter((e) => e.online === true && agentPorts.has(Number(e.port))).length + const offline = epsUnion.filter((e) => e.online !== true).length if (reg.length === 0) { add( 'OBS-09', true, - `在册实例面 无(从 /status.endpoints[] 派生为空:端点表 ${eps.length} 条 = agent ${agentOnly} 条 + 离线 ${offline} 条)` + - ` ⇒ SKIP + 留痕|${classifyEmptyView().trace}`, + `在册实例面 无(从并集端点表派生为空:并集 ${epsUnion.length} 条 = agent ${agentOnly} 条 + 离线 ${offline} 条)` + + ` ⇒ SKIP + 留痕|${classifyEmptyView().trace}${unionNote}`, true, true, ) @@ -951,10 +1140,11 @@ function main() { add( 'OBS-09', unknown.length === 0 && bad.length === 0, - `在册实例面(派生 ${reg.length} 条):${probes.map(show).join(' ')}` + + `在册实例面(并集派生 ${reg.length} 条):${probes.map(show).join(' ')}` + ` (阈值 ∈ {${codeSet.join(',')}})` + `${bad.length > 0 ? ` |❌ ${bad.map(show).join(' ')}` : ''}` + - `${unknown.length > 0 ? ` |❌ 缺码 ${unknown.map((p) => p.port).join(',')}(夹具未给该端口的码 / 真机取不到)` : ''}`, + `${unknown.length > 0 ? ` |❌ 缺码 ${unknown.map((p) => p.port).join(',')}(夹具未给该端口的码 / 真机取不到)` : ''}` + + `${unionNote}`, true, ) } @@ -968,15 +1158,15 @@ function main() { * ⚠️ 数据源就是 `/status` ⇒ **两种模式都要判**(与 `OBS-09` 同规则:夹具模式也要能证"既可 PASS 也可 FAIL")。 */ const judgeObs08 = () => { - if (eps.length === 0) { + if (epsUnion.length === 0) { const cls = classifyEmptyView() if (cls.kind === 'elsewhere') { - add('OBS-08', true, `端点表 0 条 ⇒ SKIP + 留痕|${cls.trace}`, true, true) + add('OBS-08', true, `并集端点表 0 条 ⇒ SKIP + 留痕|${cls.trace}`, true, true) } else { add( 'OBS-08', false, - `端点表 0 条 ⇒ FAIL(${cls.kind === 'nowhere' ? '两台中继都无该客户端会话' : '不可判'})|${cls.trace}`, + `并集端点表 0 条 ⇒ FAIL(${cls.kind === 'nowhere' ? '两台中继都无该客户端会话' : '不可判'})|${cls.trace}`, true, ) } @@ -984,14 +1174,23 @@ function main() { } add( 'OBS-08', - eps.every((e) => e.online === true), - `端点表 ${eps.length} 条 / 离线 ${eps.filter((e) => e.online !== true).length} 条`, + epsUnion.every((e) => e.online === true), + `端点表(并集) ${epsUnion.length} 条 / 离线 ${epsUnion.filter((e) => e.online !== true).length} 条` + + ` |并集: 47 视角 ${eps.length} 条 + 对端 ${peerViewNow.ok ? (Array.isArray(peerViewNow.status.endpoints) ? peerViewNow.status.endpoints.length : 0) : '?'} 条 去重后 ${epsUnion.length} 条` + + `${unionNote}`, true, ) } if (!fixture) { - add('OBS-01', Number(cap.used) >= r.num('MIN_HOSTS'), `在册节点 used=${cap.used} (阈值 ≥ ${r.num('MIN_HOSTS')})`) + add( + 'OBS-01', + usedUnion.used >= r.num('MIN_HOSTS'), + `在册节点 used=${usedUnion.used} (阈值 ≥ ${r.num('MIN_HOSTS')})` + + ` |并集: 47 视角 used=${cap.used} + 对端 used=${peerViewNow.ok ? ((peerViewNow.status.capacity ?? {}).used ?? '?') : '?'}` + + `${usedUnion.exact ? ' ⇒ 按 network/hostId 去重后计' : ' ⇒ ⚠️ sessions[] 不全、回落 max(各侧 used)(⛔ 不假装是并集)'}` + + `${unionNote}`, + ) add( 'OBS-02', Number(cap.max) === r.num('RELAY_MAX_HOSTS') && Number(cap.free) === Number(cap.max) - Number(cap.used), @@ -1187,6 +1386,8 @@ function main() { dHitsGate <= r.num('PRESENCE_GATE_HITS_MAX'), `门窗口 ΔstatusHits=${dHitsGate}(≥ 1 = 探针自身读数|≤ ${r.num('PRESENCE_GATE_HITS_MAX')} = 除探针外**零**命中)` + `,窗口 ${r.num('PRESENCE_GATE_WINDOW_MS')}ms,期间 subs=${counters3.subs}` + + // 🆕 序㊾:对端那份是**另算的独立采样**(读 106 的 counters),⛔ 不进本算式 —— 显式留痕,防日后误并。 + ` |Δ 口径 = **仅 47** 的 counters(对端 106 的采样另算一次、⛔ 不入 Δ;对 47 的读数次数仍为三次)` + `${counters3Ok ? '' : ' ❌ 判别器缺'}`, true, ) @@ -2222,6 +2423,125 @@ function main() { } } + /** + * ── `OBS-29`:**块 id 的 per-network 域分离(C)真生效**(序㊻ · `04-133 §3`)── + * + * 🔴 本行是**判据重裁**的产物:原设计的负腿("稀释源换成确定性派生量 ⇒ 必红")随 **D 被实测 + * 判为"作用面为空"**(序㊺)而**失去对象** ⇒ 重裁为 **C 自己的判据面**(编号仍自 `OBS-29` 起)。 + * + * 判据(⛔ **正腿与负腿缺一不可**): + * ① **正腿 P1**:同字节 + **不同 network** ⇒ 块 id **不同**,**且两侧都 ≠ 裸哈希** + * (⚠️ 只判"不同"不够:两侧**都**回落裸哈希时也会"不同"以外的形态漏判 —— 所以必须同时钉住 + * "都不等于裸哈希",这才是"域分离真的生效"而不是"两边走了两条不同的错路")。 + * ② **正腿 P2**:同 network + 同字节 ⇒ 块 id **相同**(⛔ 只测 P1 会漏掉"**去重被干掉**")。 + * ③ **正腿 P3**:`store` 写侧复算(`putRejected = 0`)+ 读侧复算(`corruptReads = 0`) + * + `chunker#reassemble` 用同一把域密钥能重组回原内容 ⇒ **证调用点无漏改** + * (🔴 漏一处 ⇒ 写侧复算必抛 ⇒ 本腿必红)。 + * ④ 🔴 **负腿 N1(必须真跑、必须具名 `flat-key-collapses`)**:把 `network` 维度**去掉** + * (两侧取同一 network ⇒ 同一把域密钥)⇒ ①的谓词**必须转假**。 + * ⑤ 🔴 **负腿 N2(具名 `empty-key-falls-back-to-bare-hash`)**:`netKey` **取空** + * ⇒ 谓词**必须转假**,且**取空与生效必须可分**(`keyed ≠ bare`)。 + * + * 数据源 = **本机进程内自检**(直取 `lib/net/relay/content/*`)⇒ ⛔ **零 ssh、零生产依赖、 + * 夹具模式照跑**(故 `judged = true`)。⚠️ 它判的是"**实现与装配**",不判"生产已生效"。 + * + * ⏳ **真机腿(登记为回头条件 · 部署那一棒必须补)**:`/status.content` 断言 + * `blockIdKeyed === true`,且 **47 与 106 的 `content.blockIdKeyId` 逐字相同** —— + * 跨机口径不一致 ⇒ 跨机取块**全部**判校验失败(块本身是好的,最难定位的形态)。 + */ + { + const LEG_NAMES = [ + 'P1-跨网必不同且都非裸哈希', + 'P2-同网必相同(去重未丢)', + 'P3-装配面无漏改', + 'N1-flat-key-collapses', + 'N2-empty-key-falls-back-to-bare-hash', + ] + const leg = {} + let hardFail + let detail = '' + try { + const chunker = require('../lib/net/relay/content/chunker.js') + const cmod = require('../lib/net/relay/content/crypto.js') + const rtmod = require('../lib/net/relay/content/runtime.js') + // 🔴 产物必须**已换代**:旧 `lib` 里没有这两个导出 ⇒ FAIL 点名(这正是"装了没生效"的机器判据)。 + if ( + typeof cmod.deriveBlockIdKey !== 'function' || + typeof cmod.ContentCipher !== 'function' || + typeof rtmod.ContentRuntime !== 'function' + ) { + hardFail = '❌ `lib` 产物未换代(缺 `deriveBlockIdKey` / `ContentCipher` / `ContentRuntime`)⇒ 先 `npm run build`' + } else { + const key = Buffer.alloc(cmod.KEY_LEN, 0x5a) + const bytes = Buffer.alloc(1024 * 1024 + 3, 0) + for (let i = 0; i < bytes.length; i += 1) bytes[i] = (i * 31 + 7) & 0xff // 确定性、逐块不同 + const cipher = new cmod.ContentCipher({ groupKey: 'ops|content', epoch: 1, key }) + const rtOf = (network) => new rtmod.ContentRuntime({ network, group: 'content', cipher }) + const idsOf = (network) => rtOf(network).planContent(bytes).ids + const bare = chunker.planOf(bytes).ids + const a = idsOf('ops') + const b = idsOf('u:1') + const same = idsOf('ops') + // ① P1 —— 跨网必不同,且两侧都不是裸哈希(**逐块同位比对**,⛔ 不做跨位比对) + const crossDiff = a.length === b.length && a.every((x, i) => x !== b[i]) + leg['P1-跨网必不同且都非裸哈希'] = + crossDiff && a.every((x, i) => x !== bare[i]) && b.every((x, i) => x !== bare[i]) + // ② P2 —— 同网必相同(去重还在) + leg['P2-同网必相同(去重未丢)'] = same.length === a.length && same.every((x, i) => x === a[i]) + // ③ P3 —— 装配面:写侧复算 + 读侧复算 + 重组位,三处都要过 + const rt = rtOf('ops') + const put = rt.putContent(bytes) + const snap = rt.snapshot() + const readBack = rt.store.get(put.plan[0]) + const parts = new Map() + for (const id of put.plan) parts.set(id, rt.store.get(id)) + let reOk = false + try { + const back = chunker.reassemble(put.plan, parts, { + netKey: rt.netKey, + decode: (x) => cipher.decodeBlock(x), + }) + reOk = back.equals(bytes) + } catch { + reOk = false + } + leg['P3-装配面无漏改'] = + snap.store.putRejected === 0 && snap.store.corruptReads === 0 && readBack !== undefined && reOk + detail = + `写侧 putRejected=${snap.store.putRejected}(须 0)|读侧 corruptReads=${snap.store.corruptReads}(须 0)` + + `|重组位=${reOk ? '逐字节相同 ✓' : '❌'}` + // ④ N1 —— 去掉 network 维度(两侧同一 network ⇒ 同一把域密钥)⇒ 谓词必须转假 + const flatA = idsOf('ops') + const flatB = idsOf('ops') + leg['N1-flat-key-collapses'] = flatA[0] === flatB[0] && flatA[0] !== bare[0] + // ⑤ N2 —— netKey 取空 ⇒ 谓词必须转假,且取空与生效可分 + const kOn = cmod.deriveBlockIdKey(key, 'ops') + leg['N2-empty-key-falls-back-to-bare-hash'] = + chunker.blockIdOf(bytes.subarray(0, 64)) === chunker.blockIdOf(bytes.subarray(0, 64)) && + chunker.blockIdOf(bytes.subarray(0, 64), kOn) !== chunker.blockIdOf(bytes.subarray(0, 64)) + } + } catch (err) { + hardFail = `❌ 自检抛错:${err.message}` + } + if (hardFail !== undefined) { + add('OBS-29', false, `块 id 域分离(C)真生效:${hardFail}`, true) + } else { + const missing = LEG_NAMES.filter((n) => leg[n] !== true) + const ok = missing.length === 0 + add( + 'OBS-29', + ok, + `块 id 域分离(C · per-network keyed hash)腿数 ${LEG_NAMES.length - missing.length}/${LEG_NAMES.length}` + + `${ok ? ' 全绿 ✅' : ` ❌ 缺 ${missing.join(',')}`}|${leg['P1-跨网必不同且都非裸哈希'] ? 'P1 ✅' : 'P1 ❌'}` + + ` ${leg['P2-同网必相同(去重未丢)'] ? 'P2 ✅' : 'P2 ❌'} ${leg['P3-装配面无漏改'] ? 'P3 ✅' : 'P3 ❌'}` + + ` ${leg['N1-flat-key-collapses'] ? 'N1 ✅(⛔ 判据有牙)' : 'N1 ❌'}` + + ` ${leg['N2-empty-key-falls-back-to-bare-hash'] ? 'N2 ✅' : 'N2 ❌'}|${detail}` + + `|⏳ 真机腿(content.blockIdKeyed + 两机 blockIdKeyId 逐字相同)待部署后补`, + true, + ) + } + } + const prefix = fixture ? '⚠️ FIXTURE ' : '' for (const row of rows) { process.stdout.write(`${prefix}${row.skip === true ? 'SKIP' : row.ok ? 'PASS' : 'FAIL'} ${row.id} ${row.text}\n`) } diff --git a/src/net/relay/content/chunker.ts b/src/net/relay/content/chunker.ts index 3313221..627cf75 100644 --- a/src/net/relay/content/chunker.ts +++ b/src/net/relay/content/chunker.ts @@ -36,15 +36,24 @@ * ⚠️ 唯一例外是**解密实现**本身(`crypto.decodeBlock`)——它是**一处实现、两个调用位** * (`source.ts` 链的统一返回点 / 本模块的重组位),同一批字节**只过其中一处**。 * + * ## 🆕 序㊻ · C(域分离):**per-network keyed hash** + * 块 id / 内容 id 从"裸哈希"升级为 **`HMAC-SHA256(netKey, bytes)`**,输出仍取前 + * `BLOCK_ID_HEX_LEN` = 32 hex。`netKey` 是**域密钥**(由 `crypto.ts` 从组密钥按 network + * 维度派生),经与 `encode` / `decode` **同一个注入通道**(`ChunkTransforms.netKey`)传进来。 + * - **治**:跨 network 的 COF / LRI(同一块 id 在 A / B 两个网同时出现 ⇒ 推出跨租户相关性)。 + * - ⛔ **不治**:同一 network 内部持钥者枚举(那是 `04-133 §3.2` 写死的口径)。 + * - 🔴 **缺省不传 / 传空 ⇒ 回落裸 `sha256`** —— 这既是**回滚路径**,也是"既有单测语义不变"的保证。 + * * ## ⛔ 本模块**不做**的事(故意) * - 不做 IO、不读文件、不网络 —— **纯函数**,可单测、可在任何进程里跑; * - 不做压缩;⛔ **不自己实现加解密**(只调用注入的 `encode` / `decode`); + * - 🔑 **不自己派生 `netKey`**(本模块不认识"组密钥"这个概念 —— 派生在 `crypto.ts`); * - 不做"块 → 来源"的映射(那是 `store.ts` / `source.ts` 的事)。 * * @module dshs/net/relay/content/chunker */ -import { createHash } from 'node:crypto' +import { createHash, createHmac } from 'node:crypto' /** * 默认块大小(字节)—— **1 MiB**。 @@ -102,16 +111,51 @@ export interface ChunkTransforms { encode?: (plain: Buffer) => Buffer /** 读侧:`落库字节 → 明文块`(解密)。失败 ⇒ 返回 `undefined`(由调用方**具名**处置)。 */ decode?: (stored: Buffer) => Buffer | undefined + /** + * 🆕 序㊻ · C(域分离):块 id 的**域密钥**(per-network keyed hash)。 + * + * 给了它 ⇒ `blockIdOf` / `contentIdOf` 走 `HMAC-SHA256(netKey, bytes)`(输出仍取前 + * `BLOCK_ID_HEX_LEN` 位);⛔ **缺省 / 空 ⇒ 回落裸 `sha256`**(= 回滚路径)。 + * + * ⚠️ 必须是**确定性**的:它只由「组密钥 + network」派生(`crypto.ts#deriveBlockIdKey`)。 + * ⛔ **不许把 network 之外的随机量塞进来** —— 那会让块 id 次次不同 ⇒ 去重与 peer 命中全废 + * (`E1` 退回 `4.00×`,与 `encode` 非确定性的后果**同一条路径**)。 + */ + netKey?: Buffer } -/** 块 id:`sha256(块字节)` 的前 `BLOCK_ID_HEX_LEN` 位。 */ -export function blockIdOf(bytes: Buffer): string { - return createHash('sha256').update(bytes).digest('hex').slice(0, BLOCK_ID_HEX_LEN) +/** + * 两个 id 函数的**唯一实现**(口径只能有一处)。 + * + * ⚠️ 刻意写成"两条整句分支"而不是"选一个 Hash 对象再链式调用":后者的联合类型在 + * `strict` 下会漂,而这个函数是**全集群块 id 口径的唯一定义处** ⇒ 宁可啰嗦、不要巧。 + */ +function idDigestOf(bytes: Buffer, netKey?: Buffer): string { + const hex = + netKey === undefined || netKey.length === 0 + ? createHash('sha256').update(bytes).digest('hex') + : createHmac('sha256', netKey).update(bytes).digest('hex') + return hex.slice(0, BLOCK_ID_HEX_LEN) } -/** 整份内容的 id:`sha256(全部字节)` 的前 `BLOCK_ID_HEX_LEN` 位。 */ -export function contentIdOf(bytes: Buffer): string { - return createHash('sha256').update(bytes).digest('hex').slice(0, BLOCK_ID_HEX_LEN) +/** + * 块 id。 + * + * - ⛔ 不传 `netKey`(或缺省 / 空)⇒ **裸 `sha256(块字节)`** 前 `BLOCK_ID_HEX_LEN` 位(与序㉔ 逐字一致); + * - ✅ 传了 `netKey` ⇒ **`HMAC-SHA256(netKey, 块字节)`** 前同样位数(序㊻ · C 域分离)。 + */ +export function blockIdOf(bytes: Buffer, netKey?: Buffer): string { + return idDigestOf(bytes, netKey) +} + +/** + * 整份内容的 id。 + * + * - ⛔ 不传 `netKey` ⇒ 裸 `sha256(全部字节)`(与序㉔ 逐字一致); + * - ✅ 传了 `netKey` ⇒ `HMAC-SHA256(netKey, 全部字节)`(序㊻ · C 域分离)。 + */ +export function contentIdOf(bytes: Buffer, netKey?: Buffer): string { + return idDigestOf(bytes, netKey) } /** 块 id 是否合法(纯小写 hex、长度恰好 `BLOCK_ID_HEX_LEN`)。 */ @@ -141,6 +185,7 @@ export function chunkify( throw new Error(`chunker: 块大小必须是正整数,收到 ${String(blockSize)}`) } const encode = transforms?.encode + const netKey = transforms?.netKey const chunks: Chunk[] = [] const seen = new Set() const ids: string[] = [] @@ -149,7 +194,7 @@ export function chunkify( // ⚠️ 必须 `Buffer.from(...)` 复制:`subarray` 是**视图**,原 buffer 被复用时会**内容漂移** // (块已落盘、id 却是按旧内容算的 ⇒ 校验必红且极难定位)。 const own = encode === undefined ? Buffer.from(slice) : encode(Buffer.from(slice)) - const id = blockIdOf(own) + const id = blockIdOf(own, netKey) chunks.push({ id, index, offset, bytes: own }) if (!seen.has(id)) { seen.add(id) @@ -158,7 +203,9 @@ export function chunkify( } // 整体指纹:给了 `encode` ⇒ 挂**落库字节流**(⛔ 否则整份内容的指纹会继续暴露给中继)。 const contentId = - encode === undefined ? contentIdOf(bytes) : contentIdOf(Buffer.concat(chunks.map((c) => c.bytes))) + encode === undefined + ? contentIdOf(bytes, netKey) + : contentIdOf(Buffer.concat(chunks.map((c) => c.bytes)), netKey) return { chunks, contentId, size: bytes.length, ids } } @@ -178,10 +225,11 @@ export function planOf( throw new Error(`chunker: 块大小必须是正整数,收到 ${String(blockSize)}`) } const encode = transforms?.encode + const netKey = transforms?.netKey const ids: string[] = [] for (let offset = 0; offset < bytes.length; offset += blockSize) { const slice = bytes.subarray(offset, Math.min(offset + blockSize, bytes.length)) - ids.push(blockIdOf(encode === undefined ? slice : encode(Buffer.from(slice)))) + ids.push(blockIdOf(encode === undefined ? slice : encode(Buffer.from(slice)), netKey)) } return { ids, size: bytes.length } } @@ -201,13 +249,16 @@ export function planOf( */ export function reassemble(plan: string[], parts: Map, transforms?: ChunkTransforms): Buffer { const decode = transforms?.decode + const netKey = transforms?.netKey const out: Buffer[] = [] for (let index = 0; index < plan.length; index += 1) { const expected = plan[index] if (expected === undefined) throw new Error(`chunker: 计划在第 ${index} 项处断裂`) const got = parts.get(expected) if (got === undefined) throw new Error(`chunker: 缺少块 index=${index} id=${expected}`) - const actual = blockIdOf(got) + // 🔴 复算必须用**同一把域密钥**(`netKey`)。⛔ 漏传 ⇒ 启用域分离后**每个块都判校验失败** + // (现场表现 = "取回的块全被丢弃",而块本身是好的 —— 这正是本线要根治的难定位形态)。 + const actual = blockIdOf(got, netKey) if (actual !== expected) { throw new Error(`chunker: 块校验失败 index=${index} expected=${expected} actual=${actual}(丢弃)`) } diff --git a/src/net/relay/content/crypto.ts b/src/net/relay/content/crypto.ts index 3fb5a68..e1406af 100644 --- a/src/net/relay/content/crypto.ts +++ b/src/net/relay/content/crypto.ts @@ -301,6 +301,38 @@ export function describeGroupKey(file: string, r: GroupKeyLoadResult): string { : `[content-crypto] ⛔ 不启用加密:${r.reason} —— ${r.detail}` } +// ── 🆕 序㊻ · C(域分离):块 id 的 per-network 域密钥 ────────────────────────────── + +/** + * 块 id 域密钥的**派生标签**。 + * + * 🔴 **改这个字面量 = 全部块 id 换代**(缓存全清、去重率归零重算)⇒ ⛔ 只在换代时动, + * 且必须与 `E1` 基线重置**同时做**(`04-133 §3.3`)。 + */ +export const BLOCK_ID_DOMAIN_TAG = 'dshs-overlay-block-id/v1' + +/** + * 🆕 序㊻ · C(域分离):从**组密钥本体**按 **network 维度**派生块 id 的域密钥。 + * + * ## 为什么复用组密钥链路(⛔ 不新增密钥文件 / ⛔ 不新增 env) + * 块 id 的可观测面只有一件:**同一个 id 是否在两个 network 里同时出现**(跨租户相关性)。 + * 要挡住它,只需要**每网一把互不相同的派生钥**;而组密钥已经**按 `(network, group)` 分发到位、 + * `0600` 落盘、可随 epoch 轮换** ⇒ 直接派生即可,无需任何新的运维对象。 + * + * ## 三条口径(写死) + * - ✅ **确定性**:同钥同网 ⇒ 同派生钥(否则块 id 次次不同,去重全废); + * - ✅ **单向**:只有持组密钥者能算出某个 network 的派生钥 ⇒ **无钥者算不出另一个网的块 id** + * (= 跨网 COF / LRI 被切断的那一半); + * - ⚠️ **轮换组密钥 ⇒ 派生钥变 ⇒ 块 id 换代**。⚠️ 这与"轮换后密文全变 ⇒ 块 id 全变"**同向**, + * ⇒ **不引入额外代价**(密文口径本来就是 `sha256(密文)`)。 + * + * @param groupKeyMaterial 组密钥本体(32 字节 AES key,**来自 `0600` 文件**) + * @param network 本节点所属网络标识(`network.ts#OPS_NETWORK` 一类) + */ +export function deriveBlockIdKey(groupKeyMaterial: Buffer, network: string): Buffer { + return createHmac('sha256', groupKeyMaterial).update(BLOCK_ID_DOMAIN_TAG).update(network, 'utf8').digest() +} + // ── 加解密 ──────────────────────────────────────────────────────────────────── /** `ContentCipher` 构造选项。 */ @@ -397,6 +429,16 @@ export class ContentCipher { return keyIdOf(this.key) } + /** + * 🆕 序㊻ · C(域分离):**本网**的块 id 域密钥(由当前**写入**密钥派生)。 + * + * ⚠️ 返回的是**派生钥**(32 B),⛔ **不是密钥本体** —— 但它仍是密钥材料 ⇒ + * 调用方**不许打印、不许进日志、不许进 `/status`**(`/status` 只放 `keyIdOf()` 指纹)。 + */ + blockIdKeyOf(network: string): Buffer { + return deriveBlockIdKey(this.key, network) + } + /** AAD = `|` —— 把"组"与"代"绑进认证。 */ private aadOf(epoch: number): Buffer { return Buffer.from(`${this.groupKey}|${epoch}`, 'utf8') diff --git a/src/net/relay/content/runtime.ts b/src/net/relay/content/runtime.ts index ea59d87..db5df54 100644 --- a/src/net/relay/content/runtime.ts +++ b/src/net/relay/content/runtime.ts @@ -49,6 +49,8 @@ import type { SourceHitCounters, SourceTier } from './source.js' import { ContentStore, DEFAULT_MAX_BYTES } from './store.js' import type { ContentStoreCounters } from './store.js' import { chunkify, planOf, reassemble, blockIdOf, DEFAULT_BLOCK_SIZE } from './chunker.js' +import type { ChunkTransforms } from './chunker.js' +import { keyIdOf } from './crypto.js' import type { ContentCipher, ContentCryptoCounters } from './crypto.js' import { DIRECT_CAND_MAX_ADDRS, isValidAddress } from '../direct/candidate.js' import type { CandidateVerdict, DirectAddress } from '../direct/candidate.js' @@ -292,6 +294,16 @@ export interface ContentSnapshot { * ⚠️ **不启用加密时本键整体缺席** ⇒ `OBS-23` 记 **SKIP**("缺省不启用"是合法状态)。 */ crypto?: ContentCryptoCounters + /** + * 🆕 序㊻ · C(域分离):块 id 是否走**per-network keyed hash**。 + * ⚠️ **未启用域分离时本键整体缺席**(⛔ 不补 `false`)⇒ 与 `crypto` 同一纪律。 + */ + blockIdKeyed?: boolean + /** + * 🆕 序㊻ · C:域密钥的**可公开指纹**(16 hex;⛔ 不是密钥)。 + * 🔑 **跨机口径一致性**的机器判据:47 与 106 必须逐字相同。 + */ + blockIdKeyId?: string } /** * 内容面运行时 —— 一个进程一份,**唯一**的报数入口。 @@ -307,6 +319,22 @@ export class ContentRuntime { readonly blockSize: number /** 🆕 组密钥加解密器(`undefined` = 不启用加密)。 */ readonly cipher: ContentCipher | undefined + /** + * 🆕 序㊻ · C(域分离):块 id 的**域密钥**(`undefined` = 裸哈希口径 ⇒ 与序㉔ 逐字一致)。 + * + * 🔑 派生方式 = `cipher.blockIdKeyOf(network)`(组密钥 + network ⇒ 不新增密钥文件 / 不新增 env)。 + * ⇒ **加密与域分离同开同关**:没启用组密钥就没有域密钥,块 id 回到裸哈希(= 回滚路径)。 + * + * ⛔ **这是密钥材料** —— 不许打印、不许进日志、不许进 `/status` + * (`/status` 只放 {@link blockIdKeyId} 指纹)。 + */ + readonly netKey: Buffer | undefined + /** + * 🆕 序㊻ · C:域密钥的**可公开指纹**(`undefined` = 未启用域分离)。 + * ⚠️ 它是"**两机口径是否一致**"的机器判据:47 与 106 的该值必须逐字相同, + * 否则跨机取块会**全部判校验失败**(而块本身是好的 —— 最难定位的形态)。 + */ + readonly blockIdKeyId: string | undefined private readonly storeMaxBytes: number /** 本节点所属网(候选登记的防御性比对用;⛔ 不从别处猜)。 */ @@ -344,9 +372,14 @@ export class ContentRuntime { this.network = opts.network this.log = opts.log this.cipher = opts.cipher + // 🆕 序㊻ · C:域密钥与 cipher **同开同关**(⛔ 不新增 env / 不新增密钥文件)。 + // ⚠️ 顺序:必须在 `new ContentStore` 之前 —— store 的两处复算用它。 + this.netKey = this.cipher?.blockIdKeyOf(opts.network) + this.blockIdKeyId = this.netKey === undefined ? undefined : keyIdOf(this.netKey) this.store = new ContentStore({ maxBytes: this.storeMaxBytes, ...(opts.maxBlockBytes === undefined ? {} : { maxBlockBytes: opts.maxBlockBytes }), + ...(this.netKey === undefined ? {} : { netKey: this.netKey }), }) this.peers = new ContentPeerGroup({ network: opts.network, @@ -447,7 +480,7 @@ export class ContentRuntime { } // ── 🔴 **D7 闸门**:复算块 id(⛔ 这一行不许省、不许"先信后验")──────────── this.wire.idChecks += 1 - const actual = blockIdOf(gotBytes) + const actual = blockIdOf(gotBytes, this.netKey) if (actual !== id) { this.wire.idMismatches += 1 this.wire.errors[name] += 1 @@ -540,6 +573,27 @@ export class ContentRuntime { return this.cipher !== undefined } + /** + * 🆕 序㊻ · C:**写侧变换**(加密 + 域密钥)—— 一个对象同时给两样,⛔ 不许只给一半。 + * + * ⛔ 不启用组密钥 ⇒ `undefined`(与序㉔ 逐字一致)。🔴 只给 `encode` 不给 `netKey` 的形态 + * = "块 id 挂密文但不带域维度" ⇒ 正是本线要根治的**静默失效**;所以两样在一个函数里产出。 + */ + private writeTransforms(): ChunkTransforms | undefined { + const cipher = this.cipher + if (cipher === undefined) return undefined + const encode = (plain: Buffer): Buffer => cipher.encryptBlock(plain) + return this.netKey === undefined ? { encode } : { encode, netKey: this.netKey } + } + + /** 🆕 序㊻ · C:**读侧变换**(重组位的解密 + 域密钥)。⚠️ 生产路径的解密在 `source` 链。 */ + private readTransforms(): ChunkTransforms | undefined { + const cipher = this.cipher + if (cipher === undefined) return undefined + const decode = (stored: Buffer): Buffer | undefined => cipher.decodeBlock(stored) + return this.netKey === undefined ? { decode } : { decode, netKey: this.netKey } + } + /** * 🆕 **写内容**(单 B 的写侧统一入口):明文 → 切块 → 加密 → 内容寻址入库。 * @@ -547,11 +601,7 @@ export class ContentRuntime { * ⇒ 单块改动会让其后所有块失效(丢掉 `E3`「只传变化块」)。 */ putContent(bytes: Buffer): { plan: string[]; size: number; contentId: string; dedupIds: string[] } { - const r = chunkify( - bytes, - this.blockSize, - this.cipher === undefined ? undefined : { encode: (plain) => this.cipher?.encryptBlock(plain) as Buffer }, - ) + const r = chunkify(bytes, this.blockSize, this.writeTransforms()) for (const c of r.chunks) this.store.put(c.id, c.bytes) // ⚠️ 返回**逐块有序** id(`plan`)而非去重后的 `ids`:取回/重组必须按序,去重列表只作"要几个块"的口径 return { plan: r.chunks.map((c) => c.id), size: r.size, contentId: r.contentId, dedupIds: r.ids } @@ -580,11 +630,7 @@ export class ContentRuntime { */ async reassembleContent(stored: Map, ids: readonly string[]): Promise { try { - return reassemble( - [...ids], - stored, - this.cipher === undefined ? undefined : { decode: (b) => this.cipher?.decodeBlock(b) }, - ) + return reassemble([...ids], stored, this.readTransforms()) } catch (err) { this.lastReassembleError = err instanceof Error ? err.message : String(err) return undefined @@ -596,11 +642,7 @@ export class ContentRuntime { /** 🆕 只算"这份内容要哪些块"(写侧 `putContent` 的坐标版 —— 查本地/peer 前用)。 */ planContent(bytes: Buffer): { ids: string[]; size: number } { - return planOf( - bytes, - this.blockSize, - this.cipher === undefined ? undefined : { encode: (plain) => this.cipher?.encryptBlock(plain) as Buffer }, - ) + return planOf(bytes, this.blockSize, this.writeTransforms()) } /** @@ -618,7 +660,9 @@ export class ContentRuntime { const plain = Buffer.from(marker, 'utf8') const ok = cipher.selfProbe(marker, { put: (blob) => { - const id = blockIdOf(blob) + // 🔴 序㊻ · C:这里的 id **必须**与 store 的复算口径一致(同样带 `netKey`)。 + // 漏传 ⇒ `store.put` 抛"块校验失败" ⇒ 启动自证直接失败(= 调用点漏改的现行判据)。 + const id = blockIdOf(blob, this.netKey) this.lastProbeId = id this.store.put(id, blob) }, @@ -658,6 +702,9 @@ export class ContentRuntime { peerWire: this.peerWireSnapshot(), // ⚠️ 不启用加密 ⇒ 本键**整体缺席**(不是补零!补零会让"没启用"与"启用了但零值"同形) ...(this.cipher === undefined ? {} : { crypto: this.cipher.counters() }), + // 🆕 序㊻ · C(域分离):**同样"未启用即整体缺席"** —— 由 `blockIdKeyId` 的存在性 + // 区分"没开域分离"与"开了但指纹是空串"(⛔ 不补 false、⛔ 不补空串)。 + ...(this.blockIdKeyId === undefined ? {} : { blockIdKeyed: true, blockIdKeyId: this.blockIdKeyId }), } } } diff --git a/src/net/relay/content/store.ts b/src/net/relay/content/store.ts index 037650d..75006aa 100644 --- a/src/net/relay/content/store.ts +++ b/src/net/relay/content/store.ts @@ -68,6 +68,15 @@ export interface ContentStoreOptions { dir?: string /** 单块上限(字节)。缺省 = `maxBytes`(即"只要装得下就收")。 */ maxBlockBytes?: number + /** + * 🆕 序㊻ · C(域分离):块 id 的**域密钥**(per-network keyed hash)。 + * + * 给了它 ⇒ 本层的两处复算(入库前 / 取出后)走 `HMAC-SHA256(netKey, bytes)`; + * ⛔ 缺省 ⇒ 回落裸 `sha256`(= 回滚路径)。⚠️ **必须与写侧的 `netKey` 一致** —— + * 不一致的症状是"每个块都判校验失败"(`putRejected` / `corruptReads` 涨), + * 而块本身是好的(本线最恨的难定位形态)。 + */ + netKey?: Buffer } /** @@ -95,6 +104,8 @@ export class ContentStore { private readonly maxBytes: number private readonly maxBlockBytes: number private readonly dir: string | undefined + /** 🆕 序㊻ · C:块 id 的域密钥(`undefined` = 裸哈希口径,与序㉔ 逐字一致)。 */ + private readonly netKey: Buffer | undefined private usedBytes = 0 private seq = 0 private readonly c: ContentStoreCounters = { @@ -119,6 +130,7 @@ export class ContentStore { } this.maxBlockBytes = Math.min(mb, max) this.dir = opts.dir + this.netKey = opts.netKey if (this.dir !== undefined) mkdirSync(this.dir, { recursive: true }) } @@ -168,7 +180,7 @@ export class ContentStore { ) } // ── E4 写侧:入库前**必须**复算 id ────────────────────────────────── - const actual = blockIdOf(bytes) + const actual = blockIdOf(bytes, this.netKey) if (actual !== id) { this.c.putRejected += 1 throw new Error(`content-store: 块校验失败(丢弃)expected=${id} actual=${actual}`) @@ -206,7 +218,7 @@ export class ContentStore { } const hit = this.map.get(id) if (hit !== undefined) { - const verify = blockIdOf(hit.bytes) + const verify = blockIdOf(hit.bytes, this.netKey) if (verify !== id) { // 内存里的块被改过(理论上不该发生)⇒ 丢弃 + 计数 this.c.corruptReads += 1 @@ -224,7 +236,7 @@ export class ContentStore { const p = this.pathOf(id) try { const buf = readFileSync(p) - const verify = blockIdOf(buf) + const verify = blockIdOf(buf, this.netKey) if (verify !== id) { this.c.corruptReads += 1 this.removeOnDisk(id) diff --git a/src/net/relay/index.ts b/src/net/relay/index.ts index 88a0832..813ef10 100644 --- a/src/net/relay/index.ts +++ b/src/net/relay/index.ts @@ -104,7 +104,7 @@ export { planOf, reassemble, } from './content/chunker.js' -export type { Chunk, ChunkedContent } from './content/chunker.js' +export type { Chunk, ChunkedContent, ChunkTransforms } from './content/chunker.js' export { ContentStore, DEFAULT_MAX_BYTES } from './content/store.js' export type { ContentStoreCounters, ContentStoreOptions } from './content/store.js' export { ContentSourceChain, SOURCE_TIERS, DEFAULT_TIER_ORDER, emptySourceCounters } from './content/source.js' @@ -144,6 +144,9 @@ export { TAG_LEN, KEY_LEN, MIN_BLOB_LEN, + // 🆕 序㊻ · C(域分离):块 id 的 per-network 域密钥(⛔ 不新增密钥文件 / 不新增 env)。 + BLOCK_ID_DOMAIN_TAG, + deriveBlockIdKey, } from './content/crypto.js' export type { ContentCryptoCounters, diff --git a/src/net/relay/main.ts b/src/net/relay/main.ts index f1876fb..5775959 100644 --- a/src/net/relay/main.ts +++ b/src/net/relay/main.ts @@ -242,6 +242,17 @@ async function main(): Promise { }) const contentStatusProvider = (): Record => contentRuntime.snapshot() as unknown as Record + /** + * 🆕 序㊻ · C(域分离):**启动判别器**(防"装了但没生效")。 + * + * ⛔ 不打印域密钥本体(它是密钥材料)—— 只打印**可公开指纹**。 + * 🔑 该指纹是"两机口径一致"的比对位:47 与 106 逐字相同才说明同一块 id 口径。 + */ + log( + contentRuntime.blockIdKeyId === undefined + ? '[content] 块 id 口径 = 裸 sha256(⛔ 未启用域分离 —— 缺组密钥,或组密钥未装载)' + : `[content] 块 id 口径 = HMAC-SHA256(域密钥) blockIdKeyId=${contentRuntime.blockIdKeyId}`, + ) /** * 🔴 **活性自证**(防"装了但一次都没命中")。 * @@ -273,8 +284,11 @@ async function main(): Promise { return } // ── 序㉔ 原路径(⛔ 不启用加密时逐字保留,行为不许变) + // 🔴 序㊻ · C:本分支**恒有** `netKey === undefined`(它就是 `cryptoEnabled === false` 的那一支) + // ⇒ 显式传 `contentRuntime.netKey` 而不是省略,是为了让"调用点是否过 netKey"**在源码上可核** + // (⛔ 漏一处的代价 = 每个块都判校验失败且极难定位)。 const { blockIdOf } = await import('./content/chunker.js') - const id = blockIdOf(bytes) + const id = blockIdOf(bytes, contentRuntime.netKey) contentRuntime.store.put(id, bytes) const outcome = await contentRuntime.source.fetch(id) if (outcome?.bytes === undefined) { diff --git a/src/supervisor/orchestrator.ts b/src/supervisor/orchestrator.ts index 52923ff..db2c629 100644 --- a/src/supervisor/orchestrator.ts +++ b/src/supervisor/orchestrator.ts @@ -343,6 +343,28 @@ export class LocalSpawner implements Spawner { notes: [], } + /** + * 覆盖网络线 序 ㊽:**本轮认领的探活全部落定**之后的回调。 + * + * ## 为什么需要它(这正是一个实测缺陷的修法) + * 认领来的存量实例**刻意不进 `mains`**(文件头 序 ㉕ 的边界:`launchToken` 不可恢复), + * 而 `listUserInstances()` 的口径**就是 `mains`** ⇒ 上一进程遗留的实例监听端口**没有任何人** + * 会向 relay 重新声明一遍。实测症状(2026-09-19):106 的实例 `:21001` 进程健在、 + * `[rehydrate] probe OK` 也打了,但两台中继的端点表里都没有它(47 侧只留一条 + * `w-106:19000 online=false` 的**孤儿**条目)⇒ Manager 侧 `(hostId, port)` 翻译不出来。 + * + * ⇒ 由 **worker agent** 接这个回调,把 `adoptedInstancePorts()` 并进对账口径(⛔ 不改认领语义、 + * ⛔ 不把认领实例写进 `mains`):端口一落定就登记,**不必等 20 s 对账节拍**。 + * ⚠️ 缺省 `undefined` ⇒ 行为与改造前**逐字一致**(其他装配点不受影响)。 + */ + onRehydrateSettled: (() => void) | undefined + + /** 序 ㊽:本轮仍在途的探活条数。探活是**异步**的(socket 事件)⇒ ⛔ 不能只看 `schedule` 的基例。 */ + private pendingProbes = 0 + + /** 序 ㊽:本轮 `schedule` 已走完(不会再产生新探活)。`true` + `pendingProbes === 0` ⇒ 落定。 */ + private rehydrateScheduled = false + constructor( private readonly config: ServerConfig, /** Resolve the user's own API key (decrypted); null = user has none. */ @@ -1411,16 +1433,62 @@ export class LocalSpawner implements Spawner { return { ...this.rehydrate, notes: [...this.rehydrate.notes] } } + /** + * 覆盖网络线 序 ㊽:**已认领且探活通过**的实例监听端口(worker agent 的对账口径之一)。 + * + * 与 {@link listUserInstances} 的关系 = **互补,不合并**:那个的口径是"本进程 launch 过的" + * (`mains`),这个是"本进程**接管**的"(`adopted`)。认领来的实例没有 `launchToken`、 + * 也进不了 `status(userId)`(文件头 序 ㉕ 的客观边界,⛔ 未改),但"**这个端口在听、且归本 + * worker 管**"与实例身份无关 ⇒ 该登记给 relay 就得登记(否则跨机代理查不到落点)。 + * + * ⚠️ 只报 `alive === true` 的:探活没过的那条已被 `probeAdopted` 按旧行为停掉了, + * 报出去只会让上游去登记一个死口。⛔ 未出生的探活(`alive === undefined`)同样不报。 + */ + adoptedInstancePorts(): number[] { + const out: number[] = [] + for (const rec of this.adopted.values()) { + if (rec.alive === true && rec.info.port !== undefined) out.push(rec.info.port) + } + return out.sort((a, b) => a - b) + } + + /** + * 序 ㊽:本轮 `schedule` 收尾 —— ⚠️ **探活可能还没落定**(异步)⇒ 只置标志, + * 由 {@link probeAdopted} 的最后一条补射(见 {@link maybeRehydrateSettled})。 + */ + private settleRehydrate(): void { + this.rehydrateScheduled = true + this.maybeRehydrateSettled() + } + + /** 序 ㊽:`schedule` 走完 + 在途探活归零 ⇒ **恰好通知一次**(幂等;回调抛错⛔不许拖垮编排器)。 */ + private maybeRehydrateSettled(): void { + if (!this.rehydrateScheduled || this.pendingProbes > 0) return + this.rehydrateScheduled = false + try { + this.onRehydrateSettled?.() + } catch (err) { + process.stderr.write( + `[rehydrate] ⚠️ 落定回调抛错(已吞,不阻断):${err instanceof Error ? err.message : String(err)}\n`, + ) + } + } + private rehydrateAdoptedScopes(): void { + // 序 ㊽:新一轮认领起算 —— 上一轮的落定标志必须清掉(否则残留的 `true` 会让本轮提前通知)。 + this.pendingProbes = 0 + this.rehydrateScheduled = false // ① 非 account 形态不产生 scope ⇒ 保持旧语义(此处是空操作)。 if (this.config.isolationMode !== 'account') { this.cleanAllStaleScopes() + this.settleRehydrate() return } const found = this.scanExistingScopes() this.rehydrate.scanned = found.length if (found.length === 0) { process.stderr.write('[rehydrate] 无既有实例 scope ⇒ 不动作(与旧行为等价)\n') + this.settleRehydrate() return } const perUid = new Map() @@ -1429,6 +1497,7 @@ export class LocalSpawner implements Spawner { const schedule = (i: number): void => { if (i >= found.length) { process.stderr.write(`[rehydrate] summary ${JSON.stringify(this.rehydrateReport())}\n`) + this.settleRehydrate() return } const t = setTimeout(() => { @@ -1505,9 +1574,13 @@ export class LocalSpawner implements Spawner { this.rehydrate.stopped += 1 this.adopted.delete(rec.unit) this.stopUnit(rec.unit) + // 序 ㊽:这一条**不产生**探活 ⇒ 若此刻已是本轮最后一条,落定通知不能等它。 + this.maybeRehydrateSettled() return } let settled = false + // 序 ㊽:在途计数 —— 探活由 socket 事件驱动,`schedule` 的基例可能先于它跑完。 + this.pendingProbes += 1 const sock = connect({ host: '127.0.0.1', port }) const done = (ok: boolean): void => { if (settled) return @@ -1521,15 +1594,17 @@ export class LocalSpawner implements Spawner { if (ok) { this.rehydrate.probeOk += 1 process.stderr.write(`[rehydrate] probe OK ${rec.unit} :${port}\n`) - return + } else { + this.rehydrate.probeFail += 1 + const note = `probe-fail ${rec.unit} :${port}` + this.rehydrate.notes.push(note) + process.stderr.write(`[rehydrate] ⛔ ${note} ⇒ 判孤儿,按旧行为停掉\n`) + this.rehydrate.stopped += 1 + this.adopted.delete(rec.unit) + this.stopUnit(rec.unit) } - this.rehydrate.probeFail += 1 - const note = `probe-fail ${rec.unit} :${port}` - this.rehydrate.notes.push(note) - process.stderr.write(`[rehydrate] ⛔ ${note} ⇒ 判孤儿,按旧行为停掉\n`) - this.rehydrate.stopped += 1 - this.adopted.delete(rec.unit) - this.stopUnit(rec.unit) + this.pendingProbes -= 1 + this.maybeRehydrateSettled() } sock.setTimeout(this.rehydrateProbeMs(), () => done(false)) sock.once('error', () => done(false)) diff --git a/src/worker/agent.ts b/src/worker/agent.ts index 3668ef7..540cea7 100644 --- a/src/worker/agent.ts +++ b/src/worker/agent.ts @@ -253,16 +253,42 @@ export function buildWorkerAgent( } } + /** + * **本 worker 此刻真的在管的实例端口**(对账口径的**唯一**来源)。 + * + * 🔴 覆盖网络线 序 ㊽:必须是**两条腿的并集** —— + * ① `listUserInstances()` = 本进程 `launch` 过的(`LocalSpawner.mains`); + * ② `adoptedInstancePorts()` = 本进程**认领**来的存量实例(⛔ 不进 `mains`,见其文件头 序 ㉕)。 + * + * 只取 ① 就是本次实测的缺陷:worker 重启后 `mains` 空 ⇒ 上一进程遗留的实例端口**没人重新声明**, + * relay 端点表里只留一条 `online=false` 的孤儿条目(实测 47 侧 `w-106:19000`)⇒ Manager 侧 + * `(hostId, port)` 翻译不出来。并进 ② 之后,`forward(port)` 会把那条孤儿**就地覆盖**成在线 + * (relay 侧 `ensureEndpoint` 复用既有条目、只换绑定会话,⛔ 不新开口、⛔ 不动白名单)。 + */ + const liveInstancePorts = async (): Promise> => { + const live = new Set( + (await spawner.listUserInstances()).map((i) => i.port).filter((p): p is number => p !== undefined), + ) + for (const port of spawner.adoptedInstancePorts()) live.add(port) + return live + } + /** 把活着的实例端口补齐、把已消失的撤掉(崩溃退出也走这里收敛,不必逐个挂 exit 钩子)。 */ const reconcileTunnel = async (): Promise => { if (tunnel === undefined || !tunnelReady) return - const live = new Set((await spawner.listUserInstances()).map((i) => i.port).filter((p): p is number => p !== undefined)) + const live = await liveInstancePorts() for (const port of live) await tunnel.forward(port) for (const port of tunnel.ports) { if (!live.has(port) && !staticPorts.includes(port)) await tunnel.cancel(port) } } + /** 隧道一拍 = 自愈 + 对账(序 ㊽:定时器与"认领落定"回调**共用同一份语义**,⛔ 不写第二套)。 */ + const tunnelTick = async (): Promise => { + await healTunnel() + await reconcileTunnel() + } + let tunnelTimer: NodeJS.Timeout | undefined if (tunnel !== undefined) { void tunnel @@ -274,9 +300,19 @@ export function buildWorkerAgent( console.error('[tunnel] 建立失败(跨机代理将不可用,本机功能不受影响):', err instanceof Error ? err.message : err) }) tunnelTimer = setInterval(() => { - void healTunnel().then(reconcileTunnel) + void tunnelTick() }, 20_000) tunnelTimer.unref?.() + /** + * 序 ㊽:**认领(rehydrate)落定即登记** —— ⛔ 不等 20 s 对账节拍。 + * + * 认领来的存量实例端口**只有这一条路**会被声明给 relay(它们不进 `mains`)⇒ 若只靠 20 s + * 定时器,重启后有一段"实例活着但中继查不到落点"的窗口;本回调把它压到探活落定的那一刻。 + * ⚠️ 定时器**保留**:它仍是断链自愈 / 端口漂移的兜底(回调只负责"起步那一拍")。 + */ + spawner.onRehydrateSettled = () => { + void tunnelTick() + } } const app = Fastify({ logger: { level: options.logLevel ?? 'info' }, bodyLimit: MAX_PATCH_BYTES + 4096 }) const cache: OpCache = { order: [], results: new Map() } diff --git a/test/overlay-content.test.mjs b/test/overlay-content.test.mjs index a835929..42b2d8f 100644 --- a/test/overlay-content.test.mjs +++ b/test/overlay-content.test.mjs @@ -545,6 +545,9 @@ import { keyIdOf, loadGroupKeyFile, verifyGroupKeyCredential, + // 🆕 序㊻ · C(域分离) + BLOCK_ID_DOMAIN_TAG, + deriveBlockIdKey, } from '../lib/net/relay/content/crypto.js' /** 造一个确定性的组密钥(⛔ 不用随机 —— 用例必须可复现)。 */ @@ -842,3 +845,204 @@ test('F5/E1 口径:加密前后"回源份数"不变(同组 N 次取用 ⇒ assert.strictEqual(rt.snapshot().store.puts, 3, '同内容只入库 3 个块(去重)') assert.strictEqual(rt.snapshot().crypto.decryptRejected, 0) }) + +// ───────────────────────────────────────────────────────────────────────────── +// 组 G(序㊻ · C 域分离):块 id 的 **per-network keyed hash** +// +// 🔑 这一组是 `OBS-29` 的**离线等价体**(探针那条判据在同一批函数上跑)。 +// 两条腿**必须同时有**(缺一即视为未覆盖): +// · **正腿** = 同字节 + **不同 network** ⇒ 块 id **不同**; +// · 🔴 **负腿** = **去掉 per-network 维度**(`netKey` 取同值 / 取空)⇒ 同一谓词**必红**。 +// ⛔ 只给正腿 = 会漏掉"去了重"(把去重也一起干掉照样全绿); +// ⛔ 没有负腿 = "上了个**无效的**域分离"会全绿 —— 本线老病根(装了但没生效 = 静默放行)。 +// ───────────────────────────────────────────────────────────────────────────── + +/** C 的取数夹具:**同一把组密钥** + 不同 network ⇒ 只有 network 维度在变。 */ +function keyingFixture(keySeed = 21) { + const cipher = new ContentCipher({ groupKey: 'ops|content', epoch: 1, key: makeKey(keySeed) }) + const bytes = makeBytes(3 * 1024 * 1024 + 7, 5) // 4 块(⛔ 不用整块数 —— 边界块也一起验) + // ⚠️ **同网复用同一个 runtime**(缓存):既省事,也让"同网 ⇒ 同一套 id / 同一份存储"这一 + // 语义在夹具层面就成立(`E1` 重取需要"同一个网内重复取用")。 + const cache = new Map() + const rtOf = (network) => { + if (!cache.has(network)) cache.set(network, new ContentRuntime({ network, group: 'content', cipher })) + return cache.get(network) + } + return { cipher, bytes, rtOf } +} + +/** 🔴 正腿的**谓词本体**(放在函数里 ⇒ 正/负两腿断言的是同一个谓词,⛔ 不是两份写法)。 */ +const idDiffersAcrossNetworks = (planA, planB) => planA[0] !== planB[0] + +test('G1 🔴 正腿:同字节 + 不同 network ⇒ 块 id 必须不同(且两侧都不是裸哈希)', () => { + const { bytes, rtOf } = keyingFixture() + const a = rtOf('ops').planContent(bytes).ids + const b = rtOf('u:1').planContent(bytes).ids + const bare = planOf(bytes).ids + assert.strictEqual(idDiffersAcrossNetworks(a, b), true, '不同 network ⇒ 块 id 必须不同(域分离生效)') + assert.notStrictEqual(a[0], bare[0], '⚠️ A 网不得等于裸哈希 —— 否则 = 域分离没生效("装了没生效"的本形)') + assert.notStrictEqual(b[0], bare[0], '⚠️ B 网不得等于裸哈希(同上)') + assert.strictEqual(a.length, 4, '4 块:正腿必须覆盖多块而不是只比第一块') + for (let i = 0; i < a.length; i += 1) { + assert.notStrictEqual(a[i], b[i], `第 ${i} 块:跨网必须不同`) + } +}) + +test('G1-b 正腿(id 函数直测):blockIdOf / contentIdOf 的 netKey 维度必须起作用', () => { + const k = makeKey(21) + const b = makeBytes(4096, 5) + assert.notStrictEqual(blockIdOf(b, deriveBlockIdKey(k, 'ops')), blockIdOf(b, deriveBlockIdKey(k, 'u:1'))) + assert.notStrictEqual(contentIdOf(b, deriveBlockIdKey(k, 'ops')), contentIdOf(b, deriveBlockIdKey(k, 'u:1'))) +}) + +test('G2 🔴 正腿:同 network + 同字节 ⇒ 块 id 必须相同(**去重不得丢**)', () => { + const { bytes, rtOf } = keyingFixture() + const rt1 = rtOf('ops') + const rt2 = rtOf('ops') + assert.strictEqual(idDiffersAcrossNetworks(rt1.planContent(bytes).ids, rt2.planContent(bytes).ids), false) + assert.deepStrictEqual(rt1.planContent(bytes).ids, rt2.planContent(bytes).ids, '同网同内容 ⇒ 计划逐字一致') + // 去重仍然成立:同一份内容入库两次 ⇒ store 只入 4 个**唯一**块(3 整块 + 1 边界块) + const put1 = rt1.putContent(bytes) + const put2 = rt1.putContent(bytes) + assert.deepStrictEqual(put2.plan, put1.plan, '同网重复写 ⇒ 计划 id 逐字一致(去重必须还在)') + assert.strictEqual(rt1.snapshot().store.puts, 4, '同内容只入库 4 个块(⛔ 域分离不得把去重干掉)') + assert.strictEqual(rt1.snapshot().store.putRejected, 0) +}) + +test('G3 装配面:域分离启用后 store 两处复算 / 链取回 / 重组位三条路都必须过(= 调用点无漏改)', async () => { + const { bytes, rtOf } = keyingFixture() + const rt = rtOf('ops') + const put = rt.putContent(bytes) + // ① store 写侧复算(漏改 ⇒ 这里必然抛 / putRejected 涨) + assert.strictEqual(rt.snapshot().store.putRejected, 0, 'store 写侧复算必须过') + // ② 生产路径:优先级链取回(local 档命中 → 唯一解密点) + const got = await rt.fetchContent(put.plan) + assert.ok(got !== undefined && got.equals(bytes), '经优先级链取回必须逐字节等于原内容') + assert.strictEqual(rt.snapshot().store.corruptReads, 0, 'store 读侧复算必须过') + // ③ 工具路径:重组位(`chunker#reassemble` 也要同一个 netKey) + const stored = new Map() + for (const id of put.plan) stored.set(id, rt.store.get(id)) + const back = await rt.reassembleContent(stored, put.plan) + assert.ok(back !== undefined && back.equals(bytes), '重组位必须逐字节等于原内容(⛔ 漏 netKey ⇒ 必抛校验失败)') + // ④ D7 闸门:**跨网**取回的块(块本身完好)必须被丢弃 —— 域不同 ⇒ id 不等 ⇒ 不算命中 + const other = rtOf('u:1') + const putOther = other.putContent(bytes) + assert.notDeepStrictEqual(putOther.plan, put.plan, '两个域的块 id 必须不同') + assert.strictEqual(rt.store.has(putOther.plan[0]), false, '跨域的块 id 不得在本地命中') +}) + +test('G4 回滚路径:缺省 / 空 netKey ⇒ 回落裸 sha256(与序㉔ 逐字一致)', () => { + const b = makeBytes(4096, 5) + const bare = createHash('sha256').update(b).digest('hex').slice(0, BLOCK_ID_HEX_LEN) + assert.strictEqual(blockIdOf(b), bare, '缺省必须恰为裸 sha256 前 32 hex') + assert.strictEqual(blockIdOf(b, Buffer.alloc(0)), bare, '⚠️ 空 netKey 必须**等价于**不传(回滚路径)') + assert.strictEqual(contentIdOf(b, Buffer.alloc(0)), bare) + // ⛔ 反向:非空 netKey **必须**离出裸哈希(否则"回落"与"生效"不可分 ⇒ 判据无牙) + assert.notStrictEqual(blockIdOf(b, Buffer.alloc(4, 0xab)), bare, '非空 netKey 必须走 HMAC 分支') + // 不启用组密钥的 runtime ⇒ 恒回落裸哈希(= 缺省不启用 / 回滚路径成立) + const plain = new ContentRuntime({ network: 'ops', group: 'content' }) + assert.strictEqual(plain.netKey, undefined) + assert.strictEqual(plain.blockIdKeyId, undefined) + assert.deepStrictEqual(plain.planContent(b).ids, planOf(b).ids, '不启用 ⇒ 计划逐字等于裸哈希口径') +}) + +test('G5 🔴 负腿「netKey 取同值」:去掉 per-network 维度 ⇒ 正腿谓词必红(具名 flat-key-collapses)', () => { + const { bytes, rtOf } = keyingFixture() + // 正腿:两个**不同** network + assert.strictEqual( + idDiffersAcrossNetworks(rtOf('ops').planContent(bytes).ids, rtOf('u:1').planContent(bytes).ids), + true, + '正腿基线:不同 network ⇒ 谓词必须为真', + ) + // 🔴 负腿:把 network 维度**去掉**(两侧取同一个 network ⇒ 同一把域密钥) + const flatA = rtOf('ops').planContent(bytes).ids + const flatB = rtOf('ops').planContent(bytes).ids + assert.strictEqual( + idDiffersAcrossNetworks(flatA, flatB), + false, + '⚠️ flat-key-collapses:域密钥取同值 ⇒ 谓词**必须为假**(判据有牙;若这里为真说明判据根本没看 network)', + ) + // ⚠️ 且此时**仍然是"带密钥"的路径**(不等于裸哈希)—— 排除了"负腿其实是回落裸哈希"的混淆 + assert.notStrictEqual(flatA[0], planOf(bytes).ids[0], '负腿必须走 HMAC 路径(⛔ 不是回落裸哈希)') +}) + +test('G6 🔴 负腿「netKey 取空」:回落裸哈希 ⇒ 同一正腿谓词必红(具名 empty-key-falls-back-to-bare-hash)', () => { + const b = makeBytes(4096, 5) + const k = deriveBlockIdKey(makeKey(21), 'ops') + // 谓词本体在**函数级**再跑一遍(与 G1/G5 同一个谓词形状) + const differs = (x, y) => x !== y + assert.strictEqual(differs(blockIdOf(b, k), blockIdOf(b, k)), false, '取空(两侧同一把钥)⇒ 必为假') + assert.strictEqual(differs(blockIdOf(b), blockIdOf(b)), false, '两侧都不传 ⇒ 必为假') + // ⛔ 反向:只把**一侧**取空 ⇒ 谓词为真(说明"取空"确实会改变取值 ⇒ 判据对 netKey 敏感) + assert.strictEqual(differs(blockIdOf(b, k), blockIdOf(b)), true, '一侧带钥一侧不带 ⇒ 必须不同') + // ⚠️ 取空与生效**必须可分**:否则这条负腿没有判别力 + assert.notStrictEqual(blockIdOf(b, k), blockIdOf(b)) +}) + +test('G7 派生:确定性 + 只由 (组密钥, network) 决定 + 换钥必变 + 不等于密钥本体', () => { + const k = makeKey(21) + assert.strictEqual(deriveBlockIdKey(k, 'ops').length, KEY_LEN, '派生钥必须是 32 B(HMAC-SHA256 输出)') + assert.ok(deriveBlockIdKey(k, 'ops').equals(deriveBlockIdKey(k, 'ops')), '确定性:同钥同网 ⇒ 同一把') + assert.ok(!deriveBlockIdKey(k, 'ops').equals(deriveBlockIdKey(k, 'u:1')), 'network 维度必须起作用') + assert.ok(!deriveBlockIdKey(k, 'ops').equals(deriveBlockIdKey(makeKey(22), 'ops')), '换组密钥 ⇒ 派生钥必变') + assert.ok(!deriveBlockIdKey(k, 'ops').equals(k), '⛔ 派生钥不得等于组密钥本体(否则"派生"是假的)') + assert.strictEqual(typeof BLOCK_ID_DOMAIN_TAG, 'string') + assert.ok(BLOCK_ID_DOMAIN_TAG.length > 0, '域标签必须非空(它进 HMAC ⇒ 换标签 = 全部块 id 换代)') +}) + +test('G8 观测面:未启用 ⇒ 两键整体缺席;启用 ⇒ 只放指纹(⛔ 不许出现域密钥本体)', () => { + const plain = new ContentRuntime({ network: 'ops', group: 'content' }) + const sp = plain.snapshot() + assert.ok(!('blockIdKeyed' in sp), '未启用域分离 ⇒ blockIdKeyed 必须**缺席**(⛔ 不补 false)') + assert.ok(!('blockIdKeyId' in sp), '未启用域分离 ⇒ blockIdKeyId 必须缺席') + + const { rtOf } = keyingFixture() + const rt = rtOf('ops') + const s = rt.snapshot() + assert.strictEqual(s.blockIdKeyed, true) + assert.match(String(s.blockIdKeyId), /^[0-9a-f]{16}$/, '指纹必须是 16 hex') + assert.strictEqual(s.blockIdKeyId, keyIdOf(rt.netKey), '指纹必须 = keyIdOf(域密钥)') + // 🔑 跨机口径一致(47 / 106 必须逐字相同;不同 ⇒ 跨机取块全判校验失败) + assert.strictEqual(rtOf('ops').blockIdKeyId, rtOf('ops').blockIdKeyId, '同钥同网 ⇒ 口径指纹必须一致') + assert.notStrictEqual(rtOf('ops').blockIdKeyId, rtOf('u:1').blockIdKeyId, '不同网 ⇒ 指纹必须不同') + // 🔴 泄漏守卫:整份 /status 文本里不得出现域密钥本体(hex / base64 两种写法都扫) + const text = JSON.stringify(s) + assert.ok(!text.includes(rt.netKey.toString('hex')), '⛔ /status 不得出现域密钥(hex)') + assert.ok(!text.includes(rt.netKey.toString('base64')), '⛔ /status 不得出现域密钥(base64)') +}) + +test('G9 边界:域分离启用后"同内容不同域"的块**共享必然失效**(= 设计代价,⛔ 不是缺陷)', async () => { + const { bytes, rtOf } = keyingFixture() + const a = rtOf('ops') + const b = rtOf('u:1') + const putA = a.putContent(bytes) + // B 域完全独立:既查不到 A 的块,也解不开 A 的密文(AAD 里就有 groupKey 的网名) + assert.strictEqual(b.store.has(putA.plan[0]), false, '跨域不得命中') + const gotB = await b.fetchContent(putA.plan) + assert.strictEqual(gotB, undefined, '跨域取内容必须**整体失败**(⛔ 不许拼半截 / 不许静默回源代价)') +}) + +test('G10 🔴 `E1` 基线**重取**(C 新 id 口径):同网重复取用 ⇒ 零增量回源;跨网 ⇒ 各 1 份', async () => { + // ⚠️ `E1` 的**定义不重估**(回源字节 ≈ 1 份 × 组数)—— 只重取**基线读数**。 + const { bytes, rtOf } = keyingFixture() + const A = rtOf('ops') + const putA = A.putContent(bytes) + const hit0 = A.snapshot().source.local + for (let i = 0; i < 4; i += 1) { + const got = await A.fetchContent(putA.plan) + assert.ok(got !== undefined && got.equals(bytes), '第 ' + i + ' 轮取回必须逐字节等于原内容') + } + const s = A.snapshot() + assert.strictEqual(s.source.local - hit0, putA.plan.length * 4, '4 轮 × 4 块 ⇒ 全部 local 命中') + assert.strictEqual(s.source.origin, 0, '⚠️ 稳态回源必须 0("1 份"由首次写入承担 —— 新口径下不变)') + assert.strictEqual(s.store.puts, putA.dedupIds.length, '同内容只入库**去重后**的块数') + assert.strictEqual(s.store.putRejected, 0) + // 跨网:B 网必须**各自一份**(id 不同 ⇒ 天然不去重 —— 这正是 C 的域收窄点) + const B = rtOf('u:1') + assert.strictEqual(B.snapshot().source.local, 0, 'B 网起点零命中(= 各自回源 1 份)') + const putB = B.putContent(bytes) + assert.notDeepStrictEqual(putB.plan, putA.plan, '两个网的块 id 序列必须不同') + const gotB = await B.fetchContent(putB.plan) + assert.ok(gotB !== undefined && gotB.equals(bytes), 'B 网自己那一份必须能取回') + assert.notStrictEqual(putB.dedupIds[0], putA.dedupIds[0], '跨网不得共享块(= 设计意图,⛔ 不是缺陷)') +})