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.
- package/client/cli-code.test.mjs +108 -0
- package/client/cli.mjs +67 -22
- package/host/server.mjs +147 -45
- package/host/test/auth.test.mjs +21 -7
- package/host/test/group-applications-admin.test.mjs +5 -3
- package/host/test/group-election.test.mjs +105 -31
- package/host/test/group-epoch.test.mjs +33 -17
- package/host/test/group-key-rotation.test.mjs +9 -0
- package/host/test/group-security-lane-a.test.mjs +281 -0
- package/host/test/pairing-ecdh.test.mjs +6 -5
- package/lib/admin-actions.test.mjs +11 -3
- package/lib/admin-archives.test.mjs +11 -3
- package/lib/admin-dispatch.test.mjs +11 -3
- package/lib/admin-group-apply.test.mjs +11 -3
- package/lib/admin-group.test.mjs +11 -3
- package/lib/admin-surface.test.mjs +477 -0
- package/lib/admin-unpair.test.mjs +6 -1
- package/lib/admin.mjs +188 -29
- package/lib/client-render.test.mjs +23 -1
- package/lib/client.js +56 -7
- package/package.json +1 -1
|
@@ -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
|
-
'
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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)
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
838
|
+
/**
|
|
839
|
+
* 生成配对码:6 位数字、10 分钟、覆盖旧码(一次性由 consume 保证)。
|
|
840
|
+
* M6:码**只存进程内存** —— state.json 里不再有它,进程重启即失效,
|
|
841
|
+
* 同机另一个进程(或拿到文件写权限的人)也无法预置一枚可用的码。
|
|
842
|
+
*/
|
|
835
843
|
issuePairingCode() {
|
|
836
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
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(
|
|
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
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
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),
|
package/host/test/auth.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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('
|
|
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
|
-
//
|
|
461
|
-
const
|
|
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
|
|
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 };
|