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
This commit is contained in:
extremeheat 2021-04-21 06:22:51 -04:00 committed by GitHub
commit b60fd53ad5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 206 additions and 29 deletions

View file

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

View file

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

View file

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

37
examples/relay.js Normal file
View file

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

View file

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

17
index.d.ts vendored
View file

@ -95,11 +95,28 @@ declare module "bedrock-protocol" {
export class Server extends EventEmitter {
clients: Map<string, Player>
// 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

59
test/proxy.js Normal file
View file

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