diff --git a/docs/API.md b/docs/API.md index 88067bca..6e962210 100644 --- a/docs/API.md +++ b/docs/API.md @@ -138,6 +138,13 @@ Returns a `Client` instance and perform login. * connect : a function taking the client as parameter and that should client.setSocket(socket) and client.emit('connect') when appropriate (see the proxy examples for an example of use) * agent : a http agent that can be used to set proxy settings for yggdrasil authentication (see proxy-agent on npm) + * proxy : (optional) proxy configuration object for connecting through proxies + * type : proxy type, one of `'socks4'`, `'socks5'`, `'http'`, or `'https'` + * host : proxy server hostname + * port : proxy server port + * auth : (optional) authentication object for proxy + * username : proxy username + * password : proxy password * fakeHost : (optional) hostname to send to the server in the set_protocol packet * profilesFolder : optional * (mojang account) the path to the folder that contains your `launcher_profiles.json`. defaults to your minecraft folder if it exists, otherwise the local directory. set to `false` to disable managing profiles @@ -152,6 +159,9 @@ Returns a `Client` instance and perform login. * pickRealm(realms) : A function which will have an array of the user Realms (joined/owned) passed to it. The function should return a Realm. * Client : You can pass a custom client class to use instead of the default one, which would allow you to create completely custom communication. Also note that you can use the `stream` option instead where you can supply custom duplex, but this will still use serialization/deserialization of packets. +### Proxy Support + +The `proxy` option allows connecting to Minecraft servers through SOCKS4, SOCKS5, HTTP, or HTTPS proxies. This feature integrates seamlessly with authentication and supports SRV record resolution. See the `examples/client_builtin_proxy/` folder for usage examples. ## mc.Client(isServer,version,[customPackets]) diff --git a/examples/client_builtin_proxy/client_builtin_proxy.js b/examples/client_builtin_proxy/client_builtin_proxy.js new file mode 100644 index 00000000..458b0321 --- /dev/null +++ b/examples/client_builtin_proxy/client_builtin_proxy.js @@ -0,0 +1,41 @@ +'use strict' + +const mc = require('minecraft-protocol') + +const [,, host, port, username, proxyHost, proxyPort] = process.argv +if (!host || !port || !username || !proxyHost || !proxyPort) { + console.log('Usage: node client_builtin_proxy.js ') + console.log('Example: node client_builtin_proxy.js localhost 25565 testuser 127.0.0.1 1080') + process.exit(1) +} + +const client = mc.createClient({ + host, + port: parseInt(port), + username, + auth: 'offline', + proxy: { + type: 'socks5', + host: proxyHost, + port: parseInt(proxyPort) + // auth: { username: 'proxyuser', password: 'proxypass' } // optional + } +}) + +client.on('connect', function () { + console.log('Connected to server via SOCKS5 proxy!') +}) + +client.on('disconnect', function (packet) { + console.log('Disconnected from server: ' + packet.reason) +}) + +client.on('end', function () { + console.log('Connection ended') + process.exit() +}) + +client.on('error', function (err) { + console.log('Connection error:', err.message) + process.exit(1) +}) diff --git a/package.json b/package.json index 7b1f0f5b..fb253422 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,8 @@ "node-fetch": "^2.6.1", "node-rsa": "^0.4.2", "prismarine-auth": "^2.2.0", + "proxy-agent": "^6.3.1", + "socks": "^2.7.1", "prismarine-chat": "^1.10.0", "prismarine-nbt": "^2.5.0", "prismarine-realms": "^1.2.0", diff --git a/src/client/proxy.js b/src/client/proxy.js new file mode 100644 index 00000000..9a30be05 --- /dev/null +++ b/src/client/proxy.js @@ -0,0 +1,149 @@ +'use strict' + +const http = require('http') +const https = require('https') + +/** + * Creates a proxy-aware connect function based on proxy configuration + * @param {Object} proxyConfig - Proxy configuration + * @param {string} proxyConfig.type - Proxy type ('socks4', 'socks5', 'http', 'https') + * @param {string} proxyConfig.host - Proxy host + * @param {number} proxyConfig.port - Proxy port + * @param {Object} [proxyConfig.auth] - Authentication credentials + * @param {string} [proxyConfig.auth.username] - Username + * @param {string} [proxyConfig.auth.password] - Password + * @param {string} targetHost - Target Minecraft server host + * @param {number} targetPort - Target Minecraft server port + * @returns {Function} Connection handler function + */ +function createProxyConnect (proxyConfig, targetHost, targetPort) { + switch (proxyConfig.type.toLowerCase()) { + case 'http': + case 'https': + return createHttpConnect(proxyConfig, targetHost, targetPort) + case 'socks4': + case 'socks5': + return createSocksConnect(proxyConfig, targetHost, targetPort) + default: + throw new Error(`Unsupported proxy type: ${proxyConfig.type}`) + } +} + +/** + * Creates HTTP CONNECT proxy function (based on existing client_http_proxy example) + */ +function createHttpConnect (proxyConfig, targetHost, targetPort) { + return function (client) { + const connectOptions = { + host: proxyConfig.host, + port: proxyConfig.port, + method: 'CONNECT', + path: `${targetHost}:${targetPort}` + } + + // Add authentication header if provided + if (proxyConfig.auth) { + const credentials = Buffer.from(`${proxyConfig.auth.username}:${proxyConfig.auth.password}`).toString('base64') + connectOptions.headers = { + 'Proxy-Authorization': `Basic ${credentials}` + } + } + + const httpModule = proxyConfig.type.toLowerCase() === 'https' ? https : http + const req = httpModule.request(connectOptions) + + req.on('connect', (res, stream) => { + if (res.statusCode === 200) { + client.setSocket(stream) + client.emit('connect') + } else { + client.emit('error', new Error(`HTTP CONNECT failed: ${res.statusCode} ${res.statusMessage}`)) + } + }) + + req.on('error', (err) => { + client.emit('error', new Error(`HTTP proxy error: ${err.message}`)) + }) + + req.end() + } +} + +/** + * Creates SOCKS proxy function (based on existing client_socks_proxy example) + */ +function createSocksConnect (proxyConfig, targetHost, targetPort) { + return function (client) { + let socks + try { + socks = require('socks').SocksClient + } catch (err) { + client.emit('error', new Error('SOCKS proxy requires "socks" package: npm install socks')) + return + } + + const socksOptions = { + proxy: { + host: proxyConfig.host, + port: proxyConfig.port, + type: proxyConfig.type === 'socks4' ? 4 : 5 + }, + command: 'connect', + destination: { + host: targetHost, + port: targetPort + } + } + + // Add authentication if provided (SOCKS5 only) + if (proxyConfig.auth && proxyConfig.type === 'socks5') { + socksOptions.proxy.userId = proxyConfig.auth.username + socksOptions.proxy.password = proxyConfig.auth.password + } + + socks.createConnection(socksOptions, (err, info) => { + if (err) { + client.emit('error', new Error(`SOCKS proxy error: ${err.message}`)) + return + } + client.setSocket(info.socket) + client.emit('connect') + }) + } +} + +/** + * Creates a proxy-aware agent for HTTP requests (used for authentication) + */ +function createProxyAgent (proxyConfig) { + try { + const ProxyAgent = require('proxy-agent') + const protocol = proxyConfig.type.toLowerCase() === 'https' + ? 'https:' + : proxyConfig.type.toLowerCase() === 'http' + ? 'http:' + : proxyConfig.type.toLowerCase() === 'socks5' + ? 'socks5:' + : proxyConfig.type.toLowerCase() === 'socks4' ? 'socks4:' : 'http:' + + const agentOptions = { + protocol, + host: proxyConfig.host, + port: proxyConfig.port + } + + if (proxyConfig.auth) { + agentOptions.auth = `${proxyConfig.auth.username}:${proxyConfig.auth.password}` + } + + return new ProxyAgent(agentOptions) + } catch (err) { + // Fallback to basic agent if proxy-agent not available + return new http.Agent() + } +} + +module.exports = { + createProxyConnect, + createProxyAgent +} diff --git a/src/client/tcp_dns.js b/src/client/tcp_dns.js index ff27be83..26143ede 100644 --- a/src/client/tcp_dns.js +++ b/src/client/tcp_dns.js @@ -1,5 +1,6 @@ const net = require('net') const dns = require('dns') +const { createProxyConnect } = require('./proxy') module.exports = function (client, options) { // Default options @@ -15,6 +16,16 @@ module.exports = function (client, options) { return } + // Helper function to connect (direct or via proxy) + const connectToTarget = (targetHost, targetPort) => { + if (options.proxy) { + const proxyConnect = createProxyConnect(options.proxy, targetHost, targetPort) + proxyConnect(client) + } else { + client.setSocket(net.connect(targetPort, targetHost)) + } + } + // If port was not defined (defauls to 25565), host is not an ip neither localhost if (options.port === 25565 && net.isIP(options.host) === 0 && options.host !== 'localhost') { // Try to resolve SRV records for the comain @@ -22,23 +33,21 @@ module.exports = function (client, options) { // Error resolving domain if (err) { // Could not resolve SRV lookup, connect directly - client.setSocket(net.connect(options.port, options.host)) + connectToTarget(options.host, options.port) return } // SRV Lookup resolved conrrectly if (addresses && addresses.length > 0) { - options.host = addresses[0].name - options.port = addresses[0].port - client.setSocket(net.connect(addresses[0].port, addresses[0].name)) + connectToTarget(addresses[0].name, addresses[0].port) } else { // Otherwise, just connect using the provided hostname and port - client.setSocket(net.connect(options.port, options.host)) + connectToTarget(options.host, options.port) } }) } else { // Otherwise, just connect using the provided hostname and port - client.setSocket(net.connect(options.port, options.host)) + connectToTarget(options.host, options.port) } } } diff --git a/src/createClient.js b/src/createClient.js index 912e331e..82f61616 100644 --- a/src/createClient.js +++ b/src/createClient.js @@ -14,6 +14,7 @@ const tcpDns = require('./client/tcp_dns') const autoVersion = require('./client/autoVersion') const pluginChannels = require('./client/pluginChannels') const versionChecking = require('./client/versionChecking') +const { createProxyAgent } = require('./client/proxy') const uuid = require('./datatypes/uuid') module.exports = createClient @@ -36,6 +37,11 @@ function createClient (options) { const client = new Client(false, version.minecraftVersion, options.customPackets, hideErrors) + // Set up proxy agent if proxy is configured + if (options.proxy && !options.agent) { + options.agent = createProxyAgent(options.proxy) + } + tcpDns(client, options) if (options.auth instanceof Function) { options.auth(client, options) diff --git a/test/clientTest.js b/test/clientTest.js index 63c2be29..269d4689 100644 --- a/test/clientTest.js +++ b/test/clientTest.js @@ -285,6 +285,84 @@ for (const supportedVersion of mc.supportedVersions) { }) }) }) + + it('connects through SOCKS5 proxy', function (done) { + const net = require('net') + let proxyPort + let proxyServer + + // Simple SOCKS5 proxy that forwards to MC server + const createProxy = () => { + return net.createServer((clientSocket) => { + let stage = 'auth' + + clientSocket.on('data', (data) => { + if (stage === 'auth') { + if (data[0] === 0x05) { + clientSocket.write(Buffer.from([0x05, 0x00])) // No auth required + stage = 'connect' + } + } else if (stage === 'connect') { + if (data[0] === 0x05 && data[1] === 0x01) { + // Parse the connect request properly (for completeness) + // We don't actually need to parse it since we forward to fixed target + + // Forward to MC server + const serverSocket = net.createConnection(PORT, 'localhost', () => { + // Send proper SOCKS5 success response + const response = Buffer.alloc(10) + response[0] = 0x05 // Version + response[1] = 0x00 // Success + response[2] = 0x00 // Reserved + response[3] = 0x01 // IPv4 + response.writeUInt32BE(0x7f000001, 4) // 127.0.0.1 + response.writeUInt16BE(PORT, 8) // Port + clientSocket.write(response) + + clientSocket.pipe(serverSocket) + serverSocket.pipe(clientSocket) + }) + serverSocket.on('error', () => clientSocket.destroy()) + } + } + }) + clientSocket.on('error', () => {}) + }) + } + + // Start proxy server + const { getPort } = require('./common/util') + getPort().then(port => { + proxyPort = port + proxyServer = createProxy() + proxyServer.listen(proxyPort, () => { + // Connect through proxy + const client = mc.createClient({ + username: 'ProxyUser', + version: version.minecraftVersion, + port: PORT, + auth: 'offline', + proxy: { + type: 'socks5', + host: 'localhost', + port: proxyPort + } + }) + + client.on('login', () => { + console.log('✓ Connected through SOCKS5 proxy!') + client.end() + proxyServer.close() + done() + }) + + client.on('error', (err) => { + proxyServer.close() + done(err) + }) + }) + }).catch(done) + }) }) describe.skip('online', function () {