Compare commits

..

No commits in common. "88fa8d3ad047aed93a34ff4a969ddf6a3f300651" and "260dcbb65279001145ca63a4e6e642d7f63c2d95" have entirely different histories.

13 changed files with 145 additions and 382 deletions

View File

@ -8,7 +8,7 @@
--- ---
- [x] 实现子网路由功能 - [ ] 实现子网路由功能
- [ ] 实现配置保存和加载功能(支持加密私钥) - [ ] 实现配置保存和加载功能(支持加密私钥)
--- ---

43
package-lock.json generated
View File

@ -10,20 +10,16 @@
"dependencies": { "dependencies": {
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"class-transformer": "^0.5.1",
"crypto-js": "^4.2.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-select": "^5.10.2", "react-select": "^5.10.2",
"react-switch": "^7.1.0", "react-switch": "^7.1.0",
"reflect-metadata": "^0.2.2",
"tweetnacl": "^1.0.3" "tweetnacl": "^1.0.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/base64-js": "^1.3.2", "@types/base64-js": "^1.3.2",
"@types/crypto-js": "^4.2.2",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
@ -65,6 +61,7 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.28.6", "@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6", "@babel/generator": "^7.28.6",
@ -1567,13 +1564,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/crypto-js": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-color": { "node_modules/@types/d3-color": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
@ -1648,6 +1638,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -1730,6 +1721,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -1869,6 +1861,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -1930,12 +1923,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT"
},
"node_modules/classcat": { "node_modules/classcat": {
"version": "5.0.5", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
@ -2016,17 +2003,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/d3-color": { "node_modules/d3-color": {
"version": "3.1.0", "version": "3.1.0",
@ -2085,6 +2067,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -2253,6 +2236,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -3071,6 +3055,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -3143,6 +3128,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -3152,6 +3138,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -3242,12 +3229,6 @@
"react-dom": ">=16.6.0" "react-dom": ">=16.6.0"
} }
}, },
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -3543,6 +3524,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -3664,6 +3646,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@ -12,20 +12,16 @@
"dependencies": { "dependencies": {
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"class-transformer": "^0.5.1",
"crypto-js": "^4.2.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-select": "^5.10.2", "react-select": "^5.10.2",
"react-switch": "^7.1.0", "react-switch": "^7.1.0",
"reflect-metadata": "^0.2.2",
"tweetnacl": "^1.0.3" "tweetnacl": "^1.0.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/base64-js": "^1.3.2", "@types/base64-js": "^1.3.2",
"@types/crypto-js": "^4.2.2",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",

View File

@ -18,20 +18,15 @@ import {
EdgeMouseHandler EdgeMouseHandler
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { AppNode, AppEdge, NodeData, EdgeData, NodeDataUpdate, EdgeDataUpdate } from './types/graph'; import { AppNode, AppEdge, NodeData, EdgeData, NodeDataUpdate, EdgeDataUpdate, Settings, initialSettings, SettingsContext } from './types/graph';
import {Settings, SettingsContext} from './types/settings'
import CustomNode from './components/CustomNode'; import CustomNode from './components/CustomNode';
import NodeEditor from './components/NodeEditor'; import NodeEditor from './components/NodeEditor';
import EdgeEditor from './components/EdgeEditor' import EdgeEditor from './components/EdgeEditor'
import SettingsEditor from './components/SettingsEditor' import SettingsEditor from './components/SettingsEditor'
import Toggle from "./components/Toggle" import Toggle from "./components/Toggle"
import { generateWireGuardPrivateKey } from './utils/wireguardConfig'; import { generateWireGuardPrivateKey } from './utils/wireguardConfig';
import CryptoJS from 'crypto-js';
import './App.css'; import './App.css';
import toast, { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import SaveConfig from './types/saveConfig'
import { plainToInstance, instanceToPlain } from 'class-transformer';
const initialNodes: AppNode[] = []; const initialNodes: AppNode[] = [];
const initialEdges: AppEdge[] = []; const initialEdges: AppEdge[] = [];
@ -53,7 +48,7 @@ function generateNodeData(count: number) : NodeData | null {
function FlowContent(): ReactNode { function FlowContent(): ReactNode {
const [nodes, setNodes] = useState<AppNode[]>(initialNodes); const [nodes, setNodes] = useState<AppNode[]>(initialNodes);
const [edges, setEdges] = useState<AppEdge[]>(initialEdges); const [edges, setEdges] = useState<AppEdge[]>(initialEdges);
const [settings, setSettings] = useState<Settings>(new Settings()); const [settings, setSettings] = useState<Settings>(initialSettings);
const [editingNode, setEditingNode] = useState<NodeData | undefined>(undefined); const [editingNode, setEditingNode] = useState<NodeData | undefined>(undefined);
const [editingEdge, setEditingEdge] = useState<EdgeData | undefined>(undefined); const [editingEdge, setEditingEdge] = useState<EdgeData | undefined>(undefined);
@ -147,70 +142,6 @@ function FlowContent(): ReactNode {
); );
}; };
const handleSaveConfig = async (): Promise<void> => {
try {
const pass = window.prompt('输入用于加密私钥的密码(留空则不加密):');
const saveConfig: SaveConfig = {settings: settings, nodes: [], edges: [], encrypted: false}
if(pass) saveConfig.encrypted = true;
nodes.forEach(node => {
const nodeData = node.data;
let privateKey = nodeData.privateKey;
if(pass) privateKey = CryptoJS.AES.encrypt(privateKey, pass).toString();
saveConfig.nodes.push({...node, data: {...nodeData, privateKey: privateKey}});
});
edges.forEach(edge => {
saveConfig.edges.push(edge);
});
const json = instanceToPlain(saveConfig);
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'wg-config.json';
a.click();
URL.revokeObjectURL(url);
} catch (e) {
toast.error('保存失败: ' + e);
}
};
const handleLoadConfig = async (): Promise<void> => {
try {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const jsonString = await file.text();
const plainObject = JSON.parse(jsonString);
const saveConfig: SaveConfig = plainToInstance(SaveConfig, plainObject);
if (saveConfig.encrypted) {
const pass = window.prompt('输入用于解密私钥的密码:');
if (!pass) { toast.error('需要密码以解密私钥'); return; }
try {
for(let node of saveConfig.nodes) {
node.data.privateKey = CryptoJS.AES.decrypt(node.data.privateKey, pass).toString(CryptoJS.enc.Utf8);
}
} catch (err) {
toast.error('解密失败: ' + err);
return;
}
}
setSettings(saveConfig.settings);
setNodes(saveConfig.nodes);
setEdges(saveConfig.edges);
};
input.click();
} catch (e) {
toast.error('加载失败: ' + e);
}
};
return ( return (
<div style={{ width: '100vw', height: '100vh' }}> <div style={{ width: '100vw', height: '100vh' }}>
<Toaster/> <Toaster/>
@ -256,14 +187,6 @@ function FlowContent(): ReactNode {
📋 📋
</button> </button>
<button className="toolbar-btn" onClick={handleSaveConfig} title="保存配置">
💾
</button>
<button className="toolbar-btn" onClick={handleLoadConfig} title="加载配置">
📂
</button>
</div> </div>
</div> </div>

View File

@ -1,9 +1,9 @@
import { ReactNode, useContext } from 'react'; import { ReactNode, useContext } from 'react';
import { Handle, Position, NodeProps, useReactFlow} from '@xyflow/react'; import { Handle, Position, NodeProps, useReactFlow} from '@xyflow/react';
import { AppNode, AppEdge, AppGraph, NodeData } from '../types/graph'; import { AppNode, AppEdge, AppGraph, NodeData } from '../types/graph';
import {Settings, SettingsContext, SubnetInfo} from '../types/settings' import {Settings, SettingsContext} from '../types/settings'
import StringBuilder from '../utils/StringBuilder'; import StringBuilder from '../utils/StringBuilder';
import { Result } from '../utils/Result';
import { CIDR, IPUtils } from '../utils/iputils'; import { CIDR, IPUtils } from '../utils/iputils';
import { tryDerivePublicKey } from '../utils/wireguardConfig' import { tryDerivePublicKey } from '../utils/wireguardConfig'
import './CustomNode.css'; import './CustomNode.css';
@ -17,6 +17,26 @@ class ConfigResult {
) {} ) {}
} }
// function mapAddressToCIDR(nodeIds : string[], getAddress: GetAddress) : Record<string, CIDR> {
// const nodeIdToCIDR : Record<string, CIDR> = {};
// for(const nodeId of nodeIds) {
// const address = getAddress(nodeId);
// if(!address) continue;
// const result = IPUtils.parse(address);
// const cidr = result.cidr;
// if(!cidr) {
// throw new Error("节点地址无效");
// }
// if(cidr.version === 'IPv4') {
// cidr.mask = 32;
// } else if(cidr.version === 'IPv6') {
// cidr.mask = 128;
// }
// nodeIdToCIDR[nodeId] = cidr;
// }
// return nodeIdToCIDR;
// }
function generateInterfaceConfig(settings: Settings, data: NodeData) : StringBuilder { function generateInterfaceConfig(settings: Settings, data: NodeData) : StringBuilder {
const address = [data.ipv4Address, data.ipv6Address].flatMap(p => p ? [p] : []).join(', '); const address = [data.ipv4Address, data.ipv6Address].flatMap(p => p ? [p] : []).join(', ');
const config = new StringBuilder(); const config = new StringBuilder();
@ -32,65 +52,8 @@ function generateInterfaceConfig(settings: Settings, data: NodeData) : StringBui
return config; return config;
} }
function getFromChildRouterMapper(subnet: SubnetInfo, graph: AppGraph, nodeId: string, disallowCIDRs: CIDR[]) : Result<Map<string, CIDR[]>> { function generateConfig(settings: Settings, data: NodeData, graph: AppGraph) : ConfigResult {
const subGraph = graph.getConnectedSubgraph(subnet.nodes.map(kv => kv.nodeId)); const config = generateInterfaceConfig(settings, data);
if(!subGraph)
return Result.Error(`图不连通,无法生存子网路由:${subnet.subnet.toString()}`);
const fromChildMapper: Map<string, string[]> = new Map();
const visited: Set<string> = new Set();
visited.add(nodeId);
const queue : string[] = [];
const children = subGraph.getChildren(nodeId);
children.forEach(child => {
fromChildMapper.set(child, [child]);
visited.add(child);
queue.push(child);
});
const allNodes = [];
while(queue.length > 0) {
const curr = queue.shift()!;
const fromChild = fromChildMapper.get(curr);
if(!fromChild) continue;
allNodes.push(curr);
const nexts = subGraph.getChildren(curr);
for(const next of nexts) {
if(visited.has(next)) continue;
fromChild.push(next);
visited.add(next);
queue.push(next);
}
}
const allCIDRs = allNodes.flatMap((nodeId: string) => {
const cidr = subnet.nodes.find(n => n.nodeId === nodeId)?.cidr;
return cidr ? [cidr] : [];
});
const result: Map<string, CIDR[]> = new Map();
for (const [fromChild, nodes] of fromChildMapper) {
const targetCIDRs = nodes.flatMap((nodeId: string) => {
const cidr = subnet.nodes.find(n => n.nodeId === nodeId)?.cidr;
if(!cidr || disallowCIDRs.some(disallow => disallow.contains(cidr))) return [];
return [cidr];
});
const mergeResult = IPUtils.mergeCIDRs(allCIDRs, targetCIDRs);
if(!mergeResult) {
return Result.Error("无法生成路由配置");
}
result.set(fromChild, mergeResult);
}
return Result.Result(result);
}
function generateConfig(settings: Settings, data: NodeData, graph: AppGraph, subnets: SubnetInfo[]) : ConfigResult {
const config: StringBuilder = generateInterfaceConfig(settings, data);
const disallowCIDRs : CIDR[] = []; const disallowCIDRs : CIDR[] = [];
if(data.disallowIPs) { if(data.disallowIPs) {
@ -104,126 +67,119 @@ function generateConfig(settings: Settings, data: NodeData, graph: AppGraph, sub
} }
} }
const allowIPsConfig: Map<string, string[]> = new Map(); const belongsToEdge : Record<string, string | undefined> = {[node.id]: node.id};
for(const subnet of subnets) { const queue : AppNode[] = [];
const result = getFromChildRouterMapper(subnet, graph, data.id, disallowCIDRs); const nearEdges = getNearEdges(node);
const mapper = result.result; nearEdges.forEach(edge => {
if(!mapper) return new ConfigResult(false, undefined, `路由生成失败:${result.errorInfo()}`); const nextNode = getNextNode(edge, node)!;
belongsToEdge[nextNode.id] = edge.id;
queue.push(nextNode);
});
for(const [fromChild, cidrs] of mapper) { while(queue.length > 0) {
if(!allowIPsConfig.has(fromChild)) allowIPsConfig.set(fromChild, []); const currentNode = queue.shift()!;
const allowIPs = allowIPsConfig.get(fromChild)!; const fromEdgeId = belongsToEdge[currentNode.id];
for(const cidr of cidrs) { if(!fromEdgeId) continue;
allowIPs.push(cidr.toString());
getNearEdges(currentNode).forEach(edge => {
const nextNode = getNextNode(edge, currentNode)!;
if(!belongsToEdge[nextNode.id]) {
belongsToEdge[nextNode.id] = fromEdgeId;
queue.push(nextNode);
} }
} });
} }
const nexts = graph.getChildren(data.id); const groupedByEdge: Record<string, string[] | undefined> = {};
for(const next of nexts) { const nodeIds : string[] = [];
const nextData = graph.queryNode(next)!.data; for (const nodeId in belongsToEdge) {
const edgeId = belongsToEdge[nodeId];
if(edgeId === nodeId) continue; // 跳过起始节点
nodeIds.push(nodeId);
if(!edgeId) continue;
if (!groupedByEdge[edgeId]) {
groupedByEdge[edgeId] = [];
}
groupedByEdge[edgeId].push(nodeId);
}
const edge = graph.queryEdge(data.id, next)!; for(const edgeId in groupedByEdge) {
const groupNodeIds = groupedByEdge[edgeId]!;
const edge = getEdge(edgeId)!;
const nextNode = getNextNode(edge, node)!;
const nextNodeData = nextNode.data;
const publicKey = tryDerivePublicKey(nextData.privateKey); const publicKey = tryDerivePublicKey(nextNodeData.privateKey);
if(!publicKey) return new ConfigResult(false, undefined, "无法从私钥派生公钥"); if(!publicKey) return new ConfigResult(false, undefined, "无法从私钥派生公钥");
config.appendLine(""); config.appendLine("");
config.appendLine("[Peer]"); config.appendLine("[Peer]");
config.appendLine(`# ${nextData.label}`); config.appendLine(`# ${nextNodeData.label}`);
config.appendLine(`PublicKey = ${ publicKey}`); config.appendLine(`PublicKey = ${ publicKey}`);
const edgeData = edge.data; if(edge.data?.isTwoWayEdge || edge.source === node.id) {
if(edgeData && (edgeData.isTwoWayEdge || edge.source === data.id)) { if(edge.data?.persistentKeepalive) {
if(edgeData.persistentKeepalive) { config.appendLine(`PersistentKeepalive = ${edge.data.persistentKeepalive}`);
config.appendLine(`PersistentKeepalive = ${edgeData.persistentKeepalive}`);
} }
let listenAddress = nextData.listenAddress; if(!nextNodeData.listenAddress) {
if(listenAddress) { return new ConfigResult(false, undefined, `节点 ${nextNodeData.label} 未设置监听地址,无法生成配置`);
if(listenAddress.includes(":")) listenAddress = `[${listenAddress}]`;
const listenPort = nextData.listenPort || settings.listenPort;
listenAddress = `${listenAddress}:${listenPort}`
} }
if(!edgeData.isTwoWayEdge && edge.source === data.id && edgeData.endPint) { const parse = IPUtils.parse(`${nextNodeData.listenAddress}/0`);
listenAddress = edgeData.endPint; if(!parse.cidr) {
return new ConfigResult(false, undefined, `节点 ${nextNodeData.label} 的监听地址无效`);
} }
if(!listenAddress) { const listenAddress = parse.cidr.version === 'IPv4' ? nextNodeData.listenAddress : `[${nextNodeData.listenAddress}]`;
return new ConfigResult(false, undefined, `节点 ${nextData.label} 未设置监听地址,无法生成配置`); const listenPort = nextNodeData.listenPort || settings.listenPort;
} config.appendLine(`EndPoint = ${listenAddress}:${listenPort}`);
config.appendLine(`EndPoint = ${listenAddress}`);
} }
const allowIPs = allowIPsConfig.get(next); const subnets : Record<string, CIDR>[] = [];
if(allowIPs && allowIPs.length > 0) {
try {
subnets.push(mapAddressToCIDR(nodeIds, nodeId => node.data.ipv4Address && getNode(nodeId)?.data.ipv4Address));
subnets.push(mapAddressToCIDR(nodeIds, nodeId => node.data.ipv6Address && getNode(nodeId)?.data.ipv6Address));
} catch(e) {
if(e instanceof Error) {
return new ConfigResult(false, undefined, e.message);
}
}
const allowIPs : string[] = [];
for(const subnetMap of subnets) {
const allCIDRs = nodeIds.flatMap(id => subnetMap[id] ? [subnetMap[id]] : []);
const targetCIDRs = groupNodeIds.flatMap(id => {
const cidr = subnetMap[id];
if(!cidr || disallowCIDRs.some(disallow => disallow.contains(cidr))) return [];
return [cidr];
});
const mergeResult = IPUtils.mergeCIDRs(allCIDRs, targetCIDRs);
if(!mergeResult) {
return new ConfigResult(false, undefined, `无法生成路由配置`);
}
mergeResult.forEach(cidr => {allowIPs.push(cidr.toString())});
}
if(allowIPs.length > 0) {
config.appendLine(`AllowedIPs = ${allowIPs.join(', ')}`); config.appendLine(`AllowedIPs = ${allowIPs.join(', ')}`);
} }
} }
console.log(config.toString()) // console.log(config.toString());
return new ConfigResult(true, config.toString()); return new ConfigResult(true, config.toString());
} }
function getSubnet(nodes: AppNode[], version: 'ipv4' | 'ipv6'): Result<SubnetInfo | undefined> {
const addresses: {nodeId: string, cidr: CIDR}[] = [];
for(const node of nodes) {
const address = version === 'ipv4' ? node.data.ipv4Address : node.data.ipv6Address;
if(!address) continue;
const result = IPUtils.parse(address);
const cidr = result.cidr;
if(!cidr) return Result.Error(result.error);
addresses.push({nodeId: node.id, cidr: cidr});
}
if(addresses.length === 0) return Result.Result(undefined);
const subnet: SubnetInfo = {subnet: addresses[0].cidr, nodes: []}
for(const address of addresses) {
const cidr = address.cidr;
if(!subnet.subnet.contains(cidr)) {
return Result.Error("不在同一子网下");
}
subnet.nodes.push({...address, cidr: new CIDR(cidr.version, cidr.binary, version === 'ipv4' ? 32 : 128)});
}
return Result.Result(subnet);
}
export default function CustomNode({ export default function CustomNode({
data, data,
selected selected
}: NodeProps<AppNode>): ReactNode { }: NodeProps<AppNode>): ReactNode {
const settings = useContext(SettingsContext); const settings = useContext(SettingsContext);
const { getNodes, getEdges } = useReactFlow<AppNode, AppEdge>(); const { getNode, getEdge, getEdges } = useReactFlow<AppNode, AppEdge>();
const handleGenerate = (node : NodeData) => { const handleGenerate = (node : NodeData) => {
const graph = new AppGraph(getNodes, getEdges); const result = generateConfig(settings, node, getEdges, getEdge, getNode);
const nodes = getNodes();
let subnets: SubnetInfo[] = [];
const v4 = getSubnet(nodes, 'ipv4');
const v6 = getSubnet(nodes, 'ipv6');
if(!v4.isValid()) {
toast.error(`ipv4子网配置有误${v4.errorInfo()}`);
return;
}
if(!v6.isValid()) {
toast.error(`ipv6子网配置有误${v6.errorInfo()}`);
return;
}
if(v4.result) subnets.push(v4.result);
if(v6.result) subnets.push(v6.result);
subnets = subnets.concat(settings.subnets);
const result = generateConfig(settings, node, graph, subnets);
if(result.success && result.config) { if(result.success && result.config) {
navigator.clipboard.writeText(result.config).then(() => { navigator.clipboard.writeText(result.config).then(() => {
toast.success("配置已复制到剪贴板"); toast.success("配置已复制到剪贴板");

View File

@ -16,12 +16,10 @@ export default function NodeEditor({
}: EdgeEditorProps): ReactNode { }: EdgeEditorProps): ReactNode {
const [keepalive, setKeepalive] = useState(edge.persistentKeepalive); const [keepalive, setKeepalive] = useState(edge.persistentKeepalive);
const [endpoint, setEndPoint] = useState(edge.endPint);
const { getNode, getEdge } = useReactFlow<AppNode, AppEdge>(); const { getNode, getEdge } = useReactFlow<AppNode, AppEdge>();
const handleSave = (): void => { const handleSave = (): void => {
onUpdate({persistentKeepalive : keepalive, endPint: endpoint}); onUpdate({persistentKeepalive : keepalive});
onClose(); onClose();
}; };
@ -58,20 +56,7 @@ export default function NodeEditor({
} }
placeholder={`留空或0代表不保活`} placeholder={`留空或0代表不保活`}
/> />
</div>
{!edge.isTwoWayEdge && (
<div className="form-group">
<label>EndPoint</label>
<input
type="text"
value={endpoint || ''}
onChange={e => setEndPoint(e.target.value)}
placeholder={`留空使用节点上配置的监听地址`}
/>
</div> </div>
)}
<div className="editor-actions"> <div className="editor-actions">
<button className="btn-save" onClick={handleSave}></button> <button className="btn-save" onClick={handleSave}></button>

View File

@ -1,6 +1,5 @@
import { useState, ReactNode } from 'react'; import { useState, ReactNode } from 'react';
import { NodeData, NodeDataUpdate } from '../types/graph'; import { NodeData, Settings, NodeDataUpdate } from '../types/graph';
import { Settings } from '../types/settings';
import { generateWireGuardPrivateKey } from '../utils/wireguardConfig' import { generateWireGuardPrivateKey } from '../utils/wireguardConfig'
import { IPUtils} from '../utils/iputils' import { IPUtils} from '../utils/iputils'
import './FormEditor.css'; import './FormEditor.css';
@ -13,7 +12,7 @@ interface NodeEditorProps {
onClose: () => void; onClose: () => void;
} }
function Validate(updateData : NodeDataUpdate) : string[] { function Validate(updateData : NodeDataUpdate, settings : Settings) : string[] {
const errors: string[] = []; const errors: string[] = [];
const {ipv4Address, ipv6Address, mtu, listenPort} = updateData; const {ipv4Address, ipv6Address, mtu, listenPort} = updateData;
@ -96,7 +95,7 @@ export default function NodeEditor({
notes: notes notes: notes
} }
const validation = Validate(updateData); const validation = Validate(updateData, settings);
setErrors(validation); setErrors(validation);
if(validation.length > 0) { if(validation.length > 0) {
return ; return ;

View File

@ -1,4 +1,3 @@
import 'reflect-metadata';
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'

View File

@ -30,7 +30,6 @@ export type EdgeData = {
export type EdgeDataUpdate = { export type EdgeDataUpdate = {
persistentKeepalive?: number; persistentKeepalive?: number;
endPint?: string;
} }
type GetEdges = () => AppEdge[]; type GetEdges = () => AppEdge[];
@ -41,8 +40,8 @@ export class AppGraph {
private readonly _getNextNodeIds = new Map<string, string[]>(); private readonly _getNextNodeIds = new Map<string, string[]>();
constructor( constructor(
public readonly getNodes: GetNodes,
public readonly getEdges: GetEdges, public readonly getEdges: GetEdges,
public readonly getNodes: GetNodes
) { ) {
const getNextNodeIds = this._getNextNodeIds; const getNextNodeIds = this._getNextNodeIds;
const nodeIds = getNodes().map(node => node.id); const nodeIds = getNodes().map(node => node.id);
@ -58,21 +57,9 @@ export class AppGraph {
} }
} }
queryNode(nodeId: string): AppNode | undefined {
return this.getNodes().find(n => n.id === nodeId);
}
queryEdge(source: string, target: string): AppEdge | undefined { private static checkConnected(graph: AppGraph): boolean {
return this.getEdges().find(e => const nodes = graph.getNodes();
(e.source === source && e.target === target) || (e.target === source && e.source === target));
}
getChildren(nodeId: string) : string[] {
return this._getNextNodeIds.get(nodeId) ?? [];
}
private checkConnected(): boolean {
const nodes = this.getNodes();
if(nodes.length === 0) return true; if(nodes.length === 0) return true;
const visited = new Set<string>(); const visited = new Set<string>();
@ -81,7 +68,7 @@ export class AppGraph {
queue.push(first.id); queue.push(first.id);
visited.add(first.id); visited.add(first.id);
const getNextNodeIds = this._getNextNodeIds; const getNextNodeIds = graph._getNextNodeIds;
while(queue.length > 0) { while(queue.length > 0) {
const curr = queue.shift()!; const curr = queue.shift()!;
const next = getNextNodeIds.get(curr); const next = getNextNodeIds.get(curr);
@ -96,24 +83,7 @@ export class AppGraph {
return visited.size === nodes.length; return visited.size === nodes.length;
} }
getConnectedSubgraph(nodeIds: string[]): AppGraph | undefined { getConnectedSubgraph(nodeIds: string[]): AppGraph | undefined {
let completed = false; const node
let graph: AppGraph = this;
while(!completed) {
completed = true;
const nodes = graph.getNodes();
for(const node of nodes) {
if(nodeIds.includes(node.id)) continue;
const newGraph = new AppGraph(() => this.getNodes().filter(n => n.id !== node.id), this.getEdges);
if(!newGraph.checkConnected()) continue;
graph = newGraph;
completed = false;
}
}
return graph;
} }
} }

View File

@ -1,14 +0,0 @@
import { Type } from 'class-transformer';
import { Settings } from './settings';
import { AppNode, AppEdge } from './graph';
export default class SaveConfig {
encrypted: boolean = false;
@Type(() => Settings)
settings: Settings = new Settings();
nodes: AppNode[] = [];
edges: AppEdge[] = [];
}

View File

@ -1,28 +1,22 @@
import { Type } from 'class-transformer';
import { createContext } from 'react'; import { createContext } from 'react';
import { CIDR } from '../utils/iputils'; import { CIDR } from '../utils/iputils';
export class NodeToCIDR { export interface SubnetInfo {
nodeId: string = ''; subnet: CIDR;
nodes: Array<{nodeId: string, cidr: CIDR | undefined}>;
@Type(() => CIDR) // 必须显式标记,即使是 undefined 也要处理
cidr: CIDR | undefined;
} }
export class SubnetInfo { export interface Settings {
@Type(() => CIDR) listenPort: number;
subnet!: CIDR; mtu: number;
@Type(() => NodeToCIDR) subnets: SubnetInfo[];
nodes: NodeToCIDR[] = [];
} }
export class Settings { export const initialSettings : Settings = {
listenPort: number = 38894; listenPort: 38894,
mtu: number = 1420; mtu: 1420,
subnets: []
};
@Type(() => SubnetInfo) export const SettingsContext = createContext<Settings>(initialSettings);
subnets: SubnetInfo[] = [];
}
export const SettingsContext = createContext<Settings>(new Settings());

View File

@ -1,26 +0,0 @@
export class Result<T> {
private constructor(
public readonly errors: string[],
public readonly result?: T,
) {}
isValid(): boolean {
return this.errors.length === 0;
}
errorInfo(): string {
return this.errors.join("\n");
}
static Error<T>(error: string): Result<T> {
return new Result<T>([error]);
}
static Errors<T>(errors: string[]): Result<T> {
return new Result<T>(errors);
}
static Result<T>(result: T) {
return new Result<T>([], result);
}
}

View File

@ -20,9 +20,7 @@
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}, },
"include": ["src"], "include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]