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 +5 -1
- package/host/server.mjs +320 -33
- package/host/test/group-cluster.mjs +60 -1
- package/host/test/group-commit.test.mjs +48 -1
- package/host/test/group-election.test.mjs +64 -1
- package/host/test/group-key-rotation.test.mjs +219 -0
- package/host/test/group-takeover.test.mjs +122 -0
- package/host/test/group.test.mjs +2 -1
- package/lib/admin-group.test.mjs +89 -0
- package/lib/admin.mjs +28 -4
- package/package.json +1 -1
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
|
-
>
|
|
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.
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
1828
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
2950
|
-
|
|
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:
|
|
3273
|
-
groupId: String(
|
|
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
|
|
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
|
-
|
|
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:
|
|
3994
|
-
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
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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']),
|