@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.
package/lib/index.js ADDED
@@ -0,0 +1,360 @@
1
+ // DSH 远程隧道云 · 官方客户端插件(Host 侧)
2
+ //
3
+ // 职责(保持精简,全部为独立实现):
4
+ // 1. 本地门禁代理:监听本机端口 → 转发 DSH 本体,注入 DSH 原生 loopback cookie;
5
+ // 2. SaaS 隧道客户端:设备码绑定 → 短期令牌 → v2 控制通道(转发 HTTP / SSE / WS);
6
+ // 3. 设置页面的 RPC 控制面:状态、绑定、解绑、启停、访问链接。
7
+ import { randomBytes } from 'node:crypto';
8
+ import { readFileSync } from 'node:fs';
9
+ import { hostname, platform as osPlatform, arch as osArch } from 'node:os';
10
+ import { join, dirname } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import QRCode from 'qrcode';
13
+
14
+ import { PLUGIN_ID, PACKAGE_NAME, DEFAULT_PORT, RPC_CHANNEL, RPC_ENDPOINTS, GATEWAY_PATH } from './identity.js';
15
+ import { resolveConfigPath } from './data-dir.js';
16
+ import {
17
+ ensureConfigSecrets,
18
+ loadConfig,
19
+ maskSecret,
20
+ normalizeConfig,
21
+ saveConfig,
22
+ createSecret,
23
+ hashPassword,
24
+ } from './config.js';
25
+ import { GateProxy } from './gate.js';
26
+ import { SaasTunnelClient } from './saas/tunnel-client.mjs';
27
+ import {
28
+ claimDeviceCode,
29
+ fetchDeviceCodeStatus,
30
+ maskDeviceSecret,
31
+ normalizeSaasBaseUrl,
32
+ refreshDeviceToken,
33
+ requestDeviceCode,
34
+ buildDeviceInfo,
35
+ } from './saas/device-auth.mjs';
36
+
37
+ const PACKAGE_JSON = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
38
+ const VERSION = PACKAGE_JSON.version ?? '0.0.0';
39
+ const PAIRING_POLL_MS = 2000;
40
+ const PAIRING_MAX_MS = 10 * 60 * 1000;
41
+
42
+ export const name = PLUGIN_ID;
43
+ export const inject = ['connection', 'webServer'];
44
+
45
+ function ok(value) {
46
+ return { ok: true, value };
47
+ }
48
+
49
+ function fail(code, message) {
50
+ return { ok: false, error: { code: String(code || 'bad-request'), message: String(message ?? '') } };
51
+ }
52
+
53
+ function errorPayload(err) {
54
+ return fail(err?.code || 'internal', err?.message ?? String(err));
55
+ }
56
+
57
+ function registerRpc(ctx, handler, logger) {
58
+ const rpc = ctx?.connection?.rpc;
59
+ if (!rpc?.handle) {
60
+ logger?.warn?.('connection RPC unavailable — panel will stay disabled');
61
+ return () => {};
62
+ }
63
+ try {
64
+ const dispose = rpc.handle(RPC_CHANNEL, handler, { authority: 'loopback' });
65
+ return typeof dispose === 'function' ? dispose : () => {};
66
+ } catch (err) {
67
+ logger?.warn?.(`RPC register with options failed, retrying without: ${err?.message ?? err}`);
68
+ try {
69
+ const dispose = rpc.handle(RPC_CHANNEL, handler);
70
+ return typeof dispose === 'function' ? dispose : () => {};
71
+ } catch (retryErr) {
72
+ logger?.error?.(`RPC register failed: ${retryErr?.message ?? retryErr}`);
73
+ return () => {};
74
+ }
75
+ }
76
+ }
77
+
78
+ export async function apply(ctx, pluginConfig = {}) {
79
+ const logger = ctx?.logger?.(name) ?? console;
80
+ const dshPort = Number(ctx?.webServer?.port) || Number(pluginConfig.targetPort) || 3080;
81
+ const configPath = resolveConfigPath();
82
+
83
+ let config = loadConfig(configPath);
84
+ if (pluginConfig.port) config.port = Number(pluginConfig.port) || config.port;
85
+ if (pluginConfig.gatewayUrl) config.gatewayUrl = String(pluginConfig.gatewayUrl);
86
+ if (ensureConfigSecrets(config)) config = saveConfig(config, configPath);
87
+
88
+ const internalSecret = createSecret(24);
89
+ const gate = new GateProxy({
90
+ targetPort: dshPort,
91
+ port: config.port || DEFAULT_PORT,
92
+ config,
93
+ internalSecret,
94
+ logger,
95
+ dshHome: process.env.DSH_HOME,
96
+ });
97
+ const gatePort = await gate.start();
98
+
99
+ let tunnel = null;
100
+ let pairing = null;
101
+ let pairingTimer = null;
102
+ let pairingDeadline = 0;
103
+ let lastError = null;
104
+
105
+ const persist = (patch) => {
106
+ config = saveConfig({ ...config, ...patch, access: { ...config.access, ...(patch?.access ?? {}) } }, configPath);
107
+ gate.config = config;
108
+ return config;
109
+ };
110
+
111
+ const clearPairing = () => {
112
+ if (pairingTimer) clearInterval(pairingTimer);
113
+ pairingTimer = null;
114
+ pairingDeadline = 0;
115
+ };
116
+
117
+ const stopTunnel = async ({ persistAutoStart = false } = {}) => {
118
+ if (tunnel) {
119
+ try { await tunnel.disconnect(); } catch {}
120
+ tunnel = null;
121
+ }
122
+ if (persistAutoStart) persist({ autoStart: false });
123
+ lastError = null;
124
+ };
125
+
126
+ const gatewayBase = () => normalizeSaasBaseUrl(config.gatewayUrl);
127
+
128
+ const createTunnel = () => new SaasTunnelClient({
129
+ gatewayUrl: gatewayBase(),
130
+ gatewayPath: GATEWAY_PATH,
131
+ localPort: gatePort,
132
+ internalTunnelSecret: internalSecret,
133
+ protocol: 'v2',
134
+ deviceInfo: buildDeviceInfo({
135
+ name: hostname(),
136
+ platform: osPlatform(),
137
+ hostname: hostname(),
138
+ arch: osArch(),
139
+ pluginVersion: VERSION,
140
+ }),
141
+ tokenProvider: () => refreshDeviceToken({
142
+ gatewayUrl: gatewayBase(),
143
+ deviceId: config.deviceId,
144
+ secret: config.deviceSecret,
145
+ }),
146
+ onHelloAck: (ack) => {
147
+ lastError = null;
148
+ logger?.info?.(`SaaS tunnel ready (publicId=%s)`, ack?.tunnel?.publicId ?? '');
149
+ },
150
+ onRouteUpdate: ({ routes, rev }) => {
151
+ persist({ routesVersion: rev });
152
+ logger?.info?.(`SaaS tunnel routes updated (rev=%s, count=%d)`, rev, Array.isArray(routes) ? routes.length : 0);
153
+ },
154
+ onDrain: () => logger?.warn?.('SaaS tunnel draining by gateway'),
155
+ onDrainComplete: () => { lastError = 'SaaS 网关已下线(drain)'; },
156
+ onStateChange: ({ state, detail }) => {
157
+ if (state === 'error') lastError = detail;
158
+ },
159
+ logger,
160
+ });
161
+
162
+ const ensureConnected = async () => {
163
+ if (!config.deviceId || !config.deviceSecret) {
164
+ throw new Error('尚未绑定设备:请先获取设备码并在控制台确认');
165
+ }
166
+ if (tunnel?.connected) return tunnel;
167
+ if (!tunnel) tunnel = createTunnel();
168
+ await tunnel.connect();
169
+ return tunnel;
170
+ };
171
+
172
+ const pairStatus = async () => {
173
+ if (!pairing) return null;
174
+ if (pairing.status === 'pending' && pairingDeadline && Date.now() > pairingDeadline) {
175
+ pairing.status = 'expired';
176
+ clearPairing();
177
+ }
178
+ return { ...pairing };
179
+ };
180
+
181
+ const startPairingPoll = () => {
182
+ clearPairing();
183
+ pairingDeadline = Date.now() + PAIRING_MAX_MS;
184
+ pairingTimer = setInterval(async () => {
185
+ if (!pairing || pairing.status !== 'pending') return;
186
+ try {
187
+ const info = await fetchDeviceCodeStatus({ gatewayUrl: gatewayBase(), code: pairing.code });
188
+ if (info.status === 'approved') {
189
+ const claimed = await claimDeviceCode({ gatewayUrl: gatewayBase(), code: pairing.code });
190
+ pairing.status = 'bound';
191
+ persist({
192
+ deviceId: String(claimed?.deviceId ?? ''),
193
+ deviceSecret: String(claimed?.secret ?? ''),
194
+ tunnel: claimed?.tunnel ?? null,
195
+ routesVersion: 0,
196
+ });
197
+ clearPairing();
198
+ if (config.autoStart) await ensureConnected().catch((err) => { lastError = err?.message ?? String(err); });
199
+ } else if (info.status === 'expired' || info.status === 'consumed') {
200
+ pairing.status = info.status;
201
+ clearPairing();
202
+ }
203
+ } catch (err) {
204
+ lastError = err?.message ?? String(err);
205
+ }
206
+ }, PAIRING_POLL_MS);
207
+ pairingTimer.unref?.();
208
+ };
209
+
210
+ const status = async () => {
211
+ const bound = Boolean(config.deviceId && config.deviceSecret);
212
+ const publicUrl = tunnel?.publicUrl || '';
213
+ return {
214
+ plugin: PACKAGE_NAME,
215
+ version: VERSION,
216
+ phase: tunnel?.connected ? 'ready' : (tunnel?.state ?? 'idle'),
217
+ phaseDetail: tunnel?.stateDetail ?? '',
218
+ connected: Boolean(tunnel?.connected),
219
+ gatewayUrl: config.gatewayUrl,
220
+ device: {
221
+ bound,
222
+ id: config.deviceId || '',
223
+ idMasked: config.deviceId ? maskSecret(config.deviceId) : '',
224
+ secretMasked: maskDeviceSecret(config.deviceSecret),
225
+ tunnel: config.tunnel ?? null,
226
+ },
227
+ publicUrl,
228
+ routes: Array.isArray(tunnel?.routes) ? tunnel.routes : [],
229
+ lastError: lastError || tunnel?.lastError || '',
230
+ proxy: { port: gatePort, host: '127.0.0.1', url: `http://127.0.0.1:${gatePort}` },
231
+ pairing: await pairStatus(),
232
+ protocol: tunnel?.protocolVersion ?? 2,
233
+ access: { passwordSet: gate.passwordConfigured() },
234
+ };
235
+ };
236
+
237
+ const requireAdmin = (payload) => {
238
+ if (!gate.verifyAdminToken(payload?.adminToken)) {
239
+ const error = new Error('需要管理员权限:请在本机打开 DSH 设置页「远程访问」操作');
240
+ error.code = 'unauthorized';
241
+ throw error;
242
+ }
243
+ };
244
+
245
+ const rpcHandler = async (endpoint, payload = {}) => {
246
+ try {
247
+ // getStatus 是面板启动引导(读取代理端口以领取 adminToken),只暴露脱敏状态;
248
+ // 其余端点一律要求物理回环签发的 adminToken。
249
+ if (endpoint === RPC_ENDPOINTS.getStatus) return ok(await status());
250
+ requireAdmin(payload);
251
+ switch (endpoint) {
252
+ case RPC_ENDPOINTS.saveGateway: {
253
+ const baseUrl = normalizeSaasBaseUrl(payload?.gatewayUrl);
254
+ persist({ gatewayUrl: baseUrl });
255
+ return ok({ gatewayUrl: baseUrl });
256
+ }
257
+ case RPC_ENDPOINTS.requestDeviceCode: {
258
+ const created = await requestDeviceCode({
259
+ gatewayUrl: gatewayBase(),
260
+ deviceInfo: buildDeviceInfo({
261
+ name: hostname(),
262
+ platform: osPlatform(),
263
+ hostname: hostname(),
264
+ arch: osArch(),
265
+ pluginVersion: VERSION,
266
+ }),
267
+ });
268
+ pairing = {
269
+ code: created.code,
270
+ expiresAt: created.expiresAt ?? null,
271
+ expiresAtMs: created.expiresAtMs ?? null,
272
+ verificationUrl: created.verificationUrl ?? '',
273
+ status: 'pending',
274
+ };
275
+ lastError = null;
276
+ startPairingPoll();
277
+ return ok({ ...pairing });
278
+ }
279
+ case RPC_ENDPOINTS.bindDevice: {
280
+ const code = String(payload?.code ?? '').trim();
281
+ if (!code) return fail('bad-request', '设备码不能为空');
282
+ const claimed = await claimDeviceCode({ gatewayUrl: gatewayBase(), code });
283
+ pairing = { code, status: 'bound', expiresAt: null, expiresAtMs: null, verificationUrl: '' };
284
+ clearPairing();
285
+ persist({
286
+ deviceId: String(claimed?.deviceId ?? ''),
287
+ deviceSecret: String(claimed?.secret ?? ''),
288
+ tunnel: claimed?.tunnel ?? null,
289
+ routesVersion: 0,
290
+ });
291
+ if (payload?.autoStart === true) {
292
+ persist({ autoStart: true });
293
+ await ensureConnected();
294
+ }
295
+ return ok({ deviceId: config.deviceId, tunnel: config.tunnel });
296
+ }
297
+ case RPC_ENDPOINTS.unbindDevice:
298
+ await stopTunnel();
299
+ clearPairing();
300
+ pairing = null;
301
+ persist({ deviceId: '', deviceSecret: '', tunnel: null, routesVersion: 0, autoStart: false });
302
+ return ok({ unbound: true });
303
+ case RPC_ENDPOINTS.startTunnel:
304
+ if (!gate.passwordConfigured()) return fail('password_not_set', '请先设置访问密码:远程访客需要凭此口令访问');
305
+ persist({ autoStart: payload?.autoStart !== false });
306
+ await ensureConnected();
307
+ return ok(await status());
308
+ case RPC_ENDPOINTS.stopTunnel:
309
+ await stopTunnel({ persistAutoStart: true });
310
+ return ok(await status());
311
+ case RPC_ENDPOINTS.getAccessUrl: {
312
+ const publicUrl = tunnel?.publicUrl || '';
313
+ const url = publicUrl ? gate.accessUrl(publicUrl) : '';
314
+ let qrDataUrl = null;
315
+ if (url) {
316
+ try {
317
+ qrDataUrl = await QRCode.toDataURL(url, { width: 240, margin: 2, color: { dark: '#1F2421', light: '#FFFFFF' } });
318
+ } catch { qrDataUrl = null; }
319
+ }
320
+ return ok({ url, token: config.access.token, qrDataUrl });
321
+ }
322
+ case RPC_ENDPOINTS.setAccessPassword: {
323
+ const password = String(payload?.password ?? '');
324
+ if (password.length < 6) return fail('bad-request', '访问密码至少 6 位');
325
+ const digest = hashPassword(password);
326
+ persist({ access: { ...config.access, ...digest } });
327
+ return ok({ passwordSet: true });
328
+ }
329
+ case RPC_ENDPOINTS.clearAccessPassword: {
330
+ persist({ access: { ...config.access, passwordHash: '', passwordSalt: '' } });
331
+ return ok({ passwordSet: false });
332
+ }
333
+ default:
334
+ return fail('bad-request', `未知 RPC 端点: ${endpoint}`);
335
+ }
336
+ } catch (err) {
337
+ lastError = err?.message ?? String(err);
338
+ return errorPayload(err);
339
+ }
340
+ };
341
+
342
+ const disposeRpc = registerRpc(ctx, rpcHandler, logger);
343
+
344
+ if (config.deviceSecret && config.autoStart && gate.passwordConfigured()) {
345
+ ensureConnected().catch((err) => { lastError = err?.message ?? String(err); });
346
+ }
347
+
348
+ const dispose = async () => {
349
+ clearPairing();
350
+ disposeRpc();
351
+ await stopTunnel();
352
+ await gate.stop();
353
+ };
354
+ try { ctx.effect?.(() => dispose, `${PLUGIN_ID}: lifecycle`); } catch {}
355
+
356
+ return { gate, dispose };
357
+ }
358
+
359
+ export { GateProxy } from './gate.js';
360
+ export { SaasTunnelClient } from './saas/tunnel-client.mjs';