mn-ws-server 0.0.0-stage → 0.2.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/dist/server.js ADDED
@@ -0,0 +1,725 @@
1
+ import http from 'node:http';
2
+ import { WebSocketServer, WebSocket } from 'ws';
3
+ import { FRAME, encodeFrame, decodeFrame, nextRequestId, normalizeHeaders, stripResponseHeaders, flattenHeaders, MAX_BODY_BYTES, DEFAULT_REQUEST_TIMEOUT_MS, } from './protocol.js';
4
+ import { resolveApp, describeApp } from './apps.js';
5
+ import { Logger } from './logger.js';
6
+ import { Store } from './store.js';
7
+ import { AuthHub } from './auth.js';
8
+ export function createServer(userConfig = {}) {
9
+ const config = {
10
+ host: '0.0.0.0',
11
+ port: 7777,
12
+ wsPath: '/ws',
13
+ token: '',
14
+ issuer: 'http://47.110.80.47:8082',
15
+ idpClientId: 'mn-ws-server',
16
+ loginClientId: 'mn-ws-client',
17
+ redirectUri: '',
18
+ jwtSecret: '',
19
+ authEnabled: false,
20
+ insecure: false,
21
+ requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
22
+ dataDir: null,
23
+ logLevel: 'info',
24
+ ...userConfig,
25
+ };
26
+ const logger = new Logger({ level: config.logLevel, dir: config.dataDir, name: 'mn-ws-server' });
27
+ const store = new Store({ dir: config.dataDir, name: 'requests.jsonl' });
28
+ const auth = new AuthHub({
29
+ enabled: config.authEnabled,
30
+ issuer: config.issuer,
31
+ clientId: config.idpClientId,
32
+ loginClientId: config.loginClientId,
33
+ redirectUri: config.redirectUri,
34
+ jwtSecret: config.jwtSecret,
35
+ dataDir: config.dataDir,
36
+ });
37
+ // ────────────────── 状态 ──────────────────
38
+ /** app -> 桥 */
39
+ const bridges = new Map();
40
+ /** 曾经注册过的 app 名 —— 用来区分「从没这个应用」与「这个应用的桥掉线了」 */
41
+ const knownApps = new Set();
42
+ /** requestId -> 等待中的请求 */
43
+ const pending = new Map();
44
+ /** ws -> 连接元信息 */
45
+ const peers = new Map();
46
+ const stats = {
47
+ connections: 0,
48
+ requestsIn: 0,
49
+ forwarded: 0,
50
+ offline: 0,
51
+ rejected: 0,
52
+ errors: 0,
53
+ bytesIn: 0,
54
+ bytesOut: 0,
55
+ };
56
+ let nextPeerId = 1;
57
+ // ────────────────── HTTP 层 ──────────────────
58
+ const httpServer = http.createServer(async (req, res) => {
59
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
60
+ const send = (code, body) => {
61
+ const payload = typeof body === 'string' ? body : (JSON.stringify(body) ?? '');
62
+ res.writeHead(code, {
63
+ 'content-type': 'application/json; charset=utf-8',
64
+ 'content-length': Buffer.byteLength(payload),
65
+ 'cache-control': 'no-store',
66
+ });
67
+ res.end(payload);
68
+ };
69
+ try {
70
+ if (req.method === 'OPTIONS') {
71
+ res.writeHead(204).end();
72
+ return;
73
+ }
74
+ // ── 公开端点 ──
75
+ if (url.pathname === '/health') {
76
+ send(200, {
77
+ ok: true,
78
+ service: 'mn-ws-server',
79
+ uptime: process.uptime(),
80
+ bridges: bridges.size,
81
+ pending: pending.size,
82
+ authEnabled: config.authEnabled,
83
+ });
84
+ return;
85
+ }
86
+ if (url.pathname === '/stats') {
87
+ send(200, {
88
+ ok: true,
89
+ stats,
90
+ apps: [...bridges.keys()],
91
+ pending: pending.size,
92
+ });
93
+ return;
94
+ }
95
+ // ── 统一登录的公开参数(绝不含 secret)──
96
+ if (url.pathname === '/auth/oidc/config') {
97
+ send(200, { ok: true, ...auth.publicConfig() });
98
+ return;
99
+ }
100
+ // ── 用认证中心的 access_token 换本服务的设备令牌 ──
101
+ // 注意这里读的是 **Authorization**:这一段是「拿认证中心的令牌换设备令牌」,
102
+ // 与 L1 的 X-Mn-Ws-Token 不是一回事。
103
+ if (url.pathname === '/auth/verify' && req.method === 'POST') {
104
+ const raw = await readBody(req);
105
+ const body = raw.length ? JSON.parse(raw.toString('utf8')) : {};
106
+ const bearer = authorizationBearerOf(req);
107
+ try {
108
+ const identity = await auth.verifyBearer(bearer);
109
+ const clientId = String(body.clientId || '').trim();
110
+ if (!clientId) {
111
+ send(400, { ok: false, error: '缺少 clientId', code: 'bad_request' });
112
+ return;
113
+ }
114
+ auth.registerDevice({
115
+ clientId,
116
+ userId: identity.userId,
117
+ deviceName: body.deviceName ? String(body.deviceName) : '',
118
+ });
119
+ const issued = auth.issueDeviceToken({
120
+ userId: identity.userId,
121
+ clientId,
122
+ deviceName: body.deviceName ? String(body.deviceName) : '',
123
+ });
124
+ send(200, {
125
+ ok: true,
126
+ userId: identity.userId,
127
+ email: identity.email,
128
+ username: identity.preferredUsername,
129
+ nickname: identity.name,
130
+ token: issued.token,
131
+ tokenExpiresAt: issued.expiresAt,
132
+ clientId,
133
+ deviceName: body.deviceName || '',
134
+ });
135
+ }
136
+ catch (e) {
137
+ const err = e;
138
+ send(401, { ok: false, error: err.message || 'unauthorized', code: err.code || 'unauthorized' });
139
+ }
140
+ return;
141
+ }
142
+ // ── 元信息目录:登录后拉「哪些可以路由」 ──
143
+ // 远端客户端拿到凭据后的第一步就是拉这份目录 —— 看清有哪些应用、
144
+ // 各自在哪个端口、由哪台桥承载,再决定调哪个、怎么调(路径前缀或
145
+ // X-Nx-App 头)。
146
+ if (url.pathname === '/api/catalog') {
147
+ const caller = identifyCaller(req);
148
+ if (!caller.ok) {
149
+ sendRes(res, caller.status, { ok: false, error: caller.error });
150
+ return;
151
+ }
152
+ const list = [];
153
+ for (const [app, b] of bridges) {
154
+ // 具名用户只看得到自己名下的桥
155
+ if (!caller.isGate && b.userId !== caller.userId)
156
+ continue;
157
+ list.push({
158
+ app,
159
+ title: b.meta && b.meta.title ? b.meta.title : app,
160
+ port: b.meta ? b.meta.port : null,
161
+ base: b.meta ? b.meta.base : null,
162
+ bridgeId: b.bridgeId,
163
+ connectedAt: b.connectedAt,
164
+ });
165
+ }
166
+ sendRes(res, 200, { ok: true, count: list.length, apps: list });
167
+ return;
168
+ }
169
+ // ── 已注册桥的清单(要鉴权)──
170
+ if (url.pathname === '/clients') {
171
+ const caller = identifyCaller(req);
172
+ if (!caller.ok) {
173
+ send(caller.status, { ok: false, error: caller.error });
174
+ return;
175
+ }
176
+ const list = [...bridges.entries()].map(([app, b]) => ({
177
+ app,
178
+ bridgeId: b.bridgeId,
179
+ userId: b.userId,
180
+ connectedAt: b.connectedAt,
181
+ }));
182
+ const visible = caller.isGate ? list : list.filter((b) => b.userId === caller.userId);
183
+ send(200, { ok: true, count: visible.length, bridges: visible });
184
+ return;
185
+ }
186
+ // ── 桥接入口:/<app>/* ──
187
+ const resolved = resolveApp(url.pathname, normalizeHeaders(req.headers));
188
+ if ('error' in resolved) {
189
+ stats.rejected += 1;
190
+ send(400, { ok: false, error: resolved.error });
191
+ return;
192
+ }
193
+ const { app, path } = resolved;
194
+ const caller = identifyCaller(req);
195
+ if (!caller.ok) {
196
+ send(caller.status, { ok: false, error: caller.error });
197
+ return;
198
+ }
199
+ const bridge = bridges.get(app);
200
+ if (!bridge) {
201
+ stats.offline += 1;
202
+ // 区分两种「没有」:这个应用从来没注册过(404),
203
+ // 还是注册过但桥掉线了(502,桥会自动重连)。
204
+ const known = knownApps.has(app);
205
+ store.append({
206
+ app, method: req.method, path, status: known ? 502 : 404, userId: caller.userId,
207
+ });
208
+ sendRes(res, known ? 502 : 404, {
209
+ ok: false,
210
+ error: known ? 'bridge_offline' : 'unknown_app',
211
+ app,
212
+ });
213
+ return;
214
+ }
215
+ // 属主校验:具名用户只能走自己的桥
216
+ if (!caller.isGate && bridge.userId !== caller.userId) {
217
+ stats.rejected += 1;
218
+ logger.warn('rejected: 跨属主访问', {
219
+ app, userId: caller.userId, owner: bridge.userId,
220
+ });
221
+ store.append({ app, method: req.method, path, status: 403, userId: caller.userId });
222
+ sendRes(res, 403, { ok: false, error: 'forbidden', app });
223
+ return;
224
+ }
225
+ stats.requestsIn += 1;
226
+ let body;
227
+ try {
228
+ body = await readBody(req);
229
+ }
230
+ catch {
231
+ stats.rejected += 1;
232
+ sendRes(res, 413, { ok: false, error: 'body_too_large', limit: MAX_BODY_BYTES });
233
+ return;
234
+ }
235
+ stats.bytesIn += body.length;
236
+ const id = nextRequestId();
237
+ const headers = {};
238
+ for (const [k, v] of Object.entries(normalizeHeaders(req.headers))) {
239
+ headers[k] = v;
240
+ }
241
+ const frame = {
242
+ type: FRAME.REQUEST,
243
+ id,
244
+ app,
245
+ method: req.method,
246
+ path,
247
+ query: Object.fromEntries(url.searchParams.entries()),
248
+ headers,
249
+ body: body.toString('base64'),
250
+ ts: Date.now(),
251
+ };
252
+ const timer = setTimeout(() => {
253
+ const p = pending.get(id);
254
+ if (!p)
255
+ return;
256
+ pending.delete(id);
257
+ stats.errors += 1;
258
+ store.append({ app, method: req.method, path, status: 504, userId: caller.userId });
259
+ sendRes(res, 504, { ok: false, error: 'bridge_timeout', app });
260
+ }, config.requestTimeoutMs);
261
+ pending.set(id, { res, timer, bridgeId: bridge.bridgeId, app, startedAt: Date.now() });
262
+ // 客户端断开 → 通知 client half 中断对应请求,别让本机白跑
263
+ const onClose = () => {
264
+ const p = pending.get(id);
265
+ if (!p)
266
+ return;
267
+ pending.delete(id);
268
+ clearTimeout(p.timer);
269
+ try {
270
+ sendRes(res, 499, { ok: false, error: 'client_disconnected', app });
271
+ }
272
+ catch { /* 连接已断,写不进去就算了 */ }
273
+ try {
274
+ bridge.ws.send(encodeFrame({ type: FRAME.ABORT, id, reason: 'client_disconnected' }));
275
+ }
276
+ catch { /* ignore */ }
277
+ };
278
+ req.on('close', onClose);
279
+ res.on('finish', () => {
280
+ const p = pending.get(id);
281
+ if (p) {
282
+ pending.delete(id);
283
+ clearTimeout(p.timer);
284
+ }
285
+ });
286
+ try {
287
+ bridge.ws.send(encodeFrame(frame));
288
+ stats.forwarded += 1;
289
+ }
290
+ catch (e) {
291
+ const err = e;
292
+ const p = pending.get(id);
293
+ if (p) {
294
+ pending.delete(id);
295
+ clearTimeout(p.timer);
296
+ }
297
+ stats.errors += 1;
298
+ sendRes(res, 502, { ok: false, error: 'bridge_send_failed', detail: err.message });
299
+ }
300
+ }
301
+ catch (e) {
302
+ const err = e;
303
+ stats.errors += 1;
304
+ logger.error('HTTP 处理异常', { error: err.message });
305
+ try {
306
+ sendRes(res, 500, { ok: false, error: 'internal_error' });
307
+ }
308
+ catch { /* ignore */ }
309
+ }
310
+ });
311
+ // ────────────────── WebSocket 层 ──────────────────
312
+ const wss = new WebSocketServer({
313
+ server: httpServer,
314
+ path: config.wsPath,
315
+ maxPayload: MAX_BODY_BYTES * 2,
316
+ });
317
+ wss.on('connection', (ws, req) => {
318
+ const peerId = `peer-${nextPeerId++}`;
319
+ peers.set(ws, {
320
+ peerId,
321
+ bridgeId: null,
322
+ userId: null,
323
+ registered: false,
324
+ connectedAt: new Date().toISOString(),
325
+ ip: req.socket.remoteAddress,
326
+ });
327
+ stats.connections += 1;
328
+ logger.info('peer connected', { peerId, ip: req.socket.remoteAddress });
329
+ // 连接后立即下发欢迎帧,便于客户端确认链路
330
+ sendWs(ws, {
331
+ type: FRAME.WELCOME,
332
+ peerId,
333
+ server: 'mn-ws-server',
334
+ wsPath: config.wsPath,
335
+ protocol: 1,
336
+ });
337
+ ws.isAlive = true;
338
+ ws.on('pong', () => { ws.isAlive = true; });
339
+ ws.on('message', (raw) => {
340
+ const msg = decodeFrame(raw);
341
+ if (!msg) {
342
+ stats.rejected += 1;
343
+ logger.warn('收到非法帧', { peerId });
344
+ sendWs(ws, { type: FRAME.ERROR, error: 'invalid_json' });
345
+ return;
346
+ }
347
+ const meta = peers.get(ws);
348
+ if (!meta)
349
+ return;
350
+ // ── 注册 ──
351
+ if (msg.type === FRAME.REGISTER) {
352
+ handleRegister(ws, meta, msg);
353
+ return;
354
+ }
355
+ // 未注册完成的连接不接受任何请求帧
356
+ if (!meta.registered) {
357
+ stats.rejected += 1;
358
+ sendWs(ws, { type: FRAME.ERROR, error: 'not_registered' });
359
+ return;
360
+ }
361
+ // ── 响应 / 错误 / 分块 ──
362
+ if (msg.type === FRAME.RESPONSE || msg.type === FRAME.ERROR || msg.type === FRAME.CHUNK) {
363
+ const p = pending.get(String(msg.id || ''));
364
+ if (!p) {
365
+ logger.warn('收到无主的响应帧', { peerId, id: msg.id });
366
+ return;
367
+ }
368
+ if (msg.type === FRAME.CHUNK) {
369
+ // 流式响应:第一块建响应,后续块追加
370
+ if (!p.chunks) {
371
+ p.chunks = [];
372
+ p.status = msg.status || 200;
373
+ p.resHeaders = msg.headers || {};
374
+ }
375
+ p.chunks.push(Buffer.from(String(msg.data || ''), 'base64'));
376
+ return;
377
+ }
378
+ pending.delete(String(msg.id));
379
+ clearTimeout(p.timer);
380
+ if (msg.type === FRAME.ERROR) {
381
+ // **错误帧不能走 writeBack**:那里按 msg.status 写响应头,
382
+ // 而 ERROR 帧没有 status(是 undefined → 0),writeHead(0) 抛错后
383
+ // 退化成「200 + 空 body」——客户端看到的是「请求成功了但没内容」。
384
+ // 这里明确回 502,并把错误原文带上。
385
+ stats.errors += 1;
386
+ logger.warn('本机执行失败', {
387
+ app: p.app, code: msg.code, error: msg.error, id: msg.id,
388
+ });
389
+ store.append({
390
+ app: p.app,
391
+ status: 502,
392
+ error: msg.error || msg.code,
393
+ durationMs: Date.now() - p.startedAt,
394
+ });
395
+ sendRes(p.res, 502, {
396
+ ok: false,
397
+ error: msg.error || msg.code || 'bridge_error',
398
+ code: msg.code || 'bridge_error',
399
+ app: p.app,
400
+ });
401
+ return;
402
+ }
403
+ writeBack(p, msg);
404
+ }
405
+ });
406
+ ws.on('close', () => {
407
+ const meta = peers.get(ws);
408
+ peers.delete(ws);
409
+ if (!meta)
410
+ return;
411
+ logger.info('peer disconnected', { peerId: meta.peerId, bridgeId: meta.bridgeId });
412
+ // 这个桥上的 app 全部摘掉 —— 留着就是僵尸路由
413
+ for (const [app, b] of [...bridges.entries()]) {
414
+ if (b.ws === ws)
415
+ bridges.delete(app);
416
+ }
417
+ // 该桥上还没回的请求全部失败
418
+ for (const [id, p] of [...pending.entries()]) {
419
+ if (p.bridgeId !== meta.bridgeId)
420
+ continue;
421
+ pending.delete(id);
422
+ clearTimeout(p.timer);
423
+ stats.errors += 1;
424
+ try {
425
+ sendRes(p.res, 502, { ok: false, error: 'bridge_disconnected' });
426
+ }
427
+ catch { /* ignore */ }
428
+ }
429
+ });
430
+ ws.on('error', (e) => {
431
+ logger.warn('peer socket 错误', { peerId, error: e && e.message });
432
+ });
433
+ });
434
+ /**
435
+ * 处理 client half 的注册帧。
436
+ *
437
+ * 鉴权与「HTTP 调用方」同一套:设备令牌 → 具名用户;静态门禁 → 全量。
438
+ * 注册时带上 app 表(app → base),服务端据此知道这台桥能桥什么。
439
+ */
440
+ function handleRegister(ws, meta, msg) {
441
+ const bearer = String(msg.token || '');
442
+ let userId = '';
443
+ let isGate = false;
444
+ const payload = auth.verifyDeviceToken(bearer);
445
+ if (payload) {
446
+ userId = String(payload.sub);
447
+ }
448
+ else if (config.token && bearer === config.token) {
449
+ userId = '@gate';
450
+ isGate = true;
451
+ }
452
+ else {
453
+ stats.rejected += 1;
454
+ logger.warn('rejected: 注册令牌无效', { peerId: meta.peerId });
455
+ sendWs(ws, { type: FRAME.ERROR, error: 'invalid_token' });
456
+ try {
457
+ ws.close(4403, 'invalid token');
458
+ }
459
+ catch { /* ignore */ }
460
+ return;
461
+ }
462
+ const apps = Array.isArray(msg.apps) ? msg.apps : [];
463
+ const clean = [];
464
+ for (const a of apps) {
465
+ const item = (a || {});
466
+ if (!item || typeof item.app !== 'string')
467
+ continue;
468
+ // 补全元信息(标题 / 端口)—— client 报的是它本地认定的元信息,
469
+ // server 只做归一化,不去猜。
470
+ clean.push(describeApp(item.app, String(item.base || '')));
471
+ }
472
+ // 同 app 被别的桥占用 → 顶替(旧桥的该 app 摘掉)
473
+ const replaced = [];
474
+ for (const { app } of clean) {
475
+ const prev = bridges.get(app);
476
+ if (prev && prev.ws !== ws) {
477
+ replaced.push(app);
478
+ bridges.delete(app);
479
+ logger.warn('同 app 被新桥顶替', { app, old: prev.bridgeId, next: meta.peerId });
480
+ }
481
+ }
482
+ for (const { app } of clean) {
483
+ knownApps.add(app);
484
+ const appMeta = clean.find((m) => m.app === app);
485
+ bridges.set(app, {
486
+ ws,
487
+ bridgeId: String(msg.bridgeId || meta.peerId),
488
+ userId,
489
+ isGate,
490
+ apps: clean,
491
+ meta: appMeta,
492
+ connectedAt: new Date().toISOString(),
493
+ ip: meta.ip,
494
+ });
495
+ }
496
+ meta.registered = true;
497
+ meta.bridgeId = String(msg.bridgeId || meta.peerId);
498
+ meta.userId = userId;
499
+ logger.info('bridge registered', {
500
+ peerId: meta.peerId, bridgeId: meta.bridgeId, userId, apps: clean.map((a) => a.app),
501
+ });
502
+ sendWs(ws, {
503
+ type: FRAME.REGISTERED,
504
+ bridgeId: meta.bridgeId,
505
+ apps: clean.map((a) => a.app),
506
+ replaced,
507
+ });
508
+ }
509
+ /** 把 client half 的响应原样写回给 HTTP 客户端。 */
510
+ function writeBack(p, msg) {
511
+ const body = Buffer.from(String(msg.body || ''), 'base64');
512
+ stats.bytesOut += body.length;
513
+ store.append({
514
+ app: p.app,
515
+ status: msg.status || 0,
516
+ bytes: body.length,
517
+ durationMs: Date.now() - p.startedAt,
518
+ });
519
+ try {
520
+ // 协议层的头值是数组;writeHead 只收字符串(set-cookie 例外,它要数组)
521
+ const headers = flattenHeaders(stripResponseHeaders(normalizeHeaders((msg.headers || {}))), ['set-cookie']);
522
+ // content-length 由我们自己算:body 可能被 base64 往返过
523
+ headers['content-length'] = String(body.length);
524
+ p.res.writeHead(msg.status || 200, headers);
525
+ if (p.chunks && p.chunks.length) {
526
+ p.res.write(Buffer.concat(p.chunks));
527
+ }
528
+ p.res.end(body);
529
+ }
530
+ catch (e) {
531
+ const err = e;
532
+ logger.warn('写回响应失败', { error: err.message });
533
+ try {
534
+ p.res.end();
535
+ }
536
+ catch { /* ignore */ }
537
+ }
538
+ }
539
+ // ────────────────── 工具 ──────────────────
540
+ /**
541
+ * 给 HTTP 客户端回一个 JSON。
542
+ *
543
+ * 注意:这个函数与下面的 [sendWs] **必须分开命名**。曾经两个都叫 sendJson,
544
+ * 后一个函数声明把前一个整个遮蔽掉 —— 于是所有 `sendRes(res, 502, {...})`
545
+ * 实际落到了 WS 版本(`ws.readyState` 在 ServerResponse 上是 undefined),
546
+ * 静默什么都不发,客户端表现为「请求挂死直到超时」。
547
+ */
548
+ function sendRes(res, code, obj) {
549
+ const payload = JSON.stringify(obj);
550
+ res.writeHead(code, {
551
+ 'content-type': 'application/json; charset=utf-8',
552
+ 'content-length': Buffer.byteLength(payload),
553
+ 'cache-control': 'no-store',
554
+ });
555
+ res.end(payload);
556
+ }
557
+ /** 给 WebSocket 客户端发一帧。 */
558
+ function sendWs(ws, obj) {
559
+ if (!ws || ws.readyState !== WebSocket.OPEN)
560
+ return false;
561
+ try {
562
+ ws.send(encodeFrame(obj));
563
+ return true;
564
+ }
565
+ catch {
566
+ return false;
567
+ }
568
+ }
569
+ function readBody(req) {
570
+ return new Promise((resolve, reject) => {
571
+ const chunks = [];
572
+ let size = 0;
573
+ req.on('data', (c) => {
574
+ size += c.length;
575
+ if (size > MAX_BODY_BYTES) {
576
+ reject(new Error('body_too_large'));
577
+ req.destroy();
578
+ return;
579
+ }
580
+ chunks.push(c);
581
+ });
582
+ req.on('end', () => resolve(Buffer.concat(chunks)));
583
+ req.on('error', reject);
584
+ });
585
+ }
586
+ // ────────────────── 鉴权判定 ──────────────────
587
+ /**
588
+ * 判定调用方身份(L1 通道层)。
589
+ *
590
+ * 凭据走**独立头** `X-Mn-Ws-Token`,**不占用 Authorization** ——
591
+ * Authorization 是留给 nx 自己的(L3 应用层,nx-kv 就是读
592
+ * `Authorization: Bearer <token>`)。两层用同一个头会互相打架:
593
+ * 门禁令牌被 nx 当成会话令牌去校验 → 401;反过来也一样。
594
+ */
595
+ function identifyCaller(req) {
596
+ const gate = gateTokenOf(req);
597
+ if (!gate) {
598
+ return { ok: false, status: 401, error: 'missing_mn_ws_token' };
599
+ }
600
+ // 设备令牌 → 具名用户,只能访问自己名下的桥
601
+ const payload = auth.verifyDeviceToken(gate);
602
+ if (payload) {
603
+ return { ok: true, userId: String(payload.sub), isGate: false };
604
+ }
605
+ // 静态门禁 → 全量访问(共享密钥,慎用)
606
+ if (config.token && gate === config.token) {
607
+ return { ok: true, userId: '@gate', isGate: true };
608
+ }
609
+ return { ok: false, status: 401, error: 'invalid_token' };
610
+ }
611
+ /** L1 凭据:只在 X-Mn-Ws-Token 里读。 */
612
+ function gateTokenOf(req) {
613
+ const h = req.headers['x-mn-ws-token'];
614
+ if (typeof h === 'string' && h.trim())
615
+ return h.trim();
616
+ if (Array.isArray(h) && h.length)
617
+ return String(h[0]).trim();
618
+ return '';
619
+ }
620
+ /** 认证中心的 access_token(只在 /auth/verify 用)。 */
621
+ function authorizationBearerOf(req) {
622
+ const h = req.headers.authorization;
623
+ if (typeof h === 'string' && h.toLowerCase().startsWith('bearer ')) {
624
+ return h.slice(7).trim();
625
+ }
626
+ return '';
627
+ }
628
+ // ────────────────── 生命周期 ──────────────────
629
+ /**
630
+ * 预热统一登录的发现文档(后台、幂等、带重试)。
631
+ *
632
+ * 为什么必须预热:`/auth/oidc/config` 报的 `idpAvailable` 含义是
633
+ * 「**成功拉到过**发现文档」,而不是「认证中心在线」。
634
+ * 不预热的话这个字段永远是 false —— 而部署脚本正是拿它断言
635
+ * 「统一登录确实生效」,于是一个完全正常的部署会被判成失败。
636
+ *
637
+ * 为什么带重试:容器可能比认证中心先起来(编排顺序不保证),
638
+ * 第一次失败就放弃 = 统一登录一直不可用,直到人工重启。
639
+ *
640
+ * 失败不固化缓存(AuthHub 只缓存成功的结果),所以重试是有效的。
641
+ */
642
+ function warmUpDiscovery() {
643
+ if (!auth.enabled)
644
+ return;
645
+ const attempt = (n) => {
646
+ auth.discover()
647
+ .then(() => {
648
+ logger.info('统一登录发现文档就绪', { issuer: auth.issuer, attempt: n });
649
+ })
650
+ .catch((e) => {
651
+ logger.warn('发现文档拉取失败,稍后重试', { attempt: n, error: e.message });
652
+ if (n < DISCOVERY_WARMUP_MAX_ATTEMPTS) {
653
+ const t = setTimeout(() => attempt(n + 1), DISCOVERY_WARMUP_RETRY_MS);
654
+ // unref:这个定时器不该拖住进程退出(测试里 close() 后要能立刻退干净)
655
+ if (typeof t.unref === 'function')
656
+ t.unref();
657
+ }
658
+ });
659
+ };
660
+ attempt(1);
661
+ }
662
+ function listen() {
663
+ return new Promise((resolve_, reject) => {
664
+ httpServer.once('error', reject);
665
+ httpServer.listen(config.port, config.host, () => {
666
+ const addr = httpServer.address();
667
+ const actual = typeof addr === 'object' && addr ? addr.port : config.port;
668
+ logger.info('mn-ws-server listening', {
669
+ port: actual,
670
+ host: config.host,
671
+ wsPath: config.wsPath,
672
+ authEnabled: config.authEnabled,
673
+ gateToken: !!config.token,
674
+ });
675
+ warmUpDiscovery();
676
+ resolve_(actual);
677
+ });
678
+ });
679
+ }
680
+ async function close() {
681
+ logger.info('shutting down');
682
+ for (const [, p] of pending) {
683
+ clearTimeout(p.timer);
684
+ try {
685
+ sendRes(p.res, 503, { ok: false, error: 'shutting_down' });
686
+ }
687
+ catch { /* ignore */ }
688
+ }
689
+ pending.clear();
690
+ for (const ws of wss.clients) {
691
+ try {
692
+ ws.close(1001, 'server shutting down');
693
+ }
694
+ catch { /* ignore */ }
695
+ }
696
+ await new Promise((r) => wss.close(r));
697
+ // ⚠️ 必须显式掐掉 keep-alive 连接:Node 19+ 的全局 agent 默认 keepAlive,
698
+ // 而 httpServer.close() 会**等这些空闲连接自己结束**才回调 ——
699
+ // 结果是 shutdown 永远不返回(生产上表现为 SIGTERM 后容器不退出,
700
+ // 只能等编排强杀)。pending 请求已经回了 503,这里断连接是安全的。
701
+ await new Promise((r) => {
702
+ httpServer.close(() => r());
703
+ httpServer.closeAllConnections();
704
+ });
705
+ logger.close();
706
+ }
707
+ return {
708
+ config,
709
+ logger,
710
+ store,
711
+ auth,
712
+ bridges,
713
+ knownApps,
714
+ pending,
715
+ peers,
716
+ stats,
717
+ listen,
718
+ close,
719
+ APP_HEADER: 'x-nx-app',
720
+ };
721
+ }
722
+ /** 发现文档预热的重试参数(次 / 毫秒)。 */
723
+ const DISCOVERY_WARMUP_MAX_ATTEMPTS = 10;
724
+ const DISCOVERY_WARMUP_RETRY_MS = 15_000;
725
+ //# sourceMappingURL=server.js.map