Files
OpenFlare/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts
T
2026-07-13 15:10:28 +08:00

329 lines
9.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) };
}