diff --git a/docs/API.md b/docs/API.md index 1cd65246..565bdee4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -147,9 +147,11 @@ 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` @@ -157,6 +159,7 @@ Returns a `Client` instance and perform login. * 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: '' }`. 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. @@ -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 diff --git a/src/client/cookies.js b/src/client/cookies.js new file mode 100644 index 00000000..8badc9df --- /dev/null +++ b/src/client/cookies.js @@ -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' +} diff --git a/src/client/play.js b/src/client/play.js index 221f14c4..d3e3fd09 100644 --- a/src/client/play.js +++ b/src/client/play.js @@ -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, '')) { @@ -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', {}) @@ -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', {}) diff --git a/src/createClient.js b/src/createClient.js index 432c8fec..5fbe9632 100644 --- a/src/createClient.js +++ b/src/createClient.js @@ -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') @@ -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) diff --git a/test/serverTest.js b/test/serverTest.js index 95cb3382..96162f38 100644 --- a/test/serverTest.js +++ b/test/serverTest.js @@ -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) { @@ -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 } + }) + }) + }) + } }) }