feat: 合并边缘节点运维并完善 Surge 配置安全

迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。

完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。

验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
This commit is contained in:
2026-10-03 23:19:59 +08:00
parent 7358008936
commit 5326c572a2
93 changed files with 6780 additions and 320 deletions

View File

@@ -25,6 +25,8 @@ export function normalizeSsCipher(method: string): string {
export interface NodeCapabilityInput {
protocol: Protocol;
network: 'tcp' | 'ws';
alterId?: number;
security?: string;
}
export interface CapabilityVerdict {
@@ -40,5 +42,11 @@ export function clientSupports(client: ClientType, node: NodeCapabilityInput): C
if (client === 'surge' && node.protocol === 'shadowsocks' && node.network === 'ws') {
return { ok: false, reason: 'Surge 不支持 Shadowsocks over WebSocket' };
}
if (client === 'surge' && node.protocol === 'vmess') {
if ((node.alterId ?? 0) !== 0) return { ok: false, reason: 'Surge 下发仅支持 alterId=0 的 VMess AEAD 节点' };
if (!['auto', 'aes-128-gcm', 'chacha20-poly1305', 'chacha20-ietf-poly1305'].includes(node.security ?? 'auto')) {
return { ok: false, reason: `Surge 不支持该 VMess 加密方式:${node.security}` };
}
}
return { ok: true };
}

40
shared/src/rule-syntax.ts Normal file
View File

@@ -0,0 +1,40 @@
/** Split structural commas while preserving quoted values and nested expressions. */
function split(value: string): string[] | null {
const parts: string[] = [];
let start = 0, depth = 0, quote = '', escaped = false;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (escaped) { escaped = false; continue; }
if (quote) {
if (ch === '\\') escaped = true;
else if (ch === quote) quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; continue; }
if (ch === '#' || ch === ';') return null;
if (ch === '(') depth++;
if (ch === ')' && --depth < 0) return null;
if (ch === ',' && depth === 0) { parts.push(value.slice(start, i).trim()); start = i + 1; }
}
if (depth || quote || escaped) return null;
parts.push(value.slice(start).trim());
return parts;
}
const leafTypes = new Set(['DOMAIN', 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD', 'DOMAIN-WILDCARD', 'RULE-SET', 'DOMAIN-SET', 'IP-CIDR', 'IP-CIDR6', 'IP-ASN', 'GEOIP', 'PROCESS-NAME', 'PROTOCOL', 'USER-AGENT', 'URL-REGEX', 'SRC-IP', 'DEST-PORT', 'SRC-PORT', 'SUBNET', 'SCRIPT', 'HOSTNAME-TYPE', 'CELLULAR-CARRIER', 'CELLULAR-RADIO']);
export const SURGE_PROTOCOLS = ['HTTP', 'HTTPS', 'TCP', 'UDP', 'QUIC', 'STUN', 'MTProto', 'DOH', 'DOH3', 'DOQ', 'DOT', 'DNS'];
/** Surge logical sub-rules have no policy; NOT has one child, nesting at most 10. */
export function logicalValueValid(operator: string, value: string, depth = 1): boolean {
value = value.trim();
if (depth > 10 || !value.startsWith('(') || !value.endsWith(')')) return false;
const children = split(value.slice(1, -1));
if (!children?.length || (operator === 'NOT' && children.length !== 1)) return false;
return children.every((child) => {
if (!child.startsWith('(') || !child.endsWith(')')) return false;
const parts = split(child.slice(1, -1));
if (!parts || parts.length < 2 || !parts[1] || parts.slice(2).some((flag) => !['no-resolve', 'extended-matching'].includes(flag))) return false;
if (['AND', 'OR', 'NOT'].includes(parts[0])) return logicalValueValid(parts[0], parts[1], depth + 1);
return leafTypes.has(parts[0]) && (parts[0] !== 'PROTOCOL' || SURGE_PROTOCOLS.includes(parts[1]));
});
}

View File

@@ -1,25 +1,32 @@
import { z } from 'zod';
import { PROTOCOLS } from './protocols.js';
import { logicalValueValid, SURGE_PROTOCOLS } from './rule-syntax.js';
// These names are shared by Surge's declaration syntax and ShadowRocket's
// unquoted policy syntax. Reject delimiters rather than silently renaming them.
const singleLine = z.string().refine((v) => !/[\x00-\x1f\x7f\u2028\u2029]/.test(v), '不能包含换行或控制字符');
const policyName = singleLine.pipe(z.string().min(1).max(64))
.refine((v) => v === v.trim() && !/[=,"'\\#;\[\]]/.test(v), '名称不能包含配置分隔符、引号或首尾空格');
export const nodeInputSchema = z
.object({
name: z.string().min(1).max(64),
name: policyName,
protocol: z.enum(PROTOCOLS),
server: z.string().min(1),
server: singleLine.pipe(z.string().min(1)),
port: z.number().int().min(1).max(65535),
network: z.enum(['tcp', 'ws']).default('tcp'),
wsPath: z.string().startsWith('/').optional().nullable(),
wsHost: z.string().optional().nullable(),
wsPath: singleLine.pipe(z.string().startsWith('/')).optional().nullable(),
wsHost: singleLine.optional().nullable(),
tls: z.boolean().default(false),
sni: z.string().optional().nullable(),
sni: singleLine.optional().nullable(),
skipCertVerify: z.boolean().default(false),
uuid: z.string().uuid().optional().nullable(),
alterId: z.number().int().min(0).default(0),
security: z.string().default('auto'),
password: z.string().optional().nullable(),
method: z.string().optional().nullable(),
security: singleLine.default('auto'),
password: singleLine.optional().nullable(),
method: singleLine.optional().nullable(),
obfsType: z.enum(['salamander']).optional().nullable(),
obfsPassword: z.string().optional().nullable(),
obfsPassword: singleLine.optional().nullable(),
upMbps: z.number().int().positive().optional().nullable(),
downMbps: z.number().int().positive().optional().nullable(),
region: z.string().max(8).optional().nullable(),
@@ -46,12 +53,12 @@ export type NodeInput = z.input<typeof nodeInputSchema>;
export type NodeParsed = z.output<typeof nodeInputSchema>;
export const groupInputSchema = z.object({
name: z.string().min(1).max(64),
name: policyName,
type: z.enum(['select', 'url-test', 'fallback']),
testUrl: z.string().url().optional().nullable(),
interval: z.number().int().positive().optional().nullable(),
tolerance: z.number().int().positive().optional().nullable(),
filterRegex: z.string().optional().nullable(),
tolerance: z.number().int().nonnegative().optional().nullable(),
filterRegex: singleLine.refine((v) => { try { new RegExp(v); return true; } catch { return false; } }, '筛选正则无效').optional().nullable(),
includeAllNodes: z.boolean().default(false),
sortOrder: z.number().int().default(0),
members: z
@@ -59,7 +66,7 @@ export const groupInputSchema = z.object({
z.object({
kind: z.enum(['node', 'group', 'builtin']),
nodeId: z.string().optional().nullable(),
refName: z.string().optional().nullable(),
refName: policyName.optional().nullable(),
})
)
.default([]),
@@ -91,10 +98,28 @@ export const BUILTIN_POLICIES = ['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-D
export const ruleInputSchema = z.object({
type: z.enum(RULE_TYPES),
value: z.string().optional().nullable(),
policy: z.string().min(1),
params: z.array(z.string()).default([]),
value: singleLine.optional().nullable(),
policy: policyName,
params: z.array(singleLine.refine((v) => /^[a-z][a-z0-9-]*(?:=.+)?$/i.test(v), '规则参数需要有效的名称')).default([]),
enabled: z.boolean().default(true),
}).superRefine((v, ctx) => {
if (v.type !== 'FINAL' && !v.value?.trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['value'], message: '规则需要匹配值' });
}
if (v.type === 'FINAL' && (!v.enabled || v.value)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'FINAL 必须启用且不包含匹配值' });
}
if (v.type !== 'URL-REGEX' && !['AND', 'OR', 'NOT'].includes(v.type) && /[,"'\\#;]/.test(v.value ?? '')) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['value'], message: '该规则类型不接受配置分隔符' });
}
if (['AND', 'OR', 'NOT'].includes(v.type)) {
if (!logicalValueValid(v.type, v.value ?? '')) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['value'], message: '逻辑规则需要有效的无策略子规则与完整括号表达式' });
}
}
if (v.type === 'PROTOCOL' && !SURGE_PROTOCOLS.includes(v.value ?? '')) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['value'], message: 'PROTOCOL 关键字无效,注意大小写' });
}
});
export type RuleInput = z.input<typeof ruleInputSchema>;