mirror of
https://github.com/WorldEditAxe/eaglerproxy.git
synced 2024-11-13 17:16:04 -08:00
342 lines
17 KiB
JavaScript
342 lines
17 KiB
JavaScript
|
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;
|
||
|
}
|
||
|
}
|