调整
This commit is contained in:
parent
e3fed713ff
commit
a81869c96d
36
package-lock.json
generated
36
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
66
src/App.tsx
66
src/App.tsx
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
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>
|
||||
<label>私钥</label>
|
||||
<div className="item-group">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.ipAddress || ''}
|
||||
onChange={(e) => handleInputChange('ipAddress', e.target.value)}
|
||||
placeholder="例如: 10.0.0.1"
|
||||
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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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[] = [];
|
||||
export function tryDerivePublicKey(privKeyBase64: string): string | null {
|
||||
try {
|
||||
const privKeyUint8 = toByteArray(privKeyBase64);
|
||||
if (privKeyUint8.length !== 32) throw new Error("Invalid length");
|
||||
|
||||
// [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}`);
|
||||
const keyPair = nacl.box.keyPair.fromSecretKey(privKeyUint8);
|
||||
return fromByteArray(keyPair.publicKey);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user