diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c23fb9..89c270e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - node-version: [20.x] + node-version: [24.x] runs-on: ${{ matrix.os }} timeout-minutes: 8 steps: diff --git a/README.md b/README.md index 2417b45..5f68332 100644 --- a/README.md +++ b/README.md @@ -26,16 +26,25 @@ Or with npm to use programmatically: `npx minecraft-bedrock-server --version 1.18.0 --online --path ./my1.18server` To query a version's Bedrock protocol metadata, start a temporary server and print -its RakNet PONG response as JSON: +its RakNet or Nethernet advertisement as JSON: ```sh npx minecraft-bedrock-server -v 1.19.1 --dump-pong-details ``` -This includes the raw PONG payload and any fields available from the response, -including `protocolVersion`, `versionName`, MOTDs, player counts, game mode, -and IPv4/IPv6 ports. Incomplete or non-standard PONG responses are returned -with the fields that could be extracted. +The result includes `transport` (`raknet` or `nethernet`) and available metadata: +`protocolVersion`, `versionName`, MOTDs, player counts, and game mode. RakNet +also advertises IPv4/IPv6 ports. `rawPong` contains the RakNet text or the +Nethernet hexadecimal advertisement; Nethernet also reports `advertisementVersion`. +Fields absent from the advertisement are omitted, including game/protocol versions +on older Nethernet v4 advertisements. Discovery and advertisement parsing are provided by `bedrock-protocol`; the advertised game version need not be supported for connections. + +Nethernet discovery uses local UDP port 7551 and requires +`enable-lan-visibility=true` (the server default). Run one discoverable local +server at a time when using this helper. The configured `server-port` is the +Nethernet HTTP signalling port, not the discovery port; `server-portv6` is ignored +by Nethernet BDS. Empty advertisements are retried and eventually produce an error. +Older servers continue to use RakNet discovery on their configured ports. any extraneous -- options will be placed inside the `server.properties` file, e.g. `--level-name coolWorld`. @@ -91,7 +100,7 @@ npx minecraft-bedrock-server -v 1.19.1 --dump-pong-details **via code** The underlying API `getPongDetails` downloads and starts the requested server version, sends a -RakNet unconnected ping, and stops the server after receiving its response: +discovery request using the transport in its generated `server.properties`, and stops the server after receiving its response: ```js const details = await bedrockServer.getPongDetails('1.19.1', { @@ -105,6 +114,7 @@ The above log or return data in this structure: ```js { + transport: 'raknet', rawPong: 'MCPE;Dedicated Server;527;1.19.1;0;10;...', edition: 'MCPE', motd: 'Dedicated Server', @@ -135,7 +145,7 @@ Options: --port6 Port to listen on for IPv6 (default: 19133) --online Whether to run in online mode --path Custom path to the server directory - --dump-pong-details Start a server and print its RakNet PONG details as JSON + --dump-pong-details Start a server and print its RakNet or Nethernet advertisement as JSON --versions Passing --versions will list all versions --download Download (but not run) the server binary for this platfrom (default: linux) Usage: @@ -148,6 +158,8 @@ Usage: See the exported [TypeScript defs for method docs](src/index.d.ts). +Requires Node.js 24 or newer, matching `bedrock-protocol`. + ## Testing `npm test` diff --git a/package.json b/package.json index 669ed25..bc42f97 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,15 @@ "dependencies": { "basic-args": "^1.4.0", "debug": "^4.3.3", - "prismarine-nbt": "^2.5.0" + "prismarine-nbt": "^2.5.0", + "bedrock-protocol": "^3.60.0" }, "devDependencies": { "minecraft-bedrock-server": "file:.", "mocha": "^10.4.0", "standard": "^17.1.0" + }, + "engines": { + "node": ">=24" } } diff --git a/src/cli.js b/src/cli.js index 95a810b..173a7fe 100755 --- a/src/cli.js +++ b/src/cli.js @@ -14,7 +14,7 @@ const opt = require('basic-args')({ path: { type: String, description: 'Custom path to the server directory', default: null }, versions: { type: Boolean, description: 'Passing --versions will list all versions' }, - 'dump-pong-details': { type: Boolean, description: 'Start a server and print its RakNet PONG details as JSON' }, + 'dump-pong-details': { type: Boolean, description: 'Start a server and print its RakNet or Nethernet advertisement as JSON' }, download: { type: String, description: `Download (but not run) the server binary for this platfrom (default: ${process.platform})`, default: null } }, examples: [ @@ -48,7 +48,8 @@ async function main () { 'server-port': opt.port, 'server-portv6': opt.port6, 'online-mode': Boolean(opt.online), - path: opt.path ? opt.path : undefined + path: opt.path ? opt.path : undefined, + ...opt._ }))) } else { const customOptions = opt._ || {} diff --git a/src/index.d.ts b/src/index.d.ts index 78e8aee..d8e4e0b 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -22,6 +22,8 @@ declare module "minecraft-bedrock-server" { type PermissionLevel = "visitor" | "member" | "operator"; type ServerOptions = { + "transport"?: "raknet" | "nethernet"; // Defaults to the server binary's configured transport + "enable-lan-visibility"?: boolean; // Required for Nethernet discovery "server-name"?: string; // Used as the server name "gamemode"?: GameMode; // Sets the game mode for new players "force-gamemode"?: boolean; // Prevents the server from sending to the client gamemode values other than the gamemode value saved by the server during world creation @@ -69,22 +71,26 @@ declare module "minecraft-bedrock-server" { function startServerAndWait2(version: string, withTimeout: number, options: ServerOptionsEx): Promise type PongDetails = { - rawPong: string, - edition: string, - motd: string, - protocolVersion: number, - versionName: string, - playerCount: number, - maxPlayerCount: number, - serverUniqueId: string, - motd2: string, - gameMode: string, - gameModeNumeric: number, - portIPv4: number, - portIPv6: number + transport: "raknet" | "nethernet", + rawPong: string, // RakNet text or Nethernet hexadecimal advertisement + advertisementVersion?: number, + edition?: string, + motd?: string, + protocolVersion?: number, + versionName?: string, + playerCount?: number, + maxPlayerCount?: number, + serverUniqueId?: string, + motd2?: string, + gameMode?: string, + gameModeNumeric?: number, + portIPv4?: number, + portIPv6?: number } - // Starts a server, queries its RakNet PONG details, and stops it. + function parsePongDetails(buffer: Buffer): PongDetails + + // Starts a server, queries its RakNet or Nethernet advertisement, and stops it. function getPongDetails(version: string, options?: ServerOptionsEx & { timeout?: number, pingTimeout?: number, pongRetries?: number }): Promise interface BedrockVanillaServer { diff --git a/src/index.js b/src/index.js index 6e4d028..403c655 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,7 @@ const http = require('https') const fs = require('fs') const cp = require('child_process') -const dgram = require('dgram') -const crypto = require('crypto') +const { ping, ServerAdvertisement } = require('bedrock-protocol') const { join, resolve } = require('path') const debug = process.env.CI ? console.debug : require('debug')('minecraft-bedrock-server') const https = require('https') @@ -237,95 +236,47 @@ async function startServerAndWait2 (version, withTimeout, options) { } } -const raknetMagic = Buffer.from('00ffff00fefefefefdfdfdfd12345678', 'hex') - -function parsePongDetails (buffer) { - const stringLength = buffer.length >= 35 ? buffer.readUInt16BE(33) : 0 - const rawPong = buffer.subarray(35, Math.min(buffer.length, 35 + stringLength)).toString() - const [ - edition, - motd, - protocolVersion, - versionName, - playerCount, - maxPlayerCount, - serverUniqueId, - motd2, - gameMode, - gameModeNumeric, - portIPv4, - portIPv6 - ] = rawPong.split(';') - const number = value => value && Number.isFinite(Number(value)) ? Number(value) : undefined +function pongDetails (ad, transport) { + const nethernet = transport === 'nethernet' return { - rawPong, - edition, - motd, - protocolVersion: number(protocolVersion), - versionName, - playerCount: number(playerCount), - maxPlayerCount: number(maxPlayerCount), - serverUniqueId, - motd2, - gameMode, - gameModeNumeric: number(gameModeNumeric), - portIPv4: number(portIPv4), - portIPv6: number(portIPv6) + transport, + rawPong: ad.raw, + advertisementVersion: nethernet ? ad.version : undefined, + edition: ad.header, + motd: ad.motd, + protocolVersion: nethernet && ad.version !== 7 ? undefined : Number(ad.protocol) || undefined, + versionName: nethernet ? (ad.version === 7 ? ad.gameVersion : undefined) : ad.version, + playerCount: nethernet ? ad.playerCount : ad.playersOnline, + maxPlayerCount: ad.playersMax, + serverUniqueId: nethernet ? ad.networkId?.toString() : ad.serverId, + motd2: ad.levelName, + gameMode: ad.gamemode, + gameModeNumeric: ad.gamemodeId, + portIPv4: ad.portV4, + portIPv6: ad.portV6 } } -function requestPong (port, timeout = 5000) { - return new Promise((resolve, reject) => { - const targets = Array.isArray(port) ? port : [{ port, host: '127.0.0.1', type: 'udp4' }] - const sockets = new Map() - const ping = Buffer.alloc(33) - ping[0] = 0x01 - ping.writeBigInt64BE(BigInt(Date.now()), 1) - raknetMagic.copy(ping, 9) - crypto.randomBytes(8).copy(ping, 25) - let bestPong - let closed = false - const close = () => { - if (closed) return - closed = true - clearTimeout(timer) - clearInterval(interval) - for (const socket of sockets.values()) socket.close() - } - const sendPing = () => { - for (const target of targets) sockets.get(target.type)?.send(ping, target.port, target.host) - } - const interval = setInterval(sendPing, 250) - const timer = setTimeout(() => { - close() - if (bestPong) resolve(bestPong) - else reject(new Error('Timed out waiting for RakNet PONG')) - }, timeout) - for (const target of targets) { - if (!sockets.has(target.type)) { - const socket = dgram.createSocket(target.type) - socket.on('message', (message) => { - const pong = parsePongDetails(message) - if (pong.rawPong) { - close() - resolve(pong) - } else { - bestPong = pong - } - }) - socket.on('error', () => { - socket.close() - sockets.delete(target.type) - if (!sockets.size) { - close() - reject(new Error('Unable to send RakNet PONG request')) - } - }) - sockets.set(target.type, socket) - } - } - sendPing() - }) +// Retain the public RakNet packet helper, delegating the advertisement parsing. +function parsePongDetails (buffer) { + const length = buffer.length >= 35 ? buffer.readUInt16BE(33) : 0 + const raw = buffer.subarray(35, 35 + length).toString() + return pongDetails(new ServerAdvertisement().fromString(raw), 'raknet') +} + +async function requestPong (targets, timeout, transport) { + const controller = new AbortController() + try { + return await Promise.any(targets.map(async ({ host, port }) => { + const ad = await ping({ host, port, transport, timeout, signal: controller.signal }) + if (!ad.raw) throw new Error(`Empty ${transport} advertisement`) + return pongDetails(ad, transport) + })) + } catch (error) { + throw error.errors[0] + } finally { + controller.abort() + } } async function getPongDetails (version, options = { 'server-port': 19130, 'server-portv6': 19133 }) { @@ -335,13 +286,17 @@ async function getPongDetails (version, options = { 'server-port': 19130, 'serve if (!Number.isInteger(pongRetries) || pongRetries < 1) throw new Error('pongRetries must be a positive integer') const handle = await startServerAndWait(version, timeout, serverOptions) try { - const ports = [{ port, host: '127.0.0.1', type: 'udp4' }] + const directory = resolve(serverOptions.root || '.', serverOptions.path || 'bds-' + version) + const properties = fs.readFileSync(join(directory, 'server.properties'), 'utf8') + const transport = [...properties.matchAll(/^transport\s*=\s*(\w+)/gm)].at(-1)?.[1] || 'raknet' + if (!['raknet', 'nethernet'].includes(transport)) throw new Error(`Unsupported server transport: ${transport}`) + const ports = [{ port: transport === 'nethernet' ? 7551 : port, host: '127.0.0.1', type: 'udp4' }] const port6 = Number(options['server-portv6']) - if (port6) ports.push({ port: port6, host: '::1', type: 'udp6' }) + if (port6 && transport === 'raknet') ports.push({ port: port6, host: '::1', type: 'udp6' }) let lastError for (let attempt = 0; attempt < pongRetries; attempt++) { try { - return await requestPong(ports, pingTimeout) + return await requestPong(ports, pingTimeout, transport) } catch (error) { lastError = error } diff --git a/test/test.js b/test/test.js index 59265f5..bb50011 100644 --- a/test/test.js +++ b/test/test.js @@ -4,7 +4,7 @@ const bedrockServer = require('minecraft-bedrock-server') const fs = require('fs') const assert = require('assert') const { join } = require('path') -const versions = ['1.16.210', '1.18.0', '1.21.80'] +const versions = ['1.16.210', '1.18.0', '1.21.80', '1.26.51'] for (const version of versions) { describe(`${version}`, function () { @@ -42,6 +42,8 @@ describe('auxiliary methods', function () { packet.writeUInt16BE(Buffer.byteLength(details), 33) packet.write(details, 35) assert.deepStrictEqual(bedrockServer.parsePongDetails(packet), { + transport: 'raknet', + advertisementVersion: undefined, rawPong: details, edition: 'MCPE', motd: 'Dedicated Server', @@ -80,22 +82,28 @@ describe('auxiliary methods', function () { ) }) - it('extracts PONG details from a real server', async function () { - this.timeout(90000) - const path = join(__dirname, 'bds-1.21.80') - const executable = process.platform === 'win32' ? 'bedrock_server.exe' : 'bedrock_server' - if (!fs.existsSync(join(path, executable))) this.skip() - const port = 19132 + ((Math.random() * 1000) | 0) - const details = await bedrockServer.getPongDetails('1.21.80', { - path, - 'server-port': port, - 'server-portv6': port + 1 + for (const [version, transport] of [['1.21.80', 'raknet'], ['1.26.51', 'nethernet']]) { + it(`extracts ${transport} metadata from a real ${version} server`, async function () { + this.timeout(90000) + const path = join(__dirname, 'bds-' + version) + const executable = process.platform === 'win32' ? 'bedrock_server.exe' : 'bedrock_server' + if (!fs.existsSync(join(path, executable))) this.skip() + const port = 19132 + ((Math.random() * 1000) | 0) + const details = await bedrockServer.getPongDetails(version, { + path, + 'server-port': port, + 'server-portv6': port + 1 + }) + assert.strictEqual(details.transport, transport) + assert(details.rawPong) + assert(details.protocolVersion > 0) + assert.strictEqual(details.versionName.split('.').slice(0, 3).join('.'), version) + if (transport === 'raknet') { + assert.strictEqual(details.portIPv4, port) + assert.strictEqual(details.portIPv6, port + 1) + } }) - assert(details.rawPong) - assert(details.protocolVersion > 0) - assert.strictEqual(details.portIPv4, port) - assert.strictEqual(details.portIPv6, port + 1) - }) + } it('getLatestVersions works', async function () { const versions = await bedrockServer.getLatestVersions() @@ -113,6 +121,7 @@ describe('auxiliary methods', function () { describe('cli', function () { it('works', function () { + this.timeout(10000) const helpStr = cp.execSync('npx minecraft-bedrock-server --help').toString() assert(helpStr) })