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
This commit is contained in:
extremeheat 2021-04-18 09:19:59 -04:00 committed by GitHub
commit 999bfd3569
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 580 additions and 135 deletions

View file

@ -1 +1,7 @@
node_modules
node_modules/
npm-debug.log
__*
src/**/*.json
# Runtime generated data
data/**/sample
tools/bds*

123
README.md
View file

@ -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)
<!-- ## Projects Using bedrock-protocol
* [mineflayer](https://github.com/PrismarineJS/mineflayer/) - create bots with a stable, high level API.
* [pakkit](https://github.com/Heath123/pakkit) To monitor your packets
* [flying-squid](https://github.com/PrismarineJS/flying-squid/) - create minecraft bots with a stable, high level API. -->
## 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)
<!-- ## Related
* [map-colors](https://github.com/AresRPG/aresrpg-map-colors) can be used to convert any image into a buffer of minecraft compatible colors -->

115
docs/API.md Normal file
View file

@ -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. <br/>(Future feature, see [#69][1]) If not specified, should automatically match server version. <br/>(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

7
docs/FAQ.md Normal file
View file

@ -0,0 +1,7 @@
## Cant 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"
```

View file

@ -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

View file

@ -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()}`
})
}
})

View file

@ -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', {

5
examples/ping.js Normal file
View file

@ -0,0 +1,5 @@
const { ping } = require('bedrock-protocol')
ping({ host: 'play.cubecraft.net', port: 19132 }).then(res => {
console.log(res)
})

View file

@ -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()} !`)
})
})

View file

@ -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)

111
index.d.ts vendored Normal file
View file

@ -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<string, Player>
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
}

View file

@ -3,5 +3,6 @@ module.exports = {
...require('./src/server'),
...require('./src/serverPlayer'),
...require('./src/relay'),
...require('./src/createClient')
...require('./src/createClient'),
...require('./src/createServer')
}

View file

@ -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',

View file

@ -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 }
}

View file

@ -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')

View file

@ -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()

View file

@ -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 }

11
src/createServer.js Normal file
View file

@ -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 }

View file

@ -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 () {

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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')

View file

@ -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)
}
})
})
}
})

View file

@ -19,6 +19,7 @@ async function test (version) {
})
console.log('Started client')
client.connect()
let loop

View file

@ -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 () => {

View file

@ -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')) {

View file

@ -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 }

View file

@ -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 },