Files
tictactics/server.js

661 lines
16 KiB
JavaScript

const crypto = require("crypto");
const fs = require("fs");
const http = require("http");
const path = require("path");
const appPort = Number(process.env.APP_PORT || 8787);
const root = __dirname;
const boardSize = 5;
const winTarget = 4;
const empty = "";
const maxClients = Number(process.env.MAX_CLIENTS || 200);
const maxClientsPerIp = Number(process.env.MAX_CLIENTS_PER_IP || 12);
const maxMessageBytes = Number(process.env.MAX_MESSAGE_BYTES || 1024);
const messageWindowMs = Number(process.env.MESSAGE_WINDOW_MS || 10_000);
const maxMessagesPerWindow = Number(process.env.MAX_MESSAGES_PER_WINDOW || 40);
const minActionIntervalMs = Number(process.env.MIN_ACTION_INTERVAL_MS || 180);
const idleSocketTimeoutMs = Number(process.env.IDLE_SOCKET_TIMEOUT_MS || 120_000);
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
const trustProxy = process.env.TRUST_PROXY === "true";
const clients = new Map();
const rooms = new Map();
const clientsByIp = new Map();
let waitingClientId = "";
let nextClientId = 1;
let nextRoomId = 1;
const staticFiles = new Map([
["/", "index.html"],
["/index.html", "index.html"],
["/styles.css", "styles.css"],
["/game.js", "game.js"],
]);
const mimeTypes = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
};
const directions = [
[-1, -1],
[-1, 0],
[-1, 1],
[0, -1],
[0, 1],
[1, -1],
[1, 0],
[1, 1],
];
const winAxes = [
[0, 1],
[1, 0],
[1, 1],
[1, -1],
];
const server = http.createServer((request, response) => {
setSecurityHeaders(response);
if (request.method !== "GET" && request.method !== "HEAD") {
response.writeHead(405, { Allow: "GET, HEAD" });
response.end("Method not allowed");
return;
}
const requestUrl = new URL(request.url, `http://${request.headers.host}`);
if (requestUrl.pathname === "/healthz") {
response.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
response.end(request.method === "HEAD" ? undefined : JSON.stringify({ ok: true }));
return;
}
if (requestUrl.pathname === "/lobby") {
response.writeHead(200, {
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
});
response.end(request.method === "HEAD" ? undefined : JSON.stringify(lobbyStatus()));
return;
}
const staticName = staticFiles.get(requestUrl.pathname);
if (!staticName) {
response.writeHead(404);
response.end("Not found");
return;
}
const filePath = path.join(root, staticName);
fs.readFile(filePath, (error, content) => {
if (error) {
response.writeHead(404);
response.end("Not found");
return;
}
response.writeHead(200, {
"Cache-Control": "no-store",
"Content-Type": mimeTypes[path.extname(filePath)] || "application/octet-stream",
});
response.end(request.method === "HEAD" ? undefined : content);
});
});
server.on("upgrade", (request, socket) => {
if (request.url !== "/ws") {
rejectUpgrade(socket, 404, "Not found");
return;
}
if (!isAllowedOrigin(request)) {
rejectUpgrade(socket, 403, "Forbidden");
return;
}
if (clients.size >= maxClients) {
rejectUpgrade(socket, 503, "Server full");
return;
}
const ip = clientIp(request);
if ((clientsByIp.get(ip) || 0) >= maxClientsPerIp) {
rejectUpgrade(socket, 429, "Too many connections");
return;
}
const key = request.headers["sec-websocket-key"];
if (!isValidWebSocketKey(key) || request.headers["sec-websocket-version"] !== "13") {
rejectUpgrade(socket, 400, "Bad websocket request");
return;
}
const accept = crypto
.createHash("sha1")
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
.digest("base64");
socket.write([
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${accept}`,
"",
"",
].join("\r\n"));
addClient(socket, ip);
});
function setSecurityHeaders(response) {
response.setHeader("Content-Security-Policy", "default-src 'self'; connect-src 'self'; script-src 'self'; style-src 'self'; base-uri 'none'; frame-ancestors 'none'");
response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
response.setHeader("Referrer-Policy", "no-referrer");
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("X-Frame-Options", "DENY");
}
function rejectUpgrade(socket, status, message) {
socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
socket.destroy();
}
function isValidWebSocketKey(key) {
if (typeof key !== "string") {
return false;
}
try {
return Buffer.from(key, "base64").length === 16;
} catch {
return false;
}
}
function isAllowedOrigin(request) {
const origin = request.headers.origin;
if (!origin) {
return true;
}
if (allowedOrigins.length > 0) {
return allowedOrigins.includes(origin);
}
try {
return new URL(origin).host === request.headers.host;
} catch {
return false;
}
}
function clientIp(request) {
const forwardedFor = request.headers["x-forwarded-for"];
if (trustProxy && typeof forwardedFor === "string" && forwardedFor.length > 0) {
return forwardedFor.split(",")[0].trim();
}
return request.socket.remoteAddress || "unknown";
}
function addClient(socket, ip) {
const id = String(nextClientId);
nextClientId += 1;
const client = {
id,
ip,
socket,
roomId: "",
player: "",
buffer: Buffer.alloc(0),
messageTimestamps: [],
lastActionAt: 0,
};
clients.set(id, client);
clientsByIp.set(ip, (clientsByIp.get(ip) || 0) + 1);
socket.setNoDelay(true);
socket.setTimeout(idleSocketTimeoutMs, () => closeClient(client, 1001, "Idle timeout"));
socket.on("data", (buffer) => {
try {
handleSocketData(client, buffer);
} catch {
closeClient(client, 1002, "Protocol error");
}
});
socket.on("close", () => removeClient(client));
socket.on("error", () => removeClient(client));
if (waitingClientId && clients.has(waitingClientId)) {
pairClients(clients.get(waitingClientId), client);
waitingClientId = "";
return;
}
waitingClientId = id;
send(client, { type: "waiting" });
}
function pairClients(xClient, oClient) {
const roomId = String(nextRoomId);
nextRoomId += 1;
xClient.roomId = roomId;
xClient.player = "X";
oClient.roomId = roomId;
oClient.player = "O";
rooms.set(roomId, {
id: roomId,
players: [xClient.id, oClient.id],
board: createBoard(),
captures: { X: 0, O: 0 },
currentPlayer: "X",
gameOver: false,
});
send(xClient, { type: "paired", player: "X", roomId });
send(oClient, { type: "paired", player: "O", roomId });
}
function lobbyStatus() {
return {
activeGames: rooms.size,
waitingPlayers: waitingClientId && clients.has(waitingClientId) ? 1 : 0,
};
}
function handleSocketData(client, buffer) {
if (!clients.has(client.id)) {
return;
}
if (client.buffer.length + buffer.length > maxMessageBytes) {
closeClient(client, 1009, "Message too large");
return;
}
client.buffer = Buffer.concat([client.buffer, buffer]);
while (client.buffer.length > 0) {
const frame = readFrame(client.buffer);
if (!frame) {
return;
}
client.buffer = client.buffer.subarray(frame.consumed);
if (frame.opcode === 0x8) {
closeClient(client, 1000, "Closing");
return;
}
if (frame.opcode === 0x9) {
client.socket.write(Buffer.from([0x8a, 0x00]));
continue;
}
if (frame.opcode !== 0x1) {
closeClient(client, 1003, "Unsupported frame");
return;
}
handleTextMessage(client, frame.payload.toString("utf8"));
}
}
function handleTextMessage(client, message) {
if (!checkMessageRate(client)) {
closeClient(client, 1008, "Rate limit exceeded");
return;
}
let payload;
try {
payload = JSON.parse(message);
} catch {
send(client, { type: "error", message: "Invalid message." });
return;
}
if (!isObject(payload) || !isSafeAction(client)) {
send(client, { type: "error", message: "Slow down." });
return;
}
const room = rooms.get(client.roomId);
if (!room || payload.roomId !== client.roomId) {
send(client, { type: "error", message: "You are not currently paired." });
return;
}
if (payload.type === "move") {
const result = applyMove(room, client, payload);
if (!result.ok) {
send(client, { type: "error", message: result.message });
return;
}
relayToOpponent(client, {
type: "move",
row: payload.row,
col: payload.col,
player: client.player,
});
return;
}
if (payload.type === "reset") {
room.board = createBoard();
room.captures = { X: 0, O: 0 };
room.currentPlayer = "X";
room.gameOver = false;
relayToOpponent(client, { type: "reset" });
return;
}
send(client, { type: "error", message: "Unsupported message type." });
}
function checkMessageRate(client) {
const now = Date.now();
client.messageTimestamps = client.messageTimestamps.filter((timestamp) => now - timestamp < messageWindowMs);
client.messageTimestamps.push(now);
return client.messageTimestamps.length <= maxMessagesPerWindow;
}
function isSafeAction(client) {
const now = Date.now();
if (now - client.lastActionAt < minActionIntervalMs) {
return false;
}
client.lastActionAt = now;
return true;
}
function applyMove(room, client, payload) {
if (room.gameOver) {
return { ok: false, message: "Game is already over." };
}
if (client.player !== room.currentPlayer || payload.player !== client.player) {
return { ok: false, message: "Not your turn." };
}
if (!isBoardCoordinate(payload.row) || !isBoardCoordinate(payload.col)) {
return { ok: false, message: "Move is out of bounds." };
}
if (room.board[payload.row][payload.col] !== empty) {
return { ok: false, message: "Cell is already occupied." };
}
room.board[payload.row][payload.col] = client.player;
const captured = captureFrom(room.board, payload.row, payload.col, client.player);
room.captures[client.player] += captured.length;
if (findWin(room.board, client.player).length > 0 || isDraw(room.board)) {
room.gameOver = true;
} else {
room.currentPlayer = opponentOf(client.player);
}
return { ok: true };
}
function removeClient(client) {
if (!clients.has(client.id)) {
return;
}
clients.delete(client.id);
const ipCount = (clientsByIp.get(client.ip) || 1) - 1;
if (ipCount <= 0) {
clientsByIp.delete(client.ip);
} else {
clientsByIp.set(client.ip, ipCount);
}
if (waitingClientId === client.id) {
waitingClientId = "";
}
if (!client.roomId) {
return;
}
const room = rooms.get(client.roomId);
rooms.delete(client.roomId);
if (!room) {
return;
}
for (const playerId of room.players) {
if (playerId === client.id || !clients.has(playerId)) {
continue;
}
const opponent = clients.get(playerId);
opponent.roomId = "";
opponent.player = "";
send(opponent, {
type: "opponent-left",
message: "Opponent disconnected. Waiting for a new player...",
});
waitingClientId = opponent.id;
}
}
function relayToOpponent(client, payload) {
const room = rooms.get(client.roomId);
if (!room) {
send(client, { type: "error", message: "Room no longer exists." });
return;
}
for (const playerId of room.players) {
if (playerId !== client.id && clients.has(playerId)) {
send(clients.get(playerId), payload);
}
}
}
function send(client, payload) {
if (client.socket.destroyed) {
return;
}
client.socket.write(encodeFrame(JSON.stringify(payload)));
}
function closeClient(client, code, reason) {
if (client.socket.destroyed) {
removeClient(client);
return;
}
client.socket.write(encodeCloseFrame(code, reason));
client.socket.end();
removeClient(client);
}
function readFrame(buffer) {
if (buffer.length < 2) {
return null;
}
const firstByte = buffer[0];
const secondByte = buffer[1];
const masked = (secondByte & 0x80) === 0x80;
let length = secondByte & 0x7f;
let offset = 2;
if (!masked) {
throwAndClose();
}
if (length === 126) {
if (buffer.length < offset + 2) {
return null;
}
length = buffer.readUInt16BE(offset);
offset += 2;
} else if (length === 127) {
if (buffer.length < offset + 8) {
return null;
}
length = Number(buffer.readBigUInt64BE(offset));
offset += 8;
}
if (length > maxMessageBytes) {
throwAndClose();
}
const maskEnd = offset + 4;
const payloadEnd = maskEnd + length;
if (buffer.length < payloadEnd) {
return null;
}
const mask = buffer.subarray(offset, maskEnd);
const payload = buffer.subarray(maskEnd, payloadEnd);
const decoded = Buffer.alloc(payload.length);
for (let index = 0; index < payload.length; index += 1) {
decoded[index] = payload[index] ^ mask[index % 4];
}
return {
consumed: payloadEnd,
opcode: firstByte & 0x0f,
payload: decoded,
};
}
function throwAndClose() {
throw new Error("Invalid websocket frame");
}
function encodeFrame(message) {
const payload = Buffer.from(message);
const length = payload.length;
if (length < 126) {
return Buffer.concat([Buffer.from([0x81, length]), payload]);
}
const header = Buffer.alloc(4);
header[0] = 0x81;
header[1] = 126;
header.writeUInt16BE(length, 2);
return Buffer.concat([header, payload]);
}
function encodeCloseFrame(code, reason) {
const reasonBuffer = Buffer.from(reason.slice(0, 80));
const payload = Buffer.alloc(2 + reasonBuffer.length);
payload.writeUInt16BE(code, 0);
reasonBuffer.copy(payload, 2);
return Buffer.concat([Buffer.from([0x88, payload.length]), payload]);
}
function createBoard() {
return Array.from({ length: boardSize }, () => Array(boardSize).fill(empty));
}
function captureFrom(board, row, col, player) {
const opponent = opponentOf(player);
const captured = [];
for (const [rowStep, colStep] of directions) {
const candidates = [];
let nextRow = row + rowStep;
let nextCol = col + colStep;
while (isInside(nextRow, nextCol) && board[nextRow][nextCol] === opponent) {
candidates.push([nextRow, nextCol]);
nextRow += rowStep;
nextCol += colStep;
}
if (candidates.length > 0 && isInside(nextRow, nextCol) && board[nextRow][nextCol] === player) {
captured.push(...candidates);
}
}
for (const [captureRow, captureCol] of captured) {
board[captureRow][captureCol] = empty;
}
return captured;
}
function findWin(board, player) {
for (let row = 0; row < boardSize; row += 1) {
for (let col = 0; col < boardSize; col += 1) {
if (board[row][col] !== player) {
continue;
}
for (const [rowStep, colStep] of winAxes) {
const cells = [[row, col]];
for (let index = 1; index < winTarget; index += 1) {
const nextRow = row + rowStep * index;
const nextCol = col + colStep * index;
if (!isInside(nextRow, nextCol) || board[nextRow][nextCol] !== player) {
break;
}
cells.push([nextRow, nextCol]);
}
if (cells.length === winTarget) {
return cells;
}
}
}
}
return [];
}
function isDraw(board) {
return board.every((row) => row.every((cell) => cell !== empty));
}
function opponentOf(player) {
return player === "X" ? "O" : "X";
}
function isInside(row, col) {
return row >= 0 && row < boardSize && col >= 0 && col < boardSize;
}
function isBoardCoordinate(value) {
return Number.isInteger(value) && value >= 0 && value < boardSize;
}
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
server.on("clientError", (_error, socket) => {
socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
});
server.listen(appPort, () => {
console.log(`Tictactics multiplayer server running at http://localhost:${appPort}`);
});