Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 9 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,16 +147,19 @@ Returns a `Client` instance and perform login.
* id : a numeric client id used for referring to multiple clients in a server
* validateChannelProtocol (optional) : whether or not to enable protocol validation for custom protocols using plugin channels. Defaults to true
* disableChatSigning (optional) : Don't try obtaining chat signing keys from Mojang (1.19+)
* clientSettings (optional) : Client Information (settings) sent to the server during the configuration phase (1.20.2+). All fields are optional and default to vanilla-safe values:
* cookies (optional) : cookies to answer `cookie_request` packets with, as an object or Map of key to Buffer (1.20.5+). Pass the previous connection's `client._cookies` when following a `transfer` packet, like the vanilla client does
* brand (optional) : client brand sent on the `minecraft:brand` plugin channel when first entering the configuration phase (1.20.2+), default `'vanilla'`
* clientSettings (optional) : Client Information (settings) sent to the server, after the brand, when first entering the configuration phase (1.20.2+); like the vanilla client, they are not re-sent when a server sends the client back to configuration. All fields are optional and default to the vanilla values:
* locale : language/locale string, default `'en_us'`
* viewDistance : view distance in chunks, default `10`
* viewDistance : view distance in chunks, default `12`
* chatFlags : chat mode, `0` = enabled, `1` = commands only, `2` = hidden, default `0`
* chatColors : whether chat colors are enabled, default `true`
* skinParts : displayed skin parts bitmask, default `127`
* mainHand : `0` = left, `1` = right, default `1`
* enableTextFiltering : default `false`
* enableServerListing : whether the player appears in server status player samples, default `true`
* particleStatus : `'all'`, `'decreased'` or `'minimal'` (1.21.3+), default `'all'`
* knownPacks (optional) : data packs the client has locally, as an array of `{ namespace, id, version }` (1.20.5+). The reply to the server's `select_known_packs` is the part of the server's list found here, like the vanilla client, which knows `{ namespace: 'minecraft', id: 'core', version: '<game version>' }`. The server omits the registry entries of the packs in the reply, so the default is `[]` and the server sends its full registry data
* realms : An object which should contain one of the following properties: `realmId` or `pickRealm`. When defined will attempt to join a Realm without needing to specify host/port. **The authenticated account must either own the Realm or have been invited to it**
* realmId : The id of the Realm to join.
* pickRealm(realms) : A function which will have an array of the user Realms (joined/owned) passed to it. The function should return a Realm.
Expand All @@ -168,6 +171,10 @@ Returns a `Client` instance and perform login.
Create a new client, if `isServer` is true then it is a server-side client, otherwise it's a client-side client.
Takes a minecraft `version` as second argument.

### client._cookies

Map of cookies (key to Buffer) received through `store_cookie` packets, seeded from the `cookies` option (1.20.5+). The client answers `cookie_request` packets from it in the login, configuration and play states, replying with an absent value for unknown cookies, like the vanilla client.

### client.write(name, params)

write a packet
Expand Down
27 changes: 27 additions & 0 deletions src/client/cookies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use strict'

// One map, keyed by resource location, must answer cookie requests in every state.
// `cookie_request` and `cookie_response` exist only in the login, configuration and play
// states of 1.20.5+, so the listeners never fire (nor write) elsewhere.
module.exports = function (client, options) {
client._cookies = new Map(options.cookies instanceof Map ? options.cookies : Object.entries(options.cookies ?? {}))

client.on('store_cookie', (packet) => {
client._cookies.set(packet.key, packet.value)
})

client.on('cookie_request', (packet) => {
let value = client._cookies.get(packet.cookie)
if (value === undefined && !cookieValueIsOptional(client.version)) {
// Where minecraft-data (1.21.8) declares the value as a bare ByteArray, an empty one
// is wire-identical to an absent option: both serialize as a single 0x00 byte
value = Buffer.alloc(0)
}
client.write('cookie_response', { key: packet.cookie, value })
})
}

function cookieValueIsOptional (version) {
const type = require('minecraft-data')(version).protocol.types.packet_common_cookie_response
return type[1].find(field => field.name === 'value').type[0] === 'option'
}
57 changes: 35 additions & 22 deletions src/client/play.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ module.exports = function (client, options) {
}
})

client.once('login', (packet) => {
// Every login packet must start a fresh chat session (new session UUID, message
// index 0) with the same profile key pair
client.on('login', (packet) => {
if (packet.enforcesSecureChat) client.serverFeatures.enforcesSecureChat = packet.enforcesSecureChat
const mcData = require('minecraft-data')(client.version)
if (mcData.supportFeature('useChatSessions') && client.profileKeys && client.cipher && client.session.selectedProfile.id === client.uuid.replace(/-/g, '')) {
Expand All @@ -35,6 +37,7 @@ module.exports = function (client, options) {
const mcData = require('minecraft-data')(client.version)
client.uuid = packet.uuid
client.username = packet.username
let sentClientInformation = false

if (mcData.supportFeature('hasConfigurationState')) {
client.write('login_acknowledged', {})
Expand All @@ -53,27 +56,37 @@ module.exports = function (client, options) {
client.write('configuration_acknowledged', {})
}
client.state = states.CONFIGURATION
// Mirror the vanilla client, which sends Client Information during the
// configuration phase. Some servers (e.g. Hypixel) wait for it before
// sending finish_configuration and will close the socket otherwise.
// Defaults are vanilla-safe and can be overridden per-field via the
// `clientSettings` option. A client that also sends Client Information in
// the play state (e.g. mineflayer on its 'login' event) still takes
// precedence there, exactly as the vanilla client re-sends settings. (#3623)
const clientSettings = options.clientSettings || {}
client.write('settings', {
locale: clientSettings.locale ?? 'en_us',
viewDistance: clientSettings.viewDistance ?? 10,
chatFlags: clientSettings.chatFlags ?? 0,
chatColors: clientSettings.chatColors ?? true,
skinParts: clientSettings.skinParts ?? 127,
mainHand: clientSettings.mainHand ?? 1,
enableTextFiltering: clientSettings.enableTextFiltering ?? false,
enableServerListing: clientSettings.enableServerListing ?? true,
particleStatus: clientSettings.particleStatus ?? 'all'
})
client.once('select_known_packs', () => {
client.write('select_known_packs', { packs: [] })
// Brand then Client Information are sent once per connection, on the first entry
// into configuration; re-entering configuration from play sends nothing else. Some
// servers (e.g. Hypixel) close the socket unless Client Information arrives before
// they send finish_configuration
if (!sentClientInformation) {
sentClientInformation = true
client.write('custom_payload', {
channel: 'minecraft:brand',
data: client.serializer.proto.createPacketBuffer('string', options.brand ?? 'vanilla')
})
const clientSettings = options.clientSettings || {}
client.write('settings', {
locale: clientSettings.locale ?? 'en_us',
viewDistance: clientSettings.viewDistance ?? 12,
chatFlags: clientSettings.chatFlags ?? 0,
chatColors: clientSettings.chatColors ?? true,
skinParts: clientSettings.skinParts ?? 127,
mainHand: clientSettings.mainHand ?? 1,
enableTextFiltering: clientSettings.enableTextFiltering ?? false,
enableServerListing: clientSettings.enableServerListing ?? true,
particleStatus: clientSettings.particleStatus ?? 'all'
})
}
// The server omits the registry entries of every pack in the reply, so a pack may
// only be listed when its data is available locally
client.once('select_known_packs', (packet) => {
const knownPacks = options.knownPacks ?? []
client.write('select_known_packs', {
packs: packet.packs.filter(pack => knownPacks.some(known =>
known.namespace === pack.namespace && known.id === pack.id && known.version === pack.version))
})
})
client.once('code_of_conduct', () => {
client.write('accept_code_of_conduct', {})
Expand Down
2 changes: 2 additions & 0 deletions src/createClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const auth = require('./client/mojangAuth')
const microsoftAuth = require('./client/microsoftAuth')
const setProtocol = require('./client/setProtocol')
const play = require('./client/play')
const cookies = require('./client/cookies')
const tcpDns = require('./client/tcp_dns')
const autoVersion = require('./client/autoVersion')
const pluginChannels = require('./client/pluginChannels')
Expand Down Expand Up @@ -71,6 +72,7 @@ function createClient (options) {
keepalive(client, options)
encrypt(client, options)
play(client, options)
cookies(client, options)
compress(client, options)
pluginChannels(client, options)
versionChecking(client, options)
Expand Down
181 changes: 181 additions & 0 deletions test/serverTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ for (const supportedVersion of mc.supportedVersions) {
let PORT
const mcData = require('minecraft-data')(supportedVersion)
const version = mcData.version
// minecraft-data 1.21.8 declares cookie_response.value as a bare ByteArray, so an absent
// value cannot be sent there
const hasCookies = 'packet_common_cookie_request' in mcData.protocol.types &&
mcData.protocol.types.packet_common_cookie_response[1][1].type[0] === 'option'

const loginPacket = (client, server) => {
if (mcData.loginPacket) {
Expand Down Expand Up @@ -564,6 +568,183 @@ for (const supportedVersion of mc.supportedVersions) {
})
})
})

if (mcData.supportFeature('hasConfigurationState')) {
it('sends brand then client information once when entering configuration', function (done) {
const server = mc.createServer({
'online-mode': false,
version: version.minecraftVersion,
port: PORT
})
const received = []
server.on('connection', function (client) {
client.on('custom_payload', (packet) => {
if (client.state !== mc.states.CONFIGURATION) return
received.push({ channel: packet.channel, brand: packet.data.subarray(1).toString('utf8'), lengthPrefix: packet.data[0] })
})
client.on('settings', (packet) => {
if (client.state !== mc.states.CONFIGURATION) return
received.push({ settings: packet })
})
// The nmp server does not handle configuration_acknowledged; the state is moved by hand
client.on('configuration_acknowledged', () => {
client.state = mc.states.CONFIGURATION
client.once('finish_configuration', () => {
client.state = mc.states.PLAY
assert.deepStrictEqual(received, [
{ channel: 'minecraft:brand', brand: 'nmp-test', lengthPrefix: 8 },
{
settings: {
locale: 'en_us',
viewDistance: 7,
chatFlags: 0,
chatColors: true,
skinParts: 127,
mainHand: 1,
enableTextFiltering: false,
enableServerListing: true,
...(mcData.version.version >= 768 ? { particleStatus: 'all' } : {})
}
}
])
server.close()
})
client.write('finish_configuration', {})
})
})
server.on('playerJoin', function (client) {
client.write('login', loginPacket(client, server))
client.write('start_configuration', {})
})
server.on('close', done)
server.on('listening', function () {
mc.createClient({
username: 'configPlayer',
host: '127.0.0.1',
version: version.minecraftVersion,
port: PORT,
brand: 'nmp-test',
clientSettings: { viewDistance: 7 }
})
})
})
}

if ('packet_common_select_known_packs' in mcData.protocol.types) {
it('selects the known packs it shares with the server', function (done) {
const core = { namespace: 'minecraft', id: 'core', version: version.minecraftVersion }
const server = mc.createServer({
'online-mode': false,
version: version.minecraftVersion,
port: PORT
})
let reply
server.on('connection', function (client) {
client.on('select_known_packs', (packet) => {
reply = packet.packs
})
// Runs before the login plugin's own login_acknowledged handler
client.once('login_acknowledged', () => {
client.state = mc.states.CONFIGURATION
client.write('select_known_packs', { packs: [core, { namespace: 'test', id: 'server-only', version: '1' }] })
})
})
server.on('playerJoin', function () {
assert.deepStrictEqual(reply, [core])
server.close()
})
server.on('close', done)
server.on('listening', function () {
mc.createClient({
username: 'packPlayer',
host: '127.0.0.1',
version: version.minecraftVersion,
port: PORT,
knownPacks: [core, { namespace: 'test', id: 'client-only', version: '1' }]
})
})
})
}

if (hasCookies) {
it('answers login cookie requests from the cookies option', function (done) {
const seeded = Buffer.from('seeded-cookie')
// Login cookies must be exchanged before set_compression; mc.createServer sends
// set_compression first, so the login state is driven by hand
const server = net.createServer((socket) => {
const client = new mc.Client(true, version.minecraftVersion)
client.setSocket(socket)
client.on('set_protocol', () => {
client.state = mc.states.LOGIN
})
client.on('login_start', () => {
client.write('cookie_request', { cookie: 'test:seeded' })
})
client.on('cookie_response', (packet) => {
assert.strictEqual(client.state, mc.states.LOGIN)
assert.deepStrictEqual(packet, { key: 'test:seeded', value: seeded })
client.end('done')
server.close()
})
})
server.on('close', done)
server.listen(PORT, '127.0.0.1', () => {
mc.createClient({
username: 'cookieMonster',
host: '127.0.0.1',
version: version.minecraftVersion,
port: PORT,
cookies: { 'test:seeded': seeded }
})
})
})

it('answers cookie requests in configuration and play', function (done) {
const seeded = Buffer.from('seeded-cookie')
const stored = Buffer.from('stored-cookie')
const server = mc.createServer({
'online-mode': false,
version: version.minecraftVersion,
port: PORT
})
const responses = []
server.on('connection', function (client) {
client.on('cookie_response', (packet) => {
responses.push({ state: client.state, key: packet.key, value: packet.value })
if (responses.length === 3) {
assert.deepStrictEqual(responses, [
{ state: mc.states.CONFIGURATION, key: 'test:seeded', value: seeded },
{ state: mc.states.CONFIGURATION, key: 'test:stored', value: stored },
{ state: mc.states.PLAY, key: 'test:unknown', value: undefined }
])
server.close()
}
})
// Runs before the login plugin's own login_acknowledged handler, which sends
// registry data and finish_configuration
client.once('login_acknowledged', () => {
client.state = mc.states.CONFIGURATION
client.write('cookie_request', { cookie: 'test:seeded' })
client.write('store_cookie', { key: 'test:stored', value: stored })
client.write('cookie_request', { cookie: 'test:stored' })
})
})
server.on('playerJoin', function (client) {
client.write('login', loginPacket(client, server))
client.write('cookie_request', { cookie: 'test:unknown' })
})
server.on('close', done)
server.on('listening', function () {
mc.createClient({
username: 'cookieMonster',
host: '127.0.0.1',
version: version.minecraftVersion,
port: PORT,
cookies: { 'test:seeded': seeded }
})
})
})
}
})
}

Expand Down
Loading