From 57f1b3dcae7efdf780d5b9c5f38bea07c930261d Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 4 Oct 2024 01:08:24 +0100 Subject: [PATCH 01/32] Implement Nethernet spec --- src/nethernet.js | 81 ++++++ src/nethernet/client.js | 272 ++++++++++++++++++ src/nethernet/connection.js | 113 ++++++++ src/nethernet/discovery/ServerData.js | 45 +++ src/nethernet/discovery/crypto.js | 30 ++ .../discovery/packets/MessagePacket.js | 31 ++ src/nethernet/discovery/packets/Packet.js | 37 +++ .../discovery/packets/RequestPacket.js | 23 ++ .../discovery/packets/ResponsePacket.js | 29 ++ src/nethernet/net.js | 41 +++ src/nethernet/server.js | 192 +++++++++++++ src/nethernet/signalling.js | 27 ++ 12 files changed, 921 insertions(+) create mode 100644 src/nethernet.js create mode 100644 src/nethernet/client.js create mode 100644 src/nethernet/connection.js create mode 100644 src/nethernet/discovery/ServerData.js create mode 100644 src/nethernet/discovery/crypto.js create mode 100644 src/nethernet/discovery/packets/MessagePacket.js create mode 100644 src/nethernet/discovery/packets/Packet.js create mode 100644 src/nethernet/discovery/packets/RequestPacket.js create mode 100644 src/nethernet/discovery/packets/ResponsePacket.js create mode 100644 src/nethernet/net.js create mode 100644 src/nethernet/server.js create mode 100644 src/nethernet/signalling.js diff --git a/src/nethernet.js b/src/nethernet.js new file mode 100644 index 00000000..53cff1c2 --- /dev/null +++ b/src/nethernet.js @@ -0,0 +1,81 @@ +const { waitFor } = require('./datatypes/util') +const { Client } = require('./nethernet/client') +const { Server } = require('./nethernet/server') + +class NethernetClient { + constructor (options = {}) { + this.onConnected = () => { } + this.onCloseConnection = () => { } + this.onEncapsulated = () => { } + + this.nethernet = new Client({ ...options }) + + 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.connection.sendReliable(data) + } + + async ping (timeout = 10000) { + return waitFor((done) => { + this.nethernet.ping().then(data => { done(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) => { + 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/nethernet/client.js b/src/nethernet/client.js new file mode 100644 index 00000000..32329b7c --- /dev/null +++ b/src/nethernet/client.js @@ -0,0 +1,272 @@ +const dgram = require('dgram') +const { write } = require('sdp-transform') +const { EventEmitter } = require('events') +const { RTCIceCandidate, RTCPeerConnection } = require('werift') + +const { Connection } = require('./connection') +const { getRandomUint64 } = require('../datatypes/util') +const { SignalType, SignalStructure } = require('./signalling') + +const { getBroadcastAddress } = require('./net') +const { PACKET_TYPE } = require('./discovery/packets/packet') +const { RequestPacket } = require('./discovery/packets/RequestPacket') +const { MessagePacket } = require('./discovery/packets/MessagePacket') +const { ResponsePacket } = require('./discovery/packets/ResponsePacket') +const { decrypt, encrypt, calculateChecksum } = require('./discovery/crypto') + +const PORT = 7551 +const BROADCAST_ADDRESS = getBroadcastAddress() + +class Client extends EventEmitter { + constructor (options = {}) { + super() + + this.options = options + + this.networkId = getRandomUint64() + + this.connectionId = getRandomUint64() + + this.targetNetworkId = options.networkId + + this.socket = dgram.createSocket('udp4') + + this.socket.on('message', (buffer, rinfo) => { + this.processPacket(buffer, rinfo) + }) + + this.responses = new Map() + + this.addresses = new Map() + + this.credentials = [] + + this.signalHandler = this.sendDiscoveryMessage + + this.pingInterval = setInterval(() => { + this.sendDiscoveryRequest() + }, 2000) + } + + async handleCandidate (signal) { + await this.rtcConnection.addIceCandidate(new RTCIceCandidate({ candidate: signal.data })) + } + + async handleAnswer (signal) { + await this.rtcConnection.setRemoteDescription({ type: 'answer', sdp: signal.data }) + } + + async createOffer () { + this.rtcConnection = new RTCPeerConnection({ + iceServers: this.credentials + }) + + this.connection = new Connection(this, this.connectionId, this.rtcConnection) + + const candidates = [] + + this.rtcConnection.onicecandidate = (e) => { + if (e.candidate) { + candidates.push(e.candidate.candidate) + } + } + + this.connection.setChannels( + this.rtcConnection.createDataChannel('ReliableDataChannel'), + this.rtcConnection.createDataChannel('UnreliableDataChannel') + ) + + this.rtcConnection.onconnectionstatechange = () => { + const state = this.rtcConnection.connectionState + if (state === 'connected') this.emit('connected', this.connection) + if (state === 'disconnected') this.emit('closeConnection', this.connectionId, 'disconnected') + } + + await this.rtcConnection.createOffer() + + const ice = this.rtcConnection.iceTransports[0] + + const dtls = this.rtcConnection.dtlsTransports[0] + + if (!ice || !dtls) { + throw new Error('Failed to create transports') + } + + const iceParams = ice.iceGather.localParameters + const dtlsParams = dtls.localParameters + + if (dtlsParams.fingerprints.length === 0) { + throw new Error('local DTLS parameters has no fingerprints') + } + + const desc = write({ + version: 0, + origin: { + username: '-', + sessionId: getRandomUint64().toString(), + sessionVersion: 2, + netType: 'IN', + ipVer: 4, + address: '127.0.0.1' + }, + name: '-', + timing: { start: 0, stop: 0 }, + groups: [{ type: 'BUNDLE', mids: '0' }], + extmapAllowMixed: 'extmap-allow-mixed', + msidSemantic: { semantic: '', token: 'WMS' }, + media: [ + { + rtp: [], + fmtp: [], + type: 'application', + port: 9, + protocol: 'UDP/DTLS/SCTP', + payloads: 'webrtc-datachannel', + connection: { ip: '0.0.0.0', version: 4 }, + iceUfrag: iceParams.usernameFragment, + icePwd: iceParams.password, + iceOptions: 'trickle', + fingerprint: { type: dtlsParams.fingerprints[0].algorithm, hash: dtlsParams.fingerprints[0].value }, + setup: 'active', + mid: '0', + sctpPort: 5000, + maxMessageSize: 65536 + } + ] + }) + + await this.rtcConnection.setLocalDescription({ type: 'offer', sdp: desc }) + + this.signalHandler( + new SignalStructure(SignalType.ConnectRequest, this.connectionId, desc, this.targetNetworkId) + ) + + for (const candidate of candidates) { + this.signalHandler( + new SignalStructure(SignalType.CandidateAdd, this.connectionId, candidate, this.targetNetworkId) + ) + } + } + + processPacket (buffer, rinfo) { + if (buffer.length < 32) { + throw new Error('Packet is too short') + } + + const decryptedData = decrypt(buffer.slice(32)) + + const checksum = calculateChecksum(decryptedData) + + if (Buffer.compare(buffer.slice(0, 32), checksum) !== 0) { + throw new Error('Checksum mismatch') + } + + const packetType = decryptedData.readUInt16LE(2) + + switch (packetType) { + case PACKET_TYPE.DISCOVERY_REQUEST: + break + case PACKET_TYPE.DISCOVERY_RESPONSE: + this.handleResponse(new ResponsePacket(decryptedData).decode(), rinfo) + break + case PACKET_TYPE.DISCOVERY_MESSAGE: + this.handleMessage(new MessagePacket(decryptedData).decode()) + break + default: + throw new Error('Unknown packet type') + } + } + + handleResponse (packet, rinfo) { + this.addresses.set(packet.senderId, rinfo) + this.responses.set(packet.senderId, packet.data) + this.emit('discoveryResponse', packet) + } + + handleMessage (packet) { + if (packet.data === 'Ping') { + return + } + + const signal = SignalStructure.fromString(packet.data) + + signal.networkId = packet.senderId + + this.handleSignal(signal) + } + + handleSignal (signal) { + switch (signal.type) { + case SignalType.ConnectResponse: + this.handleAnswer(signal) + break + case SignalType.CandidateAdd: + this.handleCandidate(signal) + break + } + } + + sendDiscoveryRequest () { + const requestPacket = new RequestPacket() + + requestPacket.senderId = this.networkId + + requestPacket.encode() + + const buf = requestPacket.getBuffer() + + const packetToSend = Buffer.concat([calculateChecksum(buf), encrypt(buf)]) + + this.socket.send(packetToSend, PORT, BROADCAST_ADDRESS) + } + + sendDiscoveryMessage (signal) { + const rinfo = this.addresses.get(signal.networkId) + + const messagePacket = new MessagePacket() + + messagePacket.senderId = this.networkId + messagePacket.recipientId = BigInt(signal.networkId) + messagePacket.data = signal.toString() + messagePacket.encode() + + const buf = messagePacket.getBuffer() + + const packetToSend = Buffer.concat([calculateChecksum(buf), encrypt(buf)]) + + this.socket.send(packetToSend, rinfo.port, rinfo.address) + } + + async connect () { + this.running = true + + await this.ping() + + await this.createOffer() + } + + async ping () { + this.running = true + + return new Promise((resolve, reject) => { + this.on('discoveryResponse', (packet) => { + if (packet.senderId === this.targetNetworkId) { + resolve(packet.data) + } + }) + }) + } + + close (reason) { + if (!this.running) return + clearInterval(this.pingInterval) + this.connection?.close() + setTimeout(() => this.socket.close(), 100) + this.connection = null + this.running = false + this.emit('disconnect', reason) + this.removeAllListeners() + } +} + +module.exports = { Client } diff --git a/src/nethernet/connection.js b/src/nethernet/connection.js new file mode 100644 index 00000000..c5e8aebf --- /dev/null +++ b/src/nethernet/connection.js @@ -0,0 +1,113 @@ +const debug = require('debug')('minecraft-protocol') + +const MAX_MESSAGE_SIZE = 10_000 + +class Connection { + constructor (nethernet, address, rtcConnection) { + this.nethernet = nethernet + + this.address = address + + this.rtcConnection = rtcConnection + + this.reliable = null + + this.unreliable = null + + this.promisedSegments = 0 + + this.buf = Buffer.alloc(0) + } + + setChannels (reliable, unreliable) { + if (reliable) { + this.reliable = reliable + this.reliable.onmessage = (msg) => this.handleMessage(msg.data) + } + if (unreliable) { + this.unreliable = unreliable + } + } + + handleMessage (data) { + if (typeof data === 'string') { + data = Buffer.from(data) + } + + if (data.length < 2) { + throw new Error('Unexpected EOF') + } + + const segments = data[0] + + debug(`handleMessage segments: ${segments}`) + + data = data.subarray(1) + + if (this.promisedSegments > 0 && this.promisedSegments - 1 !== segments) { + throw new Error(`Invalid promised segments: expected ${this.promisedSegments - 1}, got ${segments}`) + } + + this.promisedSegments = segments + + this.buf = this.buf ? Buffer.concat([this.buf, data]) : data + + if (this.promisedSegments > 0) { + return + } + + this.nethernet.emit('encapsulated', this.buf, this.address) + + this.buf = null + } + + sendReliable (data) { + if (!this.reliable) { + throw new Error('Reliable data channel is not available') + } + + let n = 0 + + if (typeof data === 'string') { + data = Buffer.from(data) + } + + let segments = Math.ceil(data.length / MAX_MESSAGE_SIZE) + + for (let i = 0; i < data.length; i += MAX_MESSAGE_SIZE) { + segments-- + + let end = i + MAX_MESSAGE_SIZE + if (end > data.length) end = data.length + + const frag = data.subarray(i, end) + const message = Buffer.concat([Buffer.from([segments]), frag]) + + debug('Sending fragment', segments, 'header', message[0]) + + this.reliable.send(message) + + n += frag.length + } + + if (segments !== 0) { + throw new Error('Segments count did not reach 0 after sending all fragments') + } + + return n + } + + close () { + if (this.reliable) { + this.reliable.close() + } + if (this.unreliable) { + this.unreliable.close() + } + if (this.rtcConnection) { + this.rtcConnection.close() + } + } +} + +module.exports = { Connection } diff --git a/src/nethernet/discovery/ServerData.js b/src/nethernet/discovery/ServerData.js new file mode 100644 index 00000000..3d003a5d --- /dev/null +++ b/src/nethernet/discovery/ServerData.js @@ -0,0 +1,45 @@ +const BinaryStream = require('@jsprismarine/jsbinaryutils').default + +class ServerData extends BinaryStream { + encode () { + this.writeByte(this.version) + this.writeString(this.motd) + this.writeString(this.levelName) + this.writeIntLE(this.gamemodeId) + this.writeIntLE(this.playerCount) + this.writeIntLE(this.playersMax) + this.writeBoolean(this.isEditorWorld) + this.writeBoolean(this.hardcore) + this.writeIntLE(this.transportLayer) + } + + decode () { + this.version = this.readByte() + this.motd = this.readString() + this.levelName = this.readString() + this.gamemodeId = this.readIntLE() + this.playerCount = this.readIntLE() + this.playersMax = this.readIntLE() + this.isEditorWorld = this.readBoolean() + this.hardcore = this.readBoolean() + this.transportLayer = this.readIntLE() + } + + readString () { + return this.read(this.readByte()).toString() + } + + writeString (v) { + this.writeByte(Buffer.byteLength(v)) + this.write(Buffer.from(v, 'utf-8')) + } + + prependLength () { + const buf = Buffer.alloc(2) + buf.writeUInt16LE(this.binary.length, 0) + this.binary = [...buf, ...this.binary] + this.writeIndex += 2 + } +} + +module.exports = { ServerData } diff --git a/src/nethernet/discovery/crypto.js b/src/nethernet/discovery/crypto.js new file mode 100644 index 00000000..0954932e --- /dev/null +++ b/src/nethernet/discovery/crypto.js @@ -0,0 +1,30 @@ +const crypto = require('crypto') + +const appIdBuffer = Buffer.allocUnsafe(8) +appIdBuffer.writeBigUInt64LE(BigInt(0xdeadbeef)) + +const AES_KEY = crypto.createHash('sha256') + .update(appIdBuffer) + .digest() + +function encrypt (data) { + const cipher = crypto.createCipheriv('aes-256-ecb', AES_KEY, null) + return Buffer.concat([cipher.update(data), cipher.final()]) +} + +function decrypt (data) { + const decipher = crypto.createDecipheriv('aes-256-ecb', AES_KEY, null) + return Buffer.concat([decipher.update(data), decipher.final()]) +} + +function calculateChecksum (data) { + const hmac = crypto.createHmac('sha256', AES_KEY) + hmac.update(data) + return hmac.digest() +} + +module.exports = { + encrypt, + decrypt, + calculateChecksum +} diff --git a/src/nethernet/discovery/packets/MessagePacket.js b/src/nethernet/discovery/packets/MessagePacket.js new file mode 100644 index 00000000..fa8b8abc --- /dev/null +++ b/src/nethernet/discovery/packets/MessagePacket.js @@ -0,0 +1,31 @@ +const { PACKET_TYPE, Packet } = require('./packet') + +class MessagePacket extends Packet { + constructor (data) { + super(PACKET_TYPE.DISCOVERY_MESSAGE, data) + } + + encode () { + super.encode() + this.writeUnsignedLongLE(this.recipientId) + + this.writeUnsignedIntLE(this.data.length) + this.write(Buffer.from(this.data, 'utf-8')) + + this.prependLength() + + return this + } + + decode () { + super.decode() + this.recipientId = this.readUnsignedLongLE() + + const length = this.readUnsignedIntLE() + this.data = this.read(length).toString() + + return this + } +} + +module.exports = { MessagePacket } diff --git a/src/nethernet/discovery/packets/Packet.js b/src/nethernet/discovery/packets/Packet.js new file mode 100644 index 00000000..141d9a86 --- /dev/null +++ b/src/nethernet/discovery/packets/Packet.js @@ -0,0 +1,37 @@ +const PACKET_TYPE = { + DISCOVERY_REQUEST: 0, + DISCOVERY_RESPONSE: 1, + DISCOVERY_MESSAGE: 2 +} + +const BinaryStream = require('@jsprismarine/jsbinaryutils').default + +class Packet extends BinaryStream { + constructor (id, buffer) { + super(buffer) + + this.id = id + } + + encode () { + this.writeUnsignedShortLE(this.id) + this.writeUnsignedLongLE(this.senderId) + this.write(Buffer.alloc(8)) + } + + decode () { + this.packetLength = this.readUnsignedShortLE() + this.id = this.readUnsignedShortLE() + this.senderId = this.readUnsignedLongLE() + this.read(8) + } + + prependLength () { + const buf = Buffer.alloc(2) + buf.writeUInt16LE(this.binary.length, 0) + this.binary = [...buf, ...this.binary] + this.writeIndex += 2 + } +} + +module.exports = { PACKET_TYPE, Packet } diff --git a/src/nethernet/discovery/packets/RequestPacket.js b/src/nethernet/discovery/packets/RequestPacket.js new file mode 100644 index 00000000..47d9419c --- /dev/null +++ b/src/nethernet/discovery/packets/RequestPacket.js @@ -0,0 +1,23 @@ +const { PACKET_TYPE, Packet } = require('./packet') + +class RequestPacket extends Packet { + constructor (data) { + super(PACKET_TYPE.DISCOVERY_REQUEST, data) + } + + encode () { + super.encode() + + this.prependLength() + + return this + } + + decode () { + super.decode() + + return this + } +} + +module.exports = { RequestPacket } diff --git a/src/nethernet/discovery/packets/ResponsePacket.js b/src/nethernet/discovery/packets/ResponsePacket.js new file mode 100644 index 00000000..2d02a909 --- /dev/null +++ b/src/nethernet/discovery/packets/ResponsePacket.js @@ -0,0 +1,29 @@ +const { PACKET_TYPE, Packet } = require('./packet') + +class ResponsePacket extends Packet { + constructor (data) { + super(PACKET_TYPE.DISCOVERY_RESPONSE, data) + } + + encode () { + super.encode() + const hex = this.data.toString('hex') + + this.writeUnsignedIntLE(hex.length) + this.write(Buffer.from(hex, 'utf-8')) + + this.prependLength() + + return this + } + + decode () { + super.decode() + const length = this.readUnsignedIntLE() + this.data = Buffer.from(this.read(length).toString('utf-8'), 'hex') + + return this + } +} + +module.exports = { ResponsePacket } diff --git a/src/nethernet/net.js b/src/nethernet/net.js new file mode 100644 index 00000000..95cae7c3 --- /dev/null +++ b/src/nethernet/net.js @@ -0,0 +1,41 @@ +const os = require('os') + +function getBroadcastAddress () { + const networkInterfaces = os.networkInterfaces() + let broadcastAddress = null + + for (const interfaceName in networkInterfaces) { + const interfaces = networkInterfaces[interfaceName] + for (const iface of interfaces) { + if (iface.family === 'IPv4' && !iface.internal) { + const ipAddress = iface.address + const netmask = iface.netmask + + broadcastAddress = calculateBroadcastAddress(ipAddress, netmask) + } + } + } + + return broadcastAddress +} + +function calculateBroadcastAddress (ipAddress, netmask) { + const ipParts = ipAddress.split('.').map(Number) + const maskParts = netmask.split('.').map(Number) + + const ip = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3] + const mask = (maskParts[0] << 24) | (maskParts[1] << 16) | (maskParts[2] << 8) | maskParts[3] + + const broadcast = ip | (~mask >>> 0) + + return [ + (broadcast >>> 24) & 0xff, + (broadcast >>> 16) & 0xff, + (broadcast >>> 8) & 0xff, + broadcast & 0xff + ].join('.') +} + +module.exports = { + getBroadcastAddress +} diff --git a/src/nethernet/server.js b/src/nethernet/server.js new file mode 100644 index 00000000..b70ce851 --- /dev/null +++ b/src/nethernet/server.js @@ -0,0 +1,192 @@ +const dgram = require('dgram') +const { EventEmitter } = require('events') +const { RTCIceCandidate, RTCPeerConnection } = require('werift') + +const { Connection } = require('./connection') +const { SignalStructure, SignalType } = require('./signalling') + +const { PACKET_TYPE } = require('./discovery/packets/packet') +const { MessagePacket } = require('./discovery/packets/MessagePacket') +const { ResponsePacket } = require('./discovery/packets/ResponsePacket') +const { decrypt, encrypt, calculateChecksum } = require('./discovery/crypto') + +const debug = require('debug')('minecraft-protocol') + +class Server extends EventEmitter { + constructor (options = {}) { + super() + + this.options = options + + this.networkId = options.networkId + + this.connections = new Map() + } + + async handleCandidate (signal) { + const conn = this.connections.get(signal.connectionId) + + if (conn) { + await conn.rtcConnection.addIceCandidate(new RTCIceCandidate({ candidate: signal.data })) + } else { + debug('Received candidate for unknown connection', signal) + } + } + + async handleOffer (signal, respond, credentials = []) { + const rtcConnection = new RTCPeerConnection({ + iceServers: credentials + }) + + const connection = new Connection(this, signal.connectionId, rtcConnection) + + this.connections.set(signal.connectionId, connection) + + rtcConnection.onicecandidate = (e) => { + if (e.candidate) { + respond( + new SignalStructure(SignalType.CandidateAdd, signal.connectionId, e.candidate.candidate, signal.networkId) + ) + } + } + + rtcConnection.ondatachannel = ({ channel }) => { + if (channel.label === 'ReliableDataChannel') connection.setChannels(channel) + if (channel.label === 'UnreliableDataChannel') connection.setChannels(null, channel) + } + + rtcConnection.onconnectionstatechange = () => { + const state = rtcConnection.connectionState + if (state === 'connected') this.emit('openConnection', connection) + if (state === 'disconnected') this.emit('closeConnection', signal.connectionId, 'disconnected') + } + + await rtcConnection.setRemoteDescription({ type: 'offer', sdp: signal.data }) + + const answer = await rtcConnection.createAnswer() + + await rtcConnection.setLocalDescription(answer) + + respond( + new SignalStructure(SignalType.ConnectResponse, signal.connectionId, answer.sdp, signal.networkId) + ) + } + + processPacket (buffer, rinfo) { + if (buffer.length < 32) { + throw new Error('Packet is too short') + } + + const decryptedData = decrypt(buffer.slice(32)) + + const checksum = calculateChecksum(decryptedData) + + if (Buffer.compare(buffer.slice(0, 32), checksum) !== 0) { + throw new Error('Checksum mismatch') + } + + const packetType = decryptedData.readUInt16LE(2) + + switch (packetType) { + case PACKET_TYPE.DISCOVERY_REQUEST: + this.handleRequest(rinfo) + break + case PACKET_TYPE.DISCOVERY_RESPONSE: + break + case PACKET_TYPE.DISCOVERY_MESSAGE: + this.handleMessage(new MessagePacket(decryptedData).decode(), rinfo) + break + default: + throw new Error('Unknown packet type') + } + } + + setAdvertisement (buffer) { + this.advertisement = buffer + } + + handleRequest (rinfo) { + const data = this.advertisement + + if (!data) { + return new Error('Advertisement data not set yet') + } + + const responsePacket = new ResponsePacket() + + responsePacket.senderId = this.networkId + responsePacket.data = data + + responsePacket.encode() + + const buf = responsePacket.getBuffer() + + const packetToSend = Buffer.concat([calculateChecksum(buf), encrypt(buf)]) + + this.socket.send(packetToSend, rinfo.port, rinfo.address) + } + + handleMessage (packet, rinfo) { + if (packet.data === 'Ping') { + return + } + + const respond = (signal) => { + const messagePacket = new MessagePacket() + + messagePacket.senderId = this.networkId + messagePacket.recipientId = signal.networkId + messagePacket.data = signal.toString() + messagePacket.encode() + + const buf = messagePacket.getBuffer() + + const packetToSend = Buffer.concat([calculateChecksum(buf), encrypt(buf)]) + + this.socket.send(packetToSend, rinfo.port, rinfo.address) + } + + const signal = SignalStructure.fromString(packet.data) + + signal.networkId = packet.senderId + + switch (signal.type) { + case SignalType.ConnectRequest: + this.handleOffer(signal, respond) + break + case SignalType.CandidateAdd: + this.handleCandidate(signal) + break + } + } + + async listen () { + this.socket = dgram.createSocket('udp4') + + this.socket.on('message', (buffer, rinfo) => { + this.processPacket(buffer, rinfo) + }) + + await new Promise((resolve, reject) => { + const failFn = e => reject(e) + this.socket.once('error', failFn) + this.socket.bind(7551, () => { + this.socket.removeListener('error', failFn) + resolve(true) + }) + }) + } + + close (reason) { + for (const conn of this.connections.values()) { + conn.close() + } + + this.socket.close(() => { + this.emit('close', reason) + this.removeAllListeners() + }) + } +} + +module.exports = { Server } diff --git a/src/nethernet/signalling.js b/src/nethernet/signalling.js new file mode 100644 index 00000000..3b4e8fcb --- /dev/null +++ b/src/nethernet/signalling.js @@ -0,0 +1,27 @@ +const SignalType = { + ConnectRequest: 'CONNECTREQUEST', + ConnectResponse: 'CONNECTRESPONSE', + CandidateAdd: 'CANDIDATEADD', + ConnectError: 'CONNECTERROR' +} + +class SignalStructure { + constructor (type, connectionId, data, networkId) { + this.type = type + this.connectionId = connectionId + this.data = data + this.networkId = networkId + } + + toString () { + return `${this.type} ${this.connectionId} ${this.data}` + } + + static fromString (message) { + const [type, connectionId, ...data] = message.split(' ') + + return new this(type, BigInt(connectionId), data.join(' ')) + } +} + +module.exports = { SignalStructure, SignalType } From 3611fc87e59dd1cf35d5b611bec3ec3ee7ae8016 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 4 Oct 2024 01:08:35 +0100 Subject: [PATCH 02/32] Add WebSocket signalling channel --- src/websocket/signal.js | 177 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 src/websocket/signal.js diff --git a/src/websocket/signal.js b/src/websocket/signal.js new file mode 100644 index 00000000..6039fcad --- /dev/null +++ b/src/websocket/signal.js @@ -0,0 +1,177 @@ +const { WebSocket } = require('ws') +const { stringify } = require('json-bigint') +const { once, EventEmitter } = require('node:events') +const { SignalStructure } = require('../nethernet/signalling') + +const debug = require('debug')('minecraft-protocol') + +const MessageType = { + RequestPing: 0, + Signal: 1, + Credentials: 2 +} + +class Signal extends EventEmitter { + constructor (networkId, authflow) { + super() + + this.networkId = networkId + + this.authflow = authflow + + this.ws = null + + this.pingInterval = null + + this.credentials = null + } + + async connect () { + if (this.ws?.readyState === WebSocket.OPEN) throw new Error('Already connected signalling 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.getMinecraftServicesToken() + + 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 })) + } + }) + + 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') + + this.destroy(true) + } + } + + 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 = JSON.parse(message.Message).TurnAuthServers.map(credential => { + return { + urls: credential.Urls, + credential: credential.Password, + username: credential.Username + } + }) + + 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 = { Signal } From ff602443b0743b17e732567c32d88072057daf42 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 4 Oct 2024 01:10:13 +0100 Subject: [PATCH 03/32] Add Nethernet ping advertisement --- src/server/advertisement.js | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/server/advertisement.js b/src/server/advertisement.js index 2d0a4c27..851a2e39 100644 --- a/src/server/advertisement.js +++ b/src/server/advertisement.js @@ -1,5 +1,51 @@ const { Versions, CURRENT_VERSION } = require('../options') +const { ServerData } = require('../nethernet/discovery/ServerData') + +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() }, From fbb490e9b6adce9c420f022adcec9e45107288c7 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 4 Oct 2024 01:11:19 +0100 Subject: [PATCH 04/32] Add Session handling --- src/client/auth.js | 75 +++++++++++++-- src/xsapi/rest.js | 189 ++++++++++++++++++++++++++++++++++++++ src/xsapi/rta.js | 223 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 477 insertions(+), 10 deletions(-) create mode 100644 src/xsapi/rest.js create mode 100644 src/xsapi/rta.js diff --git a/src/client/auth.js b/src/client/auth.js index 30822a72..43a7c6a7 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/rta') 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.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.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.session = new SessionDirectory(options.authflow, {}) + + const getSessions = async () => { + const sessions = await client.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.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,14 +119,13 @@ 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 loginData = 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 }) - const chains = loginData.chain - debug('loginData', { chainLength: chains.length, hasToken: Boolean(loginData.token) }) + debug('chains', chains) // First chain is Mojang stuff, second is Xbox profile data used by mc const jwt = chains[1] @@ -86,7 +140,7 @@ async function authenticate (client, options) { xuid: xboxProfile?.extraData?.XUID || 0 } - return postAuthenticate(client, profile, loginData) + return postAuthenticate(client, profile, chains) } catch (err) { console.error(err) client.emit('error', err) @@ -103,19 +157,20 @@ function createOfflineSession (client, options) { uuid: uuidFrom(options.username), // random xuid: 0 } - return postAuthenticate(client, profile, { chain: [], token: '' }) // No extra JWTs, only send our own login data + return postAuthenticate(client, profile, []) // No extra JWTs, only send 1 client signed chain with all the data } -function postAuthenticate (client, profile, auth = {}) { +function postAuthenticate (client, profile, chains) { client.profile = profile client.username = profile.name - client.accessToken = auth.chain || [] - client.multiplayerToken = auth.token || '' + client.accessToken = chains client.emit('session', profile) } module.exports = { createOfflineSession, authenticate, - realmAuthenticate + realmAuthenticate, + worldAuthenticate, + serverAuthenticate } diff --git a/src/xsapi/rest.js b/src/xsapi/rest.js new file mode 100644 index 00000000..effeed1a --- /dev/null +++ b/src/xsapi/rest.js @@ -0,0 +1,189 @@ +const { stringify } = require('json-bigint') +const { default: fetch } = require('node-fetch') +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/rta.js b/src/xsapi/rta.js new file mode 100644 index 00000000..98ca5e38 --- /dev/null +++ b/src/xsapi/rta.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 } From a5f1a685f7db106eff9521eb814b4fa10c2bfa1c Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 4 Oct 2024 01:11:54 +0100 Subject: [PATCH 05/32] Add nethernet transport --- examples/client/nethernet.js | 45 ++++++++++++++++++++++ examples/server/nethernet.js | 20 ++++++++++ package.json | 13 +++++-- src/client.js | 27 ++++++++++++-- src/connection.js | 1 + src/createClient.js | 72 ++++++++++++++++++++++++++++-------- src/createServer.js | 46 ++++++++++++++++++++++- src/datatypes/util.js | 23 +++++++++++- src/options.js | 2 + src/server.js | 36 ++++++++++++------ src/serverPlayer.js | 2 + 11 files changed, 253 insertions(+), 34 deletions(-) create mode 100644 examples/client/nethernet.js create mode 100644 examples/server/nethernet.js diff --git a/examples/client/nethernet.js b/examples/client/nethernet.js new file mode 100644 index 00000000..aefcd73c --- /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 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 1620bcc3..d330d12e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bedrock-protocol", - "version": "3.55.0", + "version": "3.53.0", "description": "Minecraft Bedrock Edition protocol library", "main": "index.js", "types": "index.d.ts", @@ -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": "^3.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 d1ff44df..a482e76b 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 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..935ce6c3 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) }) } -} -async function ping ({ host, port }) { - const con = new RakClient({ host, port }) + client.once('close', () => { + if (client.session) client.session.end() + if (client.signalling) client.signalling.destroy() + }) +} - 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 88b362d0..2c6d81dc 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 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/options.js b/src/options.js index 709c2d41..0485f3d7 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 0b43fd2a..b1bbb1d2 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 b7b2ef29..0e445e66 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 From f7b6873ba990ded44be49cc62f018f4ba852cdb2 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 4 Oct 2024 01:37:41 +0100 Subject: [PATCH 06/32] Fix tests --- src/nethernet/client.js | 2 +- src/nethernet/discovery/packets/MessagePacket.js | 2 +- src/nethernet/discovery/packets/RequestPacket.js | 2 +- src/nethernet/discovery/packets/ResponsePacket.js | 2 +- src/nethernet/server.js | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/nethernet/client.js b/src/nethernet/client.js index 32329b7c..67aeb815 100644 --- a/src/nethernet/client.js +++ b/src/nethernet/client.js @@ -8,7 +8,7 @@ const { getRandomUint64 } = require('../datatypes/util') const { SignalType, SignalStructure } = require('./signalling') const { getBroadcastAddress } = require('./net') -const { PACKET_TYPE } = require('./discovery/packets/packet') +const { PACKET_TYPE } = require('./discovery/packets/Packet') const { RequestPacket } = require('./discovery/packets/RequestPacket') const { MessagePacket } = require('./discovery/packets/MessagePacket') const { ResponsePacket } = require('./discovery/packets/ResponsePacket') diff --git a/src/nethernet/discovery/packets/MessagePacket.js b/src/nethernet/discovery/packets/MessagePacket.js index fa8b8abc..4625da65 100644 --- a/src/nethernet/discovery/packets/MessagePacket.js +++ b/src/nethernet/discovery/packets/MessagePacket.js @@ -1,4 +1,4 @@ -const { PACKET_TYPE, Packet } = require('./packet') +const { PACKET_TYPE, Packet } = require('./Packet') class MessagePacket extends Packet { constructor (data) { diff --git a/src/nethernet/discovery/packets/RequestPacket.js b/src/nethernet/discovery/packets/RequestPacket.js index 47d9419c..3d2f5891 100644 --- a/src/nethernet/discovery/packets/RequestPacket.js +++ b/src/nethernet/discovery/packets/RequestPacket.js @@ -1,4 +1,4 @@ -const { PACKET_TYPE, Packet } = require('./packet') +const { PACKET_TYPE, Packet } = require('./Packet') class RequestPacket extends Packet { constructor (data) { diff --git a/src/nethernet/discovery/packets/ResponsePacket.js b/src/nethernet/discovery/packets/ResponsePacket.js index 2d02a909..c692582e 100644 --- a/src/nethernet/discovery/packets/ResponsePacket.js +++ b/src/nethernet/discovery/packets/ResponsePacket.js @@ -1,4 +1,4 @@ -const { PACKET_TYPE, Packet } = require('./packet') +const { PACKET_TYPE, Packet } = require('./Packet') class ResponsePacket extends Packet { constructor (data) { diff --git a/src/nethernet/server.js b/src/nethernet/server.js index b70ce851..c9d218db 100644 --- a/src/nethernet/server.js +++ b/src/nethernet/server.js @@ -5,7 +5,7 @@ const { RTCIceCandidate, RTCPeerConnection } = require('werift') const { Connection } = require('./connection') const { SignalStructure, SignalType } = require('./signalling') -const { PACKET_TYPE } = require('./discovery/packets/packet') +const { PACKET_TYPE } = require('./discovery/packets/Packet') const { MessagePacket } = require('./discovery/packets/MessagePacket') const { ResponsePacket } = require('./discovery/packets/ResponsePacket') const { decrypt, encrypt, calculateChecksum } = require('./discovery/crypto') From 91289979dd39dc43ed23b129aaa55faaf2146d44 Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 5 Oct 2024 17:23:33 +0100 Subject: [PATCH 07/32] Correctly build credentials --- src/websocket/signal.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/websocket/signal.js b/src/websocket/signal.js index 6039fcad..e56df872 100644 --- a/src/websocket/signal.js +++ b/src/websocket/signal.js @@ -139,7 +139,7 @@ class Signal extends EventEmitter { this.credentials = JSON.parse(message.Message).TurnAuthServers.map(credential => { return { - urls: credential.Urls, + urls: credential.Urls.join(','), credential: credential.Password, username: credential.Username } From d64d420c60aa17fbc4d2694cfb9433da51cc432f Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 Nov 2024 23:17:05 +0000 Subject: [PATCH 08/32] Use active broadcast address --- src/nethernet/net.js | 44 +++++++++++++++----------------------------- 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/src/nethernet/net.js b/src/nethernet/net.js index 95cae7c3..4de9ebb7 100644 --- a/src/nethernet/net.js +++ b/src/nethernet/net.js @@ -1,39 +1,25 @@ const os = require('os') -function getBroadcastAddress () { - const networkInterfaces = os.networkInterfaces() - let broadcastAddress = null +function getBroadcastAddress() { + const interfaces = os.networkInterfaces(); - for (const interfaceName in networkInterfaces) { - const interfaces = networkInterfaces[interfaceName] - for (const iface of interfaces) { + for (const interfaceName in interfaces) { + for (const iface of interfaces[interfaceName]) { + // Only consider IPv4, non-internal (non-loopback) addresses if (iface.family === 'IPv4' && !iface.internal) { - const ipAddress = iface.address - const netmask = iface.netmask - - broadcastAddress = calculateBroadcastAddress(ipAddress, netmask) + const ip = iface.address.split('.').map(Number); + const netmask = iface.netmask.split('.').map(Number); + const broadcast = ip.map((octet, i) => (octet | (~netmask[i] & 255))); + + console.log(`Active Interface: ${interfaceName}`); + console.log(`IP Address: ${iface.address}`); + console.log(`Netmask: ${iface.netmask}`); + console.log(`Broadcast Address: ${broadcast.join('.')}`); + + return broadcast.join('.'); // Return the broadcast address } } } - - return broadcastAddress -} - -function calculateBroadcastAddress (ipAddress, netmask) { - const ipParts = ipAddress.split('.').map(Number) - const maskParts = netmask.split('.').map(Number) - - const ip = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3] - const mask = (maskParts[0] << 24) | (maskParts[1] << 16) | (maskParts[2] << 8) | maskParts[3] - - const broadcast = ip | (~mask >>> 0) - - return [ - (broadcast >>> 24) & 0xff, - (broadcast >>> 16) & 0xff, - (broadcast >>> 8) & 0xff, - broadcast & 0xff - ].join('.') } module.exports = { From e622abc685512ea1da08da896106c586d7021cb6 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 Nov 2024 23:17:38 +0000 Subject: [PATCH 09/32] Fix signalling handling --- src/createServer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/createServer.js b/src/createServer.js index 2c6d81dc..7e0f6d95 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -22,10 +22,10 @@ function createServer (options) { server.signalling.on('signal', (signal) => { switch (signal.type) { case SignalType.ConnectRequest: - server.transportServer.nethernet.handleOffer(signal, server.signalling.write, server.signalling.credentials) + server.transport.nethernet.handleOffer(signal, server.signalling.write.bind(server.signalling), server.signalling.credentials) break case SignalType.CandidateAdd: - server.transportServer.nethernet.handleCandidate(signal) + server.transport.nethernet.handleCandidate(signal) break } }) From 8663b3169423fe2ce45418f51a6b5c698dcfe8c3 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 Nov 2024 23:18:28 +0000 Subject: [PATCH 10/32] Downgrade to werift v0.19.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d330d12e..081fa75f 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "raknet-native": "^1.0.3", "sdp-transform": "^2.14.2", "uuid-1345": "^1.0.2", - "werift": "^0.20.0", + "werift": "^0.19.9", "ws": "^8.18.0", "xbox-rta": "^2.1.0" }, From bee7e2fa651289b382c4dc893a2444cd23409bbf Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 Nov 2024 23:21:18 +0000 Subject: [PATCH 11/32] Lint --- src/nethernet/net.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/nethernet/net.js b/src/nethernet/net.js index 4de9ebb7..3611c264 100644 --- a/src/nethernet/net.js +++ b/src/nethernet/net.js @@ -1,22 +1,22 @@ const os = require('os') -function getBroadcastAddress() { - const interfaces = os.networkInterfaces(); +function getBroadcastAddress () { + const interfaces = os.networkInterfaces() for (const interfaceName in interfaces) { for (const iface of interfaces[interfaceName]) { // Only consider IPv4, non-internal (non-loopback) addresses if (iface.family === 'IPv4' && !iface.internal) { - const ip = iface.address.split('.').map(Number); - const netmask = iface.netmask.split('.').map(Number); - const broadcast = ip.map((octet, i) => (octet | (~netmask[i] & 255))); + const ip = iface.address.split('.').map(Number) + const netmask = iface.netmask.split('.').map(Number) + const broadcast = ip.map((octet, i) => (octet | (~netmask[i] & 255))) - console.log(`Active Interface: ${interfaceName}`); - console.log(`IP Address: ${iface.address}`); - console.log(`Netmask: ${iface.netmask}`); - console.log(`Broadcast Address: ${broadcast.join('.')}`); - - return broadcast.join('.'); // Return the broadcast address + console.log(`Active Interface: ${interfaceName}`) + console.log(`IP Address: ${iface.address}`) + console.log(`Netmask: ${iface.netmask}`) + console.log(`Broadcast Address: ${broadcast.join('.')}`) + + return broadcast.join('.') // Return the broadcast address } } } From dac29d03b2bcc319287f2783ad5fba24310ad005 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 Nov 2024 23:53:58 +0000 Subject: [PATCH 12/32] Remove unnecessary ping --- src/nethernet/client.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/nethernet/client.js b/src/nethernet/client.js index 67aeb815..e80b6e2b 100644 --- a/src/nethernet/client.js +++ b/src/nethernet/client.js @@ -240,8 +240,6 @@ class Client extends EventEmitter { async connect () { this.running = true - await this.ping() - await this.createOffer() } From beb436f66817b2b0188c9b8b6c7ac1ed1798a1de Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 6 Nov 2024 00:07:34 +0000 Subject: [PATCH 13/32] Remove debug logs --- src/nethernet/net.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/nethernet/net.js b/src/nethernet/net.js index 3611c264..8377ed8c 100644 --- a/src/nethernet/net.js +++ b/src/nethernet/net.js @@ -11,11 +11,6 @@ function getBroadcastAddress () { const netmask = iface.netmask.split('.').map(Number) const broadcast = ip.map((octet, i) => (octet | (~netmask[i] & 255))) - console.log(`Active Interface: ${interfaceName}`) - console.log(`IP Address: ${iface.address}`) - console.log(`Netmask: ${iface.netmask}`) - console.log(`Broadcast Address: ${broadcast.join('.')}`) - return broadcast.join('.') // Return the broadcast address } } From d3746db0078f0f08ee6e453bb7520393673e8052 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 8 Nov 2024 17:38:03 +0000 Subject: [PATCH 14/32] Send initial discovery request --- src/nethernet/client.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/nethernet/client.js b/src/nethernet/client.js index e80b6e2b..b9a6181e 100644 --- a/src/nethernet/client.js +++ b/src/nethernet/client.js @@ -43,6 +43,8 @@ class Client extends EventEmitter { this.signalHandler = this.sendDiscoveryMessage + this.sendDiscoveryRequest() + this.pingInterval = setInterval(() => { this.sendDiscoveryRequest() }, 2000) From a30b7ef9a6f8161e895ce655255d695b3c4a123b Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 20 Jan 2025 12:13:15 +0000 Subject: [PATCH 15/32] Rename `Signal` to `NethernetSignal` --- src/createClient.js | 4 ++-- src/createServer.js | 4 ++-- src/websocket/signal.js | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/createClient.js b/src/createClient.js index 935ce6c3..3d7e3fd7 100644 --- a/src/createClient.js +++ b/src/createClient.js @@ -6,7 +6,7 @@ const Options = require('./options') const advertisement = require('./server/advertisement') const auth = require('./client/auth') const { NethernetClient } = require('./nethernet') -const { Signal } = require('./websocket/signal') +const { NethernetSignal } = require('./websocket/signal') /** @param {{ version?: number, host: string, port?: number, connectTimeout?: number, skipPing?: boolean }} options */ function createClient (options) { @@ -57,7 +57,7 @@ function createClient (options) { /** @param {Client} client */ async function connect (client) { if (client.options.useSignalling) { - client.signalling = new Signal(client.connection.nethernet.networkId, client.options.authflow) + client.signalling = new NethernetSignal(client.connection.nethernet.networkId, client.options.authflow) await client.signalling.connect() diff --git a/src/createServer.js b/src/createServer.js index 7e0f6d95..4c343aab 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -1,5 +1,5 @@ const { Server } = require('./server') -const { Signal } = require('./websocket/signal') +const { NethernetSignal } = require('./websocket/signal') const assert = require('assert') const { getRandomUint64 } = require('./datatypes/util') @@ -15,7 +15,7 @@ function createServer (options) { function startSignalling () { if (server.options.transport === 'nethernet') { - server.signalling = new Signal(server.options.networkId, server.options.authflow) + server.signalling = new NethernetSignal(server.options.networkId, server.options.authflow) server.signalling.connect() .then(() => { diff --git a/src/websocket/signal.js b/src/websocket/signal.js index e56df872..d4918f27 100644 --- a/src/websocket/signal.js +++ b/src/websocket/signal.js @@ -11,7 +11,7 @@ const MessageType = { Credentials: 2 } -class Signal extends EventEmitter { +class NethernetSignal extends EventEmitter { constructor (networkId, authflow) { super() @@ -174,4 +174,4 @@ class Signal extends EventEmitter { } } -module.exports = { Signal } +module.exports = { NethernetSignal } From 5f4ed393e50ca74c94f3bba22e1f9b6ae2592796 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 29 Jan 2025 01:00:31 +0000 Subject: [PATCH 16/32] Compression, batching and protocol fixes --- examples/client/nethernet.js | 2 +- src/client.js | 5 +- src/nethernet.js | 12 +++-- src/nethernet/client.js | 85 ++++++++++++++++++++++--------- src/nethernet/connection.js | 4 +- src/nethernet/discovery/crypto.js | 2 +- src/nethernet/net.js | 2 +- src/nethernet/server.js | 47 +++++++++++++++-- src/nethernet/util.js | 10 ++++ src/server.js | 7 ++- src/transforms/framer.js | 2 +- 11 files changed, 131 insertions(+), 47 deletions(-) create mode 100644 src/nethernet/util.js diff --git a/examples/client/nethernet.js b/examples/client/nethernet.js index aefcd73c..fc3aea92 100644 --- a/examples/client/nethernet.js +++ b/examples/client/nethernet.js @@ -1,4 +1,4 @@ -process.env.DEBUG = '*' +process.env.DEBUG = 'minecraft-protocol' const readline = require('readline') const { createClient } = require('bedrock-protocol') diff --git a/src/client.js b/src/client.js index a482e76b..51075cee 100644 --- a/src/client.js +++ b/src/client.js @@ -27,7 +27,6 @@ class Client extends Connection { 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) @@ -57,12 +56,12 @@ class Client extends Connection { if (this.options.transport === 'nethernet') { this.connection = new NethernetClient({ networkId }) - this.batchHeader = [] + 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.batchHeader = 0xfe this.disableEncryption = false } diff --git a/src/nethernet.js b/src/nethernet.js index 53cff1c2..9a3b96b9 100644 --- a/src/nethernet.js +++ b/src/nethernet.js @@ -8,7 +8,7 @@ class NethernetClient { this.onCloseConnection = () => { } this.onEncapsulated = () => { } - this.nethernet = new Client({ ...options }) + this.nethernet = new Client(options.networkId) this.nethernet.on('connected', (client) => { this.onConnected(client) @@ -28,12 +28,13 @@ class NethernetClient { } sendReliable (data) { - this.nethernet.connection.sendReliable(data) + this.nethernet.send(data) } async ping (timeout = 10000) { + this.nethernet.ping() return waitFor((done) => { - this.nethernet.ping().then(data => { done(data) }) + this.nethernet.once('pong', (ret) => { done(ret.data) }) }, timeout, () => { throw new Error('Ping timed out') }) @@ -49,7 +50,7 @@ class NethernetServer { this.onOpenConnection = () => { } this.onCloseConnection = () => { } this.onEncapsulated = () => { } - this.onClose = () => {} + this.onClose = () => { } this.updateAdvertisement = () => { this.nethernet.setAdvertisement(server.getAdvertisement().toBuffer()) } @@ -57,6 +58,9 @@ class NethernetServer { this.nethernet = new Server({ ...options }) this.nethernet.on('openConnection', (client) => { + client.sendReliable = function (buffer) { + return this.send(buffer) + } this.onOpenConnection(client) }) diff --git a/src/nethernet/client.js b/src/nethernet/client.js index b9a6181e..418e5040 100644 --- a/src/nethernet/client.js +++ b/src/nethernet/client.js @@ -1,10 +1,9 @@ -const dgram = require('dgram') +const dgram = require('node:dgram') const { write } = require('sdp-transform') -const { EventEmitter } = require('events') +const { EventEmitter } = require('node:events') const { RTCIceCandidate, RTCPeerConnection } = require('werift') const { Connection } = require('./connection') -const { getRandomUint64 } = require('../datatypes/util') const { SignalType, SignalStructure } = require('./signalling') const { getBroadcastAddress } = require('./net') @@ -14,29 +13,33 @@ const { MessagePacket } = require('./discovery/packets/MessagePacket') const { ResponsePacket } = require('./discovery/packets/ResponsePacket') const { decrypt, encrypt, calculateChecksum } = require('./discovery/crypto') +const { getRandomUint64 } = require('./util') + +const debug = require('debug')('minecraft-protocol') + const PORT = 7551 const BROADCAST_ADDRESS = getBroadcastAddress() class Client extends EventEmitter { - constructor (options = {}) { + constructor (networkId) { super() - this.options = options + this.serverNetworkId = networkId this.networkId = getRandomUint64() + debug('C: Generated networkId:', this.networkId) this.connectionId = getRandomUint64() - - this.targetNetworkId = options.networkId + debug('C: Generated connectionId:', this.connectionId) this.socket = dgram.createSocket('udp4') this.socket.on('message', (buffer, rinfo) => { + debug('C: Received message from', rinfo.address, ':', rinfo.port) this.processPacket(buffer, rinfo) }) this.responses = new Map() - this.addresses = new Map() this.credentials = [] @@ -44,21 +47,26 @@ class Client extends EventEmitter { this.signalHandler = this.sendDiscoveryMessage this.sendDiscoveryRequest() + debug('C: Sent initial discovery request') this.pingInterval = setInterval(() => { + debug('C: Sending periodic discovery request') this.sendDiscoveryRequest() }, 2000) } async handleCandidate (signal) { + debug('C: Handling ICE candidate signal:', signal) await this.rtcConnection.addIceCandidate(new RTCIceCandidate({ candidate: signal.data })) } async handleAnswer (signal) { + debug('C: Handling answer signal:', signal) await this.rtcConnection.setRemoteDescription({ type: 'answer', sdp: signal.data }) } async createOffer () { + debug('C: Creating RTC offer') this.rtcConnection = new RTCPeerConnection({ iceServers: this.credentials }) @@ -69,6 +77,7 @@ class Client extends EventEmitter { this.rtcConnection.onicecandidate = (e) => { if (e.candidate) { + debug('C: Collected ICE candidate:', e.candidate.candidate) candidates.push(e.candidate.candidate) } } @@ -78,19 +87,21 @@ class Client extends EventEmitter { this.rtcConnection.createDataChannel('UnreliableDataChannel') ) + this.connection.reliable.onopen = () => { this.emit('connected', this.connection) } + this.rtcConnection.onconnectionstatechange = () => { const state = this.rtcConnection.connectionState - if (state === 'connected') this.emit('connected', this.connection) - if (state === 'disconnected') this.emit('closeConnection', this.connectionId, 'disconnected') + debug('C: Connection state changed:', state) + if (state === 'disconnected') this.emit('disconnect', this.connectionId, 'disconnected') } await this.rtcConnection.createOffer() const ice = this.rtcConnection.iceTransports[0] - const dtls = this.rtcConnection.dtlsTransports[0] if (!ice || !dtls) { + debug('C: Failed to create ICE or DTLS transports') throw new Error('Failed to create transports') } @@ -98,6 +109,7 @@ class Client extends EventEmitter { const dtlsParams = dtls.localParameters if (dtlsParams.fingerprints.length === 0) { + debug('C: No DTLS fingerprints available') throw new Error('local DTLS parameters has no fingerprints') } @@ -139,54 +151,67 @@ class Client extends EventEmitter { await this.rtcConnection.setLocalDescription({ type: 'offer', sdp: desc }) + debug('C: Local SDP set:', desc) + this.signalHandler( - new SignalStructure(SignalType.ConnectRequest, this.connectionId, desc, this.targetNetworkId) + new SignalStructure(SignalType.ConnectRequest, this.connectionId, desc, this.serverNetworkId) ) for (const candidate of candidates) { + debug('C: Sending ICE candidate signal:', candidate) this.signalHandler( - new SignalStructure(SignalType.CandidateAdd, this.connectionId, candidate, this.targetNetworkId) + new SignalStructure(SignalType.CandidateAdd, this.connectionId, candidate, this.serverNetworkId) ) } } processPacket (buffer, rinfo) { + debug('C: Processing packet from', rinfo.address, ':', rinfo.port) if (buffer.length < 32) { + debug('C: Received packet is too short') throw new Error('Packet is too short') } const decryptedData = decrypt(buffer.slice(32)) - const checksum = calculateChecksum(decryptedData) if (Buffer.compare(buffer.slice(0, 32), checksum) !== 0) { + debug('C: Checksum mismatch for packet from', rinfo.address) throw new Error('Checksum mismatch') } const packetType = decryptedData.readUInt16LE(2) + debug('C: Packet type:', packetType) switch (packetType) { case PACKET_TYPE.DISCOVERY_REQUEST: + debug('C: Received DISCOVERY_REQUEST packet') break case PACKET_TYPE.DISCOVERY_RESPONSE: + debug('C: Received DISCOVERY_RESPONSE packet') this.handleResponse(new ResponsePacket(decryptedData).decode(), rinfo) break case PACKET_TYPE.DISCOVERY_MESSAGE: + debug('C: Received DISCOVERY_MESSAGE packet') this.handleMessage(new MessagePacket(decryptedData).decode()) break default: + debug('C: Unknown packet type:', packetType) throw new Error('Unknown packet type') } } handleResponse (packet, rinfo) { + debug('C: Handling discovery response from', rinfo.address, 'with data:', packet) this.addresses.set(packet.senderId, rinfo) this.responses.set(packet.senderId, packet.data) - this.emit('discoveryResponse', packet) + this.emit('pong', packet) } handleMessage (packet) { + debug('C: Handling discovery message:', packet) if (packet.data === 'Ping') { + debug('C: Ignoring ping message') return } @@ -194,21 +219,26 @@ class Client extends EventEmitter { signal.networkId = packet.senderId + debug('C: Processing signal:', signal) this.handleSignal(signal) } handleSignal (signal) { + debug('C: Handling signal of type:', signal.type) switch (signal.type) { case SignalType.ConnectResponse: + debug('C: Handling ConnectResponse signal') this.handleAnswer(signal) break case SignalType.CandidateAdd: + debug('C: Handling CandidateAdd signal') this.handleCandidate(signal) break } } sendDiscoveryRequest () { + debug('C: Sending discovery request') const requestPacket = new RequestPacket() requestPacket.senderId = this.networkId @@ -223,8 +253,14 @@ class Client extends EventEmitter { } sendDiscoveryMessage (signal) { + debug('C: Sending discovery message for signal:', signal) const rinfo = this.addresses.get(signal.networkId) + if (!rinfo) { + debug('C: No address found for networkId:', signal.networkId) + return + } + const messagePacket = new MessagePacket() messagePacket.senderId = this.networkId @@ -240,31 +276,30 @@ class Client extends EventEmitter { } async connect () { + debug('C: Initiating connection') this.running = true await this.createOffer() } - async ping () { - this.running = true + send (buffer) { + this.connection.send(buffer) + } - return new Promise((resolve, reject) => { - this.on('discoveryResponse', (packet) => { - if (packet.senderId === this.targetNetworkId) { - resolve(packet.data) - } - }) - }) + ping () { + debug('C: Sending ping') + + this.sendDiscoveryRequest() } close (reason) { + debug('C: Closing client with reason:', reason) if (!this.running) return clearInterval(this.pingInterval) this.connection?.close() setTimeout(() => this.socket.close(), 100) this.connection = null this.running = false - this.emit('disconnect', reason) this.removeAllListeners() } } diff --git a/src/nethernet/connection.js b/src/nethernet/connection.js index c5e8aebf..96ef0ec2 100644 --- a/src/nethernet/connection.js +++ b/src/nethernet/connection.js @@ -61,8 +61,8 @@ class Connection { this.buf = null } - sendReliable (data) { - if (!this.reliable) { + send (data) { + if (!this.reliable || this.reliable.readyState !== 'open') { throw new Error('Reliable data channel is not available') } diff --git a/src/nethernet/discovery/crypto.js b/src/nethernet/discovery/crypto.js index 0954932e..8a4840a0 100644 --- a/src/nethernet/discovery/crypto.js +++ b/src/nethernet/discovery/crypto.js @@ -1,4 +1,4 @@ -const crypto = require('crypto') +const crypto = require('node:crypto') const appIdBuffer = Buffer.allocUnsafe(8) appIdBuffer.writeBigUInt64LE(BigInt(0xdeadbeef)) diff --git a/src/nethernet/net.js b/src/nethernet/net.js index 8377ed8c..378321b7 100644 --- a/src/nethernet/net.js +++ b/src/nethernet/net.js @@ -1,4 +1,4 @@ -const os = require('os') +const os = require('node:os') function getBroadcastAddress () { const interfaces = os.networkInterfaces() diff --git a/src/nethernet/server.js b/src/nethernet/server.js index c9d218db..93980514 100644 --- a/src/nethernet/server.js +++ b/src/nethernet/server.js @@ -1,5 +1,5 @@ -const dgram = require('dgram') -const { EventEmitter } = require('events') +const dgram = require('node:dgram') +const { EventEmitter } = require('node:events') const { RTCIceCandidate, RTCPeerConnection } = require('werift') const { Connection } = require('./connection') @@ -10,6 +10,8 @@ const { MessagePacket } = require('./discovery/packets/MessagePacket') const { ResponsePacket } = require('./discovery/packets/ResponsePacket') const { decrypt, encrypt, calculateChecksum } = require('./discovery/crypto') +const { getRandomUint64 } = require('./util') + const debug = require('debug')('minecraft-protocol') class Server extends EventEmitter { @@ -18,22 +20,26 @@ class Server extends EventEmitter { this.options = options - this.networkId = options.networkId + this.networkId = options.networkId ?? getRandomUint64() this.connections = new Map() + + debug('S: Server initialised with networkId: %s', this.networkId) } async handleCandidate (signal) { const conn = this.connections.get(signal.connectionId) if (conn) { + debug('S: Adding ICE candidate for connectionId: %s', signal.connectionId) await conn.rtcConnection.addIceCandidate(new RTCIceCandidate({ candidate: signal.data })) } else { - debug('Received candidate for unknown connection', signal) + debug('S: Received candidate for unknown connection', signal) } } async handleOffer (signal, respond, credentials = []) { + debug('S: Handling offer for connectionId: %s', signal.connectionId) const rtcConnection = new RTCPeerConnection({ iceServers: credentials }) @@ -44,6 +50,7 @@ class Server extends EventEmitter { rtcConnection.onicecandidate = (e) => { if (e.candidate) { + debug('S: ICE candidate generated for connectionId: %s', signal.connectionId) respond( new SignalStructure(SignalType.CandidateAdd, signal.connectionId, e.candidate.candidate, signal.networkId) ) @@ -51,21 +58,24 @@ class Server extends EventEmitter { } rtcConnection.ondatachannel = ({ channel }) => { + debug('S: Data channel established with label: %s', channel.label) if (channel.label === 'ReliableDataChannel') connection.setChannels(channel) if (channel.label === 'UnreliableDataChannel') connection.setChannels(null, channel) } rtcConnection.onconnectionstatechange = () => { const state = rtcConnection.connectionState + debug('S: Connection state changed for connectionId: %s, state: %s', signal.connectionId, state) if (state === 'connected') this.emit('openConnection', connection) if (state === 'disconnected') this.emit('closeConnection', signal.connectionId, 'disconnected') } await rtcConnection.setRemoteDescription({ type: 'offer', sdp: signal.data }) + debug('S: Remote description set for connectionId: %s', signal.connectionId) const answer = await rtcConnection.createAnswer() - await rtcConnection.setLocalDescription(answer) + debug('S: Local description set (answer) for connectionId: %s', signal.connectionId) respond( new SignalStructure(SignalType.ConnectResponse, signal.connectionId, answer.sdp, signal.networkId) @@ -73,7 +83,9 @@ class Server extends EventEmitter { } processPacket (buffer, rinfo) { + debug('S: Processing packet from %s:%s', rinfo.address, rinfo.port) if (buffer.length < 32) { + debug('S: Packet is too short') throw new Error('Packet is too short') } @@ -82,33 +94,42 @@ class Server extends EventEmitter { const checksum = calculateChecksum(decryptedData) if (Buffer.compare(buffer.slice(0, 32), checksum) !== 0) { + debug('S: Checksum mismatch') throw new Error('Checksum mismatch') } const packetType = decryptedData.readUInt16LE(2) + debug('S: Packet type: %s', packetType) switch (packetType) { case PACKET_TYPE.DISCOVERY_REQUEST: + debug('S: Handling discovery request') this.handleRequest(rinfo) break case PACKET_TYPE.DISCOVERY_RESPONSE: + debug('S: Discovery response received (ignored)') break case PACKET_TYPE.DISCOVERY_MESSAGE: + debug('S: Handling discovery message') this.handleMessage(new MessagePacket(decryptedData).decode(), rinfo) break default: + debug('S: Unknown packet type: %s', packetType) throw new Error('Unknown packet type') } } setAdvertisement (buffer) { + debug('S: Setting advertisement data') this.advertisement = buffer } handleRequest (rinfo) { + debug('S: Handling request from %s:%s', rinfo.address, rinfo.port) const data = this.advertisement if (!data) { + debug('S: Advertisement data not set') return new Error('Advertisement data not set yet') } @@ -124,14 +145,18 @@ class Server extends EventEmitter { const packetToSend = Buffer.concat([calculateChecksum(buf), encrypt(buf)]) this.socket.send(packetToSend, rinfo.port, rinfo.address) + debug('S: Response sent to %s:%s', rinfo.address, rinfo.port) } handleMessage (packet, rinfo) { + debug('S: Handling message from %s:%s', rinfo.address, rinfo.port) if (packet.data === 'Ping') { + debug('S: Ping message received') return } const respond = (signal) => { + debug('S: Responding with signal: %o', signal) const messagePacket = new MessagePacket() messagePacket.senderId = this.networkId @@ -144,6 +169,7 @@ class Server extends EventEmitter { const packetToSend = Buffer.concat([calculateChecksum(buf), encrypt(buf)]) this.socket.send(packetToSend, rinfo.port, rinfo.address) + debug('S: Signal response sent to %s:%s', rinfo.address, rinfo.port) } const signal = SignalStructure.fromString(packet.data) @@ -152,18 +178,22 @@ class Server extends EventEmitter { switch (signal.type) { case SignalType.ConnectRequest: + debug('S: Handling ConnectRequest signal') this.handleOffer(signal, respond) break case SignalType.CandidateAdd: + debug('S: Handling CandidateAdd signal') this.handleCandidate(signal) break } } async listen () { + debug('S: Starting server') this.socket = dgram.createSocket('udp4') this.socket.on('message', (buffer, rinfo) => { + debug('S: Message received from %s:%s', rinfo.address, rinfo.port) this.processPacket(buffer, rinfo) }) @@ -171,18 +201,25 @@ class Server extends EventEmitter { const failFn = e => reject(e) this.socket.once('error', failFn) this.socket.bind(7551, () => { + debug('S: Server is listening on port 7551') this.socket.removeListener('error', failFn) resolve(true) }) }) } + send (buffer) { + this.connection.send(buffer) + } + close (reason) { + debug('S: Closing server: %s', reason) for (const conn of this.connections.values()) { conn.close() } this.socket.close(() => { + debug('S: Server closed') this.emit('close', reason) this.removeAllListeners() }) diff --git a/src/nethernet/util.js b/src/nethernet/util.js new file mode 100644 index 00000000..f420abdc --- /dev/null +++ b/src/nethernet/util.js @@ -0,0 +1,10 @@ +const getRandomUint64 = () => { + const high = Math.floor(Math.random() * 0xFFFFFFFF) + const low = Math.floor(Math.random() * 0xFFFFFFFF) + + return (BigInt(high) << 32n) | BigInt(low) +} + +module.exports = { + getRandomUint64 +} diff --git a/src/server.js b/src/server.js index b1bbb1d2..399814f7 100644 --- a/src/server.js +++ b/src/server.js @@ -17,12 +17,12 @@ class Server extends EventEmitter { if (this.options.transport === 'nethernet') { this.transportServer = require('./nethernet').NethernetServer this.advertisement = new NethernetServerAdvertisement(this.options.motd, this.options.version) - this.batchHeader = [] + this.batchHeader = null 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.batchHeader = 0xfe this.disableEncryption = false } else { throw new Error(`Unsupported transport: ${this.options.transport} (nethernet, raknet)`) @@ -36,7 +36,6 @@ class Server extends EventEmitter { this.clients = {} this.clientCount = 0 this.conLog = debug - this.batchHeader = 0xfe this.setCompressor(this.options.compressionAlgorithm, this.options.compressionLevel, this.options.compressionThreshold) } @@ -132,7 +131,7 @@ class Server extends EventEmitter { async listen () { const { host, port, maxPlayers } = this.options // eslint-disable-next-line new-cap - this.transport = new this.transportServer({ host, port, networkId: this.options.networkId }, this) + this.transport = new this.transportServer({ host, port, networkId: this.options.networkId, maxPlayers }, this) try { await this.transport.listen() diff --git a/src/transforms/framer.js b/src/transforms/framer.js index a2d9c4f3..11581fae 100644 --- a/src/transforms/framer.js +++ b/src/transforms/framer.js @@ -41,7 +41,7 @@ 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) + const buffer = buf.slice(this.batchHeader ? 1 : 0) // Decompress let decompressed if (client.features.compressorInHeader && client.compressionReady) { From 03495676b0e9cea0b9516592bb63cb47f5a86ba0 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 29 Jan 2025 09:08:53 +0000 Subject: [PATCH 17/32] Use correct buffer --- src/transforms/framer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transforms/framer.js b/src/transforms/framer.js index 11581fae..2ec0c239 100644 --- a/src/transforms/framer.js +++ b/src/transforms/framer.js @@ -45,7 +45,7 @@ class Framer { // Decompress let decompressed if (client.features.compressorInHeader && client.compressionReady) { - decompressed = this.decompress(buffer[0], buffer.slice(1)) + decompressed = this.decompress(buf[0], buffer.slice(1)) } else { // On old versions, compressor is session-wide ; failing to decompress // a packet will assume it's not compressed From 4e5ef2ad4d35fb40bec412d609d0d15cffdf77ed Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 29 Jan 2025 09:16:08 +0000 Subject: [PATCH 18/32] Update to latest pauth API --- src/createClient.js | 2 +- src/createServer.js | 2 +- src/websocket/signal.js | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/createClient.js b/src/createClient.js index 3d7e3fd7..a2870a78 100644 --- a/src/createClient.js +++ b/src/createClient.js @@ -59,7 +59,7 @@ async function connect (client) { if (client.options.useSignalling) { client.signalling = new NethernetSignal(client.connection.nethernet.networkId, client.options.authflow) - await client.signalling.connect() + await client.signalling.connect(client.options.version) client.connection.nethernet.credentials = client.signalling.credentials client.connection.nethernet.signalHandler = client.signalling.write.bind(client.signalling) diff --git a/src/createServer.js b/src/createServer.js index 4c343aab..0dd4dc93 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -17,7 +17,7 @@ function createServer (options) { if (server.options.transport === 'nethernet') { server.signalling = new NethernetSignal(server.options.networkId, server.options.authflow) - server.signalling.connect() + server.signalling.connect(server.options.version) .then(() => { server.signalling.on('signal', (signal) => { switch (signal.type) { diff --git a/src/websocket/signal.js b/src/websocket/signal.js index d4918f27..a9bd036f 100644 --- a/src/websocket/signal.js +++ b/src/websocket/signal.js @@ -26,9 +26,9 @@ class NethernetSignal extends EventEmitter { this.credentials = null } - async connect () { + async connect (version) { if (this.ws?.readyState === WebSocket.OPEN) throw new Error('Already connected signalling server') - await this.init() + await this.init(version) await once(this, 'credentials') } @@ -69,8 +69,8 @@ class NethernetSignal extends EventEmitter { } } - async init () { - const xbl = await this.authflow.getMinecraftServicesToken() + async init (version) { + const xbl = await this.authflow.getMinecraftBedrockServicesToken({ version }) const address = `wss://signal.franchise.minecraft-services.net/ws/v1.0/signaling/${this.networkId}` From c9bc0c1dc8cb870c055f77566f4d9c9e78dd1632 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 29 Jan 2025 12:46:45 +0000 Subject: [PATCH 19/32] Use static arguments --- src/transforms/framer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/transforms/framer.js b/src/transforms/framer.js index 2ec0c239..6bcb9351 100644 --- a/src/transforms/framer.js +++ b/src/transforms/framer.js @@ -40,12 +40,12 @@ 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(this.batchHeader ? 1 : 0) + 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) { - decompressed = this.decompress(buf[0], buffer.slice(1)) + decompressed = this.decompress(buffer[0], buffer.slice(1)) } else { // On old versions, compressor is session-wide ; failing to decompress // a packet will assume it's not compressed From 552fc8bd4ac0c58533007220ff2ce7c29436fec3 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 29 Jan 2025 12:47:14 +0000 Subject: [PATCH 20/32] Linting --- src/transforms/framer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transforms/framer.js b/src/transforms/framer.js index 6bcb9351..81c683db 100644 --- a/src/transforms/framer.js +++ b/src/transforms/framer.js @@ -41,7 +41,7 @@ class Framer { static decode (client, buf) { // Read header 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) + const buffer = buf.slice(client.batchHeader ? 1 : 0) // Decompress let decompressed if (client.features.compressorInHeader && client.compressionReady) { From 4b082fcd01825f3b2415bbfc5d96079e279873d8 Mon Sep 17 00:00:00 2001 From: LucienHH Date: Fri, 11 Apr 2025 02:09:52 +0100 Subject: [PATCH 21/32] Move protocol to `node-nethernet` --- package.json | 4 +- src/createServer.js | 2 +- src/nethernet.js | 3 +- src/nethernet/client.js | 307 ------------------ src/nethernet/connection.js | 113 ------- src/nethernet/discovery/ServerData.js | 45 --- src/nethernet/discovery/crypto.js | 30 -- .../discovery/packets/MessagePacket.js | 31 -- src/nethernet/discovery/packets/Packet.js | 37 --- .../discovery/packets/RequestPacket.js | 23 -- .../discovery/packets/ResponsePacket.js | 29 -- src/nethernet/net.js | 22 -- src/nethernet/server.js | 229 ------------- src/nethernet/signalling.js | 27 -- src/nethernet/util.js | 10 - src/server/advertisement.js | 2 +- src/websocket/signal.js | 2 +- 17 files changed, 5 insertions(+), 911 deletions(-) delete mode 100644 src/nethernet/client.js delete mode 100644 src/nethernet/connection.js delete mode 100644 src/nethernet/discovery/ServerData.js delete mode 100644 src/nethernet/discovery/crypto.js delete mode 100644 src/nethernet/discovery/packets/MessagePacket.js delete mode 100644 src/nethernet/discovery/packets/Packet.js delete mode 100644 src/nethernet/discovery/packets/RequestPacket.js delete mode 100644 src/nethernet/discovery/packets/ResponsePacket.js delete mode 100644 src/nethernet/net.js delete mode 100644 src/nethernet/server.js delete mode 100644 src/nethernet/signalling.js delete mode 100644 src/nethernet/util.js diff --git a/package.json b/package.json index 081fa75f..c61bbcc5 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ ], "license": "MIT", "dependencies": { - "@jsprismarine/jsbinaryutils": "^5.5.3", "debug": "^4.3.1", "json-bigint": "^1.0.0", "jsonwebtoken": "^9.0.0", @@ -29,14 +28,13 @@ "minecraft-data": "^3.0.0", "minecraft-folder-path": "^1.2.0", "node-fetch": "^2.6.1", + "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", - "sdp-transform": "^2.14.2", "uuid-1345": "^1.0.2", - "werift": "^0.19.9", "ws": "^8.18.0", "xbox-rta": "^2.1.0" }, diff --git a/src/createServer.js b/src/createServer.js index 0dd4dc93..8e312637 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -4,7 +4,7 @@ const assert = require('assert') const { getRandomUint64 } = require('./datatypes/util') const { serverAuthenticate } = require('./client/auth') -const { SignalType } = require('./nethernet/signalling') +const { SignalType } = require('node-nethernet') /** @param {{ port?: number, version?: number, networkId?: string, transport?: string, delayedInit?: boolean }} options */ function createServer (options) { diff --git a/src/nethernet.js b/src/nethernet.js index 9a3b96b9..c3a757ab 100644 --- a/src/nethernet.js +++ b/src/nethernet.js @@ -1,6 +1,5 @@ const { waitFor } = require('./datatypes/util') -const { Client } = require('./nethernet/client') -const { Server } = require('./nethernet/server') +const { Client, Server } = require('node-nethernet') class NethernetClient { constructor (options = {}) { diff --git a/src/nethernet/client.js b/src/nethernet/client.js deleted file mode 100644 index 418e5040..00000000 --- a/src/nethernet/client.js +++ /dev/null @@ -1,307 +0,0 @@ -const dgram = require('node:dgram') -const { write } = require('sdp-transform') -const { EventEmitter } = require('node:events') -const { RTCIceCandidate, RTCPeerConnection } = require('werift') - -const { Connection } = require('./connection') -const { SignalType, SignalStructure } = require('./signalling') - -const { getBroadcastAddress } = require('./net') -const { PACKET_TYPE } = require('./discovery/packets/Packet') -const { RequestPacket } = require('./discovery/packets/RequestPacket') -const { MessagePacket } = require('./discovery/packets/MessagePacket') -const { ResponsePacket } = require('./discovery/packets/ResponsePacket') -const { decrypt, encrypt, calculateChecksum } = require('./discovery/crypto') - -const { getRandomUint64 } = require('./util') - -const debug = require('debug')('minecraft-protocol') - -const PORT = 7551 -const BROADCAST_ADDRESS = getBroadcastAddress() - -class Client extends EventEmitter { - constructor (networkId) { - super() - - this.serverNetworkId = networkId - - this.networkId = getRandomUint64() - debug('C: Generated networkId:', this.networkId) - - this.connectionId = getRandomUint64() - debug('C: Generated connectionId:', this.connectionId) - - this.socket = dgram.createSocket('udp4') - - this.socket.on('message', (buffer, rinfo) => { - debug('C: Received message from', rinfo.address, ':', rinfo.port) - this.processPacket(buffer, rinfo) - }) - - this.responses = new Map() - this.addresses = new Map() - - this.credentials = [] - - this.signalHandler = this.sendDiscoveryMessage - - this.sendDiscoveryRequest() - debug('C: Sent initial discovery request') - - this.pingInterval = setInterval(() => { - debug('C: Sending periodic discovery request') - this.sendDiscoveryRequest() - }, 2000) - } - - async handleCandidate (signal) { - debug('C: Handling ICE candidate signal:', signal) - await this.rtcConnection.addIceCandidate(new RTCIceCandidate({ candidate: signal.data })) - } - - async handleAnswer (signal) { - debug('C: Handling answer signal:', signal) - await this.rtcConnection.setRemoteDescription({ type: 'answer', sdp: signal.data }) - } - - async createOffer () { - debug('C: Creating RTC offer') - this.rtcConnection = new RTCPeerConnection({ - iceServers: this.credentials - }) - - this.connection = new Connection(this, this.connectionId, this.rtcConnection) - - const candidates = [] - - this.rtcConnection.onicecandidate = (e) => { - if (e.candidate) { - debug('C: Collected ICE candidate:', e.candidate.candidate) - candidates.push(e.candidate.candidate) - } - } - - this.connection.setChannels( - this.rtcConnection.createDataChannel('ReliableDataChannel'), - this.rtcConnection.createDataChannel('UnreliableDataChannel') - ) - - this.connection.reliable.onopen = () => { this.emit('connected', this.connection) } - - this.rtcConnection.onconnectionstatechange = () => { - const state = this.rtcConnection.connectionState - debug('C: Connection state changed:', state) - if (state === 'disconnected') this.emit('disconnect', this.connectionId, 'disconnected') - } - - await this.rtcConnection.createOffer() - - const ice = this.rtcConnection.iceTransports[0] - const dtls = this.rtcConnection.dtlsTransports[0] - - if (!ice || !dtls) { - debug('C: Failed to create ICE or DTLS transports') - throw new Error('Failed to create transports') - } - - const iceParams = ice.iceGather.localParameters - const dtlsParams = dtls.localParameters - - if (dtlsParams.fingerprints.length === 0) { - debug('C: No DTLS fingerprints available') - throw new Error('local DTLS parameters has no fingerprints') - } - - const desc = write({ - version: 0, - origin: { - username: '-', - sessionId: getRandomUint64().toString(), - sessionVersion: 2, - netType: 'IN', - ipVer: 4, - address: '127.0.0.1' - }, - name: '-', - timing: { start: 0, stop: 0 }, - groups: [{ type: 'BUNDLE', mids: '0' }], - extmapAllowMixed: 'extmap-allow-mixed', - msidSemantic: { semantic: '', token: 'WMS' }, - media: [ - { - rtp: [], - fmtp: [], - type: 'application', - port: 9, - protocol: 'UDP/DTLS/SCTP', - payloads: 'webrtc-datachannel', - connection: { ip: '0.0.0.0', version: 4 }, - iceUfrag: iceParams.usernameFragment, - icePwd: iceParams.password, - iceOptions: 'trickle', - fingerprint: { type: dtlsParams.fingerprints[0].algorithm, hash: dtlsParams.fingerprints[0].value }, - setup: 'active', - mid: '0', - sctpPort: 5000, - maxMessageSize: 65536 - } - ] - }) - - await this.rtcConnection.setLocalDescription({ type: 'offer', sdp: desc }) - - debug('C: Local SDP set:', desc) - - this.signalHandler( - new SignalStructure(SignalType.ConnectRequest, this.connectionId, desc, this.serverNetworkId) - ) - - for (const candidate of candidates) { - debug('C: Sending ICE candidate signal:', candidate) - this.signalHandler( - new SignalStructure(SignalType.CandidateAdd, this.connectionId, candidate, this.serverNetworkId) - ) - } - } - - processPacket (buffer, rinfo) { - debug('C: Processing packet from', rinfo.address, ':', rinfo.port) - if (buffer.length < 32) { - debug('C: Received packet is too short') - throw new Error('Packet is too short') - } - - const decryptedData = decrypt(buffer.slice(32)) - const checksum = calculateChecksum(decryptedData) - - if (Buffer.compare(buffer.slice(0, 32), checksum) !== 0) { - debug('C: Checksum mismatch for packet from', rinfo.address) - throw new Error('Checksum mismatch') - } - - const packetType = decryptedData.readUInt16LE(2) - debug('C: Packet type:', packetType) - - switch (packetType) { - case PACKET_TYPE.DISCOVERY_REQUEST: - debug('C: Received DISCOVERY_REQUEST packet') - break - case PACKET_TYPE.DISCOVERY_RESPONSE: - debug('C: Received DISCOVERY_RESPONSE packet') - this.handleResponse(new ResponsePacket(decryptedData).decode(), rinfo) - break - case PACKET_TYPE.DISCOVERY_MESSAGE: - debug('C: Received DISCOVERY_MESSAGE packet') - this.handleMessage(new MessagePacket(decryptedData).decode()) - break - default: - debug('C: Unknown packet type:', packetType) - throw new Error('Unknown packet type') - } - } - - handleResponse (packet, rinfo) { - debug('C: Handling discovery response from', rinfo.address, 'with data:', packet) - this.addresses.set(packet.senderId, rinfo) - this.responses.set(packet.senderId, packet.data) - this.emit('pong', packet) - } - - handleMessage (packet) { - debug('C: Handling discovery message:', packet) - if (packet.data === 'Ping') { - debug('C: Ignoring ping message') - return - } - - const signal = SignalStructure.fromString(packet.data) - - signal.networkId = packet.senderId - - debug('C: Processing signal:', signal) - this.handleSignal(signal) - } - - handleSignal (signal) { - debug('C: Handling signal of type:', signal.type) - switch (signal.type) { - case SignalType.ConnectResponse: - debug('C: Handling ConnectResponse signal') - this.handleAnswer(signal) - break - case SignalType.CandidateAdd: - debug('C: Handling CandidateAdd signal') - this.handleCandidate(signal) - break - } - } - - sendDiscoveryRequest () { - debug('C: Sending discovery request') - const requestPacket = new RequestPacket() - - requestPacket.senderId = this.networkId - - requestPacket.encode() - - const buf = requestPacket.getBuffer() - - const packetToSend = Buffer.concat([calculateChecksum(buf), encrypt(buf)]) - - this.socket.send(packetToSend, PORT, BROADCAST_ADDRESS) - } - - sendDiscoveryMessage (signal) { - debug('C: Sending discovery message for signal:', signal) - const rinfo = this.addresses.get(signal.networkId) - - if (!rinfo) { - debug('C: No address found for networkId:', signal.networkId) - return - } - - const messagePacket = new MessagePacket() - - messagePacket.senderId = this.networkId - messagePacket.recipientId = BigInt(signal.networkId) - messagePacket.data = signal.toString() - messagePacket.encode() - - const buf = messagePacket.getBuffer() - - const packetToSend = Buffer.concat([calculateChecksum(buf), encrypt(buf)]) - - this.socket.send(packetToSend, rinfo.port, rinfo.address) - } - - async connect () { - debug('C: Initiating connection') - this.running = true - - await this.createOffer() - } - - send (buffer) { - this.connection.send(buffer) - } - - ping () { - debug('C: Sending ping') - - this.sendDiscoveryRequest() - } - - close (reason) { - debug('C: Closing client with reason:', reason) - if (!this.running) return - clearInterval(this.pingInterval) - this.connection?.close() - setTimeout(() => this.socket.close(), 100) - this.connection = null - this.running = false - this.removeAllListeners() - } -} - -module.exports = { Client } diff --git a/src/nethernet/connection.js b/src/nethernet/connection.js deleted file mode 100644 index 96ef0ec2..00000000 --- a/src/nethernet/connection.js +++ /dev/null @@ -1,113 +0,0 @@ -const debug = require('debug')('minecraft-protocol') - -const MAX_MESSAGE_SIZE = 10_000 - -class Connection { - constructor (nethernet, address, rtcConnection) { - this.nethernet = nethernet - - this.address = address - - this.rtcConnection = rtcConnection - - this.reliable = null - - this.unreliable = null - - this.promisedSegments = 0 - - this.buf = Buffer.alloc(0) - } - - setChannels (reliable, unreliable) { - if (reliable) { - this.reliable = reliable - this.reliable.onmessage = (msg) => this.handleMessage(msg.data) - } - if (unreliable) { - this.unreliable = unreliable - } - } - - handleMessage (data) { - if (typeof data === 'string') { - data = Buffer.from(data) - } - - if (data.length < 2) { - throw new Error('Unexpected EOF') - } - - const segments = data[0] - - debug(`handleMessage segments: ${segments}`) - - data = data.subarray(1) - - if (this.promisedSegments > 0 && this.promisedSegments - 1 !== segments) { - throw new Error(`Invalid promised segments: expected ${this.promisedSegments - 1}, got ${segments}`) - } - - this.promisedSegments = segments - - this.buf = this.buf ? Buffer.concat([this.buf, data]) : data - - if (this.promisedSegments > 0) { - return - } - - this.nethernet.emit('encapsulated', this.buf, this.address) - - this.buf = null - } - - send (data) { - if (!this.reliable || this.reliable.readyState !== 'open') { - throw new Error('Reliable data channel is not available') - } - - let n = 0 - - if (typeof data === 'string') { - data = Buffer.from(data) - } - - let segments = Math.ceil(data.length / MAX_MESSAGE_SIZE) - - for (let i = 0; i < data.length; i += MAX_MESSAGE_SIZE) { - segments-- - - let end = i + MAX_MESSAGE_SIZE - if (end > data.length) end = data.length - - const frag = data.subarray(i, end) - const message = Buffer.concat([Buffer.from([segments]), frag]) - - debug('Sending fragment', segments, 'header', message[0]) - - this.reliable.send(message) - - n += frag.length - } - - if (segments !== 0) { - throw new Error('Segments count did not reach 0 after sending all fragments') - } - - return n - } - - close () { - if (this.reliable) { - this.reliable.close() - } - if (this.unreliable) { - this.unreliable.close() - } - if (this.rtcConnection) { - this.rtcConnection.close() - } - } -} - -module.exports = { Connection } diff --git a/src/nethernet/discovery/ServerData.js b/src/nethernet/discovery/ServerData.js deleted file mode 100644 index 3d003a5d..00000000 --- a/src/nethernet/discovery/ServerData.js +++ /dev/null @@ -1,45 +0,0 @@ -const BinaryStream = require('@jsprismarine/jsbinaryutils').default - -class ServerData extends BinaryStream { - encode () { - this.writeByte(this.version) - this.writeString(this.motd) - this.writeString(this.levelName) - this.writeIntLE(this.gamemodeId) - this.writeIntLE(this.playerCount) - this.writeIntLE(this.playersMax) - this.writeBoolean(this.isEditorWorld) - this.writeBoolean(this.hardcore) - this.writeIntLE(this.transportLayer) - } - - decode () { - this.version = this.readByte() - this.motd = this.readString() - this.levelName = this.readString() - this.gamemodeId = this.readIntLE() - this.playerCount = this.readIntLE() - this.playersMax = this.readIntLE() - this.isEditorWorld = this.readBoolean() - this.hardcore = this.readBoolean() - this.transportLayer = this.readIntLE() - } - - readString () { - return this.read(this.readByte()).toString() - } - - writeString (v) { - this.writeByte(Buffer.byteLength(v)) - this.write(Buffer.from(v, 'utf-8')) - } - - prependLength () { - const buf = Buffer.alloc(2) - buf.writeUInt16LE(this.binary.length, 0) - this.binary = [...buf, ...this.binary] - this.writeIndex += 2 - } -} - -module.exports = { ServerData } diff --git a/src/nethernet/discovery/crypto.js b/src/nethernet/discovery/crypto.js deleted file mode 100644 index 8a4840a0..00000000 --- a/src/nethernet/discovery/crypto.js +++ /dev/null @@ -1,30 +0,0 @@ -const crypto = require('node:crypto') - -const appIdBuffer = Buffer.allocUnsafe(8) -appIdBuffer.writeBigUInt64LE(BigInt(0xdeadbeef)) - -const AES_KEY = crypto.createHash('sha256') - .update(appIdBuffer) - .digest() - -function encrypt (data) { - const cipher = crypto.createCipheriv('aes-256-ecb', AES_KEY, null) - return Buffer.concat([cipher.update(data), cipher.final()]) -} - -function decrypt (data) { - const decipher = crypto.createDecipheriv('aes-256-ecb', AES_KEY, null) - return Buffer.concat([decipher.update(data), decipher.final()]) -} - -function calculateChecksum (data) { - const hmac = crypto.createHmac('sha256', AES_KEY) - hmac.update(data) - return hmac.digest() -} - -module.exports = { - encrypt, - decrypt, - calculateChecksum -} diff --git a/src/nethernet/discovery/packets/MessagePacket.js b/src/nethernet/discovery/packets/MessagePacket.js deleted file mode 100644 index 4625da65..00000000 --- a/src/nethernet/discovery/packets/MessagePacket.js +++ /dev/null @@ -1,31 +0,0 @@ -const { PACKET_TYPE, Packet } = require('./Packet') - -class MessagePacket extends Packet { - constructor (data) { - super(PACKET_TYPE.DISCOVERY_MESSAGE, data) - } - - encode () { - super.encode() - this.writeUnsignedLongLE(this.recipientId) - - this.writeUnsignedIntLE(this.data.length) - this.write(Buffer.from(this.data, 'utf-8')) - - this.prependLength() - - return this - } - - decode () { - super.decode() - this.recipientId = this.readUnsignedLongLE() - - const length = this.readUnsignedIntLE() - this.data = this.read(length).toString() - - return this - } -} - -module.exports = { MessagePacket } diff --git a/src/nethernet/discovery/packets/Packet.js b/src/nethernet/discovery/packets/Packet.js deleted file mode 100644 index 141d9a86..00000000 --- a/src/nethernet/discovery/packets/Packet.js +++ /dev/null @@ -1,37 +0,0 @@ -const PACKET_TYPE = { - DISCOVERY_REQUEST: 0, - DISCOVERY_RESPONSE: 1, - DISCOVERY_MESSAGE: 2 -} - -const BinaryStream = require('@jsprismarine/jsbinaryutils').default - -class Packet extends BinaryStream { - constructor (id, buffer) { - super(buffer) - - this.id = id - } - - encode () { - this.writeUnsignedShortLE(this.id) - this.writeUnsignedLongLE(this.senderId) - this.write(Buffer.alloc(8)) - } - - decode () { - this.packetLength = this.readUnsignedShortLE() - this.id = this.readUnsignedShortLE() - this.senderId = this.readUnsignedLongLE() - this.read(8) - } - - prependLength () { - const buf = Buffer.alloc(2) - buf.writeUInt16LE(this.binary.length, 0) - this.binary = [...buf, ...this.binary] - this.writeIndex += 2 - } -} - -module.exports = { PACKET_TYPE, Packet } diff --git a/src/nethernet/discovery/packets/RequestPacket.js b/src/nethernet/discovery/packets/RequestPacket.js deleted file mode 100644 index 3d2f5891..00000000 --- a/src/nethernet/discovery/packets/RequestPacket.js +++ /dev/null @@ -1,23 +0,0 @@ -const { PACKET_TYPE, Packet } = require('./Packet') - -class RequestPacket extends Packet { - constructor (data) { - super(PACKET_TYPE.DISCOVERY_REQUEST, data) - } - - encode () { - super.encode() - - this.prependLength() - - return this - } - - decode () { - super.decode() - - return this - } -} - -module.exports = { RequestPacket } diff --git a/src/nethernet/discovery/packets/ResponsePacket.js b/src/nethernet/discovery/packets/ResponsePacket.js deleted file mode 100644 index c692582e..00000000 --- a/src/nethernet/discovery/packets/ResponsePacket.js +++ /dev/null @@ -1,29 +0,0 @@ -const { PACKET_TYPE, Packet } = require('./Packet') - -class ResponsePacket extends Packet { - constructor (data) { - super(PACKET_TYPE.DISCOVERY_RESPONSE, data) - } - - encode () { - super.encode() - const hex = this.data.toString('hex') - - this.writeUnsignedIntLE(hex.length) - this.write(Buffer.from(hex, 'utf-8')) - - this.prependLength() - - return this - } - - decode () { - super.decode() - const length = this.readUnsignedIntLE() - this.data = Buffer.from(this.read(length).toString('utf-8'), 'hex') - - return this - } -} - -module.exports = { ResponsePacket } diff --git a/src/nethernet/net.js b/src/nethernet/net.js deleted file mode 100644 index 378321b7..00000000 --- a/src/nethernet/net.js +++ /dev/null @@ -1,22 +0,0 @@ -const os = require('node:os') - -function getBroadcastAddress () { - const interfaces = os.networkInterfaces() - - for (const interfaceName in interfaces) { - for (const iface of interfaces[interfaceName]) { - // Only consider IPv4, non-internal (non-loopback) addresses - if (iface.family === 'IPv4' && !iface.internal) { - const ip = iface.address.split('.').map(Number) - const netmask = iface.netmask.split('.').map(Number) - const broadcast = ip.map((octet, i) => (octet | (~netmask[i] & 255))) - - return broadcast.join('.') // Return the broadcast address - } - } - } -} - -module.exports = { - getBroadcastAddress -} diff --git a/src/nethernet/server.js b/src/nethernet/server.js deleted file mode 100644 index 93980514..00000000 --- a/src/nethernet/server.js +++ /dev/null @@ -1,229 +0,0 @@ -const dgram = require('node:dgram') -const { EventEmitter } = require('node:events') -const { RTCIceCandidate, RTCPeerConnection } = require('werift') - -const { Connection } = require('./connection') -const { SignalStructure, SignalType } = require('./signalling') - -const { PACKET_TYPE } = require('./discovery/packets/Packet') -const { MessagePacket } = require('./discovery/packets/MessagePacket') -const { ResponsePacket } = require('./discovery/packets/ResponsePacket') -const { decrypt, encrypt, calculateChecksum } = require('./discovery/crypto') - -const { getRandomUint64 } = require('./util') - -const debug = require('debug')('minecraft-protocol') - -class Server extends EventEmitter { - constructor (options = {}) { - super() - - this.options = options - - this.networkId = options.networkId ?? getRandomUint64() - - this.connections = new Map() - - debug('S: Server initialised with networkId: %s', this.networkId) - } - - async handleCandidate (signal) { - const conn = this.connections.get(signal.connectionId) - - if (conn) { - debug('S: Adding ICE candidate for connectionId: %s', signal.connectionId) - await conn.rtcConnection.addIceCandidate(new RTCIceCandidate({ candidate: signal.data })) - } else { - debug('S: Received candidate for unknown connection', signal) - } - } - - async handleOffer (signal, respond, credentials = []) { - debug('S: Handling offer for connectionId: %s', signal.connectionId) - const rtcConnection = new RTCPeerConnection({ - iceServers: credentials - }) - - const connection = new Connection(this, signal.connectionId, rtcConnection) - - this.connections.set(signal.connectionId, connection) - - rtcConnection.onicecandidate = (e) => { - if (e.candidate) { - debug('S: ICE candidate generated for connectionId: %s', signal.connectionId) - respond( - new SignalStructure(SignalType.CandidateAdd, signal.connectionId, e.candidate.candidate, signal.networkId) - ) - } - } - - rtcConnection.ondatachannel = ({ channel }) => { - debug('S: Data channel established with label: %s', channel.label) - if (channel.label === 'ReliableDataChannel') connection.setChannels(channel) - if (channel.label === 'UnreliableDataChannel') connection.setChannels(null, channel) - } - - rtcConnection.onconnectionstatechange = () => { - const state = rtcConnection.connectionState - debug('S: Connection state changed for connectionId: %s, state: %s', signal.connectionId, state) - if (state === 'connected') this.emit('openConnection', connection) - if (state === 'disconnected') this.emit('closeConnection', signal.connectionId, 'disconnected') - } - - await rtcConnection.setRemoteDescription({ type: 'offer', sdp: signal.data }) - debug('S: Remote description set for connectionId: %s', signal.connectionId) - - const answer = await rtcConnection.createAnswer() - await rtcConnection.setLocalDescription(answer) - debug('S: Local description set (answer) for connectionId: %s', signal.connectionId) - - respond( - new SignalStructure(SignalType.ConnectResponse, signal.connectionId, answer.sdp, signal.networkId) - ) - } - - processPacket (buffer, rinfo) { - debug('S: Processing packet from %s:%s', rinfo.address, rinfo.port) - if (buffer.length < 32) { - debug('S: Packet is too short') - throw new Error('Packet is too short') - } - - const decryptedData = decrypt(buffer.slice(32)) - - const checksum = calculateChecksum(decryptedData) - - if (Buffer.compare(buffer.slice(0, 32), checksum) !== 0) { - debug('S: Checksum mismatch') - throw new Error('Checksum mismatch') - } - - const packetType = decryptedData.readUInt16LE(2) - - debug('S: Packet type: %s', packetType) - switch (packetType) { - case PACKET_TYPE.DISCOVERY_REQUEST: - debug('S: Handling discovery request') - this.handleRequest(rinfo) - break - case PACKET_TYPE.DISCOVERY_RESPONSE: - debug('S: Discovery response received (ignored)') - break - case PACKET_TYPE.DISCOVERY_MESSAGE: - debug('S: Handling discovery message') - this.handleMessage(new MessagePacket(decryptedData).decode(), rinfo) - break - default: - debug('S: Unknown packet type: %s', packetType) - throw new Error('Unknown packet type') - } - } - - setAdvertisement (buffer) { - debug('S: Setting advertisement data') - this.advertisement = buffer - } - - handleRequest (rinfo) { - debug('S: Handling request from %s:%s', rinfo.address, rinfo.port) - const data = this.advertisement - - if (!data) { - debug('S: Advertisement data not set') - return new Error('Advertisement data not set yet') - } - - const responsePacket = new ResponsePacket() - - responsePacket.senderId = this.networkId - responsePacket.data = data - - responsePacket.encode() - - const buf = responsePacket.getBuffer() - - const packetToSend = Buffer.concat([calculateChecksum(buf), encrypt(buf)]) - - this.socket.send(packetToSend, rinfo.port, rinfo.address) - debug('S: Response sent to %s:%s', rinfo.address, rinfo.port) - } - - handleMessage (packet, rinfo) { - debug('S: Handling message from %s:%s', rinfo.address, rinfo.port) - if (packet.data === 'Ping') { - debug('S: Ping message received') - return - } - - const respond = (signal) => { - debug('S: Responding with signal: %o', signal) - const messagePacket = new MessagePacket() - - messagePacket.senderId = this.networkId - messagePacket.recipientId = signal.networkId - messagePacket.data = signal.toString() - messagePacket.encode() - - const buf = messagePacket.getBuffer() - - const packetToSend = Buffer.concat([calculateChecksum(buf), encrypt(buf)]) - - this.socket.send(packetToSend, rinfo.port, rinfo.address) - debug('S: Signal response sent to %s:%s', rinfo.address, rinfo.port) - } - - const signal = SignalStructure.fromString(packet.data) - - signal.networkId = packet.senderId - - switch (signal.type) { - case SignalType.ConnectRequest: - debug('S: Handling ConnectRequest signal') - this.handleOffer(signal, respond) - break - case SignalType.CandidateAdd: - debug('S: Handling CandidateAdd signal') - this.handleCandidate(signal) - break - } - } - - async listen () { - debug('S: Starting server') - this.socket = dgram.createSocket('udp4') - - this.socket.on('message', (buffer, rinfo) => { - debug('S: Message received from %s:%s', rinfo.address, rinfo.port) - this.processPacket(buffer, rinfo) - }) - - await new Promise((resolve, reject) => { - const failFn = e => reject(e) - this.socket.once('error', failFn) - this.socket.bind(7551, () => { - debug('S: Server is listening on port 7551') - this.socket.removeListener('error', failFn) - resolve(true) - }) - }) - } - - send (buffer) { - this.connection.send(buffer) - } - - close (reason) { - debug('S: Closing server: %s', reason) - for (const conn of this.connections.values()) { - conn.close() - } - - this.socket.close(() => { - debug('S: Server closed') - this.emit('close', reason) - this.removeAllListeners() - }) - } -} - -module.exports = { Server } diff --git a/src/nethernet/signalling.js b/src/nethernet/signalling.js deleted file mode 100644 index 3b4e8fcb..00000000 --- a/src/nethernet/signalling.js +++ /dev/null @@ -1,27 +0,0 @@ -const SignalType = { - ConnectRequest: 'CONNECTREQUEST', - ConnectResponse: 'CONNECTRESPONSE', - CandidateAdd: 'CANDIDATEADD', - ConnectError: 'CONNECTERROR' -} - -class SignalStructure { - constructor (type, connectionId, data, networkId) { - this.type = type - this.connectionId = connectionId - this.data = data - this.networkId = networkId - } - - toString () { - return `${this.type} ${this.connectionId} ${this.data}` - } - - static fromString (message) { - const [type, connectionId, ...data] = message.split(' ') - - return new this(type, BigInt(connectionId), data.join(' ')) - } -} - -module.exports = { SignalStructure, SignalType } diff --git a/src/nethernet/util.js b/src/nethernet/util.js deleted file mode 100644 index f420abdc..00000000 --- a/src/nethernet/util.js +++ /dev/null @@ -1,10 +0,0 @@ -const getRandomUint64 = () => { - const high = Math.floor(Math.random() * 0xFFFFFFFF) - const low = Math.floor(Math.random() * 0xFFFFFFFF) - - return (BigInt(high) << 32n) | BigInt(low) -} - -module.exports = { - getRandomUint64 -} diff --git a/src/server/advertisement.js b/src/server/advertisement.js index 851a2e39..f7e50c84 100644 --- a/src/server/advertisement.js +++ b/src/server/advertisement.js @@ -1,6 +1,6 @@ const { Versions, CURRENT_VERSION } = require('../options') -const { ServerData } = require('../nethernet/discovery/ServerData') +const { ServerData } = require('node-nethernet') class NethernetServerAdvertisement { version = 3 diff --git a/src/websocket/signal.js b/src/websocket/signal.js index a9bd036f..84b2d646 100644 --- a/src/websocket/signal.js +++ b/src/websocket/signal.js @@ -1,7 +1,7 @@ const { WebSocket } = require('ws') const { stringify } = require('json-bigint') const { once, EventEmitter } = require('node:events') -const { SignalStructure } = require('../nethernet/signalling') +const { SignalStructure } = require('node-nethernet') const debug = require('debug')('minecraft-protocol') From d24817f240690d7334691ae104e6b97ef05b279a Mon Sep 17 00:00:00 2001 From: LucienHH Date: Fri, 11 Apr 2025 14:00:08 +0100 Subject: [PATCH 22/32] Fix connecting via signalling --- src/createClient.js | 7 +++-- src/websocket/signal.js | 64 ++++++++++++++++++++++++++++++----------- 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/src/createClient.js b/src/createClient.js index a2870a78..8d82a5ac 100644 --- a/src/createClient.js +++ b/src/createClient.js @@ -57,14 +57,16 @@ function createClient (options) { /** @param {Client} client */ async function connect (client) { if (client.options.useSignalling) { - client.signalling = new NethernetSignal(client.connection.nethernet.networkId, client.options.authflow) + client.signalling = new NethernetSignal(client.connection.nethernet.networkId, client.options.authflow, client.options.version) - await client.signalling.connect(client.options.version) + 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)) + } else { + await client.connection.nethernet.ping() } // Actually connect @@ -122,7 +124,6 @@ async function connect (client) { } async function ping ({ host, port, networkId }) { - console.log('Pinging', host, port, networkId) if (networkId) { const con = new NethernetClient({ networkId }) try { diff --git a/src/websocket/signal.js b/src/websocket/signal.js index 84b2d646..4e7c0434 100644 --- a/src/websocket/signal.js +++ b/src/websocket/signal.js @@ -12,23 +12,27 @@ const MessageType = { } class NethernetSignal extends EventEmitter { - constructor (networkId, authflow) { + constructor (networkId, authflow, version) { super() this.networkId = networkId this.authflow = authflow + this.version = version + this.ws = null + this.credentials = null + this.pingInterval = null - this.credentials = null + this.retryCount = 0 } - async connect (version) { - if (this.ws?.readyState === WebSocket.OPEN) throw new Error('Already connected signalling server') - await this.init(version) + async connect () { + if (this.ws?.readyState === WebSocket.OPEN) throw new Error('Already connected signaling server') + await this.init() await once(this, 'credentials') } @@ -69,8 +73,10 @@ class NethernetSignal extends EventEmitter { } } - async init (version) { - const xbl = await this.authflow.getMinecraftBedrockServicesToken({ version }) + 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}` @@ -84,7 +90,7 @@ class NethernetSignal extends EventEmitter { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ Type: MessageType.RequestPing })) } - }) + }, 5000) ws.onopen = () => { this.onOpen() @@ -119,7 +125,13 @@ class NethernetSignal extends EventEmitter { if (code === 1006) { debug('Signal Connection Closed Unexpectedly') - this.destroy(true) + if (this.retryCount < 5) { + this.retryCount++ + this.destroy(true) + } else { + this.destroy() + throw new Error('Signal Connection Closed Unexpectedly') + } } } @@ -137,13 +149,7 @@ class NethernetSignal extends EventEmitter { return } - this.credentials = JSON.parse(message.Message).TurnAuthServers.map(credential => { - return { - urls: credential.Urls.join(','), - credential: credential.Password, - username: credential.Username - } - }) + this.credentials = parseTurnServers(message.Message) this.emit('credentials', this.credentials) @@ -175,3 +181,29 @@ class NethernetSignal extends EventEmitter { } 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 +} From 7a7390204cf47aead9537f9f2a56619270151746 Mon Sep 17 00:00:00 2001 From: LucienHH Date: Sat, 12 Apr 2025 13:01:00 +0100 Subject: [PATCH 23/32] Move nethernet properties under .nethernet.* --- src/client.js | 4 ++++ src/client/auth.js | 10 +++++----- src/createClient.js | 22 ++++++++++++---------- src/createServer.js | 12 ++++++------ src/server.js | 1 + 5 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/client.js b/src/client.js index 51075cee..ca7472b4 100644 --- a/src/client.js +++ b/src/client.js @@ -21,6 +21,10 @@ 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 diff --git a/src/client/auth.js b/src/client/auth.js index 43a7c6a7..52958aff 100644 --- a/src/client/auth.js +++ b/src/client/auth.js @@ -22,7 +22,7 @@ async function serverAuthenticate (server, options) { options.authflow ??= new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode) - server.session = new SessionDirectory(options.authflow, { + server.nethernet.session = new SessionDirectory(options.authflow, { world: { hostName: server.advertisement.motd, name: server.advertisement.levelName, @@ -33,7 +33,7 @@ async function serverAuthenticate (server, options) { } }) - await server.session.createSession(options.networkId) + await server.nethernet.session.createSession(options.networkId) } async function worldAuthenticate (client, options) { @@ -43,10 +43,10 @@ async function worldAuthenticate (client, options) { const xbl = await options.authflow.getXboxToken() - client.session = new SessionDirectory(options.authflow, {}) + client.nethernet.session = new SessionDirectory(options.authflow, {}) const getSessions = async () => { - const sessions = await client.session.host.rest.getSessions(xbl.userXUID) + 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 @@ -62,7 +62,7 @@ async function worldAuthenticate (client, options) { if (!world) throw Error('Couldn\'t find a session to connect to.') - const session = await client.session.joinSession(world.sessionRef.name) + const session = await client.nethernet.session.joinSession(world.sessionRef.name) const networkId = session.properties.custom.SupportedConnections.find(e => e.ConnectionType === 3).NetherNetId diff --git a/src/createClient.js b/src/createClient.js index 8d82a5ac..0c617636 100644 --- a/src/createClient.js +++ b/src/createClient.js @@ -56,17 +56,19 @@ function createClient (options) { /** @param {Client} client */ async function connect (client) { - if (client.options.useSignalling) { - client.signalling = new NethernetSignal(client.connection.nethernet.networkId, client.options.authflow, client.options.version) + 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.signalling.connect() + await client.nethernet.signalling.connect() - client.connection.nethernet.credentials = client.signalling.credentials - client.connection.nethernet.signalHandler = client.signalling.write.bind(client.signalling) + client.connection.nethernet.credentials = client.nethernet.signalling.credentials + client.connection.nethernet.signalHandler = client.nethernet.signalling.write.bind(client.nethernet.signalling) - client.signalling.on('signal', signal => client.connection.nethernet.handleSignal(signal)) - } else { - await client.connection.nethernet.ping() + client.nethernet.signalling.on('signal', signal => client.connection.nethernet.handleSignal(signal)) + } else { + await client.connection.nethernet.ping() + } } // Actually connect @@ -118,8 +120,8 @@ async function connect (client) { } client.once('close', () => { - if (client.session) client.session.end() - if (client.signalling) client.signalling.destroy() + if (client.nethernet.session) client.nethernet.session.end() + if (client.nethernet.signalling) client.nethernet.signalling.destroy() }) } diff --git a/src/createServer.js b/src/createServer.js index 8e312637..3811c164 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -15,14 +15,14 @@ function createServer (options) { function startSignalling () { if (server.options.transport === 'nethernet') { - server.signalling = new NethernetSignal(server.options.networkId, server.options.authflow) + server.nethernet.signalling = new NethernetSignal(server.options.networkId, server.options.authflow) - server.signalling.connect(server.options.version) + server.nethernet.signalling.connect(server.options.version) .then(() => { - server.signalling.on('signal', (signal) => { + server.nethernet.signalling.on('signal', (signal) => { switch (signal.type) { case SignalType.ConnectRequest: - server.transport.nethernet.handleOffer(signal, server.signalling.write.bind(server.signalling), server.signalling.credentials) + 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) @@ -44,8 +44,8 @@ function createServer (options) { } server.once('close', () => { - if (server.session) server.session.end() - if (server.signalling) server.signalling.destroy() + if (server.nethernet.session) server.nethernet.session.end() + if (server.nethernet.signalling) server.nethernet.signalling.destroy() }) return server diff --git a/src/server.js b/src/server.js index 399814f7..efee0041 100644 --- a/src/server.js +++ b/src/server.js @@ -19,6 +19,7 @@ class Server extends EventEmitter { 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) From 6c048a75e9127108ea1187393ef0ad8dda77948a Mon Sep 17 00:00:00 2001 From: LucienHH Date: Sat, 12 Apr 2025 13:01:03 +0100 Subject: [PATCH 24/32] Create nethernet_local.js --- examples/client/nethernet_local.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 examples/client/nethernet_local.js 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++ + }) +}) From 60c7ff993acd02e9e0376427ec14ddab46d94a7d Mon Sep 17 00:00:00 2001 From: LucienHH Date: Sat, 12 Apr 2025 13:02:08 +0100 Subject: [PATCH 25/32] Remove node-fetch --- package.json | 1 - src/xsapi/rest.js | 1 - 2 files changed, 2 deletions(-) diff --git a/package.json b/package.json index c61bbcc5..4d846ff5 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "jsp-raknet": "^2.1.3", "minecraft-data": "^3.0.0", "minecraft-folder-path": "^1.2.0", - "node-fetch": "^2.6.1", "node-nethernet": "github:LucienHH/node-nethernet#protocol", "prismarine-auth": "github:LucienHH/prismarine-auth#playfab", "prismarine-nbt": "^2.0.0", diff --git a/src/xsapi/rest.js b/src/xsapi/rest.js index effeed1a..8dec8277 100644 --- a/src/xsapi/rest.js +++ b/src/xsapi/rest.js @@ -1,5 +1,4 @@ const { stringify } = require('json-bigint') -const { default: fetch } = require('node-fetch') const { checkStatus } = require('../datatypes/util') const SessionConfig = { From 6bf0c1f4402a110f172852f0c34bb354ede0bed6 Mon Sep 17 00:00:00 2001 From: LucienHH Date: Sat, 12 Apr 2025 13:20:09 +0100 Subject: [PATCH 26/32] Rename rta to session --- src/client/auth.js | 2 +- src/xsapi/{rta.js => session.js} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/xsapi/{rta.js => session.js} (100%) diff --git a/src/client/auth.js b/src/client/auth.js index 52958aff..e57eaf74 100644 --- a/src/client/auth.js +++ b/src/client/auth.js @@ -4,7 +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/rta') +const { SessionDirectory } = require('../xsapi/session') function validateOptions (options) { if (!options.profilesFolder) { diff --git a/src/xsapi/rta.js b/src/xsapi/session.js similarity index 100% rename from src/xsapi/rta.js rename to src/xsapi/session.js From f3230cfe74f8de10ef32219f19181ab2b91c7fb8 Mon Sep 17 00:00:00 2001 From: LucienHH Date: Fri, 15 Aug 2025 23:57:40 +0100 Subject: [PATCH 27/32] Implement ServerData --- index.js | 5 +- src/server/advertisement.js | 106 ++++++++++++++++++++++++++++-------- 2 files changed, 86 insertions(+), 25 deletions(-) diff --git a/index.js b/index.js index 7018ae36..3748b4b2 100644 --- a/index.js +++ b/index.js @@ -10,7 +10,7 @@ const { Relay } = require('./src/relay') const { createClient, ping } = require('./src/createClient') const { createServer } = require('./src/createServer') const { Titles } = require('prismarine-auth') -const { ServerAdvertisement } = require('./src/server/advertisement') +const { ServerAdvertisement, NethernetServerAdvertisement } = require('./src/server/advertisement') module.exports = { Client, @@ -20,5 +20,6 @@ module.exports = { ping, createServer, title: Titles, - ServerAdvertisement + ServerAdvertisement, + NethernetServerAdvertisement } diff --git a/src/server/advertisement.js b/src/server/advertisement.js index f7e50c84..28046202 100644 --- a/src/server/advertisement.js +++ b/src/server/advertisement.js @@ -1,7 +1,5 @@ const { Versions, CURRENT_VERSION } = require('../options') -const { ServerData } = require('node-nethernet') - class NethernetServerAdvertisement { version = 3 motd = 'Bedrock Protocol Server' @@ -18,31 +16,93 @@ class NethernetServerAdvertisement { } static fromBuffer (buffer) { - const responsePacket = new ServerData(buffer) - - responsePacket.decode() - - Object.assign(this, responsePacket) + const advertisement = new NethernetServerAdvertisement() + let offset = 0 + + // Version (1 byte) + if (buffer.length < 1) return advertisement + advertisement.version = buffer.readUInt8(offset++) + + // MOTD (1 byte length + string) + if (offset >= buffer.length) return advertisement + const motdLength = buffer.readUInt8(offset++) + if (offset + motdLength > buffer.length) return advertisement + advertisement.motd = buffer.toString('utf8', offset, offset + motdLength) + offset += motdLength + + // Level name (1 byte length + string) + if (offset >= buffer.length) return advertisement + const levelNameLength = buffer.readUInt8(offset++) + if (offset + levelNameLength > buffer.length) return advertisement + advertisement.levelName = buffer.toString('utf8', offset, offset + levelNameLength) + offset += levelNameLength + + // Gamemode ID (4 bytes LE) + if (offset + 4 > buffer.length) return advertisement + advertisement.gamemodeId = buffer.readInt32LE(offset) + offset += 4 + + // Player count (4 bytes LE) + if (offset + 4 > buffer.length) return advertisement + advertisement.playerCount = buffer.readInt32LE(offset) + offset += 4 + + // The remaining structure seems different from expected + // Let's just read what we can safely + if (offset + 4 <= buffer.length) { + advertisement.playersMax = buffer.readInt32LE(offset) + offset += 4 + } + + // Try to read remaining bytes as individual flags/values + if (offset < buffer.length) { + advertisement.isEditorWorld = buffer.readUInt8(offset++) === 1 + } + + if (offset < buffer.length) { + advertisement.hardcore = buffer.readUInt8(offset++) === 1 + } + + // The last few bytes might be a different format + if (offset < buffer.length) { + advertisement.transportLayer = buffer.readUInt8(offset++) + } - return this + return advertisement } 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() + const motdBuffer = Buffer.from(this.motd, 'utf8') + const levelNameBuffer = Buffer.from(this.levelName, 'utf8') + + const buffers = [] + + buffers.push(Buffer.from([this.version])) + buffers.push(Buffer.from([motdBuffer.length])) + buffers.push(motdBuffer) + buffers.push(Buffer.from([levelNameBuffer.length])) + buffers.push(levelNameBuffer) + + const gamemodeBuffer = Buffer.alloc(4) + gamemodeBuffer.writeInt32LE(this.gamemodeId, 0) + buffers.push(gamemodeBuffer) + + const playerCountBuffer = Buffer.alloc(4) + playerCountBuffer.writeInt32LE(this.playerCount, 0) + buffers.push(playerCountBuffer) + + const playersMaxBuffer = Buffer.alloc(4) + playersMaxBuffer.writeInt32LE(this.playersMax, 0) + buffers.push(playersMaxBuffer) + + buffers.push(Buffer.from([this.isEditorWorld ? 1 : 0])) + buffers.push(Buffer.from([this.hardcore ? 1 : 0])) + + const transportBuffer = Buffer.alloc(4) + transportBuffer.writeInt32LE(this.transportLayer, 0) + buffers.push(transportBuffer) + + return Buffer.concat(buffers) } } From 508a15cc83190a04fe2c025a18759db14f868584 Mon Sep 17 00:00:00 2001 From: LucienHH Date: Sat, 16 Aug 2025 00:04:29 +0100 Subject: [PATCH 28/32] Cleanup unused methods --- src/server/advertisement.js | 18 +----------------- src/xsapi/rest.js | 5 ----- src/xsapi/session.js | 4 ---- 3 files changed, 1 insertion(+), 26 deletions(-) diff --git a/src/server/advertisement.js b/src/server/advertisement.js index 28046202..b5f892c0 100644 --- a/src/server/advertisement.js +++ b/src/server/advertisement.js @@ -19,42 +19,27 @@ class NethernetServerAdvertisement { const advertisement = new NethernetServerAdvertisement() let offset = 0 - // Version (1 byte) - if (buffer.length < 1) return advertisement advertisement.version = buffer.readUInt8(offset++) - // MOTD (1 byte length + string) - if (offset >= buffer.length) return advertisement const motdLength = buffer.readUInt8(offset++) - if (offset + motdLength > buffer.length) return advertisement advertisement.motd = buffer.toString('utf8', offset, offset + motdLength) offset += motdLength - // Level name (1 byte length + string) - if (offset >= buffer.length) return advertisement const levelNameLength = buffer.readUInt8(offset++) - if (offset + levelNameLength > buffer.length) return advertisement advertisement.levelName = buffer.toString('utf8', offset, offset + levelNameLength) offset += levelNameLength - // Gamemode ID (4 bytes LE) - if (offset + 4 > buffer.length) return advertisement advertisement.gamemodeId = buffer.readInt32LE(offset) offset += 4 - // Player count (4 bytes LE) - if (offset + 4 > buffer.length) return advertisement advertisement.playerCount = buffer.readInt32LE(offset) offset += 4 - - // The remaining structure seems different from expected - // Let's just read what we can safely + if (offset + 4 <= buffer.length) { advertisement.playersMax = buffer.readInt32LE(offset) offset += 4 } - // Try to read remaining bytes as individual flags/values if (offset < buffer.length) { advertisement.isEditorWorld = buffer.readUInt8(offset++) === 1 } @@ -63,7 +48,6 @@ class NethernetServerAdvertisement { advertisement.hardcore = buffer.readUInt8(offset++) === 1 } - // The last few bytes might be a different format if (offset < buffer.length) { advertisement.transportLayer = buffer.readUInt8(offset++) } diff --git a/src/xsapi/rest.js b/src/xsapi/rest.js index 8dec8277..32837575 100644 --- a/src/xsapi/rest.js +++ b/src/xsapi/rest.js @@ -152,11 +152,6 @@ class Rest { 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: { diff --git a/src/xsapi/session.js b/src/xsapi/session.js index 98ca5e38..848bdde5 100644 --- a/src/xsapi/session.js +++ b/src/xsapi/session.js @@ -127,10 +127,6 @@ class SessionDirectory { 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) } From c754c0cb7159c7f14176be808c12c0ee70376d1f Mon Sep 17 00:00:00 2001 From: LucienHH Date: Sat, 16 Aug 2025 00:05:12 +0100 Subject: [PATCH 29/32] Lint --- src/server/advertisement.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/server/advertisement.js b/src/server/advertisement.js index b5f892c0..6b18d24d 100644 --- a/src/server/advertisement.js +++ b/src/server/advertisement.js @@ -20,7 +20,7 @@ class NethernetServerAdvertisement { let offset = 0 advertisement.version = buffer.readUInt8(offset++) - + const motdLength = buffer.readUInt8(offset++) advertisement.motd = buffer.toString('utf8', offset, offset + motdLength) offset += motdLength @@ -31,7 +31,7 @@ class NethernetServerAdvertisement { advertisement.gamemodeId = buffer.readInt32LE(offset) offset += 4 - + advertisement.playerCount = buffer.readInt32LE(offset) offset += 4 @@ -39,15 +39,15 @@ class NethernetServerAdvertisement { advertisement.playersMax = buffer.readInt32LE(offset) offset += 4 } - + if (offset < buffer.length) { advertisement.isEditorWorld = buffer.readUInt8(offset++) === 1 } - + if (offset < buffer.length) { advertisement.hardcore = buffer.readUInt8(offset++) === 1 } - + if (offset < buffer.length) { advertisement.transportLayer = buffer.readUInt8(offset++) } @@ -58,34 +58,34 @@ class NethernetServerAdvertisement { toBuffer () { const motdBuffer = Buffer.from(this.motd, 'utf8') const levelNameBuffer = Buffer.from(this.levelName, 'utf8') - + const buffers = [] - + buffers.push(Buffer.from([this.version])) buffers.push(Buffer.from([motdBuffer.length])) buffers.push(motdBuffer) buffers.push(Buffer.from([levelNameBuffer.length])) buffers.push(levelNameBuffer) - + const gamemodeBuffer = Buffer.alloc(4) gamemodeBuffer.writeInt32LE(this.gamemodeId, 0) buffers.push(gamemodeBuffer) - + const playerCountBuffer = Buffer.alloc(4) playerCountBuffer.writeInt32LE(this.playerCount, 0) buffers.push(playerCountBuffer) - + const playersMaxBuffer = Buffer.alloc(4) playersMaxBuffer.writeInt32LE(this.playersMax, 0) buffers.push(playersMaxBuffer) - + buffers.push(Buffer.from([this.isEditorWorld ? 1 : 0])) buffers.push(Buffer.from([this.hardcore ? 1 : 0])) - + const transportBuffer = Buffer.alloc(4) transportBuffer.writeInt32LE(this.transportLayer, 0) buffers.push(transportBuffer) - + return Buffer.concat(buffers) } } From 71ecfee736c325ca7b4ce2f277835a07dd975895 Mon Sep 17 00:00:00 2001 From: LucienHH Date: Mon, 15 Sep 2025 15:10:04 +0100 Subject: [PATCH 30/32] Update to support PrismarineJS/nethernet --- examples/client/nethernet_local.js | 2 +- src/createClient.js | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/client/nethernet_local.js b/examples/client/nethernet_local.js index eba411b8..a6c659f6 100644 --- a/examples/client/nethernet_local.js +++ b/examples/client/nethernet_local.js @@ -10,7 +10,7 @@ c.once('pong', (pong) => { const client = createClient({ transport: 'nethernet', // Use the Nethernet transport - networkId: pong.senderId, + networkId: pong.sender_id, useSignalling: false }) diff --git a/src/createClient.js b/src/createClient.js index 0c617636..b81d23c2 100644 --- a/src/createClient.js +++ b/src/createClient.js @@ -56,7 +56,7 @@ function createClient (options) { /** @param {Client} client */ async function connect (client) { - if (client.transport === 'nethernet') { + if (client.options.transport === 'nethernet') { if (client.options.useSignalling) { client.nethernet.signalling = new NethernetSignal(client.connection.nethernet.networkId, client.options.authflow, client.options.version) @@ -67,7 +67,7 @@ async function connect (client) { client.nethernet.signalling.on('signal', signal => client.connection.nethernet.handleSignal(signal)) } else { - await client.connection.nethernet.ping() + await client.connection.ping() } } @@ -126,10 +126,11 @@ async function connect (client) { } async function ping ({ host, port, networkId }) { + console.log(`Pinging ${host}:${port} with networkId ${networkId}`) if (networkId) { const con = new NethernetClient({ networkId }) try { - return advertisement.NethernetServerAdvertisement.fromBuffer(await con.ping()) + return advertisement.NethernetServerAdvertisement.fromBuffer(Buffer.from(await con.ping(), 'hex')) } finally { con.close() } From 5fc6377046c8558705939751eb0ee738fdda176d Mon Sep 17 00:00:00 2001 From: LucienHH Date: Fri, 17 Apr 2026 14:57:07 +0100 Subject: [PATCH 31/32] Update Nethernet signalling support and update advertisement --- examples/client/nethernet_realm.js | 24 +++++++++++++ src/client.js | 21 +++++++++-- src/createClient.js | 17 --------- src/server/advertisement.js | 56 ++++++++++++++++++++---------- src/websocket/signal.js | 28 ++++----------- 5 files changed, 88 insertions(+), 58 deletions(-) create mode 100644 examples/client/nethernet_realm.js diff --git a/examples/client/nethernet_realm.js b/examples/client/nethernet_realm.js new file mode 100644 index 00000000..2910d798 --- /dev/null +++ b/examples/client/nethernet_realm.js @@ -0,0 +1,24 @@ +process.env.DEBUG = 'minecraft-protocol' + +const { createClient } = require('bedrock-protocol') + +const client = createClient({ + transport: 'nethernet', // Use the Nethernet transport + useSignalling: true, + networkId: '', + skipPing: true +}) + +client.on('text', (packet) => { // Listen for chat messages and echo them back. + if (packet.source_name !== client.username) { + client.queue('text', { + type: 'chat', + needs_translation: false, + source_name: client.username, + xuid: '', + platform_chat_id: '', + filtered_message: '', + message: `${packet.source_name} said: ${packet.message} on ${new Date().toLocaleString()}` + }) + } +}) diff --git a/src/client.js b/src/client.js index ca7472b4..66af1cb3 100644 --- a/src/client.js +++ b/src/client.js @@ -9,6 +9,7 @@ const { NethernetClient } = require('./nethernet') const { KeyExchange } = require('./handshake/keyExchange') const Login = require('./handshake/login') const LoginVerify = require('./handshake/loginVerify') +const { NethernetSignal } = require('./websocket/signal') const debugging = false @@ -87,7 +88,23 @@ class Client extends Connection { connect () { if (!this.connection) throw new Error('Connect not currently allowed') // must wait for `connect_allowed`, or use `createClient` - this.on('session', this._connect) + this.on('session', (sessionData) => { + if (this.options.transport === 'nethernet' && this.options.useSignalling) { + this.nethernet.signalling = new NethernetSignal(this.connection.nethernet.networkId, this.options.authflow, this.options.version) + + this.nethernet.signalling.connect() + + this.connection.nethernet.signalHandler = this.nethernet.signalling.write.bind(this.nethernet.signalling) + + this.nethernet.signalling.on('signal', signal => this.connection.nethernet.handleSignal(signal)) + this.nethernet.signalling.on('credentials', (credentials) => { + this.connection.nethernet.credentials = credentials + this._connect(sessionData) + }) + } else { + this._connect(sessionData) + } + }) if (this.options.offline) { debug('offline mode, not authenticating', this.options) @@ -276,7 +293,7 @@ class Client extends Connection { break case 'start_game': this.startGameData = pakData.params - // fallsthrough + // fallsthrough case 'item_registry': // 1.21.60+ send itemstates in item_registry packet pakData.params.itemstates?.forEach(state => { if (state.name === 'minecraft:shield') { diff --git a/src/createClient.js b/src/createClient.js index b81d23c2..67b65672 100644 --- a/src/createClient.js +++ b/src/createClient.js @@ -6,7 +6,6 @@ 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) { @@ -56,21 +55,6 @@ function createClient (options) { /** @param {Client} client */ async function connect (client) { - if (client.options.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.ping() - } - } - // Actually connect client.connect() @@ -126,7 +110,6 @@ async function connect (client) { } async function ping ({ host, port, networkId }) { - console.log(`Pinging ${host}:${port} with networkId ${networkId}`) if (networkId) { const con = new NethernetClient({ networkId }) try { diff --git a/src/server/advertisement.js b/src/server/advertisement.js index 6b18d24d..fa117cef 100644 --- a/src/server/advertisement.js +++ b/src/server/advertisement.js @@ -1,15 +1,16 @@ const { Versions, CURRENT_VERSION } = require('../options') class NethernetServerAdvertisement { - version = 3 + version = 4 motd = 'Bedrock Protocol Server' levelName = 'bedrock-protocol' - gamemodeId = 2 - playerCount = 0 - playersMax = 5 + gamemodeId = 0 + playerCount = 1 + playersMax = 8 isEditorWorld = false hardcore = false - transportLayer = 2 + unknown1 = 4 + unknown2 = 8 constructor (obj) { Object.assign(this, obj) @@ -19,37 +20,47 @@ class NethernetServerAdvertisement { const advertisement = new NethernetServerAdvertisement() let offset = 0 + // version advertisement.version = buffer.readUInt8(offset++) + // motd const motdLength = buffer.readUInt8(offset++) advertisement.motd = buffer.toString('utf8', offset, offset + motdLength) offset += motdLength + // level name const levelNameLength = buffer.readUInt8(offset++) advertisement.levelName = buffer.toString('utf8', offset, offset + levelNameLength) offset += levelNameLength - advertisement.gamemodeId = buffer.readInt32LE(offset) - offset += 4 + // gamemode + advertisement.gamemodeId = buffer.readUInt8(offset++) + // player count advertisement.playerCount = buffer.readInt32LE(offset) offset += 4 - if (offset + 4 <= buffer.length) { - advertisement.playersMax = buffer.readInt32LE(offset) - offset += 4 - } + // max count + advertisement.playersMax = buffer.readInt32LE(offset) + offset += 4 + // is editor if (offset < buffer.length) { advertisement.isEditorWorld = buffer.readUInt8(offset++) === 1 } + // is hardcore if (offset < buffer.length) { advertisement.hardcore = buffer.readUInt8(offset++) === 1 } + // unknown1 and unknown2 + if (offset < buffer.length) { + advertisement.unknown1 = buffer.readUInt8(offset++) + } + if (offset < buffer.length) { - advertisement.transportLayer = buffer.readUInt8(offset++) + advertisement.unknown2 = buffer.readUInt8(offset++) } return advertisement @@ -61,30 +72,39 @@ class NethernetServerAdvertisement { const buffers = [] + // version buffers.push(Buffer.from([this.version])) + + // motd buffers.push(Buffer.from([motdBuffer.length])) buffers.push(motdBuffer) + + // level name buffers.push(Buffer.from([levelNameBuffer.length])) buffers.push(levelNameBuffer) - const gamemodeBuffer = Buffer.alloc(4) - gamemodeBuffer.writeInt32LE(this.gamemodeId, 0) - buffers.push(gamemodeBuffer) + // gamemode + buffers.push(Buffer.from([this.gamemodeId])) + // player count const playerCountBuffer = Buffer.alloc(4) playerCountBuffer.writeInt32LE(this.playerCount, 0) buffers.push(playerCountBuffer) + // max count const playersMaxBuffer = Buffer.alloc(4) playersMaxBuffer.writeInt32LE(this.playersMax, 0) buffers.push(playersMaxBuffer) + // is editor buffers.push(Buffer.from([this.isEditorWorld ? 1 : 0])) + + // is hardcore buffers.push(Buffer.from([this.hardcore ? 1 : 0])) - const transportBuffer = Buffer.alloc(4) - transportBuffer.writeInt32LE(this.transportLayer, 0) - buffers.push(transportBuffer) + // unknown1 and unknown2 + buffers.push(Buffer.from([this.unknown1])) + buffers.push(Buffer.from([this.unknown2])) return Buffer.concat(buffers) } diff --git a/src/websocket/signal.js b/src/websocket/signal.js index 4e7c0434..1e901bc4 100644 --- a/src/websocket/signal.js +++ b/src/websocket/signal.js @@ -83,7 +83,7 @@ class NethernetSignal extends EventEmitter { debug('Connecting to Signal', address) const ws = new WebSocket(address, { - headers: { Authorization: xbl.mcToken } + headers: { Authorization: xbl.mcToken, 'session-id': this.networkId, 'request-id': Date.now().toString() } }) this.pingInterval = setInterval(() => { @@ -183,27 +183,13 @@ class NethernetSignal extends EventEmitter { 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 - }) - } - } - } + const iceServers = data.TurnAuthServers.map(server => ({ + urls: server.Urls, + username: server.Username, + credential: server.Password + })) - return servers + return iceServers } From e301fc74830de8cef9a6e62db7973980a5e7b09a Mon Sep 17 00:00:00 2001 From: LucienHH Date: Wed, 22 Apr 2026 21:26:38 +0100 Subject: [PATCH 32/32] Update auth --- examples/client/nethernet_local.js | 2 +- package.json | 6 +++--- src/client/auth.js | 14 ++++++++------ 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/examples/client/nethernet_local.js b/examples/client/nethernet_local.js index a6c659f6..861e0d86 100644 --- a/examples/client/nethernet_local.js +++ b/examples/client/nethernet_local.js @@ -3,7 +3,7 @@ process.env.DEBUG = 'minecraft-protocol' const { Client } = require('node-nethernet') const { createClient } = require('bedrock-protocol') -const c = new Client() +const c = new Client(0n) c.once('pong', (pong) => { c.close() diff --git a/package.json b/package.json index 4d846ff5..87845fb1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bedrock-protocol", - "version": "3.53.0", + "version": "3.55.0", "description": "Minecraft Bedrock Edition protocol library", "main": "index.js", "types": "index.d.ts", @@ -28,7 +28,7 @@ "minecraft-data": "^3.0.0", "minecraft-folder-path": "^1.2.0", "node-nethernet": "github:LucienHH/node-nethernet#protocol", - "prismarine-auth": "github:LucienHH/prismarine-auth#playfab", + "prismarine-auth": "^3.0.0", "prismarine-nbt": "^2.0.0", "prismarine-realms": "^1.1.0", "protodef": "^1.14.0", @@ -57,4 +57,4 @@ "url": "https://github.com/PrismarineJS/bedrock-protocol/issues" }, "homepage": "https://github.com/PrismarineJS/bedrock-protocol#readme" -} +} \ No newline at end of file diff --git a/src/client/auth.js b/src/client/auth.js index e57eaf74..13fe992e 100644 --- a/src/client/auth.js +++ b/src/client/auth.js @@ -120,12 +120,13 @@ async function authenticate (client, options) { validateOptions(options) try { options.authflow ??= new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode) - const chains = await options.authflow.getMinecraftBedrockToken(client.clientX509).catch(e => { + const loginData = await options.authflow.getMinecraftBedrockToken(client.clientX509).catch(e => { if (options.password) console.warn('Sign in failed, try removing the password field') throw e }) + const chains = loginData.chain - debug('chains', chains) + debug('loginData', { chainLength: chains.length, hasToken: Boolean(loginData.token) }) // First chain is Mojang stuff, second is Xbox profile data used by mc const jwt = chains[1] @@ -140,7 +141,7 @@ async function authenticate (client, options) { xuid: xboxProfile?.extraData?.XUID || 0 } - return postAuthenticate(client, profile, chains) + return postAuthenticate(client, profile, loginData) } catch (err) { console.error(err) client.emit('error', err) @@ -157,13 +158,14 @@ function createOfflineSession (client, options) { uuid: uuidFrom(options.username), // random xuid: 0 } - return postAuthenticate(client, profile, []) // No extra JWTs, only send 1 client signed chain with all the data + return postAuthenticate(client, profile, { chain: [], token: '' }) // No extra JWTs, only send our own login data } -function postAuthenticate (client, profile, chains) { +function postAuthenticate (client, profile, auth = {}) { client.profile = profile client.username = profile.name - client.accessToken = chains + client.accessToken = auth.chain || [] + client.multiplayerToken = auth.token || '' client.emit('session', profile) }