Vanilla server tests, client offline mode (#49)

* vanilla server launcher

* update package.json

* re-add babel to fix standard

* fix ci

* add buffer-equal

* simple fixes

* add offline client support

* fix closing bugs, proper wait for server start

* add test to mocha

* change test timeout to 2 min

* increase timeouts

Co-authored-by: Romain Beaumont <romain.rom1@gmail.com>
This commit is contained in:
extremeheat 2021-03-17 18:04:14 -04:00 committed by GitHub
commit 458136d877
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 289 additions and 60 deletions

3
.gitignore vendored
View file

@ -9,4 +9,5 @@ data/**/read.js
data/**/write.js
data/**/size.js
samples/*.txt
samples/*.json
samples/*.json
tools/bds*

View file

@ -15,7 +15,7 @@ function loadVersions () {
} catch {}
for (const file of files) {
const rfile = file.replace(join(__dirname, '/', version) + '/', '')
fileMap[rfile] ??= []
fileMap[rfile] = fileMap[rfile] ?? []
fileMap[rfile].push([Versions[version], file])
fileMap[rfile].sort().reverse()
}

View file

@ -9,6 +9,7 @@
"test": "mocha",
"pretest": "npm run lint",
"lint": "standard",
"vanillaServer": "node tools/startVanillaServer.js",
"fix": "standard --fix"
},
"keywords": [
@ -29,15 +30,18 @@
"jsonwebtoken": "^8.5.1",
"jsp-raknet": "github:extremeheat/raknet#client",
"minecraft-folder-path": "^1.1.0",
"node-fetch": "^2.6.1",
"prismarine-nbt": "^1.5.0",
"protodef": "github:extremeheat/node-protodef#compiler2",
"protodef": "^1.11.0",
"raknet-native": "^0.1.0",
"uuid-1345": "^1.0.2"
},
"devDependencies": {
"@babel/eslint-parser": "^7.13.10",
"babel-eslint": "^10.1.0",
"buffer-equal": "^1.0.0",
"mocha": "^8.3.2",
"protodef-yaml": "^1.0.1",
"standard": "^16.0.3"
},
"standard": {

View file

@ -7,16 +7,30 @@ const curve = 'secp384r1'
module.exports = (client, server, options) => {
const skinGeom = fs.readFileSync(DataProvider(options.protocolVersion).getPath('skin_geom.txt'), 'utf-8')
client.createClientChain = (mojangKey) => {
client.createClientChain = (mojangKey, offline) => {
mojangKey = mojangKey || require('./constants').PUBLIC_KEY
const alice = client.ecdhKeyPair
const alicePEM = ecPem(alice, curve) // https://github.com/nodejs/node/issues/15116#issuecomment-384790125
const alicePEMPrivate = alicePEM.encodePrivateKey()
const token = JWT.sign({
identityPublicKey: mojangKey,
certificateAuthority: true
}, alicePEMPrivate, { algorithm: 'ES384', header: { x5u: client.clientX509 } })
let token
if (offline) {
const payload = {
extraData: {
displayName: client.username,
identity: client.profile.uuid,
titleId: '89692877'
},
certificateAuthority: true,
identityPublicKey: client.clientX509
}
token = JWT.sign(payload, alicePEMPrivate, { algorithm: 'ES384', notBefore: 0, issuer: 'self', expiresIn: 60 * 60, header: { x5u: client.clientX509 } })
} else {
token = JWT.sign({
identityPublicKey: mojangKey,
certificateAuthority: true
}, alicePEMPrivate, { algorithm: 'ES384', header: { x5u: client.clientX509 } })
}
client.clientIdentityChain = token
client.createClientUserChain(alicePEMPrivate)
@ -24,46 +38,42 @@ module.exports = (client, server, options) => {
client.createClientUserChain = (privateKey) => {
let payload = {
ServerAddress: options.hostname,
ThirdPartyName: client.profile.name,
DeviceOS: client.session?.deviceOS || 1,
GameVersion: options.version || '1.16.201',
ClientRandomId: Date.now(), // TODO make biggeer
DeviceId: '2099de18-429a-465a-a49b-fc4710a17bb3', // TODO random
LanguageCode: 'en_GB', // TODO locale
AnimatedImageData: [],
PersonaPieces: [],
PieceTintColours: [],
SelfSignedId: '78eb38a6-950e-3ab9-b2cf-dd849e343701',
SkinId: '5eb65f73-af11-448e-82aa-1b7b165316ad.persona-e199672a8c1a87e0-0',
SkinData: 'AAAAAA==',
SkinResourcePatch: 'ewogICAiZ2VvbWV0cnkiIDogewogICAgICAiYW5pbWF0ZWRfMTI4eDEyOCIgOiAiZ2VvbWV0cnkuYW5pbWF0ZWRfMTI4eDEyOF9wZXJzb25hLWUxOTk2NzJhOGMxYTg3ZTAtMCIsCiAgICAgICJhbmltYXRlZF9mYWNlIiA6ICJnZW9tZXRyeS5hbmltYXRlZF9mYWNlX3BlcnNvbmEtZTE5OTY3MmE4YzFhODdlMC0wIiwKICAgICAgImRlZmF1bHQiIDogImdlb21ldHJ5LnBlcnNvbmFfZTE5OTY3MmE4YzFhODdlMC0wIgogICB9Cn0K',
SkinGeometryData: skinGeom,
SkinImageHeight: 1,
SkinImageWidth: 1,
ArmSize: 'wide',
CapeData: '',
CapeId: '',
CapeImageHeight: 0,
CapeImageWidth: 0,
CapeOnClassicSkin: false,
PlatformOfflineId: '',
PlatformOnlineId: '', // chat
// a bunch of meaningless junk
ClientRandomId: 1, // TODO make biggeer
CurrentInputMode: 1,
DefaultInputMode: 1,
DeviceId: '2099de18-429a-465a-a49b-fc4710a17bb3', // TODO random
DeviceModel: '',
DeviceOS: client.session?.deviceOS || 7,
GameVersion: options.version || '1.16.201',
GuiScale: -1,
UIProfile: 0,
TenantId: '',
PremiumSkin: false,
PersonaSkin: false,
LanguageCode: 'en_GB', // TODO locale
PersonaPieces: [],
PersonaSkin: true,
PieceTintColors: [],
PlatformOfflineId: '',
PlatformOnlineId: '', // chat
PremiumSkin: false,
SelfSignedId: '78eb38a6-950e-3ab9-b2cf-dd849e343701',
ServerAddress: `${options.hostname}:${options.port}`,
SkinAnimationData: '',
SkinColor: '#ffffcd96',
SkinData: 'AAAAAA==',
SkinGeometryData: skinGeom,
SkinId: '5eb65f73-af11-448e-82aa-1b7b165316ad.persona-e199672a8c1a87e0-0',
SkinImageHeight: 1,
SkinImageWidth: 1,
SkinResourcePatch: '',
ThirdPartyName: client.profile.name,
ThirdPartyNameOnly: false,
SkinColor: '#ffffcd96'
UIProfile: 0
}
payload = require('./logPack.json')
const customPayload = options.userData || {}
payload = { ...payload, ...customPayload }

View file

@ -1,6 +1,6 @@
const { ClientStatus, Connection } = require('./connection')
const { createDeserializer, createSerializer } = require('./transforms/serializer')
const { RakClient } = require('./Rak')
const { RakClient } = require('./rak')
const { serialize } = require('./datatypes/util')
const fs = require('fs')
const debug = require('debug')('minecraft-protocol')
@ -14,6 +14,8 @@ const LoginVerify = require('./auth/loginVerify')
const debugging = false
class Client extends Connection {
connection
/** @param {{ version: number, hostname: string, port: number }} options */
constructor (options) {
super()
@ -26,15 +28,19 @@ class Client extends Connection {
Login(this, null, this.options)
LoginVerify(this, null, this.options)
if (options.password) {
auth.authenticatePassword(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, options)
auth.authenticateDeviceCode(this, this.options)
}
this.startGameData = {}
this.on('session', this.connect)
this.startQueue()
this.inLog = (...args) => debug('C ->', ...args)
this.outLog = (...args) => debug('C <-', ...args)
@ -64,14 +70,14 @@ class Client extends Connection {
this.connection = new RakClient({ useWorkers: true, hostname, port })
this.connection.onConnected = () => this.sendLogin()
this.connection.onCloseConnection = () => this._close()
this.connection.onCloseConnection = () => this.close()
this.connection.onEncapsulated = this.onEncapsulated
this.connection.connect()
}
sendLogin () {
this.status = ClientStatus.Authenticating
this.createClientChain()
this.createClientChain(null, this.options.offline)
const chain = [
this.clientIdentityChain, // JWT we generated for auth
@ -79,7 +85,6 @@ class Client extends Connection {
]
const encodedChain = JSON.stringify({ chain })
const bodyLength = this.clientUserChain.length + encodedChain.length + 8
debug('Auth chain', chain)
@ -100,8 +105,8 @@ class Client extends Connection {
process.exit(1) // TODO: handle
}
onPlayStatus(statusPacket) {
if (this.status == ClientStatus.Initializing && this.options.autoInitPlayer === true) {
onPlayStatus (statusPacket) {
if (this.status === ClientStatus.Initializing && this.options.autoInitPlayer === true) {
if (statusPacket.status === 'player_spawn') {
this.status = ClientStatus.Initialized
this.write('set_local_player_as_initialized', { runtime_entity_id: this.startGameData.runtime_entity_id })
@ -110,14 +115,12 @@ class Client extends Connection {
}
}
_close() {
close () {
clearInterval(this.loop)
this.q = []
this.q2 = []
}
close () {
this._close()
this.connection.close()
this.connection?.close()
this.removeAllListeners()
console.log('Closed!')
}

View file

@ -1,4 +1,5 @@
const { MsAuthFlow } = require('./authFlow.js')
const { uuidFrom } = require('../datatypes/util')
/**
* Obtains Minecaft profile data using a Minecraft access token and starts the join sequence
@ -27,6 +28,22 @@ async function postAuthenticate (client, options, chains) {
client.emit('session', profile)
}
/**
* Creates an offline session for the client
*/
function createOfflineSession (client, options) {
if (!options.username) throw Error('Must specify a valid username')
const profile = {
name: options.username,
uuid: uuidFrom(options.username), // random
xuid: 0
}
client.profile = profile
client.username = profile.name
client.accessToken = [] // No extra JWTs, only send 1 client signed chain with all the data
client.emit('session', profile)
}
/**
* Authenticates with Mincrosoft through user credentials, then
* with Xbox Live, Minecraft, checks entitlements and returns profile
@ -61,6 +78,7 @@ async function authenticateDeviceCode (client, options) {
}
module.exports = {
createOfflineSession,
authenticatePassword,
authenticateDeviceCode
}

View file

@ -1,4 +1,5 @@
const fs = require('fs')
const UUID = require('uuid-1345')
function getFiles (dir) {
let results = []
@ -19,15 +20,23 @@ function sleep (ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function waitFor (cb, withTimeout) {
return Promise.race([
new Promise((resolve) => cb(resolve)),
sleep(withTimeout)
async function waitFor (cb, withTimeout, onTimeout) {
let t
const ret = await Promise.race([
new Promise(resolve => cb(resolve)),
new Promise(resolve => { t = setTimeout(() => resolve('timeout'), withTimeout) })
])
clearTimeout(t)
if (ret === 'timeout') onTimeout()
return ret
}
function serialize (obj = {}, fmt) {
return JSON.stringify(obj, (k, v) => typeof v === 'bigint' ? v.toString() : v, fmt)
}
module.exports = { getFiles, sleep, waitFor, serialize }
function uuidFrom (string) {
return UUID.v3({ namespace: '6ba7b811-9dad-11d1-80b4-00c04fd430c8', name: string })
}
module.exports = { getFiles, sleep, waitFor, serialize, uuidFrom }

View file

@ -7,9 +7,11 @@ const defaultOptions = {
// https://minecraft.gamepedia.com/Protocol_version#Bedrock_Edition_2
version: CURRENT_VERSION,
// client: If we should send SetPlayerInitialized to the server after getting play_status spawn.
// if this is disabled, no 'spawn' event will be emitted, you should manually set
// if this is disabled, no 'spawn' event will be emitted, you should manually set
// client.status to ClientStatus.Initialized after sending the init packet.
autoInitPlayer: true
autoInitPlayer: true,
// If true, do not authenticate with Xbox Live
offline: false
}
const Versions = {

View file

@ -49,7 +49,7 @@ class RakNativeClient extends EventEmitter {
this.raknet.connect()
}
close() {
close () {
this.connected = false
setTimeout(() => {
this.raknet.close()
@ -99,7 +99,7 @@ class RakNativeServer extends EventEmitter {
this.raknet.listen()
}
close() {
close () {
this.raknet.close()
}
}

View file

@ -71,7 +71,7 @@ class Player extends Connection {
*/
sendDisconnectStatus (playStatus) {
this.write('play_status', { status: playStatus })
this.connection.close()
this.close()
}
/**
@ -82,7 +82,7 @@ class Player extends Connection {
hide_disconnect_screen: hide,
message: reason
})
this.connection.close()
this.close()
}
// After sending Server to Client Handshake, this handles the client's
@ -95,6 +95,14 @@ class Player extends Connection {
this.emit('join')
}
close () {
this.q = []
this.q2 = []
clearInterval(this.loop)
this.connection?.close()
this.removeAllListeners()
}
readPacket (packet) {
// console.log('packet', packet)
try {

62
test/vanilla.js Normal file
View file

@ -0,0 +1,62 @@
// process.env.DEBUG = 'minecraft-protocol raknet'
const vanillaServer = require('../tools/startVanillaServer')
const { Client } = require('../src/client')
const { waitFor } = require('../src/datatypes/util')
async function test () {
// Start the server, wait for it to accept clients, throws on timeout
const handle = await vanillaServer.startServerAndWait('1.16.201', 1000 * 120)
console.log('Started server')
const client = new Client({
hostname: '127.0.0.1',
port: 19130,
username: 'Notch',
offline: true
})
console.log('Started client')
let loop
await waitFor((res) => {
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: 1 })
clearInterval(loop)
loop = setInterval(() => {
client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: BigInt(Date.now()) })
}, 200)
console.log('Awaiting join')
client.on('spawn', () => {
console.log('✔ Client has spawned')
client.close()
handle.kill()
res()
})
})
}, 1000 * 60, () => {
client.close()
handle.kill()
throw Error('❌ client timed out ')
})
clearInterval(loop)
}
if (!module.parent) test()
module.exports = { clientTest: test }

10
test/vanilla.test.js Normal file
View file

@ -0,0 +1,10 @@
/* eslint-env jest */
const { clientTest } = require('./vanilla')
describe('vanilla server test', function () {
this.timeout(120 * 1000)
it('client spawns', async () => {
await clientTest()
})
})

102
tools/startVanillaServer.js Normal file
View file

@ -0,0 +1,102 @@
const http = require('https')
const fs = require('fs')
const cp = require('child_process')
const debug = require('debug')('minecraft-protocol')
const { getFiles, waitFor } = require('../src/datatypes/util')
const head = (url) => new Promise((resolve, reject) => http.request(url, { method: 'HEAD' }, resolve).on('error', reject).end())
const get = (url, out) => cp.execSync(`curl -o ${out} ${url}`)
// Get the latest versions
// TODO: once we support multi-versions
function fetchLatestStable () {
get('https://raw.githubusercontent.com/minecraft-linux/mcpelauncher-versiondb/master/versions.json', 'versions.json')
const versions = JSON.parse(fs.readFileSync('./versions.json'))
const latest = versions[versions.length - 1]
return latest.version_name
}
// Download + extract vanilla server and enter the directory
async function download (os, version) {
process.chdir(__dirname)
const verStr = version.split('.').slice(0, 3).join('.')
const dir = 'bds-' + version
if (fs.existsSync(dir) && getFiles(dir).length) {
process.chdir('bds-' + version) // Enter server folder
return verStr
}
try { fs.mkdirSync(dir) } catch { }
process.chdir('bds-' + version) // Enter server folder
const url = (os, version) => `https://minecraft.azureedge.net/bin-${os}/bedrock-server-${version}.zip`
let found = false
for (let i = 0; i < 8; i++) { // Check for the latest server build for version (major.minor.patch.BUILD)
const u = url(os, `${verStr}.${String(i).padStart(2, '0')}`)
debug('Opening', u)
const ret = await head(u)
if (ret.statusCode === 200) {
found = u
debug('Found server', ret.statusCode)
break
}
}
if (!found) throw Error('did not find server bin for ' + os + ' ' + version)
console.info('🔻 Downloading', found)
get(found, 'bds.zip')
console.info('⚡ Unzipping')
// Unzip server
if (process.platform === 'linux') cp.execSync('unzip bds.zip')
else cp.execSync('tar -xf bds.zip')
return verStr
}
// Setup the server
function configure () {
let config = fs.readFileSync('./server.properties', 'utf-8')
config += '\nlevel-generator=2\nserver-port=19130\nplayer-idle-timeout=1\nallow-cheats=true\ndefault-player-permission-level=operator\nonline-mode=false'
fs.writeFileSync('./server.properties', config)
}
function run (inheritStdout = true) {
const exe = process.platform === 'win32' ? 'bedrock_server.exe' : './bedrock_server'
return cp.spawn(exe, inheritStdout ? { stdio: 'inherit' } : {})
}
// Run the server
async function startServer (version, onStart) {
const os = process.platform === 'win32' ? 'win' : process.platform
if (os !== 'win' && os !== 'linux') {
throw Error('unsupported os ' + os)
}
await download(os, version)
configure()
const handle = run(!onStart)
if (onStart) {
handle.stdout.on('data', data => data.includes('Server started.') ? onStart() : null)
handle.stdout.pipe(process.stdout)
handle.stderr.pipe(process.stdout)
}
return handle
}
// Start the server and wait for it to be ready, with a timeout
async function startServerAndWait (version, withTimeout) {
let handle
await waitFor(async res => {
handle = await startServer(version, res)
}, withTimeout, () => {
handle?.kill()
throw new Error('Server did not start on time ' + withTimeout)
})
return handle
}
if (!module.parent) {
// if (process.argv.length < 3) throw Error('Missing version argument')
startServer(process.argv[2] || '1.16.201')
}
module.exports = { fetchLatestStable, startServer, startServerAndWait }