diff --git a/examples/client/nethernet.js b/examples/client/nethernet.js new file mode 100644 index 00000000..fc3aea92 --- /dev/null +++ b/examples/client/nethernet.js @@ -0,0 +1,45 @@ +process.env.DEBUG = 'minecraft-protocol' + +const readline = require('readline') +const { createClient } = require('bedrock-protocol') + +async function pickSession (availableSessions) { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }) + + console.log('Available Sessions:') + + availableSessions.forEach((session, index) => console.log(`${index + 1}. ${session.customProperties.hostName} ${session.customProperties.worldName} (${session.customProperties.version})`)) + + rl.question('Please select a session by number: ', (answer) => { + const sessionIndex = parseInt(answer) - 1 + + if (sessionIndex >= 0 && sessionIndex < availableSessions.length) { + const selectedSession = availableSessions[sessionIndex] + console.log(`You selected: ${selectedSession.customProperties.hostName} ${selectedSession.customProperties.worldName} (${selectedSession.customProperties.version})`) + resolve(selectedSession) + } else { + console.log('Invalid selection. Please try again.') + resolve(pickSession()) + } + + rl.close() + }) + }) +} + +const client = createClient({ + transport: 'nethernet', // Use the Nethernet transport + world: { + pickSession + } +}) + +let ix = 0 +client.on('packet', (args) => { + console.log(`Packet ${ix} recieved`) + ix++ +}) diff --git a/examples/client/nethernet_local.js b/examples/client/nethernet_local.js new file mode 100644 index 00000000..eba411b8 --- /dev/null +++ b/examples/client/nethernet_local.js @@ -0,0 +1,22 @@ +process.env.DEBUG = 'minecraft-protocol' + +const { Client } = require('node-nethernet') +const { createClient } = require('bedrock-protocol') + +const c = new Client() + +c.once('pong', (pong) => { + c.close() + + const client = createClient({ + transport: 'nethernet', // Use the Nethernet transport + networkId: pong.senderId, + useSignalling: false + }) + + let ix = 0 + client.on('packet', (args) => { + console.log(`Packet ${ix} recieved`) + ix++ + }) +}) diff --git a/examples/server/nethernet.js b/examples/server/nethernet.js new file mode 100644 index 00000000..6b3a3927 --- /dev/null +++ b/examples/server/nethernet.js @@ -0,0 +1,20 @@ +/* eslint-disable */ +process.env.DEBUG = 'minecraft-protocol' + +const bedrock = require('bedrock-protocol') + +const server = bedrock.createServer({ + transport: 'nethernet', + useSignalling: true, // disable for LAN connections only + motd: { + motd: 'Funtime Server', + levelName: 'Wonderland' + } +}) + +server.on('connect', client => { + client.on('join', () => { // The client has joined the server. + const date = new Date() // Once client is in the server, send a colorful kick message + client.disconnect(`Good ${date.getHours() < 12 ? '§emorning§r' : '§3afternoon§r'}\n\nMy time is ${date.toLocaleString()} !`) + }) +}) diff --git a/package.json b/package.json index f4a8a575..eedb0bff 100644 --- a/package.json +++ b/package.json @@ -22,16 +22,20 @@ "license": "MIT", "dependencies": { "debug": "^4.3.1", + "json-bigint": "^1.0.0", "jsonwebtoken": "^9.0.0", "jsp-raknet": "^2.1.3", "minecraft-data": "^3.0.0", "minecraft-folder-path": "^1.2.0", - "prismarine-auth": "^2.0.0", + "node-nethernet": "github:LucienHH/node-nethernet#protocol", + "prismarine-auth": "github:LucienHH/prismarine-auth#playfab", "prismarine-nbt": "^2.0.0", "prismarine-realms": "^1.1.0", "protodef": "^1.14.0", "raknet-native": "^1.0.3", - "uuid-1345": "^1.0.2" + "uuid-1345": "^1.0.2", + "ws": "^8.18.0", + "xbox-rta": "^2.1.0" }, "optionalDependencies": { "raknet-node": "^0.5.0" diff --git a/src/client.js b/src/client.js index 472a7779..bd6c8cd1 100644 --- a/src/client.js +++ b/src/client.js @@ -5,6 +5,7 @@ const debug = require('debug')('minecraft-protocol') const Options = require('./options') const auth = require('./client/auth') const initRaknet = require('./rak') +const { NethernetClient } = require('./nethernet') const { KeyExchange } = require('./handshake/keyExchange') const Login = require('./handshake/login') const LoginVerify = require('./handshake/loginVerify') @@ -20,13 +21,16 @@ class Client extends Connection { super() this.options = { ...Options.defaultOptions, ...options } + if (this.options.transport === 'nethernet') { + this.nethernet = {} + } + this.startGameData = {} this.clientRuntimeId = null // Start off without compression on 1.19.30, zlib on below this.compressionAlgorithm = this.versionGreaterThanOrEqualTo('1.19.30') ? 'none' : 'deflate' this.compressionThreshold = 512 this.compressionLevel = this.options.compressionLevel - this.batchHeader = 0xfe if (isDebug) { this.inLog = (...args) => debug('C ->', ...args) @@ -49,10 +53,21 @@ class Client extends Connection { Login(this, null, this.options) LoginVerify(this, null, this.options) - const { RakClient } = initRaknet(this.options.raknetBackend) const host = this.options.host const port = this.options.port - this.connection = new RakClient({ useWorkers: this.options.useRaknetWorkers, host, port }, this) + + const networkId = this.options.networkId + + if (this.options.transport === 'nethernet') { + this.connection = new NethernetClient({ networkId }) + this.batchHeader = null + this.disableEncryption = true + } else if (this.options.transport === 'raknet') { + const { RakClient } = initRaknet(this.options.raknetBackend) + this.connection = new RakClient({ useWorkers: this.options.useRaknetWorkers, host, port }, this) + this.batchHeader = 0xfe + this.disableEncryption = false + } this.emit('connect_allowed') } @@ -84,7 +99,16 @@ class Client extends Connection { } validateOptions () { - if (!this.options.host || this.options.port == null) throw Error('Invalid host/port') + switch (this.options.transport) { + case 'nethernet': + if (!this.options.networkId) throw Error('Invalid networkId') + break + case 'raknet': + if (!this.options.host || this.options.port == null) throw Error('Invalid host/port') + break + default: + throw Error(`Unsupported transport: ${this.options.transport} (nethernet, raknet)`) + } Options.validateOptions(this.options) } diff --git a/src/client/auth.js b/src/client/auth.js index 7a07d83c..e57eaf74 100644 --- a/src/client/auth.js +++ b/src/client/auth.js @@ -4,6 +4,7 @@ const minecraftFolderPath = require('minecraft-folder-path') const debug = require('debug')('minecraft-protocol') const { uuidFrom } = require('../datatypes/util') const { RealmAPI } = require('prismarine-realms') +const { SessionDirectory } = require('../xsapi/session') function validateOptions (options) { if (!options.profilesFolder) { @@ -16,6 +17,60 @@ function validateOptions (options) { } } +async function serverAuthenticate (server, options) { + validateOptions(options) + + options.authflow ??= new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode) + + server.nethernet.session = new SessionDirectory(options.authflow, { + world: { + hostName: server.advertisement.motd, + name: server.advertisement.levelName, + version: options.version, + protocol: options.protocolVersion, + memberCount: server.advertisement.playerCount, + maxMemberCount: server.advertisement.playersMax + } + }) + + await server.nethernet.session.createSession(options.networkId) +} + +async function worldAuthenticate (client, options) { + validateOptions(options) + + options.authflow = new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode) + + const xbl = await options.authflow.getXboxToken() + + client.nethernet.session = new SessionDirectory(options.authflow, {}) + + const getSessions = async () => { + const sessions = await client.nethernet.session.host.rest.getSessions(xbl.userXUID) + debug('sessions', sessions) + if (!sessions.length) throw Error('Couldn\'t find any sessions for the authenticated account') + return sessions + } + + let world + + if (options.world.pickSession) { + if (typeof options.world.pickSession !== 'function') throw Error('world.pickSession must be a function') + const sessions = await getSessions() + world = await options.world.pickSession(sessions) + } + + if (!world) throw Error('Couldn\'t find a session to connect to.') + + const session = await client.nethernet.session.joinSession(world.sessionRef.name) + + const networkId = session.properties.custom.SupportedConnections.find(e => e.ConnectionType === 3).NetherNetId + + if (!networkId) throw Error('Couldn\'t find a Nethernet ID to connect to.') + + options.networkId = BigInt(networkId) +} + async function realmAuthenticate (options) { validateOptions(options) @@ -64,8 +119,8 @@ async function realmAuthenticate (options) { async function authenticate (client, options) { validateOptions(options) try { - const authflow = options.authflow || new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode) - const chains = await authflow.getMinecraftBedrockToken(client.clientX509).catch(e => { + options.authflow ??= new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode) + const chains = await options.authflow.getMinecraftBedrockToken(client.clientX509).catch(e => { if (options.password) console.warn('Sign in failed, try removing the password field') throw e }) @@ -115,5 +170,7 @@ function postAuthenticate (client, profile, chains) { module.exports = { createOfflineSession, authenticate, - realmAuthenticate + realmAuthenticate, + worldAuthenticate, + serverAuthenticate } diff --git a/src/connection.js b/src/connection.js index f1e90511..31ce2fe9 100644 --- a/src/connection.js +++ b/src/connection.js @@ -48,6 +48,7 @@ class Connection extends EventEmitter { } startEncryption (iv) { + if (this.disableEncryption) return this.encryptionEnabled = true this.inLog?.('Started encryption', this.sharedSecret, iv) this.decrypt = cipher.createDecryptor(this, iv) diff --git a/src/createClient.js b/src/createClient.js index 9d14134a..0c617636 100644 --- a/src/createClient.js +++ b/src/createClient.js @@ -5,6 +5,8 @@ const assert = require('assert') const Options = require('./options') const advertisement = require('./server/advertisement') const auth = require('./client/auth') +const { NethernetClient } = require('./nethernet') +const { NethernetSignal } = require('./websocket/signal') /** @param {{ version?: number, host: string, port?: number, connectTimeout?: number, skipPing?: boolean }} options */ function createClient (options) { @@ -17,20 +19,34 @@ function createClient (options) { client.init() } else { ping(client.options).then(ad => { - const adVersion = ad.version?.split('.').slice(0, 3).join('.') // Only 3 version units - client.options.version = options.version ?? (Options.Versions[adVersion] ? adVersion : Options.CURRENT_VERSION) + if (client.options.transport === 'raknet') { + const adVersion = ad.version?.split('.').slice(0, 3).join('.') // Only 3 version units + client.options.version = options.version ?? (Options.Versions[adVersion] ? adVersion : Options.CURRENT_VERSION) - if (ad.portV4 && client.options.followPort) { - client.options.port = ad.portV4 + if (ad.portV4 && client.options.followPort) { + client.options.port = ad.portV4 + } + + client.conLog?.(`Connecting to ${client.options.host}:${client.options.port} ${ad.motd} (${ad.levelName}), version ${ad.version} ${client.options.version !== ad.version ? ` (as ${client.options.version})` : ''}`) + } else if (client.options.transport === 'nethernet') { + client.conLog?.(`Connecting to ${client.options.networkId} ${ad.motd} (${ad.levelName})`) } - client.conLog?.(`Connecting to ${client.options.host}:${client.options.port} ${ad.motd} (${ad.levelName}), version ${ad.version} ${client.options.version !== ad.version ? ` (as ${client.options.version})` : ''}`) client.init() - }).catch(e => client.emit('error', e)) + }).catch(e => { + if (!client.options.useSignalling) { + client.emit('error', e) + } else { + client.conLog?.('Could not ping server through local signalling, trying to connect over franchise signalling instead') + client.init() + } + }) } } - if (options.realms) { + if (options.world) { + auth.worldAuthenticate(client, client.options).then(onServerInfo).catch(e => client.emit('error', e)) + } else if (options.realms) { auth.realmAuthenticate(client.options).then(onServerInfo).catch(e => client.emit('error', e)) } else { onServerInfo() @@ -38,7 +54,23 @@ function createClient (options) { return client } -function connect (client) { +/** @param {Client} client */ +async function connect (client) { + if (client.transport === 'nethernet') { + if (client.options.useSignalling) { + client.nethernet.signalling = new NethernetSignal(client.connection.nethernet.networkId, client.options.authflow, client.options.version) + + await client.nethernet.signalling.connect() + + client.connection.nethernet.credentials = client.nethernet.signalling.credentials + client.connection.nethernet.signalHandler = client.nethernet.signalling.write.bind(client.nethernet.signalling) + + client.nethernet.signalling.on('signal', signal => client.connection.nethernet.handleSignal(signal)) + } else { + await client.connection.nethernet.ping() + } + } + // Actually connect client.connect() @@ -86,15 +118,28 @@ function connect (client) { clearInterval(keepalive) }) } -} -async function ping ({ host, port }) { - const con = new RakClient({ host, port }) + client.once('close', () => { + if (client.nethernet.session) client.nethernet.session.end() + if (client.nethernet.signalling) client.nethernet.signalling.destroy() + }) +} - try { - return advertisement.fromServerName(await con.ping()) - } finally { - con.close() +async function ping ({ host, port, networkId }) { + if (networkId) { + const con = new NethernetClient({ networkId }) + try { + return advertisement.NethernetServerAdvertisement.fromBuffer(await con.ping()) + } finally { + con.close() + } + } else { + const con = new RakClient({ host, port }) + try { + return advertisement.fromServerName(await con.ping()) + } finally { + con.close() + } } } diff --git a/src/createServer.js b/src/createServer.js index 88b362d0..3811c164 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -1,9 +1,53 @@ const { Server } = require('./server') +const { NethernetSignal } = require('./websocket/signal') +const assert = require('assert') +const { getRandomUint64 } = require('./datatypes/util') +const { serverAuthenticate } = require('./client/auth') +const { SignalType } = require('node-nethernet') + +/** @param {{ port?: number, version?: number, networkId?: string, transport?: string, delayedInit?: boolean }} options */ function createServer (options) { + assert(options) + if (!options.networkId) options.networkId = getRandomUint64() if (!options.port) options.port = 19132 const server = new Server(options) - server.listen() + + function startSignalling () { + if (server.options.transport === 'nethernet') { + server.nethernet.signalling = new NethernetSignal(server.options.networkId, server.options.authflow) + + server.nethernet.signalling.connect(server.options.version) + .then(() => { + server.nethernet.signalling.on('signal', (signal) => { + switch (signal.type) { + case SignalType.ConnectRequest: + server.transport.nethernet.handleOffer(signal, server.nethernet.signalling.write.bind(server.nethernet.signalling), server.nethernet.signalling.credentials) + break + case SignalType.CandidateAdd: + server.transport.nethernet.handleCandidate(signal) + break + } + }) + }) + .catch(e => server.emit('error', e)) + } + } + + if (server.options.useSignalling) { + serverAuthenticate(server, server.options) + .then(startSignalling) + .then(() => server.listen()) + .catch(e => server.emit('error', e)) + } else { + server.listen() + } + + server.once('close', () => { + if (server.nethernet.session) server.nethernet.session.end() + if (server.nethernet.signalling) server.nethernet.signalling.destroy() + }) + return server } diff --git a/src/datatypes/util.js b/src/datatypes/util.js index 7070ce50..44a55374 100644 --- a/src/datatypes/util.js +++ b/src/datatypes/util.js @@ -1,5 +1,26 @@ const fs = require('fs') const UUID = require('uuid-1345') +const { parse } = require('json-bigint') + +const debug = require('debug')('minecraft-protocol') + +async function checkStatus (res) { + if (res.ok) { // res.status >= 200 && res.status < 300 + return res.text().then(parse) + } else { + const resp = await res.text() + debug('Request fail', resp) + throw Error(`${res.status} ${res.statusText} ${resp}`) + } +} + +function getRandomUint64 () { + const high = Math.floor(Math.random() * 0xFFFFFFFF) + const low = Math.floor(Math.random() * 0xFFFFFFFF) + + const result = (BigInt(high) << 32n) | BigInt(low) + return result +} function getFiles (dir) { let results = [] @@ -45,4 +66,4 @@ function nextUUID () { const isDebug = process.env.DEBUG?.includes('minecraft-protocol') -module.exports = { getFiles, sleep, waitFor, serialize, uuidFrom, nextUUID, isDebug } +module.exports = { getFiles, sleep, waitFor, serialize, uuidFrom, nextUUID, isDebug, getRandomUint64, checkStatus } diff --git a/src/nethernet.js b/src/nethernet.js new file mode 100644 index 00000000..c3a757ab --- /dev/null +++ b/src/nethernet.js @@ -0,0 +1,84 @@ +const { waitFor } = require('./datatypes/util') +const { Client, Server } = require('node-nethernet') + +class NethernetClient { + constructor (options = {}) { + this.onConnected = () => { } + this.onCloseConnection = () => { } + this.onEncapsulated = () => { } + + this.nethernet = new Client(options.networkId) + + this.nethernet.on('connected', (client) => { + this.onConnected(client) + }) + + this.nethernet.on('disconnect', (reason) => { + this.onCloseConnection(reason) + }) + + this.nethernet.on('encapsulated', (data, address) => { + this.onEncapsulated({ buffer: data }, address) + }) + } + + async connect () { + await this.nethernet.connect() + } + + sendReliable (data) { + this.nethernet.send(data) + } + + async ping (timeout = 10000) { + this.nethernet.ping() + return waitFor((done) => { + this.nethernet.once('pong', (ret) => { done(ret.data) }) + }, timeout, () => { + throw new Error('Ping timed out') + }) + } + + close () { + this.nethernet.close() + } +} + +class NethernetServer { + constructor (options = {}, server) { + this.onOpenConnection = () => { } + this.onCloseConnection = () => { } + this.onEncapsulated = () => { } + this.onClose = () => { } + this.updateAdvertisement = () => { + this.nethernet.setAdvertisement(server.getAdvertisement().toBuffer()) + } + + this.nethernet = new Server({ ...options }) + + this.nethernet.on('openConnection', (client) => { + client.sendReliable = function (buffer) { + return this.send(buffer) + } + this.onOpenConnection(client) + }) + + this.nethernet.on('closeConnection', (address, reason) => { + this.onCloseConnection(address, reason) + }) + + this.nethernet.on('encapsulated', (data, address) => { + this.onEncapsulated(data, address) + }) + } + + async listen () { + await this.nethernet.listen() + } + + close () { + this.nethernet.close() + } +} + +module.exports = { NethernetClient, NethernetServer } diff --git a/src/options.js b/src/options.js index 515b41f0..8e1394f4 100644 --- a/src/options.js +++ b/src/options.js @@ -12,6 +12,8 @@ const skippedVersionsOnGithubCI = ['1.16.210', '1.17.10', '1.17.30', '1.18.11', const testedVersions = process.env.CI ? Object.keys(Versions).filter(v => !skippedVersionsOnGithubCI.includes(v)) : Object.keys(Versions) const defaultOptions = { + // Choice of raknet or nethernet + transport: 'raknet', // https://minecraft.wiki/w/Protocol_version#Bedrock_Edition_2 version: CURRENT_VERSION, // client: If we should send SetPlayerInitialized to the server after getting play_status spawn. diff --git a/src/server.js b/src/server.js index 0f51baec..23481123 100644 --- a/src/server.js +++ b/src/server.js @@ -2,8 +2,9 @@ const { EventEmitter } = require('events') const { createDeserializer, createSerializer } = require('./transforms/serializer') const { Player } = require('./serverPlayer') const { sleep } = require('./datatypes/util') -const { ServerAdvertisement } = require('./server/advertisement') +const { ServerAdvertisement, NethernetServerAdvertisement } = require('./server/advertisement') const Options = require('./options') + const debug = globalThis.isElectron ? console.debug : require('debug')('minecraft-protocol') class Server extends EventEmitter { @@ -13,18 +14,29 @@ class Server extends EventEmitter { this.options = { ...Options.defaultOptions, ...options } this.validateOptions() - this.RakServer = require('./rak')(this.options.raknetBackend).RakServer + if (this.options.transport === 'nethernet') { + this.transportServer = require('./nethernet').NethernetServer + this.advertisement = new NethernetServerAdvertisement(this.options.motd, this.options.version) + this.batchHeader = null + this.disableEncryption = true + this.nethernet = {} + } else if (this.options.transport === 'raknet') { + this.transportServer = require('./rak')(this.options.raknetBackend).RakServer + this.advertisement = new ServerAdvertisement(this.options.motd, this.options.port, this.options.version) + this.batchHeader = 0xfe + this.disableEncryption = false + } else { + throw new Error(`Unsupported transport: ${this.options.transport} (nethernet, raknet)`) + } this._loadFeatures(this.options.version) this.serializer = createSerializer(this.options.version) this.deserializer = createDeserializer(this.options.version) - this.advertisement = new ServerAdvertisement(this.options.motd, this.options.port, this.options.version) this.advertisement.playersMax = options.maxPlayers ?? 3 /** @type {Object} */ this.clients = {} this.clientCount = 0 this.conLog = debug - this.batchHeader = 0xfe this.setCompressor(this.options.compressionAlgorithm, this.options.compressionLevel, this.options.compressionThreshold) } @@ -118,29 +130,31 @@ class Server extends EventEmitter { async listen () { const { host, port, maxPlayers } = this.options - this.raknet = new this.RakServer({ host, port, maxPlayers }, this) + // eslint-disable-next-line new-cap + this.transport = new this.transportServer({ host, port, networkId: this.options.networkId, maxPlayers }, this) try { - await this.raknet.listen() + await this.transport.listen() } catch (e) { console.warn(`Failed to bind server on [${this.options.host}]/${this.options.port}, is the port free?`) throw e } this.conLog('Listening on', host, port, this.options.version) - this.raknet.onOpenConnection = this.onOpenConnection - this.raknet.onCloseConnection = this.onCloseConnection - this.raknet.onEncapsulated = this.onEncapsulated - this.raknet.onClose = (reason) => this.close(reason || 'Raknet closed') + this.transport.onOpenConnection = this.onOpenConnection + this.transport.onCloseConnection = this.onCloseConnection + this.transport.onEncapsulated = this.onEncapsulated + this.transport.onClose = (reason) => this.close(reason || 'Transport closed') this.serverTimer = setInterval(() => { - this.raknet.updateAdvertisement() + this.transport.updateAdvertisement() }, 1000) return { host, port } } async close (disconnectReason = 'Server closed') { + this.emit('close', disconnectReason) for (const caddr in this.clients) { const client = this.clients[caddr] client.disconnect(disconnectReason) @@ -152,7 +166,7 @@ class Server extends EventEmitter { // Allow some time for client to get disconnect before closing connection. await sleep(60) - this.raknet.close() + this.transport.close() } } diff --git a/src/server/advertisement.js b/src/server/advertisement.js index 2d0a4c27..f7e50c84 100644 --- a/src/server/advertisement.js +++ b/src/server/advertisement.js @@ -1,5 +1,51 @@ const { Versions, CURRENT_VERSION } = require('../options') +const { ServerData } = require('node-nethernet') + +class NethernetServerAdvertisement { + version = 3 + motd = 'Bedrock Protocol Server' + levelName = 'bedrock-protocol' + gamemodeId = 2 + playerCount = 0 + playersMax = 5 + isEditorWorld = false + hardcore = false + transportLayer = 2 + + constructor (obj) { + Object.assign(this, obj) + } + + static fromBuffer (buffer) { + const responsePacket = new ServerData(buffer) + + responsePacket.decode() + + Object.assign(this, responsePacket) + + return this + } + + toBuffer () { + const responsePacket = new ServerData() + + responsePacket.version = this.version + responsePacket.motd = this.motd + responsePacket.levelName = this.levelName + responsePacket.gamemodeId = this.gamemodeId + responsePacket.playerCount = this.playerCount + responsePacket.playersMax = this.playersMax + responsePacket.editorWorld = this.isEditorWorld + responsePacket.hardcore = this.hardcore + responsePacket.transportLayer = this.transportLayer + + responsePacket.encode() + + return responsePacket.getBuffer() + } +} + class ServerAdvertisement { motd = 'Bedrock Protocol Server' levelName = 'bedrock-protocol' @@ -61,6 +107,7 @@ class ServerAdvertisement { module.exports = { ServerAdvertisement, + NethernetServerAdvertisement, getServerName (client) { return new ServerAdvertisement().toBuffer() }, diff --git a/src/serverPlayer.js b/src/serverPlayer.js index b7ef02d4..6cccef75 100644 --- a/src/serverPlayer.js +++ b/src/serverPlayer.js @@ -29,6 +29,8 @@ class Player extends Connection { } this.batchHeader = this.server.batchHeader + this.disableEncryption = this.server.disableEncryption + // Compression is server-wide this.compressionAlgorithm = this.server.compressionAlgorithm this.compressionLevel = this.server.compressionLevel diff --git a/src/transforms/framer.js b/src/transforms/framer.js index a2d9c4f3..81c683db 100644 --- a/src/transforms/framer.js +++ b/src/transforms/framer.js @@ -40,8 +40,8 @@ class Framer { static decode (client, buf) { // Read header - if (this.batchHeader && buf[0] !== this.batchHeader) throw Error(`bad batch packet header, received: ${buf[0]}, expected: ${this.batchHeader}`) - const buffer = buf.slice(1) + if (client.batchHeader && buf[0] !== client.batchHeader) throw Error(`bad batch packet header, received: ${buf[0]}, expected: ${client.batchHeader}`) + const buffer = buf.slice(client.batchHeader ? 1 : 0) // Decompress let decompressed if (client.features.compressorInHeader && client.compressionReady) { diff --git a/src/websocket/signal.js b/src/websocket/signal.js new file mode 100644 index 00000000..4e7c0434 --- /dev/null +++ b/src/websocket/signal.js @@ -0,0 +1,209 @@ +const { WebSocket } = require('ws') +const { stringify } = require('json-bigint') +const { once, EventEmitter } = require('node:events') +const { SignalStructure } = require('node-nethernet') + +const debug = require('debug')('minecraft-protocol') + +const MessageType = { + RequestPing: 0, + Signal: 1, + Credentials: 2 +} + +class NethernetSignal extends EventEmitter { + constructor (networkId, authflow, version) { + super() + + this.networkId = networkId + + this.authflow = authflow + + this.version = version + + this.ws = null + + this.credentials = null + + this.pingInterval = null + + this.retryCount = 0 + } + + async connect () { + if (this.ws?.readyState === WebSocket.OPEN) throw new Error('Already connected signaling server') + await this.init() + + await once(this, 'credentials') + } + + async destroy (resume = false) { + debug('Disconnecting from Signal') + + if (this.pingInterval) { + clearInterval(this.pingInterval) + this.pingInterval = null + } + + if (this.ws) { + this.ws.onmessage = null + this.ws.onclose = null + + const shouldClose = this.ws.readyState === WebSocket.OPEN + + if (shouldClose) { + let outerResolve + + const promise = new Promise((resolve) => { + outerResolve = resolve + }) + + this.ws.onclose = outerResolve + + this.ws.close(1000, 'Normal Closure') + + await promise + } + + this.ws.onerror = null + } + + if (resume) { + return this.init() + } + } + + async init () { + const xbl = await this.authflow.getMinecraftBedrockServicesToken({ version: this.version }) + + debug('Fetched XBL Token', xbl) + + const address = `wss://signal.franchise.minecraft-services.net/ws/v1.0/signaling/${this.networkId}` + + debug('Connecting to Signal', address) + + const ws = new WebSocket(address, { + headers: { Authorization: xbl.mcToken } + }) + + this.pingInterval = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ Type: MessageType.RequestPing })) + } + }, 5000) + + ws.onopen = () => { + this.onOpen() + } + + ws.onclose = (event) => { + this.onClose(event.code, event.reason) + } + + ws.onerror = (event) => { + this.onError(event) + } + + ws.onmessage = (event) => { + this.onMessage(event.data) + } + + this.ws = ws + } + + onOpen () { + debug('Connected to Signal') + } + + onError (err) { + debug('Signal Error', err) + } + + onClose (code, reason) { + debug(`Signal Disconnected with code ${code} and reason ${reason}`) + + if (code === 1006) { + debug('Signal Connection Closed Unexpectedly') + + if (this.retryCount < 5) { + this.retryCount++ + this.destroy(true) + } else { + this.destroy() + throw new Error('Signal Connection Closed Unexpectedly') + } + } + } + + onMessage (res) { + if (!(typeof res === 'string')) return debug('Received non-string message', res) + + const message = JSON.parse(res) + + debug('Recieved message', message) + + switch (message.Type) { + case MessageType.Credentials: { + if (message.From !== 'Server') { + debug('Received credentials from non-Server', 'message', message) + return + } + + this.credentials = parseTurnServers(message.Message) + + this.emit('credentials', this.credentials) + + break + } + case MessageType.Signal: { + const signal = SignalStructure.fromString(message.Message) + + signal.networkId = message.From + + this.emit('signal', signal) + break + } + case MessageType.RequestPing: { + debug('Signal Pinged') + } + } + } + + write (signal) { + if (!this.ws) throw new Error('WebSocket not connected') + + const message = stringify({ Type: MessageType.Signal, To: signal.networkId, Message: signal.toString() }) + + debug('Sending Signal', message) + + this.ws.send(message) + } +} + +module.exports = { NethernetSignal } + +function parseTurnServers (dataString) { + const servers = [] + + const data = JSON.parse(dataString) + + if (!data.TurnAuthServers) return servers + + for (const server of data.TurnAuthServers) { + if (!server.Urls) continue + + for (const url of server.Urls) { + const match = url.match(/(stun|turn):([^:]+):(\d+)/) + if (match) { + servers.push({ + hostname: match[2], + port: parseInt(match[3], 10), + username: server.Username || undefined, + password: server.Password || undefined + }) + } + } + } + + return servers +} diff --git a/src/xsapi/rest.js b/src/xsapi/rest.js new file mode 100644 index 00000000..8dec8277 --- /dev/null +++ b/src/xsapi/rest.js @@ -0,0 +1,188 @@ +const { stringify } = require('json-bigint') +const { checkStatus } = require('../datatypes/util') + +const SessionConfig = { + MinecraftTitleID: '896928775', + MinecraftSCID: '4fc10100-5f7a-4470-899b-280835760c07', + MinecraftTemplateName: 'MinecraftLobby' +} + +const Joinability = { + /** + * Only players who have been invited can join the session. + * */ + InviteOnly: 'invite_only', + /** + * Friends of the authenticating account can join/view the session without an invite. + * */ + FriendsOnly: 'friends_only', + /** + * Anyone that's a friend or friend of a friend can join/view the session without an invite. + * @default + * */ + FriendsOfFriends: 'friends_of_friends' +} + +const JoinabilityConfig = { + [Joinability.InviteOnly]: { + joinability: 'invite_only', + joinRestriction: 'local', + broadcastSetting: 1 + }, + [Joinability.FriendsOnly]: { + joinability: 'joinable_by_friends', + joinRestriction: 'followed', + broadcastSetting: 2 + }, + [Joinability.FriendsOfFriends]: { + joinability: 'joinable_by_friends', + joinRestriction: 'followed', + broadcastSetting: 3 + } +} + +const isXuid = xuid => /^\d{16}$/.test(xuid) + +class Rest { + constructor (authflow, options = {}) { + this.authflow = authflow + this.options = options + } + + async get (url, config = {}) { + return await this._request('GET', { url, ...config }) + } + + async post (url, config = {}) { + return await this._request('POST', { url, ...config }) + } + + async put (url, config = {}) { + return await this._request('PUT', { url, ...config }) + } + + async delete (url, config = {}) { + return await this._request('DELETE', { url, ...config }) + } + + async _request (method, config) { + const auth = await this.authflow.getXboxToken('http://xboxlive.com') + + const payload = { + method, + url: config.url, + headers: { + authorization: `XBL3.0 x=${auth.userHash};${auth.XSTSToken}`, + 'accept-language': 'en-US', + ...config.headers + }, + data: undefined + } + + if (config.contractVersion) payload.headers['x-xbl-contract-version'] = config.contractVersion + if (config.data) payload.body = stringify(config.data) + + return fetch(payload.url, payload).then(checkStatus) + } + + async getProfile (input) { + input = input === 'me' ? 'me' : isXuid(input) ? `xuids(${input})` : `gt(${encodeURIComponent(input)})` + const response = await this.get(`https://profile.xboxlive.com/users/${input}/settings`, { contractVersion: '2' }) + + return response.profileUsers[0] + } + + async sendHandle (payload) { + return this.post('https://sessiondirectory.xboxlive.com/handles', { + data: payload, + contractVersion: '107' + }) + } + + async setActivity (sessionName) { + return this.sendHandle({ + version: 1, + type: 'activity', + sessionRef: { scid: SessionConfig.MinecraftSCID, templateName: SessionConfig.MinecraftTemplateName, name: sessionName } + }) + } + + async sendInvite (sessionName, xuid) { + return this.sendHandle({ + version: 1, + type: 'invite', + sessionRef: { scid: SessionConfig.MinecraftSCID, templateName: SessionConfig.MinecraftTemplateName, name: sessionName }, + invitedXuid: xuid, + inviteAttributes: { titleId: SessionConfig.MinecraftTitleID } + }) + } + + async getSessions (xuid) { + const response = await this.post('https://sessiondirectory.xboxlive.com/handles/query?include=relatedInfo,customProperties', { + data: { + type: 'activity', + scid: SessionConfig.MinecraftSCID, + owners: { + people: { + moniker: 'people', + monikerXuid: xuid + } + } + }, + contractVersion: '107' + }) + + return response.results + } + + async getSession (sessionName) { + const response = await this.get(`https://sessiondirectory.xboxlive.com/serviceconfigs/${SessionConfig.MinecraftSCID}/sessionTemplates/${SessionConfig.MinecraftTemplateName}/sessions/${sessionName}`, { + contractVersion: '107' + }) + + return response + } + + async updateSession (sessionName, payload) { + const response = await this.put(`https://sessiondirectory.xboxlive.com/serviceconfigs/${SessionConfig.MinecraftSCID}/sessionTemplates/${SessionConfig.MinecraftTemplateName}/sessions/${sessionName}`, { + data: payload, + contractVersion: '107' + }) + + return response + } + + async updateMemberCount (sessionName, count, maxCount) { + const payload = maxCount ? { MemberCount: count, MaxMemberCount: maxCount } : { MemberCount: count } + await this.updateSession(sessionName, { properties: { custom: payload } }) + } + + async addConnection (sessionName, xuid, connectionId, subscriptionId) { + const payload = { + members: { + me: { + constants: { system: { xuid, initialize: true } }, + properties: { + system: { active: true, connection: connectionId, subscription: { id: subscriptionId, changeTypes: ['everything'] } } + } + } + } + } + + await this.updateSession(sessionName, payload) + } + + async updateConnection (sessionName, connectionId) { + const payload = { + members: { me: { properties: { system: { active: true, connection: connectionId } } } } + } + + await this.updateSession(sessionName, payload) + } + + async leaveSession (sessionName) { + await this.updateSession(sessionName, { members: { me: null } }) + } +} + +module.exports = { Rest, Joinability, JoinabilityConfig, SessionConfig, isXuid } diff --git a/src/xsapi/session.js b/src/xsapi/session.js new file mode 100644 index 00000000..98ca5e38 --- /dev/null +++ b/src/xsapi/session.js @@ -0,0 +1,223 @@ +const { v4 } = require('uuid-1345') +const { XboxRTA } = require('xbox-rta') +const { Rest, Joinability, JoinabilityConfig, isXuid } = require('./rest') + +const debug = require('debug')('minecraft-protocol') + +class Host { + constructor (session, authflow) { + this.session = session + + this.authflow = authflow + + this.rest = new Rest(this.authflow) + + this.subscriptionId = v4() + + this.profile = null + + this.rta = null + + this.connectionId = null + } + + async connect () { + this.rta = new XboxRTA(this.authflow) + + this.profile = await this.rest.getProfile('me') + + await this.rta.connect() + + const subResponse = await this.rta.subscribe('https://sessiondirectory.xboxlive.com/connections/') + + this.connectionId = subResponse.data.ConnectionId + + this.rta.on('subscribe', (event) => this.onSubscribe(event)) + } + + async onSubscribe (event) { + const connectionId = event.data?.ConnectionId + + if (connectionId && typeof connectionId === 'string') { + debug('Received RTA subscribe event', event) + + try { + this.connectionId = connectionId + + await this.rest.updateConnection(this.session.session.name, connectionId) + await this.rest.setActivity(this.session.session.name) + } catch (e) { + debug('Failed to update connection, session may have been abandoned', e) + await this.session.end(true) + } + } + } +} + +class SessionDirectory { + constructor (authflow, options) { + this.options = { + joinability: Joinability.FriendsOfFriends, + ...options, + world: { + hostName: 'Bedrock Protocol Server', + name: 'bedrock-protocol', + version: '1.21.20', + memberCount: 0, + maxMemberCount: 10, + ...options.world + } + } + + this.authflow = authflow + + this.host = new Host(this, this.authflow) + + this.session = { name: '' } + } + + async joinSession (sessionName) { + this.session.name = sessionName + + await this.host.connect() + + await this.host.rest.addConnection(this.session.name, this.host.profile.id, this.host.connectionId, this.host.subscriptionId) + + await this.host.rest.setActivity(this.session.name) + + return this.getSession() + } + + async createSession (networkId) { + this.options.networkId = networkId + + this.session.name = v4() + + await this.host.connect() + + await this.createAndPublishSession() + } + + async end (resume = false) { + if (this.host.rta) { + await this.host.rta.destroy() + } + + await this.host.rest.leaveSession(this.session.name) + .catch(() => { debug(`Failed to leave session ${this.session.name}`) }) + + debug(`Abandoned session, name: ${this.session.name} - Resume: ${resume}`) + + if (resume) { + return this.start() + } + } + + async invitePlayer (identifier) { + debug(`Inviting player, identifier: ${identifier}`) + + if (!isXuid(identifier)) { + const profile = await this.host.rest.getProfile(identifier) + .catch(() => { throw new Error(`Failed to get profile for identifier: ${identifier}`) }) + identifier = profile.id + } + + await this.host.rest.sendInvite(this.session.name, identifier) + + debug(`Invited player, xuid: ${identifier}`) + } + + async updateMemberCount (count, maxCount) { + await this.host.rest.updateMemberCount(this.session.name, count, maxCount) + } + + async getSession () { + return await this.host.rest.getSession(this.session.name) + } + + async updateSession (payload) { + await this.host.rest.updateSession(this.session.name, payload) + } + + async createAndPublishSession () { + await this.updateSession(this.createSessionBody()) + + debug(`Created session, name: ${this.session.name}`) + + await this.host.rest.setActivity(this.session.name) + + const session = await this.getSession() + + await this.updateSession({ properties: session.properties }) + + debug(`Published session, name: ${this.session.name}`) + + return session + } + + createSessionBody () { + if (!this.host.connectionId) throw new Error('No session owner') + + const joinability = JoinabilityConfig[this.options.joinability] + + return { + properties: { + system: { + joinRestriction: joinability.joinRestriction, + readRestriction: 'followed', + closed: false + }, + custom: { + hostName: String(this.options.world.hostName), + worldName: String(this.options.world.name), + version: String(this.options.world.version), + MemberCount: Number(this.options.world.memberCount), + MaxMemberCount: Number(this.options.world.maxMemberCount), + Joinability: joinability.joinability, + ownerId: this.host.profile.id, + rakNetGUID: '', + worldType: 'Survival', + protocol: Number(this.options.world.protocol), + BroadcastSetting: joinability.broadcastSetting, + OnlineCrossPlatformGame: true, + CrossPlayDisabled: false, + TitleId: 0, + TransportLayer: 2, + LanGame: true, + isEditorWorld: false, + isHardcore: false, + SupportedConnections: [ + { + ConnectionType: 3, + HostIpAddress: '', + HostPort: 0, + NetherNetId: this.options.networkId + } + ] + } + }, + members: { + me: { + constants: { + system: { + xuid: this.host.profile.id, + initialize: true + } + }, + properties: { + system: { + active: true, + connection: this.host.connectionId, + subscription: { + id: this.host.subscriptionId, + changeTypes: ['everything'] + } + } + } + } + } + } + } +} + +module.exports = { SessionDirectory, Host }