diff --git a/apps/scheduler/src/addDefaultMasterServers.ts b/apps/scheduler/src/addDefaultMasterServers.ts index 4605e1cc..40ad5062 100644 --- a/apps/scheduler/src/addDefaultMasterServers.ts +++ b/apps/scheduler/src/addDefaultMasterServers.ts @@ -1,25 +1,18 @@ import { prisma } from "./prisma"; +// 0.6 masters listen on port 8300, 0.7 masters on port 8283 export async function addDefaultMasterServers() { await prisma.masterServer.createMany({ - data: [ + data: [1, 2, 3, 4].flatMap((index) => [ { - address: 'master1.teeworlds.com', + address: `master${index}.teeworlds.com`, port: 8300, }, { - address: 'master2.teeworlds.com', - port: 8300, - }, - { - address: 'master3.teeworlds.com', - port: 8300, - }, - { - address: 'master4.teeworlds.com', - port: 8300, + address: `master${index}.teeworlds.com`, + port: 8283, }, - ], + ]), skipDuplicates: true, }) } diff --git a/apps/worker/src/packet.ts b/apps/worker/src/packet.ts index ddb29b0a..111395eb 100644 --- a/apps/worker/src/packet.ts +++ b/apps/worker/src/packet.ts @@ -42,11 +42,30 @@ export function unpackBool(packet: Packet): boolean { return parseInt(unpackString(packet), 10) !== 0; } +// 0.7 packed int format: ESDDDDDD EDDDDDDD EDD... (E: extend, S: sign, D: data) +export function unpackInt7(packet: Packet): number { + const masks = [0x7f, 0x7f, 0x7f, 0x0f]; + const shifts = [6, 13, 20, 27]; + + const first = packet.data[packet.offset]; + const sign = (first >> 6) & 1; + let value = first & 0x3f; + + for (let i = 0; packet.data[packet.offset] & 0x80 && i < masks.length; i += 1) { + packet.offset += 1; + value |= (packet.data[packet.offset] & masks[i]) << shifts[i]; + } + + packet.offset += 1; + return sign ? ~value : value; +} + export enum ServerHeader { Vanilla, Legacy64, Extended, ExtendedMore, + Vanilla7, } export function headerBuffer(header: string): Buffer { @@ -59,17 +78,39 @@ const SERVER_HEADER_LEGACY64 = headerBuffer('dtsf'); const SERVER_HEADER_EXTENDED = headerBuffer('iext'); const SERVER_HEADER_EXTENDED_MORE = headerBuffer('iex+'); -export function unpackServerHeader(packet: Packet): ServerHeader { - unpackBytes(packet, 6); - const header = unpackBytes(packet, 8); +// 0.6 connless packets start with 6 padding bytes, 0.7 ones with a 9 bytes +// header: flags/version byte then two 4 bytes tokens. Returns undefined for +// anything else, like 0.7 control packets. +function unpackConnlessHeader(packet: Packet): { magic: Buffer, version7: boolean } | undefined { + if (packet.data[0] === 0xff) { + unpackBytes(packet, 6); + return { magic: unpackBytes(packet, 8), version7: false }; + } + + if ((packet.data[0] & 0xfc) >> 2 === 0x08) { + unpackBytes(packet, 9); + return { magic: unpackBytes(packet, 8), version7: true }; + } + + return undefined; +} + +export function unpackServerHeader(packet: Packet): ServerHeader | undefined { + const connlessHeader = unpackConnlessHeader(packet); - if (header.equals(SERVER_HEADER_VANILLA)) { - return ServerHeader.Vanilla; - } else if (header.equals(SERVER_HEADER_LEGACY64)) { + if (connlessHeader === undefined) { + return undefined; + } + + const { magic, version7 } = connlessHeader; + + if (magic.equals(SERVER_HEADER_VANILLA)) { + return version7 ? ServerHeader.Vanilla7 : ServerHeader.Vanilla; + } else if (magic.equals(SERVER_HEADER_LEGACY64)) { return ServerHeader.Legacy64; - } else if (header.equals(SERVER_HEADER_EXTENDED)) { + } else if (magic.equals(SERVER_HEADER_EXTENDED)) { return ServerHeader.Extended; - } else if (header.equals(SERVER_HEADER_EXTENDED_MORE)) { + } else if (magic.equals(SERVER_HEADER_EXTENDED_MORE)) { return ServerHeader.ExtendedMore; } @@ -82,13 +123,52 @@ export enum MasterHeader { const MASTER_HEADER_VANILLA = headerBuffer('lis2'); -export function unpackMasterHeader(packet: Packet): MasterHeader { - unpackBytes(packet, 6); - const header = unpackBytes(packet, 8); +export function unpackMasterHeader(packet: Packet): MasterHeader | undefined { + const connlessHeader = unpackConnlessHeader(packet); - if (header.equals(MASTER_HEADER_VANILLA)) { + if (connlessHeader === undefined) { + return undefined; + } + + if (connlessHeader.magic.equals(MASTER_HEADER_VANILLA)) { return MasterHeader.Vanilla; } throw new Error('Invalid master header'); } + +const CTRLMSG_TOKEN = 0x05; + +export function randomToken(): number { + return Math.floor(Math.random() * 0xfffffffe); +} + +// 0.7 requires a token handshake before answering connless requests: send a +// control message with our token, padded to 512 bytes, and the server replies +// with the token to use in packConnless7(). +export function packCtrlTokenRequest(myToken: number): Buffer { + const buffer = Buffer.alloc(7 + 1 + 512); + buffer[0] = 0x04; + buffer.writeUInt32BE(0xffffffff, 3); + buffer[7] = CTRLMSG_TOKEN; + buffer.writeUInt32BE(myToken, 8); + return buffer; +} + +export function peekCtrlToken(packet: Packet): number | undefined { + const { data } = packet; + + if ((data[0] & 0xfc) >> 2 !== 0x01 || data.length < 12 || data[7] !== CTRLMSG_TOKEN) { + return undefined; + } + + return data.readUInt32BE(8); +} + +export function packConnless7(serverToken: number, myToken: number, payload: Buffer): Buffer { + const header = Buffer.alloc(9); + header[0] = 0x21; + header.writeUInt32BE(serverToken, 1); + header.writeUInt32BE(myToken, 5); + return Buffer.concat([header, payload]); +} diff --git a/apps/worker/src/packets/gameServerInfo.ts b/apps/worker/src/packets/gameServerInfo.ts index d2c98588..d2ae4c77 100644 --- a/apps/worker/src/packets/gameServerInfo.ts +++ b/apps/worker/src/packets/gameServerInfo.ts @@ -1,4 +1,4 @@ -import { Packet, ServerHeader, packetIsConsumed, unpackBool, unpackInt, unpackServerHeader, unpackString } from "../packet"; +import { Packet, ServerHeader, packetIsConsumed, unpackBool, unpackInt, unpackInt7, unpackServerHeader, unpackString } from "../packet"; type Client = { name: string; @@ -58,19 +58,30 @@ function initGameServerInfoPacket(): GameServerInfoPacket { }; } -function unpackGameServerInfoPacket(packet: Packet, gameServerInfoPacket: GameServerInfoPacket) { +function unpackGameServerInfoPacket(packet: Packet, gameServerInfoPacket: GameServerInfoPacket): boolean { const header = unpackServerHeader(packet); switch (header) { + case undefined: + return false; case ServerHeader.Vanilla: - return unpackGameServerInfoVanilla(packet, gameServerInfoPacket); + unpackGameServerInfoVanilla(packet, gameServerInfoPacket); + break; case ServerHeader.Legacy64: - return unpackGameServerInfoLegacy64(packet, gameServerInfoPacket); + unpackGameServerInfoLegacy64(packet, gameServerInfoPacket); + break; case ServerHeader.Extended: - return unpackGameServerInfoExtended(packet, gameServerInfoPacket); + unpackGameServerInfoExtended(packet, gameServerInfoPacket); + break; case ServerHeader.ExtendedMore: - return unpackGameServerInfoExtendedMore(packet, gameServerInfoPacket); + unpackGameServerInfoExtendedMore(packet, gameServerInfoPacket); + break; + case ServerHeader.Vanilla7: + unpackGameServerInfoVanilla7(packet, gameServerInfoPacket); + break; } + + return true; } function unpackGameServerInfoVanilla(packet: Packet, gameServerInfoPacket: GameServerInfoPacket) { @@ -211,12 +222,54 @@ function unpackGameServerInfoExtendedMore(packet: Packet, gameServerInfoPacket: } } -export function unpackGameServerInfoPackets(packets: Packet[]): GameServerInfoPacket { +function unpackGameServerInfoVanilla7(packet: Packet, gameServerInfoPacket: GameServerInfoPacket) { + unpackInt7(packet); // token + + gameServerInfoPacket.version = unpackString(packet); + gameServerInfoPacket.name = unpackString(packet); + + unpackString(packet); // hostname + + gameServerInfoPacket.map = unpackString(packet); + gameServerInfoPacket.gameType = unpackString(packet); + + unpackInt7(packet); // flags + unpackInt7(packet); // skill level + + gameServerInfoPacket.numPlayers = unpackInt7(packet); + gameServerInfoPacket.maxPlayers = unpackInt7(packet); + + gameServerInfoPacket.numClients = unpackInt7(packet); + gameServerInfoPacket.maxClients = unpackInt7(packet); + + while (!packetIsConsumed(packet)) { + const name = unpackString(packet); + const clan = unpackString(packet); + const country = unpackInt7(packet); + const score = unpackInt7(packet); + const playerType = unpackInt7(packet); // 1: spectator, 2: bot + + addClient(gameServerInfoPacket, { + name, + clan, + country, + score, + inGame: (playerType & 1) === 0, + + _origin: ServerHeader.Vanilla7, + }); + } +} + +export function unpackGameServerInfoPackets(packets: Packet[]): GameServerInfoPacket | undefined { const gameServerInfoPacket = initGameServerInfoPacket(); + let unpackedCount = 0; for (const packet of packets) { - unpackGameServerInfoPacket(packet, gameServerInfoPacket); + if (unpackGameServerInfoPacket(packet, gameServerInfoPacket)) { + unpackedCount += 1; + } } - return gameServerInfoPacket; + return unpackedCount > 0 ? gameServerInfoPacket : undefined; } diff --git a/apps/worker/src/packets/masterServerInfo.ts b/apps/worker/src/packets/masterServerInfo.ts index 5e56a7ab..250b7840 100644 --- a/apps/worker/src/packets/masterServerInfo.ts +++ b/apps/worker/src/packets/masterServerInfo.ts @@ -17,6 +17,8 @@ function unpackMasterPacket(packet: Packet): MasterServerPacketInfo { const header = unpackMasterHeader(packet); switch (header) { + case undefined: + return { gameServers: [] }; case MasterHeader.Vanilla: return unpackMasterVanillaContent(packet); } diff --git a/apps/worker/src/packets/packets.test.ts b/apps/worker/src/packets/packets.test.ts new file mode 100644 index 00000000..6e2ec975 --- /dev/null +++ b/apps/worker/src/packets/packets.test.ts @@ -0,0 +1,132 @@ +import { headerBuffer, packetFromBuffer, peekCtrlToken, unpackInt7 } from "../packet"; +import { unpackGameServerInfoPackets } from "./gameServerInfo"; +import { unpackMasterPackets } from "./masterServerInfo"; + +function packInt7(value: number): number[] { + let sign = 0; + if (value < 0) { + sign = 0x40; + value = ~value; + } + + const bytes = [sign | (value & 0x3f)]; + value >>>= 6; + + while (value) { + bytes[bytes.length - 1] |= 0x80; + bytes.push(value & 0x7f); + value >>>= 7; + } + + return bytes; +} + +function packString(value: string): number[] { + return [...Buffer.from(value), 0]; +} + +function connless7Packet(magic: string, payload: number[]) { + return packetFromBuffer(Buffer.from([ + 0x21, + 0x11, 0x22, 0x33, 0x44, + 0x55, 0x66, 0x77, 0x88, + ...headerBuffer(magic), + ...payload, + ])); +} + +function ctrlTokenPacket(serverToken: number) { + const buffer = Buffer.alloc(12); + buffer[0] = 0x04; + buffer[7] = 0x05; + buffer.writeUInt32BE(serverToken, 8); + return packetFromBuffer(buffer); +} + +test('unpackInt7', () => { + for (const value of [0, 1, 63, 64, -1, -64, 1234567, -1234567]) { + const packet = packetFromBuffer(Buffer.from(packInt7(value))); + expect(unpackInt7(packet)).toBe(value); + expect(packet.offset).toBe(packet.data.length); + } +}); + +test('peekCtrlToken', () => { + expect(peekCtrlToken(ctrlTokenPacket(0xaabbccdd))).toBe(0xaabbccdd); + expect(peekCtrlToken(connless7Packet('inf3', []))).toBeUndefined(); +}); + +test('unpackGameServerInfoPackets version 0.7', () => { + const packet = connless7Packet('inf3', [ + ...packInt7(0), // token + ...packString('0.7.5'), + ...packString('server name'), + ...packString('hostname'), + ...packString('ctf5'), + ...packString('CTF'), + ...packInt7(1), // flags + ...packInt7(1), // skill level + ...packInt7(1), + ...packInt7(8), + ...packInt7(2), + ...packInt7(16), + + ...packString('player1'), + ...packString('clan1'), + ...packInt7(-1), + ...packInt7(10), + ...packInt7(0), + + ...packString('spectator1'), + ...packString(''), + ...packInt7(64), + ...packInt7(-3), + ...packInt7(1), + ]); + + expect(unpackGameServerInfoPackets([packet])).toEqual({ + version: '0.7.5', + name: 'server name', + map: 'ctf5', + gameType: 'CTF', + numPlayers: 1, + maxPlayers: 8, + numClients: 2, + maxClients: 16, + clients: [ + expect.objectContaining({ + name: 'player1', + clan: 'clan1', + country: -1, + score: 10, + inGame: true, + }), + expect.objectContaining({ + name: 'spectator1', + clan: '', + country: 64, + score: -3, + inGame: false, + }), + ], + }); +}); + +test('unpackGameServerInfoPackets skips control packets', () => { + expect(unpackGameServerInfoPackets([ctrlTokenPacket(0x12345678)])).toBeUndefined(); +}); + +test('unpackMasterPackets version 0.7', () => { + const listPacket = connless7Packet('lis2', [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 1, 2, 3, 4, 0x20, 0x6f, + ]); + + expect(unpackMasterPackets([ctrlTokenPacket(0x12345678), listPacket])).toEqual({ + gameServers: [ + { + ip: '1.2.3.4', + port: 8303, + }, + ], + }); +}); diff --git a/apps/worker/src/socket.ts b/apps/worker/src/socket.ts index 0cf14011..042817d1 100644 --- a/apps/worker/src/socket.ts +++ b/apps/worker/src/socket.ts @@ -1,5 +1,5 @@ import { RemoteInfo, Socket, createSocket } from "dgram"; -import { Packet, packetFromBuffer } from "./packet"; +import { Packet, packCtrlTokenRequest, packConnless7, packetFromBuffer, peekCtrlToken, randomToken } from "./packet"; import { isIP } from "net"; type Sockets = { @@ -7,7 +7,7 @@ type Sockets = { socket6: Socket; packetsByIpAndPort: Record< string, - { packets: Packet[]; } + { packets: Packet[]; onPacket?: (packet: Packet) => void; } >; }; @@ -32,6 +32,7 @@ async function createSockets() { if (receivedPacket !== undefined) { receivedPacket.packets.push(packet); + receivedPacket.onPacket?.(packet); } }; @@ -70,9 +71,29 @@ export function sendData(sockets: Sockets, data: Buffer, ip: string, port: numbe }); } -export function listenForPackets(sockets: Sockets, ip: string, port: number) { +export function listenForPackets(sockets: Sockets, ip: string, port: number, onPacket?: (packet: Packet) => void) { const ipAndPort = ipAndPortToString(ip, port); - sockets.packetsByIpAndPort[ipAndPort] = { packets: [] }; + sockets.packetsByIpAndPort[ipAndPort] = { packets: [], onPacket }; +} + +// Sends 0.6 requests right away. 0.7 requires a token handshake first, so the +// 0.7 request is sent when the server answers the token request. +export function sendRequests(sockets: Sockets, ip: string, port: number, requests06: Buffer[], request7: Buffer) { + const myToken = randomToken(); + + listenForPackets(sockets, ip, port, (packet) => { + const serverToken = peekCtrlToken(packet); + + if (serverToken !== undefined) { + sendData(sockets, packConnless7(serverToken, myToken, request7), ip, port); + } + }); + + for (const request of requests06) { + sendData(sockets, request, ip, port); + } + + sendData(sockets, packCtrlTokenRequest(myToken), ip, port); } export function getReceivedPackets(sockets: Sockets, ip: string, port: number) { diff --git a/apps/worker/src/workers/pollGameServer.ts b/apps/worker/src/workers/pollGameServer.ts index 3f2be5ad..aa5c610f 100644 --- a/apps/worker/src/workers/pollGameServer.ts +++ b/apps/worker/src/workers/pollGameServer.ts @@ -1,6 +1,7 @@ import { prisma } from "../prisma"; -import { resetPackets, getReceivedPackets, sendData, setupSockets, listenForPackets } from "../socket"; +import { resetPackets, getReceivedPackets, sendRequests, setupSockets } from "../socket"; import { GameServerInfoPacket, unpackGameServerInfoPackets } from "../packets/gameServerInfo"; +import { headerBuffer } from "../packet"; import { scheduleUpdatePlayTime, scheduleRankPlayer, PollGameServerJobData, processPollGameServerJobs, wait } from "@teerank/teerank"; import { GameServer, GameServerState, Prisma } from "@prisma/client"; import { upsertPlayers } from "@prisma/client/sql"; @@ -39,6 +40,8 @@ const PACKET_GETINFO64 = Buffer.from([ 0 ]); +const REQUEST_GETINFO7 = Buffer.from([...headerBuffer('gie3'), 0]); + const MAX_FAILURE_COUNT = getEnvInt('MAX_FAILURE_COUNT', 30); function skipPolling(gameServer: GameServer & { gameServerState: GameServerState | null }) { @@ -241,44 +244,42 @@ async function processor(jobData: PollGameServerJobData) { const sockets = await setupSockets(); - listenForPackets(sockets, gameServer.ip, gameServer.port); - - sendData(sockets, PACKET_GETINFO, gameServer.ip, gameServer.port); - sendData(sockets, PACKET_GETINFO64, gameServer.ip, gameServer.port); + sendRequests(sockets, gameServer.ip, gameServer.port, [PACKET_GETINFO, PACKET_GETINFO64], REQUEST_GETINFO7); await wait(2000); const receivedPackets = getReceivedPackets(sockets, gameServer.ip, gameServer.port); - if (receivedPackets.packets.length > 0) { - try { - const gameServerInfo = unpackGameServerInfoPackets(receivedPackets.packets) + try { + const gameServerInfo = unpackGameServerInfoPackets(receivedPackets.packets); + + if (gameServerInfo !== undefined) { const snapshotId = await processGameServerInfo(gameServer, gameServerInfo); await Promise.all([ scheduleUpdatePlayTime({ snapshotId }), scheduleRankPlayer({ snapshotId }) ]); - } catch (e) { - console.warn(`${gameServer.ip}:${gameServer.port}: ${e}`) - } - } else { - await prisma.gameServerState.deleteMany({ - where: { - gameServerId: gameServer.id, - }, - }); + } else { + await prisma.gameServerState.deleteMany({ + where: { + gameServerId: gameServer.id, + }, + }); - await prisma.gameServer.update({ - where: { - id: gameServer.id, - }, - data: { - failureCount: { - increment: 1, + await prisma.gameServer.update({ + where: { + id: gameServer.id, }, - }, - }); + data: { + failureCount: { + increment: 1, + }, + }, + }); + } + } catch (e) { + console.warn(`${gameServer.ip}:${gameServer.port}: ${e}`) } resetPackets(sockets, gameServer.ip, gameServer.port); diff --git a/apps/worker/src/workers/pollMasterServer.ts b/apps/worker/src/workers/pollMasterServer.ts index ab1e9b82..3e42b2b9 100644 --- a/apps/worker/src/workers/pollMasterServer.ts +++ b/apps/worker/src/workers/pollMasterServer.ts @@ -1,8 +1,9 @@ import { prisma } from "../prisma"; import { lookup } from "dns/promises"; import { unpackMasterPackets } from "../packets/masterServerInfo"; -import { resetPackets, getReceivedPackets, sendData, setupSockets, listenForPackets } from "../socket"; +import { resetPackets, getReceivedPackets, sendRequests, setupSockets } from "../socket"; import { PollMasterServerJobData, processPollMasterServerJobs, wait } from "@teerank/teerank"; +import { headerBuffer } from "../packet"; function stringToCharCode(str: string) { return str.split('').map((char) => char.charCodeAt(0)); @@ -24,6 +25,8 @@ const PACKET_GETLIST = Buffer.from([ ...stringToCharCode('req2'), ]); +const REQUEST_GETLIST7 = headerBuffer('req2'); + async function processor(jobData: PollMasterServerJobData) { const masterServer = await prisma.masterServer.findUniqueOrThrow({ where: { @@ -39,9 +42,7 @@ async function processor(jobData: PollMasterServerJobData) { console.log(`Polling ${masterServer.address}:${masterServer.port}`); const ip = await lookup(masterServer.address); - listenForPackets(sockets, ip.address, masterServer.port); - - sendData(sockets, PACKET_GETLIST, ip.address, masterServer.port); + sendRequests(sockets, ip.address, masterServer.port, [PACKET_GETLIST], REQUEST_GETLIST7); await wait(2000); const receivedPackets = getReceivedPackets(sockets, ip.address, masterServer.port);