From b506ffad55fef6c1183612c919579f77f57b9fdf Mon Sep 17 00:00:00 2001 From: Tronax Date: Sun, 16 Aug 2026 12:15:52 +0200 Subject: [PATCH] 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 --- .dockerignore | 19 ++++++ Dockerfile | 32 +++++++++ README.md | 39 +++++++++++ docker-compose.yml | 10 +++ server/server.mjs | 157 ++++++++++++++++++++++++++++++++++++--------- src/game/net.ts | 9 ++- 6 files changed, 235 insertions(+), 31 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f225237 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +node_modules +dist +.git +.gitignore +.dockerignore +Dockerfile +docker-compose.yml +*.log +npm-debug.log* +public/shot*.png +test-results +coverage +.vscode +.idea +.DS_Store +.env +.env.* +README.md +scripts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..48f6066 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# ---------- Build stage ---------- +FROM node:22-alpine AS build +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# ---------- Runtime stage ---------- +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production + +# production dependencies only (ws) +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +COPY server/server.mjs server/server.mjs +COPY --from=build /app/dist dist/ + +# run as unprivileged user +USER node + +EXPOSE 3001 +ENV PORT=3001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://localhost:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "server/server.mjs"] diff --git a/README.md b/README.md index 27aed8b..6adc15e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,45 @@ Enthält **Solo-Kampagne**, **2-Spieler-Coop** und **1v1-Duell** (Online/Lokal p --- +## 🐳 Docker + +Das Spiel gibt es als **All-in-One-Container**: Frontend (statisch) und Multiplayer-Server teilen sich **einen Port** – ideal für Self-Hosting. + +```bash +# Image bauen (Multi-Stage-Build: Build → schlankes Runtime-Image) +docker build -t trxtd:latest . + +# Container starten +docker run -d --name trxtd -p 3001:3001 trxtd:latest + +# Oder mit Docker Compose (empfohlen) +docker compose up -d +``` + +Danach läuft das komplette Spiel inkl. Online-Multiplayer unter **http://localhost:3001**. + +**Enthaltene Härtung:** +- Multi-Stage-Build (kein Build-Tooling im Runtime-Image) +- Läuft als unprivilegierter Nutzer (`node`) +- `HEALTHCHECK` über `/health` +- `/assets/` mit immutablen Cache-Headern, SPA-Fallback für alle anderen Routen +- WebSocket-Sicherheit: Payload-Limit (4 KB), Rate-Limiting (30 Aktionen/s), Schema-Validierung, Ping/Pong-Heartbeat + +**Nützliche Umgebungsvariablen:** + +| Variable | Default | Bedeutung | +| --- | --- | --- | +| `PORT` | `3001` | HTTP- und WebSocket-Port | +| `DIST_DIR` | `./dist` | Ordner mit den statischen Dateien | + +**Manuell ohne Docker im Produktionsmodus starten:** +```bash +npm run build +npm run server # serves dist/ + ws auf :3001 +``` + +--- + ## Spielmodi ### 1. Solo-Kampagne diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4f4a47c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + trxtd: + build: . + image: trxtd:latest + container_name: trxtd + ports: + - "3001:3001" + restart: unless-stopped + environment: + - PORT=3001 diff --git a/server/server.mjs b/server/server.mjs index 8ebe493..f9a3bbf 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -1,24 +1,131 @@ /** - * TRXTD multiplayer relay server with security hardening: + * 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 // 4 KB is plenty for simple game actions +const MAX_PAYLOAD_BYTES = 4096 const MAX_ACTIONS_PER_SEC = 30 -const ROOM_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes max room life +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({ - port: PORT, + noServer: true, maxPayload: MAX_PAYLOAD_BYTES, }) -/** code -> { code, mode, created: number, seq: number, players: [{ ws, id, name, lastAct: number, actCount: number }] } */ +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' @@ -36,7 +143,6 @@ function makeCode() { 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' } @@ -79,9 +185,8 @@ function leaveRoom(ws) { } function validateAction(a) { - if (!a || typeof a !== 'object') return false - const type = a.type - switch (type) { + if (!a || typeof a !== 'object' || Array.isArray(a)) return false + switch (a.type) { case 'build': return ( ['arrow', 'cannon', 'frost', 'tesla', 'laser'].includes(a.kind) && @@ -113,7 +218,7 @@ function validateAction(a) { } } -// Heartbeat interval to drop unresponsive clients +// heartbeat + room timeout const pingInterval = setInterval(() => { const now = Date.now() for (const [code, room] of rooms.entries()) { @@ -123,14 +228,14 @@ const pingInterval = setInterval(() => { p.ws._room = undefined } rooms.delete(code) - continue } } wss.clients.forEach((ws) => { if (ws.isAlive === false) { leaveRoom(ws) - return ws.terminate() + ws.terminate() + return } ws.isAlive = false try { @@ -153,19 +258,19 @@ wss.on('connection', (ws) => { ws.isAlive = true }) - ws.on('message', (data) => { + ws.on('message', (data, isBinary) => { ws.isAlive = true - if (typeof data !== 'string' && !Buffer.isBuffer(data)) return + if (isBinary) return // only JSON text frames are allowed let m try { m = JSON.parse(data.toString()) } catch { - return // drop invalid JSON silently + return } if (!m || typeof m !== 'object' || typeof m.t !== 'string') return - // Rate-limiting check + // rate limiting const now = Date.now() if (now > ws._actBucket.resetAt) { ws._actBucket.count = 0 @@ -182,14 +287,7 @@ wss.on('connection', (ws) => { 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 }], - } + 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 }) @@ -201,8 +299,7 @@ wss.on('connection', (ws) => { 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 }) + 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 }) @@ -212,7 +309,7 @@ wss.on('connection', (ws) => { case 'start': { const room = roomOf(ws) if (!room || room.players.length !== 2) return - if (room.players[0].ws !== ws) return // only host may start + 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 }) @@ -238,7 +335,7 @@ wss.on('connection', (ws) => { if (!room) return const from = room.players.findIndex((p) => p.ws === ws) if (from === -1) return - if (typeof m.tick !== 'number') return + if (typeof m.tick !== 'number' || !Number.isFinite(m.tick)) return broadcast(room, { t: 'prog', from, tick: m.tick }) break } @@ -253,6 +350,6 @@ wss.on('connection', (ws) => { ws.on('error', () => leaveRoom(ws)) }) -wss.on('listening', () => { - console.log(`TRXTD Multiplayer-Server läuft auf ws://localhost:${PORT}`) +httpServer.listen(PORT, () => { + console.log(`TRXTD-Server läuft auf http://localhost:${PORT} (Static: ${DIST_DIR})`) }) diff --git a/src/game/net.ts b/src/game/net.ts index f08651a..0abc5da 100644 --- a/src/game/net.ts +++ b/src/game/net.ts @@ -33,9 +33,16 @@ export interface NetHandlers { export function serverUrl(): string { if (typeof location === 'undefined') return 'ws://localhost:3001' + // explicit override, e.g. ?server=ws://192.168.1.10:3001 const custom = new URLSearchParams(location.search).get('server') if (custom) return custom - return `ws://${location.hostname}:3001` + // vite dev/preview servers run the ws relay on a separate port + if (location.port === '5173' || location.port === '4173') { + return `ws://${location.hostname}:3001` + } + // production (Docker / node server): frontend and ws share one origin + const proto = location.protocol === 'https:' ? 'wss' : 'ws' + return `${proto}://${location.host}` } /** Thin WebSocket wrapper for the TRXTD room/action protocol. */