creditdaddy 1.3.2 → 1.4.0

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/src/daemon.js CHANGED
@@ -1,1059 +1,1266 @@
1
- /**
2
- * CreditDaddy 守护进程 — 本地 HTTP API + Web 面板。
3
- *
4
- * 架构参考 WorkDaddy:
5
- * - 默认只监听 127.0.0.1,数据不出本机
6
- * - 无依赖,Node 18+ 原生 http 模块
7
- * - 监听回环地址时校验 Host 头(防 DNS 重绑定),跨站 Origin 一律拒绝(防 CSRF)
8
- *
9
- * API:
10
- * GET /api/accounts 账号列表(脱敏)
11
- * POST /api/accounts 添加账号 {name, provider, token}
12
- * PATCH /api/accounts/:id 修改备注名 {name}
13
- * DELETE /api/accounts/:id 删除账号
14
- * POST /api/accounts/:id/checkin 手动为单个账号签到
15
- * GET /api/accounts/:id/quota 查询积分(统一结构)
16
- * POST /api/accounts/:id/switch 切换 WorkBuddy / ZCode 客户端当前登录账号 {force?}
17
- * GET /api/accounts/:id/zcode/plans ZCode 可领取的活动列表
18
- * POST /api/accounts/:id/zcode/claim ZCode 领取活动 {planId, captchaParam?, region?}
19
- * GET /api/zcode/captcha-config ZCode 领取验证码配置(sceneId / prefix / region)
20
- * POST /api/checkin 全部签到 {provider?, skipIfCheckedToday?}
21
- * POST /api/auth/device/start 发起浏览器登录 {provider: qoder / qoder-cn / workbuddy / workbuddy-intl / zcode-bigmodel / zcode-zai / minimax}
22
- * POST /api/auth/device/poll 轮询浏览器登录结果 {sessionId}
23
- * GET /api/local/detect 检测本机 Qoder 客户端 / IDE / CLI
24
- * POST /api/local/scan 读取本机已登录账号(解密客户端凭据 + 扫描旧版 IDE)
25
- * POST /api/local/import 导入扫描候选 {candidateId, provider?, name?}
26
- * GET /api/tenrouter 10Router 集成配置(key 脱敏);PUT 保存 {endpoint, key?, syncEnabled?, sources?};DELETE 清除
27
- * POST /api/tenrouter/test 测试地址与 key {endpoint?, key?}
28
- * GET /api/tenrouter/quotas 10Router 其他供应商额度总览(?force=1 跳过缓存)
29
- * GET /api/tenrouter/health 10Router 自身健康(/api/health 转发:ok / driver / lastDriverError)
30
- * POST /api/tenrouter/sync 立即同步本机用量到 10Router {dryRun?}
31
- * POST /api/tenrouter/sync-accounts 把本机账号推送到 10Router 连接(OAuth 通道需已配面板密码)
32
- * GET /api/qoder/umid Qoder 设备身份组件状态(Linux / fnOS)
33
- * POST /api/qoder/umid/install 下载官方 qodercli 并提取设备身份组件
34
- * GET /api/logs 最近日志
35
- * GET /api/status 守护进程状态
36
- * GET /api/settings 面板设置(访问密码开关状态,不回显密码)
37
- * PUT /api/settings 设置/修改/关闭面板访问密码 {panelKey? | disable?}
38
- * POST /api/export 导出账号 {password, provider?}(口令必填,10router 兼容加密文件)
39
- * POST /api/import 导入账号 {data, password?}(CreditDaddy / 10router 导出文件)
40
- * GET / Web 面板
41
- */
42
-
43
- import http from 'node:http';
44
- import crypto from 'node:crypto';
45
- import os from 'node:os';
46
- import fs from 'node:fs/promises';
47
- import path from 'node:path';
48
- import { fileURLToPath } from 'node:url';
49
- import { logger, getLogs } from './logger.js';
50
- import {
51
- loadAccounts, loadState, withAccounts, publicAccount, dataDir, loadSettings, saveSettings, withSettings,
52
- } from './store.js';
53
- import { addAccount, importAccounts, refreshContext } from './accounts.js';
54
- import { productImpl } from './providers.js';
55
- import { readWorkbuddySessions, writeWorkbuddySession, workbuddyAuthDir, currentWorkbuddyUid } from './workbuddyLocal.js';
56
- import { liveToAccount as zcodeLiveAccount, switchTo as zcodeSwitchTo, currentZcodeUid, currentZcodeIdentity, detectZcode, ensureVirtualDeviceMid, terminateZcode, zcodeRunning } from './zcodeLocal.js';
57
- import { liveToAccount as mirasimLiveAccount, switchTo as mirasimSwitchTo, currentMirasimUid, detectMirasim, terminateMirasim, mirasimRunning } from './mirasimLocal.js';
58
- import { liveToAccount as catpawLiveAccount, switchTo as catpawSwitchTo, currentCatpawToken, detectCatpaw, terminateCatpaw } from './catpawLocal.js';
59
- import { liveToAccount as traeLiveAccount, detectTrae, switchTo as traeSwitchTo, snapshotLive as traeSnapshotLive } from './traeLocal.js';
60
- import { liveToAccount as minimaxLiveAccount, detectMiniMax, currentMiniMaxUid, currentMiniMaxToken, currentMiniMaxRecordKey } from './minimaxLocal.js';
61
- import * as minimaxGateway from './minimaxGateway.js';
62
- import * as traeGateway from './traeGateway.js';
63
- import { fetchClaimPlans, claimPlan, fetchCaptchaConfig, proxyFirst, setProxyFirst, proxyUrl, setProxyUrl, autoClaimEnabled, autoClaimUntil, setAutoClaimEnabled, claimIntervalMin, setClaimIntervalMin, claimWindowMin, setClaimWindowMin } from './zcodeClient.js';
64
- import { exportAccounts, parseImport, TransferError } from './transfer.js';
65
- import { syncAccountsTo10r } from './tenrouterAccounts.js';
66
- import { runCheckinTick, getSchedulerInfo, dayKey, enableZcodeAutoClaimWindow, refreshZcodeScheduler, pollZcodeNow } from './checkin.js';
67
- import { detectQoderApps, readQoderAppAccounts, riskIdentityAvailable, riskIdentitySource, switchTo as qoderSwitchTo, qoderRunning, terminateQoder } from './qoderApp.js';
68
- import { umidInfo, installUmid } from './qoderUmid.js';
69
- import * as tenrouter from './tenrouter.js';
70
- import * as zcodeGateway from './zcodeGateway.js';
71
- import { startDeviceFlow, pollDeviceFlow, LOGIN_KINDS } from './authDevice.js';
72
- import { detectInstalls, scanLocalTokens, putCandidate, peekCandidate } from './localDetect.js';
73
- import { PROVIDER_LABEL, APP_VERSION, PROVIDERS, PROJECT_URL } from './constants.js';
74
-
75
- const PRODUCT_IDS = ['qoder', 'workbuddy', 'zcode', 'mirasim', 'catpaw', 'trae', 'minimax'];
76
-
77
- /** 面板访问密码:面板「设置」写入的 settings.json.panelKey 优先(可设 / 可关);
78
- * 未设置(或被面板关闭)时回退环境变量 CREDITDADDY_PASSWORD(fnOS / 命令行部署注入),
79
- * 保证安装向导密码在 NAS 部署里始终有效。设置后所有 /api/* 需要 x-qd-key 头;密码不回显。 */
80
- let PANEL_KEY = '';
81
-
82
- async function initPanelKey() {
83
- let s = {};
84
- try { s = await loadSettings(); } catch {}
85
- const env = process.env.CREDITDADDY_PASSWORD || process.env.QODERDADDY_PASSWORD || '';
86
- PANEL_KEY = typeof s.panelKey === 'string' && s.panelKey ? s.panelKey : env;
87
- }
88
-
89
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
90
- const PANEL_FILE = path.join(__dirname, 'panel.html');
91
- const DEFAULT_PORT = 47860;
92
- const MAX_BODY_BYTES = 2 * 1024 * 1024;
93
- const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
94
-
95
- // Qoder 当前登录缓存:面板 30s 一轮 status,DPAPI 解密结果没必要每轮重算
96
- let qoderCurCache = { at: 0, data: null };
97
- async function qoderCurrentInfo() {
98
- if (qoderCurCache.data && Date.now() - qoderCurCache.at < 60_000) return qoderCurCache.data;
99
- const data = { apps: detectQoderApps(), uids: {}, backfill: [] };
100
- try {
101
- const r = await readQoderAppAccounts();
102
- for (const c of r.accounts) {
103
- data.uids[c.provider] = c.user.id || null;
104
- if (c.user.id) data.backfill.push({ provider: c.provider, uid: c.user.id, authJson: c.authJson, file: c.file });
105
- }
106
- } catch { /* 解密失败只留检测信息 */ }
107
- qoderCurCache = { at: Date.now(), data };
108
- return data;
109
- }
110
-
111
- class HttpError extends Error {
112
- constructor(status, message) { super(message); this.status = status; }
113
- }
114
-
115
- function json(res, code, obj) {
116
- const body = JSON.stringify(obj);
117
- res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
118
- res.end(body);
119
- }
120
-
121
- async function readBody(req) {
122
- const chunks = [];
123
- let size = 0;
124
- for await (const c of req) {
125
- size += c.length;
126
- if (size > MAX_BODY_BYTES) throw new HttpError(413, '请求体过大');
127
- chunks.push(c);
128
- }
129
- const raw = Buffer.concat(chunks).toString('utf8');
130
- try { return raw ? JSON.parse(raw) : {}; } catch { throw new HttpError(400, '请求体不是合法 JSON'); }
131
- }
132
-
133
- /** 常量时间比较,避免通过响应时间逐字符猜出密钥 */
134
- function keyMatches(given) {
135
- const a = Buffer.from(String(given));
136
- const b = Buffer.from(PANEL_KEY);
137
- return a.length === b.length && crypto.timingSafeEqual(a, b);
138
- }
139
-
140
- function hostnameOf(hostHeader) {
141
- try { return new URL(`http://${hostHeader}`).hostname; } catch { return ''; }
142
- }
143
-
144
- /** 本机自身的地址 / 主机名(60s 缓存):非回环监听时用于放行合法 Host,挡 DNS 重绑定 */
145
- let localHostCache = { at: 0, set: null };
146
- function localHosts() {
147
- const now = Date.now();
148
- if (!localHostCache.set || now - localHostCache.at > 60_000) {
149
- const s = new Set(LOOPBACK_HOSTS);
150
- try { s.add(os.hostname().toLowerCase()); } catch {}
151
- try {
152
- for (const list of Object.values(os.networkInterfaces())) {
153
- for (const it of list || []) {
154
- if (!it?.address) continue;
155
- const a = it.address.toLowerCase();
156
- s.add(a);
157
- if (a.includes(':')) s.add(`[${a}]`);
158
- }
159
- }
160
- } catch {}
161
- localHostCache = { at: now, set: s };
162
- }
163
- return localHostCache.set;
164
- }
165
-
166
- /**
167
- * 浏览器侧防护,返回拒绝原因(null = 放行):
168
- * - Host 头必须是本机地址(挡住 DNS 重绑定读取 token):
169
- * · 回环监听:只允许 127.0.0.1 / localhost / ::1
170
- * · 非回环监听(0.0.0.0 / 局域网 IP):未设访问密钥时,允许本机全部网卡地址与主机名;
171
- * 设了密钥时密钥本身就是门槛(重绑定攻击者拿不到密钥),不再校验 Host
172
- * - 带 Origin 的请求必须与 Host 同源(挡住任意网页跨站 POST 添加/导入账号、触发签到)
173
- * 非浏览器客户端(curl、CLI、托盘菜单)不带 Origin,不受影响。
174
- */
175
- export function rejectForeignRequest(headers, bindHost) {
176
- const host = headers.host || '';
177
- const hostName = hostnameOf(host).toLowerCase();
178
- const loopbackBind = LOOPBACK_HOSTS.has(bindHost);
179
- if (loopbackBind && !LOOPBACK_HOSTS.has(hostName)) {
180
- return 'Host 不被允许';
181
- }
182
- if (!loopbackBind && !PANEL_KEY && !localHosts().has(hostName)) {
183
- return 'Host 不被允许';
184
- }
185
- const origin = headers.origin;
186
- if (origin) {
187
- let originHost = '';
188
- try { originHost = new URL(origin).host; } catch {}
189
- if (originHost !== host) return '跨站请求被拒绝';
190
- }
191
- return null;
192
- }
193
-
194
- /**
195
- * PUT /api/settings 的实际处理(鉴权已在入口完成):
196
- * { panelKey: '新密码' } 设置 / 修改面板访问密码(≥4 位、不含空白)
197
- * { disable: true } 关闭面板访问密码(导出账号的加密口令不受影响,始终必填)
198
- * 口令校验规则与导出加密一致(sealTransfer:至少 4 个字符)。
199
- */
200
- async function handleSettingsPut(res, body) {
201
- if (body?.disable === true) {
202
- await saveSettings({ panelKey: '' });
203
- await initPanelKey(); // 环境变量部署(fnOS)下关闭后仍保留安装向导密码
204
- logger.warn('DAEMON', PANEL_KEY ? '面板访问密码已在设置里关闭,但部署环境仍注入密码(保持开启)' : '面板访问密码已关闭(导出账号仍需加密口令)');
205
- return json(res, 200, { ok: true, panelKeyEnabled: Boolean(PANEL_KEY) });
206
- }
207
- const next = typeof body?.panelKey === 'string' ? body.panelKey.trim() : '';
208
- if (!next) return json(res, 400, { error: '请提供新密码,或提交 disable 关闭' });
209
- if (next.length < 4) return json(res, 400, { error: '面板访问密码至少 4 位' });
210
- if (/\s/.test(next)) return json(res, 400, { error: '面板访问密码不能包含空格' });
211
- await saveSettings({ panelKey: next });
212
- await initPanelKey();
213
- logger.info('DAEMON', '面板访问密码已更新');
214
- return json(res, 200, { ok: true, panelKeyEnabled: Boolean(PANEL_KEY) });
215
- }
216
-
217
- async function handleApi(req, res, url) {
218
- if (PANEL_KEY) {
219
- // 只认 x-qd-key 头:query 传密会落入 fnOS / 反代的访问日志
220
- const key = req.headers['x-qd-key'] || '';
221
- if (!keyMatches(key)) {
222
- return json(res, 401, { error: '需要访问密钥(x-qd-key 头)', code: 'PANEL_KEY' });
223
- }
224
- }
225
- const p = url.pathname;
226
- const method = req.method;
227
-
228
- // 账号列表
229
- if (p === '/api/accounts' && method === 'GET') {
230
- const accounts = await loadAccounts();
231
- return json(res, 200, {
232
- accounts: accounts.map(publicAccount),
233
- providers: PROVIDER_LABEL,
234
- dataDir: dataDir(),
235
- });
236
- }
237
-
238
- // 添加账号(自动拉取 userinfo 补全名字)
239
- if (p === '/api/accounts' && method === 'POST') {
240
- const body = await readBody(req);
241
- let result;
242
- try {
243
- result = await addAccount({ name: body.name, provider: body.provider, token: body.token });
244
- } catch (e) { return json(res, 400, { error: e.message }); }
245
- const { account, duplicate } = result;
246
- if (duplicate) return json(res, 409, { error: `该账号已存在(${account.name || account.id})` });
247
- if (account.verified === false) {
248
- logger.warn('DAEMON', `账号 ${account.name || account.id} 校验失败:${account.verifyError}`);
249
- }
250
- logger.info('DAEMON', `添加账号 ${account.name || account.id}(${PROVIDER_LABEL[account.provider]})`);
251
- return json(res, 201, { account: publicAccount(account) });
252
- }
253
-
254
- // 修改备注名
255
- const idMatch = p.match(/^\/api\/accounts\/([\w-]+)$/);
256
- if (idMatch && method === 'PATCH') {
257
- const body = await readBody(req);
258
- const name = String(body?.name || '').trim().slice(0, 64) || null;
259
- const found = await withAccounts((accounts) => {
260
- const a = accounts.find((x) => x.id === idMatch[1]);
261
- if (a) a.name = name;
262
- return a || null;
263
- });
264
- if (!found) return json(res, 404, { error: '账号不存在' });
265
- return json(res, 200, { account: publicAccount(found) });
266
- }
267
-
268
- // 删除账号
269
- const delMatch = idMatch;
270
- if (delMatch && method === 'DELETE') {
271
- const removed = await withAccounts((accounts) => {
272
- const i = accounts.findIndex((a) => a.id === delMatch[1]);
273
- return i < 0 ? null : accounts.splice(i, 1)[0];
274
- });
275
- if (!removed) return json(res, 404, { error: '账号不存在' });
276
- logger.info('DAEMON', `删除账号 ${removed.name || removed.id}`);
277
- return json(res, 200, { ok: true });
278
- }
279
-
280
- // 单账号签到
281
- const checkinMatch = p.match(/^\/api\/accounts\/([\w-]+)\/checkin$/);
282
- if (checkinMatch && method === 'POST') {
283
- const accounts = await loadAccounts();
284
- const account = accounts.find((a) => a.id === checkinMatch[1]);
285
- const { results, summary } = account?.provider === 'zcode'
286
- ? await pollZcodeNow([account.id])
287
- : await runCheckinTick({ onlyAccountId: checkinMatch[1], skipIfCheckedToday: false });
288
- return json(res, 200, { results, summary });
289
- }
290
-
291
- // 全部签到(默认跳过今日已签;传 skipIfCheckedToday:false 强制全部重签)
292
- if (p === '/api/checkin' && method === 'POST') {
293
- const body = await readBody(req).catch(() => ({}));
294
- const opts = {
295
- provider: body?.provider || undefined,
296
- product: body?.product || undefined,
297
- skipIfCheckedToday: body?.skipIfCheckedToday !== false,
298
- };
299
- const { results, summary } = opts.provider === 'zcode' || opts.product === 'zcode'
300
- ? await pollZcodeNow()
301
- : await runCheckinTick(opts);
302
- return json(res, 200, { results, summary });
303
- }
304
-
305
- // 积分查询(统一结构:total / used / remaining / parts[])
306
- const quotaMatch = p.match(/^\/api\/accounts\/([\w-]+)\/quota$/);
307
- if (quotaMatch && method === 'GET') {
308
- const accounts = await loadAccounts();
309
- const account = accounts.find((a) => a.id === quotaMatch[1]);
310
- if (!account) return json(res, 404, { error: '账号不存在' });
311
- try {
312
- const log = (m) => logger.info('QUOTA', `${account.name || account.id}:${m}`);
313
- const ctx = { log, ...refreshContext(account, log) };
314
- return json(res, 200, { quota: await productImpl(account.provider).quota(account, ctx) });
315
- } catch (e) {
316
- return json(res, 502, { error: e.message });
317
- }
318
- }
319
-
320
- // ZCode 活动领取
321
- const zcodePlansMatch = p.match(/^\/api\/accounts\/([\w-]+)\/zcode\/plans$/);
322
- if (zcodePlansMatch && method === 'GET') {
323
- const accounts = await loadAccounts();
324
- const account = accounts.find((a) => a.id === zcodePlansMatch[1]);
325
- if (!account) return json(res, 404, { error: '账号不存在' });
326
- if (account.provider !== 'zcode') return json(res, 400, { error: '只有 ZCode 账号支持活动领取' });
327
- try {
328
- return json(res, 200, await fetchClaimPlans(account));
329
- } catch (e) {
330
- return json(res, 502, { error: e.message });
331
- }
332
- }
333
- const zcodeClaimMatch = p.match(/^\/api\/accounts\/([\w-]+)\/zcode\/claim$/);
334
- if (zcodeClaimMatch && method === 'POST') {
335
- const body = await readBody(req).catch(() => ({}));
336
- if (!body?.planId || typeof body.planId !== 'string') return json(res, 400, { error: '缺少 planId' });
337
- const accounts = await loadAccounts();
338
- const account = accounts.find((a) => a.id === zcodeClaimMatch[1]);
339
- if (!account) return json(res, 404, { error: '账号不存在' });
340
- if (account.provider !== 'zcode') return json(res, 400, { error: '只有 ZCode 账号支持活动领取' });
341
- try {
342
- const outcome = await claimPlan(account, body.planId.slice(0, 128), {
343
- captchaParam: typeof body.captchaParam === 'string' ? body.captchaParam : '',
344
- region: typeof body.region === 'string' ? body.region : '',
345
- });
346
- logger.info('DAEMON', `ZCode ${account.name || account.id} 领取成功:${outcome.planName}`);
347
- await zcodeGateway.clearQuotaMark(account.id);
348
- await withAccounts((list) => {
349
- const cur = list.find((a) => a.id === account.id);
350
- if (cur) cur.lastResult = { status: 'checked-in', message: `已领取:${outcome.planName}`, amount: 0, at: new Date().toISOString() };
351
- });
352
- return json(res, 200, outcome);
353
- } catch (e) {
354
- logger.warn('DAEMON', `ZCode ${account.name || account.id} 领取失败:${e.message}`);
355
- return json(res, 502, { error: e.message, code: e.code, nextAt: e.nextAt });
356
- }
357
- }
358
- if (p === '/api/zcode/captcha-config' && method === 'GET') {
359
- try {
360
- return json(res, 200, await fetchCaptchaConfig());
361
- } catch (e) {
362
- return json(res, 502, { error: e.message });
363
- }
364
- }
365
- // ZCode 出口偏好:默认「直连优先、代理兜底」,可切「代理优先、直连兜底」;代理地址可面板配置(存 zcode-net.json,0600)
366
- // 自动领取开启时附带 1 小时时限(autoClaimUntil),到期调度器自动关闭并停止轮询
367
- if (p === '/api/zcode-gateway' && method === 'GET') {
368
- return json(res, 200, await zcodeGateway.gatewayStatus());
369
- }
370
- if (p === '/api/zcode-gateway' && method === 'PUT') {
371
- const body = await readBody(req).catch(() => ({}));
372
- if (body?.enabled !== undefined) await zcodeGateway.setGatewayEnabled(body.enabled === true);
373
- if (body?.lan !== undefined || body?.allow !== undefined) {
374
- const patch = {};
375
- if (body?.lan !== undefined) patch.zcodeGatewayLan = body.lan === true;
376
- if (body?.allow !== undefined) {
377
- if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
378
- const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
379
- if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
380
- patch.zcodeGatewayAllow = cleaned;
381
- }
382
- await (await import('./store.js')).saveSettings(patch);
383
- const lanNow = patch.zcodeGatewayLan ?? (await (await import('./store.js')).loadSettings()).zcodeGatewayLan === true;
384
- const allowText = patch.zcodeGatewayAllow ? patch.zcodeGatewayAllow.join(', ') : '';
385
- logger.info('DAEMON', `ZCode 网关局域网:${lanNow ? '开' : '关(仅本机)'}${allowText ? `,白名单:${allowText}` : ''}(绑定改动重启后生效)`);
386
- }
387
- return json(res, 200, await zcodeGateway.gatewayStatus());
388
- }
389
- // MiniMax 本地网关控制
390
- if (p === '/api/minimax-gateway' && method === 'GET') {
391
- return json(res, 200, await minimaxGateway.gatewayStatus());
392
- }
393
- if (p === '/api/minimax-gateway' && method === 'PUT') {
394
- const body = await readBody(req).catch(() => ({}));
395
- if (body?.enabled !== undefined) await minimaxGateway.setGatewayEnabled(body.enabled === true);
396
- if (body?.lan !== undefined || body?.allow !== undefined) {
397
- const patch = {};
398
- if (body?.lan !== undefined) patch.minimaxGatewayLan = body.lan === true;
399
- if (body?.allow !== undefined) {
400
- if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
401
- const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
402
- if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
403
- patch.minimaxGatewayAllow = cleaned;
404
- }
405
- await (await import('./store.js')).saveSettings(patch);
406
- const lanNow = patch.minimaxGatewayLan ?? (await (await import('./store.js')).loadSettings()).minimaxGatewayLan === true;
407
- const allowText = patch.minimaxGatewayAllow ? patch.minimaxGatewayAllow.join(', ') : '';
408
- logger.info('DAEMON', `MiniMax 网关局域网:${lanNow ? '开' : '关(仅本机)'}${allowText ? `,白名单:${allowText}` : ''}(绑定改动重启后生效)`);
409
- }
410
- return json(res, 200, await minimaxGateway.gatewayStatus());
411
- }
412
- // Trae 本地网关控制
413
- if (p === '/api/trae-gateway' && method === 'GET') {
414
- return json(res, 200, await traeGateway.gatewayStatus());
415
- }
416
- if (p === '/api/trae-gateway' && method === 'PUT') {
417
- const body = await readBody(req).catch(() => ({}));
418
- if (body?.enabled !== undefined) await traeGateway.setGatewayEnabled(body.enabled === true);
419
- if (body?.lan !== undefined || body?.allow !== undefined) {
420
- const patch = {};
421
- if (body?.lan !== undefined) patch.traeGatewayLan = body.lan === true;
422
- if (body?.allow !== undefined) {
423
- if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
424
- const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
425
- if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
426
- patch.traeGatewayAllow = cleaned;
427
- }
428
- await (await import('./store.js')).saveSettings(patch);
429
- const lanNow = patch.traeGatewayLan ?? (await (await import('./store.js')).loadSettings()).traeGatewayLan === true;
430
- const allowText = patch.traeGatewayAllow ? patch.traeGatewayAllow.join(', ') : '';
431
- logger.info('DAEMON', `Trae 网关局域网:${lanNow ? '开' : '关(仅本机)'}${allowText ? `,白名单:${allowText}` : ''}(绑定改动重启后生效)`);
432
- }
433
- return json(res, 200, await traeGateway.gatewayStatus());
434
- }
435
- if (p === '/api/zcode/net' && method === 'GET') {
436
- const u = proxyUrl();
437
- const masked = u ? (() => { try { const x = new URL(u); x.password = x.password ? '*'.repeat(4) : ''; return x.toString(); } catch { return '***'; } })() : null;
438
- const ac = autoClaimEnabled();
439
- return json(res, 200, {
440
- proxyFirst: proxyFirst(), proxyUrlMasked: masked,
441
- autoClaim: ac, autoClaimUntil: ac ? autoClaimUntil() : null,
442
- claimIntervalMin: claimIntervalMin(), claimWindowMin: claimWindowMin(),
443
- hasEnvProxy: Boolean(process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy),
444
- });
445
- }
446
- if (p === '/api/zcode/net' && method === 'PUT') {
447
- const body = await readBody(req).catch(() => ({}));
448
- try {
449
- if (body?.proxyFirst !== undefined) setProxyFirst(body.proxyFirst === true);
450
- if (body?.proxyUrl !== undefined) setProxyUrl(body.proxyUrl);
451
- // 自动领取计划(轮询周期 / 运行时长)先落盘,再按新配置启动
452
- if (body?.claimIntervalMin !== undefined) setClaimIntervalMin(body.claimIntervalMin);
453
- if (body?.claimWindowMin !== undefined) setClaimWindowMin(body.claimWindowMin);
454
- if (body?.claimIntervalMin !== undefined || body?.claimWindowMin !== undefined) refreshZcodeScheduler();
455
- if (body?.autoClaim !== undefined) {
456
- if (body.autoClaim === true) enableZcodeAutoClaimWindow(); // 开 = 按「自动领取计划」启动(2 分钟后首轮)
457
- else { setAutoClaimEnabled(false); refreshZcodeScheduler(); } // 关 = 立即停表
458
- }
459
- } catch (e) { return json(res, 400, { error: e.message }); }
460
- const ac = autoClaimEnabled();
461
- logger.info('DAEMON', `ZCode 出口:${proxyFirst() ? '代理优先' : '直连优先'},自动领取:${ac ? `开(每 ${claimIntervalMin()} 分钟,${claimWindowMin() > 0 ? `运行 ${claimWindowMin()} 分钟` : '一直运行'})` : '关'},代理 ${proxyUrl() || '(环境变量/未设置)'}`);
462
- return json(res, 200, {
463
- proxyFirst: proxyFirst(), autoClaim: ac, autoClaimUntil: ac ? autoClaimUntil() : null,
464
- claimIntervalMin: claimIntervalMin(), claimWindowMin: claimWindowMin(),
465
- });
466
- }
467
-
468
- // 切换 WorkBuddy / ZCode 客户端当前登录账号
469
- const switchMatch = p.match(/^\/api\/accounts\/([\w-]+)\/switch$/);
470
- if (switchMatch && method === 'POST') {
471
- const body = await readBody(req).catch(() => ({}));
472
- const accounts = await loadAccounts();
473
- const target = accounts.find((a) => a.id === switchMatch[1]);
474
- if (!target) return json(res, 404, { error: '账号不存在' });
475
- if (target.provider === 'zcode') {
476
- // 防丢号:先把 ZCode 当前登录(凭据 + config.json + 设备 ID)同步 / 保存进账号库,再覆盖
477
- const live = zcodeLiveAccount();
478
- if (live && live.uid !== target.uid) {
479
- await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 ZCode 当前登录失败:' + e.message));
480
- }
481
- try {
482
- // 强制切换:先关掉运行中的 ZCode,否则它会把内存里的旧登录覆盖回文件,切换等于没切
483
- let closedClient = false;
484
- if (body?.force === true) {
485
- const t = terminateZcode();
486
- closedClient = t.closed === true;
487
- if (closedClient) logger.info('DAEMON', '已关闭 ZCode 客户端(强制切换)');
488
- else if (t.running) logger.warn('DAEMON', '未能完全结束 ZCode 进程,继续强制切换');
489
- }
490
- ensureVirtualDeviceMid(target);
491
- const r = zcodeSwitchTo(target, { force: body?.force === true });
492
- // 虚拟设备 ID 首次生成时需落盘
493
- await withAccounts((list) => {
494
- const cur = list.find((a) => a.id === target.id);
495
- if (cur) cur.meta = { ...(cur.meta || {}), deviceMid: target.meta.deviceMid };
496
- });
497
- logger.info('DAEMON', r.alreadyActive ? `ZCode 当前已是 ${target.name || target.id}` : `ZCode 已切换到 ${target.name || target.id}`);
498
- return json(res, 200, { ok: true, closedClient, ...r });
499
- } catch (e) {
500
- return json(res, e.zcodeRunning ? 409 : 400, { error: e.message, code: e.zcodeRunning ? 'ZCODE_RUNNING' : undefined });
501
- }
502
- }
503
- if (target.provider === 'mirasim') {
504
- // 防丢号:先把当前登录同步进账号库
505
- try {
506
- const live = await mirasimLiveAccount();
507
- if (live && live.uid !== target.uid) {
508
- await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 mirasim 当前登录失败:' + e.message));
509
- }
510
- } catch (e) {}
511
- try {
512
- let closedClient = false;
513
- if (body?.force === true) {
514
- const t = terminateMirasim();
515
- closedClient = t.closed === true;
516
- if (closedClient) logger.info('DAEMON', '已关闭 Mirasim 客户端(强制切换)');
517
- }
518
- const r = await mirasimSwitchTo(target, { force: body?.force === true });
519
- logger.info('DAEMON', r.alreadyActive ? `mirasim 当前已是 ${target.name || target.id}` : `mirasim 已切换到 ${target.name || target.id}`);
520
- return json(res, 200, { ok: true, closedClient, ...r });
521
- } catch (e) {
522
- return json(res, e.mirasimRunning ? 409 : 400, { error: e.message, code: e.mirasimRunning ? 'MIRASIM_RUNNING' : undefined });
523
- }
524
- }
525
- if (target.provider === 'catpaw') {
526
- // 防丢号:先把妙手当前登录同步进账号库
527
- try {
528
- const live = await catpawLiveAccount();
529
- if (live && live.token !== target.token) {
530
- await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步妙手当前登录失败:' + e.message));
531
- }
532
- } catch (e) {}
533
- try {
534
- let closedClient = false;
535
- if (body?.force === true) {
536
- const t = terminateCatpaw();
537
- closedClient = t.closed === true;
538
- if (closedClient) logger.info('DAEMON', '已关闭妙手客户端(强制切换)');
539
- else if (t.running) logger.warn('DAEMON', '未能完全结束妙手进程,继续强制切换');
540
- }
541
- const r = await catpawSwitchTo(target, { force: body?.force === true });
542
- logger.info('DAEMON', r.alreadyActive ? `妙手当前已是 ${target.name || target.id}` : `妙手已切换到 ${target.name || target.id}(重新打开客户端生效)`);
543
- return json(res, 200, { ok: true, closedClient, ...r });
544
- } catch (e) {
545
- return json(res, e.catpawRunning ? 409 : 400, { error: e.message, code: e.catpawRunning ? 'CATPAW_RUNNING' : undefined });
546
- }
547
- }
548
- if (target.provider === 'qoder' || target.provider === 'qoder-cn') {
549
- // 防丢号:先把本机当前登录的最新 auth 快照回写账号库(客户端会静默刷新 token,旧快照可能已失效),再覆盖
550
- try {
551
- const lr = await readQoderAppAccounts();
552
- const live = lr.accounts.find((c) => c.provider === target.provider);
553
- if (live && live.user.id && String(live.user.id) !== String(target.uid)) {
554
- await addAccount({
555
- provider: live.provider, token: live.token, name: live.user.name || live.user.email,
556
- uid: live.user.id, email: live.user.email, refreshToken: live.refreshToken, expiresAt: live.expiresAt,
557
- source: 'local-app', meta: { qoderAuth: live.authJson, qoderAuthFile: live.file },
558
- }, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 Qoder 当前登录失败:' + e.message));
559
- }
560
- } catch (e) { logger.warn('DAEMON', '读取 Qoder 当前登录失败:' + e.message); }
561
- try {
562
- // 强制切换:先关掉运行中的 Qoder,否则它退出时会把内存里的旧登录覆盖回文件
563
- let closedClient = false;
564
- if (body?.force === true) {
565
- const t = terminateQoder();
566
- closedClient = t.closed === true;
567
- if (closedClient) logger.info('DAEMON', '已关闭 Qoder 客户端(强制切换)');
568
- else if (t.running) logger.warn('DAEMON', '未能完全结束 Qoder 进程,继续强制切换');
569
- }
570
- const r = await qoderSwitchTo(target, { force: body?.force === true });
571
- logger.info('DAEMON', r.alreadyActive ? `Qoder 当前已是 ${target.name || target.id}` : `Qoder 已切换到 ${target.name || target.id}(重新打开 Qoder 生效)`);
572
- return json(res, 200, { ok: true, closedClient, ...r });
573
- } catch (e) {
574
- return json(res, e.qoderRunning ? 409 : 400, { error: e.message, code: e.qoderRunning ? 'QODER_RUNNING' : undefined });
575
- }
576
- }
577
- if (target.provider === 'trae') {
578
- // 防丢号:先把 Trae 当前登录同步进账号库并建快照,再覆盖成目标账号的登录态
579
- try {
580
- const live = await traeLiveAccount();
581
- if (live && live.token !== target.token) {
582
- await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 Trae 当前登录失败:' + e.message));
583
- traeSnapshotLive();
584
- }
585
- } catch (e) {}
586
- try {
587
- // 退进程交给 switchTo:它要先确认目标有快照才会动手,避免「白关一次 Trae」
588
- const r = await traeSwitchTo(target, { force: body?.force === true });
589
- if (r.closedClient) logger.info('DAEMON', '已关闭 Trae 客户端(强制切换)');
590
- logger.info('DAEMON', r.alreadyActive ? `Trae 当前已是 ${target.name || target.id}` : `Trae 已切换到 ${target.name || target.id}(重新打开客户端生效)`);
591
- return json(res, 200, { ok: true, ...r });
592
- } catch (e) {
593
- return json(res, e.traeRunning ? 409 : 400, { error: e.message, code: e.traeRunning ? 'TRAE_RUNNING' : undefined });
594
- }
595
- }
596
- if (!target.provider.startsWith('workbuddy')) return json(res, 400, { error: '只有 WorkBuddy / ZCode / mirasim / 妙手 / Trae 账号支持切换' });
597
- // 先把客户端当前会话的最新 token 收回账号库,避免被覆盖后丢失
598
- const cur = readWorkbuddySessions().accounts.find((a) => a.current);
599
- if (cur && cur.uid !== target.uid) {
600
- const { file: _f, fileTime: _t, current: _c, ...rec } = cur;
601
- await addAccount(rec, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 WorkBuddy 当前会话失败:' + e.message));
602
- }
603
- try {
604
- const r = writeWorkbuddySession(target);
605
- logger.info('DAEMON', `WorkBuddy 已切换到 ${target.name || target.id}`);
606
- return json(res, 200, { ok: true, file: r.file, previousUid: r.previousUid });
607
- } catch (e) {
608
- return json(res, 400, { error: e.message });
609
- }
610
- }
611
-
612
- // ── 设备码登录(Qoder) ──
613
- if (p === '/api/auth/device/start' && method === 'POST') {
614
- const body = await readBody(req).catch(() => ({}));
615
- try {
616
- const kind = LOGIN_KINDS.includes(body?.provider) ? body.provider : 'qoder';
617
- const flow = await startDeviceFlow(kind);
618
- return json(res, 200, flow);
619
- } catch (e) { return json(res, 500, { error: e.message }); }
620
- }
621
- if (p === '/api/auth/device/poll' && method === 'POST') {
622
- const body = await readBody(req).catch(() => ({}));
623
- if (!body?.sessionId) return json(res, 400, { error: '缺少 sessionId' });
624
- try {
625
- return json(res, 200, await pollDeviceFlow(body.sessionId));
626
- } catch (e) { return json(res, 500, { error: e.message }); }
627
- }
628
-
629
- // ── Qoder 网页会话(桌面版登录窗口关闭前抓取,用于逐资源包用量明细) ──
630
- // Cookie 先探测归属(响应里的 user_id),只写到同 uid 的 qoder 账号上;探测失败视为会话无效。
631
- if (p === '/api/auth/qoder-web-session' && method === 'POST') {
632
- const body = await readBody(req).catch(() => ({}));
633
- const cookie = String(body?.cookie || '').trim();
634
- const kind = body?.kind === 'qoder-cn' ? 'qoder-cn' : 'qoder';
635
- if (!cookie || cookie.length > 16384) return json(res, 400, { error: '缺少 cookie' });
636
- const { probeWebSession } = await import('./qoderClient.js');
637
- const probe = await probeWebSession(kind, cookie);
638
- if (!probe?.user_id) return json(res, 400, { error: '网页会话无效(探测接口未通过)' });
639
- const now = new Date().toISOString();
640
- const bound = await withAccounts((list) => {
641
- const hits = list.filter((a) => a.provider === kind && a.uid === probe.user_id);
642
- for (const a of hits) {
643
- a.meta = { ...(a.meta || {}), qoderWebSession: { cookie, capturedAt: now } };
644
- a.updatedAt = now;
645
- }
646
- return hits.length;
647
- });
648
- if (!bound) {
649
- // 登录窗口可能先于设备码入库关闭:暂存待绑定会话,addAccount 补全 uid 后自动挂上
650
- await withSettings((settings) => {
651
- settings.pendingQoderWebSessions = {
652
- ...(settings.pendingQoderWebSessions || {}),
653
- [probe.user_id]: { kind, cookie, capturedAt: now },
654
- };
655
- });
656
- logger.info('DAEMON', `收到 Qoder 网页会话(uid ${probe.user_id.slice(0, 8)}…),暂无匹配账号,已暂存待绑定`);
657
- } else {
658
- logger.info('DAEMON', `已绑定 Qoder 网页会话到 ${bound} 个账号(逐资源包明细可用)`);
659
- }
660
- return json(res, 200, { ok: true, bound });
661
- }
662
-
663
- // ── 本机检测 / 凭据扫描 ──
664
- if (p === '/api/local/detect' && method === 'GET') {
665
- return json(res, 200, { apps: detectQoderApps(), workbuddyDir: workbuddyAuthDir(), zcode: detectZcode(), mirasim: detectMirasim(), catpaw: detectCatpaw(), trae: detectTrae(), minimax: detectMiniMax(), legacy: detectInstalls() });
666
- }
667
- if (p === '/api/local/scan' && method === 'POST') {
668
- const existing = await loadAccounts();
669
- const known = (provider, token, uid) => existing.some((a) => (!provider || a.provider === provider)
670
- && (a.token === token || (uid && a.uid === uid)));
671
- const candidates = [];
672
- const errors = [];
673
- const addRecord = (record, extra) => {
674
- candidates.push({
675
- id: putCandidate({ token: record.token, record }),
676
- kind: 'app', provider: record.provider,
677
- name: record.name, email: record.email, uid: record.uid, phone: record.meta?.phone ? record.meta.phone.slice(0, 3) + '****' + record.meta.phone.slice(-4) : null,
678
- tokenMasked: record.token.slice(0, 5) + '...' + record.token.slice(-4),
679
- expiresAt: record.expiresAt, hasRefreshToken: Boolean(record.refreshToken),
680
- imported: known(record.provider, record.token, record.uid),
681
- ...extra,
682
- });
683
- };
684
- // 1) Qoder 客户端:解密 auth.v1.dat
685
- const app = await readQoderAppAccounts();
686
- errors.push(...app.errors);
687
- for (const c of app.accounts) {
688
- addRecord({
689
- provider: c.provider, token: c.token, name: c.user.name || c.user.email, uid: c.user.id, email: c.user.email,
690
- refreshToken: c.refreshToken, expiresAt: c.expiresAt, source: 'local-app',
691
- // 存整份解密后的 auth.v1.dat:切换时要原样写回(里面除 token 还有客户端读的其它字段)
692
- meta: { qoderAuth: c.authJson, qoderAuthFile: c.file },
693
- }, { source: c.source });
694
- }
695
- // 2) WorkBuddy 客户端:当前会话 + 历史会话
696
- const wb = readWorkbuddySessions();
697
- errors.push(...wb.errors);
698
- for (const c of wb.accounts) {
699
- const { file: _f, fileTime: _t, current, ...record } = c;
700
- addRecord({ ...record, source: current ? 'workbuddy-current' : 'workbuddy-history' }, { source: c.source, current });
701
- }
702
- // 3) ZCode 客户端:解密 ~/.zcode/v2/credentials.json 的当前登录
703
- try {
704
- const z = zcodeLiveAccount();
705
- if (z) addRecord(z, { source: 'ZCode 当前登录', current: true });
706
- } catch (e) { errors.push({ file: '~/.zcode/v2/credentials.json', error: e.message }); }
707
- // 4) mirasim 客户端:解密 ~/.mirasim/setting.json 的当前登录
708
- try {
709
- const m = await mirasimLiveAccount();
710
- if (m) addRecord(m, { source: 'mirasim 当前登录', current: true });
711
- } catch (e) {
712
- // MISSING_SECRET_KEY / DECRYPT_FAILED 的文案已在 mirasimLocal 里写清检查过的位置
713
- errors.push({ file: '~/.mirasim/setting.json', error: e.message });
714
- }
715
- // 5) 妙手客户端:解密 %APPDATA%\catpaw-moon\catx-credential.json 的当前登录
716
- try {
717
- const cp = await catpawLiveAccount();
718
- if (cp) addRecord(cp, { source: '妙手当前登录', current: true });
719
- } catch (e) { errors.push({ file: 'catpaw-moon/catx-credential.json', error: e.message }); }
720
- // 6) Trae 客户端:解密 <userData>\User\globalStorage\storage.json 的 tc 信封当前登录
721
- try {
722
- const tr = await traeLiveAccount();
723
- if (tr) addRecord(tr, { source: 'Trae 当前登录', current: true });
724
- } catch (e) { errors.push({ file: 'TRAE SOLO CN/User/globalStorage/storage.json', error: e.message }); }
725
- // 7) MiniMax 客户端:读取 ~/.minimax 凭据当前登录
726
- try {
727
- const mm = await minimaxLiveAccount();
728
- if (mm) addRecord(mm, { source: 'MiniMax 当前登录', current: true });
729
- } catch (e) { errors.push({ file: '~/.minimax/auth/.../auth.json', error: e.message }); }
730
- // 8) 旧版 VS Code 系 Qoder IDE / CLI:明文 token 扫描(归属需用户选择)
731
- const det = detectInstalls();
732
- const dirs = det.ideDataDirs.filter(d => d.exists).map(d => d.path);
733
- if (det.cliDir.exists) dirs.push(det.cliDir.path);
734
- const legacy = await scanLocalTokens(dirs);
735
- for (const c of legacy.candidates) {
736
- candidates.push({ ...c, provider: null, source: '旧版 Qoder IDE / CLI 文件', imported: known(null, peekCandidate(c.id)?.token) });
737
- }
738
- return json(res, 200, { candidates, errors, scanned: legacy.scanned, scannedDirs: [...dirs, wb.dir] });
739
- }
740
- if (p === '/api/local/import' && method === 'POST') {
741
- const body = await readBody(req);
742
- const cand = peekCandidate(String(body?.candidateId || ''));
743
- if (!cand) return json(res, 404, { error: '候选不存在或已过期,请重新扫描' });
744
- const record = cand.record || {
745
- provider: body.provider === 'qoder-cn' ? 'qoder-cn' : 'qoder',
746
- token: cand.token, source: 'local-scan',
747
- };
748
- const { account, duplicate, updated } = await addAccount(
749
- { ...record, name: body.name || record.name || null },
750
- { trusted: Boolean(cand.record) },
751
- );
752
- if (duplicate && !updated) return json(res, 409, { error: '该账号已存在,信息已是最新' });
753
- // Trae 的登录态是 15 项文件快照而非单个凭据文件,导入时就建一次快照,否则该账号不可切换
754
- if (account.provider === 'trae') traeSnapshotLive();
755
- logger.info('DAEMON', (updated ? '已用本机凭据更新:' : '从本机导入账号:') + (account.name || account.id));
756
- return json(res, updated ? 200 : 201, { account: publicAccount(account), updated });
757
- }
758
-
759
- // ── 10Router 集成(虚拟 key:额度总览 + 用量同步) ──
760
- if (p === '/api/tenrouter' && method === 'GET') {
761
- return json(res, 200, await tenrouter.publicConfig());
762
- }
763
- if (p === '/api/tenrouter' && method === 'PUT') {
764
- const body = await readBody(req).catch(() => ({}));
765
- try {
766
- await tenrouter.updateConfig({ endpoint: body?.endpoint, key: body?.key, adminPassword: body?.adminPassword, syncEnabled: body?.syncEnabled, sources: body?.sources });
767
- return json(res, 200, await tenrouter.publicConfig());
768
- } catch (e) { return json(res, 400, { error: e.message }); }
769
- }
770
- if (p === '/api/tenrouter' && method === 'DELETE') {
771
- await tenrouter.updateConfig({ endpoint: '' });
772
- return json(res, 200, await tenrouter.publicConfig());
773
- }
774
- if (p === '/api/tenrouter/test' && method === 'POST') {
775
- const body = await readBody(req).catch(() => ({}));
776
- let override;
777
- try {
778
- // 可以在保存前测试面板里填的新地址 / key;key 留空则用已保存的
779
- if (body?.endpoint) override = { endpoint: tenrouter.normalizeEndpoint(body.endpoint) };
780
- if (body?.key && String(body.key).trim()) override = { ...(override || {}), key: String(body.key).trim() };
781
- } catch (e) { return json(res, 400, { ok: false, error: '地址格式不正确:' + e.message }); }
782
- return json(res, 200, await tenrouter.testConnection(override));
783
- }
784
- if (p === '/api/tenrouter/quotas' && method === 'GET') {
785
- try {
786
- return json(res, 200, await tenrouter.fetchQuotas({ force: url.searchParams.get('force') === '1' }));
787
- } catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 502, { error: e.message, code: e.code }); }
788
- }
789
- if (p === '/api/tenrouter/health' && method === 'GET') {
790
- return json(res, 200, await tenrouter.fetchHealth());
791
- }
792
- if (p === '/api/tenrouter/sync' && method === 'POST') {
793
- const body = await readBody(req).catch(() => ({}));
794
- try {
795
- return json(res, 200, await tenrouter.runUsageSync({ dryRun: body?.dryRun === true }));
796
- } catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 500, { error: e.message, code: e.code }); }
797
- }
798
- if (p === '/api/tenrouter/sync-accounts' && method === 'POST') {
799
- const body = await readBody(req).catch(() => ({}));
800
- try {
801
- // 请求里带了面板密码就先落盘(下次同步不必重填),否则用已保存的
802
- if (typeof body?.adminPassword === 'string' && body.adminPassword) {
803
- await tenrouter.updateConfig({ adminPassword: body.adminPassword });
804
- }
805
- const c = tenrouter.loadConfig();
806
- return json(res, 200, await syncAccountsTo10r({ endpoint: c.endpoint, key: c.key, adminPassword: c.adminPassword, accounts: await loadAccounts() }));
807
- } catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 500, { error: e.message, code: e.code }); }
808
- }
809
-
810
- // Qoder 设备身份组件(Linux / fnOS:从官方 qodercli 提取 UMID,国际版签到用)
811
- if (p === '/api/qoder/umid' && method === 'GET') {
812
- return json(res, 200, { ...umidInfo(), riskIdentity: riskIdentityAvailable(), riskSource: riskIdentitySource() });
813
- }
814
- if (p === '/api/qoder/umid/install' && method === 'POST') {
815
- try {
816
- await installUmid();
817
- return json(res, 200, { ok: true, ...umidInfo(), riskIdentity: riskIdentityAvailable(), riskSource: riskIdentitySource() });
818
- } catch (e) { return json(res, 500, { error: e.message }); }
819
- }
820
-
821
- // 日志(tag 过滤:?tag=MINIMAX-GW / ZCODE-GW 取网关调用日志,与运行日志分开展示)
822
- if (p === '/api/logs' && method === 'GET') {
823
- const tag = url.searchParams.get('tag') || null;
824
- return json(res, 200, { logs: getLogs(200, tag) });
825
- }
826
-
827
- // 状态
828
- if (p === '/api/status' && method === 'GET') {
829
- const accounts = await loadAccounts();
830
- const state = await loadState();
831
- // Qoder 当前登录(面板轮询每 30s 打一次,DPAPI 解密结果缓存 1 分钟)
832
- const qoder = await qoderCurrentInfo();
833
- // 旧账号没赶上「整份 auth 快照」的导入:本机 Qoder 有登录且账号库里有缺快照的同 uid 账号时,顺手回填(可逆、只在缺失时发生)
834
- const needBackfill = qoder.backfill.length
835
- && accounts.some((a) => (a.provider === 'qoder' || a.provider === 'qoder-cn')
836
- && a.uid && !a.meta?.qoderAuth
837
- && qoder.backfill.some((b) => b.provider === a.provider && String(b.uid) === String(a.uid)));
838
- if (needBackfill) {
839
- await withAccounts((list) => {
840
- for (const b of qoder.backfill) {
841
- const cur = list.find((a) => a.provider === b.provider && a.uid && b.uid && String(a.uid) === String(b.uid) && !a.meta?.qoderAuth);
842
- if (cur) cur.meta = { ...(cur.meta || {}), qoderAuth: b.authJson, qoderAuthFile: b.file };
843
- }
844
- }).catch((e) => logger.warn('DAEMON', '回填 Qoder 登录快照失败:' + e.message));
845
- }
846
- // 妙手凭据文件只存 token:当前登录按 token 对齐账号库,避免每轮状态都打网关查 uid
847
- const cpToken = currentCatpawToken();
848
- const mmToken = currentMiniMaxToken();
849
- const mmRecKey = currentMiniMaxRecordKey();
850
- // 1) token match;2) record-key match (token 轮换时仍稳定);3) uid-cache fallback
851
- let mmLiveUid = null;
852
- if (mmToken) {
853
- mmLiveUid = accounts.find((a) => a.provider === 'minimax' && a.token === mmToken)?.uid;
854
- }
855
- if (!mmLiveUid && mmRecKey) {
856
- mmLiveUid = accounts.find((a) => a.provider === 'minimax' && a.meta?.authRecordKey === mmRecKey)?.uid;
857
- }
858
- if (!mmLiveUid) mmLiveUid = currentMiniMaxUid();
859
- const traeDet = detectTrae();
860
- return json(res, 200, {
861
- ok: true,
862
- app: 'CreditDaddy',
863
- version: APP_VERSION,
864
- homepage: PROJECT_URL,
865
- accountsCount: accounts.length,
866
- dataDir: dataDir(),
867
- today: dayKey(),
868
- todayDone: state?.qoderDailyDone || {},
869
- todayByProduct: {
870
- qoder: dayKey(Date.now(), 'qoder'),
871
- workbuddy: dayKey(Date.now(), 'workbuddy'),
872
- trae: dayKey(Date.now(), 'trae'),
873
- minimax: dayKey(Date.now(), 'minimax'),
874
- },
875
- scheduler: getSchedulerInfo(),
876
- riskIdentity: riskIdentityAvailable(),
877
- riskSource: riskIdentitySource(),
878
- umid: umidInfo(),
879
- platform: process.platform,
880
- workbuddyCurrentUid: currentWorkbuddyUid(),
881
- qoderCurrent: qoder.uids,
882
- qoderClient: { installed: qoder.apps.some((x) => x.installed), signedIn: qoder.apps.some((x) => x.signedIn), running: qoderRunning() },
883
- zcodeCurrentUid: currentZcodeUid(),
884
- zcodeCurrentIdentity: currentZcodeIdentity(),
885
- zcodeClient: (() => { const d = detectZcode(); return { installed: d.exists, signedIn: d.signedIn, running: zcodeRunning() }; })(),
886
- mirasimCurrentUid: currentMirasimUid(),
887
- mirasimClient: (() => { const d = detectMirasim(); return { installed: d.clientInstalled, signedIn: d.signedIn, running: d.running }; })(),
888
- catpawCurrentUid: (cpToken && accounts.find((a) => a.provider === 'catpaw' && a.token === cpToken)?.uid) || null,
889
- catpawClient: (() => { const d = detectCatpaw(); return { installed: d.clientInstalled, signedIn: d.signedIn, running: d.running }; })(),
890
- traeCurrentUid: traeDet.uid,
891
- traeClient: { installed: traeDet.clientInstalled, signedIn: traeDet.signedIn, running: traeDet.running },
892
- minimaxCurrentUid: mmLiveUid,
893
- minimaxClient: (() => { const d = detectMiniMax(); return { installed: d.installed, signedIn: d.signedIn, running: d.running }; })(),
894
- keyRequired: Boolean(PANEL_KEY),
895
- });
896
- }
897
-
898
- // Panel 设置:GET 读取(只回状态不回显密码),PUT 设置 / 修改 / 关闭访问密码
899
- if (p === '/api/settings' && method === 'GET') {
900
- return json(res, 200, { panelKeyEnabled: Boolean(PANEL_KEY) });
901
- }
902
- if (p === '/api/settings' && method === 'PUT') {
903
- const body = await readBody(req).catch(() => ({}));
904
- return await handleSettingsPut(res, body);
905
- }
906
-
907
- // 导出(加密口令必填——账号含 token,不允许再导出明文文件)
908
- if (p === '/api/export' && method === 'POST') {
909
- const body = await readBody(req).catch(() => ({}));
910
- const provider = PROVIDERS.includes(body?.provider) ? body.provider : undefined;
911
- const product = PRODUCT_IDS.includes(body?.product) ? body.product : undefined;
912
- const password = typeof body?.password === 'string' ? body.password : '';
913
- if (password.length < 4) return json(res, 400, { error: '导出必须设置加密口令(至少 4 位)', code: 'PASSWORD_REQUIRED' });
914
- try {
915
- return json(res, 200, exportAccounts(await loadAccounts(), { password, provider, product }));
916
- } catch (e) {
917
- if (e instanceof TransferError) return json(res, 400, { error: e.message, code: e.code });
918
- throw e;
919
- }
920
- }
921
-
922
- // 导入
923
- if (p === '/api/import' && method === 'POST') {
924
- const body = await readBody(req);
925
- // 新格式 {data, password};兼容直接 POST 文件内容
926
- const data = body && typeof body === 'object' && !Array.isArray(body) && 'data' in body ? body.data : body;
927
- let parsed;
928
- try {
929
- parsed = parseImport(data, { password: body?.password });
930
- } catch (e) {
931
- if (e instanceof TransferError) return json(res, 400, { error: e.message, code: e.code });
932
- throw e;
933
- }
934
- const r = await importAccounts(parsed.accounts);
935
- const skipped = r.skipped + parsed.skipped;
936
- logger.info('DAEMON', `导入完成(${parsed.source}):新增 ${r.added},续期 ${r.updated},跳过 ${skipped}`);
937
- return json(res, 200, { added: r.added, updated: r.updated, skipped, source: parsed.source });
938
- }
939
-
940
- // 重启服务(桌面客户端或 CLI)
941
- if (p === '/api/system/restart' && method === 'POST') {
942
- json(res, 200, { ok: true, message: '正在重启服务…' });
943
- setTimeout(async () => {
944
- try {
945
- if (restartHandler) {
946
- await restartHandler();
947
- return;
948
- }
949
- // Node CLI 环境兜底重启:派生自身并退出当前进程
950
- const { spawn } = await import('node:child_process');
951
- spawn(process.execPath, process.argv.slice(1), {
952
- detached: true,
953
- stdio: 'inherit',
954
- env: process.env,
955
- }).unref();
956
- process.exit(0);
957
- } catch (err) {
958
- logger.error('DAEMON', `自动重启失败: ${err?.message || err}`);
959
- }
960
- }, 400);
961
- return;
962
- }
963
-
964
- return json(res, 404, { error: '未知接口' });
965
- }
966
-
967
- let restartHandler = null;
968
- /** 设置外部重启处理器(桌面版 Electron 注入 app.relaunch / app.exit) */
969
- export function setRestartHandler(fn) { restartHandler = typeof fn === 'function' ? fn : null; }
970
-
971
- /** 当前生效的面板访问密码(同进程宿主——桌面壳——调用本机 API 时带上 x-qd-key 头) */
972
- export function getPanelKey() { return PANEL_KEY; }
973
-
974
- export async function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
975
- await initPanelKey();
976
- let panelHtml = '';
977
- fs.readFile(PANEL_FILE, 'utf8').then((h) => { panelHtml = h; }).catch(() => {});
978
-
979
- const server = http.createServer(async (req, res) => {
980
- const url = new URL(req.url, 'http://localhost');
981
- try {
982
- const denied = rejectForeignRequest(req.headers, host);
983
- if (denied) {
984
- logger.warn('DAEMON', `拒绝请求 ${req.method} ${url.pathname}:${denied}(Host=${req.headers.host || ''} Origin=${req.headers.origin || ''})`);
985
- return json(res, 403, { error: denied });
986
- }
987
- // ZCode 免费额度网关(数据面,先于面板路由;仅本机回路,见 zcodeGateway.js)。
988
- // 规范路径带品牌段 /gateway/zcode/v1/messages,与 minimax/trae 网关命名一致,也是
989
- // gatewayStatus() 展示的 endpoint。/gateway/v1/messages 与 /v1/messages 为旧别名,
990
- // 保留向后兼容:/v1/messages 与 zcode-api 端点同形,10router 的 zcode-free 供应商
991
- // 只需换 host:port 即可在 CreditDaddy 网关与 zcode-api 之间切换。
992
- if (
993
- url.pathname === '/gateway/zcode/v1/messages'
994
- || url.pathname === '/gateway/v1/messages'
995
- || url.pathname === '/v1/messages'
996
- ) {
997
- return await zcodeGateway.handleGateway(req, res);
998
- }
999
- // MiniMax Code 本地 Anthropic 兼容网关
1000
- if (url.pathname === '/gateway/minimax/v1/messages') {
1001
- return await minimaxGateway.handleGateway(req, res);
1002
- }
1003
- // Trae SOLO 本地 Anthropic 兼容网关
1004
- if (url.pathname === '/gateway/trae/v1/messages') {
1005
- return await traeGateway.handleGateway(req, res);
1006
- }
1007
- if (url.pathname.startsWith('/api/')) {
1008
- return await handleApi(req, res, url);
1009
- }
1010
- // Web 面板
1011
- if (url.pathname === '/' || url.pathname === '/index.html') {
1012
- if (!panelHtml) { try { panelHtml = await fs.readFile(PANEL_FILE, 'utf8'); } catch {} }
1013
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1014
- return res.end(panelHtml || '<h1>CreditDaddy</h1><p>panel.html 缺失</p>');
1015
- }
1016
- res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
1017
- res.end('Not Found');
1018
- } catch (err) {
1019
- const status = err instanceof HttpError ? err.status : 500;
1020
- if (status >= 500) logger.error('DAEMON', `请求处理失败:${err?.message || err}`);
1021
- if (!res.headersSent) json(res, status, { error: err?.message || '内部错误' });
1022
- }
1023
- });
1024
-
1025
- return new Promise((resolve, reject) => {
1026
- let attempts = 0;
1027
- const bind = (p) => {
1028
- // 失败重试时必须摘掉上一次的 listening 监听,否则成功后会重复触发
1029
- const onError = (err) => {
1030
- server.off('listening', onListening);
1031
- if (err && err.code === 'EADDRINUSE' && attempts < 10) {
1032
- attempts += 1;
1033
- logger.warn('DAEMON', `端口 ${p} 被占用,改试 ${p + 1}`);
1034
- server.close();
1035
- setTimeout(() => bind(p + 1), 300);
1036
- } else {
1037
- // 兜底失败必须 reject:否则这个 promise 永远挂着,调用方的 startScheduler() 不会跑,
1038
- // 进程既不监听也不退出,只在日志里留一行,看起来像「启动了但没反应」
1039
- const msg = err?.message || String(err);
1040
- logger.error('DAEMON', `监听失败:${msg}`);
1041
- reject(new Error(`无法监听 ${host}:${p}:${msg}`));
1042
- }
1043
- };
1044
- const onListening = () => {
1045
- server.off('error', onError);
1046
- const bound = server.address().port;
1047
- logger.info('DAEMON', `CreditDaddy 守护进程已启动:http://${host}:${bound}`);
1048
- if ((host === '0.0.0.0' || host === '::') && !PANEL_KEY) {
1049
- logger.warn('DAEMON', '监听 0.0.0.0 且未设置 CREDITDADDY_PASSWORD —— 局域网内任何人可访问账号 API,建议设置访问密钥');
1050
- }
1051
- resolve({ server, port: bound });
1052
- };
1053
- server.once('error', onError);
1054
- server.once('listening', onListening);
1055
- server.listen(p, host);
1056
- };
1057
- bind(Number(port) || DEFAULT_PORT);
1058
- });
1059
- }
1
+ /**
2
+ * CreditDaddy 守护进程 — 本地 HTTP API + Web 面板。
3
+ *
4
+ * 架构参考 WorkDaddy:
5
+ * - 默认只监听 127.0.0.1,数据不出本机
6
+ * - 无依赖,Node 18+ 原生 http 模块
7
+ * - 监听回环地址时校验 Host 头(防 DNS 重绑定),跨站 Origin 一律拒绝(防 CSRF)
8
+ *
9
+ * API:
10
+ * GET /api/accounts 账号列表(脱敏)
11
+ * POST /api/accounts 添加账号 {name, provider, token}
12
+ * PATCH /api/accounts/:id 修改备注名 {name}
13
+ * DELETE /api/accounts/:id 删除账号
14
+ * POST /api/accounts/:id/checkin 手动为单个账号签到
15
+ * GET /api/accounts/:id/quota 查询积分(统一结构)
16
+ * POST /api/accounts/:id/switch 切换 WorkBuddy / ZCode 客户端当前登录账号 {force?}
17
+ * GET /api/accounts/:id/zcode/plans ZCode 可领取的活动列表
18
+ * POST /api/accounts/:id/zcode/claim ZCode 领取活动 {planId, captchaParam?, region?}
19
+ * GET /api/zcode/captcha-config ZCode 领取验证码配置(sceneId / prefix / region)
20
+ * POST /api/checkin 全部签到 {provider?, skipIfCheckedToday?}
21
+ * POST /api/auth/device/start 发起浏览器登录 {provider: qoder / qoder-cn / workbuddy / workbuddy-intl / zcode-bigmodel / zcode-zai / minimax}
22
+ * POST /api/auth/device/poll 轮询浏览器登录结果 {sessionId}
23
+ * GET /api/local/detect 检测本机 Qoder 客户端 / IDE / CLI
24
+ * POST /api/local/scan 读取本机已登录账号(解密客户端凭据 + 扫描旧版 IDE)
25
+ * POST /api/local/import 导入扫描候选 {candidateId, provider?, name?}
26
+ * GET /api/tenrouter 10Router 集成配置(key 脱敏);PUT 保存 {endpoint, key?, syncEnabled?, sources?};DELETE 清除
27
+ * POST /api/tenrouter/test 测试地址与 key {endpoint?, key?}
28
+ * GET /api/tenrouter/quotas 10Router 其他供应商额度总览(?force=1 跳过缓存)
29
+ * GET /api/tenrouter/health 10Router 自身健康(/api/health 转发:ok / driver / lastDriverError)
30
+ * POST /api/tenrouter/sync 立即同步本机用量到 10Router {dryRun?}
31
+ * POST /api/tenrouter/sync-accounts 把本机账号推送到 10Router 连接(OAuth 通道需已配面板密码)
32
+ * GET /api/qoder/umid Qoder 设备身份组件状态(Linux / fnOS)
33
+ * POST /api/qoder/umid/install 下载官方 qodercli 并提取设备身份组件
34
+ * GET /api/logs 最近日志
35
+ * GET /api/status 守护进程状态
36
+ * GET /api/settings 面板设置(访问密码开关状态,不回显密码)
37
+ * PUT /api/settings 设置/修改/关闭面板访问密码 {panelKey? | disable?}
38
+ * POST /api/export 导出账号 {password, provider?}(口令必填,10router 兼容加密文件)
39
+ * POST /api/import 导入账号 {data, password?}(CreditDaddy / 10router 导出文件)
40
+ * GET / Web 面板
41
+ */
42
+
43
+ import http from 'node:http';
44
+ import crypto from 'node:crypto';
45
+ import os from 'node:os';
46
+ import fs from 'node:fs/promises';
47
+ import path from 'node:path';
48
+ import { fileURLToPath } from 'node:url';
49
+ import { logger, getLogs, readArchiveJsonl } from './logger.js';
50
+ import {
51
+ loadAccounts, loadState, withAccounts, publicAccount, dataDir, loadSettings, saveSettings, withSettings,
52
+ } from './store.js';
53
+ import { addAccount, importAccounts, refreshContext } from './accounts.js';
54
+ import { productImpl } from './providers.js';
55
+ import { readWorkbuddySessions, writeWorkbuddySession, workbuddyAuthDir, currentWorkbuddyUid } from './workbuddyLocal.js';
56
+ import { refreshWorkbuddyToken } from './workbuddyClient.js';
57
+ import { liveToAccount as zcodeLiveAccount, switchTo as zcodeSwitchTo, currentZcodeUid, currentZcodeIdentity, detectZcode, ensureVirtualDeviceMid, terminateZcode, zcodeRunning } from './zcodeLocal.js';
58
+ import { liveToAccount as mirasimLiveAccount, switchTo as mirasimSwitchTo, currentMirasimUid, detectMirasim, terminateMirasim, mirasimRunning } from './mirasimLocal.js';
59
+ import { liveToAccount as catpawLiveAccount, switchTo as catpawSwitchTo, currentCatpawToken, detectCatpaw, terminateCatpaw } from './catpawLocal.js';
60
+ import { liveToAccount as traeLiveAccount, detectTrae, switchTo as traeSwitchTo, snapshotLive as traeSnapshotLive } from './traeLocal.js';
61
+ import { liveToAccount as minimaxLiveAccount, detectMiniMax, currentMiniMaxUid, currentMiniMaxToken, currentMiniMaxRecordKey } from './minimaxLocal.js';
62
+ import * as minimaxGateway from './minimaxGateway.js';
63
+ import * as traeGateway from './traeGateway.js';
64
+ import { fetchClaimPlans, claimPlan, fetchCaptchaConfig, proxyFirst, setProxyFirst, proxyUrl, setProxyUrl, autoClaimEnabled, autoClaimUntil, setAutoClaimEnabled, claimIntervalMin, setClaimIntervalMin, claimWindowMin, setClaimWindowMin } from './zcodeClient.js';
65
+ import { exportAccounts, buildExportPayload, parseImport, TransferError } from './transfer.js';
66
+ import { syncAccountsTo10r } from './tenrouterAccounts.js';
67
+ import { runCheckinTick, getSchedulerInfo, dayKey, enableZcodeAutoClaimWindow, refreshZcodeScheduler, pollZcodeNow, resyncAutoCheckinScheduler } from './checkin.js';
68
+ import { detectQoderApps, readQoderAppAccounts, riskIdentityAvailable, riskIdentitySource, switchTo as qoderSwitchTo, qoderRunning, terminateQoder } from './qoderApp.js';
69
+ import { umidInfo, installUmid } from './qoderUmid.js';
70
+ import * as tenrouter from './tenrouter.js';
71
+ import * as zcodeGateway from './zcodeGateway.js';
72
+ import { startDeviceFlow, pollDeviceFlow, LOGIN_KINDS } from './authDevice.js';
73
+ import { detectInstalls, scanLocalTokens, putCandidate, peekCandidate } from './localDetect.js';
74
+ import { PROVIDER_LABEL, APP_VERSION, PROVIDERS, PROJECT_URL } from './constants.js';
75
+ import { getLocale, setLocale, normalizeLocale, loadDict, t as tr } from './i18n.js';
76
+
77
+ const PRODUCT_IDS = ['qoder', 'workbuddy', 'zcode', 'mirasim', 'catpaw', 'trae', 'minimax'];
78
+
79
+ /** 面板访问密码:面板「设置」写入的 settings.json.panelKey 优先(可设 / 可关);
80
+ * 未设置(或被面板关闭)时回退环境变量 CREDITDADDY_PASSWORD(fnOS / 命令行部署注入),
81
+ * 保证安装向导密码在 NAS 部署里始终有效。设置后所有 /api/* 需要 x-qd-key 头;密码不回显。 */
82
+ let PANEL_KEY = '';
83
+ let PANEL_KEY_HASH = null; // 'scrypt:<salt_hex>:<hash_hex>' — 面板密码只存这个,不存明文
84
+ let PANEL_SESSION = null; // 进程级会话钥(重启随机):桌面壳寄主的 loopback 凭证,外部没人知道
85
+ let PANEL_KEY_MODE = 'none'; // 'plain' | 'hash' | 'none'
86
+
87
+ function scryptPanelKey(peer) {
88
+ const salt = crypto.randomBytes(8);
89
+ const h = crypto.scryptSync(String(peer), salt, 32);
90
+ return `scrypt:${salt.toString('hex')}:${h.toString('hex')}`;
91
+ }
92
+ function scryptMatches(peer, stored) {
93
+ try {
94
+ const [algo, saltHex, hashHex] = String(stored).split(':');
95
+ if (algo !== 'scrypt' || !saltHex || !hashHex) return false;
96
+ const h = crypto.scryptSync(String(peer), Buffer.from(saltHex, 'hex'), 32);
97
+ return crypto.timingSafeEqual(h, Buffer.from(hashHex, 'hex'));
98
+ } catch { return false; }
99
+ }
100
+
101
+ /** 面板密码是否生效(哈希 / 明文 / 环境变量任一路径都算)—— PANEL_KEY 字符串本身在哈希模式下为空。 */
102
+ export function panelKeyRequired() { return PANEL_KEY_MODE !== 'none'; }
103
+
104
+ async function initPanelKey() {
105
+ let s = {};
106
+ try { s = await loadSettings(); } catch {}
107
+ const env = process.env.CREDITDADDY_PASSWORD || process.env.QODERDADDY_PASSWORD || '';
108
+ // 存储面板密码只存 scrypt 哈希——此前的明文 panelKey 见了顺手升级(写为哈希并清除明文)。
109
+ if (typeof s.panelKeyHash === 'string' && s.panelKeyHash.startsWith('scrypt:')) {
110
+ PANEL_KEY_HASH = s.panelKeyHash;
111
+ PANEL_KEY_MODE = 'hash';
112
+ PANEL_KEY = '';
113
+ if (s.panelKey) await saveSettings({ panelKey: '' });
114
+ } else {
115
+ const plain = typeof s.panelKey === 'string' && s.panelKey ? s.panelKey : '';
116
+ if (plain) {
117
+ PANEL_KEY_HASH = scryptPanelKey(plain);
118
+ PANEL_KEY_MODE = 'hash';
119
+ PANEL_KEY = '';
120
+ await saveSettings({ panelKeyHash: PANEL_KEY_HASH, panelKey: '' });
121
+ } else if (env) {
122
+ PANEL_KEY = env;
123
+ PANEL_KEY_HASH = null;
124
+ PANEL_KEY_MODE = 'plain'; // 环境变量来源的密码按现状走(部署侧管理)
125
+ } else {
126
+ PANEL_KEY = '';
127
+ PANEL_KEY_HASH = null;
128
+ PANEL_KEY_MODE = 'none';
129
+ }
130
+ }
131
+ PANEL_SESSION = crypto.randomBytes(16).toString('hex'); // 每次拉起重新随机(桌面上次用过期票不会误通行)
132
+ setLocale(s.locale);
133
+ }
134
+
135
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
136
+ const PANEL_FILE = path.join(__dirname, 'panel.html');
137
+ const DEFAULT_PORT = 47860;
138
+ const MAX_BODY_BYTES = 2 * 1024 * 1024;
139
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
140
+
141
+ // Qoder 当前登录缓存:面板 30s 一轮 status,DPAPI 解密结果没必要每轮重算
142
+ let qoderCurCache = { at: 0, data: null };
143
+ async function qoderCurrentInfo() {
144
+ if (qoderCurCache.data && Date.now() - qoderCurCache.at < 60_000) return qoderCurCache.data;
145
+ const data = { apps: detectQoderApps(), uids: {}, backfill: [] };
146
+ try {
147
+ const r = await readQoderAppAccounts();
148
+ for (const c of r.accounts) {
149
+ data.uids[c.provider] = c.user.id || null;
150
+ if (c.user.id) data.backfill.push({ provider: c.provider, uid: c.user.id, authJson: c.authJson, file: c.file });
151
+ }
152
+ } catch { /* 解密失败只留检测信息 */ }
153
+ qoderCurCache = { at: Date.now(), data };
154
+ return data;
155
+ }
156
+
157
+ class HttpError extends Error {
158
+ constructor(status, message) { super(message); this.status = status; }
159
+ }
160
+
161
+ function json(res, code, obj) {
162
+ const body = JSON.stringify(obj);
163
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
164
+ res.end(body);
165
+ }
166
+
167
+ async function readBody(req) {
168
+ const chunks = [];
169
+ let size = 0;
170
+ for await (const c of req) {
171
+ size += c.length;
172
+ if (size > MAX_BODY_BYTES) throw new HttpError(413, '请求体过大');
173
+ chunks.push(c);
174
+ }
175
+ const raw = Buffer.concat(chunks).toString('utf8');
176
+ try { return raw ? JSON.parse(raw) : {}; } catch { throw new HttpError(400, '请求体不是合法 JSON'); }
177
+ }
178
+
179
+ /** 常量时间比较,避免通过响应时间逐字符猜出密钥;
180
+ * mode 'hash' → scrypt 推导比;
181
+ * mode 'plain' → 仅环境变量来源,按原常量时间比;
182
+ * mode 'none' → 永远 false(本函数不该进来) */
183
+ function keyMatches(given) {
184
+ const head = String(given);
185
+ if (PANEL_SESSION && head === PANEL_SESSION) return true; // 桌面寄主会话钥(重启随机,外部不可伪造)
186
+ if (PANEL_KEY_MODE === 'hash') return scryptMatches(head, PANEL_KEY_HASH);
187
+ const a = Buffer.from(head);
188
+ const b = Buffer.from(PANEL_KEY);
189
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
190
+ }
191
+
192
+ function hostnameOf(hostHeader) {
193
+ try { return new URL(`http://${hostHeader}`).hostname; } catch { return ''; }
194
+ }
195
+
196
+ /** 本机自身的地址 / 主机名(60s 缓存):非回环监听时用于放行合法 Host,挡 DNS 重绑定 */
197
+ let localHostCache = { at: 0, set: null };
198
+ function localHosts() {
199
+ const now = Date.now();
200
+ if (!localHostCache.set || now - localHostCache.at > 60_000) {
201
+ const s = new Set(LOOPBACK_HOSTS);
202
+ try { s.add(os.hostname().toLowerCase()); } catch {}
203
+ try {
204
+ for (const list of Object.values(os.networkInterfaces())) {
205
+ for (const it of list || []) {
206
+ if (!it?.address) continue;
207
+ const a = it.address.toLowerCase();
208
+ s.add(a);
209
+ if (a.includes(':')) s.add(`[${a}]`);
210
+ }
211
+ }
212
+ } catch {}
213
+ localHostCache = { at: now, set: s };
214
+ }
215
+ return localHostCache.set;
216
+ }
217
+
218
+ /**
219
+ * 浏览器侧防护,返回拒绝原因(null = 放行):
220
+ * - Host 头必须是本机地址(挡住 DNS 重绑定读取 token):
221
+ * · 回环监听:只允许 127.0.0.1 / localhost / ::1
222
+ * · 非回环监听(0.0.0.0 / 局域网 IP):未设访问密钥时,允许本机全部网卡地址与主机名;
223
+ * 设了密钥时密钥本身就是门槛(重绑定攻击者拿不到密钥),不再校验 Host
224
+ * - 带 Origin 的请求必须与 Host 同源(挡住任意网页跨站 POST 添加/导入账号、触发签到)
225
+ * 非浏览器客户端(curl、CLI、托盘菜单)不带 Origin,不受影响。
226
+ */
227
+ export function rejectForeignRequest(headers, bindHost) {
228
+ const host = headers.host || '';
229
+ const hostName = hostnameOf(host).toLowerCase();
230
+ const loopbackBind = LOOPBACK_HOSTS.has(bindHost);
231
+ if (loopbackBind && !LOOPBACK_HOSTS.has(hostName)) {
232
+ return 'Host 不被允许';
233
+ }
234
+ if (!loopbackBind && !panelKeyRequired() && !localHosts().has(hostName)) {
235
+ return 'Host 不被允许';
236
+ }
237
+ const origin = headers.origin;
238
+ if (origin) {
239
+ let originHost = '';
240
+ try { originHost = new URL(origin).host; } catch {}
241
+ if (originHost !== host) return '跨站请求被拒绝';
242
+ }
243
+ return null;
244
+ }
245
+
246
+ /**
247
+ * PUT /api/settings 的实际处理(鉴权已在入口完成):
248
+ * { panelKey: '新密码' } 设置 / 修改面板访问密码(≥4 位、不含空白)
249
+ * { disable: true } 关闭面板访问密码(导出账号的加密口令不受影响,始终必填)
250
+ * 口令校验规则与导出加密一致(sealTransfer:至少 4 个字符)。
251
+ */
252
+ async function handleSettingsPut(res, body) {
253
+ if (body?.disable === true) {
254
+ await saveSettings({ panelKey: '' });
255
+ await initPanelKey(); // 环境变量部署(fnOS)下关闭后仍保留安装向导密码
256
+ logger.warn('DAEMON', panelKeyRequired() ? '面板访问密码已在设置里关闭,但部署环境仍注入密码(保持开启)' : '面板访问密码已关闭(导出账号仍需加密口令)', {});
257
+ return json(res, 200, { ok: true, panelKeyEnabled: panelKeyRequired() });
258
+ }
259
+ const next = typeof body?.panelKey === 'string' ? body.panelKey.trim() : '';
260
+ if (!next) return json(res, 400, { error: '请提供新密码,或提交 disable 关闭' });
261
+ if (next.length < 4) return json(res, 400, { error: '面板访问密码至少 4 位' });
262
+ if (/\s/.test(next)) return json(res, 400, { error: '面板访问密码不能包含空格' });
263
+ // 只存 scrypt 哈希,明文设置完立刻请出数据目录
264
+ await saveSettings({ panelKeyHash: scryptPanelKey(next), panelKey: '' });
265
+ await initPanelKey();
266
+ logger.info('DAEMON', '面板访问密码已更新(scrypt 哈希落盘)', {});
267
+ return json(res, 200, { ok: true, panelKeyEnabled: panelKeyRequired() });
268
+ }
269
+
270
+ // socket 地址级回环判定(不是 hostname):PANEL_KEY 未设时 fail-closed 的关键输入
271
+ function isLoopbackSocketAddr(remote) {
272
+ const r = String(remote || '');
273
+ return !r || /^(::1|::ffff:127\.0\.0\.1|127\.0\.0\.1)$/.test(r);
274
+ }
275
+
276
+ // PANEL_KEY 按源 IP 失败计数限速:401 暴力猜密是免费的网络噪音――连错 10 次锁 5 分钟。
277
+ // timingSafeEqual 防了侧信道,防不了离线字典速度撞库;失败计数只留进程内,重启归零。
278
+ const keyFails = new Map(); // socketRemote → { n, lockedUntil }
279
+ const KEY_FAIL_LIMIT = 10;
280
+ const KEY_FAIL_LOCK_MS = 5 * 60_000;
281
+
282
+ function remoteKeyAddr(req) { return String((req.socket && req.socket.remoteAddress) || ''); }
283
+ function keyThrottled(remote) {
284
+ const rec = keyFails.get(remote);
285
+ if (!rec) return false;
286
+ if (rec.lockedUntil && Date.now() < rec.lockedUntil) return true;
287
+ if (rec.lockedUntil) keyFails.delete(remote);
288
+ return false;
289
+ }
290
+ function noteKeyMiss(remote) {
291
+ const rec = keyFails.get(remote) || { n: 0 };
292
+ rec.n += 1;
293
+ if (rec.n >= KEY_FAIL_LIMIT) { rec.lockedUntil = Date.now() + KEY_FAIL_LOCK_MS; rec.n = 0;
294
+ logger.warn('DAEMON', '面板密码连续 {n} 次错误,来源 {remote} 已限流 5 分钟', { n: KEY_FAIL_LIMIT, remote }); }
295
+ keyFails.set(remote, rec);
296
+ }
297
+
298
+ async function handleApi(req, res, url) {
299
+ if (!panelKeyRequired()) {
300
+ const remote = (req.socket && req.socket.remoteAddress) || '';
301
+ if (!isLoopbackSocketAddr(remote)) {
302
+ logger.warn('DAEMON', '拒绝非本机请求(未设面板访问密码):{method} {path} 来自 {remote}', { method: req.method, path: url.pathname, remote });
303
+ return json(res, 403, { error: '未设置面板访问密码时,面板账号接口仅允许本机访问——请在「设置 → 通用 → 面板访问密码」设置密码后再从局域网访问' });
304
+ }
305
+ }
306
+ if (panelKeyRequired()) {
307
+ const remote = remoteKeyAddr(req);
308
+ if (keyThrottled(remote)) {
309
+ return json(res, 429, { error: '面板密码连续错误过多,已限流 5 分钟' });
310
+ }
311
+ // 只认 x-qd-key 头:query 传密会落入 fnOS / 反代的访问日志
312
+ const key = req.headers['x-qd-key'] || '';
313
+ if (!keyMatches(key)) {
314
+ noteKeyMiss(remote);
315
+ return json(res, 401, { error: '需要访问密钥(x-qd-key 头)', code: 'PANEL_KEY' });
316
+ }
317
+ keyFails.delete(remote); // 猜对了立即清零
318
+ }
319
+ const p = url.pathname;
320
+ const method = req.method;
321
+
322
+ // 账号列表
323
+ if (p === '/api/accounts' && method === 'GET') {
324
+ const accounts = await loadAccounts();
325
+ return json(res, 200, {
326
+ accounts: accounts.map(publicAccount),
327
+ providers: PROVIDER_LABEL,
328
+ dataDir: dataDir(),
329
+ });
330
+ }
331
+
332
+ // 添加账号(自动拉取 userinfo 补全名字)
333
+ if (p === '/api/accounts' && method === 'POST') {
334
+ const body = await readBody(req);
335
+ let result;
336
+ try {
337
+ result = await addAccount({ name: body.name, provider: body.provider, token: body.token });
338
+ } catch (e) { return json(res, 400, { error: e.message }); }
339
+ const { account, duplicate } = result;
340
+ if (duplicate) return json(res, 409, { error: `该账号已存在(${account.name || account.id})` });
341
+ if (account.verified === false) {
342
+ logger.warn('DAEMON', '账号 {name} 校验失败:{err}', { name: account.name || account.id, err: account.verifyError });
343
+ }
344
+ logger.info('DAEMON', '添加账号 {name}({provider})', { name: account.name || account.id, provider: PROVIDER_LABEL[account.provider] });
345
+ return json(res, 201, { account: publicAccount(account) });
346
+ }
347
+
348
+ // 修改备注名
349
+ const idMatch = p.match(/^\/api\/accounts\/([\w-]+)$/);
350
+ if (idMatch && method === 'PATCH') {
351
+ const body = await readBody(req);
352
+ const name = String(body?.name || '').trim().slice(0, 64) || null;
353
+ const found = await withAccounts((accounts) => {
354
+ const a = accounts.find((x) => x.id === idMatch[1]);
355
+ if (a) a.name = name;
356
+ return a || null;
357
+ });
358
+ if (!found) return json(res, 404, { error: '账号不存在' });
359
+ return json(res, 200, { account: publicAccount(found) });
360
+ }
361
+
362
+ // 删除账号
363
+ const delMatch = idMatch;
364
+ if (delMatch && method === 'DELETE') {
365
+ const removed = await withAccounts((accounts) => {
366
+ const i = accounts.findIndex((a) => a.id === delMatch[1]);
367
+ return i < 0 ? null : accounts.splice(i, 1)[0];
368
+ });
369
+ if (!removed) return json(res, 404, { error: '账号不存在' });
370
+ logger.info('DAEMON', '删除账号 {name}', { name: removed.name || removed.id });
371
+ return json(res, 200, { ok: true });
372
+ }
373
+
374
+ // 单账号签到
375
+ const checkinMatch = p.match(/^\/api\/accounts\/([\w-]+)\/checkin$/);
376
+ if (checkinMatch && method === 'POST') {
377
+ const accounts = await loadAccounts();
378
+ const account = accounts.find((a) => a.id === checkinMatch[1]);
379
+ const { results, summary } = account?.provider === 'zcode'
380
+ ? await pollZcodeNow([account.id])
381
+ : await runCheckinTick({ onlyAccountId: checkinMatch[1], skipIfCheckedToday: false });
382
+ return json(res, 200, { results, summary });
383
+ }
384
+
385
+ // 全部签到(默认跳过今日已签;传 skipIfCheckedToday:false 强制全部重签)
386
+ if (p === '/api/checkin' && method === 'POST') {
387
+ const body = await readBody(req).catch(() => ({}));
388
+ const opts = {
389
+ provider: body?.provider || undefined,
390
+ product: body?.product || undefined,
391
+ skipIfCheckedToday: body?.skipIfCheckedToday !== false,
392
+ };
393
+ const { results, summary } = opts.provider === 'zcode' || opts.product === 'zcode'
394
+ ? await pollZcodeNow()
395
+ : await runCheckinTick(opts);
396
+ return json(res, 200, { results, summary });
397
+ }
398
+
399
+ // 积分查询(统一结构:total / used / remaining / parts[])
400
+ const quotaMatch = p.match(/^\/api\/accounts\/([\w-]+)\/quota$/);
401
+ if (quotaMatch && method === 'GET') {
402
+ const accounts = await loadAccounts();
403
+ const account = accounts.find((a) => a.id === quotaMatch[1]);
404
+ if (!account) return json(res, 404, { error: '账号不存在' });
405
+ try {
406
+ const log = (m) => logger.info('QUOTA', '{name}:{msg}', { name: account.name || account.id, msg: m });
407
+ const ctx = { log, ...refreshContext(account, log) };
408
+ return json(res, 200, { quota: await productImpl(account.provider).quota(account, ctx) });
409
+ } catch (e) {
410
+ return json(res, 502, { error: e.message });
411
+ }
412
+ }
413
+
414
+ // ZCode 活动领取
415
+ const zcodePlansMatch = p.match(/^\/api\/accounts\/([\w-]+)\/zcode\/plans$/);
416
+ if (zcodePlansMatch && method === 'GET') {
417
+ const accounts = await loadAccounts();
418
+ const account = accounts.find((a) => a.id === zcodePlansMatch[1]);
419
+ if (!account) return json(res, 404, { error: '账号不存在' });
420
+ if (account.provider !== 'zcode') return json(res, 400, { error: '只有 ZCode 账号支持活动领取' });
421
+ try {
422
+ return json(res, 200, await fetchClaimPlans(account));
423
+ } catch (e) {
424
+ return json(res, 502, { error: e.message });
425
+ }
426
+ }
427
+ const zcodeClaimMatch = p.match(/^\/api\/accounts\/([\w-]+)\/zcode\/claim$/);
428
+ if (zcodeClaimMatch && method === 'POST') {
429
+ const body = await readBody(req).catch(() => ({}));
430
+ if (!body?.planId || typeof body.planId !== 'string') return json(res, 400, { error: '缺少 planId' });
431
+ const accounts = await loadAccounts();
432
+ const account = accounts.find((a) => a.id === zcodeClaimMatch[1]);
433
+ if (!account) return json(res, 404, { error: '账号不存在' });
434
+ if (account.provider !== 'zcode') return json(res, 400, { error: '只有 ZCode 账号支持活动领取' });
435
+ try {
436
+ const outcome = await claimPlan(account, body.planId.slice(0, 128), {
437
+ captchaParam: typeof body.captchaParam === 'string' ? body.captchaParam : '',
438
+ region: typeof body.region === 'string' ? body.region : '',
439
+ });
440
+ logger.info('DAEMON', 'ZCode {name} 领取成功:{plan}', { name: account.name || account.id, plan: outcome.planName });
441
+ await zcodeGateway.clearQuotaMark(account.id);
442
+ await withAccounts((list) => {
443
+ const cur = list.find((a) => a.id === account.id);
444
+ if (cur) cur.lastResult = { status: 'checked-in', message: `已领取:${outcome.planName}`, amount: 0, at: new Date().toISOString() };
445
+ });
446
+ return json(res, 200, outcome);
447
+ } catch (e) {
448
+ logger.warn('DAEMON', 'ZCode {name} 领取失败:{err}', { name: account.name || account.id, err: e.message });
449
+ return json(res, 502, { error: e.message, code: e.code, nextAt: e.nextAt });
450
+ }
451
+ }
452
+ if (p === '/api/zcode/captcha-config' && method === 'GET') {
453
+ try {
454
+ return json(res, 200, await fetchCaptchaConfig());
455
+ } catch (e) {
456
+ return json(res, 502, { error: e.message });
457
+ }
458
+ }
459
+ // ZCode 出口偏好:默认「直连优先、代理兜底」,可切「代理优先、直连兜底」;代理地址可面板配置(存 zcode-net.json,0600)
460
+ // 自动领取开启时附带 1 小时时限(autoClaimUntil),到期调度器自动关闭并停止轮询
461
+ if (p === '/api/zcode-gateway' && method === 'GET') {
462
+ return json(res, 200, await zcodeGateway.gatewayStatus());
463
+ }
464
+ if (p === '/api/zcode-gateway' && method === 'PUT') {
465
+ const body = await readBody(req).catch(() => ({}));
466
+ if (body?.enabled !== undefined) await zcodeGateway.setGatewayEnabled(body.enabled === true);
467
+ if (body?.lan !== undefined || body?.allow !== undefined) {
468
+ const patch = {};
469
+ if (body?.lan !== undefined) patch.zcodeGatewayLan = body.lan === true;
470
+ if (body?.allow !== undefined) {
471
+ if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
472
+ const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
473
+ if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
474
+ patch.zcodeGatewayAllow = cleaned;
475
+ }
476
+ await (await import('./store.js')).saveSettings(patch);
477
+ const lanNow = patch.zcodeGatewayLan ?? (await (await import('./store.js')).loadSettings()).zcodeGatewayLan === true;
478
+ const allowText = patch.zcodeGatewayAllow ? patch.zcodeGatewayAllow.join(', ') : '';
479
+ logger.info('DAEMON', 'ZCode 网关局域网:{state}{allow}(绑定改动重启后生效)', { state: lanNow ? '开' : '关(仅本机)', allow: allowText ? ',白名单:{list}' : '', list: allowText || '' });
480
+ }
481
+ return json(res, 200, await zcodeGateway.gatewayStatus());
482
+ }
483
+ if (p === '/api/i18n' && method === 'GET') {
484
+ const locale = getLocale();
485
+ return json(res, 200, { locale, dict: await loadDict(locale) });
486
+ }
487
+ // MiniMax 本地网关控制
488
+ if (p === '/api/minimax-gateway' && method === 'GET') {
489
+ return json(res, 200, await minimaxGateway.gatewayStatus());
490
+ }
491
+ if (p === '/api/minimax-gateway' && method === 'PUT') {
492
+ const body = await readBody(req).catch(() => ({}));
493
+ if (body?.enabled !== undefined) await minimaxGateway.setGatewayEnabled(body.enabled === true);
494
+ if (body?.lan !== undefined || body?.allow !== undefined) {
495
+ const patch = {};
496
+ if (body?.lan !== undefined) patch.minimaxGatewayLan = body.lan === true;
497
+ if (body?.allow !== undefined) {
498
+ if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
499
+ const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
500
+ if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
501
+ patch.minimaxGatewayAllow = cleaned;
502
+ }
503
+ await (await import('./store.js')).saveSettings(patch);
504
+ const lanNow = patch.minimaxGatewayLan ?? (await (await import('./store.js')).loadSettings()).minimaxGatewayLan === true;
505
+ const allowText = patch.minimaxGatewayAllow ? patch.minimaxGatewayAllow.join(', ') : '';
506
+ logger.info('DAEMON', 'MiniMax 网关局域网:{state}{allow}(绑定改动重启后生效)', { state: lanNow ? '开' : '关(仅本机)', allow: allowText ? ',白名单:{list}' : '', list: allowText || '' });
507
+ }
508
+ return json(res, 200, await minimaxGateway.gatewayStatus());
509
+ }
510
+ // Trae 本地网关控制
511
+ if (p === '/api/trae-gateway' && method === 'GET') {
512
+ return json(res, 200, await traeGateway.gatewayStatus());
513
+ }
514
+ if (p === '/api/trae-gateway' && method === 'PUT') {
515
+ const body = await readBody(req).catch(() => ({}));
516
+ if (body?.enabled !== undefined) await traeGateway.setGatewayEnabled(body.enabled === true);
517
+ if (body?.lan !== undefined || body?.allow !== undefined) {
518
+ const patch = {};
519
+ if (body?.lan !== undefined) patch.traeGatewayLan = body.lan === true;
520
+ if (body?.allow !== undefined) {
521
+ if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
522
+ const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
523
+ if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
524
+ patch.traeGatewayAllow = cleaned;
525
+ }
526
+ await (await import('./store.js')).saveSettings(patch);
527
+ const lanNow = patch.traeGatewayLan ?? (await (await import('./store.js')).loadSettings()).traeGatewayLan === true;
528
+ const allowText = patch.traeGatewayAllow ? patch.traeGatewayAllow.join(', ') : '';
529
+ logger.info('DAEMON', 'Trae 网关局域网:{state}{allow}(绑定改动重启后生效)', { state: lanNow ? '开' : '关(仅本机)', allow: allowText ? ',白名单:{list}' : '', list: allowText || '' });
530
+ }
531
+ return json(res, 200, await traeGateway.gatewayStatus());
532
+ }
533
+ if (p === '/api/zcode/net' && method === 'GET') {
534
+ const u = proxyUrl();
535
+ const masked = u ? (() => { try { const x = new URL(u); x.password = x.password ? '*'.repeat(4) : ''; return x.toString(); } catch { return '***'; } })() : null;
536
+ const ac = autoClaimEnabled();
537
+ return json(res, 200, {
538
+ proxyFirst: proxyFirst(), proxyUrlMasked: masked,
539
+ autoClaim: ac, autoClaimUntil: ac ? autoClaimUntil() : null,
540
+ claimIntervalMin: claimIntervalMin(), claimWindowMin: claimWindowMin(),
541
+ hasEnvProxy: Boolean(process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy),
542
+ });
543
+ }
544
+ if (p === '/api/zcode/net' && method === 'PUT') {
545
+ const body = await readBody(req).catch(() => ({}));
546
+ try {
547
+ if (body?.proxyFirst !== undefined) setProxyFirst(body.proxyFirst === true);
548
+ if (body?.proxyUrl !== undefined) setProxyUrl(body.proxyUrl);
549
+ // 自动领取计划(轮询周期 / 运行时长)先落盘,再按新配置启动
550
+ if (body?.claimIntervalMin !== undefined) setClaimIntervalMin(body.claimIntervalMin);
551
+ if (body?.claimWindowMin !== undefined) setClaimWindowMin(body.claimWindowMin);
552
+ if (body?.claimIntervalMin !== undefined || body?.claimWindowMin !== undefined) refreshZcodeScheduler();
553
+ if (body?.autoClaim !== undefined) {
554
+ if (body.autoClaim === true) enableZcodeAutoClaimWindow(); // 开 = 按「自动领取计划」启动(2 分钟后首轮)
555
+ else { setAutoClaimEnabled(false); refreshZcodeScheduler(); } // 关 = 立即停表
556
+ }
557
+ } catch (e) { return json(res, 400, { error: e.message }); }
558
+ const ac = autoClaimEnabled();
559
+ logger.info('DAEMON', 'ZCode 出口:{route},自动领取:{claim},代理 {proxy}', { route: proxyFirst() ? '代理优先' : '直连优先', claim: ac ? '开(每 {min} 分钟,{window})' : '关', min: claimIntervalMin(), window: claimWindowMin() > 0 ? '运行 {windowMin} 分钟' : '一直运行', windowMin: claimWindowMin(), proxy: proxyUrl() || '(环境变量/未设置)' });
560
+ return json(res, 200, {
561
+ proxyFirst: proxyFirst(), autoClaim: ac, autoClaimUntil: ac ? autoClaimUntil() : null,
562
+ claimIntervalMin: claimIntervalMin(), claimWindowMin: claimWindowMin(),
563
+ });
564
+ }
565
+
566
+ // 切换 WorkBuddy / ZCode 客户端当前登录账号
567
+ const switchMatch = p.match(/^\/api\/accounts\/([\w-]+)\/switch$/);
568
+ if (switchMatch && method === 'POST') {
569
+ const body = await readBody(req).catch(() => ({}));
570
+ const accounts = await loadAccounts();
571
+ const target = accounts.find((a) => a.id === switchMatch[1]);
572
+ if (!target) return json(res, 404, { error: '账号不存在' });
573
+ if (target.provider === 'zcode') {
574
+ // 防丢号:先把 ZCode 当前登录(凭据 + config.json + 设备 ID)同步 / 保存进账号库,再覆盖
575
+ const live = zcodeLiveAccount();
576
+ if (live && live.uid !== target.uid) {
577
+ await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 ZCode 当前登录失败:{err}', { err: e.message }));
578
+ }
579
+ try {
580
+ // 强制切换:先关掉运行中的 ZCode,否则它会把内存里的旧登录覆盖回文件,切换等于没切
581
+ let closedClient = false;
582
+ if (body?.force === true) {
583
+ const t = terminateZcode();
584
+ closedClient = t.closed === true;
585
+ if (closedClient) logger.info('DAEMON', '已关闭 ZCode 客户端(强制切换)', {});
586
+ else if (t.running) logger.warn('DAEMON', '未能完全结束 ZCode 进程,继续强制切换', {});
587
+ }
588
+ ensureVirtualDeviceMid(target);
589
+ const r = zcodeSwitchTo(target, { force: body?.force === true });
590
+ // 虚拟设备 ID 首次生成时需落盘
591
+ await withAccounts((list) => {
592
+ const cur = list.find((a) => a.id === target.id);
593
+ if (cur) cur.meta = { ...(cur.meta || {}), deviceMid: target.meta.deviceMid };
594
+ });
595
+ logger.info('DAEMON', r.alreadyActive ? 'ZCode 当前已是 {name}' : 'ZCode 已切换到 {name}', { name: target.name || target.id });
596
+ return json(res, 200, { ok: true, closedClient, ...r });
597
+ } catch (e) {
598
+ return json(res, e.zcodeRunning ? 409 : 400, { error: e.message, code: e.zcodeRunning ? 'ZCODE_RUNNING' : undefined });
599
+ }
600
+ }
601
+ if (target.provider === 'mirasim') {
602
+ // 防丢号:先把当前登录同步进账号库
603
+ try {
604
+ const live = await mirasimLiveAccount();
605
+ if (live && live.uid !== target.uid) {
606
+ await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 mirasim 当前登录失败:{err}', { err: e.message }));
607
+ }
608
+ } catch (e) {}
609
+ try {
610
+ let closedClient = false;
611
+ if (body?.force === true) {
612
+ const t = terminateMirasim();
613
+ closedClient = t.closed === true;
614
+ if (closedClient) logger.info('DAEMON', '已关闭 Mirasim 客户端(强制切换)', {});
615
+ }
616
+ const r = await mirasimSwitchTo(target, { force: body?.force === true });
617
+ logger.info('DAEMON', r.alreadyActive ? 'mirasim 当前已是 {name}' : 'mirasim 已切换到 {name}', { name: target.name || target.id });
618
+ return json(res, 200, { ok: true, closedClient, ...r });
619
+ } catch (e) {
620
+ return json(res, e.mirasimRunning ? 409 : 400, { error: e.message, code: e.mirasimRunning ? 'MIRASIM_RUNNING' : undefined });
621
+ }
622
+ }
623
+ if (target.provider === 'catpaw') {
624
+ // 防丢号:先把妙手当前登录同步进账号库
625
+ try {
626
+ const live = await catpawLiveAccount();
627
+ if (live && live.token !== target.token) {
628
+ await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步妙手当前登录失败:{err}', { err: e.message }));
629
+ }
630
+ } catch (e) {}
631
+ try {
632
+ let closedClient = false;
633
+ if (body?.force === true) {
634
+ const t = terminateCatpaw();
635
+ closedClient = t.closed === true;
636
+ if (closedClient) logger.info('DAEMON', '已关闭妙手客户端(强制切换)', {});
637
+ else if (t.running) logger.warn('DAEMON', '未能完全结束妙手进程,继续强制切换', {});
638
+ }
639
+ const r = await catpawSwitchTo(target, { force: body?.force === true });
640
+ logger.info('DAEMON', r.alreadyActive ? '妙手当前已是 {name}' : '妙手已切换到 {name}(重新打开客户端生效)', { name: target.name || target.id });
641
+ return json(res, 200, { ok: true, closedClient, ...r });
642
+ } catch (e) {
643
+ return json(res, e.catpawRunning ? 409 : 400, { error: e.message, code: e.catpawRunning ? 'CATPAW_RUNNING' : undefined });
644
+ }
645
+ }
646
+ if (target.provider === 'qoder' || target.provider === 'qoder-cn') {
647
+ // 防丢号:先把本机当前登录的最新 auth 快照回写账号库(客户端会静默刷新 token,旧快照可能已失效),再覆盖
648
+ try {
649
+ const lr = await readQoderAppAccounts();
650
+ const live = lr.accounts.find((c) => c.provider === target.provider);
651
+ if (live && live.user.id && String(live.user.id) !== String(target.uid)) {
652
+ await addAccount({
653
+ provider: live.provider, token: live.token, name: live.user.name || live.user.email,
654
+ uid: live.user.id, email: live.user.email, refreshToken: live.refreshToken, expiresAt: live.expiresAt,
655
+ source: 'local-app', meta: { qoderAuth: live.authJson, qoderAuthFile: live.file },
656
+ }, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 Qoder 当前登录失败:{err}', { err: e.message }));
657
+ }
658
+ } catch (e) { logger.warn('DAEMON', '读取 Qoder 当前登录失败:{err}', { err: e.message }); }
659
+ try {
660
+ // 强制切换:先关掉运行中的 Qoder,否则它退出时会把内存里的旧登录覆盖回文件
661
+ let closedClient = false;
662
+ if (body?.force === true) {
663
+ const t = terminateQoder();
664
+ closedClient = t.closed === true;
665
+ if (closedClient) logger.info('DAEMON', '已关闭 Qoder 客户端(强制切换)', {});
666
+ else if (t.running) logger.warn('DAEMON', '未能完全结束 Qoder 进程,继续强制切换', {});
667
+ }
668
+ const r = await qoderSwitchTo(target, { force: body?.force === true });
669
+ logger.info('DAEMON', r.alreadyActive ? 'Qoder 当前已是 {name}' : 'Qoder 已切换到 {name}(重新打开 Qoder 生效)', { name: target.name || target.id });
670
+ return json(res, 200, { ok: true, closedClient, ...r });
671
+ } catch (e) {
672
+ return json(res, e.qoderRunning ? 409 : 400, { error: e.message, code: e.qoderRunning ? 'QODER_RUNNING' : undefined });
673
+ }
674
+ }
675
+ if (target.provider === 'trae') {
676
+ // 防丢号:先把 Trae 当前登录同步进账号库并建快照,再覆盖成目标账号的登录态
677
+ try {
678
+ const live = await traeLiveAccount();
679
+ if (live && live.token !== target.token) {
680
+ await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 Trae 当前登录失败:{err}', { err: e.message }));
681
+ traeSnapshotLive();
682
+ }
683
+ } catch (e) {}
684
+ try {
685
+ // 退进程交给 switchTo:它要先确认目标有快照才会动手,避免「白关一次 Trae」
686
+ const r = await traeSwitchTo(target, { force: body?.force === true });
687
+ if (r.closedClient) logger.info('DAEMON', '已关闭 Trae 客户端(强制切换)', {});
688
+ logger.info('DAEMON', r.alreadyActive ? 'Trae 当前已是 {name}' : 'Trae 已切换到 {name}(重新打开客户端生效)', { name: target.name || target.id });
689
+ return json(res, 200, { ok: true, ...r });
690
+ } catch (e) {
691
+ return json(res, e.traeRunning ? 409 : 400, { error: e.message, code: e.traeRunning ? 'TRAE_RUNNING' : undefined });
692
+ }
693
+ }
694
+ // MiniMax 不做切换:客户端凭据只有一条路径派生的活动记录,且 refreshToken 轮换式一次性,
695
+ // CreditDaddy 与客户端共用必然互抢(invalid_grant),切号能力已按用户决定移除。
696
+ if (!target.provider.startsWith('workbuddy')) return json(res, 400, { error: '只有 WorkBuddy / ZCode / mirasim / 妙手 / Trae 账号支持切换' });
697
+ // 先把客户端当前会话的最新 token 收回账号库,避免被覆盖后丢失
698
+ const cur = readWorkbuddySessions().accounts.find((a) => a.current);
699
+ if (cur && cur.uid !== target.uid) {
700
+ const { file: _f, fileTime: _t, current: _c, ...rec } = cur;
701
+ await addAccount(rec, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 WorkBuddy 当前会话失败:{err}', { err: e.message }));
702
+ }
703
+ try {
704
+ const r = writeWorkbuddySession(target);
705
+ logger.info('DAEMON', 'WorkBuddy 已切换到 {name}', { name: target.name || target.id });
706
+ return json(res, 200, { ok: true, file: r.file, previousUid: r.previousUid });
707
+ } catch (e) {
708
+ return json(res, 400, { error: e.message });
709
+ }
710
+ }
711
+
712
+ // ── 设备码登录(Qoder) ──
713
+ if (p === '/api/auth/device/start' && method === 'POST') {
714
+ const body = await readBody(req).catch(() => ({}));
715
+ try {
716
+ const kind = LOGIN_KINDS.includes(body?.provider) ? body.provider : 'qoder';
717
+ const flow = await startDeviceFlow(kind);
718
+ return json(res, 200, flow);
719
+ } catch (e) { return json(res, 500, { error: e.message }); }
720
+ }
721
+ if (p === '/api/auth/device/poll' && method === 'POST') {
722
+ const body = await readBody(req).catch(() => ({}));
723
+ if (!body?.sessionId) return json(res, 400, { error: '缺少 sessionId' });
724
+ try {
725
+ return json(res, 200, await pollDeviceFlow(body.sessionId));
726
+ } catch (e) { return json(res, 500, { error: e.message }); }
727
+ }
728
+
729
+ // ── Qoder 网页会话(桌面版登录窗口关闭前抓取,用于逐资源包用量明细) ──
730
+ // Cookie 先探测归属(响应里的 user_id),只写到同 uid 的 qoder 账号上;探测失败视为会话无效。
731
+ if (p === '/api/auth/qoder-web-session' && method === 'POST') {
732
+ const body = await readBody(req).catch(() => ({}));
733
+ const cookie = String(body?.cookie || '').trim();
734
+ const kind = body?.kind === 'qoder-cn' ? 'qoder-cn' : 'qoder';
735
+ // 诊断:登录窗关闭时两个域都会上报;收到 0 条 Cookie 说明窗口内根本没建立该域的网站会话
736
+ const count = body?.diag?.cookieCount;
737
+ logger.info('DAEMON', 'Qoder 网页会话收割({kind}):{result}', { kind, result: typeof count === 'number' ? '{count} 条 Cookie' : '已上报', count });
738
+ if (!cookie || cookie.length > 16384) return json(res, 200, { ok: true, bound: 0, empty: true });
739
+ const { probeWebSession } = await import('./qoderClient.js');
740
+ const probe = await probeWebSession(kind, cookie);
741
+ if (!probe?.user_id) {
742
+ logger.warn('DAEMON', 'Qoder {edition}网页会话探测未通过(接口不认这份 Cookie),已丢弃', { edition: kind === 'qoder-cn' ? '国内版' : '国际版' });
743
+ return json(res, 400, { error: '网页会话无效(探测接口未通过)' });
744
+ }
745
+ const now = new Date().toISOString();
746
+ const bound = await withAccounts((list) => {
747
+ const hits = list.filter((a) => a.provider === kind && a.uid === probe.user_id);
748
+ for (const a of hits) {
749
+ a.meta = { ...(a.meta || {}), qoderWebSession: { cookie, capturedAt: now } };
750
+ a.updatedAt = now;
751
+ }
752
+ return hits.length;
753
+ });
754
+ if (!bound) {
755
+ // 登录窗口可能先于设备码入库关闭:暂存待绑定会话,addAccount 补全 uid 后自动挂上
756
+ await withSettings((settings) => {
757
+ settings.pendingQoderWebSessions = {
758
+ ...(settings.pendingQoderWebSessions || {}),
759
+ [probe.user_id]: { kind, cookie, capturedAt: now },
760
+ };
761
+ });
762
+ logger.info('DAEMON', '收到 Qoder 网页会话(uid {uid}...),暂无匹配账号,已暂存待绑定', { uid: probe.user_id.slice(0, 8) });
763
+ } else {
764
+ logger.info('DAEMON', '已绑定 Qoder 网页会话到 {n} 个账号(逐资源包明细可用)', { n: bound });
765
+ }
766
+ return json(res, 200, { ok: true, bound });
767
+ }
768
+
769
+ // ── 本机检测 / 凭据扫描 ──
770
+ if (p === '/api/local/detect' && method === 'GET') {
771
+ return json(res, 200, { apps: detectQoderApps(), workbuddyDir: workbuddyAuthDir(), zcode: detectZcode(), mirasim: detectMirasim(), catpaw: detectCatpaw(), trae: detectTrae(), minimax: detectMiniMax(), legacy: detectInstalls() });
772
+ }
773
+ if (p === '/api/local/scan' && method === 'POST') {
774
+ const existing = await loadAccounts();
775
+ const known = (provider, token, uid) => existing.some((a) => (!provider || a.provider === provider)
776
+ && (a.token === token || (uid && a.uid === uid)));
777
+ const candidates = [];
778
+ const errors = [];
779
+ const addRecord = (record, extra) => {
780
+ candidates.push({
781
+ id: putCandidate({ token: record.token, record }),
782
+ kind: 'app', provider: record.provider,
783
+ name: record.name, email: record.email, uid: record.uid, phone: record.meta?.phone ? record.meta.phone.slice(0, 3) + '****' + record.meta.phone.slice(-4) : null,
784
+ tokenMasked: record.token.slice(0, 5) + '...' + record.token.slice(-4),
785
+ expiresAt: record.expiresAt, hasRefreshToken: Boolean(record.refreshToken),
786
+ imported: known(record.provider, record.token, record.uid),
787
+ ...extra,
788
+ });
789
+ };
790
+ // 1) Qoder 客户端:解密 auth.v1.dat
791
+ const app = await readQoderAppAccounts();
792
+ errors.push(...app.errors);
793
+ for (const c of app.accounts) {
794
+ addRecord({
795
+ provider: c.provider, token: c.token, name: c.user.name || c.user.email, uid: c.user.id, email: c.user.email,
796
+ refreshToken: c.refreshToken, expiresAt: c.expiresAt, source: 'local-app',
797
+ // 存整份解密后的 auth.v1.dat:切换时要原样写回(里面除 token 还有客户端读的其它字段)
798
+ meta: { qoderAuth: c.authJson, qoderAuthFile: c.file },
799
+ }, { source: c.source });
800
+ }
801
+ // 2) WorkBuddy 客户端:当前会话 + 历史会话
802
+ const wb = readWorkbuddySessions();
803
+ errors.push(...wb.errors);
804
+ for (const c of wb.accounts) {
805
+ const { file: _f, fileTime: _t, current, ...record } = c;
806
+ addRecord({ ...record, source: current ? 'workbuddy-current' : 'workbuddy-history' }, { source: c.source, current });
807
+ }
808
+ // 3) ZCode 客户端:解密 ~/.zcode/v2/credentials.json 的当前登录
809
+ try {
810
+ const z = zcodeLiveAccount();
811
+ if (z) addRecord(z, { source: 'ZCode 当前登录', current: true });
812
+ } catch (e) { errors.push({ file: '~/.zcode/v2/credentials.json', error: e.message }); }
813
+ // 4) mirasim 客户端:解密 ~/.mirasim/setting.json 的当前登录
814
+ try {
815
+ const m = await mirasimLiveAccount();
816
+ if (m) addRecord(m, { source: 'mirasim 当前登录', current: true });
817
+ } catch (e) {
818
+ // MISSING_SECRET_KEY / DECRYPT_FAILED 的文案已在 mirasimLocal 里写清检查过的位置
819
+ errors.push({ file: '~/.mirasim/setting.json', error: e.message });
820
+ }
821
+ // 5) 妙手客户端:解密 %APPDATA%\catpaw-moon\catx-credential.json 的当前登录
822
+ try {
823
+ const cp = await catpawLiveAccount();
824
+ if (cp) addRecord(cp, { source: '妙手当前登录', current: true });
825
+ } catch (e) { errors.push({ file: 'catpaw-moon/catx-credential.json', error: e.message }); }
826
+ // 6) Trae 客户端:解密 <userData>\User\globalStorage\storage.json 的 tc 信封当前登录
827
+ try {
828
+ const tr = await traeLiveAccount();
829
+ if (tr) addRecord(tr, { source: 'Trae 当前登录', current: true });
830
+ } catch (e) { errors.push({ file: 'TRAE SOLO CN/User/globalStorage/storage.json', error: e.message }); }
831
+ // 7) MiniMax 客户端:读取 ~/.minimax 凭据当前登录
832
+ try {
833
+ const mm = await minimaxLiveAccount();
834
+ if (mm) addRecord(mm, { source: 'MiniMax 当前登录', current: true });
835
+ } catch (e) { errors.push({ file: '~/.minimax/auth/.../auth.json', error: e.message }); }
836
+ // 8) 旧版 VS Code 系 Qoder IDE / CLI:明文 token 扫描(归属需用户选择)
837
+ const det = detectInstalls();
838
+ const dirs = det.ideDataDirs.filter(d => d.exists).map(d => d.path);
839
+ if (det.cliDir.exists) dirs.push(det.cliDir.path);
840
+ const legacy = await scanLocalTokens(dirs);
841
+ for (const c of legacy.candidates) {
842
+ candidates.push({ ...c, provider: null, source: '旧版 Qoder IDE / CLI 文件', imported: known(null, peekCandidate(c.id)?.token) });
843
+ }
844
+ return json(res, 200, { candidates, errors, scanned: legacy.scanned, scannedDirs: [...dirs, wb.dir] });
845
+ }
846
+ if (p === '/api/local/import' && method === 'POST') {
847
+ const body = await readBody(req);
848
+ const cand = peekCandidate(String(body?.candidateId || ''));
849
+ if (!cand) return json(res, 404, { error: '候选不存在或已过期,请重新扫描' });
850
+ const record = cand.record || {
851
+ provider: body.provider === 'qoder-cn' ? 'qoder-cn' : 'qoder',
852
+ token: cand.token, source: 'local-scan',
853
+ };
854
+ if (record.provider?.startsWith('workbuddy') && record.refreshToken) {
855
+ try {
856
+ const fresh = await refreshWorkbuddyToken(record);
857
+ Object.assign(record, { token: fresh.token, refreshToken: fresh.refreshToken, expiresAt: fresh.expiresAt });
858
+ } catch (e) {
859
+ if (e.authInvalid) return json(res, 409, { error: e.message });
860
+ return json(res, 502, { error: `WorkBuddy 本机凭据校验失败:${e.message}` });
861
+ }
862
+ }
863
+ const { account, duplicate, updated } = await addAccount(
864
+ { ...record, name: body.name || record.name || null },
865
+ { trusted: Boolean(cand.record) },
866
+ );
867
+ if (duplicate && !updated) return json(res, 409, { error: '该账号已存在,信息已是最新' });
868
+ // Trae 的登录态是 15 项文件快照而非单个凭据文件,导入时就建一次快照,否则该账号不可切换
869
+ if (account.provider === 'trae') traeSnapshotLive();
870
+ logger.info('DAEMON', updated ? '已用本机凭据更新:{name}' : '从本机导入账号:{name}', { name: account.name || account.id });
871
+ return json(res, updated ? 200 : 201, { account: publicAccount(account), updated });
872
+ }
873
+
874
+ // ── 10Router 集成(虚拟 key:额度总览 + 用量同步) ──
875
+ if (p === '/api/tenrouter' && method === 'GET') {
876
+ return json(res, 200, await tenrouter.publicConfig());
877
+ }
878
+ if (p === '/api/tenrouter' && method === 'PUT') {
879
+ const body = await readBody(req).catch(() => ({}));
880
+ try {
881
+ await tenrouter.updateConfig({ endpoint: body?.endpoint, key: body?.key, adminPassword: body?.adminPassword, syncEnabled: body?.syncEnabled, sources: body?.sources });
882
+ return json(res, 200, await tenrouter.publicConfig());
883
+ } catch (e) { return json(res, 400, { error: e.message }); }
884
+ }
885
+ if (p === '/api/tenrouter' && method === 'DELETE') {
886
+ await tenrouter.updateConfig({ endpoint: '' });
887
+ return json(res, 200, await tenrouter.publicConfig());
888
+ }
889
+ if (p === '/api/tenrouter/test' && method === 'POST') {
890
+ const body = await readBody(req).catch(() => ({}));
891
+ let override;
892
+ try {
893
+ // 可以在保存前测试面板里填的新地址 / key;key 留空则用已保存的
894
+ if (body?.endpoint) override = { endpoint: tenrouter.normalizeEndpoint(body.endpoint) };
895
+ if (body?.key && String(body.key).trim()) override = { ...(override || {}), key: String(body.key).trim() };
896
+ } catch (e) { return json(res, 400, { ok: false, error: '地址格式不正确:' + e.message }); }
897
+ return json(res, 200, await tenrouter.testConnection(override));
898
+ }
899
+ if (p === '/api/tenrouter/quotas' && method === 'GET') {
900
+ try {
901
+ return json(res, 200, await tenrouter.fetchQuotas({ force: url.searchParams.get('force') === '1' }));
902
+ } catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 502, { error: e.message, code: e.code }); }
903
+ }
904
+ if (p === '/api/tenrouter/health' && method === 'GET') {
905
+ return json(res, 200, await tenrouter.fetchHealth());
906
+ }
907
+ if (p === '/api/tenrouter/sync' && method === 'POST') {
908
+ const body = await readBody(req).catch(() => ({}));
909
+ try {
910
+ return json(res, 200, await tenrouter.runUsageSync({ dryRun: body?.dryRun === true }));
911
+ } catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 500, { error: e.message, code: e.code }); }
912
+ }
913
+ if (p === '/api/tenrouter/sync-accounts' && method === 'POST') {
914
+ const body = await readBody(req).catch(() => ({}));
915
+ try {
916
+ // 请求里带了面板密码就先落盘(下次同步不必重填),否则用已保存的
917
+ if (typeof body?.adminPassword === 'string' && body.adminPassword) {
918
+ await tenrouter.updateConfig({ adminPassword: body.adminPassword });
919
+ }
920
+ const c = tenrouter.loadConfig();
921
+ return json(res, 200, await syncAccountsTo10r({ endpoint: c.endpoint, key: c.key, adminPassword: c.adminPassword, accounts: await loadAccounts() }));
922
+ } catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 500, { error: e.message, code: e.code }); }
923
+ }
924
+
925
+ // Qoder 设备身份组件(Linux / fnOS:从官方 qodercli 提取 UMID,国际版签到用)
926
+ if (p === '/api/qoder/umid' && method === 'GET') {
927
+ return json(res, 200, { ...umidInfo(), riskIdentity: riskIdentityAvailable(), riskSource: riskIdentitySource() });
928
+ }
929
+ if (p === '/api/qoder/umid/install' && method === 'POST') {
930
+ try {
931
+ await installUmid();
932
+ return json(res, 200, { ok: true, ...umidInfo(), riskIdentity: riskIdentityAvailable(), riskSource: riskIdentitySource() });
933
+ } catch (e) { return json(res, 500, { error: e.message }); }
934
+ }
935
+
936
+ // 日志(tag 过滤:?tag=MINIMAX-GW / ZCODE-GW 取网关调用日志,与运行日志分开展示)
937
+ if (p === '/api/logs' && method === 'GET') {
938
+ const tag = url.searchParams.get('tag') || null;
939
+ // ?history=1:读当日结构化归档(.jsonl),突破环形缓冲 300 条上限,按当前语言重渲染
940
+ if (url.searchParams.get('history') === '1') {
941
+ let lines = readArchiveJsonl({ limit: 2000 });
942
+ lines = tag ? lines.filter((l) => l.tag === tag) : lines.filter((l) => !(l.tag || '').endsWith('-GW'));
943
+ return json(res, 200, { logs: lines });
944
+ }
945
+ return json(res, 200, { logs: getLogs(200, tag) });
946
+ }
947
+
948
+ // 状态
949
+ if (p === '/api/status' && method === 'GET') {
950
+ const accounts = await loadAccounts();
951
+ const state = await loadState();
952
+ // Qoder 当前登录(面板轮询每 30s 打一次,DPAPI 解密结果缓存 1 分钟)
953
+ const qoder = await qoderCurrentInfo();
954
+ // 旧账号没赶上「整份 auth 快照」的导入:本机 Qoder 有登录且账号库里有缺快照的同 uid 账号时,顺手回填(可逆、只在缺失时发生)
955
+ const needBackfill = qoder.backfill.length
956
+ && accounts.some((a) => (a.provider === 'qoder' || a.provider === 'qoder-cn')
957
+ && a.uid && !a.meta?.qoderAuth
958
+ && qoder.backfill.some((b) => b.provider === a.provider && String(b.uid) === String(a.uid)));
959
+ if (needBackfill) {
960
+ await withAccounts((list) => {
961
+ for (const b of qoder.backfill) {
962
+ const cur = list.find((a) => a.provider === b.provider && a.uid && b.uid && String(a.uid) === String(b.uid) && !a.meta?.qoderAuth);
963
+ if (cur) cur.meta = { ...(cur.meta || {}), qoderAuth: b.authJson, qoderAuthFile: b.file };
964
+ }
965
+ }).catch((e) => logger.warn('DAEMON', '回填 Qoder 登录快照失败:{err}', { err: e.message }));
966
+ }
967
+ // 妙手凭据文件只存 token:当前登录按 token 对齐账号库,避免每轮状态都打网关查 uid
968
+ const cpToken = currentCatpawToken();
969
+ const mmToken = currentMiniMaxToken();
970
+ const mmRecKey = currentMiniMaxRecordKey();
971
+ // 1) token match;2) record-key match (token 轮换时仍稳定);3) uid-cache fallback
972
+ let mmLiveUid = null;
973
+ if (mmToken) {
974
+ mmLiveUid = accounts.find((a) => a.provider === 'minimax' && a.token === mmToken)?.uid;
975
+ }
976
+ if (!mmLiveUid && mmRecKey) {
977
+ // record-key 匹配可能命中多个(历史脏数据里本机导入与浏览器登录挂过同一个 key),
978
+ // 此时用 uid 缓存的当前值收敛,避免随机取到「不是客户端当前登录」的那个账号。
979
+ const byRec = accounts.filter((a) => a.provider === 'minimax' && a.meta?.authRecordKey === mmRecKey);
980
+ if (byRec.length === 1) mmLiveUid = byRec[0].uid;
981
+ else if (byRec.length > 1) {
982
+ const mmCachedUid = currentMiniMaxUid();
983
+ mmLiveUid = byRec.find((a) => a.uid === mmCachedUid)?.uid || null;
984
+ }
985
+ }
986
+ if (!mmLiveUid) mmLiveUid = currentMiniMaxUid();
987
+ const traeDet = detectTrae();
988
+ return json(res, 200, {
989
+ ok: true,
990
+ app: 'CreditDaddy',
991
+ version: APP_VERSION,
992
+ homepage: PROJECT_URL,
993
+ accountsCount: accounts.length,
994
+ dataDir: dataDir(),
995
+ today: dayKey(),
996
+ todayDone: state?.qoderDailyDone || {},
997
+ todayByProduct: {
998
+ qoder: dayKey(Date.now(), 'qoder'),
999
+ workbuddy: dayKey(Date.now(), 'workbuddy'),
1000
+ trae: dayKey(Date.now(), 'trae'),
1001
+ minimax: dayKey(Date.now(), 'minimax'),
1002
+ },
1003
+ scheduler: await getSchedulerInfo(),
1004
+ riskIdentity: riskIdentityAvailable(),
1005
+ riskSource: riskIdentitySource(),
1006
+ umid: umidInfo(),
1007
+ platform: process.platform,
1008
+ workbuddyCurrentUid: currentWorkbuddyUid(),
1009
+ qoderCurrent: qoder.uids,
1010
+ qoderClient: { installed: qoder.apps.some((x) => x.installed), signedIn: qoder.apps.some((x) => x.signedIn), running: qoderRunning() },
1011
+ zcodeCurrentUid: currentZcodeUid(),
1012
+ zcodeCurrentIdentity: currentZcodeIdentity(),
1013
+ zcodeClient: (() => { const d = detectZcode(); return { installed: d.exists, signedIn: d.signedIn, running: zcodeRunning() }; })(),
1014
+ mirasimCurrentUid: currentMirasimUid(),
1015
+ mirasimClient: (() => { const d = detectMirasim(); return { installed: d.clientInstalled, signedIn: d.signedIn, running: d.running }; })(),
1016
+ catpawCurrentUid: (cpToken && accounts.find((a) => a.provider === 'catpaw' && a.token === cpToken)?.uid) || null,
1017
+ catpawClient: (() => { const d = detectCatpaw(); return { installed: d.clientInstalled, signedIn: d.signedIn, running: d.running }; })(),
1018
+ traeCurrentUid: traeDet.uid,
1019
+ traeClient: { installed: traeDet.clientInstalled, signedIn: traeDet.signedIn, running: traeDet.running },
1020
+ minimaxCurrentUid: mmLiveUid,
1021
+ minimaxClient: (() => { const d = detectMiniMax(); return { installed: d.installed, signedIn: d.signedIn, running: d.running }; })(),
1022
+ keyRequired: panelKeyRequired(),
1023
+ });
1024
+ }
1025
+
1026
+ // Panel 设置:GET 读取(只回状态不回显密码),PUT 设置 / 修改 / 关闭访问密码
1027
+ if (p === '/api/settings' && method === 'GET') {
1028
+ return json(res, 200, { panelKeyEnabled: panelKeyRequired(), locale: getLocale(), workbuddyActivity: (await loadSettings()).workbuddyActivity !== false });
1029
+ }
1030
+ if (p === '/api/settings' && method === 'PUT') {
1031
+ const body = await readBody(req).catch(() => ({}));
1032
+ if (body?.locale !== undefined) {
1033
+ const locale = normalizeLocale(body.locale);
1034
+ setLocale(locale);
1035
+ await saveSettings({ locale });
1036
+ }
1037
+ // 全局自动签到 开关 / 频率(面板「设置 → 通用」)
1038
+ if (typeof body?.autoCheckin === 'boolean') await saveSettings({ autoCheckin: body.autoCheckin });
1039
+ if (body?.checkinIntervalMin !== undefined) {
1040
+ const min = Math.max(15, Math.min(1440, Number(body.checkinIntervalMin) || 120));
1041
+ await saveSettings({ checkinIntervalMin: min });
1042
+ }
1043
+ if (body?.autoCheckin !== undefined || body?.checkinIntervalMin !== undefined) {
1044
+ await resyncAutoCheckinScheduler();
1045
+ return json(res, 200, { ok: true });
1046
+ }
1047
+ // WB 国际版活跃请求总开关(面板「设置 → 通用」)
1048
+ if (typeof body?.workbuddyActivity === 'boolean') {
1049
+ await saveSettings({ workbuddyActivity: body.workbuddyActivity });
1050
+ return json(res, 200, { ok: true });
1051
+ }
1052
+ // 只有携带密码字段才走密码分支:纯语言/其它设置的请求此前会掉进
1053
+ // 「请提供新密码」400,面板 api() 抛错导致语言切换点了不刷新(i18n 切换失效根因)
1054
+ if (body?.panelKey !== undefined || body?.disable === true) return await handleSettingsPut(res, body);
1055
+ if (body?.locale !== undefined) return json(res, 200, { ok: true, panelKeyEnabled: panelKeyRequired(), locale: getLocale() });
1056
+ return json(res, 400, { error: '请求未携带任何设置字段' });
1057
+ }
1058
+
1059
+ // 导出(加密口令必填——账号含 token,不允许再导出明文文件)
1060
+ if (p === '/api/export' && method === 'POST') {
1061
+ const body = await readBody(req).catch(() => ({}));
1062
+ const provider = PROVIDERS.includes(body?.provider) ? body.provider : undefined;
1063
+ const product = PRODUCT_IDS.includes(body?.product) ? body.product : undefined;
1064
+ const password = typeof body?.password === 'string' ? body.password : '';
1065
+ // 日志只记范围与条数——口令、token 一概不落
1066
+ const scope = provider ? (PROVIDER_LABEL[provider] || provider) : (product ? `${product}(全部版本)` : '全部账号');
1067
+ if (password.length < 4) {
1068
+ logger.warn('DAEMON', '导出被拒({scope}):未设置加密口令', { scope });
1069
+ return json(res, 400, { error: '导出必须设置加密口令(至少 4 位)', code: 'PASSWORD_REQUIRED' });
1070
+ }
1071
+ try {
1072
+ const accounts = await loadAccounts();
1073
+ const blob = exportAccounts(accounts, { password, provider, product });
1074
+ // 信封是密文、不带条数,用同参数重算一遍过滤结果记进日志
1075
+ logger.info('DAEMON', '导出账号({scope}):{n} 个(密文口令导出)', { scope, n: buildExportPayload(accounts, { provider, product }).accounts.length });
1076
+ return json(res, 200, blob);
1077
+ } catch (e) {
1078
+ if (e instanceof TransferError) {
1079
+ logger.warn('DAEMON', '导出失败({scope}):{code}', { scope, code: e.code });
1080
+ return json(res, 400, { error: e.message, code: e.code });
1081
+ }
1082
+ throw e;
1083
+ }
1084
+ }
1085
+
1086
+ // 导入
1087
+ if (p === '/api/import' && method === 'POST') {
1088
+ const body = await readBody(req);
1089
+ // 新格式 {data, password};兼容直接 POST 文件内容
1090
+ const data = body && typeof body === 'object' && !Array.isArray(body) && 'data' in body ? body.data : body;
1091
+ let parsed;
1092
+ try {
1093
+ parsed = parseImport(data, { password: body?.password });
1094
+ } catch (e) {
1095
+ if (e instanceof TransferError) {
1096
+ logger.warn('DAEMON', '导入失败:{code}({message})', { code: e.code, message: e.message });
1097
+ return json(res, 400, { error: e.message, code: e.code });
1098
+ }
1099
+ throw e;
1100
+ }
1101
+ const r = await importAccounts(parsed.accounts);
1102
+ const skipped = r.skipped + parsed.skipped;
1103
+ logger.info('DAEMON', '导入完成({source}):新增 {added},续期 {updated},跳过 {skipped}', { source: parsed.source, added: r.added, updated: r.updated, skipped });
1104
+ return json(res, 200, { added: r.added, updated: r.updated, skipped, source: parsed.source });
1105
+ }
1106
+
1107
+ // 重启服务(桌面客户端或 CLI)
1108
+ if (p === '/api/system/restart' && method === 'POST') {
1109
+ json(res, 200, { ok: true, message: '正在重启服务…' });
1110
+ setTimeout(async () => {
1111
+ try {
1112
+ if (restartHandler) {
1113
+ await restartHandler();
1114
+ return;
1115
+ }
1116
+ // Node CLI 环境兜底重启:派生自身并退出当前进程
1117
+ const { spawn } = await import('node:child_process');
1118
+ spawn(process.execPath, process.argv.slice(1), {
1119
+ detached: true,
1120
+ stdio: 'inherit',
1121
+ env: process.env,
1122
+ }).unref();
1123
+ process.exit(0);
1124
+ } catch (err) {
1125
+ logger.error('DAEMON', '自动重启失败: {err}', { err: err?.message || err });
1126
+ }
1127
+ }, 400);
1128
+ return;
1129
+ }
1130
+
1131
+ return json(res, 404, { error: '未知接口' });
1132
+ }
1133
+
1134
+ let restartHandler = null;
1135
+ /** 设置外部重启处理器(桌面版 Electron 注入 app.relaunch / app.exit) */
1136
+ export function setRestartHandler(fn) { restartHandler = typeof fn === 'function' ? fn : null; }
1137
+
1138
+ /** 当前生效的面板访问密码(同进程宿主——桌面壳——调用本机 API 时带上 x-qd-key 头) */
1139
+ export function getPanelKey() { return PANEL_KEY; }
1140
+ export function getSessionKey() { return PANEL_SESSION; }
1141
+ export function verifyPanelPassword(input) {
1142
+ if (PANEL_KEY_MODE === 'hash') return scryptMatches(String(input), PANEL_KEY_HASH);
1143
+ if (PANEL_KEY_MODE === 'plain') return keyMatches(String(input));
1144
+ return false;
1145
+ }
1146
+
1147
+ /** 出口代理地址(面板「ZCode 网络出口」里存的,环境变量不算——登录窗显式设置用) */
1148
+ export function getLoginProxyUrl() { return proxyUrl() || null; }
1149
+
1150
+ export async function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
1151
+ await initPanelKey();
1152
+ // 跨进程单实例守护(P0-3 层):daemon.json 记录 pid+port,端口探活信带——两个实例
1153
+ // 共用同一 dataDir 时 accounts/settings/state.json 的后写会覆盖前写(进程内串行队列
1154
+ // 不跨进程)。发现活实例直接拒启,不要悄悄抬到下一个端口共存。
1155
+ {
1156
+ const { statusRuntime } = await import('./bgdaemon.js');
1157
+ const cur = await statusRuntime().catch(() => null);
1158
+ if (cur && cur.running) {
1159
+ logger.warn('DAEMON', 'CreditDaddy 守护进程已在运行(pid {pid},{host}:{port})—— 拒绝再起实例共用同一数据目录', { pid: cur.pid, host: cur.host, port: cur.port });
1160
+ const err = new Error('CreditDaddy 守护进程已在运行(pid ' + cur.pid + ',' + (cur.host || '127.0.0.1') + ':' + cur.port + '),如需重启请先停止旧实例(CLI 可用 creditdaddy stop)');
1161
+ err.code = 'ALREADY_RUNNING';
1162
+ throw err;
1163
+ }
1164
+ }
1165
+ // 一次性清理:1.3.3 曾短暂支持 minimax-intl(MiniMax 国际版),实测其 API 侧对国内出口
1166
+ // 全链路 401(区域识别无法绕过),双版本策略已撤回——存量国际版账号移除,避免签到 /
1167
+ // 额度轮询对未知 provider 反复报错。仅在真的命中存量时落盘。
1168
+ {
1169
+ const list = await loadAccounts();
1170
+ const dead = list.filter((a) => a.provider === 'minimax-intl');
1171
+ if (dead.length) {
1172
+ await withAccounts((l) => { l.splice(0, l.length, ...l.filter((a) => a.provider !== 'minimax-intl')); });
1173
+ logger.info('DAEMON', '已移除 {count} 个 MiniMax 国际版账号(该渠道已下线,额度接口对国内网络不可用)', { count: dead.length });
1174
+ }
1175
+ }
1176
+ let panelHtml = '';
1177
+ fs.readFile(PANEL_FILE, 'utf8').then((h) => { panelHtml = h; }).catch(() => {});
1178
+
1179
+ const server = http.createServer(async (req, res) => {
1180
+ const url = new URL(req.url, 'http://localhost');
1181
+ try {
1182
+ const denied = rejectForeignRequest(req.headers, host);
1183
+ if (denied) {
1184
+ logger.warn('DAEMON', '拒绝请求 {method} {path}:{denied}(Host={host} Origin={origin})', { method: req.method, path: url.pathname, denied, host: req.headers.host || '', origin: req.headers.origin || '' });
1185
+ return json(res, 403, { error: denied });
1186
+ }
1187
+ // ZCode 免费额度网关(数据面,先于面板路由;仅本机回路,见 zcodeGateway.js)。
1188
+ // 规范路径带品牌段 /gateway/zcode/v1/messages,与 minimax/trae 网关命名一致,也是
1189
+ // gatewayStatus() 展示的 endpoint。/gateway/v1/messages 与 /v1/messages 为旧别名,
1190
+ // 保留向后兼容:/v1/messages 与 zcode-api 端点同形,10router 的 zcode-free 供应商
1191
+ // 只需换 host:port 即可在 CreditDaddy 网关与 zcode-api 之间切换。
1192
+ if (
1193
+ url.pathname === '/gateway/zcode/v1/messages'
1194
+ || url.pathname === '/gateway/v1/messages'
1195
+ || url.pathname === '/v1/messages'
1196
+ ) {
1197
+ return await zcodeGateway.handleGateway(req, res);
1198
+ }
1199
+ // MiniMax Code 本地 Anthropic 兼容网关
1200
+ if (url.pathname === '/gateway/minimax/v1/messages') {
1201
+ return await minimaxGateway.handleGateway(req, res);
1202
+ }
1203
+ // Trae SOLO 本地 Anthropic 兼容网关
1204
+ if (url.pathname === '/gateway/trae/v1/messages') {
1205
+ return await traeGateway.handleGateway(req, res);
1206
+ }
1207
+ if (url.pathname.startsWith('/api/')) {
1208
+ return await handleApi(req, res, url);
1209
+ }
1210
+ // Web 面板(未设密码时同 API 口径 fail-closed)
1211
+ if (url.pathname === '/' || url.pathname === '/index.html') {
1212
+ if (!panelKeyRequired()) {
1213
+ const remote = (req.socket && req.socket.remoteAddress) || '';
1214
+ if (!isLoopbackSocketAddr(remote)) {
1215
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
1216
+ return res.end('CreditDaddy:未设置面板访问密码时,面板仅允许本机访问——请在本机面板「设置 → 通用 → 面板访问密码」设置密码后再从局域网访问');
1217
+ }
1218
+ }
1219
+ if (!panelHtml) { try { panelHtml = await fs.readFile(PANEL_FILE, 'utf8'); } catch {} }
1220
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1221
+ return res.end(panelHtml || '<h1>CreditDaddy</h1><p>panel.html 缺失</p>');
1222
+ }
1223
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
1224
+ res.end('Not Found');
1225
+ } catch (err) {
1226
+ const status = err instanceof HttpError ? err.status : 500;
1227
+ if (status >= 500) logger.error('DAEMON', '请求处理失败:{err}', { err: err?.message || err });
1228
+ if (!res.headersSent) json(res, status, { error: err?.message || '内部错误' });
1229
+ }
1230
+ });
1231
+
1232
+ return new Promise((resolve, reject) => {
1233
+ let attempts = 0;
1234
+ const bind = (p) => {
1235
+ // 失败重试时必须摘掉上一次的 listening 监听,否则成功后会重复触发
1236
+ const onError = (err) => {
1237
+ server.off('listening', onListening);
1238
+ if (err && err.code === 'EADDRINUSE' && attempts < 10) {
1239
+ attempts += 1;
1240
+ logger.warn('DAEMON', '端口 {port} 被占用,改试 {next}', { port: p, next: p + 1 });
1241
+ server.close();
1242
+ setTimeout(() => bind(p + 1), 300);
1243
+ } else {
1244
+ // 兜底失败必须 reject:否则这个 promise 永远挂着,调用方的 startScheduler() 不会跑,
1245
+ // 进程既不监听也不退出,只在日志里留一行,看起来像「启动了但没反应」
1246
+ const msg = err?.message || String(err);
1247
+ logger.error('DAEMON', '监听失败:{msg}', { msg });
1248
+ reject(new Error(`无法监听 ${host}:${p}:${msg}`));
1249
+ }
1250
+ };
1251
+ const onListening = () => {
1252
+ server.off('error', onError);
1253
+ const bound = server.address().port;
1254
+ logger.info('DAEMON', 'CreditDaddy 守护进程已启动:http://{host}:{port}', { host, port: bound });
1255
+ if ((host === '0.0.0.0' || host === '::') && !panelKeyRequired()) {
1256
+ logger.warn('DAEMON', '监听 0.0.0.0 且未设置 CREDITDADDY_PASSWORD —— 局域网内任何人可访问账号 API,建议设置访问密钥', {});
1257
+ }
1258
+ resolve({ server, port: bound });
1259
+ };
1260
+ server.once('error', onError);
1261
+ server.once('listening', onListening);
1262
+ server.listen(p, host);
1263
+ };
1264
+ bind(Number(port) || DEFAULT_PORT);
1265
+ });
1266
+ }