/** * TRXTD multiplayer relay server with security hardening: * - WebSocket maxPayload limit (prevents DoS) * - Heartbeat (ping/pong) to clean dead sockets * - Rate-limiting per connection (action flood protection) * - Strict message schema validation * - Inactive room cleanup timeout */ import { WebSocketServer } from 'ws' const PORT = Number(process.env.PORT || 3001) const MAX_PAYLOAD_BYTES = 4096 // 4 KB is plenty for simple game actions const MAX_ACTIONS_PER_SEC = 30 const ROOM_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes max room life const wss = new WebSocketServer({ port: PORT, maxPayload: MAX_PAYLOAD_BYTES, }) /** code -> { code, mode, created: number, seq: number, players: [{ ws, id, name, lastAct: number, actCount: number }] } */ const rooms = new Map() const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' function makeCode() { let code let attempts = 0 do { code = Array.from({ length: 4 }, () => CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)]).join('') attempts++ if (attempts > 1000) break } while (rooms.has(code)) return code } function sanitizeName(raw) { if (typeof raw !== 'string') return 'Spieler' // keep alphanumeric, spaces, dashes and common accents, max 12 chars const clean = raw.trim().replace(/[^\p{L}\p{N}_\- ]/gu, '').slice(0, 12) return clean || 'Spieler' } function send(ws, obj) { if (ws && ws.readyState === 1) { try { ws.send(JSON.stringify(obj)) } catch { /* ignore socket send failure */ } } } function roomOf(ws) { const code = ws._room return code ? rooms.get(code) : undefined } function broadcast(room, obj, exceptWs) { for (const p of room.players) { if (p.ws !== exceptWs) send(p.ws, obj) } } function playerInfo(room) { return room.players.map((p) => ({ id: p.id, name: p.name })) } function leaveRoom(ws) { const room = roomOf(ws) if (!room) return room.players = room.players.filter((p) => p.ws !== ws) ws._room = undefined if (room.players.length === 0) { rooms.delete(room.code) } else { broadcast(room, { t: 'peer-left' }) } } function validateAction(a) { if (!a || typeof a !== 'object') return false const type = a.type switch (type) { case 'build': return ( ['arrow', 'cannon', 'frost', 'tesla', 'laser'].includes(a.kind) && Number.isInteger(a.tx) && a.tx >= 0 && a.tx < 20 && Number.isInteger(a.ty) && a.ty >= 0 && a.ty < 11 ) case 'upgrade': case 'sell': return Number.isInteger(a.towerId) && a.towerId > 0 case 'targeting': return ( Number.isInteger(a.towerId) && a.towerId > 0 && ['first', 'last', 'strong', 'close'].includes(a.mode) ) case 'obstacle': return Number.isInteger(a.tx) && a.tx >= 0 && a.tx < 20 && Number.isInteger(a.ty) && a.ty >= 0 && a.ty < 11 case 'wave': case 'rush': return true case 'speed': return [1, 2, 3].includes(a.s) default: return false } } // Heartbeat interval to drop unresponsive clients const pingInterval = setInterval(() => { const now = Date.now() for (const [code, room] of rooms.entries()) { if (now - room.created > ROOM_TIMEOUT_MS) { for (const p of room.players) { send(p.ws, { t: 'error', msg: 'Raum-Zeitüberschreitung.' }) p.ws._room = undefined } rooms.delete(code) continue } } wss.clients.forEach((ws) => { if (ws.isAlive === false) { leaveRoom(ws) return ws.terminate() } ws.isAlive = false try { ws.ping() } catch { leaveRoom(ws) } }) }, 15000) wss.on('close', () => { clearInterval(pingInterval) }) wss.on('connection', (ws) => { ws.isAlive = true ws._actBucket = { count: 0, resetAt: Date.now() + 1000 } ws.on('pong', () => { ws.isAlive = true }) ws.on('message', (data) => { ws.isAlive = true if (typeof data !== 'string' && !Buffer.isBuffer(data)) return let m try { m = JSON.parse(data.toString()) } catch { return // drop invalid JSON silently } if (!m || typeof m !== 'object' || typeof m.t !== 'string') return // Rate-limiting check const now = Date.now() if (now > ws._actBucket.resetAt) { ws._actBucket.count = 0 ws._actBucket.resetAt = now + 1000 } ws._actBucket.count++ if (ws._actBucket.count > MAX_ACTIONS_PER_SEC) { send(ws, { t: 'error', msg: 'Zu viele Anfragen gesendet.' }) return } switch (m.t) { case 'create': { leaveRoom(ws) if (m.mode !== 'coop' && m.mode !== 'duel') return const code = makeCode() const name = sanitizeName(m.name) const room = { code, mode: m.mode, created: Date.now(), seq: 0, players: [{ ws, id: 0, name }], } rooms.set(code, room) ws._room = code send(ws, { t: 'room', code, mode: room.mode, players: playerInfo(room), you: 0 }) break } case 'join': { leaveRoom(ws) const code = String(m.code || '').toUpperCase().trim() const room = rooms.get(code) if (!room) return send(ws, { t: 'error', msg: 'Raum nicht gefunden.' }) if (room.players.length >= 2) return send(ws, { t: 'error', msg: 'Raum ist bereits voll.' }) const name = sanitizeName(m.name) room.players.push({ ws, id: 1, name }) ws._room = code for (const p of room.players) { send(p.ws, { t: 'room', code, mode: room.mode, players: playerInfo(room), you: p.id }) } break } case 'start': { const room = roomOf(ws) if (!room || room.players.length !== 2) return if (room.players[0].ws !== ws) return // only host may start const seed = (Math.random() * 1e9) | 0 for (const p of room.players) { send(p.ws, { t: 'start', seed, mode: room.mode, players: playerInfo(room), you: p.id }) } break } case 'act': { const room = roomOf(ws) if (!room || room.players.length < 2) return const from = room.players.findIndex((p) => p.ws === ws) if (from === -1) return if (!validateAction(m.a)) return if (!Number.isInteger(m.tick) || m.tick < 0) return room.seq = (room.seq || 0) + 1 for (const p of room.players) { send(p.ws, { t: 'act', seq: room.seq, from, tick: m.tick, a: m.a }) } break } case 'prog': { const room = roomOf(ws) if (!room) return const from = room.players.findIndex((p) => p.ws === ws) if (from === -1) return if (typeof m.tick !== 'number') return broadcast(room, { t: 'prog', from, tick: m.tick }) break } case 'leave': { leaveRoom(ws) break } } }) ws.on('close', () => leaveRoom(ws)) ws.on('error', () => leaveRoom(ws)) }) wss.on('listening', () => { console.log(`TRXTD Multiplayer-Server läuft auf ws://localhost:${PORT}`) })