- Add Dockerfile (multi-stage build, unprivileged user, healthcheck), docker-compose.yml and .dockerignore - Server now serves static dist/ frontend and WebSocket relay on a single port (PORT, default 3001) with path-traversal protection, immutable asset caching and SPA fallback - Client connects via same origin in production; dedicated ws port is only used for vite dev/preview - Document Docker usage, env vars and manual production mode in README
355 lines
9.8 KiB
JavaScript
355 lines
9.8 KiB
JavaScript
/**
|
||
* 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
|
||
|
||
// ------------------------------------------------------------------ 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',
|
||
})
|
||
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,
|
||
})
|
||
|
||
httpServer.on('upgrade', (req, socket, head) => {
|
||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||
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
|
||
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})`)
|
||
})
|