@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/bin/tcpmapping.js
CHANGED
|
@@ -1,269 +1,538 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const { spawn
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const os = require('os');
|
|
7
|
+
const http = require('http');
|
|
8
|
+
const net = require('net');
|
|
7
9
|
|
|
8
10
|
const APP_NAME = 'tcpmapping';
|
|
9
|
-
const DATA_DIR = path.join(os.homedir(), '.tcpmapping');
|
|
11
|
+
const DATA_DIR = process.env.TCPMAPPING_DATA_DIR || path.join(os.homedir(), '.tcpmapping');
|
|
10
12
|
const PID_FILE = path.join(DATA_DIR, 'tcpmapping.pid');
|
|
11
13
|
const LOG_FILE = path.join(DATA_DIR, 'tcpmapping.log');
|
|
12
14
|
const SERVER_FILE = path.join(__dirname, '..', 'server.js');
|
|
13
15
|
const SETUP_PASSWORD_FILE = path.join(__dirname, '..', 'setup-password.js');
|
|
14
16
|
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
+
// 解析命令行参数
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
const options = { port: null, host: null };
|
|
20
|
+
const positional = [];
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
const arg = argv[i];
|
|
24
|
+
|
|
25
|
+
if (arg === '--port' || arg === '-p') {
|
|
26
|
+
options.port = argv[++i];
|
|
27
|
+
} else if (arg.startsWith('--port=')) {
|
|
28
|
+
options.port = arg.slice('--port='.length);
|
|
29
|
+
} else if (arg === '--host' || arg === '-H' || arg === '--listen') {
|
|
30
|
+
options.host = argv[++i];
|
|
31
|
+
} else if (arg.startsWith('--host=')) {
|
|
32
|
+
options.host = arg.slice('--host='.length);
|
|
33
|
+
} else if (arg.startsWith('--listen=')) {
|
|
34
|
+
options.host = arg.slice('--listen='.length);
|
|
35
|
+
} else {
|
|
36
|
+
positional.push(arg);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { options, positional };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { options: webuiOptions, positional: args } = parseArgs(process.argv.slice(2));
|
|
17
44
|
const command = args[0] || 'help';
|
|
18
45
|
|
|
19
|
-
|
|
46
|
+
if (webuiOptions.port !== null && !isValidPort(webuiOptions.port)) {
|
|
47
|
+
console.error(`无效的端口: ${webuiOptions.port}(应为 1-65535)`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (webuiOptions.host !== null && !isValidHost(webuiOptions.host)) {
|
|
52
|
+
console.error(`无效的监听地址: ${webuiOptions.host}`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 颜色输出(非 TTY 或设置了 NO_COLOR 时自动关闭)
|
|
57
|
+
const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
58
|
+
const colorize = (code) => (text) => useColor ? `\x1b[${code}m${text}\x1b[0m` : String(text);
|
|
59
|
+
|
|
20
60
|
const colors = {
|
|
21
|
-
green: (
|
|
22
|
-
red: (
|
|
23
|
-
yellow: (
|
|
24
|
-
blue: (
|
|
25
|
-
cyan: (
|
|
26
|
-
gray: (
|
|
61
|
+
green: colorize(32),
|
|
62
|
+
red: colorize(31),
|
|
63
|
+
yellow: colorize(33),
|
|
64
|
+
blue: colorize(34),
|
|
65
|
+
cyan: colorize(36),
|
|
66
|
+
gray: colorize(90)
|
|
27
67
|
};
|
|
28
68
|
|
|
69
|
+
function delay(ms) {
|
|
70
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
71
|
+
}
|
|
72
|
+
|
|
29
73
|
// 确保数据目录存在
|
|
30
74
|
function ensureDataDir() {
|
|
31
75
|
if (!fs.existsSync(DATA_DIR)) {
|
|
32
|
-
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
76
|
+
fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
|
|
33
77
|
}
|
|
34
78
|
}
|
|
35
79
|
|
|
80
|
+
// 读取配置文件
|
|
81
|
+
function readConfig() {
|
|
82
|
+
const candidates = [
|
|
83
|
+
path.join(process.cwd(), 'config.json'),
|
|
84
|
+
path.join(DATA_DIR, 'config.json')
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
for (const configPath of candidates) {
|
|
88
|
+
try {
|
|
89
|
+
if (fs.existsSync(configPath)) {
|
|
90
|
+
return { path: configPath, data: JSON.parse(fs.readFileSync(configPath, 'utf8')) };
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.warn(colors.yellow(`警告: 配置文件解析失败 (${configPath}): ${error.message}`));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { path: null, data: null };
|
|
98
|
+
}
|
|
99
|
+
|
|
36
100
|
// 获取配置文件路径(按优先级)
|
|
37
101
|
function getConfigPath() {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (fs.existsSync(cwdPath)) {
|
|
41
|
-
return cwdPath;
|
|
42
|
-
}
|
|
102
|
+
return readConfig().path;
|
|
103
|
+
}
|
|
43
104
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
105
|
+
// 获取认证配置文件路径
|
|
106
|
+
function getAuthConfigPath() {
|
|
107
|
+
const candidates = [
|
|
108
|
+
path.join(process.cwd(), 'auth-config.json'),
|
|
109
|
+
path.join(DATA_DIR, 'auth-config.json')
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
for (const authPath of candidates) {
|
|
113
|
+
if (fs.existsSync(authPath)) {
|
|
114
|
+
return authPath;
|
|
115
|
+
}
|
|
48
116
|
}
|
|
49
117
|
|
|
50
118
|
return null;
|
|
51
119
|
}
|
|
52
120
|
|
|
53
|
-
//
|
|
54
|
-
function
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
121
|
+
// 验证端口
|
|
122
|
+
function isValidPort(port) {
|
|
123
|
+
const p = Number(port);
|
|
124
|
+
return Number.isInteger(p) && p >= 1 && p <= 65535;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 验证 IP 地址或主机名(与 server.js 保持一致)
|
|
128
|
+
function isValidHost(host) {
|
|
129
|
+
if (!host || typeof host !== 'string') return false;
|
|
130
|
+
|
|
131
|
+
const value = host.trim();
|
|
132
|
+
if (!value || value.length > 253) return false;
|
|
133
|
+
if (net.isIP(value)) return true;
|
|
134
|
+
|
|
135
|
+
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])?)*$/;
|
|
136
|
+
return hostnamePattern.test(value);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 获取 WebUI 访问信息(优先级:命令行参数 > 环境变量 > 配置文件)
|
|
140
|
+
function getWebuiInfo() {
|
|
141
|
+
const config = readConfig().data;
|
|
142
|
+
const webui = (config && config.webui) || {};
|
|
143
|
+
|
|
144
|
+
let host = webui.host || '127.0.0.1';
|
|
145
|
+
let port = Number(webui.port) || 9999;
|
|
146
|
+
|
|
147
|
+
if (process.env.WEBUI_PORT && isValidPort(process.env.WEBUI_PORT)) {
|
|
148
|
+
port = Number(process.env.WEBUI_PORT);
|
|
149
|
+
}
|
|
150
|
+
if (process.env.WEBUI_HOST && isValidHost(process.env.WEBUI_HOST)) {
|
|
151
|
+
host = process.env.WEBUI_HOST.trim();
|
|
58
152
|
}
|
|
59
153
|
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
154
|
+
// 命令行参数优先级最高
|
|
155
|
+
if (webuiOptions.port !== null) {
|
|
156
|
+
port = Number(webuiOptions.port);
|
|
157
|
+
}
|
|
158
|
+
if (webuiOptions.host !== null) {
|
|
159
|
+
host = webuiOptions.host.trim();
|
|
63
160
|
}
|
|
64
161
|
|
|
65
|
-
return
|
|
162
|
+
return {
|
|
163
|
+
enabled: webui.enabled !== false,
|
|
164
|
+
host,
|
|
165
|
+
port
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 将监听地址转换为可访问地址
|
|
170
|
+
function getAccessHost(host) {
|
|
171
|
+
if (host === '0.0.0.0' || host === '::' || !host) {
|
|
172
|
+
return 'localhost';
|
|
173
|
+
}
|
|
174
|
+
if (host.includes(':')) {
|
|
175
|
+
return `[${host}]`;
|
|
176
|
+
}
|
|
177
|
+
return host;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 列出可访问地址(监听所有网卡时包含本机局域网 IP,便于远程访问)
|
|
181
|
+
function getAccessHosts(host) {
|
|
182
|
+
if (host !== '0.0.0.0' && host !== '::') {
|
|
183
|
+
return [getAccessHost(host)];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const hosts = ['localhost'];
|
|
187
|
+
const interfaces = os.networkInterfaces();
|
|
188
|
+
for (const entries of Object.values(interfaces)) {
|
|
189
|
+
for (const entry of entries || []) {
|
|
190
|
+
if (entry.family === 'IPv4' && !entry.internal) {
|
|
191
|
+
hosts.push(entry.address);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return hosts;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 检查进程是否存在
|
|
199
|
+
function isAlive(pid) {
|
|
200
|
+
try {
|
|
201
|
+
process.kill(pid, 0);
|
|
202
|
+
return true;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// 无权限说明进程存在(属于其他用户)
|
|
205
|
+
return error.code === 'EPERM';
|
|
206
|
+
}
|
|
66
207
|
}
|
|
67
208
|
|
|
68
209
|
// 检查进程是否运行
|
|
69
210
|
function isRunning() {
|
|
70
211
|
ensureDataDir();
|
|
212
|
+
|
|
71
213
|
if (!fs.existsSync(PID_FILE)) {
|
|
72
|
-
return
|
|
214
|
+
return null;
|
|
73
215
|
}
|
|
74
216
|
|
|
75
|
-
const pid = fs.readFileSync(PID_FILE, 'utf8').trim();
|
|
217
|
+
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
|
|
218
|
+
|
|
219
|
+
if (Number.isInteger(pid) && pid > 0 && isAlive(pid)) {
|
|
220
|
+
return pid;
|
|
221
|
+
}
|
|
76
222
|
|
|
223
|
+
// 进程不存在,清理过期的 PID 文件
|
|
77
224
|
try {
|
|
78
|
-
process.kill(parseInt(pid), 0);
|
|
79
|
-
return parseInt(pid);
|
|
80
|
-
} catch (e) {
|
|
81
|
-
// 进程不存在,清理PID文件
|
|
82
225
|
fs.unlinkSync(PID_FILE);
|
|
83
|
-
|
|
226
|
+
} catch (error) {
|
|
227
|
+
// 忽略清理失败
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 读取日志末尾内容
|
|
233
|
+
function readLogTail(lineCount) {
|
|
234
|
+
try {
|
|
235
|
+
if (!fs.existsSync(LOG_FILE)) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const stat = fs.statSync(LOG_FILE);
|
|
240
|
+
const maxBytes = 64 * 1024;
|
|
241
|
+
const size = Math.min(stat.size, maxBytes);
|
|
242
|
+
if (size === 0) {
|
|
243
|
+
return '';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const buffer = Buffer.alloc(size);
|
|
247
|
+
const fd = fs.openSync(LOG_FILE, 'r');
|
|
248
|
+
try {
|
|
249
|
+
fs.readSync(fd, buffer, 0, size, stat.size - size);
|
|
250
|
+
} finally {
|
|
251
|
+
fs.closeSync(fd);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return buffer.toString('utf8').split('\n').slice(-lineCount).join('\n');
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return null;
|
|
84
257
|
}
|
|
85
258
|
}
|
|
86
259
|
|
|
87
260
|
// 启动服务
|
|
88
|
-
function start() {
|
|
261
|
+
async function start() {
|
|
89
262
|
const pid = isRunning();
|
|
90
263
|
if (pid) {
|
|
91
|
-
console.log(colors.yellow(`${APP_NAME}
|
|
264
|
+
console.log(colors.yellow(`${APP_NAME} 已在运行 (PID: ${pid})`));
|
|
265
|
+
console.log(colors.gray('如需修改端口或监听地址,请使用: tcpmapping restart --port <port> --host <host>'));
|
|
92
266
|
return;
|
|
93
267
|
}
|
|
94
268
|
|
|
95
|
-
|
|
96
|
-
const configPath = getConfigPath();
|
|
97
|
-
const authPath = getAuthConfigPath();
|
|
98
|
-
|
|
99
|
-
if (!authPath) {
|
|
269
|
+
if (!getAuthConfigPath()) {
|
|
100
270
|
console.log(colors.yellow('未设置管理员密码,请先运行:'));
|
|
101
271
|
console.log(colors.cyan(' tcpmapping setup-password'));
|
|
102
272
|
return;
|
|
103
273
|
}
|
|
104
274
|
|
|
105
275
|
ensureDataDir();
|
|
106
|
-
console.log(colors.blue(
|
|
276
|
+
console.log(colors.blue(`正在启动 ${APP_NAME}...`));
|
|
277
|
+
|
|
278
|
+
// 将生效的 WebUI 地址显式传给子进程,确保实际监听与提示一致
|
|
279
|
+
const webui = getWebuiInfo();
|
|
107
280
|
|
|
108
281
|
const out = fs.openSync(LOG_FILE, 'a');
|
|
109
282
|
const err = fs.openSync(LOG_FILE, 'a');
|
|
110
283
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
284
|
+
let child;
|
|
285
|
+
try {
|
|
286
|
+
child = spawn(process.execPath, [SERVER_FILE], {
|
|
287
|
+
detached: true,
|
|
288
|
+
stdio: ['ignore', out, err],
|
|
289
|
+
// 使用当前目录,以便 server 可以找到当前目录的 config.json
|
|
290
|
+
cwd: process.cwd(),
|
|
291
|
+
env: {
|
|
292
|
+
...process.env,
|
|
293
|
+
WEBUI_HOST: webui.host,
|
|
294
|
+
WEBUI_PORT: String(webui.port)
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
} finally {
|
|
298
|
+
// 子进程已继承文件描述符,父进程可以关闭
|
|
299
|
+
fs.closeSync(out);
|
|
300
|
+
fs.closeSync(err);
|
|
301
|
+
}
|
|
116
302
|
|
|
117
|
-
fs.writeFileSync(PID_FILE, child.pid
|
|
303
|
+
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
118
304
|
child.unref();
|
|
119
305
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
306
|
+
// 短暂等待,捕获启动即失败的情况(如端口被占用、配置错误)
|
|
307
|
+
await delay(500);
|
|
308
|
+
|
|
309
|
+
if (!isAlive(child.pid)) {
|
|
310
|
+
try {
|
|
311
|
+
fs.unlinkSync(PID_FILE);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
// 忽略
|
|
314
|
+
}
|
|
315
|
+
console.log(colors.red(`${APP_NAME} 启动失败`));
|
|
316
|
+
const tail = readLogTail(15);
|
|
317
|
+
if (tail) {
|
|
318
|
+
console.log(colors.gray('最近日志:'));
|
|
319
|
+
console.log(tail);
|
|
320
|
+
}
|
|
321
|
+
console.log(colors.gray(`完整日志: ${LOG_FILE}`));
|
|
322
|
+
process.exitCode = 1;
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
console.log(colors.green(`${APP_NAME} 启动成功 (PID: ${child.pid})`));
|
|
327
|
+
if (webui.enabled) {
|
|
328
|
+
const urls = getAccessHosts(webui.host).map(host => `http://${host}:${webui.port}`);
|
|
329
|
+
console.log(colors.cyan(`WebUI: ${urls.join('\n ')}`));
|
|
330
|
+
if (webui.host === '0.0.0.0' || webui.host === '::') {
|
|
331
|
+
console.log(colors.yellow('提示: WebUI 监听在所有网卡上,请确保使用强密码'));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
console.log(colors.gray(`日志文件: ${LOG_FILE}`));
|
|
335
|
+
const configPath = getConfigPath();
|
|
123
336
|
if (configPath) {
|
|
124
|
-
console.log(colors.gray(
|
|
337
|
+
console.log(colors.gray(`配置文件: ${configPath}`));
|
|
125
338
|
}
|
|
126
339
|
}
|
|
127
340
|
|
|
128
341
|
// 停止服务
|
|
129
|
-
function stop() {
|
|
342
|
+
async function stop() {
|
|
130
343
|
const pid = isRunning();
|
|
131
344
|
if (!pid) {
|
|
132
|
-
console.log(colors.yellow(`${APP_NAME}
|
|
345
|
+
console.log(colors.yellow(`${APP_NAME} 未在运行`));
|
|
133
346
|
return;
|
|
134
347
|
}
|
|
135
348
|
|
|
136
|
-
console.log(colors.blue(
|
|
349
|
+
console.log(colors.blue(`正在停止 ${APP_NAME} (PID: ${pid})...`));
|
|
137
350
|
|
|
138
351
|
try {
|
|
139
352
|
process.kill(pid, 'SIGTERM');
|
|
140
353
|
|
|
141
|
-
//
|
|
354
|
+
// 最多等待 5 秒,让服务优雅退出
|
|
142
355
|
let attempts = 0;
|
|
143
|
-
while (attempts <
|
|
356
|
+
while (attempts < 20 && isAlive(pid)) {
|
|
357
|
+
await delay(250);
|
|
358
|
+
attempts++;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// 仍未退出则强制终止
|
|
362
|
+
if (isAlive(pid)) {
|
|
363
|
+
console.log(colors.yellow('服务未在预期时间内退出,正在强制终止...'));
|
|
144
364
|
try {
|
|
145
|
-
process.kill(pid,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
attempts++;
|
|
149
|
-
} catch (e) {
|
|
150
|
-
// 进程已退出
|
|
151
|
-
break;
|
|
365
|
+
process.kill(pid, 'SIGKILL');
|
|
366
|
+
} catch (error) {
|
|
367
|
+
// 进程可能已退出
|
|
152
368
|
}
|
|
369
|
+
await delay(200);
|
|
153
370
|
}
|
|
154
371
|
|
|
155
|
-
// 如果进程仍在运行,强制终止
|
|
156
372
|
try {
|
|
157
|
-
process.kill(pid, 0);
|
|
158
|
-
process.kill(pid, 'SIGKILL');
|
|
159
|
-
} catch (e) {
|
|
160
|
-
// 进程已退出
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (fs.existsSync(PID_FILE)) {
|
|
164
373
|
fs.unlinkSync(PID_FILE);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
// 忽略
|
|
165
376
|
}
|
|
166
377
|
|
|
167
|
-
console.log(colors.green(`${APP_NAME}
|
|
168
|
-
} catch (
|
|
169
|
-
console.log(colors.red(
|
|
378
|
+
console.log(colors.green(`${APP_NAME} 已停止`));
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console.log(colors.red(`停止 ${APP_NAME} 失败: ${error.message}`));
|
|
170
381
|
}
|
|
171
382
|
}
|
|
172
383
|
|
|
173
384
|
// 重启服务
|
|
174
|
-
function restart() {
|
|
175
|
-
console.log(colors.blue(
|
|
176
|
-
stop();
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
385
|
+
async function restart() {
|
|
386
|
+
console.log(colors.blue(`正在重启 ${APP_NAME}...`));
|
|
387
|
+
await stop();
|
|
388
|
+
await delay(300);
|
|
389
|
+
await start();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// 请求健康检查接口
|
|
393
|
+
function fetchHealth(host, port) {
|
|
394
|
+
return new Promise((resolve) => {
|
|
395
|
+
const request = http.get({
|
|
396
|
+
host: host,
|
|
397
|
+
port: port,
|
|
398
|
+
path: '/api/health',
|
|
399
|
+
timeout: 2000
|
|
400
|
+
}, (res) => {
|
|
401
|
+
let data = '';
|
|
402
|
+
res.on('data', chunk => data += chunk);
|
|
403
|
+
res.on('end', () => {
|
|
404
|
+
try {
|
|
405
|
+
resolve(JSON.parse(data));
|
|
406
|
+
} catch (error) {
|
|
407
|
+
resolve(null);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
request.on('error', () => resolve(null));
|
|
413
|
+
request.on('timeout', () => {
|
|
414
|
+
request.destroy();
|
|
415
|
+
resolve(null);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
180
418
|
}
|
|
181
419
|
|
|
182
420
|
// 查看状态
|
|
183
|
-
function status() {
|
|
421
|
+
async function status() {
|
|
184
422
|
const pid = isRunning();
|
|
185
423
|
const configPath = getConfigPath();
|
|
186
424
|
const authPath = getAuthConfigPath();
|
|
425
|
+
const webui = getWebuiInfo();
|
|
187
426
|
|
|
188
427
|
console.log(colors.cyan('=== TcpMapping Status ===\n'));
|
|
189
428
|
|
|
190
|
-
// 服务状态
|
|
191
429
|
if (pid) {
|
|
192
|
-
console.log(colors.green(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
req.on('error', () => showPaths());
|
|
211
|
-
req.on('timeout', () => { req.destroy(); showPaths(); });
|
|
212
|
-
} catch (e) {
|
|
213
|
-
showPaths();
|
|
430
|
+
console.log(colors.green(`服务状态: 运行中 (PID: ${pid})`));
|
|
431
|
+
|
|
432
|
+
if (webui.enabled) {
|
|
433
|
+
const host = webui.host === '0.0.0.0' || webui.host === '::' ? '127.0.0.1' : webui.host;
|
|
434
|
+
const health = await fetchHealth(host, webui.port);
|
|
435
|
+
|
|
436
|
+
if (health) {
|
|
437
|
+
console.log(` WebUI: ${getAccessHosts(webui.host).map(item => `http://${item}:${webui.port}`).join(' ')}`);
|
|
438
|
+
console.log(` 运行时间: ${Math.floor(health.uptime / 60)} 分钟`);
|
|
439
|
+
console.log(` 活跃映射: ${health.activeMappings}`);
|
|
440
|
+
console.log(` 活跃连接: ${health.activeConnections}`);
|
|
441
|
+
} else {
|
|
442
|
+
console.log(colors.yellow(` 健康检查失败: http://${getAccessHost(webui.host)}:${webui.port}/api/health`));
|
|
443
|
+
}
|
|
444
|
+
} else {
|
|
445
|
+
console.log(colors.gray(' WebUI: 已禁用'));
|
|
214
446
|
}
|
|
215
447
|
} else {
|
|
216
|
-
console.log(colors.red('
|
|
217
|
-
showPaths();
|
|
448
|
+
console.log(colors.red('服务状态: 已停止'));
|
|
218
449
|
}
|
|
219
450
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
console.log(`Log: ${LOG_FILE}`);
|
|
227
|
-
}
|
|
451
|
+
console.log('');
|
|
452
|
+
console.log(colors.cyan('=== Configuration ===\n'));
|
|
453
|
+
console.log(`数据目录: ${DATA_DIR}`);
|
|
454
|
+
console.log(`配置文件: ${configPath || colors.yellow('未找到 (运行: tcpmapping init)')}`);
|
|
455
|
+
console.log(`认证配置: ${authPath || colors.yellow('未找到 (运行: tcpmapping setup-password)')}`);
|
|
456
|
+
console.log(`日志文件: ${LOG_FILE}`);
|
|
228
457
|
}
|
|
229
458
|
|
|
230
459
|
// 查看日志
|
|
231
460
|
function logs(follow = false) {
|
|
232
461
|
ensureDataDir();
|
|
462
|
+
|
|
233
463
|
if (!fs.existsSync(LOG_FILE)) {
|
|
234
|
-
console.log(colors.yellow('
|
|
464
|
+
console.log(colors.yellow('未找到日志文件'));
|
|
235
465
|
return;
|
|
236
466
|
}
|
|
237
467
|
|
|
238
|
-
if (follow) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
});
|
|
245
|
-
} else {
|
|
246
|
-
const content = fs.readFileSync(LOG_FILE, 'utf8');
|
|
247
|
-
const lines = content.split('\n').slice(-50);
|
|
248
|
-
console.log(lines.join('\n'));
|
|
468
|
+
if (!follow) {
|
|
469
|
+
const tail = readLogTail(50);
|
|
470
|
+
if (tail !== null) {
|
|
471
|
+
console.log(tail);
|
|
472
|
+
}
|
|
473
|
+
return;
|
|
249
474
|
}
|
|
475
|
+
|
|
476
|
+
console.log(colors.blue(`正在跟踪 ${LOG_FILE}... (Ctrl+C 退出)`));
|
|
477
|
+
|
|
478
|
+
const tail = readLogTail(20);
|
|
479
|
+
if (tail) {
|
|
480
|
+
console.log(tail);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
let offset = fs.statSync(LOG_FILE).size;
|
|
484
|
+
|
|
485
|
+
// 使用轮询方式,兼容 Windows / macOS / Linux
|
|
486
|
+
const watcher = (current) => {
|
|
487
|
+
if (current.size < offset) {
|
|
488
|
+
// 文件被截断(例如日志轮转)
|
|
489
|
+
offset = 0;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (current.size > offset) {
|
|
493
|
+
try {
|
|
494
|
+
const length = current.size - offset;
|
|
495
|
+
const buffer = Buffer.alloc(length);
|
|
496
|
+
const fd = fs.openSync(LOG_FILE, 'r');
|
|
497
|
+
try {
|
|
498
|
+
fs.readSync(fd, buffer, 0, length, offset);
|
|
499
|
+
} finally {
|
|
500
|
+
fs.closeSync(fd);
|
|
501
|
+
}
|
|
502
|
+
process.stdout.write(buffer);
|
|
503
|
+
offset = current.size;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
// 文件可能在读取过程中被轮转,忽略本次读取
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
fs.watchFile(LOG_FILE, { interval: 500 }, watcher);
|
|
511
|
+
|
|
512
|
+
const cleanup = () => {
|
|
513
|
+
fs.unwatchFile(LOG_FILE, watcher);
|
|
514
|
+
process.exit(0);
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
process.on('SIGINT', cleanup);
|
|
518
|
+
process.on('SIGTERM', cleanup);
|
|
250
519
|
}
|
|
251
520
|
|
|
252
521
|
// 设置密码
|
|
253
522
|
function setupPassword() {
|
|
254
523
|
ensureDataDir();
|
|
255
|
-
console.log(colors.blue('
|
|
256
|
-
console.log(colors.gray(
|
|
524
|
+
console.log(colors.blue('正在设置管理员密码...'));
|
|
525
|
+
console.log(colors.gray(`配置将保存到: ${DATA_DIR}`));
|
|
257
526
|
console.log('');
|
|
258
527
|
|
|
259
|
-
const child = spawn(
|
|
528
|
+
const child = spawn(process.execPath, [SETUP_PASSWORD_FILE], {
|
|
260
529
|
stdio: 'inherit',
|
|
261
|
-
cwd: DATA_DIR, // 在数据目录运行,使auth-config.json保存到数据目录
|
|
530
|
+
cwd: DATA_DIR, // 在数据目录运行,使 auth-config.json 保存到数据目录
|
|
262
531
|
env: { ...process.env, TCPMAPPING_DATA_DIR: DATA_DIR }
|
|
263
532
|
});
|
|
264
533
|
|
|
265
534
|
child.on('exit', (code) => {
|
|
266
|
-
process.exit(code);
|
|
535
|
+
process.exit(code === null ? 1 : code);
|
|
267
536
|
});
|
|
268
537
|
}
|
|
269
538
|
|
|
@@ -271,39 +540,47 @@ function setupPassword() {
|
|
|
271
540
|
function init() {
|
|
272
541
|
ensureDataDir();
|
|
273
542
|
|
|
274
|
-
// 检查当前目录和数据目录是否已有配置
|
|
275
543
|
const cwdConfig = path.join(process.cwd(), 'config.json');
|
|
276
544
|
const dataConfig = path.join(DATA_DIR, 'config.json');
|
|
277
545
|
|
|
278
546
|
if (fs.existsSync(cwdConfig)) {
|
|
279
|
-
console.log(colors.yellow(
|
|
547
|
+
console.log(colors.yellow('当前目录已存在 config.json'));
|
|
280
548
|
console.log(colors.gray(` ${cwdConfig}`));
|
|
281
549
|
return;
|
|
282
550
|
}
|
|
283
551
|
|
|
284
552
|
if (fs.existsSync(dataConfig)) {
|
|
285
|
-
console.log(colors.yellow(
|
|
553
|
+
console.log(colors.yellow('数据目录已存在 config.json'));
|
|
286
554
|
console.log(colors.gray(` ${dataConfig}`));
|
|
287
555
|
console.log('');
|
|
288
|
-
console.log('
|
|
556
|
+
console.log('如需为当前项目创建独立配置,可复制一份:');
|
|
289
557
|
console.log(colors.cyan(` cp ${dataConfig} ./config.json`));
|
|
290
558
|
return;
|
|
291
559
|
}
|
|
292
560
|
|
|
293
561
|
const defaultConfig = {
|
|
294
562
|
mappings: [],
|
|
295
|
-
webui: {
|
|
296
|
-
|
|
563
|
+
webui: {
|
|
564
|
+
enabled: true,
|
|
565
|
+
// 默认只监听本机;如需远程访问管理面板,请使用 --host 0.0.0.0 并配置防火墙
|
|
566
|
+
host: webuiOptions.host ? webuiOptions.host.trim() : '127.0.0.1',
|
|
567
|
+
port: webuiOptions.port !== null ? Number(webuiOptions.port) : 9999
|
|
568
|
+
},
|
|
569
|
+
logging: {
|
|
570
|
+
enabled: true,
|
|
571
|
+
level: 'info'
|
|
572
|
+
},
|
|
573
|
+
timeouts: {
|
|
574
|
+
connect: 30000,
|
|
575
|
+
idle: 300000
|
|
576
|
+
}
|
|
297
577
|
};
|
|
298
578
|
|
|
299
|
-
// 保存到数据目录
|
|
300
579
|
fs.writeFileSync(dataConfig, JSON.stringify(defaultConfig, null, 2));
|
|
301
|
-
console.log(colors.green('
|
|
580
|
+
console.log(colors.green('已创建 config.json'));
|
|
302
581
|
console.log(colors.gray(` ${dataConfig}`));
|
|
303
582
|
console.log('');
|
|
304
|
-
console.log('
|
|
305
|
-
console.log('Or copy it to current directory for project-specific config:');
|
|
306
|
-
console.log(colors.cyan(` cp ${dataConfig} ./config.json`));
|
|
583
|
+
console.log('请编辑配置文件添加端口映射,或启动后通过 WebUI 添加。');
|
|
307
584
|
}
|
|
308
585
|
|
|
309
586
|
// 显示版本
|
|
@@ -317,52 +594,75 @@ function help() {
|
|
|
317
594
|
console.log(`
|
|
318
595
|
${colors.cyan('TCP Mapping Server')} - TCP端口转发服务器
|
|
319
596
|
|
|
320
|
-
${colors.yellow('
|
|
321
|
-
tcpmapping <command>
|
|
322
|
-
|
|
323
|
-
${colors.yellow('
|
|
324
|
-
${colors.green('start')}
|
|
325
|
-
${colors.green('stop')}
|
|
326
|
-
${colors.green('restart')}
|
|
327
|
-
${colors.green('status')}
|
|
328
|
-
${colors.green('logs')}
|
|
329
|
-
${colors.green('logs -f')}
|
|
330
|
-
${colors.green('setup-password')}
|
|
331
|
-
${colors.green('init')}
|
|
332
|
-
${colors.green('version')}
|
|
333
|
-
${colors.green('help')}
|
|
334
|
-
|
|
335
|
-
${colors.yellow('
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
tcpmapping
|
|
597
|
+
${colors.yellow('用法:')}
|
|
598
|
+
tcpmapping <command> [options]
|
|
599
|
+
|
|
600
|
+
${colors.yellow('命令:')}
|
|
601
|
+
${colors.green('start')} 后台启动服务
|
|
602
|
+
${colors.green('stop')} 停止服务
|
|
603
|
+
${colors.green('restart')} 重启服务
|
|
604
|
+
${colors.green('status')} 查看服务状态和配置路径
|
|
605
|
+
${colors.green('logs')} 查看最近日志
|
|
606
|
+
${colors.green('logs -f')} 实时跟踪日志
|
|
607
|
+
${colors.green('setup-password')} 设置管理员密码
|
|
608
|
+
${colors.green('init')} 创建默认 config.json
|
|
609
|
+
${colors.green('version')} 显示版本
|
|
610
|
+
${colors.green('help')} 显示帮助
|
|
611
|
+
|
|
612
|
+
${colors.yellow('选项:')}
|
|
613
|
+
${colors.green('-p, --port <port>')} WebUI 端口(默认 9999)
|
|
614
|
+
${colors.green('-H, --host <host>')} 监听地址(默认 127.0.0.1;公网访问用 0.0.0.0,别名 --listen)
|
|
615
|
+
|
|
616
|
+
选项对 start / restart / status / init 生效,优先级高于环境变量和配置文件。
|
|
617
|
+
|
|
618
|
+
${colors.yellow('配置路径(按优先级):')}
|
|
619
|
+
1. ./config.json 当前目录(项目配置)
|
|
620
|
+
2. ~/.tcpmapping/ 用户数据目录(全局配置)
|
|
621
|
+
|
|
622
|
+
${colors.yellow('环境变量:')}
|
|
623
|
+
WEBUI_PORT / WEBUI_HOST 等效于 --port / --host
|
|
624
|
+
|
|
625
|
+
${colors.yellow('示例:')}
|
|
626
|
+
tcpmapping init --host 0.0.0.0 --port 5000 # 初始化并允许远程访问
|
|
627
|
+
tcpmapping setup-password # 设置管理员密码
|
|
628
|
+
tcpmapping start --host 0.0.0.0 --port 5000 # 对外监听 5000 端口
|
|
629
|
+
tcpmapping status --port 5000 # 查看 5000 端口上的服务状态
|
|
630
|
+
tcpmapping restart --port 5000 # 以新端口重启
|
|
344
631
|
|
|
345
632
|
${colors.yellow('WebUI:')}
|
|
346
|
-
|
|
633
|
+
默认访问 http://localhost:9999;使用 --host 0.0.0.0 后可通过公网 IP 访问,
|
|
634
|
+
请务必设置强密码,并建议配合防火墙或 HTTPS 反向代理使用。
|
|
347
635
|
`);
|
|
348
636
|
}
|
|
349
637
|
|
|
350
638
|
// 执行命令
|
|
351
639
|
switch (command) {
|
|
352
640
|
case 'start':
|
|
353
|
-
start()
|
|
641
|
+
start().catch(error => {
|
|
642
|
+
console.error(colors.red(`启动失败: ${error.message}`));
|
|
643
|
+
process.exitCode = 1;
|
|
644
|
+
});
|
|
354
645
|
break;
|
|
355
646
|
case 'stop':
|
|
356
|
-
stop()
|
|
647
|
+
stop().catch(error => {
|
|
648
|
+
console.error(colors.red(`停止失败: ${error.message}`));
|
|
649
|
+
process.exitCode = 1;
|
|
650
|
+
});
|
|
357
651
|
break;
|
|
358
652
|
case 'restart':
|
|
359
|
-
restart()
|
|
653
|
+
restart().catch(error => {
|
|
654
|
+
console.error(colors.red(`重启失败: ${error.message}`));
|
|
655
|
+
process.exitCode = 1;
|
|
656
|
+
});
|
|
360
657
|
break;
|
|
361
658
|
case 'status':
|
|
362
|
-
status()
|
|
659
|
+
status().catch(error => {
|
|
660
|
+
console.error(colors.red(`获取状态失败: ${error.message}`));
|
|
661
|
+
process.exitCode = 1;
|
|
662
|
+
});
|
|
363
663
|
break;
|
|
364
664
|
case 'logs':
|
|
365
|
-
logs(args[1] === '-f');
|
|
665
|
+
logs(args[1] === '-f' || args[1] === '--follow');
|
|
366
666
|
break;
|
|
367
667
|
case 'setup-password':
|
|
368
668
|
case 'passwd':
|