import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare'; export type GraphIssueCode = | 'schema' | 'size_limit' | 'empty_id' | 'duplicate_node_id' | 'duplicate_edge_id' | 'start_count' | 'allow_count' | 'start_incoming' | 'missing_handle' | 'duplicate_handle' | 'invalid_edge' | 'invalid_config' | 'cycle' | 'unreachable' | 'non_terminating'; export interface GraphIssue { code: GraphIssueCode; message: string; nodeId?: string; edgeId?: string; } const handles: Partial> = { start: ['next'], ip_match: ['true', 'false'], geo_match: ['true', 'false'], pow: ['next'], }; export function validateGraph(graph: WAFRuleGraph): GraphIssue[] { const issues: GraphIssue[] = []; const nodeMap = new Map(graph.nodes.map((node) => [node.id, node])); if (graph.schema_version !== 1) issues.push({ code: 'schema', message: '规则图 schema_version 必须为 1' }); if ( graph.nodes.length > 128 || graph.edges.length > 256 || new TextEncoder().encode(JSON.stringify(graph)).length > 256 * 1024 ) issues.push({ code: 'size_limit', message: '规则图超过大小限制' }); const nodeIds = new Set(); for (const node of graph.nodes) { if (!node.id.trim()) issues.push({ code: 'empty_id', message: '节点 ID 不能为空', nodeId: node.id, }); if (nodeIds.has(node.id)) issues.push({ code: 'duplicate_node_id', message: `节点 ID ${node.id} 重复`, nodeId: node.id, }); nodeIds.add(node.id); const configIssue = validateNodeConfig(node); if (configIssue) issues.push({ code: 'invalid_config', message: configIssue, nodeId: node.id, }); } const edgeIds = new Set(); for (const edge of graph.edges) { if (!edge.id.trim()) issues.push({ code: 'empty_id', message: '连线 ID 不能为空', edgeId: edge.id, }); if (edgeIds.has(edge.id)) issues.push({ code: 'duplicate_edge_id', message: `连线 ID ${edge.id} 重复`, edgeId: edge.id, }); edgeIds.add(edge.id); if (nodeMap.get(edge.target)?.type === 'start') issues.push({ code: 'start_incoming', message: '开始节点不能有入边', edgeId: edge.id, nodeId: edge.target, }); } if (graph.nodes.filter((node) => node.type === 'start').length !== 1) issues.push({ code: 'start_count', message: '规则图必须恰好有一个开始节点', }); if (graph.nodes.filter((node) => node.type === 'allow').length !== 1) issues.push({ code: 'allow_count', message: '规则图必须恰好有一个通过节点', }); for (const edge of graph.edges) { const source = nodeMap.get(edge.source); if ( !source || !nodeMap.has(edge.target) || !(handles[source.type] ?? []).includes(edge.source_handle) ) { issues.push({ code: 'invalid_edge', message: `连线 ${edge.id} 的端点或出口无效`, edgeId: edge.id, }); } } for (const node of graph.nodes) { for (const handle of handles[node.type] ?? []) { const outgoing = graph.edges.filter( (edge) => edge.source === node.id && edge.source_handle === handle, ); if (outgoing.length === 0) issues.push({ code: 'missing_handle', message: `节点 ${node.id} 的 ${handle} 出口未连接`, nodeId: node.id, }); if (outgoing.length > 1) issues.push({ code: 'duplicate_handle', message: `节点 ${node.id} 的 ${handle} 出口只能连接一次`, nodeId: node.id, }); } } const adjacency = new Map( graph.nodes.map((node) => [node.id, [] as string[]]), ); const reverse = new Map(graph.nodes.map((node) => [node.id, [] as string[]])); for (const edge of graph.edges) { adjacency.get(edge.source)?.push(edge.target); reverse.get(edge.target)?.push(edge.source); } const start = graph.nodes.find((node) => node.type === 'start'); const reachable = walk(start ? [start.id] : [], adjacency); for (const node of graph.nodes) if (!reachable.has(node.id)) issues.push({ code: 'unreachable', message: `节点 ${node.id} 无法从开始节点到达`, nodeId: node.id, }); const terminals = graph.nodes .filter((node) => node.type === 'allow' || node.type === 'block') .map((node) => node.id); const canTerminate = walk(terminals, reverse); for (const node of graph.nodes) if (!canTerminate.has(node.id)) issues.push({ code: 'non_terminating', message: `节点 ${node.id} 无法抵达终止节点`, nodeId: node.id, }); if (hasCycle(graph)) issues.push({ code: 'cycle', message: '规则图不能包含循环' }); return issues; } function validateNodeConfig(node: WAFRuleNode): string | undefined { if (node.type === 'ip_match') { if (node.config.ips.some((value) => !isIP(value))) return `节点 ${node.id} 包含无效 IP`; if ( node.config.cidrs.some((value) => { const parts = value.split('/'); if (parts.length !== 2) return true; const [ip, bits] = parts; return ( !isIP(ip) || !/^\d+$/.test(bits) || Number(bits) > (ip.includes(':') ? 128 : 32) ); }) ) return `节点 ${node.id} 包含无效 CIDR`; if (node.config.ip_group_ids.some((id) => !Number.isInteger(id) || id <= 0)) return `节点 ${node.id} 包含无效 IP 组`; } if ( node.type === 'geo_match' && (node.config.countries.some((code) => !/^[A-Z]{2}$/.test(code)) || node.config.regions.some( (code) => !/^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(code), )) ) return `节点 ${node.id} 包含无效地域代码`; if ( node.type === 'pow' && (!['fast', 'slow'].includes(node.config.algorithm) || !isIntegerInRange(node.config.difficulty, 1, 16) || !isIntegerInRange(node.config.session_ttl, 60) || !isIntegerInRange(node.config.challenge_ttl, 30)) ) return `节点 ${node.id} 的 PoW 配置超出范围`; if ( node.type === 'block' && (!isIntegerInRange(node.config.status_code, 400, 599) || new TextEncoder().encode(node.config.response_body).length > 16 * 1024) ) return `节点 ${node.id} 的阻止响应配置无效`; return undefined; } function isIntegerInRange( value: number, min: number, max = Number.MAX_SAFE_INTEGER, ): boolean { return ( Number.isFinite(value) && Number.isInteger(value) && value >= min && value <= max ); } function isIP(value: string): boolean { if (value.includes(':')) return isIPv6(value); const parts = value.split('.'); return ( parts.length === 4 && parts.every( (part) => /^(0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255, ) ); } function isIPv6(value: string): boolean { if ( !/^[0-9a-f:.]+$/i.test(value) || value.includes(':::') || value.split('::').length > 2 ) return false; const compressed = value.includes('::'); const sections = value.split('::'); const groups = sections.flatMap((section) => section ? section.split(':') : [], ); let units = 0; for (let index = 0; index < groups.length; index++) { const group = groups[index]; if (group.includes('.')) { if (index !== groups.length - 1 || !isIP(group)) return false; units += 2; } else { if (!/^[0-9a-f]{1,4}$/i.test(group)) return false; units++; } } return compressed ? units < 8 : units === 8; } function walk(seeds: string[], links: Map): Set { const seen = new Set(); const stack = [...seeds]; while (stack.length) { const id = stack.pop()!; if (seen.has(id)) continue; seen.add(id); stack.push(...(links.get(id) ?? [])); } return seen; } function hasCycle(graph: WAFRuleGraph): boolean { const indegree = new Map(graph.nodes.map((node) => [node.id, 0])); for (const edge of graph.edges) if (indegree.has(edge.target) && indegree.has(edge.source)) indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1); const queue = [...indegree] .filter(([, degree]) => degree === 0) .map(([id]) => id); let visited = 0; while (queue.length) { const id = queue.shift()!; visited++; for (const edge of graph.edges.filter((item) => item.source === id)) { const next = (indegree.get(edge.target) ?? 0) - 1; indegree.set(edge.target, next); if (next === 0) queue.push(edge.target); } } return visited !== graph.nodes.length; } export function wouldCreateCycle( graph: WAFRuleGraph, source: string, target: string, ): boolean { if (source === target) return true; const adjacency = new Map( graph.nodes.map((node) => [node.id, [] as string[]]), ); for (const edge of graph.edges) adjacency.get(edge.source)?.push(edge.target); return walk([target], adjacency).has(source); } export function removeNodeFromGraph( graph: WAFRuleGraph, nodeId: string, ): WAFRuleGraph { return { ...graph, nodes: graph.nodes.filter((node) => node.id !== nodeId), edges: graph.edges.filter( (edge) => edge.source !== nodeId && edge.target !== nodeId, ), }; } export function removeEdgeFromGraph( graph: WAFRuleGraph, edgeId: string, ): WAFRuleGraph { return { ...graph, edges: graph.edges.filter((edge) => edge.id !== edgeId) }; }