This commit is contained in:
limil 2026-02-01 21:43:17 +08:00
parent e3fed713ff
commit a81869c96d
8 changed files with 168 additions and 258 deletions

36
package-lock.json generated
View File

@ -9,11 +9,14 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"base64-js": "^1.5.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"tweetnacl": "^1.0.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/base64-js": "^1.3.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",
@ -1411,6 +1414,13 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/base64-js": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/base64-js/-/base64-js-1.3.2.tgz",
"integrity": "sha512-Q2Xn2/vQHRGLRXhQ5+BSLwhHkR3JVflxVKywH0Q6fVoAiUE8fFYL2pE5/l2ZiOiBDfA8qUqRnSxln4G/NFz1Sg==",
"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",
@ -1619,6 +1629,25 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.9.18", "version": "2.9.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz",
@ -2918,6 +2947,11 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@ -11,11 +11,14 @@
}, },
"dependencies": { "dependencies": {
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"base64-js": "^1.5.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"tweetnacl": "^1.0.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/base64-js": "^1.3.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

@ -17,24 +17,62 @@ import {
IsValidConnection IsValidConnection
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { AppNode, AppEdge, NodeData } from './types/graph'; import { AppNode, AppEdge, NodeData, Settings } from './types/graph';
import CustomNode from './components/CustomNode'; import CustomNode from './components/CustomNode';
import NodeEditor from './components/NodeEditor'; import NodeEditor from './components/NodeEditor';
import Toggle from "./components/Toggle" import Toggle from "./components/Toggle"
import { generateWireGuardPrivateKey } from './utils/wireguardConfig';
import './App.css'; import './App.css';
const initialNodes: AppNode[] = []; const initialNodes: AppNode[] = [];
const initialEdges: AppEdge[] = []; const initialEdges: AppEdge[] = [];
const initialSettings : Settings = {
v4SubNetPrefix: [172, 29],
listenPort: 38894,
};
export function generateEdgeId() : string {
return `e-${crypto.randomUUID()}`;
}
export function generateNodeId() : string {
return `n-${crypto.randomUUID()}`;
}
const nodeTypes : NodeTypes = { const nodeTypes : NodeTypes = {
custom: CustomNode, custom: CustomNode,
}; };
function getFirstAvailableId(ids: number[]) : number {
const idSet = new Set(ids);
let id = 1;
while (idSet.has(id)) {
id++;
}
return id;
}
function generateNodeData(nodes : NodeData[]) : NodeData | null {
const hostId = getFirstAvailableId(nodes.map(n => n.hostId))
if(hostId > 255) return null
const privateKey = generateWireGuardPrivateKey();
const node : NodeData = {
label: `Node-${hostId}`,
privateKey: privateKey,
groupId: 0,
hostId: hostId,
}
return node
}
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>(initialSettings);
const [editingNode, setEditingNode] = useState<NodeData | null>(null); const [editingNode, setEditingNode] = useState<NodeData | null>(null);
const [showConfigViewer, setShowConfigViewer] = useState(false);
const [enableTwoWay, setEnableTwoWay] = useState(false); const [enableTwoWay, setEnableTwoWay] = useState(false);
const onNodesChange = useCallback( const onNodesChange = useCallback(
@ -80,17 +118,12 @@ function FlowContent(): ReactNode {
[edges]); [edges]);
const handleAddNode = (): void => { const handleAddNode = (): void => {
const newNodeId = `n${Date.now()}`; const result = generateNodeData(nodes.map(n => n.data));
if(result == null) return;
const newNode: AppNode = { const newNode: AppNode = {
id: newNodeId, id: generateNodeId(),
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: result,
label: `Node-${String.fromCharCode(65 + (nodes.length % 26))}`,
ipAddress: `10.0.0.${nodes.length + 2}`,
listenPort: `${51820 + nodes.length}`,
privateKey: '',
publicKey: '',
},
type: 'custom', type: 'custom',
selected: true selected: true
}; };
@ -146,7 +179,7 @@ function FlowContent(): ReactNode {
</button> </button>
<button className="toolbar-btn" onClick={() => setShowConfigViewer(true)} title="设置"> <button className="toolbar-btn" onClick={() => {}} title="设置">
📋 📋
</button> </button>
@ -158,16 +191,9 @@ function FlowContent(): ReactNode {
node={editingNode} node={editingNode}
onUpdate={handleUpdateNode} onUpdate={handleUpdateNode}
onClose={() => setEditingNode(null)} onClose={() => setEditingNode(null)}
settings={settings}
/> />
)} )}
{/* {showConfigViewer && (
<ConfigViewer
nodes={nodes}
edges={edges}
onClose={() => setShowConfigViewer(false)}
/>
)} */}
</div> </div>
); );
} }

View File

@ -14,18 +14,6 @@ export default function CustomNode({
</div> </div>
<div className="node-info"> <div className="node-info">
{data.ipAddress && (
<div className="info-item">
<span className="label">IP:</span>
<span className="value">{data.ipAddress}</span>
</div>
)}
{data.listenPort && (
<div className="info-item">
<span className="label">Port:</span>
<span className="value">{data.listenPort}</span>
</div>
)}
</div> </div>
{[Position.Top, Position.Bottom, Position.Right, Position.Left].map((position) => ( {[Position.Top, Position.Bottom, Position.Right, Position.Left].map((position) => (

View File

@ -70,6 +70,8 @@
} }
.form-group { .form-group {
display: flex;
flex-direction: column;
margin-bottom: 15px; margin-bottom: 15px;
} }
@ -83,7 +85,7 @@
.form-group input, .form-group input,
.form-group textarea { .form-group textarea {
width: 100%; flex: 1;
padding: 8px; padding: 8px;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
@ -111,33 +113,46 @@
border-top: 1px solid #eee; border-top: 1px solid #eee;
} }
.btn-save, .item-group {
.btn-cancel { display: flex;
flex: 1; gap: 10px;
padding: 10px; }
/* 1. 基础通用样式 */
.btn-interect, .btn-save, .btn-cancel {
padding: 8px 16px; /* 补充了间距,确保按钮有厚度 */
border: none; border: none;
border-radius: 4px; border-radius: 4px;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.1s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
} }
.btn-save { /* 2. 统一交互反馈:悬停变深,点下缩放 */
.btn-interect:hover, .btn-save:hover, .btn-cancel:hover {
filter: brightness(0.9); /* 自动变深,无需指定具体颜色 */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.btn-interect:active, .btn-save:active, .btn-cancel:active {
transform: scale(0.96);
}
.btn-save, .btn-cancel {
flex: 1;
}
.btn-interect, .btn-save {
background: #1677ff; background: #1677ff;
color: white; color: white;
} }
.btn-save:hover {
background: #0b66d4;
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.3);
}
.btn-cancel { .btn-cancel {
background: #f5f5f5; background: #f5f5f5;
color: #333; color: #333;
} }
.btn-cancel:hover {
background: #e6e6e6;
}

View File

@ -1,16 +1,19 @@
import { useState, ReactNode } from 'react'; import { useState, ReactNode } from 'react';
import { validateNodeConfig } from '../utils/wireguardConfig'; import { NodeData, Settings } from '../types/graph';
import { NodeData } from '../types/graph';
import './NodeEditor.css'; import './NodeEditor.css';
interface NodeEditorProps { interface NodeEditorProps {
node: NodeData; node: NodeData;
settings: Settings;
onUpdate: (data: NodeData) => void; onUpdate: (data: NodeData) => void;
onClose: () => void; onClose: () => void;
} }
export default function NodeEditor({ export default function NodeEditor({
node, node,
settings,
onUpdate, onUpdate,
onClose onClose
}: NodeEditorProps): ReactNode { }: NodeEditorProps): ReactNode {
@ -25,15 +28,15 @@ export default function NodeEditor({
}; };
const handleSave = (): void => { const handleSave = (): void => {
const validation = validateNodeConfig(formData); // const validation = validateNodeConfig(formData);
if (!validation.isValid) { // if (!validation.isValid) {
setErrors(validation.errors); // setErrors(validation.errors);
return; // return;
} // }
setErrors([]); // setErrors([]);
onUpdate(formData); // onUpdate(formData);
onClose(); // onClose();
}; };
return ( return (
@ -63,13 +66,16 @@ export default function NodeEditor({
</div> </div>
<div className="form-group"> <div className="form-group">
<label>IP地址</label> <label></label>
<div className="item-group">
<input <input
type="text" value={formData.privateKey || ''}
value={formData.ipAddress || ''} onChange={(e) => handleInputChange('privateKey', e.target.value)}
onChange={(e) => handleInputChange('ipAddress', e.target.value)} readOnly
placeholder="例如: 10.0.0.1"
/> />
<button className="btn-interect"></button>
</div>
</div> </div>
<div className="form-group"> <div className="form-group">
@ -82,36 +88,6 @@ export default function NodeEditor({
/> />
</div> </div>
<div className="form-group">
<label></label>
<textarea
value={formData.privateKey || ''}
onChange={(e) => handleInputChange('privateKey', e.target.value)}
placeholder="粘贴Base64编码的私钥"
rows={3}
/>
</div>
<div className="form-group">
<label></label>
<textarea
value={formData.publicKey || ''}
onChange={(e) => handleInputChange('publicKey', e.target.value)}
placeholder="粘贴Base64编码的公钥"
rows={3}
/>
</div>
<div className="form-group">
<label> ()</label>
<input
type="text"
value={formData.endpoint || ''}
onChange={(e) => handleInputChange('endpoint', e.target.value)}
placeholder="例如: 192.168.1.100:51820"
/>
</div>
<div className="form-group"> <div className="form-group">
<label>DNS服务器 ()</label> <label>DNS服务器 ()</label>
<input <input

View File

@ -7,18 +7,20 @@ export type AppEdge = Edge<EdgeData>;
export type NodeData = { export type NodeData = {
// basic // basic
label: string; label: string;
subnet: string;
privateKey: string; privateKey: string;
publicKey: string; groupId: number;
hostId: number;
// options // options
PostUp?: string; postUp?: string;
PostDown?: string; postDown?: string;
persistentKeepalive?: string; persistentKeepalive?: string;
dnsServers?: string; dnsServers?: string;
disallowSubnet?: string; disallowSubnet?: string;
allowIPs?: string; allowIPs?: string;
MTU?: number mtu?: number;
listenPort?: number;
notes?: string;
} }
export type EdgeData = { export type EdgeData = {
@ -26,10 +28,10 @@ export type EdgeData = {
} }
export type Settings = { export type Settings = {
v4SubNetPrefix: string; v4SubNetPrefix: [number, number];
v6SubNetPrefix: string; listenPort: number;
listenPort: string;
// global options // global options
MTU?: number; v6SubNetPrefix?: [string, string, string, string];
mtu?: number;
} }

View File

@ -1,154 +1,20 @@
import { AppNode, AppEdge, NodeData } from "../types/graph"; import nacl from 'tweetnacl';
import { fromByteArray, toByteArray } from 'base64-js';
export function generateWireGuardPrivateKey() : string {
interface ValidationResult { const privateKeyBuffer = nacl.randomBytes(32);
isValid: boolean; const keyPair = nacl.box.keyPair.fromSecretKey(privateKeyBuffer);
errors: string[]; return fromByteArray(keyPair.secretKey);
} }
/** export function tryDerivePublicKey(privKeyBase64: string): string | null {
* WireGuard格式的配置文件 try {
*/ const privKeyUint8 = toByteArray(privKeyBase64);
export function generateWireGuardConfig( if (privKeyUint8.length !== 32) throw new Error("Invalid length");
node: AppNode,
allNodes: AppNode[],
allEdges: AppEdge[]
): string {
const config: string[] = [];
// [Interface] 部分 const keyPair = nacl.box.keyPair.fromSecretKey(privKeyUint8);
config.push('[Interface]'); return fromByteArray(keyPair.publicKey);
config.push(`PrivateKey = ${node.data.privateKey}`); } catch (e) {
config.push(`Address = ${node.data.ipAddress}/32`); return null;
if (node.data.listenPort) {
config.push(`ListenPort = ${node.data.listenPort}`);
} }
if (node.data.dnsServers) {
config.push(`DNS = ${node.data.dnsServers}`);
}
config.push('');
// 找到与该节点相连的所有节点
const connectedNodeIds = new Set<string>();
allEdges.forEach(edge => {
if (edge.source === node.id) {
connectedNodeIds.add(edge.target);
}
if (edge.target === node.id) {
connectedNodeIds.add(edge.source);
}
});
// 为每个连接的节点添加 [Peer] 部分
connectedNodeIds.forEach(peerId => {
const peerNode = allNodes.find(n => n.id === peerId);
if (peerNode && peerNode.data.publicKey && peerNode.data.ipAddress) {
config.push('[Peer]');
config.push(`PublicKey = ${peerNode.data.publicKey}`);
config.push(`AllowedIPs = ${peerNode.data.ipAddress}/32`);
if (peerNode.data.endpoint) {
config.push(`Endpoint = ${peerNode.data.endpoint}`);
}
if (peerNode.data.persistentKeepalive) {
config.push(`PersistentKeepalive = ${peerNode.data.persistentKeepalive}`);
}
config.push('');
}
});
return config.join('\n');
}
/**
*
*/
export function generateAllConfigs(
nodes: AppNode[],
edges: AppEdge[]
): Record<string, { name: string; config: string }> {
const configs: Record<string, { name: string; config: string }> = {};
nodes.forEach(node => {
if (node.id && node.data.privateKey && node.data.ipAddress) {
configs[node.id] = {
name: node.data.label || node.id,
config: generateWireGuardConfig(node, nodes, edges)
};
}
});
return configs;
}
/**
*
*/
export function downloadConfig(
nodeId: string,
nodeName: string,
configContent: string
): void {
const element = document.createElement('a');
element.setAttribute(
'href',
'data:text/plain;charset=utf-8,' + encodeURIComponent(configContent)
);
element.setAttribute('download', `${nodeId}-${nodeName}.conf`);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
/**
*
*/
export function validateNodeConfig(node: Partial<NodeData>): ValidationResult {
const errors: string[] = [];
if (!node.label || node.label.trim() === '') {
errors.push('节点名称不能为空');
}
if (!node.ipAddress || node.ipAddress.trim() === '') {
errors.push('IP地址不能为空');
} else if (!isValidIP(node.ipAddress)) {
errors.push('IP地址格式无效');
}
if (!node.privateKey || node.privateKey.trim() === '') {
errors.push('私钥不能为空');
}
if (!node.publicKey || node.publicKey.trim() === '') {
errors.push('公钥不能为空');
}
return {
isValid: errors.length === 0,
errors
};
}
/**
* IP地址格式
*/
function isValidIP(ip: string): boolean {
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (!ipv4Regex.test(ip)) {
return false;
}
const parts = ip.split('.');
return parts.every(part => {
const num = parseInt(part, 10);
return num >= 0 && num <= 255;
});
} }