tt-help-cli-ycl 1.4.172 → 1.4.176
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/package.json +1 -1
- package/scripts/reset-login-pool.mjs +66 -0
- package/scripts/test-login-pool-e2e-real.mjs +134 -0
- package/scripts/test-login-pool-ui.mjs +211 -0
- package/scripts/test-login-pool.mjs +381 -0
- package/scripts/test-subscription-allocator.mjs +52 -0
- package/scripts/test-watchdog-subs-retry.mjs +123 -0
- package/src/clash/runtime.js +8 -4
- package/src/cli/explore.js +269 -23
- package/src/cli/search.js +241 -56
- package/src/cli/watchdog.js +34 -0
- package/src/lib/args.js +15 -2
- package/src/lib/browser/cdp.js +12 -2
- package/src/lib/browser/health-checker.js +16 -13
- package/src/lib/linux-env-init.js +258 -0
- package/src/lib/login-pool-client.js +156 -0
- package/src/scraper/modules/follow-extractor.js +23 -5
- package/src/scraper/modules/guess-extractor.js +4 -1
- package/src/watch/login-pool.js +755 -0
- package/src/watch/public/app.js +90 -44
- package/src/watch/public/index.html +17 -3
- package/src/watch/server.js +252 -0
- package/src/watch/subscription-allocator.js +18 -5
- package/src/watch/subscription-center.js +18 -2
- package/src/watch/watchdog-server.js +113 -0
- package/src/watchdog/agent.js +87 -281
- package/src/watchdog/login-apply.js +232 -0
- package/src/watchdog/login-import.js +180 -0
- package/src/watchdog/upgrader.js +59 -5
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享登录池单元测试 — 模块级直驱(不起 HTTP)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* 1. 入池(cookies 归一化/username 兜底/去重/池满)+ 视图脱敏(明文永不出)
|
|
6
|
+
* 2. 指派独占约束(同账号双租拒绝 / 目标端口占用拒绝 / force 替换 / suspect 需 force)
|
|
7
|
+
* 3. 心跳下发(applyLogin/clearLogin 一次性取走,cookies 仅在心跳响应出现)
|
|
8
|
+
* 4. 续租 + 健康采集(lastBusinessOkAt/emptyStreak)
|
|
9
|
+
* 5. 掉线回收(leaseExpired)+ 命令超时作废(deliveryExpired 回收)
|
|
10
|
+
* 6. release(含 clearOnNode)+ update 状态迁移 + delete 保护
|
|
11
|
+
*
|
|
12
|
+
* 跑法: HOME=$(mktemp -d) node scripts/test-login-pool.mjs
|
|
13
|
+
*/
|
|
14
|
+
import assert from "assert";
|
|
15
|
+
import { readFileSync, existsSync } from "fs";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
|
|
18
|
+
const HOME = process.env.HOME;
|
|
19
|
+
if (!HOME || HOME === "/") {
|
|
20
|
+
console.error("请用隔离 HOME 运行: HOME=$(mktemp -d) node scripts/test-login-pool.mjs");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
getLoginPoolView,
|
|
26
|
+
addAccount,
|
|
27
|
+
updateAccount,
|
|
28
|
+
deleteAccount,
|
|
29
|
+
assignAccount,
|
|
30
|
+
releaseAccount,
|
|
31
|
+
acquireAccount,
|
|
32
|
+
importAccount,
|
|
33
|
+
heartbeatLoginPool,
|
|
34
|
+
reapExpiredLeases,
|
|
35
|
+
} = await import("../src/watch/login-pool.js");
|
|
36
|
+
|
|
37
|
+
let pass = 0;
|
|
38
|
+
let fail = 0;
|
|
39
|
+
const check = (name, cond, extra = "") => {
|
|
40
|
+
if (cond) {
|
|
41
|
+
pass++;
|
|
42
|
+
console.log(` ✓ ${name}`);
|
|
43
|
+
} else {
|
|
44
|
+
fail++;
|
|
45
|
+
console.log(` ✗ ${name} ${extra}`);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const COOKIES_A = [
|
|
50
|
+
{ name: "sessionid", value: "sid-a", domain: ".tiktok.com", expires: Date.now() / 1000 + 86400 },
|
|
51
|
+
{ name: "unique_id", value: "userA", domain: ".tiktok.com" },
|
|
52
|
+
{ name: "tt-target-idc", value: "useast2a", domain: ".tiktok.com" },
|
|
53
|
+
];
|
|
54
|
+
const COOKIES_B = [
|
|
55
|
+
{ name: "sessionid", value: "sid-b", domain: ".tiktok.com" },
|
|
56
|
+
{ name: "unique_id", value: "userB", domain: ".tiktok.com" },
|
|
57
|
+
];
|
|
58
|
+
const COOKIES_C = [
|
|
59
|
+
{ name: "sessionid", value: "sid-c", domain: ".tiktok.com" },
|
|
60
|
+
{ name: "unique_id", value: "userC", domain: ".tiktok.com" },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
// ── 1. 入池 ──
|
|
64
|
+
console.log("\n[1] 入池");
|
|
65
|
+
{
|
|
66
|
+
const a = addAccount({ cookies: COOKIES_A, note: "F-1:8224 原生" });
|
|
67
|
+
check("自动从 unique_id 提取 username", a.username === "userA", `got ${a.username}`);
|
|
68
|
+
check("初始状态 pool", a.status === "pool");
|
|
69
|
+
check("cookieCount 正确", a.cookieCount === 3);
|
|
70
|
+
check("hasSessionid", a.hasSessionid === true);
|
|
71
|
+
check("视图无明文 value", !JSON.stringify(a).includes("sid-a"));
|
|
72
|
+
|
|
73
|
+
const b = addAccount({ username: "userB-manual", cookies: COOKIES_B });
|
|
74
|
+
check("显式 username 优先", b.username === "userB-manual");
|
|
75
|
+
|
|
76
|
+
let threw = false;
|
|
77
|
+
try {
|
|
78
|
+
addAccount({ cookies: COOKIES_A });
|
|
79
|
+
} catch (e) {
|
|
80
|
+
threw = true;
|
|
81
|
+
check("同账号拒绝重复入池", /已在池中/.test(e.message));
|
|
82
|
+
}
|
|
83
|
+
check("重复入池确实抛错", threw);
|
|
84
|
+
|
|
85
|
+
threw = false;
|
|
86
|
+
try {
|
|
87
|
+
addAccount({ cookies: "not-an-array" });
|
|
88
|
+
} catch (e) {
|
|
89
|
+
threw = true;
|
|
90
|
+
check("非法 cookies 抛错", /cookies/.test(e.message));
|
|
91
|
+
}
|
|
92
|
+
check("非法 cookies 确实抛错", threw);
|
|
93
|
+
|
|
94
|
+
const view = getLoginPoolView();
|
|
95
|
+
check("视图含 2 账号", view.total === 2);
|
|
96
|
+
check("视图整体无明文", !JSON.stringify(view).includes("sid-b"));
|
|
97
|
+
|
|
98
|
+
// 文件落盘(debounce 3s——直接看 tmp 是否会出现太侵入,这里验证文件最终写出)
|
|
99
|
+
const file = join(HOME, ".tt-help-watchdog-logins.json");
|
|
100
|
+
check("存储文件路径符合设计", file.endsWith(".tt-help-watchdog-logins.json"));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── 2. 指派独占约束 ──
|
|
104
|
+
console.log("\n[2] 指派独占约束");
|
|
105
|
+
let accA, accB, accC;
|
|
106
|
+
{
|
|
107
|
+
const view = getLoginPoolView();
|
|
108
|
+
accA = view.accounts.find((x) => x.username === "userA");
|
|
109
|
+
accB = view.accounts.find((x) => x.username === "userB-manual");
|
|
110
|
+
const c = addAccount({ cookies: COOKIES_C }); // 第三个账号做端口占用/force 替换测试
|
|
111
|
+
accC = c;
|
|
112
|
+
|
|
113
|
+
const leased = assignAccount({ id: accA.id, userId: "F-11", port: 9222 });
|
|
114
|
+
check("指派后 leased", leased.status === "leased");
|
|
115
|
+
check("lease 记录节点+端口", leased.lease.userId === "F-11" && leased.lease.port === 9222);
|
|
116
|
+
|
|
117
|
+
const leasedB = assignAccount({ id: accB.id, userId: "F-1", port: 8222 });
|
|
118
|
+
check("不同机器可并行持有不同账号", leasedB.status === "leased");
|
|
119
|
+
|
|
120
|
+
// 同账号双租:A 已 leased,再指派 A 必须拒绝
|
|
121
|
+
let threw = false;
|
|
122
|
+
try {
|
|
123
|
+
assignAccount({ id: accA.id, userId: "F-1", port: 9223 });
|
|
124
|
+
} catch (e) {
|
|
125
|
+
threw = true;
|
|
126
|
+
check("同账号双租拒绝(独占核心)", /已租给/.test(e.message));
|
|
127
|
+
}
|
|
128
|
+
check("同账号双租确实抛错", threw);
|
|
129
|
+
|
|
130
|
+
// 目标端口占用:F-11:9222 已有 A,指派 C 到同端口拒绝
|
|
131
|
+
threw = false;
|
|
132
|
+
try {
|
|
133
|
+
assignAccount({ id: accC.id, userId: "F-11", port: 9222 });
|
|
134
|
+
} catch (e) {
|
|
135
|
+
threw = true;
|
|
136
|
+
check("目标端口占用拒绝", /已持有/.test(e.message));
|
|
137
|
+
}
|
|
138
|
+
check("端口占用确实抛错", threw);
|
|
139
|
+
|
|
140
|
+
// force 替换:C 强制上 F-11:9222,A 被逐回池 + 节点收到 clearLogin
|
|
141
|
+
const forced = assignAccount({ id: accC.id, userId: "F-11", port: 9222, force: true });
|
|
142
|
+
check("force 替换成功", forced.status === "leased");
|
|
143
|
+
const after = getLoginPoolView();
|
|
144
|
+
const aNow = after.accounts.find((x) => x.username === "userA");
|
|
145
|
+
check("被替换账号回池", aNow.status === "pool" && !aNow.lease);
|
|
146
|
+
|
|
147
|
+
// suspect 需 force(用 C)
|
|
148
|
+
updateAccount({ id: accC.id, status: "suspect" }); // leased → suspect 允许(强制迁移)
|
|
149
|
+
const cNow = getLoginPoolView().accounts.find((x) => x.username === "userC");
|
|
150
|
+
check("leased → suspect 迁移并清租约", cNow.status === "suspect" && !cNow.lease);
|
|
151
|
+
threw = false;
|
|
152
|
+
try {
|
|
153
|
+
assignAccount({ id: accC.id, userId: "F-2", port: 9224 });
|
|
154
|
+
} catch (e) {
|
|
155
|
+
threw = true;
|
|
156
|
+
check("suspect 无 force 拒绝", /suspect/.test(e.message));
|
|
157
|
+
}
|
|
158
|
+
check("suspect 拒绝确实抛错", threw);
|
|
159
|
+
const forcedSuspect = assignAccount({ id: accC.id, userId: "F-2", port: 9224, force: true });
|
|
160
|
+
check("suspect + force 可指派", forcedSuspect.status === "leased");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── 3. 心跳下发(一次性取走,cookies 只在心跳响应) ──
|
|
164
|
+
console.log("\n[3] 心跳下发");
|
|
165
|
+
{
|
|
166
|
+
// 指派 C(上一步) 已排队 applyLogin → F-2 下次心跳取走
|
|
167
|
+
const hb = heartbeatLoginPool("F-2", { config: { userId: "F-2" } });
|
|
168
|
+
check("心跳返回 applyLogin 命令", hb && hb.commands && hb.commands.length === 1, JSON.stringify(hb));
|
|
169
|
+
const cmd = hb.commands[0];
|
|
170
|
+
check("action=applyLogin", cmd.action === "applyLogin");
|
|
171
|
+
check("命令含明文 cookies(下发通道)", Array.isArray(cmd.cookies) && cmd.cookies.some((c) => c.name === "sessionid"));
|
|
172
|
+
check("命令含端口+账号名", cmd.port === 9224 && cmd.username === "userC");
|
|
173
|
+
|
|
174
|
+
// 视图标记 pendingDelivery 取走后应消失
|
|
175
|
+
const cView = getLoginPoolView().accounts.find((x) => x.username === "userC");
|
|
176
|
+
check("取走后 pendingDelivery=false", cView.lease.pendingDelivery === false);
|
|
177
|
+
|
|
178
|
+
// 再次心跳无命令
|
|
179
|
+
const hb2 = heartbeatLoginPool("F-2", {});
|
|
180
|
+
check("一次性:二次心跳无命令", hb2 === undefined);
|
|
181
|
+
|
|
182
|
+
// 【回归】租约迁移时遗留命令必须被清除:C 曾被指到 F-11:9222(后被 suspect 迁走),
|
|
183
|
+
// F-11 心跳绝不能再收到 C 的 applyLogin(否则登录态泄给无租约节点)。
|
|
184
|
+
// 注意:F-11 队列里的 clearLogin(userA) 是 force 替换时有意下发的端口清理,属正确行为
|
|
185
|
+
const hbF11 = heartbeatLoginPool("F-11", {});
|
|
186
|
+
const staleApply = hbF11 && hbF11.commands.some((c) => c.action === "applyLogin" && c.username === "userC");
|
|
187
|
+
check("孤儿 applyLogin 已清除(F-11 收不到已迁走账号)", !staleApply, JSON.stringify(hbF11));
|
|
188
|
+
check("有意下发的 clearLogin(端口清理)保留", hbF11 && hbF11.commands.some((c) => c.action === "clearLogin" && c.username === "userA"));
|
|
189
|
+
|
|
190
|
+
// GET 视图仍然无明文
|
|
191
|
+
check("GET 视图持续无明文", !JSON.stringify(getLoginPoolView()).includes("sid-b"));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── 4. 续租 + 健康采集 + 节点执行回报 ──
|
|
195
|
+
console.log("\n[4] 续租 + 健康采集 + 节点执行回报");
|
|
196
|
+
{
|
|
197
|
+
const okAt = Date.now() - 60000;
|
|
198
|
+
heartbeatLoginPool("F-2", {
|
|
199
|
+
loginPool: { ports: { 9224: { username: "userC", lastBusinessOkAt: okAt, emptyStreak: 2 } } },
|
|
200
|
+
});
|
|
201
|
+
let b = getLoginPoolView().accounts.find((x) => x.username === "userC");
|
|
202
|
+
check("业务出活时间入库", b.lease.lastBusinessOkAt === okAt);
|
|
203
|
+
check("空响应连击入库", b.lease.emptyStreak === 2);
|
|
204
|
+
check("续租刷新 lastHealthAt", b.lease.lastHealthAt > 0);
|
|
205
|
+
|
|
206
|
+
// 节点 applyLogin 成功回报(含持久化)
|
|
207
|
+
heartbeatLoginPool("F-2", {
|
|
208
|
+
loginPool: {
|
|
209
|
+
applied: [{ action: "applyLogin", accountId: b.id, port: 9224, username: "userC", ok: true, persisted: true, at: Date.now() }],
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
b = getLoginPoolView().accounts.find((x) => x.username === "userC");
|
|
213
|
+
check("注入成功回报入库", b.lease.lastAppliedOk === true && b.lease.persisted === true);
|
|
214
|
+
check("历史记录 applied", b.history.some((h) => h.event === "applied"));
|
|
215
|
+
|
|
216
|
+
// 节点 applyLogin 失败回报
|
|
217
|
+
heartbeatLoginPool("F-2", {
|
|
218
|
+
loginPool: {
|
|
219
|
+
applied: [{ action: "applyLogin", accountId: b.id, port: 9224, ok: false, error: "CDP 超时", at: Date.now() }],
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
b = getLoginPoolView().accounts.find((x) => x.username === "userC");
|
|
223
|
+
check("失败回报覆盖状态(最新为准)", b.lease.lastAppliedOk === false);
|
|
224
|
+
check("历史记录 applyFailed", b.history.some((h) => h.event === "applyFailed"));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── 5. 掉线回收 + 命令超时作废 ──
|
|
228
|
+
console.log("\n[5] 掉线回收 + 命令超时作废");
|
|
229
|
+
{
|
|
230
|
+
// 直接改内存时间不可行(模块内部 now),改用字段注入验证 reap 逻辑:
|
|
231
|
+
// 给 A 重新指派,然后人为把 lease.lastHealthAt 拨回 13 小时前
|
|
232
|
+
const aLeased = assignAccount({ id: accA.id, userId: "F-3", port: 9225 });
|
|
233
|
+
check("A 再指派成功", aLeased.status === "leased");
|
|
234
|
+
// 取走 F-3 的命令,避免干扰
|
|
235
|
+
heartbeatLoginPool("F-3", {});
|
|
236
|
+
|
|
237
|
+
// 内部状态篡改:用 update 不行(不暴露 lease 写),通过文件回写模拟时间流逝
|
|
238
|
+
const file = join(HOME, ".tt-help-watchdog-logins.json");
|
|
239
|
+
// 强制落盘:scheduleSave 是 3s debounce;reapExpiredLeases 内部 ensureLoaded 读文件,
|
|
240
|
+
// 这里直接改文件 + 清模块缓存不可行(ESM 无缓存清理)→ 用 lastHealthAt 拨回方式:
|
|
241
|
+
// 模块导出 _accountsInternal 供测试直改内存
|
|
242
|
+
const internal = (await import("../src/watch/login-pool.js"))._accountsInternal();
|
|
243
|
+
const aInternal = internal.find((x) => x.username === "userA");
|
|
244
|
+
aInternal.lease.lastHealthAt = Date.now() - 13 * 60 * 60 * 1000; // 13h 前
|
|
245
|
+
const reaped = reapExpiredLeases();
|
|
246
|
+
check("超时租约被回收", reaped === 1, `reaped=${reaped}`);
|
|
247
|
+
const aView = getLoginPoolView().accounts.find((x) => x.username === "userA");
|
|
248
|
+
check("回收后回池", aView.status === "pool" && !aView.lease);
|
|
249
|
+
check("历史记录 leaseExpired", aView.history.some((h) => h.event === "leaseExpired"));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── 6. release / update / delete ──
|
|
253
|
+
console.log("\n[6] release / update / delete");
|
|
254
|
+
{
|
|
255
|
+
const b = getLoginPoolView().accounts.find((x) => x.username === "userC");
|
|
256
|
+
const rel = releaseAccount({ id: b.id, clearOnNode: true });
|
|
257
|
+
check("释放回池", rel.status === "pool" && !rel.lease);
|
|
258
|
+
const hb = heartbeatLoginPool("F-2", {});
|
|
259
|
+
check("clearOnNode 下发 clearLogin", hb && hb.commands[0].action === "clearLogin");
|
|
260
|
+
|
|
261
|
+
// retired 后才能删
|
|
262
|
+
updateAccount({ id: b.id, status: "retired" });
|
|
263
|
+
const del = deleteAccount(b.id);
|
|
264
|
+
check("retired 后删除成功", del.username === "userC");
|
|
265
|
+
|
|
266
|
+
// leased 删除保护:重新入池 + 指派 + 尝试删
|
|
267
|
+
const d = addAccount({ username: "userD", cookies: COOKIES_B });
|
|
268
|
+
assignAccount({ id: d.id, userId: "F-4", port: 9226 });
|
|
269
|
+
heartbeatLoginPool("F-4", {});
|
|
270
|
+
let threw = false;
|
|
271
|
+
try {
|
|
272
|
+
deleteAccount(d.id);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
threw = true;
|
|
275
|
+
check("leased 删除保护", /先 release/.test(e.message));
|
|
276
|
+
}
|
|
277
|
+
check("leased 删除确实抛错", threw);
|
|
278
|
+
|
|
279
|
+
// 落盘文件存在且不含明文(saveNow debounce 后才有;主动触发一次读回验证结构)
|
|
280
|
+
const file = join(HOME, ".tt-help-watchdog-logins.json");
|
|
281
|
+
await new Promise((r) => setTimeout(r, 3200)); // 等 debounce
|
|
282
|
+
if (existsSync(file)) {
|
|
283
|
+
const raw = readFileSync(file, "utf-8");
|
|
284
|
+
check("文件落盘(含明文 cookies,属设计内)", raw.includes("sessionid"));
|
|
285
|
+
check("文件为 accounts 数组结构", JSON.parse(raw).accounts.length >= 1);
|
|
286
|
+
} else {
|
|
287
|
+
check("文件落盘", false, "3.2s 后仍无文件");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── 7. 取号排序(双键+冷却)· 自动释放(一实例一号)· retired 排除 ──
|
|
292
|
+
console.log("\n[7] 取号·导入·自动释放");
|
|
293
|
+
{
|
|
294
|
+
// 清池重建:全量 release→retired→delete,再导入 4 个新号(uc 全 0,createdAt 递增)
|
|
295
|
+
for (const a of getLoginPoolView().accounts) {
|
|
296
|
+
try { releaseAccount({ id: a.id }); } catch {}
|
|
297
|
+
try { updateAccount({ id: a.id, status: "retired" }); } catch {}
|
|
298
|
+
try { deleteAccount(a.id); } catch (e) { console.log(" [wipe]", e.message); }
|
|
299
|
+
}
|
|
300
|
+
const mk = (u) => importAccount({
|
|
301
|
+
userId: "seed",
|
|
302
|
+
username: u,
|
|
303
|
+
cookies: [{ name: "sessionid", value: "sid-" + u, domain: ".tiktok.com", expires: Math.floor(Date.now() / 1000) + 90 * 86400 }],
|
|
304
|
+
});
|
|
305
|
+
mk("userE"); mk("userF"); mk("userG"); mk("userH");
|
|
306
|
+
check("重建后池=4", getLoginPoolView().total === 4);
|
|
307
|
+
|
|
308
|
+
// 7.1 双键排序:uc 全 0 → createdAt 升序 → userE
|
|
309
|
+
const t1 = acquireAccount({ userId: "n1" });
|
|
310
|
+
check("永不用过的优先(userE)", t1.username === "userE", `got ${t1.username}`);
|
|
311
|
+
check("取号次数 +1", t1.useCount === 1);
|
|
312
|
+
|
|
313
|
+
// 7.2 第二低(userF)[自动释放:n2 无旧租约,无操作]
|
|
314
|
+
const t2 = acquireAccount({ userId: "n2" });
|
|
315
|
+
check("其次取 userF", t2.username === "userF", `got ${t2.username}`);
|
|
316
|
+
|
|
317
|
+
// 7.3 错峰冷却:还回 userE(lastUsedAt=now → 10min 冷却)→ 不立即复用
|
|
318
|
+
releaseAccount({ id: t1.id });
|
|
319
|
+
const t3 = acquireAccount({ userId: "n3" });
|
|
320
|
+
check("刚还的号不立即复用(取 userG)", t3.username === "userG", `got ${t3.username}`);
|
|
321
|
+
|
|
322
|
+
// 7.4 最后一个非冷却空闲号(userH)
|
|
323
|
+
const t4 = acquireAccount({ userId: "n4" });
|
|
324
|
+
check("取 userH", t4.username === "userH", `got ${t4.username}`);
|
|
325
|
+
|
|
326
|
+
// 7.5 ③ 自动释放:池内放入新号 → n1 再取 → 拿新号 + n1 名下旧租约(userE)自动归还
|
|
327
|
+
importAccount({ userId: "x", username: "userZ", cookies: [{ name: "sessionid", value: "sid-z" }] });
|
|
328
|
+
const t5 = acquireAccount({ userId: "n1" });
|
|
329
|
+
check("n1 取到新放入的 userZ", t5.username === "userZ", `got ${t5.username}`);
|
|
330
|
+
const eAfter = getLoginPoolView().accounts.find((x) => x.username === "userE");
|
|
331
|
+
check("旧租约被自动释放(userE 回池)", eAfter.status === "pool", `got ${eAfter.status}`);
|
|
332
|
+
|
|
333
|
+
// 7.6 去重:同 username 重复导入 → 幂等不覆盖
|
|
334
|
+
const impDup = importAccount({ userId: "n9", username: "userZ", cookies: [{ name: "sessionid", value: "HIJACK" }] });
|
|
335
|
+
check("重复导入幂等", impDup.dedup === true);
|
|
336
|
+
check("不覆盖池内 cookies", !JSON.stringify(impDup.account).includes("HIJACK"));
|
|
337
|
+
|
|
338
|
+
// 7.7 retired 不参与取号:停用 userZ → 取号拿到别的
|
|
339
|
+
const zId = getLoginPoolView().accounts.find((x) => x.username === "userZ").id;
|
|
340
|
+
releaseAccount({ id: zId }).account; // 若在租则释放
|
|
341
|
+
updateAccount({ id: zId, status: "retired" });
|
|
342
|
+
const t6 = acquireAccount({ userId: "n5" });
|
|
343
|
+
check("retired 不被取用", t6.username !== "userZ", `got ${t6.username}`);
|
|
344
|
+
|
|
345
|
+
// 7.8 参数拒绝
|
|
346
|
+
let threw = false;
|
|
347
|
+
try { importAccount({ userId: "n6", username: "x" }); } catch (e) { threw = true; check("缺 cookies 拒绝", /cookies/.test(e.message)); }
|
|
348
|
+
check("缺 cookies 确实抛错", threw);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── 8. 登录态导入 import(节点 → 中心,登记语义 + lastImportFrom) ──
|
|
352
|
+
console.log("\n[8] 登录态导入 import");
|
|
353
|
+
{
|
|
354
|
+
// 清池重建
|
|
355
|
+
for (const a of getLoginPoolView().accounts) {
|
|
356
|
+
try { releaseAccount({ id: a.id }); } catch {}
|
|
357
|
+
try { updateAccount({ id: a.id, status: "retired" }); } catch {}
|
|
358
|
+
try { deleteAccount(a.id); } catch (e) { console.log(" [wipe]", e.message); }
|
|
359
|
+
}
|
|
360
|
+
const COOKIES_D = [
|
|
361
|
+
{ name: "sessionid", value: "sid-d", domain: ".tiktok.com", expires: Math.floor(Date.now() / 1000) + 86400 },
|
|
362
|
+
{ name: "unique_id", value: "userD", domain: ".tiktok.com" },
|
|
363
|
+
];
|
|
364
|
+
const imp = importAccount({ userId: "F-9", port: 9300, username: "userD", cookies: COOKIES_D, note: "节点启动同步" });
|
|
365
|
+
check("导入为空闲(无租约,未被消费)", imp.account.status === "pool" && imp.account.lease === null);
|
|
366
|
+
check("导入不计次数(useCount=0)", imp.account.useCount === 0);
|
|
367
|
+
check("记录来源元数据 lastImportFrom", imp.account.lastImportFrom && imp.account.lastImportFrom.userId === "F-9" && imp.account.lastImportFrom.port === 9300);
|
|
368
|
+
check("不入命令队列(不下发注入)", !(heartbeatLoginPool("F-9", {}) || {}).commands);
|
|
369
|
+
|
|
370
|
+
const imp2 = importAccount({ userId: "F-8", port: 9301, username: "userD", cookies: [{ name: "sessionid", value: "OTHER-SESSION" }], note: "另机重复会话" });
|
|
371
|
+
check("重复导入幂等(dedup=true)", imp2.dedup === true);
|
|
372
|
+
check("不覆盖池内 cookies", !JSON.stringify(imp2.account).includes("OTHER-SESSION"));
|
|
373
|
+
check("原来源元数据不动", imp2.account.lastImportFrom.userId === "F-9");
|
|
374
|
+
|
|
375
|
+
let threw = false;
|
|
376
|
+
try { importAccount({ userId: "F-1", username: "x" }); } catch (e) { threw = true; check("缺 cookies 拒绝", /cookies/.test(e.message)); }
|
|
377
|
+
check("缺 cookies 确实抛错", threw);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
console.log(`\n═══ 结果: ${pass} 通过, ${fail} 失败 ═══`);
|
|
381
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
@@ -216,5 +216,57 @@ t("空池 + 空存量 → 返回空列表 + pool-empty(无害兜底)", () =>
|
|
|
216
216
|
assert.strictEqual(r.warn, "pool-empty");
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
+
console.log("\n[权重] weight: 打分第二项 = 占用/weight,缺省/非法 = 1 退化为现状");
|
|
220
|
+
t("全部缺省 weights → 与无权重版结果一致(兼容性回归)", () => {
|
|
221
|
+
const noW = allocateSubscriptions({ available: avail4, count: 2, prevAlloc: [], usage: usageOf([f1]), isAvailUrl: isAvail4 });
|
|
222
|
+
const withW = allocateSubscriptions({ available: avail4, count: 2, prevAlloc: [], usage: usageOf([f1]), weights: {}, isAvailUrl: isAvail4 });
|
|
223
|
+
assert.deepStrictEqual(withW.alloc, noW.alloc, "weights 缺省 = 不传");
|
|
224
|
+
assert.deepStrictEqual(urls(withW.alloc), ["D", "A"]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
t("C 设 2 倍权重,从零配 5 槽 → [A,B,C,D,C](C 占 2/5)", () => {
|
|
228
|
+
const r = allocateSubscriptions({
|
|
229
|
+
available: avail4, count: 5, prevAlloc: [], usage: {},
|
|
230
|
+
weights: { [C.url]: 2 }, isAvailUrl: isAvail4,
|
|
231
|
+
});
|
|
232
|
+
assert.deepStrictEqual(urls(r.alloc), ["A", "B", "C", "D", "C"]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
t("加权占用:C(w2) 已占 2 槽仍可与 A(w1) 占 1 槽并列,池序决胜取 C", () => {
|
|
236
|
+
// C 被其它机器占 2 槽(归一后 2/2=1),A 占 1 槽(1/1=1)→ 并列;C 池序靠前
|
|
237
|
+
const availCA = [C, A];
|
|
238
|
+
const r = allocateSubscriptions({
|
|
239
|
+
available: availCA, count: 1, prevAlloc: [], usage: { [C.url]: 2, [A.url]: 1 },
|
|
240
|
+
weights: { [C.url]: 2 }, isAvailUrl: mkIsAvail([A, C]),
|
|
241
|
+
});
|
|
242
|
+
assert.deepStrictEqual(urls(r.alloc), ["C"]);
|
|
243
|
+
// 无权重时同场景会选占用最少的 A
|
|
244
|
+
const noW = allocateSubscriptions({
|
|
245
|
+
available: availCA, count: 1, prevAlloc: [], usage: { [C.url]: 2, [A.url]: 1 },
|
|
246
|
+
isAvailUrl: mkIsAvail([A, C]),
|
|
247
|
+
});
|
|
248
|
+
assert.deepStrictEqual(urls(noW.alloc), ["A"]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
t("非法权重(0/负数/非数字)一律按 1 兑底", () => {
|
|
252
|
+
const badW = allocateSubscriptions({
|
|
253
|
+
available: avail4, count: 2, prevAlloc: [], usage: usageOf([f1]),
|
|
254
|
+
weights: { [A.url]: 0, [B.url]: -3, [C.url]: "abc" }, isAvailUrl: isAvail4,
|
|
255
|
+
});
|
|
256
|
+
assert.deepStrictEqual(urls(badW.alloc), ["D", "A"]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
t("权重不破坏粘性/决定性:带权重重跑幂等", () => {
|
|
260
|
+
const args = {
|
|
261
|
+
available: avail4, count: 5, prevAlloc: [A, B].map((v) => ({ name: v.name, url: v.url })),
|
|
262
|
+
usage: {}, weights: { [C.url]: 2 }, isAvailUrl: isAvail4,
|
|
263
|
+
};
|
|
264
|
+
const r1 = allocateSubscriptions(args);
|
|
265
|
+
const again = allocateSubscriptions({ ...args, prevAlloc: r1.alloc });
|
|
266
|
+
assert.deepStrictEqual(again.alloc, r1.alloc, "粘性: 对结果重算不再变化");
|
|
267
|
+
assert.strictEqual(urls(r1.alloc)[0], "A", "存量槽原位保留");
|
|
268
|
+
assert.strictEqual(urls(r1.alloc)[1], "B");
|
|
269
|
+
});
|
|
270
|
+
|
|
219
271
|
console.log(`\n订阅分配器单元测试通过 ✅ (${passed} 用例)\n`);
|
|
220
272
|
process.exit(0);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bug 2 台架验证:订阅应用失败后心跳重试死锁
|
|
3
|
+
*
|
|
4
|
+
* 场景:agent「变化才转发」sig 已消耗 + daemon「失败不持久化待重发」约定脱节。
|
|
5
|
+
* 修复:agent 读 clash-health.json 的 meta.proxy.applyResult,失败(at > 转发时刻)则重置 sig,
|
|
6
|
+
* 服务端每跳无条件下发 proxySettings → 同轮心跳重发全量。
|
|
7
|
+
*
|
|
8
|
+
* 台架:真 WatchdogAgent(spawnDaemon:false)+ 本地 mock watch 服务(每跳返回固定订阅),
|
|
9
|
+
* 观测点 = ipc 默认通道命令文件 /tmp/clash-cmd.json 的写入次数。
|
|
10
|
+
* daemon 侧行为(不真跑 daemon)由台架直接写 /tmp/clash-health.json 模拟。
|
|
11
|
+
*
|
|
12
|
+
* 跑法:node scripts/test-watchdog-subs-retry.mjs
|
|
13
|
+
*/
|
|
14
|
+
import http from "http";
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync, statSync } from "fs";
|
|
16
|
+
import { WatchdogAgent } from "../src/watchdog/agent.js";
|
|
17
|
+
|
|
18
|
+
const CMD_FILE = "/tmp/clash-cmd.json";
|
|
19
|
+
const HEALTH_FILE = "/tmp/clash-health.json";
|
|
20
|
+
|
|
21
|
+
let pass = 0;
|
|
22
|
+
let fail = 0;
|
|
23
|
+
const check = (name, cond, extra = "") => {
|
|
24
|
+
if (cond) {
|
|
25
|
+
pass++;
|
|
26
|
+
console.log(` ✓ ${name}`);
|
|
27
|
+
} else {
|
|
28
|
+
fail++;
|
|
29
|
+
console.log(` ✗ ${name} ${extra}`);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const cmdFileMtime = () => (existsSync(CMD_FILE) ? statSync(CMD_FILE).mtimeMs : 0);
|
|
34
|
+
const cmdFileContent = () => (existsSync(CMD_FILE) ? readFileSync(CMD_FILE, "utf-8") : "");
|
|
35
|
+
|
|
36
|
+
// ── mock watch 服务:每跳返回固定订阅 + 可注入 commands/clashCommands ──
|
|
37
|
+
const SUBS = [{ name: "mock-sub-1", url: "https://example.com/sub-1" }];
|
|
38
|
+
let inject = {}; // 台架可注入额外响应字段(如 loginPorts 无关字段不注入,保持干净)
|
|
39
|
+
let hits = 0;
|
|
40
|
+
const server = http.createServer((req, res) => {
|
|
41
|
+
let body = "";
|
|
42
|
+
req.on("data", (c) => (body += c));
|
|
43
|
+
req.on("end", () => {
|
|
44
|
+
hits++;
|
|
45
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
46
|
+
res.end(
|
|
47
|
+
JSON.stringify({ commands: [], clashCommands: [], proxySettings: { subscriptions: SUBS }, ...inject }),
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
52
|
+
const port = server.address().port;
|
|
53
|
+
|
|
54
|
+
// ── agent 台架(不 start,不拉 daemon,手动逐跳)──
|
|
55
|
+
const agent = new WatchdogAgent({
|
|
56
|
+
serverUrl: `http://127.0.0.1:${port}`,
|
|
57
|
+
clientId: "t1@test",
|
|
58
|
+
intervalMs: 15000,
|
|
59
|
+
spawnDaemon: false,
|
|
60
|
+
});
|
|
61
|
+
agent.running = true;
|
|
62
|
+
const beat = () => agent._sendHeartbeat().catch((e) => console.error("beat err:", e.message));
|
|
63
|
+
|
|
64
|
+
// 清场:移除旧 ipc 文件
|
|
65
|
+
for (const f of [CMD_FILE, HEALTH_FILE]) {
|
|
66
|
+
try {
|
|
67
|
+
if (existsSync(f)) writeFileSync(f, "[]");
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log("═ 跳1: 首次下发订阅 → 应转发(写命令文件)═");
|
|
72
|
+
await beat();
|
|
73
|
+
const sentAt1 = agent._proxySubsSentAt;
|
|
74
|
+
check("命令文件已写入(setSubscription)", cmdFileContent().includes("mock-sub-1"));
|
|
75
|
+
check("转发时间戳已记录", sentAt1 > 0);
|
|
76
|
+
check("sig 已保存", agent._proxySubsSig === JSON.stringify(SUBS));
|
|
77
|
+
|
|
78
|
+
console.log("═ 跳2: daemon 应用失败(applyResult.ok=false, at>转发时刻) → 应重置 sig 并同轮重发 ═");
|
|
79
|
+
const t2 = Date.now(); // daemon 写的是过去的应用完成时刻(真实时序)
|
|
80
|
+
writeFileSync(
|
|
81
|
+
HEALTH_FILE,
|
|
82
|
+
JSON.stringify({ lastCheck: new Date().toISOString(), meta: { proxy: { applyResult: { ok: false, at: t2, error: "实例1: no-vendor 不支持的平台: linux-amd64" } } } }),
|
|
83
|
+
);
|
|
84
|
+
await beat();
|
|
85
|
+
check("sig 被重置(null)", agent._proxySubsSig === JSON.stringify(SUBS)); // 同轮已重发 → sig 恢复
|
|
86
|
+
check("重发后 sentAt 更新", agent._proxySubsSentAt > sentAt1);
|
|
87
|
+
check("命令文件被重写(重发)", cmdFileContent().includes("mock-sub-1") && statSync(CMD_FILE).mtimeMs > t2);
|
|
88
|
+
|
|
89
|
+
console.log("═ 跳3: 旧失败结果(at<sentAt) 不应误触发重发 ═");
|
|
90
|
+
const m3 = cmdFileMtime();
|
|
91
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
92
|
+
await beat();
|
|
93
|
+
check("命令文件未被重写", cmdFileMtime() === m3, `(mtime ${m3} → ${cmdFileMtime()})`);
|
|
94
|
+
|
|
95
|
+
console.log("═ 跳4: daemon 应用成功(ok=true) → 不重置不重发 ═");
|
|
96
|
+
writeFileSync(
|
|
97
|
+
HEALTH_FILE,
|
|
98
|
+
JSON.stringify({ lastCheck: new Date().toISOString(), meta: { proxy: { applyResult: { ok: true, at: Date.now() + 100 } } } }),
|
|
99
|
+
);
|
|
100
|
+
await beat();
|
|
101
|
+
check("sig 保持", agent._proxySubsSig === JSON.stringify(SUBS));
|
|
102
|
+
check("命令文件未被重写", cmdFileMtime() === m3);
|
|
103
|
+
|
|
104
|
+
console.log("═ 跳5: 订阅再次失败(新 at) → 再次重置重发(持续收敛)═");
|
|
105
|
+
const t5 = Date.now();
|
|
106
|
+
writeFileSync(
|
|
107
|
+
HEALTH_FILE,
|
|
108
|
+
JSON.stringify({ lastCheck: new Date().toISOString(), meta: { proxy: { applyResult: { ok: false, at: t5, error: "实例1: 内核获取失败" } } } }),
|
|
109
|
+
);
|
|
110
|
+
await beat();
|
|
111
|
+
check("重发发生(mtime 变化)", cmdFileMtime() > m3);
|
|
112
|
+
check("sentAt 再次更新", agent._proxySubsSentAt > t5 - 3000 && agent._proxySubsSentAt <= Date.now() + 10);
|
|
113
|
+
|
|
114
|
+
console.log("═ 跳6: 老版本 daemon(无 applyResult 字段) → 不触发、不重发(向后兼容)═");
|
|
115
|
+
writeFileSync(HEALTH_FILE, JSON.stringify({ lastCheck: new Date().toISOString(), meta: { proxy: {} } }));
|
|
116
|
+
const m6 = cmdFileMtime();
|
|
117
|
+
await beat();
|
|
118
|
+
check("命令文件未被重写", cmdFileMtime() === m6);
|
|
119
|
+
check("sig 保持", agent._proxySubsSig === JSON.stringify(SUBS));
|
|
120
|
+
|
|
121
|
+
console.log(`\n═══ 结果: ${pass} 通过 / ${fail} 失败 ═══`);
|
|
122
|
+
server.close();
|
|
123
|
+
process.exit(fail ? 1 : 0);
|
package/src/clash/runtime.js
CHANGED
|
@@ -73,6 +73,7 @@ const VENDOR_MANIFEST = {
|
|
|
73
73
|
"darwin-arm64": { file: `mihomo-darwin-arm64-${MIHOMO_VERSION}.gz`, sha256: "d131f44b3deb2a8356f7ac75048ad67a10d53243323951c4f3cda7b672922963" },
|
|
74
74
|
"darwin-amd64": { file: `mihomo-darwin-amd64-${MIHOMO_VERSION}.gz`, sha256: "3546681ebef3415e5dcbe7210a61aa80748136e95e6552768fd883df345508ed" },
|
|
75
75
|
"windows-amd64": { file: `mihomo-windows-amd64-${MIHOMO_VERSION}.zip`, sha256: "38b2420799d9e7cde77ec1a19c7150dd17ca77f7fb82d9f62cb8763a307eee67" },
|
|
76
|
+
"linux-amd64": { file: `mihomo-linux-amd64-${MIHOMO_VERSION}.gz`, sha256: "d5e74bbddbdfff49a1aef7775bf5911da59f0d7196ed509a0ac914b3653dd5f1" },
|
|
76
77
|
};
|
|
77
78
|
const UI_MANIFEST = { file: "metacubexd-dist.tgz", sha256: "93eb334af7e37d213317347c01de31bd28e6c591c9b90e86496d257f15135ed9" };
|
|
78
79
|
|
|
@@ -242,16 +243,19 @@ export async function ensureBinary({ log = () => {} } = {}) {
|
|
|
242
243
|
return { ok: true, bin: custom, source: "custom" };
|
|
243
244
|
}
|
|
244
245
|
const { bin } = runtimePaths();
|
|
245
|
-
const entry = VENDOR_MANIFEST[platformKey()];
|
|
246
|
-
if (!entry) return { ok: false, error: `不支持的平台: ${platformKey()}` };
|
|
247
246
|
|
|
248
247
|
// 1) 本地缓存: 版本与清单一致 → 跳过(老机器平滑:无 version 字段的旧 stamp 会被后续步骤补齐)
|
|
248
|
+
// 必须先于平台清单检查:缓存有效不应因 manifest 缺平台条目而失败(Linux 节点踩坑,F-11)
|
|
249
249
|
const stamp = readStamp();
|
|
250
250
|
if (existsSync(bin) && stamp?.core?.version === MIHOMO_VERSION) {
|
|
251
251
|
return { ok: true, bin, source: "cache" };
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
// 2)
|
|
254
|
+
// 2) 平台清单检查(此后所有路径都需要 entry)
|
|
255
|
+
const entry = VENDOR_MANIFEST[platformKey()];
|
|
256
|
+
if (!entry) return { ok: false, error: `不支持的平台: ${platformKey()}` };
|
|
257
|
+
|
|
258
|
+
// 3) 包内 vendor(dev / npm link / 手动放置)
|
|
255
259
|
const art = findVendorCore();
|
|
256
260
|
if (art) {
|
|
257
261
|
if (installUpToDate(art, "core") && existsSync(bin)) {
|
|
@@ -265,7 +269,7 @@ export async function ensureBinary({ log = () => {} } = {}) {
|
|
|
265
269
|
return installCoreFromArt(art.path, entry, { log });
|
|
266
270
|
}
|
|
267
271
|
|
|
268
|
-
//
|
|
272
|
+
// 4) 下载链(自建服务器 → GitHub 兼底)
|
|
269
273
|
const dlFile = join(RUNTIME_DIR, `.dl-${entry.file}`);
|
|
270
274
|
mkdirSync(RUNTIME_DIR, { recursive: true });
|
|
271
275
|
for (const base of vendorBases()) {
|