@lancewu602/mihomo-dash 0.0.0-stage → 0.6.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/LICENSE +21 -0
- package/README.md +99 -2
- package/bin/mihomo-dash.js +4 -0
- package/package.json +63 -4
- package/server/CHANGELOG.md +51 -0
- package/server/package.json +13 -0
- package/server/src/cli/index.js +75 -0
- package/server/src/cli/service.js +167 -0
- package/server/src/cli/status.js +245 -0
- package/server/src/http/config-files.js +148 -0
- package/server/src/http/config-order.js +186 -0
- package/server/src/http/config-sections.js +278 -0
- package/server/src/http/default-config.js +69 -0
- package/server/src/http/groups.js +386 -0
- package/server/src/http/index.js +260 -0
- package/server/src/http/kernel-admin.js +522 -0
- package/server/src/http/kernel-proxy.js +43 -0
- package/server/src/http/providers.js +251 -0
- package/server/src/http/rules.js +509 -0
- package/server/src/http/settings.js +409 -0
- package/server/src/http/system-proxy.js +86 -0
- package/server/src/index.js +26 -0
- package/server/src/kernel/archive.js +181 -0
- package/server/src/kernel/binary.js +33 -0
- package/server/src/kernel/client.js +138 -0
- package/server/src/kernel/install.js +150 -0
- package/server/src/kernel/lifecycle.js +339 -0
- package/server/src/kernel/run.js +11 -0
- package/server/src/shared/args.js +57 -0
- package/server/src/shared/atomic-file.js +41 -0
- package/server/src/shared/exec.js +44 -0
- package/server/src/shared/kernel-config.js +27 -0
- package/server/src/shared/log-file.js +92 -0
- package/server/src/shared/panel.js +58 -0
- package/server/src/shared/paths.js +72 -0
- package/server/src/shared/service.js +552 -0
- package/server/src/shared/system-proxy.js +375 -0
- package/server/src/shared/text.js +11 -0
- package/server/tests/cli-service.test.js +153 -0
- package/server/tests/config-dir.test.js +133 -0
- package/server/tests/config-order.test.js +148 -0
- package/server/tests/groups.test.js +558 -0
- package/server/tests/kernel-admin.test.js +647 -0
- package/server/tests/kernel.test.js +250 -0
- package/server/tests/log-file.test.js +110 -0
- package/server/tests/providers.test.js +259 -0
- package/server/tests/rules.test.js +463 -0
- package/server/tests/service.test.js +470 -0
- package/server/tests/settings.test.js +456 -0
- package/server/tests/status.test.js +269 -0
- package/server/tests/system-proxy.test.js +497 -0
- package/server/tsconfig.json +17 -0
- package/web/dist/assets/index-BpDzYhuI.css +1 -0
- package/web/dist/assets/index-WmujD2EM.js +515 -0
- package/web/dist/favicon.svg +5 -0
- package/web/dist/index.html +14 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 系统代理开关:macOS 的系统代理(networksetup)与 Linux GNOME 的 gsettings 代理。
|
|
3
|
+
*
|
|
4
|
+
* 与内核是**两层独立的东西**,这决定了「停止代理」的正确操作:
|
|
5
|
+
* - 内核的 `mode`(direct / rule)决定流量走不走节点;
|
|
6
|
+
* - 系统代理决定「系统会不会把流量交给 127.0.0.1:<port>」。
|
|
7
|
+
*
|
|
8
|
+
* 所以关掉系统代理 = 系统直连(内核可以继续跑,端口照旧监听),这才是通常想说的「停止代理」;
|
|
9
|
+
* 想连进程一起停用:侧栏底部的「停止内核」。
|
|
10
|
+
*
|
|
11
|
+
* 实现约定:
|
|
12
|
+
* - 所有外部命令都是**固定 argv 直接 spawn,不经过 shell**;网络服务名从系统枚举里挑、
|
|
13
|
+
* host / port 过白名单校验,绝不把外部字符串拼进命令。
|
|
14
|
+
* - macOS 只动「当前活动网络服务」(按默认路由的接口反查),不扫全部服务;实测普通用户
|
|
15
|
+
* 即可写这些设置,不需要 sudo。
|
|
16
|
+
* - `run` 可注入:测试用桩函数断言真实 argv,不会碰本机设置。
|
|
17
|
+
*/
|
|
18
|
+
import { defaultRun } from './exec.js';
|
|
19
|
+
|
|
20
|
+
export { defaultRun };
|
|
21
|
+
/** macOS 三个协议在 networksetup / scutil 里的名字对应关系 */
|
|
22
|
+
const MAC_PROTOCOLS = [
|
|
23
|
+
{
|
|
24
|
+
key: 'http',
|
|
25
|
+
label: 'HTTP',
|
|
26
|
+
getter: '-getwebproxy',
|
|
27
|
+
setter: '-setwebproxy',
|
|
28
|
+
stateFlag: '-setwebproxystate',
|
|
29
|
+
enable: 'HTTPEnable',
|
|
30
|
+
host: 'HTTPProxy',
|
|
31
|
+
port: 'HTTPPort',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
key: 'https',
|
|
35
|
+
label: 'HTTPS',
|
|
36
|
+
getter: '-getsecurewebproxy',
|
|
37
|
+
setter: '-setsecurewebproxy',
|
|
38
|
+
stateFlag: '-setsecurewebproxystate',
|
|
39
|
+
enable: 'HTTPSEnable',
|
|
40
|
+
host: 'HTTPSProxy',
|
|
41
|
+
port: 'HTTPSPort',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
key: 'socks',
|
|
45
|
+
label: 'SOCKS',
|
|
46
|
+
getter: '-getsocksfirewallproxy',
|
|
47
|
+
setter: '-setsocksfirewallproxy',
|
|
48
|
+
stateFlag: '-setsocksfirewallproxystate',
|
|
49
|
+
enable: 'SOCKSEnable',
|
|
50
|
+
host: 'SOCKSProxy',
|
|
51
|
+
port: 'SOCKSPort',
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
const GNOME_PROXY = 'org.gnome.system.proxy';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 开启系统代理时**固定补上**的绕过项:回环 + 私网 + link-local + 多播 + 本地域名。
|
|
58
|
+
* 这些交给代理没意义,反而会把本机 / 局域网服务绕坏。
|
|
59
|
+
* 语义是「只增不减」:与系统里已有的条目求并集,**绝不删**手工加的那些。
|
|
60
|
+
* `note` 只用于面板展示(得看每项到底绕过什么)。
|
|
61
|
+
* @type {{ value: string, note: string }[]}
|
|
62
|
+
*/
|
|
63
|
+
export const BYPASS_DEFAULTS = [
|
|
64
|
+
{ value: 'localhost', note: '本机名' },
|
|
65
|
+
{ value: '127.0.0.1', note: 'IPv4 回环' },
|
|
66
|
+
{ value: '::1', note: 'IPv6 回环' },
|
|
67
|
+
{ value: '10.0.0.0/8', note: 'IPv4 私网(10.x)' },
|
|
68
|
+
{ value: '172.16.0.0/12', note: 'IPv4 私网(172.16–172.31)' },
|
|
69
|
+
{ value: '192.168.0.0/16', note: 'IPv4 私网(192.168.x)' },
|
|
70
|
+
{ value: '169.254.0.0/16', note: 'IPv4 link-local(DHCP 失败时的自分配地址)' },
|
|
71
|
+
{ value: 'fe80::/10', note: 'IPv6 link-local' },
|
|
72
|
+
{ value: 'fc00::/7', note: 'IPv6 私网(ULA)' },
|
|
73
|
+
{ value: '224.0.0.0/4', note: 'IPv4 多播' },
|
|
74
|
+
{ value: '*.local', note: 'Bonjour / mDNS 本地域名' },
|
|
75
|
+
{ value: '*.lan', note: '常见内网域名后缀' },
|
|
76
|
+
{ value: '*.home.arpa', note: '家庭网保留后缀(RFC 8375)' },
|
|
77
|
+
{ value: '*.localdomain', note: '常见内网域名后缀' },
|
|
78
|
+
{ value: '*.internal', note: '常见内网域名后缀' },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/** 固定项的字面量列表(mergeBypass 用;与 BYPASS_DEFAULTS 同序) */
|
|
82
|
+
const BYPASS_VALUES = BYPASS_DEFAULTS.map((d) => d.value);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 现有绕过列表 + {@link BYPASS_DEFAULTS}:顺序保留,缺失的追加在后面(大小写不敏感去重)。
|
|
86
|
+
* @param {string[]} [current]
|
|
87
|
+
* @returns {{ list: string[], added: string[] }}
|
|
88
|
+
*/
|
|
89
|
+
export function mergeBypass(current = []) {
|
|
90
|
+
const list = current.map((s) => String(s));
|
|
91
|
+
const have = new Set(list.map((s) => s.trim().toLowerCase()));
|
|
92
|
+
const added = BYPASS_VALUES.filter((d) => !have.has(d.toLowerCase()));
|
|
93
|
+
return { list: [...list, ...added], added };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 地址/端口白名单校验:只有这些字符能出现在 argv 里 */
|
|
97
|
+
export function assertHostPort(host, port) {
|
|
98
|
+
const h = String(host ?? '').trim();
|
|
99
|
+
if (!/^[0-9A-Za-z.\-:[\]]+$/.test(h)) throw new Error(`代理地址不合法:${host}`);
|
|
100
|
+
const p = Number(port);
|
|
101
|
+
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error(`代理端口不合法:${port}`);
|
|
102
|
+
return { host: h, port: p };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `scutil --proxy` 输出解析(含 ExceptionsList 数组) */
|
|
106
|
+
export function parseScutilProxy(text) {
|
|
107
|
+
const scalars = {};
|
|
108
|
+
const bypass = [];
|
|
109
|
+
let inExceptions = false;
|
|
110
|
+
for (const raw of String(text).split('\n')) {
|
|
111
|
+
const line = raw.trim();
|
|
112
|
+
if (!line) continue;
|
|
113
|
+
if (/^ExceptionsList\s*:/.test(line)) {
|
|
114
|
+
inExceptions = true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (inExceptions) {
|
|
118
|
+
if (line === '}') {
|
|
119
|
+
inExceptions = false;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const item = line.match(/^\d+\s*:\s*(.+)$/);
|
|
123
|
+
if (item) bypass.push(item[1].trim());
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const kv = line.match(/^([A-Za-z]\w*)\s*:\s*(.*)$/);
|
|
127
|
+
if (kv) scalars[kv[1]] = kv[2].trim();
|
|
128
|
+
}
|
|
129
|
+
const isOn = (v) => v === '1' || v === 'true' || v === 'yes';
|
|
130
|
+
const protocols = {};
|
|
131
|
+
for (const p of MAC_PROTOCOLS) {
|
|
132
|
+
protocols[p.key] = {
|
|
133
|
+
enabled: isOn(scalars[p.enable]),
|
|
134
|
+
host: scalars[p.host] || '',
|
|
135
|
+
port: Number.parseInt(scalars[p.port] || '0', 10) || 0,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
protocols,
|
|
140
|
+
autoProxy: {
|
|
141
|
+
enabled: isOn(scalars.ProxyAutoConfigEnable),
|
|
142
|
+
url: scalars.ProxyAutoConfigURLString || null,
|
|
143
|
+
},
|
|
144
|
+
autoDiscovery: isOn(scalars.ProxyAutoDiscoveryEnable),
|
|
145
|
+
bypass,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `networksetup -listnetworkserviceorder` → [{ name, device }],顺序即优先级 */
|
|
150
|
+
export function parseServiceOrder(text) {
|
|
151
|
+
const lines = String(text).split('\n');
|
|
152
|
+
const services = [];
|
|
153
|
+
for (let i = 0; i < lines.length; i++) {
|
|
154
|
+
const head = lines[i].match(/^\(\d+\)\s+(.+?)\s*$/);
|
|
155
|
+
if (!head) continue;
|
|
156
|
+
const device = lines[i + 1]?.match(/Device:\s*([^)]+)\)/);
|
|
157
|
+
services.push({ name: head[1], device: device ? device[1].trim() : null });
|
|
158
|
+
}
|
|
159
|
+
return services;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** `route -n get default` → 默认路由的接口名(如 en0) */
|
|
163
|
+
export function parseDefaultInterface(text) {
|
|
164
|
+
const m = String(text).match(/^\s*interface:\s*(\S+)/m);
|
|
165
|
+
return m ? m[1] : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** macOS 实现:读 scutil --proxy,写 networksetup(只动活动服务) */
|
|
169
|
+
function macOSProxy(run) {
|
|
170
|
+
const net = (args) => run('networksetup', args);
|
|
171
|
+
const readState = async () => parseScutilProxy((await run('scutil', ['--proxy'])).stdout);
|
|
172
|
+
|
|
173
|
+
/** 当前活动网络服务:默认路由接口对应的那个,退而求其次用顺序表里第一个启用的 */
|
|
174
|
+
async function activeService() {
|
|
175
|
+
const order = parseServiceOrder((await net(['-listnetworkserviceorder'])).stdout);
|
|
176
|
+
const all = (await net(['-listallnetworkservices'])).stdout;
|
|
177
|
+
const enabled = new Set(
|
|
178
|
+
all
|
|
179
|
+
.split('\n')
|
|
180
|
+
.slice(1)
|
|
181
|
+
.map((l) => l.trim())
|
|
182
|
+
.filter((l) => l && !l.startsWith('*')),
|
|
183
|
+
);
|
|
184
|
+
const usable = order.filter((s) => enabled.has(s.name));
|
|
185
|
+
const iface = parseDefaultInterface((await run('route', ['-n', 'get', 'default'])).stdout);
|
|
186
|
+
const hit = usable.find((s) => s.device === iface) || usable[0];
|
|
187
|
+
return { name: hit?.name || null, device: hit?.device || null };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function read() {
|
|
191
|
+
const state = await readState();
|
|
192
|
+
const svc = await activeService();
|
|
193
|
+
return {
|
|
194
|
+
supported: true,
|
|
195
|
+
platform: 'darwin',
|
|
196
|
+
service: svc.name,
|
|
197
|
+
device: svc.device,
|
|
198
|
+
enabled:
|
|
199
|
+
state.protocols.http.enabled ||
|
|
200
|
+
state.protocols.https.enabled ||
|
|
201
|
+
state.protocols.socks.enabled,
|
|
202
|
+
protocols: state.protocols,
|
|
203
|
+
autoProxy: state.autoProxy,
|
|
204
|
+
autoDiscovery: state.autoDiscovery,
|
|
205
|
+
bypass: state.bypass,
|
|
206
|
+
hint: null,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function setEnabled(enabled, target = {}) {
|
|
211
|
+
const svc = await activeService();
|
|
212
|
+
if (!svc.name) throw new Error('没找到可用的网络服务(networksetup 没列出任何服务?)');
|
|
213
|
+
const before = await readState();
|
|
214
|
+
const applied = [];
|
|
215
|
+
/** @type {string[]} 本次补进绕过列表的项 */
|
|
216
|
+
let bypassAdded = [];
|
|
217
|
+
|
|
218
|
+
if (!enabled) {
|
|
219
|
+
for (const p of MAC_PROTOCOLS) {
|
|
220
|
+
if (!before.protocols[p.key].enabled) continue;
|
|
221
|
+
const r = await net([p.stateFlag, svc.name, 'off']);
|
|
222
|
+
if (!r.ok) throw new Error(`关闭 ${p.label} 代理失败:${r.stderr || r.status}`);
|
|
223
|
+
applied.push(`关闭 ${p.label}`);
|
|
224
|
+
}
|
|
225
|
+
if (before.autoProxy.enabled) {
|
|
226
|
+
await net(['-setautoproxystate', svc.name, 'off']);
|
|
227
|
+
applied.push('关闭 PAC(自动代理配置)');
|
|
228
|
+
}
|
|
229
|
+
if (before.autoDiscovery) {
|
|
230
|
+
await net(['-setproxyautodiscovery', svc.name, 'off']);
|
|
231
|
+
applied.push('关闭自动代理发现');
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
const { host, port } = assertHostPort(target.host || '127.0.0.1', target.port ?? 7890);
|
|
235
|
+
for (const p of MAC_PROTOCOLS) {
|
|
236
|
+
// -set*proxy 一次就把地址与端口写好并启用(不带认证参数即无认证)
|
|
237
|
+
const r = await net([p.setter, svc.name, host, String(port)]);
|
|
238
|
+
if (!r.ok) throw new Error(`设置 ${p.label} 代理失败:${r.stderr || r.status}`);
|
|
239
|
+
applied.push(`${p.label} → ${host}:${port}`);
|
|
240
|
+
}
|
|
241
|
+
// 固定绕过项:补上缺的(已有的一个不动,所以只写回「并集」)
|
|
242
|
+
const { list, added } = mergeBypass(before.bypass);
|
|
243
|
+
if (added.length) {
|
|
244
|
+
const r = await net(['-setproxybypassdomains', svc.name, ...list]);
|
|
245
|
+
if (!r.ok) throw new Error(`设置绕过列表失败:${r.stderr || r.status}`);
|
|
246
|
+
applied.push(`绕过列表 +${added.length} 项`);
|
|
247
|
+
}
|
|
248
|
+
bypassAdded = added;
|
|
249
|
+
}
|
|
250
|
+
return { service: svc.name, applied, bypassAdded, state: await read() };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { read, setEnabled };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** GNOME 实现:gsettings(用户级,不需要 root) */
|
|
257
|
+
function linuxProxy(run) {
|
|
258
|
+
const gs = (args) => run('gsettings', args);
|
|
259
|
+
const unquote = (v) =>
|
|
260
|
+
String(v ?? '')
|
|
261
|
+
.trim()
|
|
262
|
+
.replace(/^'(.*)'$/, '$1');
|
|
263
|
+
|
|
264
|
+
/** @returns {Promise<{ ok: boolean, value: string, stderr?: string }>} */
|
|
265
|
+
async function get(key) {
|
|
266
|
+
const r = await gs(['get', GNOME_PROXY, ...key.split(' ')]);
|
|
267
|
+
return { ok: r.ok, value: unquote(r.stdout), stderr: r.stderr };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function read() {
|
|
271
|
+
const mode = await get('mode');
|
|
272
|
+
if (!mode.ok) {
|
|
273
|
+
return {
|
|
274
|
+
supported: false,
|
|
275
|
+
platform: 'linux',
|
|
276
|
+
hint: '没找到 gsettings(非 GNOME 桌面):请用桌面环境自己的代理开关,或手工设 http_proxy 环境变量',
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const protocols = {};
|
|
280
|
+
for (const key of ['http', 'https', 'socks']) {
|
|
281
|
+
const host = await get(`${key} host`);
|
|
282
|
+
const port = await get(`${key} port`);
|
|
283
|
+
protocols[key] = {
|
|
284
|
+
enabled: mode.value === 'manual',
|
|
285
|
+
host: host.value,
|
|
286
|
+
port: Number.parseInt(port.value, 10) || 0,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const ignore = await get('ignore-hosts');
|
|
290
|
+
return {
|
|
291
|
+
supported: true,
|
|
292
|
+
platform: 'linux',
|
|
293
|
+
service: 'GNOME',
|
|
294
|
+
device: null,
|
|
295
|
+
enabled: mode.value === 'manual' || mode.value === 'auto',
|
|
296
|
+
mode: mode.value,
|
|
297
|
+
protocols,
|
|
298
|
+
autoProxy: { enabled: mode.value === 'auto', url: null },
|
|
299
|
+
autoDiscovery: false,
|
|
300
|
+
bypass: (ignore.value.match(/'([^']*)'/g) || []).map(unquote),
|
|
301
|
+
hint: null,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function setEnabled(enabled, target = {}) {
|
|
306
|
+
const state = await read();
|
|
307
|
+
if (!state.supported) throw new Error(state.hint);
|
|
308
|
+
const applied = [];
|
|
309
|
+
/** @type {string[]} 本次补进绕过列表的项 */
|
|
310
|
+
let bypassAdded = [];
|
|
311
|
+
if (!enabled) {
|
|
312
|
+
if (state.enabled) {
|
|
313
|
+
const r = await gs(['set', GNOME_PROXY, 'mode', 'none']);
|
|
314
|
+
if (!r.ok) throw new Error(`关闭系统代理失败:${r.stderr || r.status}`);
|
|
315
|
+
applied.push('mode → none');
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
const { host, port } = assertHostPort(target.host || '127.0.0.1', target.port ?? 7890);
|
|
319
|
+
for (const key of ['http', 'https', 'socks']) {
|
|
320
|
+
await gs(['set', `${GNOME_PROXY}.${key}`, 'host', host]);
|
|
321
|
+
await gs(['set', `${GNOME_PROXY}.${key}`, 'port', String(port)]);
|
|
322
|
+
}
|
|
323
|
+
// 固定绕过项(ignore-hosts):补上缺的,已有的不动
|
|
324
|
+
const { list, added } = mergeBypass(state.bypass);
|
|
325
|
+
if (added.length) {
|
|
326
|
+
const r = await gs(['set', GNOME_PROXY, 'ignore-hosts', gvariantList(list)]);
|
|
327
|
+
if (!r.ok) throw new Error(`设置绕过列表失败:${r.stderr || r.status}`);
|
|
328
|
+
applied.push(`绕过列表 +${added.length} 项`);
|
|
329
|
+
bypassAdded = added;
|
|
330
|
+
}
|
|
331
|
+
const r = await gs(['set', GNOME_PROXY, 'mode', 'manual']);
|
|
332
|
+
if (!r.ok) throw new Error(`开启系统代理失败:${r.stderr || r.status}`);
|
|
333
|
+
applied.push(`mode → manual(http/https/socks → ${host}:${port})`);
|
|
334
|
+
}
|
|
335
|
+
return { service: state.service, applied, bypassAdded, state: await read() };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return { read, setEnabled };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 不支持的平台:读返回提示,写直接拒 */
|
|
342
|
+
function unsupportedProxy(platform) {
|
|
343
|
+
const hint =
|
|
344
|
+
platform === 'win32'
|
|
345
|
+
? 'Windows 没有统一的系统代理命令行接口:请用「Internet 选项 → 连接 → 局域网设置」,或用代理客户端自己的开关'
|
|
346
|
+
: `暂不支持在 ${platform} 上改系统代理`;
|
|
347
|
+
return {
|
|
348
|
+
async read() {
|
|
349
|
+
return { supported: false, platform, hint };
|
|
350
|
+
},
|
|
351
|
+
async setEnabled() {
|
|
352
|
+
throw new Error(hint);
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** 逗号拼接前对单个值加引号并转义(gsettings 的 GVariant 字符串) */
|
|
358
|
+
const gvariantItem = (v) => `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
359
|
+
|
|
360
|
+
/** 字符串数组 → gsettings 吃的 GVariant:`['a', 'b']` */
|
|
361
|
+
function gvariantList(items) {
|
|
362
|
+
return `[${items.map(gvariantItem).join(', ')}]`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* @param {{ run?: typeof defaultRun, platform?: string }} [options]
|
|
367
|
+
* @returns {{ read: () => Promise<Record<string, any>>, setEnabled: (enabled: boolean, target?: { host?: string, port?: number }) => Promise<{ service: string|null, applied: string[], bypassAdded: string[], state: Record<string, any> }> }}
|
|
368
|
+
*/
|
|
369
|
+
export function createSystemProxy(options = {}) {
|
|
370
|
+
const run = options.run || defaultRun;
|
|
371
|
+
const platform = options.platform || process.platform;
|
|
372
|
+
if (platform === 'darwin') return macOSProxy(run);
|
|
373
|
+
if (platform === 'linux') return linuxProxy(run);
|
|
374
|
+
return unsupportedProxy(platform);
|
|
375
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** 终端文本对齐:中文按 2 列宽算、纯 ASCII 按 1 列(`doctor` 与 `status` 共用)。 */
|
|
2
|
+
|
|
3
|
+
/** 显示宽度:CJK / 全角按 2 列,其余按 1 列 */
|
|
4
|
+
export function displayWidth(text) {
|
|
5
|
+
let width = 0;
|
|
6
|
+
for (const ch of text) width += ch >= '\u1100' && ch <= '\uffe6' ? 2 : 1;
|
|
7
|
+
return width;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** 右侧补空格到指定显示宽度(超长原样返回,不截断) */
|
|
11
|
+
export const pad = (text, width) => text + ' '.repeat(Math.max(0, width - displayWidth(text)));
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mihomo-dash service` 的 CLI 层测试:只验参数分发与输出,
|
|
3
|
+
* 平台实现(launchd / systemd)由 service.test.js 覆盖,这里用桩 manager 注入。
|
|
4
|
+
*/
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { cmdService } from '../src/cli/service.js';
|
|
8
|
+
|
|
9
|
+
/** 记录输出、替掉 manager;exit 用抛错模拟(cmdService 里 process.exit 不好在进程内测) */
|
|
10
|
+
function harness(overrides = {}) {
|
|
11
|
+
const lines = [];
|
|
12
|
+
const calls = [];
|
|
13
|
+
const manager = {
|
|
14
|
+
platform: 'darwin',
|
|
15
|
+
dir: '/tmp/LaunchAgents',
|
|
16
|
+
render: (job, opts) => {
|
|
17
|
+
calls.push(['render', job, opts.port]);
|
|
18
|
+
return `[${job}] entry=${job === 'panel' ? 'server/src/index.js' : 'server/src/kernel/run.js'}`;
|
|
19
|
+
},
|
|
20
|
+
install: async (opts) => {
|
|
21
|
+
calls.push(['install', opts.manualRunning]);
|
|
22
|
+
return {
|
|
23
|
+
supported: true,
|
|
24
|
+
files: ['/tmp/LaunchAgents/a.plist', '/tmp/LaunchAgents/b.plist'],
|
|
25
|
+
actions: ['launchctl bootstrap gui/501 a.plist'],
|
|
26
|
+
notes: [],
|
|
27
|
+
errors: [],
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
uninstall: async () => {
|
|
31
|
+
calls.push(['uninstall']);
|
|
32
|
+
return {
|
|
33
|
+
supported: true,
|
|
34
|
+
removed: ['/tmp/LaunchAgents/a.plist'],
|
|
35
|
+
actions: [],
|
|
36
|
+
notes: [],
|
|
37
|
+
errors: [],
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
status: async () => {
|
|
41
|
+
calls.push(['status']);
|
|
42
|
+
return {
|
|
43
|
+
supported: true,
|
|
44
|
+
platform: 'linux',
|
|
45
|
+
dir: '/home/u/.config/systemd/user',
|
|
46
|
+
jobs: [
|
|
47
|
+
{ job: 'panel', installed: true, loaded: true, running: true, pid: 42, file: '/a' },
|
|
48
|
+
{ job: 'kernel', installed: false, loaded: false, running: false, pid: 0, file: '/b' },
|
|
49
|
+
],
|
|
50
|
+
linger: null,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
return { manager, lines, calls, out: (line) => lines.push(line) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test('print:默认打两个 job,可只打一个', async () => {
|
|
59
|
+
const h = harness();
|
|
60
|
+
await cmdService(['print'], {}, { manager: h.manager, out: h.out });
|
|
61
|
+
assert.deepEqual(h.calls, [
|
|
62
|
+
['render', 'panel', 8981],
|
|
63
|
+
['render', 'kernel', 8981],
|
|
64
|
+
]);
|
|
65
|
+
assert.match(h.lines.join('\n'), /\[panel\] entry=server\/src\/index\.js/);
|
|
66
|
+
|
|
67
|
+
const one = harness();
|
|
68
|
+
await cmdService(['print', 'kernel'], { port: '9000' }, { manager: one.manager, out: one.out });
|
|
69
|
+
assert.deepEqual(one.calls, [['render', 'kernel', 9000]]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('print:未知 job 返回 2,且不渲染', async () => {
|
|
73
|
+
const h = harness();
|
|
74
|
+
assert.equal(await cmdService(['print', 'nope'], {}, { manager: h.manager, out: h.out }), 2);
|
|
75
|
+
assert.equal(h.calls.length, 0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('install:把正在跑的 job 传给 manager,并打印写入的文件', async () => {
|
|
79
|
+
const h = harness();
|
|
80
|
+
await cmdService(['install'], {}, { manager: h.manager, out: h.out });
|
|
81
|
+
// 探测的是真实面板端口:本机有没有面板在跑都会影响这个列表,所以只断言「传了数组」
|
|
82
|
+
assert.equal(h.calls[0][0], 'install');
|
|
83
|
+
assert.ok(Array.isArray(h.calls[0][1]));
|
|
84
|
+
assert.match(h.lines.join('\n'), /已写入 \/tmp\/LaunchAgents\/a\.plist/);
|
|
85
|
+
assert.match(h.lines.join('\n'), /只开机拉起一次、不保活/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('uninstall:打印删除的文件', async () => {
|
|
89
|
+
const h = harness();
|
|
90
|
+
await cmdService(['uninstall'], {}, { manager: h.manager, out: h.out });
|
|
91
|
+
assert.deepEqual(h.calls, [['uninstall']]);
|
|
92
|
+
assert.match(h.lines.join('\n'), /已删除 \/tmp\/LaunchAgents\/a\.plist/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('status:逐 job 打印安装/运行状态;Linux 提示 linger', async () => {
|
|
96
|
+
const h = harness();
|
|
97
|
+
await cmdService(['status'], {}, { manager: h.manager, out: h.out });
|
|
98
|
+
const text = h.lines.join('\n');
|
|
99
|
+
assert.match(text, /panel\s+已安装 · running · pid 42/);
|
|
100
|
+
assert.match(text, /kernel\s+未安装 · not loaded/);
|
|
101
|
+
assert.match(text, /linger 未开启/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('不支持平台:提示原因并返回非 0(不静默成功)', async () => {
|
|
105
|
+
const h = harness({
|
|
106
|
+
install: async () => ({ supported: false, hint: '暂不支持在 win32 上装开机自启' }),
|
|
107
|
+
});
|
|
108
|
+
const code = await cmdService(['install'], {}, { manager: h.manager, out: h.out });
|
|
109
|
+
assert.equal(code, 1);
|
|
110
|
+
assert.match(h.lines.join('\n'), /本平台不支持:暂不支持在 win32 上装开机自启/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('未知子命令返回 2,且不碰 manager', async () => {
|
|
114
|
+
const h = harness();
|
|
115
|
+
assert.equal(await cmdService(['nope'], {}, { manager: h.manager, out: h.out }), 2);
|
|
116
|
+
assert.equal(h.calls.length, 0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('--system 与 --user 同时给:返回 2,不碰 manager', async () => {
|
|
120
|
+
const h = harness();
|
|
121
|
+
const code = await cmdService(
|
|
122
|
+
['install'],
|
|
123
|
+
{ system: true, user: true },
|
|
124
|
+
{
|
|
125
|
+
manager: h.manager,
|
|
126
|
+
out: h.out,
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
assert.equal(code, 2);
|
|
130
|
+
assert.equal(h.calls.length, 0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('status 打印作用域;用户级才提示 linger', async () => {
|
|
134
|
+
const h = harness();
|
|
135
|
+
await cmdService(['status'], {}, { manager: h.manager, out: h.out });
|
|
136
|
+
// 桩 status 没给 scope → CLI 按用户级处理(并据此提示 linger)
|
|
137
|
+
assert.match(h.lines.join('\n'), /作用域:user/);
|
|
138
|
+
assert.match(h.lines.join('\n'), /linger 未开启/);
|
|
139
|
+
|
|
140
|
+
const sys = harness({
|
|
141
|
+
status: async () => ({
|
|
142
|
+
supported: true,
|
|
143
|
+
platform: 'linux',
|
|
144
|
+
scope: 'system',
|
|
145
|
+
dir: '/etc/systemd/system',
|
|
146
|
+
jobs: [{ job: 'panel', installed: true, loaded: true, running: true, pid: 1, file: '/x' }],
|
|
147
|
+
linger: null,
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
await cmdService(['status'], {}, { manager: sys.manager, out: sys.out });
|
|
151
|
+
assert.match(sys.lines.join('\n'), /作用域:system/);
|
|
152
|
+
assert.doesNotMatch(sys.lines.join('\n'), /linger/);
|
|
153
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 内核配置目录的「钉死」行为(server/src/shared/paths.js)与相关端点测试。
|
|
3
|
+
*
|
|
4
|
+
* 2026-11 定:`MIHOMO_DIR` 显式覆盖 > 面板自有落点 `MIHOMO_DASH_DIR/data`;**不再扫**
|
|
5
|
+
* ~/.config/mihomo、/etc/mihomo、/opt/homebrew/etc/mihomo 这些地方(以前是候选表第一个含
|
|
6
|
+
* config.yaml 的胜出,卸载自有内核后面板会悄悄转去管别处的配置)。配置文件页据此在「还没有
|
|
7
|
+
* config.yaml」时给提示,而不是显示别人的配置(默认配置只在首次安装内核时写)。
|
|
8
|
+
* 运行:npm test
|
|
9
|
+
*/
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { after, before, test } from 'node:test';
|
|
12
|
+
import express from 'express';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import http from 'node:http';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** @type {Record<string, any>} */ let mods = {};
|
|
19
|
+
/** @type {string} */ let tmpDir;
|
|
20
|
+
/** @type {string} */ let cliDir;
|
|
21
|
+
/** @type {string} */ let cfgDir;
|
|
22
|
+
/** @type {import('node:http').Server} */ let kernel;
|
|
23
|
+
/** @type {import('node:http').Server} */ let server;
|
|
24
|
+
/** @type {string} */ let base;
|
|
25
|
+
/** @type {number} */ let stubPort = 0;
|
|
26
|
+
|
|
27
|
+
/** 桩内核:只响应 PUT /configs?force=true(热重载) */
|
|
28
|
+
before(async () => {
|
|
29
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mihomo-dash-cfgdir-'));
|
|
30
|
+
cliDir = path.join(tmpDir, 'cli');
|
|
31
|
+
cfgDir = path.join(tmpDir, 'mihomo');
|
|
32
|
+
kernel = http.createServer((_req, res) => {
|
|
33
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
34
|
+
res.end('{}');
|
|
35
|
+
});
|
|
36
|
+
await new Promise((r) => kernel.listen(0, '127.0.0.1', r));
|
|
37
|
+
stubPort = kernel.address().port;
|
|
38
|
+
|
|
39
|
+
process.env.MIHOMO_DASH_DIR = cliDir;
|
|
40
|
+
process.env.MIHOMO_DIR = cfgDir;
|
|
41
|
+
mods.paths = await import('../src/shared/paths.js');
|
|
42
|
+
mods.files = await import('../src/http/config-files.js');
|
|
43
|
+
|
|
44
|
+
const app = express();
|
|
45
|
+
app.use(express.json());
|
|
46
|
+
// 只挂「配置文件」页用到的那几条(其余端点与本文件无关)
|
|
47
|
+
app.get('/api/file', (req, res) => {
|
|
48
|
+
try {
|
|
49
|
+
res.json(mods.files.readFileInfo(String(req.query.name || '')));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
res.status(400).json({ error: String(err?.message || err) });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
app.put('/api/file', (req, res) => {
|
|
55
|
+
try {
|
|
56
|
+
mods.files.saveFile(String(req.body?.name ?? ''), String(req.body?.content ?? ''));
|
|
57
|
+
res.json({ ok: true });
|
|
58
|
+
} catch (err) {
|
|
59
|
+
res.status(400).json({ error: String(err?.message || err) });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
server = app.listen(0, '127.0.0.1');
|
|
63
|
+
await new Promise((r) => server.once('listening', r));
|
|
64
|
+
base = `http://127.0.0.1:${server.address().port}`;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
after(async () => {
|
|
68
|
+
await new Promise((r) => server.close(r));
|
|
69
|
+
await new Promise((r) => kernel.close(r));
|
|
70
|
+
delete process.env.MIHOMO_DIR;
|
|
71
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('不设 MIHOMO_DIR:配置目录就是面板状态目录下的 data/(不存在也照返回,不去扫别处)', () => {
|
|
75
|
+
delete process.env.MIHOMO_DIR;
|
|
76
|
+
mods.paths.resetMihomoDirCache();
|
|
77
|
+
try {
|
|
78
|
+
const dir = mods.paths.detectMihomoDir();
|
|
79
|
+
assert.equal(dir, path.join(cliDir, 'data'));
|
|
80
|
+
assert.equal(mods.paths.configPath(), path.join(cliDir, 'data', 'config.yaml'));
|
|
81
|
+
// 老候选表里的目录一个都不该被选中
|
|
82
|
+
for (const old of [
|
|
83
|
+
'/etc/mihomo',
|
|
84
|
+
'/opt/homebrew/etc/mihomo',
|
|
85
|
+
path.join(os.homedir(), '.config', 'mihomo'),
|
|
86
|
+
]) {
|
|
87
|
+
assert.notEqual(dir, old);
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
mods.paths.resetMihomoDirCache();
|
|
91
|
+
process.env.MIHOMO_DIR = cfgDir;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('设了 MIHOMO_DIR:就用它,且不回退(目录里没有 config.yaml 也认)', () => {
|
|
96
|
+
const elsewhere = path.join(tmpDir, 'elsewhere');
|
|
97
|
+
process.env.MIHOMO_DIR = elsewhere;
|
|
98
|
+
mods.paths.resetMihomoDirCache();
|
|
99
|
+
try {
|
|
100
|
+
assert.equal(mods.paths.detectMihomoDir(), elsewhere);
|
|
101
|
+
assert.equal(mods.paths.configPath(), path.join(elsewhere, 'config.yaml'));
|
|
102
|
+
} finally {
|
|
103
|
+
process.env.MIHOMO_DIR = cfgDir;
|
|
104
|
+
mods.paths.resetMihomoDirCache();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('读文件:不存在不算错 —— 返回 exists:false + 空内容 + 路径', async () => {
|
|
109
|
+
const got = await (await fetch(`${base}/api/file?name=config.yaml`)).json();
|
|
110
|
+
assert.equal(got.exists, false);
|
|
111
|
+
assert.equal(got.content, '');
|
|
112
|
+
assert.equal(got.path, path.join(cfgDir, 'config.yaml'));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('写盘会自动建目录(配置目录被删掉之后也能直接保存)', async () => {
|
|
116
|
+
const dir = path.join(cfgDir, 'providers');
|
|
117
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
118
|
+
const res = await fetch(`${base}/api/file`, {
|
|
119
|
+
method: 'PUT',
|
|
120
|
+
headers: { 'content-type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({ name: 'providers/sub.yaml', content: 'proxies: []\n' }),
|
|
122
|
+
});
|
|
123
|
+
assert.equal(res.status, 200);
|
|
124
|
+
assert.equal(fs.readFileSync(path.join(dir, 'sub.yaml'), 'utf8'), 'proxies: []\n');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('MIHOMO_DIR 覆盖下的那份配置能被正常读写', () => {
|
|
128
|
+
fs.mkdirSync(cfgDir, { recursive: true });
|
|
129
|
+
const file = mods.paths.configPath();
|
|
130
|
+
fs.writeFileSync(file, `external-controller: 127.0.0.1:${stubPort}\nmixed-port: 7890\n`);
|
|
131
|
+
assert.equal(file, path.join(cfgDir, 'config.yaml'));
|
|
132
|
+
assert.match(mods.files.readFile('config.yaml'), /mixed-port: 7890/);
|
|
133
|
+
});
|