@myassis/gateway 1.0.86 → 1.0.88
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/api/index.js +5 -2
- package/dist/config/capabilities.js +46 -0
- package/dist/config/index.js +28 -3
- package/dist/main.js +64 -28
- package/dist/routes/auth.js +78 -0
- package/dist/routes/relay.js +173 -0
- package/dist/services/ServiceManager.js +9 -3
- package/dist/services/relay/LoopbackForwarder.js +292 -0
- package/dist/services/relay/RelayClient.js +541 -0
- package/dist/services/relay/protocol.js +526 -0
- package/dist/services/relay/relayConfig.js +64 -0
- package/package.json +1 -1
package/dist/api/index.js
CHANGED
|
@@ -16,6 +16,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
16
16
|
exports.settingsApi = exports.tasksApi = exports.modelsApi = exports.skillHubApi = exports.skillsApi = exports.authApi = exports.ApiError = exports.getServerBaseUrl = exports.getRequestToken = exports.runWithToken = void 0;
|
|
17
17
|
const crypto_1 = __importDefault(require("crypto"));
|
|
18
18
|
const async_hooks_1 = require("async_hooks");
|
|
19
|
+
const index_js_1 = require("../config/index.js");
|
|
19
20
|
// 多用户模式请求上下文:存储当前请求的 token
|
|
20
21
|
const requestContext = new async_hooks_1.AsyncLocalStorage();
|
|
21
22
|
function runWithToken(token, fn) {
|
|
@@ -32,8 +33,10 @@ function getServerBaseUrl() {
|
|
|
32
33
|
return SERVER_BASE_URL;
|
|
33
34
|
}
|
|
34
35
|
exports.getServerBaseUrl = getServerBaseUrl;
|
|
35
|
-
// Server
|
|
36
|
-
|
|
36
|
+
// Server 服务地址。必须走 appConfig:这里曾经自己又写了一个
|
|
37
|
+
// `|| 'http://localhost:3000'` 的默认值,与 config 里的 9091 分裂成两个事实,
|
|
38
|
+
// 结果是同一个网关进程里业务 API 和其他模块连到不同地址。
|
|
39
|
+
const SERVER_BASE_URL = index_js_1.appConfig.serverBaseUrl;
|
|
37
40
|
// 签名密钥(用于请求签名)
|
|
38
41
|
const SIGN_KEY = process.env.API_SIGN_KEY || 'gateway-secret-key';
|
|
39
42
|
// API 响应错误类
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Gateway 能力位
|
|
4
|
+
*
|
|
5
|
+
* Desktop 需要在连接前知道当前网关支持哪些链路能力,
|
|
6
|
+
* 才能决定是否展示「中继模式」入口、以及是否提示用户升级网关。
|
|
7
|
+
* 能力位通过 /health 暴露(无需鉴权,Desktop 在登录前即可探测)。
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.getFeatures = exports.getRelayStatus = exports.RELAY_SUPPORTED = exports.RELAY_PROTOCOL_VERSION = void 0;
|
|
11
|
+
const RelayClient_js_1 = require("../services/relay/RelayClient.js");
|
|
12
|
+
const relayConfig_js_1 = require("../services/relay/relayConfig.js");
|
|
13
|
+
const protocol_js_1 = require("../services/relay/protocol.js");
|
|
14
|
+
Object.defineProperty(exports, "RELAY_PROTOCOL_VERSION", { enumerable: true, get: function () { return protocol_js_1.RELAY_PROTOCOL_VERSION; } });
|
|
15
|
+
/**
|
|
16
|
+
* 本网关是否已内置中继客户端实现。
|
|
17
|
+
* 与 RelayClient 的存在与否绑定;Desktop 据此判断是否展示中继入口。
|
|
18
|
+
*/
|
|
19
|
+
exports.RELAY_SUPPORTED = true;
|
|
20
|
+
/** 汇总中继状态 */
|
|
21
|
+
function getRelayStatus() {
|
|
22
|
+
const credential = (0, relayConfig_js_1.getRelayCredential)();
|
|
23
|
+
const status = RelayClient_js_1.relayClient.getStatus();
|
|
24
|
+
return {
|
|
25
|
+
supported: exports.RELAY_SUPPORTED,
|
|
26
|
+
enabled: (0, relayConfig_js_1.isRelayEnabled)(),
|
|
27
|
+
connected: status.connected,
|
|
28
|
+
paired: credential !== null,
|
|
29
|
+
// gatewayId 是中继寻址标识,未配对时不暴露空串以免前端误判为已配对
|
|
30
|
+
gatewayId: credential?.gatewayId,
|
|
31
|
+
protocolVersion: protocol_js_1.RELAY_PROTOCOL_VERSION,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
exports.getRelayStatus = getRelayStatus;
|
|
35
|
+
/**
|
|
36
|
+
* 能力位列表。
|
|
37
|
+
* 采用 `名称@版本` 形式,便于 Desktop 做区间兼容判断。
|
|
38
|
+
*/
|
|
39
|
+
function getFeatures() {
|
|
40
|
+
const features = ['direct@1'];
|
|
41
|
+
if (exports.RELAY_SUPPORTED) {
|
|
42
|
+
features.push(`relay@${protocol_js_1.RELAY_PROTOCOL_VERSION}`);
|
|
43
|
+
}
|
|
44
|
+
return features;
|
|
45
|
+
}
|
|
46
|
+
exports.getFeatures = getFeatures;
|
package/dist/config/index.js
CHANGED
|
@@ -65,19 +65,31 @@ const envPath = getEnvPath();
|
|
|
65
65
|
if (envPath) {
|
|
66
66
|
(0, dotenv_1.config)({ path: envPath });
|
|
67
67
|
logger.info(`已加载环境变量文件: ${envPath}`);
|
|
68
|
+
logger.info(`当前工作目录: ${process.cwd()}`);
|
|
68
69
|
}
|
|
69
70
|
else {
|
|
70
|
-
logger.info(
|
|
71
|
+
logger.info(`未找到 .env 文件(工作目录: ${process.cwd()}),使用系统环境变量`);
|
|
71
72
|
}
|
|
72
73
|
// pkg 打包后自动设为 production
|
|
73
74
|
const isPackaged = !!process.pkg;
|
|
74
75
|
const defaultEnv = isPackaged ? 'production' : 'development';
|
|
76
|
+
/**
|
|
77
|
+
* Server 地址的环境默认值。
|
|
78
|
+
*
|
|
79
|
+
* 生产一律连云端域名,开发连本机。之前无论哪种环境都写死回落
|
|
80
|
+
* localhost,于是安装版一旦没注入 SERVER_BASE_URL 就默默去连本机,
|
|
81
|
+
* 表现为登录 500 「网络请求失败」—— 一个没有任何提示的错配。
|
|
82
|
+
*/
|
|
83
|
+
const PROD_SERVER_BASE_URL = 'https://api.my-assis.com';
|
|
84
|
+
const DEV_SERVER_BASE_URL = 'http://localhost:9091';
|
|
85
|
+
const resolvedNodeEnv = process.env.NODE_ENV || defaultEnv;
|
|
86
|
+
const defaultServerBaseUrl = resolvedNodeEnv === 'production' ? PROD_SERVER_BASE_URL : DEV_SERVER_BASE_URL;
|
|
75
87
|
// 环境变量配置
|
|
76
88
|
exports.appConfig = {
|
|
77
|
-
serverBaseUrl: process.env.SERVER_BASE_URL || '
|
|
89
|
+
serverBaseUrl: (process.env.SERVER_BASE_URL || defaultServerBaseUrl).replace(/\/+$/, ''),
|
|
78
90
|
port: parseInt(process.env.PORT || '3001', 10),
|
|
79
91
|
clientUrl: process.env.CLIENT_URL || 'http://localhost:3000',
|
|
80
|
-
nodeEnv:
|
|
92
|
+
nodeEnv: resolvedNodeEnv,
|
|
81
93
|
messageKeep: 4,
|
|
82
94
|
/** 摘要后保留的原始历史消息条数 */
|
|
83
95
|
historyKeep: parseInt(process.env.HISTORY_KEEP || '4', 10),
|
|
@@ -99,4 +111,17 @@ exports.appConfig = {
|
|
|
99
111
|
summaryTriggerChars: parseInt(process.env.SUMMARY_TRIGGER_CHARS || '60000', 10),
|
|
100
112
|
appName: '我的助手'
|
|
101
113
|
};
|
|
114
|
+
/**
|
|
115
|
+
* 启动时把实际生效的关键配置打出来。
|
|
116
|
+
*
|
|
117
|
+
* 之前只记了「已加载环境变量文件」,却不说最终跑的是哪个地址。
|
|
118
|
+
* 结果是生产服务误读开发 .env、连到局域网 IP 时,日志里毫无线索,
|
|
119
|
+
* 只能从一句登录 500「网络请求失败」倒推。
|
|
120
|
+
*
|
|
121
|
+
* 标注来源(env / 默认值)是故意的:「地址对不对」和
|
|
122
|
+
* 「地址是谁给的」是两个问题,后者才能区分「.env 没读到」与「.env 写错了」。
|
|
123
|
+
*/
|
|
124
|
+
logger.info(`生效配置: env=${exports.appConfig.nodeEnv}, port=${exports.appConfig.port}, ` +
|
|
125
|
+
`serverBaseUrl=${exports.appConfig.serverBaseUrl}` +
|
|
126
|
+
` (${process.env.SERVER_BASE_URL ? '来自 SERVER_BASE_URL' : '环境默认值'})`);
|
|
102
127
|
exports.default = exports.appConfig;
|
package/dist/main.js
CHANGED
|
@@ -10,6 +10,7 @@ const helmet_1 = __importDefault(require("helmet"));
|
|
|
10
10
|
const compression_1 = __importDefault(require("compression"));
|
|
11
11
|
const http_1 = __importDefault(require("http"));
|
|
12
12
|
const index_js_1 = require("./config/index.js");
|
|
13
|
+
const capabilities_js_1 = require("./config/capabilities.js");
|
|
13
14
|
const shared_1 = require("@myassis/shared");
|
|
14
15
|
const auth_js_1 = __importDefault(require("./routes/auth.js"));
|
|
15
16
|
const agent_js_1 = __importDefault(require("./routes/agent.js"));
|
|
@@ -24,12 +25,14 @@ const tasks_js_1 = __importDefault(require("./routes/tasks.js"));
|
|
|
24
25
|
const upload_js_1 = __importDefault(require("./routes/upload.js"));
|
|
25
26
|
const version_js_1 = __importDefault(require("./routes/version.js"));
|
|
26
27
|
const quota_js_1 = __importDefault(require("./routes/quota.js"));
|
|
28
|
+
const relay_js_1 = __importDefault(require("./routes/relay.js"));
|
|
27
29
|
const errorHandler_js_1 = require("./middleware/errorHandler.js");
|
|
28
30
|
const broadcast_js_1 = require("./middleware/broadcast.js");
|
|
29
31
|
const index_js_2 = require("./stores/index.js");
|
|
30
32
|
const persistStore_js_1 = require("./stores/persistStore.js");
|
|
31
33
|
const WebSocketService_js_1 = require("./services/WebSocketService.js");
|
|
32
34
|
const TaskSchedulerService_js_1 = require("./services/TaskSchedulerService.js");
|
|
35
|
+
const RelayClient_js_1 = require("./services/relay/RelayClient.js");
|
|
33
36
|
const ServiceManager_js_1 = require("./services/ServiceManager.js");
|
|
34
37
|
const logger = (0, shared_1.getLogger)('index');
|
|
35
38
|
// 内置放通的官方域名(含子域名),无需用户手动 addCors
|
|
@@ -165,26 +168,26 @@ if (cliCommand) {
|
|
|
165
168
|
}
|
|
166
169
|
}
|
|
167
170
|
else if (cliCommand === '--help' || cliCommand === '-h') {
|
|
168
|
-
console.log(`我的助手 Gateway CLI
|
|
169
|
-
|
|
170
|
-
用法: gateway <命令>
|
|
171
|
-
|
|
172
|
-
服务管理命令:
|
|
173
|
-
install 安装 Gateway 服务(后台运行)
|
|
174
|
-
uninstall 卸载 Gateway 服务
|
|
175
|
-
start 启动 Gateway 服务
|
|
176
|
-
stop 停止 Gateway 服务
|
|
177
|
-
restart 重启 Gateway 服务
|
|
178
|
-
update 更新 Gateway(需重新安装)
|
|
179
|
-
status 查看服务状态
|
|
180
|
-
|
|
181
|
-
CORS 管理命令:
|
|
182
|
-
addCors <域名> 添加允许的跨域域名
|
|
183
|
-
removeCors <域名> 移除允许的跨域域名
|
|
184
|
-
listCors 列出所有跨域域名(含内置域名)
|
|
185
|
-
|
|
186
|
-
其他:
|
|
187
|
-
--help, -h 显示本帮助信息
|
|
171
|
+
console.log(`我的助手 Gateway CLI
|
|
172
|
+
|
|
173
|
+
用法: gateway <命令>
|
|
174
|
+
|
|
175
|
+
服务管理命令:
|
|
176
|
+
install 安装 Gateway 服务(后台运行)
|
|
177
|
+
uninstall 卸载 Gateway 服务
|
|
178
|
+
start 启动 Gateway 服务
|
|
179
|
+
stop 停止 Gateway 服务
|
|
180
|
+
restart 重启 Gateway 服务
|
|
181
|
+
update 更新 Gateway(需重新安装)
|
|
182
|
+
status 查看服务状态
|
|
183
|
+
|
|
184
|
+
CORS 管理命令:
|
|
185
|
+
addCors <域名> 添加允许的跨域域名
|
|
186
|
+
removeCors <域名> 移除允许的跨域域名
|
|
187
|
+
listCors 列出所有跨域域名(含内置域名)
|
|
188
|
+
|
|
189
|
+
其他:
|
|
190
|
+
--help, -h 显示本帮助信息
|
|
188
191
|
`);
|
|
189
192
|
}
|
|
190
193
|
else {
|
|
@@ -272,7 +275,15 @@ else {
|
|
|
272
275
|
app.use(express_1.default.json({ limit: '10mb' }));
|
|
273
276
|
// Health check
|
|
274
277
|
app.get('/health', (req, res) => {
|
|
275
|
-
res.json({
|
|
278
|
+
res.json({
|
|
279
|
+
status: 'ok',
|
|
280
|
+
service: 'gateway',
|
|
281
|
+
version: '2.0.0',
|
|
282
|
+
wsOnline: WebSocketService_js_1.webSocketService.getOnlineCount(),
|
|
283
|
+
// 能力位:Desktop 据此决定是否展示中继入口、是否提示升级网关
|
|
284
|
+
features: (0, capabilities_js_1.getFeatures)(),
|
|
285
|
+
relay: (0, capabilities_js_1.getRelayStatus)(),
|
|
286
|
+
});
|
|
276
287
|
});
|
|
277
288
|
// 写请求成功后向该用户的其他终端广播数据变更(多终端同步)。
|
|
278
289
|
// 必须注册在业务路由之前:它通过包装 res.json 生效。
|
|
@@ -291,6 +302,7 @@ else {
|
|
|
291
302
|
app.use('/api/v1/upload', upload_js_1.default);
|
|
292
303
|
app.use('/api/v1/version', version_js_1.default);
|
|
293
304
|
app.use('/api/v1/quota', quota_js_1.default);
|
|
305
|
+
app.use('/api/v1/relay', relay_js_1.default);
|
|
294
306
|
// Load auth from persistent storage on startup
|
|
295
307
|
index_js_2.authStore.load();
|
|
296
308
|
// Error handler
|
|
@@ -301,9 +313,17 @@ else {
|
|
|
301
313
|
const configuredPort = index_js_1.appConfig.port;
|
|
302
314
|
let schedulerStarted = false;
|
|
303
315
|
let webSocketInitialized = false;
|
|
316
|
+
let relayStarted = false;
|
|
304
317
|
const startServer = (port) => {
|
|
305
318
|
const handleListenError = (err) => {
|
|
306
319
|
server.removeListener('error', handleListenError);
|
|
320
|
+
// 关键:listen(port, cb) 以 once('listening') 注册 cb,而端口占用走的是 error
|
|
321
|
+
// 事件,cb 并不会被移除、会一直挂在同一个 server 上。不在这里摘掉的话,
|
|
322
|
+
// 下一个端口监听成功时**上一次的 cb 也会触发**,且其闭包里的 port 是旧值。
|
|
323
|
+
// 实测症状:先打印「running on port 3001」再打印「3001 被占用,回退 3002」,
|
|
324
|
+
// 并把 3001 当作实际端口交给中继回环,请求于是被转发到同机的另一个网关进程
|
|
325
|
+
// (隧道在线、/health 正常,但所有 /api/v1/* 返回 404)。
|
|
326
|
+
server.removeListener('listening', handleListening);
|
|
307
327
|
if (err.code === 'EADDRINUSE') {
|
|
308
328
|
const nextPort = port + 1;
|
|
309
329
|
if (nextPort <= (configuredPort + 10)) {
|
|
@@ -319,14 +339,17 @@ else {
|
|
|
319
339
|
logger.error(`Server error: ${err.message}`);
|
|
320
340
|
process.exit(1);
|
|
321
341
|
};
|
|
322
|
-
|
|
323
|
-
server.listen(port, () => {
|
|
342
|
+
const handleListening = () => {
|
|
324
343
|
server.removeListener('error', handleListenError);
|
|
325
|
-
|
|
326
|
-
|
|
344
|
+
// 实际端口只认 server.address():闭包里的 port 只是「这次尝试的端口」,不等于
|
|
345
|
+
// 「最终监听的端口」。回环转发与 WS 广播都依赖它,取错就会打到别的进程上。
|
|
346
|
+
const address = server.address();
|
|
347
|
+
const actualPort = typeof address === 'object' && address ? address.port : port;
|
|
348
|
+
if (actualPort !== configuredPort) {
|
|
349
|
+
logger.info(`Port ${configuredPort} is in use, fallback to port ${actualPort}`);
|
|
327
350
|
}
|
|
328
351
|
// 动态更新 appConfig.port(供 WebSocket 广播实际端口)
|
|
329
|
-
index_js_1.appConfig.port =
|
|
352
|
+
index_js_1.appConfig.port = actualPort;
|
|
330
353
|
// HTTP Server 监听成功后再初始化 WebSocket,避免端口冲突时 WebSocketServer 误报 EADDRINUSE
|
|
331
354
|
if (!webSocketInitialized) {
|
|
332
355
|
WebSocketService_js_1.webSocketService.initialize(server);
|
|
@@ -337,16 +360,27 @@ else {
|
|
|
337
360
|
TaskSchedulerService_js_1.taskSchedulerService.start();
|
|
338
361
|
schedulerStarted = true;
|
|
339
362
|
}
|
|
340
|
-
|
|
363
|
+
// 中继客户端必须在此启动而非模块顶层:回环转发需要**实际**监听端口,
|
|
364
|
+
// 而端口存在被占用后自动顺延的逻辑(3001 → 3002…),配置值不可信。
|
|
365
|
+
if (!relayStarted) {
|
|
366
|
+
RelayClient_js_1.relayClient.start(actualPort);
|
|
367
|
+
relayStarted = true;
|
|
368
|
+
}
|
|
369
|
+
logger.info(`我的助手 Gateway Service running on port ${actualPort}`);
|
|
341
370
|
// 启动自愈:检查开机自启 Run key 是否健康,不健康则自动 rewrite。
|
|
342
371
|
// 异步执行,失败不影响主流程。
|
|
343
372
|
(0, ServiceManager_js_1.ensureAutoStartHealthy)().catch(() => { });
|
|
344
|
-
}
|
|
373
|
+
};
|
|
374
|
+
server.once('error', handleListenError);
|
|
375
|
+
server.once('listening', handleListening);
|
|
376
|
+
server.listen(port);
|
|
345
377
|
};
|
|
346
378
|
startServer(configuredPort);
|
|
347
379
|
// 优雅关闭
|
|
348
380
|
process.on('SIGTERM', () => {
|
|
349
381
|
logger.info('收到 SIGTERM,正在关闭...');
|
|
382
|
+
// 先停中继:让远端客户端立刻收到明确的流中止,而不是等隧道超时
|
|
383
|
+
RelayClient_js_1.relayClient.stop('网关正在关闭');
|
|
350
384
|
WebSocketService_js_1.webSocketService.shutdown();
|
|
351
385
|
TaskSchedulerService_js_1.taskSchedulerService.stop();
|
|
352
386
|
server.close(() => {
|
|
@@ -356,6 +390,8 @@ else {
|
|
|
356
390
|
});
|
|
357
391
|
process.on('SIGINT', () => {
|
|
358
392
|
logger.info('收到 SIGINT,正在关闭...');
|
|
393
|
+
// 先停中继:让远端客户端立刻收到明确的流中止,而不是等隧道超时
|
|
394
|
+
RelayClient_js_1.relayClient.stop('网关正在关闭');
|
|
359
395
|
WebSocketService_js_1.webSocketService.shutdown();
|
|
360
396
|
TaskSchedulerService_js_1.taskSchedulerService.stop();
|
|
361
397
|
server.close(() => {
|
package/dist/routes/auth.js
CHANGED
|
@@ -7,6 +7,7 @@ const auth_js_1 = require("../middleware/auth.js");
|
|
|
7
7
|
const index_js_3 = require("../api/index.js");
|
|
8
8
|
const shared_1 = require("@myassis/shared");
|
|
9
9
|
const system_js_1 = require("@myassis/shared/dist/utils/system.js");
|
|
10
|
+
const relayConfig_js_1 = require("../services/relay/relayConfig.js");
|
|
10
11
|
const logger = (0, shared_1.getLogger)('auth');
|
|
11
12
|
const router = (0, express_1.Router)();
|
|
12
13
|
// Get publish token - 获取发布技能库所需的 token
|
|
@@ -201,4 +202,81 @@ router.post('/account/delete', auth_js_1.requireAuth, async (req, res) => {
|
|
|
201
202
|
});
|
|
202
203
|
}
|
|
203
204
|
});
|
|
205
|
+
/**
|
|
206
|
+
* POST /api/v1/auth/adopt-token
|
|
207
|
+
*
|
|
208
|
+
* 让网关「采信」一个由 Server 签发的 accessToken。
|
|
209
|
+
*
|
|
210
|
+
* 为什么需要它:中继模式下 Desktop 无法向网关登录 —— 网关的 /auth/login 会把
|
|
211
|
+
* 凭据转发给 Server 换 token,但走中继访问网关本身就要求先有 Server 的 JWT,
|
|
212
|
+
* 形成死锁。同时网关的 requireAuth 只认自己 authStore 里的 token,
|
|
213
|
+
* 因此即便 Desktop 直接向 Server 登录成功,后续中继请求在网关侧仍是 401。
|
|
214
|
+
*
|
|
215
|
+
* 解法:Desktop 在中继模式下直连 Server 登录(Server 本就是签发方),
|
|
216
|
+
* 再用拿到的 token 调本接口,由网关向 Server 验签并确认属主后写入 authStore。
|
|
217
|
+
*
|
|
218
|
+
* 三道防线(缺一不可):
|
|
219
|
+
* 1. 本接口不能用 requireAuth(此时 token 尚未被采信),改为向 Server 的
|
|
220
|
+
* /auth/me 验签 —— 网关不持有 JWT_SECRET,无法自行验签,这与既有设计一致;
|
|
221
|
+
* 2. 校验 token 属主必须等于配对时记录的 userId。否则任何知道 gatewayId 的人
|
|
222
|
+
* 都能用自己的账号让别人的网关采信自己的 token,等于远程登录别人的电脑;
|
|
223
|
+
* 3. 走中继到达这里的请求,已先被 Server 的 hasGatewayAccess 拦过一层授权。
|
|
224
|
+
*/
|
|
225
|
+
router.post('/adopt-token', async (req, res) => {
|
|
226
|
+
try {
|
|
227
|
+
const { accessToken, refreshToken, expiresIn } = req.body ?? {};
|
|
228
|
+
if (typeof accessToken !== 'string' || !accessToken) {
|
|
229
|
+
res.status(400).json({ success: false, error: 'Missing accessToken' });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const credential = (0, relayConfig_js_1.getRelayCredential)();
|
|
233
|
+
if (!credential) {
|
|
234
|
+
// 未配对的网关不应接受任何令牌注入:此时没有「属主」可供比对
|
|
235
|
+
res.status(403).json({ success: false, error: 'Gateway is not paired' });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
// 向 Server 验签并取得该 token 的真实属主
|
|
239
|
+
let user;
|
|
240
|
+
try {
|
|
241
|
+
const me = await (0, index_js_3.runWithToken)(accessToken, () => index_js_1.authApi.me(accessToken));
|
|
242
|
+
user = me?.data ?? me?.user;
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
logger.warn('adopt-token 验签失败:', error?.message);
|
|
246
|
+
res.status(401).json({ success: false, error: 'Invalid token' });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!user?.id) {
|
|
250
|
+
res.status(401).json({ success: false, error: 'Invalid token' });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// 属主校验:只有配对该网关的用户能让它采信自己的令牌。
|
|
254
|
+
// 注意这里必须对「userId 缺失」也拒绝,而不是用 `credential.userId &&` 短路 ——
|
|
255
|
+
// 否则一条没有属主的旧凭据(早期版本遗留,或 persistStore 被手工改过)
|
|
256
|
+
// 会让本接口退化成「任何人都能注入令牌」,属于典型的 fail-open。
|
|
257
|
+
if (!credential.userId || String(user.id) !== String(credential.userId)) {
|
|
258
|
+
logger.warn(`adopt-token 属主校验失败: token=${user.id} paired=${credential.userId ?? '(缺失)'}`);
|
|
259
|
+
// 与越权访问返回同样笼统的 403,不透出属主信息
|
|
260
|
+
res.status(403).json({ success: false, error: 'Forbidden' });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
index_js_2.authStore.save({
|
|
264
|
+
accessToken,
|
|
265
|
+
refreshToken: typeof refreshToken === 'string' ? refreshToken : undefined,
|
|
266
|
+
expiresIn: typeof expiresIn === 'number' ? expiresIn : undefined,
|
|
267
|
+
expiresAt: typeof expiresIn === 'number' ? Date.now() + expiresIn * 1000 : undefined,
|
|
268
|
+
user: {
|
|
269
|
+
id: String(user.id),
|
|
270
|
+
nickname: String(user.nickname ?? user.account ?? ''),
|
|
271
|
+
account: String(user.account ?? ''),
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
logger.info(`已采信中继登录令牌 user=${user.id}`);
|
|
275
|
+
res.json({ success: true, data: { user } });
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
logger.error('adopt-token error:', error);
|
|
279
|
+
res.status(500).json({ success: false, error: error.message || 'adopt-token failed' });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
204
282
|
exports.default = router;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 中继管理路由
|
|
4
|
+
*
|
|
5
|
+
* 供 Desktop 在**直连模式下**管理中继:查看状态、开关、与 Server 完成配对。
|
|
6
|
+
* 注意配对必须先直连一次网关来完成(需要用户在本机确认),
|
|
7
|
+
* 这是「知道 gatewayId 就能远程敲别人电脑」的核心防线。
|
|
8
|
+
*/
|
|
9
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
|
+
};
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.consumePairCode = void 0;
|
|
14
|
+
const express_1 = require("express");
|
|
15
|
+
const axios_1 = __importDefault(require("axios"));
|
|
16
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
17
|
+
const shared_1 = require("@myassis/shared");
|
|
18
|
+
const auth_js_1 = require("../middleware/auth.js");
|
|
19
|
+
const RelayClient_js_1 = require("../services/relay/RelayClient.js");
|
|
20
|
+
const relayConfig_js_1 = require("../services/relay/relayConfig.js");
|
|
21
|
+
const protocol_js_1 = require("../services/relay/protocol.js");
|
|
22
|
+
const logger = (0, shared_1.getLogger)('routes/relay');
|
|
23
|
+
const router = (0, express_1.Router)();
|
|
24
|
+
/** 配对码有效期:足够用户在另一台设备上输入,又不至于长期可用 */
|
|
25
|
+
const PAIR_CODE_TTL_MS = 5 * 60 * 1000;
|
|
26
|
+
/**
|
|
27
|
+
* 一次性配对码(仅存内存)。
|
|
28
|
+
* 不落盘是有意的:网关重启后旧码即失效,减少泄露窗口。
|
|
29
|
+
*/
|
|
30
|
+
let pairCode = null;
|
|
31
|
+
/** 生成 6 位数字配对码,用 crypto 而非 Math.random 避免可预测 */
|
|
32
|
+
function generatePairCode() {
|
|
33
|
+
return String(crypto_1.default.randomInt(0, 1000000)).padStart(6, '0');
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* GET /api/v1/relay/status
|
|
37
|
+
* 中继状态。不需要鉴权:Desktop 在登录前也要据此决定展示哪些入口。
|
|
38
|
+
*/
|
|
39
|
+
router.get('/status', (req, res) => {
|
|
40
|
+
const status = RelayClient_js_1.relayClient.getStatus();
|
|
41
|
+
res.json({
|
|
42
|
+
success: true,
|
|
43
|
+
data: {
|
|
44
|
+
enabled: (0, relayConfig_js_1.isRelayEnabled)(),
|
|
45
|
+
paired: (0, relayConfig_js_1.getRelayCredential)() !== null,
|
|
46
|
+
relayUrl: (0, relayConfig_js_1.getRelayBaseUrl)(),
|
|
47
|
+
protocolVersion: protocol_js_1.RELAY_PROTOCOL_VERSION,
|
|
48
|
+
...status,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
/**
|
|
53
|
+
* POST /api/v1/relay/pair
|
|
54
|
+
* 与 Server 完成配对:用当前登录用户的 token 注册本网关,换回 gatewayId 与 secret。
|
|
55
|
+
*/
|
|
56
|
+
router.post('/pair', auth_js_1.requireAuth, async (req, res) => {
|
|
57
|
+
try {
|
|
58
|
+
const relayUrl = (0, relayConfig_js_1.getRelayBaseUrl)();
|
|
59
|
+
if (!relayUrl) {
|
|
60
|
+
res.status(500).json({ success: false, error: '未配置中继入口地址(RELAY_URL / SERVER_BASE_URL)' });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const name = typeof req.body?.name === 'string' && req.body.name.trim()
|
|
64
|
+
? req.body.name.trim()
|
|
65
|
+
: `${process.platform}-gateway`;
|
|
66
|
+
const response = await axios_1.default.post(`${relayUrl}/api/v1/gateways/register`, { name, platform: process.platform, version: getLocalVersion() }, {
|
|
67
|
+
timeout: 15000,
|
|
68
|
+
headers: { Authorization: `Bearer ${req.token}`, 'Content-Type': 'application/json' },
|
|
69
|
+
});
|
|
70
|
+
const data = response.data?.data;
|
|
71
|
+
if (!data?.gatewayId || !data?.secret) {
|
|
72
|
+
res.status(502).json({ success: false, error: 'Server 未返回有效的配对凭据' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// 属主必须记下来:它是 adopt-token 判断「谁有权让本网关采信令牌」的唯一依据。
|
|
76
|
+
// requireAuth 正常情况下一定填了 req.userId,但这里不做乐观假设 —— 一条缺属主的
|
|
77
|
+
// 凭据会让后续属主校验失去依据,宁可当场失败。
|
|
78
|
+
if (!req.userId) {
|
|
79
|
+
res.status(500).json({ success: false, error: '无法确定配对用户,请重新登录后再试' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
(0, relayConfig_js_1.setRelayCredential)({
|
|
83
|
+
gatewayId: data.gatewayId,
|
|
84
|
+
secret: data.secret,
|
|
85
|
+
pairedAt: Date.now(),
|
|
86
|
+
userId: String(req.userId),
|
|
87
|
+
});
|
|
88
|
+
logger.info(`配对成功 gatewayId=${data.gatewayId}`);
|
|
89
|
+
// 配对不隐含开启:是否启用外网可达仍交由用户显式决定
|
|
90
|
+
res.json({ success: true, data: { gatewayId: data.gatewayId, enabled: (0, relayConfig_js_1.isRelayEnabled)() } });
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
logger.error('配对失败:', error);
|
|
94
|
+
res.status(error.response?.status || 500).json({
|
|
95
|
+
success: false,
|
|
96
|
+
error: error.response?.data?.error || error.message || '配对失败',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
/**
|
|
101
|
+
* POST /api/v1/relay/enable
|
|
102
|
+
* 开启中继并立即建连。
|
|
103
|
+
*/
|
|
104
|
+
router.post('/enable', auth_js_1.requireAuth, (req, res) => {
|
|
105
|
+
if (!(0, relayConfig_js_1.getRelayCredential)()) {
|
|
106
|
+
res.status(400).json({ success: false, error: '尚未配对,请先调用 /relay/pair' });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
(0, relayConfig_js_1.setRelayEnabled)(true);
|
|
110
|
+
RelayClient_js_1.relayClient.enable();
|
|
111
|
+
res.json({ success: true, data: RelayClient_js_1.relayClient.getStatus() });
|
|
112
|
+
});
|
|
113
|
+
/**
|
|
114
|
+
* POST /api/v1/relay/disable
|
|
115
|
+
* 关闭中继并断开隧道。
|
|
116
|
+
*/
|
|
117
|
+
router.post('/disable', auth_js_1.requireAuth, (req, res) => {
|
|
118
|
+
(0, relayConfig_js_1.setRelayEnabled)(false);
|
|
119
|
+
RelayClient_js_1.relayClient.stop('用户关闭中继');
|
|
120
|
+
res.json({ success: true, data: RelayClient_js_1.relayClient.getStatus() });
|
|
121
|
+
});
|
|
122
|
+
/**
|
|
123
|
+
* POST /api/v1/relay/unpair
|
|
124
|
+
* 解绑:清除本地凭据并断开。Server 侧吊销由 Desktop 另行调用,
|
|
125
|
+
* 此处不代劳——网关可能已无法访问 Server,不能因此阻塞本地清理。
|
|
126
|
+
*/
|
|
127
|
+
router.post('/unpair', auth_js_1.requireAuth, (req, res) => {
|
|
128
|
+
(0, relayConfig_js_1.setRelayEnabled)(false);
|
|
129
|
+
RelayClient_js_1.relayClient.stop('已解绑');
|
|
130
|
+
(0, relayConfig_js_1.clearRelayCredential)();
|
|
131
|
+
res.json({ success: true });
|
|
132
|
+
});
|
|
133
|
+
/**
|
|
134
|
+
* POST /api/v1/relay/pair-code
|
|
135
|
+
* 生成一次性配对码,供用户在新设备(尤其是网页版)上确认接入。
|
|
136
|
+
*/
|
|
137
|
+
router.post('/pair-code', auth_js_1.requireAuth, (req, res) => {
|
|
138
|
+
pairCode = { code: generatePairCode(), expiresAt: Date.now() + PAIR_CODE_TTL_MS };
|
|
139
|
+
res.json({ success: true, data: { code: pairCode.code, expiresAt: pairCode.expiresAt } });
|
|
140
|
+
});
|
|
141
|
+
/**
|
|
142
|
+
* 校验配对码(供中继链路上的接入确认使用)。
|
|
143
|
+
* 一次性:校验通过即失效,避免被重复利用。
|
|
144
|
+
*/
|
|
145
|
+
function consumePairCode(code) {
|
|
146
|
+
if (!pairCode)
|
|
147
|
+
return false;
|
|
148
|
+
if (Date.now() > pairCode.expiresAt) {
|
|
149
|
+
pairCode = null;
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
// 定长比较,避免通过响应时间侧信道逐位猜测
|
|
153
|
+
const ok = code.length === pairCode.code.length &&
|
|
154
|
+
crypto_1.default.timingSafeEqual(Buffer.from(code), Buffer.from(pairCode.code));
|
|
155
|
+
if (ok)
|
|
156
|
+
pairCode = null;
|
|
157
|
+
return ok;
|
|
158
|
+
}
|
|
159
|
+
exports.consumePairCode = consumePairCode;
|
|
160
|
+
/** 读取网关自身版本 */
|
|
161
|
+
function getLocalVersion() {
|
|
162
|
+
try {
|
|
163
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
164
|
+
const { readFileSync } = require('fs');
|
|
165
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
166
|
+
const { resolve } = require('path');
|
|
167
|
+
return JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')).version || 'unknown';
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return 'unknown';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
exports.default = router;
|
|
@@ -108,14 +108,20 @@ function getGatewayArgs() {
|
|
|
108
108
|
/**
|
|
109
109
|
* Gateway 运行的工作目录。
|
|
110
110
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
111
|
+
* - 打包为 exe:exe 所在目录;
|
|
112
|
+
* - node 启动:入口脚本(dist/index.js)所在目录,即 dist。
|
|
113
|
+
*
|
|
114
|
+
* 两者都是「产物所在目录」,于是 getEnvPath() 的第一条规则
|
|
115
|
+
* `process.cwd()/.env` 恰好命中随产物发布的 dist/.env(生产配置)。
|
|
116
|
+
*
|
|
117
|
+
* 不能再向上退一层到「包根」:包根下躺着开发用的 .env,
|
|
118
|
+
* 生产服务会因此读到 SERVER_BASE_URL=局域网 IP,且毫无提示(见 f126be961)。
|
|
113
119
|
*/
|
|
114
120
|
function getGatewayWorkDir() {
|
|
115
121
|
if (isPackagedExe())
|
|
116
122
|
return path_1.default.dirname(process.execPath);
|
|
117
123
|
const script = getServiceScript();
|
|
118
|
-
return script ? path_1.default.dirname(
|
|
124
|
+
return script ? path_1.default.dirname(script) : path_1.default.dirname(process.execPath);
|
|
119
125
|
}
|
|
120
126
|
/**
|
|
121
127
|
* 检查 Gateway 进程是否在运行
|