diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..ef48078 --- /dev/null +++ b/server/config.js @@ -0,0 +1,51 @@ +// This folder contains options for both the bridge and networking adapter. +// Environment files and .env files are available here. Set the value of any config option to process.env. +export const config = { + adapter: { + name: "EaglerProxy", + bindHost: "0.0.0.0", + bindPort: 8080, + maxConcurrentClients: 100, + // set this to false if you are unable to install sharp due to either the use of a platform that does not support native modules + // or if you are unable to install the required dependencies. this will cause the proxy to use jimp instead of sharp, which may + // degrade your proxy's performance. + useNatives: true, + skinServer: { + skinUrlWhitelist: undefined, + cache: { + useCache: true, + folderName: "skinCache", + skinCacheLifetime: 60 * 60 * 1000, + skinCachePruneInterval: 10 * 60 * 1000, + }, + }, + motd: true + ? "FORWARD" // "FORWARD" regularly polls the server for the MOTD + : { + iconURL: "motd.png", // must be a valid file path + l1: "yes", + l2: "no", + }, // providing an object as such will allow you to supply your own MOTD + ratelimits: { + lockout: 10, + limits: { + http: 100, + ws: 100, + motd: 100, + skins: 1000, // adjust as necessary + skinsIp: 10000, + connect: 100, + }, + }, + origins: { + allowOfflineDownloads: true, + originWhitelist: null, + originBlacklist: null, + }, + server: { + host: "127.0.0.1", + port: 1111, + }, + tls: undefined, + }, +}; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..9780ea3 --- /dev/null +++ b/server/index.js @@ -0,0 +1,25 @@ +import * as dotenv from "dotenv"; +import { Proxy } from "./proxy/Proxy.js"; +import { config } from "./config.js"; +dotenv.config(); +import { Logger } from "./logger.js"; +import { PROXY_BRANDING } from "./meta.js"; +import { PluginManager } from "./proxy/pluginLoader/PluginManager.js"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { ImageEditor } from "./proxy/skins/ImageEditor.js"; +const logger = new Logger("Launcher"); +let proxy; +global.CONFIG = config; +config.adapter.useNatives = config.adapter.useNatives ?? true; +logger.info("Loading libraries..."); +await ImageEditor.loadLibraries(config.adapter.useNatives); +logger.info("Loading plugins..."); +const pluginManager = new PluginManager(join(dirname(fileURLToPath(import.meta.url)), "plugins")); +global.PLUGIN_MANAGER = pluginManager; +await pluginManager.loadPlugins(); +proxy = new Proxy(config.adapter, pluginManager); +pluginManager.proxy = proxy; +logger.info(`Launching ${PROXY_BRANDING}...`); +await proxy.init(); +global.PROXY = proxy; diff --git a/server/launcher_types.js b/server/launcher_types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/server/launcher_types.js @@ -0,0 +1 @@ +export {}; diff --git a/server/logger.js b/server/logger.js new file mode 100644 index 0000000..46128e8 --- /dev/null +++ b/server/logger.js @@ -0,0 +1,57 @@ +import { Chalk } from "chalk"; +const color = new Chalk({ level: 2 }); +let global_verbose = false; +export function verboseLogging(newVal) { + global_verbose = newVal ?? global_verbose ? false : true; +} +function jsonLog(type, message) { + return (JSON.stringify({ + type: type, + message: message, + }) + "\n"); +} +export class Logger { + loggerName; + verbose; + jsonLog = process.argv.includes("--json") || process.argv.includes("-j"); + constructor(name, verbose) { + this.loggerName = name; + if (verbose) + this.verbose = verbose; + else + this.verbose = global_verbose; + } + info(s) { + if (!this.jsonLog) + process.stdout.write(`${color.green("I")} ${color.gray(new Date().toISOString())} ${color.reset(`${color.yellow(`${this.loggerName}:`)} ${s}`)}\n`); + else + process.stdout.write(jsonLog("info", s)); + } + warn(s) { + if (!this.jsonLog) + process.stdout.write(`${color.yellow("W")} ${color.gray(new Date().toISOString())} ${color.yellow(`${color.yellow(`${this.loggerName}:`)} ${s}`)}\n`); + else + process.stderr.write(jsonLog("warn", s)); + } + error(s) { + if (!this.jsonLog) + process.stderr.write(`* ${color.red("E")} ${color.gray(new Date().toISOString())} ${color.redBright(`${color.red(`${this.loggerName}:`)} ${s}`)}\n`); + else + process.stderr.write(jsonLog("error", s)); + } + fatal(s) { + if (!this.jsonLog) + process.stderr.write(`** ${color.red("F!")} ${color.gray(new Date().toISOString())} ${color.bgRedBright(color.redBright(`${color.red(`${this.loggerName}:`)} ${s}`))}\n`); + else + process.stderr.write(jsonLog("fatal", s)); + } + debug(s) { + if (this.verbose || global_verbose) { + if (!this.jsonLog) + process.stderr.write(`${color.gray("D")} ${color.gray(new Date().toISOString())} ${color.gray(`${color.gray(`${this.loggerName}:`)} ${s}`)}\n`); + else + process.stderr.write(jsonLog("debug", s)); + } + } +} +verboseLogging(process.env.DEBUG != null && process.env.DEBUG != "false" ? true : false); diff --git a/server/meta.js b/server/meta.js new file mode 100644 index 0000000..dd57d2b --- /dev/null +++ b/server/meta.js @@ -0,0 +1,8 @@ +const f = Object.freeze; +// bridge meta +export const BRIDGE_VERSION = f(1); +// adapter meta +export const PROXY_BRANDING = f("EaglerProxy"); +export const PROXY_VERSION = f("1.0.8"); +export const NETWORK_VERSION = f(0x03); +export const VANILLA_PROTOCOL_VERSION = f(47); diff --git a/server/plugins/DummyFile.js b/server/plugins/DummyFile.js new file mode 100644 index 0000000..e69de29 diff --git a/server/plugins/EagProxyAAS/CustomAuthflow.js b/server/plugins/EagProxyAAS/CustomAuthflow.js new file mode 100644 index 0000000..e8344f7 --- /dev/null +++ b/server/plugins/EagProxyAAS/CustomAuthflow.js @@ -0,0 +1,186 @@ +import fs from "fs"; +import crypto from "crypto"; +import Constants from "prismarine-auth/src/common/Constants.js"; +const { Endpoints, msalConfig } = Constants; +import LiveTokenManager from "prismarine-auth/src/TokenManagers/LiveTokenManager.js"; +import JavaTokenManager from "prismarine-auth/src/TokenManagers/MinecraftJavaTokenManager.js"; +import XboxTokenManager from "prismarine-auth/src/TokenManagers/XboxTokenManager.js"; +import MsaTokenManager from "prismarine-auth/src/TokenManagers/MsaTokenManager.js"; +import BedrockTokenManager from "prismarine-auth/src/TokenManagers/MinecraftBedrockTokenManager.js"; +async function retry(methodFn, beforeRetry, times) { + while (times--) { + if (times !== 0) { + try { + return await methodFn(); + } + catch (e) { + if (e instanceof URIError) { + throw e; + } + else { + // debug(e); + } + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + await beforeRetry(); + } + else { + return await methodFn(); + } + } +} +export class CustomAuthflow { + username; + options; + codeCallback; + msa; + doTitleAuth; + xbl; + mba; + mca; + constructor(username = "", cache, options, codeCallback) { + this.username = username; + if (options && !options.flow) { + throw new Error("Missing 'flow' argument in options. See docs for more information."); + } + this.options = options || { flow: "msal" }; + this.initTokenManagers(username, cache); + this.codeCallback = codeCallback; + } + initTokenManagers(username, cache) { + if (this.options.flow === "live" || this.options.flow === "sisu") { + if (!this.options.authTitle) + throw new Error(`Please specify an "authTitle" in Authflow constructor when using ${this.options.flow} flow`); + this.msa = new LiveTokenManager(this.options.authTitle, ["service::user.auth.xboxlive.com::MBI_SSL"], cache({ cacheName: this.options.flow, username })); + this.doTitleAuth = true; + } + else if (this.options.flow === "msal") { + const config = Object.assign({ ...msalConfig }, this.options.authTitle ? { auth: { ...msalConfig.auth, clientId: this.options.authTitle } } : {}); + this.msa = new MsaTokenManager(config, ["XboxLive.signin", "offline_access"], cache({ cacheName: "msal", username })); + } + else { + throw new Error(`Unknown flow: ${this.options.flow} (expected "live", "sisu", or "msal")`); + } + const keyPair = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); + this.xbl = new XboxTokenManager(keyPair, cache({ cacheName: "xbl", username })); + this.mba = new BedrockTokenManager(cache({ cacheName: "bed", username })); + this.mca = new JavaTokenManager(cache({ cacheName: "mca", username })); + } + static resetTokenCaches(cache) { + if (!cache) + throw new Error("You must provide a cache directory to reset."); + try { + if (fs.existsSync(cache)) { + fs.rmSync(cache, { recursive: true }); + return true; + } + } + catch (e) { + console.log("Failed to clear cache dir", e); + return false; + } + } + async getMsaToken() { + if (await this.msa.verifyTokens()) { + const { token } = await this.msa.getAccessToken(); + return token; + } + else { + const ret = await this.msa.authDeviceCode((response) => { + if (this.codeCallback) + return this.codeCallback(response); + console.info("[msa] First time signing in. Please authenticate now:"); + console.info(response.message); + }); + if (ret.account) { + console.info(`[msa] Signed in as ${ret.account.username}`); + } + else { + // We don't get extra account data here per scope + console.info("[msa] Signed in with Microsoft"); + } + return ret.accessToken; + } + } + async getXboxToken(relyingParty = this.options.relyingParty || Endpoints.XboxRelyingParty, forceRefresh = false) { + const options = { ...this.options, relyingParty }; + const { xstsToken, userToken, deviceToken, titleToken } = await this.xbl.getCachedTokens(relyingParty); + if (xstsToken.valid && !forceRefresh) { + return xstsToken.data; + } + if (options.password) { + const xsts = await this.xbl.doReplayAuth(this.username, options.password, options); + return xsts; + } + return await retry(async () => { + const msaToken = await this.getMsaToken(); + // sisu flow generates user and title tokens differently to other flows and should also be used to refresh them if they are invalid + if (options.flow === "sisu" && (!userToken.valid || !deviceToken.valid || !titleToken.valid)) { + const dt = await this.xbl.getDeviceToken(options); + const sisu = await this.xbl.doSisuAuth(msaToken, dt, options); + return sisu; + } + const ut = userToken.token ?? (await this.xbl.getUserToken(msaToken, options.flow === "msal")); + const dt = deviceToken.token ?? (await this.xbl.getDeviceToken(options)); + const tt = titleToken.token ?? (this.doTitleAuth ? await this.xbl.getTitleToken(msaToken, dt) : undefined); + const xsts = await this.xbl.getXSTSToken({ userToken: ut, deviceToken: dt, titleToken: tt }, options); + return xsts; + }, () => { + this.msa.forceRefresh = true; + }, 2); + } + async getMinecraftJavaToken(options = {}, quit) { + const response = { token: "", entitlements: {}, profile: {} }; + if (await this.mca.verifyTokens()) { + const { token } = await this.mca.getCachedAccessToken(); + response.token = token; + } + else { + await retry(async () => { + const xsts = await this.getXboxToken(Endpoints.PCXSTSRelyingParty); + response.token = await this.mca.getAccessToken(xsts); + if (quit.quit) + return; + }, () => { + this.xbl.forceRefresh = true; + }, 2); + } + if (quit.quit) + return; + if (options.fetchEntitlements) { + response.entitlements = await this.mca.fetchEntitlements(response.token).catch((e) => { }); + } + if (options.fetchProfile) { + response.profile = await this.mca.fetchProfile(response.token).catch((e) => { }); + } + if (options.fetchCertificates) { + response.certificates = await this.mca.fetchCertificates(response.token).catch((e) => []); + } + return response; + } + async getMinecraftBedrockToken(publicKey) { + // TODO: Fix cache, in order to do cache we also need to cache the ECDH keys so disable it + // is this even a good idea to cache? + if ((await this.mba.verifyTokens()) && false) { + // eslint-disable-line + const { chain } = this.mba.getCachedAccessToken(); + return chain; + } + else { + if (!publicKey) + throw new Error("Need to specifiy a ECDH x509 URL encoded public key"); + return await retry(async () => { + const xsts = await this.getXboxToken(Endpoints.BedrockXSTSRelyingParty); + const token = await this.mba.getAccessToken(publicKey, xsts); + // If we want to auth with a title ID, make sure there's a TitleID in the response + const body = JSON.parse(Buffer.from(token.chain[1].split(".")[1], "base64").toString()); + if (!body.extraData.titleId && this.doTitleAuth) { + throw Error("missing titleId in response"); + } + return token.chain; + }, () => { + this.xbl.forceRefresh = true; + }, 2); + } + } +} diff --git a/server/plugins/EagProxyAAS/auth.js b/server/plugins/EagProxyAAS/auth.js new file mode 100644 index 0000000..d2e11d8 --- /dev/null +++ b/server/plugins/EagProxyAAS/auth.js @@ -0,0 +1,55 @@ +import { randomUUID } from "crypto"; +import EventEmitter from "events"; +import pauth from "prismarine-auth"; +import { CustomAuthflow } from "./CustomAuthflow.js"; +const { Authflow, Titles } = pauth; +const Enums = PLUGIN_MANAGER.Enums; +class InMemoryCache { + cache = {}; + async getCached() { + return this.cache; + } + async setCached(value) { + this.cache = value; + } + async setCachedPartial(value) { + this.cache = { + ...this.cache, + ...value, + }; + } +} +export function auth(quit) { + const emitter = new EventEmitter(); + const userIdentifier = randomUUID(); + const flow = new CustomAuthflow(userIdentifier, ({ username, cacheName }) => new InMemoryCache(), { + authTitle: Titles.MinecraftJava, + flow: "sisu", + deviceType: "Win32", + }, (code) => { + console.log = () => { }; + emitter.emit("code", code); + }); + flow + .getMinecraftJavaToken({ fetchProfile: true }, quit) + .then(async (data) => { + if (!data || quit.quit) + return; + const _data = (await flow.mca.cache.getCached()).mca; + if (data.profile == null || data.profile.error) + return emitter.emit("error", new Error(Enums.ChatColor.RED + "Couldn't fetch profile data, does the account own Minecraft: Java Edition?")); + emitter.emit("done", { + accessToken: data.token, + expiresOn: _data.obtainedOn + _data.expires_in * 1000, + selectedProfile: data.profile, + availableProfiles: [data.profile], + }); + }) + .catch((err) => { + if (err.toString().includes("Not Found")) + emitter.emit("error", new Error(Enums.ChatColor.RED + "The provided account doesn't own Minecraft: Java Edition!")); + else + emitter.emit("error", new Error(Enums.ChatColor.YELLOW + err.toString())); + }); + return emitter; +} diff --git a/server/plugins/EagProxyAAS/auth_easymc.js b/server/plugins/EagProxyAAS/auth_easymc.js new file mode 100644 index 0000000..6b81a81 --- /dev/null +++ b/server/plugins/EagProxyAAS/auth_easymc.js @@ -0,0 +1,31 @@ +import { Enums } from "../../proxy/Enums.js"; +export async function getTokenProfileEasyMc(token) { + const fetchOptions = { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + token, + }), + }; + const res = await fetch("https://api.easymc.io/v1/token/redeem", fetchOptions); + const resJson = await res.json(); + if (resJson.error) + throw new Error(Enums.ChatColor.RED + `${resJson.error}`); + if (!resJson) + throw new Error(Enums.ChatColor.RED + "EasyMC replied with an empty response!?"); + if (resJson.session?.length !== 43 || resJson.mcName?.length < 3 || resJson.uuid?.length !== 36) + throw new Error(Enums.ChatColor.RED + "Invalid response from EasyMC received!"); + return { + auth: "mojang", + sessionServer: "https://sessionserver.easymc.io", + username: resJson.mcName, + haveCredentials: true, + session: { + accessToken: resJson.session, + selectedProfile: { + name: resJson.mcName, + id: resJson.uuid, + }, + }, + }; +} diff --git a/server/plugins/EagProxyAAS/commands.js b/server/plugins/EagProxyAAS/commands.js new file mode 100644 index 0000000..9b7dfc4 --- /dev/null +++ b/server/plugins/EagProxyAAS/commands.js @@ -0,0 +1,284 @@ +import { dirname, join } from "path"; +import { Enums } from "../../proxy/Enums.js"; +import { config } from "./config.js"; +import { ConnectType } from "./types.js"; +import fs from "fs/promises"; +import { fileURLToPath } from "url"; +const SEPARATOR = "======================================"; +const METADATA = JSON.parse((await fs.readFile(join(dirname(fileURLToPath(import.meta.url)), "metadata.json"))).toString()); +export function sendPluginChatMessage(client, ...components) { + if (components.length == 0) + throw new Error("There must be one or more passed components!"); + else { + client.ws.send(client.serverSerializer.createPacketBuffer({ + name: "chat", + params: { + message: JSON.stringify({ + text: "[EagPAAS] ", + color: "gold", + extra: components, + }), + }, + })); + } +} +export function handleCommand(sender, cmd) { + switch (cmd.toLowerCase().split(/ /gim)[0]) { + default: + sendPluginChatMessage(sender, { + text: `"${cmd.split(/ /gim, 1)[0]}" is not a valid command!`, + color: "red", + }); + break; + case "/eag-help": + helpCommand(sender); + break; + case "/eag-toggleparticles": + toggleParticles(sender); + break; + case "/eag-switchservers": + switchServer(cmd, sender); + break; + } +} +export function helpCommand(sender) { + sendPluginChatMessage(sender, { + text: SEPARATOR, + color: "yellow", + }); + sendPluginChatMessage(sender, { + text: "Available Commands:", + color: "aqua", + }); + sendPluginChatMessage(sender, { + text: "/eag-help", + color: "light_green", + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Click me to run this command!", + }, + clickEvent: { + action: "run_command", + value: "/eag-help", + }, + extra: [ + { + text: " - Prints out a list of commmands", + color: "aqua", + }, + ], + }); + sendPluginChatMessage(sender, { + text: "/eag-toggleparticles", + color: "light_green", + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Click me to run this command!", + }, + clickEvent: { + action: "run_command", + value: "/eag-toggleparticles", + }, + extra: [ + { + text: " - Toggles whether or not particles should be rendered/shown on the client. Turning this on can potentially boost FPS.", + color: "aqua", + }, + ], + }); + sendPluginChatMessage(sender, { + text: `/eag-switchservers ${config.allowCustomPorts ? " [port]" : ""}`, + color: "light_green", + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Click me to paste this command to chat!", + }, + clickEvent: { + action: "suggest_command", + value: `/eag-switchservers ${config.allowCustomPorts ? " [port]" : ""}`, + }, + extra: [ + { + text: " - Switch between servers on-the-fly. Switching to servers in online mode requires logging in via online mode or EasyMC!", + color: "aqua", + }, + ], + }); + sendPluginChatMessage(sender, { + text: `Running ${METADATA.name} on version v${METADATA.version}.`, + color: "gray", + }); + sendPluginChatMessage(sender, { + text: SEPARATOR, + color: "yellow", + }); +} +export function toggleParticles(sender) { + const listener = sender._particleListener; + if (listener != null) { + sender.removeListener("vanillaPacket", listener); + sender._particleListener = undefined; + sendPluginChatMessage(sender, { + text: "Disabled particle hider!", + color: "red", + }); + } + else { + sender._particleListener = (packet, origin) => { + if (origin == "SERVER") { + if (packet.name == "world_particles") { + packet.cancel = true; + } + else if (packet.name == "world_event") { + if (packet.params.effectId >= 2000) { + packet.cancel = true; + } + } + } + }; + sender.on("vanillaPacket", sender._particleListener); + sendPluginChatMessage(sender, { + text: "Enabled particle hider!", + color: "green", + }); + } +} +export async function switchServer(cmd, sender) { + if (sender._serverSwitchLock) { + return sendPluginChatMessage(sender, { + text: `There is already a pending server switch - please wait, and be patient!`, + color: "red", + }); + } + let split = cmd.split(/ /gim).slice(1), mode = split[0]?.toLowerCase(), ip = split[1], port = split[2]; + if (mode != "online" && mode != "offline") { + return sendPluginChatMessage(sender, { + text: `Invalid command usage - please provide a valid mode! `, + color: "red", + extra: [ + { + text: `/eag-switchservers ${config.allowCustomPorts ? " [port]" : ""}.`, + color: "gold", + }, + ], + }); + } + if (ip == null) { + return sendPluginChatMessage(sender, { + text: `Invalid command usage - please provide a valid IP or hostname (like example.com, 1.2.3.4, etc.)! `, + color: "red", + extra: [ + { + text: `/eag-switchservers ${config.allowCustomPorts ? " [port]" : ""}.`, + color: "gold", + }, + ], + }); + } + if (port != null && + (isNaN(Number(port)) || Number(port) < 1 || Number(port) > 65535)) { + return sendPluginChatMessage(sender, { + text: `Invalid command usage - a port must be a number above 0 and below 65536! `, + color: "red", + extra: [ + { + text: `/eag-switchservers ${config.allowCustomPorts ? " [port]" : ""}.`, + color: "gold", + }, + ], + }); + } + if (port != null && !config.allowCustomPorts) { + return sendPluginChatMessage(sender, { + text: `Invalid command usage - custom server ports are disabled on this proxy instance! `, + color: "red", + extra: [ + { + text: `/eag-switchservers ${config.allowCustomPorts ? " [port]" : ""}.`, + color: "gold", + }, + ], + }); + } + let connectionType = mode == "offline" ? ConnectType.OFFLINE : ConnectType.ONLINE, addr = ip, addrPort = Number(port); + if (connectionType == ConnectType.ONLINE) { + if (sender._onlineSession == null) { + sendPluginChatMessage(sender, { + text: `You either connected to this proxy under offline mode, or your online/EasyMC session has timed out and has become invalid.`, + color: "red", + }); + return sendPluginChatMessage(sender, { + text: `To switch to online servers, please reconnect and log-in through online/EasyMC mode.`, + color: "red", + }); + } + else { + const savedAuth = sender._onlineSession; + sendPluginChatMessage(sender, { + text: `(joining server under ${savedAuth.username}/your ${savedAuth.isEasyMC ? "EasyMC" : "Minecraft"} account's username)`, + color: "aqua", + }); + sendPluginChatMessage(sender, { + text: "Attempting to switch servers, please wait... (if you don't get connected to the target server after a while, the server might not be a Minecraft server at all. Reconnect and try again.)", + color: "gray", + }); + sender._serverSwitchLock = true; + try { + await sender.switchServers({ + host: addr, + port: addrPort, + version: "1.8.8", + keepAlive: false, + skipValidation: true, + hideErrors: true, + ...savedAuth, + }); + sender._serverSwitchLock = false; + } + catch (err) { + if (sender.state != Enums.ClientState.DISCONNECTED) { + sender.disconnect(Enums.ChatColor.RED + + `Something went wrong whilst switching servers: ${err.message}${err.code == "ENOTFOUND" + ? addr.includes(":") + ? `\n${Enums.ChatColor.GRAY}Suggestion: Replace the : in your IP with a space.` + : "\nIs that IP valid?" + : ""}`); + } + } + } + } + else { + sendPluginChatMessage(sender, { + text: `(joining server under ${sender.username}/Eaglercraft username)`, + color: "aqua", + }); + sendPluginChatMessage(sender, { + text: "Attempting to switch servers, please wait... (if you don't get connected to the target server for a while, the server might be online only)", + color: "gray", + }); + try { + sender._serverSwitchLock = true; + await sender.switchServers({ + host: addr, + port: addrPort, + version: "1.8.8", + username: sender.username, + auth: "offline", + keepAlive: false, + skipValidation: true, + hideErrors: true, + }); + sender._serverSwitchLock = false; + } + catch (err) { + if (sender.state != Enums.ClientState.DISCONNECTED) { + sender.disconnect(Enums.ChatColor.RED + + `Something went wrong whilst switching servers: ${err.message}${err.code == "ENOTFOUND" + ? addr.includes(":") + ? `\n${Enums.ChatColor.GRAY}Suggestion: Replace the : in your IP with a space.` + : "\nIs that IP valid?" + : ""}`); + } + } + } +} diff --git a/server/plugins/EagProxyAAS/config.js b/server/plugins/EagProxyAAS/config.js new file mode 100644 index 0000000..622d686 --- /dev/null +++ b/server/plugins/EagProxyAAS/config.js @@ -0,0 +1,12 @@ +export const config = { + bindInternalServerPort: 25569, + bindInternalServerIp: "127.0.0.1", + allowCustomPorts: true, + allowDirectConnectEndpoints: false, + disallowHypixel: false, + showDisclaimers: false, + authentication: { + enabled: false, + password: "nope", + }, +}; diff --git a/server/plugins/EagProxyAAS/index.js b/server/plugins/EagProxyAAS/index.js new file mode 100644 index 0000000..56d3f8d --- /dev/null +++ b/server/plugins/EagProxyAAS/index.js @@ -0,0 +1,211 @@ +import { config } from "./config.js"; +import { createServer } from "minecraft-protocol"; +import { ConnectionState } from "./types.js"; +import { handleConnect, hushConsole, sendChatComponent, setSG } from "./utils.js"; +import path from "path"; +import { readFileSync } from "fs"; +import { handleCommand } from "./commands.js"; +import { registerEndpoints } from "./service/endpoints.js"; +const PluginManager = PLUGIN_MANAGER; +const metadata = JSON.parse(readFileSync(process.platform == "win32" ? path.join(path.dirname(new URL(import.meta.url).pathname), "metadata.json").slice(1) : path.join(path.dirname(new URL(import.meta.url).pathname), "metadata.json")).toString()); +const Logger = PluginManager.Logger; +const Enums = PluginManager.Enums; +const Chat = PluginManager.Chat; +const Constants = PluginManager.Constants; +const Motd = PluginManager.Motd; +const Player = PluginManager.Player; +const MineProtocol = PluginManager.MineProtocol; +const EaglerSkins = PluginManager.EaglerSkins; +const Util = PluginManager.Util; +hushConsole(); +const logger = new Logger("EaglerProxyAAS"); +logger.info(`Starting ${metadata.name} v${metadata.version}...`); +logger.info(`(internal server port: ${config.bindInternalServerPort}, internal server IP: ${config.bindInternalServerIp})`); +logger.info("Starting internal server..."); +let server = createServer({ + host: config.bindInternalServerIp, + port: config.bindInternalServerPort, + motdMsg: `${Enums.ChatColor.GOLD}EaglerProxy as a Service`, + "online-mode": false, + version: "1.8.9", +}), sGlobals = { + server: server, + players: new Map(), +}; +setSG(sGlobals); +server.on("login", (client) => { + const proxyPlayer = PluginManager.proxy.players.get(client.username); + if (proxyPlayer != null) { + const url = new URL(proxyPlayer.ws.httpRequest.url, `http${PluginManager.proxy.config.tls?.enabled ? "s" : ""}://${proxyPlayer.ws.httpRequest.headers.host}`); + if (url.pathname == "/connect-vanilla") { + const host = url.searchParams.get("ip"), port = url.searchParams.get("port"), type = url.searchParams.get("authType"); + if (isNaN(Number(port))) + return proxyPlayer.disconnect(Enums.ChatColor.RED + "Bad port number"); + if (!/^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$|^(([a-zA-Z]|[a-zA-Z][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$|^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$/.test(host)) { + return proxyPlayer.disconnect(Enums.ChatColor.RED + "Bad host provided"); + } + if (type == "ONLINE") { + const _profile = proxyPlayer.ws.httpRequest.headers["Minecraft-Profile"]; + if (!_profile) + proxyPlayer.disconnect(Enums.ChatColor.RED + "Missing Minecraft-Profile header"); + let profile; + try { + profile = JSON.parse(_profile); + } + catch (err) { + proxyPlayer.disconnect(Enums.ChatColor.RED + "Could not read Minecraft-Profile header"); + } + logger.info(`Direct OFFLINE proxy forward connection from Eaglercraft player (${client.username}) received.`); + proxyPlayer.on("vanillaPacket", (packet, origin) => { + if (origin == "CLIENT" && packet.name == "chat" && packet.params.message.toLowerCase().startsWith("/eag-") && !packet.cancel) { + packet.cancel = true; + handleCommand(proxyPlayer, packet.params.message); + } + }); + sendChatComponent(client, { + text: `Joining server under ${profile.selectedProfile.name}/your Minecraft account's username! Run `, + color: "aqua", + extra: [ + { + text: "/eag-help", + color: "gold", + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Click me to run this command!", + }, + clickEvent: { + action: "run_command", + value: "/eag-help", + }, + }, + { + text: " for a list of proxy commands.", + color: "aqua", + }, + ], + }); + proxyPlayer._onlineSession = { + auth: "mojang", + username: profile.selectedProfile.name, + session: { + accessToken: profile.accessToken, + clientToken: profile.selectedProfile.id, + selectedProfile: { + id: profile.selectedProfile.id, + name: profile.selectedProfile.name, + }, + }, + }; + proxyPlayer + .switchServers({ + host: host, + port: Number(port), + version: "1.8.8", + username: profile.selectedProfile.name, + auth: "mojang", + keepAlive: false, + session: { + accessToken: profile.accessToken, + clientToken: profile.selectedProfile.id, + selectedProfile: { + id: profile.selectedProfile.id, + name: profile.selectedProfile.name, + }, + }, + skipValidation: true, + hideErrors: true, + }) + .catch((err) => { + if (!client.ended) { + proxyPlayer.disconnect(Enums.ChatColor.RED + + `Something went wrong whilst switching servers: ${err.message}${err.code == "ENOTFOUND" ? (host.includes(":") ? `\n${Enums.ChatColor.GRAY}Suggestion: Replace the : in your IP with a space.` : "\nIs that IP valid?") : ""}`); + } + }); + } + else if (type == "OFFLINE") { + logger.info(`Direct ONLINE proxy forward connection from Eaglercraft player (${client.username}) received.`); + logger.info(`Player ${client.username} is attempting to connect to ${host}:${port} under their Eaglercraft username (${client.username}) using offline mode!`); + proxyPlayer.on("vanillaPacket", (packet, origin) => { + if (origin == "CLIENT" && packet.name == "chat" && packet.params.message.toLowerCase().startsWith("/eag-") && !packet.cancel) { + packet.cancel = true; + handleCommand(proxyPlayer, packet.params.message); + } + }); + sendChatComponent(client, { + text: `Joining server under ${client.username}/your Eaglercraft account's username! Run `, + color: "aqua", + extra: [ + { + text: "/eag-help", + color: "gold", + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Click me to run this command!", + }, + clickEvent: { + action: "run_command", + value: "/eag-help", + }, + }, + { + text: " for a list of proxy commands.", + color: "aqua", + }, + ], + }); + proxyPlayer + .switchServers({ + host: host, + port: Number(port), + auth: "offline", + username: client.username, + version: "1.8.8", + keepAlive: false, + skipValidation: true, + hideErrors: true, + }) + .catch((err) => { + if (!client.ended) { + proxyPlayer.disconnect(Enums.ChatColor.RED + + `Something went wrong whilst switching servers: ${err.message}${err.code == "ENOTFOUND" ? (host.includes(":") ? `\n${Enums.ChatColor.GRAY}Suggestion: Replace the : in your IP with a space.` : "\nIs that IP valid?") : ""}`); + } + }); + } + else { + proxyPlayer.disconnect(Enums.ChatColor.RED + "Missing authentication type"); + } + } + else { + logger.info(`Client ${client.username} has connected to the authentication server.`); + client.on("end", () => { + sGlobals.players.delete(client.username); + logger.info(`Client ${client.username} has disconnected from the authentication server.`); + }); + const cs = { + state: ConnectionState.AUTH, + gameClient: client, + token: null, + lastStatusUpdate: null, + }; + sGlobals.players.set(client.username, cs); + handleConnect(cs); + } + } + else { + logger.warn(`Proxy player object is null for ${client.username}?!`); + client.end("Indirect connection to internal authentication server detected!"); + } +}); +logger.info("Redirecting backend server IP... (this is required for the plugin to function)"); +CONFIG.adapter.server = { + host: config.bindInternalServerIp, + port: config.bindInternalServerPort, +}; +CONFIG.adapter.motd = { + l1: Enums.ChatColor.GOLD + "EaglerProxy as a Service", +}; +if (config.allowDirectConnectEndpoints) { + PLUGIN_MANAGER.addListener("proxyFinishLoading", () => { + registerEndpoints(); + }); +} diff --git a/server/plugins/EagProxyAAS/metadata.json b/server/plugins/EagProxyAAS/metadata.json new file mode 100644 index 0000000..832d254 --- /dev/null +++ b/server/plugins/EagProxyAAS/metadata.json @@ -0,0 +1,34 @@ +{ + "name": "EaglerProxy as a Service", + "id": "eagpaas", + "version": "1.2.1", + "entry_point": "index.js", + "requirements": [ + { + "id": "eaglerproxy", + "version": "any" + }, + { + "id": "module:vec3", + "version": "^0.1.0" + }, + { + "id": "module:prismarine-chunk", + "version": "^1.33.0" + }, + { + "id": "module:prismarine-block", + "version": "^1.16.0" + }, + { + "id": "module:prismarine-registry", + "version": "^1.6.0" + }, + { + "id": "module:minecraft-protocol", + "version": "^1.40.0" + } + ], + "load_after": [], + "incompatibilities": [] +} diff --git a/server/plugins/EagProxyAAS/service/endpoints.js b/server/plugins/EagProxyAAS/service/endpoints.js new file mode 100644 index 0000000..60ff907 --- /dev/null +++ b/server/plugins/EagProxyAAS/service/endpoints.js @@ -0,0 +1,88 @@ +import { auth } from "../auth.js"; +import { config } from "../config.js"; +export async function registerEndpoints() { + const proxy = PLUGIN_MANAGER.proxy; + proxy.on("httpConnection", (req, res, ctx) => { + if (req.url.startsWith("/eagpaas/metadata")) { + ctx.handled = true; + res.writeHead(200).end(JSON.stringify({ + branding: "EagProxyAAS", + version: "1", + })); + } + else if (req.url.startsWith("/eagpaas/validate")) { + ctx.handled = true; + if (config.authentication.enabled) { + if (req.headers["authorization"] !== `Basic ${config.authentication.password}`) { + return res.writeHead(403).end(JSON.stringify({ + success: false, + reason: "Access Denied", + })); + } + } + res.writeHead(200).end(JSON.stringify({ + success: true, + })); + } + }); + proxy.on("wsConnection", (ws, req, ctx) => { + try { + if (req.url.startsWith("/eagpaas/token")) { + ctx.handled = true; + if (config.authentication.enabled) { + if (req.headers.authorization !== `Basic ${config.authentication.password}`) { + ws.send(JSON.stringify({ + type: "ERROR", + error: "Access Denied", + })); + ws.close(); + return; + } + } + const quit = { quit: false }, authHandler = auth(quit), codeCallback = (code) => { + ws.send(JSON.stringify({ + type: "CODE", + data: code, + })); + }; + ws.once("close", () => { + quit.quit = true; + }); + authHandler + .on("code", codeCallback) + .on("error", (err) => { + ws.send(JSON.stringify({ + type: "ERROR", + reason: err, + })); + ws.close(); + }) + .on("done", (result) => { + ws.send(JSON.stringify({ + type: "COMPLETE", + data: result, + })); + ws.close(); + }); + } + else if (req.url.startsWith("/eagpaas/ping")) { + ctx.handled = true; + if (config.authentication.enabled) { + if (req.headers.authorization !== `Basic ${config.authentication.password}`) { + ws.send(JSON.stringify({ + type: "ERROR", + error: "Access Denied", + })); + ws.close(); + return; + } + } + ws.once("message", (_) => { + ws.send(_); + ws.close(); + }); + } + } + catch (err) { } + }); +} diff --git a/server/plugins/EagProxyAAS/types.js b/server/plugins/EagProxyAAS/types.js new file mode 100644 index 0000000..aed7643 --- /dev/null +++ b/server/plugins/EagProxyAAS/types.js @@ -0,0 +1,38 @@ +export var ConnectionState; +(function (ConnectionState) { + ConnectionState[ConnectionState["AUTH"] = 0] = "AUTH"; + ConnectionState[ConnectionState["SUCCESS"] = 1] = "SUCCESS"; + ConnectionState[ConnectionState["DISCONNECTED"] = 2] = "DISCONNECTED"; +})(ConnectionState || (ConnectionState = {})); +export var ChatColor; +(function (ChatColor) { + ChatColor["BLACK"] = "\u00A70"; + ChatColor["DARK_BLUE"] = "\u00A71"; + ChatColor["DARK_GREEN"] = "\u00A72"; + ChatColor["DARK_CYAN"] = "\u00A73"; + ChatColor["DARK_RED"] = "\u00A74"; + ChatColor["PURPLE"] = "\u00A75"; + ChatColor["GOLD"] = "\u00A76"; + ChatColor["GRAY"] = "\u00A77"; + ChatColor["DARK_GRAY"] = "\u00A78"; + ChatColor["BLUE"] = "\u00A79"; + ChatColor["BRIGHT_GREEN"] = "\u00A7a"; + ChatColor["CYAN"] = "\u00A7b"; + ChatColor["RED"] = "\u00A7c"; + ChatColor["PINK"] = "\u00A7d"; + ChatColor["YELLOW"] = "\u00A7e"; + ChatColor["WHITE"] = "\u00A7f"; + // text styling + ChatColor["OBFUSCATED"] = "\u00A7k"; + ChatColor["BOLD"] = "\u00A7l"; + ChatColor["STRIKETHROUGH"] = "\u00A7m"; + ChatColor["UNDERLINED"] = "\u00A7n"; + ChatColor["ITALIC"] = "\u00A7o"; + ChatColor["RESET"] = "\u00A7r"; +})(ChatColor || (ChatColor = {})); +export var ConnectType; +(function (ConnectType) { + ConnectType["ONLINE"] = "ONLINE"; + ConnectType["OFFLINE"] = "OFFLINE"; + ConnectType["EASYMC"] = "EASYMC"; +})(ConnectType || (ConnectType = {})); diff --git a/server/plugins/EagProxyAAS/utils.js b/server/plugins/EagProxyAAS/utils.js new file mode 100644 index 0000000..7cf91e1 --- /dev/null +++ b/server/plugins/EagProxyAAS/utils.js @@ -0,0 +1,766 @@ +import { ConnectType } from "./types.js"; +import * as Chunk from "prismarine-chunk"; +import * as Block from "prismarine-block"; +import * as Registry from "prismarine-registry"; +import vec3 from "vec3"; +import { ConnectionState } from "./types.js"; +import { auth } from "./auth.js"; +import { config } from "./config.js"; +import { handleCommand } from "./commands.js"; +import { getTokenProfileEasyMc } from "./auth_easymc.js"; +const { Vec3 } = vec3; +const Enums = PLUGIN_MANAGER.Enums; +const Util = PLUGIN_MANAGER.Util; +const MAX_LIFETIME_CONNECTED = 10 * 60 * 1000, MAX_LIFETIME_AUTH = 5 * 60 * 1000, MAX_LIFETIME_LOGIN = 1 * 60 * 1000; +const REGISTRY = Registry.default("1.8.8"), McBlock = Block.default("1.8.8"), LOGIN_CHUNK = generateSpawnChunk().dump(); +const logger = new PLUGIN_MANAGER.Logger("PlayerHandler"); +let SERVER = null; +export function hushConsole() { + const ignoredMethod = () => { }; + global.console.info = ignoredMethod; + global.console.warn = ignoredMethod; + global.console.error = ignoredMethod; + global.console.debug = ignoredMethod; +} +export function setSG(svr) { + SERVER = svr; +} +export function disconectIdle() { + SERVER.players.forEach((client) => { + if (client.state == ConnectionState.AUTH && Date.now() - client.lastStatusUpdate > MAX_LIFETIME_AUTH) { + client.gameClient.end("Timed out waiting for user to login via Microsoft!"); + } + else if (client.state == ConnectionState.SUCCESS && Date.now() - client.lastStatusUpdate > MAX_LIFETIME_CONNECTED) { + client.gameClient.end(Enums.ChatColor.RED + "Please enter the IP of the server you'd like to connect to in chat."); + } + }); +} +export function handleConnect(client) { + client.gameClient.write("login", { + entityId: 1, + gameMode: 2, + dimension: 1, + difficulty: 1, + maxPlayers: 1, + levelType: "flat", + reducedDebugInfo: false, + }); + client.gameClient.write("map_chunk", { + x: 0, + z: 0, + groundUp: true, + bitMap: 0xffff, + chunkData: LOGIN_CHUNK, + }); + client.gameClient.write("position", { + x: 0, + y: 65, + z: 8.5, + yaw: -90, + pitch: 0, + flags: 0x01, + }); + client.gameClient.write("playerlist_header", { + header: JSON.stringify({ + text: ` ${Enums.ChatColor.GOLD}EaglerProxy Authentication Server `, + }), + footer: JSON.stringify({ + text: `${Enums.ChatColor.GOLD}Please wait for instructions.`, + }), + }); + onConnect(client); +} +export function awaitCommand(client, filter) { + return new Promise((res, rej) => { + const onMsg = (packet) => { + if (filter(packet.message)) { + client.removeListener("chat", onMsg); + client.removeListener("end", onEnd); + res(packet.message); + } + }; + const onEnd = () => rej("Client disconnected before promise could be resolved"); + client.on("chat", onMsg); + client.on("end", onEnd); + }); +} +export function sendMessage(client, msg) { + client.write("chat", { + message: JSON.stringify({ text: msg }), + position: 1, + }); +} +export function sendCustomMessage(client, msg, color, ...components) { + client.write("chat", { + message: JSON.stringify(components.length > 0 + ? { + text: msg, + color, + extra: components, + } + : { text: msg, color }), + position: 1, + }); +} +export function sendChatComponent(client, component) { + client.write("chat", { + message: JSON.stringify(component), + position: 1, + }); +} +export function sendMessageWarning(client, msg) { + client.write("chat", { + message: JSON.stringify({ + text: msg, + color: "yellow", + }), + position: 1, + }); +} +export function sendMessageLogin(client, url, token) { + client.write("chat", { + message: JSON.stringify({ + text: "Please open ", + color: Enums.ChatColor.RESET, + extra: [ + { + text: "this link", + color: "gold", + clickEvent: { + action: "open_url", + value: `${url}/?otc=${token}`, + }, + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Click to open me in a new window!", + }, + }, + { + text: " to authenticate via Microsoft.", + }, + ], + }), + position: 1, + }); +} +export function updateState(client, newState, uri, code) { + switch (newState) { + case "CONNECTION_TYPE": + client.write("playerlist_header", { + header: JSON.stringify({ + text: ` ${Enums.ChatColor.GOLD}EaglerProxy Authentication Server `, + }), + footer: JSON.stringify({ + text: `${Enums.ChatColor.RED}Choose the connection type: 1 = online, 2 = offline, 3 = EasyMC.`, + }), + }); + break; + case "AUTH_EASYMC": + client.write("playerlist_header", { + header: JSON.stringify({ + text: ` ${Enums.ChatColor.GOLD}EaglerProxy Authentication Server `, + }), + footer: JSON.stringify({ + text: `${Enums.ChatColor.RED}easymc.io/get${Enums.ChatColor.GOLD} | ${Enums.ChatColor.RED}/login `, + }), + }); + break; + case "AUTH": + if (code == null || uri == null) + throw new Error("Missing code/uri required for title message type AUTH"); + client.write("playerlist_header", { + header: JSON.stringify({ + text: ` ${Enums.ChatColor.GOLD}EaglerProxy Authentication Server `, + }), + footer: JSON.stringify({ + text: `${Enums.ChatColor.RED}${uri}${Enums.ChatColor.GOLD} | Code: ${Enums.ChatColor.RED}${code}`, + }), + }); + break; + case "SERVER": + client.write("playerlist_header", { + header: JSON.stringify({ + text: ` ${Enums.ChatColor.GOLD}EaglerProxy Authentication Server `, + }), + footer: JSON.stringify({ + text: `${Enums.ChatColor.RED}/join ${config.allowCustomPorts ? " [port]" : ""}`, + }), + }); + break; + } +} +// assuming that the player will always stay at the same pos +export function playSelectSound(client) { + client.write("named_sound_effect", { + soundName: "note.hat", + x: 8.5, + y: 65, + z: 8.5, + volume: 100, + pitch: 63, + }); +} +export async function onConnect(client) { + try { + client.state = ConnectionState.AUTH; + client.lastStatusUpdate = Date.now(); + client.gameClient.on("packet", (packet, meta) => { + if (meta.name == "client_command" && packet.payload == 1) { + client.gameClient.write("statistics", { + entries: [], + }); + } + }); + if (config.showDisclaimers) { + sendMessageWarning(client.gameClient, `WARNING: This proxy allows you to connect to any 1.8.9 server. Gameplay has shown no major issues, but please note that EaglercraftX may flag some anticheats while playing.`); + await new Promise((res) => setTimeout(res, 2000)); + sendMessageWarning(client.gameClient, `ADVISORY FOR HYPIXEL PLAYERS: THIS PROXY FALLS UNDER HYPIXEL'S "DISALLOWED MODIFICATIONS" MOD CATEGORY. JOINING THE SERVER WILL RESULT IN AN IRREPEALABLE PUNISHMENT BEING APPLIED TO YOUR ACCOUNT. YOU HAVE BEEN WARNED - PLAY AT YOUR OWN RISK!`); + await new Promise((res) => setTimeout(res, 2000)); + sendMessageWarning(client.gameClient, `WARNING: It is highly suggested that you turn down settings, as gameplay tends to be very laggy and unplayable on low powered devices.`); + await new Promise((res) => setTimeout(res, 2000)); + } + if (config.authentication.enabled) { + sendCustomMessage(client.gameClient, "This instance is password-protected. Sign in with /password ", "gold"); + const password = await awaitCommand(client.gameClient, (msg) => msg.startsWith("/password ")); + if (password === `/password ${config.authentication.password}`) { + sendCustomMessage(client.gameClient, "Successfully signed into instance!", "green"); + } + else { + client.gameClient.end(Enums.ChatColor.RED + "Bad password!"); + return; + } + } + sendCustomMessage(client.gameClient, "What would you like to do?", "gray"); + sendChatComponent(client.gameClient, { + text: "1) ", + color: "gold", + extra: [ + { + text: "Connect to an online server (Minecraft account needed)", + color: "white", + }, + ], + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Click me to select!", + }, + clickEvent: { + action: "run_command", + value: "$1", + }, + }); + sendChatComponent(client.gameClient, { + text: "2) ", + color: "gold", + extra: [ + { + text: "Connect to an offline server (no Minecraft account needed)", + color: "white", + }, + ], + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Click me to select!", + }, + clickEvent: { + action: "run_command", + value: "$2", + }, + }); + sendChatComponent(client.gameClient, { + text: "3) ", + color: "gold", + extra: [ + { + text: "Connect to an online server via EasyMC account pool (no Minecraft account needed)", + color: "white", + }, + ], + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Click me to select!", + }, + clickEvent: { + action: "run_command", + value: "$3", + }, + }); + sendCustomMessage(client.gameClient, "Select an option from the above (1 = online, 2 = offline, 3 = EasyMC), either by clicking or manually typing out the option's number on the list.", "green"); + updateState(client.gameClient, "CONNECTION_TYPE"); + let chosenOption = null; + while (true) { + const option = await awaitCommand(client.gameClient, (msg) => true); + switch (option.replace(/\$/gim, "")) { + default: + sendCustomMessage(client.gameClient, `I don't understand what you meant by "${option}", please reply with a valid option!`, "red"); + break; + case "1": + chosenOption = ConnectType.ONLINE; + break; + case "2": + chosenOption = ConnectType.OFFLINE; + break; + case "3": + chosenOption = ConnectType.EASYMC; + break; + } + if (chosenOption != null) { + if (option.startsWith("$")) + playSelectSound(client.gameClient); + break; + } + } + if (chosenOption == ConnectType.ONLINE) { + if (config.showDisclaimers) { + sendMessageWarning(client.gameClient, `WARNING: You will be prompted to log in via Microsoft to obtain a session token necessary to join games. Any data related to your account will not be saved and for transparency reasons this proxy's source code is available on Github.`); + } + await new Promise((res) => setTimeout(res, 2000)); + client.lastStatusUpdate = Date.now(); + let errored = false, savedAuth; + const quit = { quit: false }, authHandler = auth(quit), codeCallback = (code) => { + updateState(client.gameClient, "AUTH", code.verification_uri, code.user_code); + sendMessageLogin(client.gameClient, code.verification_uri, code.user_code); + }; + client.gameClient.once("end", (res) => { + quit.quit = true; + }); + authHandler.once("error", (err) => { + if (!client.gameClient.ended) + client.gameClient.end(err.message); + errored = true; + }); + if (errored) + return; + authHandler.on("code", codeCallback); + await new Promise((res) => authHandler.once("done", (result) => { + console.log(result); + savedAuth = result; + res(result); + })); + sendMessage(client.gameClient, Enums.ChatColor.BRIGHT_GREEN + "Successfully logged into Minecraft!"); + client.state = ConnectionState.SUCCESS; + client.lastStatusUpdate = Date.now(); + updateState(client.gameClient, "SERVER"); + sendMessage(client.gameClient, `Provide a server to join. ${Enums.ChatColor.GOLD}/join ${config.allowCustomPorts ? " [port]" : ""}${Enums.ChatColor.RESET}.`); + let host, port; + while (true) { + const msg = await awaitCommand(client.gameClient, (msg) => msg.startsWith("/join")), parsed = msg.split(/ /gi, 3); + if (parsed.length < 2) + sendMessage(client.gameClient, `Please provide a server to connect to. ${Enums.ChatColor.GOLD}/join ${config.allowCustomPorts ? " [port]" : ""}${Enums.ChatColor.RESET}.`); + else if (parsed.length > 2 && isNaN(parseInt(parsed[2]))) + sendMessage(client.gameClient, `A valid port number has to be passed! ${Enums.ChatColor.GOLD}/join ${config.allowCustomPorts ? " [port]" : ""}${Enums.ChatColor.RESET}.`); + else { + host = parsed[1]; + if (parsed.length > 2) + port = parseInt(parsed[2]); + if (port != null && !config.allowCustomPorts) { + sendCustomMessage(client.gameClient, "You are not allowed to use custom server ports! /join " + (config.allowCustomPorts ? " [port]" : ""), "red"); + host = null; + port = null; + } + else { + if (host.match(/^(?:\*\.)?((?!hypixel\.net$)[^.]+\.)*hypixel\.net$/) && config.disallowHypixel) { + sendCustomMessage(client.gameClient, "Disallowed server, refusing to connect! Hypixel has been known to falsely flag Eaglercraft clients, and thus we do not allow connecting to their server. /join " + (config.allowCustomPorts ? " [port]" : ""), "red"); + } + else { + port = port ?? 25565; + break; + } + } + } + } + try { + sendChatComponent(client.gameClient, { + text: `Joining server under ${savedAuth.selectedProfile.name}/your Minecraft account's username! Run `, + color: "aqua", + extra: [ + { + text: "/eag-help", + color: "gold", + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Click me to run this command!", + }, + clickEvent: { + action: "run_command", + value: "/eag-help", + }, + }, + { + text: " for a list of proxy commands.", + color: "aqua", + }, + ], + }); + logger.info(`Player ${client.gameClient.username} is attempting to connect to ${host}:${port} under their Minecraft account's username (${savedAuth.selectedProfile.name}) using online mode!`); + const player = PLUGIN_MANAGER.proxy.players.get(client.gameClient.username); + player.on("vanillaPacket", (packet, origin) => { + if (origin == "CLIENT" && packet.name == "chat" && packet.params.message.toLowerCase().startsWith("/eag-") && !packet.cancel) { + packet.cancel = true; + handleCommand(player, packet.params.message); + } + }); + player._onlineSession = { + auth: "mojang", + username: savedAuth.selectedProfile.name, + session: { + accessToken: savedAuth.accessToken, + clientToken: savedAuth.selectedProfile.id, + selectedProfile: { + id: savedAuth.selectedProfile.id, + name: savedAuth.selectedProfile.name, + }, + }, + }; + await player.switchServers({ + host: host, + port: port, + version: "1.8.8", + username: savedAuth.selectedProfile.name, + auth: "mojang", + keepAlive: false, + session: { + accessToken: savedAuth.accessToken, + clientToken: savedAuth.selectedProfile.id, + selectedProfile: { + id: savedAuth.selectedProfile.id, + name: savedAuth.selectedProfile.name, + }, + }, + skipValidation: true, + hideErrors: true, + }); + } + catch (err) { + if (!client.gameClient.ended) { + client.gameClient.end(Enums.ChatColor.RED + + `Something went wrong whilst switching servers: ${err.message}${err.code == "ENOTFOUND" ? (host.includes(":") ? `\n${Enums.ChatColor.GRAY}Suggestion: Replace the : in your IP with a space.` : "\nIs that IP valid?") : ""}`); + } + } + } + else if (chosenOption == ConnectType.EASYMC) { + const EASYMC_GET_TOKEN_URL = "easymc.io/get"; + client.state = ConnectionState.AUTH; + client.lastStatusUpdate = Date.now(); + updateState(client.gameClient, "AUTH_EASYMC"); + sendMessageWarning(client.gameClient, `WARNING: You've chosen to use an account from EasyMC's account pool. Please note that accounts and shared, and may be banned from whatever server you are attempting to join.`); + sendChatComponent(client.gameClient, { + text: "Please generate an alt token at ", + color: "white", + extra: [ + { + text: EASYMC_GET_TOKEN_URL, + color: "gold", + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Click me to open in a new window!", + }, + clickEvent: { + action: "open_url", + value: `https://${EASYMC_GET_TOKEN_URL}`, + }, + }, + { + text: ", and then run ", + color: "white", + }, + { + text: "/login ", + color: "gold", + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Copy me to chat!", + }, + clickEvent: { + action: "suggest_command", + value: `/login `, + }, + }, + { + text: " to log in.", + color: "white", + }, + ], + }); + let appendOptions; + while (true) { + const tokenResponse = await awaitCommand(client.gameClient, (msg) => msg.toLowerCase().startsWith("/login")), splitResponse = tokenResponse.split(/ /gim, 2).slice(1); + if (splitResponse.length != 1) { + sendChatComponent(client.gameClient, { + text: "Invalid usage! Please use the command as follows: ", + color: "red", + extra: [ + { + text: "/login ", + color: "gold", + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Copy me to chat!", + }, + clickEvent: { + action: "suggest_command", + value: `/login `, + }, + }, + { + text: ".", + color: "red", + }, + ], + }); + } + else { + const token = splitResponse[0]; + if (token.length != 20) { + sendChatComponent(client.gameClient, { + text: "Please provide a valid token (you can get one ", + color: "red", + extra: [ + { + text: "here", + color: "white", + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Click me to open in a new window!", + }, + clickEvent: { + action: "open_url", + value: `https://${EASYMC_GET_TOKEN_URL}`, + }, + }, + { + text: "). ", + color: "red", + }, + { + text: "/login ", + color: "gold", + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Copy me to chat!", + }, + clickEvent: { + action: "suggest_command", + value: `/login `, + }, + }, + { + text: ".", + color: "red", + }, + ], + }); + } + else { + sendCustomMessage(client.gameClient, "Validating alt token...", "gray"); + try { + appendOptions = await getTokenProfileEasyMc(token); + sendCustomMessage(client.gameClient, `Successfully validated your alt token and retrieved your session profile! You'll be joining to your preferred server as ${appendOptions.username}.`, "green"); + break; + } + catch (err) { + sendChatComponent(client.gameClient, { + text: `EasyMC's servers replied with an error (${err.message}), please try again! `, + color: "red", + extra: [ + { + text: "/login ", + color: "gold", + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Copy me to chat!", + }, + clickEvent: { + action: "suggest_command", + value: `/login `, + }, + }, + { + text: ".", + color: "red", + }, + ], + }); + } + } + } + } + client.state = ConnectionState.SUCCESS; + client.lastStatusUpdate = Date.now(); + updateState(client.gameClient, "SERVER"); + sendMessage(client.gameClient, `Provide a server to join. ${Enums.ChatColor.GOLD}/join ${config.allowCustomPorts ? " [port]" : ""}${Enums.ChatColor.RESET}.`); + let host, port; + while (true) { + const msg = await awaitCommand(client.gameClient, (msg) => msg.startsWith("/join")), parsed = msg.split(/ /gi, 3); + if (parsed.length < 2) + sendMessage(client.gameClient, `Please provide a server to connect to. ${Enums.ChatColor.GOLD}/join ${config.allowCustomPorts ? " [port]" : ""}${Enums.ChatColor.RESET}.`); + else if (parsed.length > 2 && isNaN(parseInt(parsed[2]))) + sendMessage(client.gameClient, `A valid port number has to be passed! ${Enums.ChatColor.GOLD}/join ${config.allowCustomPorts ? " [port]" : ""}${Enums.ChatColor.RESET}.`); + else { + host = parsed[1]; + if (parsed.length > 2) + port = parseInt(parsed[2]); + if (port != null && !config.allowCustomPorts) { + sendCustomMessage(client.gameClient, "You are not allowed to use custom server ports! /join ", "red"); + host = null; + port = null; + } + else { + port = port ?? 25565; + break; + } + } + } + try { + sendChatComponent(client.gameClient, { + text: `Joining server under ${appendOptions.username}/EasyMC account username! Run `, + color: "aqua", + extra: [ + { + text: "/eag-help", + color: "gold", + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Click me to run this command!", + }, + clickEvent: { + action: "run_command", + value: "/eag-help", + }, + }, + { + text: " for a list of proxy commands.", + color: "aqua", + }, + ], + }); + logger.info(`Player ${client.gameClient.username} is attempting to connect to ${host}:${port} under their EasyMC alt token's username (${appendOptions.username}) using EasyMC mode!`); + const player = PLUGIN_MANAGER.proxy.players.get(client.gameClient.username); + player.on("vanillaPacket", (packet, origin) => { + if (origin == "CLIENT" && packet.name == "chat" && packet.params.message.toLowerCase().startsWith("/eag-") && !packet.cancel) { + packet.cancel = true; + handleCommand(player, packet.params.message); + } + }); + player._onlineSession = { + ...appendOptions, + isEasyMC: true, + }; + await player.switchServers({ + host: host, + port: port, + version: "1.8.8", + keepAlive: false, + skipValidation: true, + hideErrors: true, + ...appendOptions, + }); + } + catch (err) { + if (!client.gameClient.ended) { + client.gameClient.end(Enums.ChatColor.RED + + `Something went wrong whilst switching servers: ${err.message}${err.code == "ENOTFOUND" ? (host.includes(":") ? `\n${Enums.ChatColor.GRAY}Suggestion: Replace the : in your IP with a space.` : "\nIs that IP valid?") : ""}`); + } + } + } + else { + client.state = ConnectionState.SUCCESS; + client.lastStatusUpdate = Date.now(); + updateState(client.gameClient, "SERVER"); + sendMessage(client.gameClient, `Provide a server to join. ${Enums.ChatColor.GOLD}/join ${config.allowCustomPorts ? " [port]" : ""}${Enums.ChatColor.RESET}.`); + let host, port; + while (true) { + const msg = await awaitCommand(client.gameClient, (msg) => msg.startsWith("/join")), parsed = msg.split(/ /gi, 3); + if (parsed.length < 2) + sendMessage(client.gameClient, `Please provide a server to connect to. ${Enums.ChatColor.GOLD}/join ${config.allowCustomPorts ? " [port]" : ""}${Enums.ChatColor.RESET}.`); + else if (parsed.length > 2 && isNaN(parseInt(parsed[2]))) + sendMessage(client.gameClient, `A valid port number has to be passed! ${Enums.ChatColor.GOLD}/join ${config.allowCustomPorts ? " [port]" : ""}${Enums.ChatColor.RESET}.`); + else { + host = parsed[1]; + if (parsed.length > 2) + port = parseInt(parsed[2]); + if (port != null && !config.allowCustomPorts) { + sendCustomMessage(client.gameClient, "You are not allowed to use custom server ports! /join ", "red"); + host = null; + port = null; + } + else { + port = port ?? 25565; + break; + } + } + } + try { + sendChatComponent(client.gameClient, { + text: `Joining server under ${client.gameClient.username}/Eaglercraft username! Run `, + color: "aqua", + extra: [ + { + text: "/eag-help", + color: "gold", + hoverEvent: { + action: "show_text", + value: Enums.ChatColor.GOLD + "Click me to run this command!", + }, + clickEvent: { + action: "run_command", + value: "/eag-help", + }, + }, + { + text: " for a list of proxy commands.", + color: "aqua", + }, + ], + }); + logger.info(`Player ${client.gameClient.username} is attempting to connect to ${host}:${port} under their Eaglercraft username (${client.gameClient.username}) using offline mode!`); + const player = PLUGIN_MANAGER.proxy.players.get(client.gameClient.username); + player.on("vanillaPacket", (packet, origin) => { + if (origin == "CLIENT" && packet.name == "chat" && packet.params.message.toLowerCase().startsWith("/eag-") && !packet.cancel) { + packet.cancel = true; + handleCommand(player, packet.params.message); + } + }); + await player.switchServers({ + host: host, + port: port, + auth: "offline", + username: client.gameClient.username, + version: "1.8.8", + keepAlive: false, + skipValidation: true, + hideErrors: true, + }); + } + catch (err) { + if (!client.gameClient.ended) { + client.gameClient.end(Enums.ChatColor.RED + + `Something went wrong whilst switching servers: ${err.message}${err.code == "ENOTFOUND" ? (host.includes(":") ? `\n${Enums.ChatColor.GRAY}Suggestion: Replace the : in your IP with a space.` : "\nIs that IP valid?") : ""}`); + } + } + } + } + catch (err) { + if (!client.gameClient.ended) { + logger.error(`Error whilst processing user ${client.gameClient.username}: ${err.stack || err}`); + client.gameClient.end(Enums.ChatColor.YELLOW + "Something went wrong whilst processing your request. Please reconnect."); + } + } +} +export function generateSpawnChunk() { + const chunk = new (Chunk.default(REGISTRY))(null); + chunk.initialize(() => new McBlock(REGISTRY.blocksByName.air.id, REGISTRY.biomesByName.the_end.id, 0)); + chunk.setBlock(new Vec3(8, 64, 8), new McBlock(REGISTRY.blocksByName.sea_lantern.id, REGISTRY.biomesByName.the_end.id, 0)); + chunk.setBlock(new Vec3(8, 67, 8), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.the_end.id, 0)); + chunk.setBlock(new Vec3(7, 65, 8), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.the_end.id, 0)); + chunk.setBlock(new Vec3(7, 66, 8), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.the_end.id, 0)); + chunk.setBlock(new Vec3(9, 65, 8), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.the_end.id, 0)); + chunk.setBlock(new Vec3(9, 66, 8), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.the_end.id, 0)); + chunk.setBlock(new Vec3(8, 65, 7), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.the_end.id, 0)); + chunk.setBlock(new Vec3(8, 66, 7), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.the_end.id, 0)); + chunk.setBlock(new Vec3(8, 65, 9), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.the_end.id, 0)); + chunk.setBlock(new Vec3(8, 66, 9), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.the_end.id, 0)); + // chunk.setBlockLight(new Vec3(8, 65, 8), 15); + chunk.setBlockLight(new Vec3(8, 66, 8), 15); + return chunk; +} diff --git a/server/proxy/BungeeUtil.js b/server/proxy/BungeeUtil.js new file mode 100644 index 0000000..d76b7c2 --- /dev/null +++ b/server/proxy/BungeeUtil.js @@ -0,0 +1,98 @@ +import { Logger } from "../logger.js"; +import mcp from "minecraft-protocol"; +const { createSerializer, createDeserializer } = mcp; +export var BungeeUtil; +(function (BungeeUtil) { + class PacketUUIDTranslator { + serverSidePlayerUUID; + clientSidePlayerUUID; + static CAST_UUID_SERVER = [ + "update_attributes", + "named_entity_spawn", + // drop this packet (twitch.tv integration not available anymore) + "player_info", + ]; + static CAST_UUID_CLIENT = ["spectate"]; + _logger; + constructor(ssPlayerUUID, csPlayerUUID) { + this.serverSidePlayerUUID = ssPlayerUUID; + this.clientSidePlayerUUID = csPlayerUUID; + this._logger = new Logger("PacketTranslator"); + } + translatePacketClient(packet, meta) { + if (meta.name == "spectate") { + if (packet.target == this.clientSidePlayerUUID) { + packet.target = this.serverSidePlayerUUID; + } + else if (packet.target == this.serverSidePlayerUUID) { + packet.target = this.clientSidePlayerUUID; + } + } + return [meta.name, packet]; + } + translatePacketServer(packet, meta) { + if (meta.name == "update_attributes") { + for (const prop of packet.properties) { + for (const modifier of prop.modifiers) { + if (modifier.uuid == this.serverSidePlayerUUID) { + modifier.uuid = this.clientSidePlayerUUID; + } + else if (modifier.uuid == this.clientSidePlayerUUID) { + modifier.uuid = this.serverSidePlayerUUID; + } + } + } + } + else if (meta.name == "named_entity_spawn") { + if (packet.playerUUID == this.serverSidePlayerUUID) { + packet.playerUUID = this.clientSidePlayerUUID; + } + else if (packet.playerUUID == this.clientSidePlayerUUID) { + packet.playerUUID = this.serverSidePlayerUUID; + } + } + else if (meta.name == "player_info") { + for (const player of packet.data) { + if (player.UUID == this.serverSidePlayerUUID) { + player.UUID = this.clientSidePlayerUUID; + } + else if (player.UUID == this.clientSidePlayerUUID) { + player.UUID = this.serverSidePlayerUUID; + } + } + } + return [meta.name, packet]; + } + } + BungeeUtil.PacketUUIDTranslator = PacketUUIDTranslator; + function getRespawnSequence(login, serializer) { + const dimset = getDimSets(login.dimension); + return [ + serializer.createPacketBuffer({ + name: "respawn", + params: { + dimension: dimset[0], + difficulty: login.difficulty, + gamemode: login.gameMode, + levelType: login.levelType, + }, + }), + serializer.createPacketBuffer({ + name: "respawn", + params: { + dimension: dimset[1], + difficulty: login.difficulty, + gamemode: login.gameMode, + levelType: login.levelType, + }, + }), + ]; + } + BungeeUtil.getRespawnSequence = getRespawnSequence; + function getDimSets(loginDim) { + return [ + loginDim == -1 ? 0 : loginDim == 0 ? -1 : loginDim == 1 ? 0 : 0, + loginDim, + ]; + } +})(BungeeUtil || (BungeeUtil = {})); diff --git a/server/proxy/Chat.js b/server/proxy/Chat.js new file mode 100644 index 0000000..c84ec19 --- /dev/null +++ b/server/proxy/Chat.js @@ -0,0 +1,39 @@ +import { Enums } from "./Enums.js"; +export var Chat; +(function (Chat) { + function chatToPlainString(chat) { + let ret = ""; + if (chat.text != null) + ret += chat.text; + if (chat.extra != null) { + chat.extra.forEach((extra) => { + let append = ""; + if (extra.bold) + append += Enums.ChatColor.BOLD; + if (extra.italic) + append += Enums.ChatColor.ITALIC; + if (extra.underlined) + append += Enums.ChatColor.UNDERLINED; + if (extra.strikethrough) + append += Enums.ChatColor.STRIKETHROUGH; + if (extra.obfuscated) + append += Enums.ChatColor.OBFUSCATED; + if (extra.color) + append += + extra.color == "reset" + ? Enums.ChatColor.RESET + : resolveColor(extra.color); + append += extra.text; + ret += append; + }); + } + return ret; + } + Chat.chatToPlainString = chatToPlainString; + const ccValues = Object.values(Enums.ChatColor); + const ccKeys = Object.keys(Enums.ChatColor).map((str) => str.toLowerCase()); + function resolveColor(colorStr) { + return (Object.values(Enums.ChatColor)[ccKeys.indexOf(colorStr.toLowerCase())] ?? + colorStr); + } +})(Chat || (Chat = {})); diff --git a/server/proxy/Constants.js b/server/proxy/Constants.js new file mode 100644 index 0000000..fa0ce54 --- /dev/null +++ b/server/proxy/Constants.js @@ -0,0 +1,14 @@ +import * as meta from "../meta.js"; +export var Constants; +(function (Constants) { + Constants.EAGLERCRAFT_SKIN_CHANNEL_NAME = "EAG|Skins-1.8"; + Constants.MAGIC_ENDING_SERVER_SKIN_DOWNLOAD_BUILTIN = [0x00, 0x00, 0x00]; + Constants.MAGIC_ENDING_CLIENT_UPLOAD_SKIN_BUILTIN = [0x00, 0x05, 0x01, 0x00, 0x00, 0x00]; + Constants.EAGLERCRAFT_SKIN_CUSTOM_LENGTH = 64 ** 2 * 4; + Constants.JOIN_SERVER_PACKET = 0x01; + Constants.PLAYER_LOOK_PACKET = 0x08; + Constants.ICON_SQRT = 64; + Constants.END_BUFFER_LENGTH = Constants.ICON_SQRT ** 8; + Constants.IMAGE_DATA_PREPEND = "data:image/png;base64,"; +})(Constants || (Constants = {})); +export const UPGRADE_REQUIRED_RESPONSE = ` EaglerProxy landing page

426 - Upgrade Required

Hello there! It appears as if you've reached the landing page for this EaglerProxy instance. Unfortunately, you cannot connect to the proxy server from here. To connect, use this server IP/URL: loading... (connect from any recent EaglercraftX client via Multiplayer > Direct Connect)

`; diff --git a/server/proxy/Enums.js b/server/proxy/Enums.js new file mode 100644 index 0000000..6199b39 --- /dev/null +++ b/server/proxy/Enums.js @@ -0,0 +1,73 @@ +export var Enums; +(function (Enums) { + let PacketId; + (function (PacketId) { + PacketId[PacketId["CSLoginPacket"] = 1] = "CSLoginPacket"; + PacketId[PacketId["SCIdentifyPacket"] = 2] = "SCIdentifyPacket"; + PacketId[PacketId["SCDisconnectPacket"] = 255] = "SCDisconnectPacket"; + PacketId[PacketId["SCChannelMessagePacket"] = 63] = "SCChannelMessagePacket"; + PacketId[PacketId["CSChannelMessagePacket"] = 23] = "CSChannelMessagePacket"; + PacketId[PacketId["CSUsernamePacket"] = 4] = "CSUsernamePacket"; + PacketId[PacketId["SCSyncUuidPacket"] = 5] = "SCSyncUuidPacket"; + PacketId[PacketId["CSSetSkinPacket"] = 7] = "CSSetSkinPacket"; + PacketId[PacketId["CSReadyPacket"] = 8] = "CSReadyPacket"; + PacketId[PacketId["SCReadyPacket"] = 9] = "SCReadyPacket"; + })(PacketId = Enums.PacketId || (Enums.PacketId = {})); + let ChannelMessageType; + (function (ChannelMessageType) { + ChannelMessageType[ChannelMessageType["CLIENT"] = 23] = "CLIENT"; + ChannelMessageType[ChannelMessageType["SERVER"] = 63] = "SERVER"; + })(ChannelMessageType = Enums.ChannelMessageType || (Enums.ChannelMessageType = {})); + let EaglerSkinPacketId; + (function (EaglerSkinPacketId) { + EaglerSkinPacketId[EaglerSkinPacketId["CFetchSkinEaglerPlayerReq"] = 3] = "CFetchSkinEaglerPlayerReq"; + EaglerSkinPacketId[EaglerSkinPacketId["SFetchSkinBuiltInRes"] = 4] = "SFetchSkinBuiltInRes"; + EaglerSkinPacketId[EaglerSkinPacketId["SFetchSkinRes"] = 5] = "SFetchSkinRes"; + EaglerSkinPacketId[EaglerSkinPacketId["CFetchSkinReq"] = 6] = "CFetchSkinReq"; + })(EaglerSkinPacketId = Enums.EaglerSkinPacketId || (Enums.EaglerSkinPacketId = {})); + let ClientState; + (function (ClientState) { + ClientState["PRE_HANDSHAKE"] = "PRE_HANDSHAKE"; + ClientState["POST_HANDSHAKE"] = "POST_HANDSHAKE"; + ClientState["DISCONNECTED"] = "DISCONNECTED"; + })(ClientState = Enums.ClientState || (Enums.ClientState = {})); + let PacketBounds; + (function (PacketBounds) { + PacketBounds["C"] = "C"; + PacketBounds["S"] = "S"; + })(PacketBounds = Enums.PacketBounds || (Enums.PacketBounds = {})); + let SkinType; + (function (SkinType) { + SkinType[SkinType["BUILTIN"] = 0] = "BUILTIN"; + SkinType[SkinType["CUSTOM"] = 1] = "CUSTOM"; + })(SkinType = Enums.SkinType || (Enums.SkinType = {})); + let ChatColor; + (function (ChatColor) { + ChatColor["AQUA"] = "\u00A7b"; + ChatColor["BLACK"] = "\u00A70"; + ChatColor["DARK_BLUE"] = "\u00A71"; + ChatColor["DARK_GREEN"] = "\u00A72"; + ChatColor["DARK_CYAN"] = "\u00A73"; + ChatColor["DARK_RED"] = "\u00A74"; + ChatColor["PURPLE"] = "\u00A75"; + ChatColor["GOLD"] = "\u00A76"; + ChatColor["GRAY"] = "\u00A77"; + ChatColor["GREEN"] = "\u00A7a"; + ChatColor["DARK_GRAY"] = "\u00A78"; + ChatColor["BLUE"] = "\u00A79"; + ChatColor["BRIGHT_GREEN"] = "\u00A7a"; + ChatColor["LIGHT_PURPLE"] = "\u00A7d"; + ChatColor["CYAN"] = "\u00A7b"; + ChatColor["RED"] = "\u00A7c"; + ChatColor["PINK"] = "\u00A7d"; + ChatColor["YELLOW"] = "\u00A7e"; + ChatColor["WHITE"] = "\u00A7f"; + // text styling + ChatColor["OBFUSCATED"] = "\u00A7k"; + ChatColor["BOLD"] = "\u00A7l"; + ChatColor["STRIKETHROUGH"] = "\u00A7m"; + ChatColor["UNDERLINED"] = "\u00A7n"; + ChatColor["ITALIC"] = "\u00A7o"; + ChatColor["RESET"] = "\u00A7r"; + })(ChatColor = Enums.ChatColor || (Enums.ChatColor = {})); +})(Enums || (Enums = {})); diff --git a/server/proxy/Motd.js b/server/proxy/Motd.js new file mode 100644 index 0000000..62aa3ac --- /dev/null +++ b/server/proxy/Motd.js @@ -0,0 +1,86 @@ +import { randomUUID } from "crypto"; +import pkg from "minecraft-protocol"; +import { PROXY_BRANDING, PROXY_VERSION } from "../meta.js"; +import { Chat } from "./Chat.js"; +import { Constants } from "./Constants.js"; +import { ImageEditor } from "./skins/ImageEditor.js"; +const { ping } = pkg; +export var Motd; +(function (Motd) { + class MOTD { + jsonMotd; + image; + usingNatives; + constructor(motd, native, image) { + this.jsonMotd = motd; + this.image = image; + this.usingNatives = native; + } + static async generateMOTDFromPing(host, port, useNatives) { + const pingRes = await ping({ host: host, port: port }); + if (typeof pingRes.version == "string") + throw new Error("Non-1.8 server detected!"); + else { + const newPingRes = pingRes; + let image; + if (newPingRes.favicon != null) { + if (!newPingRes.favicon.startsWith(Constants.IMAGE_DATA_PREPEND)) + throw new Error("Invalid MOTD image!"); + image = useNatives + ? await ImageEditor.generateEaglerMOTDImage(Buffer.from(newPingRes.favicon.substring(Constants.IMAGE_DATA_PREPEND.length), "base64")) + : await ImageEditor.generateEaglerMOTDImageJS(Buffer.from(newPingRes.favicon.substring(Constants.IMAGE_DATA_PREPEND.length), "base64")); + } + return new MOTD({ + brand: PROXY_BRANDING, + cracked: true, + data: { + cache: true, + icon: newPingRes.favicon != null ? true : false, + max: newPingRes.players.max, + motd: [typeof newPingRes.description == "string" ? newPingRes.description : Chat.chatToPlainString(newPingRes.description), ""], + online: newPingRes.players.online, + players: newPingRes.players.sample != null ? newPingRes.players.sample.map((v) => v.name) : [], + }, + name: "placeholder name", + secure: false, + time: Date.now(), + type: "motd", + uuid: randomUUID(), // replace placeholder with global. cached UUID + vers: `${PROXY_BRANDING}/${PROXY_VERSION}`, + }, useNatives, image); + } + } + static async generateMOTDFromConfig(config, useNatives) { + if (typeof config.motd != "string") { + const motd = new MOTD({ + brand: PROXY_BRANDING, + cracked: true, + data: { + cache: true, + icon: config.motd.iconURL != null ? true : false, + max: config.maxConcurrentClients, + motd: [config.motd.l1, config.motd.l2 ?? ""], + online: 0, + players: [], + }, + name: config.name, + secure: false, + time: Date.now(), + type: "motd", + uuid: randomUUID(), + vers: `${PROXY_BRANDING}/${PROXY_VERSION}`, + }, useNatives); + if (config.motd.iconURL != null) { + motd.image = useNatives ? await ImageEditor.generateEaglerMOTDImage(config.motd.iconURL) : await ImageEditor.generateEaglerMOTDImageJS(config.motd.iconURL); // TODO: swap between native and pure JS + } + return motd; + } + else + throw new Error("MOTD is set to be forwarded in the config!"); + } + toBuffer() { + return [JSON.stringify(this.jsonMotd), this.image]; + } + } + Motd.MOTD = MOTD; +})(Motd || (Motd = {})); diff --git a/server/proxy/Packet.js b/server/proxy/Packet.js new file mode 100644 index 0000000..9b99cb5 --- /dev/null +++ b/server/proxy/Packet.js @@ -0,0 +1,24 @@ +import { dirname, join } from "path"; +import { fileURLToPath, pathToFileURL } from "url"; +import { Util } from "./Util.js"; +export async function loadPackets(dir) { + const files = (await Util.recursiveFileSearch(dir ?? join(dirname(fileURLToPath(import.meta.url)), "packets"))).filter((f) => f.endsWith(".js") && !f.endsWith(".disabled.js")); + const packetRegistry = new Map(); + for (const file of files) { + const imp = await import(process.platform == "win32" ? pathToFileURL(file).toString() : file); + for (const val of Object.values(imp)) { + if (val != null) { + let e; + try { + e = new val(); + } + catch { } + if (e != null && e.type == "packet") { + e.class = val; + packetRegistry.set(e.packetId, e); + } + } + } + } + return packetRegistry; +} diff --git a/server/proxy/Player.js b/server/proxy/Player.js new file mode 100644 index 0000000..b6a7842 --- /dev/null +++ b/server/proxy/Player.js @@ -0,0 +1,324 @@ +import EventEmitter from "events"; +import pkg, { createClient, states } from "minecraft-protocol"; +import { Logger } from "../logger.js"; +import { Chat } from "./Chat.js"; +import { Enums } from "./Enums.js"; +import SCDisconnectPacket from "./packets/SCDisconnectPacket.js"; +import { MineProtocol } from "./Protocol.js"; +import { Util } from "./Util.js"; +import { BungeeUtil } from "./BungeeUtil.js"; +const { createSerializer, createDeserializer } = pkg; +export class Player extends EventEmitter { + ws; + username; + skin; + uuid; + state = Enums.ClientState.PRE_HANDSHAKE; + serverConnection; + _switchingServers = false; + _logger; + _alreadyConnected = false; + translator; + serverSerializer; + clientSerializer; + serverDeserializer; + clientDeserializer; + _kickMessage; + constructor(ws, playerName, serverConnection) { + super(); + this._logger = new Logger(`PlayerHandler-${playerName}`); + this.ws = ws; + this.username = playerName; + this.serverConnection = serverConnection; + if (this.username != null) + this.uuid = Util.generateUUIDFromPlayer(this.username); + this.serverSerializer = createSerializer({ + state: states.PLAY, + isServer: true, + version: "1.8.9", + customPackets: null, + }); + this.clientSerializer = createSerializer({ + state: states.PLAY, + isServer: false, + version: "1.8.9", + customPackets: null, + }); + this.serverDeserializer = createDeserializer({ + state: states.PLAY, + isServer: true, + version: "1.8.9", + customPackets: null, + }); + this.clientDeserializer = createSerializer({ + state: states.PLAY, + isServer: true, + version: "1.8.9", + customPackets: null, + }); + } + initListeners() { + this.ws.on("close", () => { + this.state = Enums.ClientState.DISCONNECTED; + if (this.serverConnection) + this.serverConnection.end(); + this.emit("disconnect", this); + }); + this.ws.on("message", (msg) => { + if (msg instanceof Buffer == false) + return; + const decoder = PACKET_REGISTRY.get(msg[0]); + if (decoder && decoder.sentAfterHandshake) { + if (!decoder && this.state != Enums.ClientState.POST_HANDSHAKE && msg.length >= 1) { + this._logger.warn(`Packet with ID 0x${Buffer.from([msg[0]]).toString("hex")} is missing a corresponding packet handler! Processing for this packet will be skipped.`); + } + else { + let parsed, err; + try { + parsed = new decoder.class(); + parsed.deserialize(msg); + } + catch (err) { + if (this.state != Enums.ClientState.POST_HANDSHAKE) + this._logger.warn(`Packet ID 0x${Buffer.from([msg[0]]).toString("hex")} failed to parse! The packet will be skipped.`); + err = true; + } + if (!err) { + this.emit("proxyPacket", parsed, this); + return; + } + } + } + else { + try { + const parsed = this.serverDeserializer.parsePacketBuffer(msg)?.data, translated = this.translator.translatePacketClient(parsed.params, parsed), packetData = { + name: translated[0], + params: translated[1], + cancel: false, + }; + this.emit("vanillaPacket", packetData, "CLIENT", this); + if (!packetData.cancel) { + this._sendPacketToServer(this.clientSerializer.createPacketBuffer({ + name: packetData.name, + params: packetData.params, + })); + } + } + catch (err) { + this._logger.debug(`Client ${this.username} sent an unrecognized packet that could not be parsed!\n${err.stack ?? err}`); + } + } + }); + } + write(packet) { + this.ws.send(packet.serialize()); + } + async read(packetId, filter) { + let res; + await Util.awaitPacket(this.ws, (packet) => { + if ((packetId != null && packetId == packet[0]) || packetId == null) { + const decoder = PACKET_REGISTRY.get(packet[0]); + if (decoder != null && decoder.packetId == packet[0] && (this.state == Enums.ClientState.PRE_HANDSHAKE || decoder.sentAfterHandshake) && decoder.boundTo == Enums.PacketBounds.S) { + let parsed, err = false; + try { + parsed = new decoder.class(); + parsed.deserialize(packet); + } + catch (_err) { + err = true; + } + if (!err) { + if (filter && filter(parsed)) { + res = parsed; + return true; + } + else if (filter == null) { + res = parsed; + return true; + } + } + } + } + return false; + }); + return res; + } + disconnect(message) { + if (this.state == Enums.ClientState.POST_HANDSHAKE) { + this.ws.send(Buffer.concat([[0x40], MineProtocol.writeString(typeof message == "string" ? message : JSON.stringify(message))].map((arr) => (arr instanceof Uint8Array ? arr : Buffer.from(arr))))); + this.ws.close(); + } + else { + const packet = new SCDisconnectPacket(); + packet.reason = message; + this.ws.send(packet.serialize()); + this.ws.close(); + } + } + async connect(options) { + if (this._alreadyConnected) + throw new Error(`Invalid state: Player has already been connected to a server, and .connect() was just called. Please use switchServers() instead.`); + this._alreadyConnected = true; + this.serverConnection = createClient(Object.assign({ + version: "1.8.9", + keepAlive: false, + hideErrors: false, + }, options)); + await this._bindListenersMineClient(this.serverConnection); + } + switchServers(options) { + if (!this._alreadyConnected) + throw new Error(`Invalid state: Player hasn't already been connected to a server, and .switchServers() has been called. Please use .connect() when initially connecting to a server, and only use .switchServers() if you want to switch servers.`); + return new Promise(async (res, rej) => { + const oldConnection = this.serverConnection; + this._switchingServers = true; + this.ws.send(this.serverSerializer.createPacketBuffer({ + name: "chat", + params: { + message: `${Enums.ChatColor.GRAY}Switching servers...`, + position: 1, + }, + })); + this.ws.send(this.serverSerializer.createPacketBuffer({ + name: "playerlist_header", + params: { + header: JSON.stringify({ + text: "", + }), + footer: JSON.stringify({ + text: "", + }), + }, + })); + this.serverConnection = createClient(Object.assign({ + version: "1.8.9", + keepAlive: false, + hideErrors: false, + }, options)); + await this._bindListenersMineClient(this.serverConnection, true, () => oldConnection.end()) + .then(() => { + this.emit("switchServer", this.serverConnection, this); + res(); + }) + .catch((err) => { + this.serverConnection = oldConnection; + rej(err); + }); + }); + } + async _bindListenersMineClient(client, switchingServers, onSwitch) { + return new Promise((res, rej) => { + let stream = false, uuid; + const listener = (msg) => { + if (stream) { + client.writeRaw(msg); + } + }, errListener = (err) => { + if (!stream) { + rej(err); + } + else { + this.disconnect(`${Enums.ChatColor.RED}Something went wrong: ${err.stack ?? err}`); + } + }; + setTimeout(() => { + if (!stream && this.state != Enums.ClientState.DISCONNECTED) { + client.end("Timed out waiting for server connection."); + this.disconnect(Enums.ChatColor.RED + "Timed out waiting for server connection!"); + throw new Error("Timed out waiting for server connection!"); + } + }, 30000); + client.on("error", errListener); + client.on("end", (reason) => { + if (!this._switchingServers && !switchingServers) { + this.disconnect(this._kickMessage ?? reason); + } + this.ws.removeListener("message", listener); + }); + client.once("connect", () => { + this.emit("joinServer", client, this); + }); + client.on("packet", (packet, meta) => { + if (meta.name == "kick_disconnect") { + let json; + try { + json = JSON.parse(packet.reason); + } + catch { } + if (json != null) { + this._kickMessage = Chat.chatToPlainString(json); + } + else + this._kickMessage = packet.reason; + this._switchingServers = false; + this.disconnect(this._kickMessage); + } + else if (meta.name == "disconnect") { + let json; + try { + json = JSON.parse(packet.reason); + } + catch { } + if (json != null) { + this._kickMessage = Chat.chatToPlainString(json); + } + else + this._kickMessage = packet.reason; + this._switchingServers = false; + this.disconnect(this._kickMessage); + } + if (!stream) { + if (switchingServers) { + if (meta.name == "login" && meta.state == states.PLAY && uuid) { + this.translator = new BungeeUtil.PacketUUIDTranslator(client.uuid, this.uuid); + const pckSeq = BungeeUtil.getRespawnSequence(packet, this.serverSerializer); + this.ws.send(this.serverSerializer.createPacketBuffer({ + name: "login", + params: packet, + })); + pckSeq.forEach((p) => this.ws.send(p)); + stream = true; + if (onSwitch) + onSwitch(); + res(null); + } + else if (meta.name == "success" && meta.state == states.LOGIN && !uuid) { + uuid = packet.uuid; + } + } + else { + if (meta.name == "login" && meta.state == states.PLAY && uuid) { + this.translator = new BungeeUtil.PacketUUIDTranslator(client.uuid, this.uuid); + this.ws.send(this.serverSerializer.createPacketBuffer({ + name: "login", + params: packet, + })); + stream = true; + if (onSwitch) + onSwitch(); + res(null); + } + else if (meta.name == "success" && meta.state == states.LOGIN && !uuid) { + uuid = packet.uuid; + } + } + } + else { + const translated = this.translator.translatePacketServer(packet, meta), eventData = { + name: translated[0], + params: translated[1], + cancel: false, + }; + this.emit("vanillaPacket", eventData, "SERVER", this); + if (!eventData.cancel) { + this.ws.send(this.serverSerializer.createPacketBuffer({ + name: eventData.name, + params: eventData.params, + })); + } + } + }); + this._sendPacketToServer = listener; + }); + } +} diff --git a/server/proxy/Protocol.js b/server/proxy/Protocol.js new file mode 100644 index 0000000..aa1b18a --- /dev/null +++ b/server/proxy/Protocol.js @@ -0,0 +1,85 @@ +import { encodeULEB128 as _encodeVarInt, decodeULEB128 as _decodeVarInt } from "@thi.ng/leb128"; +import { Util } from "./Util.js"; +// reference: https://wiki.vg/index.php?title=Protocol&oldid=7368 (id: 73) +// use https://hexed.it/ for hex analysis, dumps.ts for example dumps +// this simple wrapper only contains utilities for reading & writing VarInts and strings, which are the +// datatypes being used thus far. There may be more, but however, they will be added here as needed. +export var MineProtocol; +(function (MineProtocol) { + function writeVarInt(int) { + return Buffer.from(_encodeVarInt(int)); + } + MineProtocol.writeVarInt = writeVarInt; + function readVarInt(buff, offset) { + buff = offset ? buff.subarray(offset) : buff; + const read = _decodeVarInt(buff), len = read[1]; + return { + // potential oversight? + value: Number(read[0]), + newBuffer: buff.subarray(len), + }; + } + MineProtocol.readVarInt = readVarInt; + function writeVarLong(long) { + return writeVarInt(long); + } + MineProtocol.writeVarLong = writeVarLong; + function readVarLong(buff, offset) { + return readVarInt(buff, offset); + } + MineProtocol.readVarLong = readVarLong; + function writeBinary(data) { + return Buffer.concat([writeVarInt(data.length), data]); + } + MineProtocol.writeBinary = writeBinary; + function readBinary(buff, offset) { + buff = offset ? buff.subarray(offset) : buff; + const len = readVarInt(buff), data = len.newBuffer.subarray(0, len.value); + return { + value: data, + newBuffer: len.newBuffer.subarray(len.value), + }; + } + MineProtocol.readBinary = readBinary; + function writeString(str) { + const bufferized = Buffer.from(str, "utf8"), len = writeVarInt(bufferized.length); + return Buffer.concat([len, bufferized]); + } + MineProtocol.writeString = writeString; + function readString(buff, offset) { + buff = offset ? buff.subarray(offset) : buff; + const len = readVarInt(buff), str = len.newBuffer.subarray(0, len.value).toString("utf8"); + return { + value: str, + newBuffer: len.newBuffer.subarray(len.value), + }; + } + MineProtocol.readString = readString; + const _readShort = (a, b) => (a << 8) | (b << 0); + function readShort(buff, offset) { + buff = offset ? buff.subarray(offset) : buff; + return { + value: _readShort(buff[0], buff[1]), + newBuffer: buff.subarray(2), + }; + } + MineProtocol.readShort = readShort; + function writeShort(num) { + const alloc = Buffer.alloc(2); + alloc.writeInt16BE(num); + return alloc; + } + MineProtocol.writeShort = writeShort; + function readUUID(buff, offset) { + buff = offset ? buff.subarray(offset) : buff; + return { + value: Util.uuidBufferToString(buff.subarray(0, 16)), + newBuffer: buff.subarray(16), + }; + } + MineProtocol.readUUID = readUUID; + function writeUUID(uuid) { + return typeof uuid == "string" ? Util.uuidStringToBuffer(uuid) : uuid; + } + MineProtocol.writeUUID = writeUUID; +})(MineProtocol || (MineProtocol = {})); diff --git a/server/proxy/Proxy.js b/server/proxy/Proxy.js new file mode 100644 index 0000000..7895439 --- /dev/null +++ b/server/proxy/Proxy.js @@ -0,0 +1,341 @@ +import { WebSocketServer } from "ws"; +import { Logger } from "../logger.js"; +import { loadPackets } from "./Packet.js"; +import * as http from "http"; +import * as https from "https"; +import { readFile } from "fs/promises"; +import { Util } from "./Util.js"; +import CSLoginPacket from "./packets/CSLoginPacket.js"; +import SCIdentifyPacket from "./packets/SCIdentifyPacket.js"; +import { Motd } from "./Motd.js"; +import { Player } from "./Player.js"; +import { Enums } from "./Enums.js"; +import { NETWORK_VERSION, PROXY_BRANDING, PROXY_VERSION, VANILLA_PROTOCOL_VERSION } from "../meta.js"; +import { SCSyncUuidPacket } from "./packets/SCSyncUuidPacket.js"; +import { SCReadyPacket } from "./packets/SCReadyPacket.js"; +import { Chalk } from "chalk"; +import EventEmitter from "events"; +import { EaglerSkins } from "./skins/EaglerSkins.js"; +import { Constants, UPGRADE_REQUIRED_RESPONSE } from "./Constants.js"; +import ProxyRatelimitManager from "./ratelimit/ProxyRatelimitManager.js"; +import { SkinServer } from "./skins/SkinServer.js"; +let instanceCount = 0; +const chalk = new Chalk({ level: 2 }); +export class Proxy extends EventEmitter { + packetRegistry; + players = new Map(); + pluginManager; + config; + wsServer; + httpServer; + skinServer; + broadcastMotd; + ratelimit; + _logger; + initalHandlerLogger; + loaded; + constructor(config, pluginManager) { + super(); + this._logger = new Logger(`EaglerProxy-${instanceCount}`); + this.initalHandlerLogger = new Logger(`EaglerProxy-InitialHandler`); + // hijack the initial handler logger to append [InitialHandler] to the beginning + this.initalHandlerLogger._info = this.initalHandlerLogger.info; + this.initalHandlerLogger.info = (msg) => { + this.initalHandlerLogger._info(`${chalk.blue("[InitialHandler]")} ${msg}`); + }; + this.initalHandlerLogger._warn = this.initalHandlerLogger.warn; + this.initalHandlerLogger.warn = (msg) => { + this.initalHandlerLogger._warn(`${chalk.blue("[InitialHandler]")} ${msg}`); + }; + this.initalHandlerLogger._error = this.initalHandlerLogger.error; + this.initalHandlerLogger.error = (msg) => { + this.initalHandlerLogger._error(`${chalk.blue("[InitialHandler]")} ${msg}`); + }; + this.initalHandlerLogger._fatal = this.initalHandlerLogger.fatal; + this.initalHandlerLogger.fatal = (msg) => { + this.initalHandlerLogger._fatal(`${chalk.blue("[InitialHandler]")} ${msg}`); + }; + this.initalHandlerLogger._debug = this.initalHandlerLogger.debug; + this.initalHandlerLogger.debug = (msg) => { + this.initalHandlerLogger._debug(`${chalk.blue("[InitialHandler]")} ${msg}`); + }; + this.config = config; + this.pluginManager = pluginManager; + instanceCount++; + process.on("uncaughtException", (err) => { + this._logger.warn(`An uncaught exception was caught! Error: ${err.stack}`); + }); + process.on("unhandledRejection", (err) => { + this._logger.warn(`An unhandled rejection was caught! Rejection: ${err.stack || err}`); + }); + } + async init() { + this._logger.info(`Starting ${PROXY_BRANDING} v${PROXY_VERSION}...`); + global.PROXY = this; + if (this.loaded) + throw new Error("Can't initiate if proxy instance is already initialized or is being initialized!"); + this.loaded = true; + this.packetRegistry = await loadPackets(); + this.skinServer = new SkinServer(this, this.config.useNatives, this.config.skinServer.cache.skinCachePruneInterval, this.config.skinServer.cache.skinCacheLifetime, this.config.skinServer.cache.folderName, this.config.skinServer.cache.useCache, this.config.skinServer.skinUrlWhitelist); + global.PACKET_REGISTRY = this.packetRegistry; + if (this.config.motd == "FORWARD") { + this._pollServer(this.config.server.host, this.config.server.port); + } + else { + const broadcastMOTD = await Motd.MOTD.generateMOTDFromConfig(this.config, this.config.useNatives); + broadcastMOTD._static = true; + this.broadcastMotd = broadcastMOTD; + // playercount will be dynamically updated + } + if (this.config.tls && this.config.tls.enabled) { + this.httpServer = https + .createServer({ + key: await readFile(this.config.tls.key), + cert: await readFile(this.config.tls.cert), + }, (req, res) => this._handleNonWSRequest(req, res, this.config)) + .listen(this.config.bindPort || 8080, this.config.bindHost || "127.0.0.1"); + this.wsServer = new WebSocketServer({ + noServer: true, + }); + } + else { + this.httpServer = http.createServer((req, res) => this._handleNonWSRequest(req, res, this.config)).listen(this.config.bindPort || 8080, this.config.bindHost || "127.0.0.1"); + this.wsServer = new WebSocketServer({ + noServer: true, + }); + } + this.httpServer.on("error", (err) => { + this._logger.warn(`HTTP server threw an error: ${err.stack}`); + }); + this.wsServer.on("error", (err) => { + this._logger.warn(`WebSocket server threw an error: ${err.stack}`); + }); + this.httpServer.on("upgrade", async (r, s, h) => { + try { + await this._handleWSConnectionReq(r, s, h); + } + catch (err) { + this._logger.error(`Error was caught whilst trying to handle WebSocket upgrade! Error: ${err.stack ?? err}`); + } + }); + process.on("beforeExit", () => { + this._logger.info("Cleaning up before exiting..."); + this.players.forEach((plr) => plr.disconnect(Enums.ChatColor.YELLOW + "Proxy is shutting down.")); + }); + this.ratelimit = new ProxyRatelimitManager(this.config.ratelimits); + this.pluginManager.emit("proxyFinishLoading", this, this.pluginManager); + this._logger.info(`Started WebSocket server and binded to ${this.config.bindHost} on port ${this.config.bindPort}.`); + } + _handleNonWSRequest(req, res, config) { + if (this.ratelimit.http.consume(req.socket.remoteAddress).success) { + const ctx = { handled: false }; + this.emit("httpConnection", req, res, ctx); + if (!ctx.handled) + res.setHeader("Content-Type", "text/html").writeHead(426).end(UPGRADE_REQUIRED_RESPONSE); + } + } + LOGIN_TIMEOUT = 30000; + async _handleWSConnection(ws, req) { + const rl = this.ratelimit.ws.consume(req.socket.remoteAddress); + if (!rl.success) { + return ws.close(); + } + const ctx = { handled: false }; + await this.emit("wsConnection", ws, req, ctx); + if (ctx.handled) + return; + const firstPacket = await Util.awaitPacket(ws); + let player, handled; + setTimeout(() => { + if (!handled) { + this.initalHandlerLogger.warn(`Disconnecting client ${player ? player.username ?? `[/${ws._socket.remoteAddress}:${ws._socket.remotePort}` : `[/${ws._socket.remoteAddress}:${ws._socket.remotePort}`} due to connection timing out.`); + if (player) + player.disconnect(`${Enums.ChatColor.YELLOW} Your connection timed out whilst processing handshake, please try again.`); + else + ws.close(); + } + }, this.LOGIN_TIMEOUT); + try { + if (firstPacket.toString() === "Accept: MOTD") { + if (!this.ratelimit.motd.consume(req.socket.remoteAddress).success) { + return ws.close(); + } + if (this.broadcastMotd) { + if (this.broadcastMotd._static) { + this.broadcastMotd.jsonMotd.data.online = this.players.size; + // sample for players + this.broadcastMotd.jsonMotd.data.players = []; + const playerSample = [...this.players.keys()].filter((sample) => !sample.startsWith("!phs_")).slice(0, 5); + this.broadcastMotd.jsonMotd.data.players = playerSample; + if (this.players.size - playerSample.length > 0) + this.broadcastMotd.jsonMotd.data.players.push(`${Enums.ChatColor.GRAY}${Enums.ChatColor.ITALIC}(and ${this.players.size - playerSample.length} more)`); + const bufferized = this.broadcastMotd.toBuffer(); + ws.send(bufferized[0]); + if (bufferized[1] != null) + ws.send(bufferized[1]); + } + else { + const motd = this.broadcastMotd.toBuffer(); + ws.send(motd[0]); + if (motd[1] != null) + ws.send(motd[1]); + } + } + handled = true; + ws.close(); + } + else { + ws.httpRequest = req; + player = new Player(ws); + const rl = this.ratelimit.connect.consume(req.socket.remoteAddress); + if (!rl.success) { + handled = true; + player.disconnect(`${Enums.ChatColor.RED}You have been ratelimited!\nTry again in ${Enums.ChatColor.WHITE}${rl.retryIn / 1000}${Enums.ChatColor.RED} seconds`); + return; + } + const loginPacket = new CSLoginPacket().deserialize(firstPacket); + player.state = Enums.ClientState.PRE_HANDSHAKE; + if (loginPacket.gameVersion != VANILLA_PROTOCOL_VERSION) { + player.disconnect(`${Enums.ChatColor.RED}Please connect to this proxy on EaglercraftX 1.8.9.`); + return; + } + else if (loginPacket.networkVersion != NETWORK_VERSION) { + player.disconnect(`${Enums.ChatColor.RED}Your EaglercraftX version is too ${loginPacket.networkVersion > NETWORK_VERSION ? "new" : "old"}! Please ${loginPacket.networkVersion > NETWORK_VERSION ? "downgrade" : "update"}.`); + return; + } + try { + Util.validateUsername(loginPacket.username); + } + catch (err) { + player.disconnect(`${Enums.ChatColor.RED}${err.reason || err}`); + return; + } + player.username = loginPacket.username; + player.uuid = Util.generateUUIDFromPlayer(player.username); + if (this.players.size > this.config.maxConcurrentClients) { + player.disconnect(`${Enums.ChatColor.YELLOW}Proxy is full! Please try again later.`); + return; + } + else if (this.players.get(player.username) != null || this.players.get(`!phs.${player.uuid}`) != null) { + player.disconnect(`${Enums.ChatColor.YELLOW}Someone under your username (${player.username}) is already connected to the proxy!`); + return; + } + this.players.set(`!phs.${player.uuid}`, player); + this._logger.info(`Player ${loginPacket.username} (${Util.generateUUIDFromPlayer(loginPacket.username)}) running ${loginPacket.brand}/${loginPacket.version} (net ver: ${loginPacket.networkVersion}, game ver: ${loginPacket.gameVersion}) is attempting to connect!`); + player.write(new SCIdentifyPacket()); + const usernamePacket = (await player.read(Enums.PacketId.CSUsernamePacket)); + if (usernamePacket.username !== player.username) { + player.disconnect(`${Enums.ChatColor.YELLOW}Failed to complete handshake. Your game version may be too old or too new.`); + return; + } + const syncUuid = new SCSyncUuidPacket(); + syncUuid.username = player.username; + syncUuid.uuid = player.uuid; + player.write(syncUuid); + const prom = await Promise.all([player.read(Enums.PacketId.CSReadyPacket), (await player.read(Enums.PacketId.CSSetSkinPacket))]), skin = prom[1], obj = new EaglerSkins.EaglerSkin(); + obj.owner = player; + obj.type = skin.skinType; + if (skin.skinType == Enums.SkinType.CUSTOM) + obj.skin = skin.skin; + else + obj.builtInSkin = skin.skinId; + player.skin = obj; + player.write(new SCReadyPacket()); + this.players.delete(`!phs.${player.uuid}`); + this.players.set(player.username, player); + player.initListeners(); + this._bindListenersToPlayer(player); + player.state = Enums.ClientState.POST_HANDSHAKE; + this._logger.info(`Handshake Success! Connecting player ${player.username} to server...`); + handled = true; + await player.connect({ + host: this.config.server.host, + port: this.config.server.port, + username: player.username, + }); + this._logger.info(`Player ${player.username} successfully connected to server.`); + this.emit("playerConnect", player); + } + } + catch (err) { + this.initalHandlerLogger.warn(`Error occurred whilst handling handshake: ${err.stack ?? err}`); + handled = true; + ws.close(); + if (player && player.uuid && this.players.has(`!phs.${player.uuid}`)) + this.players.delete(`!phs.${player.uuid}`); + if (player && player.uuid && this.players.has(player.username)) + this.players.delete(player.username); + } + } + _bindListenersToPlayer(player) { + let sentDisconnectMsg = false; + player.on("disconnect", () => { + if (this.players.has(player.username)) + this.players.delete(player.username); + this.initalHandlerLogger.info(`DISCONNECT ${player.username} <=> DISCONNECTED`); + if (!sentDisconnectMsg) + this._logger.info(`Player ${player.username} (${player.uuid}) disconnected from the proxy server.`); + }); + player.on("proxyPacket", async (packet) => { + if (packet.packetId == Enums.PacketId.CSChannelMessagePacket) { + try { + const msg = packet; + if (msg.channel == Constants.EAGLERCRAFT_SKIN_CHANNEL_NAME) { + await this.skinServer.handleRequest(msg, player, this); + } + } + catch (err) { + this._logger.error(`Failed to process channel message packet! Error: ${err.stack || err}`); + } + } + }); + player.on("switchServer", (client) => { + this.initalHandlerLogger.info(`SWITCH_SERVER ${player.username} <=> ${client.socket.remoteAddress}:${client.socket.remotePort}`); + }); + player.on("joinServer", (client) => { + this.initalHandlerLogger.info(`SERVER_CONNECTED ${player.username} <=> ${client.socket.remoteAddress}:${client.socket.remotePort}`); + }); + } + static POLL_INTERVAL = 10000; + _pollServer(host, port, interval) { + (async () => { + while (true) { + const motd = await Motd.MOTD.generateMOTDFromPing(host, port, this.config.useNatives).catch((err) => { + this._logger.warn(`Error polling ${host}:${port} for MOTD: ${err.stack ?? err}`); + }); + if (motd) + this.broadcastMotd = motd; + await new Promise((res) => setTimeout(res, interval ?? Proxy.POLL_INTERVAL)); + } + })(); + } + async _handleWSConnectionReq(req, socket, head) { + const origin = req.headers.origin == null || req.headers.origin == "null" ? null : req.headers.origin; + if (!this.config.origins.allowOfflineDownloads && origin == null) { + socket.destroy(); + return; + } + if (this.config.origins.originBlacklist != null && this.config.origins.originBlacklist.some((host) => Util.areDomainsEqual(host, origin))) { + socket.destroy(); + return; + } + if (this.config.origins.originWhitelist != null && !this.config.origins.originWhitelist.some((host) => Util.areDomainsEqual(host, origin))) { + socket.destroy(); + return; + } + try { + await this.wsServer.handleUpgrade(req, socket, head, (ws) => this._handleWSConnection(ws, req)); + } + catch (err) { + this._logger.error(`Error was caught whilst trying to handle WebSocket connection request! Error: ${err.stack ?? err}`); + socket.destroy(); + } + } + fetchUserByUUID(uuid) { + for (const [username, player] of this.players) { + if (player.uuid == uuid) + return player; + } + return null; + } +} diff --git a/server/proxy/Util.js b/server/proxy/Util.js new file mode 100644 index 0000000..f97b431 --- /dev/null +++ b/server/proxy/Util.js @@ -0,0 +1,179 @@ +import { createHash } from "crypto"; +import { encodeULEB128, decodeULEB128 } from "@thi.ng/leb128"; +import { parseDomain, ParseResultType } from "parse-domain"; +import { access, readdir } from "fs/promises"; +import { resolve } from "path"; +export var Util; +(function (Util) { + Util.encodeVarInt = encodeULEB128; + Util.decodeVarInt = decodeULEB128; + const USERNAME_REGEX = /[^0-9^a-z^A-Z^_]/gi; + function generateUUIDFromPlayer(user) { + const str = `OfflinePlayer:${user}`; + let md5Bytes = createHash("md5").update(str).digest(); + md5Bytes[6] &= 0x0f; /* clear version */ + md5Bytes[6] |= 0x30; /* set to version 3 */ + md5Bytes[8] &= 0x3f; /* clear variant */ + md5Bytes[8] |= 0x80; /* set to IETF variant */ + return uuidBufferToString(md5Bytes); + } + Util.generateUUIDFromPlayer = generateUUIDFromPlayer; + // excerpt from uuid-buffer + function uuidStringToBuffer(uuid) { + if (!uuid) + return Buffer.alloc(16); // Return empty buffer + const hexStr = uuid.replace(/-/g, ""); + if (uuid.length != 36 || hexStr.length != 32) + throw new Error(`Invalid UUID string: ${uuid}`); + return Buffer.from(hexStr, "hex"); + } + Util.uuidStringToBuffer = uuidStringToBuffer; + function uuidBufferToString(buffer) { + if (buffer.length != 16) + throw new Error(`Invalid buffer length for uuid: ${buffer.length}`); + if (buffer.equals(Buffer.alloc(16))) + return null; // If buffer is all zeros, return null + const str = buffer.toString("hex"); + return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}`; + } + Util.uuidBufferToString = uuidBufferToString; + function awaitPacket(ws, filter) { + return new Promise((res, rej) => { + let resolved = false; + const msgCb = (msg) => { + if (filter != null && filter(msg)) { + resolved = true; + ws.removeListener("message", msgCb); + ws.removeListener("close", discon); + ws.setMaxListeners(ws.getMaxListeners() - 2 < 0 ? 5 : ws.getMaxListeners() - 2); + res(msg); + } + else if (filter == null) { + resolved = true; + ws.removeListener("message", msgCb); + ws.removeListener("close", discon); + ws.setMaxListeners(ws.getMaxListeners() - 2 < 0 ? 5 : ws.getMaxListeners() - 2); + res(msg); + } + }; + const discon = () => { + resolved = true; + ws.removeListener("message", msgCb); + ws.removeListener("close", discon); + ws.setMaxListeners(ws.getMaxListeners() - 2 < 0 ? 5 : ws.getMaxListeners() - 2); + rej("Connection closed"); + }; + ws.setMaxListeners(ws.getMaxListeners() + 2); + ws.on("message", msgCb); + ws.on("close", discon); + setTimeout(() => { + ws.removeListener("message", msgCb); + ws.removeListener("close", discon); + ws.setMaxListeners(ws.getMaxListeners() - 2 < 0 ? 5 : ws.getMaxListeners() - 2); + rej("Timed out"); + }, 10000); + }); + } + Util.awaitPacket = awaitPacket; + function validateUsername(user) { + if (user.length > 20) + throw new Error("Username is too long!"); + if (user.length < 3) + throw new Error("Username is too short!"); + if (!!user.match(USERNAME_REGEX)) + throw new Error("Invalid username. Username can only contain alphanumeric characters, and the underscore (_) character."); + } + Util.validateUsername = validateUsername; + function areDomainsEqual(d1, d2) { + if (d1.endsWith("*.")) + d1 = d1.replace("*.", "WILDCARD-LOL-EXTRA-LONG-SUBDOMAIN-TO-LOWER-CHANCES-OF-COLLISION."); + const parseResult1 = parseDomain(d1), parseResult2 = parseDomain(d2); + if (parseResult1.type != ParseResultType.Invalid && parseResult2.type != ParseResultType.Invalid) { + if (parseResult1.type == ParseResultType.Ip && parseResult2.type == ParseResultType.Ip) { + return parseResult1.hostname == parseResult2.hostname ? true : false; + } + else if (parseResult1.type == ParseResultType.Listed && parseResult2.type == ParseResultType.Listed) { + if (parseResult1.subDomains[0] == "WILDCARD-LOL-EXTRA-LONG-SUBDOMAIN-TO-LOWER-CHANCES-OF-COLLISION") { + // wildcard + const domainPlusTld1 = parseResult1.domain + ("." + parseResult1.topLevelDomains.join(".")); + const domainPlusTld2 = parseResult2.domain + ("." + parseResult2.topLevelDomains.join(".")); + return domainPlusTld1 == domainPlusTld2 ? true : false; + } + else { + // no wildcard + return d1 == d2 ? true : false; + } + } + else if (parseResult1.type == ParseResultType.NotListed && parseResult2.type == ParseResultType.NotListed) { + if (parseResult1.labels[0] == "WILDCARD-LOL-EXTRA-LONG-SUBDOMAIN-TO-LOWER-CHANCES-OF-COLLISION") { + // wildcard + const domainPlusTld1 = parseResult1.labels.slice(2).join("."); + const domainPlusTld2 = parseResult1.labels.slice(2).join("."); + return domainPlusTld1 == domainPlusTld2 ? true : false; + } + else { + // no wildcard + return d1 == d2 ? true : false; + } + } + else if (parseResult1.type == ParseResultType.Reserved && parseResult2.type == ParseResultType.Reserved) { + if (parseResult1.hostname == "" && parseResult1.hostname === parseResult2.hostname) + return true; + else { + // uncertain, fallback to exact hostname matching + return d1 == d2 ? true : false; + } + } + } + else { + return false; + } + } + Util.areDomainsEqual = areDomainsEqual; + async function* _getFiles(dir) { + const dirents = await readdir(dir, { withFileTypes: true }); + for (const dirent of dirents) { + const res = resolve(dir, dirent.name); + if (dirent.isDirectory()) { + yield* _getFiles(res); + } + else { + yield res; + } + } + } + async function recursiveFileSearch(dir) { + const ents = []; + for await (const f of _getFiles(dir)) { + ents.push(f); + } + return ents; + } + Util.recursiveFileSearch = recursiveFileSearch; + async function fsExists(path) { + try { + await access(path); + } + catch (err) { + if (err.code == "ENOENT") + return false; + else + return true; + } + return true; + } + Util.fsExists = fsExists; + function generatePositionPacket(currentPos, newPos) { + const DEFAULT_RELATIVITY = 0x01; // relative to X-axis + const newPosPacket = { + x: newPos.x - currentPos.x * 2, + y: newPos.y, + z: newPos.z, + yaw: newPos.yaw, + pitch: newPos.pitch, + flags: DEFAULT_RELATIVITY, + }; + return newPosPacket; + } + Util.generatePositionPacket = generatePositionPacket; +})(Util || (Util = {})); diff --git a/server/proxy/databases/DiskDB.js b/server/proxy/databases/DiskDB.js new file mode 100644 index 0000000..c0f2f29 --- /dev/null +++ b/server/proxy/databases/DiskDB.js @@ -0,0 +1,51 @@ +import path from "path"; +import fs from "fs/promises"; +import fss from "fs"; +export default class DiskDB { + folder; + static VALIDATION_REGEX = /^[0-9a-zA-Z_]+$/; + nameGenerator; + encoder; + decoder; + constructor(folder, encoder, decoder, nameGenerator) { + this.folder = path.isAbsolute(folder) ? folder : path.resolve(folder); + this.encoder = encoder; + this.decoder = decoder; + this.nameGenerator = nameGenerator; + if (!fss.existsSync(this.folder)) + fss.mkdirSync(this.folder); + } + async filter(f) { + for (const file of await fs.readdir(this.folder)) { + const fp = path.join(this.folder, file); + if (!f(this.decoder(await fs.readFile(fp)))) + await fs.rm(fp); + } + } + async get(k) { + k = this.nameGenerator(k); + if (!DiskDB.VALIDATION_REGEX.test(k)) + throw new InvalidKeyError("Invalid key, key can only consist of alphanumeric characters and _"); + const pth = path.join(this.folder, `${k}.data`); + try { + return this.decoder(await fs.readFile(pth)); + } + catch (err) { + return null; + } + } + async set(k, v) { + k = this.nameGenerator(k); + if (!DiskDB.VALIDATION_REGEX.test(k)) + throw new InvalidKeyError("Invalid key, key can only consist of alphanumeric characters and _"); + const pth = path.join(this.folder, `${k}.data`); + await fs.writeFile(pth, this.encoder(v)); + } +} +class InvalidKeyError extends Error { + constructor(msg) { + super(`[InvalidKeyError] : ${msg}`); + this.name = "InvalidKeyError"; + Object.setPrototypeOf(this, InvalidKeyError); + } +} diff --git a/server/proxy/packets/CSLoginPacket.js b/server/proxy/packets/CSLoginPacket.js new file mode 100644 index 0000000..edf28a4 --- /dev/null +++ b/server/proxy/packets/CSLoginPacket.js @@ -0,0 +1,42 @@ +import { NETWORK_VERSION, VANILLA_PROTOCOL_VERSION } from "../../meta.js"; +import { Enums } from "../Enums.js"; +import { MineProtocol } from "../Protocol.js"; +export default class CSLoginPacket { + packetId = Enums.PacketId.CSLoginPacket; + type = "packet"; + boundTo = Enums.PacketBounds.S; + sentAfterHandshake = false; + networkVersion = NETWORK_VERSION; + gameVersion = VANILLA_PROTOCOL_VERSION; + brand; + version; + username; + _getMagicSeq() { + return Buffer.concat([ + [0x02, 0x00, 0x02, 0x00, 0x02, 0x00], + [this.networkVersion], + [0x00, 0x01, 0x00], + [this.gameVersion], + ].map((arr) => Buffer.from(arr))); + } + serialize() { + return Buffer.concat([ + [Enums.PacketId.CSLoginPacket], + this._getMagicSeq(), + MineProtocol.writeString(this.brand), + MineProtocol.writeString(this.version), + [0x00], + MineProtocol.writeString(this.username), + ].map((arr) => (arr instanceof Uint8Array ? arr : Buffer.from(arr)))); + } + deserialize(packet) { + if (packet[0] != this.packetId) + throw TypeError("Invalid packet ID detected!"); + packet = packet.subarray(1 + this._getMagicSeq().length); + const brand = MineProtocol.readString(packet), version = MineProtocol.readString(brand.newBuffer), username = MineProtocol.readString(version.newBuffer, 1); + this.brand = brand.value; + this.version = version.value; + this.username = username.value; + return this; + } +} diff --git a/server/proxy/packets/CSReadyPacket.js b/server/proxy/packets/CSReadyPacket.js new file mode 100644 index 0000000..35a3781 --- /dev/null +++ b/server/proxy/packets/CSReadyPacket.js @@ -0,0 +1,13 @@ +import { Enums } from "../Enums.js"; +export class CSReadyPacket { + packetId = Enums.PacketId.CSReadyPacket; + type = "packet"; + boundTo = Enums.PacketBounds.S; + sentAfterHandshake = false; + serialize() { + return Buffer.from([this.packetId]); + } + deserialize(packet) { + return this; + } +} diff --git a/server/proxy/packets/CSSetSkinPacket.js b/server/proxy/packets/CSSetSkinPacket.js new file mode 100644 index 0000000..75364c9 --- /dev/null +++ b/server/proxy/packets/CSSetSkinPacket.js @@ -0,0 +1,56 @@ +import { Constants } from "../Constants.js"; +import { Enums } from "../Enums.js"; +import { MineProtocol } from "../Protocol.js"; +export class CSSetSkinPacket { + packetId = Enums.PacketId.CSSetSkinPacket; + type = "packet"; + boundTo = Enums.PacketBounds.S; + sentAfterHandshake = false; + version = "skin_v1"; + skinType; + skinDimensions; + skin; + skinId; + serialize() { + if (this.skinType == Enums.SkinType.BUILTIN) { + return Buffer.concat([ + Buffer.from([this.packetId]), + MineProtocol.writeString(this.version), + MineProtocol.writeVarInt(this.skinDimensions), + this.skin, + ]); + } + else { + return Buffer.concat([ + [this.packetId], + MineProtocol.writeString(this.version), + Constants.MAGIC_ENDING_CLIENT_UPLOAD_SKIN_BUILTIN, + [this.skinId], + ].map((arr) => (arr instanceof Uint8Array ? arr : Buffer.from(arr)))); + } + } + deserialize(packet) { + packet = packet.subarray(1); + const version = MineProtocol.readString(packet); + let skinType; + if (!Constants.MAGIC_ENDING_CLIENT_UPLOAD_SKIN_BUILTIN.some((byte, index) => byte !== version.newBuffer[index])) { + // built in + skinType = Enums.SkinType.BUILTIN; + const id = MineProtocol.readVarInt(version.newBuffer.subarray(Constants.MAGIC_ENDING_CLIENT_UPLOAD_SKIN_BUILTIN.length)); + this.version = version.value; + this.skinType = skinType; + this.skinId = id.value; + return this; + } + else { + // custom + skinType = Enums.SkinType.CUSTOM; + const dimensions = MineProtocol.readVarInt(version.newBuffer), skin = dimensions.newBuffer.subarray(3).subarray(0, 16384); + this.version = version.value; + this.skinType = skinType; + this.skinDimensions = dimensions.value; + this.skin = skin; + return this; + } + } +} diff --git a/server/proxy/packets/CSUsernamePacket.js b/server/proxy/packets/CSUsernamePacket.js new file mode 100644 index 0000000..bf127bd --- /dev/null +++ b/server/proxy/packets/CSUsernamePacket.js @@ -0,0 +1,24 @@ +import { Enums } from "../Enums.js"; +import { MineProtocol } from "../Protocol.js"; +export class CSUsernamePacket { + packetId = Enums.PacketId.CSUsernamePacket; + type = "packet"; + boundTo = Enums.PacketBounds.S; + sentAfterHandshake = false; + username; + static DEFAULT = "default"; + serialize() { + return Buffer.concat([ + [this.packetId], + MineProtocol.writeString(this.username), + MineProtocol.writeString(CSUsernamePacket.DEFAULT), + [0x0], + ].map((arr) => (arr instanceof Uint8Array ? arr : Buffer.from(arr)))); + } + deserialize(packet) { + packet = packet.subarray(1); + const username = MineProtocol.readString(packet); + this.username = username.value; + return this; + } +} diff --git a/server/proxy/packets/SCDisconnectPacket.js b/server/proxy/packets/SCDisconnectPacket.js new file mode 100644 index 0000000..cc90c39 --- /dev/null +++ b/server/proxy/packets/SCDisconnectPacket.js @@ -0,0 +1,29 @@ +import { Chat } from "../Chat.js"; +import { Enums } from "../Enums.js"; +import { MineProtocol } from "../Protocol.js"; +export default class SCDisconnectPacket { + packetId = Enums.PacketId.SCDisconnectPacket; + type = "packet"; + boundTo = Enums.PacketBounds.C; + sentAfterHandshake = false; + static REASON = 0x8; + reason; + serialize() { + const msg = typeof this.reason == "string" + ? this.reason + : Chat.chatToPlainString(this.reason); + return Buffer.concat([ + [0xff], + MineProtocol.writeVarInt(SCDisconnectPacket.REASON), + MineProtocol.writeString(" " + msg + " "), + ].map((arr) => (arr instanceof Uint8Array ? arr : Buffer.from(arr)))); + } + deserialize(packet) { + if (packet[0] != this.packetId) + throw new Error("Invalid packet ID!"); + packet = packet.subarray(1 + MineProtocol.writeVarInt(SCDisconnectPacket.REASON).length); + const reason = MineProtocol.readString(packet); + this.reason = reason.value; + return this; + } +} diff --git a/server/proxy/packets/SCIdentifyPacket.js b/server/proxy/packets/SCIdentifyPacket.js new file mode 100644 index 0000000..04b14da --- /dev/null +++ b/server/proxy/packets/SCIdentifyPacket.js @@ -0,0 +1,33 @@ +import { NETWORK_VERSION, PROXY_BRANDING, PROXY_VERSION, VANILLA_PROTOCOL_VERSION, } from "../../meta.js"; +import { Enums } from "../Enums.js"; +import { MineProtocol } from "../Protocol.js"; +export default class SCIdentifyPacket { + packetId = Enums.PacketId.SCIdentifyPacket; + type = "packet"; + boundTo = Enums.PacketBounds.C; + sentAfterHandshake = false; + protocolVer = NETWORK_VERSION; + gameVersion = VANILLA_PROTOCOL_VERSION; + branding = PROXY_BRANDING; + version = PROXY_VERSION; + serialize() { + return Buffer.concat([ + [0x02], + MineProtocol.writeShort(this.protocolVer), + MineProtocol.writeShort(this.gameVersion), + MineProtocol.writeString(this.branding), + MineProtocol.writeString(this.version), + [0x00, 0x00, 0x00], + ].map((arr) => (arr instanceof Uint8Array ? arr : Buffer.from(arr)))); + } + deserialize(packet) { + if (packet[0] != this.packetId) + throw TypeError("Invalid packet ID detected!"); + packet = packet.subarray(1); + const protoVer = MineProtocol.readShort(packet), gameVer = MineProtocol.readShort(protoVer.newBuffer), branding = MineProtocol.readString(gameVer.newBuffer), version = MineProtocol.readString(branding.newBuffer); + this.gameVersion = gameVer.value; + this.branding = branding.value; + this.version = version.value; + return this; + } +} diff --git a/server/proxy/packets/SCReadyPacket.js b/server/proxy/packets/SCReadyPacket.js new file mode 100644 index 0000000..5fb6c8b --- /dev/null +++ b/server/proxy/packets/SCReadyPacket.js @@ -0,0 +1,13 @@ +import { Enums } from "../Enums.js"; +export class SCReadyPacket { + packetId = Enums.PacketId.SCReadyPacket; + type = "packet"; + boundTo = Enums.PacketBounds.C; + sentAfterHandshake = false; + serialize() { + return Buffer.from([this.packetId]); + } + deserialize(packet) { + return this; + } +} diff --git a/server/proxy/packets/SCSyncUuidPacket.js b/server/proxy/packets/SCSyncUuidPacket.js new file mode 100644 index 0000000..1dbdb6f --- /dev/null +++ b/server/proxy/packets/SCSyncUuidPacket.js @@ -0,0 +1,25 @@ +import { Enums } from "../Enums.js"; +import { MineProtocol } from "../Protocol.js"; +import { Util } from "../Util.js"; +export class SCSyncUuidPacket { + packetId = Enums.PacketId.SCSyncUuidPacket; + type = "packet"; + boundTo = Enums.PacketBounds.C; + sentAfterHandshake = false; + username; + uuid; + serialize() { + return Buffer.concat([ + [this.packetId], + MineProtocol.writeString(this.username), + Util.uuidStringToBuffer(this.uuid), + ].map((arr) => (arr instanceof Uint8Array ? arr : Buffer.from(arr)))); + } + deserialize(packet) { + packet = packet.subarray(1); + const username = MineProtocol.readString(packet), uuid = username.newBuffer.subarray(0, 15); + this.username = username.value; + this.uuid = Util.uuidBufferToString(uuid); + return this; + } +} diff --git a/server/proxy/packets/channel/CSChannelMessage.js b/server/proxy/packets/channel/CSChannelMessage.js new file mode 100644 index 0000000..298f305 --- /dev/null +++ b/server/proxy/packets/channel/CSChannelMessage.js @@ -0,0 +1,21 @@ +import { Enums } from "../../Enums.js"; +import { MineProtocol } from "../../Protocol.js"; +export class CSChannelMessagePacket { + packetId = Enums.PacketId.CSChannelMessagePacket; + type = "packet"; + boundTo = Enums.PacketBounds.S; + sentAfterHandshake = true; + messageType = Enums.ChannelMessageType.CLIENT; + channel; + data; + serialize() { + return Buffer.concat([[this.packetId], MineProtocol.writeString(this.channel), this.data].map((arr) => (arr instanceof Uint8Array ? arr : Buffer.from(arr)))); + } + deserialize(packet) { + packet = packet.subarray(1); + const channel = MineProtocol.readString(packet), data = channel.newBuffer; + this.channel = channel.value; + this.data = data; + return this; + } +} diff --git a/server/proxy/packets/channel/SCChannelMessage.js b/server/proxy/packets/channel/SCChannelMessage.js new file mode 100644 index 0000000..93c8b71 --- /dev/null +++ b/server/proxy/packets/channel/SCChannelMessage.js @@ -0,0 +1,21 @@ +import { Enums } from "../../Enums.js"; +import { MineProtocol } from "../../Protocol.js"; +export class SCChannelMessagePacket { + packetId = Enums.PacketId.SCChannelMessagePacket; + type = "packet"; + boundTo = Enums.PacketBounds.C; + sentAfterHandshake = true; + messageType = Enums.ChannelMessageType.SERVER; + channel; + data; + serialize() { + return Buffer.concat([[this.packetId], MineProtocol.writeString(this.channel), this.data].map((arr) => (arr instanceof Uint8Array ? arr : Buffer.from(arr)))); + } + deserialize(packet) { + packet = packet.subarray(1); + const channel = MineProtocol.readString(packet), data = channel.newBuffer; + this.channel = channel.value; + this.data = data; + return this; + } +} diff --git a/server/proxy/pluginLoader/PluginLoaderTypes.js b/server/proxy/pluginLoader/PluginLoaderTypes.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/server/proxy/pluginLoader/PluginLoaderTypes.js @@ -0,0 +1 @@ +export {}; diff --git a/server/proxy/pluginLoader/PluginManager.js b/server/proxy/pluginLoader/PluginManager.js new file mode 100644 index 0000000..1141c43 --- /dev/null +++ b/server/proxy/pluginLoader/PluginManager.js @@ -0,0 +1,244 @@ +import * as fs from "fs/promises"; +import * as pathUtil from "path"; +import * as semver from "semver"; +import { EventEmitter } from "events"; +import { pathToFileURL } from "url"; +import { Logger } from "../../logger.js"; +import { PROXY_VERSION } from "../../meta.js"; +import { Util } from "../Util.js"; +import { Enums } from "../Enums.js"; +import { Chat } from "../Chat.js"; +import { Constants } from "../Constants.js"; +import { Motd } from "../Motd.js"; +import { Player } from "../Player.js"; +import { MineProtocol } from "../Protocol.js"; +import { EaglerSkins } from "../skins/EaglerSkins.js"; +import { BungeeUtil } from "../BungeeUtil.js"; +export class PluginManager extends EventEmitter { + plugins; + proxy; + Logger = Logger; + Enums = Enums; + Chat = Chat; + Constants = Constants; + Motd = Motd; + Player = Player; + MineProtocol = MineProtocol; + EaglerSkins = EaglerSkins; + Util = Util; + BungeeUtil = BungeeUtil; + _loadDir; + _logger; + constructor(loadDir) { + super(); + this.setMaxListeners(0); + this._loadDir = loadDir; + this.plugins = new Map(); + this.Logger = Logger; + this._logger = new this.Logger("PluginManager"); + } + async loadPlugins() { + this._logger.info("Loading plugin metadata files..."); + const pluginMeta = await this._findPlugins(this._loadDir); + await this._validatePluginList(pluginMeta); + let pluginsString = ""; + for (const [id, plugin] of pluginMeta) { + pluginsString += `${id}@${plugin.version}`; + } + pluginsString = pluginsString.substring(0, pluginsString.length - 1); + this._logger.info(`Found ${pluginMeta.size} plugin(s): ${pluginsString}`); + if (pluginMeta.size !== 0) { + this._logger.info(`Loading ${pluginMeta.size} plugin(s)...`); + const successLoadCount = await this._loadPlugins(pluginMeta, this._getLoadOrder(pluginMeta)); + this._logger.info(`Successfully loaded ${successLoadCount} plugin(s).`); + } + this.emit("pluginsFinishLoading", this); + } + async _findPlugins(dir) { + const ret = new Map(); + const lsRes = (await Promise.all((await fs.readdir(dir)).filter((ent) => !ent.endsWith(".disabled")).map(async (res) => [pathUtil.join(dir, res), await fs.stat(pathUtil.join(dir, res))]))); + for (const [path, details] of lsRes) { + if (details.isFile()) { + if (path.endsWith(".jar")) { + this._logger.warn(`Non-EaglerProxy plugin found! (${path})`); + this._logger.warn(`BungeeCord plugins are NOT supported! Only custom EaglerProxy plugins are allowed.`); + } + else if (path.endsWith(".zip")) { + this._logger.warn(`.zip file found in plugin directory! (${path})`); + this._logger.warn(`A .zip file was found in the plugins directory! Perhaps you forgot to unzip it?`); + } + else + this._logger.debug(`Skipping file found in plugin folder: ${path}`); + } + else { + const metadataPath = pathUtil.resolve(pathUtil.join(path, "metadata.json")); + let metadata; + try { + const file = await fs.readFile(metadataPath); + metadata = JSON.parse(file.toString()); + // do some type checking + if (typeof metadata.name != "string") + throw new TypeError(".name is either null or not of a string type!"); + if (typeof metadata.id != "string") + throw new TypeError(".id is either null or not of a string type!"); + if (/ /gm.test(metadata.id)) + throw new Error(`.id contains whitespace!`); + if (!semver.valid(metadata.version)) + throw new Error(".version is either null, not a string, or is not a valid SemVer!"); + if (typeof metadata.entry_point != "string") + throw new TypeError(".entry_point is either null or not a string!"); + if (!metadata.entry_point.endsWith(".js")) + throw new Error(`.entry_point (${metadata.entry_point}) references a non-JavaScript file!`); + if (!(await Util.fsExists(pathUtil.resolve(path, metadata.entry_point)))) + throw new Error(`.entry_point (${metadata.entry_point}) references a non-existent file!`); + if (metadata.requirements instanceof Array == false) + throw new TypeError(".requirements is either null or not an array!"); + for (const requirement of metadata.requirements) { + if (typeof requirement != "object" || requirement == null) + throw new TypeError(`.requirements[${metadata.requirements.indexOf(requirement)}] is either null or not an object!`); + if (typeof requirement.id != "string") + throw new TypeError(`.requirements[${metadata.requirements.indexOf(requirement)}].id is either null or not a string!`); + if (/ /gm.test(requirement.id)) + throw new TypeError(`.requirements[${metadata.requirements.indexOf(requirement)}].id contains whitespace!`); + if (semver.validRange(requirement.version) == null && requirement.version != "any") + throw new TypeError(`.requirements[${metadata.requirements.indexOf(requirement)}].version is either null or not a valid SemVer!`); + } + if (metadata.load_after instanceof Array == false) + throw new TypeError(".load_after is either null or not an array!"); + for (const loadReq of metadata.load_after) { + if (typeof loadReq != "string") + throw new TypeError(`.load_after[${metadata.load_after.indexOf(loadReq)}] is either null, or not a valid ID!`); + if (/ /gm.test(loadReq)) + throw new TypeError(`.load_after[${metadata.load_after.indexOf(loadReq)}] contains whitespace!`); + } + if (metadata.incompatibilities instanceof Array == false) + throw new TypeError(".incompatibilities is either null or not an array!"); + for (const incompatibility of metadata.incompatibilities) { + if (typeof incompatibility != "object" || incompatibility == null) + throw new TypeError(`.incompatibilities[${metadata.load_after.indexOf(incompatibility)}] is either null or not an object!`); + if (typeof incompatibility.id != "string") + throw new TypeError(`.incompatibilities[${metadata.load_after.indexOf(incompatibility)}].id is either null or not a string!`); + if (/ /gm.test(incompatibility.id)) + throw new TypeError(`.incompatibilities[${metadata.load_after.indexOf(incompatibility)}].id contains whitespace!`); + if (semver.validRange(incompatibility.version) == null) + throw new TypeError(`.incompatibilities[${metadata.load_after.indexOf(incompatibility)}].version is either null or not a valid SemVer!`); + } + if (ret.has(metadata.id)) + throw new Error(`Duplicate plugin ID detected: ${metadata.id}. Are there duplicate plugins in the plugin folder?`); + ret.set(metadata.id, { + path: pathUtil.resolve(path), + ...metadata, + }); + } + catch (err) { + this._logger.warn(`Failed to load plugin metadata file at ${metadataPath}: ${err.stack ?? err}`); + this._logger.warn("This plugin will skip loading due to an error."); + } + } + } + return ret; + } + async _validatePluginList(plugins) { + for (const [id, plugin] of plugins) { + for (const req of plugin.requirements) { + if (!plugins.has(req.id) && req.id != "eaglerproxy" && !req.id.startsWith("module:")) { + this._logger.fatal(`Error whilst loading plugins: Plugin ${plugin.name}@${plugin.version} requires plugin ${req.id}@${req.version}, but it is not found!`); + this._logger.fatal("Loading has halted due to missing dependencies."); + process.exit(1); + } + if (req.id == "eaglerproxy") { + if (!semver.satisfies(PROXY_VERSION, req.version) && req.version != "any") { + this._logger.fatal(`Error whilst loading plugins: Plugin ${plugin.name}@${plugin.version} requires a proxy version that satisfies the SemVer requirement ${req.version}, but the proxy version is ${PROXY_VERSION} and does not satisfy the SemVer requirement!`); + this._logger.fatal("Loading has halted due to dependency issues."); + process.exit(1); + } + } + else if (req.id.startsWith("module:")) { + const moduleName = req.id.replace("module:", ""); + try { + await import(moduleName); + } + catch (err) { + if (err.code == "ERR_MODULE_NOT_FOUND") { + this._logger.fatal(`Plugin ${plugin.name}@${plugin.version} requires NPM module ${moduleName}${req.version == "any" ? "" : `@${req.version}`} to be installed, but it is not found!`); + this._logger.fatal(`Please install this missing package by running "npm install ${moduleName}${req.version == "any" ? "" : `@${req.version}`}". If you're using yarn, run "yarn add ${moduleName}${req.version == "any" ? "" : `@${req.version}`}" instead.`); + this._logger.fatal("Loading has halted due to dependency issues."); + process.exit(1); + } + } + } + else { + let dep = plugins.get(req.id); + if (!semver.satisfies(dep.version, req.version) && req.version != "any") { + this._logger.fatal(`Error whilst loading plugins: Plugin ${plugin.name}@${plugin.version} requires a version of plugin ${dep.name} that satisfies the SemVer requirement ${req.version}, but the plugin ${dep.name}'s version is ${dep.version} and does not satisfy the SemVer requirement!`); + this._logger.fatal("Loading has halted due to dependency issues."); + process.exit(1); + } + } + } + plugin.incompatibilities.forEach((incomp) => { + const plugin_incomp = plugins.get(incomp.id); + if (plugin_incomp) { + if (semver.satisfies(plugin_incomp.version, incomp.version)) { + this._logger.fatal(`Error whilst loading plugins: Plugin incompatibility found! Plugin ${plugin.name}@${plugin.version} is incompatible with ${plugin_incomp.name}@${plugin_incomp.version} as it satisfies the SemVer requirement of ${incomp.version}!`); + this._logger.fatal("Loading has halted due to plugin incompatibility issues."); + process.exit(1); + } + } + else if (incomp.id == "eaglerproxy") { + if (semver.satisfies(PROXY_VERSION, incomp.version)) { + this._logger.fatal(`Error whilst loading plugins: Plugin ${plugin.name}@${plugin.version} is incompatible with proxy version ${PROXY_VERSION} as it satisfies the SemVer requirement of ${incomp.version}!`); + this._logger.fatal("Loading has halted due to plugin incompatibility issues."); + process.exit(1); + } + } + }); + } + } + _getLoadOrder(plugins) { + let order = [], lastPlugin; + plugins.forEach((v) => order.push(v.id)); + for (const [id, plugin] of plugins) { + const load = plugin.load_after.filter((dep) => plugins.has(dep)); + if (load.length < 0) { + order.push(plugin.id); + } + else { + let mostLastIndexFittingDeps = -1; + for (const loadEnt of load) { + if (loadEnt != lastPlugin) { + if (order.indexOf(loadEnt) + 1 > mostLastIndexFittingDeps) { + mostLastIndexFittingDeps = order.indexOf(loadEnt) + 1; + } + } + } + if (mostLastIndexFittingDeps != -1) { + order.splice(order.indexOf(plugin.id), 1); + order.splice(mostLastIndexFittingDeps - 1, 0, plugin.id); + lastPlugin = plugin; + } + } + } + return order; + } + async _loadPlugins(plugins, order) { + let successCount = 0; + for (const id of order) { + let pluginMeta = plugins.get(id); + try { + const imp = await import(process.platform == "win32" ? pathToFileURL(pathUtil.join(pluginMeta.path, pluginMeta.entry_point)).toString() : pathUtil.join(pluginMeta.path, pluginMeta.entry_point)); + this.plugins.set(pluginMeta.id, { + exports: imp, + metadata: pluginMeta, + }); + successCount++; + this.emit("pluginLoad", pluginMeta.id, imp); + } + catch (err) { + this._logger.warn(`Failed to load plugin entry point for plugin (${pluginMeta.name}) at ${pluginMeta.path}: ${err.stack ?? err}`); + this._logger.warn("This plugin will skip loading due to an error."); + } + return successCount; + } + } +} diff --git a/server/proxy/ratelimit/BucketRatelimiter.js b/server/proxy/ratelimit/BucketRatelimiter.js new file mode 100644 index 0000000..b35b74b --- /dev/null +++ b/server/proxy/ratelimit/BucketRatelimiter.js @@ -0,0 +1,116 @@ +export default class BucketRateLimiter { + capacity; + refillsPerMin; + keyMap; + static GC_TOLERANCE = 50; + sweeper; + constructor(capacity, refillsPerMin) { + this.capacity = capacity; + this.refillsPerMin = refillsPerMin; + this.keyMap = new Map(); + this.sweeper = setInterval(() => { + this.removeFull(); + }, 5000); + } + cleanUp() { + clearInterval(this.sweeper); + } + consume(key, consumeTokens = 1) { + if (this.keyMap.has(key)) { + const bucket = this.keyMap.get(key); + const now = Date.now(); + if (now - bucket.lastRefillTime > 60000 && bucket.tokens < this.capacity) { + const refillTimes = Math.floor((now - bucket.lastRefillTime) / 60000); + bucket.tokens = Math.min(this.capacity, bucket.tokens + refillTimes * this.refillsPerMin); + bucket.lastRefillTime = now - (refillTimes % 60000); + } + else if (now - bucket.lastRefillTime > 60000 && bucket.tokens >= this.capacity) + bucket.lastRefillTime = now; + if (bucket.tokens >= consumeTokens) { + bucket.tokens -= consumeTokens; + return { success: true }; + } + else { + const difference = consumeTokens - bucket.tokens; + return { + success: false, + missingTokens: difference, + retryIn: Math.ceil(difference / this.refillsPerMin) * 60000 - ((now - bucket.lastRefillTime) % 60000), + retryAt: Date.now() + Math.ceil(difference / this.refillsPerMin) * 60000 - ((now - bucket.lastRefillTime) % 60000), + }; + } + } + else { + const bucket = { + tokens: this.capacity, + lastRefillTime: Date.now(), + }; + if (bucket.tokens >= consumeTokens) { + bucket.tokens -= consumeTokens; + this.keyMap.set(key, bucket); + return { success: true }; + } + else { + const difference = consumeTokens - bucket.tokens; + const now = Date.now(); + return { + success: false, + missingTokens: difference, + retryIn: Math.ceil(difference / this.refillsPerMin) * 60000 - ((now - bucket.lastRefillTime) % 60000), + retryAt: Date.now() + Math.ceil(difference / this.refillsPerMin) * 60000 - ((now - bucket.lastRefillTime) % 60000), + }; + } + } + } + addToBucket(key, amount) { + if (this.keyMap.has(key)) { + this.keyMap.get(key).tokens += amount; + } + else { + this.keyMap.set(key, { + tokens: this.capacity + amount, + lastRefillTime: Date.now(), + }); + } + } + setBucketSize(key, amount) { + if (this.keyMap.has(key)) { + this.keyMap.get(key).tokens = amount; + } + else { + this.keyMap.set(key, { + tokens: amount, + lastRefillTime: Date.now(), + }); + } + } + subtractFromBucket(key, amount) { + if (this.keyMap.has(key)) { + const bucket = this.keyMap.get(key); + bucket.tokens -= amount; + } + else { + this.keyMap.set(key, { + tokens: this.capacity - amount, + lastRefillTime: Date.now(), + }); + } + } + removeFull() { + let remove = []; + const now = Date.now(); + this.keyMap.forEach((v, k) => { + if (now - v.lastRefillTime > 60000 && v.tokens < this.capacity) { + const refillTimes = Math.floor((now - v.lastRefillTime) / 60000); + v.tokens = Math.min(this.capacity, v.tokens + refillTimes * this.refillsPerMin); + v.lastRefillTime = now - (refillTimes % 60000); + } + else if (now - v.lastRefillTime > 60000 && v.tokens >= this.capacity) + v.lastRefillTime = now; + if (v.tokens == this.capacity) { + remove.push(k); + } + }); + remove.forEach((v) => this.keyMap.delete(v)); + } +} diff --git a/server/proxy/ratelimit/ExponentialBackoffRequestController.js b/server/proxy/ratelimit/ExponentialBackoffRequestController.js new file mode 100644 index 0000000..8bceaa0 --- /dev/null +++ b/server/proxy/ratelimit/ExponentialBackoffRequestController.js @@ -0,0 +1,59 @@ +const wait = (ms) => new Promise((res) => setTimeout(res, ms)); +export default class ExponentialBackoffRequestController { + queue; + flushQueueAfterTries; + baseDelay; + ended; + aborted; + constructor(baseDelay = 3000, triesBeforeFlush = 10) { + this.flushQueueAfterTries = triesBeforeFlush; + this.baseDelay = baseDelay; + this.queue = []; + this.ended = false; + this.aborted = false; + setTimeout(() => this.tick(), 0); + } + async tick() { + while (true) { + if (this.ended) + break; + for (const task of this.queue) { + if (this.ended || this.aborted) + break; + let times = 0, breakOut = false; + while (true) { + try { + await task(); + break; + } + catch (err) { + times++; + await wait(this.baseDelay * 2 ** times); + if (times > this.flushQueueAfterTries) { + this.queue.forEach((task) => task(new Error("Controller overload!"))); + breakOut = true; + break; + } + } + } + if (breakOut) + break; + } + if (this.aborted) + this.aborted = false; + this.queue = []; + await wait(1); + } + } + end() { + this.ended = true; + } + flush() { + this.aborted = false; + this.queue.forEach((task) => task(new Error("Aborted"))); + this.queue = []; + } + queueTask(task) { + this.queue.push(task); + } +} diff --git a/server/proxy/ratelimit/ProxyRatelimitManager.js b/server/proxy/ratelimit/ProxyRatelimitManager.js new file mode 100644 index 0000000..d7a0d02 --- /dev/null +++ b/server/proxy/ratelimit/ProxyRatelimitManager.js @@ -0,0 +1,17 @@ +import BucketRateLimiter from "./BucketRatelimiter.js"; +export default class ProxyRatelimitManager { + http; + motd; + ws; + connect; + skinsIP; + skinsConnection; + constructor(config) { + this.http = new BucketRateLimiter(config.limits.http, config.limits.http); + this.ws = new BucketRateLimiter(config.limits.ws, config.limits.ws); + this.motd = new BucketRateLimiter(config.limits.motd, config.limits.motd); + this.connect = new BucketRateLimiter(config.limits.connect, config.limits.connect); + this.skinsIP = new BucketRateLimiter(config.limits.skinsIp, config.limits.skinsIp); + this.skinsConnection = new BucketRateLimiter(config.limits.skins, config.limits.skins); + } +} diff --git a/server/proxy/skins/EaglerSkins.js b/server/proxy/skins/EaglerSkins.js new file mode 100644 index 0000000..469927f --- /dev/null +++ b/server/proxy/skins/EaglerSkins.js @@ -0,0 +1,134 @@ +import { Constants } from "../Constants.js"; +import { Enums } from "../Enums.js"; +import { MineProtocol } from "../Protocol.js"; +import { Util } from "../Util.js"; +import fetch from "node-fetch"; +// TODO: convert all functions to use MineProtocol's UUID manipulation functions +export var EaglerSkins; +(function (EaglerSkins) { + async function skinUrlFromUuid(uuid) { + const response = (await (await fetch(`https://sessionserver.mojang.com/session/minecraft/profile/${uuid}`)).json()); + const parsed = JSON.parse(Buffer.from(response.properties[0].value, "base64").toString()); + return parsed.textures.SKIN.url; + } + EaglerSkins.skinUrlFromUuid = skinUrlFromUuid; + function downloadSkin(skinUrl) { + const url = new URL(skinUrl); + if (url.protocol != "https:" && url.protocol != "http:") + throw new Error("Invalid skin URL protocol!"); + return new Promise(async (res, rej) => { + const skin = await fetch(skinUrl); + if (skin.status != 200) { + rej({ + url: skinUrl, + status: skin.status, + }); + return; + } + else { + res(Buffer.from(await skin.arrayBuffer())); + } + }); + } + EaglerSkins.downloadSkin = downloadSkin; + function safeDownloadSkin(skinUrl, backoff) { + return new Promise((res, rej) => { + backoff.queueTask(async (err) => { + if (err) + return rej(err); + try { + res(await downloadSkin(skinUrl)); + } + catch (err) { + if (err.status == 429) + throw new Error("Ratelimited!"); + else + throw new Error("Unexpected HTTP status code: " + err.status); + } + }); + }); + } + EaglerSkins.safeDownloadSkin = safeDownloadSkin; + function readClientDownloadSkinRequestPacket(message) { + const ret = { + id: null, + uuid: null, + url: null, + }; + const id = MineProtocol.readVarInt(message), uuid = MineProtocol.readUUID(id.newBuffer), url = MineProtocol.readString(uuid.newBuffer, 1); + ret.id = id.value; + ret.uuid = uuid.value; + ret.url = url.value; + return ret; + } + EaglerSkins.readClientDownloadSkinRequestPacket = readClientDownloadSkinRequestPacket; + function writeClientDownloadSkinRequestPacket(uuid, url) { + return Buffer.concat([[Enums.EaglerSkinPacketId.CFetchSkinReq], MineProtocol.writeUUID(uuid), [0x0], MineProtocol.writeString(url)].map((arr) => (arr instanceof Uint8Array ? arr : Buffer.from(arr)))); + } + EaglerSkins.writeClientDownloadSkinRequestPacket = writeClientDownloadSkinRequestPacket; + function readServerFetchSkinResultBuiltInPacket(message) { + const ret = { + id: null, + uuid: null, + skinId: null, + }; + const id = MineProtocol.readVarInt(message), uuid = MineProtocol.readUUID(id.newBuffer), skinId = MineProtocol.readVarInt(id.newBuffer.subarray(id.newBuffer.length)); + ret.id = id.value; + ret.uuid = uuid.value; + ret.skinId = skinId.value; + return this; + } + EaglerSkins.readServerFetchSkinResultBuiltInPacket = readServerFetchSkinResultBuiltInPacket; + function writeServerFetchSkinResultBuiltInPacket(uuid, skinId) { + uuid = typeof uuid == "string" ? Util.uuidStringToBuffer(uuid) : uuid; + return Buffer.concat([Buffer.from([Enums.EaglerSkinPacketId.SFetchSkinBuiltInRes]), uuid, Buffer.from([skinId >> 24, skinId >> 16, skinId >> 8, skinId & 0xff])]); + } + EaglerSkins.writeServerFetchSkinResultBuiltInPacket = writeServerFetchSkinResultBuiltInPacket; + function readServerFetchSkinResultCustomPacket(message) { + const ret = { + id: null, + uuid: null, + skin: null, + }; + const id = MineProtocol.readVarInt(message), uuid = MineProtocol.readUUID(id.newBuffer), skin = uuid.newBuffer.subarray(0, Constants.EAGLERCRAFT_SKIN_CUSTOM_LENGTH); + ret.id = id.value; + ret.uuid = uuid.value; + ret.skin = skin; + return this; + } + EaglerSkins.readServerFetchSkinResultCustomPacket = readServerFetchSkinResultCustomPacket; + function writeClientFetchEaglerSkin(uuid, url) { + uuid = typeof uuid == "string" ? Util.uuidStringToBuffer(uuid) : uuid; + return Buffer.concat([[Enums.EaglerSkinPacketId.CFetchSkinEaglerPlayerReq], uuid, [0x00], MineProtocol.writeString(url)].map((arr) => (arr instanceof Uint8Array ? arr : Buffer.from(arr)))); + } + EaglerSkins.writeClientFetchEaglerSkin = writeClientFetchEaglerSkin; + function writeServerFetchSkinResultCustomPacket(uuid, skin, downloaded) { + uuid = typeof uuid == "string" ? Util.uuidStringToBuffer(uuid) : uuid; + return Buffer.concat([ + [Enums.EaglerSkinPacketId.SFetchSkinRes], + uuid, + [-1], // TODO: if buggy, use 0xff instead + skin.subarray(0, Constants.EAGLERCRAFT_SKIN_CUSTOM_LENGTH), + ].map((arr) => (arr instanceof Uint8Array ? arr : Buffer.from(arr)))); + } + EaglerSkins.writeServerFetchSkinResultCustomPacket = writeServerFetchSkinResultCustomPacket; + function readClientFetchEaglerSkinPacket(buff) { + const ret = { + id: null, + uuid: null, + }; + const id = MineProtocol.readVarInt(buff), uuid = MineProtocol.readUUID(id.newBuffer); + ret.id = id.value; + ret.uuid = uuid.value; + return ret; + } + EaglerSkins.readClientFetchEaglerSkinPacket = readClientFetchEaglerSkinPacket; + class EaglerSkin { + owner; + type; + // update this over time + builtInSkin; + skin; + } + EaglerSkins.EaglerSkin = EaglerSkin; +})(EaglerSkins || (EaglerSkins = {})); diff --git a/server/proxy/skins/ImageEditor.js b/server/proxy/skins/ImageEditor.js new file mode 100644 index 0000000..5f8a6e8 --- /dev/null +++ b/server/proxy/skins/ImageEditor.js @@ -0,0 +1,221 @@ +import { Logger } from "../../logger.js"; +import { Constants } from "../Constants.js"; +import fs from "fs/promises"; +let Jimp = null; +let sharp = null; +export var ImageEditor; +(function (ImageEditor) { + let loadedLibraries = false; + async function loadLibraries(native) { + if (loadedLibraries) + return; + if (native) + sharp = (await import("sharp")).default; + else { + try { + Jimp = (await import("jimp")).default; + } + catch (err) { + const logger = new Logger("ImageEditor.js"); + logger.fatal("**** ERROR: UNABLE TO LOAD JIMP!"); + logger.fatal("Please ensure that Jimp is installed by running 'npm install jimp' in the terminal."); + logger.fatal("If you'd like to use the faster native image editor, please set the 'useNatives' option in the config to true."); + logger.fatal(`Error: ${err.stack}`); + process.exit(1); + } + Jimp.appendConstructorOption("Custom Bitmap Constructor", (args) => args[0] && args[0].width != null && args[0].height != null && args[0].data != null, (res, rej, args) => { + this.bitmap = args[0]; + res(); + }); + } + loadedLibraries = true; + } + ImageEditor.loadLibraries = loadLibraries; + async function copyRawPixelsJS(imageIn, imageOut, dx1, dy1, dx2, dy2, sx1, sy1, sx2, sy2) { + console.log(imageOut); + if (dx1 > dx2) { + return _copyRawPixelsJS(imageIn, imageOut, sx1, sy1, dx2, dy1, sx2 - sx1, sy2 - sy1, imageIn.getWidth(), imageOut.getWidth(), true); + } + else { + return _copyRawPixelsJS(imageIn, imageOut, sx1, sy1, dx2, dy1, sx2 - sx1, sy2 - sy1, imageIn.getWidth(), imageOut.getWidth(), false); + } + } + ImageEditor.copyRawPixelsJS = copyRawPixelsJS; + // async function _copyRawPixelsJS(imageIn: Jimp, imageOut: Jimp, srcX: number, srcY: number, dstX: number, dstY: number, width: number, height: number, imgSrcWidth: number, imgDstWidth: number, flip: boolean): Promise { + // const inData = imageIn.bitmap.data, + // outData = imageOut.bitmap.data; + // for (let y = 0; y < height; y++) { + // for (let x = 0; x < width; x++) { + // let srcIndex = (srcY + y) * imgSrcWidth + srcX + x; + // let dstIndex = (dstY + y) * imgDstWidth + dstX + x; + // if (flip) { + // srcIndex = (srcY + y) * imgSrcWidth + srcX + (width - x - 1); + // } + // for (let c = 0; c < 4; c++) { + // // Assuming RGBA channels + // outData[dstIndex * 4 + c] = inData[srcIndex * 4 + c]; + // } + // } + // } + // return imageOut; + // // return sharp(outData, { + // // raw: { + // // width: outMeta.width!, + // // height: outMeta.height!, + // // channels: 4, + // // }, + // // }); + // } + async function _copyRawPixelsJS(imageIn, imageOut, srcX, srcY, dstX, dstY, width, height, imgSrcWidth, imgDstWidth, flip) { + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let srcIndex = (srcY + y) * imgSrcWidth + srcX + x; + if (flip) { + srcIndex = (srcY + y) * imgSrcWidth + srcX + (width - x - 1); + } + const pixelColor = imageIn.getPixelColor(srcX + x, srcY + y); + const rgba = Jimp.intToRGBA(pixelColor); + imageOut.setPixelColor(Jimp.rgbaToInt(rgba.r, rgba.g, rgba.b, rgba.a), dstX + x, dstY + y); + } + } + return imageOut; + } + async function copyRawPixels(imageIn, imageOut, dx1, dy1, dx2, dy2, sx1, sy1, sx2, sy2) { + const inMeta = await imageIn.metadata(), outMeta = await imageOut.metadata(); + if (dx1 > dx2) { + return _copyRawPixels(imageIn, imageOut, sx1, sy1, dx2, dy1, sx2 - sx1, sy2 - sy1, inMeta.width, outMeta.width, true); + } + else { + return _copyRawPixels(imageIn, imageOut, sx1, sy1, dx1, dy1, sx2 - sx1, sy2 - sy1, inMeta.width, outMeta.width, false); + } + } + ImageEditor.copyRawPixels = copyRawPixels; + async function _copyRawPixels(imageIn, imageOut, srcX, srcY, dstX, dstY, width, height, imgSrcWidth, imgDstWidth, flip) { + const inData = await imageIn.raw().toBuffer(); + const outData = await imageOut.raw().toBuffer(); + const outMeta = await imageOut.metadata(); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let srcIndex = (srcY + y) * imgSrcWidth + srcX + x; + let dstIndex = (dstY + y) * imgDstWidth + dstX + x; + if (flip) { + srcIndex = (srcY + y) * imgSrcWidth + srcX + (width - x - 1); + } + for (let c = 0; c < 4; c++) { + // Assuming RGBA channels + outData[dstIndex * 4 + c] = inData[srcIndex * 4 + c]; + } + } + } + return sharp(outData, { + raw: { + width: outMeta.width, + height: outMeta.height, + channels: 4, + }, + }); + } + async function toEaglerSkinJS(image) { + let jimpImage = await Jimp.read(image), height = jimpImage.getHeight(); + if (height != 64) { + // assume 32 height skin + let imageOut = await Jimp.create(64, 64, 0x0); + for (let x = 0; x < jimpImage.getWidth(); x++) { + for (let y = 0; y < jimpImage.getHeight(); y++) { + imageOut.setPixelColor(jimpImage.getPixelColor(x, y), x, y); + } + } + imageOut = await copyRawPixelsJS(jimpImage, imageOut, 24, 48, 20, 52, 4, 16, 8, 20); + imageOut = await copyRawPixelsJS(jimpImage, imageOut, 28, 48, 24, 52, 8, 16, 12, 20); + imageOut = await copyRawPixelsJS(jimpImage, imageOut, 20, 52, 16, 64, 8, 20, 12, 32); + imageOut = await copyRawPixelsJS(jimpImage, imageOut, 24, 52, 20, 64, 4, 20, 8, 32); + imageOut = await copyRawPixelsJS(jimpImage, imageOut, 28, 52, 24, 64, 0, 20, 4, 32); + imageOut = await copyRawPixelsJS(jimpImage, imageOut, 32, 52, 28, 64, 12, 20, 16, 32); + imageOut = await copyRawPixelsJS(jimpImage, imageOut, 40, 48, 36, 52, 44, 16, 48, 20); + imageOut = await copyRawPixelsJS(jimpImage, imageOut, 44, 48, 40, 52, 48, 16, 52, 20); + imageOut = await copyRawPixelsJS(jimpImage, imageOut, 36, 52, 32, 64, 48, 20, 52, 32); + imageOut = await copyRawPixelsJS(jimpImage, imageOut, 40, 52, 36, 64, 44, 20, 48, 32); + imageOut = await copyRawPixelsJS(jimpImage, imageOut, 44, 52, 40, 64, 40, 20, 44, 32); + imageOut = await copyRawPixelsJS(jimpImage, imageOut, 48, 52, 44, 64, 52, 20, 56, 32); + jimpImage = imageOut; + } + const newBuff = Buffer.alloc(Constants.EAGLERCRAFT_SKIN_CUSTOM_LENGTH); + const bitmap = jimpImage.bitmap.data; + for (let i = 1; i < 64 ** 2; i++) { + const bytePos = i * 4; + // red, green, blue, alpha => alpha, blue, green, red + newBuff[bytePos] = bitmap[bytePos + 3]; + newBuff[bytePos + 1] = bitmap[bytePos + 2]; + newBuff[bytePos + 2] = bitmap[bytePos + 1]; + newBuff[bytePos + 3] = bitmap[bytePos]; + } + return newBuff; + } + ImageEditor.toEaglerSkinJS = toEaglerSkinJS; + async function toEaglerSkin(image) { + const meta = await sharp(image).metadata(); + let sharpImage = sharp(image); + if (meta.height != 64) { + // assume 32 height skin + let imageOut = sharp(await sharpImage.extend({ bottom: 32, background: { r: 0, g: 0, b: 0, alpha: 0 } }).toBuffer()); + imageOut = await copyRawPixels(sharpImage, imageOut, 24, 48, 20, 52, 4, 16, 8, 20); + imageOut = await copyRawPixels(sharpImage, imageOut, 28, 48, 24, 52, 8, 16, 12, 20); + imageOut = await copyRawPixels(sharpImage, imageOut, 20, 52, 16, 64, 8, 20, 12, 32); + imageOut = await copyRawPixels(sharpImage, imageOut, 24, 52, 20, 64, 4, 20, 8, 32); + imageOut = await copyRawPixels(sharpImage, imageOut, 28, 52, 24, 64, 0, 20, 4, 32); + imageOut = await copyRawPixels(sharpImage, imageOut, 32, 52, 28, 64, 12, 20, 16, 32); + imageOut = await copyRawPixels(sharpImage, imageOut, 40, 48, 36, 52, 44, 16, 48, 20); + imageOut = await copyRawPixels(sharpImage, imageOut, 44, 48, 40, 52, 48, 16, 52, 20); + imageOut = await copyRawPixels(sharpImage, imageOut, 36, 52, 32, 64, 48, 20, 52, 32); + imageOut = await copyRawPixels(sharpImage, imageOut, 40, 52, 36, 64, 44, 20, 48, 32); + imageOut = await copyRawPixels(sharpImage, imageOut, 44, 52, 40, 64, 40, 20, 44, 32); + imageOut = await copyRawPixels(sharpImage, imageOut, 48, 52, 44, 64, 52, 20, 56, 32); + sharpImage = imageOut; + } + const r = await sharpImage.extractChannel("red").raw({ depth: "uchar" }).toBuffer(); + const g = await sharpImage.extractChannel("green").raw({ depth: "uchar" }).toBuffer(); + const b = await sharpImage.extractChannel("blue").raw({ depth: "uchar" }).toBuffer(); + const a = await sharpImage.ensureAlpha().extractChannel(3).toColorspace("b-w").raw({ depth: "uchar" }).toBuffer(); + const newBuff = Buffer.alloc(Constants.EAGLERCRAFT_SKIN_CUSTOM_LENGTH); + for (let i = 1; i < 64 ** 2; i++) { + const bytePos = i * 4; + newBuff[bytePos] = a[i]; + newBuff[bytePos + 1] = b[i]; + newBuff[bytePos + 2] = g[i]; + newBuff[bytePos + 3] = r[i]; + } + return newBuff; + } + ImageEditor.toEaglerSkin = toEaglerSkin; + function generateEaglerMOTDImage(file) { + return new Promise((res, rej) => { + sharp(file) + .resize(Constants.ICON_SQRT, Constants.ICON_SQRT, { + kernel: "nearest", + }) + .raw({ + depth: "uchar", + }) + .toBuffer() + .then((buff) => { + for (const pixel of buff) { + if ((pixel & 0xffffff) == 0) { + buff[buff.indexOf(pixel)] = 0; + } + } + res(buff); + }) + .catch(rej); + }); + } + ImageEditor.generateEaglerMOTDImage = generateEaglerMOTDImage; + function generateEaglerMOTDImageJS(file) { + return new Promise(async (res, rej) => { + Jimp.read(typeof file == "string" ? await fs.readFile(file) : file, async (err, image) => { + image = image.resize(Constants.ICON_SQRT, Constants.ICON_SQRT, Jimp.RESIZE_NEAREST_NEIGHBOR); + res(image.bitmap.data); + }); + }); + } + ImageEditor.generateEaglerMOTDImageJS = generateEaglerMOTDImageJS; +})(ImageEditor || (ImageEditor = {})); diff --git a/server/proxy/skins/SkinServer.js b/server/proxy/skins/SkinServer.js new file mode 100644 index 0000000..5191fd3 --- /dev/null +++ b/server/proxy/skins/SkinServer.js @@ -0,0 +1,123 @@ +import DiskDB from "../databases/DiskDB.js"; +import crypto from "crypto"; +import { Logger } from "../../logger.js"; +import { Constants } from "../Constants.js"; +import { Enums } from "../Enums.js"; +import { Util } from "../Util.js"; +import { SCChannelMessagePacket } from "../packets/channel/SCChannelMessage.js"; +import { EaglerSkins } from "./EaglerSkins.js"; +import { ImageEditor } from "./ImageEditor.js"; +import { MineProtocol } from "../Protocol.js"; +import ExponentialBackoffRequestController from "../ratelimit/ExponentialBackoffRequestController.js"; +export class SkinServer { + allowedSkinDomains; + cache; + proxy; + backoffController; + usingNative; + usingCache; + _logger; + deleteTask; + lifetime; + constructor(proxy, native, sweepInterval, cacheLifetime, cacheFolder = "./skinCache", useCache = true, allowedSkinDomains) { + this.allowedSkinDomains = allowedSkinDomains ?? ["textures.minecraft.net"]; + if (useCache) { + this.cache = new DiskDB(cacheFolder, (v) => exportCachedSkin(v), (b) => readCachedSkin(b), (k) => k.replaceAll("-", "")); + } + this.proxy = proxy ?? PROXY; + this.usingCache = useCache; + this.usingNative = native; + this.lifetime = cacheLifetime; + this.backoffController = new ExponentialBackoffRequestController(); + this._logger = new Logger("SkinServer"); + this._logger.info("Started EaglercraftX skin server."); + if (useCache) + this.deleteTask = setInterval(async () => await this.cache.filter((ent) => Date.now() < ent.expires), sweepInterval); + } + unload() { + if (this.deleteTask != null) + clearInterval(this.deleteTask); + } + async handleRequest(packet, caller, proxy) { + if (packet.messageType == Enums.ChannelMessageType.SERVER) + throw new Error("Server message was passed to client message handler!"); + else if (packet.channel != Constants.EAGLERCRAFT_SKIN_CHANNEL_NAME) + throw new Error("Cannot handle non-EaglerX skin channel messages!"); + { + const rl = proxy.ratelimit.skinsConnection.consume(caller.username), rlip = proxy.ratelimit.skinsIP.consume(caller.ws._socket.remoteAddress); + if (!rl.success || !rlip.success) + return; + } + switch (packet.data[0]) { + default: + throw new Error("Unknown operation!"); + break; + case Enums.EaglerSkinPacketId.CFetchSkinEaglerPlayerReq: + const parsedPacket_0 = EaglerSkins.readClientFetchEaglerSkinPacket(packet.data); + const player = this.proxy.fetchUserByUUID(parsedPacket_0.uuid); + if (player) { + if (player.skin.type == Enums.SkinType.BUILTIN) { + const response = new SCChannelMessagePacket(); + response.channel = Constants.EAGLERCRAFT_SKIN_CHANNEL_NAME; + response.data = EaglerSkins.writeServerFetchSkinResultBuiltInPacket(player.uuid, player.skin.builtInSkin); + caller.write(response); + } + else if (player.skin.type == Enums.SkinType.CUSTOM) { + const response = new SCChannelMessagePacket(); + response.channel = Constants.EAGLERCRAFT_SKIN_CHANNEL_NAME; + response.data = EaglerSkins.writeServerFetchSkinResultCustomPacket(player.uuid, player.skin.skin, false); + caller.write(response); + } + else + this._logger.warn(`Player ${caller.username} attempted to fetch player ${player.uuid}'s skin, but their skin hasn't loaded yet!`); + } + break; + case Enums.EaglerSkinPacketId.CFetchSkinReq: + const parsedPacket_1 = EaglerSkins.readClientDownloadSkinRequestPacket(packet.data), url = new URL(parsedPacket_1.url).hostname; + if (!this.allowedSkinDomains.some((domain) => Util.areDomainsEqual(domain, url))) { + this._logger.warn(`Player ${caller.username} tried to download a skin with a disallowed domain name (${url})!`); + break; + } + try { + let cacheHit = null, skin = null; + if (this.usingCache) { + (cacheHit = await this.cache.get(parsedPacket_1.uuid)), (skin = cacheHit != null ? cacheHit.data : null); + if (!skin) { + const fetched = await EaglerSkins.safeDownloadSkin(parsedPacket_1.url, this.backoffController); + skin = fetched; + await this.cache.set(parsedPacket_1.uuid, { + uuid: parsedPacket_1.uuid, + expires: Date.now() + this.lifetime, + data: fetched, + }); + } + } + else { + skin = await EaglerSkins.safeDownloadSkin(parsedPacket_1.url, this.backoffController); + } + const processed = this.usingNative ? await ImageEditor.toEaglerSkin(skin) : await ImageEditor.toEaglerSkinJS(skin), response = new SCChannelMessagePacket(); + response.channel = Constants.EAGLERCRAFT_SKIN_CHANNEL_NAME; + response.data = EaglerSkins.writeServerFetchSkinResultCustomPacket(parsedPacket_1.uuid, processed, true); + caller.write(response); + } + catch (err) { + this._logger.warn(`Failed to fetch skin URL ${parsedPacket_1.url} for player ${caller.username}: ${err.stack ?? err}`); + } + } + } +} +function digestMd5Hex(data) { + return crypto.createHash("md5").update(data).digest("hex"); +} +function exportCachedSkin(skin) { + const endUuid = MineProtocol.writeString(skin.uuid), encExp = MineProtocol.writeVarLong(skin.expires), encSkin = MineProtocol.writeBinary(skin.data); + return Buffer.concat([endUuid, encExp, encSkin]); +} +function readCachedSkin(data) { + const readUuid = MineProtocol.readString(data), readExp = MineProtocol.readVarLong(readUuid.newBuffer), readSkin = MineProtocol.readBinary(readExp.newBuffer); + return { + uuid: readUuid.value, + expires: readExp.value, + data: readSkin.value, + }; +}