dsh-lan-peers 0.7.2 → 0.7.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/HANDOVER.md CHANGED
@@ -180,7 +180,11 @@ ssh ai@192.168.5.51 "sudo systemctl restart dsh-web"
180
180
  4. 重启桌面版。
181
181
 
182
182
  改完文件不必重启:**改 profile 的 `cordis.patch.yml` 会触发热重载**(改包内文件不会,需要动一下 profile 文件)。
183
- > ⚠️ **2026-10-27 实测反例**:桌面版(0.4.0,Windows)`touch ~/.dsh/profiles/desktop/cordis.patch.yml` **没有**触发热重载——`~/.dsh/lan-peers.log` 无新的 `listening` 行,`/api/lan-peers/panel.js` 仍返回旧内容。改动包内 `lib/` 后要生效必须**重启桌面应用**。这条待复核是否与版本有关,暂勿再把它当作可靠热重载手段。
183
+ > ✅ **2026-10-27 更正(重要)**:**改 profile `cordis.patch.yml` 的内容会触发插件热重载**——实测桌面版 14:58:06 因新增一行 `hubPriority: 10` 而重载,日志出现新的 `module imported` 与 `listening`,`/v1/health` 从 0.7.0 直接变成 0.7.2(说明它重新 import 了 `link:` 指向的最新代码)。
184
+ > 但**单纯 `touch`(不改内容)不会**触发——早先据此写下的「热重载不可靠」是**错误结论**,已更正。
185
+ > 实用含义:想在不重启应用的前提下加载新的服务端代码,**改一下 profile 配置内容(哪怕加一行注释)** 即可;客户端组件(面板 UI)是否随之刷新仍未验证,界面上看不到变化时仍需重启。
186
+
187
+ > ⚠️ 旧的错误结论(保留作教训):曾记录「touch profile 不触发热重载」——错在只 `touch` 没改内容。
184
188
 
185
189
  ## 8. 回归检查清单(发版/部署后跑)
186
190
 
package/host/server.mjs CHANGED
@@ -96,7 +96,7 @@ import { parseArgs } from 'node:util';
96
96
  // ---------------------------------------------------------------------------
97
97
 
98
98
  /** 插件版本,用于 /v1/health.version */
99
- export const PLUGIN_VERSION = '0.7.2';
99
+ export const PLUGIN_VERSION = '0.7.4';
100
100
 
101
101
  /** §4.1 允许的时钟偏移 */
102
102
  export const CLOCK_SKEW_MS = 60_000;
@@ -986,6 +986,9 @@ export class GroupStore {
986
986
  if (!Array.isArray(data.messages)) data.messages = [];
987
987
  // S3 §5:未提交区。与 messages 分开存,读写都不进 history/pull/events。
988
988
  if (!Array.isArray(data.pending)) data.pending = [];
989
+ // 被移除的成员:记住它,防止它用「自助 join」把自己加回来(踢人要真的踢掉)。
990
+ // 只有运维显式 addMember 会把这条记录清掉(等价于重新邀请)。
991
+ if (typeof data.blocked !== 'object' || data.blocked === null || Array.isArray(data.blocked)) data.blocked = {};
989
992
  data.pending = data.pending.filter((entry) => entry && typeof entry === 'object'
990
993
  && Number.isFinite(Number(entry.seq)) && Number(entry.seq) > 0);
991
994
  if (typeof data.members !== 'object' || data.members === null || Array.isArray(data.members)) data.members = {};
@@ -1044,6 +1047,11 @@ export class GroupStore {
1044
1047
  const id = String(peerId ?? '').trim();
1045
1048
  if (id === '') throw new Error('member peerId must not be empty');
1046
1049
  const data = this.#load();
1050
+ // 运维显式加人 = 重新邀请:解除「被移除」记录
1051
+ if (Object.prototype.hasOwnProperty.call(data.blocked ?? {}, id)) {
1052
+ delete data.blocked[id];
1053
+ this.#save(data);
1054
+ }
1047
1055
  const existing = data.members[id];
1048
1056
  if (!existing) {
1049
1057
  data.members[id] = { name: String(name ?? ''), addedAt: nowIso(this.#now()) };
@@ -1055,11 +1063,24 @@ export class GroupStore {
1055
1063
  return data.members[id];
1056
1064
  }
1057
1065
 
1066
+ /** 这个 peerId 是否被运维移除过(自助 join 必须拒绝) */
1067
+ isBlocked(peerId) {
1068
+ const data = this.#load();
1069
+ return Object.prototype.hasOwnProperty.call(data.blocked ?? {}, String(peerId ?? ''));
1070
+ }
1071
+
1058
1072
  removeMember(peerId) {
1059
1073
  const data = this.#load();
1060
1074
  const id = String(peerId ?? '');
1061
1075
  if (!Object.prototype.hasOwnProperty.call(data.members, id)) return false;
1062
1076
  delete data.members[id];
1077
+ // 记住「被移除」:否则它还能用 per-peer 签名走 /v1/group/join(autoJoin)把自己加回来
1078
+ data.blocked[id] = nowIso(this.#now());
1079
+ // 移除成员 = 它可能还握着旧 groupKey:必须立刻轮换密钥,否则「踢人」形同虚设
1080
+ // (被移除者只要还能用旧钥签名,就能把自己的心跳送进来)。
1081
+ data.key = randomBytes(32).toString('base64url');
1082
+ const version = Number(data.keyVersion);
1083
+ data.keyVersion = (Number.isInteger(version) && version >= 1 ? version : 1) + 1;
1063
1084
  this.#save(data);
1064
1085
  return true;
1065
1086
  }
@@ -1074,10 +1095,16 @@ export class GroupStore {
1074
1095
  return name;
1075
1096
  }
1076
1097
 
1077
- /** 追加一条消息:seq = ++lastSeq(消息被删也不回收号) */
1098
+ /**
1099
+ * 追加一条消息(**单阶段写**:quorum:none、以及管理接口/面板的直写路径)。
1100
+ * 取号必须避开 pending 已预留的空号:两阶段提交的提案先占住 lastSeq+1,
1101
+ * 此时若再来一条直写也取 lastSeq+1,两条消息就会共用同一个 seq ——
1102
+ * 客户端按 seq 去重,后一条会被静默丢弃。
1103
+ */
1078
1104
  append({ peerId, name, kind, text = '', meta = {} }) {
1079
1105
  const data = this.#load();
1080
- const seq = Number(data.lastSeq) + 1;
1106
+ const reserved = data.pending.reduce((max, entry) => Math.max(max, Number(entry?.seq) || 0), 0);
1107
+ const seq = Math.max(Number(data.lastSeq) || 0, reserved) + 1;
1081
1108
  data.lastSeq = seq;
1082
1109
  const message = {
1083
1110
  seq,
@@ -1809,7 +1836,7 @@ export function createPeerServer(overrides = {}) {
1809
1836
  * groupId 一致、senderPeerId 在成员表、ts ±60s、nonce 120s 去重。
1810
1837
  * 返回 {ok:true, senderId, ctx} 或 {ok:false, status, error}。
1811
1838
  */
1812
- function verifyGroupAuth(req, rawBody) {
1839
+ function verifyGroupAuth(req, rawBody, { allowUnknownSender = false } = {}) {
1813
1840
  const senderId = headerValue(req, 'x-peer-id');
1814
1841
  const groupId = headerValue(req, GROUP_ID_HEADER.toLowerCase());
1815
1842
  const tsRaw = headerValue(req, 'x-peer-ts');
@@ -1824,9 +1851,8 @@ export function createPeerServer(overrides = {}) {
1824
1851
  const ctx = localGroupContext();
1825
1852
  if (!ctx || !ctx.groupKey) return { ok: false, status: 401, error: 'group-auth-unavailable' };
1826
1853
  if (String(groupId) !== String(ctx.groupId)) return { ok: false, status: 401, error: 'group-mismatch' };
1827
- if (senderId !== store.instanceId && !Object.prototype.hasOwnProperty.call(ctx.members, senderId)) {
1828
- return { ok: false, status: 401, error: 'not-a-member' };
1829
- }
1854
+ // 先验签、再查成员表:被移除的成员拿着**旧群密钥**打进来应当是 bad-signature(密钥世代不对),
1855
+ // 而不是 not-a-member —— 这样「移除成员即轮换密钥」才有可验证的信号。
1830
1856
  const canonical = buildGroupCanonical({
1831
1857
  method: req.method,
1832
1858
  path: req.url,
@@ -1839,6 +1865,15 @@ export function createPeerServer(overrides = {}) {
1839
1865
  const { signKey } = deriveGroupKeys(ctx.groupKey);
1840
1866
  const expected = signGroupCanonical({ signKey, canonical });
1841
1867
  if (!timingSafeStringEqual(expected, signature)) return { ok: false, status: 401, error: 'bad-signature' };
1868
+ if (senderId !== store.instanceId && !Object.prototype.hasOwnProperty.call(ctx.members, senderId)) {
1869
+ // hub 是权威成员表:不认识就是不属于群,一律拒绝(自愈只给成员侧的副本用)
1870
+ if (!allowUnknownSender || groupStore) return { ok: false, status: 401, error: 'not-a-member' };
1871
+ // 自愈(真机 not-a-member 回归):群密钥签名已经证明对端握有群密钥,只是本机成员表
1872
+ // 还停在一份不完整的快照上(成员在 bootstrap 之后才加入)。选举/心跳是领导权协议,
1873
+ // 先把它补进成员表,否则对端永远投不进来、整个群选不出 leader。
1874
+ upsertGroupMember(senderId);
1875
+ if (config.logger) config.logger(`[group] member healed peer=${shortPeerId(senderId)}`);
1876
+ }
1842
1877
  // 只有签名通过的 nonce 才登记(同 per-peer 路径)
1843
1878
  rememberGroupNonce(nonce, nowMs);
1844
1879
  return { ok: true, senderId, ctx };
@@ -2495,6 +2530,11 @@ export function createPeerServer(overrides = {}) {
2495
2530
  const existing = groupMemberOf(auth.peerId);
2496
2531
  if (!existing) {
2497
2532
  if (!config.group.autoJoin) return { status: 403, body: { error: 'not-a-member' }, peerId: auth.peerId };
2533
+ // 被移除过的成员:自助 join 不生效(必须有运维显式 addMember 重新邀请)
2534
+ if (groupStore.isBlocked(auth.peerId)) {
2535
+ if (config.logger) config.logger(`[group] join refused peer=${shortPeerId(auth.peerId)} reason=removed`);
2536
+ return { status: 403, body: { error: 'not-a-member' }, peerId: auth.peerId };
2537
+ }
2498
2538
  groupStore.addMember(auth.peerId, requestedName ?? auth.peer?.name ?? '');
2499
2539
  if (config.logger) config.logger(`[group] join ${auth.peerId}`);
2500
2540
  } else if (requestedName && requestedName !== existing.name) {
@@ -2676,6 +2716,97 @@ export function createPeerServer(overrides = {}) {
2676
2716
  memberProgress.set(id, { lastSeq: Math.floor(seq), at: config.now() });
2677
2717
  }
2678
2718
 
2719
+ /** 把对端补进本机成员表(自愈用):hub 写 group.json,成员写 group-key.json */
2720
+ /** 群密钥世代(hub 读 group.json 的 keyVersion,成员读 group-key.json 的 epoch) */
2721
+ function groupKeyVersion() {
2722
+ const ctx = localGroupContext();
2723
+ return Number(ctx?.epoch) || 0;
2724
+ }
2725
+
2726
+ /**
2727
+ * 成员侧换钥:hub 轮换 groupKey(例如移除成员)后,老密钥的请求会变成 401 bad-signature。
2728
+ * 用 per-peer 签名重取 GET /v1/group/state(响应加密、含 groupKey/keyVersion),
2729
+ * 更新本地 group-key.json 后由调用方重试一次。
2730
+ */
2731
+ let lastKeyRefreshAt = 0;
2732
+
2733
+ async function refreshGroupKeyFromHub() {
2734
+ if (groupRole !== 'member') return { ok: false, reason: 'not-member' };
2735
+ // 节流:同一秒内的连续坏签名不重复拉 state(脏请求打不爆)
2736
+ if (config.now() - lastKeyRefreshAt < 1_000) return { ok: false, reason: 'throttled' };
2737
+ lastKeyRefreshAt = config.now();
2738
+ const hub = groupHubPeer();
2739
+ if (!hub) return { ok: false, reason: 'hub-not-paired' };
2740
+ try {
2741
+ const state = await fetchGroupState({
2742
+ baseUrl: hub.baseUrl,
2743
+ secret: hub.secret,
2744
+ senderId: store.instanceId,
2745
+ timeoutMs: 5_000,
2746
+ });
2747
+ if (!state?.groupId || !state?.groupKey) return { ok: false, reason: 'group-state-incomplete' };
2748
+ const existing = readGroupKeyFile(config.stateDir) ?? { members: {} };
2749
+ const keyVersion = Number(state.keyVersion) || Number(state.epoch) || 0;
2750
+ const members = { ...(existing.members ?? {}) };
2751
+ if (!members[store.instanceId]) members[store.instanceId] = { name: config.name, baseUrl: '' };
2752
+ writeGroupKeyFile(config.stateDir, {
2753
+ ...existing,
2754
+ groupId: String(state.groupId),
2755
+ key: String(state.groupKey),
2756
+ // epoch 只增不减:轮换后这里跟着 keyVersion 走,角色初始化再用 max() 兜一次
2757
+ epoch: Math.max(Number(existing.epoch) || 0, keyVersion),
2758
+ leaderPeerId: String(existing.leaderPeerId ?? ''),
2759
+ name: config.name,
2760
+ members,
2761
+ });
2762
+ replicaStore?.ensureHeader?.({ groupId: String(state.groupId) });
2763
+ if (config.logger) config.logger(`[group] key refreshed keyVersion=${keyVersion}`);
2764
+ return { ok: true, keyVersion };
2765
+ } catch (error) {
2766
+ if (config.logger) {
2767
+ config.logger(`[group] key refresh failed reason=${String(error?.message ?? error).slice(0, 60)}`);
2768
+ }
2769
+ return { ok: false, reason: 'refresh-failed' };
2770
+ }
2771
+ }
2772
+
2773
+ /**
2774
+ * 群级请求的统一入口:正常发一次;成员侧遇到 401 bad-signature 就换钥再发**一次**。
2775
+ * callFn 拿到的 ctx(groupKey/groupId)在重试时是重新读盘得到的,保证用的是新钥。
2776
+ */
2777
+ async function withGroupKey(callFn) {
2778
+ const ctx = localGroupContext();
2779
+ if (!ctx?.groupKey) throw new Error('group-key-missing');
2780
+ try {
2781
+ return await callFn(ctx);
2782
+ } catch (error) {
2783
+ const code = String(error?.code ?? '');
2784
+ const status = Number(error?.status);
2785
+ const retryable = groupRole === 'member'
2786
+ && ((status === 401 && code === 'bad-signature')
2787
+ || (status === 400 && code === 'bad-encryption'));
2788
+ if (!retryable) throw error;
2789
+ const refreshed = await refreshGroupKeyFromHub();
2790
+ if (refreshed.ok !== true) throw error;
2791
+ const fresh = localGroupContext();
2792
+ if (!fresh?.groupKey) throw error;
2793
+ return callFn(fresh);
2794
+ }
2795
+ }
2796
+
2797
+ function upsertGroupMember(peerId) {
2798
+ const id = String(peerId ?? '');
2799
+ if (id === '') return;
2800
+ if (groupStore) {
2801
+ groupStore.addMember(id, '');
2802
+ return;
2803
+ }
2804
+ const existing = readGroupKeyFile(config.stateDir);
2805
+ if (!existing || existing.members?.[id]) return;
2806
+ const members = { ...existing.members, [id]: { name: '', baseUrl: '' } };
2807
+ writeGroupKeyFile(config.stateDir, { ...existing, members });
2808
+ }
2809
+
2679
2810
  function groupMemberName(peerId) {
2680
2811
  const ctx = localGroupContext();
2681
2812
  return String(ctx?.members?.[peerId]?.name ?? '') || config.name;
@@ -2686,9 +2817,13 @@ export function createPeerServer(overrides = {}) {
2686
2817
  * 1) X-Peer-Auth: group —— 群级签名(成员之间、成员→hub 的复制/补差);
2687
2818
  * 2) 现有 per-peer 签名 —— CLI 排障用(仍要过成员门控,与 state/history 同口径)。
2688
2819
  */
2689
- function authorizeGroupRequest(req, rawBody) {
2820
+ /** 领导权协议:允许「成员表未同步」的对端自愈进来(其余路径仍严格按成员表 401) */
2821
+ const GROUP_SELF_HEAL_PATHS = new Set(['/v1/group/election', '/v1/group/leader']);
2822
+
2823
+ function authorizeGroupRequest(req, rawBody, url = null) {
2690
2824
  if (headerValue(req, GROUP_AUTH_HEADER.toLowerCase()) === GROUP_AUTH_VALUE) {
2691
- const checked = verifyGroupAuth(req, rawBody);
2825
+ const allowUnknownSender = Boolean(url && GROUP_SELF_HEAL_PATHS.has(url.pathname));
2826
+ const checked = verifyGroupAuth(req, rawBody, { allowUnknownSender });
2692
2827
  if (!checked.ok) return checked;
2693
2828
  return { ok: true, mode: 'group', peerId: checked.senderId, ctx: checked.ctx };
2694
2829
  }
@@ -2849,6 +2984,12 @@ export function createPeerServer(overrides = {}) {
2849
2984
  let roleTimer = null;
2850
2985
  let heartbeatTimer = null;
2851
2986
  let roleSeeded = false;
2987
+ /** 本进程启动时刻(受限启动竞选的「稳定期」起点) */
2988
+ const processStartedAtMs = config.now();
2989
+ /** 契约补充:每个进程生命周期最多尝试一次按优先级接管 */
2990
+ let takeoverAttempted = false;
2991
+ let takeoverAttempts = 0;
2992
+ const takeoverStableMs = heartbeatMs * 2;
2852
2993
  /** 提交串行化:一次只让一条消息走 propose→commit,seq 才连续、pending 区才不撞号 */
2853
2994
  let postChain = Promise.resolve();
2854
2995
 
@@ -2885,7 +3026,12 @@ export function createPeerServer(overrides = {}) {
2885
3026
  groupId: String(ctx?.groupId ?? st.groupId ?? ''),
2886
3027
  });
2887
3028
  }
2888
- if (st.groupId === '' && ctx?.groupId) return roleStore.update({ groupId: String(ctx.groupId) });
3029
+ if (st.groupId === '' && ctx?.groupId) {
3030
+ return roleStore.update({ groupId: String(ctx.groupId) });
3031
+ }
3032
+ // 密钥轮换(keyVersion 变大)后角色 epoch 至少跟上:epoch 只升不降,
3033
+ // 否则重启后会以更低的 epoch 参与裁决(契约 §4 里 epoch 低就是服从)。
3034
+ if (st.epoch < baseEpoch) return roleStore.update({ epoch: baseEpoch });
2889
3035
  return st;
2890
3036
  }
2891
3037
 
@@ -2946,11 +3092,13 @@ export function createPeerServer(overrides = {}) {
2946
3092
  const id = String(peerId ?? '');
2947
3093
  if (id === '') return '';
2948
3094
  if (id === store.instanceId) return localAdvertiseBaseUrl();
2949
- const learned = String(peerBaseUrls.get(id) ?? '').trim().replace(/\/+$/, '');
2950
- if (learned !== '') return learned;
3095
+ // 群成员表优先:它同样是成员自报的地址(hub 写 group.json / 成员写 group-key.json),
3096
+ // 但它是**持久化且运维可见可覆盖**的那一份;学到的缓存只做兜底(两者来源相同,顺序不影响线上)。
2951
3097
  const fromGroup = String(localGroupContext()?.members?.[id]?.baseUrl ?? '')
2952
3098
  .trim().replace(/\/+$/, '');
2953
3099
  if (fromGroup !== '') return fromGroup;
3100
+ const learned = String(peerBaseUrls.get(id) ?? '').trim().replace(/\/+$/, '');
3101
+ if (learned !== '') return learned;
2954
3102
  const recorded = String(groupStore ? groupStore.state().members[id]?.baseUrl ?? '' : '')
2955
3103
  .trim().replace(/\/+$/, '');
2956
3104
  if (recorded !== '') return recorded;
@@ -3152,10 +3300,12 @@ export function createPeerServer(overrides = {}) {
3152
3300
  selfBaseUrl: localAdvertiseBaseUrl(),
3153
3301
  timeoutMs: peerTimeoutMs,
3154
3302
  };
3155
- const results = await Promise.allSettled(targets.map((peerId) => sendGroupHeartbeat({
3303
+ const results = await Promise.allSettled(targets.map((peerId) => withGroupKey((keyCtx) => sendGroupHeartbeat({
3156
3304
  ...base,
3305
+ groupKey: keyCtx.groupKey,
3306
+ groupId: String(keyCtx.groupId),
3157
3307
  baseUrl: baseUrlOf(peerId),
3158
- })));
3308
+ }))));
3159
3309
  let conflictWith = '';
3160
3310
  let ahead = null;
3161
3311
  for (const result of results) {
@@ -3206,10 +3356,12 @@ export function createPeerServer(overrides = {}) {
3206
3356
  timeoutMs: peerTimeoutMs,
3207
3357
  };
3208
3358
  const targets = peerTargets();
3209
- const results = await Promise.allSettled(targets.map((peerId) => commitGroupMessage({
3359
+ const results = await Promise.allSettled(targets.map((peerId) => withGroupKey((keyCtx) => commitGroupMessage({
3210
3360
  ...base,
3361
+ groupKey: keyCtx.groupKey,
3362
+ groupId: String(keyCtx.groupId),
3211
3363
  baseUrl: baseUrlOf(peerId),
3212
- })));
3364
+ }))));
3213
3365
  return { ok: true, delivered: results.filter((entry) => entry.status === 'fulfilled').length, total: targets.length };
3214
3366
  }
3215
3367
 
@@ -3267,17 +3419,17 @@ export function createPeerServer(overrides = {}) {
3267
3419
  let unreachable = unreachableBase;
3268
3420
  const tasks = targets.map(async (peerId) => {
3269
3421
  try {
3270
- const reply = await requestGroupVote({
3422
+ const reply = await withGroupKey((keyCtx) => requestGroupVote({
3271
3423
  baseUrl: baseUrlOf(peerId),
3272
- groupKey: ctx.groupKey,
3273
- groupId: String(ctx.groupId),
3424
+ groupKey: keyCtx.groupKey,
3425
+ groupId: String(keyCtx.groupId),
3274
3426
  senderId: store.instanceId,
3275
3427
  epoch,
3276
3428
  candidate: store.instanceId,
3277
3429
  priority: hubPriority,
3278
3430
  selfBaseUrl: localAdvertiseBaseUrl(),
3279
3431
  timeoutMs: peerTimeoutMs,
3280
- });
3432
+ }));
3281
3433
  if (Number(reply?.epoch) > epoch && String(reply?.leaderPeerId ?? '') !== '') ahead = reply;
3282
3434
  if (typeof reply?.advertiseBaseUrl === 'string') rememberPeerBaseUrl(peerId, reply.advertiseBaseUrl);
3283
3435
  return reply?.vote === true;
@@ -3336,11 +3488,61 @@ export function createPeerServer(overrides = {}) {
3336
3488
  }
3337
3489
  }
3338
3490
 
3491
+ /**
3492
+ * 受限的「启动后按优先级接管」(每进程最多一次)。
3493
+ *
3494
+ * 背景:契约 §4 是「epoch 高者胜,同 epoch 才比 priority」,所以优先级**不能跨 epoch** 夺权。
3495
+ * 真机事故形态:优先节点(NAS,hubPriority=0)重启期间备份节点抢到 epoch=4 当了 leader,
3496
+ * NAS 回来只能一直当 follower —— priority 永远等不到生效的机会。
3497
+ *
3498
+ * 因此给「配置上没有 group.hub 的本机」(即配置上的 hub)一次**有边界**的重试权:
3499
+ * 进程启动稳定 2×heartbeatMs、当前有一个租约有效的 leader、且本机优先级严格更高时,
3500
+ * 发起一次 epoch+1 的选举。赢则收回 leadership,输则本进程内永久放弃(不循环重试)。
3501
+ * 成员节点(配了 group.hub)不参与:运维的接线意图是「以 hub 为准」。
3502
+ */
3503
+ async function maybePriorityTakeover() {
3504
+ if (!roleStore || takeoverAttempted) return { attempted: false, reason: 'already-done-or-off' };
3505
+ if (String(config.group.hub ?? '') !== '') return { attempted: false, reason: 'member-role' };
3506
+ const st = ensureRole();
3507
+ if (!st || st.role !== 'follower') return { attempted: false, reason: 'not-follower' };
3508
+ // 只在「跟得好好的」时候动手:没有 leader 或租约已过期,交给正常的 lease-expired 选举
3509
+ if (st.leaderPeerId === '' || config.now() > st.leaseUntil) {
3510
+ return { attempted: false, reason: 'no-stable-leader' };
3511
+ }
3512
+ if (config.now() - processStartedAtMs < takeoverStableMs) {
3513
+ return { attempted: false, reason: 'not-stable-yet' };
3514
+ }
3515
+ const leaderPriority = priorityOf(st.leaderPeerId);
3516
+ // 只有**严格**更高(数值更小)才值得动一次;相等一律不动(避免同优先级互抢)
3517
+ if (!(hubPriority < leaderPriority)) return { attempted: false, reason: 'not-higher-priority' };
3518
+ takeoverAttempted = true;
3519
+ takeoverAttempts += 1;
3520
+ const epoch = st.epoch + 1;
3521
+ if (config.logger) {
3522
+ config.logger(`[group] takeover attempt epoch=${epoch} priority=${hubPriority} leaderPriority=${leaderPriority}`);
3523
+ }
3524
+ const out = await runElection({ reason: 'priority-takeover', forcedEpoch: epoch });
3525
+ if (config.logger) {
3526
+ if (out?.ok === true) {
3527
+ config.logger(`[group] takeover won epoch=${out.epoch} votes=${out.votes}/${out.needed}`);
3528
+ } else {
3529
+ config.logger(
3530
+ `[group] takeover lost epoch=${out?.epoch ?? epoch} votes=${out?.votes ?? 1}/${out?.needed ?? quorumNeeded()}`
3531
+ + ` reason=${out?.reason ?? 'unknown'}`,
3532
+ );
3533
+ }
3534
+ }
3535
+ return { attempted: true, result: out };
3536
+ }
3339
3537
  /** 租约检查:非 leader 且租约过期 → 进入候选并发起选举(抖动在 runElection 里) */
3340
3538
  async function roleTick() {
3341
3539
  const st = ensureRole();
3342
3540
  if (!st || st.role === 'leader') return { ok: false, reason: 'leader-or-off' };
3343
3541
  if (electionInFlight) return { ok: false, reason: 'election-in-flight' };
3542
+ // ① 受限的启动竞选(每进程最多一次;不满足条件时什么也不做)
3543
+ const takeover = await maybePriorityTakeover();
3544
+ if (takeover.attempted) return takeover.result ?? { ok: false, reason: 'takeover-attempted' };
3545
+ // ② 租约过期 → 正常选举
3344
3546
  if (config.now() <= st.leaseUntil) return { ok: false, reason: 'lease-valid' };
3345
3547
  if (peerTargets().length === 0) return { ok: false, reason: 'no-peers-with-address' };
3346
3548
  return runElection({ reason: 'lease-expired' });
@@ -3437,6 +3639,8 @@ export function createPeerServer(overrides = {}) {
3437
3639
  // 分母与门槛:成员表全集(含没有地址的成员),地址只影响投递
3438
3640
  memberCount: quorumMembers().length,
3439
3641
  quorumNeeded: quorumNeeded(),
3642
+ // 群密钥世代(只给世代,不给密钥):轮换后可从这里看出来
3643
+ keyVersion: groupKeyVersion(),
3440
3644
  degraded: isDegraded(),
3441
3645
  advertiseBaseUrl: localAdvertiseBaseUrl(),
3442
3646
  },
@@ -3694,7 +3898,12 @@ export function createPeerServer(overrides = {}) {
3694
3898
  };
3695
3899
  const tasks = targets.map(async (peerId) => {
3696
3900
  try {
3697
- const reply = await proposeGroupMessage({ ...base, baseUrl: baseUrlOf(peerId) });
3901
+ const reply = await withGroupKey((keyCtx) => proposeGroupMessage({
3902
+ ...base,
3903
+ groupKey: keyCtx.groupKey,
3904
+ groupId: String(keyCtx.groupId),
3905
+ baseUrl: baseUrlOf(peerId),
3906
+ }));
3698
3907
  return reply?.ok === true;
3699
3908
  } catch {
3700
3909
  return false;
@@ -3737,7 +3946,13 @@ export function createPeerServer(overrides = {}) {
3737
3946
  };
3738
3947
  const handler = handlers[`${method} ${url.pathname}`];
3739
3948
  if (!handler) return { status: 405, body: { error: 'method-not-allowed' } };
3740
- const auth = authorizeGroupRequest(req, rawBody);
3949
+ let auth = authorizeGroupRequest(req, rawBody, url);
3950
+ if (!auth.ok && groupRole === 'member' && String(auth.error) === 'bad-signature') {
3951
+ // hub 轮换密钥后,成员手里的旧钥会让**入站**请求也变成 bad-signature;
3952
+ // 这里先按 per-peer 换钥,再验一次(只重试一次),避免「谁也没法先动」的死锁。
3953
+ const refreshed = await refreshGroupKeyFromHub();
3954
+ if (refreshed.ok === true) auth = authorizeGroupRequest(req, rawBody, url);
3955
+ }
3741
3956
  if (!auth.ok) return { status: auth.status, body: { error: auth.error }, peerId: null };
3742
3957
  return handler(req, rawBody, url, auth);
3743
3958
  }
@@ -3894,10 +4109,15 @@ export function createPeerServer(overrides = {}) {
3894
4109
  return { peerId: String(hub.peerId ?? ''), name: String(hub.name ?? ''), baseUrl, secret: String(hub.secret) };
3895
4110
  }
3896
4111
 
4112
+ /**
4113
+ * 刷新成员侧成员表。**只增不减**:一份过早/不完整的快照(成员在 bootstrap 之后才加入)
4114
+ * 不该把仍在群的成员从本机表里抹掉 —— 真机上这会让该成员的心跳/投票被 401 not-a-member 挡在门外。
4115
+ * 移除成员的权威在 hub 侧 group.json(本机表里多出来的条目只会让多数派更保守)。
4116
+ */
3897
4117
  function saveGroupMembers(members, names = {}) {
3898
4118
  const existing = readGroupKeyFile(config.stateDir);
3899
4119
  if (!existing) return;
3900
- const next = {};
4120
+ const next = { ...existing.members };
3901
4121
  for (const peerId of members) {
3902
4122
  const id = String(peerId ?? '');
3903
4123
  if (id === '') continue;
@@ -3988,10 +4208,10 @@ export function createPeerServer(overrides = {}) {
3988
4208
  }
3989
4209
  if (!ctx?.groupKey) return { ok: false, reason: 'no-group-key' };
3990
4210
  const sinceSeq = replicaStore.state().lastSeq;
3991
- const page = await pullGroupMessages({
4211
+ const page = await withGroupKey((keyCtx) => pullGroupMessages({
3992
4212
  baseUrl: hub.baseUrl,
3993
- groupKey: ctx.groupKey,
3994
- groupId: ctx.groupId,
4213
+ groupKey: keyCtx.groupKey,
4214
+ groupId: keyCtx.groupId,
3995
4215
  senderId: store.instanceId,
3996
4216
  sinceSeq,
3997
4217
  limit: GROUP_HISTORY_MAX_LIMIT,
@@ -3999,7 +4219,7 @@ export function createPeerServer(overrides = {}) {
3999
4219
  // 自报复制进度:hub 的面板据此显示「落后 leader 多少条」
4000
4220
  lastSeq: sinceSeq,
4001
4221
  timeoutMs: Math.max(1000, replicaPullMs),
4002
- });
4222
+ }));
4003
4223
  if (String(page?.groupId ?? '') !== String(ctx.groupId)) {
4004
4224
  throw new Error('group-mismatch');
4005
4225
  }
@@ -4091,14 +4311,38 @@ export function createPeerServer(overrides = {}) {
4091
4311
  if (groupAuthRequest) {
4092
4312
  const groupCtx = localGroupContext();
4093
4313
  if (groupCtx?.groupKey) {
4094
- const { bodyKey } = deriveGroupKeys(groupCtx.groupKey);
4314
+ let bodyKey = deriveGroupKeys(groupCtx.groupKey).bodyKey;
4095
4315
  groupResponseKey = bodyKey;
4096
4316
  const encrypted = String(req.headers[ENC_HEADER] ?? '') === ENC_VERSION;
4097
4317
  if (encrypted && rawBody.length > 0) {
4318
+ let decrypted = false;
4098
4319
  try {
4099
4320
  rawBody = decryptGroupBody(bodyKey, rawBody.toString('utf8'));
4321
+ decrypted = true;
4100
4322
  } catch {
4323
+ decrypted = false;
4324
+ }
4325
+ if (!decrypted) {
4326
+ // hub 轮换了群密钥:成员侧先按 per-peer 换钥再解一次(只一次)
4327
+ const healed = groupRole === 'member' ? await refreshGroupKeyFromHub() : { ok: false };
4328
+ if (healed.ok === true) {
4329
+ const freshCtx = localGroupContext();
4330
+ if (freshCtx?.groupKey) {
4331
+ bodyKey = deriveGroupKeys(freshCtx.groupKey).bodyKey;
4332
+ groupResponseKey = bodyKey;
4333
+ try {
4334
+ rawBody = decryptGroupBody(bodyKey, rawBody.toString('utf8'));
4335
+ decrypted = true;
4336
+ } catch {
4337
+ decrypted = false;
4338
+ }
4339
+ }
4340
+ }
4341
+ }
4342
+ if (!decrypted) {
4343
+ // 对端拿的可能仍是旧钥:错误体走**明文**,否则它连信封都解不开
4101
4344
  result = { status: 400, body: { error: 'bad-encryption' } };
4345
+ groupResponseKey = null;
4102
4346
  }
4103
4347
  }
4104
4348
  }
@@ -4138,7 +4382,9 @@ export function createPeerServer(overrides = {}) {
4138
4382
 
4139
4383
  if (!res.headersSent) {
4140
4384
  try {
4141
- const body = groupResponseKey
4385
+ // 错误体一律明文:群级请求失败时对端可能拿着旧钥(密钥刚轮换),加密过去它连错误码都读不到,
4386
+ // 也就无法据此换钥重试。错误体只有 {error, epoch, ...} 之类的码,不含任何密钥。
4387
+ const body = (groupResponseKey && result.status < 400)
4142
4388
  ? {
4143
4389
  enc: ENC_VERSION,
4144
4390
  data: encryptGroupBody(groupResponseKey, Buffer.from(JSON.stringify(result.body), 'utf8')),
@@ -4151,10 +4397,13 @@ export function createPeerServer(overrides = {}) {
4151
4397
  }
4152
4398
  if (config.logger) {
4153
4399
  const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
4154
- // 只记录方法、路径、状态、耗时、peerId;绝不记录 header / secret / 签名原文
4400
+ // 只记录方法、路径、状态、耗时、peerId、错误码(4xx/5xx 时才带,便于排障);
4401
+ // 绝不记录 header / secret / 签名原文
4402
+ const errorCode = result.status >= 400 ? String(result.body?.error ?? '') : '';
4155
4403
  config.logger(
4156
4404
  `${nowIso(Date.now())} ${String(req.method).toUpperCase()} ${url.pathname} `
4157
- + `${result.status} ${elapsedMs.toFixed(1)}ms peer=${result.peerId ?? '-'}`,
4405
+ + `${result.status} ${elapsedMs.toFixed(1)}ms peer=${result.peerId ?? '-'}`
4406
+ + `${errorCode === '' ? '' : ` err=${errorCode}`}`,
4158
4407
  );
4159
4408
  }
4160
4409
  });
@@ -4178,6 +4427,7 @@ export function createPeerServer(overrides = {}) {
4178
4427
  groupId: st.groupId,
4179
4428
  degraded: isDegraded(),
4180
4429
  quorum: quorumMode,
4430
+ keyVersion: groupKeyVersion(),
4181
4431
  };
4182
4432
  },
4183
4433
  /** 契约 §7:POST /api/lan-peers/group/leader {action:claim} 的落点 */
@@ -4202,6 +4452,15 @@ export function createPeerServer(overrides = {}) {
4202
4452
  heartbeat: () => broadcastHeartbeat(),
4203
4453
  quorumMode: () => quorumMode,
4204
4454
  pendingCount: () => pendingCount(),
4455
+ /** 受限启动竞选的状态:attempted 只在本进程尝试过后为 true,attempts 恒 ≤ 1 */
4456
+ takeover: () => ({
4457
+ attempted: takeoverAttempted,
4458
+ attempts: takeoverAttempts,
4459
+ stableMs: takeoverStableMs,
4460
+ sinceStartMs: config.now() - processStartedAtMs,
4461
+ priority: hubPriority,
4462
+ leaderPriority: roleState()?.leaderPeerId ? priorityOf(roleState().leaderPeerId) : null,
4463
+ }),
4205
4464
  degraded: () => isDegraded(),
4206
4465
  roleFile: roleStore ? roleStore.rolePath : null,
4207
4466
  };
@@ -4226,7 +4485,18 @@ export function createPeerServer(overrides = {}) {
4226
4485
  state: (options = {}) => groupStore.state(options),
4227
4486
  members: () => groupMembersView(),
4228
4487
  addMember: (peerId, name) => groupStore.addMember(peerId, name),
4229
- removeMember: (peerId) => groupStore.removeMember(peerId),
4488
+ removeMember: (peerId) => {
4489
+ const removed = groupStore.removeMember(peerId);
4490
+ if (!removed) return removed;
4491
+ const id = String(peerId ?? '');
4492
+ memberProgress.delete(id);
4493
+ peerBaseUrls.delete(id);
4494
+ peerPriorities.delete(id);
4495
+ if (config.logger) {
4496
+ config.logger(`[group] key rotated keyVersion=${groupStore.state().keyVersion} reason=member-removed`);
4497
+ }
4498
+ return removed;
4499
+ },
4230
4500
  rename: (nextName) => groupStore.renameGroup(nextName),
4231
4501
  post: (input) => groupStore.append(input),
4232
4502
  // 与 /v1/group/history 同一个渲染口径:同名成员在这里就已经带上短 id 后缀
@@ -4350,7 +4620,10 @@ Options:
4350
4620
  --group-auto-join 允许已配对成员自助加入(默认关闭)
4351
4621
  --group-retention-days <n> 历史保留天数(默认 7,0 = 读到时立即清旧)
4352
4622
  --group-advertise-base-url <url> 本机对外可达地址(默认自动探测私有 IPv4)
4353
- --group-quorum <mode> majority|none(S3 生效;本轮只解析)
4623
+ --group-quorum <mode> majority|none(S3:majority = propose/commit 两阶段)
4624
+ --group-heartbeat-ms <ms> leader 心跳间隔(默认 3000;租约 = 3×)
4625
+ --group-commit-timeout-ms <ms> propose→commit 超时(默认 3000)
4626
+ --group-hub-priority <n> 同 epoch 裁决优先级(数值小者优先,默认 0)
4354
4627
  --group-replica-pull-ms <ms> 成员向 leader 补差间隔(默认 10000)
4355
4628
  --code 只生成并打印一次性配对码,然后退出
4356
4629
  -h, --help 显示帮助
@@ -4380,6 +4653,9 @@ async function main(argv) {
4380
4653
  'group-retention-days': { type: 'string' },
4381
4654
  'group-advertise-base-url': { type: 'string' },
4382
4655
  'group-quorum': { type: 'string' },
4656
+ 'group-heartbeat-ms': { type: 'string' },
4657
+ 'group-commit-timeout-ms': { type: 'string' },
4658
+ 'group-hub-priority': { type: 'string' },
4383
4659
  'group-replica-pull-ms': { type: 'string' },
4384
4660
  code: { type: 'boolean', default: false },
4385
4661
  help: { type: 'boolean', short: 'h', default: false },
@@ -4407,6 +4683,8 @@ async function main(argv) {
4407
4683
  group: (values.group || values['group-hub'] != null || values['group-name'] != null
4408
4684
  || values['group-auto-join'] || values['group-retention-days'] != null
4409
4685
  || values['group-advertise-base-url'] != null || values['group-quorum'] != null
4686
+ || values['group-heartbeat-ms'] != null || values['group-commit-timeout-ms'] != null
4687
+ || values['group-hub-priority'] != null
4410
4688
  || values['group-replica-pull-ms'] != null)
4411
4689
  ? {
4412
4690
  enabled: values.group === true,
@@ -4418,6 +4696,15 @@ async function main(argv) {
4418
4696
  : Number(values['group-retention-days']),
4419
4697
  advertiseBaseUrl: values['group-advertise-base-url'],
4420
4698
  quorum: values['group-quorum'],
4699
+ heartbeatMs: values['group-heartbeat-ms'] == null
4700
+ ? undefined
4701
+ : Number(values['group-heartbeat-ms']),
4702
+ commitTimeoutMs: values['group-commit-timeout-ms'] == null
4703
+ ? undefined
4704
+ : Number(values['group-commit-timeout-ms']),
4705
+ hubPriority: values['group-hub-priority'] == null
4706
+ ? undefined
4707
+ : Number(values['group-hub-priority']),
4421
4708
  replicaPullMs: values['group-replica-pull-ms'] == null
4422
4709
  ? undefined
4423
4710
  : Number(values['group-replica-pull-ms']),