dsh-lan-peers 0.8.2 → 0.8.4
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 +417 -58
- 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/helpers.mjs +6 -0
- package/host/test/pairing-ecdh.test.mjs +6 -5
- package/host/test/rate-limit.test.mjs +408 -0
- 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;
|