@qzsy/dsh-bridge 0.0.0-stage → 3.0.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.
@@ -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
+ }