@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,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 规则页的两块内容(都落 config.yaml 的 `rules` 段,写盘 → 热重载):
|
|
3
|
+
*
|
|
4
|
+
* 1. **自定义规则**:`# >>> mihomo-dash 自定义规则` 标记块里的条目,面板只允许新增 `DOMAIN-SUFFIX`
|
|
5
|
+
* (2026-11 定:曾经放开过任意类型,随后按用户要求收回;GEOSITE / IP-CIDR 这类去「配置文件」页写)。
|
|
6
|
+
* 支持新增与删除(都不再提供排序),新规则插到第一条 `MATCH`/`FINAL` 之前。
|
|
7
|
+
* 2. **系统规则**({@link SYSTEM_RULES}):固定那几条 GEOSITE + 末尾 `MATCH`,卡片上逐条启 / 禁、并能选目标。
|
|
8
|
+
* 禁用的写法是**把该行注释掉**(内容留着,随时能再启用),因此解析结果里它就消失了 —— 这就是「热重载后不再生效」。
|
|
9
|
+
* `MATCH` 是兜底规则,不给开关(禁掉会让未匹配的流量没处去)。
|
|
10
|
+
*
|
|
11
|
+
* 两处改写都保留注释与其它段落:文本级定位 + `verifyRewrite` 校验「解析结果与预期一致」后才写盘。
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import { configPath } from '../shared/paths.js';
|
|
15
|
+
import { saveConfigWithReload } from './config-files.js';
|
|
16
|
+
import {
|
|
17
|
+
findSection,
|
|
18
|
+
indentOf,
|
|
19
|
+
listIndent,
|
|
20
|
+
listSection,
|
|
21
|
+
loadConfig,
|
|
22
|
+
normalize,
|
|
23
|
+
openEmptyFlowSection,
|
|
24
|
+
removeRange,
|
|
25
|
+
scalarItemOf,
|
|
26
|
+
sectionEnd,
|
|
27
|
+
verifyRewrite,
|
|
28
|
+
} from './config-sections.js';
|
|
29
|
+
|
|
30
|
+
const SECTION = 'rules';
|
|
31
|
+
/** 标记块:面板在这里增删 / 排序;块外规则只读不动 */
|
|
32
|
+
const BEGIN_MARK = '# >>> mihomo-dash 自定义规则';
|
|
33
|
+
const BEGIN_LINE = `${BEGIN_MARK}(这块由面板维护,请通过规则页增删排序)`;
|
|
34
|
+
const END_MARK = '# <<< mihomo-dash 自定义规则';
|
|
35
|
+
/** DOMAIN-SUFFIX / DOMAIN 的域名:至少两级标签,支持 `+.example.com`(含子域)写法 */
|
|
36
|
+
const DOMAIN_RE = /^(?:\+\.)?(?:[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?\.)+[a-z]{2,}$/i;
|
|
37
|
+
/** 没有参数的规则类型:`MATCH,target` / `FINAL,target` */
|
|
38
|
+
const NO_PAYLOAD = new Set(['MATCH', 'FINAL']);
|
|
39
|
+
/** 面板唯一允许新增的规则类型 */
|
|
40
|
+
const ONLY_TYPE = 'DOMAIN-SUFFIX';
|
|
41
|
+
/** 末尾兜底型规则:新规则一律插到它们之前 */
|
|
42
|
+
const CATCH_ALL = new Set(['MATCH', 'FINAL']);
|
|
43
|
+
const TYPE_RE = /^[A-Z][A-Z0-9-]{0,31}$/;
|
|
44
|
+
|
|
45
|
+
/** 校验并规范化域名(小写、去空白) */
|
|
46
|
+
export function normalizeDomain(raw) {
|
|
47
|
+
const d = String(raw ?? '')
|
|
48
|
+
.trim()
|
|
49
|
+
.toLowerCase();
|
|
50
|
+
if (!d) throw new Error('域名不能为空');
|
|
51
|
+
if (!DOMAIN_RE.test(d)) {
|
|
52
|
+
throw new Error(`域名不合法:${raw}(示例 example.com,含子域可用 +.example.com)`);
|
|
53
|
+
}
|
|
54
|
+
return d;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 校验规则目标(策略组名 / DIRECT / REJECT 等) */
|
|
58
|
+
function assertTarget(raw) {
|
|
59
|
+
const t = String(raw ?? '').trim();
|
|
60
|
+
if (!t) throw new Error('目标不能为空(策略组 / DIRECT / REJECT)');
|
|
61
|
+
if (/[\n\r,#]/.test(t)) throw new Error('目标不能包含 , # 或换行');
|
|
62
|
+
if (t.length > 64) throw new Error('目标过长(≤64 字符)');
|
|
63
|
+
return t;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 拆解规则行 `TYPE,参数,目标[,其它参数…]`。
|
|
68
|
+
* `MATCH` / `FINAL` 没有参数(`MATCH,DIRECT`),参数位为空串。
|
|
69
|
+
* @param {string} raw
|
|
70
|
+
* @returns {{ type: string, payload: string, target: string, params: string[] }|null} 结构不足时 null
|
|
71
|
+
*/
|
|
72
|
+
export function parseRuleLine(raw) {
|
|
73
|
+
const parts = String(raw ?? '')
|
|
74
|
+
.split(',')
|
|
75
|
+
.map((s) => s.trim());
|
|
76
|
+
const type = (parts[0] || '').toUpperCase();
|
|
77
|
+
if (!TYPE_RE.test(type)) return null;
|
|
78
|
+
if (NO_PAYLOAD.has(type)) {
|
|
79
|
+
if (parts.length < 2 || !parts[1]) return null;
|
|
80
|
+
return { type, payload: '', target: parts[1], params: parts.slice(2) };
|
|
81
|
+
}
|
|
82
|
+
if (parts.length < 3 || !parts[1] || !parts[2]) return null;
|
|
83
|
+
return { type, payload: parts[1], target: parts[2], params: parts.slice(3) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 把解析结果拼回一行(`params` 原样跟在后面,`no-resolve` 这类写法不会被吃掉) */
|
|
87
|
+
export function ruleLine({ type, payload, target, params = [] }) {
|
|
88
|
+
return [type, payload, target, ...params].filter((s) => s !== '' && s != null).join(',');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 校验并规范化一条要新增的规则:**只认 `DOMAIN-SUFFIX`**。
|
|
93
|
+
* 可以给 `type` / `payload` / `target`,也可以直接给整行原文 `rule`(如 `DOMAIN-SUFFIX,a.com,DIRECT`)。
|
|
94
|
+
* @param {{ rule?: string, type?: string, payload?: string, target?: string }} input
|
|
95
|
+
* @returns {{ type: string, payload: string, target: string, params: string[], rule: string }}
|
|
96
|
+
*/
|
|
97
|
+
export function normalizeRule(input) {
|
|
98
|
+
const raw = String(input?.rule ?? '').trim();
|
|
99
|
+
let parsed;
|
|
100
|
+
if (raw) {
|
|
101
|
+
parsed = parseRuleLine(raw);
|
|
102
|
+
if (!parsed) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`规则格式不对:${raw}(应为 ${ONLY_TYPE},域名,目标,例如 ${ONLY_TYPE},example.com,DIRECT)`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
const type = String(input?.type ?? ONLY_TYPE)
|
|
109
|
+
.trim()
|
|
110
|
+
.toUpperCase();
|
|
111
|
+
parsed = {
|
|
112
|
+
type: type || ONLY_TYPE,
|
|
113
|
+
payload: String(input?.payload ?? '').trim(),
|
|
114
|
+
target: String(input?.target ?? '').trim(),
|
|
115
|
+
params: [],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (parsed.type !== ONLY_TYPE) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`面板只加 ${ONLY_TYPE} 规则(收到 ${parsed.type || '空类型'}):GEOSITE / IP-CIDR 这类请去「配置文件」页写`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
parsed.payload = normalizeDomain(parsed.payload);
|
|
124
|
+
parsed.target = assertTarget(parsed.target);
|
|
125
|
+
return { ...parsed, rule: ruleLine(parsed) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const isBegin = (line) => line.trim().startsWith(BEGIN_MARK);
|
|
129
|
+
const isEnd = (line) => line.trim().startsWith(END_MARK);
|
|
130
|
+
|
|
131
|
+
/** 定位标记块 [begin, end](含两行标记);缺少任一标记时返回 null */
|
|
132
|
+
function findBlock(lines) {
|
|
133
|
+
const begin = lines.findIndex(isBegin);
|
|
134
|
+
if (begin < 0) return null;
|
|
135
|
+
for (let i = begin + 1; i < lines.length; i++) {
|
|
136
|
+
if (isEnd(lines[i])) return { begin, end: i };
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 标记块内的标量条目(带所在行号) */
|
|
142
|
+
function blockItems(lines, block) {
|
|
143
|
+
const items = [];
|
|
144
|
+
for (let i = block.begin + 1; i < block.end; i++) {
|
|
145
|
+
const raw = scalarItemOf(lines[i]);
|
|
146
|
+
if (raw !== null) items.push({ index: i, raw });
|
|
147
|
+
}
|
|
148
|
+
return items;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** [sectionLine+1, until) 之间的条目数:新条目 / 被删条目在解析结果里的下标 */
|
|
152
|
+
function countItems(lines, sectionLine, until) {
|
|
153
|
+
let n = 0;
|
|
154
|
+
for (let i = sectionLine + 1; i < until; i++) if (scalarItemOf(lines[i]) !== null) n++;
|
|
155
|
+
return n;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 读取面板管理的规则(标记块内全部条目,按文件里的顺序)。
|
|
160
|
+
* @returns {{ exists: boolean, rules: Array<{ raw: string, type: string, payload: string, target: string }>, total: number }}
|
|
161
|
+
*/
|
|
162
|
+
export function listCustomRules() {
|
|
163
|
+
const { text, doc } = loadConfigOrEmpty();
|
|
164
|
+
const lines = text.split('\n');
|
|
165
|
+
const block = findBlock(lines);
|
|
166
|
+
const rules = [];
|
|
167
|
+
if (block) {
|
|
168
|
+
for (const { raw } of blockItems(lines, block)) {
|
|
169
|
+
const p = parseRuleLine(raw);
|
|
170
|
+
rules.push(
|
|
171
|
+
p
|
|
172
|
+
? { raw, type: p.type, payload: p.payload, target: p.target }
|
|
173
|
+
: { raw, type: '', payload: '', target: '' },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { exists: !!block, rules, total: listSection(doc, SECTION).length };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 新增规则:写入标记块(无块时在 rules 段首创建)并热重载。
|
|
182
|
+
* 插入位置 = 第一条 `MATCH` / `FINAL` 之前(那之后写什么都不会生效),没有就追加到末尾。
|
|
183
|
+
* @param {{ rule?: string, type?: string, payload?: string, target?: string }} input
|
|
184
|
+
*/
|
|
185
|
+
export async function addRule(input) {
|
|
186
|
+
const { type, payload, rule } = normalizeRule(input);
|
|
187
|
+
|
|
188
|
+
const { text, doc } = loadConfig();
|
|
189
|
+
const list = listSection(doc, SECTION);
|
|
190
|
+
const lines = text.split('\n');
|
|
191
|
+
const at = findSection(lines, SECTION);
|
|
192
|
+
if (at < 0) throw new Error('config.yaml 中未找到 rules 段');
|
|
193
|
+
|
|
194
|
+
// 同类型同参数只允许一条(目标不同也算重复),否则顺序与去重都说不清
|
|
195
|
+
const dup = list.findIndex((r) => {
|
|
196
|
+
const p = parseRuleLine(r);
|
|
197
|
+
return p && p.type === type && p.payload.toLowerCase() === payload.toLowerCase();
|
|
198
|
+
});
|
|
199
|
+
if (dup >= 0) {
|
|
200
|
+
throw new Error(`规则已存在:第 ${dup + 1} 条 ${String(list[dup]).trim()}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
openEmptyFlowSection(lines, at, SECTION); // 兼容 `rules: []`
|
|
204
|
+
const block = findBlock(lines);
|
|
205
|
+
const pad = ' '.repeat(block ? indentOf(lines[block.begin]) : listIndent(lines, at));
|
|
206
|
+
// 没有块时先建块(两行标记),块建在 rules 段首
|
|
207
|
+
const blockLines = block ? [] : [`${pad}${BEGIN_LINE}`, `${pad}${END_MARK}`];
|
|
208
|
+
if (blockLines.length) lines.splice(at + 1, 0, ...blockLines);
|
|
209
|
+
|
|
210
|
+
const target = findBlock(lines);
|
|
211
|
+
const items = blockItems(lines, target);
|
|
212
|
+
const firstCatchAll = items.find((it) => {
|
|
213
|
+
const p = parseRuleLine(it.raw);
|
|
214
|
+
return p && CATCH_ALL.has(p.type);
|
|
215
|
+
});
|
|
216
|
+
const insertPos = firstCatchAll ? firstCatchAll.index : target.end;
|
|
217
|
+
const out = [...lines.slice(0, insertPos), `${pad}- ${rule}`, ...lines.slice(insertPos)];
|
|
218
|
+
|
|
219
|
+
const before = countItems(lines, at, insertPos); // 新条目在 rules 列表中的下标
|
|
220
|
+
const next = normalize(out);
|
|
221
|
+
verifyRewrite(next, {
|
|
222
|
+
...doc,
|
|
223
|
+
[SECTION]: [...list.slice(0, before), rule, ...list.slice(before)],
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const reload = await saveConfigWithReload(next);
|
|
227
|
+
return { ok: true, rule, insertedBefore: firstCatchAll ? firstCatchAll.raw : null, ...reload };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 删除规则:只从标记块内移除该行(块外的手工规则不动);块内最后一条被删时标记一并移除。
|
|
232
|
+
* @param {{ raw?: string }} input 规则原文(`raw` 精确匹配,两边空白忽略)
|
|
233
|
+
*/
|
|
234
|
+
export async function removeRule(input) {
|
|
235
|
+
const raw = String(input?.raw ?? '').trim();
|
|
236
|
+
if (!raw) throw new Error('缺少要删除的规则(raw)');
|
|
237
|
+
|
|
238
|
+
const { text, doc } = loadConfig();
|
|
239
|
+
const list = listSection(doc, SECTION);
|
|
240
|
+
const lines = text.split('\n');
|
|
241
|
+
const at = findSection(lines, SECTION);
|
|
242
|
+
if (at < 0) throw new Error('config.yaml 中未找到 rules 段');
|
|
243
|
+
|
|
244
|
+
const block = findBlock(lines);
|
|
245
|
+
if (!block) throw new Error('config.yaml 中没有面板管理的规则块');
|
|
246
|
+
|
|
247
|
+
const items = blockItems(lines, block);
|
|
248
|
+
const hit = items.find(({ raw: line }) => line.trim() === raw);
|
|
249
|
+
if (!hit) throw new Error(`面板管理的规则块中未找到 ${raw}`);
|
|
250
|
+
|
|
251
|
+
const before = countItems(lines, at, hit.index); // 该条目在 rules 列表中的下标
|
|
252
|
+
if (String(list[before] ?? '').trim() !== hit.raw.trim()) {
|
|
253
|
+
throw new Error('规则块与 rules 段解析结果不一致(已放弃删除)');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let out;
|
|
257
|
+
let expected;
|
|
258
|
+
if (list.length === 1) {
|
|
259
|
+
// 最后一条规则:连同 rules 段头一起移除(空段解析出来是 null,与预期对不上)
|
|
260
|
+
out = removeRange(lines, at, sectionEnd(lines, at + 1));
|
|
261
|
+
expected = { ...doc };
|
|
262
|
+
delete expected[SECTION];
|
|
263
|
+
} else {
|
|
264
|
+
// 块内只剩这一条时连标记一起删
|
|
265
|
+
out =
|
|
266
|
+
items.length === 1
|
|
267
|
+
? removeRange(lines, block.begin, block.end + 1, { blank: false })
|
|
268
|
+
: removeRange(lines, hit.index, hit.index + 1, { blank: false });
|
|
269
|
+
expected = { ...doc, [SECTION]: list.filter((_, i) => i !== before) };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const next = normalize(out);
|
|
273
|
+
verifyRewrite(next, expected);
|
|
274
|
+
|
|
275
|
+
const reload = await saveConfigWithReload(next);
|
|
276
|
+
return { ok: true, rule: hit.raw.trim(), ...reload };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 「系统规则」卡片固定展示的那几条(顺序即写入顺序)。
|
|
281
|
+
* - `desc`:给人看的概要(卡片表格里那一列),不参与任何改写
|
|
282
|
+
* - `target`:默认目标;`'PROXY'` 是占位,表示「用配置里第一个策略组」(没有组时退回 `DIRECT`)
|
|
283
|
+
* - `locked`:不给启 / 禁开关(`MATCH` 兜底那条)
|
|
284
|
+
*/
|
|
285
|
+
export const SYSTEM_RULES = [
|
|
286
|
+
{
|
|
287
|
+
key: 'private',
|
|
288
|
+
desc: '局域网 / 内网地址',
|
|
289
|
+
type: 'GEOSITE',
|
|
290
|
+
payload: 'private',
|
|
291
|
+
target: 'DIRECT',
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
key: 'ads',
|
|
295
|
+
desc: '广告与追踪域名',
|
|
296
|
+
type: 'GEOSITE',
|
|
297
|
+
payload: 'category-ads-all',
|
|
298
|
+
target: 'REJECT',
|
|
299
|
+
},
|
|
300
|
+
{ key: 'cn', desc: '中国大陆站点', type: 'GEOSITE', payload: 'cn', target: 'DIRECT' },
|
|
301
|
+
{ key: 'gfw', desc: '被墙(GFW)站点', type: 'GEOSITE', payload: 'gfw', target: 'PROXY' },
|
|
302
|
+
{
|
|
303
|
+
key: 'scholar',
|
|
304
|
+
desc: '学术站点(非中国大陆)',
|
|
305
|
+
type: 'GEOSITE',
|
|
306
|
+
payload: 'category-scholar-!cn',
|
|
307
|
+
target: 'PROXY',
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
key: 'ai',
|
|
311
|
+
desc: 'AI 服务(非中国大陆)',
|
|
312
|
+
type: 'GEOSITE',
|
|
313
|
+
payload: 'category-ai-!cn',
|
|
314
|
+
target: 'PROXY',
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
key: 'match',
|
|
318
|
+
desc: '以上都没命中时的兜底',
|
|
319
|
+
type: 'MATCH',
|
|
320
|
+
payload: '',
|
|
321
|
+
target: 'DIRECT',
|
|
322
|
+
locked: true,
|
|
323
|
+
},
|
|
324
|
+
];
|
|
325
|
+
|
|
326
|
+
const specOf = (key) => SYSTEM_RULES.find((r) => r.key === String(key ?? '').trim());
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 读 config.yaml,但**文件还不存在时不算错**:返回空文本 + 空文档。
|
|
330
|
+
* 这样规则页在内核刚装上、还没保存过配置的状态下也能把「系统规则」那几条固定列出来(都显示为未启用),
|
|
331
|
+
* 而不是整块读不出来 / 弹一堆错。解析失败仍然照旧抛错(那是真问题)。
|
|
332
|
+
*/
|
|
333
|
+
function loadConfigOrEmpty() {
|
|
334
|
+
if (!fs.existsSync(configPath())) return { text: '', doc: {}, missing: true };
|
|
335
|
+
return { ...loadConfig(), missing: false };
|
|
336
|
+
}
|
|
337
|
+
/** 配置里第一个策略组名(GEOSITE,gfw 这类默认走它);没有组就退回 DIRECT */
|
|
338
|
+
function firstGroupTarget(doc) {
|
|
339
|
+
const groups = Array.isArray(doc?.['proxy-groups']) ? doc['proxy-groups'] : [];
|
|
340
|
+
const name = groups.find((g) => g && typeof g === 'object' && g.name)?.name;
|
|
341
|
+
return name ? String(name) : 'DIRECT';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** 把某一行当作规则来读:去掉注释前缀后能拆出 TYPE,参数,目标 才算(返回 null 表示不是规则行) */
|
|
345
|
+
function ruleOnLine(line) {
|
|
346
|
+
const text = line.replace(/^\s*#\s*/, '').trim();
|
|
347
|
+
if (!text.startsWith('- ')) return null;
|
|
348
|
+
return parseRuleLine(text.slice(2));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 行的缩进(写回时用):保持原缩进,0 / 2 两种列表写法都不能被改动 */
|
|
352
|
+
function lineIndent(line) {
|
|
353
|
+
return ' '.repeat(indentOf(line));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 「系统规则」卡片的数据:固定那几条在当前配置里的状态。
|
|
358
|
+
* 不存在的条目也算一条(`enabled: false`,目标用默认值),这样卡片能把它启起来。
|
|
359
|
+
* @returns {{rules: Array<{key,desc,type,payload,target,enabled,locked,raw}>, lockedTarget: string}}
|
|
360
|
+
*/
|
|
361
|
+
export function listSystemRules() {
|
|
362
|
+
const { text, doc } = loadConfigOrEmpty();
|
|
363
|
+
const lines = text.split('\n');
|
|
364
|
+
const at = findSection(lines, SECTION);
|
|
365
|
+
const end = at < 0 ? -1 : sectionEnd(lines, at + 1);
|
|
366
|
+
const fallback = firstGroupTarget(doc);
|
|
367
|
+
|
|
368
|
+
const rules = SYSTEM_RULES.map((spec) => {
|
|
369
|
+
const want = { type: spec.type, payload: (spec.payload || '').toLowerCase() };
|
|
370
|
+
let hit = null;
|
|
371
|
+
for (let i = at + 1; end > 0 && i < end; i++) {
|
|
372
|
+
const parsed = ruleOnLine(lines[i]);
|
|
373
|
+
if (parsed && parsed.type === want.type && parsed.payload.toLowerCase() === want.payload) {
|
|
374
|
+
hit = { i, parsed, enabled: !/^\s*#/.test(lines[i]) };
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const defaultTarget = spec.target === 'PROXY' ? fallback : spec.target;
|
|
379
|
+
return {
|
|
380
|
+
key: spec.key,
|
|
381
|
+
desc: spec.desc,
|
|
382
|
+
type: spec.type,
|
|
383
|
+
payload: spec.payload,
|
|
384
|
+
target: hit?.parsed.target || defaultTarget,
|
|
385
|
+
enabled: !!hit?.enabled,
|
|
386
|
+
locked: !!spec.locked,
|
|
387
|
+
raw: hit
|
|
388
|
+
? lines[hit.i]
|
|
389
|
+
.replace(/^\s*#\s*/, '')
|
|
390
|
+
.trim()
|
|
391
|
+
.slice(2)
|
|
392
|
+
.trim()
|
|
393
|
+
: '',
|
|
394
|
+
};
|
|
395
|
+
});
|
|
396
|
+
return { rules, lockedTarget: fallback };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* 启 / 禁一条系统规则,或改它的目标:改 `rules` 段里对应那一行(行不存在时按固定顺序插入)→ 热重载。
|
|
401
|
+
* @param {{ key?: string, enabled?: boolean, target?: string }} input
|
|
402
|
+
* @returns {Promise<{ ok: true, key: string, enabled: boolean, target: string, rule: string }>}
|
|
403
|
+
*/
|
|
404
|
+
export async function setSystemRule(input) {
|
|
405
|
+
const spec = specOf(input?.key);
|
|
406
|
+
if (!spec) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
`未知的系统规则:${input?.key ?? ''}(可选 ${SYSTEM_RULES.map((r) => r.key).join(' / ')})`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
if (input?.enabled === false && spec.locked) {
|
|
412
|
+
throw new Error(`${spec.type},${spec.target} 是兜底规则,不能禁用`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const { text, doc } = loadConfig();
|
|
416
|
+
const lines = text.split('\n');
|
|
417
|
+
const at = findSection(lines, SECTION);
|
|
418
|
+
if (at < 0) throw new Error('config.yaml 中未找到 rules 段');
|
|
419
|
+
const list = listSection(doc, SECTION);
|
|
420
|
+
const end = sectionEnd(lines, at + 1);
|
|
421
|
+
const fallback = firstGroupTarget(doc);
|
|
422
|
+
|
|
423
|
+
const want = { type: spec.type, payload: (spec.payload || '').toLowerCase() };
|
|
424
|
+
let hit = null;
|
|
425
|
+
for (let i = at + 1; i < end; i++) {
|
|
426
|
+
const parsed = ruleOnLine(lines[i]);
|
|
427
|
+
if (parsed && parsed.type === want.type && parsed.payload.toLowerCase() === want.payload) {
|
|
428
|
+
hit = { i, parsed };
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const target = assertTarget(
|
|
434
|
+
input?.target ?? hit?.parsed.target ?? (spec.target === 'PROXY' ? fallback : spec.target),
|
|
435
|
+
);
|
|
436
|
+
// 没传 enabled 就保持现状(只想改目标时不至于顺手把它关了)
|
|
437
|
+
const enabled =
|
|
438
|
+
typeof input?.enabled === 'boolean' ? input.enabled : hit ? !/^\s*#/.test(lines[hit.i]) : false;
|
|
439
|
+
const rule = ruleLine({ type: spec.type, payload: spec.payload, target });
|
|
440
|
+
|
|
441
|
+
if (!hit) {
|
|
442
|
+
if (!enabled) return { ok: true, key: spec.key, enabled: false, target, rule }; // 本来就没启用,无需写盘
|
|
443
|
+
const insertPos = systemRuleInsertPos(lines, at, end, spec);
|
|
444
|
+
const pad = ' '.repeat(listIndent(lines, at));
|
|
445
|
+
const before = countItems(lines, at, insertPos);
|
|
446
|
+
const out = [...lines.slice(0, insertPos), `${pad}- ${rule}`, ...lines.slice(insertPos)];
|
|
447
|
+
const next = normalize(out);
|
|
448
|
+
verifyRewrite(next, {
|
|
449
|
+
...doc,
|
|
450
|
+
[SECTION]: [...list.slice(0, before), rule, ...list.slice(before)],
|
|
451
|
+
});
|
|
452
|
+
const reload = await saveConfigWithReload(next);
|
|
453
|
+
return { ok: true, key: spec.key, enabled: true, target, rule, inserted: true, ...reload };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const before = countItems(lines, at, hit.i);
|
|
457
|
+
const wasEnabled = !/^\s*#/.test(lines[hit.i]);
|
|
458
|
+
if (wasEnabled === enabled && hit.parsed.target === target) {
|
|
459
|
+
return { ok: true, key: spec.key, enabled, target, rule, unchanged: true };
|
|
460
|
+
}
|
|
461
|
+
const next = enabled
|
|
462
|
+
? `${lineIndent(lines[hit.i])}- ${rule}`
|
|
463
|
+
: `${lineIndent(lines[hit.i])}# - ${rule}`;
|
|
464
|
+
const out = [...lines.slice(0, hit.i), next, ...lines.slice(hit.i + 1)];
|
|
465
|
+
const expected = enabled
|
|
466
|
+
? {
|
|
467
|
+
...doc,
|
|
468
|
+
[SECTION]: wasEnabled
|
|
469
|
+
? list.map((r, i) => (i === before ? rule : r))
|
|
470
|
+
: [...list.slice(0, before), rule, ...list.slice(before)],
|
|
471
|
+
}
|
|
472
|
+
: {
|
|
473
|
+
...doc,
|
|
474
|
+
[SECTION]: wasEnabled ? list.filter((_, i) => i !== before) : list,
|
|
475
|
+
};
|
|
476
|
+
const text2 = normalize(out);
|
|
477
|
+
verifyRewrite(text2, expected);
|
|
478
|
+
const reload = await saveConfigWithReload(text2);
|
|
479
|
+
return { ok: true, key: spec.key, enabled, target, rule, ...reload };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* 新启用的系统规则插到哪:按 {@link SYSTEM_RULES} 的顺序,插到「排在它后面的第一条已存在系统规则」之前 ——
|
|
484
|
+
* 这样即使几条规则被删过,也能保持 private → ads → cn → gfw → scholar → ai → MATCH 的固定顺序。
|
|
485
|
+
*/
|
|
486
|
+
function systemRuleInsertPos(lines, at, end, spec) {
|
|
487
|
+
const hitKeys = new Map(); // 行号 → 该行是哪条系统规则的 key
|
|
488
|
+
for (let i = at + 1; i < end; i++) {
|
|
489
|
+
const parsed = ruleOnLine(lines[i]);
|
|
490
|
+
if (!parsed) continue;
|
|
491
|
+
const found = SYSTEM_RULES.find(
|
|
492
|
+
(r) =>
|
|
493
|
+
r.type === parsed.type && (r.payload || '').toLowerCase() === parsed.payload.toLowerCase(),
|
|
494
|
+
);
|
|
495
|
+
if (found) hitKeys.set(i, found.key);
|
|
496
|
+
}
|
|
497
|
+
const mine = SYSTEM_RULES.findIndex((r) => r.key === spec.key);
|
|
498
|
+
for (let i = at + 1; i < end; i++) {
|
|
499
|
+
const key = hitKeys.get(i);
|
|
500
|
+
if (!key) continue;
|
|
501
|
+
if (SYSTEM_RULES.findIndex((r) => r.key === key) > mine) return i;
|
|
502
|
+
}
|
|
503
|
+
// 后面没有系统规则了:插到第一条 MATCH/FINAL 之前,没有就追加到 rules 段末尾
|
|
504
|
+
for (let i = at + 1; i < end; i++) {
|
|
505
|
+
const parsed = ruleOnLine(lines[i]);
|
|
506
|
+
if (parsed && CATCH_ALL.has(parsed.type)) return i;
|
|
507
|
+
}
|
|
508
|
+
return end;
|
|
509
|
+
}
|