dsh-lan-peers 0.4.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +199 -0
- package/HANDOVER.md +201 -15
- package/client/cli-group.test.mjs +327 -0
- package/client/cli-unpair.test.mjs +148 -0
- package/client/cli.mjs +435 -3
- package/host/server.mjs +3004 -183
- package/host/test/auth.test.mjs +3 -2
- package/host/test/group-auth.test.mjs +423 -0
- package/host/test/group-cluster.mjs +179 -0
- package/host/test/group-commit.test.mjs +281 -0
- package/host/test/group-election.test.mjs +342 -0
- package/host/test/group-replica.test.mjs +453 -0
- package/host/test/group.test.mjs +775 -0
- package/host/test/pairing-ecdh.test.mjs +3 -2
- package/host/test/unpair.test.mjs +237 -0
- package/lib/admin-actions.test.mjs +90 -0
- package/lib/admin-group.test.mjs +919 -0
- package/lib/admin-unpair.test.mjs +158 -0
- package/lib/admin.mjs +1357 -27
- package/lib/client-render.test.mjs +225 -0
- package/lib/client.js +318 -4
- package/lib/group-client.mjs +1015 -0
- package/lib/index.js +22 -2
- package/lib/panel-drag.test.mjs +208 -0
- package/lib/panel-group.test.mjs +628 -0
- package/lib/panel-unpair.test.mjs +183 -0
- package/lib/tools.mjs +28 -0
- package/package.json +1 -1
package/DESIGN.md
CHANGED
|
@@ -197,3 +197,202 @@ canonical 的 body 用 `sha256("")`。**每条事件载荷独立加密**(与 /
|
|
|
197
197
|
### 12.5 验收
|
|
198
198
|
|
|
199
199
|
原 §11 六条继续有效,新增:ECDH 两轮成功/被篡改 400/过期 401/码一次性;legacy 路径仍 200 且有弃用告警;`waitMs` 终态提前返回且带 `elapsedMs`、缺省立即返回;/v1/events 每条可解密且终态断流;新旧四类组合互通(见 §12.1)。
|
|
200
|
+
|
|
201
|
+
## 13. v0.4.1(在途,未发布):手动取消配对
|
|
202
|
+
|
|
203
|
+
> 状态:实现并测试完成,**未发布**(用户要求暂缓)。原则同 §12:只新增端点与 capability,不动既有语义。
|
|
204
|
+
|
|
205
|
+
### 13.1 capability
|
|
206
|
+
|
|
207
|
+
追加 `"unpair"` → `["ask","pair","pair-ecdh","stream","unpair"]`。只增不减,旧客户端不认识即忽略;回退决策仍靠运行时探测。
|
|
208
|
+
|
|
209
|
+
### 13.2 端点
|
|
210
|
+
|
|
211
|
+
`POST /v1/unpair`(签名 + 加密,与 `/v1/ask`、`/v1/answer`、`/v1/peers` 同一门控,缺 `X-Peer-Enc` → 400 `encryption-required`,签名为空/不符 → 401)
|
|
212
|
+
语义:**请求方要求本端把请求方删掉**,故删除对象恒为 `auth.peerId`,**body 一律忽略**(否则任一已配对者可删掉别人的配对)。无 body 合法(canonical 的 body 摘要为 `sha256("")`)。
|
|
213
|
+
成功 → `200 {ok:true, removed:<bool>, peers:<剩余数>}`;同时作废该 peerId 挂起的 ECDH 握手,避免删除后又被 `/v1/pair/confirm` 写回。
|
|
214
|
+
|
|
215
|
+
`StateStore.removePeer(peerId)`:先重读 `peers.json`(同机 CLI 可能刚写过)→ 存在才删 → 0600 落盘 → 返回 `true/false`;幂等;清掉该 peer 的写盘节流记录。
|
|
216
|
+
|
|
217
|
+
### 13.3 管理接口(回环,仅 Host 本机)
|
|
218
|
+
|
|
219
|
+
`POST /api/lan-peers/unpair`,body `{peerId}`:
|
|
220
|
+
- 未知/缺失 peerId → 404 `{error:"unknown-peer"}`;
|
|
221
|
+
- **尽力通知对端**:有 `baseUrl` 且 `/v1/health` 的 capability 含 `unpair`(2s 探活、5s 调用)时用该 peer 的 `secret` 发签名 `POST /v1/unpair`;
|
|
222
|
+
- **对端失败绝不阻塞本地删除**(`unreachable`/`unsupported`/`no-address`/`no-secret`/错误原文都只记录);
|
|
223
|
+
- 响应 `{ok:true, peer:{peerId,name}, remote:{notified:<bool>, reason?:<string>}}`,不含任何 secret。
|
|
224
|
+
|
|
225
|
+
### 13.4 兼容
|
|
226
|
+
|
|
227
|
+
0.3.1 对端不声明 `unpair`:管理接口只发一次 `/v1/health` 探活即跳过通知(`reason:"unsupported"`),本地照删;老对端仍持有本端条目,但它再派单会被 401 `unknown-peer` 拒绝。互通矩阵 harness 六行断言在 unpair 加入后仍全 PASS。
|
|
228
|
+
|
|
229
|
+
### 13.5 验收
|
|
230
|
+
|
|
231
|
+
`removePeer` 落盘与幂等;`/v1/unpair` 未签名 401、只删请求方、body 指定他人无效、删除后再请求 401 `unknown-peer`、作废挂起握手;管理接口未知 → 404、对端不可达仍本地删除;CLI 未知/歧义 exit 2;两处界面「解除」按钮 + 二次确认 + 请求期禁用。
|
|
232
|
+
|
|
233
|
+
## 14. v0.5.0(在途,未发布):群聊(Phase1+2)
|
|
234
|
+
|
|
235
|
+
> 状态:实现并测试完成(全量 192/192),未发布、未部署。**只新增群相关端点与管理接口**,不改 §12/§13 的既有语义。
|
|
236
|
+
|
|
237
|
+
### 14.1 拓扑与原则
|
|
238
|
+
|
|
239
|
+
**hub 星型中继**:成员只与 hub 配对,hub 是**唯一顺序源**(`seq` 单调递增)。原因:群聊必须让所有人看到一致的消息次序;全互联需要 N(N-1)/2 次配对 + 分布式定序。capability 预留 `group`(本轮只实现 hub 模式)。
|
|
240
|
+
|
|
241
|
+
**关键取舍:不改 1:1 会话的归属校验**(§12 的 `/v1/answer`、`/v1/events` 仍只允许发起者本人读)。共享进度靠**发起端读自己的事件流、把摘要镜像给 hub**、hub 再扇出——这样不必为了群聊放开一条安全约束。
|
|
242
|
+
|
|
243
|
+
### 14.2 capability 与配置
|
|
244
|
+
|
|
245
|
+
capability 追加 `group` → `["ask","pair","pair-ecdh","stream","unpair","group"]`。老版本不认识即忽略。
|
|
246
|
+
|
|
247
|
+
```yaml
|
|
248
|
+
group:
|
|
249
|
+
enabled: false # 本机启用群功能(成员侧也要 true 才会解析 hub、镜像进度)
|
|
250
|
+
hub: "" # 成员侧:hub 的 peer 名字或 peerId;hub 本身留空
|
|
251
|
+
name: "default" # 群名(hub 首次初始化时使用)
|
|
252
|
+
autoJoin: false # hub 侧:是否允许已配对成员自助加入
|
|
253
|
+
retentionDays: 7 # 历史保留天数
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### 14.3 端点(仅 hub 提供;成员侧一律 404)
|
|
257
|
+
|
|
258
|
+
| 端点 | 说明 |
|
|
259
|
+
|---|---|
|
|
260
|
+
| `GET /v1/group/state` | `{groupId,name,epoch,keyVersion,members[],groupKey?}`;成员含 `groupKey`(响应已被该成员密钥加密);非成员 `403 not-a-member` |
|
|
261
|
+
| `POST /v1/group/join` | `autoJoin=true` 则加入;否则 `403` |
|
|
262
|
+
| `POST /v1/group/post` | `{kind∈chat|progress|result|system, text≤4000, meta}` → `201 {seq,ts}`;超长 `413` |
|
|
263
|
+
| `GET /v1/group/history?sinceSeq&limit` | `{groupId,epoch,lastSeq,messages[]}` |
|
|
264
|
+
| `GET /v1/group/events?sinceSeq` | SSE,`event: message` 逐帧加密(复用 `sseFrame`,逐帧新 nonce),`ping` 每 10s,断流用 `sinceSeq` 续传 |
|
|
265
|
+
|
|
266
|
+
`meta` 白名单:`peer/sessionId/phase/status/transport/elapsedMs/replyChars`,字符串值截断 200 字符。
|
|
267
|
+
|
|
268
|
+
### 14.4 存储(仅 hub 落盘)
|
|
269
|
+
|
|
270
|
+
`<stateDir>/group.json`(0600):`{groupId,name,createdAt,keyVersion,key,lastSeq,members{peerId:{name,addedAt}},messages[{seq,ts,from{peerId,name},kind,text,meta}]}`
|
|
271
|
+
- 读写都 prune:删 `retentionDays` 之前的消息;5000 条硬上限(丢最旧);
|
|
272
|
+
- `seq` 单调递增,**删旧消息不回退、号不复用**(客户端用 seq 去重)。
|
|
273
|
+
|
|
274
|
+
### 14.5 隐私边界(硬约束)
|
|
275
|
+
|
|
276
|
+
进度镜像只发**状态摘要**:`progress`(无 text)+ `result`(`text` 如 `done/1227ms/142字`,只含状态、耗时与长度)。**绝不发送 prompt 原文、回复全文、文件内容**。有单测断言。
|
|
277
|
+
|
|
278
|
+
### 14.6 管理接口(回环 `/api/lan-peers/group*`)
|
|
279
|
+
|
|
280
|
+
面板与 CLI 只经这里,**浏览器永不直连对端**:`GET /group?sinceSeq`(未启用返回 `{enabled:false,...}` 而非报错)、`POST /group/post|member|config|join`。hub 侧直读本地 `group.json`;成员侧由插件代理到 hub。`groupKey` 在适配层剥掉,不进管理接口、不进日志。
|
|
281
|
+
|
|
282
|
+
### 14.7 验收
|
|
283
|
+
|
|
284
|
+
三身份真进程组群、消息 `seq` 单调、实时 `tail`、非成员 `403`、hub 重启后历史可读、`retentionDays=0` 清旧、日志无 `groupKey`/`secret`/`text` 全文。
|
|
285
|
+
|
|
286
|
+
## 15. v0.6.0(在途,未发布):动态 hub 第一阶段(S1 成员互访 + S2 日志复制)
|
|
287
|
+
|
|
288
|
+
> 背景:§14 的群只有 hub 能服务端点,hub 一死成员彼此无法通信。本节让成员之间可认证互访、并各自保存日志副本,为 S3(选举)铺路。**凡 §14 已定义的端点语义不变**;`/v1/ask|answer|events|unpair` 与 1:1 会话归属校验**一行未动**。
|
|
289
|
+
|
|
290
|
+
### 15.1 群级鉴权(S1)
|
|
291
|
+
|
|
292
|
+
- 请求头 `X-Peer-Auth: group`(不带则仍走 per-peer 签名,行为不变);另带 `X-Peer-Group: <groupId>`、`X-Peer-Base: <advertiseBaseUrl>`、`X-Peer-LastSeq: <n>` 用于自报。
|
|
293
|
+
- **canonical 为 8 行**(与冻结契约文件一致;早先口头说的"7 行"是笔误):
|
|
294
|
+
```
|
|
295
|
+
GROUP
|
|
296
|
+
<METHOD>
|
|
297
|
+
<path 含 query>
|
|
298
|
+
<ts>
|
|
299
|
+
<nonce>
|
|
300
|
+
<groupId>
|
|
301
|
+
<senderPeerId>
|
|
302
|
+
<sha256(body) hex | GET 用 sha256("")>
|
|
303
|
+
```
|
|
304
|
+
- 签名密钥 `HKDF-SHA256(groupKey, salt="dsh-lan-peers/group-auth/v1", info="dsh-lan-peers/group-sign/v1")`;响应加密密钥 `info="dsh-lan-peers/group-body/v1"`;信封布局与 wire-crypto 相同(base64url(nonce‖ct‖tag)),**没有新造原语**。
|
|
305
|
+
- **路径白名单只有四条**:`/v1/group/probe`、`/v1/group/leader`、`/v1/group/pull`、`/v1/group/replicate`。其它路径带群头 → `401 {error:"group-auth-scope"}`(**群密钥不能驱动 ask/unpair**)。
|
|
306
|
+
- 校验顺序:groupId 一致 → senderPeerId 在成员表 → ts ±60s → nonce 120s 去重(独立命名空间,与 per-peer 分开)。
|
|
307
|
+
- 四个端点**同时接受群级签名与 per-peer 签名**(后者仍过成员门控),因此没有 `group-key.json` 的 CLI 也能排障。
|
|
308
|
+
|
|
309
|
+
### 15.2 成员可达地址
|
|
310
|
+
|
|
311
|
+
`group.advertiseBaseUrl` 配置优先;否则探测首选私有 IPv4(排除 loopback/docker/veth/br-/virbr/vmnet/vbox/vEthernet/hyper-v/tailscale/utun/wg 等虚拟网卡,只留 RFC1918;192.168 > 10/8 > 172.16/12;同档按网卡名稳定排序),拼上真实监听端口。地址写进群状态 `members[].baseUrl`,面板显示"别人看到的你的地址"。
|
|
312
|
+
|
|
313
|
+
### 15.3 日志复制(S2)
|
|
314
|
+
|
|
315
|
+
- 成员本地 `<stateDir>/group-replica.json`(0600):`{groupId, epoch, leaderPeerId, lastSeq, messages[]}`,与 hub `group.json` 同保留策略(retentionDays / 5000 条 / seq 单调不回退);按 seq 去重、只增不减;更高 epoch 才换 leader;groupId 不符拒收。
|
|
316
|
+
- 成员密钥单独存 `<stateDir>/group-key.json`(0600,**故意不塞进 replica**,保持 §14.4 形状冻结)。
|
|
317
|
+
- hub `post` 成功即提交(写本地),随后**入队异步**推送 `replicate` 给所有成员;失败留队 + 2s 重试,**绝不影响 `post` 的 201**。
|
|
318
|
+
- 成员每 `group.replicaPullMs`(默认 10s)`pull?sinceSeq=<local>&limit=200` 补差;启动时 bootstrap(per-peer 取 state 拿 groupKey,403 且 autoJoin 则先 join 再取)。
|
|
319
|
+
- **hub 拒收 replicate**(`409 not-a-replica`):只有成员保存副本,避免日志出现第二作者。
|
|
320
|
+
- **S2 提交语义**:写本地即提交、复制 best-effort。`group.quorum`(默认 `majority`、可配 `none`)**本轮只解析不生效**——两阶段提交必须与 S3 选举同时做,否则自相矛盾。S3 之前断电仍可能丢最后几条"已复制但未落盘"的消息。
|
|
321
|
+
|
|
322
|
+
### 15.4 成员进度(供面板显示)
|
|
323
|
+
|
|
324
|
+
hub 收到成员自报的 `X-Peer-LastSeq` 后记在**内存 Map**(不写 `group.json`,避免写放大),通过 `server.group.members()` 暴露;**从未上报的成员为 `null`**(不填 0,否则面板会把"从未同步"显示成"已同步 0 条")。
|
|
325
|
+
|
|
326
|
+
### 15.5 配置新增
|
|
327
|
+
|
|
328
|
+
```yaml
|
|
329
|
+
group:
|
|
330
|
+
advertiseBaseUrl: "" # 本机对外可达地址;空=自动探测
|
|
331
|
+
quorum: majority # majority | none(S3 生效;本轮仅解析)
|
|
332
|
+
replicaPullMs: 10000 # 成员拉取间隔
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### 15.6 验收(已执行,2026-10-27)
|
|
336
|
+
|
|
337
|
+
真进程 hub+成员 10/10:probe 成功;带群头 `POST /v1/ask` → 401 scope;`post` 后成员 replica 0ms 收到;把 replica 的 lastSeq 改 0 → `pull` 118ms 补齐;**杀 hub 后成员副本仍含全部消息**;日志含 `[group] probe|replicate|pull` 一行摘要且不含 groupKey/secret/正文。全量测试 232/232。
|
|
338
|
+
|
|
339
|
+
## 16. v0.7.0(在途,未发布):动态 hub(S3 选举 + 租约 + 两阶段提交)
|
|
340
|
+
|
|
341
|
+
> 目标:每个成员都具备 leader 能力,同一时刻只有一个 leader;leader 失效自动接任;**不丢已提交消息、不错序、不分裂**。只新增端点与字段,§14/§15 语义不变,1:1 归属校验一行未动。
|
|
342
|
+
|
|
343
|
+
### 16.1 角色与租约
|
|
344
|
+
|
|
345
|
+
- 角色状态落 `<stateDir>/group-role.json`(0600):`{role, epoch, leaderPeerId, leaseUntil, commitSeq, votedFor, groupId}`。
|
|
346
|
+
- hub(不配 `group.hub`)首次即 leader、epoch 取群的 keyVersion;成员首次从 follower 起;**文件存在即沿用**(重启不会把已降级的节点又抬成 leader);成员尚未 bootstrap(无 groupKey)时不落盘。
|
|
347
|
+
- 心跳 `POST /v1/group/leader`(与 §15 的 **GET 查询同路径**)每 `heartbeatMs`(默认 3000);租约 = `3×heartbeatMs`(默认 9000)。
|
|
348
|
+
- 租约检查 `roleTick` 200ms;过期后进入候选(随机抖动 0–500ms,抖动在"宣布候选"之前)。
|
|
349
|
+
|
|
350
|
+
### 16.2 选举与冲突裁决
|
|
351
|
+
|
|
352
|
+
- 候选发 `POST /v1/group/election` `{epoch, candidate, priority}`;投票条件:候选在成员表内、epoch 更新或同 epoch 优先级更高、本 epoch 未投他人(`votedFor` 持久化)。
|
|
353
|
+
- 票数 ≥ quorum 才上任,上任即广播心跳。
|
|
354
|
+
- 冲突裁决:**epoch 高者胜**;同 epoch 按 `hubPriority`(小者优先)→ `peerId` 字典序;非法自称 leader 收 `409 leader-conflict` 并降级。
|
|
355
|
+
- **已知抖动**:两台幸存者租约同刻到点时会连选一次(实测 epoch 3 → 4,最终收敛、seq 连续、无丢失)。缓解已做(落败轮次撤回自投票、让位给更合法一方),彻底消除需"落败者退避到租约过期后再参选"(下一轮)。
|
|
356
|
+
|
|
357
|
+
### 16.3 两阶段提交(`group.quorum: majority`)
|
|
358
|
+
|
|
359
|
+
- `post` → 分配 seq → 写入 `group.json` 的 `pending`(**与 messages 分开**,`state()` 暴露 `pendingCount`)→ `POST /v1/group/propose`。
|
|
360
|
+
- 成员把提案存本地 pending,**不对外可见**(不进 history/pull/events);ack 数达 quorum → leader 移入 messages、广播 `POST /v1/group/commit`、返回 201。
|
|
361
|
+
- 超时(`commitTimeoutMs` 默认 3000)或 ack 不足 → 丢弃 pending,返回 `503 quorum-unavailable`。**未提交消息永不对外可见**(有测试与真机断言)。
|
|
362
|
+
- `seq` 在 propose 时取 `lastSeq+1` 但**不推进 lastSeq**;提案被丢弃后该号可复用——这是"失败重试后 seq 连续无重复"的实现方式。
|
|
363
|
+
- `commit` 额外携带 `message`(追加字段):防"propose 丢包而 commit 到达"时把同号旧提案误转正;成员两者都按 seq 去重。
|
|
364
|
+
- `post` 提交串行化(postChain),并发不撞号。
|
|
365
|
+
- `group.quorum: none` → 跳过两阶段,leader 直接提交并返回 201(可用性优先,**明确承担分裂风险**,两节点场景适用)。
|
|
366
|
+
|
|
367
|
+
### 16.4 **quorum 分母(重要口径与风险)**
|
|
368
|
+
|
|
369
|
+
实现取 **`self + 在网络上自报过地址的成员`**,而非"成员表总数"。原因:从不上线的成员既收不到提案也不可能 ack,算进分母会让群**永远写不进去**(也会打破 S1/S2 里用假 peerId 的既有用例)。三成员真实群全员自报地址时,与契约"成员总数含自己"等价(实测:只剩 1 台时 quorum 仍为 2 → 拒写)。
|
|
370
|
+
**风险**:若某成员在成员表里但从未上报地址,分母会偏小,理论上降低分裂门槛。缓解建议(下一轮):把"未上报地址的成员数"暴露到管理接口与面板,让用户看见分母被削弱。
|
|
371
|
+
|
|
372
|
+
### 16.5 端点(群级鉴权白名单共 **8 条**)
|
|
373
|
+
|
|
374
|
+
`probe` · `replicate` · `pull` · `leader`(**GET=查询 / POST=心跳**)· `election` · `propose` · `commit` · `lease`(只读租约快照,为 CLI 与排障补的第 8 条)。
|
|
375
|
+
|
|
376
|
+
其它路径带 `X-Peer-Auth: group` 一律 `401 group-auth-scope`。
|
|
377
|
+
|
|
378
|
+
**post 的可用性语义**:非 leader 节点不提供 `/v1/group/post`(仍按 v0.5 返回 404);leader 拿不到多数派 → `503 quorum-unavailable`。客户端应把两者都视为"本次未被接受",重试即可。
|
|
379
|
+
|
|
380
|
+
### 16.6 配置新增
|
|
381
|
+
|
|
382
|
+
```yaml
|
|
383
|
+
group:
|
|
384
|
+
heartbeatMs: 3000 # 心跳间隔;租约 = 3×
|
|
385
|
+
commitTimeoutMs: 3000 # propose→commit 超时
|
|
386
|
+
hubPriority: 0 # 数值小者优先当 leader;同值按 peerId 字典序
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### 16.7 管理与 CLI
|
|
390
|
+
|
|
391
|
+
- `GET /api/lan-peers/group` 追加 `role / leaseUntil / quorum / commitSeq / pendingCount / degraded`(只加不改,未启用分支形状不变)。
|
|
392
|
+
- `POST /api/lan-peers/group/leader {action:"claim"}` → 走真实选举(epoch = max(当前+1, 传入)),赢票即广播心跳。
|
|
393
|
+
- CLI:`group leader [--peer X]`(本地角色 + 可查对端 lease)、`group claim`(本机立刻发起选举)。
|
|
394
|
+
- `server.group.role()` → `{role, epoch, leaderPeerId, leaseUntil, commitSeq, pendingCount, votedFor, groupId, degraded, quorum}`;`server.group.claim()` → `{ok, role, epoch, leaderPeerId, reason, votes, needed}`。
|
|
395
|
+
|
|
396
|
+
### 16.8 验收(2026-10-27 实测)
|
|
397
|
+
|
|
398
|
+
全量 **269/269**;三成员真进程:30s 内**选举 0 次**;`claim` → epoch 2 且成员可当 leader 写;杀 leader **6963ms 接任**、epoch+1、恢复后 `seqs=[1,2,3]` 连续无重复;少数派 `post` 失败且 marker **不在任何成员 history/副本**;`quorum:none` 单活可写;`[group]` 日志 105 行、密钥与正文痕迹 0、定时器全 `unref` 且 `close()` 清理。
|
package/HANDOVER.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# dsh-lan-peers 交接文档
|
|
2
2
|
|
|
3
3
|
> 面向接手开发/运维的人。读完这一份应该能:改代码、跑测试、发版、更新部署、排查常见故障。
|
|
4
|
-
> 最后更新:2026-10-27 · 对应版本 `0.4.0`(ECDH 配对 + 流式回传;兼容 0.3.1
|
|
4
|
+
> 最后更新:2026-10-27 · 对应版本 `0.4.0`(ECDH 配对 + 流式回传;兼容 0.3.1。**已发布 npm 0.4.0、NAS 已升级;桌面版待重启生效**)
|
|
5
5
|
|
|
6
6
|
## 1. 这是什么
|
|
7
7
|
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
| 位置 | 形态 | 版本 |
|
|
13
13
|
|---|---|---|
|
|
14
|
-
| npm | 公开包 `dsh-lan-peers`,`latest` | 0.
|
|
15
|
-
| NAS `192.168.5.51`(fn-302,Debian 12) | `link:` 安装进 `/home/ai/.dsh/profiles/web`,systemd `dsh-web` 托管 | 0.
|
|
16
|
-
| Windows 桌面版(Electron) |
|
|
14
|
+
| npm | 公开包 `dsh-lan-peers`,`latest` | **0.4.0**(2026-10-27 发布,34 文件,shasum `204999eb…`)|
|
|
15
|
+
| NAS `192.168.5.51`(fn-302,Debian 12) | `link:` 安装进 `/home/ai/.dsh/profiles/web`,systemd `dsh-web` 托管 | **0.4.2**(2026-10-27 20:35 部署;含 unpair + admin 审计日志)|
|
|
16
|
+
| Windows 桌面版(Electron) | `link:D:/DS-Harness/lan-peers`(见 §7)| **0.4.0**(2026-10-27 重启生效;面板与 SSE 派单已实测)|
|
|
17
17
|
|
|
18
18
|
NAS 与桌面已互相配对,双向派单与加密都实测通过。
|
|
19
19
|
|
|
@@ -25,7 +25,7 @@ lan-peers/
|
|
|
25
25
|
├── cordis.patch.yml bundle 默认行(listenPort 7788,默认拒绝远端派单)
|
|
26
26
|
├── host/
|
|
27
27
|
│ ├── server.mjs 1610 行 · 协议服务端:签名、加密、配对/ECDH、ask/answer/events、CIDR 白名单
|
|
28
|
-
|
|
28
|
+
auth/api/encryption/pairing-ecdh/stream/unpair 测试 + helpers;lib/admin-unpair、lib/panel-drag、lib/panel-unpair、client/cli-unpair 等(全量 129 条)
|
|
29
29
|
├── lib/
|
|
30
30
|
│ ├── index.js 181 行 · DSH 插件入口:解析服务、起 server、注册工具/路由/注入
|
|
31
31
|
│ ├── client.js 205 行 · 桌面/网页客户端组件(__ModuleLoader__ + slots + 派单实时视图)
|
|
@@ -35,6 +35,7 @@ lan-peers/
|
|
|
35
35
|
│ ├── runner-dsh.mjs 112 行 · HTTP runner(备用,需要本机 token)
|
|
36
36
|
│ ├── wire-crypto.mjs 51 行 · HKDF + AES-256-GCM
|
|
37
37
|
│ ├── pairing-ecdh.mjs 139 行 · v0.4.0 · P-256 两轮配对密钥:generateKeyPair / deriveWrapKey / wrapSecret / unwrapSecret
|
|
38
|
+
│ ├── group-client.mjs ~340 行 · v0.5.0 · 群聊客户端助手:fetchGroupState/postGroup/fetchGroupHistory/readGroupEvents/joinGroup/createGroupMirror
|
|
38
39
|
│ └── dsh-tools.mjs ~30 行 · defineTool 的稳健解析(CLI / Electron asar / cwd)
|
|
39
40
|
├── client/
|
|
40
41
|
│ ├── cli.mjs 351 行 · code / pair / peers / ask / events(也是 npm bin)
|
|
@@ -44,7 +45,8 @@ lan-peers/
|
|
|
44
45
|
├── overlays/ web.patch.yml · desktop.patch.yml(部署用覆盖片段)
|
|
45
46
|
├── README.md 面向使用者的安装文档
|
|
46
47
|
├── DESIGN.md 协议契约(签名、加密、端点、安全默认、验收用例)
|
|
47
|
-
|
|
48
|
+
├── PUBLISH.md 发版步骤
|
|
49
|
+
└── interop/ v0.4.0 · 新旧互通 harness(run-interop.mjs + README):真实进程跑六行兼容矩阵;**不在 npm 包里**,纯仓库资产
|
|
48
50
|
```
|
|
49
51
|
|
|
50
52
|
约定:`host/` 可以脱离 DSH 独立运行(`node host/server.mjs`),`lib/` 才依赖 DSH 的 ctx。
|
|
@@ -59,9 +61,17 @@ lan-peers/
|
|
|
59
61
|
| `POST /v1/ask` | 签名+加密 | `{requestId, prompt, timeoutSec}` → `202 {sessionId}` |
|
|
60
62
|
| `GET /v1/answer?sessionId=&waitMs=` | 签名+加密 | 无 `waitMs` → 立即返回(0.3.1 语义);有则 hold 到终态或 ≤25s,始终附 `elapsedMs` |
|
|
61
63
|
| `GET /v1/events?sessionId=` | 签名 | v0.4.0 · SSE 事件流(status/done/error/ping),**每条事件独立加密**,终态断流 |
|
|
64
|
+
| `POST /v1/unpair` | 签名+加密 | v0.4.1 · **只删请求方自己**(`auth.peerId`,body 一律忽略);无 body 合法;200 `{ok, removed, peers}`;顺带作废该 peer 挂起的 ECDH 握手 |
|
|
65
|
+
| `GET /v1/group/state` | 签名+加密 | v0.5.0 · 仅 hub 提供:`{groupId,name,epoch,keyVersion,members,groupKey?}`;非成员 `403 not-a-member` |
|
|
66
|
+
| `POST /v1/group/join` | 签名+加密 | `autoJoin=true` 才允许自助加入,否则 403 |
|
|
67
|
+
| `POST /v1/group/post` | 签名+加密 | `{kind∈chat/progress/result/system, text≤4000, meta}` → `201 {seq,ts}`;超长 413 |
|
|
68
|
+
| `GET /v1/group/history?sinceSeq&limit` | 签名+加密 | `{groupId,epoch,lastSeq,messages}` |
|
|
69
|
+
| `GET /v1/group/events?sinceSeq` | 签名 | SSE:`event: message` 逐帧加密(复用 `sseFrame`)、`ping` 10s、断流用 sinceSeq 续传 |
|
|
62
70
|
| `GET /v1/peers` | 签名+加密 | 对端视角的伙伴列表(不含密钥) |
|
|
63
71
|
|
|
64
|
-
|
|
72
|
+
`capability` 取值:`ask` · `pair` · `pair-ecdh`(v0.4.0)· `stream`(v0.4.0)· `unpair`(v0.4.1)· `group`(v0.5.0)。全部只增不减,旧客户端按不认识即忽略。
|
|
73
|
+
|
|
74
|
+
签名:`HMAC-SHA256
|
|
65
75
|
|
|
66
76
|
加密:`X-Peer-Enc: v1`;密钥 = `HKDF(secret, salt="dsh-lan-peers/v1", info="…/body-encryption/v1")`;`base64url(nonce‖ct‖tag)`;响应 `{"enc":"v1","data":…}`。
|
|
67
77
|
|
|
@@ -88,7 +98,7 @@ lan-peers/
|
|
|
88
98
|
cd lan-peers
|
|
89
99
|
node --test host/test/auth.test.mjs host/test/api.test.mjs host/test/encryption.test.mjs \
|
|
90
100
|
client/protocol.test.mjs lib/wire-crypto.test.mjs # 43 条,应全绿(v0.4.0 起其中 1 条 capability 断言随契约升级)
|
|
91
|
-
node --test host/test/*.test.mjs lib/*.test.mjs client/*.test.mjs #
|
|
101
|
+
node --test host/test/*.test.mjs lib/*.test.mjs client/*.test.mjs # 全量:269 条)(0.4.1 候选起含 26 条 unpair 相关测试 + 面板拖动/夹取行为测试)
|
|
92
102
|
|
|
93
103
|
# 脱离 DSH 起一个独立 host(内存 runner,方便调协议)
|
|
94
104
|
node host/server.mjs --port 7799 --state-dir /tmp/lp --allow-remote-prompts --name dev
|
|
@@ -102,11 +112,20 @@ node client/cli.mjs ask --peer dev "hi" --state-dir /tmp/lp-cli
|
|
|
102
112
|
node client/cli.mjs pair --url http://127.0.0.1:7799 --code 123456 --name dev # 默认 auto:能 ECDH 就 ECDH
|
|
103
113
|
node client/cli.mjs pair --url http://127.0.0.1:7799 --code 123456 --legacy-pair
|
|
104
114
|
node client/cli.mjs events --peer dev --session <id> --duration 30
|
|
115
|
+
node client/cli.mjs unpair --peer dev # v0.4.1:解除配对(尽力通知对端,失败只警告)
|
|
116
|
+
node client/cli.mjs unpair --peer dev --local-only
|
|
117
|
+
|
|
118
|
+
# v0.5.0 群聊(成员视角;hub 需在 config 里 group.enabled=true)
|
|
119
|
+
node client/cli.mjs group join --peer dev --state-dir /tmp/lp-cli
|
|
120
|
+
node client/cli.mjs group state --peer dev --state-dir /tmp/lp-cli
|
|
121
|
+
node client/cli.mjs group post --peer dev --text "hello" --state-dir /tmp/lp-cli
|
|
122
|
+
node client/cli.mjs group history --peer dev --since 0 --state-dir /tmp/lp-cli
|
|
123
|
+
node client/cli.mjs group tail --peer dev --since 0 --duration 30 --state-dir /tmp/lp-cli
|
|
105
124
|
node client/cli.mjs ask --peer dev "hi" --legacy-poll # 强制 1.5s 轮询,验证旧路径
|
|
106
125
|
|
|
107
126
|
# 新旧互通矩阵(真实进程:两个 host + 两侧真 CLI;约 17s,退出码即结论)
|
|
108
|
-
node
|
|
109
|
-
#
|
|
127
|
+
node interop/run-interop.mjs
|
|
128
|
+
# 只测基线时(把「被测」指向 0.3.1 快照):INTEROP_NEW_ROOT=../.tmp/baseline-0.3.1 node interop/run-interop.mjs → A–D PASS、E/F 自动 SKIP
|
|
110
129
|
```
|
|
111
130
|
|
|
112
131
|
**日志**:插件每次 apply 都往 `~/.dsh/lan-peers.log` 追加一行(`module imported` / `services:` / `listening` / `failed to listen`)。Electron 的 stdout 拿不到,这是唯一可靠的诊断入口。
|
|
@@ -128,7 +147,8 @@ npm publish # 需要登录;见下
|
|
|
128
147
|
|
|
129
148
|
**两个已踩过的坑**:
|
|
130
149
|
|
|
131
|
-
- **2FA 暂存发布**:账号开了 2FA
|
|
150
|
+
- **2FA 暂存发布**:账号开了 2FA 时若用 Classic token,`npm publish` 会返回 0 但只创建 `0.0.0-stage` 占位,必须去 npmjs.com 的包页面点 Approve。**v0.4.0 实测:本机 `~/.npmrc` 用的是 granular token(`npm_…`),`npm publish` 直接成功、无 OTP 交互**;若哪天换回 Classic token 就会重新踩这个坑。想跳过一次性的 2FA 就用 Automation/granular token。
|
|
151
|
+
- **注册表传播有延迟**:`npm publish` 成功(返回 `+ pkg@ver`)后 `npm view <pkg> version` 仍可能返回旧版本约 1 分钟(本次轮询到第 5 次、约 60s 才见 0.4.0)。发版验证脚本要带重试,别据此误判失败。
|
|
132
152
|
- **`bin` 路径**:必须是 `client/cli.mjs`(不能带 `./`),否则 npm 会静默剔除 bin,`npx` 不可用。
|
|
133
153
|
|
|
134
154
|
发完验证:
|
|
@@ -160,6 +180,7 @@ ssh ai@192.168.5.51 "sudo systemctl restart dsh-web"
|
|
|
160
180
|
4. 重启桌面版。
|
|
161
181
|
|
|
162
182
|
改完文件不必重启:**改 profile 的 `cordis.patch.yml` 会触发热重载**(改包内文件不会,需要动一下 profile 文件)。
|
|
183
|
+
> ⚠️ **2026-10-27 实测反例**:桌面版(0.4.0,Windows)`touch ~/.dsh/profiles/desktop/cordis.patch.yml` **没有**触发热重载——`~/.dsh/lan-peers.log` 无新的 `listening` 行,`/api/lan-peers/panel.js` 仍返回旧内容。改动包内 `lib/` 后要生效必须**重启桌面应用**。这条待复核是否与版本有关,暂勿再把它当作可靠热重载手段。
|
|
163
184
|
|
|
164
185
|
## 8. 回归检查清单(发版/部署后跑)
|
|
165
186
|
|
|
@@ -175,7 +196,7 @@ node lan-peers/client/cli.mjs ask --peer <对端名> "回归测试:只回复
|
|
|
175
196
|
# 用 .tmp/plain-probe.mjs 一类的脚本发一次不带 X-Peer-Enc 的签名请求 → 400 encryption-required
|
|
176
197
|
|
|
177
198
|
4) 新旧互通矩阵(v0.4.0 起必跑)
|
|
178
|
-
node
|
|
199
|
+
node interop/run-interop.mjs # A 旧→新明文+弃用告警 / B 新→旧回退明文 / C 无 waitMs 语义 / D 回退1.5s轮询 / E ECDH 两轮 / F SSE
|
|
179
200
|
|
|
180
201
|
5) 从 npm 真装
|
|
181
202
|
# 临时 profile:dependencies: {dsh-lan-peers: "^0.4.0"} + bundles 加上它 → 启动 → 看 7788
|
|
@@ -207,7 +228,10 @@ node .tmp/interop/run-interop.mjs # A 旧→新明文+弃用告警 / B 新→
|
|
|
207
228
|
|
|
208
229
|
- **npm 账号**:`hanson_sun`(包 maintainer)。发布凭据保存在本机 `~/.npmrc`(token),建议定期轮换。
|
|
209
230
|
- **代码位置**:`D:\DS-Harness\lan-peers`(唯一权威副本;NAS 与桌面都是 `link:` 指向它或同步副本)。
|
|
210
|
-
- **NAS 凭据**:SSH 密钥在 `D:\DS-Harness\ssh-agent-auth\ai_ed25519
|
|
231
|
+
- **NAS 凭据**:SSH 密钥在 `D:\DS-Harness\ssh-agent-auth\ai_ed25519`(**带 passphrase**,密码不在 `passphrase.txt`——该文件不存在,实际在 `~/.dsh/dsh-ssh.json`);sudo 密码用 DPAPI 加密(脚本 `~/.dsh/scripts/fnos-secret.ps1`,`get` 取 base64)。
|
|
232
|
+
**sudo 提权推荐写法(2026-10-27 验证,比文档旧写法更干净)**:本机取 DPAPI base64 时用 `$env:USERPROFILE`(git-bash 下 `$HOME` 是 `/c/Users/user`,PowerShell 会因 POSIX 路径报 `CommandNotFoundException`,曾导致 restart 静默失败);然后把 base64 **经 SSH stdin** 喂给远端:`printf '%s\n' "$b64" | ssh … "base64 -d | sudo -S -p '' <cmd>"` —— 密码不进远端命令行/ps/日志/回显。旧写法 `echo '<b64>' | base64 -d` 拼进远程命令也行,但更容易在引号里出错。
|
|
233
|
+
**已知残留**:NAS 上仍留有 0.3.x 时期的 `PUBLISH.md`(部署包不含该文件,`--overwrite` 只覆盖不删除),无害。
|
|
234
|
+
**两个已踩的坑**:① Windows 下私钥 ACL
|
|
211
235
|
- **配置位置**:NAS `/home/ai/.dsh/profiles/web/{package.json,cordis.patch.yml}`;Windows `C:\Users\user\.dsh\profiles\desktop\{package.json,cordis.patch.yml}`。
|
|
212
236
|
- **状态/密钥**:各实例 `stateDir` 下的 `state.json`、`peers.json`(0600),含 instanceId 与伙伴密钥,**不要提交或外传**。
|
|
213
237
|
|
|
@@ -224,7 +248,7 @@ node .tmp/interop/run-interop.mjs # A 旧→新明文+弃用告警 / B 新→
|
|
|
224
248
|
| `/v1/events` SSE(逐事件加密、fetch 流式读) | host 道 + 客户端道 | **已落地并验收** | §2 结构(已改)、§3(已改)、§4(已改) |
|
|
225
249
|
| `lib/pairing-ecdh.mjs` 共享加密助手 | host 道 | **已落地并验收**(139 行,冻结 4 函数 + 附加导出) | §2 结构(已改)、DESIGN §12.4 |
|
|
226
250
|
| `lan_peer_ask` 长轮询化 + 面板实时状态 + CLI 两轮配对/`events` | 客户端道 | **已落地并验收**(96/96 全量绿;真进程新↔新走 SSE、新→旧走 1.5s 轮询) | §2 结构、§5 命令 |
|
|
227
|
-
| 新-旧互通 harness(真实进程) | 互通道 |
|
|
251
|
+
| 新-旧互通 harness(真实进程) | 互通道 | **已落地并验收**,已搬入仓库 `lan-peers/interop/`(六行全 PASS;搬家后从新位置复跑仍 exit 0;不进 npm 包;`INTEROP_OLD_ROOT` 默认指向 `.tmp/baseline-0.3.1`)| §2 结构、§5、§8(已改)|
|
|
228
252
|
| capability 追加 `pair-ecdh`/`stream`;legacy 路径保留 + 弃用告警 | host 道 | 实现中 | §3、§9(配对明文行改为"仅 legacy") |
|
|
229
253
|
|
|
230
254
|
**缺陷记录(v0.4.0 期间,均为真进程实测发现)**
|
|
@@ -242,4 +266,166 @@ node .tmp/interop/run-interop.mjs # A 旧→新明文+弃用告警 / B 新→
|
|
|
242
266
|
- `peers.json` 每条 peer 多存 `transport: 'ecdh'|'legacy'`,`upsertPeer` 合并写入,旧读取方无感。
|
|
243
267
|
- `host/test/auth.test.mjs` 的 capability 断言随契约升级
|
|
244
268
|
|
|
245
|
-
|
|
269
|
+
| NAS 部署 + 真实派单 | 交付道 | **已完成**(health 0.4.0、日志有新 `listening`、派单 1.58s 走 `/v1/events`、4 条配对未变、`/tmp/lan-peers-backup-0.3.1.tgz` 留作回滚)| §1(已改)、§7 |
|
|
270
|
+
| npm publish 0.4.0 | 交付道 | **已完成**(`latest=0.4.0`;临时 profile 真装验证 version/exports/bin/新文件全对)| §1(已改)、§6 |
|
|
271
|
+
| 桌面版重启加载 0.4.0 | 交付道 | **已完成并复核**(重启后 health=0.4.0 且 capability 齐全;插件工具 `lan_peers` 可见 `fn-302` 在线;`lan_peer_ask` 派单 dispatch 台账 `transport:"sse"`、980ms;`/api/lan-peers/panel.js` 200/7220B 且 `node --check` 通过)| §1(已改)、§7 |
|
|
272
|
+
|
|
273
|
+
## 13. 在途未发布改动(0.4.1 候选,用户要求暂缓发布)
|
|
274
|
+
|
|
275
|
+
### 13.1 伙伴按钮为什么可能「不见」——已加自愈与埋点
|
|
276
|
+
|
|
277
|
+
按钮只有两条来源:桌面窗口里走 React 组件(注册到 slot `conversation.composer.dock`),网页 UI 里走注入脚本 `panel.js`。**槽位在某个窗口/视图里可能压根不挂载**,于是按钮就消失(现象是「一会儿有、重启后没有」)。
|
|
278
|
+
|
|
279
|
+
因此加了两条保障:
|
|
280
|
+
|
|
281
|
+
1. **桌面端以 body 级面板为主**(2026-10-27 定案):客户端模块注册槽位后,1.5s 时 `fetch('/api/lan-peers/panel.js')` 取回独立面板并**内联执行**(body 级、与 web 端同一份实现),成功后把槽位里那个 React 浮层隐藏,保证界面上只有一个按钮;**取不到脚本时保留 React 浮层**,不会退化成「没有按钮」。
|
|
282
|
+
**为什么不再信槽位浮层**:实测 `launcher-rect` 为 `{"x":1107,"y":421,"w":78,"h":37,"vw":1193,"vh":737}`——矩形完全在视口内、`component-mounted` 也正常,但人眼看不到。原因是 `position: fixed` 遇到带 `transform`/`filter` 的祖先会退化成容器相对定位,并且 `z-index` 只在**自己所在的层叠上下文**里有效:祖先层低于兄弟层时按钮就被画在下面。**判断"有没有按钮"必须做命中测试(`elementFromPoint`),不能只看 DOM 存在性或矩形。**
|
|
283
|
+
**为什么脚本要 fetch 后内联**:`<script src="/api/…">` 在 `dsh-app://` 自定义协议下不保证被服务;相对 `fetch('/api/…')` 走 IPC 桥,确定可用。
|
|
284
|
+
2. **界面上报**:客户端把 `module-loaded` / `slots` / `slot-registered` / `component-mounted` / `fallback-panel-inject` / `window-error` / `unhandled-rejection` 上报到 `POST /api/lan-peers/client-log`,写入 `~/.dsh/lan-peers.log`(前缀 `[client]`)。桌面窗口没有 devtools,这是唯一能看到界面侧异常的通道。
|
|
285
|
+
|
|
286
|
+
**用户确认(2026-10-27)**:按钮可见、可拖动;点开后伙伴行 `fn-302 · http://192.168.5.51:7788` 右侧有「解除」,点击有二次确认——**入口与功能均已确认可用**。
|
|
287
|
+
|
|
288
|
+
**「为什么有两个按钮」(2026-10-27 用户提出)**:过渡期的双保险——槽位里的 React 浮动按钮 + 注入的 body 级面板,两者同时存在。现在改为**单入口**:注入成功后广播 `dsh-lan-peers:standdown`,React 组件据此 `return null` **整体退场**(不再只靠 `style.display='none'`,因为 React 重渲染可能把命令式样式冲掉);注入失败则 React 那份保留,不会变成"没有按钮"。**注意**:该改动需重启应用才生效;重启前仍会看到两个按钮。断言:`lib/client-render.test.mjs` 的「body 级面板接管后 React 面板整体退场」。
|
|
289
|
+
|
|
290
|
+
**2026-10-27 最终结论**:让按钮真正出现的,是 **body 级注入面板**
|
|
291
|
+
|
|
292
|
+
**环境局限(别再踩)**:这台机器上 PowerShell `CopyFromScreen` 抓到的是 1194×785 的虚拟屏,与用户实际显示不一致,**远端截图不能作为"界面上有没有某个东西"的证据**;同理 `elementFromPoint` 的命中结果也可能与肉眼所见不符。判断界面问题时,以用户眼睛为准。
|
|
293
|
+
|
|
294
|
+
**偶发失败**:`host/test/auth.test.mjs` 在**全量并行**跑时偶发 1 条超时(`helpers.mjs:160` 请求超时),单独跑 18/18 稳定、复跑全量 139/139 稳定。属负载敏感,不是回归。
|
|
295
|
+
|
|
296
|
+
**排障顺序**(照这个走,不要靠猜)
|
|
297
|
+
|
|
298
|
+
历史记录:重启后短时间内看不到按钮曾被我误判为「代码没生效」,还擅自把伙伴列表挪到面板顶部(已撤回)。真正原因是槽位挂载时机。
|
|
299
|
+
|
|
300
|
+
重启后短时间内**看不到** `🔗 伙伴` 按钮是正常的:插件 `apply()` 完成才注册客户端 slot / 注入面板脚本,而应用窗口先渲染。实测本次重启:插件 `listening` 在 12:00:42Z,按钮在之后几秒才出现。**排障时不要据此判定"改动没生效"**——先等几秒或重开面板,再核对 `/api/lan-peers/panel.js` 与 `/v1/health` 的 capability。(2026-10-27 我因此误判过一次,还擅自把伙伴列表挪到面板顶部,已撤回。)
|
|
301
|
+
|
|
302
|
+
### 13.0 NAS 上 4 条配对变成 1 条(2026-10-27,未完全归因)
|
|
303
|
+
|
|
304
|
+
20:28 部署后核对为 4 条,20:30 再配对时 `peers.json` 只剩 1 条(3 条消失,**无备份、密钥已丢、无法恢复**)。已排除的:
|
|
305
|
+
- **不是配对逻辑**:隔离实验(4 条假伙伴 + 一次真实 ECDH 配对)→ 计数 4→5,`upsertPeer` 合并正常;
|
|
306
|
+
- NAS 请求日志显示 20:28:37 之后到 20:30:35 之间**没有任何 `/v1/*` 请求**;
|
|
307
|
+
- 因此只可能是期间通过 **NAS 自己的网页面板(admin 接口)** 被解除——**而当时 admin 接口完全没有日志**,所以无法归因。
|
|
308
|
+
|
|
309
|
+
**已修的两个真实缺口**:
|
|
310
|
+
1. **admin 动作现在全部留痕**:`[admin] rename / pairing code issued ttl=…s(不含码)/ pair -> <name> (id8) transport=… / unpair <id8> name=… removed=… remote=notified|local-only:<reason>`,未知 peer 的解除也记一行(`-> unknown-peer (nothing changed)`)。新增 `lib/admin-actions.test.mjs` 3 条断言锁住。
|
|
311
|
+
2. **CLI `pair --name` 语义 bug**:`--name` 本是「对端显示名」,却被当成**自己的名字**发给对端(`peerName: name`),导致 NAS 把我们的记录命名成了 `fn-302`(与它自己同名)。已改为 `peerName = state.json 里的本机 name`,`--name` 只作为本地对端标签。**注意**:NAS 上那条既有记录仍是旧名字,下次重新配对会被覆盖纠正。
|
|
312
|
+
|
|
313
|
+
> **已上线并实测**:0.4.2 部署后,NAS 上触发一次发码即出现 `[admin] pairing code issued ttl=600s`,且对该行做 `[0-9]{6}` 扫描命中 0(码不进日志)。
|
|
314
|
+
|
|
315
|
+
> 教训:**管理动作也要留日志**
|
|
316
|
+
|
|
317
|
+
### 13.2 手动取消配对(unpair)
|
|
318
|
+
|
|
319
|
+
**配对链路恢复(2026-10-27 20:30)**:用一次标准 ECDH 配对恢复(NAS 出码 → 桌面 CLI 配),桌面侧重新得到 `fn-302`;`lan_peers` 显示在线、`lan_peer_ask` 实测 reply「链路恢复」。
|
|
320
|
+
|
|
321
|
+
**首次真实使用记录(2026-10-27 20:21:45)**
|
|
322
|
+
|
|
323
|
+
**背景**:`lib/admin.mjs` 原本有个 `unpair` 路由,但它是桩——调用不存在的 `store.removePeer`,永远返回 `501`;两处界面都没有入口。
|
|
324
|
+
|
|
325
|
+
**实现**:`StateStore.removePeer`(先读盘、存在才删、0600 落盘、幂等、清写盘节流记录)· `POST /v1/unpair`(签名+加密,**只删请求方自己**,body 忽略,作废其挂起 ECDH 握手)· `POST /api/lan-peers/unpair`(未知 peerId → 404;先尽力通知对端——2s 探活 + capability 门控 + 5s 调用,**任何失败只记原因,本地一定删**)· CLI `unpair`(按 name/peerId 精确解析、同名歧义 exit 2 列候选、`--local-only`、`--timeout`)· 两处界面各加带 `window.confirm` 的「解除」按钮(请求期禁用)。
|
|
326
|
+
|
|
327
|
+
**为什么只删请求方自己**:`/v1/unpair` 若接受 body 指定 peerId,等于让任一已配对者删掉别的配对;实现里显式忽略 body,并由测试断言覆盖。
|
|
328
|
+
|
|
329
|
+
**验证**:全量 **129/129 绿**(新增 26 条:host 9 · admin 5 · panel 6 · CLI 6);互通矩阵 A–F 仍全 PASS(0.3.1 兼容未退化);临时双 host 端到端解除后两侧 `peers` 均 1→0,对端日志有 `POST /v1/unpair 200`;**真实 `fn-302` 配对数据零接触**(`~/.dsh/lan-peers-desktop/peers.json` mtime 仍是 10-26 22:11)。
|
|
330
|
+
|
|
331
|
+
**授权变更的既有断言**:capability 记账三处(`auth.test.mjs:94` + `pairing-ecdh.test.mjs:260/268`)随契约追加 `unpair`,均保持 `deepEqual` 精确匹配,语义未放宽。
|
|
332
|
+
|
|
333
|
+
**未做**:未改版本号、未发布、未部署;桌面端要看到「解除」按钮需重启应用。
|
|
334
|
+
|
|
335
|
+
**改动**:伙伴浮动按钮挡住 DSH 自己的设置入口(两者都在左下角)。现改为:默认 **右下角**,可指针拖动到任意位置、视口夹取、4px 阈值不影响点击、结果存 `localStorage['dsh-lan-peers:launcher:v1']`(网页注入面板与 React 面板共用同一 key),方向键可微调(`Shift` 加速)。面板会跟随按钮左右/上下翻转并夹在视口内。
|
|
336
|
+
|
|
337
|
+
**落点**:`lib/client.js`(React,桌面/网页 app 内)与 `lib/admin.mjs` 的 `PANEL_SCRIPT`(网页注入脚本)各自一份等价实现——两者无法共享代码,改一处必须同步另一处。
|
|
338
|
+
|
|
339
|
+
**验证**:`lib/panel-drag.test.mjs` 用最小 DOM 桩执行 `PANEL_SCRIPT` 并模拟指针事件,7 条断言全绿(默认右下 / 拖动写位置 / <4px 抖动不算拖动 / 拖后不误触开关 / 拖到按钮外松手不吞下一次点击 / 拖出视口夹回 / 存档位置被采用);全量 103/103 绿。
|
|
340
|
+
|
|
341
|
+
**自己修掉的一个陷阱**:拖动如果在按钮外松手,浏览器可能不派发 `click`,用于「吞掉拖后那次 click」的标记会残留并吞掉下一次真点击。已改为每次 `pointerdown` 先清标记,并由上面第 5 条断言锁住。`lib/client.js` 只做了语法校验与代码走查——**React 那份没有行为测试**(无 React 运行环境),必须靠人眼在应用里确认。
|
|
342
|
+
|
|
343
|
+
**用户确认(2026-10-27,重启后)**:按钮默认在右下角、可拖动、松手后记住位置,设置入口不再被压——**已解决**。
|
|
344
|
+
|
|
345
|
+
**未做**:未改版本号、未发布 npm、未部署 NAS(用户要求暂缓)。桌面端要看到效果必须重启应用(见 §7 的热重载反例)。
|
|
346
|
+
|
|
347
|
+
**重启后复核(2026-10-27,桌面版 0.4.0 生效)**
|
|
348
|
+
|
|
349
|
+
- 本机 `/v1/health` → `{"version":"0.4.0","capability":["ask","pair","pair-ecdh","stream"],"peers":1}`(重启前是 0.3.1/`["ask","pair"]`)。
|
|
350
|
+
- 端到端派单(经插件工具而非 CLI):`lan_peer_ask fn-302 "只回复四个字:重启成功"` → reply `重启成功`,980ms;`/api/lan-peers/dispatch` 台账 `transport:"sse"`、`elapsedMs:896`。**新-新 SSE 路径在真实 DSH 内确认可用**。
|
|
351
|
+
- 面板注入脚本 `/api/lan-peers/panel.js` → 200、7220B、`node --check` 语法通过(视觉呈现仍需人眼看一眼:左下角 🔗 面板的派单实时状态)。
|
|
352
|
+
|
|
353
|
+
**发布记录**
|
|
354
|
+
|
|
355
|
+
- 0.4.0 · 2026-10-27 · `npm publish` 成功(granular token,无 OTP)·34 文件 / 87.9 kB · shasum `204999ebe7fa3190fd2603c81ab4d96767e52538`;NAS 与 npm 均已 0.4.0,桌面待重启。
|
|
356
|
+
- **0.4.1 → NAS(2026-10-27 20:27)**:未发 npm,只部署 NAS。备份 `/tmp/lan-peers-backup-0.4.0-20261027T122711Z.tgz`(88660B,sha256 `5185a9cb…`);包内 45 条目、双端 sha256 一致、覆盖式解包;`dsh-web` active;health = `0.4.1` + capability 含 `unpair`;真实派单走 SSE 成功(elapsed 1227ms)。
|
|
357
|
+
- **0.4.2 → NAS(2026-10-27 20:35)**:只部署 NAS,未发 npm。备份 `/tmp/lan-peers-backup-0.4.1-20261027T123459Z.tgz`(116433B,49 条目);包 46 条目、双端 sha256 `675ac1d9…` 一致、覆盖解包;health = `0.4.2` + `unpair`;`[admin] pairing code issued ttl=600s` 实测出现且**日志无 6 位码**(正则命中 0);真实派单 reply「二次部署」;NAS `peers` 1→1 不变。
|
|
358
|
+
- NAS 回滚(0.4.2 → 0.4.1):`tar xzf /tmp/lan-peers-backup-0.4.1-20261027T123459Z.tgz -C /vol1/1000` → `sudo systemctl restart dsh-web`。
|
|
359
|
+
- NAS 回滚(0.4.1 → 0.4.0)
|
|
360
|
+
- 回滚:npm 侧 `npm dist-tag add dsh-lan-peers@0.3.1 latest`
|
|
361
|
+
|
|
362
|
+
**发布时必做**(本轮已完成):
|
|
363
|
+
|
|
364
|
+
## 14. 群聊(v0.5.0,在途未发布)
|
|
365
|
+
|
|
366
|
+
契约见 `DESIGN.md` §14(权威来源)。要点:
|
|
367
|
+
|
|
368
|
+
- **拓扑**:hub 星型中继,成员只与 hub 配对;hub 是唯一顺序源(`seq` 单调,删旧不回退)。
|
|
369
|
+
- **不改 1:1 语义**:`/v1/answer`、`/v1/events` 的「只能读自己的会话」保持;进度靠**发起端镜像摘要给 hub**再扇出。
|
|
370
|
+
- **隐私**:只发状态摘要(`progress` 无正文、`result` 形如 `done/1227ms/142字`),**不发 prompt 原文/回复全文**(有单测)。
|
|
371
|
+
- **存储**:hub 侧 `<stateDir>/group.json`(0600),默认 7 天保留、5000 条上限。
|
|
372
|
+
- **管理接口**:`/api/lan-peers/group[/post|/member|/config|/join]`;浏览器不直连对端,`groupKey` 在适配层剥离、不进日志。
|
|
373
|
+
- **面板**:`🔗 伙伴` 面板顶部两个页签「伙伴」「群组」;群组页含成员表(在线点 + 最近活动)、消息流(2s `sinceSeq` 增量、隐藏页面时停轮询)、聊天输入框;hub 侧另有成员增删与改群名控件。所有动态内容用 `textContent`。
|
|
374
|
+
|
|
375
|
+
**hub 侧启用示例**(cordis config):
|
|
376
|
+
```yaml
|
|
377
|
+
group: { enabled: true, name: "team", autoJoin: true, retentionDays: 7 }
|
|
378
|
+
```
|
|
379
|
+
**成员侧**:`group: { enabled: true, hub: "<hub 的 peer 名或 id>" }`,然后在面板「群组」页点「加入群组」。
|
|
380
|
+
|
|
381
|
+
**本地快速验证(三身份,不需要 DSH)**:
|
|
382
|
+
```bash
|
|
383
|
+
node host/server.mjs --port 34831 --state-dir /tmp/hub --name hub-node --group --group-auto-join --group-name demo &
|
|
384
|
+
# 两个成员各自 pair(用 hub 的 code)→ group join → group post → 另一个 group history/tail 看得到
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
**真机验收记录(2026-10-27,我跑的三身份)**:capability 含 `group`;两成员同一 `groupId`、`epoch=1`;消息 `seq 1,2,3` 单调;成员 B 的 `tail` 实时收到 A 的新消息;非成员 `403 not-a-member`(CLI exit 2);hub 日志 `groupKey` 命中 0。
|
|
388
|
+
|
|
389
|
+
**显示名与去重(已实现并复核)**:成员名默认取配对时上报的 `state.json name`,没有则退化为主机名(多台同型号机器会撞名)。现在:
|
|
390
|
+
- `group join --name <显示名>` 可自定义(**只写自己**,body 里的 peerId 一律忽略;重复 join 即本人改名);空/超 64/非字符串 → 400。
|
|
391
|
+
- hub 在**渲染层**去重:同名成员显示为 `<name> (<peerId 前 8 位>)`,不重名时零变化;`group.json` 里保留原始 name(不改存储形状)。
|
|
392
|
+
- 实测:两节点都退回 hostname 时显示 `PW-NEW22G6LR3 (d640ecc6)` / `PW-NEW22G6LR3 (3e52ab05)`;B 改名后 A 的后缀自动消失;落盘仍是原始名。
|
|
393
|
+
|
|
394
|
+
## 15. 群聊第二阶段(v0.6.0,在途未发布):成员互访 + 日志复制
|
|
395
|
+
|
|
396
|
+
> **真机暴露并修掉的一个 bug(2026-10-27)**:成员侧面板报 `hub peer has no address/secret: fn-302`。根因是 `lib/admin.mjs` 用 `store.listPeers()`(**脱敏视图,不含 secret**)去解析 `group.hub`。修法:新增单点助手 `groupHubPeer()` —— 先用脱敏视图认出"是谁",再用 `store.getPeer(peerId)` 取带 secret 的完整记录;两处调用点(`leaderOf()`、成员侧 `link()`)统一走它。**教训:凡需要 secret 的地方一律用 `getPeer()`,`listPeers()` 只用于展示。** 已补 3 条测试(含"临时回退修复必红"的复现证明)。
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
契约见 `DESIGN.md` §15。要在 §14 之上多知道四件事:
|
|
400
|
+
|
|
401
|
+
1. **群级鉴权**:成员之间用 `X-Peer-Auth: group`(用 groupKey 派生密钥签名)互访,**只允许** `probe/leader/pull/replicate` 四条路径;带该头打 `/v1/ask` 会被 `401 group-auth-scope`。canonical 是 **8 行**(首行字面量 `GROUP`)。
|
|
402
|
+
2. **成员本地两份文件**:`group-key.json`(群密钥与成员表)与 `group-replica.json`(日志副本,形状与 hub 的 `group.json` 同策略)。两者都是 0600,**都不许外传**。
|
|
403
|
+
3. **换 leader 仍要手动**(S3 才自动):目标机开 `group.enabled` 且不配 `group.hub`;各成员把 `group.hub` 指向它。因为 S2 已有副本,**换人不丢消息**。
|
|
404
|
+
4. **commit 语义**:hub 写本地即提交,复制是异步 best-effort;`group.quorum` 本轮**只解析不生效**,断电可能丢最后几条。
|
|
405
|
+
|
|
406
|
+
**常用命令**:`node client/cli.mjs group probe --peer <hub>`(看 epoch/lastSeq/对端看到的我的地址)、`group replica`(看本地副本 lastSeq 与条数)。
|
|
407
|
+
|
|
408
|
+
**真机三节点验收(2026-10-27,桌面 + NAS + 临时第三台)**:NAS 作 hub 并启用群(`groupId 7432674b…`,`selfRole:hub`,`advertiseBaseUrl` 显式写死);桌面作成员(`group.hub: fn-302` + `autoJoin: true`)重启后自动 bootstrap 入群(`group-key.json`/`group-replica.json` 生成,`[group] pull` 每 10s)。验证结果:
|
|
409
|
+
- 三台互见:NAS 成员表 3 人,各自 `baseUrl` 正确(第三台自动探测 `192.168.5.211:34861`);桌面按 name、第三台按自动探测都连通。
|
|
410
|
+
- 消息跨节点:桌面发 `seq=1` → NAS 立即可见、桌面副本 10s 内同步;第三台发 `seq=2` → 桌面副本同样拿到。
|
|
411
|
+
- **杀节点不丢**:杀掉第三台后其 `group-replica.json` 仍含全部消息(`lastSeq=2`)。
|
|
412
|
+
- **进度镜像隐私边界(真机)**:桌面真派一单(prompt 含 `SECRET-MARKER-9f3a`)→ 群流只出现 `progress`(text 为空、meta 含 phase/status)与 `result`(text `done/1176ms/4字`);**群消息里既无 prompt 原文也无回复全文**(脚本断言均为 false)。
|
|
413
|
+
- 临时第三台验收后已从群成员表移除并解除配对,NAS 群恢复为 2 人(桌面 + NAS)。
|
|
414
|
+
- **成员侧面板(修完 bug 后)真机复核**:桌面 `/api/lan-peers/group` → `error=null`、`leader=fn-302 (epoch 1)`、`replica={lastSeq:4,lag:0}`、成员 2 人各带 `baseUrl`;面板发送链路(`POST /api/lan-peers/group/post`)→ `seq=5`,NAS 侧**立即**看到 `#5 chat from=dsh-desktop`。
|
|
415
|
+
|
|
416
|
+
**真机验收记录(2026-10-27,我跑的 hub+成员双进程)**:probe 通过;带群头打 `/v1/ask` → 401;`post` 后成员副本 0ms 同步;改小 lastSeq 后 `pull` 118ms 补齐;**杀 hub 后副本完整**;日志无密钥无正文。
|
|
417
|
+
|
|
418
|
+
## 16. 动态 hub / 选举(v0.7.0,在途未发布)
|
|
419
|
+
|
|
420
|
+
契约见 `DESIGN.md` §16。运维要记的四点:
|
|
421
|
+
|
|
422
|
+
1. **角色与租约**:`<stateDir>/group-role.json`(0600)。hub 首次即 leader;成员从 follower 起;**文件存在即沿用**——想强制夺回 leader 用 `group claim`,不要手改文件。
|
|
423
|
+
2. **参数**:`group.heartbeatMs`(默认 3000,租约 = 3×)、`group.commitTimeoutMs`(默认 3000)、`group.hubPriority`(小者优先当 leader;同值按 peerId 字典序)。
|
|
424
|
+
3. **写路径两种失败要分清**:非 leader 节点 `post` → **404**(v0.5 语义);leader 拿不到多数派 → **503 quorum-unavailable**。两者都表示"本次未被接受",客户端重试即可;**未提交消息永不出现在 history/pull/events**。
|
|
425
|
+
4. **已知抖动**:两台幸存者租约同刻到点时会连选一次(实测 epoch 3→4 后收敛,seq 连续、无丢失)。下一轮用"落败者退避到租约过期后再参选"消除。
|
|
426
|
+
|
|
427
|
+
**常用命令**:`node client/cli.mjs group leader [--peer X]`(看本机角色 / 查对端租约)、`group claim`(本机立刻发起选举,运维手动接管)。
|
|
428
|
+
|
|
429
|
+
**真机验收(2026-10-27,我跑的)**:三成员 30s 内选举 0 次;`claim` epoch 1→2 且成员可当 leader 写;杀 leader 接任 **6963ms**、epoch+1、恢复后 `seqs=[1,2,3]` 连续无重复;少数派 `post` 失败且 marker 不在任何成员副本;`quorum:none` 单活可写;`[group]` 日志 105 行、密钥与正文痕迹 0、定时器全 `unref`、`close()` 后无残留。
|
|
430
|
+
|
|
431
|
+
**待考虑(下一轮)**:把"未上报地址的成员数"暴露到面板,让 quorum 分母被削弱这件事对用户可见(见 `DESIGN.md` §16.4)。
|