@qzsy/dsh-bridge 0.0.0-stage → 2.18.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/CHANGELOG.md +1107 -0
- package/LICENSE +21 -0
- package/README.en.md +488 -0
- package/README.md +509 -2
- package/client/build.mjs +43 -0
- package/client/client.js +9864 -0
- package/client/host-families.js +442 -0
- package/client/index.js +6883 -0
- package/client/locales.mjs +242 -0
- package/client/mobile-styles.js +1299 -0
- package/client/mobile-yield.js +219 -0
- package/client/panel-locales.mjs +777 -0
- package/client/picker-yield.js +77 -0
- package/client/resource-url-compat.js +90 -0
- package/client/settings-nav-icon.js +105 -0
- package/client/theme-tokens.js +49 -0
- package/client/tunnel-guard.js +46 -0
- package/client/unlock-manager.js +144 -0
- package/cordis.patch.yml +4 -0
- package/lib/auth/dsh-native-cookie.js +148 -0
- package/lib/auth/login-template.js +516 -0
- package/lib/auth/manager.js +596 -0
- package/lib/bridge-rpc-constants.js +66 -0
- package/lib/bridge-rpc.js +722 -0
- package/lib/cloudflared-manager.mjs +1277 -0
- package/lib/cloudflared-network-args.js +99 -0
- package/lib/cloudflared-process-scan.js +153 -0
- package/lib/compat.js +172 -0
- package/lib/connection-compat.js +115 -0
- package/lib/feishu/comment.js +50 -0
- package/lib/feishu/gateway.js +644 -0
- package/lib/feishu/index.js +432 -0
- package/lib/feishu/lark-bundled.mjs +131929 -0
- package/lib/feishu/markdown.js +148 -0
- package/lib/feishu/node.js +1152 -0
- package/lib/feishu/slash-commands.js +255 -0
- package/lib/index.js +3554 -0
- package/lib/platform/account-ops.js +248 -0
- package/lib/platform/activity.js +169 -0
- package/lib/platform/ask-user.js +293 -0
- package/lib/platform/base.js +147 -0
- package/lib/platform/changes-digest.js +93 -0
- package/lib/platform/commands.js +267 -0
- package/lib/platform/config-restore.js +78 -0
- package/lib/platform/conversation-bridge.js +1544 -0
- package/lib/platform/dsh-storage.js +222 -0
- package/lib/platform/index.js +10 -0
- package/lib/platform/instances.js +458 -0
- package/lib/platform/manager.js +130 -0
- package/lib/platform/message-split.js +229 -0
- package/lib/platform/session-catalog.js +405 -0
- package/lib/platform/session-config.js +52 -0
- package/lib/platform/stream-slices.js +21 -0
- package/lib/qq/gateway.js +1107 -0
- package/lib/qq/index.js +561 -0
- package/lib/qq/node.js +1153 -0
- package/lib/qq/qqbot-bundled.mjs +8273 -0
- package/lib/restart-helper.mjs +133 -0
- package/lib/saas/device-auth.mjs +366 -0
- package/lib/saas/tunnel-client.mjs +695 -0
- package/lib/security/log-sanitize.js +50 -0
- package/lib/security/path-validator.js +195 -0
- package/lib/security/rate-limiter.js +93 -0
- package/lib/session-strip.js +100 -0
- package/lib/sse-keepalive.js +115 -0
- package/lib/telegram/gateway.js +643 -0
- package/lib/telegram/index.js +255 -0
- package/lib/telegram/node.js +410 -0
- package/lib/tunnel-client.mjs +537 -0
- package/lib/wechat/gateway.js +1000 -0
- package/lib/wechat/index.js +282 -0
- package/lib/wechat/media.js +302 -0
- package/lib/wechat/node.js +360 -0
- package/llms.txt +36 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +135 -4
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// 无托管器场景下的 DSH 重启助手(由 BridgeService.restartDsh 派生为独立进程)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:旧进程调用 process.exit(0) 后,端口不会立刻释放;若新进程马上启动,
|
|
4
|
+
// 很容易撞上 EADDRINUSE 然后无声退出(stdio 被 ignore 时连报错都看不到),
|
|
5
|
+
// 结果就是"点了重启,dsh 再也没起来"。本助手按顺序做三件事并把过程写进日志:
|
|
6
|
+
// 1. 等旧进程真正退出;
|
|
7
|
+
// 2. 等端口释放(能连上说明还被占着);
|
|
8
|
+
// 3. 才派生新的 dsh,并在最长 120s 内确认端口可连接;失败则记日志并以非 0 退出。
|
|
9
|
+
//
|
|
10
|
+
// 用法(由 lib/index.js 组装 payload):node restart-helper.mjs '<json>'
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { appendFileSync, openSync, mkdirSync } from 'node:fs';
|
|
13
|
+
import { dirname } from 'node:path';
|
|
14
|
+
import { connect } from 'node:net';
|
|
15
|
+
|
|
16
|
+
const payload = JSON.parse(process.argv[2] ?? '{}');
|
|
17
|
+
const {
|
|
18
|
+
pid, port, argv, cwd, execPath, logFile, childLog,
|
|
19
|
+
// 超时可注入(单测用),生产用默认值
|
|
20
|
+
waitTimeoutMs = 30_000,
|
|
21
|
+
readyTimeoutMs = 120_000,
|
|
22
|
+
} = payload;
|
|
23
|
+
const startedAt = Date.now();
|
|
24
|
+
|
|
25
|
+
function log(message) {
|
|
26
|
+
try { appendFileSync(logFile, `[${new Date().toISOString()}] [helper] ${message}\n`); } catch { /* 日志不可写时忽略 */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
30
|
+
|
|
31
|
+
function processAlive(targetPid) {
|
|
32
|
+
try {
|
|
33
|
+
process.kill(targetPid, 0);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 端口上是否有人在监听(能建立连接即视为被占用) */
|
|
41
|
+
async function portAccepts(portNumber) {
|
|
42
|
+
return await new Promise((resolve) => {
|
|
43
|
+
let settled = false;
|
|
44
|
+
const done = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
45
|
+
let socket;
|
|
46
|
+
try {
|
|
47
|
+
socket = connect({ host: '127.0.0.1', port: portNumber });
|
|
48
|
+
} catch {
|
|
49
|
+
done(false);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
socket.setTimeout(1200);
|
|
53
|
+
socket.once('connect', () => { done(true); socket.destroy(); });
|
|
54
|
+
socket.once('timeout', () => { done(false); socket.destroy(); });
|
|
55
|
+
socket.once('error', () => { done(false); socket.destroy(); });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function waitUntil(check, timeoutMs, label) {
|
|
60
|
+
const deadline = Date.now() + timeoutMs;
|
|
61
|
+
for (;;) {
|
|
62
|
+
if (await check()) return true;
|
|
63
|
+
if (Date.now() >= deadline) {
|
|
64
|
+
log(`等待超时:${label}(${timeoutMs}ms)`);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
await sleep(400);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function main() {
|
|
72
|
+
log(`启动:oldPid=${pid} port=${port} cwd=${cwd}`);
|
|
73
|
+
|
|
74
|
+
if (!(await waitUntil(() => !processAlive(pid), waitTimeoutMs, `旧进程 ${pid} 退出`))) {
|
|
75
|
+
log('旧进程仍在运行,放弃本次重启(不做任何破坏性动作)');
|
|
76
|
+
process.exit(2);
|
|
77
|
+
}
|
|
78
|
+
if (!(await waitUntil(async () => !(await portAccepts(port)), waitTimeoutMs, `端口 ${port} 释放`))) {
|
|
79
|
+
log(`端口 ${port} 仍被占用,放弃本次重启`);
|
|
80
|
+
process.exit(3);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let stdio = 'ignore';
|
|
84
|
+
try {
|
|
85
|
+
mkdirSync(dirname(childLog), { recursive: true });
|
|
86
|
+
stdio = openSync(childLog, 'a');
|
|
87
|
+
} catch { /* 打不开就用 ignore */ }
|
|
88
|
+
|
|
89
|
+
// spawn 的失败是异步 'error' 事件(例如 execPath 不存在 → ENOENT),只 try/catch 抓不到;
|
|
90
|
+
// 不挂监听会变成未捕获异常,而且父进程给助手设的是 stdio:'ignore' → 失败细节全部丢失。
|
|
91
|
+
// 这里挂监听并把原因写进日志,且**确认 spawn 成功后才**记录"已派生"。
|
|
92
|
+
const spawned = await new Promise((resolve) => {
|
|
93
|
+
let settled = false;
|
|
94
|
+
const settle = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
95
|
+
let child;
|
|
96
|
+
try {
|
|
97
|
+
const childEnv = { ...process.env };
|
|
98
|
+
if (process.platform !== 'win32') {
|
|
99
|
+
const execDir = dirname(execPath);
|
|
100
|
+
const defaultPaths = [execDir, '/usr/local/bin', '/usr/bin', '/bin'].filter(Boolean);
|
|
101
|
+
const existingPath = childEnv.PATH || '';
|
|
102
|
+
const missingPaths = defaultPaths.filter((p) => !existingPath.split(':').includes(p));
|
|
103
|
+
if (missingPaths.length > 0) {
|
|
104
|
+
childEnv.PATH = existingPath ? `${existingPath}:${missingPaths.join(':')}` : missingPaths.join(':');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
child = spawn(execPath, argv, { cwd, env: childEnv, detached: true, stdio: ['ignore', stdio, stdio] });
|
|
108
|
+
} catch (err) {
|
|
109
|
+
log(`派生新 dsh 失败(同步异常):${err?.message ?? err}`);
|
|
110
|
+
settle(null);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
child.once('error', (err) => {
|
|
114
|
+
log(`派生新 dsh 失败:${err?.message ?? err}`);
|
|
115
|
+
settle(null);
|
|
116
|
+
});
|
|
117
|
+
child.once('spawn', () => {
|
|
118
|
+
log(`已派生新 dsh:pid=${child.pid ?? '(未知)'}(输出见 ${childLog})`);
|
|
119
|
+
settle(child);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (!spawned) process.exit(4);
|
|
124
|
+
spawned.unref();
|
|
125
|
+
|
|
126
|
+
const up = await waitUntil(() => portAccepts(port), readyTimeoutMs, `新 dsh 监听 ${port}`);
|
|
127
|
+
log(up
|
|
128
|
+
? `✅ 新 dsh 已就绪,耗时 ${Math.round((Date.now() - startedAt) / 1000)}s`
|
|
129
|
+
: `❌ 新 dsh 未能在 ${Math.round(readyTimeoutMs / 1000)}s 内就绪,请查看 ${childLog}`);
|
|
130
|
+
process.exit(up ? 0 : 1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
void main();
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// DSH Bridge - SaaS 设备认证(设备码注册 + 短期设备令牌刷新)
|
|
2
|
+
//
|
|
3
|
+
// 契约见 docs/architecture/saas-tunnel.md §4/§7(字段名不可擅自改名):
|
|
4
|
+
// POST /api/saas/device/code → { code, expiresAt, verificationUrl }
|
|
5
|
+
// GET /api/saas/device/code/:code → { status: pending|approved|consumed|expired }
|
|
6
|
+
// POST /api/saas/device/code/:code/claim → { deviceId, secret, tunnel:{publicId,name} }
|
|
7
|
+
// POST /api/saas/device/token → { token, expiresAt, tunnel, gateway:{url,path} }
|
|
8
|
+
//
|
|
9
|
+
// 本模块只负责 HTTP 与状态判定,不做落盘:deviceSecret 由调用方写进 config.json
|
|
10
|
+
// (lib/index.js 的 updateConfig → writeConfig 固定 mode=0600,与现有配置落盘方式一致)。
|
|
11
|
+
// 网络层用全局 fetch(Node 18+/Bun 内置),不引入新依赖。
|
|
12
|
+
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
14
|
+
const DEFAULT_CLAIM_POLL_MS = 2000;
|
|
15
|
+
const DEFAULT_CLAIM_TIMEOUT_MS = 10 * 60 * 1000;
|
|
16
|
+
const SECRET_MASK = '••••••••';
|
|
17
|
+
|
|
18
|
+
/** 设备认证失败:code 与契约里的 error.code 对齐,便于上层区分终态/可重试。 */
|
|
19
|
+
export class SaasAuthError extends Error {
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} message
|
|
22
|
+
* @param {{code?: string, status?: number, retryable?: boolean, terminal?: boolean, cause?: unknown}} [options]
|
|
23
|
+
*/
|
|
24
|
+
constructor(message, { code = 'saas_auth_error', status = 0, retryable = false, terminal = false, cause } = {}) {
|
|
25
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
26
|
+
this.name = 'SaasAuthError';
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.retryable = retryable;
|
|
30
|
+
this.terminal = terminal;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 归一化 SaaS 控制面基址:只接受 http(s)/ws(s),ws(s) 转 http(s) 以便调用 /api/saas/*。
|
|
36
|
+
* 丢弃 query/hash;容忍用户直接粘贴网关连接地址(自动剥掉 `/_gw/connect` 后缀)。
|
|
37
|
+
*
|
|
38
|
+
* @param {unknown} value
|
|
39
|
+
* @returns {string} 形如 `https://tunnel.example.com`(无尾斜杠)
|
|
40
|
+
* @throws {SaasAuthError} 空值/非法 URL/非法协议
|
|
41
|
+
*/
|
|
42
|
+
export function normalizeSaasBaseUrl(value) {
|
|
43
|
+
const raw = String(value ?? '').trim();
|
|
44
|
+
if (!raw) throw new SaasAuthError('SaaS 网关地址不能为空', { code: 'bad_request', terminal: true });
|
|
45
|
+
let url;
|
|
46
|
+
try {
|
|
47
|
+
url = new URL(raw);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new SaasAuthError('SaaS 网关地址不是合法 URL(示例:https://tunnel.example.com)', { code: 'bad_request', terminal: true });
|
|
50
|
+
}
|
|
51
|
+
if (url.protocol === 'ws:') url.protocol = 'http:';
|
|
52
|
+
else if (url.protocol === 'wss:') url.protocol = 'https:';
|
|
53
|
+
else if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
54
|
+
throw new SaasAuthError('SaaS 网关地址只支持 http/https(也接受 ws/wss)', { code: 'bad_request', terminal: true });
|
|
55
|
+
}
|
|
56
|
+
url.search = '';
|
|
57
|
+
url.hash = '';
|
|
58
|
+
let pathname = url.pathname;
|
|
59
|
+
if (pathname.endsWith('/_gw/connect')) pathname = pathname.slice(0, -'/_gw/connect'.length);
|
|
60
|
+
url.pathname = pathname || '/';
|
|
61
|
+
let base = url.toString();
|
|
62
|
+
while (base.endsWith('/')) base = base.slice(0, -1);
|
|
63
|
+
return base;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 归一化 token 接口返回的网关连接信息。
|
|
68
|
+
* @param {{url?: string, path?: string}|null|undefined} gateway
|
|
69
|
+
* @returns {{url: string, path: string}|null}
|
|
70
|
+
*/
|
|
71
|
+
export function normalizeSaasGateway(gateway) {
|
|
72
|
+
if (!gateway || typeof gateway !== 'object') return null;
|
|
73
|
+
const rawUrl = String(gateway.url ?? '').trim();
|
|
74
|
+
if (!rawUrl) return null;
|
|
75
|
+
let url;
|
|
76
|
+
try {
|
|
77
|
+
url = new URL(rawUrl);
|
|
78
|
+
} catch {
|
|
79
|
+
throw new SaasAuthError('SaaS 网关连接地址不是合法 URL', { code: 'bad_request' });
|
|
80
|
+
}
|
|
81
|
+
if (!['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)) {
|
|
82
|
+
throw new SaasAuthError('SaaS 网关连接地址只支持 ws/wss/http/https', { code: 'bad_request' });
|
|
83
|
+
}
|
|
84
|
+
let path = String(gateway.path ?? '').trim() || '/_gw/connect';
|
|
85
|
+
if (!path.startsWith('/')) path = `/${path}`;
|
|
86
|
+
return { url: `${url.protocol}//${url.host}`, path };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 解析 expiresAt:兼容 ISO 字符串、epoch 秒(JWT exp)、epoch 毫秒。
|
|
91
|
+
* @param {unknown} value
|
|
92
|
+
* @returns {number|null} epoch 毫秒;无法解析返回 null
|
|
93
|
+
*/
|
|
94
|
+
export function parseSaasExpiresAt(value) {
|
|
95
|
+
if (value === null || value === undefined || value === '') return null;
|
|
96
|
+
const numeric = Number(value);
|
|
97
|
+
if (Number.isFinite(numeric) && numeric > 0) {
|
|
98
|
+
// 1e12 毫秒 ≈ 2001-09;小于它的正数按秒级时间戳解释
|
|
99
|
+
return numeric < 1e12 ? Math.round(numeric * 1000) : Math.round(numeric);
|
|
100
|
+
}
|
|
101
|
+
const parsed = Date.parse(String(value));
|
|
102
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* deviceSecret 面板回显掩码(永远不回传明文)。
|
|
107
|
+
* @param {unknown} secret
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
export function maskDeviceSecret(secret) {
|
|
111
|
+
const raw = String(secret ?? '');
|
|
112
|
+
if (!raw) return '';
|
|
113
|
+
if (raw.length <= 8) return SECRET_MASK;
|
|
114
|
+
return `${SECRET_MASK}${raw.slice(-4)}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 生成 POST /api/saas/device/code 的 device 信息(契约字段)。
|
|
119
|
+
* @param {Record<string, unknown>} [info]
|
|
120
|
+
* @returns {{name: string, platform: string} & Record<string, string>}
|
|
121
|
+
*/
|
|
122
|
+
export function buildDeviceInfo(info = {}) {
|
|
123
|
+
/** @type {Record<string, string>} */
|
|
124
|
+
const out = {};
|
|
125
|
+
for (const key of ['name', 'platform', 'hostname', 'arch', 'pluginVersion', 'dshVersion']) {
|
|
126
|
+
const value = info?.[key];
|
|
127
|
+
if (value === undefined || value === null) continue;
|
|
128
|
+
const text = String(value).trim();
|
|
129
|
+
if (text) out[key] = text;
|
|
130
|
+
}
|
|
131
|
+
if (!out.name) out.name = out.hostname || 'DSH Bridge';
|
|
132
|
+
if (!out.platform) out.platform = 'unknown';
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function statusToErrorCode(status) {
|
|
137
|
+
if (status === 401) return 'auth_failed';
|
|
138
|
+
if (status === 403) return 'auth_failed';
|
|
139
|
+
if (status === 404) return 'bad_request';
|
|
140
|
+
if (status === 409) return 'conflict';
|
|
141
|
+
if (status === 429) return 'rate_limited';
|
|
142
|
+
if (status >= 500) return 'server_error';
|
|
143
|
+
return 'bad_request';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 统一 JSON 请求:错误体 `{code,message}` 原样带出;网络/超时映射成可读错误。
|
|
148
|
+
* @private
|
|
149
|
+
*/
|
|
150
|
+
async function requestJson({ url, method = 'GET', body, fetchImpl = globalThis.fetch, signal, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
151
|
+
if (typeof fetchImpl !== 'function') {
|
|
152
|
+
throw new SaasAuthError('当前运行环境缺少 fetch,无法连接 SaaS 控制面', { code: 'bad_request', terminal: true });
|
|
153
|
+
}
|
|
154
|
+
if (signal?.aborted) throw new SaasAuthError('请求已取消', { code: 'cancelled' });
|
|
155
|
+
|
|
156
|
+
const controller = new AbortController();
|
|
157
|
+
const onAbort = () => controller.abort();
|
|
158
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
159
|
+
const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
|
|
160
|
+
try {
|
|
161
|
+
const headers = { accept: 'application/json' };
|
|
162
|
+
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
163
|
+
const res = await fetchImpl(url, {
|
|
164
|
+
method,
|
|
165
|
+
headers,
|
|
166
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
167
|
+
signal: controller.signal,
|
|
168
|
+
});
|
|
169
|
+
const text = await res.text().catch(() => '');
|
|
170
|
+
let data = null;
|
|
171
|
+
if (text) {
|
|
172
|
+
try { data = JSON.parse(text); } catch { data = null; }
|
|
173
|
+
}
|
|
174
|
+
if (!res.ok) {
|
|
175
|
+
const code = String(data?.code ?? data?.error?.code ?? statusToErrorCode(res.status));
|
|
176
|
+
const message = String(data?.message ?? data?.error?.message ?? `SaaS 控制面请求失败 (HTTP ${res.status})`);
|
|
177
|
+
throw new SaasAuthError(message, {
|
|
178
|
+
code,
|
|
179
|
+
status: res.status,
|
|
180
|
+
retryable: res.status === 429 || res.status >= 500,
|
|
181
|
+
terminal: res.status === 401 || res.status === 403 || res.status === 404,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return data;
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (err instanceof SaasAuthError) throw err;
|
|
187
|
+
if (controller.signal.aborted) {
|
|
188
|
+
if (signal?.aborted) throw new SaasAuthError('请求已取消', { code: 'cancelled' });
|
|
189
|
+
throw new SaasAuthError(`SaaS 控制面请求超时 (${Math.round(timeoutMs / 1000)}s)`, { code: 'timeout', retryable: true });
|
|
190
|
+
}
|
|
191
|
+
throw new SaasAuthError(`无法连接 SaaS 控制面: ${err?.message ?? err}`, { code: 'network_error', retryable: true, cause: err });
|
|
192
|
+
} finally {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 申请设备码(第一步)。
|
|
200
|
+
* @param {{gatewayUrl: string, deviceInfo?: object, fetchImpl?: Function, signal?: AbortSignal, timeoutMs?: number}} options
|
|
201
|
+
* @returns {Promise<{code: string, expiresAt: string|null, expiresAtMs: number|null, verificationUrl: string}>}
|
|
202
|
+
*/
|
|
203
|
+
export async function requestDeviceCode({ gatewayUrl, deviceInfo, fetchImpl, signal, timeoutMs } = {}) {
|
|
204
|
+
const base = normalizeSaasBaseUrl(gatewayUrl);
|
|
205
|
+
const data = await requestJson({
|
|
206
|
+
url: `${base}/api/saas/device/code`,
|
|
207
|
+
method: 'POST',
|
|
208
|
+
body: buildDeviceInfo(deviceInfo),
|
|
209
|
+
fetchImpl,
|
|
210
|
+
signal,
|
|
211
|
+
timeoutMs,
|
|
212
|
+
});
|
|
213
|
+
if (!data?.code) {
|
|
214
|
+
throw new SaasAuthError('SaaS 控制面未返回设备码', { code: 'bad_request', terminal: true });
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
code: String(data.code),
|
|
218
|
+
expiresAt: data.expiresAt ?? null,
|
|
219
|
+
expiresAtMs: parseSaasExpiresAt(data.expiresAt),
|
|
220
|
+
verificationUrl: String(data.verificationUrl ?? ''),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 查询设备码状态(轮询用)。
|
|
226
|
+
* @returns {Promise<{status: string, expiresAt: string|null, expiresAtMs: number|null}>}
|
|
227
|
+
*/
|
|
228
|
+
export async function fetchDeviceCodeStatus({ gatewayUrl, code, fetchImpl, signal, timeoutMs } = {}) {
|
|
229
|
+
const base = normalizeSaasBaseUrl(gatewayUrl);
|
|
230
|
+
const trimmed = String(code ?? '').trim();
|
|
231
|
+
if (!trimmed) throw new SaasAuthError('设备码不能为空', { code: 'bad_request' });
|
|
232
|
+
const data = await requestJson({
|
|
233
|
+
url: `${base}/api/saas/device/code/${encodeURIComponent(trimmed)}`,
|
|
234
|
+
fetchImpl,
|
|
235
|
+
signal,
|
|
236
|
+
timeoutMs,
|
|
237
|
+
});
|
|
238
|
+
return {
|
|
239
|
+
status: String(data?.status ?? 'pending'),
|
|
240
|
+
expiresAt: data?.expiresAt ?? null,
|
|
241
|
+
expiresAtMs: parseSaasExpiresAt(data?.expiresAt),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 领取一次性 deviceSecret(第三/四步)。
|
|
247
|
+
* @returns {Promise<{deviceId: string, secret: string, tunnel: {publicId: string, name: string}|null}>}
|
|
248
|
+
*/
|
|
249
|
+
export async function claimDeviceCode({ gatewayUrl, code, fetchImpl, signal, timeoutMs } = {}) {
|
|
250
|
+
const base = normalizeSaasBaseUrl(gatewayUrl);
|
|
251
|
+
const trimmed = String(code ?? '').trim();
|
|
252
|
+
if (!trimmed) throw new SaasAuthError('设备码不能为空', { code: 'bad_request' });
|
|
253
|
+
const data = await requestJson({
|
|
254
|
+
url: `${base}/api/saas/device/code/${encodeURIComponent(trimmed)}/claim`,
|
|
255
|
+
method: 'POST',
|
|
256
|
+
fetchImpl,
|
|
257
|
+
signal,
|
|
258
|
+
timeoutMs,
|
|
259
|
+
});
|
|
260
|
+
if (!data?.deviceId || !data?.secret) {
|
|
261
|
+
throw new SaasAuthError('设备码领取失败:控制面未返回设备凭证', { code: 'bad_request' });
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
deviceId: String(data.deviceId),
|
|
265
|
+
secret: String(data.secret),
|
|
266
|
+
tunnel: normalizeTunnel(data.tunnel),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 用 deviceSecret 换短期 deviceToken(每次连接/临期前调用)。
|
|
272
|
+
* @returns {Promise<{token: string, expiresAt: string|null, expiresAtMs: number|null, tunnel: object|null, gateway: {url: string, path: string}|null}>}
|
|
273
|
+
*/
|
|
274
|
+
export async function refreshDeviceToken({ gatewayUrl, deviceId, secret, fetchImpl, signal, timeoutMs } = {}) {
|
|
275
|
+
const base = normalizeSaasBaseUrl(gatewayUrl);
|
|
276
|
+
const id = String(deviceId ?? '').trim();
|
|
277
|
+
const value = String(secret ?? '');
|
|
278
|
+
if (!id || !value) throw new SaasAuthError('设备尚未绑定:缺少 deviceId/deviceSecret', { code: 'auth_failed', terminal: true });
|
|
279
|
+
const data = await requestJson({
|
|
280
|
+
url: `${base}/api/saas/device/token`,
|
|
281
|
+
method: 'POST',
|
|
282
|
+
body: { deviceId: id, secret: value },
|
|
283
|
+
fetchImpl,
|
|
284
|
+
signal,
|
|
285
|
+
timeoutMs,
|
|
286
|
+
});
|
|
287
|
+
if (!data?.token) {
|
|
288
|
+
throw new SaasAuthError('SaaS 控制面未返回设备令牌', { code: 'bad_request' });
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
token: String(data.token),
|
|
292
|
+
expiresAt: data.expiresAt ?? null,
|
|
293
|
+
expiresAtMs: parseSaasExpiresAt(data.expiresAt),
|
|
294
|
+
tunnel: normalizeTunnel(data.tunnel),
|
|
295
|
+
gateway: normalizeSaasGateway(data.gateway),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function normalizeTunnel(tunnel) {
|
|
300
|
+
if (!tunnel || typeof tunnel !== 'object') return null;
|
|
301
|
+
const publicId = String(tunnel.publicId ?? '').trim();
|
|
302
|
+
const name = String(tunnel.name ?? '').trim();
|
|
303
|
+
if (!publicId && !name) return null;
|
|
304
|
+
return { publicId, name };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function defaultSleep(ms) {
|
|
308
|
+
return new Promise((resolve) => {
|
|
309
|
+
const timer = setTimeout(resolve, ms);
|
|
310
|
+
// 后台设备码等待不应单独钉住宿主进程(DSH 自身还有其他句柄);
|
|
311
|
+
// 也避免异常路径下轮询定时器把测试进程拖住。
|
|
312
|
+
timer.unref?.();
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 轮询设备码直到 approved 并领取凭证(设备码注册流程的完整等待段)。
|
|
318
|
+
*
|
|
319
|
+
* @param {object} options
|
|
320
|
+
* @param {string} options.gatewayUrl
|
|
321
|
+
* @param {string} options.code
|
|
322
|
+
* @param {number} [options.intervalMs] 轮询间隔,默认 2s
|
|
323
|
+
* @param {number} [options.timeoutMs] 总超时,默认 10min
|
|
324
|
+
* @param {(status: string) => void} [options.onStatus] 每次状态变化回调(pending/approved/...)
|
|
325
|
+
* @param {Function} [options.fetchImpl]
|
|
326
|
+
* @param {AbortSignal} [options.signal]
|
|
327
|
+
* @param {(ms: number) => Promise<void>} [options.sleep] 测试注入
|
|
328
|
+
* @returns {Promise<{deviceId: string, secret: string, tunnel: object|null}>}
|
|
329
|
+
*/
|
|
330
|
+
export async function waitForDeviceClaim({
|
|
331
|
+
gatewayUrl,
|
|
332
|
+
code,
|
|
333
|
+
intervalMs = DEFAULT_CLAIM_POLL_MS,
|
|
334
|
+
timeoutMs = DEFAULT_CLAIM_TIMEOUT_MS,
|
|
335
|
+
onStatus,
|
|
336
|
+
fetchImpl,
|
|
337
|
+
signal,
|
|
338
|
+
sleep = defaultSleep,
|
|
339
|
+
} = {}) {
|
|
340
|
+
const startedAt = Date.now();
|
|
341
|
+
const pollMs = Math.max(1, Number(intervalMs) || DEFAULT_CLAIM_POLL_MS);
|
|
342
|
+
const budgetMs = Math.max(1, Number(timeoutMs) || DEFAULT_CLAIM_TIMEOUT_MS);
|
|
343
|
+
let lastStatus = null;
|
|
344
|
+
for (;;) {
|
|
345
|
+
if (signal?.aborted) throw new SaasAuthError('设备码绑定已取消', { code: 'cancelled' });
|
|
346
|
+
const status = await fetchDeviceCodeStatus({ gatewayUrl, code, fetchImpl, signal });
|
|
347
|
+
if (status.status !== lastStatus) {
|
|
348
|
+
lastStatus = status.status;
|
|
349
|
+
try { onStatus?.(lastStatus); } catch {}
|
|
350
|
+
}
|
|
351
|
+
if (lastStatus === 'approved') {
|
|
352
|
+
return claimDeviceCode({ gatewayUrl, code, fetchImpl, signal });
|
|
353
|
+
}
|
|
354
|
+
if (lastStatus === 'consumed') {
|
|
355
|
+
throw new SaasAuthError('设备码已被使用,请重新生成', { code: 'code_consumed', terminal: true });
|
|
356
|
+
}
|
|
357
|
+
if (lastStatus === 'expired') {
|
|
358
|
+
throw new SaasAuthError('设备码已过期,请重新生成', { code: 'code_expired', terminal: true });
|
|
359
|
+
}
|
|
360
|
+
const elapsed = Date.now() - startedAt;
|
|
361
|
+
if (elapsed >= budgetMs) {
|
|
362
|
+
throw new SaasAuthError('设备码绑定超时,请重新生成', { code: 'code_timeout', terminal: true });
|
|
363
|
+
}
|
|
364
|
+
await sleep(Math.min(pollMs, Math.max(1, budgetMs - elapsed)));
|
|
365
|
+
}
|
|
366
|
+
}
|