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:
parent
5ea8056e04
commit
b60fd53ad5
13 changed files with 206 additions and 29 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
40
docs/API.md
40
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
|
||||
|
|
|
|||
37
examples/relay.js
Normal file
37
examples/relay.js
Normal 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
|
||||
|
|
@ -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
17
index.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
32
src/relay.js
32
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' ;)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
59
test/proxy.js
Normal 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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue