dsh-lan-peers 0.8.0 → 0.8.1

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/README.md CHANGED
@@ -76,7 +76,7 @@ dsh plugin --profile web add dsh-lan-peers
76
76
  workspace: ~/.dsh/lan-peers/workspace # 远端派单会话的工作目录
77
77
  allowRemotePrompts: false # 默认关;打开后才接受别人的派单
78
78
  requireEncryption: true # 默认开;旧版本对端会被拒绝
79
- trustedCidrs: ["192.168.5.0/24"] # 可选,限制来源网段
79
+ trustedCidrs: ["192.168.1.0/24"] # 可选,限制来源网段
80
80
  agentPreset: standard
81
81
  ```
82
82
 
@@ -98,13 +98,13 @@ dsh plugin --profile web add dsh-lan-peers
98
98
 
99
99
  ## 配对
100
100
 
101
- **界面**:左下角 🔗 伙伴 → 「生成配对码」,把 6 位码给对方;对方在「添加伙伴」里填本机地址(如 `192.168.5.51:7788`)+ 码。两个方向各配一次,双方都能主动派单。
101
+ **界面**:左下角 🔗 伙伴 → 「生成配对码」,把 6 位码给对方;对方在「添加伙伴」里填本机地址(如 `192.168.1.10:7788`)+ 码。两个方向各配一次,双方都能主动派单。
102
102
 
103
103
  **命令行**:
104
104
 
105
105
  ```bash
106
106
  npx dsh-lan-peers code --state-dir ~/.dsh/lan-peers
107
- npx dsh-lan-peers pair --url http://192.168.5.51:7788 --code 123456 --name my-nas
107
+ npx dsh-lan-peers pair --url http://192.168.1.10:7788 --code 123456 --name my-nas
108
108
  npx dsh-lan-peers peers --probe
109
109
  ```
110
110
 
@@ -112,12 +112,12 @@ npx dsh-lan-peers peers --probe
112
112
 
113
113
  模型侧(会话里直接说):
114
114
 
115
- > 用 lan_peer_ask 问 fn-302:`/vol5/main` 下 `pwd` 输出什么?
115
+ > 用 lan_peer_ask 问 my-nas:`/data/project` 下 `pwd` 输出什么?
116
116
 
117
117
  命令行:
118
118
 
119
119
  ```bash
120
- npx dsh-lan-peers ask --peer fn-302 "在 /vol5/main 执行 pwd" --timeout 120
120
+ npx dsh-lan-peers ask --peer my-nas "在 /data/project 执行 pwd" --timeout 120
121
121
  ```
122
122
 
123
123
  对端会在自己的 `workspace` 里建真实会话、由对端模型执行,然后把最终回复加密回传。
@@ -185,10 +185,10 @@ test('group join --name 只改自己;两个同名节点在 state / history 里
185
185
  const dirB = memberStateDir(t, { baseUrl: hub.baseUrl, hubInstanceId: hubId, identity: peerB });
186
186
 
187
187
  // 两台机器的默认主机名相同(真机现场就是这个现象)
188
- const joinedA = await runAsync(['group', 'join', '--name', 'PW-NEW22G6LR3', '--peer', 'hub', '--state-dir', dirA]);
188
+ const joinedA = await runAsync(['group', 'join', '--name', 'desk-alpha', '--peer', 'hub', '--state-dir', dirA]);
189
189
  assert.equal(joinedA.status, 0, joinedA.stderr);
190
- assert.match(joinedA.stdout, /显示名=PW-NEW22G6LR3/);
191
- const joinedB = await runAsync(['group', 'join', '--name', 'PW-NEW22G6LR3', '--peer', 'hub', '--state-dir', dirB]);
190
+ assert.match(joinedA.stdout, /显示名=desk-alpha/);
191
+ const joinedB = await runAsync(['group', 'join', '--name', 'desk-alpha', '--peer', 'hub', '--state-dir', dirB]);
192
192
  assert.equal(joinedB.status, 0, joinedB.stderr);
193
193
 
194
194
  // 不传 --name 再 join 不会把名字抹掉
@@ -197,20 +197,20 @@ test('group join --name 只改自己;两个同名节点在 state / history 里
197
197
 
198
198
  const state = await runAsync(['group', 'state', '--peer', 'hub', '--state-dir', dirA]);
199
199
  assert.equal(state.status, 0, state.stderr);
200
- assert.match(state.stdout, /PW-NEW22G6LR3 \(peer-cli\)/, '同名成员要带短 id 后缀');
201
- assert.match(state.stdout, /PW-NEW22G6LR3 \(bbbbbbbb\)/);
200
+ assert.match(state.stdout, /desk-alpha \(peer-cli\)/, '同名成员要带短 id 后缀');
201
+ assert.match(state.stdout, /desk-alpha \(bbbbbbbb\)/);
202
202
 
203
203
  await runAsync(['group', 'post', '--text', 'A 说的', '--peer', 'hub', '--state-dir', dirA]);
204
204
  await runAsync(['group', 'post', '--text', 'B 说的', '--peer', 'hub', '--state-dir', dirB]);
205
205
  const history = await runAsync(['group', 'history', '--peer', 'hub', '--state-dir', dirA]);
206
206
  assert.equal(history.status, 0, history.stderr);
207
- assert.match(history.stdout, /PW-NEW22G6LR3 \(peer-cli\) \[chat\] A 说的/, 'history 的 from.name 也要能区分');
208
- assert.match(history.stdout, /PW-NEW22G6LR3 \(bbbbbbbb\) \[chat\] B 说的/);
207
+ assert.match(history.stdout, /desk-alpha \(peer-cli\) \[chat\] A 说的/, 'history 的 from.name 也要能区分');
208
+ assert.match(history.stdout, /desk-alpha \(bbbbbbbb\) \[chat\] B 说的/);
209
209
 
210
210
  // hub 侧存的仍是原始 name(渲染层才加后缀)
211
211
  const raw = JSON.parse(fs.readFileSync(path.join(hub.stateDir, 'group.json'), 'utf8'));
212
- assert.equal(raw.members[MEMBER.peerId].name, 'PW-NEW22G6LR3');
213
- assert.equal(raw.members[peerB.peerId].name, 'PW-NEW22G6LR3');
212
+ assert.equal(raw.members[MEMBER.peerId].name, 'desk-alpha');
213
+ assert.equal(raw.members[peerB.peerId].name, 'desk-alpha');
214
214
 
215
215
  // 空 --name 直接拒绝
216
216
  const empty = await runAsync(['group', 'join', '--name', '', '--peer', 'hub', '--state-dir', dirA]);
@@ -46,7 +46,7 @@ const runCli = (args) => new Promise((resolve) => {
46
46
  test('v0.8.0 缺陷2:ECDH 配对把发起方的 baseUrl 存进接收方 peers.json', async (t) => {
47
47
  const host = await startHost(t);
48
48
  const { code } = host.server.issuePairingCode();
49
- const selfBaseUrl = 'http://192.168.5.77:7788';
49
+ const selfBaseUrl = 'http://192.168.1.77:7788';
50
50
 
51
51
  const paired = await pairPeer({
52
52
  url: host.baseUrl,
@@ -66,7 +66,7 @@ test('v0.8.0 缺陷2:ECDH 配对把发起方的 baseUrl 存进接收方 peers.
66
66
  test('v0.8.0 缺陷2:旧明文配对同样带 baseUrl(0.3.1 路径不退化)', async (t) => {
67
67
  const host = await startHost(t);
68
68
  const { code } = host.server.issuePairingCode();
69
- const selfBaseUrl = 'http://192.168.5.78:7788';
69
+ const selfBaseUrl = 'http://192.168.1.78:7788';
70
70
  const paired = await pairPeer({
71
71
  url: host.baseUrl,
72
72
  code,
@@ -103,24 +103,24 @@ test('v0.8.0 缺陷2:探测口径(私有 IPv4 优先 192.168、跳过虚拟/
103
103
  'vEthernet (WSL)': [{ family: 'IPv4', address: '172.28.0.1', internal: false }],
104
104
  'Wi-Fi': [
105
105
  { family: 'IPv6', address: 'fe80::1', internal: false },
106
- { family: 'IPv4', address: '192.168.5.211', internal: false },
106
+ { family: 'IPv4', address: '192.168.1.211', internal: false },
107
107
  ],
108
108
  '以太网': [{ family: 'IPv4', address: '10.0.0.7', internal: false }],
109
109
  Loopback: [{ family: 'IPv4', address: '127.0.0.1', internal: true }],
110
110
  公网: [{ family: 'IPv4', address: '8.8.8.8', internal: false }],
111
111
  };
112
- assert.equal(detectSelfBaseUrl({ interfaces }), 'http://192.168.5.211:7788');
113
- assert.equal(detectSelfBaseUrl({ interfaces, port: 8123 }), 'http://192.168.5.211:8123');
112
+ assert.equal(detectSelfBaseUrl({ interfaces }), 'http://192.168.1.211:7788');
113
+ assert.equal(detectSelfBaseUrl({ interfaces, port: 8123 }), 'http://192.168.1.211:8123');
114
114
  // 显式值优先,且会去掉尾部斜杠;非 http(s) 一律忽略
115
115
  assert.equal(
116
116
  detectSelfBaseUrl({ interfaces, selfBaseUrl: 'http://10.9.9.9:7788/' }),
117
117
  'http://10.9.9.9:7788',
118
118
  );
119
- assert.equal(detectSelfBaseUrl({ interfaces, selfBaseUrl: 'ftp://10.9.9.9' }), 'http://192.168.5.211:7788');
119
+ assert.equal(detectSelfBaseUrl({ interfaces, selfBaseUrl: 'ftp://10.9.9.9' }), 'http://192.168.1.211:7788');
120
120
  // 环境变量优先于探测(部署里给不出私有地址时的手动兜底)
121
121
  assert.equal(
122
- detectSelfBaseUrl({ interfaces, env: { DSH_LAN_PEERS_ADVERTISE_BASE_URL: 'http://192.168.5.30:7788' } }),
123
- 'http://192.168.5.30:7788',
122
+ detectSelfBaseUrl({ interfaces, env: { DSH_LAN_PEERS_ADVERTISE_BASE_URL: 'http://192.168.1.30:7788' } }),
123
+ 'http://192.168.1.30:7788',
124
124
  );
125
125
  // 完全没有私有地址 → 空串(调用方不会发空 baseUrl)
126
126
  assert.equal(
@@ -134,7 +134,7 @@ test('v0.8.0 缺陷2:CLI pair --base-url 走同一条路径(真进程 + 真
134
134
  const stateDir = makeTempStateDir('dsh-lan-peers-pair-cli-');
135
135
  t.after(() => fs.rmSync(stateDir, { recursive: true, force: true }));
136
136
  const { code } = host.server.issuePairingCode();
137
- const selfBaseUrl = 'http://192.168.5.79:7788';
137
+ const selfBaseUrl = 'http://192.168.1.79:7788';
138
138
 
139
139
  const out = await runCli([
140
140
  'pair', '--url', host.baseUrl, '--code', code,
package/host/server.mjs CHANGED
@@ -110,7 +110,7 @@ import { parseArgs } from 'node:util';
110
110
  // ---------------------------------------------------------------------------
111
111
 
112
112
  /** 插件版本,用于 /v1/health.version */
113
- export const PLUGIN_VERSION = '0.8.0';
113
+ export const PLUGIN_VERSION = '0.8.1';
114
114
 
115
115
  /** §4.1 允许的时钟偏移 */
116
116
  export const CLOCK_SKEW_MS = 60_000;
@@ -2779,7 +2779,7 @@ export function createPeerServer(overrides = {}) {
2779
2779
  }
2780
2780
 
2781
2781
  /**
2782
- * 展示用名字:**同名成员加短 id 后缀**("PW-NEW22G6LR3 (3c389892)")。
2782
+ * 展示用名字:**同名成员加短 id 后缀**("desk-alpha (3c389892)")。
2783
2783
  *
2784
2784
  * 为什么在渲染层做而不是写回 group.json:一台机器上跑两个 CLI 节点、或两台同型号
2785
2785
  * 机器都会上报同一个 hostname;读的时候统一加后缀,历史消息也能一并纠正,
@@ -3690,14 +3690,26 @@ export function createPeerServer(overrides = {}) {
3690
3690
  if (!state?.groupId || !state?.groupKey) return { ok: false, reason: 'group-state-incomplete' };
3691
3691
  const existing = readGroupKeyFile(config.stateDir) ?? { members: {} };
3692
3692
  const keyVersion = Number(state.keyVersion) || Number(state.epoch) || 0;
3693
- const members = { ...(existing.members ?? {}) };
3693
+ // v0.8.1:换钥顺带拿到权威成员表 —— 整体替换(并集会让人数虚高、quorum 永不可达)
3694
+ const previous = existing.members ?? {};
3695
+ const members = {};
3696
+ for (const member of Array.isArray(state.members) ? state.members : []) {
3697
+ const peerId = String(member?.peerId ?? '');
3698
+ if (peerId === '') continue;
3699
+ members[peerId] = {
3700
+ name: String(member?.name ?? '') || String(previous[peerId]?.name ?? ''),
3701
+ baseUrl: String(member?.baseUrl ?? '') || String(previous[peerId]?.baseUrl ?? ''),
3702
+ };
3703
+ }
3704
+ if (Object.keys(members).length === 0) Object.assign(members, previous);
3694
3705
  if (!members[store.instanceId]) members[store.instanceId] = { name: config.name, baseUrl: '' };
3706
+ rememberAuthoritativeMembers(Object.keys(members), 'state');
3695
3707
  writeGroupKeyFile(config.stateDir, {
3696
3708
  ...existing,
3697
3709
  groupId: String(state.groupId),
3698
3710
  key: String(state.groupKey),
3699
- // epoch 只增不减:轮换后这里跟着 keyVersion 走,角色初始化再用 max() 兜一次
3700
- epoch: Math.max(Number(existing.epoch) || 0, keyVersion),
3711
+ // 只增不减:轮换后这里跟着 keyVersion 走,角色初始化再用 max() 兜一次
3712
+ keyVersion: Math.max(Number(existing.keyVersion) || 0, keyVersion),
3701
3713
  leaderPeerId: String(existing.leaderPeerId ?? ''),
3702
3714
  name: config.name,
3703
3715
  // 换钥顺带刷新权威群名(hub 改名后成员侧也能跟上)
@@ -4132,10 +4144,65 @@ export function createPeerServer(overrides = {}) {
4132
4144
  * 于是自投即当选(2026-10-27 真机事故:桌面节点在两节点群里 epoch=2 自立为 leader)。
4133
4145
  * 地址只决定「提案/拉票发给谁」,不决定多数派的分母(契约 §3:N = 成员总数含自己)。
4134
4146
  */
4135
- function quorumMembers() {
4147
+ /**
4148
+ * 最近一次**权威**成员快照(v0.8.1):hub 的 state/pull/grant 说了算。
4149
+ * quorum 分母必须用它,而不是「本机表 ∪ 自愈补进来的」那份 —— 后者正是真机上
4150
+ * 「移除的人还在表里 → 分母虚高 → 成员当 leader 永远选不出」的来源。
4151
+ */
4152
+ let authoritativeMemberIds = new Set();
4153
+ let lastAuthoritativeAt = 0;
4154
+ let lastQuorumLogKey = '';
4155
+
4156
+ function rememberAuthoritativeMembers(ids, source = 'state') {
4157
+ const next = new Set();
4158
+ for (const raw of Array.isArray(ids) ? ids : []) {
4159
+ const id = String(raw ?? '');
4160
+ if (id !== '') next.add(id);
4161
+ }
4162
+ next.add(store.instanceId);
4163
+ const changed = next.size !== authoritativeMemberIds.size
4164
+ || [...next].some((id) => !authoritativeMemberIds.has(id));
4165
+ authoritativeMemberIds = next;
4166
+ lastAuthoritativeAt = config.now();
4167
+ if (changed && config.logger) {
4168
+ config.logger(
4169
+ '[group] members authoritative n=' + next.size + ' source=' + source,
4170
+ );
4171
+ }
4172
+ logQuorumIfChanged();
4173
+ return next;
4174
+ }
4175
+
4176
+ /** quorum 分母的来源:hub 的 group.json 恒权威;成员用最近一次权威快照;都没有才退回本机表 */
4177
+ function quorumSnapshot() {
4178
+ if (groupStore) {
4179
+ const ids = new Set(Object.keys(groupStore.state().members));
4180
+ ids.add(store.instanceId);
4181
+ return { ids, source: 'authoritative' };
4182
+ }
4183
+ if (authoritativeMemberIds.size > 0) {
4184
+ return { ids: new Set(authoritativeMemberIds), source: 'authoritative' };
4185
+ }
4136
4186
  const ids = new Set(groupMemberIds());
4137
4187
  ids.add(store.instanceId);
4138
- return [...ids];
4188
+ return { ids, source: 'stale' };
4189
+ }
4190
+
4191
+ function quorumMembers() {
4192
+ return [...quorumSnapshot().ids];
4193
+ }
4194
+
4195
+ /** 只在数量/门槛/来源变化时打一行(心跳 3s 一次,不能刷屏) */
4196
+ function logQuorumIfChanged() {
4197
+ if (!config.logger) return;
4198
+ const snapshot = quorumSnapshot();
4199
+ const need = quorumSize(snapshot.ids.size, quorumMode);
4200
+ const key = snapshot.ids.size + ':' + need + ':' + snapshot.source;
4201
+ if (key === lastQuorumLogKey) return;
4202
+ lastQuorumLogKey = key;
4203
+ config.logger(
4204
+ '[group] quorum members=' + snapshot.ids.size + ' need=' + need + ' source=' + snapshot.source,
4205
+ );
4139
4206
  }
4140
4207
 
4141
4208
  /** 提案目标 = 除自己以外、地址已知(含 peers.json 兜底)的成员;地址缺失只影响投递 */
@@ -4658,6 +4725,8 @@ export function createPeerServer(overrides = {}) {
4658
4725
 
4659
4726
  /** 租约检查:非 leader 且租约过期 → 进入候选并发起选举(抖动在 runElection 里) */
4660
4727
  async function roleTick() {
4728
+ // 每轮顺手对一下 quorum 口径(只在实际变化时打一行,心跳 3s 一次也不刷屏)
4729
+ logQuorumIfChanged();
4661
4730
  const st = ensureRole();
4662
4731
  if (!st || st.role === 'leader') return { ok: false, reason: 'leader-or-off' };
4663
4732
  if (electionInFlight) return { ok: false, reason: 'election-in-flight' };
@@ -5304,28 +5373,45 @@ export function createPeerServer(overrides = {}) {
5304
5373
  }
5305
5374
 
5306
5375
  /**
5307
- * 刷新成员侧成员表。**只增不减**:一份过早/不完整的快照(成员在 bootstrap 之后才加入)
5308
- * 不该把仍在群的成员从本机表里抹掉 —— 真机上这会让该成员的心跳/投票被 401 not-a-member 挡在门外。
5309
- * 移除成员的权威在 hub 侧 group.json(本机表里多出来的条目只会让多数派更保守)。
5376
+ * **权威替换**成员侧成员表(v0.8.1)。
5377
+ *
5378
+ * v0.7.x 为了修「401 not-a-member 死锁」把这里改成了并集(只增不减)。并集解决了
5379
+ * 「快照不完整时误删」,代价是**权威移除永远传不到成员**:removed 的人留在本机表里,
5380
+ * quorum 分母虚高(真机:NAS 侧 2 人、桌面侧 12 秒后仍是 4 人 → 桌面当 leader 时
5381
+ * 以为需要 3 票,而实际只有 2 台能投票,于是永远选不出、永远写不进去)。
5382
+ *
5383
+ * 现在区分两种写入语义:
5384
+ * · 权威快照(hub 的 /v1/group/state、pull 的 memberIds、grant)→ 整体替换(本函数);
5385
+ * · 自愈入表(对端用 election/heartbeat 证明自己握着群密钥)→ upsertGroupMember 并集。
5386
+ *
5387
+ * 名字与地址仍尽量沿用本地已知值(快照里通常没有),但**条目本身以权威快照为准**。
5310
5388
  */
5311
- function saveGroupMembers(members, names = {}) {
5389
+ function replaceGroupMembers(members, names = {}) {
5312
5390
  const existing = readGroupKeyFile(config.stateDir);
5313
- if (!existing) return;
5314
- const next = { ...existing.members };
5391
+ if (!existing) return null;
5392
+ const previous = existing.members ?? {};
5393
+ const next = {};
5315
5394
  for (const peerId of members) {
5316
5395
  const id = String(peerId ?? '');
5317
5396
  if (id === '') continue;
5318
- // S3:baseUrl 是承诺集合的依据,刷新成员表时不能抹掉(学到的优先,其次文件里原有的)
5319
- const baseUrl = knownMemberAddress(id) || String(existing.members[id]?.baseUrl ?? '');
5397
+ if (next[id]) continue;
5398
+ // baseUrl 是承诺集合的依据:学到的优先,其次文件里原有的(快照里一般没有地址)
5399
+ const baseUrl = knownMemberAddress(id) || String(previous[id]?.baseUrl ?? '');
5320
5400
  next[id] = {
5321
- name: String(names[id]?.name ?? existing.members[id]?.name ?? ''),
5401
+ name: String(names[id]?.name ?? previous[id]?.name ?? ''),
5322
5402
  baseUrl,
5323
5403
  };
5324
5404
  }
5405
+ if (Object.keys(next).length === 0) return null;
5325
5406
  if (!next[store.instanceId]) {
5326
- next[store.instanceId] = { name: config.name, baseUrl: localAdvertiseBaseUrl() };
5407
+ next[store.instanceId] = {
5408
+ name: config.name,
5409
+ baseUrl: String(previous[store.instanceId]?.baseUrl ?? '') || localAdvertiseBaseUrl(),
5410
+ };
5327
5411
  }
5412
+ rememberAuthoritativeMembers(Object.keys(next), 'pull');
5328
5413
  writeGroupKeyFile(config.stateDir, { ...existing, members: next });
5414
+ return next;
5329
5415
  }
5330
5416
 
5331
5417
  /** 成员 bootstrap:用 per-peer 密钥取群状态(groupId + groupKey),失败只记一行 */
@@ -5342,16 +5428,20 @@ export function createPeerServer(overrides = {}) {
5342
5428
  state = await fetchGroupState({ baseUrl: hub.baseUrl, secret: hub.secret, senderId: store.instanceId, timeoutMs: 5000 });
5343
5429
  }
5344
5430
  if (!state?.groupId || !state?.groupKey) throw new Error('group-state-incomplete');
5431
+ // v0.8.1:hub 的 state.members 是权威快照 —— 整体替换(允许删除已移除的成员)
5345
5432
  const members = {};
5346
5433
  for (const member of Array.isArray(state.members) ? state.members : []) {
5347
- members[String(member?.peerId ?? '')] = { name: String(member?.name ?? '') };
5434
+ const peerId = String(member?.peerId ?? '');
5435
+ if (peerId === '') continue;
5436
+ members[peerId] = { name: String(member?.name ?? ''), baseUrl: String(member?.baseUrl ?? '') };
5348
5437
  }
5349
- if (!members[hub.peerId]) members[hub.peerId] = { name: hub.name };
5350
- if (!members[store.instanceId]) members[store.instanceId] = { name: config.name };
5438
+ if (!members[hub.peerId]) members[hub.peerId] = { name: hub.name, baseUrl: hub.baseUrl };
5439
+ if (!members[store.instanceId]) members[store.instanceId] = { name: config.name, baseUrl: '' };
5440
+ rememberAuthoritativeMembers(Object.keys(members), 'state');
5351
5441
  writeGroupKeyFile(config.stateDir, {
5352
5442
  groupId: String(state.groupId),
5353
5443
  key: String(state.groupKey),
5354
- epoch: Number(state.epoch) || 0,
5444
+ keyVersion: Number(state.keyVersion) || Number(state.epoch) || 0,
5355
5445
  leaderPeerId: hub.peerId,
5356
5446
  name: config.name,
5357
5447
  // hub 是群名的权威来源:bootstrap 时把真名落盘(广播/查找都靠它)
@@ -5487,7 +5577,7 @@ export function createPeerServer(overrides = {}) {
5487
5577
  messages: Array.isArray(page.messages) ? page.messages : [],
5488
5578
  });
5489
5579
  if (Array.isArray(page.memberIds) && page.memberIds.length > 0) {
5490
- saveGroupMembers(page.memberIds, ctx.members);
5580
+ replaceGroupMembers(page.memberIds, ctx.members);
5491
5581
  }
5492
5582
  memberLastError = '';
5493
5583
  // hub 在 pull 响应里自报地址(X-Peer-Base 同一口径):按名入群的节点本地没有 hub 的
@@ -82,11 +82,11 @@ test('§4 GET 的 body 摘要固定是 sha256(""),签名是 base64url(HMAC-SHA
82
82
  test('§11.1 GET /v1/health 无认证 200,peers 计数正确', async (t) => {
83
83
  const stateDir = makeTempStateDir();
84
84
  seedPeers(stateDir, [peerFixture('peer-a'), peerFixture('peer-b')]);
85
- const { server, baseUrl } = await startServer(t, { stateDir, name: 'fn-302' });
85
+ const { server, baseUrl } = await startServer(t, { stateDir, name: 'nas-box' });
86
86
 
87
87
  const health = await request(baseUrl, { path: '/v1/health', signed: false });
88
88
  assert.equal(health.status, 200);
89
- assert.equal(health.json.name, 'fn-302');
89
+ assert.equal(health.json.name, 'nas-box');
90
90
  assert.match(health.json.instanceId, UUID_V4);
91
91
  assert.equal(health.json.instanceId, server.store.instanceId);
92
92
  // 版本号跟随实现,发版不需要改测试
@@ -112,7 +112,7 @@ test('§8 state.json 的 instanceId/createdAt 跨重启保持不变', async (t)
112
112
  const stateDir = makeTempStateDir();
113
113
  t.after(() => fs.rmSync(stateDir, { recursive: true, force: true }));
114
114
 
115
- const first = createPeerServer({ stateDir, listenHost: '127.0.0.1', name: 'fn-302' });
115
+ const first = createPeerServer({ stateDir, listenHost: '127.0.0.1', name: 'nas-box' });
116
116
  let initial;
117
117
  try {
118
118
  await first.listen(0, '127.0.0.1');
@@ -121,7 +121,7 @@ test('§8 state.json 的 instanceId/createdAt 跨重启保持不变', async (t)
121
121
  await first.close();
122
122
  }
123
123
 
124
- const second = createPeerServer({ stateDir, listenHost: '127.0.0.1', name: 'fn-302' });
124
+ const second = createPeerServer({ stateDir, listenHost: '127.0.0.1', name: 'nas-box' });
125
125
  let reloaded;
126
126
  try {
127
127
  await second.listen(0, '127.0.0.1');
@@ -132,7 +132,7 @@ test('§8 state.json 的 instanceId/createdAt 跨重启保持不变', async (t)
132
132
 
133
133
  assert.equal(reloaded.instanceId, initial.instanceId);
134
134
  assert.equal(reloaded.createdAt, initial.createdAt);
135
- assert.equal(reloaded.name, 'fn-302');
135
+ assert.equal(reloaded.name, 'nas-box');
136
136
  if (process.platform !== 'win32') assert.equal(fileMode(path.join(stateDir, 'state.json')), 0o600);
137
137
  });
138
138
 
@@ -341,7 +341,7 @@ test('§4 验签接受 inboundSecret;默认也兼容存量的 secret(兼容
341
341
 
342
342
  test('§3/§2 配对码:6 位数字、10 分钟、一次性;成功后写入 peers.json(0600)', async (t) => {
343
343
  const stateDir = makeTempStateDir();
344
- const { server, baseUrl } = await startServer(t, { stateDir, name: 'fn-302' });
344
+ const { server, baseUrl } = await startServer(t, { stateDir, name: 'nas-box' });
345
345
 
346
346
  const issued = server.issuePairingCode();
347
347
  assert.match(issued.code, /^\d{6}$/);
@@ -357,12 +357,12 @@ test('§3/§2 配对码:6 位数字、10 分钟、一次性;成功后写入
357
357
  peerId: 'peer-a',
358
358
  peerName: 'A 的显示名',
359
359
  secret: 'S-created-by-A',
360
- baseUrl: 'http://192.168.5.21:7788',
360
+ baseUrl: 'http://192.168.1.21:7788',
361
361
  };
362
362
  const paired = await request(baseUrl, { method: 'POST', path: '/v1/pair', signed: false, body: pairBody });
363
363
  assert.equal(paired.status, 200);
364
364
  assert.equal(paired.json.peerId, server.store.instanceId);
365
- assert.equal(paired.json.peerName, 'fn-302');
365
+ assert.equal(paired.json.peerName, 'nas-box');
366
366
  assert.match(paired.json.secret, /^[A-Za-z0-9_-]{43}$/, '32 字节 base64url 应为 43 字符');
367
367
  assert.notEqual(paired.json.secret, 'S-created-by-A', '两把密钥必须不同');
368
368
 
@@ -372,7 +372,7 @@ test('§3/§2 配对码:6 位数字、10 分钟、一次性;成功后写入
372
372
  assert.equal(stored.name, 'A 的显示名');
373
373
  assert.equal(stored.secret, 'S-created-by-A');
374
374
  assert.equal(stored.inboundSecret, paired.json.secret);
375
- assert.equal(stored.baseUrl, 'http://192.168.5.21:7788');
375
+ assert.equal(stored.baseUrl, 'http://192.168.1.21:7788');
376
376
  assert.equal(readJson(statePath).pairingCode, undefined, '配对码用后即焚');
377
377
 
378
378
  const health = await request(baseUrl, { path: '/v1/health', signed: false });
@@ -455,10 +455,10 @@ test('§3 配对 body 缺字段 → 400;码格式不对 / 没发码 → 401 in
455
455
 
456
456
  test('配对码由另一个进程(CLI)写入时,正在运行的 server 也能看到', async (t) => {
457
457
  const stateDir = makeTempStateDir();
458
- const { baseUrl } = await startServer(t, { stateDir, name: 'fn-302' });
458
+ const { baseUrl } = await startServer(t, { stateDir, name: 'nas-box' });
459
459
 
460
460
  // 模拟 `lan-peers code` 在另一个进程里写 state.json
461
- const { code } = issuePairingCode({ stateDir, name: 'fn-302' });
461
+ const { code } = issuePairingCode({ stateDir, name: 'nas-box' });
462
462
 
463
463
  const paired = await request(baseUrl, {
464
464
  method: 'POST',
@@ -489,7 +489,7 @@ test('§9 默认白名单允许回环/私网,拒绝公网;CIDR 解析正确'
489
489
  const health = await request(baseUrl, { path: '/v1/health', signed: false });
490
490
  assert.equal(health.status, 200, '默认应允许 127.0.0.1');
491
491
 
492
- assert.equal(isAllowedRemote('192.168.5.44', null), true);
492
+ assert.equal(isAllowedRemote('192.168.1.44', null), true);
493
493
  assert.equal(isAllowedRemote('10.9.8.7', null), true);
494
494
  assert.equal(isAllowedRemote('127.0.0.1', null), true);
495
495
  assert.equal(isAllowedRemote('8.8.8.8', null), false, '公网必须拒绝');
@@ -498,13 +498,13 @@ test('§9 默认白名单允许回环/私网,拒绝公网;CIDR 解析正确'
498
498
  assert.equal(isAllowedRemote('2606:4700:4700::1111', null), false);
499
499
 
500
500
  // 显式配置是严格模式:回环不在列表里就不许
501
- assert.equal(isAllowedRemote('127.0.0.1', ['192.168.5.0/24']), false);
502
- assert.equal(isAllowedRemote('192.168.5.4', ['192.168.5.0/24']), true);
503
- assert.equal(isAllowedRemote('::ffff:192.168.5.4', ['192.168.5.0/24']), true);
501
+ assert.equal(isAllowedRemote('127.0.0.1', ['192.168.1.0/24']), false);
502
+ assert.equal(isAllowedRemote('192.168.1.4', ['192.168.1.0/24']), true);
503
+ assert.equal(isAllowedRemote('::ffff:192.168.1.4', ['192.168.1.0/24']), true);
504
504
  assert.equal(isAllowedRemote('10.2.3.4', ['10.0.0.0/8']), true);
505
505
  assert.equal(isAllowedRemote('11.2.3.4', ['10.0.0.0/8']), false);
506
506
 
507
- assert.deepEqual(parseCidr('192.168.5.0/24')?.bits, 32);
507
+ assert.deepEqual(parseCidr('192.168.1.0/24')?.bits, 32);
508
508
  assert.equal(parseCidr('not-a-cidr'), null);
509
509
  });
510
510
 
@@ -356,10 +356,10 @@ test('S1 地址探测:排除回环/虚拟网卡,192.168 优先,无私有
356
356
  docker0: [{ address: '172.17.0.1', family: 'IPv4', internal: false }],
357
357
  'VirtualBox Host-Only Network': [{ address: '192.168.56.1', family: 'IPv4', internal: false }],
358
358
  eth0: [{ address: '10.1.2.3', family: 'IPv4', internal: false }],
359
- wlan0: [{ address: '192.168.5.20', family: 'IPv4', internal: false }],
359
+ wlan0: [{ address: '192.168.1.20', family: 'IPv4', internal: false }],
360
360
  'Tailscale': [{ address: '100.64.0.1', family: 'IPv4', internal: false }],
361
361
  };
362
- assert.equal(pickPrivateIpv4(sample), '192.168.5.20');
362
+ assert.equal(pickPrivateIpv4(sample), '192.168.1.20');
363
363
  // 只有虚拟网卡挂着的私网地址时:一个都不选
364
364
  assert.equal(pickPrivateIpv4({
365
365
  docker0: [{ address: '172.17.0.1', family: 'IPv4', internal: false }],
@@ -407,7 +407,7 @@ test('S1 未配置 advertiseBaseUrl:探测私有 IPv4 + 实际端口;成员
407
407
  }
408
408
 
409
409
  // 成员在群级请求里带 X-Peer-Base:hub 记下「我看到的你的地址」
410
- const announced = 'http://192.168.5.21:7788';
410
+ const announced = 'http://192.168.1.21:7788';
411
411
  const second = await groupRequest(hub.baseUrl, {
412
412
  groupKey, groupId, senderId: MEMBER.peerId,
413
413
  path: '/v1/group/probe',
@@ -0,0 +1,119 @@
1
+ /**
2
+ * v0.8.1 · 成员表「权威替换」回归(真机:移除不传播 → quorum 分母虚高)。
3
+ *
4
+ * 现场:NAS(hub/leader) 把两个测试成员 remove 掉(keyVersion 1→3),NAS 侧成员表变 2 人,
5
+ * 但桌面成员 12 秒后仍是 4 人 —— 因为 v0.7.x 为了修 401 死锁把成员表刷新改成了并集(只增不减)。
6
+ * 后果:桌面一旦当 leader 就以为 N=4、需要 3 票,而实际只有 2 台能投票 → 永远选不出 / 写不进去。
7
+ *
8
+ * 现在区分两种写入语义:
9
+ * · 权威快照(hub 的 state / pull 的 memberIds / grant)→ 整体替换(允许删除);
10
+ * · 自愈入表(对端用 election/heartbeat 证明握着群密钥)→ 仍并集(upsertGroupMember)。
11
+ * 并且 quorum 分母以「最近一次权威快照」为准,日志里带 source=authoritative|stale。
12
+ */
13
+ import test from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import { applyToGroup, readGroupKeyFile, writeGroupKeyFile } from '../../lib/group-client.mjs';
16
+ import { postAs, roleOf, startCluster, waitFor } from './group-cluster.mjs';
17
+
18
+ const memberIdsOf = (node) => Object.keys(node.server.group.state().members ?? {}).sort();
19
+ const keyMembersOf = (dir) => Object.keys(readGroupKeyFile(dir).members ?? {}).sort();
20
+
21
+ /**
22
+ * 驱动一次「真的跑完」的 pull。
23
+ * 启动那轮 memberTick 可能还在飞(memberInFlight),此时 pull() 只会回 skipped —— 所以要等它 ok。
24
+ */
25
+ async function pullOnce(node) {
26
+ const done = await waitFor(async () => {
27
+ const out = await node.server.group.pull();
28
+ return out?.ok === true ? out : null;
29
+ }, { timeoutMs: 8_000 });
30
+ assert.ok(done, 'pull 没能跑完(node=' + node.peerId + ')');
31
+ return done;
32
+ }
33
+
34
+ test('v0.8.1 ① 一次 pull 周期内成员表按权威快照收缩(不是并集保留)', async (t) => {
35
+ const cluster = await startCluster(t, { count: 4 });
36
+ const [a, b, c, d] = cluster.nodes;
37
+ assert.equal(memberIdsOf(a).length, 4);
38
+
39
+ // 移除两台(等价于面板上踢人:key 轮换 + blocked)。
40
+ // 真机上被踢的机器随后就下线了,所以这里也真的关掉它们的监听 ——
41
+ // 否则它们的心跳仍会走「自愈入表」把条目加回来(那条路径是刻意保留的,见 ③)。
42
+ a.server.group.removeMember(c.peerId);
43
+ a.server.group.removeMember(d.peerId);
44
+ await cluster.makeUnreachable(c);
45
+ await cluster.makeUnreachable(d);
46
+ assert.equal(memberIdsOf(a).length, 2, 'hub 侧权威表已经是 2 人');
47
+
48
+ assert.equal(keyMembersOf(b.dir).length, 4, '前提:成员本地还留着 4 条(旧语义下的并集)');
49
+
50
+ await pullOnce(b);
51
+ assert.deepEqual(keyMembersOf(b.dir), [a.peerId, b.peerId].sort(), '一次 pull 后必须与 hub 一致(2 人)');
52
+ assert.equal(readGroupKeyFile(b.dir).members[c.peerId], undefined, '被移除的成员条目必须真的删掉');
53
+ const keyFile = readGroupKeyFile(b.dir);
54
+ assert.equal(keyFile.keyVersion, keyFile.epoch, 'keyVersion 与兼容别名 epoch 同值');
55
+ assert.ok(keyFile.keyVersion >= 2, '踢人会轮换密钥');
56
+ });
57
+
58
+ test('v0.8.1 ② 收缩后该成员当 leader:分母 2、需要 2 票,能选出来并写进去', async (t) => {
59
+ const logs = [];
60
+ const cluster = await startCluster(t, { count: 4, logger: (line) => logs.push(String(line)) });
61
+ const [a, b, c, d] = cluster.nodes;
62
+
63
+ a.server.group.removeMember(c.peerId);
64
+ a.server.group.removeMember(d.peerId);
65
+ await cluster.makeUnreachable(c);
66
+ await cluster.makeUnreachable(d);
67
+ await pullOnce(b);
68
+ assert.equal(keyMembersOf(b.dir).length, 2);
69
+
70
+ // b 在 epoch 最高值上接管(force claim 不受退避闸门限制)
71
+ const claimed = await b.server.group.claim({ epoch: Math.max(roleOf(a).epoch, roleOf(b).epoch) + 1 });
72
+ assert.equal(claimed.ok, true, JSON.stringify(claimed));
73
+ assert.equal(claimed.needed, 2, '分母是 2 人 → 只需要 2 票(旧语义下会是 3 票,永远凑不齐)');
74
+ assert.equal(roleOf(b).role, 'leader');
75
+
76
+ // 新 leader 写一条:a 一票 + 自己 = 2 → 201
77
+ const posted = await postAs(b.baseUrl, a.peerId, { kind: 'chat', text: '新 leader 的第一条' });
78
+ assert.equal(posted.status, 201, posted.text);
79
+ assert.equal(posted.json.seq, 1);
80
+ assert.match(logs.join('\n'), /\[group\] quorum members=2 need=2 source=authoritative/);
81
+ });
82
+
83
+ test('v0.8.1 ③ 自愈入表仍是并集:不在表里的对端发心跳仍能被补进来', async (t) => {
84
+ const cluster = await startCluster(t, { count: 3 });
85
+ const [, b, c] = cluster.nodes;
86
+
87
+ const keyFile = readGroupKeyFile(b.dir);
88
+ const trimmed = { ...keyFile.members };
89
+ delete trimmed[c.peerId];
90
+ writeGroupKeyFile(b.dir, { ...keyFile, members: trimmed });
91
+ assert.equal(keyMembersOf(b.dir).includes(c.peerId), false);
92
+
93
+ // c 用群级签名发心跳(证明它握着群密钥)→ b 必须把它补回成员表(自愈路径只增不减)
94
+ await c.server.group.heartbeat();
95
+ const healed = await waitFor(() => keyMembersOf(b.dir).includes(c.peerId), { timeoutMs: 5_000 });
96
+ assert.equal(healed, true, '自愈入表必须仍然有效(权威替换不能把这条路径一起砍掉)');
97
+ });
98
+
99
+ test('v0.8.1 ④ 移除后 blocked 语义不变:被移除者 post / apply 仍被拒', async (t) => {
100
+ const cluster = await startCluster(t, { count: 3, autoJoin: false });
101
+ const [a, b, c] = cluster.nodes;
102
+ a.server.group.removeMember(c.peerId);
103
+ await pullOnce(b);
104
+
105
+ assert.equal(a.server.group.isBlocked(c.peerId), true, 'hub 侧记着 blocked');
106
+ const posted = await postAs(a.baseUrl, c.peerId, { kind: 'chat', text: '被踢之后还想说' });
107
+ assert.equal(posted.status, 403, posted.text);
108
+ assert.equal(posted.json.error, 'not-a-member');
109
+
110
+ const applied = await applyToGroup({
111
+ baseUrl: b.baseUrl,
112
+ secret: 'sec-' + c.peerId,
113
+ senderId: c.peerId,
114
+ groupId: cluster.groupId,
115
+ applicant: { peerId: c.peerId, name: 'kicked', baseUrl: 'http://127.0.0.1:1' },
116
+ });
117
+ assert.deepEqual(applied, { status: 'rejected', reason: 'blocked' });
118
+ assert.deepEqual(a.server.group.applications(), []);
119
+ });