/** * TRXTD game server: static frontend (dist/) + multiplayer WebSocket relay. * Single port for everything – ideal for Docker deployments. * * Dev: npm run server (ws on :3001 next to vite on :5173) * Prod: serves dist/ + ws on PORT (default 3001) * * 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 * - Static file serving with path-traversal protection * - Inactive room cleanup timeout */ import http from 'node:http' import { promises as fs } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { WebSocketServer } from 'ws' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const DIST_DIR = process.env.DIST_DIR || path.join(__dirname, '..', 'dist') const PORT = Number(process.env.PORT || 3001) const MAX_PAYLOAD_BYTES = 4096 const MAX_ACTIONS_PER_SEC = 30 const ROOM_TIMEOUT_MS = 30 * 60 * 1000 // public hosting hardening const MAX_WS_TOTAL = 500 // total concurrent websocket connections const MAX_WS_PER_IP = 20 // concurrent websocket connections per client IP const MAX_ROOMS = 300 // concurrent rooms const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; " + "base-uri 'self'; frame-ancestors 'none'" // ------------------------------------------------------------------ client ip (proxy aware) function isPrivateIp(ip) { if (!ip) return false if (ip === '::1' || ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80')) return true const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) if (!m) return false const a = Number(m[1]) const b = Number(m[2]) return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) } /** * Real client IP. X-Forwarded-For is only trusted when the direct peer is a * private address (reverse proxy / docker network), so external clients cannot * spoof it. Do not expose the port directly to the internet. */ function clientIp(req) { const socketIp = String(req.socket?.remoteAddress || 'unknown').replace(/^::ffff:/, '') const xff = req.headers['x-forwarded-for'] if (xff && isPrivateIp(socketIp)) { const first = String(xff).split(',')[0].trim().replace(/^::ffff:/, '') if (first) return first } return socketIp } /** * WebSocket origin check: blocks cross-site websocket hijacking (other * websites opening connections to this server from a victim's browser). * Allowed: same origin, local development, entries in ALLOWED_ORIGINS. */ function originAllowed(req) { const origin = req.headers.origin if (!origin) return true // non-browser clients (curl, node, bots without origin) const host = String(req.headers.host || '').toLowerCase() let originHost = '' try { originHost = new URL(origin).host.toLowerCase() } catch { return false } if (host && originHost === host) return true if (/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(originHost)) return true const extra = String(process.env.ALLOWED_ORIGINS || '') .toLowerCase() .split(',') .map((s) => s.trim()) .filter(Boolean) if (extra.some((o) => o === originHost || o === origin.toLowerCase())) return true return false } // ------------------------------------------------------------------ static files const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', '.map': 'application/json', } const DIST_ROOT = path.normalize(DIST_DIR) async function serveStatic(req, res) { const url = new URL(req.url, 'http://localhost') if (url.pathname === '/health') { res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('ok') return } if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405, { 'Content-Type': 'text/plain' }) res.end('Method Not Allowed') return } let rel try { rel = decodeURIComponent(url.pathname) } catch { res.writeHead(400) res.end('Bad Request') return } if (rel.includes('\0')) { res.writeHead(400) res.end('Bad Request') return } let filePath = path.normalize(path.join(DIST_ROOT, rel)) if (filePath !== DIST_ROOT && !filePath.startsWith(DIST_ROOT + path.sep)) { res.writeHead(403) res.end('Forbidden') return } let data = await fs.readFile(filePath).catch(() => null) if (!data) { // SPA fallback: unknown paths without file extension get index.html if (!path.extname(rel)) { filePath = path.join(DIST_ROOT, 'index.html') data = await fs.readFile(filePath).catch(() => null) } if (!data) { res.writeHead(404, { 'Content-Type': 'text/plain' }) res.end('Not Found') return } } const ext = path.extname(filePath).toLowerCase() const type = MIME[ext] || 'application/octet-stream' const isHashedAsset = url.pathname.startsWith('/assets/') res.writeHead(200, { 'Content-Type': type, 'Cache-Control': isHashedAsset ? 'public, max-age=31536000, immutable' : 'no-cache', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': CSP, 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'no-referrer', }) res.end(req.method === 'HEAD' ? undefined : data) } const httpServer = http.createServer((req, res) => { serveStatic(req, res).catch(() => { if (!res.headersSent) res.writeHead(500) res.end('Internal Server Error') }) }) // ------------------------------------------------------------------ game rooms const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES, }) // concurrent websocket connection tracking (per IP + global) const wsPerIp = new Map() function rejectUpgrade(socket, status, reason) { try { socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`) } catch { /* ignore */ } socket.destroy() } httpServer.on('upgrade', (req, socket, head) => { if (!originAllowed(req)) { console.warn(`[ws] Origin abgelehnt: ${req.headers.origin} von ${clientIp(req)}`) return rejectUpgrade(socket, 403, 'Forbidden') } const ip = clientIp(req) const perIp = wsPerIp.get(ip) || 0 if (wss.clients.size >= MAX_WS_TOTAL || perIp >= MAX_WS_PER_IP) { console.warn(`[ws] Verbindungslimit erreicht: ${ip} (${perIp}/${MAX_WS_PER_IP}, total ${wss.clients.size}/${MAX_WS_TOTAL})`) return rejectUpgrade(socket, 503, 'Service Unavailable') } wss.handleUpgrade(req, socket, head, (ws) => { ws._ip = ip wsPerIp.set(ip, perIp + 1) ws.on('close', () => { const n = (wsPerIp.get(ip) || 1) - 1 if (n <= 0) wsPerIp.delete(ip) else wsPerIp.set(ip, n) }) wss.emit('connection', ws, req) }) }) /** code -> { code, mode, created, seq, players: [{ ws, id, name }] } */ 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' 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' || Array.isArray(a)) return false switch (a.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 + room timeout 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) } } wss.clients.forEach((ws) => { if (ws.isAlive === false) { leaveRoom(ws) ws.terminate() return } 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, isBinary) => { ws.isAlive = true if (isBinary) return // only JSON text frames are allowed let m try { m = JSON.parse(data.toString()) } catch { return } if (!m || typeof m !== 'object' || typeof m.t !== 'string') return // rate limiting 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 if (rooms.size >= MAX_ROOMS) { return send(ws, { t: 'error', msg: 'Zu viele aktive Räume. Bitte später erneut versuchen.' }) } const code = makeCode() const room = { code, mode: m.mode, created: Date.now(), seq: 0, players: [{ ws, id: 0, name: sanitizeName(m.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.' }) room.players.push({ ws, id: 1, name: sanitizeName(m.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 the 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' || !Number.isFinite(m.tick)) 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)) }) httpServer.listen(PORT, () => { console.log(`TRXTD-Server läuft auf http://localhost:${PORT} (Static: ${DIST_DIR})`) })