diff --git a/examples/client/nethernet.js b/examples/client/nethernet.js new file mode 100644 index 0000000..aefcd73 --- /dev/null +++ b/examples/client/nethernet.js @@ -0,0 +1,45 @@ +process.env.DEBUG = '*' + +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/server/nethernet.js b/examples/server/nethernet.js new file mode 100644 index 0000000..6b3a392 --- /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 16874af..ed62f80 100644 --- a/package.json +++ b/package.json @@ -21,17 +21,24 @@ ], "license": "MIT", "dependencies": { + "@jsprismarine/jsbinaryutils": "^5.5.3", "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-fetch": "^2.6.1", + "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" + "sdp-transform": "^2.14.2", + "uuid-1345": "^1.0.2", + "werift": "^0.20.0", + "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 e3af1d0..bddfb21 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') @@ -49,10 +50,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 = [] + 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') } @@ -85,7 +97,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/connection.js b/src/connection.js index f1e9051..31ce2fe 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 9d14134..935ce6c 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 { Signal } = 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,19 @@ function createClient (options) { return client } -function connect (client) { +/** @param {Client} client */ +async function connect (client) { + if (client.options.useSignalling) { + client.signalling = new Signal(client.connection.nethernet.networkId, client.options.authflow) + + await client.signalling.connect() + + client.connection.nethernet.credentials = client.signalling.credentials + client.connection.nethernet.signalHandler = client.signalling.write.bind(client.signalling) + + client.signalling.on('signal', signal => client.connection.nethernet.handleSignal(signal)) + } + // Actually connect client.connect() @@ -86,15 +114,29 @@ function connect (client) { clearInterval(keepalive) }) } + + client.once('close', () => { + if (client.session) client.session.end() + if (client.signalling) client.signalling.destroy() + }) } -async function ping ({ host, port }) { - const con = new RakClient({ host, port }) - - try { - return advertisement.fromServerName(await con.ping()) - } finally { - con.close() +async function ping ({ host, port, networkId }) { + console.log('Pinging', 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 88b362d..2c6d81d 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -1,9 +1,53 @@ const { Server } = require('./server') +const { Signal } = require('./websocket/signal') +const assert = require('assert') +const { getRandomUint64 } = require('./datatypes/util') +const { serverAuthenticate } = require('./client/auth') +const { SignalType } = require('./nethernet/signalling') + +/** @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.signalling = new Signal(server.options.networkId, server.options.authflow) + + server.signalling.connect() + .then(() => { + server.signalling.on('signal', (signal) => { + switch (signal.type) { + case SignalType.ConnectRequest: + server.transportServer.nethernet.handleOffer(signal, server.signalling.write, server.signalling.credentials) + break + case SignalType.CandidateAdd: + server.transportServer.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.session) server.session.end() + if (server.signalling) server.signalling.destroy() + }) + return server } diff --git a/src/datatypes/util.js b/src/datatypes/util.js index 7070ce5..44a5537 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/options.js b/src/options.js index ee85da3..e9c7772 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 0b43fd2..b1bbb1d 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,12 +14,23 @@ 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 = [] + this.disableEncryption = true + } 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 = {} @@ -119,29 +131,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 }, 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) @@ -153,7 +167,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/serverPlayer.js b/src/serverPlayer.js index 2e8ea77..b9b0737 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