From 999bfd3569cda99c1b705a4cd93ea803649c856b Mon Sep 17 00:00:00 2001 From: extremeheat Date: Sun, 18 Apr 2021 09:19:59 -0400 Subject: [PATCH] Enforce server auth, ping on createClient, update docs (#68) * Add createServer, ping on createClient, update README * fix createClient keepalive * resort readme, fix node 14 * Enforce auth on server connections, fix close/connect issues * add type definitions, update readme, docs * Wait some time before closing connection, update docs * wait for server close in tests, fix race bug * export a ping api * Rename api.md to API.md * add ping example --- .npmignore | 8 ++- README.md | 123 +++++++++++++++++++++++++++----- docs/API.md | 115 +++++++++++++++++++++++++++++ docs/FAQ.md | 7 ++ docs/api.md | 16 ----- examples/clientReadmeExample.js | 17 +++++ examples/clientTest.js | 1 + examples/ping.js | 5 ++ examples/serverReadmeExample.js | 14 ++++ examples/serverTest.js | 38 +++++----- index.d.ts | 111 ++++++++++++++++++++++++++++ index.js | 3 +- src/auth/login.js | 2 +- src/auth/loginVerify.js | 9 ++- src/client.js | 56 +++++++++------ src/connection.js | 2 +- src/createClient.js | 81 ++++++++++++++------- src/createServer.js | 11 +++ src/rak.js | 4 +- src/server.js | 20 ++++-- src/server/advertisement.js | 17 ++++- src/serverPlayer.js | 27 +++---- test/internal.js | 15 ++-- test/internal.test.js | 10 +-- test/vanilla.js | 1 + test/vanilla.test.js | 2 +- tools/genPacketDumps.js | 2 +- tools/startVanillaServer.js | 2 +- types/Item.js | 2 + 29 files changed, 583 insertions(+), 138 deletions(-) create mode 100644 docs/API.md create mode 100644 docs/FAQ.md delete mode 100644 docs/api.md create mode 100644 examples/clientReadmeExample.js create mode 100644 examples/ping.js create mode 100644 examples/serverReadmeExample.js create mode 100644 index.d.ts create mode 100644 src/createServer.js diff --git a/.npmignore b/.npmignore index b512c09..a085593 100644 --- a/.npmignore +++ b/.npmignore @@ -1 +1,7 @@ -node_modules \ No newline at end of file +node_modules/ +npm-debug.log +__* +src/**/*.json +# Runtime generated data +data/**/sample +tools/bds* \ No newline at end of file diff --git a/README.md b/README.md index 3773432..02c816a 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,122 @@ -# bedrock-protocol +# Minecraft Bedrock protocol library [![NPM version](https://img.shields.io/npm/v/bedrock-protocol.svg)](http://npmjs.com/package/bedrock-protocol) [![Build Status](https://github.com/PrismarineJS/bedrock-protocol/workflows/CI/badge.svg)](https://github.com/PrismarineJS/bedrock-protocol/actions?query=workflow%3A%22CI%22) [![Discord](https://img.shields.io/badge/chat-on%20discord-brightgreen.svg)](https://discord.gg/GsEFRM8) [![Try it on gitpod](https://img.shields.io/badge/try-on%20gitpod-brightgreen.svg)](https://gitpod.io/#https://github.com/PrismarineJS/bedrock-protocol) -Not ready for prime time yet, check https://github.com/PrismarineJS/bedrock-protocol/projects/1 for progress -Parse and serialize Minecraft: Pocket Edition packets +Minecraft Bedrock Edition (aka MCPE) protocol library, supporting authentication and encryption. Help [contribute](CONTRIBUTING.md). + +This is a work in progress. You can track the progress in https://github.com/PrismarineJS/bedrock-protocol/pull/34. ## Features - * Supports Minecraft Pocket Edition `1.0` - * Pure JavaScript - * Easily send and listen for any packet - * RakNet support through [node-raknet](https://github.com/mhsjlw/node-raknet) + - Supports Minecraft Bedrock version 1.16.201, 1.16.210, 1.16.220 + - Parses and serialize all packets with JavaScript objects + - Send a packet by supplying fields as a JavaScript object + - Automatically respond to keep-alive packets + - Client + - Authentication and login + - Encryption + - Online mode servers + - [Ping a server for status](docs/API.md#beping-host-port---serveradvertisement) + - Server + - Autheticate clients with Xbox Live + - Ping status + - Automatically respond to keep-alive packets + + * Robust test coverage. + * Easily extend with many other PrismarineJS projects, world providers, and more + * Optimized for rapidly staying up to date with Minecraft protocol updates. + + +Want to contribute on something important for PrismarineJS ? go to https://github.com/PrismarineJS/mineflayer/wiki/Big-Prismarine-projects ## Installation -Simply run - npm install bedrock-protocol +`npm install bedrock-protocol` -Then view our `examples` for inspiration! +## Usage -## Contributors -This project is run by these guys: +### Client example - - [mhsjlw](https://github.com/mhsjlw) - - [rom1504](https://github.com/rom1504) - - [Filiph Sandström](https://github.com/filfat) +```js +const bedrock = require('bedrock-protocol') +const client = bedrock.createClient({ + host: 'localhost', // optional + port: 19132, // optional, default 19132 + username: 'Notch', // the username you want to join as, optional if online mode + offline: true // optional, default false. if true, do not login with Xbox Live. You will not be asked to sign-in if set to true. +}) -## License -Licensed under the MIT license. +client.on('text', (packet) => { // Listen for chat messages and echo them back. + if (packet.source_name != client.options.username) { + client.queue('text', { + type: 'chat', needs_translation: false, source_name: client.username, xuid: '', platform_chat_id: '', + message: `${packet.source_name} said: ${packet.message} on ${new Date().toLocaleString()}` + }) + } +}) +``` + +### Server example + +*Can't connect locally on Windows? See the [faq](docs/FAQ.md)* +```js +const bedrock = require('bedrock-protocol') +const server = new bedrock.createServer({ + host: '0.0.0.0', // optional. Hostname to bind as. + port: 19132, // optional + version: '1.16.220' // optional. The server version, latest if not specified. +}) + +server.on('connect', client => { + client.on('join', () => { // The client has joined the server. + const d = new Date() // Once client is in the server, send a colorful kick message + client.disconnect(`Good ${d.getHours() < 12 ? '§emorning§r' : '§3afternoon§r'} :)\n\nMy time is ${d.toLocaleString()} !`) + }) +}) +``` + +### Ping example + +```js +const { ping } = require('bedrock-protocol') +ping({ host: 'play.cubecraft.net', port: 19132 }).then(res => { + console.log(res) +}) +``` + +## Documentation + +See [API documentation](docs/API.md) + +See [faq](docs/FAQ.md) + + + +## Testing + +```npm test``` + +## Debugging + +You can enable some protocol debugging output using `DEBUG` environment variable. + +Through node.js, add `process.env.DEBUG = 'minecraft-protocol'` at the top of your script. + +## Contribute + +Please read [CONTRIBUTING.md](CONTRIBUTING.md) and https://github.com/PrismarineJS/prismarine-contribute + +## History + +See [history](HISTORY.md) + + \ No newline at end of file diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..dac0dab --- /dev/null +++ b/docs/API.md @@ -0,0 +1,115 @@ +# Documentation + +## be.createClient(options) : Client + +Returns a `Client` instance and connects to the server. + +`options` is an object containing the properties : + +| Paramater | Optionality | Description | +| ----------- | ----------- |-| +| host | **Required** | Hostname to connect to, for example `127.0.0.1`. | +| port | *optional* | port to connect to, default to **19132** | +| version | *optional* | Version to connect as.
(Future feature, see [#69][1]) If not specified, should automatically match server version.
(Current feature) Defaults to latest version. | +| offline | *optional* | default to **false**. Set this to true to disable Microsoft/Xbox auth. | +| username | Conditional | Required if `offline` set to true : Username to connect to server as. | +| connectTimeout | *optional* | default to **9000ms**. How long to wait in milliseconds while trying to connect to server. | +| onMsaCode | *optional* | Callback called when signing in with a microsoft account with device code auth, `data` is an object documented [here](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code#device-authorization-response) | +| autoInitPlayer | optional | default to true, If we should send SetPlayerInitialized to the server after getting play_status spawn. | + + +## be.createServer(options) : Server + +Returns a `Server` instance and starts listening for clients. All clients will be +authenticated unless offline is set to true. + +`options` is an object containing the properties : + +| Paramater | Optionality | Description | +| ----------- | ----------- |-| +| host | **Required** | The hostname to bind to. use `0.0.0.0` to bind all IPv4 addresses. | +| port | *optional* | the port to bind to, default **19132** | +| version | *optional* | Version to run server as. Clients below this version will be kicked, clients above will still be permitted. | +| offline | *optional* | default to **false**. Set this to true to disable Microsoft/Xbox auth enforcement. | +| maxPlayers | *[Future][1]* | default to **3**. Set this to change the maximum number of players connected. | +| kickTimeout | *[Future][1]* | How long to wait before kicking a unresponsive client. | +| motd | *[Future][1]* | ServerAdvertisement instance. The server advertisment shown to clients, including the message of the day, level name. | +| advertismentFn | *[Future][1]* | optional. Custom function to call that should return a ServerAdvertisement, used for setting the RakNet server PONG data. Overrides `motd`. | + +## be.ping({ host, port }) : ServerAdvertisement + +Ping a server and get the response. See type definitions for the structure. + +## Methods + +[See the type defintions for this library for more information on methods.](../index.d.ts) + +Both Client and Server classes have `write(name, params)` and `queue(name, params)` methods. The former sends a packet immediately, and the latter queues them to be sent in the next packet batch. Prefer the latter for better performance and less blocking. + +You can use `.close()` to terminate a connection, and `.disconnect(reason)` to gracefully kick a connected client. + +## Server usage + +You can create a server as such: +```js +const bedrock = require('bedrock-protocol') +const server = bedrock.createServer({ + host: '0.0.0.0', // the hostname to bind to, use '0.0.0.0' to bind all hostnames + port: 19132, // optional, port to bind to, default 19132 + offline: false // default false. verify connections with XBL +}) +``` + +Then you can listen for clients and their events: +```js +// The 'connect' event is emitted after a new client has started a connection with the server and is handshaking. +// Its one paramater is the client class instance which handles this session from here on out. +server.on('connect', (client) => { + // 'join' is emitted after the client has authenticated & connection is now encrypted. + client.on('join', () => { + // Then we can continue with the server spawning sequence. See examples/serverTest.js for an example spawn sequence. + }) +}) + +``` + +Order of server client event emisions: +* 'connect' - emitted by `Server` after a client first joins the server. Second paramater is a `ServerPlayer` instance. +* 'login' - emitted by client after the client has been authenticated by the server +* 'join' - the client is ready to recieve game packets after successful server-client handshake/encryption +* 'spawn' - emitted after the client lets the server know that it has successfully spawned + +## Client docs + +You can create a server as such: +```js +const bedrock = require('bedrock-protocol') +const client = bedrock.createClient({ + host: '127.0.0.1', // the hostname to bind to, use '0.0.0.0' to bind all hostnames + port: 19132, // optional, port to bind to, default 19132 + username: 'Notch' // Any profile name, only used internally for account caching. You'll + // be asked to sign-in with Xbox Live the first time. +}) +``` + +```js +// The 'join' event is emitted after the player has authenticated +// and is ready to recieve chunks and start game packets +client.on('join', client => console.log('Player has joined!')) + +// The 'spawn' event is emitted. The chunks have been sent and all is well. +client.on('join', client => console.log('Player has spawned!')) + +// We can listen for text packets. See proto.yml for documentation. +client.on('text', (packet) => { + console.log('Client got text packet', packet) +}) +``` + +Order of client event emisions: +* 'connect' - emitted after a client first joins the server +* 'login' - emitted after the client has been authenticated by the server +* 'join' - the client is ready to recieve game packets after successful server-client handshake +* 'spawn' - emitted after the client has permission from the server to spawn + +[1]: https://github.com/PrismarineJS/bedrock-protocol/issues/69 diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 0000000..c4a5f06 --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,7 @@ +## Can’t connect to localhost Win10 server with Minecraft Win10 Edition + +This issue occurs due to loopback restrictions on Windows 10 UWP apps. To lift this restriction, launch Windows PowerShell as an administrator and run the following: + +```ps +CheckNetIsolation LoopbackExempt -a -n="Microsoft.MinecraftUWP_8wekyb3d8bbwe" +``` \ No newline at end of file diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index f5f6564..0000000 --- a/docs/api.md +++ /dev/null @@ -1,16 +0,0 @@ -# Documentation - -## be.createClient(options) - -Returns a `Client` instance and starts listening. All clients will be -automatically logged in and validated against microsoft's auth. - -`options` is an object containing the properties : - * host : default to undefined which means listen to all available ipv4 and ipv6 adresses - * port (optional) : default to 25565 - (see https://nodejs.org/api/net.html#net_server_listen_port_host_backlog_callback for details) - * kickTimeout (optional) : default to `10*1000` (10s), kick client that doesn't answer to keepalive after that time - * version (optional) : default to latest stable version, version of server - * autoInitPlayer (optional) : default to true, If we should send SetPlayerInitialized to the server after getting play_status spawn. - * offline (optional) : default to false, whether to auth with microsoft - * connectTimeout (optional) : default to 9000, ms to wait before aborting connection attempt diff --git a/examples/clientReadmeExample.js b/examples/clientReadmeExample.js new file mode 100644 index 0000000..32cffaf --- /dev/null +++ b/examples/clientReadmeExample.js @@ -0,0 +1,17 @@ +/* eslint-disable */ +const bedrock = require('bedrock-protocol') +const client = bedrock.createClient({ + host: 'localhost', // optional + port: 19132, // optional, default 19132 + username: 'Notch', // the username you want to join as, optional if online mode + offline: false // optional, default false. if true, do not login with Xbox Live. You will not be asked to sign-in if set to true. +}) + +client.on('text', (packet) => { // Listen for chat messages and echo them back. + if (packet.source_name != client.options.username) { + client.queue('text', { + type: 'chat', needs_translation: false, source_name: client.username, xuid: '', platform_chat_id: '', + message: `${packet.source_name} said: ${packet.message} on ${new Date().toLocaleString()}` + }) + } +}) \ No newline at end of file diff --git a/examples/clientTest.js b/examples/clientTest.js index bcb644e..717a428 100644 --- a/examples/clientTest.js +++ b/examples/clientTest.js @@ -9,6 +9,7 @@ async function test () { // You can specify version by adding : // version: '1.16.210' }) + client.connect() client.once('resource_packs_info', (packet) => { client.write('resource_pack_client_response', { diff --git a/examples/ping.js b/examples/ping.js new file mode 100644 index 0000000..5545cd5 --- /dev/null +++ b/examples/ping.js @@ -0,0 +1,5 @@ +const { ping } = require('bedrock-protocol') + +ping({ host: 'play.cubecraft.net', port: 19132 }).then(res => { + console.log(res) +}) diff --git a/examples/serverReadmeExample.js b/examples/serverReadmeExample.js new file mode 100644 index 0000000..95f4925 --- /dev/null +++ b/examples/serverReadmeExample.js @@ -0,0 +1,14 @@ +/* eslint-disable */ +const bedrock = require('bedrock-protocol') +const server = new bedrock.createServer({ + host: '0.0.0.0', // optional + port: 19132, // optional + version: '1.16.220' // The server version +}) + +server.on('connect', client => { + client.on('join', () => { // The client has joined the server. + const d = new Date() // Once client is in the server, send a colorful kick message + client.disconnect(`Good ${d.getHours() < 12 ? '§emorning§r' : '§3afternoon§r'} :)\n\nMy time is ${d.toLocaleString()} !`) + }) +}) \ No newline at end of file diff --git a/examples/serverTest.js b/examples/serverTest.js index c060954..a704dfd 100644 --- a/examples/serverTest.js +++ b/examples/serverTest.js @@ -71,25 +71,25 @@ async function startServer (version = '1.16.210', ok) { client.queue('inventory_slot', { window_id: 120, slot: 0, item: new Item().toBedrock() }) } - client.write('player_list', get('player_list')) - client.write('start_game', get('start_game')) - client.write('item_component', { entries: [] }) - client.write('set_spawn_position', get('set_spawn_position')) - client.write('set_time', { time: 5433771 }) - client.write('set_difficulty', { difficulty: 1 }) - client.write('set_commands_enabled', { enabled: true }) - client.write('adventure_settings', get('adventure_settings')) - client.write('biome_definition_list', get('biome_definition_list')) - client.write('available_entity_identifiers', get('available_entity_identifiers')) - client.write('update_attributes', get('update_attributes')) - client.write('creative_content', get('creative_content')) - client.write('inventory_content', get('inventory_content')) - client.write('player_hotbar', { selected_slot: 3, window_id: 'inventory', select_slot: true }) - client.write('crafting_data', get('crafting_data')) - client.write('available_commands', get('available_commands')) - client.write('chunk_radius_update', { chunk_radius: 1 }) - client.write('game_rules_changed', get('game_rules_changed')) - client.write('respawn', get('respawn')) + client.queue('player_list', get('player_list')) + client.queue('start_game', get('start_game')) + client.queue('item_component', { entries: [] }) + client.queue('set_spawn_position', get('set_spawn_position')) + client.queue('set_time', { time: 5433771 }) + client.queue('set_difficulty', { difficulty: 1 }) + client.queue('set_commands_enabled', { enabled: true }) + client.queue('adventure_settings', get('adventure_settings')) + client.queue('biome_definition_list', get('biome_definition_list')) + client.queue('available_entity_identifiers', get('available_entity_identifiers')) + client.queue('update_attributes', get('update_attributes')) + client.queue('creative_content', get('creative_content')) + client.queue('inventory_content', get('inventory_content')) + client.queue('player_hotbar', { selected_slot: 3, window_id: 'inventory', select_slot: true }) + client.queue('crafting_data', get('crafting_data')) + client.queue('available_commands', get('available_commands')) + client.queue('chunk_radius_update', { chunk_radius: 1 }) + client.queue('game_rules_changed', get('game_rules_changed')) + client.queue('respawn', get('respawn')) for (const chunk of chunks) { client.queue('level_chunk', chunk) diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..705e2ad --- /dev/null +++ b/index.d.ts @@ -0,0 +1,111 @@ +import EventEmitter from "events" + +declare module "bedrock-protocol" { + type Version = '1.16.220' | '1.16.210' | '1.16.201' + + export interface Options { + // The string version to start the client or server as + version: number, + // For the client, the hostname of the server to connect to (default: 127.0.0.1) + // For the server, the hostname to bind to (default: 0.0.0.0) + host: string, + // The port to connect or bind to, default: 19132 + port: number + } + + enum ClientStatus { + Disconected, Authenticating, Initializing, Initialized + } + + export class Connection extends EventEmitter { + readonly status: ClientStatus + + // Check if the passed version is less than or greater than the current connected client version. + versionLessThan(version: string | number) + versionGreaterThan(version: string | number) + + // Writes a Minecraft bedrock packet and sends it without queue batching + write(name: string, params: object) + // Adds a Minecraft bedrock packet to be sent in the next outgoing batch + queue(name: string, params: object) + // Writes a MCPE buffer to the connection and skips Protodef serialization. `immediate` if skip queue. + sendBuffer(buffer: Buffer, immediate?: boolean) + } + + type PlayStatus = + | 'login_success' + // # Displays "Could not connect: Outdated client!" + | 'failed_client' + // # Displays "Could not connect: Outdated server!" + | 'failed_spawn' + // # Sent after world data to spawn the player + | 'player_spawn' + // # Displays "Unable to connect to world. Your school does not have access to this server." + | 'failed_invalid_tenant' + // # Displays "The server is not running Minecraft: Education Edition. Failed to connect." + | 'failed_vanilla_edu' + // # Displays "The server is running an incompatible edition of Minecraft. Failed to connect." + | 'failed_edu_vanilla' + // # Displays "Wow this server is popular! Check back later to see if space opens up. Server Full" + | 'failed_server_full' + + + export class Client extends Connection { + constructor(options: Options) + // The client's EntityID returned by the server + readonly entityId: BigInt + + /** + * Close the connection, leave the server. + */ + close() + } + + /** + * `Player` represents a player connected to the server. + */ + export class Player extends Connection { + /** + * Disconnects a client before it has logged in via a PlayStatus packet. + * @param {string} playStatus + */ + sendDisconnectStatus(playStatus: PlayStatus) + + /** + * Disconnects a client + * @param reason The message to be shown to the user on disconnect + * @param hide Don't show the client the reason for the disconnect + */ + disconnect(reason: string, hide?: boolean) + + /** + * Close the connection. Already called by disconnect. Call this to manually close RakNet connection. + */ + close() + } + + export class Server extends EventEmitter { + clients: Map + constructor(options: Options) + // Disconnects all currently connected clients + close(disconnectReason: string) + } + + class ServerAdvertisement { + motd: string + name: string + protocol: number + version: string + players: { + online: number, + max: number + } + gamemode: string + serverId: string + } + + export function createClient(options: Options): Client + export function createServer(options: Options): Server + + export function ping({ host, port }) : ServerAdvertisement +} \ No newline at end of file diff --git a/index.js b/index.js index d6ba9b1..834f737 100644 --- a/index.js +++ b/index.js @@ -3,5 +3,6 @@ module.exports = { ...require('./src/server'), ...require('./src/serverPlayer'), ...require('./src/relay'), - ...require('./src/createClient') + ...require('./src/createClient'), + ...require('./src/createServer') } diff --git a/src/auth/login.js b/src/auth/login.js index 49913b4..d835326 100644 --- a/src/auth/login.js +++ b/src/auth/login.js @@ -65,7 +65,7 @@ module.exports = (client, server, options) => { // inside of PlayFab. PlayFabId: '5eb65f73-af11-448e-82aa-1b7b165316ad.persona-e199672a8c1a87e0-0', // 1.16.210 PremiumSkin: false, - SelfSignedId: '78eb38a6-950e-3ab9-b2cf-dd849e343702', + SelfSignedId: nextUUID(), ServerAddress: `${options.hostname}:${options.port}`, SkinAnimationData: '', SkinColor: '#ffffcd96', diff --git a/src/auth/loginVerify.js b/src/auth/loginVerify.js index 0809269..233e729 100644 --- a/src/auth/loginVerify.js +++ b/src/auth/loginVerify.js @@ -14,7 +14,7 @@ module.exports = (client, server, options) => { // from Xbox with addition user profile data // We verify that at least one of the tokens in the chain has been properly // signed by Mojang by checking the x509 public key in the JWT headers - // let didVerify = false + let didVerify = false let pubKey = mcPubKeyToPem(getX5U(chain[0])) // the first one is client signed, allow it let finalKey = null @@ -26,17 +26,20 @@ module.exports = (client, server, options) => { // Check if signed by Mojang key const x5u = getX5U(token) if (x5u === constants.PUBLIC_KEY && !data.extraData?.XUID) { - // didVerify = true + didVerify = true debug('Verified client with mojang key', x5u) } - // TODO: Handle `didVerify` = false pubKey = decoded.identityPublicKey ? mcPubKeyToPem(decoded.identityPublicKey) : x5u finalKey = decoded.identityPublicKey || finalKey // non pem data = { ...data, ...decoded } } // console.log('Result', data) + if (!didVerify && !options.offline) { + client.disconnect('disconnectionScreen.notAuthenticated') + } + return { key: finalKey, data } } diff --git a/src/client.js b/src/client.js index 2fb3ce1..83dcb7c 100644 --- a/src/client.js +++ b/src/client.js @@ -29,25 +29,30 @@ class Client extends Connection { Login(this, null, this.options) LoginVerify(this, null, this.options) - this.on('session', this.connect) - - if (options.offline) { - console.debug('offline mode, not authenticating', this.options) - auth.createOfflineSession(this, this.options) - } else if (options.password) { - auth.authenticatePassword(this, this.options) - } else { - auth.authenticateDeviceCode(this, this.options) - } + const hostname = this.options.hostname + const port = this.options.port + this.connection = new RakClient({ useWorkers: true, hostname, port }) this.startGameData = {} this.clientRuntimeId = null - this.startQueue() this.inLog = (...args) => debug('C ->', ...args) this.outLog = (...args) => debug('C <-', ...args) } + connect () { + this.on('session', this._connect) + + if (this.options.offline) { + console.debug('offline mode, not authenticating', this.options) + auth.createOfflineSession(this, this.options) + } else { + auth.authenticateDeviceCode(this, this.options) + } + + this.startQueue() + } + validateOptions () { if (!this.options.hostname || this.options.port == null) throw Error('Invalid hostname/port') @@ -70,18 +75,23 @@ class Client extends Connection { this.handle(buffer) } - connect = async (sessionData) => { - const hostname = this.options.hostname - const port = this.options.port - debug('[client] connecting to', hostname, port, sessionData) + async ping () { + try { + return await this.connection.ping(this.options.connectTimeout) + } catch (e) { + console.warn(`Unable to connect to [${this.options.hostname}]/${this.options.port}. Is the server running?`) + throw e + } + } - this.connection = new RakClient({ useWorkers: true, hostname, port }) + _connect = async (sessionData) => { + debug('[client] connecting to', this.options.hostname, this.options.port, sessionData, this.connection) this.connection.onConnected = () => this.sendLogin() this.connection.onCloseConnection = () => this.close() this.connection.onEncapsulated = this.onEncapsulated this.connection.connect() - this.connectTimer = setTimeout(() => { + this.connectTimeout = setTimeout(() => { if (this.status === ClientStatus.Disconnected) { this.connection.close() this.emit('error', 'connect timed out') @@ -115,6 +125,7 @@ class Client extends Connection { onDisconnectRequest (packet) { console.warn(`C Server requested ${packet.hide_disconnect_reason ? 'silent disconnect' : 'disconnect'}: ${packet.message}`) this.emit('kick', packet) + this.close() } onPlayStatus (statusPacket) { @@ -128,21 +139,24 @@ class Client extends Connection { } close () { - this.emit('close') + if (this.status !== ClientStatus.Disconnected) { + this.emit('close') // Emit close once + console.log('Client closed!') + } clearInterval(this.loop) clearTimeout(this.connectTimeout) this.q = [] this.q2 = [] this.connection?.close() this.removeAllListeners() - console.log('Client closed!') + this.status = ClientStatus.Disconnected } tryRencode (name, params, actual) { const packet = this.serializer.createPacketBuffer({ name, params }) - console.assert(packet.toString('hex') === actual.toString('hex')) - if (packet.toString('hex') !== actual.toString('hex')) { + console.assert(packet.equals(actual)) + if (!packet.equals(actual)) { const ours = packet.toString('hex').match(/.{1,16}/g).join('\n') const theirs = actual.toString('hex').match(/.{1,16}/g).join('\n') diff --git a/src/connection.js b/src/connection.js index a13872a..0b0a28c 100644 --- a/src/connection.js +++ b/src/connection.js @@ -75,7 +75,7 @@ class Connection extends EventEmitter { if (this.q.length) { // TODO: can we just build Batch before the queue loop? const batch = new BatchPacket() - this.outLog('<- BATCH', this.q2) + this.outLog('<- Batch', this.q2) const sending = [] for (let i = 0; i < this.q.length; i++) { const packet = this.q.shift() diff --git a/src/createClient.js b/src/createClient.js index 612fd48..e357627 100644 --- a/src/createClient.js +++ b/src/createClient.js @@ -1,45 +1,76 @@ const { Client } = require('./client') +const { RakClient } = require('./rak') const assert = require('assert') +const advertisement = require('./server/advertisement') -module.exports = { createClient } - -/** @param {{ version?: number, hostname: string, port?: number }} options */ +/** @param {{ version?: number, hostname: string, port?: number, connectTimeout?: number }} options */ function createClient (options) { - assert(options && options.hostname) + assert(options) + if (options.host) options.hostname = options.host const client = new Client({ port: 19132, ...options }) - client.once('resource_packs_info', (packet) => { - handleResourcePackInfo(client) - disableClientCache(client) - handleRenderDistance(client) - handleTickSync(client) - }) + if (options.skipPing) { + connect(client) + } else { // Try to ping + client.ping().then(data => { + const advert = advertisement.fromServerName(data) + console.log(`Connecting to server ${advert.motd} (${advert.name}), version ${advert.version}`) + // TODO: update connect version based on ping response + connect(client) + }, client) + } return client } -function handleResourcePackInfo (client) { - client.write('resource_pack_client_response', { - response_status: 'completed', - resourcepackids: [] - }) +function connect (client) { + // Actually connect + client.connect() - client.once('resource_pack_stack', (stack) => { + client.once('resource_packs_info', (packet) => { client.write('resource_pack_client_response', { response_status: 'completed', resourcepackids: [] }) + + client.once('resource_pack_stack', (stack) => { + client.write('resource_pack_client_response', { + response_status: 'completed', + resourcepackids: [] + }) + }) + + client.queue('client_cache_status', { enabled: false }) + client.queue('request_chunk_radius', { chunk_radius: client.renderDistance || 1 }) + client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: 0n }) + }) + + const KEEPALIVE_INTERVAL = 10 // Send tick sync packets every 10 ticks + let keepalive + client.tick = 0n + client.once('spawn', () => { + keepalive = setInterval(() => { + // Client fills out the request_time and the server does response_time in its reply. + client.queue('tick_sync', { request_time: client.tick, response_time: 0n }) + client.tick += BigInt(KEEPALIVE_INTERVAL) + }, 50 * KEEPALIVE_INTERVAL) + + client.on('tick_sync', async packet => { + client.emit('heartbeat', packet.response_time) + client.tick = packet.response_time + }) + }) + + client.once('close', () => { + clearInterval(keepalive) }) } -function handleRenderDistance (client) { - client.queue('request_chunk_radius', { chunk_radius: 1 }) +async function ping ({ host, port }) { + const con = new RakClient({ hostname: host, port }) + const ret = await con.ping() + con.close() + return advertisement.fromServerName(ret) } -function disableClientCache (client) { - client.queue('client_cache_status', { enabled: false }) -} - -function handleTickSync (client) { - client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: 0n }) -} +module.exports = { createClient, ping } diff --git a/src/createServer.js b/src/createServer.js new file mode 100644 index 0000000..3c6762c --- /dev/null +++ b/src/createServer.js @@ -0,0 +1,11 @@ +const { Server } = require('./server') + +function createServer (options) { + if (options.host) options.hostname = options.host + if (!options.port) options.port = 19132 + const server = new Server(options) + server.listen() + return server +} + +module.exports = { createServer } diff --git a/src/rak.js b/src/rak.js index 912df2d..7d922c7 100644 --- a/src/rak.js +++ b/src/rak.js @@ -36,7 +36,7 @@ class RakNativeClient extends EventEmitter { }) } - async ping () { + async ping (timeout = 1000) { this.raknet.ping() return waitFor((done) => { this.raknet.on('pong', (ret) => { @@ -44,7 +44,7 @@ class RakNativeClient extends EventEmitter { done(ret.extra.toString()) } }) - }, 1000) + }, timeout, () => { throw new Error('Ping timed out') }) } connect () { diff --git a/src/server.js b/src/server.js index c2dcdf7..b027b73 100644 --- a/src/server.js +++ b/src/server.js @@ -2,6 +2,7 @@ const { EventEmitter } = require('events') const { createDeserializer, createSerializer } = require('./transforms/serializer') const { Player } = require('./serverPlayer') const { RakServer } = require('./rak') +const { sleep } = require('./datatypes/util') const Options = require('./options') const debug = globalThis.isElectron ? console.debug : require('debug')('minecraft-protocol') @@ -31,7 +32,7 @@ class Server extends EventEmitter { } onOpenConnection = (conn) => { - this.inLog('new connection', conn) + console.debug('new connection', conn?.address) const player = new Player(this, conn) this.clients[conn.address] = player this.clientCount++ @@ -39,7 +40,7 @@ class Server extends EventEmitter { } onCloseConnection = (inetAddr, reason) => { - console.debug('close connection', inetAddr, reason) + console.debug('close connection', inetAddr?.address, reason) delete this.clients[inetAddr]?.connection // Prevent close loop this.clients[inetAddr]?.close() delete this.clients[inetAddr] @@ -57,7 +58,12 @@ class Server extends EventEmitter { async listen (hostname = this.options.hostname, port = this.options.port) { this.raknet = new RakServer({ hostname, port }) - await this.raknet.listen() + try { + await this.raknet.listen() + } catch (e) { + console.warn(`Failed to bind server on [${this.options.hostname}]/${this.options.port}, is the port free?`) + throw e + } console.debug('Listening on', hostname, port) this.raknet.onOpenConnection = this.onOpenConnection this.raknet.onCloseConnection = this.onCloseConnection @@ -65,14 +71,18 @@ class Server extends EventEmitter { return { hostname, port } } - close (disconnectReason) { + async close (disconnectReason) { for (const caddr in this.clients) { const client = this.clients[caddr] client.disconnect(disconnectReason) } - this.raknet.close() + this.clients = {} this.clientCount = 0 + + // Allow some time for client to get disconnect before closing connection. + await sleep(60) + this.raknet.close() } } diff --git a/src/server/advertisement.js b/src/server/advertisement.js index 0b9a401..978008d 100644 --- a/src/server/advertisement.js +++ b/src/server/advertisement.js @@ -1,8 +1,10 @@ +const { Versions, CURRENT_VERSION } = require('../options') + class ServerName { motd = 'Bedrock Protocol Server' name = 'bedrock-protocol' - protocol = 408 - version = '1.16.20' + protocol = Versions[CURRENT_VERSION] + version = CURRENT_VERSION players = { online: 0, max: 5 @@ -11,6 +13,14 @@ class ServerName { gamemode = 'Creative' serverId = '0' + fromString (str) { + const [header, motd, protocol, version, playersOnline, playersMax, serverId, name, gamemode] = str.split(';') + if (playersOnline) this.players.online = playersOnline + if (playersMax) this.players.max = playersMax + Object.assign(this, { header, motd, protocol, version, serverId, name, gamemode }) + return this + } + toString (version) { return [ 'MCPE', @@ -35,5 +45,8 @@ module.exports = { ServerName, getServerName (client) { return new ServerName().toBuffer() + }, + fromServerName (string) { + return new ServerName().fromString(string) } } diff --git a/src/serverPlayer.js b/src/serverPlayer.js index d2a6d93..5024a9a 100644 --- a/src/serverPlayer.js +++ b/src/serverPlayer.js @@ -38,7 +38,7 @@ class Player extends Connection { const clientVer = body.protocol_version if (this.server.options.protocolVersion) { if (this.server.options.protocolVersion < clientVer) { - this.sendDisconnectStatus('failed_client') + this.sendDisconnectStatus('failed_spawn') return } } else if (clientVer < Options.MIN_VERSION) { @@ -76,23 +76,22 @@ class Player extends Connection { * @param {string} playStatus */ sendDisconnectStatus (playStatus) { + if (this.status === ClientStatus.Disconnected) return this.write('play_status', { status: playStatus }) - this.close() + this.close('kick') } /** * Disconnects a client */ disconnect (reason = 'Server closed', hide = false) { - if ([ClientStatus.Authenticating, ClientStatus.Initializing].includes(this.status)) { - this.sendDisconnectStatus('failed_server_full') - } else { - this.write('disconnect', { - hide_disconnect_screen: hide, - message: reason - }) - } - this.close() + if (this.status === ClientStatus.Disconnected) return + this.write('disconnect', { + hide_disconnect_screen: hide, + message: reason + }) + console.debug('Kicked ', this.connection?.address, reason) + setTimeout(() => this.close('kick'), 100) // Allow time for message to be recieved. } // After sending Server to Client Handshake, this handles the client's @@ -105,7 +104,11 @@ class Player extends Connection { this.emit('join') } - close () { + close (reason) { + if (this.status !== ClientStatus.Disconnected) { + this.emit('close') // Emit close once + if (!reason) console.trace('Client closed connection', this.connection?.address) + } this.q = [] this.q2 = [] clearInterval(this.loop) diff --git a/test/internal.js b/test/internal.js index 09b184b..b811588 100644 --- a/test/internal.js +++ b/test/internal.js @@ -2,6 +2,7 @@ const { Server, Client } = require('../') const { dumpPackets } = require('../tools/genPacketDumps') const DataProvider = require('../data/provider') +const { ping } = require('../src/createClient') // First we need to dump some packets that a vanilla server would send a vanilla // client. Then we can replay those back in our custom server. @@ -9,11 +10,11 @@ function prepare (version) { return dumpPackets(version) } -async function startTest (version = '1.16.201', ok) { +async function startTest (version = '1.16.220', ok) { await prepare(version) const Item = require('../types/Item')(version) const port = 19130 - const server = new Server({ hostname: '0.0.0.0', port, version }) + const server = new Server({ hostname: '0.0.0.0', port, version, offline: true }) function getPath (packetPath) { return DataProvider(server.options.protocolVersion).getPath(packetPath) @@ -26,6 +27,9 @@ async function startTest (version = '1.16.201', ok) { server.listen() console.log('Started server') + const pongData = await ping({ host: '127.0.0.1', port }) + console.assert(pongData, 'did not get valid pong data from server') + const respawnPacket = get('packets/respawn.json') const chunks = await requestChunks(respawnPacket.x, respawnPacket.z, 1) @@ -136,11 +140,14 @@ async function startTest (version = '1.16.201', ok) { setTimeout(() => { client.close() - server.close() - ok?.() + server.close().then(() => { + ok?.() + }) }, 500) clearInterval(loop) }) + + client.connect() } const { ChunkColumn, Version } = require('bedrock-provider') diff --git a/test/internal.test.js b/test/internal.test.js index 3f37b17..c75ad3f 100644 --- a/test/internal.test.js +++ b/test/internal.test.js @@ -4,12 +4,12 @@ const { timedTest } = require('./internal') const { Versions } = require('../src/options') describe('internal client/server test', function () { - this.timeout(120 * 1000) + this.timeout(220 * 1000) - it('connects', async () => { - for (const version in Versions) { + for (const version in Versions) { + it('connects ' + version, async () => { console.debug(version) await timedTest(version) - } - }) + }) + } }) diff --git a/test/vanilla.js b/test/vanilla.js index 3d7c11a..6970a6c 100644 --- a/test/vanilla.js +++ b/test/vanilla.js @@ -19,6 +19,7 @@ async function test (version) { }) console.log('Started client') + client.connect() let loop diff --git a/test/vanilla.test.js b/test/vanilla.test.js index 252de3b..2670223 100644 --- a/test/vanilla.test.js +++ b/test/vanilla.test.js @@ -4,7 +4,7 @@ const { clientTest } = require('./vanilla') const { Versions } = require('../src/options') describe('vanilla server test', function () { - this.timeout(120 * 1000) + this.timeout(220 * 1000) for (const version in Versions) { it('client spawns ' + version, async () => { diff --git a/tools/genPacketDumps.js b/tools/genPacketDumps.js index d91dc54..94a718b 100644 --- a/tools/genPacketDumps.js +++ b/tools/genPacketDumps.js @@ -31,7 +31,7 @@ async function dump (version, force) { username: 'Boat' + random, offline: true }) - + client.connect() return waitFor(async res => { const root = join(__dirname, `../data/${client.options.version}/sample/`) if (!fs.existsSync(root + 'packets') || !fs.existsSync(root + 'chunks')) { diff --git a/tools/startVanillaServer.js b/tools/startVanillaServer.js index 169f3f7..32d0e0a 100644 --- a/tools/startVanillaServer.js +++ b/tools/startVanillaServer.js @@ -108,7 +108,7 @@ async function startServerAndWait (version, withTimeout, options) { if (!module.parent) { // if (process.argv.length < 3) throw Error('Missing version argument') - startServer(process.argv[2] || '1.16.201', null, process.argv[3] ? { 'server-port': process.argv[3] } : undefined) + startServer(process.argv[2] || '1.16.201', null, process.argv[3] ? { 'server-port': process.argv[3], 'online-mode': !!process.argv[4] } : undefined) } module.exports = { fetchLatestStable, startServer, startServerAndWait } diff --git a/types/Item.js b/types/Item.js index 1153308..123ceb4 100644 --- a/types/Item.js +++ b/types/Item.js @@ -37,6 +37,8 @@ module.exports = (version) => network_id: this.networkId, count: this.count, metadata: this.metadata, + has_stack_id: this.stackId, + stack_id: this.stackId, extra: { has_nbt: !!this.nbt, nbt: { version: 1, nbt: this.nbt },