TRXTD/server/server.mjs
Tronax b506ffad55
feat(docker): add self-hosted all-in-one container setup
- 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
2026-08-16 12:15:52 +02:00

355 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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})`)
})