645 lines
16 KiB
JavaScript
645 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;
|
|
}
|
|
|
|
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 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}`);
|
|
});
|