mihomo-cli 1.0.0-alpha.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/src/process.js ADDED
@@ -0,0 +1,532 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawn, execSync } = require('child_process');
4
+ const config = require('./config');
5
+
6
+ function clearRuntime() {
7
+ if (fs.existsSync(config.DIRS.runtime)) {
8
+ config.rmrf(config.DIRS.runtime);
9
+ }
10
+ config.ensureDirs();
11
+ }
12
+
13
+ function getPid() {
14
+ if (!fs.existsSync(config.PATHS.pidFile)) {
15
+ return null;
16
+ }
17
+ try {
18
+ const pid = parseInt(fs.readFileSync(config.PATHS.pidFile, 'utf8').trim());
19
+ return pid > 0 ? pid : null;
20
+ } catch (e) {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function isProcessRunning(pid) {
26
+ if (!pid) return false;
27
+ try {
28
+ const output = execSync('ps -p ' + pid + ' -o pid= 2>/dev/null || true', {
29
+ encoding: 'utf8',
30
+ }).trim();
31
+ return output.length > 0;
32
+ } catch (e) {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ function isRunning() {
38
+ const pid = getPid();
39
+ return pid ? isProcessRunning(pid) : false;
40
+ }
41
+
42
+ function getAllMihomoPids() {
43
+ const binaryPath = config.PATHS.mihomoBinary;
44
+
45
+ try {
46
+ const output = execSync('pgrep -f "' + binaryPath + '" 2>/dev/null || true', {
47
+ encoding: 'utf8',
48
+ }).trim();
49
+ if (!output) return [];
50
+ return output.split('\n').filter(Boolean).map(p => parseInt(p));
51
+ } catch {
52
+ return [];
53
+ }
54
+ }
55
+
56
+ function isProcessRoot(pid) {
57
+ try {
58
+ const uidOutput = execSync('ps -p ' + pid + ' -o uid= 2>/dev/null || true', {
59
+ encoding: 'utf8',
60
+ }).trim();
61
+ return uidOutput === '0';
62
+ } catch (e) {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ function isPidFileOwnedByRoot() {
68
+ if (!fs.existsSync(config.PATHS.pidFile)) {
69
+ return false;
70
+ }
71
+ try {
72
+ const stat = fs.statSync(config.PATHS.pidFile);
73
+ return stat.uid === 0;
74
+ } catch (e) {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ function checkStaleState() {
80
+ const allPids = getAllMihomoPids();
81
+ const hasRootProcess = allPids.some(p => isProcessRoot(p));
82
+ const hasRootPidFile = isPidFileOwnedByRoot();
83
+
84
+ return {
85
+ needsCleanup: allPids.length > 0 || hasRootPidFile,
86
+ allPids,
87
+ hasRootProcess,
88
+ hasRootPidFile,
89
+ needsSudo: hasRootProcess || hasRootPidFile,
90
+ };
91
+ }
92
+
93
+ function savePid(pid) {
94
+ config.ensureDirs();
95
+ fs.writeFileSync(config.PATHS.pidFile, pid.toString(), { mode: 0o600 });
96
+ }
97
+
98
+ function clearPid() {
99
+ if (!fs.existsSync(config.PATHS.pidFile)) {
100
+ return;
101
+ }
102
+ if (isPidFileOwnedByRoot()) {
103
+ console.log(' PID 文件由 root 创建,需要管理员权限删除');
104
+ try {
105
+ execSync('sudo rm -f "' + config.PATHS.pidFile + '" 2>/dev/null', {
106
+ stdio: 'inherit',
107
+ timeout: 10000,
108
+ });
109
+ } catch (e) {
110
+ console.log(' 提示: 请手动运行 "sudo rm ' + config.PATHS.pidFile + '"');
111
+ }
112
+ } else {
113
+ try {
114
+ fs.unlinkSync(config.PATHS.pidFile);
115
+ } catch (e) {
116
+ // ignore
117
+ }
118
+ }
119
+ }
120
+
121
+ function killProcess(pid, needsSudo) {
122
+ if (needsSudo === undefined) needsSudo = false;
123
+ try {
124
+ if (needsSudo) {
125
+ try {
126
+ execSync('sudo kill -9 ' + pid + ' 2>/dev/null', {
127
+ stdio: 'inherit',
128
+ timeout: 10000,
129
+ });
130
+ return true;
131
+ } catch (e) {
132
+ try {
133
+ process.kill(pid, 'SIGKILL');
134
+ return true;
135
+ } catch (e2) {
136
+ return false;
137
+ }
138
+ }
139
+ } else {
140
+ process.kill(pid, 'SIGKILL');
141
+ return true;
142
+ }
143
+ } catch (e) {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ function killAllMihomo(forceSudo) {
149
+ if (forceSudo === undefined) forceSudo = false;
150
+ const binaryPath = config.PATHS.mihomoBinary;
151
+
152
+ if (forceSudo) {
153
+ try {
154
+ execSync('sudo pkill -9 -f "' + binaryPath + '" 2>/dev/null || true', {
155
+ stdio: 'inherit',
156
+ timeout: 15000,
157
+ });
158
+ return true;
159
+ } catch {
160
+ return false;
161
+ }
162
+ } else {
163
+ try {
164
+ execSync('pkill -9 -f "' + binaryPath + '" 2>/dev/null || true');
165
+ return true;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+ }
171
+
172
+ function cleanupAll(forceSudo) {
173
+ if (forceSudo === undefined) forceSudo = false;
174
+ const pids = getAllMihomoPids();
175
+ if (pids.length === 0) {
176
+ clearPid();
177
+ return { killed: 0, failed: 0, remaining: [] };
178
+ }
179
+
180
+ const hasRootProcess = pids.some(p => isProcessRoot(p));
181
+ const hasRootPidFile = isPidFileOwnedByRoot();
182
+ const needsSudo = hasRootProcess;
183
+ const allowSudo = forceSudo || hasRootProcess || hasRootPidFile;
184
+
185
+ let killedCount = 0;
186
+ let failedPids = [];
187
+
188
+ if (needsSudo) {
189
+ console.log(' 发现 ' + pids.length + ' 个 mihomo 进程(部分需要 root 权限)');
190
+ console.log(' 正在请求管理员权限终止进程...');
191
+ const success = killAllMihomo(true);
192
+ if (success) {
193
+ killedCount = pids.length;
194
+ } else {
195
+ console.log(' 部分进程可能需要手动清理');
196
+ console.log(' 手动清理命令: sudo pkill -9 mihomo');
197
+ failedPids = pids;
198
+ }
199
+ } else {
200
+ if (pids.length > 3) {
201
+ killAllMihomo(false);
202
+ killedCount = pids.length;
203
+ } else {
204
+ pids.forEach(pid => {
205
+ if (killProcess(pid, false)) {
206
+ killedCount++;
207
+ } else {
208
+ failedPids.push(pid);
209
+ }
210
+ });
211
+ }
212
+ }
213
+
214
+ for (let i = 0; i < 50; i++) {
215
+ if (getAllMihomoPids().length === 0) break;
216
+ try {
217
+ execSync('sleep 0.1', { stdio: 'ignore' });
218
+ } catch (e) {}
219
+ }
220
+
221
+ clearPid();
222
+
223
+ return {
224
+ killed: killedCount,
225
+ failed: failedPids.length,
226
+ remaining: getAllMihomoPids(),
227
+ };
228
+ }
229
+
230
+ function createTunLaunchScript() {
231
+ const binary = config.PATHS.mihomoBinary;
232
+ const configFile = config.PATHS.configFile;
233
+ const logFile = config.PATHS.logFile;
234
+ const pidFile = config.PATHS.pidFile;
235
+ const dataDir = config.PATHS.data;
236
+
237
+ const scriptContent = '#!/bin/bash\n' +
238
+ 'BINARY="' + binary + '"\n' +
239
+ 'CONFIG_FILE="' + configFile + '"\n' +
240
+ 'LOG_FILE="' + logFile + '"\n' +
241
+ 'PID_FILE="' + pidFile + '"\n' +
242
+ 'DATA_DIR="' + dataDir + '"\n' +
243
+ '\n' +
244
+ '# 终止旧进程\n' +
245
+ 'pkill -9 -f "${BINARY}" 2>/dev/null || true\n' +
246
+ 'sleep 0.2\n' +
247
+ 'rm -f "${PID_FILE}" 2>/dev/null || true\n' +
248
+ '\n' +
249
+ '# 清空日志\n' +
250
+ 'echo "=== TUN 启动: $(date) ===" > "${LOG_FILE}"\n' +
251
+ '\n' +
252
+ '# 启动\n' +
253
+ 'cd /tmp\n' +
254
+ '"${BINARY}" -d "${DATA_DIR}" -f "${CONFIG_FILE}" >> "${LOG_FILE}" 2>&1 &\n' +
255
+ 'NEW_PID=$!\n' +
256
+ 'echo ${NEW_PID} > "${PID_FILE}"\n' +
257
+ '\n' +
258
+ '# 验证\n' +
259
+ 'for i in 1 2 3 4 5; do\n' +
260
+ ' sleep 0.4\n' +
261
+ ' if kill -0 ${NEW_PID} 2>/dev/null; then\n' +
262
+ ' echo "TUN 启动成功, PID ${NEW_PID}"\n' +
263
+ ' exit 0\n' +
264
+ ' fi\n' +
265
+ 'done\n' +
266
+ '\n' +
267
+ '# 失败,显示日志\n' +
268
+ 'echo "TUN 启动失败"\n' +
269
+ 'echo ""\n' +
270
+ 'echo "--- 日志 ---"\n' +
271
+ 'tail -25 "${LOG_FILE}" 2>/dev/null\n' +
272
+ 'exit 1\n';
273
+
274
+ const scriptPath = path.join(config.DIRS.runtime, 'launch-tun.sh');
275
+ fs.writeFileSync(scriptPath, scriptContent, { mode: 0o700 });
276
+
277
+ return scriptPath;
278
+ }
279
+
280
+ function getProcessInfo(pid) {
281
+ try {
282
+ const psOutput = execSync('ps -p ' + pid + ' -o rss=,pcpu=,comm= 2>/dev/null || true', {
283
+ encoding: 'utf8',
284
+ }).trim();
285
+
286
+ if (!psOutput) return null;
287
+
288
+ const parts = psOutput.split(/\s+/).filter(p => p);
289
+ if (parts.length < 2) return null;
290
+
291
+ const rss = parseInt(parts[0]);
292
+ const pcpu = parseFloat(parts[1]);
293
+
294
+ return {
295
+ pid,
296
+ memory: rss ? (rss / 1024).toFixed(1) + ' MB' : '未知',
297
+ cpu: pcpu ? pcpu.toFixed(1) + '%' : '未知',
298
+ isRoot: isProcessRoot(pid),
299
+ };
300
+ } catch (e) {
301
+ return { pid, memory: '未知', cpu: '未知', isRoot: false };
302
+ }
303
+ }
304
+
305
+ function getStatus() {
306
+ const running = isRunning();
307
+ const pid = getPid();
308
+ const allPids = getAllMihomoPids();
309
+
310
+ return {
311
+ running,
312
+ pid: running ? pid : null,
313
+ processInfo: running && pid ? getProcessInfo(pid) : null,
314
+ hasConfig: config.hasConfig(),
315
+ hasKernel: config.hasKernel(),
316
+ kernelVersion: config.getKernelVersion(),
317
+ allProcesses: allPids,
318
+ hasStaleProcesses: allPids.length > (running ? 1 : 0),
319
+ };
320
+ }
321
+
322
+ async function start(mode) {
323
+ if (mode === undefined) mode = 'mixed';
324
+ const isTunMode = mode === 'tun';
325
+
326
+ const staleState = checkStaleState();
327
+
328
+ if (isTunMode) {
329
+ return startTunMode(staleState);
330
+ } else {
331
+ return startMixedMode(staleState);
332
+ }
333
+ }
334
+
335
+ async function startMixedMode(staleState) {
336
+ if (staleState.needsCleanup) {
337
+ if (staleState.needsSudo) {
338
+ console.log('\n 发现需要 root 权限清理的残留进程/文件');
339
+ console.log(' 请先手动清理: sudo pkill -9 mihomo');
340
+ console.log(' 或者切换到 TUN 模式,启动时会自动清理');
341
+ throw new Error('存在需要 root 权限清理的残留');
342
+ }
343
+
344
+ const cleanupResult = cleanupAll();
345
+ if (cleanupResult.killed > 0) {
346
+ console.log(' 清理了 ' + cleanupResult.killed + ' 个残留进程');
347
+ }
348
+ }
349
+
350
+ if (isRunning()) {
351
+ const pid = getPid();
352
+ return { success: true, pid, alreadyRunning: true };
353
+ }
354
+
355
+ config.ensureDirs();
356
+
357
+ const binary = config.PATHS.mihomoBinary;
358
+ if (!fs.existsSync(binary)) {
359
+ throw new Error('未找到 mihomo 内核,请先下载内核');
360
+ }
361
+
362
+ const configFile = config.PATHS.configFile;
363
+ const logFile = config.PATHS.logFile;
364
+
365
+ if (!fs.existsSync(configFile)) {
366
+ throw new Error('未找到配置文件,请先添加订阅并启动');
367
+ }
368
+
369
+ const args = [
370
+ '-d', config.PATHS.data,
371
+ '-f', configFile,
372
+ ];
373
+
374
+ const out = fs.openSync(logFile, 'a');
375
+ const err = fs.openSync(logFile, 'a');
376
+
377
+ const child = spawn(binary, args, {
378
+ detached: true,
379
+ stdio: ['ignore', out, err],
380
+ cwd: config.PATHS.root,
381
+ });
382
+
383
+ child.unref();
384
+
385
+ const pid = child.pid;
386
+ savePid(pid);
387
+
388
+ await new Promise(resolve => setTimeout(resolve, 800));
389
+
390
+ if (!isRunning()) {
391
+ clearPid();
392
+ let errorMsg = '启动失败';
393
+ if (fs.existsSync(logFile)) {
394
+ try {
395
+ const logs = fs.readFileSync(logFile, 'utf8').slice(-3000);
396
+ if (logs.trim()) {
397
+ errorMsg += '\n 最近的日志:\n' + logs.split('\n').map(l => ' ' + l).join('\n');
398
+ }
399
+ } catch {}
400
+ }
401
+ throw new Error(errorMsg);
402
+ }
403
+
404
+ return { success: true, pid, mode: 'mixed' };
405
+ }
406
+
407
+ async function startTunMode(staleState) {
408
+ config.ensureDirs();
409
+
410
+ const binary = config.PATHS.mihomoBinary;
411
+ if (!fs.existsSync(binary)) {
412
+ throw new Error('未找到 mihomo 内核,请先下载内核');
413
+ }
414
+
415
+ const configFile = config.PATHS.configFile;
416
+
417
+ if (!fs.existsSync(configFile)) {
418
+ throw new Error('未找到配置文件,请先添加订阅并启动');
419
+ }
420
+
421
+ const launchScript = createTunLaunchScript();
422
+
423
+ if (staleState.needsCleanup) {
424
+ console.log(' 清理 ' + staleState.allPids.length + ' 个残留进程...');
425
+ }
426
+ console.log(' TUN 模式需要 sudo 权限...');
427
+
428
+ try {
429
+ execSync('sudo "' + launchScript + '"', {
430
+ stdio: 'inherit',
431
+ timeout: 60000,
432
+ });
433
+ } catch (e) {
434
+ try { fs.unlinkSync(launchScript); } catch (e2) {}
435
+ if (e.status === 1) {
436
+ throw new Error('密码错误或取消');
437
+ }
438
+ throw new Error(e.message);
439
+ }
440
+
441
+ try { fs.unlinkSync(launchScript); } catch (e) {}
442
+
443
+ await new Promise(resolve => setTimeout(resolve, 500));
444
+
445
+ const finalPid = getPid();
446
+ if (!finalPid) {
447
+ throw new Error('TUN 启动失败');
448
+ }
449
+
450
+ return { success: true, pid: finalPid, mode: 'tun' };
451
+ }
452
+
453
+ function stop(wasTunMode) {
454
+ const allPids = getAllMihomoPids();
455
+ if (allPids.length === 0) {
456
+ clearPid();
457
+ clearRuntime();
458
+ return { success: true, notRunning: true };
459
+ }
460
+
461
+ const result = cleanupAll(wasTunMode);
462
+
463
+ const remaining = getAllMihomoPids();
464
+ if (remaining.length > 0) {
465
+ console.log('');
466
+ console.log(' 仍有进程残留,需要手动清理:');
467
+ console.log(' 进程 PID: ' + remaining.join(', '));
468
+ console.log(' 手动命令: sudo pkill -9 mihomo');
469
+ console.log('');
470
+ return { success: true, warning: '部分进程未终止', remaining };
471
+ }
472
+
473
+ clearRuntime();
474
+ return { success: true, killed: result.killed };
475
+ }
476
+
477
+ function getLogPath() {
478
+ return config.PATHS.logFile;
479
+ }
480
+
481
+ function readLog(lines) {
482
+ if (lines === undefined) lines = 100;
483
+ if (!fs.existsSync(config.PATHS.logFile)) {
484
+ return '(暂无日志)';
485
+ }
486
+
487
+ try {
488
+ const content = fs.readFileSync(config.PATHS.logFile, 'utf8');
489
+ const allLines = content.split('\n');
490
+ return allLines.slice(-lines).join('\n');
491
+ } catch (e) {
492
+ return '(读取日志失败: ' + e.message + ')';
493
+ }
494
+ }
495
+
496
+ function clearLog() {
497
+ if (fs.existsSync(config.PATHS.logFile)) {
498
+ try {
499
+ fs.writeFileSync(config.PATHS.logFile, '');
500
+ return true;
501
+ } catch (e) {
502
+ return false;
503
+ }
504
+ }
505
+ return true;
506
+ }
507
+
508
+ function openUrl(url) {
509
+ try {
510
+ spawn('open', [url], { stdio: 'ignore', detached: true });
511
+ return true;
512
+ } catch (e) {
513
+ return false;
514
+ }
515
+ }
516
+
517
+ module.exports = {
518
+ getPid,
519
+ isRunning,
520
+ isProcessRunning,
521
+ getAllMihomoPids,
522
+ isProcessRoot,
523
+ checkStaleState,
524
+ cleanupAll,
525
+ getStatus,
526
+ start,
527
+ stop,
528
+ getLogPath,
529
+ readLog,
530
+ clearLog,
531
+ openUrl,
532
+ };
@@ -0,0 +1,89 @@
1
+ const axios = require('axios');
2
+ const config = require('./config');
3
+
4
+ const HTTP_CLIENT = axios.create({
5
+ timeout: 60000,
6
+ headers: {
7
+ 'User-Agent': 'mihomo-cli/1.0',
8
+ },
9
+ maxContentLength: 50 * 1024 * 1024,
10
+ maxBodyLength: 50 * 1024 * 1024,
11
+ });
12
+
13
+ async function downloadSubscription(url, subName) {
14
+ if (subName === undefined) subName = 'default';
15
+
16
+ let response;
17
+ try {
18
+ response = await HTTP_CLIENT.get(url, {
19
+ responseType: 'text',
20
+ });
21
+ } catch (e) {
22
+ const maskedUrl = config.maskUrl(url);
23
+ let errorMsg = '获取订阅失败: ' + e.message;
24
+ if (e.response) {
25
+ errorMsg += ' (HTTP ' + e.response.status + ')';
26
+ }
27
+ errorMsg += '\n URL: ' + maskedUrl;
28
+ throw new Error(errorMsg);
29
+ }
30
+
31
+ const content = response.data;
32
+ if (!content || !content.trim()) {
33
+ throw new Error('订阅内容为空');
34
+ }
35
+
36
+ let parsed;
37
+ try {
38
+ const yaml = require('js-yaml');
39
+ parsed = yaml.load(content);
40
+ } catch (e) {
41
+ try {
42
+ parsed = JSON.parse(content);
43
+ } catch (e2) {
44
+ throw new Error('订阅内容格式错误,无法解析为 YAML 或 JSON');
45
+ }
46
+ }
47
+
48
+ if (!parsed) {
49
+ throw new Error('订阅内容为空');
50
+ }
51
+
52
+ config.saveSubRawConfig(subName, content);
53
+
54
+ const subs = config.getSubscriptions();
55
+ const sub = subs.find(s => s.name === subName);
56
+ if (sub) {
57
+ sub.updatedAt = new Date().toISOString();
58
+ config.writeSettings({ subscriptions: subs });
59
+ }
60
+
61
+ return {
62
+ proxies: parsed.proxies ? parsed.proxies.length : 0,
63
+ proxyGroups: parsed['proxy-groups'] ? parsed['proxy-groups'].length : 0,
64
+ };
65
+ }
66
+
67
+ function prepareConfigForStart(mode, subName) {
68
+ if (subName === undefined) subName = 'default';
69
+
70
+ const rawContent = config.readSubRawConfig(subName);
71
+ if (!rawContent) {
72
+ throw new Error('未找到订阅配置 "' + subName + '",请先添加订阅');
73
+ }
74
+
75
+ const mergedConfig = config.buildConfig(rawContent, mode);
76
+ config.writeMihomoConfig(mergedConfig);
77
+
78
+ return {
79
+ proxies: mergedConfig.proxies ? mergedConfig.proxies.length : 0,
80
+ proxyGroups: mergedConfig['proxy-groups'] ? mergedConfig['proxy-groups'].length : 0,
81
+ };
82
+ }
83
+
84
+ module.exports = {
85
+ downloadSubscription,
86
+ prepareConfigForStart,
87
+ hasConfig: config.hasConfig,
88
+ getConfigInfo: config.getConfigInfo,
89
+ };