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
This commit is contained in:
parent
c4347f8420
commit
b506ffad55
6 changed files with 235 additions and 31 deletions
19
.dockerignore
Normal file
19
.dockerignore
Normal file
|
|
@ -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
|
||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
|
|
@ -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"]
|
||||
39
README.md
39
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
|
||||
|
|
|
|||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
services:
|
||||
trxtd:
|
||||
build: .
|
||||
image: trxtd:latest
|
||||
container_name: trxtd
|
||||
ports:
|
||||
- "3001:3001"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORT=3001
|
||||
|
|
@ -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})`)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue