q13x-eaglerproxy/server/proxy/Proxy.js

342 lines
17 KiB
JavaScript
Raw Normal View History

2024-09-04 05:02:00 -07:00
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;
}
}