mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 23:26:38 +08:00
85d5c8568c
Use React Flow controlled node state, support deleting nodes and edges, and show node properties only after selection.
151 lines
8.4 KiB
TypeScript
151 lines
8.4 KiB
TypeScript
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<Record<WAFRuleNode['type'], string[]>> = {
|
|
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<string>();
|
|
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<string>();
|
|
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<string, string[]>): Set<string> {
|
|
const seen = new Set<string>();
|
|
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)};
|
|
}
|