@chenpu17/tcpmapping 2.1.1 → 2.4.1
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 +108 -0
- package/README.md +113 -10
- package/bin/tcpmapping.js +476 -176
- package/docs/API.md +532 -0
- package/docs/screenshots/dashboard-en-dark.webp +0 -0
- package/docs/screenshots/dashboard-mobile-zh.webp +0 -0
- package/docs/screenshots/dashboard-zh-light.webp +0 -0
- package/docs/screenshots/login-zh-light.webp +0 -0
- package/docs//345/212/237/350/203/275/346/200/273/347/273/223.md +200 -0
- package/docs//345/256/211/345/205/250/351/205/215/347/275/256/350/257/264/346/230/216.md +154 -0
- package/package.json +6 -4
- package/public/common.js +411 -0
- package/public/dashboard.html +1624 -437
- package/public/login.html +430 -107
- package/server.js +1219 -464
- package/setup-password.js +137 -57
package/server.js
CHANGED
|
@@ -2,13 +2,63 @@ const net = require('net');
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const os = require('os');
|
|
5
|
+
const crypto = require('crypto');
|
|
5
6
|
const express = require('express');
|
|
6
7
|
const session = require('express-session');
|
|
7
8
|
const bcrypt = require('bcryptjs');
|
|
8
|
-
const bodyParser = require('body-parser');
|
|
9
9
|
|
|
10
10
|
// 数据目录 - 用于存储用户配置(持久化)
|
|
11
|
-
const DATA_DIR = path.join(os.homedir(), '.tcpmapping');
|
|
11
|
+
const DATA_DIR = process.env.TCPMAPPING_DATA_DIR || path.join(os.homedir(), '.tcpmapping');
|
|
12
|
+
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
13
|
+
const SESSION_NAME = 'tcpmapping.sid';
|
|
14
|
+
const VERSION = require('./package.json').version;
|
|
15
|
+
|
|
16
|
+
// 默认认证策略(仅在认证配置缺少对应字段时补齐,不会创建默认用户)
|
|
17
|
+
const DEFAULT_PASSWORD_POLICY = {
|
|
18
|
+
minLength: 8,
|
|
19
|
+
requireUppercase: true,
|
|
20
|
+
requireLowercase: true,
|
|
21
|
+
requireNumbers: true,
|
|
22
|
+
requireSpecialChars: false,
|
|
23
|
+
passwordExpireDays: 90
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const DEFAULT_SESSION_CONFIG = {
|
|
27
|
+
sessionTimeout: 24,
|
|
28
|
+
maxFailedAttempts: 5,
|
|
29
|
+
lockoutDuration: 30
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// 日志级别定义
|
|
33
|
+
const LOG_LEVELS = {
|
|
34
|
+
error: 0,
|
|
35
|
+
warn: 1,
|
|
36
|
+
info: 2,
|
|
37
|
+
debug: 3
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// 全局变量
|
|
41
|
+
let config = {};
|
|
42
|
+
const servers = new Map(); // key(host:port) -> { server, mapping }
|
|
43
|
+
const connections = new Map(); // 连接ID -> 连接信息(可序列化)
|
|
44
|
+
const connectionSockets = new Map(); // 连接ID -> { clientSocket, targetSocket, finalize }
|
|
45
|
+
const mappingStats = new Map(); // 映射ID -> 累计统计
|
|
46
|
+
const connectionHistory = []; // 最近关闭的连接记录(有界)
|
|
47
|
+
const pendingStarts = new Set(); // 正在启动的映射
|
|
48
|
+
const loginAttempts = new Map(); // IP -> { count, lastAttempt }
|
|
49
|
+
const processTraffic = { received: 0, sent: 0 }; // 进程启动以来的累计流量
|
|
50
|
+
|
|
51
|
+
const CONNECTION_HISTORY_LIMIT = 500; // 最多保留的已关闭连接记录数
|
|
52
|
+
|
|
53
|
+
// 认证配置
|
|
54
|
+
let authConfig = null;
|
|
55
|
+
|
|
56
|
+
// 用于防止通过响应时间枚举用户名
|
|
57
|
+
let dummyPasswordHash = null;
|
|
58
|
+
|
|
59
|
+
// HTTP 服务实例
|
|
60
|
+
let httpServer = null;
|
|
61
|
+
let shuttingDown = false;
|
|
12
62
|
|
|
13
63
|
// 获取配置文件路径(按优先级搜索)
|
|
14
64
|
function getConfigPath(filename) {
|
|
@@ -49,107 +99,143 @@ function getConfigSavePath(filename) {
|
|
|
49
99
|
// 确保数据目录存在
|
|
50
100
|
function ensureDataDir() {
|
|
51
101
|
if (!fs.existsSync(DATA_DIR)) {
|
|
52
|
-
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
53
|
-
console.log(`创建配置目录: ${DATA_DIR}`);
|
|
102
|
+
fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
|
|
54
103
|
}
|
|
55
104
|
}
|
|
56
105
|
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
106
|
+
// 原子写入文件,避免写入过程中崩溃导致配置损坏
|
|
107
|
+
function writeFileAtomic(filePath, data, mode) {
|
|
108
|
+
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
109
|
+
const options = mode ? { mode } : undefined;
|
|
110
|
+
fs.writeFileSync(tmpPath, data, options);
|
|
111
|
+
try {
|
|
112
|
+
fs.renameSync(tmpPath, filePath);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
try {
|
|
115
|
+
fs.unlinkSync(tmpPath);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// 忽略清理失败
|
|
118
|
+
}
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
62
122
|
|
|
63
|
-
//
|
|
64
|
-
|
|
123
|
+
// 查找认证配置文件(仅限用户目录,不使用包安装目录)
|
|
124
|
+
function findAuthConfigPath() {
|
|
125
|
+
const candidates = [
|
|
126
|
+
path.join(process.cwd(), 'auth-config.json'),
|
|
127
|
+
path.join(DATA_DIR, 'auth-config.json')
|
|
128
|
+
];
|
|
65
129
|
|
|
66
|
-
|
|
67
|
-
|
|
130
|
+
for (const candidate of candidates) {
|
|
131
|
+
if (fs.existsSync(candidate)) {
|
|
132
|
+
return candidate;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
68
138
|
|
|
69
139
|
// 加载认证配置
|
|
70
140
|
function loadAuthConfig() {
|
|
141
|
+
const authConfigPath = findAuthConfigPath();
|
|
142
|
+
if (!authConfigPath) {
|
|
143
|
+
console.error('警告: 未找到 auth-config.json,WebUI 管理界面将不可用');
|
|
144
|
+
console.error('请运行 "tcpmapping setup-password" 或 "npm run setup-password" 设置管理员密码');
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
71
148
|
try {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
149
|
+
const parsed = JSON.parse(fs.readFileSync(authConfigPath, 'utf8'));
|
|
150
|
+
|
|
151
|
+
if (!Array.isArray(parsed.users) || parsed.users.length === 0) {
|
|
152
|
+
console.error(`警告: 认证配置 ${authConfigPath} 中没有有效用户,WebUI 管理界面将不可用`);
|
|
153
|
+
return null;
|
|
75
154
|
}
|
|
76
|
-
|
|
155
|
+
|
|
156
|
+
// 补齐策略配置,兼容旧版本配置
|
|
157
|
+
parsed.passwordPolicy = { ...DEFAULT_PASSWORD_POLICY, ...(parsed.passwordPolicy || {}) };
|
|
158
|
+
parsed.sessionConfig = { ...DEFAULT_SESSION_CONFIG, ...(parsed.sessionConfig || {}) };
|
|
159
|
+
|
|
77
160
|
console.log(`加载认证配置: ${authConfigPath}`);
|
|
78
|
-
return
|
|
161
|
+
return parsed;
|
|
79
162
|
} catch (error) {
|
|
80
|
-
console.error(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// 返回默认配置(仅用于向后兼容)
|
|
84
|
-
return {
|
|
85
|
-
users: [{
|
|
86
|
-
username: 'admin',
|
|
87
|
-
passwordHash: bcrypt.hashSync('admin123', 10),
|
|
88
|
-
role: 'admin',
|
|
89
|
-
lastPasswordChange: new Date().toISOString(),
|
|
90
|
-
enabled: true
|
|
91
|
-
}],
|
|
92
|
-
passwordPolicy: {
|
|
93
|
-
minLength: 8,
|
|
94
|
-
requireUppercase: true,
|
|
95
|
-
requireLowercase: true,
|
|
96
|
-
requireNumbers: true,
|
|
97
|
-
requireSpecialChars: false,
|
|
98
|
-
passwordExpireDays: 90
|
|
99
|
-
},
|
|
100
|
-
sessionConfig: {
|
|
101
|
-
sessionTimeout: 24,
|
|
102
|
-
maxFailedAttempts: 5,
|
|
103
|
-
lockoutDuration: 30
|
|
104
|
-
}
|
|
105
|
-
};
|
|
163
|
+
console.error(`警告: 读取认证配置失败 (${error.message}),WebUI 管理界面将不可用`);
|
|
164
|
+
return null;
|
|
106
165
|
}
|
|
107
166
|
}
|
|
108
167
|
|
|
109
168
|
// 验证用户密码
|
|
110
169
|
function validateUserCredentials(username, password) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
170
|
+
if (!authConfig || !Array.isArray(authConfig.users)) {
|
|
171
|
+
return { success: false, message: '系统尚未配置管理员密码,请运行 tcpmapping setup-password' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const user = authConfig.users.find(u => u.username === username);
|
|
175
|
+
const hash = user && typeof user.passwordHash === 'string'
|
|
176
|
+
? user.passwordHash
|
|
177
|
+
: dummyPasswordHash;
|
|
178
|
+
|
|
179
|
+
let passwordMatches = false;
|
|
180
|
+
try {
|
|
181
|
+
passwordMatches = bcrypt.compareSync(password || '', hash);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
passwordMatches = false;
|
|
115
184
|
}
|
|
116
|
-
|
|
185
|
+
|
|
186
|
+
// 统一提示,避免通过错误信息枚举用户名
|
|
187
|
+
if (!user || user.enabled === false || !passwordMatches) {
|
|
188
|
+
return { success: false, message: '用户名或密码错误' };
|
|
189
|
+
}
|
|
190
|
+
|
|
117
191
|
// 检查密码是否过期
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
192
|
+
const expireDays = Number(authConfig.passwordPolicy.passwordExpireDays) || 0;
|
|
193
|
+
if (expireDays > 0) {
|
|
194
|
+
const changedAt = Date.parse(user.lastPasswordChange);
|
|
195
|
+
if (!Number.isFinite(changedAt)) {
|
|
196
|
+
return { success: false, message: '密码状态异常,请运行 tcpmapping setup-password 重新设置密码' };
|
|
197
|
+
}
|
|
198
|
+
if (Date.now() - changedAt > expireDays * 24 * 60 * 60 * 1000) {
|
|
199
|
+
return { success: false, message: `密码已超过 ${expireDays} 天未修改,请运行 tcpmapping setup-password 重置` };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { success: true, user: user };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 清理过期的登录失败记录
|
|
207
|
+
function pruneLoginAttempts() {
|
|
208
|
+
const lockoutTime = (authConfig ? authConfig.sessionConfig.lockoutDuration : 30) * 60 * 1000;
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
for (const [ip, attempts] of loginAttempts) {
|
|
211
|
+
if (now - attempts.lastAttempt > lockoutTime * 2) {
|
|
212
|
+
loginAttempts.delete(ip);
|
|
213
|
+
}
|
|
130
214
|
}
|
|
131
215
|
}
|
|
132
216
|
|
|
133
217
|
// 检查登录尝试限制
|
|
134
218
|
function checkLoginAttempts(clientIP) {
|
|
219
|
+
pruneLoginAttempts();
|
|
220
|
+
|
|
135
221
|
const attempts = loginAttempts.get(clientIP) || { count: 0, lastAttempt: 0 };
|
|
222
|
+
const sessionConfig = authConfig ? authConfig.sessionConfig : DEFAULT_SESSION_CONFIG;
|
|
136
223
|
const now = Date.now();
|
|
137
|
-
const lockoutTime =
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (attempts.count >= authConfig.sessionConfig.maxFailedAttempts) {
|
|
224
|
+
const lockoutTime = sessionConfig.lockoutDuration * 60 * 1000;
|
|
225
|
+
|
|
226
|
+
if (attempts.count >= sessionConfig.maxFailedAttempts) {
|
|
141
227
|
if (now - attempts.lastAttempt < lockoutTime) {
|
|
142
228
|
const remainingTime = Math.ceil((lockoutTime - (now - attempts.lastAttempt)) / 1000 / 60);
|
|
143
|
-
return {
|
|
144
|
-
allowed: false,
|
|
145
|
-
message: `由于多次登录失败,账户已被锁定,请${remainingTime}分钟后再试`
|
|
229
|
+
return {
|
|
230
|
+
allowed: false,
|
|
231
|
+
message: `由于多次登录失败,账户已被锁定,请${remainingTime}分钟后再试`
|
|
146
232
|
};
|
|
147
|
-
} else {
|
|
148
|
-
// 锁定期已过,重置计数
|
|
149
|
-
loginAttempts.set(clientIP, { count: 0, lastAttempt: now });
|
|
150
233
|
}
|
|
234
|
+
|
|
235
|
+
// 锁定期已过,重置计数
|
|
236
|
+
loginAttempts.set(clientIP, { count: 0, lastAttempt: now });
|
|
151
237
|
}
|
|
152
|
-
|
|
238
|
+
|
|
153
239
|
return { allowed: true };
|
|
154
240
|
}
|
|
155
241
|
|
|
@@ -166,45 +252,81 @@ function clearLoginFailures(clientIP) {
|
|
|
166
252
|
loginAttempts.delete(clientIP);
|
|
167
253
|
}
|
|
168
254
|
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
255
|
+
// 使用环境变量覆盖 WebUI 监听配置
|
|
256
|
+
function applyEnvOverrides(loadedConfig) {
|
|
257
|
+
if (process.env.WEBUI_PORT && isValidPort(process.env.WEBUI_PORT)) {
|
|
258
|
+
loadedConfig.webui.port = Number(process.env.WEBUI_PORT);
|
|
259
|
+
}
|
|
260
|
+
if (process.env.WEBUI_HOST && isValidHost(process.env.WEBUI_HOST)) {
|
|
261
|
+
loadedConfig.webui.host = process.env.WEBUI_HOST;
|
|
262
|
+
}
|
|
263
|
+
return loadedConfig;
|
|
264
|
+
}
|
|
176
265
|
|
|
177
266
|
// 读取配置文件
|
|
178
267
|
function loadConfig() {
|
|
268
|
+
const defaults = getDefaultConfig();
|
|
269
|
+
|
|
270
|
+
let configPath = null;
|
|
179
271
|
try {
|
|
180
|
-
|
|
272
|
+
configPath = getConfigPath('config.json');
|
|
181
273
|
if (!configPath) {
|
|
182
274
|
console.log('未找到配置文件,使用默认配置');
|
|
183
275
|
console.log('运行 "tcpmapping init" 创建配置文件');
|
|
184
|
-
return
|
|
276
|
+
return applyEnvOverrides(defaults);
|
|
185
277
|
}
|
|
186
|
-
|
|
187
|
-
const loadedConfig = JSON.parse(
|
|
278
|
+
|
|
279
|
+
const loadedConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
188
280
|
console.log(`加载配置文件: ${configPath}`);
|
|
189
281
|
|
|
190
|
-
|
|
191
|
-
if (!loadedConfig.mappings || !Array.isArray(loadedConfig.mappings)) {
|
|
282
|
+
if (!Array.isArray(loadedConfig.mappings)) {
|
|
192
283
|
loadedConfig.mappings = [];
|
|
193
284
|
}
|
|
194
285
|
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
286
|
+
// 规范映射数据,补齐缺失字段
|
|
287
|
+
loadedConfig.mappings = loadedConfig.mappings
|
|
288
|
+
.filter(mapping => mapping && typeof mapping === 'object')
|
|
289
|
+
.map((mapping, index) => {
|
|
290
|
+
if (!mapping.name || typeof mapping.name !== 'string') {
|
|
291
|
+
mapping.name = `映射${index + 1}`;
|
|
292
|
+
}
|
|
293
|
+
if (!mapping.listen || typeof mapping.listen !== 'object') {
|
|
294
|
+
mapping.listen = { host: '0.0.0.0', port: 0 };
|
|
295
|
+
} else {
|
|
296
|
+
mapping.listen.host = String(mapping.listen.host || '0.0.0.0').trim();
|
|
297
|
+
mapping.listen.port = Number(mapping.listen.port);
|
|
298
|
+
}
|
|
299
|
+
if (!mapping.target || typeof mapping.target !== 'object') {
|
|
300
|
+
mapping.target = { host: '', port: 0 };
|
|
301
|
+
} else {
|
|
302
|
+
mapping.target.host = String(mapping.target.host || '').trim();
|
|
303
|
+
mapping.target.port = Number(mapping.target.port);
|
|
304
|
+
}
|
|
305
|
+
if (!mapping.id || typeof mapping.id !== 'string') {
|
|
306
|
+
mapping.id = generateMappingId();
|
|
307
|
+
}
|
|
308
|
+
return mapping;
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// 提示重复的监听地址和端口
|
|
312
|
+
const seenListen = new Set();
|
|
313
|
+
loadedConfig.mappings.forEach(mapping => {
|
|
314
|
+
const key = `${mapping.listen.host}:${mapping.listen.port}`;
|
|
315
|
+
if (seenListen.has(key)) {
|
|
316
|
+
console.warn(`警告: 检测到重复的监听地址 ${key},仅第一个会生效,请检查配置文件`);
|
|
317
|
+
}
|
|
318
|
+
seenListen.add(key);
|
|
319
|
+
});
|
|
203
320
|
|
|
204
|
-
|
|
321
|
+
loadedConfig.webui = { ...defaults.webui, ...(loadedConfig.webui || {}) };
|
|
322
|
+
loadedConfig.logging = { ...defaults.logging, ...(loadedConfig.logging || {}) };
|
|
323
|
+
|
|
324
|
+
// 支持通过环境变量覆盖 WebUI 监听配置
|
|
325
|
+
return applyEnvOverrides(loadedConfig);
|
|
205
326
|
} catch (error) {
|
|
206
|
-
console.error('
|
|
207
|
-
|
|
327
|
+
console.error(`读取配置文件失败 (${configPath || 'config.json'}):`, error.message);
|
|
328
|
+
console.error('已使用默认配置继续运行,原文件不会被覆盖');
|
|
329
|
+
return applyEnvOverrides(defaults);
|
|
208
330
|
}
|
|
209
331
|
}
|
|
210
332
|
|
|
@@ -215,11 +337,16 @@ function getDefaultConfig() {
|
|
|
215
337
|
webui: {
|
|
216
338
|
enabled: true,
|
|
217
339
|
port: 9999,
|
|
218
|
-
|
|
340
|
+
// 默认只监听本机,避免管理面板暴露到公网
|
|
341
|
+
host: '127.0.0.1'
|
|
219
342
|
},
|
|
220
343
|
logging: {
|
|
221
344
|
enabled: true,
|
|
222
345
|
level: 'info'
|
|
346
|
+
},
|
|
347
|
+
timeouts: {
|
|
348
|
+
connect: 30000,
|
|
349
|
+
idle: 300000
|
|
223
350
|
}
|
|
224
351
|
};
|
|
225
352
|
}
|
|
@@ -228,7 +355,7 @@ function getDefaultConfig() {
|
|
|
228
355
|
function saveConfig(newConfig) {
|
|
229
356
|
try {
|
|
230
357
|
const configPath = getConfigSavePath('config.json');
|
|
231
|
-
|
|
358
|
+
writeFileAtomic(configPath, JSON.stringify(newConfig, null, 2));
|
|
232
359
|
config = newConfig;
|
|
233
360
|
console.log(`配置已保存: ${configPath}`);
|
|
234
361
|
return true;
|
|
@@ -240,56 +367,267 @@ function saveConfig(newConfig) {
|
|
|
240
367
|
|
|
241
368
|
// 日志输出函数
|
|
242
369
|
function log(level, message) {
|
|
243
|
-
if (config.logging
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
370
|
+
if (!config.logging || config.logging.enabled === false) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const configLevel = config.logging.level || 'info';
|
|
375
|
+
const currentLevelValue = LOG_LEVELS[level] !== undefined ? LOG_LEVELS[level] : 999;
|
|
376
|
+
const configLevelValue = LOG_LEVELS[configLevel] !== undefined ? LOG_LEVELS[configLevel] : 2;
|
|
377
|
+
|
|
378
|
+
// 只有当前日志级别的优先级 <= 配置级别优先级时才输出
|
|
379
|
+
if (currentLevelValue <= configLevelValue) {
|
|
380
|
+
const timestamp = new Date().toISOString();
|
|
381
|
+
console.log(`[${timestamp}] [${level.toUpperCase()}] ${message}`);
|
|
253
382
|
}
|
|
254
383
|
}
|
|
255
384
|
|
|
256
385
|
// 生成连接ID
|
|
257
386
|
function generateConnectionId() {
|
|
258
|
-
return
|
|
387
|
+
return crypto.randomBytes(8).toString('hex');
|
|
259
388
|
}
|
|
260
389
|
|
|
261
390
|
// 生成映射唯一ID
|
|
262
391
|
function generateMappingId() {
|
|
263
|
-
return
|
|
392
|
+
return crypto.randomBytes(8).toString('hex');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 格式化 host:port,IPv6 地址自动加方括号
|
|
396
|
+
function formatEndpoint(host, port) {
|
|
397
|
+
if (!host) return `:${port}`;
|
|
398
|
+
const value = String(host);
|
|
399
|
+
return value.includes(':') ? `[${value}]:${port}` : `${value}:${port}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 格式化毫秒时长为易读文本
|
|
403
|
+
function formatDurationMs(ms) {
|
|
404
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
405
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
406
|
+
if (totalSeconds < 3600) return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`;
|
|
407
|
+
if (totalSeconds < 86400) return `${Math.floor(totalSeconds / 3600)}h ${Math.floor((totalSeconds % 3600) / 60)}m`;
|
|
408
|
+
return `${Math.floor(totalSeconds / 86400)}d ${Math.floor((totalSeconds % 86400) / 3600)}h`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ==================== 服务自身资源采样 ====================
|
|
412
|
+
|
|
413
|
+
const CPU_COUNT = Math.max(1, (os.cpus() || []).length);
|
|
414
|
+
const resourceSnapshot = {
|
|
415
|
+
cpuPercent: 0,
|
|
416
|
+
memory: {
|
|
417
|
+
rss: 0,
|
|
418
|
+
heapTotal: 0,
|
|
419
|
+
heapUsed: 0,
|
|
420
|
+
external: 0,
|
|
421
|
+
arrayBuffers: 0,
|
|
422
|
+
systemTotal: os.totalmem(),
|
|
423
|
+
systemFree: os.freemem()
|
|
424
|
+
},
|
|
425
|
+
traffic: {
|
|
426
|
+
receivedPerSecond: 0,
|
|
427
|
+
sentPerSecond: 0,
|
|
428
|
+
totalReceived: 0,
|
|
429
|
+
totalSent: 0
|
|
430
|
+
},
|
|
431
|
+
sampledAt: null
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
let lastCpuUsage = process.cpuUsage();
|
|
435
|
+
let lastCpuSampleTime = process.hrtime.bigint();
|
|
436
|
+
let lastTrafficSample = { received: 0, sent: 0, time: Date.now() };
|
|
437
|
+
|
|
438
|
+
// 每秒采样一次;CPU 百分比按整机核数归一化(0-100)
|
|
439
|
+
function sampleProcessResources() {
|
|
440
|
+
const nowHr = process.hrtime.bigint();
|
|
441
|
+
const elapsedUs = Number(nowHr - lastCpuSampleTime) / 1000;
|
|
442
|
+
const usage = process.cpuUsage(lastCpuUsage);
|
|
443
|
+
lastCpuUsage = process.cpuUsage();
|
|
444
|
+
lastCpuSampleTime = nowHr;
|
|
445
|
+
|
|
446
|
+
const cpuUs = usage.user + usage.system;
|
|
447
|
+
const cpuPercent = elapsedUs > 0
|
|
448
|
+
? Math.min(100, Math.max(0, (cpuUs / elapsedUs) * 100 / CPU_COUNT))
|
|
449
|
+
: 0;
|
|
450
|
+
|
|
451
|
+
const mem = process.memoryUsage();
|
|
452
|
+
const now = Date.now();
|
|
453
|
+
const elapsedSec = (now - lastTrafficSample.time) / 1000;
|
|
454
|
+
let receivedPerSecond = 0;
|
|
455
|
+
let sentPerSecond = 0;
|
|
456
|
+
|
|
457
|
+
if (elapsedSec > 0) {
|
|
458
|
+
receivedPerSecond = Math.max(0, (processTraffic.received - lastTrafficSample.received) / elapsedSec);
|
|
459
|
+
sentPerSecond = Math.max(0, (processTraffic.sent - lastTrafficSample.sent) / elapsedSec);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
lastTrafficSample = { received: processTraffic.received, sent: processTraffic.sent, time: now };
|
|
463
|
+
|
|
464
|
+
resourceSnapshot.cpuPercent = cpuPercent;
|
|
465
|
+
resourceSnapshot.memory = {
|
|
466
|
+
rss: mem.rss,
|
|
467
|
+
heapTotal: mem.heapTotal,
|
|
468
|
+
heapUsed: mem.heapUsed,
|
|
469
|
+
external: mem.external,
|
|
470
|
+
arrayBuffers: mem.arrayBuffers,
|
|
471
|
+
systemTotal: os.totalmem(),
|
|
472
|
+
systemFree: os.freemem()
|
|
473
|
+
};
|
|
474
|
+
resourceSnapshot.traffic = {
|
|
475
|
+
receivedPerSecond,
|
|
476
|
+
sentPerSecond,
|
|
477
|
+
totalReceived: processTraffic.received,
|
|
478
|
+
totalSent: processTraffic.sent
|
|
479
|
+
};
|
|
480
|
+
resourceSnapshot.sampledAt = new Date(now).toISOString();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
sampleProcessResources();
|
|
484
|
+
setInterval(sampleProcessResources, 1000).unref();
|
|
485
|
+
|
|
486
|
+
// API 英文文案(用于 WebUI 切换到英文时)
|
|
487
|
+
const API_MESSAGES_EN = {
|
|
488
|
+
'需要登录': 'Authentication required',
|
|
489
|
+
'非法请求来源': 'Invalid request origin',
|
|
490
|
+
'跨站请求被拒绝': 'Cross-site request rejected',
|
|
491
|
+
'请输入用户名和密码': 'Please enter username and password',
|
|
492
|
+
'用户名或密码错误': 'Invalid username or password',
|
|
493
|
+
'登录成功': 'Login successful',
|
|
494
|
+
'已登出': 'Logged out',
|
|
495
|
+
'登录失败,请稍后重试': 'Login failed, please try again later',
|
|
496
|
+
'系统尚未配置管理员密码,请运行 tcpmapping setup-password': 'Administrator password is not configured. Run tcpmapping setup-password first.',
|
|
497
|
+
'系统尚未配置管理员密码,请先在服务器上运行 tcpmapping setup-password': 'Administrator password is not configured. Run tcpmapping setup-password on the server first.',
|
|
498
|
+
'密码状态异常,请运行 tcpmapping setup-password 重新设置密码': 'Password state is invalid. Run tcpmapping setup-password to reset it.',
|
|
499
|
+
'映射不存在': 'Mapping not found',
|
|
500
|
+
'该监听地址和端口已存在映射': 'A mapping with this listen address and port already exists',
|
|
501
|
+
'保存配置失败': 'Failed to save configuration',
|
|
502
|
+
'端口映射已启动': 'Port mapping started',
|
|
503
|
+
'端口映射已停止': 'Port mapping stopped',
|
|
504
|
+
'端口映射已在运行': 'Port mapping is already running',
|
|
505
|
+
'端口映射正在启动,请稍后重试': 'Port mapping is starting, please try again later',
|
|
506
|
+
'端口映射不存在或未在运行': 'Port mapping does not exist or is not running',
|
|
507
|
+
'端口映射已更新': 'Port mapping updated',
|
|
508
|
+
'端口映射已删除': 'Port mapping deleted',
|
|
509
|
+
'接口不存在': 'API endpoint not found',
|
|
510
|
+
'服务器内部错误': 'Internal server error',
|
|
511
|
+
'映射名称不能为空': 'Mapping name is required',
|
|
512
|
+
'映射名称不能超过 100 个字符': 'Mapping name must not exceed 100 characters',
|
|
513
|
+
'监听端口必须在 1-65535 范围内': 'Listen port must be between 1 and 65535',
|
|
514
|
+
'监听地址格式无效': 'Invalid listen address',
|
|
515
|
+
'目标主机地址格式无效': 'Invalid target host address',
|
|
516
|
+
'目标端口必须在 1-65535 范围内': 'Target port must be between 1 and 65535'
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
// 动态消息(含变量)的英文转换
|
|
520
|
+
const API_MESSAGE_PATTERNS_EN = [
|
|
521
|
+
[/^密码已超过 (\d+) 天未修改,请运行 tcpmapping setup-password 重置$/,
|
|
522
|
+
(match, days) => `Password has not been changed for ${days} days. Run tcpmapping setup-password to reset it.`],
|
|
523
|
+
[/^由于多次登录失败,账户已被锁定,请(\d+)分钟后再试$/,
|
|
524
|
+
(match, minutes) => `Account locked after multiple failed attempts. Try again in ${minutes} minute(s).`],
|
|
525
|
+
[/^监听端口 (.+) 已被占用$/,
|
|
526
|
+
(match, address) => `Port ${address} is already in use`],
|
|
527
|
+
[/^启动失败: (.+)$/,
|
|
528
|
+
(match, detail) => `Failed to start: ${detail}`]
|
|
529
|
+
];
|
|
530
|
+
|
|
531
|
+
// 将 API 返回的中文提示转换为目标语言
|
|
532
|
+
function localizeApiMessage(message, lang) {
|
|
533
|
+
if (lang !== 'en' || typeof message !== 'string') {
|
|
534
|
+
return message;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (API_MESSAGES_EN[message]) {
|
|
538
|
+
return API_MESSAGES_EN[message];
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
for (const [pattern, build] of API_MESSAGE_PATTERNS_EN) {
|
|
542
|
+
const match = message.match(pattern);
|
|
543
|
+
if (match) {
|
|
544
|
+
return build(...match);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// 校验错误使用 "; " 拼接,逐条翻译
|
|
549
|
+
if (message.includes('; ')) {
|
|
550
|
+
return message.split('; ').map(part => localizeApiMessage(part, lang)).join('; ');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return message;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// 读取请求语言(前端通过 X-Language 指定)
|
|
557
|
+
function getRequestLanguage(req) {
|
|
558
|
+
const explicit = String(req.get('X-Language') || '').toLowerCase();
|
|
559
|
+
if (explicit.startsWith('zh')) return 'zh';
|
|
560
|
+
if (explicit.startsWith('en')) return 'en';
|
|
561
|
+
|
|
562
|
+
const acceptLanguage = String(req.get('Accept-Language') || '').toLowerCase();
|
|
563
|
+
if (acceptLanguage.trim()) {
|
|
564
|
+
// Accept-Language 的第一项是客户端的首选语言
|
|
565
|
+
return acceptLanguage.trim().startsWith('zh') ? 'zh' : 'en';
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return 'zh';
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// 连接关闭原因英文对照
|
|
572
|
+
const CLOSE_REASONS_EN = {
|
|
573
|
+
'客户端关闭': 'Client closed',
|
|
574
|
+
'目标服务器关闭': 'Target closed',
|
|
575
|
+
'目标连接超时': 'Target connect timeout',
|
|
576
|
+
'目标连接空闲超时': 'Target idle timeout',
|
|
577
|
+
'客户端空闲超时': 'Client idle timeout',
|
|
578
|
+
'目标连接错误': 'Target connection error',
|
|
579
|
+
'客户端连接错误': 'Client connection error',
|
|
580
|
+
'端口映射已停止': 'Mapping stopped',
|
|
581
|
+
'服务正在关闭': 'Server shutting down',
|
|
582
|
+
'连接被主动断开': 'Closed by administrator'
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
function localizeCloseReason(reason, lang) {
|
|
586
|
+
if (lang !== 'en') {
|
|
587
|
+
return reason;
|
|
588
|
+
}
|
|
589
|
+
return CLOSE_REASONS_EN[reason] || reason;
|
|
264
590
|
}
|
|
265
591
|
|
|
266
592
|
// 验证端口号
|
|
267
593
|
function isValidPort(port) {
|
|
268
|
-
const p =
|
|
594
|
+
const p = Number(port);
|
|
269
595
|
return Number.isInteger(p) && p >= 1 && p <= 65535;
|
|
270
596
|
}
|
|
271
597
|
|
|
272
598
|
// 验证IP地址或主机名
|
|
273
599
|
function isValidHost(host) {
|
|
274
600
|
if (!host || typeof host !== 'string') return false;
|
|
275
|
-
|
|
276
|
-
const
|
|
601
|
+
|
|
602
|
+
const value = host.trim();
|
|
603
|
+
if (!value || value.length > 253) return false;
|
|
604
|
+
|
|
605
|
+
// IPv4 / IPv6
|
|
606
|
+
if (net.isIP(value)) return true;
|
|
607
|
+
|
|
608
|
+
// 域名
|
|
277
609
|
const hostnamePattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
278
|
-
return
|
|
610
|
+
return hostnamePattern.test(value);
|
|
279
611
|
}
|
|
280
612
|
|
|
281
613
|
// 验证映射配置
|
|
282
614
|
function validateMappingInput(mapping) {
|
|
283
615
|
const errors = [];
|
|
284
616
|
|
|
285
|
-
if (!mapping.name || mapping.name.trim().length === 0) {
|
|
617
|
+
if (!mapping.name || typeof mapping.name !== 'string' || mapping.name.trim().length === 0) {
|
|
286
618
|
errors.push('映射名称不能为空');
|
|
619
|
+
} else if (mapping.name.trim().length > 100) {
|
|
620
|
+
errors.push('映射名称不能超过 100 个字符');
|
|
287
621
|
}
|
|
288
622
|
|
|
289
623
|
if (!mapping.listen || !isValidPort(mapping.listen.port)) {
|
|
290
624
|
errors.push('监听端口必须在 1-65535 范围内');
|
|
291
625
|
}
|
|
292
626
|
|
|
627
|
+
if (!mapping.listen || !isValidHost(mapping.listen.host)) {
|
|
628
|
+
errors.push('监听地址格式无效');
|
|
629
|
+
}
|
|
630
|
+
|
|
293
631
|
if (!mapping.target || !isValidHost(mapping.target.host)) {
|
|
294
632
|
errors.push('目标主机地址格式无效');
|
|
295
633
|
}
|
|
@@ -304,22 +642,93 @@ function validateMappingInput(mapping) {
|
|
|
304
642
|
};
|
|
305
643
|
}
|
|
306
644
|
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
const
|
|
645
|
+
// 读取超时配置(毫秒)
|
|
646
|
+
function getTimeouts() {
|
|
647
|
+
const defaults = getDefaultConfig().timeouts;
|
|
648
|
+
const timeouts = config.timeouts || {};
|
|
649
|
+
|
|
650
|
+
const connect = Number(timeouts.connect);
|
|
651
|
+
const idle = Number(timeouts.idle);
|
|
652
|
+
|
|
653
|
+
return {
|
|
654
|
+
// 连接超时至少 1 秒
|
|
655
|
+
connect: Number.isFinite(connect) && connect > 0 ? Math.max(1000, connect) : defaults.connect,
|
|
656
|
+
// 0 表示禁用空闲超时
|
|
657
|
+
idle: Number.isFinite(idle) && idle >= 0 ? idle : defaults.idle
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// 获取映射的累计统计
|
|
662
|
+
function getMappingStats(mappingId) {
|
|
663
|
+
let stats = mappingStats.get(mappingId);
|
|
664
|
+
if (!stats) {
|
|
665
|
+
stats = {
|
|
666
|
+
totalConnections: 0,
|
|
667
|
+
bytesReceived: 0,
|
|
668
|
+
bytesSent: 0
|
|
669
|
+
};
|
|
670
|
+
mappingStats.set(mappingId, stats);
|
|
671
|
+
}
|
|
672
|
+
return stats;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// 主动销毁一条转发连接
|
|
676
|
+
function destroyConnection(connectionId, reason) {
|
|
677
|
+
const pair = connectionSockets.get(connectionId);
|
|
678
|
+
|
|
679
|
+
// 走统一的收尾逻辑,确保记录持续时长与关闭原因
|
|
680
|
+
if (pair && typeof pair.finalize === 'function') {
|
|
681
|
+
pair.finalize(reason || '连接被主动断开', 'info');
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
connections.delete(connectionId);
|
|
686
|
+
connectionSockets.delete(connectionId);
|
|
687
|
+
|
|
688
|
+
if (!pair) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (pair.clientSocket && !pair.clientSocket.destroyed) {
|
|
693
|
+
try {
|
|
694
|
+
pair.clientSocket.destroy();
|
|
695
|
+
} catch (error) {
|
|
696
|
+
// 忽略销毁错误
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (pair.targetSocket && !pair.targetSocket.destroyed) {
|
|
701
|
+
try {
|
|
702
|
+
pair.targetSocket.destroy();
|
|
703
|
+
} catch (error) {
|
|
704
|
+
// 忽略销毁错误
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
310
708
|
|
|
311
709
|
// 创建转发连接
|
|
312
710
|
function createForwardConnection(clientSocket, mapping) {
|
|
313
711
|
const connectionId = generateConnectionId();
|
|
314
|
-
const clientInfo =
|
|
712
|
+
const clientInfo = formatEndpoint(clientSocket.remoteAddress, clientSocket.remotePort);
|
|
713
|
+
const targetAddress = formatEndpoint(mapping.target.host, mapping.target.port);
|
|
714
|
+
// clientSocket.localAddress 是实际命中的网卡地址,比 0.0.0.0 更有参考价值
|
|
715
|
+
const listenAddress = clientSocket.localAddress
|
|
716
|
+
? formatEndpoint(clientSocket.localAddress, clientSocket.localPort)
|
|
717
|
+
: formatEndpoint(mapping.listen.host, mapping.listen.port);
|
|
718
|
+
const stats = getMappingStats(mapping.id);
|
|
719
|
+
|
|
720
|
+
stats.totalConnections++;
|
|
315
721
|
|
|
316
722
|
// 记录连接信息
|
|
317
723
|
connections.set(connectionId, {
|
|
318
724
|
id: connectionId,
|
|
725
|
+
mappingId: mapping.id,
|
|
319
726
|
mappingName: mapping.name,
|
|
727
|
+
sourceHost: mapping.listen.host,
|
|
320
728
|
sourcePort: mapping.listen.port,
|
|
729
|
+
listenAddress: listenAddress,
|
|
321
730
|
clientAddress: clientInfo,
|
|
322
|
-
targetAddress:
|
|
731
|
+
targetAddress: targetAddress,
|
|
323
732
|
startTime: new Date(),
|
|
324
733
|
lastActivity: Date.now(),
|
|
325
734
|
bytesReceived: 0,
|
|
@@ -327,16 +736,19 @@ function createForwardConnection(clientSocket, mapping) {
|
|
|
327
736
|
status: 'connecting'
|
|
328
737
|
});
|
|
329
738
|
|
|
330
|
-
const targetSocket = new net.Socket();
|
|
739
|
+
const targetSocket = new net.Socket({ allowHalfOpen: true });
|
|
740
|
+
const pair = { clientSocket, targetSocket, finalize: null };
|
|
741
|
+
connectionSockets.set(connectionId, pair);
|
|
331
742
|
|
|
332
|
-
|
|
333
|
-
|
|
743
|
+
const timeouts = getTimeouts();
|
|
744
|
+
let finished = false;
|
|
334
745
|
|
|
335
|
-
//
|
|
336
|
-
clientSocket.
|
|
337
|
-
targetSocket.
|
|
746
|
+
// 降低转发延迟,并启用 TCP KeepAlive 检测死连接
|
|
747
|
+
clientSocket.setNoDelay(true);
|
|
748
|
+
targetSocket.setNoDelay(true);
|
|
749
|
+
clientSocket.setKeepAlive(true, 60000);
|
|
750
|
+
targetSocket.setKeepAlive(true, 60000);
|
|
338
751
|
|
|
339
|
-
// 更新活动时间
|
|
340
752
|
const updateActivity = () => {
|
|
341
753
|
const conn = connections.get(connectionId);
|
|
342
754
|
if (conn) {
|
|
@@ -344,151 +756,244 @@ function createForwardConnection(clientSocket, mapping) {
|
|
|
344
756
|
}
|
|
345
757
|
};
|
|
346
758
|
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
759
|
+
// 统一收尾:只执行一次,避免重复销毁
|
|
760
|
+
const finalize = (reason, level) => {
|
|
761
|
+
if (finished) return;
|
|
762
|
+
finished = true;
|
|
763
|
+
|
|
764
|
+
const conn = connections.get(connectionId);
|
|
765
|
+
|
|
766
|
+
connections.delete(connectionId);
|
|
767
|
+
connectionSockets.delete(connectionId);
|
|
768
|
+
|
|
769
|
+
// 记录已关闭连接的摘要,便于事后查询源地址、目的地址与持续时长
|
|
770
|
+
if (conn) {
|
|
771
|
+
const endTime = new Date();
|
|
772
|
+
const durationMs = Math.max(0, endTime.getTime() - conn.startTime.getTime());
|
|
773
|
+
|
|
774
|
+
connectionHistory.push({
|
|
775
|
+
id: conn.id,
|
|
776
|
+
mappingId: conn.mappingId,
|
|
777
|
+
mappingName: conn.mappingName,
|
|
778
|
+
listenAddress: conn.listenAddress,
|
|
779
|
+
clientAddress: conn.clientAddress,
|
|
780
|
+
targetAddress: conn.targetAddress,
|
|
781
|
+
startTime: conn.startTime,
|
|
782
|
+
endTime: endTime,
|
|
783
|
+
durationMs: durationMs,
|
|
784
|
+
bytesReceived: conn.bytesReceived,
|
|
785
|
+
bytesSent: conn.bytesSent,
|
|
786
|
+
status: conn.status,
|
|
787
|
+
closeReason: reason
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
if (connectionHistory.length > CONNECTION_HISTORY_LIMIT) {
|
|
791
|
+
connectionHistory.splice(0, connectionHistory.length - CONNECTION_HISTORY_LIMIT);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
log(level || 'info',
|
|
795
|
+
`[${mapping.name}] 连接关闭: ${clientInfo} -> ${targetAddress},` +
|
|
796
|
+
`持续 ${formatDurationMs(durationMs)},↑${conn.bytesReceived}B ↓${conn.bytesSent}B (${reason})`);
|
|
797
|
+
} else {
|
|
798
|
+
log(level || 'info', `[${mapping.name}] 连接关闭: ${clientInfo} (${reason})`);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (!clientSocket.destroyed) clientSocket.destroy();
|
|
802
|
+
if (!targetSocket.destroyed) targetSocket.destroy();
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
pair.finalize = finalize;
|
|
806
|
+
|
|
807
|
+
// 连接超时 / 空闲超时
|
|
808
|
+
if (timeouts.connect > 0) {
|
|
809
|
+
targetSocket.setTimeout(timeouts.connect);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
targetSocket.once('connect', () => {
|
|
350
813
|
const conn = connections.get(connectionId);
|
|
351
814
|
if (conn) {
|
|
352
815
|
conn.status = 'connected';
|
|
353
|
-
|
|
816
|
+
conn.lastActivity = Date.now();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
log('info', `[${mapping.name}] 已连接到目标服务器 ${targetAddress}`);
|
|
820
|
+
|
|
821
|
+
if (timeouts.idle > 0) {
|
|
822
|
+
targetSocket.setTimeout(timeouts.idle);
|
|
823
|
+
} else {
|
|
824
|
+
targetSocket.setTimeout(0);
|
|
354
825
|
}
|
|
355
826
|
});
|
|
356
827
|
|
|
357
|
-
|
|
828
|
+
clientSocket.setTimeout(timeouts.idle > 0 ? timeouts.idle : 0);
|
|
829
|
+
|
|
358
830
|
targetSocket.on('timeout', () => {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
831
|
+
const conn = connections.get(connectionId);
|
|
832
|
+
const message = conn && conn.status === 'connecting' ? '目标连接超时' : '目标连接空闲超时';
|
|
833
|
+
log('warn', `[${mapping.name}] ${message}: ${targetAddress}`);
|
|
834
|
+
finalize(message, 'warn');
|
|
363
835
|
});
|
|
364
836
|
|
|
365
837
|
clientSocket.on('timeout', () => {
|
|
366
|
-
log('warn', `[${mapping.name}]
|
|
367
|
-
|
|
368
|
-
targetSocket.destroy();
|
|
369
|
-
clientSocket.destroy();
|
|
838
|
+
log('warn', `[${mapping.name}] 客户端连接空闲超时: ${clientInfo}`);
|
|
839
|
+
finalize('客户端空闲超时', 'warn');
|
|
370
840
|
});
|
|
371
841
|
|
|
372
|
-
//
|
|
842
|
+
// 错误处理
|
|
373
843
|
targetSocket.on('error', (err) => {
|
|
374
|
-
log('error', `[${mapping.name}] 目标服务器连接错误: ${err.message}`);
|
|
375
|
-
|
|
376
|
-
clientSocket.destroy();
|
|
844
|
+
log('error', `[${mapping.name}] 目标服务器连接错误: ${err.message} (${targetAddress})`);
|
|
845
|
+
finalize('目标连接错误', 'error');
|
|
377
846
|
});
|
|
378
847
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
connections.delete(connectionId);
|
|
383
|
-
clientSocket.destroy();
|
|
848
|
+
clientSocket.on('error', (err) => {
|
|
849
|
+
log('error', `[${mapping.name}] 客户端连接错误: ${err.message} (${clientInfo})`);
|
|
850
|
+
finalize('客户端连接错误', 'error');
|
|
384
851
|
});
|
|
385
852
|
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
853
|
+
// 关闭事件(对端正常关闭)
|
|
854
|
+
targetSocket.on('close', () => finalize('目标服务器关闭'));
|
|
855
|
+
clientSocket.on('close', () => finalize('客户端关闭'));
|
|
856
|
+
|
|
857
|
+
// 支持 TCP 半关闭:一个方向结束后仍允许另一个方向继续传输
|
|
858
|
+
clientSocket.on('end', () => {
|
|
859
|
+
if (!targetSocket.destroyed && targetSocket.writable) {
|
|
860
|
+
targetSocket.end();
|
|
861
|
+
}
|
|
391
862
|
});
|
|
392
863
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
targetSocket.destroy();
|
|
864
|
+
targetSocket.on('end', () => {
|
|
865
|
+
if (!clientSocket.destroyed && clientSocket.writable) {
|
|
866
|
+
clientSocket.end();
|
|
867
|
+
}
|
|
398
868
|
});
|
|
399
869
|
|
|
400
|
-
//
|
|
870
|
+
// 流量统计
|
|
401
871
|
clientSocket.on('data', (data) => {
|
|
402
|
-
log('debug', `[${mapping.name}] 转发数据到目标服务器,大小: ${data.length} 字节`);
|
|
403
872
|
updateActivity();
|
|
873
|
+
processTraffic.received += data.length;
|
|
404
874
|
const conn = connections.get(connectionId);
|
|
405
875
|
if (conn) {
|
|
406
876
|
conn.bytesReceived += data.length;
|
|
407
|
-
connections.set(connectionId, conn);
|
|
408
877
|
}
|
|
409
|
-
|
|
878
|
+
stats.bytesReceived += data.length;
|
|
410
879
|
});
|
|
411
880
|
|
|
412
|
-
// 数据转发:目标服务器 -> 客户端
|
|
413
881
|
targetSocket.on('data', (data) => {
|
|
414
|
-
log('debug', `[${mapping.name}] 转发数据到客户端,大小: ${data.length} 字节`);
|
|
415
882
|
updateActivity();
|
|
883
|
+
processTraffic.sent += data.length;
|
|
416
884
|
const conn = connections.get(connectionId);
|
|
417
885
|
if (conn) {
|
|
418
886
|
conn.bytesSent += data.length;
|
|
419
|
-
connections.set(connectionId, conn);
|
|
420
887
|
}
|
|
421
|
-
|
|
888
|
+
stats.bytesSent += data.length;
|
|
422
889
|
});
|
|
423
890
|
|
|
424
|
-
|
|
891
|
+
// 使用 pipe 转发,自动处理背压,避免慢速对端导致内存无限增长
|
|
892
|
+
clientSocket.pipe(targetSocket, { end: false });
|
|
893
|
+
targetSocket.pipe(clientSocket, { end: false });
|
|
894
|
+
|
|
895
|
+
// 发起连接(最后执行,确保监听器已就绪)
|
|
896
|
+
targetSocket.connect(mapping.target.port, mapping.target.host);
|
|
425
897
|
}
|
|
426
898
|
|
|
427
899
|
// 创建单个端口转发服务器
|
|
428
900
|
function createForwardingServer(mapping) {
|
|
429
|
-
const server = net.createServer((clientSocket) => {
|
|
430
|
-
|
|
431
|
-
log('info', `[${mapping.name}] 新客户端连接: ${clientInfo}`);
|
|
432
|
-
|
|
433
|
-
// 创建转发连接
|
|
901
|
+
const server = net.createServer({ allowHalfOpen: true }, (clientSocket) => {
|
|
902
|
+
log('info', `[${mapping.name}] 新客户端连接: ${clientSocket.remoteAddress}:${clientSocket.remotePort}`);
|
|
434
903
|
createForwardConnection(clientSocket, mapping);
|
|
435
904
|
});
|
|
436
905
|
|
|
437
|
-
// 处理服务器错误
|
|
438
|
-
server.on('error', (err) => {
|
|
439
|
-
log('error', `[${mapping.name}] 服务器错误: ${err.message}`);
|
|
440
|
-
if (err.code === 'EADDRINUSE') {
|
|
441
|
-
console.error(`端口 ${mapping.listen.port} 已被占用,请检查配置或停止占用该端口的程序`);
|
|
442
|
-
}
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
// 启动服务器
|
|
446
|
-
server.listen(mapping.listen.port, mapping.listen.host, () => {
|
|
447
|
-
log('info', `[${mapping.name}] 转发服务器已启动,监听地址: ${mapping.listen.host}:${mapping.listen.port}`);
|
|
448
|
-
log('info', `[${mapping.name}] 目标服务器: ${mapping.target.host}:${mapping.target.port}`);
|
|
449
|
-
console.log(`✓ [${mapping.name}] ${mapping.listen.port} -> ${mapping.target.host}:${mapping.target.port}`);
|
|
450
|
-
});
|
|
451
|
-
|
|
452
906
|
return server;
|
|
453
907
|
}
|
|
454
908
|
|
|
455
|
-
//
|
|
909
|
+
// 启动端口映射(等待监听结果后再返回)
|
|
456
910
|
function startMapping(mapping) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
911
|
+
return new Promise((resolve) => {
|
|
912
|
+
const key = `${mapping.listen.host}:${mapping.listen.port}`;
|
|
913
|
+
|
|
914
|
+
if (servers.has(key)) {
|
|
915
|
+
return resolve({ success: true, message: '端口映射已在运行' });
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (pendingStarts.has(key)) {
|
|
919
|
+
return resolve({ success: false, message: '端口映射正在启动,请稍后重试' });
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
pendingStarts.add(key);
|
|
462
923
|
|
|
463
|
-
try {
|
|
464
924
|
const server = createForwardingServer(mapping);
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
925
|
+
let settled = false;
|
|
926
|
+
|
|
927
|
+
const finish = (result) => {
|
|
928
|
+
if (settled) return;
|
|
929
|
+
settled = true;
|
|
930
|
+
pendingStarts.delete(key);
|
|
931
|
+
resolve(result);
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
const onListenError = (err) => {
|
|
935
|
+
log('error', `[${mapping.name}] 启动失败: ${err.message}`);
|
|
936
|
+
finish({
|
|
937
|
+
success: false,
|
|
938
|
+
message: err.code === 'EADDRINUSE'
|
|
939
|
+
? `监听端口 ${mapping.listen.host}:${mapping.listen.port} 已被占用`
|
|
940
|
+
: `启动失败: ${err.message}`
|
|
941
|
+
});
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
server.once('error', onListenError);
|
|
945
|
+
|
|
946
|
+
server.once('listening', () => {
|
|
947
|
+
server.removeListener('error', onListenError);
|
|
948
|
+
server.on('error', (err) => {
|
|
949
|
+
log('error', `[${mapping.name}] 服务器运行错误: ${err.message}`);
|
|
950
|
+
servers.delete(key);
|
|
951
|
+
// 运行期出错后关闭监听,避免留下无法通过接口管理的端口
|
|
952
|
+
try {
|
|
953
|
+
server.close();
|
|
954
|
+
} catch (closeError) {
|
|
955
|
+
// 忽略关闭错误
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
servers.set(key, { server, mapping });
|
|
960
|
+
|
|
961
|
+
log('info', `[${mapping.name}] 转发服务器已启动,监听地址: ${mapping.listen.host}:${mapping.listen.port}`);
|
|
962
|
+
log('info', `[${mapping.name}] 目标服务器: ${mapping.target.host}:${mapping.target.port}`);
|
|
963
|
+
console.log(`✓ [${mapping.name}] ${mapping.listen.host}:${mapping.listen.port} -> ${mapping.target.host}:${mapping.target.port}`);
|
|
964
|
+
|
|
965
|
+
finish({ success: true, message: '端口映射已启动' });
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
try {
|
|
969
|
+
server.listen(mapping.listen.port, mapping.listen.host);
|
|
970
|
+
} catch (error) {
|
|
971
|
+
onListenError(error);
|
|
972
|
+
}
|
|
973
|
+
});
|
|
470
974
|
}
|
|
471
975
|
|
|
472
976
|
// 停止端口映射
|
|
473
977
|
function stopMapping(host, port) {
|
|
474
978
|
const key = `${host}:${port}`;
|
|
475
|
-
|
|
979
|
+
|
|
476
980
|
if (!servers.has(key)) {
|
|
477
|
-
return { success: false, message: '
|
|
981
|
+
return { success: false, message: '端口映射不存在或未在运行' };
|
|
478
982
|
}
|
|
479
983
|
|
|
480
984
|
try {
|
|
481
985
|
const { server } = servers.get(key);
|
|
482
|
-
server.close();
|
|
483
986
|
servers.delete(key);
|
|
484
|
-
|
|
485
|
-
|
|
987
|
+
server.close();
|
|
988
|
+
|
|
989
|
+
// 主动断开该映射下的所有活跃连接
|
|
486
990
|
for (const [id, conn] of connections) {
|
|
487
|
-
if (conn.sourcePort === port) {
|
|
488
|
-
|
|
991
|
+
if (conn.sourceHost === host && conn.sourcePort === port) {
|
|
992
|
+
destroyConnection(id, '端口映射已停止');
|
|
489
993
|
}
|
|
490
994
|
}
|
|
491
|
-
|
|
995
|
+
|
|
996
|
+
log('info', `[${host}:${port}] 端口映射已停止`);
|
|
492
997
|
return { success: true, message: '端口映射已停止' };
|
|
493
998
|
} catch (error) {
|
|
494
999
|
return { success: false, message: error.message };
|
|
@@ -496,67 +1001,169 @@ function stopMapping(host, port) {
|
|
|
496
1001
|
}
|
|
497
1002
|
|
|
498
1003
|
// 启动所有端口映射
|
|
499
|
-
function startAllMappings() {
|
|
500
|
-
config.mappings.
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
if (!mapping.name) {
|
|
507
|
-
mapping.name = `映射${index + 1}`;
|
|
1004
|
+
async function startAllMappings() {
|
|
1005
|
+
for (let index = 0; index < config.mappings.length; index++) {
|
|
1006
|
+
const mapping = config.mappings[index];
|
|
1007
|
+
|
|
1008
|
+
if (!mapping.listen || !mapping.target || !isValidPort(mapping.listen.port) || !isValidPort(mapping.target.port)) {
|
|
1009
|
+
console.error(`映射配置 ${index + 1} 缺少必要的 listen 或 target 配置,已跳过`);
|
|
1010
|
+
continue;
|
|
508
1011
|
}
|
|
509
1012
|
|
|
510
|
-
startMapping(mapping);
|
|
511
|
-
|
|
1013
|
+
const result = await startMapping(mapping);
|
|
1014
|
+
if (!result.success) {
|
|
1015
|
+
console.error(`映射配置 ${index + 1} (${mapping.name}) 启动失败: ${result.message}`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
512
1018
|
}
|
|
513
1019
|
|
|
514
1020
|
// 生成或读取 session secret
|
|
515
1021
|
function getSessionSecret() {
|
|
516
|
-
|
|
1022
|
+
ensureDataDir();
|
|
1023
|
+
|
|
1024
|
+
const secretPath = path.join(DATA_DIR, '.session-secret');
|
|
1025
|
+
const legacyPath = path.join(__dirname, '.session-secret');
|
|
1026
|
+
|
|
1027
|
+
// 兼容旧版本:把包目录中的 secret 迁移到数据目录
|
|
1028
|
+
try {
|
|
1029
|
+
if (!fs.existsSync(secretPath) && fs.existsSync(legacyPath)) {
|
|
1030
|
+
fs.copyFileSync(legacyPath, secretPath);
|
|
1031
|
+
try {
|
|
1032
|
+
fs.chmodSync(secretPath, 0o600);
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
// Windows 上可能不支持 chmod,忽略
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
// 忽略迁移失败
|
|
1039
|
+
}
|
|
1040
|
+
|
|
517
1041
|
try {
|
|
518
1042
|
if (fs.existsSync(secretPath)) {
|
|
519
|
-
|
|
1043
|
+
const secret = fs.readFileSync(secretPath, 'utf8').trim();
|
|
1044
|
+
if (secret.length >= 32) {
|
|
1045
|
+
return secret;
|
|
1046
|
+
}
|
|
520
1047
|
}
|
|
521
1048
|
} catch (error) {
|
|
522
1049
|
// 忽略读取错误
|
|
523
1050
|
}
|
|
1051
|
+
|
|
524
1052
|
// 生成新的随机 secret
|
|
525
|
-
const crypto = require('crypto');
|
|
526
1053
|
const secret = crypto.randomBytes(32).toString('hex');
|
|
527
1054
|
try {
|
|
528
|
-
|
|
1055
|
+
writeFileAtomic(secretPath, secret, 0o600);
|
|
529
1056
|
} catch (error) {
|
|
530
1057
|
console.warn('警告: 无法保存 session secret,重启后会话将失效');
|
|
531
1058
|
}
|
|
532
1059
|
return secret;
|
|
533
1060
|
}
|
|
534
1061
|
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
|
|
1062
|
+
// 加载认证配置(需在中间件创建前完成)
|
|
1063
|
+
authConfig = loadAuthConfig();
|
|
1064
|
+
|
|
1065
|
+
// 生成用于时序混淆的哈希
|
|
1066
|
+
dummyPasswordHash = bcrypt.hashSync(crypto.randomBytes(16).toString('hex'), 10);
|
|
1067
|
+
|
|
1068
|
+
const app = express();
|
|
1069
|
+
app.disable('x-powered-by');
|
|
1070
|
+
|
|
1071
|
+
// 安全响应头
|
|
1072
|
+
app.use((req, res, next) => {
|
|
1073
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
1074
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
1075
|
+
res.setHeader('Referrer-Policy', 'same-origin');
|
|
1076
|
+
res.setHeader('Content-Security-Policy',
|
|
1077
|
+
"default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; " +
|
|
1078
|
+
"img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'");
|
|
1079
|
+
next();
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
// 请求体解析(限制大小,避免恶意大请求)
|
|
1083
|
+
app.use(express.json({ limit: '100kb' }));
|
|
1084
|
+
app.use(express.urlencoded({ extended: true, limit: '100kb' }));
|
|
1085
|
+
|
|
1086
|
+
// 根据请求语言自动本地化 API 提示信息
|
|
1087
|
+
app.use((req, res, next) => {
|
|
1088
|
+
req.lang = getRequestLanguage(req);
|
|
1089
|
+
|
|
1090
|
+
const originalJson = res.json.bind(res);
|
|
1091
|
+
res.json = (body) => {
|
|
1092
|
+
if (body && typeof body === 'object' && typeof body.message === 'string') {
|
|
1093
|
+
body.message = localizeApiMessage(body.message, req.lang);
|
|
1094
|
+
}
|
|
1095
|
+
return originalJson(body);
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
next();
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
// 会话配置
|
|
1102
|
+
const sessionTimeoutHours = authConfig
|
|
1103
|
+
? (Number(authConfig.sessionConfig.sessionTimeout) || DEFAULT_SESSION_CONFIG.sessionTimeout)
|
|
1104
|
+
: DEFAULT_SESSION_CONFIG.sessionTimeout;
|
|
1105
|
+
|
|
538
1106
|
app.use(session({
|
|
539
|
-
|
|
1107
|
+
name: SESSION_NAME,
|
|
1108
|
+
secret: process.env.SESSION_SECRET || getSessionSecret(),
|
|
540
1109
|
resave: false,
|
|
541
1110
|
saveUninitialized: false,
|
|
542
|
-
|
|
1111
|
+
rolling: true,
|
|
1112
|
+
cookie: {
|
|
1113
|
+
httpOnly: true,
|
|
1114
|
+
sameSite: 'lax',
|
|
1115
|
+
secure: false, // 通过 HTTPS 反向代理部署时可按需改为 true
|
|
1116
|
+
maxAge: sessionTimeoutHours * 60 * 60 * 1000
|
|
1117
|
+
}
|
|
543
1118
|
}));
|
|
544
1119
|
|
|
545
|
-
//
|
|
546
|
-
app.use(
|
|
1120
|
+
// API 响应不做缓存
|
|
1121
|
+
app.use('/api', (req, res, next) => {
|
|
1122
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1123
|
+
next();
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
// 简单的跨站请求防护:拒绝 Origin 与 Host 不一致的写操作
|
|
1127
|
+
app.use('/api', (req, res, next) => {
|
|
1128
|
+
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
|
|
1129
|
+
return next();
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const origin = req.get('origin');
|
|
1133
|
+
if (!origin) {
|
|
1134
|
+
return next();
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
try {
|
|
1138
|
+
const originHost = new URL(origin).host.toLowerCase();
|
|
1139
|
+
const requestHost = String(req.get('host') || '').toLowerCase();
|
|
1140
|
+
if (originHost !== requestHost) {
|
|
1141
|
+
return res.status(403).json({ success: false, message: '跨站请求被拒绝' });
|
|
1142
|
+
}
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
return res.status(403).json({ success: false, message: '非法请求来源' });
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
next();
|
|
1148
|
+
});
|
|
547
1149
|
|
|
548
|
-
//
|
|
1150
|
+
// 认证中间件(API)
|
|
549
1151
|
function requireAuth(req, res, next) {
|
|
550
1152
|
if (req.session && req.session.authenticated) {
|
|
551
1153
|
return next();
|
|
552
|
-
} else {
|
|
553
|
-
return res.status(401).json({ success: false, message: '需要登录' });
|
|
554
1154
|
}
|
|
1155
|
+
return res.status(401).json({ success: false, message: '需要登录' });
|
|
555
1156
|
}
|
|
556
1157
|
|
|
557
|
-
//
|
|
1158
|
+
// 认证中间件(页面)
|
|
1159
|
+
function requirePageAuth(req, res, next) {
|
|
1160
|
+
if (req.session && req.session.authenticated) {
|
|
1161
|
+
return next();
|
|
1162
|
+
}
|
|
1163
|
+
return res.redirect('/login');
|
|
1164
|
+
}
|
|
558
1165
|
|
|
559
|
-
//
|
|
1166
|
+
// 页面路由(放在静态文件之前,避免绕过登录直接访问 HTML)
|
|
560
1167
|
app.get('/', (req, res) => {
|
|
561
1168
|
if (req.session && req.session.authenticated) {
|
|
562
1169
|
res.redirect('/dashboard');
|
|
@@ -565,303 +1172,356 @@ app.get('/', (req, res) => {
|
|
|
565
1172
|
}
|
|
566
1173
|
});
|
|
567
1174
|
|
|
568
|
-
// 登录页面
|
|
569
1175
|
app.get('/login', (req, res) => {
|
|
570
|
-
res.sendFile(path.join(
|
|
1176
|
+
res.sendFile(path.join(PUBLIC_DIR, 'login.html'));
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
app.get('/login.html', (req, res) => {
|
|
1180
|
+
res.redirect('/login');
|
|
571
1181
|
});
|
|
572
1182
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
|
|
1183
|
+
app.get('/dashboard', requirePageAuth, (req, res) => {
|
|
1184
|
+
res.sendFile(path.join(PUBLIC_DIR, 'dashboard.html'));
|
|
576
1185
|
});
|
|
577
1186
|
|
|
1187
|
+
app.get('/dashboard.html', requirePageAuth, (req, res) => {
|
|
1188
|
+
res.redirect('/dashboard');
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
// 静态文件服务(使用绝对路径,避免受启动目录影响)
|
|
1192
|
+
app.use(express.static(PUBLIC_DIR, { index: false, maxAge: '1h' }));
|
|
1193
|
+
|
|
1194
|
+
// 在连接列表中按映射汇总当前活跃连接
|
|
1195
|
+
function buildActiveStats() {
|
|
1196
|
+
const active = new Map();
|
|
1197
|
+
for (const conn of connections.values()) {
|
|
1198
|
+
let item = active.get(conn.mappingId);
|
|
1199
|
+
if (!item) {
|
|
1200
|
+
item = { count: 0, bytesReceived: 0, bytesSent: 0 };
|
|
1201
|
+
active.set(conn.mappingId, item);
|
|
1202
|
+
}
|
|
1203
|
+
item.count++;
|
|
1204
|
+
item.bytesReceived += conn.bytesReceived;
|
|
1205
|
+
item.bytesSent += conn.bytesSent;
|
|
1206
|
+
}
|
|
1207
|
+
return active;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// 根据 ID 或索引查找映射
|
|
1211
|
+
function findMapping(identifier) {
|
|
1212
|
+
let index = config.mappings.findIndex(m => m.id === identifier);
|
|
1213
|
+
|
|
1214
|
+
if (index === -1) {
|
|
1215
|
+
const parsed = parseInt(identifier, 10);
|
|
1216
|
+
if (!isNaN(parsed) && parsed >= 0 && parsed < config.mappings.length) {
|
|
1217
|
+
index = parsed;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
if (index === -1) {
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
return { mapping: config.mappings[index], index };
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// 从请求体构造映射对象(保留未提供的字段)
|
|
1229
|
+
function buildMappingFromBody(body, base) {
|
|
1230
|
+
const source = body || {};
|
|
1231
|
+
const listen = source.listen && typeof source.listen === 'object' ? source.listen : {};
|
|
1232
|
+
const target = source.target && typeof source.target === 'object' ? source.target : {};
|
|
1233
|
+
|
|
1234
|
+
return {
|
|
1235
|
+
id: (base && base.id) || generateMappingId(),
|
|
1236
|
+
name: source.name !== undefined ? String(source.name).trim() : (base ? base.name : ''),
|
|
1237
|
+
listen: {
|
|
1238
|
+
host: listen.host !== undefined ? String(listen.host).trim() : (base ? base.listen.host : '0.0.0.0'),
|
|
1239
|
+
port: listen.port !== undefined ? Number(listen.port) : (base ? base.listen.port : 0)
|
|
1240
|
+
},
|
|
1241
|
+
target: {
|
|
1242
|
+
host: target.host !== undefined ? String(target.host).trim() : (base ? base.target.host : ''),
|
|
1243
|
+
port: target.port !== undefined ? Number(target.port) : (base ? base.target.port : 0)
|
|
1244
|
+
}
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// ==================== 认证 API ====================
|
|
1249
|
+
|
|
578
1250
|
// API: 登录
|
|
579
1251
|
app.post('/api/login', (req, res) => {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1252
|
+
if (!authConfig) {
|
|
1253
|
+
return res.status(503).json({
|
|
1254
|
+
success: false,
|
|
1255
|
+
message: '系统尚未配置管理员密码,请先在服务器上运行 tcpmapping setup-password'
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const body = req.body || {};
|
|
1260
|
+
const username = typeof body.username === 'string' ? body.username.trim() : '';
|
|
1261
|
+
const password = typeof body.password === 'string' ? body.password : '';
|
|
1262
|
+
|
|
1263
|
+
if (!username || !password) {
|
|
1264
|
+
return res.status(400).json({ success: false, message: '请输入用户名和密码' });
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
const clientIP = req.ip || req.socket.remoteAddress;
|
|
1268
|
+
|
|
583
1269
|
// 检查登录尝试限制
|
|
584
1270
|
const attemptCheck = checkLoginAttempts(clientIP);
|
|
585
1271
|
if (!attemptCheck.allowed) {
|
|
586
1272
|
return res.status(429).json({ success: false, message: attemptCheck.message });
|
|
587
1273
|
}
|
|
588
|
-
|
|
589
|
-
// 验证用户凭据
|
|
1274
|
+
|
|
590
1275
|
const validation = validateUserCredentials(username, password);
|
|
591
|
-
|
|
592
|
-
if (validation.success) {
|
|
593
|
-
// 登录成功
|
|
594
|
-
req.session.authenticated = true;
|
|
595
|
-
req.session.username = username;
|
|
596
|
-
req.session.userRole = validation.user.role;
|
|
597
|
-
|
|
598
|
-
// 清除登录失败记录
|
|
599
|
-
clearLoginFailures(clientIP);
|
|
600
|
-
|
|
601
|
-
log('info', `用户 ${username} 从 ${clientIP} 登录成功`);
|
|
602
|
-
res.json({ success: true, message: '登录成功' });
|
|
603
|
-
} else {
|
|
604
|
-
// 登录失败
|
|
1276
|
+
|
|
1277
|
+
if (!validation.success) {
|
|
605
1278
|
recordLoginFailure(clientIP);
|
|
606
|
-
|
|
607
1279
|
log('warn', `用户 ${username} 从 ${clientIP} 登录失败: ${validation.message}`);
|
|
608
|
-
res.status(401).json({ success: false, message: validation.message });
|
|
1280
|
+
return res.status(401).json({ success: false, message: validation.message });
|
|
609
1281
|
}
|
|
1282
|
+
|
|
1283
|
+
// 登录成功后重建会话,防止会话固定攻击
|
|
1284
|
+
req.session.regenerate((regenerateError) => {
|
|
1285
|
+
if (regenerateError) {
|
|
1286
|
+
log('error', `创建会话失败: ${regenerateError.message}`);
|
|
1287
|
+
return res.status(500).json({ success: false, message: '登录失败,请稍后重试' });
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
req.session.authenticated = true;
|
|
1291
|
+
req.session.username = validation.user.username;
|
|
1292
|
+
req.session.userRole = validation.user.role || 'admin';
|
|
1293
|
+
|
|
1294
|
+
req.session.save((saveError) => {
|
|
1295
|
+
if (saveError) {
|
|
1296
|
+
log('error', `保存会话失败: ${saveError.message}`);
|
|
1297
|
+
return res.status(500).json({ success: false, message: '登录失败,请稍后重试' });
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
clearLoginFailures(clientIP);
|
|
1301
|
+
log('info', `用户 ${validation.user.username} 从 ${clientIP} 登录成功`);
|
|
1302
|
+
res.json({ success: true, message: '登录成功' });
|
|
1303
|
+
});
|
|
1304
|
+
});
|
|
610
1305
|
});
|
|
611
1306
|
|
|
612
1307
|
// API: 登出
|
|
613
1308
|
app.post('/api/logout', (req, res) => {
|
|
614
|
-
req.session.destroy()
|
|
615
|
-
|
|
1309
|
+
req.session.destroy(() => {
|
|
1310
|
+
res.clearCookie(SESSION_NAME, { path: '/' });
|
|
1311
|
+
res.json({ success: true, message: '已登出' });
|
|
1312
|
+
});
|
|
616
1313
|
});
|
|
617
1314
|
|
|
618
|
-
//
|
|
619
|
-
function getConnectionCountByPort(port) {
|
|
620
|
-
let count = 0;
|
|
621
|
-
for (const conn of connections.values()) {
|
|
622
|
-
if (conn.sourcePort === port) {
|
|
623
|
-
count++;
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
return count;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// 获取指定端口的连接统计
|
|
630
|
-
function getConnectionStatsByPort(port) {
|
|
631
|
-
let count = 0;
|
|
632
|
-
let bytesReceived = 0;
|
|
633
|
-
let bytesSent = 0;
|
|
634
|
-
for (const conn of connections.values()) {
|
|
635
|
-
if (conn.sourcePort === port) {
|
|
636
|
-
count++;
|
|
637
|
-
bytesReceived += conn.bytesReceived;
|
|
638
|
-
bytesSent += conn.bytesSent;
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
return { count, bytesReceived, bytesSent };
|
|
642
|
-
}
|
|
1315
|
+
// ==================== 映射管理 API ====================
|
|
643
1316
|
|
|
644
1317
|
// API: 获取端口映射列表
|
|
645
1318
|
app.get('/api/mappings', requireAuth, (req, res) => {
|
|
1319
|
+
const activeStats = buildActiveStats();
|
|
1320
|
+
|
|
646
1321
|
const mappings = config.mappings.map(mapping => {
|
|
647
|
-
const
|
|
1322
|
+
const active = activeStats.get(mapping.id) || { count: 0, bytesReceived: 0, bytesSent: 0 };
|
|
1323
|
+
const total = getMappingStats(mapping.id);
|
|
1324
|
+
const runtime = servers.get(`${mapping.listen.host}:${mapping.listen.port}`);
|
|
1325
|
+
|
|
648
1326
|
return {
|
|
649
1327
|
...mapping,
|
|
650
|
-
status:
|
|
651
|
-
connectionCount:
|
|
652
|
-
bytesReceived:
|
|
653
|
-
bytesSent:
|
|
1328
|
+
status: runtime && runtime.mapping === mapping ? 'running' : 'stopped',
|
|
1329
|
+
connectionCount: active.count,
|
|
1330
|
+
bytesReceived: active.bytesReceived,
|
|
1331
|
+
bytesSent: active.bytesSent,
|
|
1332
|
+
totalConnections: total.totalConnections,
|
|
1333
|
+
totalBytesReceived: total.bytesReceived,
|
|
1334
|
+
totalBytesSent: total.bytesSent
|
|
654
1335
|
};
|
|
655
1336
|
});
|
|
1337
|
+
|
|
656
1338
|
res.json({ success: true, data: mappings });
|
|
657
1339
|
});
|
|
658
1340
|
|
|
659
1341
|
// API: 添加端口映射
|
|
660
|
-
app.post('/api/mappings', requireAuth, (req, res) => {
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
const mapping = {
|
|
664
|
-
name: name || '',
|
|
665
|
-
listen: {
|
|
666
|
-
host: (listen && listen.host) || '0.0.0.0',
|
|
667
|
-
port: listen ? parseInt(listen.port) : 0
|
|
668
|
-
},
|
|
669
|
-
target: {
|
|
670
|
-
host: (target && target.host) || '',
|
|
671
|
-
port: target ? parseInt(target.port) : 0
|
|
672
|
-
}
|
|
673
|
-
};
|
|
1342
|
+
app.post('/api/mappings', requireAuth, async (req, res) => {
|
|
1343
|
+
const mapping = buildMappingFromBody(req.body, null);
|
|
674
1344
|
|
|
675
|
-
// 输入验证
|
|
676
1345
|
const validation = validateMappingInput(mapping);
|
|
677
1346
|
if (!validation.isValid) {
|
|
678
1347
|
return res.status(400).json({ success: false, message: validation.errors.join('; ') });
|
|
679
1348
|
}
|
|
680
1349
|
|
|
681
|
-
// 检查端口是否已被使用
|
|
682
1350
|
const exists = config.mappings.some(m =>
|
|
683
1351
|
m.listen.host === mapping.listen.host && m.listen.port === mapping.listen.port
|
|
684
1352
|
);
|
|
685
1353
|
|
|
686
1354
|
if (exists) {
|
|
687
|
-
return res.status(400).json({ success: false, message: '
|
|
1355
|
+
return res.status(400).json({ success: false, message: '该监听地址和端口已存在映射' });
|
|
688
1356
|
}
|
|
689
1357
|
|
|
690
|
-
//
|
|
691
|
-
|
|
1358
|
+
// 先尝试启动,成功后再写入配置,避免出现"配置存在但端口未监听"的状态
|
|
1359
|
+
const result = await startMapping(mapping);
|
|
1360
|
+
if (!result.success) {
|
|
1361
|
+
return res.status(400).json(result);
|
|
1362
|
+
}
|
|
692
1363
|
|
|
693
1364
|
config.mappings.push(mapping);
|
|
1365
|
+
getMappingStats(mapping.id);
|
|
694
1366
|
|
|
695
|
-
if (saveConfig(config)) {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
res.status(500).json({ success: false, message: '保存配置失败' });
|
|
1367
|
+
if (!saveConfig(config)) {
|
|
1368
|
+
config.mappings.pop();
|
|
1369
|
+
stopMapping(mapping.listen.host, mapping.listen.port);
|
|
1370
|
+
return res.status(500).json({ success: false, message: '保存配置失败' });
|
|
700
1371
|
}
|
|
701
|
-
});
|
|
702
|
-
|
|
703
|
-
// API: 删除端口映射 (支持ID或索引)
|
|
704
|
-
app.delete('/api/mappings/:identifier', requireAuth, (req, res) => {
|
|
705
|
-
const identifier = req.params.identifier;
|
|
706
|
-
let mappingIndex = -1;
|
|
707
1372
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
// 如果没找到,尝试作为索引使用(向后兼容)
|
|
712
|
-
if (mappingIndex === -1) {
|
|
713
|
-
const index = parseInt(identifier);
|
|
714
|
-
if (!isNaN(index) && index >= 0 && index < config.mappings.length) {
|
|
715
|
-
mappingIndex = index;
|
|
716
|
-
}
|
|
717
|
-
}
|
|
1373
|
+
res.json({ ...result, data: mapping });
|
|
1374
|
+
});
|
|
718
1375
|
|
|
719
|
-
|
|
1376
|
+
// API: 更新映射配置
|
|
1377
|
+
app.put('/api/mappings/:identifier', requireAuth, async (req, res) => {
|
|
1378
|
+
const found = findMapping(req.params.identifier);
|
|
1379
|
+
if (!found) {
|
|
720
1380
|
return res.status(404).json({ success: false, message: '映射不存在' });
|
|
721
1381
|
}
|
|
722
1382
|
|
|
723
|
-
const mapping =
|
|
1383
|
+
const { mapping: oldMapping, index } = found;
|
|
1384
|
+
const newMapping = buildMappingFromBody(req.body, oldMapping);
|
|
724
1385
|
|
|
725
|
-
|
|
726
|
-
|
|
1386
|
+
const validation = validateMappingInput(newMapping);
|
|
1387
|
+
if (!validation.isValid) {
|
|
1388
|
+
return res.status(400).json({ success: false, message: validation.errors.join('; ') });
|
|
1389
|
+
}
|
|
727
1390
|
|
|
728
|
-
|
|
729
|
-
|
|
1391
|
+
const listenChanged = oldMapping.listen.host !== newMapping.listen.host ||
|
|
1392
|
+
oldMapping.listen.port !== newMapping.listen.port;
|
|
730
1393
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
});
|
|
1394
|
+
// 仅修改名称或目标时无需重启监听,避免断开已有连接;
|
|
1395
|
+
// 新连接会自动使用新的目标地址。
|
|
1396
|
+
if (!listenChanged) {
|
|
1397
|
+
const previousName = oldMapping.name;
|
|
1398
|
+
const previousTarget = { ...oldMapping.target };
|
|
737
1399
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
const identifier = req.params.identifier;
|
|
741
|
-
let mapping = config.mappings.find(m => m.id === identifier);
|
|
1400
|
+
oldMapping.name = newMapping.name;
|
|
1401
|
+
oldMapping.target = newMapping.target;
|
|
742
1402
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
mapping = config.mappings[index];
|
|
1403
|
+
if (!saveConfig(config)) {
|
|
1404
|
+
oldMapping.name = previousName;
|
|
1405
|
+
oldMapping.target = previousTarget;
|
|
1406
|
+
return res.status(500).json({ success: false, message: '保存配置失败' });
|
|
748
1407
|
}
|
|
749
|
-
}
|
|
750
1408
|
|
|
751
|
-
|
|
752
|
-
return res.status(404).json({ success: false, message: '映射不存在' });
|
|
1409
|
+
return res.json({ success: true, message: '端口映射已更新', data: oldMapping });
|
|
753
1410
|
}
|
|
754
1411
|
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
1412
|
+
const exists = config.mappings.some((m, i) =>
|
|
1413
|
+
i !== index &&
|
|
1414
|
+
m.listen.host === newMapping.listen.host &&
|
|
1415
|
+
m.listen.port === newMapping.listen.port
|
|
1416
|
+
);
|
|
1417
|
+
if (exists) {
|
|
1418
|
+
return res.status(400).json({ success: false, message: '该监听地址和端口已存在映射' });
|
|
1419
|
+
}
|
|
758
1420
|
|
|
759
|
-
|
|
760
|
-
app.post('/api/mappings/:identifier/stop', requireAuth, (req, res) => {
|
|
761
|
-
const identifier = req.params.identifier;
|
|
762
|
-
let mapping = config.mappings.find(m => m.id === identifier);
|
|
1421
|
+
const wasRunning = servers.has(`${oldMapping.listen.host}:${oldMapping.listen.port}`);
|
|
763
1422
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
const index = parseInt(identifier);
|
|
767
|
-
if (!isNaN(index) && index >= 0 && index < config.mappings.length) {
|
|
768
|
-
mapping = config.mappings[index];
|
|
769
|
-
}
|
|
1423
|
+
if (wasRunning) {
|
|
1424
|
+
stopMapping(oldMapping.listen.host, oldMapping.listen.port);
|
|
770
1425
|
}
|
|
771
1426
|
|
|
772
|
-
|
|
773
|
-
|
|
1427
|
+
let startResult = { success: true, message: '端口映射已更新' };
|
|
1428
|
+
if (wasRunning) {
|
|
1429
|
+
startResult = await startMapping(newMapping);
|
|
1430
|
+
if (!startResult.success) {
|
|
1431
|
+
// 启动失败,恢复旧映射
|
|
1432
|
+
await startMapping(oldMapping);
|
|
1433
|
+
return res.status(400).json(startResult);
|
|
1434
|
+
}
|
|
774
1435
|
}
|
|
775
1436
|
|
|
776
|
-
|
|
777
|
-
res.json(result);
|
|
778
|
-
});
|
|
1437
|
+
config.mappings[index] = newMapping;
|
|
779
1438
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
if (mappingIndex === -1) {
|
|
787
|
-
const index = parseInt(identifier);
|
|
788
|
-
if (!isNaN(index) && index >= 0 && index < config.mappings.length) {
|
|
789
|
-
mappingIndex = index;
|
|
1439
|
+
if (!saveConfig(config)) {
|
|
1440
|
+
// 保存失败,回滚
|
|
1441
|
+
config.mappings[index] = oldMapping;
|
|
1442
|
+
if (wasRunning) {
|
|
1443
|
+
stopMapping(newMapping.listen.host, newMapping.listen.port);
|
|
1444
|
+
await startMapping(oldMapping);
|
|
790
1445
|
}
|
|
1446
|
+
return res.status(500).json({ success: false, message: '保存配置失败' });
|
|
791
1447
|
}
|
|
792
1448
|
|
|
793
|
-
|
|
1449
|
+
res.json({ success: true, message: '端口映射已更新', data: newMapping });
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
// API: 删除端口映射 (支持ID或索引)
|
|
1453
|
+
app.delete('/api/mappings/:identifier', requireAuth, (req, res) => {
|
|
1454
|
+
const found = findMapping(req.params.identifier);
|
|
1455
|
+
if (!found) {
|
|
794
1456
|
return res.status(404).json({ success: false, message: '映射不存在' });
|
|
795
1457
|
}
|
|
796
1458
|
|
|
797
|
-
const
|
|
798
|
-
const
|
|
1459
|
+
const { mapping, index } = found;
|
|
1460
|
+
const wasRunning = servers.has(`${mapping.listen.host}:${mapping.listen.port}`);
|
|
799
1461
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
port: target ? parseInt(target.port) : oldMapping.target.port
|
|
1462
|
+
stopMapping(mapping.listen.host, mapping.listen.port);
|
|
1463
|
+
config.mappings.splice(index, 1);
|
|
1464
|
+
mappingStats.delete(mapping.id);
|
|
1465
|
+
|
|
1466
|
+
if (!saveConfig(config)) {
|
|
1467
|
+
// 保存失败,回滚
|
|
1468
|
+
config.mappings.splice(index, 0, mapping);
|
|
1469
|
+
if (wasRunning) {
|
|
1470
|
+
startMapping(mapping);
|
|
810
1471
|
}
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
// 验证新配置
|
|
814
|
-
const validation = validateMappingInput(newMapping);
|
|
815
|
-
if (!validation.isValid) {
|
|
816
|
-
return res.status(400).json({ success: false, message: validation.errors.join('; ') });
|
|
1472
|
+
return res.status(500).json({ success: false, message: '保存配置失败' });
|
|
817
1473
|
}
|
|
818
1474
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
oldMapping.listen.host !== newMapping.listen.host;
|
|
822
|
-
if (portChanged) {
|
|
823
|
-
const exists = config.mappings.some((m, i) =>
|
|
824
|
-
i !== mappingIndex &&
|
|
825
|
-
m.listen.host === newMapping.listen.host &&
|
|
826
|
-
m.listen.port === newMapping.listen.port
|
|
827
|
-
);
|
|
828
|
-
if (exists) {
|
|
829
|
-
return res.status(400).json({ success: false, message: '端口映射已存在' });
|
|
830
|
-
}
|
|
831
|
-
}
|
|
1475
|
+
res.json({ success: true, message: '端口映射已删除' });
|
|
1476
|
+
});
|
|
832
1477
|
|
|
833
|
-
|
|
834
|
-
|
|
1478
|
+
// API: 启动单个映射
|
|
1479
|
+
app.post('/api/mappings/:identifier/start', requireAuth, async (req, res) => {
|
|
1480
|
+
const found = findMapping(req.params.identifier);
|
|
1481
|
+
if (!found) {
|
|
1482
|
+
return res.status(404).json({ success: false, message: '映射不存在' });
|
|
1483
|
+
}
|
|
835
1484
|
|
|
836
|
-
|
|
837
|
-
|
|
1485
|
+
const result = await startMapping(found.mapping);
|
|
1486
|
+
res.json(result);
|
|
1487
|
+
});
|
|
838
1488
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
res.status(500).json({ success: false, message: '保存配置失败' });
|
|
1489
|
+
// API: 停止单个映射
|
|
1490
|
+
app.post('/api/mappings/:identifier/stop', requireAuth, (req, res) => {
|
|
1491
|
+
const found = findMapping(req.params.identifier);
|
|
1492
|
+
if (!found) {
|
|
1493
|
+
return res.status(404).json({ success: false, message: '映射不存在' });
|
|
845
1494
|
}
|
|
1495
|
+
|
|
1496
|
+
const result = stopMapping(found.mapping.listen.host, found.mapping.listen.port);
|
|
1497
|
+
res.json(result);
|
|
846
1498
|
});
|
|
847
1499
|
|
|
1500
|
+
// ==================== 监控 API ====================
|
|
1501
|
+
|
|
848
1502
|
// API: 获取连接状态
|
|
849
1503
|
app.get('/api/connections', requireAuth, (req, res) => {
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
1504
|
+
const now = Date.now();
|
|
1505
|
+
const connectionList = Array.from(connections.values()).map(conn => ({
|
|
1506
|
+
...conn,
|
|
1507
|
+
durationMs: Math.max(0, now - conn.startTime.getTime())
|
|
1508
|
+
}));
|
|
1509
|
+
|
|
1510
|
+
// 按监听地址 + 端口分组统计
|
|
853
1511
|
const stats = {};
|
|
854
1512
|
connectionList.forEach(conn => {
|
|
855
|
-
|
|
856
|
-
|
|
1513
|
+
const key = `${conn.sourceHost}:${conn.sourcePort}`;
|
|
1514
|
+
if (!stats[key]) {
|
|
1515
|
+
stats[key] = {
|
|
1516
|
+
host: conn.sourceHost,
|
|
857
1517
|
port: conn.sourcePort,
|
|
858
1518
|
mappingName: conn.mappingName,
|
|
859
1519
|
totalConnections: 0,
|
|
860
1520
|
connections: []
|
|
861
1521
|
};
|
|
862
1522
|
}
|
|
863
|
-
stats[
|
|
864
|
-
stats[
|
|
1523
|
+
stats[key].totalConnections++;
|
|
1524
|
+
stats[key].connections.push(conn);
|
|
865
1525
|
});
|
|
866
1526
|
|
|
867
1527
|
res.json({
|
|
@@ -873,16 +1533,57 @@ app.get('/api/connections', requireAuth, (req, res) => {
|
|
|
873
1533
|
});
|
|
874
1534
|
});
|
|
875
1535
|
|
|
1536
|
+
// API: 获取最近关闭的连接记录(可记录源地址、目的地址、持续时长)
|
|
1537
|
+
app.get('/api/connections/history', requireAuth, (req, res) => {
|
|
1538
|
+
const mappingId = typeof req.query.mappingId === 'string' ? req.query.mappingId : '';
|
|
1539
|
+
|
|
1540
|
+
let limit = parseInt(req.query.limit, 10);
|
|
1541
|
+
if (!Number.isFinite(limit) || limit <= 0) {
|
|
1542
|
+
limit = 50;
|
|
1543
|
+
}
|
|
1544
|
+
limit = Math.min(limit, 200);
|
|
1545
|
+
|
|
1546
|
+
const matched = mappingId
|
|
1547
|
+
? connectionHistory.filter(item => item.mappingId === mappingId)
|
|
1548
|
+
: connectionHistory;
|
|
1549
|
+
|
|
1550
|
+
res.json({
|
|
1551
|
+
success: true,
|
|
1552
|
+
data: {
|
|
1553
|
+
total: matched.length,
|
|
1554
|
+
limit: limit,
|
|
1555
|
+
items: matched.slice(-limit).reverse().map(item => ({
|
|
1556
|
+
...item,
|
|
1557
|
+
closeReason: localizeCloseReason(item.closeReason, req.lang)
|
|
1558
|
+
}))
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
});
|
|
1562
|
+
|
|
876
1563
|
// API: 获取系统状态
|
|
877
1564
|
app.get('/api/status', requireAuth, (req, res) => {
|
|
878
1565
|
res.json({
|
|
879
1566
|
success: true,
|
|
880
1567
|
data: {
|
|
1568
|
+
version: VERSION,
|
|
1569
|
+
username: req.session.username || 'admin',
|
|
881
1570
|
totalMappings: config.mappings.length,
|
|
882
1571
|
activeMappings: servers.size,
|
|
883
1572
|
totalConnections: connections.size,
|
|
884
1573
|
uptime: process.uptime(),
|
|
885
|
-
memoryUsage: process.memoryUsage()
|
|
1574
|
+
memoryUsage: process.memoryUsage(),
|
|
1575
|
+
resources: {
|
|
1576
|
+
cpuPercent: Number(resourceSnapshot.cpuPercent.toFixed(2)),
|
|
1577
|
+
cpuCount: CPU_COUNT,
|
|
1578
|
+
memory: { ...resourceSnapshot.memory },
|
|
1579
|
+
traffic: {
|
|
1580
|
+
receivedPerSecond: Math.round(resourceSnapshot.traffic.receivedPerSecond),
|
|
1581
|
+
sentPerSecond: Math.round(resourceSnapshot.traffic.sentPerSecond),
|
|
1582
|
+
totalReceived: processTraffic.received,
|
|
1583
|
+
totalSent: processTraffic.sent
|
|
1584
|
+
},
|
|
1585
|
+
sampledAt: resourceSnapshot.sampledAt
|
|
1586
|
+
}
|
|
886
1587
|
}
|
|
887
1588
|
});
|
|
888
1589
|
});
|
|
@@ -891,6 +1592,7 @@ app.get('/api/status', requireAuth, (req, res) => {
|
|
|
891
1592
|
app.get('/api/health', (req, res) => {
|
|
892
1593
|
res.json({
|
|
893
1594
|
status: 'ok',
|
|
1595
|
+
version: VERSION,
|
|
894
1596
|
timestamp: new Date().toISOString(),
|
|
895
1597
|
uptime: process.uptime(),
|
|
896
1598
|
activeMappings: servers.size,
|
|
@@ -898,52 +1600,105 @@ app.get('/api/health', (req, res) => {
|
|
|
898
1600
|
});
|
|
899
1601
|
});
|
|
900
1602
|
|
|
1603
|
+
// 未匹配的 API 返回 JSON 404
|
|
1604
|
+
app.use('/api', (req, res) => {
|
|
1605
|
+
res.status(404).json({ success: false, message: '接口不存在' });
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
// 统一错误处理
|
|
1609
|
+
app.use((err, req, res, next) => {
|
|
1610
|
+
console.error('请求处理错误:', err);
|
|
1611
|
+
if (res.headersSent) {
|
|
1612
|
+
return next(err);
|
|
1613
|
+
}
|
|
1614
|
+
res.status(500).json({ success: false, message: '服务器内部错误' });
|
|
1615
|
+
});
|
|
1616
|
+
|
|
901
1617
|
// 启动程序
|
|
902
|
-
function startServer() {
|
|
903
|
-
// 加载配置
|
|
1618
|
+
async function startServer() {
|
|
904
1619
|
config = loadConfig();
|
|
905
|
-
|
|
906
|
-
// 加载认证配置
|
|
907
|
-
authConfig = loadAuthConfig();
|
|
908
|
-
|
|
909
|
-
// 启动TCP转发服务器
|
|
1620
|
+
|
|
910
1621
|
console.log('==========================================');
|
|
911
|
-
console.log(
|
|
1622
|
+
console.log(`TCP转发服务器 v${VERSION} 正在启动...`);
|
|
912
1623
|
console.log('端口映射配置:');
|
|
913
|
-
startAllMappings();
|
|
1624
|
+
await startAllMappings();
|
|
914
1625
|
console.log('==========================================');
|
|
915
|
-
|
|
1626
|
+
|
|
916
1627
|
// 启动WebUI服务器
|
|
917
|
-
if (config.webui && config.webui.enabled) {
|
|
918
|
-
|
|
919
|
-
|
|
1628
|
+
if (config.webui && config.webui.enabled !== false) {
|
|
1629
|
+
if (!authConfig) {
|
|
1630
|
+
console.error('WebUI 未启动:缺少可用的认证配置');
|
|
1631
|
+
console.error('请运行 "tcpmapping setup-password" 设置管理员密码后重启服务');
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
const webPort = isValidPort(config.webui.port) ? Number(config.webui.port) : 9999;
|
|
1636
|
+
const webHost = isValidHost(config.webui.host) ? config.webui.host : '127.0.0.1';
|
|
920
1637
|
|
|
921
|
-
app.listen(webPort, webHost, () => {
|
|
922
|
-
console.log(`WebUI管理界面已启动: http://${webHost}:${webPort}`);
|
|
1638
|
+
httpServer = app.listen(webPort, webHost, () => {
|
|
1639
|
+
console.log(`WebUI管理界面已启动: http://${webHost === '0.0.0.0' || webHost === '::' ? 'localhost' : webHost}:${webPort}`);
|
|
1640
|
+
if (webHost === '0.0.0.0' || webHost === '::') {
|
|
1641
|
+
console.warn('警告: WebUI 监听在所有网卡上,请确保使用强密码并考虑通过防火墙限制访问');
|
|
1642
|
+
}
|
|
923
1643
|
console.log('==========================================');
|
|
924
1644
|
});
|
|
1645
|
+
|
|
1646
|
+
httpServer.on('error', (err) => {
|
|
1647
|
+
if (err.code === 'EADDRINUSE') {
|
|
1648
|
+
console.error(`WebUI 启动失败: 端口 ${webPort} 已被占用`);
|
|
1649
|
+
} else {
|
|
1650
|
+
console.error(`WebUI 启动失败: ${err.message}`);
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
} else {
|
|
1654
|
+
console.log('WebUI 已禁用(config.json 中 webui.enabled = false)');
|
|
925
1655
|
}
|
|
926
1656
|
}
|
|
927
1657
|
|
|
928
1658
|
// 优雅关闭
|
|
929
|
-
function gracefulShutdown() {
|
|
930
|
-
|
|
1659
|
+
function gracefulShutdown(signal) {
|
|
1660
|
+
if (shuttingDown) {
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
shuttingDown = true;
|
|
1664
|
+
|
|
1665
|
+
log('info', `收到 ${signal || '停止'} 信号,正在关闭所有服务器...`);
|
|
931
1666
|
console.log('正在停止所有服务器...');
|
|
932
|
-
|
|
933
|
-
//
|
|
934
|
-
|
|
935
|
-
|
|
1667
|
+
|
|
1668
|
+
// 停止接收新连接
|
|
1669
|
+
if (httpServer) {
|
|
1670
|
+
httpServer.close();
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
for (const { server } of servers.values()) {
|
|
1674
|
+
try {
|
|
1675
|
+
server.close();
|
|
1676
|
+
} catch (error) {
|
|
1677
|
+
// 忽略关闭错误
|
|
1678
|
+
}
|
|
936
1679
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1680
|
+
servers.clear();
|
|
1681
|
+
|
|
1682
|
+
// 断开所有转发连接
|
|
1683
|
+
for (const id of Array.from(connectionSockets.keys())) {
|
|
1684
|
+
destroyConnection(id, '服务正在关闭');
|
|
1685
|
+
}
|
|
1686
|
+
|
|
941
1687
|
console.log('所有服务器已停止');
|
|
942
|
-
|
|
1688
|
+
|
|
1689
|
+
// 给日志和连接一点清理时间后退出
|
|
1690
|
+
setTimeout(() => process.exit(0), 200).unref();
|
|
943
1691
|
}
|
|
944
1692
|
|
|
945
|
-
process.on('SIGINT', gracefulShutdown);
|
|
946
|
-
process.on('SIGTERM', gracefulShutdown);
|
|
1693
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
1694
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
1695
|
+
|
|
1696
|
+
process.on('unhandledRejection', (reason) => {
|
|
1697
|
+
console.error('未处理的 Promise 拒绝:', reason);
|
|
1698
|
+
});
|
|
947
1699
|
|
|
948
1700
|
// 启动服务器
|
|
949
|
-
startServer()
|
|
1701
|
+
startServer().catch((error) => {
|
|
1702
|
+
console.error('服务器启动失败:', error);
|
|
1703
|
+
process.exit(1);
|
|
1704
|
+
});
|