creditdaddy 0.8.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/LICENSE +21 -0
- package/README.md +224 -0
- package/bin/creditdaddy.js +187 -0
- package/package.json +28 -0
- package/src/accounts.js +143 -0
- package/src/authDevice.js +205 -0
- package/src/checkin.js +200 -0
- package/src/constants.js +79 -0
- package/src/daemon.js +585 -0
- package/src/localDetect.js +147 -0
- package/src/logger.js +27 -0
- package/src/panel.html +1785 -0
- package/src/providers.js +75 -0
- package/src/qoderApp.js +330 -0
- package/src/qoderClient.js +199 -0
- package/src/qoderUmid.js +177 -0
- package/src/store.js +187 -0
- package/src/tenrouter.js +221 -0
- package/src/transfer.js +141 -0
- package/src/usageSync.js +319 -0
- package/src/workbuddyAuth.js +126 -0
- package/src/workbuddyClient.js +257 -0
- package/src/workbuddyLocal.js +133 -0
- package/src/zcodeAuth.js +131 -0
- package/src/zcodeClient.js +495 -0
- package/src/zcodeLocal.js +217 -0
- package/src/zcrypto.js +160 -0
package/src/daemon.js
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
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}
|
|
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
|
+
* GET /api/qoder/umid Qoder 设备身份组件状态(Linux / fnOS)
|
|
32
|
+
* POST /api/qoder/umid/install 下载官方 qodercli 并提取设备身份组件
|
|
33
|
+
* GET /api/logs 最近日志
|
|
34
|
+
* GET /api/status 守护进程状态
|
|
35
|
+
* POST /api/export 导出账号 {password?, provider?}(有口令 → 10router 兼容加密文件)
|
|
36
|
+
* POST /api/import 导入账号 {data, password?}(CreditDaddy / 10router 导出文件)
|
|
37
|
+
* GET / Web 面板
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import http from 'node:http';
|
|
41
|
+
import crypto from 'node:crypto';
|
|
42
|
+
import fs from 'node:fs/promises';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import { fileURLToPath } from 'node:url';
|
|
45
|
+
import { logger, getLogs } from './logger.js';
|
|
46
|
+
import {
|
|
47
|
+
loadAccounts, loadState, withAccounts, publicAccount, dataDir,
|
|
48
|
+
} from './store.js';
|
|
49
|
+
import { addAccount, importAccounts, refreshContext } from './accounts.js';
|
|
50
|
+
import { productImpl } from './providers.js';
|
|
51
|
+
import { readWorkbuddySessions, writeWorkbuddySession, workbuddyAuthDir, currentWorkbuddyUid } from './workbuddyLocal.js';
|
|
52
|
+
import { liveToAccount as zcodeLiveAccount, switchTo as zcodeSwitchTo, currentZcodeUid, currentZcodeIdentity, detectZcode, ensureVirtualDeviceMid, terminateZcode } from './zcodeLocal.js';
|
|
53
|
+
import { fetchClaimPlans, claimPlan, fetchCaptchaConfig, proxyFirst, setProxyFirst } from './zcodeClient.js';
|
|
54
|
+
import { exportAccounts, parseImport, TransferError } from './transfer.js';
|
|
55
|
+
import { runCheckinTick, getSchedulerInfo, dayKey } from './checkin.js';
|
|
56
|
+
import { detectQoderApps, readQoderAppAccounts, riskIdentityAvailable, riskIdentitySource } from './qoderApp.js';
|
|
57
|
+
import { umidInfo, installUmid } from './qoderUmid.js';
|
|
58
|
+
import * as tenrouter from './tenrouter.js';
|
|
59
|
+
import { startDeviceFlow, pollDeviceFlow, LOGIN_KINDS } from './authDevice.js';
|
|
60
|
+
import { detectInstalls, scanLocalTokens, putCandidate, takeCandidate } from './localDetect.js';
|
|
61
|
+
import { PROVIDER_LABEL, APP_VERSION, PROVIDERS, PROJECT_URL } from './constants.js';
|
|
62
|
+
|
|
63
|
+
const PRODUCT_IDS = ['qoder', 'workbuddy', 'zcode'];
|
|
64
|
+
|
|
65
|
+
/** 可选访问密钥:设置 CREDITDADDY_PASSWORD 后,所有 /api/* 需要 x-qd-key 头(或 ?key=)。
|
|
66
|
+
* fnOS/NAS 部署监听 0.0.0.0 时由 cmd/main 自动生成并注入。 */
|
|
67
|
+
const PANEL_KEY = process.env.CREDITDADDY_PASSWORD || process.env.QODERDADDY_PASSWORD || '';
|
|
68
|
+
|
|
69
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
70
|
+
const PANEL_FILE = path.join(__dirname, 'panel.html');
|
|
71
|
+
const DEFAULT_PORT = 47860;
|
|
72
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
73
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
74
|
+
|
|
75
|
+
class HttpError extends Error {
|
|
76
|
+
constructor(status, message) { super(message); this.status = status; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function json(res, code, obj) {
|
|
80
|
+
const body = JSON.stringify(obj);
|
|
81
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
82
|
+
res.end(body);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readBody(req) {
|
|
86
|
+
const chunks = [];
|
|
87
|
+
let size = 0;
|
|
88
|
+
for await (const c of req) {
|
|
89
|
+
size += c.length;
|
|
90
|
+
if (size > MAX_BODY_BYTES) throw new HttpError(413, '请求体过大');
|
|
91
|
+
chunks.push(c);
|
|
92
|
+
}
|
|
93
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
94
|
+
try { return raw ? JSON.parse(raw) : {}; } catch { throw new HttpError(400, '请求体不是合法 JSON'); }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 常量时间比较,避免通过响应时间逐字符猜出密钥 */
|
|
98
|
+
function keyMatches(given) {
|
|
99
|
+
const a = Buffer.from(String(given));
|
|
100
|
+
const b = Buffer.from(PANEL_KEY);
|
|
101
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hostnameOf(hostHeader) {
|
|
105
|
+
try { return new URL(`http://${hostHeader}`).hostname; } catch { return ''; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 浏览器侧防护,返回拒绝原因(null = 放行):
|
|
110
|
+
* - 监听回环地址时,Host 必须是 127.0.0.1 / localhost / ::1(挡住 DNS 重绑定读取 token)
|
|
111
|
+
* - 带 Origin 的请求必须与 Host 同源(挡住任意网页跨站 POST 添加/导入账号、触发签到)
|
|
112
|
+
* 非浏览器客户端(curl、CLI、托盘菜单)不带 Origin,不受影响。
|
|
113
|
+
*/
|
|
114
|
+
export function rejectForeignRequest(headers, bindHost) {
|
|
115
|
+
const host = headers.host || '';
|
|
116
|
+
if (LOOPBACK_HOSTS.has(bindHost) && !LOOPBACK_HOSTS.has(hostnameOf(host))) {
|
|
117
|
+
return 'Host 不被允许';
|
|
118
|
+
}
|
|
119
|
+
const origin = headers.origin;
|
|
120
|
+
if (origin) {
|
|
121
|
+
let originHost = '';
|
|
122
|
+
try { originHost = new URL(origin).host; } catch {}
|
|
123
|
+
if (originHost !== host) return '跨站请求被拒绝';
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function handleApi(req, res, url) {
|
|
129
|
+
if (PANEL_KEY) {
|
|
130
|
+
const key = req.headers['x-qd-key'] || url.searchParams.get('key') || '';
|
|
131
|
+
if (!keyMatches(key)) {
|
|
132
|
+
return json(res, 401, { error: '需要访问密钥(x-qd-key 头或 ?key= 参数)', code: 'PANEL_KEY' });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const p = url.pathname;
|
|
136
|
+
const method = req.method;
|
|
137
|
+
|
|
138
|
+
// 账号列表
|
|
139
|
+
if (p === '/api/accounts' && method === 'GET') {
|
|
140
|
+
const accounts = await loadAccounts();
|
|
141
|
+
return json(res, 200, {
|
|
142
|
+
accounts: accounts.map(publicAccount),
|
|
143
|
+
providers: PROVIDER_LABEL,
|
|
144
|
+
dataDir: dataDir(),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 添加账号(自动拉取 userinfo 补全名字)
|
|
149
|
+
if (p === '/api/accounts' && method === 'POST') {
|
|
150
|
+
const body = await readBody(req);
|
|
151
|
+
let result;
|
|
152
|
+
try {
|
|
153
|
+
result = await addAccount({ name: body.name, provider: body.provider, token: body.token });
|
|
154
|
+
} catch (e) { return json(res, 400, { error: e.message }); }
|
|
155
|
+
const { account, duplicate } = result;
|
|
156
|
+
if (duplicate) return json(res, 409, { error: `该账号已存在(${account.name || account.id})` });
|
|
157
|
+
if (account.verified === false) {
|
|
158
|
+
logger.warn('DAEMON', `账号 ${account.name || account.id} 校验失败:${account.verifyError}`);
|
|
159
|
+
}
|
|
160
|
+
logger.info('DAEMON', `添加账号 ${account.name || account.id}(${PROVIDER_LABEL[account.provider]})`);
|
|
161
|
+
return json(res, 201, { account: publicAccount(account) });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 修改备注名
|
|
165
|
+
const idMatch = p.match(/^\/api\/accounts\/([\w-]+)$/);
|
|
166
|
+
if (idMatch && method === 'PATCH') {
|
|
167
|
+
const body = await readBody(req);
|
|
168
|
+
const name = String(body?.name || '').trim().slice(0, 64) || null;
|
|
169
|
+
const found = await withAccounts((accounts) => {
|
|
170
|
+
const a = accounts.find((x) => x.id === idMatch[1]);
|
|
171
|
+
if (a) a.name = name;
|
|
172
|
+
return a || null;
|
|
173
|
+
});
|
|
174
|
+
if (!found) return json(res, 404, { error: '账号不存在' });
|
|
175
|
+
return json(res, 200, { account: publicAccount(found) });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 删除账号
|
|
179
|
+
const delMatch = idMatch;
|
|
180
|
+
if (delMatch && method === 'DELETE') {
|
|
181
|
+
const removed = await withAccounts((accounts) => {
|
|
182
|
+
const i = accounts.findIndex((a) => a.id === delMatch[1]);
|
|
183
|
+
return i < 0 ? null : accounts.splice(i, 1)[0];
|
|
184
|
+
});
|
|
185
|
+
if (!removed) return json(res, 404, { error: '账号不存在' });
|
|
186
|
+
logger.info('DAEMON', `删除账号 ${removed.name || removed.id}`);
|
|
187
|
+
return json(res, 200, { ok: true });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 单账号签到
|
|
191
|
+
const checkinMatch = p.match(/^\/api\/accounts\/([\w-]+)\/checkin$/);
|
|
192
|
+
if (checkinMatch && method === 'POST') {
|
|
193
|
+
const { results, summary } = await runCheckinTick({ onlyAccountId: checkinMatch[1], skipIfCheckedToday: false });
|
|
194
|
+
return json(res, 200, { results, summary });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 全部签到(默认跳过今日已签;传 skipIfCheckedToday:false 强制全部重签)
|
|
198
|
+
if (p === '/api/checkin' && method === 'POST') {
|
|
199
|
+
const body = await readBody(req).catch(() => ({}));
|
|
200
|
+
const { results, summary } = await runCheckinTick({
|
|
201
|
+
provider: body?.provider || undefined,
|
|
202
|
+
product: body?.product || undefined,
|
|
203
|
+
skipIfCheckedToday: body?.skipIfCheckedToday !== false,
|
|
204
|
+
});
|
|
205
|
+
return json(res, 200, { results, summary });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 积分查询(统一结构:total / used / remaining / parts[])
|
|
209
|
+
const quotaMatch = p.match(/^\/api\/accounts\/([\w-]+)\/quota$/);
|
|
210
|
+
if (quotaMatch && method === 'GET') {
|
|
211
|
+
const accounts = await loadAccounts();
|
|
212
|
+
const account = accounts.find((a) => a.id === quotaMatch[1]);
|
|
213
|
+
if (!account) return json(res, 404, { error: '账号不存在' });
|
|
214
|
+
try {
|
|
215
|
+
const ctx = refreshContext(account, (m) => logger.info('QUOTA', `${account.name || account.id}:${m}`));
|
|
216
|
+
return json(res, 200, { quota: await productImpl(account.provider).quota(account, ctx) });
|
|
217
|
+
} catch (e) {
|
|
218
|
+
return json(res, 502, { error: e.message });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ZCode 活动领取
|
|
223
|
+
const zcodePlansMatch = p.match(/^\/api\/accounts\/([\w-]+)\/zcode\/plans$/);
|
|
224
|
+
if (zcodePlansMatch && method === 'GET') {
|
|
225
|
+
const accounts = await loadAccounts();
|
|
226
|
+
const account = accounts.find((a) => a.id === zcodePlansMatch[1]);
|
|
227
|
+
if (!account) return json(res, 404, { error: '账号不存在' });
|
|
228
|
+
if (account.provider !== 'zcode') return json(res, 400, { error: '只有 ZCode 账号支持活动领取' });
|
|
229
|
+
try {
|
|
230
|
+
return json(res, 200, await fetchClaimPlans(account));
|
|
231
|
+
} catch (e) {
|
|
232
|
+
return json(res, 502, { error: e.message });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const zcodeClaimMatch = p.match(/^\/api\/accounts\/([\w-]+)\/zcode\/claim$/);
|
|
236
|
+
if (zcodeClaimMatch && method === 'POST') {
|
|
237
|
+
const body = await readBody(req).catch(() => ({}));
|
|
238
|
+
if (!body?.planId || typeof body.planId !== 'string') return json(res, 400, { error: '缺少 planId' });
|
|
239
|
+
const accounts = await loadAccounts();
|
|
240
|
+
const account = accounts.find((a) => a.id === zcodeClaimMatch[1]);
|
|
241
|
+
if (!account) return json(res, 404, { error: '账号不存在' });
|
|
242
|
+
if (account.provider !== 'zcode') return json(res, 400, { error: '只有 ZCode 账号支持活动领取' });
|
|
243
|
+
try {
|
|
244
|
+
const outcome = await claimPlan(account, body.planId.slice(0, 128), {
|
|
245
|
+
captchaParam: typeof body.captchaParam === 'string' ? body.captchaParam : '',
|
|
246
|
+
region: typeof body.region === 'string' ? body.region : '',
|
|
247
|
+
});
|
|
248
|
+
logger.info('DAEMON', `ZCode ${account.name || account.id} 领取成功:${outcome.planName}`);
|
|
249
|
+
return json(res, 200, outcome);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
logger.warn('DAEMON', `ZCode ${account.name || account.id} 领取失败:${e.message}`);
|
|
252
|
+
return json(res, 502, { error: e.message, code: e.code, nextAt: e.nextAt });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (p === '/api/zcode/captcha-config' && method === 'GET') {
|
|
256
|
+
try {
|
|
257
|
+
return json(res, 200, await fetchCaptchaConfig());
|
|
258
|
+
} catch (e) {
|
|
259
|
+
return json(res, 502, { error: e.message });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// ZCode 出口偏好:默认「直连优先、代理兜底」,可切「代理优先、直连兜底」(存数据目录 zcode-net.json)
|
|
263
|
+
if (p === '/api/zcode/net' && method === 'GET') {
|
|
264
|
+
return json(res, 200, { proxyFirst: proxyFirst() });
|
|
265
|
+
}
|
|
266
|
+
if (p === '/api/zcode/net' && method === 'PUT') {
|
|
267
|
+
const body = await readBody(req).catch(() => ({}));
|
|
268
|
+
setProxyFirst(body?.proxyFirst === true);
|
|
269
|
+
logger.info('DAEMON', `ZCode 出口切换为:${proxyFirst() ? '代理优先' : '直连优先'}`);
|
|
270
|
+
return json(res, 200, { proxyFirst: proxyFirst() });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 切换 WorkBuddy / ZCode 客户端当前登录账号
|
|
274
|
+
const switchMatch = p.match(/^\/api\/accounts\/([\w-]+)\/switch$/);
|
|
275
|
+
if (switchMatch && method === 'POST') {
|
|
276
|
+
const body = await readBody(req).catch(() => ({}));
|
|
277
|
+
const accounts = await loadAccounts();
|
|
278
|
+
const target = accounts.find((a) => a.id === switchMatch[1]);
|
|
279
|
+
if (!target) return json(res, 404, { error: '账号不存在' });
|
|
280
|
+
if (target.provider === 'zcode') {
|
|
281
|
+
// 防丢号:先把 ZCode 当前登录(凭据 + config.json + 设备 ID)同步 / 保存进账号库,再覆盖
|
|
282
|
+
const live = zcodeLiveAccount();
|
|
283
|
+
if (live && live.uid !== target.uid) {
|
|
284
|
+
await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 ZCode 当前登录失败:' + e.message));
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
// 强制切换:先关掉运行中的 ZCode,否则它会把内存里的旧登录覆盖回文件,切换等于没切
|
|
288
|
+
let closedClient = false;
|
|
289
|
+
if (body?.force === true) {
|
|
290
|
+
const t = terminateZcode();
|
|
291
|
+
closedClient = t.closed === true;
|
|
292
|
+
if (closedClient) logger.info('DAEMON', '已关闭 ZCode 客户端(强制切换)');
|
|
293
|
+
else if (t.running) logger.warn('DAEMON', '未能完全结束 ZCode 进程,继续强制切换');
|
|
294
|
+
}
|
|
295
|
+
ensureVirtualDeviceMid(target);
|
|
296
|
+
const r = zcodeSwitchTo(target, { force: body?.force === true });
|
|
297
|
+
// 虚拟设备 ID 首次生成时需落盘
|
|
298
|
+
await withAccounts((list) => {
|
|
299
|
+
const cur = list.find((a) => a.id === target.id);
|
|
300
|
+
if (cur) cur.meta = { ...(cur.meta || {}), deviceMid: target.meta.deviceMid };
|
|
301
|
+
});
|
|
302
|
+
logger.info('DAEMON', r.alreadyActive ? `ZCode 当前已是 ${target.name || target.id}` : `ZCode 已切换到 ${target.name || target.id}`);
|
|
303
|
+
return json(res, 200, { ok: true, closedClient, ...r });
|
|
304
|
+
} catch (e) {
|
|
305
|
+
return json(res, e.zcodeRunning ? 409 : 400, { error: e.message, code: e.zcodeRunning ? 'ZCODE_RUNNING' : undefined });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (!target.provider.startsWith('workbuddy')) return json(res, 400, { error: '只有 WorkBuddy / ZCode 账号支持切换' });
|
|
309
|
+
// 先把客户端当前会话的最新 token 收回账号库,避免被覆盖后丢失
|
|
310
|
+
const cur = readWorkbuddySessions().accounts.find((a) => a.current);
|
|
311
|
+
if (cur && cur.uid !== target.uid) {
|
|
312
|
+
const { file: _f, fileTime: _t, current: _c, ...rec } = cur;
|
|
313
|
+
await addAccount(rec, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 WorkBuddy 当前会话失败:' + e.message));
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
const r = writeWorkbuddySession(target);
|
|
317
|
+
logger.info('DAEMON', `WorkBuddy 已切换到 ${target.name || target.id}`);
|
|
318
|
+
return json(res, 200, { ok: true, file: r.file, previousUid: r.previousUid });
|
|
319
|
+
} catch (e) {
|
|
320
|
+
return json(res, 400, { error: e.message });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── 设备码登录(Qoder) ──
|
|
325
|
+
if (p === '/api/auth/device/start' && method === 'POST') {
|
|
326
|
+
const body = await readBody(req).catch(() => ({}));
|
|
327
|
+
try {
|
|
328
|
+
const kind = LOGIN_KINDS.includes(body?.provider) ? body.provider : 'qoder';
|
|
329
|
+
const flow = await startDeviceFlow(kind);
|
|
330
|
+
return json(res, 200, flow);
|
|
331
|
+
} catch (e) { return json(res, 500, { error: e.message }); }
|
|
332
|
+
}
|
|
333
|
+
if (p === '/api/auth/device/poll' && method === 'POST') {
|
|
334
|
+
const body = await readBody(req).catch(() => ({}));
|
|
335
|
+
if (!body?.sessionId) return json(res, 400, { error: '缺少 sessionId' });
|
|
336
|
+
try {
|
|
337
|
+
return json(res, 200, await pollDeviceFlow(body.sessionId));
|
|
338
|
+
} catch (e) { return json(res, 500, { error: e.message }); }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── 本机检测 / 凭据扫描 ──
|
|
342
|
+
if (p === '/api/local/detect' && method === 'GET') {
|
|
343
|
+
return json(res, 200, { apps: detectQoderApps(), workbuddyDir: workbuddyAuthDir(), zcode: detectZcode(), legacy: detectInstalls() });
|
|
344
|
+
}
|
|
345
|
+
if (p === '/api/local/scan' && method === 'POST') {
|
|
346
|
+
const existing = await loadAccounts();
|
|
347
|
+
const known = (provider, token, uid) => existing.some((a) => (!provider || a.provider === provider)
|
|
348
|
+
&& (a.token === token || (uid && a.uid === uid)));
|
|
349
|
+
const candidates = [];
|
|
350
|
+
const errors = [];
|
|
351
|
+
const addRecord = (record, extra) => {
|
|
352
|
+
candidates.push({
|
|
353
|
+
id: putCandidate({ token: record.token, record }),
|
|
354
|
+
kind: 'app', provider: record.provider,
|
|
355
|
+
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,
|
|
356
|
+
tokenMasked: record.token.slice(0, 5) + '...' + record.token.slice(-4),
|
|
357
|
+
expiresAt: record.expiresAt, hasRefreshToken: Boolean(record.refreshToken),
|
|
358
|
+
imported: known(record.provider, record.token, record.uid),
|
|
359
|
+
...extra,
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
// 1) Qoder 客户端:解密 auth.v1.dat
|
|
363
|
+
const app = await readQoderAppAccounts();
|
|
364
|
+
errors.push(...app.errors);
|
|
365
|
+
for (const c of app.accounts) {
|
|
366
|
+
addRecord({
|
|
367
|
+
provider: c.provider, token: c.token, name: c.user.name || c.user.email, uid: c.user.id, email: c.user.email,
|
|
368
|
+
refreshToken: c.refreshToken, expiresAt: c.expiresAt, source: 'local-app',
|
|
369
|
+
}, { source: c.source });
|
|
370
|
+
}
|
|
371
|
+
// 2) WorkBuddy 客户端:当前会话 + 历史会话
|
|
372
|
+
const wb = readWorkbuddySessions();
|
|
373
|
+
errors.push(...wb.errors);
|
|
374
|
+
for (const c of wb.accounts) {
|
|
375
|
+
const { file: _f, fileTime: _t, current, ...record } = c;
|
|
376
|
+
addRecord({ ...record, source: current ? 'workbuddy-current' : 'workbuddy-history' }, { source: c.source, current });
|
|
377
|
+
}
|
|
378
|
+
// 3) ZCode 客户端:解密 ~/.zcode/v2/credentials.json 的当前登录
|
|
379
|
+
try {
|
|
380
|
+
const z = zcodeLiveAccount();
|
|
381
|
+
if (z) addRecord(z, { source: 'ZCode 当前登录', current: true });
|
|
382
|
+
} catch (e) { errors.push({ file: '~/.zcode/v2/credentials.json', error: e.message }); }
|
|
383
|
+
// 4) 旧版 VS Code 系 Qoder IDE / CLI:明文 token 扫描(归属需用户选择)
|
|
384
|
+
const det = detectInstalls();
|
|
385
|
+
const dirs = det.ideDataDirs.filter(d => d.exists).map(d => d.path);
|
|
386
|
+
if (det.cliDir.exists) dirs.push(det.cliDir.path);
|
|
387
|
+
const legacy = await scanLocalTokens(dirs);
|
|
388
|
+
for (const c of legacy.candidates) {
|
|
389
|
+
candidates.push({ ...c, provider: null, source: '旧版 Qoder IDE / CLI 文件', imported: known(null, takeCandidate(c.id)?.token) });
|
|
390
|
+
}
|
|
391
|
+
return json(res, 200, { candidates, errors, scanned: legacy.scanned, scannedDirs: [...dirs, wb.dir] });
|
|
392
|
+
}
|
|
393
|
+
if (p === '/api/local/import' && method === 'POST') {
|
|
394
|
+
const body = await readBody(req);
|
|
395
|
+
const cand = takeCandidate(String(body?.candidateId || ''));
|
|
396
|
+
if (!cand) return json(res, 404, { error: '候选不存在或已过期,请重新扫描' });
|
|
397
|
+
const record = cand.record || {
|
|
398
|
+
provider: body.provider === 'qoder-cn' ? 'qoder-cn' : 'qoder',
|
|
399
|
+
token: cand.token, source: 'local-scan',
|
|
400
|
+
};
|
|
401
|
+
const { account, duplicate, updated } = await addAccount(
|
|
402
|
+
{ ...record, name: body.name || record.name || null },
|
|
403
|
+
{ trusted: Boolean(cand.record) },
|
|
404
|
+
);
|
|
405
|
+
if (duplicate && !updated) return json(res, 409, { error: '该账号已存在,信息已是最新' });
|
|
406
|
+
logger.info('DAEMON', (updated ? '已用本机凭据更新:' : '从本机导入账号:') + (account.name || account.id));
|
|
407
|
+
return json(res, updated ? 200 : 201, { account: publicAccount(account), updated });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── 10Router 集成(虚拟 key:额度总览 + 用量同步) ──
|
|
411
|
+
if (p === '/api/tenrouter' && method === 'GET') {
|
|
412
|
+
return json(res, 200, await tenrouter.publicConfig());
|
|
413
|
+
}
|
|
414
|
+
if (p === '/api/tenrouter' && method === 'PUT') {
|
|
415
|
+
const body = await readBody(req).catch(() => ({}));
|
|
416
|
+
try {
|
|
417
|
+
await tenrouter.updateConfig({ endpoint: body?.endpoint, key: body?.key, syncEnabled: body?.syncEnabled, sources: body?.sources });
|
|
418
|
+
return json(res, 200, await tenrouter.publicConfig());
|
|
419
|
+
} catch (e) { return json(res, 400, { error: e.message }); }
|
|
420
|
+
}
|
|
421
|
+
if (p === '/api/tenrouter' && method === 'DELETE') {
|
|
422
|
+
await tenrouter.updateConfig({ endpoint: '' });
|
|
423
|
+
return json(res, 200, await tenrouter.publicConfig());
|
|
424
|
+
}
|
|
425
|
+
if (p === '/api/tenrouter/test' && method === 'POST') {
|
|
426
|
+
const body = await readBody(req).catch(() => ({}));
|
|
427
|
+
let override;
|
|
428
|
+
try {
|
|
429
|
+
// 可以在保存前测试面板里填的新地址 / key;key 留空则用已保存的
|
|
430
|
+
if (body?.endpoint) override = { endpoint: tenrouter.normalizeEndpoint(body.endpoint) };
|
|
431
|
+
if (body?.key && String(body.key).trim()) override = { ...(override || {}), key: String(body.key).trim() };
|
|
432
|
+
} catch (e) { return json(res, 400, { ok: false, error: '地址格式不正确:' + e.message }); }
|
|
433
|
+
return json(res, 200, await tenrouter.testConnection(override));
|
|
434
|
+
}
|
|
435
|
+
if (p === '/api/tenrouter/quotas' && method === 'GET') {
|
|
436
|
+
try {
|
|
437
|
+
return json(res, 200, await tenrouter.fetchQuotas({ force: url.searchParams.get('force') === '1' }));
|
|
438
|
+
} catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 502, { error: e.message, code: e.code }); }
|
|
439
|
+
}
|
|
440
|
+
if (p === '/api/tenrouter/health' && method === 'GET') {
|
|
441
|
+
return json(res, 200, await tenrouter.fetchHealth());
|
|
442
|
+
}
|
|
443
|
+
if (p === '/api/tenrouter/sync' && method === 'POST') {
|
|
444
|
+
const body = await readBody(req).catch(() => ({}));
|
|
445
|
+
try {
|
|
446
|
+
return json(res, 200, await tenrouter.runUsageSync({ dryRun: body?.dryRun === true }));
|
|
447
|
+
} catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 500, { error: e.message, code: e.code }); }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Qoder 设备身份组件(Linux / fnOS:从官方 qodercli 提取 UMID,国际版签到用)
|
|
451
|
+
if (p === '/api/qoder/umid' && method === 'GET') {
|
|
452
|
+
return json(res, 200, { ...umidInfo(), riskIdentity: riskIdentityAvailable(), riskSource: riskIdentitySource() });
|
|
453
|
+
}
|
|
454
|
+
if (p === '/api/qoder/umid/install' && method === 'POST') {
|
|
455
|
+
try {
|
|
456
|
+
await installUmid();
|
|
457
|
+
return json(res, 200, { ok: true, ...umidInfo(), riskIdentity: riskIdentityAvailable(), riskSource: riskIdentitySource() });
|
|
458
|
+
} catch (e) { return json(res, 500, { error: e.message }); }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// 日志
|
|
462
|
+
if (p === '/api/logs' && method === 'GET') {
|
|
463
|
+
return json(res, 200, { logs: getLogs(200) });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// 状态
|
|
467
|
+
if (p === '/api/status' && method === 'GET') {
|
|
468
|
+
const accounts = await loadAccounts();
|
|
469
|
+
const state = await loadState();
|
|
470
|
+
return json(res, 200, {
|
|
471
|
+
ok: true,
|
|
472
|
+
app: 'CreditDaddy',
|
|
473
|
+
version: APP_VERSION,
|
|
474
|
+
homepage: PROJECT_URL,
|
|
475
|
+
accountsCount: accounts.length,
|
|
476
|
+
dataDir: dataDir(),
|
|
477
|
+
today: dayKey(),
|
|
478
|
+
todayDone: state?.qoderDailyDone || {},
|
|
479
|
+
scheduler: getSchedulerInfo(),
|
|
480
|
+
riskIdentity: riskIdentityAvailable(),
|
|
481
|
+
riskSource: riskIdentitySource(),
|
|
482
|
+
umid: umidInfo(),
|
|
483
|
+
platform: process.platform,
|
|
484
|
+
workbuddyCurrentUid: currentWorkbuddyUid(),
|
|
485
|
+
zcodeCurrentUid: currentZcodeUid(),
|
|
486
|
+
zcodeCurrentIdentity: currentZcodeIdentity(),
|
|
487
|
+
keyRequired: Boolean(PANEL_KEY),
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// 导出
|
|
492
|
+
if (p === '/api/export' && method === 'POST') {
|
|
493
|
+
const body = await readBody(req).catch(() => ({}));
|
|
494
|
+
const provider = PROVIDERS.includes(body?.provider) ? body.provider : undefined;
|
|
495
|
+
const product = PRODUCT_IDS.includes(body?.product) ? body.product : undefined;
|
|
496
|
+
try {
|
|
497
|
+
return json(res, 200, exportAccounts(await loadAccounts(), { password: body?.password || undefined, provider, product }));
|
|
498
|
+
} catch (e) {
|
|
499
|
+
if (e instanceof TransferError) return json(res, 400, { error: e.message, code: e.code });
|
|
500
|
+
throw e;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// 导入
|
|
505
|
+
if (p === '/api/import' && method === 'POST') {
|
|
506
|
+
const body = await readBody(req);
|
|
507
|
+
// 新格式 {data, password};兼容直接 POST 文件内容
|
|
508
|
+
const data = body && typeof body === 'object' && !Array.isArray(body) && 'data' in body ? body.data : body;
|
|
509
|
+
let parsed;
|
|
510
|
+
try {
|
|
511
|
+
parsed = parseImport(data, { password: body?.password });
|
|
512
|
+
} catch (e) {
|
|
513
|
+
if (e instanceof TransferError) return json(res, 400, { error: e.message, code: e.code });
|
|
514
|
+
throw e;
|
|
515
|
+
}
|
|
516
|
+
const r = await importAccounts(parsed.accounts);
|
|
517
|
+
const skipped = r.skipped + parsed.skipped;
|
|
518
|
+
logger.info('DAEMON', `导入完成(${parsed.source}):新增 ${r.added},续期 ${r.updated},跳过 ${skipped}`);
|
|
519
|
+
return json(res, 200, { added: r.added, updated: r.updated, skipped, source: parsed.source });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return json(res, 404, { error: '未知接口' });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
|
|
526
|
+
let panelHtml = '';
|
|
527
|
+
fs.readFile(PANEL_FILE, 'utf8').then((h) => { panelHtml = h; }).catch(() => {});
|
|
528
|
+
|
|
529
|
+
const server = http.createServer(async (req, res) => {
|
|
530
|
+
const url = new URL(req.url, 'http://localhost');
|
|
531
|
+
try {
|
|
532
|
+
const denied = rejectForeignRequest(req.headers, host);
|
|
533
|
+
if (denied) {
|
|
534
|
+
logger.warn('DAEMON', `拒绝请求 ${req.method} ${url.pathname}:${denied}(Host=${req.headers.host || ''} Origin=${req.headers.origin || ''})`);
|
|
535
|
+
return json(res, 403, { error: denied });
|
|
536
|
+
}
|
|
537
|
+
if (url.pathname.startsWith('/api/')) {
|
|
538
|
+
return await handleApi(req, res, url);
|
|
539
|
+
}
|
|
540
|
+
// Web 面板
|
|
541
|
+
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
542
|
+
if (!panelHtml) { try { panelHtml = await fs.readFile(PANEL_FILE, 'utf8'); } catch {} }
|
|
543
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
544
|
+
return res.end(panelHtml || '<h1>CreditDaddy</h1><p>panel.html 缺失</p>');
|
|
545
|
+
}
|
|
546
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
547
|
+
res.end('Not Found');
|
|
548
|
+
} catch (err) {
|
|
549
|
+
const status = err instanceof HttpError ? err.status : 500;
|
|
550
|
+
if (status >= 500) logger.error('DAEMON', `请求处理失败:${err?.message || err}`);
|
|
551
|
+
if (!res.headersSent) json(res, status, { error: err?.message || '内部错误' });
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
return new Promise((resolve) => {
|
|
556
|
+
let attempts = 0;
|
|
557
|
+
const bind = (p) => {
|
|
558
|
+
// 失败重试时必须摘掉上一次的 listening 监听,否则成功后会重复触发
|
|
559
|
+
const onError = (err) => {
|
|
560
|
+
server.off('listening', onListening);
|
|
561
|
+
if (err && err.code === 'EADDRINUSE' && attempts < 10) {
|
|
562
|
+
attempts += 1;
|
|
563
|
+
logger.warn('DAEMON', `端口 ${p} 被占用,改试 ${p + 1}`);
|
|
564
|
+
server.close();
|
|
565
|
+
setTimeout(() => bind(p + 1), 300);
|
|
566
|
+
} else {
|
|
567
|
+
logger.error('DAEMON', `监听失败:${err?.message || err}`);
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
const onListening = () => {
|
|
571
|
+
server.off('error', onError);
|
|
572
|
+
const bound = server.address().port;
|
|
573
|
+
logger.info('DAEMON', `CreditDaddy 守护进程已启动:http://${host}:${bound}`);
|
|
574
|
+
if ((host === '0.0.0.0' || host === '::') && !PANEL_KEY) {
|
|
575
|
+
logger.warn('DAEMON', '监听 0.0.0.0 且未设置 CREDITDADDY_PASSWORD —— 局域网内任何人可访问账号 API,建议设置访问密钥');
|
|
576
|
+
}
|
|
577
|
+
resolve({ server, port: bound });
|
|
578
|
+
};
|
|
579
|
+
server.once('error', onError);
|
|
580
|
+
server.once('listening', onListening);
|
|
581
|
+
server.listen(p, host);
|
|
582
|
+
};
|
|
583
|
+
bind(Number(port) || DEFAULT_PORT);
|
|
584
|
+
});
|
|
585
|
+
}
|