feat: FunConnect v1.0.0 - Minecraft联机平台完整版
- server: Node.js TCP中继服务器,支持多节点集群 - web: React管理面板(仪表盘、房间管理、节点管理) - client: Electron桌面客户端(连接、创建/加入房间、本地代理) - deploy: Ubuntu一键部署脚本
This commit is contained in:
31
server/.env.example
Normal file
31
server/.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# FunMC Relay Server Configuration
|
||||
|
||||
# Relay TCP port for Minecraft traffic
|
||||
RELAY_PORT=25565
|
||||
|
||||
# HTTP API port
|
||||
API_PORT=3000
|
||||
|
||||
# Node identification
|
||||
NODE_ID=
|
||||
NODE_NAME=relay-node-1
|
||||
|
||||
# Master node configuration
|
||||
IS_MASTER=true
|
||||
MASTER_URL=http://master-host:3000
|
||||
|
||||
# Security
|
||||
SECRET=your-secret-key-here
|
||||
|
||||
# Limits
|
||||
MAX_ROOMS=100
|
||||
MAX_PLAYERS_PER_ROOM=20
|
||||
|
||||
# Heartbeat interval in ms
|
||||
HEARTBEAT_INTERVAL=10000
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Public host (for worker nodes to report to master)
|
||||
PUBLIC_HOST=0.0.0.0
|
||||
2006
server/package-lock.json
generated
Normal file
2006
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
server/package.json
Normal file
31
server/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "funmc-server",
|
||||
"version": "1.0.0",
|
||||
"description": "FunMC Minecraft Relay Server - Multi-node TCP proxy for Minecraft multiplayer",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"dev:watch": "nodemon --exec ts-node src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.16.0",
|
||||
"uuid": "^9.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"winston": "^3.11.0",
|
||||
"axios": "^1.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
211
server/src/api.ts
Normal file
211
server/src/api.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import express, { Request, Response, Router } from 'express';
|
||||
import { RoomManager } from './room';
|
||||
import { NodeManager } from './node-manager';
|
||||
import { RelayEngine } from './relay';
|
||||
import config from './config';
|
||||
import logger from './logger';
|
||||
|
||||
export function createApiRouter(
|
||||
roomManager: RoomManager,
|
||||
nodeManager: NodeManager,
|
||||
relayEngine: RelayEngine
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// ===== Health & Status =====
|
||||
router.get('/health', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
nodeId: config.nodeId,
|
||||
nodeName: config.nodeName,
|
||||
isMaster: config.isMaster,
|
||||
uptime: process.uptime(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/stats', (_req: Request, res: Response) => {
|
||||
const nodeStats = config.isMaster ? nodeManager.getStats() : null;
|
||||
res.json({
|
||||
node: {
|
||||
id: config.nodeId,
|
||||
name: config.nodeName,
|
||||
rooms: roomManager.getRoomCount(),
|
||||
players: roomManager.getTotalPlayers(),
|
||||
maxRooms: config.maxRooms,
|
||||
},
|
||||
cluster: nodeStats,
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Room Management =====
|
||||
router.get('/rooms', (_req: Request, res: Response) => {
|
||||
const rooms = roomManager.listRooms();
|
||||
res.json({ rooms, total: rooms.length });
|
||||
});
|
||||
|
||||
router.post('/rooms', (req: Request, res: Response) => {
|
||||
const { name, hostName, hostPort, gameVersion, gameEdition, maxPlayers, password } = req.body;
|
||||
|
||||
if (!name || !hostName || !hostPort || !gameVersion) {
|
||||
res.status(400).json({ error: '缺少必要参数: name, hostName, hostPort, gameVersion' });
|
||||
return;
|
||||
}
|
||||
|
||||
const room = roomManager.createRoom({
|
||||
name,
|
||||
hostName,
|
||||
hostPort: parseInt(hostPort),
|
||||
gameVersion,
|
||||
gameEdition: gameEdition || 'java',
|
||||
maxPlayers: parseInt(maxPlayers) || 10,
|
||||
nodeId: config.nodeId,
|
||||
password,
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
res.status(503).json({ error: '房间数量已达上限' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
room: room.toInfo(),
|
||||
connectInfo: {
|
||||
host: config.isMaster ? 'MASTER_HOST' : config.nodeName,
|
||||
port: config.port,
|
||||
roomId: room.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/rooms/:roomId', (req: Request, res: Response) => {
|
||||
const room = roomManager.getRoom(req.params.roomId);
|
||||
if (!room) {
|
||||
res.status(404).json({ error: '房间不存在' });
|
||||
return;
|
||||
}
|
||||
res.json({ room: room.toInfo() });
|
||||
});
|
||||
|
||||
router.delete('/rooms/:roomId', (req: Request, res: Response) => {
|
||||
const deleted = roomManager.deleteRoom(req.params.roomId);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: '房间不存在' });
|
||||
return;
|
||||
}
|
||||
res.json({ message: '房间已删除' });
|
||||
});
|
||||
|
||||
// ===== Node Management (Master only) =====
|
||||
router.get('/nodes', (_req: Request, res: Response) => {
|
||||
if (!config.isMaster) {
|
||||
res.status(403).json({ error: '仅主节点可管理节点列表' });
|
||||
return;
|
||||
}
|
||||
const nodes = nodeManager.listNodes();
|
||||
res.json({ nodes, total: nodes.length });
|
||||
});
|
||||
|
||||
router.post('/nodes/register', (req: Request, res: Response) => {
|
||||
if (!config.isMaster) {
|
||||
res.status(403).json({ error: '仅主节点可注册节点' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, host, apiPort, relayPort, maxRooms, region } = req.body;
|
||||
if (!name || !host || !apiPort || !relayPort) {
|
||||
res.status(400).json({ error: '缺少必要参数: name, host, apiPort, relayPort' });
|
||||
return;
|
||||
}
|
||||
|
||||
const node = nodeManager.registerNode({
|
||||
name,
|
||||
host,
|
||||
apiPort: parseInt(apiPort),
|
||||
relayPort: parseInt(relayPort),
|
||||
maxRooms: parseInt(maxRooms) || 100,
|
||||
region: region || 'default',
|
||||
});
|
||||
|
||||
res.status(201).json({ node });
|
||||
});
|
||||
|
||||
router.post('/nodes/:nodeId/heartbeat', (req: Request, res: Response) => {
|
||||
if (!config.isMaster) {
|
||||
res.status(403).json({ error: '仅主节点可接收心跳' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { roomCount, playerCount } = req.body;
|
||||
const updated = nodeManager.updateNodeHeartbeat(req.params.nodeId, {
|
||||
roomCount: parseInt(roomCount) || 0,
|
||||
playerCount: parseInt(playerCount) || 0,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: '节点不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
router.delete('/nodes/:nodeId', (req: Request, res: Response) => {
|
||||
if (!config.isMaster) {
|
||||
res.status(403).json({ error: '仅主节点可删除节点' });
|
||||
return;
|
||||
}
|
||||
|
||||
const removed = nodeManager.removeNode(req.params.nodeId);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: '节点不存在' });
|
||||
return;
|
||||
}
|
||||
res.json({ message: '节点已移除' });
|
||||
});
|
||||
|
||||
router.get('/nodes/best', (_req: Request, res: Response) => {
|
||||
if (!config.isMaster) {
|
||||
res.status(403).json({ error: '仅主节点可查询最佳节点' });
|
||||
return;
|
||||
}
|
||||
|
||||
const best = nodeManager.getBestNode();
|
||||
if (!best) {
|
||||
res.status(503).json({ error: '无可用节点' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ node: best });
|
||||
});
|
||||
|
||||
// ===== Cluster Room Query (Master aggregates from all nodes) =====
|
||||
router.get('/cluster/rooms', async (_req: Request, res: Response) => {
|
||||
if (!config.isMaster) {
|
||||
res.status(403).json({ error: '仅主节点可查询集群房间' });
|
||||
return;
|
||||
}
|
||||
|
||||
const allRooms = roomManager.listRooms();
|
||||
const nodes = nodeManager.listNodes();
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.status === 'offline') continue;
|
||||
try {
|
||||
const axios = (await import('axios')).default;
|
||||
const response = await axios.get(`http://${node.host}:${node.apiPort}/api/rooms`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
if (response.data.rooms) {
|
||||
allRooms.push(...response.data.rooms);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to fetch rooms from node ${node.name}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ rooms: allRooms, total: allRooms.length });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
32
server/src/config.ts
Normal file
32
server/src/config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
export interface ServerConfig {
|
||||
port: number;
|
||||
apiPort: number;
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
masterUrl: string;
|
||||
isMaster: boolean;
|
||||
secret: string;
|
||||
maxRooms: number;
|
||||
maxPlayersPerRoom: number;
|
||||
heartbeatInterval: number;
|
||||
logLevel: string;
|
||||
}
|
||||
|
||||
const config: ServerConfig = {
|
||||
port: parseInt(process.env.RELAY_PORT || '25565'),
|
||||
apiPort: parseInt(process.env.API_PORT || '3000'),
|
||||
nodeId: process.env.NODE_ID || '',
|
||||
nodeName: process.env.NODE_NAME || 'relay-node-1',
|
||||
masterUrl: process.env.MASTER_URL || '',
|
||||
isMaster: process.env.IS_MASTER === 'true',
|
||||
secret: process.env.SECRET || 'funmc-default-secret',
|
||||
maxRooms: parseInt(process.env.MAX_ROOMS || '100'),
|
||||
maxPlayersPerRoom: parseInt(process.env.MAX_PLAYERS_PER_ROOM || '20'),
|
||||
heartbeatInterval: parseInt(process.env.HEARTBEAT_INTERVAL || '10000'),
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
};
|
||||
|
||||
export default config;
|
||||
134
server/src/index.ts
Normal file
134
server/src/index.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import http from 'http';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import axios from 'axios';
|
||||
|
||||
import config from './config';
|
||||
import logger from './logger';
|
||||
import { RoomManager } from './room';
|
||||
import { NodeManager } from './node-manager';
|
||||
import { RelayEngine } from './relay';
|
||||
import { createApiRouter } from './api';
|
||||
import { WebSocketHandler } from './websocket';
|
||||
|
||||
async function main() {
|
||||
logger.info('========================================');
|
||||
logger.info(' FunMC Relay Server Starting...');
|
||||
logger.info('========================================');
|
||||
|
||||
if (!config.nodeId) {
|
||||
config.nodeId = uuidv4().substring(0, 8);
|
||||
}
|
||||
|
||||
logger.info(`Node ID: ${config.nodeId}`);
|
||||
logger.info(`Node Name: ${config.nodeName}`);
|
||||
logger.info(`Mode: ${config.isMaster ? 'MASTER' : 'WORKER'}`);
|
||||
logger.info(`Relay Port: ${config.port}`);
|
||||
logger.info(`API Port: ${config.apiPort}`);
|
||||
|
||||
const roomManager = new RoomManager(config.maxRooms);
|
||||
const nodeManager = new NodeManager();
|
||||
const relayEngine = new RelayEngine(roomManager);
|
||||
const wsHandler = new WebSocketHandler(roomManager, nodeManager);
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/api', createApiRouter(roomManager, nodeManager, relayEngine));
|
||||
|
||||
app.get('/', (_req, res) => {
|
||||
res.json({
|
||||
name: 'FunMC Relay Server',
|
||||
version: '1.0.0',
|
||||
nodeId: config.nodeId,
|
||||
nodeName: config.nodeName,
|
||||
isMaster: config.isMaster,
|
||||
status: 'running',
|
||||
});
|
||||
});
|
||||
|
||||
const httpServer = http.createServer(app);
|
||||
wsHandler.attach(httpServer);
|
||||
|
||||
httpServer.listen(config.apiPort, '0.0.0.0', () => {
|
||||
logger.info(`HTTP API server listening on port ${config.apiPort}`);
|
||||
});
|
||||
|
||||
await relayEngine.start(config.port);
|
||||
|
||||
if (!config.isMaster && config.masterUrl) {
|
||||
await registerWithMaster();
|
||||
startHeartbeat(roomManager);
|
||||
}
|
||||
|
||||
if (config.isMaster) {
|
||||
nodeManager.registerNode({
|
||||
name: config.nodeName,
|
||||
host: '127.0.0.1',
|
||||
apiPort: config.apiPort,
|
||||
relayPort: config.port,
|
||||
maxRooms: config.maxRooms,
|
||||
region: 'local',
|
||||
});
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('Shutting down...');
|
||||
relayEngine.stop();
|
||||
wsHandler.stop();
|
||||
httpServer.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('Shutting down...');
|
||||
relayEngine.stop();
|
||||
wsHandler.stop();
|
||||
httpServer.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
logger.info('FunMC Relay Server is ready!');
|
||||
}
|
||||
|
||||
async function registerWithMaster(): Promise<void> {
|
||||
try {
|
||||
const response = await axios.post(`${config.masterUrl}/api/nodes/register`, {
|
||||
name: config.nodeName,
|
||||
host: getPublicHost(),
|
||||
apiPort: config.apiPort,
|
||||
relayPort: config.port,
|
||||
maxRooms: config.maxRooms,
|
||||
region: 'default',
|
||||
});
|
||||
logger.info(`Registered with master node, assigned ID: ${response.data.node?.id}`);
|
||||
config.nodeId = response.data.node?.id || config.nodeId;
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to register with master: ${err.message}`);
|
||||
logger.warn('Running in standalone mode');
|
||||
}
|
||||
}
|
||||
|
||||
function startHeartbeat(roomManager: RoomManager): void {
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await axios.post(`${config.masterUrl}/api/nodes/${config.nodeId}/heartbeat`, {
|
||||
roomCount: roomManager.getRoomCount(),
|
||||
playerCount: roomManager.getTotalPlayers(),
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.warn(`Heartbeat failed: ${err.message}`);
|
||||
}
|
||||
}, config.heartbeatInterval);
|
||||
}
|
||||
|
||||
function getPublicHost(): string {
|
||||
return process.env.PUBLIC_HOST || '0.0.0.0';
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error(`Fatal error: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
20
server/src/logger.ts
Normal file
20
server/src/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import winston from 'winston';
|
||||
import config from './config';
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: config.logLevel,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
|
||||
return `[${timestamp}] [${level.toUpperCase()}] ${message}${metaStr}`;
|
||||
})
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console(),
|
||||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
||||
new winston.transports.File({ filename: 'logs/combined.log' }),
|
||||
],
|
||||
});
|
||||
|
||||
export default logger;
|
||||
154
server/src/node-manager.ts
Normal file
154
server/src/node-manager.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import axios from 'axios';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import logger from './logger';
|
||||
import config from './config';
|
||||
|
||||
export interface RelayNode {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
apiPort: number;
|
||||
relayPort: number;
|
||||
status: 'online' | 'offline' | 'busy';
|
||||
roomCount: number;
|
||||
playerCount: number;
|
||||
maxRooms: number;
|
||||
region: string;
|
||||
lastHeartbeat: Date;
|
||||
registeredAt: Date;
|
||||
}
|
||||
|
||||
export class NodeManager {
|
||||
private nodes: Map<string, RelayNode> = new Map();
|
||||
private heartbeatTimers: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
constructor() {
|
||||
if (config.isMaster) {
|
||||
setInterval(() => this.checkNodeHealth(), config.heartbeatInterval);
|
||||
}
|
||||
}
|
||||
|
||||
registerNode(nodeInfo: {
|
||||
name: string;
|
||||
host: string;
|
||||
apiPort: number;
|
||||
relayPort: number;
|
||||
maxRooms: number;
|
||||
region: string;
|
||||
}): RelayNode {
|
||||
const existing = Array.from(this.nodes.values()).find(
|
||||
n => n.host === nodeInfo.host && n.apiPort === nodeInfo.apiPort
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
existing.status = 'online';
|
||||
existing.lastHeartbeat = new Date();
|
||||
existing.name = nodeInfo.name;
|
||||
existing.maxRooms = nodeInfo.maxRooms;
|
||||
existing.region = nodeInfo.region;
|
||||
logger.info(`Node re-registered: ${existing.name} (${existing.id})`);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const node: RelayNode = {
|
||||
id: uuidv4().substring(0, 8),
|
||||
name: nodeInfo.name,
|
||||
host: nodeInfo.host,
|
||||
apiPort: nodeInfo.apiPort,
|
||||
relayPort: nodeInfo.relayPort,
|
||||
status: 'online',
|
||||
roomCount: 0,
|
||||
playerCount: 0,
|
||||
maxRooms: nodeInfo.maxRooms,
|
||||
region: nodeInfo.region,
|
||||
lastHeartbeat: new Date(),
|
||||
registeredAt: new Date(),
|
||||
};
|
||||
|
||||
this.nodes.set(node.id, node);
|
||||
logger.info(`New node registered: ${node.name} (${node.id}) at ${node.host}:${node.relayPort}`);
|
||||
return node;
|
||||
}
|
||||
|
||||
removeNode(nodeId: string): boolean {
|
||||
const timer = this.heartbeatTimers.get(nodeId);
|
||||
if (timer) clearTimeout(timer);
|
||||
this.heartbeatTimers.delete(nodeId);
|
||||
|
||||
const removed = this.nodes.delete(nodeId);
|
||||
if (removed) {
|
||||
logger.info(`Node removed: ${nodeId}`);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
updateNodeHeartbeat(nodeId: string, stats: { roomCount: number; playerCount: number }): boolean {
|
||||
const node = this.nodes.get(nodeId);
|
||||
if (!node) return false;
|
||||
|
||||
node.lastHeartbeat = new Date();
|
||||
node.roomCount = stats.roomCount;
|
||||
node.playerCount = stats.playerCount;
|
||||
node.status = node.roomCount >= node.maxRooms ? 'busy' : 'online';
|
||||
return true;
|
||||
}
|
||||
|
||||
getNode(nodeId: string): RelayNode | undefined {
|
||||
return this.nodes.get(nodeId);
|
||||
}
|
||||
|
||||
listNodes(): RelayNode[] {
|
||||
return Array.from(this.nodes.values());
|
||||
}
|
||||
|
||||
getAvailableNodes(): RelayNode[] {
|
||||
return Array.from(this.nodes.values()).filter(n => n.status === 'online');
|
||||
}
|
||||
|
||||
getBestNode(): RelayNode | undefined {
|
||||
const available = this.getAvailableNodes();
|
||||
if (available.length === 0) return undefined;
|
||||
|
||||
return available.sort((a, b) => {
|
||||
const aLoad = a.roomCount / a.maxRooms;
|
||||
const bLoad = b.roomCount / b.maxRooms;
|
||||
return aLoad - bLoad;
|
||||
})[0];
|
||||
}
|
||||
|
||||
private async checkNodeHealth(): Promise<void> {
|
||||
const now = new Date();
|
||||
for (const [nodeId, node] of this.nodes) {
|
||||
const timeSinceHeartbeat = now.getTime() - node.lastHeartbeat.getTime();
|
||||
|
||||
if (timeSinceHeartbeat > config.heartbeatInterval * 3) {
|
||||
node.status = 'offline';
|
||||
logger.warn(`Node ${node.name} (${nodeId}) marked offline - no heartbeat for ${Math.round(timeSinceHeartbeat / 1000)}s`);
|
||||
} else {
|
||||
try {
|
||||
const response = await axios.get(`http://${node.host}:${node.apiPort}/api/health`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
if (response.data.status === 'ok') {
|
||||
node.status = node.roomCount >= node.maxRooms ? 'busy' : 'online';
|
||||
node.lastHeartbeat = new Date();
|
||||
}
|
||||
} catch {
|
||||
if (timeSinceHeartbeat > config.heartbeatInterval * 2) {
|
||||
node.status = 'offline';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStats(): { totalNodes: number; onlineNodes: number; totalRooms: number; totalPlayers: number } {
|
||||
const nodes = Array.from(this.nodes.values());
|
||||
return {
|
||||
totalNodes: nodes.length,
|
||||
onlineNodes: nodes.filter(n => n.status !== 'offline').length,
|
||||
totalRooms: nodes.reduce((sum, n) => sum + n.roomCount, 0),
|
||||
totalPlayers: nodes.reduce((sum, n) => sum + n.playerCount, 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
205
server/src/relay.ts
Normal file
205
server/src/relay.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import net from 'net';
|
||||
import logger from './logger';
|
||||
import { Room, RoomManager } from './room';
|
||||
import config from './config';
|
||||
|
||||
export class RelayEngine {
|
||||
private server: net.Server | null = null;
|
||||
private roomManager: RoomManager;
|
||||
private hostConnections: Map<string, net.Socket> = new Map();
|
||||
private pendingConnections: Map<string, { roomId: string; playerName: string; resolve: (socket: net.Socket) => void }> = new Map();
|
||||
|
||||
constructor(roomManager: RoomManager) {
|
||||
this.roomManager = roomManager;
|
||||
}
|
||||
|
||||
start(port: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = net.createServer((clientSocket) => {
|
||||
this.handleConnection(clientSocket);
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
logger.error(`Relay server error: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.server.listen(port, '0.0.0.0', () => {
|
||||
logger.info(`Relay TCP server listening on port ${port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private handleConnection(clientSocket: net.Socket): void {
|
||||
const remoteAddr = `${clientSocket.remoteAddress}:${clientSocket.remotePort}`;
|
||||
logger.info(`New TCP connection from ${remoteAddr}`);
|
||||
|
||||
let buffer = Buffer.alloc(0);
|
||||
let identified = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!identified) {
|
||||
logger.warn(`Connection from ${remoteAddr} timed out waiting for identification`);
|
||||
clientSocket.destroy();
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
clientSocket.once('data', (data) => {
|
||||
clearTimeout(timeout);
|
||||
identified = true;
|
||||
buffer = Buffer.concat([buffer, data]);
|
||||
this.identifyAndRoute(clientSocket, buffer, remoteAddr);
|
||||
});
|
||||
|
||||
clientSocket.on('error', (err) => {
|
||||
logger.debug(`Socket error from ${remoteAddr}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
private identifyAndRoute(clientSocket: net.Socket, initialData: Buffer, remoteAddr: string): void {
|
||||
try {
|
||||
const headerStr = initialData.toString('utf8', 0, Math.min(initialData.length, 512));
|
||||
|
||||
if (headerStr.startsWith('FUNMC_HOST:')) {
|
||||
this.handleHostConnection(clientSocket, headerStr, remoteAddr);
|
||||
} else if (headerStr.startsWith('FUNMC_JOIN:')) {
|
||||
this.handlePlayerJoin(clientSocket, headerStr, initialData, remoteAddr);
|
||||
} else {
|
||||
logger.debug(`Unknown protocol from ${remoteAddr}, closing`);
|
||||
clientSocket.destroy();
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(`Error routing connection from ${remoteAddr}: ${err.message}`);
|
||||
clientSocket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private handleHostConnection(hostSocket: net.Socket, header: string, remoteAddr: string): void {
|
||||
const parts = header.split('\n')[0].replace('FUNMC_HOST:', '').split('|');
|
||||
if (parts.length < 2) {
|
||||
hostSocket.write('ERROR:INVALID_FORMAT\n');
|
||||
hostSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const roomId = parts[0].trim();
|
||||
const hostPort = parseInt(parts[1].trim());
|
||||
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
if (!room) {
|
||||
hostSocket.write('ERROR:ROOM_NOT_FOUND\n');
|
||||
hostSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
room.hostSocket = hostSocket;
|
||||
this.hostConnections.set(roomId, hostSocket);
|
||||
hostSocket.write('OK:HOST_REGISTERED\n');
|
||||
|
||||
logger.info(`Host registered for room ${roomId} from ${remoteAddr}, target port ${hostPort}`);
|
||||
|
||||
hostSocket.on('close', () => {
|
||||
logger.info(`Host disconnected from room ${roomId}`);
|
||||
this.hostConnections.delete(roomId);
|
||||
this.roomManager.deleteRoom(roomId);
|
||||
});
|
||||
|
||||
hostSocket.on('error', (err) => {
|
||||
logger.error(`Host socket error for room ${roomId}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
private handlePlayerJoin(playerSocket: net.Socket, header: string, initialData: Buffer, remoteAddr: string): void {
|
||||
const parts = header.split('\n')[0].replace('FUNMC_JOIN:', '').split('|');
|
||||
if (parts.length < 2) {
|
||||
playerSocket.write('ERROR:INVALID_FORMAT\n');
|
||||
playerSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const roomId = parts[0].trim();
|
||||
const playerName = parts[1].trim();
|
||||
const password = parts[2]?.trim();
|
||||
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
if (!room) {
|
||||
playerSocket.write('ERROR:ROOM_NOT_FOUND\n');
|
||||
playerSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.password && room.password !== password) {
|
||||
playerSocket.write('ERROR:WRONG_PASSWORD\n');
|
||||
playerSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.isFull) {
|
||||
playerSocket.write('ERROR:ROOM_FULL\n');
|
||||
playerSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const hostSocket = this.hostConnections.get(roomId);
|
||||
if (!hostSocket || hostSocket.destroyed) {
|
||||
playerSocket.write('ERROR:HOST_OFFLINE\n');
|
||||
playerSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const player = room.addPlayer(playerName, playerSocket);
|
||||
playerSocket.write('OK:CONNECTED\n');
|
||||
|
||||
this.setupRelay(roomId, player.id, playerSocket, hostSocket, room);
|
||||
}
|
||||
|
||||
private setupRelay(roomId: string, playerId: string, playerSocket: net.Socket, hostSocket: net.Socket, room: Room): void {
|
||||
const hostAddr = hostSocket.remoteAddress;
|
||||
const hostPort = room.hostPort;
|
||||
|
||||
const targetSocket = new net.Socket();
|
||||
|
||||
targetSocket.connect(hostPort, hostAddr || '127.0.0.1', () => {
|
||||
logger.info(`Relay established: player ${playerId} <-> host ${hostAddr}:${hostPort} in room ${roomId}`);
|
||||
|
||||
playerSocket.pipe(targetSocket);
|
||||
targetSocket.pipe(playerSocket);
|
||||
});
|
||||
|
||||
targetSocket.on('error', (err) => {
|
||||
logger.error(`Target connection error for player ${playerId}: ${err.message}`);
|
||||
playerSocket.destroy();
|
||||
});
|
||||
|
||||
targetSocket.on('close', () => {
|
||||
playerSocket.destroy();
|
||||
room.removePlayer(playerId);
|
||||
});
|
||||
|
||||
playerSocket.on('close', () => {
|
||||
targetSocket.destroy();
|
||||
room.removePlayer(playerId);
|
||||
});
|
||||
|
||||
playerSocket.on('error', (err) => {
|
||||
logger.debug(`Player socket error: ${err.message}`);
|
||||
targetSocket.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
registerHostDirect(roomId: string, hostSocket: net.Socket): void {
|
||||
this.hostConnections.set(roomId, hostSocket);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
logger.info('Relay TCP server stopped');
|
||||
}
|
||||
for (const [, socket] of this.hostConnections) {
|
||||
socket.destroy();
|
||||
}
|
||||
this.hostConnections.clear();
|
||||
}
|
||||
}
|
||||
187
server/src/room.ts
Normal file
187
server/src/room.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import net from 'net';
|
||||
import logger from './logger';
|
||||
|
||||
export interface Player {
|
||||
id: string;
|
||||
name: string;
|
||||
socket: net.Socket;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
export interface RoomInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
hostId: string;
|
||||
hostName: string;
|
||||
hostPort: number;
|
||||
gameVersion: string;
|
||||
gameEdition: 'java' | 'bedrock';
|
||||
maxPlayers: number;
|
||||
currentPlayers: number;
|
||||
nodeId: string;
|
||||
createdAt: Date;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export class Room {
|
||||
public id: string;
|
||||
public name: string;
|
||||
public hostId: string;
|
||||
public hostName: string;
|
||||
public hostSocket: net.Socket | null = null;
|
||||
public hostPort: number;
|
||||
public gameVersion: string;
|
||||
public gameEdition: 'java' | 'bedrock';
|
||||
public maxPlayers: number;
|
||||
public password?: string;
|
||||
public nodeId: string;
|
||||
public createdAt: Date;
|
||||
public players: Map<string, Player> = new Map();
|
||||
private playerSockets: Map<string, net.Socket> = new Map();
|
||||
|
||||
constructor(options: {
|
||||
name: string;
|
||||
hostName: string;
|
||||
hostPort: number;
|
||||
gameVersion: string;
|
||||
gameEdition: 'java' | 'bedrock';
|
||||
maxPlayers: number;
|
||||
nodeId: string;
|
||||
password?: string;
|
||||
}) {
|
||||
this.id = uuidv4().substring(0, 8).toUpperCase();
|
||||
this.name = options.name;
|
||||
this.hostId = uuidv4();
|
||||
this.hostName = options.hostName;
|
||||
this.hostPort = options.hostPort;
|
||||
this.gameVersion = options.gameVersion;
|
||||
this.gameEdition = options.gameEdition;
|
||||
this.maxPlayers = options.maxPlayers;
|
||||
this.nodeId = options.nodeId;
|
||||
this.password = options.password;
|
||||
this.createdAt = new Date();
|
||||
}
|
||||
|
||||
get currentPlayers(): number {
|
||||
return this.players.size;
|
||||
}
|
||||
|
||||
get isFull(): boolean {
|
||||
return this.currentPlayers >= this.maxPlayers;
|
||||
}
|
||||
|
||||
addPlayer(name: string, socket: net.Socket): Player {
|
||||
const player: Player = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
socket,
|
||||
joinedAt: new Date(),
|
||||
};
|
||||
this.players.set(player.id, player);
|
||||
this.playerSockets.set(player.id, socket);
|
||||
logger.info(`Player ${name} joined room ${this.name} (${this.id})`);
|
||||
return player;
|
||||
}
|
||||
|
||||
removePlayer(playerId: string): void {
|
||||
const player = this.players.get(playerId);
|
||||
if (player) {
|
||||
logger.info(`Player ${player.name} left room ${this.name} (${this.id})`);
|
||||
this.players.delete(playerId);
|
||||
this.playerSockets.delete(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
toInfo(): RoomInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
hostId: this.hostId,
|
||||
hostName: this.hostName,
|
||||
hostPort: this.hostPort,
|
||||
gameVersion: this.gameVersion,
|
||||
gameEdition: this.gameEdition,
|
||||
maxPlayers: this.maxPlayers,
|
||||
currentPlayers: this.currentPlayers,
|
||||
nodeId: this.nodeId,
|
||||
createdAt: this.createdAt,
|
||||
password: this.password ? '***' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
for (const [, player] of this.players) {
|
||||
try {
|
||||
player.socket.destroy();
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
if (this.hostSocket) {
|
||||
try {
|
||||
this.hostSocket.destroy();
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
this.players.clear();
|
||||
this.playerSockets.clear();
|
||||
logger.info(`Room ${this.name} (${this.id}) destroyed`);
|
||||
}
|
||||
}
|
||||
|
||||
export class RoomManager {
|
||||
private rooms: Map<string, Room> = new Map();
|
||||
private maxRooms: number;
|
||||
|
||||
constructor(maxRooms: number) {
|
||||
this.maxRooms = maxRooms;
|
||||
}
|
||||
|
||||
createRoom(options: {
|
||||
name: string;
|
||||
hostName: string;
|
||||
hostPort: number;
|
||||
gameVersion: string;
|
||||
gameEdition: 'java' | 'bedrock';
|
||||
maxPlayers: number;
|
||||
nodeId: string;
|
||||
password?: string;
|
||||
}): Room | null {
|
||||
if (this.rooms.size >= this.maxRooms) {
|
||||
logger.warn('Max rooms reached, cannot create new room');
|
||||
return null;
|
||||
}
|
||||
const room = new Room(options);
|
||||
this.rooms.set(room.id, room);
|
||||
logger.info(`Room created: ${room.name} (${room.id}) on node ${room.nodeId}`);
|
||||
return room;
|
||||
}
|
||||
|
||||
getRoom(roomId: string): Room | undefined {
|
||||
return this.rooms.get(roomId);
|
||||
}
|
||||
|
||||
deleteRoom(roomId: string): boolean {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (room) {
|
||||
room.destroy();
|
||||
this.rooms.delete(roomId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
listRooms(): RoomInfo[] {
|
||||
return Array.from(this.rooms.values()).map(r => r.toInfo());
|
||||
}
|
||||
|
||||
getRoomCount(): number {
|
||||
return this.rooms.size;
|
||||
}
|
||||
|
||||
getTotalPlayers(): number {
|
||||
let total = 0;
|
||||
for (const [, room] of this.rooms) {
|
||||
total += room.currentPlayers;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
133
server/src/websocket.ts
Normal file
133
server/src/websocket.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import WebSocket from 'ws';
|
||||
import http from 'http';
|
||||
import { RoomManager } from './room';
|
||||
import { NodeManager } from './node-manager';
|
||||
import logger from './logger';
|
||||
import config from './config';
|
||||
|
||||
export class WebSocketHandler {
|
||||
private wss: WebSocket.Server | null = null;
|
||||
private clients: Set<WebSocket> = new Set();
|
||||
private broadcastInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private roomManager: RoomManager,
|
||||
private nodeManager: NodeManager
|
||||
) {}
|
||||
|
||||
attach(server: http.Server): void {
|
||||
this.wss = new WebSocket.Server({ server, path: '/ws' });
|
||||
|
||||
this.wss.on('connection', (ws: WebSocket) => {
|
||||
this.clients.add(ws);
|
||||
logger.debug(`WebSocket client connected, total: ${this.clients.size}`);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'welcome',
|
||||
data: {
|
||||
nodeId: config.nodeId,
|
||||
nodeName: config.nodeName,
|
||||
isMaster: config.isMaster,
|
||||
},
|
||||
}));
|
||||
|
||||
ws.on('message', (message: string) => {
|
||||
try {
|
||||
const msg = JSON.parse(message.toString());
|
||||
this.handleMessage(ws, msg);
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: 'error', data: { message: '无效的消息格式' } }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
this.clients.delete(ws);
|
||||
logger.debug(`WebSocket client disconnected, total: ${this.clients.size}`);
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
logger.debug(`WebSocket error: ${err.message}`);
|
||||
this.clients.delete(ws);
|
||||
});
|
||||
});
|
||||
|
||||
this.broadcastInterval = setInterval(() => {
|
||||
this.broadcastStatus();
|
||||
}, 3000);
|
||||
|
||||
logger.info('WebSocket server attached');
|
||||
}
|
||||
|
||||
private handleMessage(ws: WebSocket, msg: any): void {
|
||||
switch (msg.type) {
|
||||
case 'getRooms':
|
||||
ws.send(JSON.stringify({
|
||||
type: 'rooms',
|
||||
data: { rooms: this.roomManager.listRooms() },
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'getNodes':
|
||||
if (config.isMaster) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'nodes',
|
||||
data: { nodes: this.nodeManager.listNodes() },
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'getStats':
|
||||
ws.send(JSON.stringify({
|
||||
type: 'stats',
|
||||
data: {
|
||||
rooms: this.roomManager.getRoomCount(),
|
||||
players: this.roomManager.getTotalPlayers(),
|
||||
nodes: config.isMaster ? this.nodeManager.getStats() : null,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
|
||||
default:
|
||||
ws.send(JSON.stringify({ type: 'error', data: { message: `未知消息类型: ${msg.type}` } }));
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastStatus(): void {
|
||||
if (this.clients.size === 0) return;
|
||||
|
||||
const status = JSON.stringify({
|
||||
type: 'status',
|
||||
data: {
|
||||
rooms: this.roomManager.getRoomCount(),
|
||||
players: this.roomManager.getTotalPlayers(),
|
||||
nodes: config.isMaster ? this.nodeManager.getStats() : null,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
for (const client of this.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(type: string, data: any): void {
|
||||
const message = JSON.stringify({ type, data });
|
||||
for (const client of this.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.broadcastInterval) {
|
||||
clearInterval(this.broadcastInterval);
|
||||
}
|
||||
if (this.wss) {
|
||||
this.wss.close();
|
||||
}
|
||||
this.clients.clear();
|
||||
}
|
||||
}
|
||||
19
server/tsconfig.json
Normal file
19
server/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user