From b60fd53ad51bed4405782bab247b6441fa7b5055 Mon Sep 17 00:00:00 2001 From: extremeheat Date: Wed, 21 Apr 2021 06:22:51 -0400 Subject: [PATCH] Add relay proxy to tests, docs, fix offline (#71) * Add relay proxy to tests, docs * Add proxy example, type defs * update docs * proxy: forward login packet, fix offline --- CONTRIBUTING.md | 9 ++++--- README.md | 1 + docs/API.md | 40 ++++++++++++++++++++++++++++ examples/relay.js | 37 ++++++++++++++++++++++++++ examples/serverTest.js | 2 +- index.d.ts | 17 ++++++++++++ src/auth/login.js | 2 +- src/client.js | 1 + src/relay.js | 32 +++++++++++++++-------- src/serverPlayer.js | 25 +++++++++--------- test/internal.js | 2 +- test/internal.test.js | 8 ++++++ test/proxy.js | 59 ++++++++++++++++++++++++++++++++++++++++++ 13 files changed, 206 insertions(+), 29 deletions(-) create mode 100644 examples/relay.js create mode 100644 test/proxy.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4129d2..746f349 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,9 @@ Steps to update: * Save and make sure to update the !version field at the top of the file * Run `npm run build` and `npm test` to test +## Code structure + +The code structure is similar to node-minecraft-protocol. For raknet, raknet-native is used for Raknet communication. ## Packet serialization @@ -29,7 +32,7 @@ Packets should go in proto.yml and extra types should go in types.yml. ```yml # This defines a new data structure, a ProtoDef container. -PlayerPosition: +Position: # Variable `x` in this struct has a type of `li32`, a little-endian 32-bit integer x: li32 # `z` is a 32-bit LE *unsigned* integer @@ -45,7 +48,7 @@ packet_player_position: # Read `on_ground` as a boolean on_ground: bool - # Read `position` as custom data type `PlayerPosition` defined above. + # Read `position` as custom data type `Position` defined above. position: Position # Reads a 8-bit unsigned integer, then maps it to a string @@ -90,7 +93,7 @@ packet_player_position: The above roughly translates to the following JavaScript code to read a packet: ```js -function read_player_position(stream) { +function read_position(stream) { const ret = {} ret.x = stream.readSignedInt32LE() ret.z = stream.readUnsignedInt32LE() diff --git a/README.md b/README.md index dd18d0a..5b17a18 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This is a work in progress. You can track the progress in https://github.com/Pri - Supports Minecraft Bedrock version 1.16.201, 1.16.210, 1.16.220 - Parse and serialize packets as JavaScript objects - Automatically respond to keep-alive packets + - [Proxy and mitm connections](docs/API.md) - Client - Authentication - Encryption diff --git a/docs/API.md b/docs/API.md index cf9939f..0b64c8e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -120,4 +120,44 @@ Order of client event emissions: For documentation on the protocol, and packets/fields see the [proto.yml](data/latest/proto.yml) and [types.yml](data/latest/proto.yml) files. More information on syntax can be found in CONTRIBUTING.md. When sending a packet, you must fill out all of the required fields. + +### Proxy docs + +You can create a proxy ("Relay") to create a machine-in-the-middle (MITM) connection to a server. You can observe and intercept packets as they go through. The Relay is a server+client combo with some special packet handling and forwarding that takes care of the authentication and encryption on the server side. You'll be asked to login if `offline` is not specified once you connect. + +```js +const { Relay } = require('bedrock-protocol') +const relay = new Relay({ + version: '1.16.220', // The version + /* Hostname and port to listen for clients on */ + hostname: '0.0.0.0', + port: 19132, + /* Where to send upstream packets to */ + destination: { + hostname: '127.0.0.1', + port: 19131 + } +}) +relay.listen() // Tell the server to start listening. + +relay.on('connect', player => { + console.log('New connection', player.connection.address) + + // Server is sending a message to the client. + player.on('clientbound', ({ name, params }) => { + if (name === 'disconnect') { // Intercept kick + params.message = 'Intercepted' // Change kick message to "Intercepted" + } + }) + // Client is sending a message to the server + player.on('serverbound', ({ name, params }) => { + if (name === 'text') { // Intercept chat message to server and append time. + params.message += `, on ${new Date().toLocaleString()}` + } + }) +}) +``` + +'Relay' emits 'clientbound' and 'serverbound' events, along with the data for the outgoing packet that can be modified. You can send a packet to the client with `player.queue()` or to the backend server with `player.upstream.queue()`. + [1]: https://github.com/PrismarineJS/bedrock-protocol/issues/69 diff --git a/examples/relay.js b/examples/relay.js new file mode 100644 index 0000000..f8e5791 --- /dev/null +++ b/examples/relay.js @@ -0,0 +1,37 @@ +const { Relay } = require('bedrock-protocol') + +// Start your server first on port 19131. + +// Start the proxy server +const relay = new Relay({ + version: '1.16.220', // The version + /* Hostname and port to listen for clients on */ + hostname: '0.0.0.0', + port: 19132, + /* Where to send upstream packets to */ + destination: { + hostname: '127.0.0.1', + port: 19131 + } +}) +relay.conLog = console.debug +relay.listen() // Tell the server to start listening. + +relay.on('connect', player => { + console.log('New connection', player.connection.address) + + // Server is sending a message to the client. + player.on('clientbound', ({ name, params }) => { + if (name === 'disconnect') { // Intercept kick + params.message = 'Intercepted' // Change kick message to "Intercepted" + } + }) + // Client is sending a message to the server + player.on('serverbound', ({ name, params }) => { + if (name === 'text') { // Intercept chat message to server and append time. + params.message += `, on ${new Date().toLocaleString()}` + } + }) +}) + +// Now clients can connect to your proxy diff --git a/examples/serverTest.js b/examples/serverTest.js index a155c2e..6cae966 100644 --- a/examples/serverTest.js +++ b/examples/serverTest.js @@ -41,7 +41,7 @@ async function startServer (version = '1.16.220', ok) { server.on('connect', client => { // Join is emitted after the client has been authenticated and encryption has started client.on('join', () => { - console.log('Client joined', client.getData()) + console.log('Client joined', client.getUserData()) // ResourcePacksInfo is sent by the server to inform the client on what resource packs the server has. It // sends a list of the resource packs it has and basic information on them like the version and description. diff --git a/index.d.ts b/index.d.ts index 3541be6..bb64b6c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -95,11 +95,28 @@ declare module "bedrock-protocol" { export class Server extends EventEmitter { clients: Map + // Connection logging function + conLog: Function constructor(options: Options) // Disconnects all currently connected clients close(disconnectReason: string) } + type RelayOptions = Options & { + hostname: string, + port: number, + // Toggle packet logging. + logging: boolean, + // Where to proxy requests to. + destination: { + hostname: string, + port: number + } + } + export class Relay extends Server { + constructor(options: RelayOptions) + } + class ServerAdvertisement { motd: string name: string diff --git a/src/auth/login.js b/src/auth/login.js index d835326..c2d56bb 100644 --- a/src/auth/login.js +++ b/src/auth/login.js @@ -79,7 +79,7 @@ module.exports = (client, server, options) => { ThirdPartyNameOnly: false, UIProfile: 0 } - const customPayload = options.userData || {} + const customPayload = options.skinData || {} payload = { ...payload, ...customPayload } client.clientUserChain = JWT.sign(payload, privateKey, diff --git a/src/client.js b/src/client.js index 83dcb7c..8bdce58 100644 --- a/src/client.js +++ b/src/client.js @@ -188,6 +188,7 @@ class Client extends Connection { this.emit('client.server_handshake', des.data.params) break case 'disconnect': // Client kicked + this.emit(des.data.name, des.data.params) // Emit before we kill all listeners. this.onDisconnectRequest(des.data.params) break case 'start_game': diff --git a/src/relay.js b/src/relay.js index 70e0023..492ca9e 100644 --- a/src/relay.js +++ b/src/relay.js @@ -3,15 +3,11 @@ const { Server } = require('./server') const { Player } = require('./serverPlayer') const debug = globalThis.isElectron ? console.debug : require('debug')('minecraft-protocol') -/** @typedef {{ hostname: string, port: number, auth: 'client' | 'server' | null, destination?: { hostname: string, port: number } }} Options */ - const debugging = true // Do re-encoding tests class RelayPlayer extends Player { constructor (server, conn) { super(server, conn) - this.server = server - this.conn = conn this.startRelaying = false this.once('join', () => { // The client has joined our proxy @@ -58,8 +54,8 @@ class RelayPlayer extends Player { } } - this.queue(name, params) this.emit('clientbound', des.data) + this.queue(name, params) } // Send queued packets to the connected client @@ -99,7 +95,7 @@ class RelayPlayer extends Player { if (debugging) { // some packet encode/decode testing stuff const rpacket = this.server.serializer.createPacketBuffer(des.data) - if (rpacket.toString('hex') !== packet.toString('hex')) { + if (!rpacket.equals(packet)) { console.warn('New', rpacket.toString('hex')) console.warn('Old', packet.toString('hex')) console.log('Failed to re-encode', des.data) @@ -107,6 +103,8 @@ class RelayPlayer extends Player { } } + this.emit('serverbound', des.data) + switch (des.data.name) { case 'client_cache_status': this.upstream.queue('client_cache_status', { enabled: false }) @@ -114,9 +112,8 @@ class RelayPlayer extends Player { default: // Emit the packet as-is back to the upstream server this.downInLog('Relaying', des.data) - this.upstream.sendBuffer(packet) + this.upstream.queue(des.data.name, des.data.params) } - this.emit('serverbound', des.data) } else { super.readPacket(packet) } @@ -138,13 +135,17 @@ class Relay extends Server { openUpstreamConnection (ds, clientAddr) { const client = new Client({ + offline: this.options.offline, + username: this.options.offline ? ds.profile.name : null, + version: this.options.version, hostname: this.options.destination.hostname, port: this.options.destination.port, - encrypt: this.options.encrypt, autoInitPlayer: false }) + // Set the login payload unless `noLoginForward` option + if (!client.noLoginForward) client.skinData = ds.skinData client.connect() - console.log('CONNECTING TO', this.options.destination.hostname, this.options.destination.port) + this.conLog('Connecting to', this.options.destination.hostname, this.options.destination.port) client.outLog = ds.upOutLog client.inLog = ds.upInLog client.once('join', () => { // Intercept once handshaking done @@ -175,9 +176,18 @@ class Relay extends Server { this.conLog('New connection from', conn.address) this.clients[conn.address] = player this.emit('connect', player) - this.openUpstreamConnection(player, conn.address) + player.on('login', () => { + this.openUpstreamConnection(player, conn.address) + }) } } + + close (...a) { + for (const [, v] of this.upstreams) { + v.close(...a) + } + super.close(...a) + } } // Too many things called 'Proxy' ;) diff --git a/src/serverPlayer.js b/src/serverPlayer.js index 5024a9a..bbbdcb6 100644 --- a/src/serverPlayer.js +++ b/src/serverPlayer.js @@ -26,7 +26,7 @@ class Player extends Connection { this.outLog = (...args) => debug('S <-', ...args) } - getData () { + getUserData () { return this.userData } @@ -51,24 +51,25 @@ class Player extends Connection { const skinChain = body.params.client_data try { - var { key, userData, chain } = this.decodeLoginJWT(authChain.chain, skinChain) // eslint-disable-line + var { key, userData, skinData } = this.decodeLoginJWT(authChain.chain, skinChain) // eslint-disable-line } catch (e) { console.error(e) - // TODO: disconnect user - throw new Error('Failed to verify user') + this.disconnect('Server authentication error') + return } debug('Verified user pub key', key, userData) - this.emit('login', { user: userData.extraData }) // emit events for user this.emit('server.client_handshake', { key }) // internal so we start encryption this.userData = userData.extraData + this.skinData = skinData this.profile = { name: userData.extraData?.displayName, uuid: userData.extraData?.identity, xuid: userData.extraData?.xuid } this.version = clientVer + this.emit('login', { user: userData.extraData }) // emit events for user } /** @@ -90,7 +91,7 @@ class Player extends Connection { hide_disconnect_screen: hide, message: reason }) - console.debug('Kicked ', this.connection?.address, reason) + this.server.conLog('Kicked ', this.connection?.address, reason) setTimeout(() => this.close('kick'), 100) // Allow time for message to be recieved. } @@ -118,20 +119,17 @@ class Player extends Connection { } readPacket (packet) { - // console.log('packet', packet) try { var des = this.server.deserializer.parsePacketBuffer(packet) // eslint-disable-line } catch (e) { this.disconnect('Server error') console.warn('Packet parsing failed! Writing dump to ./packetdump.bin') - fs.writeFileSync('packetdump.bin', packet) - fs.writeFileSync('packetdump.txt', packet.toString('hex')) - throw e + fs.writeFile('packetdump.bin', packet) + return } switch (des.data.name) { case 'login': - // console.log(des) this.onLogin(des) return case 'client_to_server_handshake': @@ -145,7 +143,10 @@ class Player extends Connection { this.emit('spawn') break default: - // console.log('ignoring, unhandled') + if (this.status === ClientStatus.Disconnected || this.status === ClientStatus.Authenticating) { + this.inLog('ignoring', des.data.name) + return + } } this.emit(des.data.name, des.data.params) } diff --git a/test/internal.js b/test/internal.js index b811588..449df62 100644 --- a/test/internal.js +++ b/test/internal.js @@ -38,7 +38,7 @@ async function startTest (version = '1.16.220', ok) { // server logic server.on('connect', client => { client.on('join', () => { - console.log('Client joined server', client.getData()) + console.log('Client joined server', client.getUserData()) client.write('resource_packs_info', { must_accept: false, diff --git a/test/internal.test.js b/test/internal.test.js index c75ad3f..ef756c6 100644 --- a/test/internal.test.js +++ b/test/internal.test.js @@ -1,6 +1,7 @@ /* eslint-env jest */ const { timedTest } = require('./internal') +const { proxyTest } = require('./proxy') const { Versions } = require('../src/options') describe('internal client/server test', function () { @@ -12,4 +13,11 @@ describe('internal client/server test', function () { await timedTest(version) }) } + + for (const version in Versions) { + it('proxies ' + version, async () => { + console.debug(version) + await proxyTest(version) + }) + } }) diff --git a/test/proxy.js b/test/proxy.js new file mode 100644 index 0000000..280efbf --- /dev/null +++ b/test/proxy.js @@ -0,0 +1,59 @@ +const { createClient, createServer, Relay } = require('bedrock-protocol') +const { sleep, waitFor } = require('../src/datatypes/util') + +function proxyTest (version, timeout = 1000 * 20) { + return waitFor(res => { + const server = createServer({ + host: '0.0.0.0', // optional + port: 19131, // optional + offline: true, + version // The server version + }) + + server.on('connect', client => { + client.on('join', () => { // The client has joined the server. + setTimeout(() => { + client.disconnect('Hello world !') + }, 1000) // allow some time for client to connect + }) + }) + + console.debug('Server started', server.options.version) + + const relay = new Relay({ + version, + offline: true, + /* Hostname and port for clients to listen to */ + hostname: '0.0.0.0', + port: 19132, + /* Where to send upstream packets to */ + destination: { + hostname: '127.0.0.1', + port: 19131 + } + }) + relay.conLog = console.debug + relay.listen() + + console.debug('Proxy started', server.options.version) + + const client = createClient({ hostname: '127.0.0.1', version, username: 'Boat', offline: true }) + + console.debug('Client started') + + client.on('disconnect', packet => { + console.assert(packet.message === 'Hello world !') + + server.close() + relay.close() + console.log('✔ OK') + sleep(500).then(res) + }) + }, timeout, () => { throw Error('timed out') }) +} + +if (!module.parent) { + proxyTest('1.16.220') +} + +module.exports = { proxyTest }