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 +5 -5
- package/client/cli-group.test.mjs +9 -9
- package/client/pairing-baseurl.test.mjs +9 -9
- package/host/server.mjs +112 -22
- package/host/test/auth.test.mjs +16 -16
- package/host/test/group-auth.test.mjs +3 -3
- package/host/test/group-members-authoritative.test.mjs +119 -0
- package/host/test/group.test.mjs +18 -18
- package/host/test/helpers.mjs +1 -1
- package/host/test/pairing-ecdh.test.mjs +2 -2
- package/host/test/unpair.test.mjs +1 -1
- package/lib/admin-actions.test.mjs +1 -1
- package/lib/admin-group-apply.test.mjs +11 -11
- package/lib/admin-group.test.mjs +32 -32
- package/lib/admin-unpair.test.mjs +3 -3
- package/lib/admin.mjs +1 -1
- package/lib/client-render.test.mjs +1 -1
- package/lib/client.js +1 -1
- package/lib/group-client.mjs +20 -11
- package/lib/panel-archives.test.mjs +3 -3
- package/lib/panel-group-apply.test.mjs +7 -7
- package/lib/panel-group.test.mjs +16 -16
- package/lib/panel-unpair.test.mjs +9 -9
- package/overlays/web.patch.yml +1 -1
- package/package.json +66 -51
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.
|
|
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.
|
|
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.
|
|
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 问
|
|
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
|
|
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', '
|
|
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, /显示名=
|
|
191
|
-
const joinedB = await runAsync(['group', 'join', '--name', '
|
|
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, /
|
|
201
|
-
assert.match(state.stdout, /
|
|
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, /
|
|
208
|
-
assert.match(history.stdout, /
|
|
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, '
|
|
213
|
-
assert.equal(raw.members[peerB.peerId].name, '
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
113
|
-
assert.equal(detectSelfBaseUrl({ interfaces, port: 8123 }), 'http://192.168.
|
|
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.
|
|
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.
|
|
123
|
-
'http://192.168.
|
|
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.
|
|
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.
|
|
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 后缀**("
|
|
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
|
-
|
|
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
|
-
//
|
|
3700
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
5308
|
-
*
|
|
5309
|
-
*
|
|
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
|
|
5389
|
+
function replaceGroupMembers(members, names = {}) {
|
|
5312
5390
|
const existing = readGroupKeyFile(config.stateDir);
|
|
5313
|
-
if (!existing) return;
|
|
5314
|
-
const
|
|
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
|
-
|
|
5319
|
-
|
|
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 ??
|
|
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] = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5580
|
+
replaceGroupMembers(page.memberIds, ctx.members);
|
|
5491
5581
|
}
|
|
5492
5582
|
memberLastError = '';
|
|
5493
5583
|
// hub 在 pull 响应里自报地址(X-Peer-Base 同一口径):按名入群的节点本地没有 hub 的
|
package/host/test/auth.test.mjs
CHANGED
|
@@ -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: '
|
|
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, '
|
|
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: '
|
|
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: '
|
|
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, '
|
|
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: '
|
|
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.
|
|
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, '
|
|
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.
|
|
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: '
|
|
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: '
|
|
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.
|
|
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.
|
|
502
|
-
assert.equal(isAllowedRemote('192.168.
|
|
503
|
-
assert.equal(isAllowedRemote('::ffff:192.168.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
});
|