@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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +105 -2
- package/client/build.mjs +41 -0
- package/client/client.js +498 -0
- package/client/index.js +297 -0
- package/client/locales.mjs +108 -0
- package/cordis.patch.yml +6 -0
- package/lib/config.js +93 -0
- package/lib/data-dir.js +16 -0
- package/lib/dsh-cookie.js +111 -0
- package/lib/gate.js +382 -0
- package/lib/identity.js +23 -0
- package/lib/index.js +360 -0
- package/lib/saas/device-auth.mjs +366 -0
- package/lib/saas/tunnel-client.mjs +757 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +80 -4
package/lib/gate.js
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// 本地门禁代理:监听本机端口 → 转发到 DSH 本体,并注入 DSH 原生 loopback cookie。
|
|
2
|
+
//
|
|
3
|
+
// 来源分类(与 docs/research/dsh-bridge-protocol.md §5.1 L1 的安全语义一致):
|
|
4
|
+
// 1. 回环且非隧道(物理本机直连)→ 免认证,享有最高特权(可领取 adminToken)。
|
|
5
|
+
// 2. 带合法 x-dsh-internal-tunnel 标记(由本插件的 SaaS 隧道客户端在本地注入,
|
|
6
|
+
// 密钥为进程内随机值,公网无法伪造)→ 视为 SaaS 隧道路径;网关已完成设备鉴权,
|
|
7
|
+
// 请求携带 x-saas-user 时记录账号身份。
|
|
8
|
+
// 3. 其它来源(局域网直连 / 伪造标记)→ 必须携带本地口令会话,或通过 ?auth=<accessToken>
|
|
9
|
+
// 换取会话 cookie;否则 401(JSON 或登录页)。
|
|
10
|
+
//
|
|
11
|
+
// /__qzsy_bridge__/loopback-token 只对第 1 类来源签发管理令牌;隧道来源一律 403。
|
|
12
|
+
import { createServer, request } from 'node:http';
|
|
13
|
+
import { connect as netConnect } from 'node:net';
|
|
14
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
15
|
+
import { getDshAuthCookie, mergeCookieHeader } from './dsh-cookie.js';
|
|
16
|
+
import { verifyPassword } from './config.js';
|
|
17
|
+
import { PLUGIN_ID } from './identity.js';
|
|
18
|
+
|
|
19
|
+
export const AUTH_COOKIE = 'qzsy_bridge_auth';
|
|
20
|
+
export const TUNNEL_HEADER = 'x-dsh-internal-tunnel';
|
|
21
|
+
export const SAAS_USER_HEADER = 'x-saas-user';
|
|
22
|
+
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
23
|
+
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
24
|
+
const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
|
|
25
|
+
|
|
26
|
+
function safeEqual(left, right) {
|
|
27
|
+
const a = Buffer.from(String(left ?? ''));
|
|
28
|
+
const b = Buffer.from(String(right ?? ''));
|
|
29
|
+
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isLoopbackAddress(address) {
|
|
33
|
+
return LOOPBACK_ADDRESSES.has(String(address ?? ''));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseCookies(header) {
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const part of String(header ?? '').split(';')) {
|
|
39
|
+
const index = part.indexOf('=');
|
|
40
|
+
if (index <= 0) continue;
|
|
41
|
+
out[part.slice(0, index).trim()] = part.slice(index + 1).trim();
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function escapeHtml(value) {
|
|
47
|
+
return String(value ?? '')
|
|
48
|
+
.replaceAll('&', '&')
|
|
49
|
+
.replaceAll('<', '<')
|
|
50
|
+
.replaceAll('>', '>')
|
|
51
|
+
.replaceAll('"', '"')
|
|
52
|
+
.replaceAll("'", ''');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cleanHeaders(headers, { cookie, targetPort } = {}) {
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
58
|
+
const lower = name.toLowerCase();
|
|
59
|
+
if (HOP_BY_HOP.has(lower) || lower === TUNNEL_HEADER || lower === SAAS_USER_HEADER) continue;
|
|
60
|
+
if (value === undefined) continue;
|
|
61
|
+
out[lower] = value;
|
|
62
|
+
}
|
|
63
|
+
if (targetPort) {
|
|
64
|
+
out.host = `127.0.0.1:${targetPort}`;
|
|
65
|
+
if (out.origin) out.origin = `http://127.0.0.1:${targetPort}`;
|
|
66
|
+
}
|
|
67
|
+
if (cookie) out.cookie = cookie;
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class GateProxy {
|
|
72
|
+
constructor({
|
|
73
|
+
targetPort,
|
|
74
|
+
port = 3092,
|
|
75
|
+
host = '0.0.0.0',
|
|
76
|
+
config = {},
|
|
77
|
+
internalSecret,
|
|
78
|
+
logger = console,
|
|
79
|
+
dshHome,
|
|
80
|
+
now = () => Date.now(),
|
|
81
|
+
adminToken,
|
|
82
|
+
} = {}) {
|
|
83
|
+
if (!targetPort) throw new Error('targetPort is required');
|
|
84
|
+
this.targetPort = Number(targetPort);
|
|
85
|
+
const numericPort = Number(port);
|
|
86
|
+
this.port = Number.isInteger(numericPort) && numericPort >= 0 ? numericPort : 3092;
|
|
87
|
+
this.host = host;
|
|
88
|
+
this.config = config;
|
|
89
|
+
this.internalSecret = String(internalSecret ?? '');
|
|
90
|
+
this.logger = logger;
|
|
91
|
+
this.dshHome = dshHome;
|
|
92
|
+
this.now = now;
|
|
93
|
+
this.adminToken = adminToken || `adm_${randomBytes(24).toString('base64url')}`;
|
|
94
|
+
this.server = null;
|
|
95
|
+
this.listeningPort = null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 关闭会话签名:v1.<expiresAt>.<hmac(expiresAt)>;不落盘、无服务端会话表。 */
|
|
99
|
+
sessionToken(expiresAt = this.now() + SESSION_MAX_AGE_MS) {
|
|
100
|
+
const secret = this.config?.access?.sessionSecret;
|
|
101
|
+
if (!secret) return '';
|
|
102
|
+
const signature = createHmac('sha256', secret).update(String(expiresAt)).digest('base64url');
|
|
103
|
+
return `v1.${expiresAt}.${signature}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
verifySession(token) {
|
|
107
|
+
const parts = String(token ?? '').split('.');
|
|
108
|
+
if (parts.length !== 3 || parts[0] !== 'v1') return false;
|
|
109
|
+
const expiresAt = Number(parts[1]);
|
|
110
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= this.now()) return false;
|
|
111
|
+
return safeEqual(parts[2], this.sessionToken(expiresAt).split('.')[2]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 来源分类:返回 { source: 'loopback'|'tunnel'|'remote', authenticated, saasUser }。 */
|
|
115
|
+
classify(req) {
|
|
116
|
+
const headers = req?.headers ?? {};
|
|
117
|
+
const marker = headers[TUNNEL_HEADER];
|
|
118
|
+
if (this.internalSecret && safeEqual(marker, this.internalSecret)) {
|
|
119
|
+
// 内部标记只证明"请求来自本插件的隧道客户端",不等于访客已认证:
|
|
120
|
+
// 仍需本地口令会话 / ?auth= 令牌换来的会话 cookie。
|
|
121
|
+
const cookies = parseCookies(headers.cookie);
|
|
122
|
+
return {
|
|
123
|
+
source: 'tunnel',
|
|
124
|
+
authenticated: this.verifySession(cookies[AUTH_COOKIE]),
|
|
125
|
+
saasUser: String(headers[SAAS_USER_HEADER] ?? ''),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const address = req?.socket?.remoteAddress;
|
|
129
|
+
if (isLoopbackAddress(address)) {
|
|
130
|
+
return { source: 'loopback', authenticated: true, saasUser: '' };
|
|
131
|
+
}
|
|
132
|
+
const cookies = parseCookies(headers.cookie);
|
|
133
|
+
if (this.verifySession(cookies[AUTH_COOKIE])) {
|
|
134
|
+
return { source: 'remote', authenticated: true, saasUser: '' };
|
|
135
|
+
}
|
|
136
|
+
return { source: 'remote', authenticated: false, saasUser: '' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 是否已设置本地访问口令(未设置时拒绝一切远程/隧道访客)。 */
|
|
140
|
+
passwordConfigured() {
|
|
141
|
+
return Boolean(this.config?.access?.passwordHash && this.config?.access?.passwordSalt);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** RPC 管理鉴权:令牌只能由物理回环调用 /__qzsy_bridge__/loopback-token 取得。 */
|
|
145
|
+
verifyAdminToken(token) {
|
|
146
|
+
return safeEqual(token, this.adminToken);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 拼接给管理员看的远程入口(带固定访问令牌,首次打开后换 cookie)。 */
|
|
150
|
+
accessUrl(baseUrl) {
|
|
151
|
+
const token = this.config?.access?.token;
|
|
152
|
+
if (!baseUrl || !token) return String(baseUrl ?? '');
|
|
153
|
+
const url = new URL(String(baseUrl));
|
|
154
|
+
url.searchParams.set('auth', token);
|
|
155
|
+
return url.toString();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_setSessionCookie(res, expiresAt = this.now() + SESSION_MAX_AGE_MS) {
|
|
159
|
+
const token = this.sessionToken(expiresAt);
|
|
160
|
+
if (!token) return;
|
|
161
|
+
res.setHeader('set-cookie', `${AUTH_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_MAX_AGE_MS / 1000)}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_setupPage() {
|
|
165
|
+
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8" />
|
|
166
|
+
<title>DSH 远程隧道云 · 需要初始化</title>
|
|
167
|
+
<style>body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:#0f1115;color:#e5e7eb;display:grid;place-items:center;min-height:100vh;margin:0}div{background:#171a21;padding:28px;border-radius:12px;width:min(92vw,420px);border:1px solid #2b303b}h1{font-size:17px;margin:0 0 12px}p{color:#9ca3af;font-size:13px;line-height:1.7;margin:6px 0}</style>
|
|
168
|
+
<div><h1>尚未设置访问口令</h1><p>DSH 远程隧道云默认拒绝未认证的远程/隧道访客。</p><p>请先在<b>本机</b> DSH 设置页「远程访问」中设置访问口令,再把「带访问令牌的链接」发给需要访问的人。</p></div></html>`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
_corsHeaders(req) {
|
|
172
|
+
const origin = String(req?.headers?.origin ?? '');
|
|
173
|
+
if (!origin) return {};
|
|
174
|
+
try {
|
|
175
|
+
const parsed = new URL(origin);
|
|
176
|
+
const host = parsed.hostname;
|
|
177
|
+
if (origin === 'dsh-app://app' || host === '127.0.0.1' || host === 'localhost' || host === '::1') {
|
|
178
|
+
return { 'access-control-allow-origin': origin, vary: 'origin' };
|
|
179
|
+
}
|
|
180
|
+
} catch {}
|
|
181
|
+
return {};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_loginPage(path, message = '') {
|
|
185
|
+
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8" />
|
|
186
|
+
<title>DSH 远程隧道云 · 访问验证</title>
|
|
187
|
+
<style>body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:#0f1115;color:#e5e7eb;display:grid;place-items:center;min-height:100vh;margin:0}form{background:#171a21;padding:28px;border-radius:12px;width:min(92vw,360px);border:1px solid #2b303b}h1{font-size:17px;margin:0 0 14px}p{color:#9ca3af;font-size:13px;line-height:1.6}input{width:100%;box-sizing:border-box;padding:10px;border-radius:8px;border:1px solid #333a46;background:#0f1115;color:inherit;margin:10px 0}button{width:100%;padding:10px;border:0;border-radius:8px;background:#4f6ef7;color:#fff;font-weight:600;cursor:pointer}.err{color:#f87171;font-size:12px;min-height:16px}</style>
|
|
188
|
+
<form method="post" action="/__qzsy_bridge__/login?next=${encodeURIComponent(path)}"><h1>访问验证</h1><p>该入口需要本地访问口令。口令由插件在 DSH 设置页「远程访问」中设置。</p><input name="password" type="password" placeholder="访问口令" autofocus /><div class="err">${escapeHtml(message)}</div><button type="submit">进入</button></form></html>`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async _readBody(req, limit = 64 * 1024) {
|
|
192
|
+
const chunks = [];
|
|
193
|
+
let size = 0;
|
|
194
|
+
for await (const chunk of req) {
|
|
195
|
+
size += chunk.length;
|
|
196
|
+
if (size > limit) throw new Error('body too large');
|
|
197
|
+
chunks.push(chunk);
|
|
198
|
+
}
|
|
199
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async _handleLocal(req, res, verdict) {
|
|
203
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
204
|
+
if (url.pathname === '/__qzsy_bridge__/loopback-token') {
|
|
205
|
+
if (verdict.source !== 'loopback') {
|
|
206
|
+
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
207
|
+
res.end(JSON.stringify({ ok: false, error: 'Forbidden: loopback only' }));
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...this._corsHeaders(req) });
|
|
211
|
+
res.end(JSON.stringify({ ok: true, adminToken: this.adminToken }));
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
if (url.pathname === '/__qzsy_bridge__/login') {
|
|
215
|
+
if (req.method === 'GET') {
|
|
216
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
217
|
+
res.end(this._loginPage(url.searchParams.get('next') || '/'));
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
if (req.method === 'POST') {
|
|
221
|
+
const body = await this._readBody(req);
|
|
222
|
+
const password = new URLSearchParams(body).get('password') ?? '';
|
|
223
|
+
if (!verifyPassword(password, this.config?.access ?? {})) {
|
|
224
|
+
res.writeHead(401, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
225
|
+
res.end(this._loginPage(url.searchParams.get('next') || '/', '口令不正确'));
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
this._setSessionCookie(res);
|
|
229
|
+
res.writeHead(302, { location: url.searchParams.get('next') || '/', 'cache-control': 'no-store' });
|
|
230
|
+
res.end();
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
_handleAuthToken(req, res, verdict) {
|
|
238
|
+
if (verdict.source === 'loopback') return false;
|
|
239
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
240
|
+
const provided = url.searchParams.get('auth');
|
|
241
|
+
if (!provided || !safeEqual(provided, this.config?.access?.token)) return false;
|
|
242
|
+
url.searchParams.delete('auth');
|
|
243
|
+
this._setSessionCookie(res, this.now() + SESSION_MAX_AGE_MS);
|
|
244
|
+
res.writeHead(302, { location: (url.pathname || '/') + (url.search || ''), 'cache-control': 'no-store' });
|
|
245
|
+
res.end();
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
_upstreamCookie(req) {
|
|
250
|
+
const dshCookie = getDshAuthCookie(this.targetPort, { dshHome: this.dshHome, now: this.now() });
|
|
251
|
+
return mergeCookieHeader(req.headers.cookie, dshCookie);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
_proxyHttp(req, res) {
|
|
255
|
+
const headers = cleanHeaders(req.headers, { cookie: this._upstreamCookie(req), targetPort: this.targetPort });
|
|
256
|
+
const upstream = request({
|
|
257
|
+
host: '127.0.0.1',
|
|
258
|
+
port: this.targetPort,
|
|
259
|
+
method: req.method,
|
|
260
|
+
path: req.url,
|
|
261
|
+
headers,
|
|
262
|
+
}, (upstreamRes) => {
|
|
263
|
+
const responseHeaders = {};
|
|
264
|
+
for (const [name, value] of Object.entries(upstreamRes.headers)) {
|
|
265
|
+
if (HOP_BY_HOP.has(name.toLowerCase())) continue;
|
|
266
|
+
responseHeaders[name] = value;
|
|
267
|
+
}
|
|
268
|
+
res.writeHead(upstreamRes.statusCode ?? 502, responseHeaders);
|
|
269
|
+
upstreamRes.pipe(res);
|
|
270
|
+
});
|
|
271
|
+
upstream.on('error', (err) => {
|
|
272
|
+
this.logger?.warn?.(`[${PLUGIN_ID}] upstream error: ${err?.message ?? err}`);
|
|
273
|
+
if (!res.headersSent) {
|
|
274
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
|
|
275
|
+
res.end('Bad Gateway: DSH 本体不可达');
|
|
276
|
+
} else {
|
|
277
|
+
res.destroy();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
req.pipe(upstream);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
_proxyUpgrade(req, socket, head) {
|
|
284
|
+
const upstream = netConnect({ host: '127.0.0.1', port: this.targetPort }, () => {
|
|
285
|
+
// Upgrade 请求必须保留 connection/upgrade/sec-websocket-* 头,不能走普通请求的逐跳过滤
|
|
286
|
+
const headers = {};
|
|
287
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
288
|
+
const lower = name.toLowerCase();
|
|
289
|
+
if (lower === TUNNEL_HEADER || lower === SAAS_USER_HEADER) continue;
|
|
290
|
+
if (value !== undefined) headers[lower] = value;
|
|
291
|
+
}
|
|
292
|
+
headers.host = `127.0.0.1:${this.targetPort}`;
|
|
293
|
+
headers.cookie = this._upstreamCookie(req);
|
|
294
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
295
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
296
|
+
if (Array.isArray(value)) for (const item of value) lines.push(`${name}: ${item}`);
|
|
297
|
+
else lines.push(`${name}: ${value}`);
|
|
298
|
+
}
|
|
299
|
+
lines.push('', '');
|
|
300
|
+
upstream.write(lines.join('\r\n'));
|
|
301
|
+
if (head?.length) upstream.write(head);
|
|
302
|
+
upstream.pipe(socket);
|
|
303
|
+
socket.pipe(upstream);
|
|
304
|
+
});
|
|
305
|
+
upstream.on('error', () => {
|
|
306
|
+
try { socket.write('HTTP/1.1 502 Bad Gateway\r\ncontent-length: 0\r\n\r\n'); } catch {}
|
|
307
|
+
socket.destroy();
|
|
308
|
+
});
|
|
309
|
+
socket.on('error', () => upstream.destroy());
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async _handle(req, res) {
|
|
313
|
+
const verdict = this.classify(req);
|
|
314
|
+
if (req.method === 'OPTIONS' && String(req.url ?? '').startsWith('/__qzsy_bridge__/')) {
|
|
315
|
+
res.writeHead(204, {
|
|
316
|
+
'access-control-allow-methods': 'GET, POST, OPTIONS',
|
|
317
|
+
'access-control-allow-headers': 'content-type',
|
|
318
|
+
'access-control-max-age': '600',
|
|
319
|
+
...this._corsHeaders(req),
|
|
320
|
+
});
|
|
321
|
+
res.end();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (await this._handleLocal(req, res, verdict)) return;
|
|
325
|
+
if (this._handleAuthToken(req, res, verdict)) return;
|
|
326
|
+
if (!verdict.authenticated) {
|
|
327
|
+
const wantsJson = String(req.headers.accept ?? '').includes('application/json')
|
|
328
|
+
|| String(req.url ?? '').startsWith('/api/');
|
|
329
|
+
const passwordSet = this.passwordConfigured();
|
|
330
|
+
if (wantsJson) {
|
|
331
|
+
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
332
|
+
res.end(JSON.stringify({ ok: false, error: 'unauthorized', code: passwordSet ? 'access_required' : 'password_not_set' }));
|
|
333
|
+
} else {
|
|
334
|
+
res.writeHead(401, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
335
|
+
res.end(passwordSet ? this._loginPage(req.url ?? '/') : this._setupPage());
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
this._proxyHttp(req, res);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async start() {
|
|
343
|
+
if (this.server) return this.listeningPort;
|
|
344
|
+
this.server = createServer((req, res) => {
|
|
345
|
+
this._handle(req, res).catch((err) => {
|
|
346
|
+
this.logger?.error?.(`[${PLUGIN_ID}] gate error: ${err?.stack ?? err}`);
|
|
347
|
+
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' });
|
|
348
|
+
res.end('Internal Error');
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
this.server.on('upgrade', (req, socket, head) => {
|
|
352
|
+
const verdict = this.classify(req);
|
|
353
|
+
if (!verdict.authenticated) {
|
|
354
|
+
try { socket.write('HTTP/1.1 401 Unauthorized\r\ncontent-length: 0\r\n\r\n'); } catch {}
|
|
355
|
+
socket.destroy();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
this._proxyUpgrade(req, socket, head);
|
|
359
|
+
});
|
|
360
|
+
await new Promise((resolve, reject) => {
|
|
361
|
+
this.server.once('error', reject);
|
|
362
|
+
this.server.listen(this.port, this.host, () => {
|
|
363
|
+
this.server.off('error', reject);
|
|
364
|
+
this.listeningPort = this.server.address()?.port ?? this.port;
|
|
365
|
+
resolve();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
this.logger?.info?.(`[${PLUGIN_ID}] gate listening on ${this.host}:${this.listeningPort} -> 127.0.0.1:${this.targetPort}`);
|
|
369
|
+
return this.listeningPort;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async stop() {
|
|
373
|
+
if (!this.server) return;
|
|
374
|
+
const server = this.server;
|
|
375
|
+
this.server = null;
|
|
376
|
+
// 已升级的 WebSocket 连接不会被常规 close 收敛,先强制断开避免宿主退出挂住
|
|
377
|
+
server.closeAllConnections?.();
|
|
378
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export { isLoopbackAddress };
|
package/lib/identity.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// 插件身份常量(Host 与浏览器客户端共享;纯 JS,不 import node:*)
|
|
2
|
+
//
|
|
3
|
+
// 所有注册键都带 qzsy 前缀,保证同一 DSH profile 内与任何其它插件不冲突。
|
|
4
|
+
// 本插件是独立实现:不依赖、不包含上游 dsh-bridge 的代码。
|
|
5
|
+
export const PLUGIN_ID = 'qzsy-dsh-bridge';
|
|
6
|
+
export const PACKAGE_NAME = '@qzsy/dsh-bridge';
|
|
7
|
+
export const DATA_DIR = 'qzsy-dsh-bridge';
|
|
8
|
+
export const DEFAULT_PORT = 3092;
|
|
9
|
+
export const DEFAULT_GATEWAY_URL = 'wss://dsh.b.nps.qzsyzn.com/_gw/connect';
|
|
10
|
+
export const GATEWAY_PATH = '/_gw/connect';
|
|
11
|
+
export const RPC_CHANNEL = `/${PLUGIN_ID}`;
|
|
12
|
+
export const RPC_ENDPOINTS = Object.freeze({
|
|
13
|
+
getStatus: 'getStatus',
|
|
14
|
+
saveGateway: 'saveGateway',
|
|
15
|
+
requestDeviceCode: 'requestDeviceCode',
|
|
16
|
+
bindDevice: 'bindDevice',
|
|
17
|
+
unbindDevice: 'unbindDevice',
|
|
18
|
+
startTunnel: 'startTunnel',
|
|
19
|
+
stopTunnel: 'stopTunnel',
|
|
20
|
+
getAccessUrl: 'getAccessUrl',
|
|
21
|
+
setAccessPassword: 'setAccessPassword',
|
|
22
|
+
clearAccessPassword: 'clearAccessPassword',
|
|
23
|
+
});
|