🎉 init: 小龙的工作空间

This commit is contained in:
大海
2026-06-06 10:40:48 +08:00
commit a188ee1426
3201 changed files with 231817 additions and 0 deletions
@@ -0,0 +1,45 @@
{
"name": "@network-tool/client-electron",
"version": "0.1.0",
"private": true,
"main": "dist/main/index.js",
"scripts": {
"dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"",
"dev:main": "tsc -p tsconfig.main.json && electron dist/main/index.js",
"dev:renderer": "vite",
"build:main": "tsc -p tsconfig.main.json",
"build:renderer": "vite build",
"build": "npm run build:main && npm run build:renderer",
"start": "electron dist/main/index.js",
"package": "electron-builder"
},
"dependencies": {
"@network-tool/shared-types": "*",
"@network-tool/shared-crypto": "*",
"@network-tool/shared-protocol": "*",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0"
},
"devDependencies": {
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"concurrently": "^8.2.2",
"electron": "^33.0.0",
"electron-builder": "^24.13.3",
"typescript": "^5.4.0",
"vite": "^5.1.0"
},
"build": {
"appId": "com.network-tool.client",
"productName": "Network Tool",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"package.json"
]
}
}
@@ -0,0 +1,160 @@
import { app, BrowserWindow, ipcMain } from 'electron';
import * as path from 'path';
import { NetworkClient } from './network-client';
let mainWindow: BrowserWindow | null = null;
let networkClient: NetworkClient | null = null;
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1024,
height: 720,
minWidth: 800,
minHeight: 600,
title: 'Network Tool - 异地组网客户端',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
},
});
// 开发模式加载 Vite dev server
const isDev = !app.isPackaged;
if (isDev) {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools({ mode: 'detach' });
} else {
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
// 初始化网络客户端
function initNetworkClient(): void {
networkClient = new NetworkClient();
}
// IPC 处理器
function setupIPC(): void {
// 用户相关
ipcMain.handle('auth:register', async (_event, email: string, password: string) => {
try {
const result = await networkClient!.apiRequest('/api/auth/register', 'POST', { email, password });
return result;
} catch (err: any) {
return { success: false, error: 'NETWORK_ERROR', message: err.message };
}
});
ipcMain.handle('auth:login', async (_event, email: string, password: string) => {
try {
const result = await networkClient!.apiRequest('/api/auth/login', 'POST', { email, password });
if (result.success) {
networkClient!.setToken(result.data.token);
}
return result;
} catch (err: any) {
return { success: false, error: 'NETWORK_ERROR', message: err.message };
}
});
ipcMain.handle('auth:me', async () => {
return networkClient!.authenticatedRequest('/api/auth/me');
});
// 设备相关
ipcMain.handle('device:register', async (_event, data: any) => {
return networkClient!.authenticatedRequest('/api/devices/register', 'POST', data);
});
ipcMain.handle('device:list', async () => {
return networkClient!.authenticatedRequest('/api/devices');
});
ipcMain.handle('device:updateStatus', async (_event, id: number, status: string) => {
return networkClient!.authenticatedRequest(`/api/devices/${id}/status`, 'PATCH', { status });
});
ipcMain.handle('device:delete', async (_event, id: number) => {
return networkClient!.authenticatedRequest(`/api/devices/${id}`, 'DELETE');
});
// 节点相关
ipcMain.handle('node:list', async () => {
return networkClient!.authenticatedRequest('/api/nodes');
});
ipcMain.handle('node:available', async () => {
return networkClient!.authenticatedRequest('/api/nodes/available');
});
// 连接相关
ipcMain.handle('connection:request', async (_event, data: any) => {
return networkClient!.authenticatedRequest('/api/connections/request', 'POST', data);
});
ipcMain.handle('connection:disconnect', async (_event, deviceId: number) => {
return networkClient!.authenticatedRequest('/api/connections/disconnect', 'POST', { device_id: deviceId });
});
ipcMain.handle('connection:logs', async () => {
return networkClient!.authenticatedRequest('/api/connections/logs');
});
// Relay Node TCP 连接
ipcMain.handle('relay:connect', async (_event, host: string, port: number, token: string, deviceId: number) => {
try {
await networkClient!.connectToRelay(host, port, token, deviceId);
return { success: true };
} catch (err: any) {
return { success: false, error: 'RELAY_CONNECT_FAILED', message: err.message };
}
});
ipcMain.handle('relay:send', async (_event, targetDeviceId: number, payload: string) => {
try {
networkClient!.sendMessage(targetDeviceId, payload);
return { success: true };
} catch (err: any) {
return { success: false, error: 'SEND_FAILED', message: err.message };
}
});
ipcMain.handle('relay:disconnect', async () => {
networkClient!.disconnectFromRelay();
return { success: true };
});
// Relay 事件 → 渲染进程
networkClient?.on('message', (msg: any) => {
mainWindow?.webContents.send('relay:message', msg);
});
networkClient?.on('status', (status: string) => {
mainWindow?.webContents.send('relay:status', status);
});
}
// 应用生命周期
app.whenReady().then(() => {
initNetworkClient();
setupIPC();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
networkClient?.disconnectFromRelay();
if (process.platform !== 'darwin') {
app.quit();
}
});
@@ -0,0 +1,173 @@
import http from 'http';
import net from 'net';
import { EventEmitter } from 'events';
import { FrameParser, encodeMessage } from '@network-tool/shared-protocol';
import type { NetworkMessage } from '@network-tool/shared-types';
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3001';
export class NetworkClient extends EventEmitter {
private token: string | null = null;
private relaySocket: net.Socket | null = null;
private relayParser = new FrameParser();
setToken(token: string): void {
this.token = token;
}
/**
* 向 Control Server 发送 HTTP 请求
*/
apiRequest(path: string, method = 'GET', body?: any): Promise<any> {
return new Promise((resolve, reject) => {
const url = new URL(path, SERVER_URL);
const data = body ? JSON.stringify(body) : undefined;
const options: http.RequestOptions = {
hostname: url.hostname,
port: url.port,
path: url.pathname,
method,
headers: {
'Content-Type': 'application/json',
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
...(data ? { 'Content-Length': Buffer.byteLength(data).toString() } : {}),
},
};
const req = http.request(options, (res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => {
try {
resolve(JSON.parse(body));
} catch {
resolve({ success: false, error: 'PARSE_ERROR', message: body });
}
});
});
req.on('error', (err) => {
resolve({ success: false, error: 'NETWORK_ERROR', message: err.message });
});
if (data) req.write(data);
req.end();
});
}
/**
* 带认证的请求
*/
async authenticatedRequest(path: string, method = 'GET', body?: any): Promise<any> {
if (!this.token) {
return { success: false, error: 'NOT_AUTHENTICATED', message: 'Please login first' };
}
return this.apiRequest(path, method, body);
}
/**
* 连接到 Relay Node
*/
connectToRelay(host: string, port: number, token: string, deviceId: number): Promise<void> {
return new Promise((resolve, reject) => {
this.disconnectFromRelay();
this.relaySocket = new net.Socket();
this.relayParser.reset();
const timeout = setTimeout(() => {
reject(new Error('Connection timeout'));
this.relaySocket?.destroy();
}, 10000);
this.relaySocket.connect(port, host, () => {
clearTimeout(timeout);
this.emit('status', 'connected');
console.log(`[Client] 已连接到 Relay Node: ${host}:${port}`);
// 发送认证消息
const authMsg: NetworkMessage = {
type: 'auth',
from_device_id: deviceId,
to_device_id: 0,
payload: JSON.stringify({
action: 'connect',
device_id: deviceId,
token,
}),
timestamp: Date.now(),
message_id: this.generateId(),
};
this.relaySocket!.write(encodeMessage(authMsg));
this.emit('status', 'authenticating');
});
this.relaySocket.on('data', (chunk: Buffer) => {
const messages = this.relayParser.push(chunk);
for (const msg of messages) {
if (msg.type === 'control') {
try {
const ctrl = JSON.parse(msg.payload);
if (ctrl.action === 'connect_ok') {
this.emit('status', 'authenticated');
resolve();
continue;
}
} catch {}
}
// 转发所有消息给渲染进程
this.emit('message', msg);
}
});
this.relaySocket.on('error', (err) => {
clearTimeout(timeout);
this.emit('status', 'error');
reject(err);
});
this.relaySocket.on('close', () => {
this.emit('status', 'disconnected');
this.relaySocket = null;
});
});
}
/**
* 通过 Relay 发送消息
*/
sendMessage(targetDeviceId: number, payload: string): void {
if (!this.relaySocket || this.relaySocket.destroyed) {
throw new Error('Not connected to relay');
}
const msg: NetworkMessage = {
type: 'data',
from_device_id: 0, // 不暴露真实 device id 给对端 (relay 会处理)
to_device_id: targetDeviceId,
payload,
timestamp: Date.now(),
message_id: this.generateId(),
};
this.relaySocket.write(encodeMessage(msg));
}
/**
* 断开 Relay 连接
*/
disconnectFromRelay(): void {
if (this.relaySocket) {
this.relaySocket.destroy();
this.relaySocket = null;
}
this.emit('status', 'disconnected');
}
private generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
}
}
@@ -0,0 +1,50 @@
import { contextBridge, ipcRenderer } from 'electron';
const api = {
// Auth
register: (email: string, password: string) =>
ipcRenderer.invoke('auth:register', email, password),
login: (email: string, password: string) =>
ipcRenderer.invoke('auth:login', email, password),
getMe: () => ipcRenderer.invoke('auth:me'),
// Devices
registerDevice: (data: { device_name: string; device_fingerprint: string; public_key: string }) =>
ipcRenderer.invoke('device:register', data),
listDevices: () => ipcRenderer.invoke('device:list'),
updateDeviceStatus: (id: number, status: string) =>
ipcRenderer.invoke('device:updateStatus', id, status),
deleteDevice: (id: number) => ipcRenderer.invoke('device:delete', id),
// Nodes
listNodes: () => ipcRenderer.invoke('node:list'),
getAvailableNodes: () => ipcRenderer.invoke('node:available'),
// Connections
requestConnection: (data: { device_id: number; node_id: number }) =>
ipcRenderer.invoke('connection:request', data),
disconnect: (deviceId: number) =>
ipcRenderer.invoke('connection:disconnect', deviceId),
getConnectionLogs: () => ipcRenderer.invoke('connection:logs'),
// Relay
connectToRelay: (host: string, port: number, token: string, deviceId: number) =>
ipcRenderer.invoke('relay:connect', host, port, token, deviceId),
sendRelayMessage: (targetDeviceId: number, payload: string) =>
ipcRenderer.invoke('relay:send', targetDeviceId, payload),
disconnectFromRelay: () => ipcRenderer.invoke('relay:disconnect'),
// Events from main → renderer
onRelayMessage: (callback: (msg: any) => void) => {
ipcRenderer.on('relay:message', (_event, msg) => callback(msg));
},
onRelayStatus: (callback: (status: string) => void) => {
ipcRenderer.on('relay:status', (_event, status) => callback(status));
},
removeRelayListeners: () => {
ipcRenderer.removeAllListeners('relay:message');
ipcRenderer.removeAllListeners('relay:status');
},
};
contextBridge.exposeInMainWorld('electronAPI', api);
@@ -0,0 +1,100 @@
import React, { useState, useEffect } from 'react';
import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { DevicesPage } from './pages/DevicesPage';
import { ConnectionsPage } from './pages/ConnectionsPage';
import { NodesPage } from './pages/NodesPage';
import { LogsPage } from './pages/LogsPage';
import { SettingsPage } from './pages/SettingsPage';
type Page =
| 'login'
| 'register'
| 'devices'
| 'connections'
| 'nodes'
| 'logs'
| 'settings';
export function App() {
const [currentPage, setCurrentPage] = useState<Page>('login');
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [user, setUser] = useState<any>(null);
useEffect(() => {
// 检查是否已登录
window.electronAPI.getMe().then((res) => {
if (res.success) {
setIsLoggedIn(true);
setUser(res.data);
setCurrentPage('connections');
}
});
}, []);
if (!isLoggedIn) {
if (currentPage === 'register') {
return <RegisterPage onSwitchToLogin={() => setCurrentPage('login')} />;
}
return (
<LoginPage
onLoginSuccess={(userData) => {
setIsLoggedIn(true);
setUser(userData);
setCurrentPage('connections');
}}
onSwitchToRegister={() => setCurrentPage('register')}
/>
);
}
const navItems: { page: Page; label: string }[] = [
{ page: 'connections', label: '连接' },
{ page: 'devices', label: '设备' },
{ page: 'nodes', label: '节点' },
{ page: 'logs', label: '日志' },
{ page: 'settings', label: '设置' },
];
return (
<div className="app-layout">
<aside className="sidebar">
<div className="sidebar-header">
<h2>🌐 </h2>
<span className="user-email">{user?.email}</span>
</div>
<nav className="sidebar-nav">
{navItems.map((item) => (
<button
key={item.page}
className={`nav-btn ${currentPage === item.page ? 'active' : ''}`}
onClick={() => setCurrentPage(item.page)}
>
{item.label}
</button>
))}
</nav>
<div className="sidebar-footer">
<button
className="nav-btn logout-btn"
onClick={() => {
setIsLoggedIn(false);
setUser(null);
setCurrentPage('login');
window.electronAPI.disconnectFromRelay();
}}
>
退
</button>
</div>
</aside>
<main className="main-content">
{currentPage === 'connections' && <ConnectionsPage />}
{currentPage === 'devices' && <DevicesPage />}
{currentPage === 'nodes' && <NodesPage />}
{currentPage === 'logs' && <LogsPage />}
{currentPage === 'settings' && <SettingsPage />}
</main>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
/// <reference types="vite/client" />
interface ElectronAPI {
// Auth
register: (email: string, password: string) => Promise<any>;
login: (email: string, password: string) => Promise<any>;
getMe: () => Promise<any>;
// Devices
registerDevice: (data: { device_name: string; device_fingerprint: string; public_key: string }) => Promise<any>;
listDevices: () => Promise<any>;
updateDeviceStatus: (id: number, status: string) => Promise<any>;
deleteDevice: (id: number) => Promise<any>;
// Nodes
listNodes: () => Promise<any>;
getAvailableNodes: () => Promise<any>;
// Connections
requestConnection: (data: { device_id: number; node_id: number }) => Promise<any>;
disconnect: (deviceId: number) => Promise<any>;
getConnectionLogs: () => Promise<any>;
// Relay
connectToRelay: (host: string, port: number, token: string, deviceId: number) => Promise<any>;
sendRelayMessage: (targetDeviceId: number, payload: string) => Promise<any>;
disconnectFromRelay: () => Promise<any>;
// Events
onRelayMessage: (callback: (msg: any) => void) => void;
onRelayStatus: (callback: (status: string) => void) => void;
removeRelayListeners: () => void;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src *" />
<title>Network Tool - 异地组网客户端</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -0,0 +1,228 @@
import React, { useState, useEffect, useRef } from 'react';
export function ConnectionsPage() {
const [devices, setDevices] = useState<any[]>([]);
const [nodes, setNodes] = useState<any[]>([]);
const [selectedDevice, setSelectedDevice] = useState<number | null>(null);
const [selectedNode, setSelectedNode] = useState<number | null>(null);
const [connected, setConnected] = useState(false);
const [relayStatus, setRelayStatus] = useState('disconnected');
const [messages, setMessages] = useState<{ text: string; incoming: boolean; time: string }[]>([]);
const [draftMessage, setDraftMessage] = useState('');
const [targetDeviceId, setTargetDeviceId] = useState('');
const [error, setError] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadDevices();
loadNodes();
window.electronAPI.onRelayMessage((msg) => {
if (msg.type === 'data') {
setMessages((prev) => [
...prev,
{
text: msg.payload,
incoming: true,
time: new Date(msg.timestamp).toLocaleTimeString(),
},
]);
} else if (msg.type === 'error') {
setMessages((prev) => [
...prev,
{
text: `[错误] ${msg.payload}`,
incoming: true,
time: new Date(msg.timestamp).toLocaleTimeString(),
},
]);
}
});
window.electronAPI.onRelayStatus((status) => {
setRelayStatus(status);
if (status === 'authenticated') {
setConnected(true);
setError('');
setMessages((prev) => [
...prev,
{ text: '✅ 已连接到转发节点', incoming: true, time: new Date().toLocaleTimeString() },
]);
} else if (status === 'disconnected') {
setConnected(false);
} else if (status === 'error') {
setError('连接失败');
}
});
return () => {
window.electronAPI.removeRelayListeners();
};
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const loadDevices = async () => {
const res = await window.electronAPI.listDevices();
if (res.success) setDevices(res.data);
};
const loadNodes = async () => {
const res = await window.electronAPI.getAvailableNodes();
if (res.success) setNodes(res.data);
};
const handleConnect = async () => {
if (!selectedDevice || !selectedNode) {
setError('请选择设备和节点');
return;
}
setError('');
setMessages([]);
const res = await window.electronAPI.requestConnection({
device_id: selectedDevice,
node_id: selectedNode,
});
if (!res.success) {
setError(res.message || '请求连接失败');
return;
}
const { token, node } = res.data;
const relayRes = await window.electronAPI.connectToRelay(
node.host,
node.port,
token,
selectedDevice
);
if (!relayRes.success) {
setError(relayRes.message || '连接节点失败');
await window.electronAPI.updateDeviceStatus(selectedDevice, 'offline');
}
};
const handleDisconnect = async () => {
await window.electronAPI.disconnectFromRelay();
if (selectedDevice) {
await window.electronAPI.updateDeviceStatus(selectedDevice, 'offline');
await window.electronAPI.disconnect(selectedDevice);
}
setConnected(false);
setMessages((prev) => [
...prev,
{ text: '🔌 已断开连接', incoming: true, time: new Date().toLocaleTimeString() },
]);
};
const handleSendMessage = async () => {
if (!draftMessage.trim() || !targetDeviceId) return;
await window.electronAPI.sendRelayMessage(parseInt(targetDeviceId), draftMessage.trim());
setMessages((prev) => [
...prev,
{ text: draftMessage.trim(), incoming: false, time: new Date().toLocaleTimeString() },
]);
setDraftMessage('');
};
return (
<div className="page">
<h1></h1>
{!connected ? (
<div className="card">
<h3></h3>
<div className="form-group">
<label></label>
<select
value={selectedDevice || ''}
onChange={(e) => setSelectedDevice(parseInt(e.target.value))}
>
<option value="">-- --</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.device_name} (ID: {d.id}, {d.status})
</option>
))}
</select>
</div>
<div className="form-group">
<label></label>
<select
value={selectedNode || ''}
onChange={(e) => setSelectedNode(parseInt(e.target.value))}
>
<option value="">-- --</option>
{nodes.map((n) => (
<option key={n.id} value={n.id}>
{n.name} ({n.host}:{n.port}, : {n.load})
</option>
))}
</select>
</div>
{error && <div className="error-msg">{error}</div>}
<button className="btn-primary" onClick={handleConnect}>
</button>
</div>
) : (
<div className="connection-active">
<div className="card connection-header">
<span>: {relayStatus === 'authenticated' ? '🟢 已连接' : '🟡 连接中...'}</span>
<button className="btn-danger" onClick={handleDisconnect}>
</button>
</div>
<div className="card">
<h3></h3>
<div className="inline-form">
<input
type="number"
value={targetDeviceId}
onChange={(e) => setTargetDeviceId(e.target.value)}
placeholder="目标设备 ID"
className="small-input"
/>
<input
type="text"
value={draftMessage}
onChange={(e) => setDraftMessage(e.target.value)}
placeholder="输入消息..."
className="flex-input"
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
/>
<button className="btn-primary" onClick={handleSendMessage}>
</button>
</div>
</div>
<div className="card">
<h3></h3>
<div className="message-log">
{messages.length === 0 ? (
<p className="empty-text"></p>
) : (
messages.map((msg, i) => (
<div key={i} className={`message ${msg.incoming ? 'incoming' : 'outgoing'}`}>
<span className="msg-time">{msg.time}</span>
<span className="msg-arrow">{msg.incoming ? '←' : '→'}</span>
<span className="msg-text">{msg.text}</span>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,140 @@
import React, { useState, useEffect } from 'react';
function generateSimpleFingerprint(): string {
// 浏览器兼容的简单指纹生成
const arr = new Uint8Array(32);
crypto.getRandomValues(arr);
return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
}
export function DevicesPage() {
const [devices, setDevices] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [deviceName, setDeviceName] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const loadDevices = async () => {
const res = await window.electronAPI.listDevices();
if (res.success) {
setDevices(res.data);
}
setLoading(false);
};
useEffect(() => {
loadDevices();
}, []);
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
if (!deviceName.trim()) {
setError('设备名称不能为空');
return;
}
const fingerprint = generateSimpleFingerprint();
const res = await window.electronAPI.registerDevice({
device_name: deviceName.trim(),
device_fingerprint: fingerprint,
public_key: '',
});
if (res.success) {
setSuccess(`设备 "${deviceName}" 注册成功!`);
setDeviceName('');
loadDevices();
} else {
setError(res.message || '注册失败');
}
};
const handleDelete = async (id: number) => {
const res = await window.electronAPI.deleteDevice(id);
if (res.success) {
loadDevices();
}
};
const handleToggleStatus = async (id: number, current: string) => {
const newStatus = current === 'online' ? 'offline' : 'online';
await window.electronAPI.updateDeviceStatus(id, newStatus);
loadDevices();
};
return (
<div className="page">
<h1></h1>
<div className="card">
<h3></h3>
<form onSubmit={handleRegister} className="inline-form">
<input
type="text"
value={deviceName}
onChange={(e) => setDeviceName(e.target.value)}
placeholder="设备名称,如:MacBook Pro"
className="flex-input"
/>
<button type="submit" className="btn-primary"></button>
</form>
{error && <div className="error-msg">{error}</div>}
{success && <div className="success-msg">{success}</div>}
</div>
<div className="card">
<h3> ({devices.length})</h3>
{loading ? (
<p>...</p>
) : devices.length === 0 ? (
<p className="empty-text"></p>
) : (
<table className="data-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th>线</th>
<th></th>
</tr>
</thead>
<tbody>
{devices.map((d) => (
<tr key={d.id}>
<td>{d.id}</td>
<td>{d.device_name}</td>
<td className="mono">{d.device_fingerprint?.substring(0, 16)}...</td>
<td>
<span className={`status-badge ${d.status}`}>
{d.status === 'online' ? '🟢 在线' : '⚫ 离线'}
</span>
</td>
<td>{d.last_seen_at}</td>
<td>
<button
className="btn-sm"
onClick={() => handleToggleStatus(d.id, d.status)}
>
{d.status === 'online' ? '离线' : '上线'}
</button>
<button
className="btn-sm btn-danger"
onClick={() => handleDelete(d.id)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,70 @@
import React, { useState } from 'react';
interface Props {
onLoginSuccess: (user: any) => void;
onSwitchToRegister: () => void;
}
export function LoginPage({ onLoginSuccess, onSwitchToRegister }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
const res = await window.electronAPI.login(email, password);
if (res.success) {
onLoginSuccess(res.data.user);
} else {
setError(res.message || '登录失败');
}
setLoading(false);
};
return (
<div className="auth-page">
<div className="auth-card">
<h1>🌐 </h1>
<h2></h2>
<form onSubmit={handleLogin}>
<div className="form-group">
<label></label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
/>
</div>
<div className="form-group">
<label></label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="输入密码"
required
/>
</div>
{error && <div className="error-msg">{error}</div>}
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? '登录中...' : '登录'}
</button>
</form>
<p className="auth-switch">
{' '}
<button onClick={onSwitchToRegister} className="link-btn">
</button>
</p>
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import React, { useState, useEffect } from 'react';
export function LogsPage() {
const [logs, setLogs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const loadLogs = async () => {
const res = await window.electronAPI.getConnectionLogs();
if (res.success) setLogs(res.data);
setLoading(false);
};
useEffect(() => {
loadLogs();
const interval = setInterval(loadLogs, 5000);
return () => clearInterval(interval);
}, []);
return (
<div className="page">
<h1></h1>
<div className="card">
<h3> ({logs.length})</h3>
{loading ? (
<p>...</p>
) : logs.length === 0 ? (
<p className="empty-text"></p>
) : (
<table className="data-table">
<thead>
<tr>
<th></th>
<th>ID</th>
<th>ID</th>
<th>ID</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{logs.map((l) => (
<tr key={l.id}>
<td>{l.created_at}</td>
<td>{l.user_id || '-'}</td>
<td>{l.device_id || '-'}</td>
<td>{l.node_id || '-'}</td>
<td>{l.action}</td>
<td>
<span className={`status-badge ${l.status}`}>
{l.status === 'success' ? '✅' : '❌'}
</span>
</td>
<td>{l.message}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import React, { useState, useEffect } from 'react';
export function NodesPage() {
const [nodes, setNodes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const loadNodes = async () => {
const res = await window.electronAPI.listNodes();
if (res.success) setNodes(res.data);
setLoading(false);
};
useEffect(() => {
loadNodes();
const interval = setInterval(loadNodes, 10000);
return () => clearInterval(interval);
}, []);
return (
<div className="page">
<h1></h1>
<div className="card">
<h3> ({nodes.length})</h3>
{loading ? (
<p>...</p>
) : nodes.length === 0 ? (
<p className="empty-text"> Relay Node </p>
) : (
<table className="data-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{nodes.map((n) => (
<tr key={n.id}>
<td>{n.id}</td>
<td>{n.name}</td>
<td>{n.host}:{n.port}</td>
<td>
<span className={`status-badge ${n.status}`}>
{n.status === 'online' ? '🟢 在线' : n.status === 'busy' ? '🟡 繁忙' : '⚫ 离线'}
</span>
</td>
<td>
<div className="load-bar">
<div className="load-fill" style={{ width: `${Math.min(n.load, 100)}%` }} />
<span>{n.load}%</span>
</div>
</td>
<td>{n.last_heartbeat_at}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,94 @@
import React, { useState } from 'react';
interface Props {
onSwitchToLogin: () => void;
}
export function RegisterPage({ onSwitchToLogin }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
if (password !== confirmPassword) {
setError('两次密码不一致');
return;
}
if (password.length < 6) {
setError('密码至少 6 位');
return;
}
setLoading(true);
const res = await window.electronAPI.register(email, password);
if (res.success) {
setSuccess('注册成功!请登录');
setTimeout(onSwitchToLogin, 1500);
} else {
setError(res.message || '注册失败');
}
setLoading(false);
};
return (
<div className="auth-page">
<div className="auth-card">
<h1>🌐 </h1>
<h2></h2>
<form onSubmit={handleRegister}>
<div className="form-group">
<label></label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
/>
</div>
<div className="form-group">
<label></label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少 6 位"
required
/>
</div>
<div className="form-group">
<label></label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="再次输入密码"
required
/>
</div>
{error && <div className="error-msg">{error}</div>}
{success && <div className="success-msg">{success}</div>}
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? '注册中...' : '注册'}
</button>
</form>
<p className="auth-switch">
{' '}
<button onClick={onSwitchToLogin} className="link-btn">
</button>
</p>
</div>
</div>
);
}
@@ -0,0 +1,66 @@
import React, { useState, useEffect } from 'react';
export function SettingsPage() {
const [user, setUser] = useState<any>(null);
const [serverUrl, setServerUrl] = useState('http://localhost:3001');
useEffect(() => {
window.electronAPI.getMe().then((res) => {
if (res.success) setUser(res.data);
});
}, []);
return (
<div className="page">
<h1></h1>
<div className="card">
<h3></h3>
{user && (
<div className="settings-info">
<div className="info-row">
<span className="info-label"></span>
<span>{user.email}</span>
</div>
<div className="info-row">
<span className="info-label"></span>
<span>{user.role === 'admin' ? '管理员' : '普通用户'}</span>
</div>
<div className="info-row">
<span className="info-label"></span>
<span>{user.created_at}</span>
</div>
</div>
)}
</div>
<div className="card">
<h3></h3>
<div className="form-group">
<label>Control Server </label>
<input
type="text"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder="http://localhost:3001"
/>
</div>
<p className="hint-text"> SERVER_URL </p>
</div>
<div className="card">
<h3></h3>
<div className="settings-info">
<div className="info-row">
<span className="info-label"></span>
<span>Network Tool - </span>
</div>
<div className="info-row">
<span className="info-label"></span>
<span>0.1.0 MVP</span>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,494 @@
/* ============================================================
Global Styles - Network Tool Client
============================================================ */
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-card: #1e293b;
--bg-input: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--accent: #3b82f6;
--accent-hover: #2563eb;
--danger: #ef4444;
--danger-hover: #dc2626;
--success: #22c55e;
--warning: #f59e0b;
--border: #334155;
--radius: 8px;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
overflow: hidden;
}
/* ============================================================
App Layout
============================================================ */
.app-layout {
display: flex;
height: 100vh;
}
.sidebar {
width: 200px;
background: var(--bg-secondary);
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
}
.sidebar-header {
padding: 20px 16px;
border-bottom: 1px solid var(--border);
}
.sidebar-header h2 {
font-size: 16px;
margin-bottom: 4px;
}
.user-email {
font-size: 12px;
color: var(--text-secondary);
}
.sidebar-nav {
flex: 1;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-btn {
display: block;
width: 100%;
padding: 10px 12px;
text-align: left;
background: transparent;
color: var(--text-secondary);
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.nav-btn:hover {
background: var(--bg-input);
color: var(--text-primary);
}
.nav-btn.active {
background: var(--accent);
color: white;
}
.sidebar-footer {
padding: 8px;
border-top: 1px solid var(--border);
}
.logout-btn {
color: var(--danger) !important;
}
.main-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* ============================================================
Auth Pages
============================================================ */
.auth-page {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: var(--bg-primary);
}
.auth-card {
background: var(--bg-card);
padding: 40px;
border-radius: 12px;
width: 400px;
max-width: 90vw;
box-shadow: var(--shadow);
}
.auth-card h1 {
text-align: center;
font-size: 20px;
margin-bottom: 8px;
}
.auth-card h2 {
text-align: center;
font-size: 16px;
color: var(--text-secondary);
margin-bottom: 24px;
}
.auth-switch {
text-align: center;
margin-top: 16px;
font-size: 14px;
color: var(--text-secondary);
}
.link-btn {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 14px;
}
.link-btn:hover {
text-decoration: underline;
}
/* ============================================================
Page
============================================================ */
.page h1 {
font-size: 22px;
margin-bottom: 20px;
}
/* ============================================================
Card
============================================================ */
.card {
background: var(--bg-card);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--shadow);
}
.card h3 {
font-size: 15px;
margin-bottom: 16px;
color: var(--text-secondary);
}
/* ============================================================
Forms
============================================================ */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 6px;
}
input, select {
width: 100%;
padding: 10px 12px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
input:focus, select:focus {
border-color: var(--accent);
}
.inline-form {
display: flex;
gap: 8px;
align-items: center;
}
.flex-input {
flex: 1;
}
.small-input {
width: 120px;
flex: none;
}
/* ============================================================
Buttons
============================================================ */
.btn-primary {
display: inline-block;
width: 100%;
padding: 12px;
background: var(--accent);
color: white;
border: none;
border-radius: var(--radius);
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-danger {
background: var(--danger);
color: white;
border: none;
padding: 8px 16px;
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
}
.btn-danger:hover {
background: var(--danger-hover);
}
.btn-sm {
background: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
margin-right: 4px;
}
.btn-sm:hover {
background: var(--accent);
border-color: var(--accent);
}
.btn-sm.btn-danger:hover {
background: var(--danger);
border-color: var(--danger);
}
/* ============================================================
Status & Alerts
============================================================ */
.status-badge {
font-size: 13px;
padding: 2px 8px;
border-radius: 10px;
}
.status-badge.online {
color: var(--success);
}
.status-badge.offline {
color: var(--text-secondary);
}
.status-badge.busy {
color: var(--warning);
}
.status-badge.success {
color: var(--success);
}
.status-badge.failed {
color: var(--danger);
}
.error-msg {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
padding: 8px 12px;
border-radius: var(--radius);
font-size: 13px;
margin-bottom: 12px;
}
.success-msg {
background: rgba(34, 197, 94, 0.15);
color: var(--success);
padding: 8px 12px;
border-radius: var(--radius);
font-size: 13px;
margin-bottom: 12px;
}
.hint-text {
font-size: 12px;
color: var(--text-secondary);
margin-top: 8px;
}
.empty-text {
color: var(--text-secondary);
font-size: 14px;
padding: 20px 0;
text-align: center;
}
/* ============================================================
Tables
============================================================ */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table th {
text-align: left;
padding: 10px 8px;
color: var(--text-secondary);
font-weight: 500;
border-bottom: 1px solid var(--border);
}
.data-table td {
padding: 10px 8px;
border-bottom: 1px solid rgba(51, 65, 85, 0.5);
}
.data-table tr:hover td {
background: rgba(59, 130, 246, 0.05);
}
.mono {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
}
/* ============================================================
Load Bar
============================================================ */
.load-bar {
display: flex;
align-items: center;
gap: 8px;
min-width: 120px;
}
.load-fill {
height: 6px;
background: var(--accent);
border-radius: 3px;
transition: width 0.5s;
}
.load-bar span {
font-size: 12px;
color: var(--text-secondary);
min-width: 35px;
}
/* ============================================================
Connection
============================================================ */
.connection-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.connection-header span {
font-size: 15px;
}
.connection-header .btn-danger {
padding: 6px 14px;
}
/* ============================================================
Message Log
============================================================ */
.message-log {
max-height: 350px;
overflow-y: auto;
}
.message {
padding: 6px 0;
font-size: 13px;
display: flex;
align-items: baseline;
gap: 8px;
}
.msg-time {
color: var(--text-secondary);
font-size: 11px;
min-width: 70px;
}
.msg-arrow {
color: var(--accent);
font-weight: bold;
}
.message.incoming .msg-arrow {
color: var(--success);
}
.msg-text {
word-break: break-all;
}
/* ============================================================
Settings
============================================================ */
.settings-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid rgba(51, 65, 85, 0.3);
font-size: 14px;
}
.info-label {
color: var(--text-secondary);
}
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"outDir": "dist/renderer",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/renderer"]
}
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "dist/main",
"rootDir": "src/main",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/main"]
}
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
root: 'src/renderer',
base: './',
build: {
outDir: '../../dist/renderer',
emptyOutDir: true,
},
server: {
port: 5173,
strictPort: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/renderer'),
},
},
});
@@ -0,0 +1,25 @@
{
"name": "@network-tool/control-server",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"build": "tsc",
"db:init": "tsx src/db/init.ts"
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"@fastify/websocket": "^10.0.1",
"@network-tool/shared-types": "*",
"@network-tool/shared-crypto": "*",
"fastify": "^4.26.0",
"pino-pretty": "^11.0.0",
"sql.js": "^1.10.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"tsx": "^4.7.0",
"typescript": "^5.4.0"
}
}
@@ -0,0 +1,196 @@
import initSqlJs, { Database as SqlJsDatabase, Statement } from 'sql.js';
import fs from 'fs';
import path from 'path';
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'network.db');
let db: SqlJsDatabase | null = null;
export async function getDb(): Promise<SqlJsDatabase> {
if (!db) {
const SQL = await initSqlJs();
// 确保 data 目录存在
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// 尝试从文件加载,否则创建新数据库
if (fs.existsSync(DB_PATH)) {
const buffer = fs.readFileSync(DB_PATH);
db = new SQL.Database(buffer);
} else {
db = new SQL.Database();
}
db.run('PRAGMA journal_mode = WAL');
db.run('PRAGMA foreign_keys = ON');
initSchema(db);
console.log('[DB] 数据库已初始化');
}
return db;
}
/**
* 手动保存数据库到磁盘
*/
export function saveDb(): void {
if (!db) return;
const data = db.export();
const buffer = Buffer.from(data);
fs.writeFileSync(DB_PATH, buffer);
console.log('[DB] 数据库已保存');
}
function initSchema(database: SqlJsDatabase): void {
database.run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('user', 'admin')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
database.run(`
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
device_name TEXT NOT NULL,
device_fingerprint TEXT UNIQUE NOT NULL,
public_key TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'offline' CHECK(status IN ('online', 'offline')),
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
database.run(`
CREATE TABLE IF NOT EXISTS relay_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'offline' CHECK(status IN ('online', 'offline', 'busy')),
load INTEGER NOT NULL DEFAULT 0,
last_heartbeat_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
database.run(`
CREATE TABLE IF NOT EXISTS connection_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
device_id INTEGER NOT NULL,
node_id INTEGER NOT NULL,
token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE,
FOREIGN KEY (node_id) REFERENCES relay_nodes(id) ON DELETE CASCADE
)
`);
database.run(`
CREATE TABLE IF NOT EXISTS connection_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
device_id INTEGER,
node_id INTEGER,
action TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('success', 'failed')),
message TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// 索引
database.run('CREATE INDEX IF NOT EXISTS idx_devices_user ON devices(user_id)');
database.run('CREATE INDEX IF NOT EXISTS idx_devices_fingerprint ON devices(device_fingerprint)');
database.run('CREATE INDEX IF NOT EXISTS idx_nodes_status ON relay_nodes(status)');
database.run('CREATE INDEX IF NOT EXISTS idx_tokens_hash ON connection_tokens(token_hash)');
database.run('CREATE INDEX IF NOT EXISTS idx_logs_created ON connection_logs(created_at)');
}
/**
* 辅助函数:查询单行
*/
export function queryOne<T = Record<string, any>>(
database: SqlJsDatabase,
sql: string,
params: any[] = []
): T | null {
try {
const stmt = database.prepare(sql);
if (params.length > 0) stmt.bind(params);
if (stmt.step()) {
const row = stmt.getAsObject() as T;
stmt.free();
return row;
}
stmt.free();
return null;
} catch (err) {
console.error('[DB] queryOne error:', sql, err);
return null;
}
}
/**
* 辅助函数:查询多行
*/
export function queryAll<T = Record<string, any>>(
database: SqlJsDatabase,
sql: string,
params: any[] = []
): T[] {
try {
const stmt = database.prepare(sql);
if (params.length > 0) stmt.bind(params);
const results: T[] = [];
while (stmt.step()) {
results.push(stmt.getAsObject() as T);
}
stmt.free();
return results;
} catch (err) {
console.error('[DB] queryAll error:', sql, err);
return [];
}
}
/**
* 辅助函数:执行 INSERT/UPDATE/DELETE
*/
export function execute(
database: SqlJsDatabase,
sql: string,
params: any[] = []
): { changes: number; lastInsertRowid: number } {
try {
database.run(sql, params);
const lastId = database.exec("SELECT last_insert_rowid() as id");
const lastInsertRowid = lastId.length > 0 ? (lastId[0].values[0][0] as number) : 0;
return {
changes: database.getRowsModified(),
lastInsertRowid,
};
} catch (err) {
console.error('[DB] execute error:', sql, err);
throw err;
}
}
/**
* 独立的数据库初始化脚本(用于 npm run db:init
*/
if (require.main === module) {
getDb().then(() => {
saveDb();
console.log('[DB] 数据库初始化完成:', DB_PATH);
process.exit(0);
});
}
@@ -0,0 +1,10 @@
import { getDb, saveDb } from './database';
getDb().then(() => {
saveDb();
console.log('[DB Init] 数据库初始化完成');
process.exit(0);
}).catch(err => {
console.error('[DB Init] 失败:', err);
process.exit(1);
});
@@ -0,0 +1,106 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import websocket from '@fastify/websocket';
import { getDb, saveDb } from './db/database';
import { authRoutes } from './routes/auth.routes';
import { deviceRoutes } from './routes/device.routes';
import { nodeRoutes } from './routes/node.routes';
import { connectionRoutes } from './routes/connection.routes';
const PORT = parseInt(process.env.PORT || '3001', 10);
const HOST = process.env.HOST || '0.0.0.0';
async function main() {
// 异步初始化数据库
await getDb();
console.log('[Server] 数据库已初始化');
const app = Fastify({
logger: {
level: 'info',
transport: {
target: 'pino-pretty',
options: { colorize: true },
},
},
});
// 插件
await app.register(cors, { origin: true });
await app.register(websocket);
// WebSocket 端点
const connectedClients = new Set<any>();
app.get('/ws', { websocket: true }, (socket, _req) => {
connectedClients.add(socket);
console.log(`[WS] 客户端连接,当前连接数: ${connectedClients.size}`);
socket.on('message', (data: Buffer) => {
try {
const msg = JSON.parse(data.toString());
for (const client of connectedClients) {
if (client !== socket && client.readyState === 1) {
client.send(JSON.stringify(msg));
}
}
} catch { /* ignore */ }
});
socket.on('close', () => {
connectedClients.delete(socket);
console.log(`[WS] 客户端断开,当前连接数: ${connectedClients.size}`);
});
});
// 注册路由
await app.register(authRoutes);
await app.register(deviceRoutes);
await app.register(nodeRoutes);
await app.register(connectionRoutes);
// 健康检查
app.get('/api/health', async () => ({
success: true,
message: 'Control Server is running',
timestamp: new Date().toISOString(),
}));
// 全局错误处理
app.setErrorHandler((error, _request, reply) => {
console.error('[Server Error]', error);
reply.status(error.statusCode || 500).send({
success: false,
error: 'INTERNAL_ERROR',
message: error.message || 'Internal server error',
});
});
// 定时保存数据库(sql.js 是内存型,需要定期持久化)
setInterval(() => {
try {
saveDb();
} catch { /* ignore */ }
}, 30000);
// 定时清理过期数据
setInterval(async () => {
try {
const db = await getDb();
const { execute } = require('./db/database');
execute(db, "UPDATE relay_nodes SET status = 'offline' WHERE status != 'offline' AND last_heartbeat_at < datetime('now', '-5 minutes')");
execute(db, "DELETE FROM connection_tokens WHERE expires_at < datetime('now') AND used_at IS NULL");
saveDb();
} catch { /* ignore */ }
}, 60000);
// 启动服务
await app.listen({ port: PORT, host: HOST });
console.log(`[Server] Control Server 已启动: http://${HOST}:${PORT}`);
console.log(`[Server] WebSocket: ws://${HOST}:${PORT}/ws`);
}
main().catch((err) => {
console.error('[Server] 启动失败:', err);
process.exit(1);
});
@@ -0,0 +1,45 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { verifyToken, JwtPayload } from '@network-tool/shared-crypto';
import { JWT_SECRET } from '../services/auth.service';
declare module 'fastify' {
interface FastifyRequest {
user?: JwtPayload;
}
}
/**
* JWT 认证中间件
*/
export async function authMiddleware(
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
reply.status(401).send({ success: false, error: 'UNAUTHORIZED', message: 'Missing or invalid token' });
return;
}
const token = authHeader.substring(7);
const payload = verifyToken(token, JWT_SECRET);
if (!payload) {
reply.status(401).send({ success: false, error: 'TOKEN_EXPIRED', message: 'Token expired or invalid' });
return;
}
request.user = payload;
}
/**
* Admin 权限中间件(需在 authMiddleware 之后使用)
*/
export async function adminMiddleware(
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
if (!request.user || request.user.role !== 'admin') {
reply.status(403).send({ success: false, error: 'FORBIDDEN', message: 'Admin access required' });
}
}
@@ -0,0 +1,96 @@
import { FastifyInstance } from 'fastify';
import { AuthService } from '../services/auth.service';
import { authMiddleware, adminMiddleware } from '../middleware/auth';
const authService = new AuthService();
export async function authRoutes(app: FastifyInstance): Promise<void> {
/**
* POST /api/auth/register - 用户注册
*/
app.post('/api/auth/register', async (request, reply) => {
try {
const { email, password } = request.body as { email: string; password: string };
if (!email || !password) {
return reply.status(400).send({
success: false,
error: 'VALIDATION_ERROR',
message: 'Email and password are required',
});
}
if (password.length < 6) {
return reply.status(400).send({
success: false,
error: 'VALIDATION_ERROR',
message: 'Password must be at least 6 characters',
});
}
const result = await authService.register({ email, password });
return reply.send({ success: true, data: result });
} catch (err: any) {
if (err.message === 'EMAIL_EXISTS') {
return reply.status(409).send({
success: false,
error: 'EMAIL_EXISTS',
message: 'Email already registered',
});
}
throw err;
}
});
/**
* POST /api/auth/login - 用户登录
*/
app.post('/api/auth/login', async (request, reply) => {
try {
const { email, password } = request.body as { email: string; password: string };
if (!email || !password) {
return reply.status(400).send({
success: false,
error: 'VALIDATION_ERROR',
message: 'Email and password are required',
});
}
const result = await authService.login({ email, password });
return reply.send({ success: true, data: result });
} catch (err: any) {
if (err.message === 'INVALID_CREDENTIALS') {
return reply.status(401).send({
success: false,
error: 'INVALID_CREDENTIALS',
message: 'Invalid email or password',
});
}
throw err;
}
});
/**
* GET /api/auth/me - 获取当前用户信息
*/
app.get('/api/auth/me', { preHandler: [authMiddleware] }, async (request, reply) => {
const user = await authService.getMe(request.user!.user_id);
if (!user) {
return reply.status(404).send({
success: false,
error: 'USER_NOT_FOUND',
message: 'User not found',
});
}
return reply.send({ success: true, data: user });
});
/**
* GET /api/admin/users - 管理所有用户
*/
app.get('/api/admin/users', { preHandler: [authMiddleware, adminMiddleware] }, async (request, reply) => {
const users = await authService.listUsers();
return reply.send({ success: true, data: users });
});
}
@@ -0,0 +1,71 @@
import { FastifyInstance } from 'fastify';
import { ConnectionService } from '../services/connection.service';
import { authMiddleware, adminMiddleware } from '../middleware/auth';
const connectionService = new ConnectionService();
export async function connectionRoutes(app: FastifyInstance): Promise<void> {
// 需要认证的路由
app.post('/api/connections/request', { preHandler: [authMiddleware] }, async (request, reply) => {
try {
const { device_id, node_id } = request.body as { device_id: number; node_id: number };
if (!device_id || !node_id) {
return reply.status(400).send({
success: false, error: 'VALIDATION_ERROR',
message: 'device_id and node_id are required',
});
}
const result = await connectionService.requestConnection(request.user!.user_id, { device_id, node_id });
return reply.send({ success: true, data: result });
} catch (err: any) {
if (err.message === 'DEVICE_NOT_FOUND') {
return reply.status(404).send({
success: false, error: 'DEVICE_NOT_FOUND',
message: 'Device not found or not owned by user',
});
}
if (err.message === 'NODE_NOT_AVAILABLE') {
return reply.status(400).send({
success: false, error: 'NODE_NOT_AVAILABLE',
message: 'Node not available',
});
}
throw err;
}
});
// verify-token 不需要用户认证(由 Relay Node 调用)
app.post('/api/connections/verify-token', async (request, reply) => {
const { token, device_id } = request.body as { token: string; device_id: number };
if (!token || !device_id) {
return reply.status(400).send({
success: false, error: 'VALIDATION_ERROR',
message: 'token and device_id are required',
});
}
const result = await connectionService.verifyToken({ token, device_id });
return reply.send({ success: true, data: result });
});
app.post('/api/connections/disconnect', { preHandler: [authMiddleware] }, async (request, reply) => {
const { device_id } = request.body as { device_id: number };
if (!device_id) {
return reply.status(400).send({
success: false, error: 'VALIDATION_ERROR',
message: 'device_id is required',
});
}
await connectionService.disconnect(request.user!.user_id, device_id);
return reply.send({ success: true, message: 'Disconnected' });
});
app.get('/api/connections/logs', { preHandler: [authMiddleware] }, async (request, reply) => {
const logs = await connectionService.getUserLogs(request.user!.user_id);
return reply.send({ success: true, data: logs });
});
app.get('/api/admin/connections/logs', { preHandler: [authMiddleware, adminMiddleware] }, async (_request, reply) => {
const logs = await connectionService.getLogs();
return reply.send({ success: true, data: logs });
});
}
@@ -0,0 +1,87 @@
import { FastifyInstance } from 'fastify';
import { DeviceService } from '../services/device.service';
import { authMiddleware, adminMiddleware } from '../middleware/auth';
const deviceService = new DeviceService();
export async function deviceRoutes(app: FastifyInstance): Promise<void> {
app.addHook('preHandler', authMiddleware);
app.post('/api/devices/register', async (request, reply) => {
try {
const { device_name, device_fingerprint, public_key } = request.body as {
device_name: string;
device_fingerprint: string;
public_key: string;
};
if (!device_name || !device_fingerprint) {
return reply.status(400).send({
success: false, error: 'VALIDATION_ERROR',
message: 'device_name and device_fingerprint are required',
});
}
const device = await deviceService.registerDevice(request.user!.user_id, {
device_name, device_fingerprint,
public_key: public_key || '',
});
return reply.send({ success: true, data: device });
} catch (err: any) {
if (err.message === 'DEVICE_ALREADY_EXISTS') {
return reply.status(409).send({
success: false, error: 'DEVICE_ALREADY_EXISTS',
message: 'Device already registered',
});
}
throw err;
}
});
app.get('/api/devices', async (request, reply) => {
const devices = await deviceService.getUserDevices(request.user!.user_id);
return reply.send({ success: true, data: devices });
});
app.patch('/api/devices/:id/status', async (request, reply) => {
const { id } = request.params as { id: string };
const { status } = request.body as { status: 'online' | 'offline' };
if (!status || !['online', 'offline'].includes(status)) {
return reply.status(400).send({
success: false, error: 'VALIDATION_ERROR',
message: 'Status must be "online" or "offline"',
});
}
const device = await deviceService.updateDeviceStatus(parseInt(id), status);
if (!device) {
return reply.status(404).send({
success: false, error: 'DEVICE_NOT_FOUND',
message: 'Device not found',
});
}
return reply.send({ success: true, data: device });
});
app.delete('/api/devices/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const deleted = await deviceService.deleteDevice(parseInt(id), request.user!.user_id);
if (!deleted) {
return reply.status(404).send({
success: false, error: 'DEVICE_NOT_FOUND',
message: 'Device not found',
});
}
return reply.send({ success: true, message: 'Device deleted' });
});
app.get('/api/admin/devices', { preHandler: [adminMiddleware] }, async (_request, reply) => {
const devices = await deviceService.getAllDevices();
return reply.send({ success: true, data: devices });
});
}
@@ -0,0 +1,65 @@
import { FastifyInstance } from 'fastify';
import { NodeService } from '../services/node.service';
import { authMiddleware, adminMiddleware } from '../middleware/auth';
const nodeService = new NodeService();
export async function nodeRoutes(app: FastifyInstance): Promise<void> {
app.post('/api/nodes/register', async (request, reply) => {
try {
const { name, host, port } = request.body as { name: string; host: string; port: number };
if (!name || !host || !port) {
return reply.status(400).send({
success: false, error: 'VALIDATION_ERROR',
message: 'name, host, and port are required',
});
}
const node = await nodeService.registerNode({ name, host, port });
return reply.send({ success: true, data: node });
} catch (err) { throw err; }
});
app.post('/api/nodes/heartbeat', async (request, reply) => {
const { node_id, load, status } = request.body as {
node_id: number; load: number; status: 'online' | 'offline' | 'busy';
};
if (!node_id) {
return reply.status(400).send({
success: false, error: 'VALIDATION_ERROR', message: 'node_id is required',
});
}
const node = await nodeService.heartbeat(node_id, { load: load ?? 0, status: status ?? 'online' });
if (!node) {
return reply.status(404).send({
success: false, error: 'NODE_NOT_FOUND', message: 'Node not found',
});
}
return reply.send({ success: true, data: node });
});
app.get('/api/nodes', { preHandler: [authMiddleware] }, async (_request, reply) => {
const nodes = await nodeService.getAllNodes();
return reply.send({ success: true, data: nodes });
});
app.get('/api/nodes/available', { preHandler: [authMiddleware] }, async (_request, reply) => {
const nodes = await nodeService.getAvailableNodes();
return reply.send({ success: true, data: nodes });
});
app.get('/api/admin/nodes', { preHandler: [authMiddleware, adminMiddleware] }, async (_request, reply) => {
const nodes = await nodeService.getAllNodes();
return reply.send({ success: true, data: nodes });
});
app.delete('/api/admin/nodes/:id', { preHandler: [authMiddleware, adminMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
const deleted = await nodeService.deleteNode(parseInt(id));
if (!deleted) {
return reply.status(404).send({
success: false, error: 'NODE_NOT_FOUND', message: 'Node not found',
});
}
return reply.send({ success: true, message: 'Node deleted' });
});
}
@@ -0,0 +1,45 @@
import { getDb, queryOne, queryAll, execute, saveDb } from '../db/database';
import type { User, UserPublic } from '@network-tool/shared-types';
import { hashPassword, verifyPassword, generateToken } from '@network-tool/shared-crypto';
const JWT_SECRET = process.env.JWT_SECRET || 'network-tool-dev-secret-change-in-production';
export { JWT_SECRET };
export class AuthService {
async register(data: { email: string; password: string }) {
const db = await getDb();
if (queryOne(db, 'SELECT id FROM users WHERE email = ?', [data.email])) {
throw new Error('EMAIL_EXISTS');
}
const hash = await hashPassword(data.password);
const result = execute(db, 'INSERT INTO users (email, password_hash) VALUES (?, ?)', [data.email, hash]);
const user = queryOne<User>(db, 'SELECT * FROM users WHERE id = ?', [result.lastInsertRowid])!;
saveDb();
const token = generateToken({ user_id: user.id, email: user.email, role: user.role }, JWT_SECRET);
return { user: this.public(user), token };
}
async login(data: { email: string; password: string }) {
const db = await getDb();
const user = queryOne<User>(db, 'SELECT * FROM users WHERE email = ?', [data.email]);
if (!user) throw new Error('INVALID_CREDENTIALS');
if (!(await verifyPassword(data.password, user.password_hash))) throw new Error('INVALID_CREDENTIALS');
const token = generateToken({ user_id: user.id, email: user.email, role: user.role }, JWT_SECRET);
return { user: this.public(user), token };
}
async getMe(userId: number): Promise<UserPublic | null> {
const db = await getDb();
const user = queryOne<User>(db, 'SELECT * FROM users WHERE id = ?', [userId]);
return user ? this.public(user) : null;
}
async listUsers(): Promise<UserPublic[]> {
const db = await getDb();
return queryAll<User>(db, 'SELECT * FROM users ORDER BY created_at DESC').map(u => this.public(u));
}
private public(u: User): UserPublic {
return { id: u.id, email: u.email, role: u.role, created_at: u.created_at };
}
}
@@ -0,0 +1,106 @@
import { getDb, queryOne, queryAll, execute, saveDb } from '../db/database';
import type { ConnectionTokenResponse, TokenVerifyResponse, ConnectionLog } from '@network-tool/shared-types';
import { generateConnectionToken, sha256, generateNonce } from '@network-tool/shared-crypto';
import { JWT_SECRET } from './auth.service';
export class ConnectionService {
async requestConnection(userId: number, data: {
device_id: number;
node_id: number;
}): Promise<ConnectionTokenResponse> {
const db = await getDb();
const device = queryOne(db, 'SELECT * FROM devices WHERE id = ? AND user_id = ?', [data.device_id, userId]);
if (!device) throw new Error('DEVICE_NOT_FOUND');
const node = queryOne(db, "SELECT * FROM relay_nodes WHERE id = ? AND status = 'online'", [data.node_id]);
if (!node) throw new Error('NODE_NOT_AVAILABLE');
execute(db, "UPDATE devices SET status = 'online', last_seen_at = datetime('now') WHERE id = ?", [data.device_id]);
const nonce = generateNonce();
const token = generateConnectionToken({
user_id: userId,
device_id: data.device_id,
node_id: data.node_id,
nonce,
}, JWT_SECRET, '5m');
const tokenHash = sha256(token);
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
execute(db,
'INSERT INTO connection_tokens (user_id, device_id, node_id, token_hash, expires_at) VALUES (?, ?, ?, ?, ?)',
[userId, data.device_id, data.node_id, tokenHash, expiresAt]
);
this._log(db, userId, data.device_id, data.node_id, 'token_issued', 'success', 'Token issued');
saveDb();
return {
token,
node: node as any,
expires_at: expiresAt,
};
}
async verifyToken(data: { token: string; device_id: number }): Promise<TokenVerifyResponse> {
const db = await getDb();
const tokenHash = sha256(data.token);
const record = queryOne<any>(db,
`SELECT * FROM connection_tokens
WHERE token_hash = ? AND device_id = ? AND used_at IS NULL
AND expires_at > datetime('now')`,
[tokenHash, data.device_id]
);
if (!record) {
this._log(db, null, data.device_id, null, 'verify_token', 'failed', 'Invalid or expired token');
return { valid: false, user_id: 0, device_id: 0, message: 'Invalid or expired token' };
}
execute(db, "UPDATE connection_tokens SET used_at = datetime('now') WHERE id = ?", [record.id]);
execute(db, "UPDATE devices SET status = 'online', last_seen_at = datetime('now') WHERE id = ?", [data.device_id]);
this._log(db, record.user_id, record.device_id, record.node_id, 'verify_token', 'success', 'Token verified');
saveDb();
return { valid: true, user_id: record.user_id, device_id: record.device_id, message: 'Token verified' };
}
async disconnect(userId: number, deviceId: number): Promise<void> {
const db = await getDb();
execute(db, "UPDATE devices SET status = 'offline', last_seen_at = datetime('now') WHERE id = ? AND user_id = ?",
[deviceId, userId]);
this._log(db, userId, deviceId, null, 'disconnect', 'success', 'Disconnected');
saveDb();
}
async getLogs(limit = 100): Promise<ConnectionLog[]> {
const db = await getDb();
return queryAll<ConnectionLog>(db, 'SELECT * FROM connection_logs ORDER BY created_at DESC LIMIT ?', [limit]);
}
async getUserLogs(userId: number, limit = 50): Promise<ConnectionLog[]> {
const db = await getDb();
return queryAll<ConnectionLog>(db,
'SELECT * FROM connection_logs WHERE user_id = ? ORDER BY created_at DESC LIMIT ?',
[userId, limit]
);
}
async cleanupExpiredTokens(): Promise<void> {
const db = await getDb();
execute(db, "DELETE FROM connection_tokens WHERE expires_at < datetime('now') AND used_at IS NULL");
saveDb();
}
private _log(db: any, userId: number | null, deviceId: number | null, nodeId: number | null,
action: string, status: 'success' | 'failed', message: string): void {
execute(db,
'INSERT INTO connection_logs (user_id, device_id, node_id, action, status, message) VALUES (?, ?, ?, ?, ?, ?)',
[userId, deviceId, nodeId, action, status, message]
);
}
}
@@ -0,0 +1,50 @@
import { getDb, queryOne, queryAll, execute } from '../db/database';
import type { Device } from '@network-tool/shared-types';
export class DeviceService {
async registerDevice(userId: number, data: { device_name: string; device_fingerprint: string; public_key: string }): Promise<Device> {
const db = await getDb();
if (queryOne(db, 'SELECT id FROM devices WHERE device_fingerprint = ?', [data.device_fingerprint])) {
throw new Error('DEVICE_ALREADY_EXISTS');
}
const result = execute(db,
'INSERT INTO devices (user_id, device_name, device_fingerprint, public_key, status) VALUES (?, ?, ?, ?, ?)',
[userId, data.device_name, data.device_fingerprint, data.public_key, 'offline']
);
return queryOne<Device>(db, 'SELECT * FROM devices WHERE id = ?', [result.lastInsertRowid])!;
}
async getUserDevices(userId: number): Promise<Device[]> {
const db = await getDb();
return queryAll<Device>(db, 'SELECT * FROM devices WHERE user_id = ? ORDER BY last_seen_at DESC', [userId]);
}
async getAllDevices(): Promise<Device[]> {
const db = await getDb();
return queryAll<Device>(db, 'SELECT * FROM devices ORDER BY last_seen_at DESC');
}
async updateDeviceStatus(deviceId: number, status: 'online' | 'offline'): Promise<Device | null> {
const db = await getDb();
const result = execute(db, "UPDATE devices SET status = ?, last_seen_at = datetime('now') WHERE id = ?", [status, deviceId]);
if (result.changes === 0) return null;
return queryOne<Device>(db, 'SELECT * FROM devices WHERE id = ?', [deviceId]);
}
async deleteDevice(deviceId: number, userId: number): Promise<boolean> {
const db = await getDb();
const result = execute(db, 'DELETE FROM devices WHERE id = ? AND user_id = ?', [deviceId, userId]);
return result.changes > 0;
}
async adminDeleteDevice(deviceId: number): Promise<boolean> {
const db = await getDb();
const result = execute(db, 'DELETE FROM devices WHERE id = ?', [deviceId]);
return result.changes > 0;
}
async getDeviceById(deviceId: number): Promise<Device | null> {
const db = await getDb();
return queryOne<Device>(db, 'SELECT * FROM devices WHERE id = ?', [deviceId]);
}
}
@@ -0,0 +1,54 @@
import { getDb, queryOne, queryAll, execute } from '../db/database';
import type { RelayNode } from '@network-tool/shared-types';
export class NodeService {
async registerNode(data: { name: string; host: string; port: number }): Promise<RelayNode> {
const db = await getDb();
const result = execute(db,
"INSERT INTO relay_nodes (name, host, port, status, load) VALUES (?, ?, ?, 'offline', 0)",
[data.name, data.host, data.port]
);
return queryOne<RelayNode>(db, 'SELECT * FROM relay_nodes WHERE id = ?', [result.lastInsertRowid])!;
}
async heartbeat(nodeId: number, data: { load: number; status: 'online' | 'offline' | 'busy' }): Promise<RelayNode | null> {
const db = await getDb();
const result = execute(db,
"UPDATE relay_nodes SET status = ?, load = ?, last_heartbeat_at = datetime('now') WHERE id = ?",
[data.status, data.load, nodeId]
);
if (result.changes === 0) return null;
return queryOne<RelayNode>(db, 'SELECT * FROM relay_nodes WHERE id = ?', [nodeId]);
}
async getAllNodes(): Promise<RelayNode[]> {
const db = await getDb();
return queryAll<RelayNode>(db, 'SELECT * FROM relay_nodes ORDER BY last_heartbeat_at DESC');
}
async getAvailableNodes(): Promise<RelayNode[]> {
const db = await getDb();
return queryAll<RelayNode>(db,
"SELECT * FROM relay_nodes WHERE status = 'online' AND load < 100 ORDER BY load ASC, last_heartbeat_at DESC"
);
}
async getNodeById(nodeId: number): Promise<RelayNode | null> {
const db = await getDb();
return queryOne<RelayNode>(db, 'SELECT * FROM relay_nodes WHERE id = ?', [nodeId]);
}
async deleteNode(nodeId: number): Promise<boolean> {
const db = await getDb();
const result = execute(db, 'DELETE FROM relay_nodes WHERE id = ?', [nodeId]);
return result.changes > 0;
}
async cleanupStaleNodes(timeoutMinutes = 5): Promise<void> {
const db = await getDb();
execute(db,
"UPDATE relay_nodes SET status = 'offline' WHERE status != 'offline' AND last_heartbeat_at < datetime('now', '-' || ? || ' minutes')",
[timeoutMinutes]
);
}
}
+33
View File
@@ -0,0 +1,33 @@
declare module 'sql.js' {
interface SqlValue {
[columnName: string]: number | string | Uint8Array | null;
}
interface QueryExecResult {
columns: string[];
values: any[][];
}
interface Statement {
bind(params?: any[]): boolean;
step(): boolean;
getAsObject(params?: any): SqlValue;
free(): boolean;
}
interface Database {
run(sql: string, params?: any[]): Database;
exec(sql: string): QueryExecResult[];
prepare(sql: string): Statement;
export(): Uint8Array;
close(): void;
getRowsModified(): number;
}
interface SqlJsStatic {
Database: new (data?: ArrayLike<number> | Buffer | null) => Database;
}
export default function initSqlJs(config?: any): Promise<SqlJsStatic>;
export { Database, Statement, SqlJsStatic };
}
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src"]
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@network-tool/relay-node",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"build": "tsc"
},
"dependencies": {
"@network-tool/shared-types": "*",
"@network-tool/shared-crypto": "*",
"@network-tool/shared-protocol": "*",
"ws": "^8.16.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/ws": "^8.5.10",
"tsx": "^4.7.0",
"typescript": "^5.4.0"
}
}
+304
View File
@@ -0,0 +1,304 @@
import net from 'net';
import http from 'http';
import { FrameParser, encodeMessage } from '@network-tool/shared-protocol';
import type { NetworkMessage, ControlMessage } from '@network-tool/shared-types';
// ============================================================
// 配置
// ============================================================
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3001';
const NODE_NAME = process.env.NODE_NAME || 'relay-node-1';
const NODE_HOST = process.env.NODE_HOST || '0.0.0.0';
const NODE_PORT = parseInt(process.env.NODE_PORT || '4001', 10);
const TCP_PORT = parseInt(process.env.TCP_PORT || '4101', 10);
const HEARTBEAT_INTERVAL = parseInt(process.env.HEARTBEAT_INTERVAL || '15000', 10);
// ============================================================
// 全局状态
// ============================================================
let nodeId: number | null = null;
const connectedClients = new Map<number, net.Socket>();
// 逻辑连接:两个设备通过同一个节点建立的对等连接
const peerConnections = new Map<string, { deviceA: number; deviceB: number }>();
// ============================================================
// HTTP 请求辅助
// ============================================================
function apiRequest(path: string, method = 'GET', body?: any): Promise<any> {
return new Promise((resolve, reject) => {
const url = new URL(path, SERVER_URL);
const data = body ? JSON.stringify(body) : undefined;
const options: http.RequestOptions = {
hostname: url.hostname,
port: url.port,
path: url.pathname,
method,
headers: {
'Content-Type': 'application/json',
...(data ? { 'Content-Length': Buffer.byteLength(data).toString() } : {}),
},
};
const req = http.request(options, (res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => {
try {
resolve(JSON.parse(body));
} catch {
reject(new Error(`Invalid JSON response: ${body}`));
}
});
});
req.on('error', reject);
if (data) req.write(data);
req.end();
});
}
// ============================================================
// 节点注册
// ============================================================
async function registerNode(): Promise<void> {
try {
console.log(`[Node] 正在向服务器注册: ${SERVER_URL}`);
const res = await apiRequest('/api/nodes/register', 'POST', {
name: NODE_NAME,
host: NODE_HOST,
port: TCP_PORT,
});
if (res.success) {
nodeId = res.data.id;
console.log(`[Node] 注册成功! Node ID: ${nodeId}`);
} else {
throw new Error(res.error || 'Registration failed');
}
} catch (err) {
console.error('[Node] 注册失败:', err);
// 5 秒后重试
setTimeout(registerNode, 5000);
}
}
// ============================================================
// 心跳
// ============================================================
async function sendHeartbeat(): Promise<void> {
if (!nodeId) return;
try {
const load = connectedClients.size;
await apiRequest('/api/nodes/heartbeat', 'POST', {
node_id: nodeId,
load,
status: 'online',
});
console.log(`[Node] 心跳 OK | 连接数: ${load}`);
} catch (err) {
console.error('[Node] 心跳失败:', err);
}
}
// ============================================================
// Token 验证
// ============================================================
async function verifyToken(token: string, deviceId: number): Promise<boolean> {
try {
const res = await apiRequest('/api/connections/verify-token', 'POST', {
token,
device_id: deviceId,
});
return res.success && res.data?.valid === true;
} catch (err) {
console.error('[Node] Token 验证失败:', err);
return false;
}
}
// ============================================================
// TCP 客户端处理
// ============================================================
function handleClientConnection(socket: net.Socket): void {
const parser = new FrameParser();
let clientDeviceId: number | null = null;
let authenticated = false;
const remoteAddr = `${socket.remoteAddress}:${socket.remotePort}`;
console.log(`[Node] 新客户端连接: ${remoteAddr}`);
socket.on('data', async (chunk: Buffer) => {
const messages = parser.push(chunk);
for (const msg of messages) {
// 第一条消息必须是认证消息
if (!authenticated) {
if (msg.type === 'auth') {
try {
const authData: ControlMessage = JSON.parse(msg.payload);
if (authData.action === 'connect' && authData.token) {
const valid = await verifyToken(authData.token, authData.device_id);
if (valid) {
authenticated = true;
clientDeviceId = authData.device_id;
connectedClients.set(clientDeviceId, socket);
console.log(`[Node] 客户端认证通过: Device ${clientDeviceId}`);
socket.write(encodeMessage({
type: 'control',
from_device_id: 0,
to_device_id: clientDeviceId,
payload: JSON.stringify({ action: 'connect_ok' }),
timestamp: Date.now(),
message_id: generateId(),
}));
} else {
socket.write(encodeMessage({
type: 'error',
from_device_id: 0,
to_device_id: authData.device_id,
payload: 'Authentication failed: invalid token',
timestamp: Date.now(),
message_id: generateId(),
}));
socket.end();
}
}
} catch {
socket.write(encodeMessage({
type: 'error',
from_device_id: 0,
to_device_id: 0,
payload: 'Authentication failed: invalid message',
timestamp: Date.now(),
message_id: generateId(),
}));
socket.end();
}
} else {
socket.end();
}
continue;
}
// 已认证的消息转发
if (msg.type === 'data' || msg.type === 'control') {
forwardMessage(msg);
}
}
});
socket.on('close', () => {
console.log(`[Node] 客户端断开: ${remoteAddr}`);
if (clientDeviceId !== null) {
connectedClients.delete(clientDeviceId);
// 通知对等端
notifyPeerDisconnect(clientDeviceId);
}
});
socket.on('error', (err) => {
console.error(`[Node] 客户端错误 ${remoteAddr}:`, err.message);
});
}
// ============================================================
// 消息转发
// ============================================================
function forwardMessage(msg: NetworkMessage): void {
const targetSocket = connectedClients.get(msg.to_device_id);
if (targetSocket && !targetSocket.destroyed) {
targetSocket.write(encodeMessage(msg));
console.log(`[Node] 转发消息: Device ${msg.from_device_id} -> Device ${msg.to_device_id}`);
} else {
// 目标不在线,通知发送方
const sourceSocket = connectedClients.get(msg.from_device_id);
if (sourceSocket && !sourceSocket.destroyed) {
sourceSocket.write(encodeMessage({
type: 'error',
from_device_id: 0,
to_device_id: msg.from_device_id,
payload: `Target device ${msg.to_device_id} is not connected`,
timestamp: Date.now(),
message_id: generateId(),
}));
}
}
}
// ============================================================
// 对等端断开通告
// ============================================================
function notifyPeerDisconnect(deviceId: number): void {
for (const [otherId, socket] of connectedClients) {
if (otherId !== deviceId && !socket.destroyed) {
socket.write(encodeMessage({
type: 'control',
from_device_id: 0,
to_device_id: otherId,
payload: JSON.stringify({
action: 'peer_disconnect',
device_id: deviceId,
}),
timestamp: Date.now(),
message_id: generateId(),
}));
}
}
}
// ============================================================
// 辅助
// ============================================================
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
}
// ============================================================
// 启动
// ============================================================
async function main(): Promise<void> {
console.log('═══════════════════════════════════════');
console.log(' Network Tool - Relay Node');
console.log('═══════════════════════════════════════');
console.log(` Server URL: ${SERVER_URL}`);
console.log(` Node Name: ${NODE_NAME}`);
console.log(` TCP Port: ${TCP_PORT}`);
console.log('═══════════════════════════════════════');
// 1. 注册节点
await registerNode();
// 2. 启动 TCP 服务器
const tcpServer = net.createServer(handleClientConnection);
tcpServer.listen(TCP_PORT, NODE_HOST, () => {
console.log(`[Node] TCP 服务启动: ${NODE_HOST}:${TCP_PORT}`);
});
// 3. 心跳定时器
setInterval(sendHeartbeat, HEARTBEAT_INTERVAL);
// 首次心跳
setTimeout(sendHeartbeat, 2000);
// 优雅退出
process.on('SIGINT', () => {
console.log('\n[Node] 正在关闭...');
tcpServer.close(() => {
console.log('[Node] TCP 服务已关闭');
process.exit(0);
});
});
process.on('SIGTERM', () => {
console.log('[Node] 收到 SIGTERM,正在关闭...');
tcpServer.close(() => process.exit(0));
});
}
main().catch((err) => {
console.error('[Node] 启动失败:', err);
process.exit(1);
});
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}