diff --git a/.gitignore b/.gitignore index 2f39796..6be9409 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ data/**/read.js data/**/write.js data/**/size.js samples/*.txt -samples/*.json \ No newline at end of file +samples/*.json +tools/bds* \ No newline at end of file diff --git a/data/provider.js b/data/provider.js index 098564a..3f51f02 100644 --- a/data/provider.js +++ b/data/provider.js @@ -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() } diff --git a/package.json b/package.json index 9984792..9202b0b 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/auth/login.js b/src/auth/login.js index e414c5d..5d3ad49 100644 --- a/src/auth/login.js +++ b/src/auth/login.js @@ -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 } diff --git a/src/client.js b/src/client.js index c9d94b4..d6c3493 100644 --- a/src/client.js +++ b/src/client.js @@ -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!') } diff --git a/src/client/auth.js b/src/client/auth.js index f4d139f..2d8d30e 100644 --- a/src/client/auth.js +++ b/src/client/auth.js @@ -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 } diff --git a/src/datatypes/util.js b/src/datatypes/util.js index 029df2b..4a14e31 100644 --- a/src/datatypes/util.js +++ b/src/datatypes/util.js @@ -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 } diff --git a/src/options.js b/src/options.js index ec1127d..cc068e6 100644 --- a/src/options.js +++ b/src/options.js @@ -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 = { diff --git a/src/rak.js b/src/rak.js index 0f44fac..e4a203d 100644 --- a/src/rak.js +++ b/src/rak.js @@ -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() } } diff --git a/src/serverPlayer.js b/src/serverPlayer.js index 7455b35..70abf8a 100644 --- a/src/serverPlayer.js +++ b/src/serverPlayer.js @@ -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 { diff --git a/test/vanilla.js b/test/vanilla.js new file mode 100644 index 0000000..1a747ab --- /dev/null +++ b/test/vanilla.js @@ -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 } diff --git a/test/vanilla.test.js b/test/vanilla.test.js new file mode 100644 index 0000000..36369bd --- /dev/null +++ b/test/vanilla.test.js @@ -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() + }) +}) \ No newline at end of file diff --git a/tools/startVanillaServer.js b/tools/startVanillaServer.js new file mode 100644 index 0000000..83856f4 --- /dev/null +++ b/tools/startVanillaServer.js @@ -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 }