dsh-lan-peers 0.8.2 → 0.8.3

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.
@@ -0,0 +1,108 @@
1
+ // dsh-lan-peers · M6 之后 `lan-peers code` 必须请本机 host 发码。
2
+ //
3
+ // 审计/产品口径:配对码只在 daemon 进程内存里,CLI 是独立进程 —— 它自己造的码对正在跑的
4
+ // host 无效,用户抄走的是废码。所以 code 子命令改成打本机管理接口(带 token),
5
+ // 本机没 host / 拿不到 token 时 exit 2 并说清怎么起 host,绝不本地造一个码。
6
+ import test from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { execFile } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import http from 'node:http';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { createAdmin } from '../lib/admin.mjs';
14
+ import { makeTempStateDir } from '../host/test/helpers.mjs';
15
+
16
+ const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), 'cli.mjs');
17
+
18
+ const run = (args) => new Promise((resolve) => {
19
+ execFile(process.execPath, [CLI, ...args], { encoding: 'utf8' }, (error, stdout, stderr) => {
20
+ resolve({ code: error ? error.code : 0, stdout: String(stdout || ''), stderr: String(stderr || '') });
21
+ });
22
+ });
23
+
24
+ /** 真 admin 挂在真 HTTP 上:token 文件由它启动时写进这个 stateDir(CLI 读同一份)。 */
25
+ async function startAdmin(t, { dir, code = '246810' } = {}) {
26
+ const state = { instanceId: 'inst-self', name: 'self' };
27
+ const store = {
28
+ instanceId: 'inst-self',
29
+ stateDir: dir,
30
+ state,
31
+ listPeers: () => [],
32
+ getPeer: () => null,
33
+ issuePairingCode: () => {
34
+ const issued = { code, expiresAt: Date.now() + 600_000 };
35
+ state.pairingCode = issued;
36
+ return issued;
37
+ },
38
+ };
39
+ const admin = createAdmin({
40
+ server: { config: { logger: () => {} }, store },
41
+ nameOf: () => 'self',
42
+ rename: (n) => n,
43
+ });
44
+ const calls = [];
45
+ const server = http.createServer((req, res) => {
46
+ let body = '';
47
+ req.on('data', (chunk) => { body += chunk; });
48
+ req.on('end', () => {
49
+ calls.push({ url: String(req.url || ''), method: String(req.method || 'GET'), headers: req.headers });
50
+ const url = new URL(String(req.url || '/'), 'http://127.0.0.1');
51
+ Promise.resolve(admin.handle(req, res, url)).then((handled) => {
52
+ if (!handled) {
53
+ res.writeHead(404, { 'content-type': 'application/json' });
54
+ res.end('{"error":"not-found"}');
55
+ }
56
+ }).catch((error) => {
57
+ res.writeHead(500, { 'content-type': 'application/json' });
58
+ res.end(JSON.stringify({ error: String(error && error.message ? error.message : error) }));
59
+ });
60
+ });
61
+ });
62
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
63
+ t.after(() => new Promise((resolve) => server.close(resolve)));
64
+ return { admin, calls, state, url: 'http://127.0.0.1:' + server.address().port };
65
+ }
66
+
67
+ test('code:请本机 host 发码并原样打印(与 admin 手里的码一致)', async (t) => {
68
+ const dir = makeTempStateDir('lanb-cli-code-');
69
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
70
+ const rig = await startAdmin(t, { dir, code: '246810' });
71
+
72
+ const out = await run(['code', '--state-dir', dir, '--url', rig.url]);
73
+ assert.equal(out.code, 0, out.stderr);
74
+ assert.match(out.stdout, /配对码: 246810/);
75
+ assert.doesNotMatch(out.stdout + out.stderr, /token/, '不能把 token 字样漏到界面上');
76
+
77
+ const issued = rig.calls.find((call) => call.url === '/api/lan-peers/code');
78
+ assert.ok(issued, '必须打 POST /api/lan-peers/code: ' + JSON.stringify(rig.calls.map((c) => c.url)));
79
+ assert.equal(issued.method, 'POST');
80
+ assert.equal(issued.headers['x-lan-peers-token'], rig.admin.token, 'token 从 <stateDir>/admin-token 读,只走请求头');
81
+
82
+ // 打印出来的码就是 host 手里的那一个:GET state 说「已生成」。
83
+ const state = await fetch(rig.url + '/api/lan-peers/state', { headers: { 'x-lan-peers-token': rig.admin.token } });
84
+ const body = await state.json();
85
+ assert.equal(body.pairing.active, true, 'CLI 打印的码必须是本机 host 活着的那一个');
86
+ assert.equal(JSON.stringify(body).includes('246810'), false, 'GET state 仍不回显码');
87
+ });
88
+
89
+ test('code:本机没有 host / 拿不到 token → exit 2,且绝不本地造码打印', async (t) => {
90
+ const dir = makeTempStateDir('lanb-cli-code-');
91
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
92
+ const rig = await startAdmin(t, { dir, code: '246810' });
93
+ // 拿掉 token 文件:等价于「本机 host 没起 / 不是同一个进程」。
94
+ fs.rmSync(path.join(dir, 'admin-token'));
95
+
96
+ const out = await run(['code', '--state-dir', dir, '--url', rig.url]);
97
+ assert.equal(out.code, 2, '拿不到码就是 exit 2: ' + out.stdout + out.stderr);
98
+ assert.match(out.stderr, /请先启动本机 host/);
99
+ assert.match(out.stderr, /token-required/, '失败原因要如实转述');
100
+ assert.doesNotMatch(out.stdout, /\d{6}/, '失败时不能在 stdout 吐出任何 6 位数字(那会被当成码)');
101
+ assert.equal(fs.existsSync(path.join(dir, 'admin-token')), false, 'CLI 不该自己去造 token');
102
+ });
103
+
104
+ test('code --help 说明「请本机 host 发码」,不再声称写 state.json', async (t) => {
105
+ const out = await run(['code', '--help']);
106
+ assert.match(out.stdout, /请本机 host 生成一次性配对码/);
107
+ assert.doesNotMatch(out.stdout, /同时写入 state\.json/, 'M6 之后 state.json 不再存码');
108
+ });
package/client/cli.mjs CHANGED
@@ -7,11 +7,9 @@
7
7
  // node cli.mjs events --peer <peerId|name> --session <sessionId> [--duration 300] [--verbose]
8
8
  // node cli.mjs unpair --peer <peerId|name> [--local-only] [--timeout 10]
9
9
  import crypto from 'node:crypto';
10
- import { execFileSync } from 'node:child_process';
11
10
  import fs from 'node:fs';
12
11
  import os from 'node:os';
13
12
  import path from 'node:path';
14
- import { fileURLToPath } from 'node:url';
15
13
  import {
16
14
  DEFAULT_ANSWER_WAIT_MS,
17
15
  MAX_ANSWER_WAIT_MS,
@@ -82,9 +80,11 @@ const USAGE = [
82
80
  ].join('\n');
83
81
 
84
82
  const CODE_USAGE = [
85
- '用法: dsh-lan-peers code [--state-dir <目录>]',
83
+ '用法: dsh-lan-peers code [--state-dir <目录>] [--url <本机管理接口>]',
86
84
  '',
87
- '生成一次性配对码,同时写入 state.json(10 分钟有效,用后即焚)。',
85
+ '请本机 host 生成一次性配对码(10 分钟有效,用后即焚)。',
86
+ 'M6 起码只活在 host 进程内存里:本命令是独立进程,自己生成的码对正在跑的 host 无效,',
87
+ '所以必须先启动本机 host(或直接用界面面板的「生成配对码」)。',
88
88
  ].join('\n');
89
89
 
90
90
  const PAIR_USAGE = [
@@ -227,20 +227,40 @@ async function signedCall({ baseUrl, secret, senderId, method = 'GET', apiPath,
227
227
  return callPeer({ baseUrl, secret, senderId, method, apiPath, body, timeoutMs: 60_000 });
228
228
  }
229
229
 
230
+ /**
231
+ * M6:配对码只活在 daemon 进程内存里(state.json 不再持久化它)。本命令是**独立进程**,
232
+ * 自己生成一个码打印出来,正在跑的 host 根本不知道 —— 用户抄走的是一条无效凭据
233
+ * (旧行为「另一个进程能写码」本身也正是要堵掉的植入路径)。所以这里改成请本机 host 发码:
234
+ * 它才是唯一该写这条的进程,也是唯一知道码的进程。
235
+ *
236
+ * 本机没有 host / 拿不到 token → exit 2 并说清怎么起 host,绝不本地造一个码糊弄过去。
237
+ */
230
238
  async function cmdCode(args) {
231
- // The code lives in state.json, which the host process re-reads; issuing it via
232
- // the host CLI keeps one writer instead of racing the running server.
233
- const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
234
- const out = execFileSync(process.execPath, [
235
- path.join(pkgRoot, 'host', 'server.mjs'),
236
- '--code',
237
- '--state-dir',
238
- stateDir(args),
239
- ], { encoding: 'utf8' });
240
- const match = /(\d{6})/.exec(out);
241
- if (!match) throw new Error(out.trim() || 'no pairing code');
242
- console.log('配对码: ' + match[1] + ' 有效期: 10 分钟,一次性');
239
+ const dir = stateDir(args);
240
+ const url = String(args.url ?? '').trim() || ADMIN_DEFAULT_URL;
241
+ let out;
242
+ try {
243
+ out = await adminCall(url, 'code', {
244
+ method: 'POST',
245
+ headers: { 'Content-Type': 'application/json' },
246
+ body: '{}',
247
+ }, dir);
248
+ } catch (error) {
249
+ const reason = String(error?.message ?? error);
250
+ process.stderr.write('生成配对码失败:本机 host 未接受 ' + url + '(' + reason + ')\n'
251
+ + '修法:请先启动本机 host(或用界面面板的「生成配对码」)—— 码只能在 host 进程里生成\n');
252
+ process.exitCode = 2;
253
+ return;
254
+ }
255
+ const code = String(out && out.code ? out.code : '').trim();
256
+ if (!/^\d{6}$/.test(code)) {
257
+ process.stderr.write('生成配对码失败:本机 host 未按契约回 6 位码\n');
258
+ process.exitCode = 2;
259
+ return;
260
+ }
261
+ console.log('配对码: ' + code + ' 有效期: 10 分钟,一次性');
243
262
  console.log('对方把它填进「添加伙伴」里即可配对(地址填本机 http://<ip>:7788)');
263
+ console.log('提示: 这个码只在本机 host 进程里有效,重启 host 即失效');
244
264
  }
245
265
 
246
266
  async function cmdPair(args) {
@@ -718,6 +738,10 @@ function formatDispatchLine(dispatch) {
718
738
  const status = String(data.status ?? '');
719
739
  const who = String(data.targetName ?? '') || String(data.target ?? '') || '—';
720
740
  if (status === 'skipped') return '未派单(消息里没有 @! 目标): ' + String(data.reason ?? 'no-target');
741
+ // M1:同名歧义是「用户得先选一个」,不是网络错误 —— 给出可操作的重发语法。
742
+ if (status === 'failed' && String(data.reason ?? '') === 'ambiguous') {
743
+ return '派单 → ' + who + ':有多个同名成员,请用 @!名字 (短id) 重发';
744
+ }
721
745
  const parts = [];
722
746
  // done 也可能带 reason(契约 §1:多个 @! 只取第一个,reason=multi-target-ignored)。
723
747
  const reason = String(data.reason ?? '');
@@ -751,7 +775,7 @@ async function triggerDispatch(args, command) {
751
775
  task: command.task,
752
776
  extraTargets: command.extraTargets,
753
777
  }),
754
- });
778
+ }, stateDir(args));
755
779
  } catch (error) {
756
780
  const reason = String(error?.message ?? error);
757
781
  process.stderr.write('消息已发出,但派单未触发:本机 host 未响应 ' + url + '(' + reason + ')\n'
@@ -828,7 +852,7 @@ async function cmdGroupAccept(dir, args) {
828
852
  method: 'POST',
829
853
  headers: { 'Content-Type': 'application/json' },
830
854
  body: JSON.stringify({ acceptDispatch: value }),
831
- });
855
+ }, dir);
832
856
  } catch (error) {
833
857
  const reason = String(error?.message ?? error);
834
858
  process.stderr.write('切换接单失败:本机 host 未接受 ' + url + '(' + reason + ')\n'
@@ -1052,16 +1076,37 @@ async function cmdGroupClaim(dir, args) {
1052
1076
 
1053
1077
  const ADMIN_DEFAULT_URL = 'http://127.0.0.1:7788';
1054
1078
  const ADMIN_TIMEOUT_MS = 10000;
1079
+ // S3:管理接口要 X-Lan-Peers-Token。host 启动时把它写到 <stateDir>/admin-token(0600),
1080
+ // CLI 只读不写;token 本身绝不出现在 URL、日志或错误消息里(只说去哪读)。
1081
+ const ADMIN_TOKEN_FILE = 'admin-token';
1082
+
1083
+ function readAdminToken(dir) {
1084
+ try {
1085
+ return fs.readFileSync(path.join(String(dir || DEFAULT_STATE_DIR), ADMIN_TOKEN_FILE), 'utf8').trim();
1086
+ } catch {
1087
+ return '';
1088
+ }
1089
+ }
1055
1090
 
1056
1091
  /** 打本机回环管理接口;非 2xx 抛 `<status> <error>` 形式的 Error(与 group-client 同款)。 */
1057
- async function adminCall(baseUrl, apiPath, init) {
1092
+ async function adminCall(baseUrl, apiPath, init, dir) {
1058
1093
  const base = String(baseUrl || ADMIN_DEFAULT_URL).replace(/\/+$/, '');
1094
+ const token = readAdminToken(dir);
1095
+ const headers = { ...(init && init.headers ? init.headers : {}) };
1096
+ if (token !== '') headers['X-Lan-Peers-Token'] = token;
1059
1097
  const reply = await fetch(base + '/api/lan-peers/' + apiPath, {
1060
1098
  ...init,
1099
+ headers,
1061
1100
  signal: AbortSignal.timeout(ADMIN_TIMEOUT_MS),
1062
1101
  });
1063
1102
  const body = await reply.json().catch(() => ({}));
1064
- if (!reply.ok) throw new Error(reply.status + ' ' + String(body.error || 'admin-call-failed'));
1103
+ if (!reply.ok) {
1104
+ const status = reply.status;
1105
+ const where = path.join(String(dir || DEFAULT_STATE_DIR), ADMIN_TOKEN_FILE);
1106
+ // 401 一定是 token 缺失/不匹配:直接指路(host 启动时写入,权限 0600)。
1107
+ const hint = status === 401 ? '(管理接口需要 token:确认本机 host 在跑,且 ' + where + ' 可读)' : '';
1108
+ throw new Error(status + ' ' + String(body.error || 'admin-call-failed') + hint);
1109
+ }
1065
1110
  return body;
1066
1111
  }
1067
1112
 
@@ -1075,7 +1120,7 @@ function reportArchiveError(error) {
1075
1120
  async function cmdGroupArchives(dir, args) {
1076
1121
  let out;
1077
1122
  try {
1078
- out = await adminCall(args.url, 'group/archives');
1123
+ out = await adminCall(args.url, 'group/archives', undefined, dir);
1079
1124
  } catch (error) {
1080
1125
  reportArchiveError(error);
1081
1126
  return;
@@ -1111,7 +1156,7 @@ async function cmdGroupArchiveDelete(dir, args) {
1111
1156
  method: 'POST',
1112
1157
  headers: { 'Content-Type': 'application/json' },
1113
1158
  body: JSON.stringify({ file: file, action: 'delete' }),
1114
- });
1159
+ }, dir);
1115
1160
  } catch (error) {
1116
1161
  reportArchiveError(error);
1117
1162
  return;
package/host/server.mjs CHANGED
@@ -118,7 +118,7 @@ import { parseArgs } from 'node:util';
118
118
  // ---------------------------------------------------------------------------
119
119
 
120
120
  /** 插件版本,用于 /v1/health.version */
121
- export const PLUGIN_VERSION = '0.8.2';
121
+ export const PLUGIN_VERSION = '0.8.3';
122
122
 
123
123
  /** §4.1 允许的时钟偏移 */
124
124
  export const CLOCK_SKEW_MS = 60_000;
@@ -743,6 +743,9 @@ export class StateStore {
743
743
 
744
744
  #state = null;
745
745
 
746
+ /** M6:配对码只活在本进程内存里(不落盘、不经文件被别的进程植入,重启即失效) */
747
+ #pairingCode = null;
748
+
746
749
  #peers = null;
747
750
 
748
751
  #lastTouchPersist = new Map();
@@ -802,7 +805,8 @@ export class StateStore {
802
805
  state.createdAt = nowIso(this.#now());
803
806
  dirty = true;
804
807
  }
805
- if (state.pairingCode && this.#now() >= Number(state.pairingCode.expiresAt ?? 0)) {
808
+ // M6:配对码不再落盘。老版本留下的字段读回时一律清掉(迁移即焚)
809
+ if (Object.prototype.hasOwnProperty.call(state, 'pairingCode')) {
806
810
  delete state.pairingCode;
807
811
  dirty = true;
808
812
  }
@@ -831,31 +835,29 @@ export class StateStore {
831
835
  return name;
832
836
  }
833
837
 
834
- /** 生成配对码:6 位数字、10 分钟、覆盖旧码(一次性由 consume 保证) */
838
+ /**
839
+ * 生成配对码:6 位数字、10 分钟、覆盖旧码(一次性由 consume 保证)。
840
+ * M6:码**只存进程内存** —— state.json 里不再有它,进程重启即失效,
841
+ * 同机另一个进程(或拿到文件写权限的人)也无法预置一枚可用的码。
842
+ */
835
843
  issuePairingCode() {
836
- const state = this.#readState();
837
- this.#state = state;
844
+ this.#state = this.#readState();
838
845
  const code = String(randomInt(0, 1_000_000)).padStart(6, '0');
839
846
  const expiresAt = this.#now() + PAIRING_CODE_TTL_MS;
840
- state.pairingCode = { code, expiresAt };
841
- writeJson0600(this.statePath, state);
847
+ this.#pairingCode = { code, expiresAt };
842
848
  return { code, expiresAt };
843
849
  }
844
850
 
845
851
  /** 消费配对码:不存在 / 过期 / 不匹配 -> 失败;成功即焚 */
846
852
  consumePairingCode(candidate) {
847
- const state = this.#readState();
848
- this.#state = state;
849
- const pair = state.pairingCode;
853
+ const pair = this.#pairingCode;
850
854
  if (!pair || typeof pair.code !== 'string') return { ok: false, reason: 'invalid' };
851
855
  if (this.#now() >= Number(pair.expiresAt ?? 0)) {
852
- delete state.pairingCode;
853
- writeJson0600(this.statePath, state);
856
+ this.#pairingCode = null;
854
857
  return { ok: false, reason: 'expired' };
855
858
  }
856
859
  if (!timingSafeStringEqual(pair.code, String(candidate ?? ''))) return { ok: false, reason: 'invalid' };
857
- delete state.pairingCode;
858
- writeJson0600(this.statePath, state);
860
+ this.#pairingCode = null;
859
861
  return { ok: true };
860
862
  }
861
863
 
@@ -864,13 +866,10 @@ export class StateStore {
864
866
  * 握手失败、篡改、超时都不该把码烧掉,只有 confirm 成功才 consume。
865
867
  */
866
868
  peekPairingCode(candidate) {
867
- const state = this.#readState();
868
- this.#state = state;
869
- const pair = state.pairingCode;
869
+ const pair = this.#pairingCode;
870
870
  if (!pair || typeof pair.code !== 'string') return { ok: false, reason: 'invalid' };
871
871
  if (this.#now() >= Number(pair.expiresAt ?? 0)) {
872
- delete state.pairingCode;
873
- writeJson0600(this.statePath, state);
872
+ this.#pairingCode = null;
874
873
  return { ok: false, reason: 'expired' };
875
874
  }
876
875
  if (!timingSafeStringEqual(pair.code, String(candidate ?? ''))) return { ok: false, reason: 'invalid' };
@@ -1450,6 +1449,14 @@ export class GroupStore {
1450
1449
  return this.#load().pending.length;
1451
1450
  }
1452
1451
 
1452
+ /** 未提交区的某一条正文(H1:commit 只认这条,不再采信 body 里的 message) */
1453
+ pendingMessage(seq) {
1454
+ const data = this.#load();
1455
+ const id = Math.floor(Number(seq) || 0);
1456
+ const found = data.pending.find((entry) => Number(entry?.seq) === id);
1457
+ return found ? { ...found, from: { ...found.from }, meta: { ...found.meta } } : null;
1458
+ }
1459
+
1453
1460
  /** 未提交区的 seq 列表(只给门面/测试看,不携带正文) */
1454
1461
  pendingSeqs() {
1455
1462
  return this.#load().pending.map((entry) => Number(entry?.seq)).filter((seq) => seq > 0);
@@ -2329,6 +2336,14 @@ export function createPeerServer(overrides = {}) {
2329
2336
  if (senderId !== store.instanceId && !Object.prototype.hasOwnProperty.call(ctx.members, senderId)) {
2330
2337
  // hub 是权威成员表:不认识就是不属于群,一律拒绝(自愈只给成员侧的副本用)
2331
2338
  if (!allowUnknownSender || groupStore) return { ok: false, status: 401, error: 'not-a-member' };
2339
+ // H3:自愈不能再「凭签名把任意 id 写进 group-key.json」。
2340
+ // 只允许**最近一次权威快照**(hub 的 state / pull 的 memberIds)里出现过的 id:
2341
+ // 快照说这个人在群里、只是本机表还停在旧版本 → 补进来;
2342
+ // 快照里根本没有的 id → 仍然 not-a-member(否则一个幽灵 id 就能把自己写进成员表)。
2343
+ if (!authoritativeMemberIds.has(senderId)) {
2344
+ if (config.logger) config.logger(`[group] self-heal refused peer=${shortPeerId(senderId)}`);
2345
+ return { ok: false, status: 401, error: 'not-a-member' };
2346
+ }
2332
2347
  // 自愈(真机 not-a-member 回归):群密钥签名已经证明对端握有群密钥,只是本机成员表
2333
2348
  // 还停在一份不完整的快照上(成员在 bootstrap 之后才加入)。选举/心跳是领导权协议,
2334
2349
  // 先把它补进成员表,否则对端永远投不进来、整个群选不出 leader。
@@ -4034,11 +4049,47 @@ export function createPeerServer(overrides = {}) {
4034
4049
  if (!Number.isFinite(epoch) || epoch < 0) {
4035
4050
  return { status: 400, body: { error: 'bad-request' }, peerId: auth.peerId };
4036
4051
  }
4052
+ // H2:副本只接「本机认定的 leader」推来的日志。
4053
+ // 少了这道校验,任何握着群密钥的成员都能往别人的副本里写任意历史(第二作者)。
4054
+ const recordedLeader = String(
4055
+ ensureRole()?.leaderPeerId ?? '',
4056
+ ) || String(replicaStore.state().leaderPeerId ?? '');
4057
+ if (recordedLeader === '') {
4058
+ return { status: 409, body: { error: 'no-leader' }, peerId: auth.peerId };
4059
+ }
4060
+ if (auth.peerId !== recordedLeader) {
4061
+ if (config.logger) {
4062
+ config.logger(
4063
+ `[group] replicate refused not-leader from=${shortPeerId(auth.peerId)} leader=${shortPeerId(recordedLeader)}`,
4064
+ );
4065
+ }
4066
+ return { status: 409, body: { error: 'not-leader', leaderPeerId: recordedLeader }, peerId: auth.peerId };
4067
+ }
4068
+ // H2:逐条校验作者在成员表里;不合规的那条丢弃并记一行(不整批拒,避免一条脏数据
4069
+ // 把整次复制卡死 —— 复制是 best-effort 的追赶通道,不是权威写路径)
4070
+ const knownAuthors = new Set(Object.keys(auth.ctx?.members ?? {}));
4071
+ knownAuthors.add(store.instanceId);
4072
+ const incoming = Array.isArray(body.messages) ? body.messages : [];
4073
+ const messages = [];
4074
+ let dropped = 0;
4075
+ for (const message of incoming) {
4076
+ const author = String(message?.from?.peerId ?? '');
4077
+ if (author === '' || !knownAuthors.has(author)) {
4078
+ dropped += 1;
4079
+ continue;
4080
+ }
4081
+ messages.push(message);
4082
+ }
4083
+ if (dropped > 0 && config.logger) {
4084
+ config.logger(
4085
+ `[group] replicate dropped n=${dropped} from=${shortPeerId(auth.peerId)} reason=unknown-author`,
4086
+ );
4087
+ }
4037
4088
  const applied = replicaStore.append({
4038
4089
  groupId,
4039
4090
  epoch: Math.floor(epoch),
4040
4091
  leaderPeerId: String(body.leaderPeerId ?? ''),
4041
- messages: Array.isArray(body.messages) ? body.messages : [],
4092
+ messages,
4042
4093
  });
4043
4094
  if (config.logger) {
4044
4095
  config.logger(
@@ -4997,15 +5048,41 @@ export function createPeerServer(overrides = {}) {
4997
5048
  }
4998
5049
  const commitSeq = Math.max(0, Math.floor(Number(body.commitSeq) || 0));
4999
5050
  const senderBaseUrl = headerValue(req, GROUP_BASE_HEADER.toLowerCase());
5000
- rememberPeerBaseUrl(leaderPeerId, senderBaseUrl);
5001
- // v0.8.0:心跳里 X-Peer-Base 是**发送方自己**的地址 —— 顺手记到发送方名下。
5051
+ // v0.8.0:心跳里 X-Peer-Base 是**发送方自己**的地址 —— 只记到发送方名下。
5002
5052
  // 少了这一笔,成员重启换端口后只有下一次 pull(测试里 600s 一次)才会纠正地址,
5003
5053
  // 期间 leader 的提案会打到旧地址上(表现:post 503 quorum-unavailable,而成员明明在线)。
5004
5054
  rememberMemberBaseUrl(auth.peerId, senderBaseUrl);
5005
5055
  rememberMemberAcceptDispatch(auth.peerId, body.acceptDispatch);
5006
- if (Number.isFinite(Number(body.priority))) peerPriorities.set(leaderPeerId, Math.floor(Number(body.priority)));
5007
5056
  const st = ensureRole();
5008
5057
  if (!st) return { status: 404, body: { error: 'not-found' }, peerId: auth.peerId };
5058
+ // S2:心跳只能为**自己**续租。少了这条,任何握着群密钥的成员发一条心跳就能把
5059
+ // 对端的 leaderPeerId 改写成任意 id(实测:一句话让对面变 follower、leader 指向别人)。
5060
+ if (leaderPeerId !== auth.peerId) {
5061
+ if (config.logger) {
5062
+ config.logger(
5063
+ `[group] heartbeat refused not-self-leader from=${shortPeerId(auth.peerId)} claim=${shortPeerId(leaderPeerId)} epoch=${epoch}`,
5064
+ );
5065
+ }
5066
+ return { status: 403, body: { error: 'not-self-leader', epoch: st.epoch }, peerId: auth.peerId };
5067
+ }
5068
+ // 地址与优先级也只许写在自己名下(以前 key 用的是 body 里的 leaderPeerId = 别人的 id)
5069
+ rememberPeerBaseUrl(auth.peerId, senderBaseUrl);
5070
+ if (Number.isFinite(Number(body.priority))) peerPriorities.set(auth.peerId, Math.floor(Number(body.priority)));
5071
+ // S2:心跳不得推进 epoch —— epoch 只能由 /v1/group/election 的票数推进。
5072
+ // 心跳带更高的 epoch 就是「靠一句话篡位」,按 stale-epoch 拒,并留一行告警。
5073
+ if (epoch > st.epoch) {
5074
+ noteEpochJump(st.epoch, epoch, 'heartbeat-rejected');
5075
+ if (config.logger) {
5076
+ config.logger(
5077
+ `[group] heartbeat refused epoch-advance from=${shortPeerId(auth.peerId)} epoch=${epoch} keep=${st.epoch}`,
5078
+ );
5079
+ }
5080
+ return {
5081
+ status: 409,
5082
+ body: { error: 'stale-epoch', epoch: st.epoch, leaderPeerId: st.leaderPeerId, commitSeq: st.commitSeq },
5083
+ peerId: auth.peerId,
5084
+ };
5085
+ }
5009
5086
  if (epoch < st.epoch) {
5010
5087
  return {
5011
5088
  status: 409,
@@ -5013,26 +5090,20 @@ export function createPeerServer(overrides = {}) {
5013
5090
  peerId: auth.peerId,
5014
5091
  };
5015
5092
  }
5016
- if (epoch === st.epoch && st.leaderPeerId !== '' && st.leaderPeerId !== leaderPeerId) {
5017
- const wins = leaderWins(
5018
- { peerId: leaderPeerId, priority: priorityOf(leaderPeerId) },
5019
- { peerId: st.leaderPeerId, priority: priorityOf(st.leaderPeerId) },
5020
- );
5021
- if (!wins) {
5022
- if (config.logger) {
5023
- config.logger(
5024
- `[group] heartbeat conflict from=${shortPeerId(leaderPeerId)} epoch=${epoch} keep=${shortPeerId(st.leaderPeerId)}`,
5025
- );
5026
- }
5027
- return {
5028
- status: 409,
5029
- body: { error: 'leader-conflict', epoch: st.epoch, leaderPeerId: st.leaderPeerId, commitSeq: st.commitSeq },
5030
- peerId: auth.peerId,
5031
- };
5032
- }
5093
+ // 同 epoch:只有本机记录的 leader 自己来才续租;领导权变更一律走 election 的票数。
5094
+ // (st.leaderPeerId === '' = 本机还没有 leader 记录,这是成员的 bootstrap 路径:
5095
+ // 此时接受发送方自报的 leader,等于把 group-key.json 里已知的那个 leader 学进角色表。)
5096
+ if (st.leaderPeerId !== '' && st.leaderPeerId !== leaderPeerId) {
5033
5097
  if (config.logger) {
5034
- config.logger(`[group] heartbeat conflict from=${shortPeerId(leaderPeerId)} epoch=${epoch} adopt`);
5098
+ config.logger(
5099
+ `[group] heartbeat conflict from=${shortPeerId(leaderPeerId)} epoch=${epoch} keep=${shortPeerId(st.leaderPeerId)}`,
5100
+ );
5035
5101
  }
5102
+ return {
5103
+ status: 409,
5104
+ body: { error: 'leader-conflict', epoch: st.epoch, leaderPeerId: st.leaderPeerId, commitSeq: st.commitSeq },
5105
+ peerId: auth.peerId,
5106
+ };
5036
5107
  }
5037
5108
  const before = st.commitSeq;
5038
5109
  const accepted = acceptLeader({ epoch, leaderPeerId, commitSeq });
@@ -5159,6 +5230,17 @@ export function createPeerServer(overrides = {}) {
5159
5230
  return { status: 200, body: { ok: true, accepted: true, epoch: st.epoch }, peerId: auth.peerId };
5160
5231
  }
5161
5232
 
5233
+ /**
5234
+ * 本机未提交区里那一条。两条存放路径都要看:
5235
+ * · propose 收到的提案 → pendingProposals(内存,hub 与成员都走这里);
5236
+ * · 本机自己 stage 的(leader 的 post 路径)→ hub 落 group.json.pending,成员落内存表。
5237
+ */
5238
+ function stagedProposal(seq) {
5239
+ const inMemory = pendingProposals.get(seq)?.message ?? null;
5240
+ if (inMemory) return inMemory;
5241
+ return groupStore ? groupStore.pendingMessage(seq) : null;
5242
+ }
5243
+
5162
5244
  /** POST /v1/group/commit —— 提交确认:pending 转正并推进 commitSeq(契约 §5) */
5163
5245
  function groupCommit(req, rawBody, url, auth) {
5164
5246
  const body = parseS3Body(rawBody);
@@ -5175,10 +5257,30 @@ export function createPeerServer(overrides = {}) {
5175
5257
  peerId: auth.peerId,
5176
5258
  };
5177
5259
  }
5178
- const staged = pendingProposals.get(seq)?.message ?? null;
5179
- const raw = body.message && typeof body.message === 'object' ? body.message : staged;
5180
- let applied = false;
5181
- if (raw) applied = applyCommitted(normalizeIncomingMessage(raw, seq));
5260
+ // H1:与 propose 同口径 —— 只有 leader 能提交。少了这条,任何成员都能把
5261
+ // 自己发明的消息「转正」进别人的日志(并顺手把 commitSeq 抬到任意值)。
5262
+ if (st.leaderPeerId !== '' && auth.peerId !== st.leaderPeerId) {
5263
+ if (config.logger) {
5264
+ config.logger(
5265
+ `[group] commit refused not-leader from=${shortPeerId(auth.peerId)} seq=${seq} epoch=${epoch}`,
5266
+ );
5267
+ }
5268
+ return { status: 409, body: { error: 'not-leader', epoch: st.epoch, commitSeq: st.commitSeq }, peerId: auth.peerId };
5269
+ }
5270
+ // H1:正文只认本机 pending 里那一条 —— body.message 不再被采信,
5271
+ // 客户端也无法用一个没提案过的 seq 抬高 commitSeq。
5272
+ const staged = stagedProposal(seq);
5273
+ if (!staged) {
5274
+ if (seq <= st.commitSeq) {
5275
+ // 幂等:这条早就转正过了(广播重投),不重复应用、不推进号
5276
+ return { status: 200, body: { ok: true, commitSeq: st.commitSeq, applied: false }, peerId: auth.peerId };
5277
+ }
5278
+ if (config.logger) {
5279
+ config.logger(`[group] commit refused unknown-seq from=${shortPeerId(auth.peerId)} seq=${seq} epoch=${epoch}`);
5280
+ }
5281
+ return { status: 409, body: { error: 'unknown-seq', epoch: st.epoch, commitSeq: st.commitSeq }, peerId: auth.peerId };
5282
+ }
5283
+ const applied = applyCommitted(normalizeIncomingMessage(staged, seq));
5182
5284
  pendingProposals.delete(seq);
5183
5285
  const next = roleStore.update({
5184
5286
  commitSeq: Math.max(st.commitSeq, seq),
@@ -350,7 +350,8 @@ test('§3/§2 配对码:6 位数字、10 分钟、一次性;成功后写入
350
350
 
351
351
  const statePath = path.join(stateDir, 'state.json');
352
352
  if (process.platform !== 'win32') assert.equal(fileMode(statePath), 0o600);
353
- assert.equal(readJson(statePath).pairingCode.code, issued.code);
353
+ // M6:配对码只活在进程内存里 —— state.json 里绝不能出现它(重启即失效、也无法被预置)
354
+ assert.equal(readJson(statePath).pairingCode, undefined, 'M6:配对码不落盘');
354
355
 
355
356
  const pairBody = {
356
357
  code: issued.code,
@@ -453,20 +454,33 @@ test('§3 配对 body 缺字段 → 400;码格式不对 / 没发码 → 401 in
453
454
  assert.equal(badFormat.json.error, 'invalid-code');
454
455
  });
455
456
 
456
- test('配对码由另一个进程(CLI)写入时,正在运行的 server 也能看到', async (t) => {
457
+ test('M6 配对码不再经 state.json 传递:另一个进程写入的码不被采信(进程内码照常可用)', async (t) => {
457
458
  const stateDir = makeTempStateDir();
458
- const { baseUrl } = await startServer(t, { stateDir, name: 'nas-box' });
459
+ const { server, baseUrl } = await startServer(t, { stateDir, name: 'nas-box' });
459
460
 
460
- // 模拟 `lan-peers code` 在另一个进程里写 state.json
461
- const { code } = issuePairingCode({ stateDir, name: 'nas-box' });
461
+ // 同机另一个进程(CLI/别的身份)往 state.json 里塞一枚码:这是 M6 要堵的植入路径
462
+ const forged = issuePairingCode({ stateDir, name: 'nas-box' });
463
+ const onDisk = readJson(path.join(stateDir, 'state.json'));
464
+ assert.equal(onDisk.pairingCode, undefined, 'M6:issuePairingCode 不再往 state.json 写码');
462
465
 
466
+ const refused = await request(baseUrl, {
467
+ method: 'POST',
468
+ path: '/v1/pair',
469
+ signed: false,
470
+ body: { code: forged.code, peerId: 'peer-cli', peerName: 'CLI', secret: 's-cli' },
471
+ });
472
+ assert.equal(refused.status, 401, refused.text);
473
+ assert.equal(refused.json.error, 'invalid-code', '另一个进程发的码不能配到这台 server');
474
+
475
+ // 本进程发的码照常能用(配对流程本身没变)
476
+ const ours = server.issuePairingCode();
463
477
  const paired = await request(baseUrl, {
464
478
  method: 'POST',
465
479
  path: '/v1/pair',
466
480
  signed: false,
467
- body: { code, peerId: 'peer-cli', peerName: 'CLI', secret: 's-cli' },
481
+ body: { code: ours.code, peerId: 'peer-cli', peerName: 'CLI', secret: 's-cli' },
468
482
  });
469
- assert.equal(paired.status, 200);
483
+ assert.equal(paired.status, 200, paired.text);
470
484
  assert.equal(readJson(path.join(stateDir, 'state.json')).pairingCode, undefined);
471
485
  });
472
486
 
@@ -32,17 +32,19 @@ function makeResponse() {
32
32
  }
33
33
 
34
34
  /** 回环 admin 请求(与 GUI 面板同一个 handle 入口) */
35
- function makeRequest(method, apiPath, body) {
35
+ function makeRequest(method, apiPath, body, admin) {
36
36
  const chunks = body === undefined ? [] : [Buffer.from(JSON.stringify(body))];
37
+ const token = admin && typeof admin.token === 'string' ? admin.token : '';
37
38
  const req = {
38
39
  method,
39
40
  url: apiPath,
40
41
  socket: { remoteAddress: '127.0.0.1', setNoDelay() {} },
41
- // 回环 authority:admin 用 Host 头做回环门控
42
+ // 回环 authority + 进程 token:S3 之后两道门都要过(面板带的就是这两个头)
42
43
  headers: {
43
44
  host: '127.0.0.1:7788',
44
45
  'content-type': 'application/json',
45
46
  'content-length': String(chunks[0]?.length ?? 0),
47
+ ...(token === '' ? {} : { 'x-lan-peers-token': token }),
46
48
  },
47
49
  on() { return req; },
48
50
  once() { return req; },
@@ -53,7 +55,7 @@ function makeRequest(method, apiPath, body) {
53
55
 
54
56
  async function hit(admin, method, apiPath, body) {
55
57
  const res = makeResponse();
56
- const handled = await admin.handle(makeRequest(method, apiPath, body), res, new URL('http://127.0.0.1' + apiPath));
58
+ const handled = await admin.handle(makeRequest(method, apiPath, body, admin), res, new URL('http://127.0.0.1' + apiPath));
57
59
  let json = null;
58
60
  try { json = JSON.parse(res.body); } catch { json = null; }
59
61
  return { handled, status: res.status, json };