Skip to content

Nethernet support for joining worlds #533

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 26 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions examples/client/nethernet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
process.env.DEBUG = 'minecraft-protocol'

const readline = require('readline')
const { createClient } = require('bedrock-protocol')

async function pickSession (availableSessions) {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})

console.log('Available Sessions:')

availableSessions.forEach((session, index) => console.log(`${index + 1}. ${session.customProperties.hostName} ${session.customProperties.worldName} (${session.customProperties.version})`))

rl.question('Please select a session by number: ', (answer) => {
const sessionIndex = parseInt(answer) - 1

if (sessionIndex >= 0 && sessionIndex < availableSessions.length) {
const selectedSession = availableSessions[sessionIndex]
console.log(`You selected: ${selectedSession.customProperties.hostName} ${selectedSession.customProperties.worldName} (${selectedSession.customProperties.version})`)
resolve(selectedSession)
} else {
console.log('Invalid selection. Please try again.')
resolve(pickSession())
}

rl.close()
})
})
}

const client = createClient({
transport: 'nethernet', // Use the Nethernet transport
world: {
pickSession
}
})

let ix = 0
client.on('packet', (args) => {
console.log(`Packet ${ix} recieved`)
ix++
})
22 changes: 22 additions & 0 deletions examples/client/nethernet_local.js
Original file line number Diff line number Diff line change
@@ -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++
})
})
20 changes: 20 additions & 0 deletions examples/server/nethernet.js
Original file line number Diff line number Diff line change
@@ -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()} !`)
})
})
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,20 @@
"license": "MIT",
"dependencies": {
"debug": "^4.3.1",
"json-bigint": "^1.0.0",
"jsonwebtoken": "^9.0.0",
"jsp-raknet": "^2.1.3",
"minecraft-data": "^3.0.0",
"minecraft-folder-path": "^1.2.0",
"prismarine-auth": "^2.0.0",
"node-nethernet": "github:LucienHH/node-nethernet#protocol",
"prismarine-auth": "github:LucienHH/prismarine-auth#playfab",
"prismarine-nbt": "^2.0.0",
"prismarine-realms": "^1.1.0",
"protodef": "^1.14.0",
"raknet-native": "^1.0.3",
"uuid-1345": "^1.0.2"
"uuid-1345": "^1.0.2",
"ws": "^8.18.0",
"xbox-rta": "^2.1.0"
},
"optionalDependencies": {
"raknet-node": "^0.5.0"
Expand Down
32 changes: 28 additions & 4 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -20,13 +21,16 @@ class Client extends Connection {
super()
this.options = { ...Options.defaultOptions, ...options }

if (this.options.transport === 'nethernet') {
this.nethernet = {}
}

this.startGameData = {}
this.clientRuntimeId = null
// Start off without compression on 1.19.30, zlib on below
this.compressionAlgorithm = this.versionGreaterThanOrEqualTo('1.19.30') ? 'none' : 'deflate'
this.compressionThreshold = 512
this.compressionLevel = this.options.compressionLevel
this.batchHeader = 0xfe

if (isDebug) {
this.inLog = (...args) => debug('C ->', ...args)
Expand All @@ -49,10 +53,21 @@ class Client extends Connection {
Login(this, null, this.options)
LoginVerify(this, null, this.options)

const { RakClient } = initRaknet(this.options.raknetBackend)
const host = this.options.host
const port = this.options.port
this.connection = new RakClient({ useWorkers: this.options.useRaknetWorkers, host, port }, this)

const networkId = this.options.networkId

if (this.options.transport === 'nethernet') {
this.connection = new NethernetClient({ networkId })
this.batchHeader = null
this.disableEncryption = true
} else if (this.options.transport === 'raknet') {
const { RakClient } = initRaknet(this.options.raknetBackend)
this.connection = new RakClient({ useWorkers: this.options.useRaknetWorkers, host, port }, this)
this.batchHeader = 0xfe
this.disableEncryption = false
}

this.emit('connect_allowed')
}
Expand Down Expand Up @@ -84,7 +99,16 @@ class Client extends Connection {
}

validateOptions () {
if (!this.options.host || this.options.port == null) throw Error('Invalid host/port')
switch (this.options.transport) {
case 'nethernet':
if (!this.options.networkId) throw Error('Invalid networkId')
break
case 'raknet':
if (!this.options.host || this.options.port == null) throw Error('Invalid host/port')
break
default:
throw Error(`Unsupported transport: ${this.options.transport} (nethernet, raknet)`)
}
Options.validateOptions(this.options)
}

Expand Down
63 changes: 60 additions & 3 deletions src/client/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const minecraftFolderPath = require('minecraft-folder-path')
const debug = require('debug')('minecraft-protocol')
const { uuidFrom } = require('../datatypes/util')
const { RealmAPI } = require('prismarine-realms')
const { SessionDirectory } = require('../xsapi/session')

function validateOptions (options) {
if (!options.profilesFolder) {
Expand All @@ -16,6 +17,60 @@ function validateOptions (options) {
}
}

async function serverAuthenticate (server, options) {
validateOptions(options)

options.authflow ??= new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode)

server.nethernet.session = new SessionDirectory(options.authflow, {
world: {
hostName: server.advertisement.motd,
name: server.advertisement.levelName,
version: options.version,
protocol: options.protocolVersion,
memberCount: server.advertisement.playerCount,
maxMemberCount: server.advertisement.playersMax
}
})

await server.nethernet.session.createSession(options.networkId)
}

async function worldAuthenticate (client, options) {
validateOptions(options)

options.authflow = new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode)

const xbl = await options.authflow.getXboxToken()

client.nethernet.session = new SessionDirectory(options.authflow, {})

const getSessions = async () => {
const sessions = await client.nethernet.session.host.rest.getSessions(xbl.userXUID)
debug('sessions', sessions)
if (!sessions.length) throw Error('Couldn\'t find any sessions for the authenticated account')
return sessions
}

let world

if (options.world.pickSession) {
if (typeof options.world.pickSession !== 'function') throw Error('world.pickSession must be a function')
const sessions = await getSessions()
world = await options.world.pickSession(sessions)
}

if (!world) throw Error('Couldn\'t find a session to connect to.')

const session = await client.nethernet.session.joinSession(world.sessionRef.name)

const networkId = session.properties.custom.SupportedConnections.find(e => e.ConnectionType === 3).NetherNetId

if (!networkId) throw Error('Couldn\'t find a Nethernet ID to connect to.')

options.networkId = BigInt(networkId)
}

async function realmAuthenticate (options) {
validateOptions(options)

Expand Down Expand Up @@ -64,8 +119,8 @@ async function realmAuthenticate (options) {
async function authenticate (client, options) {
validateOptions(options)
try {
const authflow = options.authflow || new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode)
const chains = await authflow.getMinecraftBedrockToken(client.clientX509).catch(e => {
options.authflow ??= new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode)
const chains = await options.authflow.getMinecraftBedrockToken(client.clientX509).catch(e => {
if (options.password) console.warn('Sign in failed, try removing the password field')
throw e
})
Expand Down Expand Up @@ -115,5 +170,7 @@ function postAuthenticate (client, profile, chains) {
module.exports = {
createOfflineSession,
authenticate,
realmAuthenticate
realmAuthenticate,
worldAuthenticate,
serverAuthenticate
}
1 change: 1 addition & 0 deletions src/connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
75 changes: 60 additions & 15 deletions src/createClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const assert = require('assert')
const Options = require('./options')
const advertisement = require('./server/advertisement')
const auth = require('./client/auth')
const { NethernetClient } = require('./nethernet')
const { NethernetSignal } = require('./websocket/signal')

/** @param {{ version?: number, host: string, port?: number, connectTimeout?: number, skipPing?: boolean }} options */
function createClient (options) {
Expand All @@ -17,28 +19,58 @@ 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})`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we know what version to connect as here if the user does not specify/can't get via ping?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user doesn't specify it defaults to the current version and the version isn't included in the Nethernet ping response

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, what is advertisement.version then? We may have to figure out the version after connecting to the server then like nmp can do. It should be sent in login/network_settings packet otherwise. The current code should also be matching version by protocol version num over strings but that should be fixed outside this PR

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not entirely sure but I assume it's the version of NetherNet protocol being used as it's just a uint8. From my testing it's always been set to 3 over multiple version updates.

}

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()
}
return client
}

function connect (client) {
/** @param {Client} client */
async function connect (client) {
if (client.transport === 'nethernet') {
if (client.options.useSignalling) {
client.nethernet.signalling = new NethernetSignal(client.connection.nethernet.networkId, client.options.authflow, client.options.version)

await client.nethernet.signalling.connect()

client.connection.nethernet.credentials = client.nethernet.signalling.credentials
client.connection.nethernet.signalHandler = client.nethernet.signalling.write.bind(client.nethernet.signalling)

client.nethernet.signalling.on('signal', signal => client.connection.nethernet.handleSignal(signal))
} else {
await client.connection.nethernet.ping()
}
}

// Actually connect
client.connect()

Expand Down Expand Up @@ -86,15 +118,28 @@ function connect (client) {
clearInterval(keepalive)
})
}
}

async function ping ({ host, port }) {
const con = new RakClient({ host, port })
client.once('close', () => {
if (client.nethernet.session) client.nethernet.session.end()
if (client.nethernet.signalling) client.nethernet.signalling.destroy()
})
}

try {
return advertisement.fromServerName(await con.ping())
} finally {
con.close()
async function ping ({ host, port, networkId }) {
if (networkId) {
const con = new NethernetClient({ networkId })
try {
return advertisement.NethernetServerAdvertisement.fromBuffer(await con.ping())
} finally {
con.close()
}
} else {
const con = new RakClient({ host, port })
try {
return advertisement.fromServerName(await con.ping())
} finally {
con.close()
}
}
}

Expand Down
Loading
Loading