@feng3d/ctc 0.0.19 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -106
- package/dist/cli.d.ts +13 -11
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +188 -1067
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +18 -55
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +42 -188
- package/dist/config.js.map +1 -1
- package/dist/controller.d.ts +47 -5
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +10 -15
- package/dist/controller.js.map +1 -1
- package/dist/handlers/unified-handler.d.ts.map +1 -1
- package/dist/handlers/unified-handler.js +3 -1
- package/dist/handlers/unified-handler.js.map +1 -1
- package/dist/index.d.ts +20 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -328
- package/dist/index.js.map +1 -1
- package/dist/proxy-manager.d.ts +32 -32
- package/dist/proxy-manager.d.ts.map +1 -1
- package/dist/proxy-manager.js +81 -66
- package/dist/proxy-manager.js.map +1 -1
- package/package.json +5 -6
- package/admin-ui/dist/app.js +0 -29
- package/admin-ui/dist/index.html +0 -222
- package/admin-ui/dist/style.css +0 -1
- package/dist/admin-server.d.ts +0 -143
- package/dist/admin-server.d.ts.map +0 -1
- package/dist/admin-server.js +0 -407
- package/dist/admin-server.js.map +0 -1
- package/dist/forward-proxy.d.ts +0 -100
- package/dist/forward-proxy.d.ts.map +0 -1
- package/dist/forward-proxy.js +0 -407
- package/dist/forward-proxy.js.map +0 -1
- package/dist/ipc-handler.d.ts +0 -52
- package/dist/ipc-handler.d.ts.map +0 -1
- package/dist/ipc-handler.js +0 -110
- package/dist/ipc-handler.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,1107 +1,228 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* @module cli
|
|
4
|
-
* @description 穿透客户端 CLI - 简洁的命令行工具
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
5
|
+
* 穿透客户端命令行入口。
|
|
6
|
+
*
|
|
7
|
+
* 提供两条命令:
|
|
8
|
+
*
|
|
9
|
+
* 1. 设置服务端访问信息(写入配置文件):
|
|
10
|
+
* npx @feng3d/ctc set -ip <serverip> -p <serverport> -t <token> [--tls]
|
|
11
|
+
*
|
|
12
|
+
* 2. 映射本地端口到服务端(服务端自动分配端口并返回):
|
|
13
|
+
* npx @feng3d/ctc -p <localPort> [-d]
|
|
14
|
+
*
|
|
15
|
+
* 运行模式:
|
|
16
|
+
* - 默认前台:进程依附终端,终端关闭即停止
|
|
17
|
+
* - -d / --detach:后台运行,脱离终端;停止用系统 kill <pid>
|
|
16
18
|
*/
|
|
17
19
|
import { Command } from 'commander';
|
|
18
|
-
import
|
|
19
|
-
import {
|
|
20
|
-
import { join, dirname } from 'path';
|
|
20
|
+
import { spawn } from 'child_process';
|
|
21
|
+
import { writeFileSync, openSync, unlinkSync, mkdirSync, existsSync, readFileSync } from 'fs';
|
|
21
22
|
import { homedir, platform } from 'os';
|
|
22
|
-
import {
|
|
23
|
+
import { join } from 'path';
|
|
23
24
|
import { fileURLToPath } from 'url';
|
|
24
|
-
import
|
|
25
|
-
import {
|
|
26
|
-
|
|
25
|
+
import chalk from 'chalk';
|
|
26
|
+
import { PROTOCOL, DEFAULT_CONFIG } from '@feng3d/chuantou-shared';
|
|
27
|
+
import { Controller } from './controller.js';
|
|
28
|
+
import { ProxyManager } from './proxy-manager.js';
|
|
29
|
+
import { loadConfig, saveConfig } from './config.js';
|
|
30
|
+
/** chuantou 运行时目录:~/.chuantou */
|
|
27
31
|
const DATA_DIR = join(homedir(), '.chuantou');
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
/**
|
|
31
|
-
const
|
|
32
|
-
/** PID 文件 */
|
|
33
|
-
const PID_FILE = join(CLIENT_DIR, 'client.pid');
|
|
34
|
-
/** 日志文件 */
|
|
35
|
-
const LOG_FILE = join(CLIENT_DIR, 'client.log');
|
|
36
|
-
/** 管理服务器 URL 默认值 */
|
|
37
|
-
const ADMIN_URL_DEFAULT = 'http://127.0.0.1:9001';
|
|
38
|
-
/** 管理服务器端口默认值 */
|
|
39
|
-
const ADMIN_PORT_DEFAULT = 9001;
|
|
40
|
-
/**
|
|
41
|
-
* 帮助文本
|
|
42
|
-
*/
|
|
43
|
-
const HELP_TEXT = `
|
|
44
|
-
${chalk.bold('用法:')} cts <${chalk.bold('命令')}> [选项]
|
|
45
|
-
|
|
46
|
-
${chalk.bold('命令:')}
|
|
47
|
-
${chalk.cyan('start')} 启动客户端(后台守护进程)
|
|
48
|
-
${chalk.cyan('stop')} 关闭客户端
|
|
49
|
-
${chalk.cyan('restart')} 重启客户端
|
|
50
|
-
${chalk.cyan('status')} 查看运行状态
|
|
51
|
-
${chalk.cyan('proxies')} 管理反向代理映射
|
|
52
|
-
${chalk.cyan('forward')} 管理正向穿透代理
|
|
53
|
-
${chalk.cyan('config')} 管理配置
|
|
54
|
-
${chalk.cyan('boot')} 管理开机自启动
|
|
55
|
-
${chalk.cyan('logs')} 查看日志
|
|
56
|
-
${chalk.cyan('open')} 打开管理页面
|
|
57
|
-
|
|
58
|
-
${chalk.bold('全局选项:')}
|
|
59
|
-
${chalk.yellow('-h, --help')} 显示帮助信息
|
|
60
|
-
${chalk.yellow('-v, --version')} 显示版本号
|
|
61
|
-
|
|
62
|
-
${chalk.bold('start 命令选项:')}
|
|
63
|
-
${chalk.yellow('-s, --server <url>')} 服务器地址 (默认: ws://localhost:9000)
|
|
64
|
-
${chalk.yellow('-t, --token <token>')} 认证令牌
|
|
65
|
-
${chalk.yellow('-p, --proxies <proxies>')} 代理配置 (如: 8080:3000,8081:3001)
|
|
66
|
-
${chalk.yellow('--no-boot')} 不注册开机自启动
|
|
67
|
-
${chalk.yellow('-o, --open')} 启动后打开管理页面
|
|
68
|
-
|
|
69
|
-
${chalk.bold('proxies 命令(反向代理):')}
|
|
70
|
-
${chalk.yellow('list')} 列出所有代理映射
|
|
71
|
-
${chalk.yellow('add <remote:local>')} 添加代理 (如: 8080:3000)
|
|
72
|
-
${chalk.yellow('remove <port>')} 移除指定端口的代理
|
|
73
|
-
${chalk.yellow('clear')} 清空所有代理
|
|
74
|
-
|
|
75
|
-
${chalk.bold('forward 命令(正向穿透):')}
|
|
76
|
-
${chalk.yellow('list')} 列出所有正向穿透代理
|
|
77
|
-
${chalk.yellow('add <local:remote:client>')} 添加穿透 (如: 8080:3000:clientB)
|
|
78
|
-
${chalk.yellow('remove <localPort>')} 移除指定本地端口的穿透
|
|
79
|
-
${chalk.yellow('clients')} 查看在线客户端列表
|
|
80
|
-
${chalk.yellow('register')} 注册到服务器启用正向穿透
|
|
81
|
-
|
|
82
|
-
${chalk.bold('config 命令选项:')}
|
|
83
|
-
${chalk.yellow('get [key]')} 获取配置项
|
|
84
|
-
${chalk.yellow('set <key> <value>')} 设置配置项
|
|
85
|
-
${chalk.yellow('list')} 列出所有配置
|
|
86
|
-
${chalk.yellow('edit')} 编辑配置文件
|
|
87
|
-
|
|
88
|
-
${chalk.bold('boot 命令选项:')}
|
|
89
|
-
${chalk.yellow('enable')} 启用开机自启动
|
|
90
|
-
${chalk.yellow('disable')} 禁用开机自启动
|
|
91
|
-
|
|
92
|
-
${chalk.dim('配置文件位置:')} ${CONFIG_FILE}
|
|
93
|
-
${chalk.dim('日志文件位置:')} ${LOG_FILE}
|
|
94
|
-
${chalk.dim('管理页面:')} http://127.0.0.1:9001
|
|
95
|
-
|
|
96
|
-
${chalk.dim('示例:')}
|
|
97
|
-
${chalk.gray('# 启动客户端')}
|
|
98
|
-
cts start --server ws://192.168.1.100:9000 --token mytoken
|
|
99
|
-
|
|
100
|
-
${chalk.gray('# 正向穿透模式:注册并添加映射')}
|
|
101
|
-
cts forward register --description "我的电脑"
|
|
102
|
-
cts forward clients
|
|
103
|
-
cts forward add 8080:3000:clientB
|
|
104
|
-
cts forward list
|
|
105
|
-
|
|
106
|
-
${chalk.gray('# 反向代理模式:暴露本地服务')}
|
|
107
|
-
cts proxies add 8080:3000
|
|
108
|
-
cts proxies list
|
|
109
|
-
|
|
110
|
-
${chalk.gray('# 管理配置')}
|
|
111
|
-
cts config set serverUrl ws://localhost:9000
|
|
112
|
-
cts config set token mytoken
|
|
113
|
-
|
|
114
|
-
${chalk.gray('# 开机自启动')}
|
|
115
|
-
cts boot enable
|
|
116
|
-
`;
|
|
117
|
-
/**
|
|
118
|
-
* 版本号
|
|
119
|
-
*/
|
|
120
|
-
const VERSION = '0.1.0';
|
|
121
|
-
/**
|
|
122
|
-
* 显示帮助信息
|
|
123
|
-
*/
|
|
124
|
-
function showHelp() {
|
|
125
|
-
console.log(HELP_TEXT);
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* 显示版本号
|
|
129
|
-
*/
|
|
130
|
-
function showVersion() {
|
|
131
|
-
console.log(`@feng3d/cts v${VERSION}`);
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* 读取配置文件
|
|
135
|
-
*/
|
|
136
|
-
function readConfig() {
|
|
137
|
-
try {
|
|
138
|
-
const content = readFileSync(CONFIG_FILE, 'utf-8');
|
|
139
|
-
return JSON.parse(content);
|
|
140
|
-
}
|
|
141
|
-
catch {
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* 写入配置文件
|
|
147
|
-
*/
|
|
148
|
-
function writeConfig(config) {
|
|
149
|
-
mkdirSync(CLIENT_DIR, { recursive: true });
|
|
150
|
-
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
151
|
-
}
|
|
152
|
-
/**
|
|
153
|
-
* 读取 PID 文件
|
|
154
|
-
*/
|
|
155
|
-
function readPidFile() {
|
|
156
|
-
try {
|
|
157
|
-
return JSON.parse(readFileSync(PID_FILE, 'utf-8'));
|
|
158
|
-
}
|
|
159
|
-
catch {
|
|
160
|
-
return null;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* 写入 PID 文件
|
|
165
|
-
*/
|
|
166
|
-
function writePidFile(info) {
|
|
167
|
-
mkdirSync(CLIENT_DIR, { recursive: true });
|
|
168
|
-
writeFileSync(PID_FILE, JSON.stringify(info, null, 2));
|
|
169
|
-
}
|
|
170
|
-
/**
|
|
171
|
-
* 删除 PID 文件
|
|
172
|
-
*/
|
|
173
|
-
function removePidFile() {
|
|
174
|
-
try {
|
|
175
|
-
unlinkSync(PID_FILE);
|
|
176
|
-
}
|
|
177
|
-
catch {
|
|
178
|
-
// ignore
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
/**
|
|
182
|
-
* 检查客户端是否正在运行
|
|
183
|
-
*/
|
|
184
|
-
function isClientRunning() {
|
|
185
|
-
const info = readPidFile();
|
|
186
|
-
if (!info)
|
|
187
|
-
return false;
|
|
188
|
-
try {
|
|
189
|
-
process.kill(info.pid, 0);
|
|
190
|
-
return true;
|
|
191
|
-
}
|
|
192
|
-
catch {
|
|
193
|
-
return false;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* 获取客户端状态
|
|
198
|
-
*/
|
|
199
|
-
async function getClientStatus(adminPort = ADMIN_PORT_DEFAULT) {
|
|
200
|
-
try {
|
|
201
|
-
const res = await fetch(`http://127.0.0.1:${adminPort}/_ctc/status`, { method: 'GET' });
|
|
202
|
-
if (!res.ok)
|
|
203
|
-
return null;
|
|
204
|
-
const data = await res.json();
|
|
205
|
-
return data;
|
|
206
|
-
}
|
|
207
|
-
catch {
|
|
208
|
-
return null;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
/**
|
|
212
|
-
* 脚本路径
|
|
213
|
-
*/
|
|
214
|
-
const cliPath = fileURLToPath(import.meta.url);
|
|
215
|
-
// index.js 是实际运行客户端的入口(包含管理服务器)
|
|
216
|
-
const indexPath = join(dirname(cliPath), 'index.js');
|
|
217
|
-
const nodePath = process.execPath;
|
|
218
|
-
/**
|
|
219
|
-
* 主程序入口
|
|
220
|
-
*/
|
|
32
|
+
const PID_FILE = join(DATA_DIR, 'client.pid');
|
|
33
|
+
const LOG_FILE = join(DATA_DIR, 'client.log');
|
|
34
|
+
/** 标记已是后台子进程,避免无限 spawn */
|
|
35
|
+
const DETACHED_ENV = '__CHUANTOU_DETACHED';
|
|
221
36
|
const program = new Command();
|
|
222
37
|
program
|
|
223
|
-
.name('
|
|
38
|
+
.name('feng3d-ctc')
|
|
224
39
|
.description(chalk.blue('穿透 - 内网穿透客户端'))
|
|
225
|
-
.version(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
.option('--no-boot', '不注册开机自启动')
|
|
242
|
-
.option('-o, --open', '启动后打开管理页面')
|
|
243
|
-
.action(async (options) => {
|
|
244
|
-
// 检查是否已在运行
|
|
245
|
-
if (isClientRunning()) {
|
|
246
|
-
const info = readPidFile();
|
|
247
|
-
console.log(chalk.yellow('客户端已在运行中'));
|
|
248
|
-
console.log(chalk.gray(` PID: ${info?.pid}`));
|
|
249
|
-
console.log(chalk.gray(` 服务器: ${info?.serverUrl}`));
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
// 加载配置
|
|
253
|
-
let serverUrl = options.server;
|
|
254
|
-
let token = options.token;
|
|
255
|
-
const proxiesArg = options.proxies;
|
|
256
|
-
// 如果命令行没有指定,从配置文件读取
|
|
257
|
-
if (!serverUrl || !token) {
|
|
258
|
-
const config = readConfig();
|
|
259
|
-
if (config) {
|
|
260
|
-
if (!serverUrl)
|
|
261
|
-
serverUrl = config.serverUrl;
|
|
262
|
-
if (!token)
|
|
263
|
-
token = config.token || '';
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
// 如果仍然没有服务器地址,使用默认值
|
|
267
|
-
if (!serverUrl) {
|
|
268
|
-
serverUrl = 'ws://localhost:9000';
|
|
269
|
-
console.log(chalk.yellow('未指定服务器地址,使用默认值:ws://localhost:9000'));
|
|
270
|
-
}
|
|
271
|
-
// 解析代理配置
|
|
272
|
-
if (proxiesArg) {
|
|
273
|
-
const defaultConfig = {
|
|
274
|
-
serverUrl: '',
|
|
275
|
-
token: '',
|
|
276
|
-
reconnectInterval: 30000,
|
|
277
|
-
maxReconnectAttempts: 10,
|
|
278
|
-
proxies: []
|
|
279
|
-
};
|
|
280
|
-
const config = readConfig() || defaultConfig;
|
|
281
|
-
config.serverUrl = serverUrl;
|
|
282
|
-
config.token = token;
|
|
283
|
-
// 解析代理配置:remotePort:localPort[:localHost],多个用逗号分隔
|
|
284
|
-
const proxyList = proxiesArg.split(',').map((p) => p.trim()).filter((p) => p);
|
|
285
|
-
for (const proxyStr of proxyList) {
|
|
286
|
-
const parts = proxyStr.split(':');
|
|
287
|
-
if (parts.length < 2) {
|
|
288
|
-
console.log(chalk.yellow(`忽略无效的代理配置: ${proxyStr}`));
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
|
-
const remotePort = parseInt(parts[0], 10);
|
|
292
|
-
const localPort = parseInt(parts[1], 10);
|
|
293
|
-
const localHost = parts.length >= 3 ? parts[2] : 'localhost';
|
|
294
|
-
if (isNaN(remotePort) || isNaN(localPort)) {
|
|
295
|
-
console.log(chalk.yellow(`忽略无效的代理配置: ${proxyStr}`));
|
|
296
|
-
continue;
|
|
297
|
-
}
|
|
298
|
-
if (!config.proxies)
|
|
299
|
-
config.proxies = [];
|
|
300
|
-
// 检查是否已存在相同 remotePort
|
|
301
|
-
const existingIndex = config.proxies.findIndex(p => p.remotePort === remotePort);
|
|
302
|
-
if (existingIndex !== -1) {
|
|
303
|
-
config.proxies[existingIndex] = { remotePort, localPort, localHost };
|
|
304
|
-
}
|
|
305
|
-
else {
|
|
306
|
-
config.proxies.push({ remotePort, localPort, localHost });
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
writeConfig(config);
|
|
310
|
-
}
|
|
311
|
-
// 启动参数
|
|
312
|
-
const serveArgs = ['--config', CONFIG_FILE];
|
|
313
|
-
if (serverUrl)
|
|
314
|
-
serveArgs.push('--server', serverUrl);
|
|
315
|
-
if (token)
|
|
316
|
-
serveArgs.push('--token', token);
|
|
317
|
-
const adminPort = options.adminPort ? parseInt(options.adminPort, 10) : ADMIN_PORT_DEFAULT;
|
|
318
|
-
serveArgs.push('--admin-port', adminPort.toString());
|
|
319
|
-
// 确保目录存在并打开日志文件
|
|
320
|
-
mkdirSync(CLIENT_DIR, { recursive: true });
|
|
321
|
-
const logFd = openSync(LOG_FILE, 'a');
|
|
322
|
-
// 启动守护进程
|
|
323
|
-
const child = spawn(nodePath, [indexPath, ...serveArgs], {
|
|
324
|
-
detached: true,
|
|
325
|
-
stdio: ['ignore', logFd, logFd],
|
|
326
|
-
});
|
|
327
|
-
// 写入 PID 文件
|
|
328
|
-
if (child.pid !== undefined) {
|
|
329
|
-
writePidFile({
|
|
330
|
-
pid: child.pid,
|
|
331
|
-
serverUrl,
|
|
332
|
-
adminPort,
|
|
333
|
-
startedAt: Date.now(),
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
child.unref();
|
|
337
|
-
closeSync(logFd);
|
|
338
|
-
console.log(chalk.green('客户端已在后台启动'));
|
|
339
|
-
console.log(chalk.gray(` PID: ${child.pid}`));
|
|
340
|
-
console.log(chalk.gray(` 服务器: ${serverUrl}`));
|
|
341
|
-
console.log(chalk.gray(` 管理页面: http://127.0.0.1:${adminPort}`));
|
|
342
|
-
console.log(chalk.gray(` 配置: ${CONFIG_FILE}`));
|
|
343
|
-
console.log(chalk.gray(` 日志: ${LOG_FILE}`));
|
|
344
|
-
// 如果需要开机自启动(智能选择级别)
|
|
345
|
-
if (options.boot !== false) {
|
|
346
|
-
try {
|
|
347
|
-
const result = registerBoot({
|
|
348
|
-
isServer: false,
|
|
349
|
-
nodePath,
|
|
350
|
-
scriptPath: indexPath,
|
|
351
|
-
args: serveArgs,
|
|
352
|
-
}, { level: 'auto' }); // 默认使用 auto 智能选择
|
|
353
|
-
if (result.level === 'system') {
|
|
354
|
-
console.log(chalk.green('已启用开机自启动(系统级)'));
|
|
355
|
-
console.log(chalk.gray(' 启动时机: 系统启动时(无需登录桌面)'));
|
|
356
|
-
}
|
|
357
|
-
else {
|
|
358
|
-
console.log(chalk.green('已启用开机自启动(用户级)'));
|
|
359
|
-
if (result.needsElevation) {
|
|
360
|
-
console.log(chalk.yellow(' 提示: 当前为用户级启动。如需登录前启动,请以管理员/root权限重试'));
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
catch (err) {
|
|
365
|
-
console.log(chalk.yellow(`注册开机自启动失败: ${err.message}`));
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
// 打开管理页面
|
|
369
|
-
if (options.open) {
|
|
370
|
-
setTimeout(() => {
|
|
371
|
-
const adminPage = `http://127.0.0.1:${adminPort}`;
|
|
372
|
-
console.log(chalk.gray(`打开管理页面: ${adminPage}`));
|
|
373
|
-
const openCmd = platform() === 'win32' ? 'start' : 'open';
|
|
374
|
-
execSync(`${openCmd} ${adminPage}`, { stdio: 'ignore' });
|
|
375
|
-
}, 1000);
|
|
376
|
-
}
|
|
377
|
-
});
|
|
378
|
-
// ==================== stop 命令 ====================
|
|
379
|
-
const stopCmd = program.command('stop')
|
|
380
|
-
.description('关闭客户端')
|
|
381
|
-
.action(async () => {
|
|
382
|
-
const info = readPidFile();
|
|
383
|
-
if (!info) {
|
|
384
|
-
console.log(chalk.yellow('客户端未在运行'));
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
try {
|
|
388
|
-
// Windows 不支持 SIGTERM,需要使用 taskkill 命令
|
|
389
|
-
if (platform() === 'win32') {
|
|
390
|
-
execSync(`taskkill /F /PID ${info.pid}`, { stdio: 'ignore' });
|
|
391
|
-
}
|
|
392
|
-
else {
|
|
393
|
-
process.kill(info.pid, 'SIGTERM');
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
catch (err) {
|
|
397
|
-
console.log(chalk.yellow(`关闭失败: ${err.message}`));
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
removePidFile();
|
|
401
|
-
// 取消开机自启动
|
|
40
|
+
.version('0.1.0');
|
|
41
|
+
// ---- 子命令:set ----
|
|
42
|
+
// 注意:commander 短选项只能是单字符,无法直接支持 "-ip"。
|
|
43
|
+
// 通过在 parse 前将 "-ip" 重写为 "--ip" 来兼容用户习惯的 "-ip <serverip>" 写法。
|
|
44
|
+
const setCmd = program.command('set').description('设置服务端访问信息');
|
|
45
|
+
setCmd.requiredOption('--ip <ip>', '服务端 IP 或域名(可用 -ip 简写)');
|
|
46
|
+
setCmd.requiredOption('-p, --server-port <port>', '服务端控制端口');
|
|
47
|
+
setCmd.requiredOption('-t, --token <token>', '认证 token');
|
|
48
|
+
setCmd.option('--tls', '服务端启用 TLS(使用 wss/https)', false);
|
|
49
|
+
setCmd.action(async (options) => {
|
|
50
|
+
const config = {
|
|
51
|
+
serverIp: options.ip,
|
|
52
|
+
serverPort: parseInt(options.serverPort, 10),
|
|
53
|
+
token: options.token,
|
|
54
|
+
tls: !!options.tls,
|
|
55
|
+
};
|
|
402
56
|
try {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
console.log(chalk.green('客户端已关闭'));
|
|
409
|
-
});
|
|
410
|
-
// ==================== restart 命令 ====================
|
|
411
|
-
const restartCmd = program.command('restart')
|
|
412
|
-
.description('重启客户端')
|
|
413
|
-
.action(async () => {
|
|
414
|
-
console.log(chalk.blue('正在重启客户端...'));
|
|
415
|
-
const info = readPidFile();
|
|
416
|
-
const pid = info?.pid;
|
|
417
|
-
// 先停止
|
|
418
|
-
if (pid) {
|
|
419
|
-
try {
|
|
420
|
-
// Windows 不支持 SIGTERM,需要使用 taskkill 命令
|
|
421
|
-
if (platform() === 'win32') {
|
|
422
|
-
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
|
|
423
|
-
}
|
|
424
|
-
else {
|
|
425
|
-
process.kill(pid, 'SIGTERM');
|
|
426
|
-
}
|
|
427
|
-
console.log(chalk.gray(`已发送停止信号到 PID ${pid}`));
|
|
428
|
-
}
|
|
429
|
-
catch (err) {
|
|
430
|
-
console.log(chalk.yellow(`停止失败: ${err.message}`));
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
// 等待进程结束
|
|
434
|
-
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
435
|
-
// 清除 PID 文件,让 start 命令重新读取
|
|
436
|
-
removePidFile();
|
|
437
|
-
// 重新启动(不带参数,使用配置文件)
|
|
438
|
-
// 启动一个新的 start 命令进程
|
|
439
|
-
const startChild = spawn(nodePath, [cliPath, 'start'], {
|
|
440
|
-
detached: true,
|
|
441
|
-
stdio: 'ignore',
|
|
442
|
-
});
|
|
443
|
-
startChild.unref();
|
|
444
|
-
console.log(chalk.green('客户端已重启'));
|
|
445
|
-
// 等待客户端启动并显示状态
|
|
446
|
-
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
447
|
-
const newInfo = readPidFile();
|
|
448
|
-
if (newInfo) {
|
|
449
|
-
console.log(chalk.gray(` PID: ${newInfo.pid}`));
|
|
450
|
-
console.log(chalk.gray(` 管理页面: http://127.0.0.1:${newInfo.adminPort}`));
|
|
57
|
+
await saveConfig(config);
|
|
58
|
+
const scheme = config.tls ? 'wss' : 'ws';
|
|
59
|
+
console.log(chalk.green('配置已保存到 ~/.chuantou/config.json'));
|
|
60
|
+
console.log(chalk.gray(` 服务端: ${scheme}://${config.serverIp}:${config.serverPort}`));
|
|
61
|
+
console.log(chalk.gray(` 下一步: feng3d-ctc -p <本地端口> 映射本地端口`));
|
|
451
62
|
}
|
|
452
|
-
|
|
453
|
-
|
|
63
|
+
catch (error) {
|
|
64
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
65
|
+
console.error(chalk.red(`保存配置失败: ${msg}`));
|
|
66
|
+
process.exit(1);
|
|
454
67
|
}
|
|
455
68
|
});
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
.
|
|
459
|
-
.
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
69
|
+
// ---- 默认动作:映射本地端口 ----
|
|
70
|
+
program
|
|
71
|
+
.option('-p, --port <localPort>', '要映射的本地端口')
|
|
72
|
+
.option('-d, --detach', '后台运行(终端关闭不停止)')
|
|
73
|
+
.action(async (options) => {
|
|
74
|
+
// 没有 -p 时显示帮助
|
|
75
|
+
if (!options.port) {
|
|
76
|
+
program.help();
|
|
464
77
|
return;
|
|
465
78
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
console.log(chalk.gray(` 配置: ${CONFIG_FILE}`));
|
|
471
|
-
console.log(chalk.gray(` 日志: ${LOG_FILE}`));
|
|
472
|
-
console.log(chalk.gray(` 启动时间: ${new Date(info.startedAt).toLocaleString('zh-CN')}`));
|
|
473
|
-
console.log(chalk.gray(` 开机启动: ${isBootRegistered() ? '已启用' : '未启用'}`));
|
|
474
|
-
// 获取详细状态
|
|
475
|
-
const status = await getClientStatus(info.adminPort);
|
|
476
|
-
if (status) {
|
|
477
|
-
const uptime = Math.floor(status.uptime / 1000);
|
|
478
|
-
const minutes = Math.floor(uptime / 60);
|
|
479
|
-
const seconds = uptime % 60;
|
|
480
|
-
console.log(chalk.gray(` 连接状态: ${status.authenticated ? '已认证' : status.connected ? '已连接' : '未连接'}`));
|
|
481
|
-
if (uptime > 0) {
|
|
482
|
-
console.log(chalk.gray(` 运行时长: ${minutes}分${seconds}秒`));
|
|
483
|
-
}
|
|
484
|
-
if (status.reconnectAttempts && status.reconnectAttempts > 0) {
|
|
485
|
-
console.log(chalk.gray(` 重连次数: ${status.reconnectAttempts}次`));
|
|
486
|
-
}
|
|
487
|
-
if (status.proxies.length === 0) {
|
|
488
|
-
console.log(chalk.gray(' 代理映射: 无'));
|
|
489
|
-
}
|
|
490
|
-
else {
|
|
491
|
-
console.log(chalk.gray(` 代理映射: ${status.proxies.length}个`));
|
|
492
|
-
for (const proxy of status.proxies) {
|
|
493
|
-
const index = proxy.index ? `#${proxy.index}` : ' -';
|
|
494
|
-
const target = proxy.localHost || 'localhost';
|
|
495
|
-
const registered = proxy.registered === false ? chalk.yellow(' (待注册)') : '';
|
|
496
|
-
console.log(chalk.gray(` ${index} ${proxy.remotePort} -> ${target}:${proxy.localPort}`) + registered);
|
|
497
|
-
}
|
|
498
|
-
}
|
|
79
|
+
const localPort = parseInt(options.port, 10);
|
|
80
|
+
if (!localPort || localPort < 1 || localPort > 65535) {
|
|
81
|
+
console.error(chalk.red(`无效的本地端口: ${options.port}`));
|
|
82
|
+
process.exit(1);
|
|
499
83
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
});
|
|
504
|
-
// ==================== proxies 命令 ====================
|
|
505
|
-
const proxiesCmd = program.command('proxies')
|
|
506
|
-
.description('管理代理映射')
|
|
507
|
-
.argument('[command]', '子命令:list, add, remove, clear', { default: 'list' });
|
|
508
|
-
// proxies list 子命令
|
|
509
|
-
proxiesCmd.command('list')
|
|
510
|
-
.description('列出现有代理映射')
|
|
511
|
-
.action(async () => {
|
|
512
|
-
const config = readConfig();
|
|
513
|
-
const proxies = config?.proxies || [];
|
|
514
|
-
if (proxies.length === 0) {
|
|
515
|
-
console.log(chalk.yellow('暂无代理映射'));
|
|
84
|
+
// 后台模式:父进程 spawn 自身后退出
|
|
85
|
+
if (options.detach && process.env[DETACHED_ENV] !== '1') {
|
|
86
|
+
spawnDetached(localPort);
|
|
516
87
|
return;
|
|
517
88
|
}
|
|
518
|
-
|
|
519
|
-
for (let i = 0; i < proxies.length; i++) {
|
|
520
|
-
const proxy = proxies[i];
|
|
521
|
-
const index = proxy.index ? `#${proxy.index}` : ' -';
|
|
522
|
-
const target = proxy.localHost || 'localhost';
|
|
523
|
-
console.log(` ${chalk.cyan(index)} ${chalk.cyan(proxy.remotePort.toString())} -> ${chalk.cyan(target)}:${chalk.cyan(proxy.localPort.toString())}`);
|
|
524
|
-
}
|
|
89
|
+
await runProxy(localPort);
|
|
525
90
|
});
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
const remotePort = parseInt(parts[0], 10);
|
|
540
|
-
let localPort;
|
|
541
|
-
let localHost = 'localhost';
|
|
542
|
-
// 检查是否有第三部分(主机地址)
|
|
543
|
-
if (parts.length >= 3) {
|
|
544
|
-
localPort = parseInt(parts[1], 10);
|
|
545
|
-
localHost = parts[2];
|
|
546
|
-
}
|
|
547
|
-
else {
|
|
548
|
-
localPort = parseInt(parts[1], 10);
|
|
549
|
-
}
|
|
550
|
-
// 验证端口
|
|
551
|
-
if (isNaN(remotePort) || remotePort < 1024 || remotePort > 65535) {
|
|
552
|
-
console.log(chalk.yellow('远程端口无效:1024-65535'));
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
if (isNaN(localPort) || localPort < 1 || localPort > 65535) {
|
|
556
|
-
console.log(chalk.yellow('本地端口无效:1-65535'));
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
// 通过 API 添加
|
|
560
|
-
try {
|
|
561
|
-
const info = readPidFile();
|
|
562
|
-
const adminPort = info?.adminPort ?? ADMIN_PORT_DEFAULT;
|
|
563
|
-
const res = await fetch(`http://127.0.0.1:${adminPort}/_ctc/proxies`, {
|
|
564
|
-
method: 'POST',
|
|
565
|
-
headers: { 'Content-Type': 'application/json' },
|
|
566
|
-
body: JSON.stringify({
|
|
567
|
-
remotePort,
|
|
568
|
-
localPort,
|
|
569
|
-
localHost,
|
|
570
|
-
}),
|
|
571
|
-
});
|
|
572
|
-
if (res.ok) {
|
|
573
|
-
console.log(chalk.green('代理映射已添加'));
|
|
574
|
-
// 更新本地配置文件
|
|
575
|
-
const config = readConfig();
|
|
576
|
-
if (config) {
|
|
577
|
-
if (!config.proxies)
|
|
578
|
-
config.proxies = [];
|
|
579
|
-
config.proxies.push({ remotePort, localPort, localHost });
|
|
580
|
-
writeConfig(config);
|
|
581
|
-
}
|
|
582
|
-
const target = localHost || 'localhost';
|
|
583
|
-
console.log(chalk.gray(` ${remotePort} -> ${target}:${localPort}`));
|
|
584
|
-
}
|
|
585
|
-
else if (res.status === 404) {
|
|
586
|
-
console.log(chalk.yellow('代理映射不存在(管理服务器未运行)'));
|
|
587
|
-
}
|
|
588
|
-
else {
|
|
589
|
-
const data = await res.json();
|
|
590
|
-
console.log(chalk.red(`添加失败: ${data.error || '未知错误'}`));
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
catch (err) {
|
|
594
|
-
console.log(chalk.red(`请求失败: ${err.message}`));
|
|
595
|
-
}
|
|
91
|
+
// 兼容用户习惯的 "-ip" 与 "set -p" 简写:
|
|
92
|
+
// 1. commander 短选项只能是单字符,"-ip" 不合法 → 重写为 "--ip"
|
|
93
|
+
// 2. 默认动作与 set 子命令都用 "-p",program 级别的 "-p" 会拦截子命令参数。
|
|
94
|
+
// 在 set 上下文中把 "-p" 重写为 "--server-port" 以正确路由到子命令。
|
|
95
|
+
const rawArgv = process.argv.slice(2);
|
|
96
|
+
const isSetCtx = rawArgv[0] === 'set';
|
|
97
|
+
const argvRewritten = process.argv.map((arg) => {
|
|
98
|
+
if (arg === '-ip')
|
|
99
|
+
return '--ip';
|
|
100
|
+
if (isSetCtx && arg === '-p')
|
|
101
|
+
return '--server-port';
|
|
102
|
+
return arg;
|
|
596
103
|
});
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
.
|
|
600
|
-
.
|
|
601
|
-
.action(async (remotePort) => {
|
|
602
|
-
const port = parseInt(remotePort, 10);
|
|
603
|
-
if (isNaN(port) || port < 1024 || port > 65535) {
|
|
604
|
-
console.log(chalk.yellow('端口号无效:1024-65535'));
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
|
-
// 通过 API 删除
|
|
608
|
-
try {
|
|
609
|
-
const info = readPidFile();
|
|
610
|
-
const adminPort = info?.adminPort ?? ADMIN_PORT_DEFAULT;
|
|
611
|
-
const res = await fetch(`http://127.0.0.1:${adminPort}/_ctc/proxies/${port}`, {
|
|
612
|
-
method: 'DELETE',
|
|
613
|
-
});
|
|
614
|
-
if (res.ok) {
|
|
615
|
-
console.log(chalk.green(`端口 ${port} 的代理映射已移除`));
|
|
616
|
-
// 更新本地配置文件
|
|
617
|
-
const config = readConfig();
|
|
618
|
-
if (config && config.proxies) {
|
|
619
|
-
const index = config.proxies.findIndex(p => p.remotePort === port);
|
|
620
|
-
if (index !== -1) {
|
|
621
|
-
config.proxies.splice(index, 1);
|
|
622
|
-
writeConfig(config);
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
else if (res.status === 404) {
|
|
627
|
-
console.log(chalk.yellow('代理映射不存在'));
|
|
628
|
-
}
|
|
629
|
-
else {
|
|
630
|
-
const data = await res.json();
|
|
631
|
-
console.log(chalk.red(`移除失败: ${data.error || '未知错误'}`));
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
catch (err) {
|
|
635
|
-
console.log(chalk.red(`请求失败: ${err.message}`));
|
|
636
|
-
}
|
|
104
|
+
program.parseAsync(argvRewritten).catch((error) => {
|
|
105
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
106
|
+
console.error(chalk.red(`错误: ${msg}`));
|
|
107
|
+
process.exit(1);
|
|
637
108
|
});
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
109
|
+
/**
|
|
110
|
+
* 运行端口映射:连接服务端 → 认证 → 注册代理(服务端自动分配端口)→ 打印结果
|
|
111
|
+
*
|
|
112
|
+
* @param localPort - 要映射的本地端口
|
|
113
|
+
*/
|
|
114
|
+
async function runProxy(localPort) {
|
|
115
|
+
let config;
|
|
116
|
+
try {
|
|
117
|
+
config = await loadConfig();
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
121
|
+
console.error(chalk.red(msg));
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
const scheme = config.tls ? 'wss' : 'ws';
|
|
125
|
+
const serverUrl = `${scheme}://${config.serverIp}:${config.serverPort}${PROTOCOL.CONTROL_PATH}`;
|
|
126
|
+
const controller = new Controller({
|
|
127
|
+
serverUrl,
|
|
128
|
+
token: config.token,
|
|
129
|
+
reconnectInterval: DEFAULT_CONFIG.RECONNECT_INTERVAL,
|
|
130
|
+
maxReconnectAttempts: DEFAULT_CONFIG.MAX_RECONNECT_ATTEMPTS,
|
|
131
|
+
});
|
|
132
|
+
const proxyManager = new ProxyManager(controller);
|
|
133
|
+
// 认证成功后注册代理
|
|
134
|
+
controller.on('authenticated', async () => {
|
|
135
|
+
try {
|
|
136
|
+
await proxyManager.registerProxy({ localPort });
|
|
656
137
|
}
|
|
657
|
-
|
|
658
|
-
const
|
|
659
|
-
console.
|
|
138
|
+
catch (error) {
|
|
139
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
140
|
+
console.error(chalk.red(`映射失败: ${msg}`));
|
|
141
|
+
process.exit(1);
|
|
660
142
|
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
if (value === undefined) {
|
|
682
|
-
console.log(chalk.yellow(`配置项 "${key}" 不存在`));
|
|
683
|
-
console.log(chalk.gray('可用配置项:serverUrl, token, reconnectInterval, maxReconnectAttempts, proxies'));
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
// 显示值
|
|
687
|
-
if (typeof value === 'object') {
|
|
688
|
-
console.log(chalk.green(`${key}:`));
|
|
689
|
-
console.log(chalk.gray(JSON.stringify(value, null, 2)));
|
|
690
|
-
}
|
|
691
|
-
else {
|
|
692
|
-
console.log(chalk.green(`${key}: ${value}`));
|
|
693
|
-
}
|
|
694
|
-
});
|
|
695
|
-
// config set 子命令
|
|
696
|
-
configCmd.command('set')
|
|
697
|
-
.description('设置配置项')
|
|
698
|
-
.argument('<key>', '配置项名称')
|
|
699
|
-
.argument('<value>', '配置值')
|
|
700
|
-
.action(async (key, value) => {
|
|
701
|
-
// 读取现有配置
|
|
702
|
-
const config = readConfig() || {};
|
|
703
|
-
const oldValue = config[key];
|
|
704
|
-
// 解析值
|
|
705
|
-
let parsedValue = value;
|
|
706
|
-
if (key === 'serverUrl' || key === 'token') {
|
|
707
|
-
parsedValue = value;
|
|
708
|
-
}
|
|
709
|
-
else if (key === 'reconnectInterval' || key === 'maxReconnectAttempts') {
|
|
710
|
-
parsedValue = parseInt(value, 10);
|
|
711
|
-
}
|
|
712
|
-
else if (key === 'proxies') {
|
|
143
|
+
});
|
|
144
|
+
// 注册成功后打印映射端口
|
|
145
|
+
proxyManager.on('registered', (assignedPort) => {
|
|
146
|
+
const httpScheme = config.tls ? 'https' : 'http';
|
|
147
|
+
console.log('');
|
|
148
|
+
console.log(chalk.green(' 端口映射成功'));
|
|
149
|
+
console.log(chalk.gray(' ───────────────────────────────────'));
|
|
150
|
+
console.log(` ${chalk.cyan('本地端口')}: ${localPort}`);
|
|
151
|
+
console.log(` ${chalk.cyan('服务端映射端口')}: ${chalk.yellow(assignedPort)}`);
|
|
152
|
+
console.log(` ${chalk.cyan('访问地址')}: ${httpScheme}://${config.serverIp}:${assignedPort}`);
|
|
153
|
+
console.log(chalk.gray(' ───────────────────────────────────'));
|
|
154
|
+
console.log('');
|
|
155
|
+
});
|
|
156
|
+
// 写入 PID 文件
|
|
157
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
158
|
+
writeFileSync(PID_FILE, String(process.pid), 'utf-8');
|
|
159
|
+
console.log(chalk.gray(`PID: ${process.pid}`));
|
|
160
|
+
// 优雅退出
|
|
161
|
+
const shutdown = async (signal) => {
|
|
162
|
+
console.log(chalk.yellow(`\n收到 ${signal},正在停止...`));
|
|
713
163
|
try {
|
|
714
|
-
|
|
164
|
+
await proxyManager.destroy();
|
|
715
165
|
}
|
|
716
|
-
catch {
|
|
717
|
-
|
|
718
|
-
|
|
166
|
+
catch { /* ignore */ }
|
|
167
|
+
controller.destroy();
|
|
168
|
+
try {
|
|
169
|
+
if (existsSync(PID_FILE))
|
|
170
|
+
unlinkSync(PID_FILE);
|
|
719
171
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
config[key] = parsedValue;
|
|
723
|
-
writeConfig(config);
|
|
724
|
-
if (oldValue !== undefined) {
|
|
725
|
-
console.log(chalk.green(`已更新:${key}`));
|
|
726
|
-
console.log(chalk.gray(` 旧值: ${typeof oldValue === 'object' ? JSON.stringify(oldValue) : oldValue}`));
|
|
727
|
-
console.log(chalk.gray(` 新值: ${typeof parsedValue === 'object' ? JSON.stringify(parsedValue) : parsedValue}`));
|
|
728
|
-
}
|
|
729
|
-
else {
|
|
730
|
-
console.log(chalk.green(`已设置:${key} = ${typeof parsedValue === 'object' ? JSON.stringify(parsedValue) : parsedValue}`));
|
|
731
|
-
}
|
|
732
|
-
});
|
|
733
|
-
// config list 子命令
|
|
734
|
-
configCmd.command('list')
|
|
735
|
-
.description('列出所有配置项')
|
|
736
|
-
.action(async () => {
|
|
737
|
-
const config = readConfig();
|
|
738
|
-
if (!config) {
|
|
739
|
-
console.log(chalk.yellow('配置文件不存在'));
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
742
|
-
console.log(chalk.blue.bold('配置列表:'));
|
|
743
|
-
console.log(` ${chalk.cyan('serverUrl')}: ${chalk.gray(config.serverUrl || '(未设置)')}`);
|
|
744
|
-
console.log(` ${chalk.cyan('token')}: ${chalk.gray(config.token || '(未设置)')}`);
|
|
745
|
-
console.log(` ${chalk.cyan('proxies')}: ${chalk.gray(`[${config.proxies?.map(p => `${p.remotePort}:${p.localPort}`).join(', ') || '(空)'}]`)}`);
|
|
746
|
-
console.log(` ${chalk.cyan('reconnectInterval')}: ${chalk.gray(config.reconnectInterval || 30000)}ms`);
|
|
747
|
-
console.log(` ${chalk.cyan('maxReconnectAttempts')}: ${chalk.gray(config.maxReconnectAttempts || 10)}`);
|
|
748
|
-
});
|
|
749
|
-
// config edit 子命令
|
|
750
|
-
configCmd.command('edit')
|
|
751
|
-
.description('编辑配置文件')
|
|
752
|
-
.action(async () => {
|
|
753
|
-
const editors = {
|
|
754
|
-
win32: 'notepad',
|
|
755
|
-
darwin: 'open -a TextEdit',
|
|
756
|
-
linux: 'nano',
|
|
172
|
+
catch { /* ignore */ }
|
|
173
|
+
process.exit(0);
|
|
757
174
|
};
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
console.log(chalk.gray(`使用编辑器:${editor}`));
|
|
762
|
-
try {
|
|
763
|
-
execSync(`${editor} "${configPath}"`, { stdio: 'ignore' });
|
|
764
|
-
}
|
|
765
|
-
catch (err) {
|
|
766
|
-
console.log(chalk.yellow(`打开失败: ${err.message}`));
|
|
767
|
-
console.log(chalk.gray(`请手动打开:`));
|
|
768
|
-
}
|
|
769
|
-
});
|
|
770
|
-
// ==================== forward 命令(正向穿透模式)====================
|
|
771
|
-
const forwardCmd = program.command('forward')
|
|
772
|
-
.description('管理正向穿透代理(本地端口 -> 远程客户端端口)')
|
|
773
|
-
.argument('[command]', '子命令:list, add, remove, clients, register', { default: 'list' });
|
|
774
|
-
// forward list 子命令
|
|
775
|
-
forwardCmd.command('list')
|
|
776
|
-
.description('列出所有正向穿透代理')
|
|
777
|
-
.action(async () => {
|
|
175
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
176
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
177
|
+
// 启动连接
|
|
778
178
|
try {
|
|
779
|
-
|
|
780
|
-
const adminPort = info?.adminPort ?? ADMIN_PORT_DEFAULT;
|
|
781
|
-
const res = await fetch(`http://127.0.0.1:${adminPort}/_ctc/forward/list`);
|
|
782
|
-
if (!res.ok) {
|
|
783
|
-
console.log(chalk.yellow('无法获取代理列表(客户端未运行或功能不可用)'));
|
|
784
|
-
return;
|
|
785
|
-
}
|
|
786
|
-
const data = await res.json();
|
|
787
|
-
const proxies = data.proxies || [];
|
|
788
|
-
if (proxies.length === 0) {
|
|
789
|
-
console.log(chalk.yellow('暂无正向穿透代理'));
|
|
790
|
-
return;
|
|
791
|
-
}
|
|
792
|
-
console.log(chalk.blue.bold('正向穿透代理列表:'));
|
|
793
|
-
for (const proxy of proxies) {
|
|
794
|
-
const status = proxy.enabled ? chalk.green('●') : chalk.gray('○');
|
|
795
|
-
console.log(` ${status} 本地 :${proxy.localPort} → ${chalk.cyan(proxy.targetClientId)}:${proxy.targetPort}`);
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
catch (err) {
|
|
799
|
-
console.log(chalk.red(`请求失败: ${err.message}`));
|
|
800
|
-
}
|
|
801
|
-
});
|
|
802
|
-
// forward add 子命令
|
|
803
|
-
forwardCmd.command('add')
|
|
804
|
-
.description('添加正向穿透代理')
|
|
805
|
-
.argument('<mapping>', '映射格式:localPort:remotePort:targetClientId (如: 8080:3000:clientB)')
|
|
806
|
-
.action(async (mapping) => {
|
|
807
|
-
const parts = mapping.split(':');
|
|
808
|
-
if (parts.length !== 3) {
|
|
809
|
-
console.log(chalk.yellow('参数格式错误,应为:localPort:remotePort:targetClientId'));
|
|
810
|
-
console.log(chalk.gray('示例:8080:3000:clientB'));
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
|
-
const localPort = parseInt(parts[0], 10);
|
|
814
|
-
const targetPort = parseInt(parts[1], 10);
|
|
815
|
-
const targetClientId = parts[2];
|
|
816
|
-
if (isNaN(localPort) || localPort < 1 || localPort > 65535) {
|
|
817
|
-
console.log(chalk.yellow('本地端口无效:1-65535'));
|
|
818
|
-
return;
|
|
819
|
-
}
|
|
820
|
-
if (isNaN(targetPort) || targetPort < 1 || targetPort > 65535) {
|
|
821
|
-
console.log(chalk.yellow('目标端口无效:1-65535'));
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
if (!targetClientId || targetClientId.length === 0) {
|
|
825
|
-
console.log(chalk.yellow('目标客户端 ID 不能为空'));
|
|
826
|
-
return;
|
|
827
|
-
}
|
|
828
|
-
try {
|
|
829
|
-
const info = readPidFile();
|
|
830
|
-
const adminPort = info?.adminPort ?? ADMIN_PORT_DEFAULT;
|
|
831
|
-
const res = await fetch(`http://127.0.0.1:${adminPort}/_ctc/forward/add`, {
|
|
832
|
-
method: 'POST',
|
|
833
|
-
headers: { 'Content-Type': 'application/json' },
|
|
834
|
-
body: JSON.stringify({ localPort, targetClientId, targetPort }),
|
|
835
|
-
});
|
|
836
|
-
if (res.ok) {
|
|
837
|
-
console.log(chalk.green('正向穿透代理已添加'));
|
|
838
|
-
console.log(chalk.gray(` 本地 :${localPort} → ${targetClientId}:${targetPort}`));
|
|
839
|
-
}
|
|
840
|
-
else if (res.status === 404) {
|
|
841
|
-
console.log(chalk.yellow('客户端未运行或功能不可用'));
|
|
842
|
-
}
|
|
843
|
-
else {
|
|
844
|
-
const data = await res.json();
|
|
845
|
-
console.log(chalk.red(`添加失败: ${data.error || '未知错误'}`));
|
|
846
|
-
}
|
|
179
|
+
await controller.connect();
|
|
847
180
|
}
|
|
848
|
-
catch (
|
|
849
|
-
|
|
181
|
+
catch (error) {
|
|
182
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
183
|
+
console.error(chalk.red(`连接服务端失败: ${msg}`));
|
|
184
|
+
process.exit(1);
|
|
850
185
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
186
|
+
// 前台驻留
|
|
187
|
+
process.stdin.resume();
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* 以后台模式 spawn 自身
|
|
191
|
+
*/
|
|
192
|
+
function spawnDetached(localPort) {
|
|
193
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
194
|
+
cleanupStalePid(PID_FILE);
|
|
195
|
+
const logFd = openSync(LOG_FILE, 'a');
|
|
196
|
+
const nodePath = process.execPath;
|
|
197
|
+
const scriptPath = fileURLToPath(import.meta.url);
|
|
198
|
+
const child = spawn(nodePath, [scriptPath, '-p', String(localPort)], {
|
|
199
|
+
detached: true,
|
|
200
|
+
stdio: ['ignore', logFd, logFd],
|
|
201
|
+
env: { ...process.env, [DETACHED_ENV]: '1' },
|
|
202
|
+
});
|
|
203
|
+
child.unref();
|
|
204
|
+
console.log(chalk.green(`客户端已在后台启动(PID: ${child.pid})`));
|
|
205
|
+
console.log(chalk.gray(`日志: ${LOG_FILE}`));
|
|
206
|
+
console.log(chalk.gray(`停止: ${platform() === 'win32' ? `taskkill /F /PID ${child.pid}` : `kill ${child.pid}`}`));
|
|
207
|
+
setTimeout(() => process.exit(0), 100);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* 清理已失效的 PID 文件(对应进程已不存在)
|
|
211
|
+
*/
|
|
212
|
+
function cleanupStalePid(pidFile) {
|
|
213
|
+
if (!existsSync(pidFile))
|
|
860
214
|
return;
|
|
861
|
-
}
|
|
862
215
|
try {
|
|
863
|
-
const
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
method: 'POST',
|
|
867
|
-
headers: { 'Content-Type': 'application/json' },
|
|
868
|
-
body: JSON.stringify({ localPort: port }),
|
|
869
|
-
});
|
|
870
|
-
if (res.ok) {
|
|
871
|
-
console.log(chalk.green(`本地端口 ${port} 的正向穿透代理已移除`));
|
|
872
|
-
}
|
|
873
|
-
else {
|
|
874
|
-
const data = await res.json();
|
|
875
|
-
console.log(chalk.red(`移除失败: ${data.error || '未知错误'}`));
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
catch (err) {
|
|
879
|
-
console.log(chalk.red(`请求失败: ${err.message}`));
|
|
880
|
-
}
|
|
881
|
-
});
|
|
882
|
-
// forward clients 子命令
|
|
883
|
-
forwardCmd.command('clients')
|
|
884
|
-
.description('查看在线客户端列表')
|
|
885
|
-
.action(async () => {
|
|
886
|
-
try {
|
|
887
|
-
const info = readPidFile();
|
|
888
|
-
const adminPort = info?.adminPort ?? ADMIN_PORT_DEFAULT;
|
|
889
|
-
const res = await fetch(`http://127.0.0.1:${adminPort}/_ctc/forward/clients`);
|
|
890
|
-
if (!res.ok) {
|
|
891
|
-
console.log(chalk.yellow('无法获取客户端列表'));
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
|
-
const data = await res.json();
|
|
895
|
-
const clients = data.clients || [];
|
|
896
|
-
if (clients.length === 0) {
|
|
897
|
-
console.log(chalk.yellow('暂无在线客户端'));
|
|
898
|
-
return;
|
|
899
|
-
}
|
|
900
|
-
console.log(chalk.blue.bold('在线客户端列表:'));
|
|
901
|
-
for (const client of clients) {
|
|
902
|
-
const desc = client.description ? ` (${client.description})` : '';
|
|
903
|
-
console.log(` ${chalk.cyan(client.id)}${desc}`);
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
catch (err) {
|
|
907
|
-
console.log(chalk.red(`请求失败: ${err.message}`));
|
|
908
|
-
}
|
|
909
|
-
});
|
|
910
|
-
// forward register 子命令
|
|
911
|
-
forwardCmd.command('register')
|
|
912
|
-
.description('注册到服务器(启用正向穿透模式)')
|
|
913
|
-
.option('-d, --description <desc>', '客户端描述')
|
|
914
|
-
.action(async (options) => {
|
|
915
|
-
try {
|
|
916
|
-
const info = readPidFile();
|
|
917
|
-
const adminPort = info?.adminPort ?? ADMIN_PORT_DEFAULT;
|
|
918
|
-
const res = await fetch(`http://127.0.0.1:${adminPort}/_ctc/forward/register`, {
|
|
919
|
-
method: 'POST',
|
|
920
|
-
headers: { 'Content-Type': 'application/json' },
|
|
921
|
-
body: JSON.stringify({ description: options.description || '' }),
|
|
922
|
-
});
|
|
923
|
-
if (res.ok) {
|
|
924
|
-
console.log(chalk.green('已注册到服务器,正向穿透模式已启用'));
|
|
925
|
-
}
|
|
926
|
-
else {
|
|
927
|
-
const data = await res.json();
|
|
928
|
-
console.log(chalk.red(`注册失败: ${data.error || '未知错误'}`));
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
catch (err) {
|
|
932
|
-
console.log(chalk.red(`请求失败: ${err.message}`));
|
|
933
|
-
}
|
|
934
|
-
});
|
|
935
|
-
// ==================== boot 命令 ====================
|
|
936
|
-
const bootCmd = program.command('boot')
|
|
937
|
-
.description('管理开机自启动')
|
|
938
|
-
.argument('[command]', '子命令:enable, disable, status', { default: 'status' });
|
|
939
|
-
// boot status 子命令
|
|
940
|
-
bootCmd.command('status')
|
|
941
|
-
.description('查看开机自启动状态')
|
|
942
|
-
.action(async () => {
|
|
943
|
-
const activeLevel = getActiveBootLevel();
|
|
944
|
-
const hasSystemPriv = hasSystemPrivileges();
|
|
945
|
-
console.log(chalk.blue('开机自启动状态:'));
|
|
946
|
-
if (activeLevel === 'system') {
|
|
947
|
-
console.log(chalk.green(' 状态: 已启用(系统级)'));
|
|
948
|
-
console.log(chalk.gray(' 启动时机: 系统启动时(无需登录桌面)'));
|
|
949
|
-
console.log(chalk.gray(' 配置路径: ' + (platform() === 'win32' ? 'C:\\ProgramData\\chuantou' : '/etc/chuantou')));
|
|
950
|
-
}
|
|
951
|
-
else if (activeLevel === 'user') {
|
|
952
|
-
console.log(chalk.yellow(' 状态: 已启用(用户级)'));
|
|
953
|
-
console.log(chalk.gray(' 启动时机: 用户登录后'));
|
|
954
|
-
if (hasSystemPriv) {
|
|
955
|
-
console.log(chalk.dim(' 提示: 可升级到系统级启动(登录前启动)'));
|
|
216
|
+
const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
217
|
+
if (pid) {
|
|
218
|
+
process.kill(pid, 0);
|
|
956
219
|
}
|
|
957
220
|
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
// 显示权限状态
|
|
962
|
-
if (!activeLevel) {
|
|
963
|
-
console.log(chalk.gray(` 当前权限: ${hasSystemPriv ? '有管理员权限(可注册系统级)' : '普通用户(仅可注册用户级)'}`));
|
|
964
|
-
}
|
|
965
|
-
});
|
|
966
|
-
// boot enable 子命令
|
|
967
|
-
bootCmd.command('enable')
|
|
968
|
-
.description('启用开机自启动(智能选择系统级/用户级)')
|
|
969
|
-
.option('--system', '强制使用系统级启动(需要管理员权限)')
|
|
970
|
-
.option('--user', '强制使用用户级启动(登录后启动)')
|
|
971
|
-
.action(async (options) => {
|
|
972
|
-
const info = readPidFile();
|
|
973
|
-
if (!info) {
|
|
974
|
-
console.log(chalk.yellow('客户端未启动过,请先使用 start 命令启动'));
|
|
975
|
-
return;
|
|
976
|
-
}
|
|
977
|
-
try {
|
|
978
|
-
const level = options.system ? 'system' : options.user ? 'user' : 'auto';
|
|
979
|
-
const result = registerBoot({
|
|
980
|
-
isServer: false,
|
|
981
|
-
nodePath,
|
|
982
|
-
scriptPath: indexPath,
|
|
983
|
-
args: info.args || [],
|
|
984
|
-
}, { level });
|
|
985
|
-
if (result.level === 'system') {
|
|
986
|
-
console.log(chalk.green('开机自启动已启用(系统级)'));
|
|
987
|
-
console.log(chalk.gray(' 启动时机: 系统启动时(无需登录桌面)'));
|
|
988
|
-
console.log(chalk.gray(' 配置路径: ' + (platform() === 'win32' ? 'C:\\ProgramData\\chuantou' : '/etc/chuantou')));
|
|
989
|
-
}
|
|
990
|
-
else {
|
|
991
|
-
console.log(chalk.green('开机自启动已启用(用户级)'));
|
|
992
|
-
console.log(chalk.gray(' 启动时机: 用户登录后'));
|
|
993
|
-
if (result.needsElevation) {
|
|
994
|
-
console.log(chalk.yellow(' 提示: 当前为用户级启动。如需登录前启动,请以管理员/root权限重试:'));
|
|
995
|
-
console.log(chalk.gray(` ${platform() === 'win32' ? '右键 → 以管理员身份运行' : 'sudo npx @feng3d/ctc boot enable'}`));
|
|
996
|
-
}
|
|
221
|
+
catch {
|
|
222
|
+
try {
|
|
223
|
+
unlinkSync(pidFile);
|
|
997
224
|
}
|
|
225
|
+
catch { /* ignore */ }
|
|
998
226
|
}
|
|
999
|
-
|
|
1000
|
-
console.log(chalk.red(`启用失败: ${err.message}`));
|
|
1001
|
-
}
|
|
1002
|
-
});
|
|
1003
|
-
// boot disable 子命令
|
|
1004
|
-
bootCmd.command('disable')
|
|
1005
|
-
.description('禁用开机自启动(自动检测并禁用当前级别)')
|
|
1006
|
-
.option('--system', '禁用系统级启动')
|
|
1007
|
-
.option('--user', '禁用用户级启动')
|
|
1008
|
-
.action(async (options) => {
|
|
1009
|
-
try {
|
|
1010
|
-
const level = options.system ? 'system' : options.user ? 'user' : 'auto';
|
|
1011
|
-
unregisterBoot(undefined, { level });
|
|
1012
|
-
console.log(chalk.green('已禁用开机自启动'));
|
|
1013
|
-
}
|
|
1014
|
-
catch (err) {
|
|
1015
|
-
console.log(chalk.red(`禁用失败: ${err.message}`));
|
|
1016
|
-
}
|
|
1017
|
-
});
|
|
1018
|
-
// ==================== logs 命令 ====================
|
|
1019
|
-
const logsCmd = program.command('logs')
|
|
1020
|
-
.description('查看或跟踪日志')
|
|
1021
|
-
.option('-f, --follow', '跟踪日志输出(类似 tail -f)')
|
|
1022
|
-
.action(async (options) => {
|
|
1023
|
-
if (!existsSync(LOG_FILE)) {
|
|
1024
|
-
console.log(chalk.yellow('日志文件不存在'));
|
|
1025
|
-
return;
|
|
1026
|
-
}
|
|
1027
|
-
if (options.follow) {
|
|
1028
|
-
console.log(chalk.blue('跟踪日志输出 (Ctrl+C 退出)...'));
|
|
1029
|
-
console.log(chalk.dim('---'));
|
|
1030
|
-
const logFd = openSync(LOG_FILE, 'r');
|
|
1031
|
-
const buffer = Buffer.alloc(1024);
|
|
1032
|
-
let pos = 0;
|
|
1033
|
-
const readLog = () => {
|
|
1034
|
-
try {
|
|
1035
|
-
const bytesRead = readSync(logFd, buffer, 0, buffer.length, pos);
|
|
1036
|
-
if (bytesRead > 0) {
|
|
1037
|
-
const content = buffer.toString('utf-8', 0, bytesRead);
|
|
1038
|
-
process.stdout.write(content);
|
|
1039
|
-
pos += bytesRead;
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
catch {
|
|
1043
|
-
// ignore
|
|
1044
|
-
}
|
|
1045
|
-
};
|
|
1046
|
-
// 读取最新内容
|
|
1047
|
-
readLog();
|
|
1048
|
-
// 监控文件变化
|
|
1049
|
-
const watcher = () => {
|
|
1050
|
-
try {
|
|
1051
|
-
const stat = statSync(LOG_FILE);
|
|
1052
|
-
const newSize = stat.size;
|
|
1053
|
-
if (newSize > pos) {
|
|
1054
|
-
readLog();
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
catch (err) {
|
|
1058
|
-
// ignore
|
|
1059
|
-
}
|
|
1060
|
-
};
|
|
1061
|
-
// 每秒检查一次
|
|
1062
|
-
const interval = setInterval(watcher, 1000);
|
|
1063
|
-
// 处理退出
|
|
1064
|
-
process.on('SIGINT', () => {
|
|
1065
|
-
clearInterval(interval);
|
|
1066
|
-
closeSync(logFd);
|
|
1067
|
-
process.stdout.write('\n');
|
|
1068
|
-
console.log(chalk.gray('\n--- 日志跟踪已结束'));
|
|
1069
|
-
process.exit(0);
|
|
1070
|
-
});
|
|
1071
|
-
}
|
|
1072
|
-
else {
|
|
1073
|
-
// 显示所有日志
|
|
1074
|
-
const content = readFileSync(LOG_FILE, 'utf-8');
|
|
1075
|
-
console.log(chalk.blue('最近日志:'));
|
|
1076
|
-
console.log(chalk.dim('---'));
|
|
1077
|
-
console.log(content.split('\n').slice(-30).join('\n'));
|
|
1078
|
-
console.log(chalk.dim('---'));
|
|
1079
|
-
console.log(chalk.gray(`(日志文件:${LOG_FILE})`));
|
|
1080
|
-
}
|
|
1081
|
-
});
|
|
1082
|
-
// ==================== open 命令 ====================
|
|
1083
|
-
const openCmd = program.command('open')
|
|
1084
|
-
.description('在浏览器中打开管理页面')
|
|
1085
|
-
.action(async () => {
|
|
1086
|
-
const info = readPidFile();
|
|
1087
|
-
const adminPort = info?.adminPort ?? ADMIN_PORT_DEFAULT;
|
|
1088
|
-
const adminPage = `http://127.0.0.1:${adminPort}`;
|
|
1089
|
-
const openCmd = platform() === 'win32' ? 'start' : 'open';
|
|
1090
|
-
console.log(chalk.gray(`打开管理页面: ${adminPage}`));
|
|
1091
|
-
try {
|
|
1092
|
-
execSync(`${openCmd} ${adminPage}`, { stdio: 'ignore' });
|
|
1093
|
-
console.log(chalk.green('管理页面已在浏览器中打开'));
|
|
1094
|
-
}
|
|
1095
|
-
catch (err) {
|
|
1096
|
-
console.log(chalk.yellow(`打开失败: ${err.message}`));
|
|
1097
|
-
console.log(chalk.gray(`请手动访问:${adminPage}`));
|
|
1098
|
-
}
|
|
1099
|
-
});
|
|
1100
|
-
// ==================== 默认处理 ====================
|
|
1101
|
-
// 当没有指定命令时显示帮助
|
|
1102
|
-
program.action(() => {
|
|
1103
|
-
showHelp();
|
|
1104
|
-
});
|
|
1105
|
-
// 解析命令行参数
|
|
1106
|
-
program.parse();
|
|
227
|
+
}
|
|
1107
228
|
//# sourceMappingURL=cli.js.map
|