feat: 合并边缘节点运维并完善 Surge 配置安全
迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。 完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。 验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
This commit is contained in:
@@ -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