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",
"dependencies": {
"@xyflow/react": "^12.10.0",
"base64-js": "^1.5.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"tweetnacl": "^1.0.3"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/base64-js": "^1.3.2",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
@ -1411,6 +1414,13 @@
"@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": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
@ -1619,6 +1629,25 @@
"dev": true,
"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": {
"version": "2.9.18",
"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"
}
},
"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": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@ -11,11 +11,14 @@
},
"dependencies": {
"@xyflow/react": "^12.10.0",
"base64-js": "^1.5.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"tweetnacl": "^1.0.3"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/base64-js": "^1.3.2",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",

View File

@ -17,24 +17,62 @@ import {
IsValidConnection
} from '@xyflow/react';
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 NodeEditor from './components/NodeEditor';
import Toggle from "./components/Toggle"
import { generateWireGuardPrivateKey } from './utils/wireguardConfig';
import './App.css';
const initialNodes: AppNode[] = [];
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 = {
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 {
const [nodes, setNodes] = useState<AppNode[]>(initialNodes);
const [edges, setEdges] = useState<AppEdge[]>(initialEdges);
const [settings, setSettings] = useState<Settings>(initialSettings);
const [editingNode, setEditingNode] = useState<NodeData | null>(null);
const [showConfigViewer, setShowConfigViewer] = useState(false);
const [enableTwoWay, setEnableTwoWay] = useState(false);
const onNodesChange = useCallback(
@ -80,17 +118,12 @@ function FlowContent(): ReactNode {
[edges]);
const handleAddNode = (): void => {
const newNodeId = `n${Date.now()}`;
const result = generateNodeData(nodes.map(n => n.data));
if(result == null) return;
const newNode: AppNode = {
id: newNodeId,
id: generateNodeId(),
position: { x: 0, y: 0 },
data: {
label: `Node-${String.fromCharCode(65 + (nodes.length % 26))}`,
ipAddress: `10.0.0.${nodes.length + 2}`,
listenPort: `${51820 + nodes.length}`,
privateKey: '',
publicKey: '',
},
data: result,
type: 'custom',
selected: true
};
@ -146,7 +179,7 @@ function FlowContent(): ReactNode {
</button>
<button className="toolbar-btn" onClick={() => setShowConfigViewer(true)} title="设置">
<button className="toolbar-btn" onClick={() => {}} title="设置">
📋
</button>
@ -158,16 +191,9 @@ function FlowContent(): ReactNode {
node={editingNode}
onUpdate={handleUpdateNode}
onClose={() => setEditingNode(null)}
settings={settings}
/>
)}
{/* {showConfigViewer && (
<ConfigViewer
nodes={nodes}
edges={edges}
onClose={() => setShowConfigViewer(false)}
/>
)} */}
</div>
);
}

View File

@ -14,18 +14,6 @@ export default function CustomNode({
</div>
<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>
{[Position.Top, Position.Bottom, Position.Right, Position.Left].map((position) => (

View File

@ -70,6 +70,8 @@
}
.form-group {
display: flex;
flex-direction: column;
margin-bottom: 15px;
}
@ -83,7 +85,7 @@
.form-group input,
.form-group textarea {
width: 100%;
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
@ -111,33 +113,46 @@
border-top: 1px solid #eee;
}
.btn-save,
.btn-cancel {
flex: 1;
padding: 10px;
.item-group {
display: flex;
gap: 10px;
}
/* 1. 基础通用样式 */
.btn-interect, .btn-save, .btn-cancel {
padding: 8px 16px; /* 补充了间距,确保按钮有厚度 */
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
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;
color: white;
}
.btn-save:hover {
background: #0b66d4;
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.3);
}
.btn-cancel {
background: #f5f5f5;
color: #333;
}
.btn-cancel:hover {
background: #e6e6e6;
}
}

View File

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

View File

@ -7,18 +7,20 @@ export type AppEdge = Edge<EdgeData>;
export type NodeData = {
// basic
label: string;
subnet: string;
privateKey: string;
publicKey: string;
groupId: number;
hostId: number;
// options
PostUp?: string;
PostDown?: string;
postUp?: string;
postDown?: string;
persistentKeepalive?: string;
dnsServers?: string;
disallowSubnet?: string;
allowIPs?: string;
MTU?: number
mtu?: number;
listenPort?: number;
notes?: string;
}
export type EdgeData = {
@ -26,10 +28,10 @@ export type EdgeData = {
}
export type Settings = {
v4SubNetPrefix: string;
v6SubNetPrefix: string;
listenPort: string;
v4SubNetPrefix: [number, number];
listenPort: number;
// 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';
interface ValidationResult {
isValid: boolean;
errors: string[];
export function generateWireGuardPrivateKey() : string {
const privateKeyBuffer = nacl.randomBytes(32);
const keyPair = nacl.box.keyPair.fromSecretKey(privateKeyBuffer);
return fromByteArray(keyPair.secretKey);
}
/**
* WireGuard格式的配置文件
*/
export function generateWireGuardConfig(
node: AppNode,
allNodes: AppNode[],
allEdges: AppEdge[]
): string {
const config: string[] = [];
// [Interface] 部分
config.push('[Interface]');
config.push(`PrivateKey = ${node.data.privateKey}`);
config.push(`Address = ${node.data.ipAddress}/32`);
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 tryDerivePublicKey(privKeyBase64: string): string | null {
try {
const privKeyUint8 = toByteArray(privKeyBase64);
if (privKeyUint8.length !== 32) throw new Error("Invalid length");
/**
*
*/
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('节点名称不能为空');
const keyPair = nacl.box.keyPair.fromSecretKey(privKeyUint8);
return fromByteArray(keyPair.publicKey);
} catch (e) {
return null;
}
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;
});
}
}