feat: add telegram rules
This commit is contained in:
@@ -8,7 +8,7 @@ all-hybrid = false
|
||||
udp-priority = false
|
||||
# Latency Benchmark / 延迟基准测试
|
||||
internet-test-url = http://connectivitycheck.gstatic.com/generate_204
|
||||
proxy-test-url = http://captive.apple.com/generate_204
|
||||
proxy-test-url = http://cp.cloudflare.com/generate_204
|
||||
test-timeout = 3
|
||||
# GeoIP Database / GeoIP 数据库
|
||||
geoip-maxmind-url = https://github.com/Hackl0us/GeoIP2-CN/raw/release/Country.mmdb
|
||||
@@ -23,10 +23,6 @@ http-listen = 0.0.0.0
|
||||
socks5-listen = 0.0.0.0
|
||||
allow-hotspot-access = true
|
||||
# --- REMOTE CONTROLLER / 远程控制器 ---
|
||||
external-controller-access = surge@0.0.0.0:6160
|
||||
http-api = clashconnectrules@0.0.0.0:6166
|
||||
http-api-tls = true
|
||||
http-api-web-dashboard = true
|
||||
# --- COMPATIBILITY / 兼容性 ---
|
||||
compatibility-mode = 1
|
||||
# --- NETWORK / 网络 ---
|
||||
@@ -49,7 +45,7 @@ force-http-engine-hosts = *.ott.cibntv.net, 123.59.31.1, 119.18.193.135, 122.14.
|
||||
always-real-ip = link-ip.nextdns.io, *.msftconnecttest.com, *.msftncsi.com, *.srv.nintendo.net, *.stun.playstation.net, xbox.*.microsoft.com, *.xboxlive.com, *.logon.battlenet.com.cn, *.logon.battle.net, stun.l.google.com
|
||||
hijack-dns = 8.8.8.8:53, 8.8.4.4:53
|
||||
udp-policy-not-supported-behaviour = REJECT
|
||||
proxy-restricted-to-lan = false
|
||||
proxy-restricted-to-lan = true
|
||||
|
||||
[Proxy]
|
||||
|
||||
@@ -122,6 +118,7 @@ IP-CIDR,100.64.0.0/10,DIRECT,no-resolve
|
||||
IP-CIDR6,fd7a:115c:a1e0::/48,DIRECT,no-resolve
|
||||
# 阻断 STUN,避免未匹配请求落到直连兜底
|
||||
PROTOCOL,STUN,REJECT
|
||||
AND,((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com)),REJECT-NO-DROP
|
||||
|
||||
# >> 规则修正 (Unbreak Rules)
|
||||
RULE-SET,https://raw.githubusercontent.com/zxfccmm4/Profiles/refs/heads/main/Surge/Ruleset/Unbreak.list,DIRECT
|
||||
@@ -130,7 +127,7 @@ RULE-SET,https://raw.githubusercontent.com/zxfccmm4/Profiles/refs/heads/main/Sur
|
||||
DOMAIN-SET,https://ruleset.skk.moe/List/domainset/reject.conf,REJECT
|
||||
RULE-SET,https://ruleset.skk.moe/List/non_ip/reject-drop.conf,REJECT-DROP
|
||||
RULE-SET,https://ruleset.skk.moe/List/non_ip/reject.conf,REJECT
|
||||
RULE-SET,https://ruleset.skk.moe/List/ip/reject.conf,REJECT-DROP
|
||||
RULE-SET,https://ruleset.skk.moe/List/ip/reject.conf,REJECT-DROP,no-resolve
|
||||
|
||||
# >> 隐私保护 (Privacy Protection)
|
||||
DOMAIN,app-site-association.cdn-apple.com,REJECT
|
||||
@@ -231,7 +228,6 @@ RULE-SET,https://ruleset.skk.moe/List/non_ip/global_plus.conf,Automatic
|
||||
RULE-SET,https://ruleset.skk.moe/List/non_ip/global.conf,Automatic
|
||||
|
||||
# >> 网络优化与特殊规则 (Network Optimization)
|
||||
AND,((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com)),REJECT-NO-DROP
|
||||
DOMAIN-SUFFIX,smtp,DIRECT
|
||||
URL-REGEX,(Subject|HELO|SMTP),DIRECT
|
||||
|
||||
@@ -307,7 +303,7 @@ http-request ^https:\/\/github\.com\/ header-replace Accept-Language en-us
|
||||
http-request ^https:\/\/(raw|gist)\.githubusercontent\.com\/ header-replace Accept-Language en-us
|
||||
|
||||
[MITM]
|
||||
skip-server-cert-verify = true
|
||||
skip-server-cert-verify = false
|
||||
tcp-connection = true
|
||||
h2 = true
|
||||
hostname = www.google.cn, api.abema.io, *.zhihu.com, -CUSTOMMitM, sub.store
|
||||
|
||||
@@ -51,56 +51,62 @@ function seedGroups(db: Db, groups: ParsedGroup[], source: 'template' | 'custom'
|
||||
export function seedRulesFromTemplate(db: Db, opts: { keepCustom: boolean }) {
|
||||
const text = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
||||
const parsed = parseSurgeConf(text);
|
||||
db.transaction(() => {
|
||||
const custom = opts.keepCustom
|
||||
? db.select().from(rules).where(eq(rules.source, 'custom')).all()
|
||||
: [];
|
||||
db.delete(rules).where(eq(rules.source, 'template')).run();
|
||||
if (!opts.keepCustom) db.delete(rules).where(eq(rules.source, 'custom')).run();
|
||||
|
||||
const custom = opts.keepCustom
|
||||
? db.select().from(rules).where(eq(rules.source, 'custom')).all()
|
||||
: [];
|
||||
db.delete(rules).where(eq(rules.source, 'template')).run();
|
||||
if (!opts.keepCustom) db.delete(rules).where(eq(rules.source, 'custom')).run();
|
||||
|
||||
let seq = 0;
|
||||
// 规则首条匹配即生效:自定义规则保持原相对顺序,整体排在全部模板规则之前
|
||||
for (const c of custom.sort((a, b) => a.seq - b.seq)) {
|
||||
db.insert(rules)
|
||||
.values({ ...c, seq: seq++ })
|
||||
.run();
|
||||
}
|
||||
for (const r of parsed.rules) {
|
||||
let value = r.value;
|
||||
if ((r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') && value && /^https?:\/\//.test(value)) {
|
||||
const existing = db.select().from(rulesets).where(eq(rulesets.surgeUrl, value)).get();
|
||||
if (existing) {
|
||||
value = existing.id;
|
||||
} else {
|
||||
const mapping = mapRulesetUrl(value, r.type);
|
||||
const rulesetId = nanoid(10);
|
||||
db.insert(rulesets)
|
||||
.values({
|
||||
id: rulesetId,
|
||||
name: rulesetNameFromUrl(value),
|
||||
surgeUrl: value,
|
||||
clashUrl: mapping.clashUrl,
|
||||
clashBehavior: mapping.clashBehavior,
|
||||
clashFormat: mapping.clashFormat,
|
||||
needsConvert: mapping.needsConvert,
|
||||
})
|
||||
.run();
|
||||
value = rulesetId;
|
||||
}
|
||||
let seq = 0;
|
||||
// 规则首条匹配即生效:自定义规则保持原相对顺序,整体排在全部模板规则之前
|
||||
for (const c of custom.filter((r) => r.type !== 'FINAL').sort((a, b) => a.seq - b.seq)) {
|
||||
db.update(rules).set({ seq: seq++ }).where(eq(rules.id, c.id)).run();
|
||||
}
|
||||
db.insert(rules)
|
||||
.values({
|
||||
id: nanoid(10),
|
||||
seq: seq++,
|
||||
type: r.type,
|
||||
value,
|
||||
policy: r.policy,
|
||||
params: JSON.stringify(r.params),
|
||||
enabled: true,
|
||||
source: 'template',
|
||||
})
|
||||
.run();
|
||||
}
|
||||
const customFinal = custom.find((r) => r.type === 'FINAL');
|
||||
for (const r of parsed.rules.filter((r) => !customFinal || r.type !== 'FINAL')) {
|
||||
let value = r.value;
|
||||
if ((r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') && value && /^https?:\/\//.test(value)) {
|
||||
const existing = db.select().from(rulesets).where(eq(rulesets.surgeUrl, value)).get();
|
||||
if (existing) {
|
||||
value = existing.id;
|
||||
} else {
|
||||
const mapping = mapRulesetUrl(value, r.type);
|
||||
const rulesetId = nanoid(10);
|
||||
db.insert(rulesets)
|
||||
.values({
|
||||
id: rulesetId,
|
||||
name: rulesetNameFromUrl(value),
|
||||
surgeUrl: value,
|
||||
clashUrl: mapping.clashUrl,
|
||||
clashBehavior: mapping.clashBehavior,
|
||||
clashFormat: mapping.clashFormat,
|
||||
needsConvert: mapping.needsConvert,
|
||||
})
|
||||
.run();
|
||||
value = rulesetId;
|
||||
}
|
||||
}
|
||||
db.insert(rules)
|
||||
.values({
|
||||
id: nanoid(10),
|
||||
seq: seq++,
|
||||
type: r.type,
|
||||
value,
|
||||
policy: r.policy,
|
||||
params: JSON.stringify(r.params),
|
||||
enabled: true,
|
||||
source: 'template',
|
||||
})
|
||||
.run();
|
||||
}
|
||||
if (customFinal) {
|
||||
for (const extra of custom.filter((r) => r.type === 'FINAL' && r.id !== customFinal.id)) {
|
||||
db.delete(rules).where(eq(rules.id, extra.id)).run();
|
||||
}
|
||||
db.update(rules).set({ seq, enabled: true }).where(eq(rules.id, customFinal.id)).run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function seedIfEmpty(db: Db) {
|
||||
|
||||
@@ -5,7 +5,12 @@ import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
import { groupInputSchema, type GroupDto } from '@proxy-station/shared';
|
||||
import { db } from '../db/index.js';
|
||||
import { groupMembers, policyGroups, rules } from '../db/schema.js';
|
||||
import { groupMembers, policyGroups, rules, nodes } from '../db/schema.js';
|
||||
import { groupError } from '../services/group-validation.js';
|
||||
|
||||
function validate(input: z.output<typeof groupInputSchema>, id?: string): string | null {
|
||||
return groupError(input, loadGroups(), db.select({ id: nodes.id, name: nodes.name }).from(nodes).all(), id);
|
||||
}
|
||||
|
||||
function loadGroups(): GroupDto[] {
|
||||
const groups = db.select().from(policyGroups).orderBy(asc(policyGroups.sortOrder)).all();
|
||||
@@ -37,27 +42,31 @@ export const groupsRoute = new Hono()
|
||||
.get('/', (c) => c.json(loadGroups()))
|
||||
.post('/', zValidator('json', groupInputSchema), (c) => {
|
||||
const input = c.req.valid('json');
|
||||
const error = validate(input);
|
||||
if (error) return c.json({ error }, error === '策略组名称已存在' ? 409 : 400);
|
||||
const id = nanoid(10);
|
||||
try {
|
||||
db.insert(policyGroups)
|
||||
.values({
|
||||
id,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
testUrl: input.testUrl ?? null,
|
||||
interval: input.interval ?? null,
|
||||
tolerance: input.tolerance ?? null,
|
||||
filterRegex: input.filterRegex || null,
|
||||
includeAllNodes: input.includeAllNodes,
|
||||
sortOrder: input.sortOrder,
|
||||
source: 'custom',
|
||||
})
|
||||
.run();
|
||||
db.transaction(() => {
|
||||
db.insert(policyGroups)
|
||||
.values({
|
||||
id,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
testUrl: input.testUrl ?? null,
|
||||
interval: input.interval ?? null,
|
||||
tolerance: input.tolerance ?? null,
|
||||
filterRegex: input.filterRegex || null,
|
||||
includeAllNodes: input.includeAllNodes,
|
||||
sortOrder: input.sortOrder,
|
||||
source: 'custom',
|
||||
})
|
||||
.run();
|
||||
replaceMembers(id, input.members);
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (String(e.message).includes('UNIQUE')) return c.json({ error: `策略组「${input.name}」已存在` }, 409);
|
||||
throw e;
|
||||
}
|
||||
replaceMembers(id, input.members);
|
||||
return c.json({ id }, 201);
|
||||
})
|
||||
.put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => {
|
||||
@@ -79,25 +88,29 @@ export const groupsRoute = new Hono()
|
||||
const existing = db.select().from(policyGroups).where(eq(policyGroups.id, id)).get();
|
||||
if (!existing) return c.json({ error: '策略组不存在' }, 404);
|
||||
const input = c.req.valid('json');
|
||||
const error = validate(input, id);
|
||||
if (error) return c.json({ error }, error === '策略组名称已存在' ? 409 : 400);
|
||||
const oldName = existing.name;
|
||||
db.update(policyGroups)
|
||||
.set({
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
testUrl: input.testUrl ?? null,
|
||||
interval: input.interval ?? null,
|
||||
tolerance: input.tolerance ?? null,
|
||||
filterRegex: input.filterRegex || null,
|
||||
includeAllNodes: input.includeAllNodes,
|
||||
})
|
||||
.where(eq(policyGroups.id, id))
|
||||
.run();
|
||||
replaceMembers(id, input.members);
|
||||
// 组名变更时同步引用它的成员与规则策略
|
||||
if (oldName !== input.name) {
|
||||
db.update(groupMembers).set({ refName: input.name }).where(eq(groupMembers.refName, oldName)).run();
|
||||
db.update(rules).set({ policy: input.name }).where(eq(rules.policy, oldName)).run();
|
||||
}
|
||||
db.transaction(() => {
|
||||
db.update(policyGroups)
|
||||
.set({
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
testUrl: input.testUrl ?? null,
|
||||
interval: input.interval ?? null,
|
||||
tolerance: input.tolerance ?? null,
|
||||
filterRegex: input.filterRegex || null,
|
||||
includeAllNodes: input.includeAllNodes,
|
||||
})
|
||||
.where(eq(policyGroups.id, id))
|
||||
.run();
|
||||
replaceMembers(id, input.members.map((m) => m.kind === 'group' && m.refName === oldName ? { ...m, refName: input.name } : m));
|
||||
// 组名变更时同步引用它的成员与规则策略
|
||||
if (oldName !== input.name) {
|
||||
db.update(groupMembers).set({ refName: input.name }).where(eq(groupMembers.refName, oldName)).run();
|
||||
db.update(rules).set({ policy: input.name }).where(eq(rules.policy, oldName)).run();
|
||||
}
|
||||
});
|
||||
return c.json({ ok: true });
|
||||
})
|
||||
.delete('/:id', (c) => {
|
||||
|
||||
130
server/src/routes/management-safety.test.ts
Normal file
130
server/src/routes/management-safety.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
|
||||
// db/index has an eager singleton: set an absolute temp path before imports.
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-management-'));
|
||||
process.env.DATA_DIR = directory;
|
||||
const { db } = await import('../db/index.js');
|
||||
const { rules, policyGroups, groupMembers, nodes, settings, rulesets } = await import('../db/schema.js');
|
||||
const { rulesRoute } = await import('./rules.js');
|
||||
const { groupsRoute } = await import('./groups.js');
|
||||
const { nodesRoute } = await import('./nodes.js');
|
||||
const { settingsRoute } = await import('./settings.js');
|
||||
const { seedRulesFromTemplate } = await import('../db/seed.js');
|
||||
const { buildProfileIR } = await import('../services/ir.js');
|
||||
|
||||
const request = (app: typeof rulesRoute, url: string, method: string, body?: unknown) => app.request(url, {
|
||||
method, headers: { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const group = (name: string, members = [{ kind: 'builtin', refName: 'DIRECT' }]) => ({ name, type: 'select', members });
|
||||
|
||||
beforeAll(() => {
|
||||
rulesRoute.onError((e, c) => c.json({ error: e.message }, 500));
|
||||
groupsRoute.onError((e, c) => c.json({ error: e.message }, 500));
|
||||
});
|
||||
beforeEach(() => {
|
||||
for (const table of [groupMembers, rules, nodes, policyGroups, settings, rulesets]) db.delete(table).run();
|
||||
});
|
||||
afterAll(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
describe('management invariants with isolated SQLite and real routes', () => {
|
||||
it('preserves custom rules on reset and keeps exactly one trailing FINAL', async () => {
|
||||
seedRulesFromTemplate(db, { keepCustom: false });
|
||||
const before = db.select().from(rules).all();
|
||||
const res = await request(rulesRoute, '/', 'POST', { type: 'DOMAIN', value: 'example.invalid', policy: 'DIRECT' });
|
||||
const { id } = await res.json();
|
||||
expect(res.status).toBe(201);
|
||||
expect((await request(rulesRoute, '/reset', 'POST')).status).toBe(200);
|
||||
const after = (await (await request(rulesRoute, '/', 'GET')).json()) as { id: string; type: string }[];
|
||||
expect(after).toHaveLength(before.length + 1);
|
||||
expect(after[0].id).toBe(id);
|
||||
expect(after.at(-1)?.type).toBe('FINAL');
|
||||
expect(after.filter((r) => r.type === 'FINAL')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rolls back the entire reset when template insertion fails', async () => {
|
||||
seedRulesFromTemplate(db, { keepCustom: false });
|
||||
await request(rulesRoute, '/', 'POST', { type: 'DOMAIN', value: 'example.invalid', policy: 'DIRECT' });
|
||||
const before = db.select().from(rules).all();
|
||||
db.run(sql.raw("CREATE TRIGGER reject_template BEFORE INSERT ON rules WHEN NEW.source = 'template' BEGIN SELECT RAISE(ABORT, 'fixture failure'); END"));
|
||||
try {
|
||||
expect((await request(rulesRoute, '/reset', 'POST')).status).toBe(500);
|
||||
expect(db.select().from(rules).all()).toEqual(before);
|
||||
} finally { db.run(sql.raw('DROP TRIGGER reject_template')); }
|
||||
});
|
||||
|
||||
it('keeps a custom FINAL instead of adding a duplicate template FINAL', async () => {
|
||||
const response = await request(rulesRoute, '/', 'POST', { type: 'FINAL', policy: 'DIRECT', params: ['dns-failed'] });
|
||||
const final = await response.json();
|
||||
expect((await request(rulesRoute, '/reset', 'POST')).status).toBe(200);
|
||||
const rows = await (await request(rulesRoute, '/', 'GET')).json();
|
||||
expect(rows.filter((r: { type: string }) => r.type === 'FINAL')).toHaveLength(1);
|
||||
expect(rows.at(-1).id).toBe(final.id);
|
||||
});
|
||||
|
||||
it('protects FINAL and rejects partial, duplicate or unsafe reorder requests', async () => {
|
||||
const final = await (await request(rulesRoute, '/', 'POST', { type: 'FINAL', policy: 'DIRECT' })).json();
|
||||
const first = await (await request(rulesRoute, '/', 'POST', { type: 'DOMAIN', value: 'example.invalid', policy: 'DIRECT' })).json();
|
||||
expect((await request(rulesRoute, '/', 'POST', { type: 'FINAL', policy: 'DIRECT' })).status).toBe(400);
|
||||
expect((await request(rulesRoute, `/${final.id}/enabled`, 'PATCH', { enabled: false })).status).toBe(400);
|
||||
expect((await request(rulesRoute, `/${final.id}`, 'DELETE')).status).toBe(400);
|
||||
expect((await request(rulesRoute, `/${final.id}`, 'PUT', { type: 'DOMAIN', value: 'bad.invalid', policy: 'DIRECT' })).status).toBe(400);
|
||||
for (const ids of [[first.id], [first.id, first.id], [final.id, first.id], [first.id, 'missing']]) {
|
||||
expect((await request(rulesRoute, '/reorder', 'PUT', { ids })).status).toBe(400);
|
||||
}
|
||||
expect((await request(rulesRoute, '/reorder', 'PUT', { ids: [first.id, final.id] })).status).toBe(200);
|
||||
expect(buildProfileIR().rules.at(-1)?.type).toBe('FINAL');
|
||||
db.update(rules).set({ enabled: false }).where(eq(rules.id, final.id)).run();
|
||||
expect(buildProfileIR().rules.at(-1)?.policy).toBe('DIRECT');
|
||||
db.delete(rules).where(eq(rules.id, final.id)).run();
|
||||
expect(buildProfileIR().rules.at(-1)?.type).toBe('FINAL');
|
||||
});
|
||||
|
||||
it('rejects cycles, dangling or wrongly typed members and reserved names', async () => {
|
||||
const a = await (await request(groupsRoute, '/', 'POST', group('A'))).json();
|
||||
expect((await request(groupsRoute, '/', 'POST', group('B', [{ kind: 'group', refName: 'A' }]))).status).toBe(201);
|
||||
const before = db.select().from(groupMembers).all();
|
||||
expect((await request(groupsRoute, `/${a.id}`, 'PUT', group('A', [{ kind: 'group', refName: 'B' }]))).status).toBe(400);
|
||||
expect(db.select().from(groupMembers).all()).toEqual(before);
|
||||
for (const input of [group('C', [{ kind: 'group', refName: 'C' }]), group('C', [{ kind: 'group', refName: 'missing' }]), group('C', [{ kind: 'builtin', refName: 'A' }]), group('DIRECT')]) {
|
||||
expect((await request(groupsRoute, '/', 'POST', input)).status).toBe(400);
|
||||
}
|
||||
expect((await request(groupsRoute, `/${a.id}`, 'PUT', group('Renamed'))).status).toBe(200);
|
||||
expect(db.select().from(groupMembers).all().some((m) => m.refName === 'Renamed')).toBe(true);
|
||||
});
|
||||
|
||||
it('rolls back group creation when member replacement fails', async () => {
|
||||
db.run(sql.raw("CREATE TRIGGER reject_member BEFORE INSERT ON group_members WHEN NEW.ref_name = 'REJECT' BEGIN SELECT RAISE(ABORT, 'fixture failure'); END"));
|
||||
try {
|
||||
expect((await request(groupsRoute, '/', 'POST', group('Atomic', [{ kind: 'builtin', refName: 'REJECT' }]))).status).toBe(500);
|
||||
expect(db.select().from(policyGroups).all()).toHaveLength(0);
|
||||
} finally { db.run(sql.raw('DROP TRIGGER reject_member')); }
|
||||
});
|
||||
|
||||
it('rejects unsafe node names, newline injection and inactive rule policies', async () => {
|
||||
const base = { protocol: 'vmess', server: 'example.com', port: 443, uuid: '00000000-0000-0000-0000-000000000000' };
|
||||
for (const name of ['Bad = Name', 'Bad,Name', 'Bad\n[Rule]', 'DIRECT']) {
|
||||
expect((await request(nodesRoute, '/', 'POST', { ...base, name })).status).toBe(400);
|
||||
}
|
||||
expect((await request(nodesRoute, '/', 'POST', { ...base, name: 'Valid', password: 'x\nFINAL,DIRECT' })).status).toBe(400);
|
||||
expect((await request(rulesRoute, '/', 'POST', { type: 'DOMAIN', value: 'a.invalid\nFINAL,DIRECT', policy: 'DIRECT' })).status).toBe(400);
|
||||
for (const value of ['((DOMAIN,example.invalid,DIRECT))', '((DOMAIN,example.invalid)),REJECT,((DOMAIN,other.invalid))', '((PROTOCOL,udp))']) {
|
||||
expect((await request(rulesRoute, '/', 'POST', { type: 'AND', value, policy: 'DIRECT' })).status).toBe(400);
|
||||
}
|
||||
expect((await request(rulesRoute, '/', 'POST', { type: 'URL-REGEX', value: '^https://example.com/(a|b),?c', policy: 'DIRECT' })).status).toBe(201);
|
||||
const a = await (await request(groupsRoute, '/', 'POST', group('A'))).json();
|
||||
await request(groupsRoute, `/${a.id}/enabled`, 'PATCH', { enabled: false });
|
||||
expect((await request(rulesRoute, '/', 'POST', { type: 'DOMAIN', value: 'example.invalid', policy: 'A' })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('does not store public control credentials or disable MITM upstream verification', async () => {
|
||||
for (const surgeSections of [{ General: 'http-api = clashconnectrules@0.0.0.0:6166' }, { General: 'http-api = "clashconnectrules@0.0.0.0:6166"' }, { MITM: 'skip-server-cert-verify = true' }, { MITM: 'skip-server-cert-verify = "true" // comment' }]) {
|
||||
expect((await request(settingsRoute, '/', 'PUT', { surgeSections })).status).toBe(400);
|
||||
}
|
||||
expect(db.select().from(settings).all()).toHaveLength(0);
|
||||
expect((await request(settingsRoute, '/', 'PUT', { surgeSections: { MITM: 'skip-server-cert-verify = false' } })).status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,13 @@ import { zValidator } from '@hono/zod-validator';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
import { nodeInputSchema, type NodeDto } from '@proxy-station/shared';
|
||||
import { BUILTIN_POLICIES, nodeInputSchema, type NodeDto } from '@proxy-station/shared';
|
||||
import { db } from '../db/index.js';
|
||||
import { nodes } from '../db/schema.js';
|
||||
import { nodes, policyGroups } from '../db/schema.js';
|
||||
|
||||
function reservedName(name: string): boolean {
|
||||
return (BUILTIN_POLICIES as readonly string[]).includes(name) || !!db.select().from(policyGroups).where(eq(policyGroups.name, name)).get();
|
||||
}
|
||||
import { encodeShareLink, parseShareLinkBatch } from '../services/sharelink/parse.js';
|
||||
|
||||
type NodeRow = typeof nodes.$inferSelect;
|
||||
@@ -58,6 +62,7 @@ export const nodesRoute = new Hono()
|
||||
})
|
||||
.post('/', zValidator('json', nodeInputSchema), (c) => {
|
||||
const input = c.req.valid('json');
|
||||
if (reservedName(input.name)) return c.json({ error: '节点名称与策略组或内置策略冲突' }, 400);
|
||||
const now = Date.now();
|
||||
const row = { id: nanoid(10), ...dtoToRowPatch(input), extra: '{}', createdAt: now, updatedAt: now };
|
||||
try {
|
||||
@@ -76,18 +81,20 @@ export const nodesRoute = new Hono()
|
||||
const parsed = parseShareLinkBatch(text);
|
||||
const taken = new Set(db.select({ name: nodes.name }).from(nodes).all().map((r) => r.name));
|
||||
const now = Date.now();
|
||||
const results = parsed.map((item) => {
|
||||
const results = db.transaction(() => parsed.map((item) => {
|
||||
if (!item.node) return { ok: false as const, uri: item.uri, error: item.error };
|
||||
const name = uniqueName(item.node.name, taken);
|
||||
taken.add(name);
|
||||
const node = { ...item.node, name };
|
||||
const validation = nodeInputSchema.safeParse(node);
|
||||
if (!validation.success || reservedName(name)) return { ok: false as const, uri: item.uri, error: '节点字段或名称无效' };
|
||||
if (commit) {
|
||||
const validated = nodeInputSchema.parse(node);
|
||||
const validated = validation.data;
|
||||
const row = { id: nanoid(10), ...dtoToRowPatch(validated), extra: '{}', createdAt: now, updatedAt: now };
|
||||
db.insert(nodes).values(row).run();
|
||||
}
|
||||
return { ok: true as const, uri: item.uri, node };
|
||||
});
|
||||
}));
|
||||
return c.json({
|
||||
committed: commit,
|
||||
total: results.length,
|
||||
@@ -107,6 +114,7 @@ export const nodesRoute = new Hono()
|
||||
const existing = db.select().from(nodes).where(eq(nodes.id, id)).get();
|
||||
if (!existing) return c.json({ error: '节点不存在' }, 404);
|
||||
const input = c.req.valid('json');
|
||||
if (reservedName(input.name)) return c.json({ error: '节点名称与策略组或内置策略冲突' }, 400);
|
||||
try {
|
||||
db.update(nodes)
|
||||
.set({ ...dtoToRowPatch(input), updatedAt: Date.now() })
|
||||
|
||||
@@ -25,36 +25,52 @@ function loadRules(): RuleDto[] {
|
||||
/** 引用不存在的策略组会被 IR 静默剔除,必须在写入前拦下 */
|
||||
function policyError(policy: string): string | null {
|
||||
if ((BUILTIN_POLICIES as readonly string[]).includes(policy)) return null;
|
||||
const exists = db.select({ id: policyGroups.id }).from(policyGroups).where(eq(policyGroups.name, policy)).get();
|
||||
return exists ? null : `策略「${policy}」不存在:请选择内置策略或已有策略组`;
|
||||
const group = db.select().from(policyGroups).where(eq(policyGroups.name, policy)).get();
|
||||
return group?.enabled ? null : `策略「${policy}」不存在或已停用:请选择内置策略或启用的策略组`;
|
||||
}
|
||||
|
||||
function inputError(input: z.output<typeof ruleInputSchema>, id?: string): string | null {
|
||||
const err = policyError(input.policy);
|
||||
if (err) return err;
|
||||
if (input.type === 'FINAL' && db.select().from(rules).all().some((r) => r.type === 'FINAL' && r.id !== id)) return '只能有一条 FINAL 规则';
|
||||
if (['RULE-SET', 'DOMAIN-SET'].includes(input.type) && !db.select().from(rulesets).where(eq(rulesets.id, input.value!)).get()) return '规则集不存在';
|
||||
return null;
|
||||
}
|
||||
|
||||
export const rulesRoute = new Hono()
|
||||
.get('/', (c) => c.json(loadRules()))
|
||||
.post('/', zValidator('json', ruleInputSchema), (c) => {
|
||||
const input = c.req.valid('json');
|
||||
const err = policyError(input.policy);
|
||||
const err = inputError(input);
|
||||
if (err) return c.json({ error: err }, 400);
|
||||
// 规则首条匹配即生效,自定义规则必须压过模板里的宽泛规则集(如 download.conf),插到最前
|
||||
db.update(rules).set({ seq: sql`${rules.seq} + 1` }).run();
|
||||
const id = nanoid(10);
|
||||
db.insert(rules)
|
||||
.values({
|
||||
id,
|
||||
seq: 0,
|
||||
type: input.type,
|
||||
value: input.value ?? null,
|
||||
policy: input.policy,
|
||||
params: JSON.stringify(input.params),
|
||||
enabled: input.enabled,
|
||||
source: 'custom',
|
||||
})
|
||||
.run();
|
||||
db.transaction(() => {
|
||||
if (input.type !== 'FINAL') db.update(rules).set({ seq: sql`${rules.seq} + 1` }).run();
|
||||
const seq = input.type === 'FINAL' ? Math.max(-1, ...db.select().from(rules).all().map((r) => r.seq)) + 1 : 0;
|
||||
db.insert(rules)
|
||||
.values({
|
||||
id,
|
||||
seq,
|
||||
type: input.type,
|
||||
value: input.value ?? null,
|
||||
policy: input.policy,
|
||||
params: JSON.stringify(input.params),
|
||||
enabled: input.enabled,
|
||||
source: 'custom',
|
||||
})
|
||||
.run();
|
||||
});
|
||||
return c.json({ id }, 201);
|
||||
})
|
||||
.put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => {
|
||||
const { ids } = c.req.valid('json');
|
||||
ids.forEach((id, i) => db.update(rules).set({ seq: i }).where(eq(rules.id, id)).run());
|
||||
const rows = db.select().from(rules).all();
|
||||
const known = new Set(rows.map((r) => r.id));
|
||||
if (ids.length !== rows.length || new Set(ids).size !== ids.length || ids.some((id) => !known.has(id))) return c.json({ error: '排序必须包含全部规则且每条仅出现一次' }, 400);
|
||||
const finals = rows.filter((r) => r.type === 'FINAL');
|
||||
if (finals.length !== 1 || !finals[0].enabled || ids.at(-1) !== finals[0].id) return c.json({ error: '启用的唯一 FINAL 必须位于末尾' }, 400);
|
||||
db.transaction(() => ids.forEach((id, i) => db.update(rules).set({ seq: i }).where(eq(rules.id, id)).run()));
|
||||
return c.json({ ok: true });
|
||||
})
|
||||
.post('/reset', (c) => {
|
||||
@@ -66,7 +82,9 @@ export const rulesRoute = new Hono()
|
||||
const existing = db.select().from(rules).where(eq(rules.id, id)).get();
|
||||
if (!existing) return c.json({ error: '规则不存在' }, 404);
|
||||
const input = c.req.valid('json');
|
||||
const err = policyError(input.policy);
|
||||
if (existing.type === 'FINAL' && input.type !== 'FINAL') return c.json({ error: '不能将 FINAL 改成其他规则类型' }, 400);
|
||||
if (existing.type !== 'FINAL' && input.type === 'FINAL') return c.json({ error: '请编辑已有 FINAL,不能改变普通规则的类型为 FINAL' }, 400);
|
||||
const err = inputError(input, id);
|
||||
if (err) return c.json({ error: err }, 400);
|
||||
db.update(rules)
|
||||
.set({
|
||||
@@ -81,6 +99,9 @@ export const rulesRoute = new Hono()
|
||||
return c.json({ ok: true });
|
||||
})
|
||||
.patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => {
|
||||
const row = db.select().from(rules).where(eq(rules.id, c.req.param('id'))).get();
|
||||
if (!row) return c.json({ error: '规则不存在' }, 404);
|
||||
if (row.type === 'FINAL' && !c.req.valid('json').enabled) return c.json({ error: '不能停用 FINAL' }, 400);
|
||||
const res = db
|
||||
.update(rules)
|
||||
.set({ enabled: c.req.valid('json').enabled })
|
||||
@@ -90,6 +111,9 @@ export const rulesRoute = new Hono()
|
||||
return c.json({ ok: true });
|
||||
})
|
||||
.delete('/:id', (c) => {
|
||||
const row = db.select().from(rules).where(eq(rules.id, c.req.param('id'))).get();
|
||||
if (!row) return c.json({ error: '规则不存在' }, 404);
|
||||
if (row.type === 'FINAL') return c.json({ error: '不能删除 FINAL' }, 400);
|
||||
const res = db.delete(rules).where(eq(rules.id, c.req.param('id'))).run();
|
||||
if (res.changes === 0) return c.json({ error: '规则不存在' }, 404);
|
||||
return c.json({ ok: true });
|
||||
|
||||
@@ -2,9 +2,19 @@ import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { getSetting, setSetting } from '../services/ir.js';
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
const SURGE_SECTION_KEYS = ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script'];
|
||||
|
||||
function unsafeSettings(sections: Record<string, string>): string | null {
|
||||
for (const line of (sections.General ?? '').split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*(http-api|external-controller-access)\s*=\s*([^@]+)@/i);
|
||||
if (match && ['surge', 'clashconnectrules'].includes(match[2].trim().replace(/^["']|["']$/g, '').toLowerCase())) return '控制接口不能使用公开模板口令;请关闭接口或使用独立凭据与访问限制';
|
||||
}
|
||||
if (/^\s*skip-server-cert-verify\s*=\s*["']?(?:true|1)["']?\s*(?:$|[#;]|\/\/)/mi.test(sections.MITM ?? '')) return 'MITM 必须验证上游服务器证书';
|
||||
return null;
|
||||
}
|
||||
|
||||
export const settingsRoute = new Hono()
|
||||
.get('/', (c) => {
|
||||
const surgeSections: Record<string, string> = {};
|
||||
@@ -14,9 +24,13 @@ export const settingsRoute = new Hono()
|
||||
.put('/', zValidator('json', z.object({ surgeSections: z.record(z.string()).optional() })), (c) => {
|
||||
const input = c.req.valid('json');
|
||||
if (input.surgeSections) {
|
||||
for (const [key, value] of Object.entries(input.surgeSections)) {
|
||||
if (SURGE_SECTION_KEYS.includes(key)) setSetting(`surge:${key}`, value);
|
||||
}
|
||||
const error = unsafeSettings(input.surgeSections);
|
||||
if (error) return c.json({ error }, 400);
|
||||
db.transaction(() => {
|
||||
for (const [key, value] of Object.entries(input.surgeSections!)) {
|
||||
if (SURGE_SECTION_KEYS.includes(key)) setSetting(`surge:${key}`, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import YAML from 'yaml';
|
||||
import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
|
||||
import type { ProfileIR, RuleIR } from '../ir.js';
|
||||
import { clientSupports, normalizeSsCipher, splitRuleExpression, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
|
||||
import type { ProfileIR } from '../ir.js';
|
||||
|
||||
export function clashProxy(n: NodeDto): Record<string, unknown> {
|
||||
const base: Record<string, unknown> = { name: n.name, server: n.server, port: n.port };
|
||||
const base: Record<string, unknown> = {
|
||||
name: n.name, server: n.server, port: n.port,
|
||||
// SIP003 的 WS 插件只转发 TCP,不能把 UDP 发往 nginx 的同端口。
|
||||
udp: !(n.protocol === 'shadowsocks' && n.network === 'ws'),
|
||||
};
|
||||
switch (n.protocol) {
|
||||
case 'vmess': {
|
||||
Object.assign(base, { type: 'vmess', uuid: n.uuid, alterId: n.alterId, cipher: n.security || 'auto' });
|
||||
const cipher = n.security === 'chacha20-ietf-poly1305' ? 'chacha20-poly1305' : n.security || 'auto';
|
||||
Object.assign(base, { type: 'vmess', uuid: n.uuid, alterId: n.alterId, cipher });
|
||||
if (n.tls) {
|
||||
base.tls = true;
|
||||
if (n.sni) base.servername = n.sni;
|
||||
if (n.skipCertVerify) base['skip-cert-verify'] = true;
|
||||
}
|
||||
if (n.network === 'ws') {
|
||||
base.network = 'ws';
|
||||
@@ -42,6 +48,7 @@ export function clashProxy(n: NodeDto): Record<string, unknown> {
|
||||
tls: n.tls,
|
||||
host: n.wsHost || n.sni || n.server,
|
||||
path: n.wsPath,
|
||||
...(n.skipCertVerify ? { 'skip-cert-verify': true } : {}),
|
||||
};
|
||||
}
|
||||
break;
|
||||
@@ -68,9 +75,39 @@ function mapPolicy(policy: string): string {
|
||||
return policy;
|
||||
}
|
||||
|
||||
/** Surge 逻辑规则里的子类型 → mihomo 命名 */
|
||||
function mapLogicValue(value: string): string {
|
||||
return value.replace(/\bPROTOCOL\b/g, 'NETWORK');
|
||||
/** 逐个转换逻辑子规则;任何不可表达的子条件使整条规则跳过,避免扩大匹配范围。 */
|
||||
function mapLogicValue(value: string): string | null {
|
||||
value = value.trim();
|
||||
if (!value.startsWith('(') || !value.endsWith(')')) return null;
|
||||
const children = splitRuleExpression(value.slice(1, -1));
|
||||
if (!children?.length) return null;
|
||||
const mapped: string[] = [];
|
||||
for (const child of children) {
|
||||
if (!child.startsWith('(') || !child.endsWith(')')) return null;
|
||||
const parts = splitRuleExpression(child.slice(1, -1));
|
||||
if (!parts || parts.length < 2) return null;
|
||||
let [type, payload] = parts;
|
||||
if (['AND', 'OR', 'NOT'].includes(type)) {
|
||||
const nested = mapLogicValue(payload);
|
||||
if (!nested) return null;
|
||||
payload = nested;
|
||||
} else if (type === 'PROTOCOL') {
|
||||
if (payload !== 'TCP' && payload !== 'UDP') return null;
|
||||
type = 'NETWORK';
|
||||
} else {
|
||||
const types: Record<string, string> = {
|
||||
DOMAIN: 'DOMAIN', 'DOMAIN-SUFFIX': 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD': 'DOMAIN-KEYWORD',
|
||||
'DOMAIN-WILDCARD': 'DOMAIN-WILDCARD', 'IP-CIDR': 'IP-CIDR', 'IP-CIDR6': 'IP-CIDR6',
|
||||
'IP-ASN': 'IP-ASN', GEOIP: 'GEOIP', 'PROCESS-NAME': 'PROCESS-NAME',
|
||||
'SRC-IP': 'SRC-IP-CIDR', 'DEST-PORT': 'DST-PORT', 'SRC-PORT': 'SRC-PORT',
|
||||
};
|
||||
if (!types[type] || /["'\\]/.test(payload)) return null;
|
||||
type = types[type];
|
||||
}
|
||||
if (parts.slice(2).some((p) => p !== 'no-resolve')) return null;
|
||||
mapped.push(`(${[type, payload, ...parts.slice(2)].join(',')})`);
|
||||
}
|
||||
return `(${mapped.join(',')})`;
|
||||
}
|
||||
|
||||
const CLASH_UNSUPPORTED_RULE_TYPES = new Set(['URL-REGEX', 'USER-AGENT']);
|
||||
@@ -100,17 +137,17 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
|
||||
if (g.filterRegex) {
|
||||
group['include-all-proxies'] = true;
|
||||
group.filter = g.filterRegex;
|
||||
if (members.length) group.proxies = [...new Set(members)];
|
||||
} else if (g.includeAllNodes) {
|
||||
group['include-all-proxies'] = true;
|
||||
const extra = members.filter((m) => !usableNames.has(m));
|
||||
if (extra.length) group.proxies = extra;
|
||||
if (members.length) group.proxies = [...new Set(members)];
|
||||
} else {
|
||||
group.proxies = members.length ? members : ['DIRECT'];
|
||||
}
|
||||
if (g.type !== 'select') {
|
||||
group.url = g.testUrl || 'http://cp.cloudflare.com/generate_204';
|
||||
group.interval = g.interval ?? 300;
|
||||
if (g.tolerance) group.tolerance = g.tolerance;
|
||||
if (g.type === 'url-test' && g.tolerance !== null) group.tolerance = g.tolerance;
|
||||
}
|
||||
return group;
|
||||
});
|
||||
@@ -158,7 +195,7 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
|
||||
}
|
||||
const policy = mapPolicy(r.policy);
|
||||
if (r.ruleset) {
|
||||
ruleLines.push(`RULE-SET,${providerNameByRulesetId.get(r.ruleset.id)},${policy}`);
|
||||
ruleLines.push(['RULE-SET', providerNameByRulesetId.get(r.ruleset.id), policy, ...r.params.filter((p) => p === 'no-resolve')].join(','));
|
||||
continue;
|
||||
}
|
||||
// Surge 内置规则集(非 URL):LAN → 私网 GEOIP;SYSTEM 无对应物,跳过
|
||||
@@ -171,7 +208,9 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
|
||||
continue;
|
||||
}
|
||||
if (r.type === 'AND' || r.type === 'OR' || r.type === 'NOT') {
|
||||
ruleLines.push(`${r.type},${mapLogicValue(r.value || '')},${policy}`);
|
||||
const value = mapLogicValue(r.value || '');
|
||||
if (value) ruleLines.push([r.type, value, policy, ...r.params.filter((p) => p === 'no-resolve')].join(','));
|
||||
else skippedRules.push(`${r.type},${r.value},${r.policy}`);
|
||||
continue;
|
||||
}
|
||||
const params = r.params.filter((p) => p === 'no-resolve');
|
||||
|
||||
148
server/src/services/generators/mobile-clients.test.ts
Normal file
148
server/src/services/generators/mobile-clients.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import YAML from 'yaml';
|
||||
import type { NodeDto } from '@proxy-station/shared';
|
||||
import type { GroupIR, ProfileIR, RuleIR } from '../ir.js';
|
||||
import { generateClash, clashProxy } from './clash.js';
|
||||
import { generateShadowrocket } from './shadowrocket.js';
|
||||
import { generateShadowrocketConf } from './shadowrocket-conf.js';
|
||||
import { parseShareLink } from '../sharelink/parse.js';
|
||||
|
||||
const node: NodeDto = {
|
||||
id: 'placeholder', name: '🇯🇵 JP VMess Direct', protocol: 'vmess',
|
||||
server: 'node.example.com', port: 443, uuid: '00000000-1111-2222-3333-444444444444',
|
||||
password: 'placeholder:password', method: 'chacha20-poly1305', network: 'ws',
|
||||
tls: true, sni: 'node.example.com', skipCertVerify: false, wsHost: 'node.example.com', wsPath: '/ws',
|
||||
alterId: 0, security: 'auto', obfsType: null, obfsPassword: null, upMbps: null, downMbps: null,
|
||||
region: null, tags: [], enabled: true, sortOrder: 0, createdAt: 0, updatedAt: 0,
|
||||
};
|
||||
const group: GroupIR = {
|
||||
name: 'Filtered', type: 'url-test', testUrl: 'https://example.com/generate_204',
|
||||
interval: 300, tolerance: 0, filterRegex: '^🇯🇵', includeAllNodes: true,
|
||||
members: [{ kind: 'builtin', name: 'DIRECT' }, { kind: 'node', name: node.name }, { kind: 'group', name: 'Source' }],
|
||||
};
|
||||
const final: RuleIR = { type: 'FINAL', value: null, policy: 'DIRECT', params: ['dns-failed'], ruleset: null };
|
||||
const base: ProfileIR = { nodes: [node], groups: [], rules: [final], sections: {} };
|
||||
const generate = (ir: ProfileIR) => generateClash(ir, { convertBaseUrl: 'https://example.com/sub/placeholder' });
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-mobile-'));
|
||||
const cli = process.env.MIHOMO_CLI ?? 'mihomo';
|
||||
const nativeAvailable = spawnSync(cli, ['-v'], { timeout: 10000 }).status === 0;
|
||||
afterAll(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
describe('mobile client node subscriptions', () => {
|
||||
it('sorts complete Shadowrocket names by string, retains all four protocols and does not mutate IR', () => {
|
||||
const names = ['🇸🇬 SG VMess Warp', '🇯🇵 JP Trojan Warp', '🇺🇸 US LA SS Direct',
|
||||
'🇸🇬 SG VMess Direct', '🇯🇵 JP HY2 Direct', '🇯🇵 JP Trojan Direct'];
|
||||
const protocols: NodeDto['protocol'][] = ['vmess', 'trojan', 'shadowsocks', 'vmess', 'hysteria2', 'trojan'];
|
||||
const nodes = names.map((name, i) => ({ ...node, name, protocol: protocols[i],
|
||||
...(protocols[i] === 'hysteria2' ? { network: 'tcp' as const, wsHost: null, wsPath: null,
|
||||
obfsType: 'salamander', obfsPassword: 'placeholder-obfs' } : {}) }));
|
||||
const output = generateShadowrocket({ ...base, nodes });
|
||||
const uris = Buffer.from(output.content, 'base64').toString('utf8').split('\n');
|
||||
expect(uris).toEqual(output.uris);
|
||||
expect(uris.map((uri) => parseShareLink(uri).name)).toEqual([...names].sort());
|
||||
expect(nodes.map((n) => n.name)).toEqual(names);
|
||||
expect(output.excluded).toEqual([]);
|
||||
for (const uri of uris) {
|
||||
const parsed = parseShareLink(uri);
|
||||
const original = nodes.find((n) => n.name === parsed.name)!;
|
||||
expect(parsed).toMatchObject({ name: original.name, protocol: original.protocol,
|
||||
server: original.server, port: original.port, network: original.network,
|
||||
tls: original.tls, sni: original.sni });
|
||||
if (original.protocol === 'vmess') expect(parsed.uuid).toBe(original.uuid);
|
||||
else expect(parsed.password).toBe(original.password);
|
||||
if (original.protocol === 'hysteria2') expect(parsed.obfsPassword).toBe(original.obfsPassword);
|
||||
}
|
||||
});
|
||||
|
||||
it('enables supported Clash UDP transports, keeps SS WS TCP-only, and preserves TLS flags', () => {
|
||||
expect(clashProxy({ ...node, skipCertVerify: true })).toMatchObject({ type: 'vmess', udp: true,
|
||||
tls: true, servername: node.sni, 'skip-cert-verify': true, 'ws-opts': { headers: { Host: node.wsHost } } });
|
||||
expect(clashProxy({ ...node, protocol: 'trojan' })).toMatchObject({ type: 'trojan', udp: true, sni: node.sni });
|
||||
expect(clashProxy({ ...node, protocol: 'shadowsocks', skipCertVerify: true })).toMatchObject({
|
||||
type: 'ss', udp: false, cipher: 'chacha20-ietf-poly1305', plugin: 'v2ray-plugin',
|
||||
'plugin-opts': { mode: 'websocket', tls: true, path: '/ws', 'skip-cert-verify': true },
|
||||
});
|
||||
expect(clashProxy({ ...node, protocol: 'shadowsocks', network: 'tcp' }).udp).toBe(true);
|
||||
expect(clashProxy({ ...node, protocol: 'hysteria2', network: 'tcp', obfsType: 'salamander',
|
||||
obfsPassword: 'placeholder-obfs', upMbps: 20, downMbps: 100 })).toMatchObject({ type: 'hysteria2',
|
||||
udp: true, obfs: 'salamander', 'obfs-password': 'placeholder-obfs', up: '20 Mbps', down: '100 Mbps' });
|
||||
});
|
||||
|
||||
it('maps the VMess cipher alias and reports unsupported nodes without leaving dangling group references', () => {
|
||||
expect(clashProxy({ ...node, security: 'chacha20-ietf-poly1305' }).cipher).toBe('chacha20-poly1305');
|
||||
const result = generate({ ...base, nodes: [{ ...node, security: 'unsupported-placeholder' }],
|
||||
groups: [{ ...group, filterRegex: null, includeAllNodes: false, members: [{ kind: 'node', name: node.name }] }] });
|
||||
const doc = YAML.parse(result.content);
|
||||
expect(result.excluded).toEqual([{ name: node.name, reason: 'Clash 不支持该 VMess 加密方式:unsupported-placeholder' }]);
|
||||
expect(doc.proxies).toEqual([]);
|
||||
expect(doc['proxy-groups'][0].proxies).toEqual(['DIRECT']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile client groups and rule semantics', () => {
|
||||
it('preserves explicit members in dynamic groups, zero tolerance, and omits fallback tolerance', () => {
|
||||
const source = { ...group, name: 'Source', filterRegex: null, includeAllNodes: false,
|
||||
members: [{ kind: 'node' as const, name: node.name }] };
|
||||
const all = { ...group, name: 'All', filterRegex: null };
|
||||
const fallback = { ...group, name: 'Fallback', type: 'fallback' as const, tolerance: 50 };
|
||||
const ir = { ...base, groups: [source, group, all, fallback] };
|
||||
const doc = YAML.parse(generate(ir).content);
|
||||
expect(doc['proxy-groups'][1]).toMatchObject({ proxies: ['DIRECT', node.name, 'Source'],
|
||||
'include-all-proxies': true, filter: group.filterRegex, tolerance: 0, url: group.testUrl });
|
||||
expect(doc['proxy-groups'][2].proxies).toEqual(['DIRECT', node.name, 'Source']);
|
||||
expect(doc['proxy-groups'][3]).not.toHaveProperty('tolerance');
|
||||
const sr = generateShadowrocketConf(ir).content;
|
||||
expect(sr).toContain(`Filtered = url-test, DIRECT, ${node.name}, Source, url=${group.testUrl}, interval=300, tolerance=0, policy-regex-filter=^🇯🇵`);
|
||||
expect(sr.split('\n').find((l) => l.startsWith('Fallback ='))).not.toContain('tolerance=');
|
||||
expect(sr).not.toContain('use=');
|
||||
});
|
||||
|
||||
it('preserves provider no-resolve, handles duplicate provider names, keeps rule order and FINAL mapping', () => {
|
||||
const ruleset = { id: 'first', name: 'same', surgeUrl: 'https://example.com/ip.list', clashUrl: null,
|
||||
clashBehavior: 'classical', clashFormat: 'yaml', needsConvert: true };
|
||||
const rules: RuleIR[] = [
|
||||
{ type: 'RULE-SET', value: 'first', policy: 'REJECT-DROP', params: ['no-resolve'], ruleset },
|
||||
{ type: 'DOMAIN', value: 'example.com', policy: 'DIRECT', params: [], ruleset: null },
|
||||
{ type: 'RULE-SET', value: 'second', policy: 'DIRECT', params: [], ruleset: { ...ruleset, id: 'second' } }, final,
|
||||
];
|
||||
const doc = YAML.parse(generate({ ...base, rules }).content);
|
||||
expect(doc.rules).toEqual(['RULE-SET,same,REJECT-DROP,no-resolve', 'DOMAIN,example.com,DIRECT',
|
||||
'RULE-SET,same-2,DIRECT', 'MATCH,DIRECT']);
|
||||
expect(doc['rule-providers']['same-2'].url).toBe('https://example.com/sub/placeholder/ruleset/second.yaml');
|
||||
});
|
||||
|
||||
it('converts only transport subrules, preserves payload literals and skips whole unsupported logical expressions', () => {
|
||||
const values = ['((PROTOCOL,UDP),(DOMAIN-SUFFIX,PROTOCOL.example.com))',
|
||||
'((OR,((PROTOCOL,TCP),(PROTOCOL,UDP))),(DEST-PORT,443))',
|
||||
'((PROTOCOL,STUN),(DOMAIN,example.com))', '((PROTOCOL,DOH),(DOMAIN,example.com))',
|
||||
'((URL-REGEX,^https://example.com),(DOMAIN,example.com))',
|
||||
'((DOMAIN,example.com,extended-matching),(PROTOCOL,UDP))'];
|
||||
const result = generate({ ...base, rules: [...values.map((value): RuleIR => ({
|
||||
type: 'AND', value, policy: 'REJECT-NO-DROP', params: [], ruleset: null,
|
||||
})), final] });
|
||||
expect(YAML.parse(result.content).rules).toEqual([
|
||||
'AND,((NETWORK,UDP),(DOMAIN-SUFFIX,PROTOCOL.example.com)),REJECT',
|
||||
'AND,((OR,((NETWORK,TCP),(NETWORK,UDP))),(DST-PORT,443)),REJECT', 'MATCH,DIRECT',
|
||||
]);
|
||||
expect(result.skippedRules).toHaveLength(4);
|
||||
});
|
||||
|
||||
it.skipIf(!nativeAvailable)('passes the native mihomo parser with all node protocols, dynamic groups and nested logic', () => {
|
||||
const nodes: NodeDto[] = [node, { ...node, name: 'Trojan', protocol: 'trojan' },
|
||||
{ ...node, name: 'SS', protocol: 'shadowsocks' },
|
||||
{ ...node, name: 'HY2', protocol: 'hysteria2', network: 'tcp', obfsType: 'salamander', obfsPassword: 'placeholder-obfs' }];
|
||||
const ir: ProfileIR = { ...base, nodes, groups: [
|
||||
{ ...group, name: 'Source', filterRegex: null, includeAllNodes: false, members: [{ kind: 'node', name: node.name }] }, group,
|
||||
], rules: [{ type: 'AND', value: '((OR,((PROTOCOL,TCP),(PROTOCOL,UDP))),(DOMAIN,example.com))',
|
||||
policy: 'REJECT', params: [], ruleset: null }, final] };
|
||||
const file = path.join(directory, 'fixture.yaml');
|
||||
writeFileSync(file, generate(ir).content, { mode: 0o600 });
|
||||
const result = spawnSync(cli, ['-t', '-d', directory, '-f', file], { encoding: 'utf8', timeout: 15000 });
|
||||
expect(result.stdout + result.stderr).toContain('test is successful');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ function shadowrocketGeneral(surgeGeneral: string): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** ShadowRocket 不认识的规则类型 */
|
||||
/** 尚未验证 Shadowrocket 配置语法的类型;这是生成器的保守范围,不是客户端能力断言。 */
|
||||
const SR_UNSUPPORTED_RULE_TYPES = new Set(['DOMAIN-SET', 'AND', 'OR', 'NOT', 'URL-REGEX', 'PROTOCOL']);
|
||||
|
||||
export interface ShadowrocketConfOutput {
|
||||
@@ -70,14 +70,12 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput
|
||||
lines.push('', '[Proxy Group]');
|
||||
for (const g of ir.groups) {
|
||||
const parts: string[] = [g.type];
|
||||
if (!g.filterRegex && !g.includeAllNodes) {
|
||||
const members = g.members.map((m) => policyName(m.name));
|
||||
parts.push(...(members.length ? members : ['DIRECT']));
|
||||
}
|
||||
const members = [...new Set(g.members.map((m) => policyName(m.name)))];
|
||||
parts.push(...(members.length ? members : !g.filterRegex && !g.includeAllNodes ? ['DIRECT'] : []));
|
||||
if (g.type !== 'select') {
|
||||
parts.push(`url=${g.testUrl || 'http://cp.cloudflare.com/generate_204'}`);
|
||||
if (g.interval) parts.push(`interval=${g.interval}`);
|
||||
if (g.tolerance) parts.push(`tolerance=${g.tolerance}`);
|
||||
if (g.type === 'url-test' && g.tolerance !== null) parts.push(`tolerance=${g.tolerance}`);
|
||||
}
|
||||
// policy-regex-filter 从 App 内全部节点里筛选;「收纳全部节点」用全匹配正则
|
||||
if (g.filterRegex) parts.push(`policy-regex-filter=${g.filterRegex}`);
|
||||
|
||||
@@ -11,7 +11,9 @@ export interface ShadowrocketOutput {
|
||||
export function generateShadowrocket(ir: ProfileIR): ShadowrocketOutput {
|
||||
const excluded: ExcludedNode[] = [];
|
||||
const uris: string[] = [];
|
||||
for (const n of ir.nodes) {
|
||||
// 按完整名称的字符串顺序排列,保留国家、协议和出口后缀;不改 IR 的数据库顺序。
|
||||
const sortedNodes = [...ir.nodes].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
||||
for (const n of sortedNodes) {
|
||||
const verdict = clientSupports('shadowrocket', n);
|
||||
if (!verdict.ok) {
|
||||
excluded.push({ name: n.name, reason: verdict.reason! });
|
||||
|
||||
@@ -48,4 +48,14 @@ describe('AI-only Surge DNS', () => {
|
||||
expect(dns.host.match(/new\.example =/g)).toHaveLength(1);
|
||||
expect(dns.host).not.toContain('old.example');
|
||||
});
|
||||
it('keeps explicit Host and earlier routing exceptions ahead of broad AI mappings', () => {
|
||||
const base = { ...ir, rules: [rule('RULE-SET', 'unbreak', 'DIRECT'), rule('DOMAIN', 'api.ai.example', 'DIRECT'), rule('DOMAIN-SUFFIX', 'ai.example')] };
|
||||
base.rules[0].ruleset = { ...ir.rules[3].ruleset!, surgeUrl: 'https://example.com/unbreak.list' };
|
||||
base.sections = { ...ir.sections, Host: 'update.ai.example = server:119.29.29.29' };
|
||||
const dns = surgeAiDns(base);
|
||||
expect(dns.host.indexOf('update.ai.example =')).toBeLessThan(dns.host.indexOf('*.ai.example ='));
|
||||
expect(dns.host.indexOf('RULE-SET:https://example.com/unbreak.list')).toBeLessThan(dns.host.indexOf('*.ai.example ='));
|
||||
expect(dns.host).toContain('api.ai.example = server:https://doh.pub/dns-query, https://dns.alidns.com/dns-query');
|
||||
expect(dns.host).not.toContain('api.ai.example = server:https://1.1.1.1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ProfileIR } from '../ir.js';
|
||||
import { surgeValue } from './surge-format.js';
|
||||
|
||||
const AI_DNS = 'https://1.1.1.1/dns-query';
|
||||
|
||||
@@ -9,18 +10,26 @@ export function surgeAiDns(ir: ProfileIR): { general: string; host: string; rule
|
||||
const ai = ir.groups.find((g) => /^[^\p{L}\p{N}]*AI$/u.test(g.name));
|
||||
if (!ai) return { general, host, rules: [] };
|
||||
|
||||
const mappings = new Set<string>();
|
||||
for (const rule of ir.rules) {
|
||||
if (rule.policy !== ai.name || !rule.value) continue;
|
||||
const aiRules = ir.rules.map((r, i) => r.policy === ai.name && ['DOMAIN', 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD', 'RULE-SET', 'DOMAIN-SET'].includes(r.type) ? i : -1).filter((i) => i >= 0);
|
||||
if (!aiRules.length) return { general, host, rules: [] };
|
||||
const defaultDns = general.match(/^encrypted-dns-server\s*=\s*(.+)$/mi)?.[1]
|
||||
?? general.match(/^dns-server\s*=\s*(.+)$/mi)?.[1] ?? 'system';
|
||||
const mappings = new Map<string, string>();
|
||||
// Preserve the domain-only ordering before AI, including remote exceptions.
|
||||
// Host cannot express protocol/process/IP conditions; those remain routing rules.
|
||||
for (const rule of ir.rules.slice(0, aiRules.at(-1)! + 1)) {
|
||||
if (!rule.value) continue;
|
||||
const value = rule.ruleset?.surgeUrl ?? rule.value;
|
||||
const resolver = rule.policy === ai.name ? AI_DNS : defaultDns;
|
||||
const add = (pattern: string) => { if (!mappings.has(pattern)) mappings.set(pattern, resolver); };
|
||||
switch (rule.type) {
|
||||
case 'DOMAIN': mappings.add(value); break;
|
||||
case 'DOMAIN': add(value); break;
|
||||
// *.example.com alone does not include the apex hostname in Surge Host syntax.
|
||||
case 'DOMAIN-SUFFIX': mappings.add(value); mappings.add(`*.${value}`); break;
|
||||
case 'DOMAIN-KEYWORD': mappings.add(`*${value}*`); break;
|
||||
case 'DOMAIN-SUFFIX': add(value); add(`*.${value}`); break;
|
||||
case 'DOMAIN-KEYWORD': add(`*${value}*`); break;
|
||||
case 'RULE-SET':
|
||||
case 'DOMAIN-SET':
|
||||
if (/^https?:\/\//.test(value)) mappings.add(`${rule.type}:${value}`);
|
||||
if (/^https?:\/\//.test(value)) add(`${rule.type}:${value}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -29,13 +38,13 @@ export function surgeAiDns(ir: ProfileIR): { general: string; host: string; rule
|
||||
const updatedGeneral = /^encrypted-dns-follow-outbound-mode\s*=/mi.test(general)
|
||||
? general.replace(/^encrypted-dns-follow-outbound-mode\s*=.*$/gmi, 'encrypted-dns-follow-outbound-mode = true')
|
||||
: `${general}\nencrypted-dns-follow-outbound-mode = true`;
|
||||
const generatedHost = [...mappings].map((pattern) => `${pattern} = server:${AI_DNS}`).join('\n');
|
||||
const name = /[\s,]/.test(ai.name) ? `"${ai.name}"` : ai.name;
|
||||
const generatedHost = [...mappings].map(([pattern, resolver]) => `${pattern} = server:${resolver}`).join('\n');
|
||||
const name = surgeValue(ai.name);
|
||||
|
||||
return {
|
||||
general: updatedGeneral,
|
||||
// AI mappings take precedence over the template's ordinary per-domain DNS mappings.
|
||||
host: `# AI DNS: generated from enabled AI routing rules\n${generatedHost}\n${host}`.trim(),
|
||||
// Deliberate Host overrides (including precise DNS exceptions) stay first.
|
||||
host: `${host}\n# DNS: domain rule order through the last AI rule\n${generatedHost}`.trim(),
|
||||
rules: [
|
||||
`IP-CIDR,1.1.1.1/32,${name},no-resolve`,
|
||||
// Keep the upstream template's default DoH on the local path.
|
||||
|
||||
21
server/src/services/generators/surge-format.ts
Normal file
21
server/src/services/generators/surge-format.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** Surge Mac 6.8+ quoted value escaping; logical-rule expressions stay structural. */
|
||||
export function surgeValue(value: string): string {
|
||||
if (/[\x00-\x1f\x7f\u2028\u2029]/.test(value)) throw new Error('Surge value contains a control character');
|
||||
return /[\s,"'\\#;]/.test(value)
|
||||
? `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||
: value;
|
||||
}
|
||||
|
||||
export function surgeDeclaration(name: string): string {
|
||||
if (!name || name !== name.trim() || /[=,"'\\#;\[\]\x00-\x1f\x7f\u2028\u2029]/.test(name)) {
|
||||
throw new Error('Unsafe Surge policy declaration name');
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export function surgeParam(param: string): string {
|
||||
const index = param.indexOf('=');
|
||||
const key = index < 0 ? param : param.slice(0, index);
|
||||
if (!/^[a-z][a-z0-9-]*$/i.test(key)) throw new Error('Unsafe Surge parameter name');
|
||||
return index < 0 ? key : `${key}=${surgeValue(param.slice(index + 1))}`;
|
||||
}
|
||||
67
server/src/services/generators/surge-safety.test.ts
Normal file
67
server/src/services/generators/surge-safety.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { clientSupports, nodeInputSchema, type NodeDto } from '@proxy-station/shared';
|
||||
import { generateSurge, surgeNodeLine } from './surge.js';
|
||||
import type { ProfileIR } from '../ir.js';
|
||||
|
||||
const node: NodeDto = {
|
||||
...nodeInputSchema.parse({ name: 'JP VMess', protocol: 'vmess', server: 'example.com', port: 443, uuid: '00000000-0000-0000-0000-000000000000', tls: true }),
|
||||
id: 'fixture', createdAt: 0, updatedAt: 0, extra: {},
|
||||
} as NodeDto;
|
||||
const base: ProfileIR = { nodes: [node], groups: [], rules: [{ type: 'FINAL', value: null, policy: 'DIRECT', params: [], ruleset: null }], sections: {} };
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-surge-'));
|
||||
const cli = process.env.SURGE_CLI ?? 'surge-cli';
|
||||
const nativeAvailable = spawnSync(cli, ['--help'], { timeout: 10000 }).status === 0;
|
||||
afterAll(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
describe('Surge safe serialization and supported semantics', () => {
|
||||
it('preserves literals and every filtered group source, including with include-all', () => {
|
||||
const groups: ProfileIR['groups'] = ['Source A', 'Source B'].map((name) => ({ name, type: 'select', members: [{ kind: 'node', name: node.name }], includeAllNodes: false, filterRegex: null, testUrl: null, interval: null, tolerance: null }));
|
||||
groups.push({ ...groups[0], name: 'Filtered', includeAllNodes: true, filterRegex: '^JP|SG', members: [{ kind: 'builtin', name: 'DIRECT' }, { kind: 'group', name: 'Source A' }, { kind: 'group', name: 'Source B' }] });
|
||||
const content = generateSurge({ ...base, groups }, { selfUrl: 'https://example.com/sub/test/surge' }).content;
|
||||
expect(content).toContain('Filtered = select, DIRECT, include-all-proxies=true, include-other-group="Source A,Source B", policy-regex-filter=^JP|SG');
|
||||
const all = { ...groups[0], name: 'All', includeAllNodes: true, members: [{ kind: 'builtin' as const, name: 'DIRECT' }] };
|
||||
expect(generateSurge({ ...base, groups: [all] }, { selfUrl: 'https://example.com/sub/test/surge' }).content).toContain('All = select, DIRECT, "JP VMess"');
|
||||
});
|
||||
|
||||
it('maps TLS and cipher flags and reports unsupported VMess combinations', () => {
|
||||
expect(surgeNodeLine({ ...node, skipCertVerify: true, security: 'chacha20-poly1305' })).toContain('skip-cert-verify=true, vmess-aead=true, encrypt-method=chacha20-ietf-poly1305');
|
||||
expect(clientSupports('surge', { ...node, alterId: 64 }).ok).toBe(false);
|
||||
expect(clientSupports('surge', { ...node, security: 'none' }).ok).toBe(false);
|
||||
const result = generateSurge({ ...base, nodes: [{ ...node, alterId: 64 }] }, { selfUrl: 'https://example.com/sub/test/surge' });
|
||||
expect(result.excluded).toHaveLength(1);
|
||||
expect(result.content).not.toContain('JP VMess =');
|
||||
});
|
||||
|
||||
it('uses one trailing FINAL and omits obsolete group url and fallback tolerance', () => {
|
||||
const groups: ProfileIR['groups'] = [{ name: 'Test', type: 'fallback', members: [{ kind: 'node', name: node.name }], includeAllNodes: false, filterRegex: null, testUrl: 'https://example.com/test', interval: 300, tolerance: 100 }];
|
||||
const content = generateSurge({ ...base, groups, rules: [base.rules[0], { type: 'DOMAIN', value: 'example.org', policy: 'DIRECT', params: [], ruleset: null }] }, { selfUrl: 'https://example.com/sub/test/surge' }).content;
|
||||
expect(content).not.toContain('url=');
|
||||
expect(content).not.toContain('tolerance=');
|
||||
expect(content).toContain('DOMAIN,example.org,DIRECT\nFINAL,DIRECT');
|
||||
expect(generateSurge({ ...base, rules: [] }, { selfUrl: 'https://example.com/sub/test/surge' }).content).toContain('FINAL,DIRECT');
|
||||
});
|
||||
|
||||
it.skipIf(!nativeAvailable)('passes the official local CLI with commas, regex escaping, filtered imports and TLS flags', () => {
|
||||
const groups: ProfileIR['groups'] = ['A', 'B'].map((name) => ({ name, type: 'select', members: [{ kind: 'node', name: node.name }], includeAllNodes: false, filterRegex: null, testUrl: null, interval: null, tolerance: null }));
|
||||
groups.push({ ...groups[0], name: 'Filtered', includeAllNodes: true, filterRegex: '^(JP|SG),?', members: [{ kind: 'builtin', name: 'DIRECT' }, { kind: 'group', name: 'A' }, { kind: 'group', name: 'B' }] });
|
||||
const cases: ProfileIR[] = [
|
||||
{ ...base, groups },
|
||||
{ ...base, rules: [{ type: 'URL-REGEX', value: '^https://example.com/(a|b),?c\\d', policy: 'DIRECT', params: [], ruleset: null }, ...base.rules] },
|
||||
{ ...base, nodes: [{ ...node, protocol: 'trojan', password: 'placeholder,with"quote\\backslash', network: 'ws', wsPath: '/a,b', wsHost: 'example.com' }] },
|
||||
{ ...base, nodes: [{ ...node, skipCertVerify: true, security: 'chacha20-poly1305' }] },
|
||||
{ ...base, rules: [{ type: 'AND', value: '((PROTOCOL,UDP),(DOMAIN-SUFFIX,example.com))', policy: 'REJECT-NO-DROP', params: ['notification-text=matched, example'], ruleset: null }, ...base.rules] },
|
||||
{ ...base, rules: [] },
|
||||
];
|
||||
for (const [i, ir] of cases.entries()) {
|
||||
const file = path.join(directory, `${i}.conf`);
|
||||
writeFileSync(file, generateSurge(ir, { selfUrl: 'https://example.com/sub/test/surge' }).content, { mode: 0o600 });
|
||||
const result = spawnSync(cli, ['--check', file], { encoding: 'utf8', timeout: 15000 });
|
||||
expect(result.stdout + result.stderr).toContain('OK');
|
||||
expect(result.status).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,54 +1,54 @@
|
||||
import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
|
||||
import type { ProfileIR } from '../ir.js';
|
||||
import { surgeAiDns } from './surge-ai-dns.js';
|
||||
|
||||
/** 成员名含空格/逗号时按 Surge 习惯加引号 */
|
||||
function quoteName(name: string): string {
|
||||
return /[\s,]/.test(name) ? `"${name}"` : name;
|
||||
}
|
||||
import { surgeDeclaration, surgeParam, surgeValue as quoteName } from './surge-format.js';
|
||||
|
||||
export function surgeNodeLine(n: NodeDto): string {
|
||||
const parts: string[] = [];
|
||||
switch (n.protocol) {
|
||||
case 'vmess': {
|
||||
parts.push('vmess', n.server, String(n.port), `username=${n.uuid}`);
|
||||
const verdict = clientSupports('surge', n);
|
||||
if (!verdict.ok) throw new Error(verdict.reason);
|
||||
parts.push('vmess', quoteName(n.server), String(n.port), `username=${n.uuid}`);
|
||||
if (n.network === 'ws') {
|
||||
parts.push('ws=true', `ws-path=${n.wsPath}`);
|
||||
if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`);
|
||||
parts.push('ws=true', `ws-path=${quoteName(n.wsPath || '/')}`);
|
||||
if (n.wsHost) parts.push(`ws-headers=${quoteName(`Host:${n.wsHost}`)}`);
|
||||
}
|
||||
if (n.tls) {
|
||||
parts.push('tls=true');
|
||||
if (n.sni) parts.push(`sni=${n.sni}`);
|
||||
if (n.sni) parts.push(`sni=${quoteName(n.sni)}`);
|
||||
if (n.skipCertVerify) parts.push('skip-cert-verify=true');
|
||||
}
|
||||
// alterId=0 的 AEAD 头必须显式打开,否则 Surge 连不上且症状隐晦
|
||||
parts.push('vmess-aead=true');
|
||||
if (n.security !== 'auto') parts.push(`encrypt-method=${normalizeSsCipher(n.security)}`);
|
||||
break;
|
||||
}
|
||||
case 'trojan': {
|
||||
parts.push('trojan', n.server, String(n.port), `password=${n.password}`);
|
||||
parts.push('trojan', quoteName(n.server), String(n.port), `password=${quoteName(n.password || '')}`);
|
||||
if (n.network === 'ws') {
|
||||
parts.push('ws=true', `ws-path=${n.wsPath}`);
|
||||
if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`);
|
||||
parts.push('ws=true', `ws-path=${quoteName(n.wsPath || '/')}`);
|
||||
if (n.wsHost) parts.push(`ws-headers=${quoteName(`Host:${n.wsHost}`)}`);
|
||||
}
|
||||
if (n.sni) parts.push(`sni=${n.sni}`);
|
||||
if (n.sni) parts.push(`sni=${quoteName(n.sni)}`);
|
||||
if (n.skipCertVerify) parts.push('skip-cert-verify=true');
|
||||
break;
|
||||
}
|
||||
case 'shadowsocks': {
|
||||
parts.push('ss', n.server, String(n.port), `encrypt-method=${normalizeSsCipher(n.method || '')}`, `password=${n.password}`);
|
||||
parts.push('ss', quoteName(n.server), String(n.port), `encrypt-method=${quoteName(normalizeSsCipher(n.method || ''))}`, `password=${quoteName(n.password || '')}`);
|
||||
break;
|
||||
}
|
||||
case 'hysteria2': {
|
||||
parts.push('hysteria2', n.server, String(n.port), `password=${n.password}`);
|
||||
if (n.sni) parts.push(`sni=${n.sni}`);
|
||||
parts.push('hysteria2', quoteName(n.server), String(n.port), `password=${quoteName(n.password || '')}`);
|
||||
if (n.sni) parts.push(`sni=${quoteName(n.sni)}`);
|
||||
// Surge 的 salamander 混淆参数名(sub-router 生产验证过)
|
||||
if (n.obfsType === 'salamander' && n.obfsPassword) parts.push(`salamander-password=${n.obfsPassword}`);
|
||||
if (n.obfsType === 'salamander' && n.obfsPassword) parts.push(`salamander-password=${quoteName(n.obfsPassword)}`);
|
||||
if (n.downMbps) parts.push(`download-bandwidth=${n.downMbps}`);
|
||||
if (n.skipCertVerify) parts.push('skip-cert-verify=true');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return `${n.name} = ${parts.join(', ')}`;
|
||||
return `${surgeDeclaration(n.name)} = ${parts.join(', ')}`;
|
||||
}
|
||||
|
||||
export interface SurgeOutput {
|
||||
@@ -83,39 +83,38 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu
|
||||
.filter((m) => m.kind !== 'node' || usableNames.has(m.name))
|
||||
.map((m) => quoteName(m.name));
|
||||
if (g.filterRegex) {
|
||||
parts.push(...g.members.filter((m) => m.kind !== 'group' && (m.kind !== 'node' || usableNames.has(m.name))).map((m) => quoteName(m.name)));
|
||||
// 按名称正则动态筛选:节点来源可以是全部节点,也可以是另一个组
|
||||
if (g.includeAllNodes) {
|
||||
parts.push('include-all-proxies=true');
|
||||
} else {
|
||||
const source = g.members.find((m) => m.kind === 'group');
|
||||
if (source) parts.push(`include-other-group=${quoteName(source.name)}`);
|
||||
}
|
||||
parts.push(`policy-regex-filter=${g.filterRegex}`);
|
||||
const sources = g.members.filter((m) => m.kind === 'group').map((m) => m.name);
|
||||
if (sources.length) parts.push(`include-other-group=${quoteName(sources.join(','))}`);
|
||||
parts.push(`policy-regex-filter=${quoteName(g.filterRegex)}`);
|
||||
} else if (g.includeAllNodes) {
|
||||
// 「全部节点」组:显式展开当前启用且 Surge 支持的节点
|
||||
parts.push(...usable.map((n) => quoteName(n.name)), ...memberNames.filter((m) => !usableNames.has(m)));
|
||||
parts.push(...[...new Set([...g.members.filter((m) => m.kind !== 'node' || usableNames.has(m.name)).map((m) => m.name), ...usable.map((n) => n.name)])].map(quoteName));
|
||||
if (usable.length === 0) parts.push('DIRECT');
|
||||
} else {
|
||||
parts.push(...(memberNames.length ? memberNames : ['DIRECT']));
|
||||
}
|
||||
if (g.type !== 'select') {
|
||||
parts.push(`url=${g.testUrl || 'http://cp.cloudflare.com/generate_204'}`);
|
||||
// Surge uses per-node test-url or General.proxy-test-url, never group url=.
|
||||
if (g.interval) parts.push(`interval=${g.interval}`);
|
||||
if (g.tolerance) parts.push(`tolerance=${g.tolerance}`);
|
||||
if (g.type === 'url-test' && g.tolerance !== null) parts.push(`tolerance=${g.tolerance}`);
|
||||
}
|
||||
lines.push(`${g.name} = ${parts.join(', ')}`);
|
||||
lines.push(`${surgeDeclaration(g.name)} = ${parts.join(', ')}`);
|
||||
}
|
||||
|
||||
lines.push('', '[Rule]');
|
||||
lines.push(...dns.rules);
|
||||
for (const r of ir.rules) {
|
||||
if (r.type === 'FINAL') {
|
||||
lines.push(['FINAL', quoteName(r.policy), ...r.params].join(','));
|
||||
continue;
|
||||
}
|
||||
for (const r of ir.rules.filter((r) => r.type !== 'FINAL')) {
|
||||
const value = r.ruleset ? r.ruleset.surgeUrl : r.value;
|
||||
lines.push([r.type, value, quoteName(r.policy), ...r.params].join(','));
|
||||
const encoded = ['AND', 'OR', 'NOT'].includes(r.type) ? value : quoteName(value || '');
|
||||
lines.push([r.type, encoded, quoteName(r.policy), ...r.params.map(surgeParam)].join(','));
|
||||
}
|
||||
const final = ir.rules.find((r) => r.type === 'FINAL');
|
||||
lines.push(['FINAL', quoteName(final?.policy ?? 'DIRECT'), ...(final?.params ?? []).map(surgeParam)].join(','));
|
||||
|
||||
for (const section of ['Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) {
|
||||
const content = section === 'Host' ? dns.host : ir.sections[section];
|
||||
|
||||
28
server/src/services/group-validation.ts
Normal file
28
server/src/services/group-validation.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { BUILTIN_POLICIES, type GroupInput } from '@proxy-station/shared';
|
||||
|
||||
interface ExistingGroup { id: string; name: string; members: { kind: string; refName: string | null }[] }
|
||||
|
||||
/** Validate the proposed graph, including references rewritten by a rename. */
|
||||
export function groupError(input: GroupInput, groups: ExistingGroup[], nodes: { id: string; name: string }[], id?: string): string | null {
|
||||
if ((BUILTIN_POLICIES as readonly string[]).includes(input.name) || nodes.some((n) => n.name === input.name)) return '策略组名称与节点或内置策略冲突';
|
||||
if (groups.some((g) => g.id !== id && g.name === input.name)) return '策略组名称已存在';
|
||||
const oldName = groups.find((g) => g.id === id)?.name;
|
||||
const rename = (name: string) => name === oldName ? input.name : name;
|
||||
const graph = new Map(groups.filter((g) => g.id !== id).map((g) => [g.name, g.members.filter((m) => m.kind === 'group' && m.refName).map((m) => rename(m.refName!))]));
|
||||
graph.set(input.name, (input.members ?? []).filter((m) => m.kind === 'group' && m.refName).map((m) => rename(m.refName!)));
|
||||
for (const m of input.members ?? []) {
|
||||
if (m.kind === 'node' && !nodes.some((n) => n.id === m.nodeId)) return '成员节点不存在';
|
||||
if (m.kind === 'group' && (!m.refName || !graph.has(rename(m.refName)))) return '成员策略组不存在';
|
||||
if (m.kind === 'builtin' && !(BUILTIN_POLICIES as readonly (string | null | undefined)[]).includes(m.refName)) return '内置策略无效';
|
||||
}
|
||||
const visiting = new Set<string>();
|
||||
const done = new Set<string>();
|
||||
function cyclic(name: string): boolean {
|
||||
if (visiting.has(name)) return true;
|
||||
if (done.has(name)) return false;
|
||||
visiting.add(name);
|
||||
if ((graph.get(name) ?? []).some(cyclic)) return true;
|
||||
visiting.delete(name); done.add(name); return false;
|
||||
}
|
||||
return [...graph.keys()].some(cyclic) ? '策略组不能循环引用' : null;
|
||||
}
|
||||
@@ -99,13 +99,15 @@ export function buildProfileIR(opts: { nodeIds?: string[] } = {}): ProfileIR {
|
||||
const ruleRows = db
|
||||
.select()
|
||||
.from(rules)
|
||||
.where(eq(rules.enabled, true))
|
||||
.orderBy(asc(rules.seq))
|
||||
.all()
|
||||
.filter((r) => r.type === 'FINAL' || !isDeadRef(r.policy))
|
||||
// FINAL 必须保留:其策略组被停用或删除时兜底 DIRECT
|
||||
.map((r) => (r.type === 'FINAL' && isDeadRef(r.policy) ? { ...r, policy: 'DIRECT' } : r));
|
||||
const ruleIRs: RuleIR[] = ruleRows.map((r) => {
|
||||
.all();
|
||||
// Defensive repair of legacy/corrupt states: exactly one FINAL, always last.
|
||||
// Management writes separately reject edits that would break this invariant.
|
||||
const storedFinal = ruleRows.find((r) => r.type === 'FINAL' && r.enabled)
|
||||
?? ruleRows.find((r) => r.type === 'FINAL');
|
||||
const activeRows = ruleRows.filter((r) => r.enabled && r.type !== 'FINAL')
|
||||
.filter((r) => !isDeadRef(r.policy));
|
||||
const ruleIRs: RuleIR[] = activeRows.map((r) => {
|
||||
const rs = r.value ? setRows.get(r.value) : undefined;
|
||||
return {
|
||||
type: r.type,
|
||||
@@ -115,6 +117,11 @@ export function buildProfileIR(opts: { nodeIds?: string[] } = {}): ProfileIR {
|
||||
ruleset: rs ?? null,
|
||||
};
|
||||
});
|
||||
ruleIRs.push({
|
||||
type: 'FINAL', value: null,
|
||||
policy: storedFinal && !isDeadRef(storedFinal.policy) ? storedFinal.policy : 'DIRECT',
|
||||
params: storedFinal ? JSON.parse(storedFinal.params) : [], ruleset: null,
|
||||
});
|
||||
|
||||
const sections: Record<string, string> = {};
|
||||
for (const key of ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) {
|
||||
|
||||
Reference in New Issue
Block a user