This commit is contained in:
ryan
2026-07-13 15:08:40 +08:00
parent 08e8eea932
commit 1eff7878a1
441 changed files with 26491 additions and 18405 deletions
@@ -1,6 +1,21 @@
import type {WAFRuleGraph, WAFRuleNode} from '@/lib/services/openflare';
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 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;
@@ -10,47 +25,117 @@ export interface GraphIssue {
}
const handles: Partial<Record<WAFRuleNode['type'], string[]>> = {
start: ['next'], ip_match: ['true', 'false'], geo_match: ['true', 'false'], pow: ['next'],
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: '规则图超过大小限制'});
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});
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});
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});
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 (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: '规则图必须恰好有一个通过节点'});
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});
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 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 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);
@@ -58,39 +143,110 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
}
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);
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: '规则图不能包含循环'});
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.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} 的阻止响应配置无效`;
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 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);
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;
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(':') : []);
const groups = sections.flatMap((section) =>
section ? section.split(':') : [],
);
let units = 0;
for (let index = 0; index < groups.length; index++) {
const group = groups[index];
@@ -119,8 +275,12 @@ function walk(seeds: string[], links: Map<string, string[]>): Set<string> {
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);
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()!;
@@ -134,17 +294,35 @@ function hasCycle(graph: WAFRuleGraph): boolean {
return visited !== graph.nodes.length;
}
export function wouldCreateCycle(graph: WAFRuleGraph, source: string, target: string): boolean {
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[]]));
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 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)};
export function removeEdgeFromGraph(
graph: WAFRuleGraph,
edgeId: string,
): WAFRuleGraph {
return { ...graph, edges: graph.edges.filter((edge) => edge.id !== edgeId) };
}