This commit is contained in:
q13x 2023-06-06 23:30:59 -07:00
parent a908e16697
commit 9784b72bd0
15 changed files with 0 additions and 4358 deletions

3
.gitignore vendored
View File

@ -1,3 +0,0 @@
node_modules
insecureSocks
build

View File

@ -1,61 +0,0 @@
# EaglercraftX (1.8.9) WebSocket Proxy
## This repository has been archived as EaglerX's client and bungee [source code](https://gitlab.com/lax1dude/eaglercraftx-1.8) has been released. Please migrate to it if you haven't already!
### Demo: `wss://eaglerx-server.worldeditaxe.repl.co/server` ([EaglerX 1.8.9 client](https://web.arch.lol/mc/1.8.8/) only)
![Two EaglerX clients connected to the same server](./assets/demo.png)
## What is this?
A WebSocket proxy that allows EaglercraftX 1.8 clients to connect to an offline vanilla Minecraft server with (mostly working) Eaglercraft skin support. This is meant to be a replacement for the unreleased official EaglercraftX bungee until it releases. It supports all offline 1.8.9 servers and even online servers when modified!
**Note:** Don't expect magic. Some things may or may not work. While the proxy has shown to be stable and working during testing, you may encounter some bugs.
## Setup Guide
### Prerequisites
* Node.js v12 and up
* An **OFFLINE** 1.8.9-compatible Minecraft server or proxy
### Setup
#### If Repl.it is acceptable, fork the [demo](https://replit.com/@WorldEditAxe/eaglerx-server) and connect to it. All proxy files will be under the `proxy` folder.
1. Download the latest package available on this repository.
2. Open a terminal and go to the folder of the repository. Run `npm i`.
3. Edit `config.js` to configure your proxy. Below is a small breakdown of the configuration file.
```js
export const config = {
// The name of the proxy. (unused, required for MOTD)
name: "Proxy",
// The address to bind the WebSocket server to.
bindHost: "0.0.0.0",
// The port to bind the WebSocket server to.
// Use 80 if security.enabled is false, and 443 when it is true.
bindPort: 80,
// The max amount of concurrent connections allowed at once. (player cap)
maxPlayers: 20,
motd: {
// The file path that leads to an image.
// A 64x64 image is recommended, but any image with a 1:1 ratio will work.
iconURL: "./icon.webp",
// The first line of the MOTD.
l1: "hi",
// The second line of the MOTD.
l2: "lol"
},
server: {
// The hostname/IP of the remote server you want the proxy to connect to.
host: "127.0.0.1",
// The port of the remote server you want the proxy to connect to.
// On most Minecraft server installations, the default port is 25565.
port: 25565
},
security: {
// Set this to true to enable encryption (wss://).
// If you're using Repl.it, there's no need to enable encryption as it comes by default on all repls.
// You will need to obtain certificate files in order to enable encryption.
enabled: false,
// The private key file provided to you by your certificate authority.
key: null,
// The certificate file provided to you by your certificate authority.
cert: null
}
};
```
3. Start your proxy by running `node index.js`.
4. Connect to your server. For the server address, use the folllowing format: `ws://<IP>:<port>`. If you are using encryption, replace `ws://` with `wss://`.
## Creating Issues
When creating a new issue, please:
- Refrain from opening duplicate issues. If your issue is already there, please join in on the conversation there instead!
- Provide a brief description of the issue, what you expected, and what you got instead. Avoid unhelpful and overly short summaries - we need to know what's wrong!

Binary file not shown.

Before

Width:  |  Height:  |  Size: 693 KiB

View File

@ -1,26 +0,0 @@
import { Config } from "./types.js";
export const config: Config = {
name: "MinecraftProxy",
bindHost: "0.0.0.0",
bindPort: 80, // 443 if using TLS
maxPlayers: 20,
motd: {
iconURL: null,
l1: "hi",
l2: "lol"
},
server: {
host: "localhost",
port: 25565
},
security: { // provide path to key & cert if you want to enable encryption/secure websockets
enabled: false,
key: null,
cert: null
}
}
export const BRANDING: Readonly<string> = Object.freeze("EaglerXProxy")
export const VERSION: Readonly<string> = "1.0.0"
export const NETWORK_VERSION: Readonly<string> = Object.freeze(BRANDING + "/" + VERSION)

View File

@ -1,64 +0,0 @@
import { UUID } from "./types.js"
export enum EaglerPacketId {
IDENTIFY_CLIENT = 0x01,
IDENTIFY_SERVER = 0x2,
LOGIN = 0x4,
LOGIN_ACK = 0x05,
SKIN = 0x07,
C_READY = 0x08,
COMPLETE_HANDSHAKE = 0x09,
DISCONNECT = 0xff
}
export enum DisconnectReason {
CUSTOM = 0x8
}
// TODO: get skin fetching working
// Skins are raw Uint8 pixel data arrays.
// A pixel is represented by three bytes, each for each primary color: red, green and blue.
export type Skin = Buffer
// Prefixed skins are prefixed with their dimensions before being
export type PrefixedSkin = Buffer
export const MAGIC_BUILTIN_SKIN_BYTES = [0x00, 0x05, 0x01, 0x00, 0x00, 0x00]
export const MAGIC_ENDING_IDENTIFY_S_BYTES = [0x00, 0x00, 0x00]
export const MAGIC_ENDING_S_SKINDL_BI = [0x00, 0x00, 0x00]
export const EAGLERCRAFT_SKIN_CHANNEL_NAME = "EAG|Skins-1.8"
// NOTE: unless explicitly marked, a number (VarInt) preceding a string is a string
export type IdentifyC = [EaglerPacketId.IDENTIFY_CLIENT, 0x01, 0x2f, number, string, number, string]
export type IdentifyS = [EaglerPacketId.IDENTIFY_SERVER, 0x01, number, string, number, string]
export type Login = [EaglerPacketId.LOGIN, number, string, number, "default", 0x0]
export type LoginAck = [EaglerPacketId.LOGIN_ACK, number, string, UUID]
export type BaseSkin = [EaglerPacketId.SKIN, number, string, ...typeof MAGIC_BUILTIN_SKIN_BYTES | [number]]
// IF base skin packet ends with magic bytes...
export type SkinBuiltIn = [EaglerPacketId.SKIN, number, string, ...typeof MAGIC_BUILTIN_SKIN_BYTES, number]
export type SkinCustom = [EaglerPacketId.SKIN, number, string, number, PrefixedSkin]
export type ClientReady = [EaglerPacketId.C_READY]
export type Joined = [EaglerPacketId.COMPLETE_HANDSHAKE]
export type Disconnect = [EaglerPacketId.DISCONNECT, number, string, DisconnectReason]
// EAGLERCRAFT SKIN PROTOCOL
// All Eaglercraft skin networking is done through plugin channels under the channel name EAG|Skins-1.8.
// Below are some packet defs.
export enum EaglerSkinPacketId {
C_FETCH_SKIN = 0x03,
S_SKIN_DL_BI = 0x04,
S_SKIN_DL = 0x05,
C_REQ_SKIN = 0x06
}
export type SkinId = number
// A Vanilla plugin channel message packet.
// Every message is encapsulated through one of these packets.
export type CBaseChannelMessage = [0x17, number, string, Buffer]
export type SBaseChannelMessage = [0x3f, number, string, Buffer]
export type CFetchSkin = [EaglerSkinPacketId.C_FETCH_SKIN, UUID]
export type SSkinDlBi = [EaglerSkinPacketId.S_SKIN_DL_BI, UUID, ...typeof MAGIC_ENDING_S_SKINDL_BI, SkinId]
export type SSkinDl = [EaglerSkinPacketId.S_SKIN_DL, UUID, number, Skin]
export type CSkinReq = [EaglerSkinPacketId.C_REQ_SKIN, UUID, 0x00, number, string]

View File

@ -1,209 +0,0 @@
import {
encodeULEB128 as encodeVarInt,
decodeULEB128 as decodeVarInt,
encodeSLEB128 as encodeSVarInt,
decodeSLEB128 as decodeSVarInt
} from "@thi.ng/leb128"
import { DecodedCFetchSkin, DecodedSSkinDl, DecodedCSkinReq, DecodedSSkinFetchBuiltin, UnpackedChannelMessage, ProxiedPlayer, ChannelMessageType } from "./types.js"
import uuidBuffer from "uuid-buffer"
import { bufferizeUUID } from "./utils.js"
import { EAGLERCRAFT_SKIN_CHANNEL_NAME, EaglerSkinPacketId, MAGIC_ENDING_S_SKINDL_BI } from "./eaglerPacketDef.js"
import sharp from "sharp"
import request from "request"
export function unpackChannelMessage(message: Buffer): UnpackedChannelMessage {
if (message[0] != 0x17 && message[0] != 0x3f)
throw new Error("Invalid packet ID detected")
const ret: UnpackedChannelMessage = {
channel: null,
data: null,
type: null
}
const Iid = decodeVarInt(message)
const channelNameLen = decodeVarInt(message.subarray(Iid[1])), channelName = message.subarray(Iid[1] + channelNameLen[1], Iid[1] + channelNameLen[1] + Number(channelNameLen[0])).toString()
ret.type = Number(Iid[0])
ret.channel = channelName
ret.data = message.subarray(Iid[1] + channelNameLen[1] + channelName.length)
return ret
}
export function packChannelMessage(channel: string, type: ChannelMessageType, message: Buffer): Buffer {
const channelNameLen = encodeVarInt(channel.length), buff = Buffer.alloc(1 + channelNameLen.length + channel.length + message.length)
buff.set([type,...channelNameLen, ...Buffer.from(channel), ...message])
return buff
}
export function decodeCFetchSkin(message: Buffer): DecodedCFetchSkin {
const ret: DecodedCFetchSkin = {
id: null,
uuid: null
}
const Iid = decodeVarInt(message), uuid = uuidBuffer.toString(message.subarray(Iid[1]))
ret.id = Number(Iid[0])
ret.uuid = uuid
return ret
}
export function encodeFetchSkin(uuid: string | Buffer): Buffer {
uuid = typeof uuid == 'string' ? bufferizeUUID(uuid) : uuid
const buff = Buffer.alloc(1 + uuid.length)
buff.set([EaglerSkinPacketId.C_FETCH_SKIN, ...uuid])
return buff
}
export function decodeSSkinDlBuiltin(message: Buffer): DecodedSSkinFetchBuiltin {
const ret: DecodedSSkinFetchBuiltin = {
id: null,
uuid: null,
skinId: null
}
const Iid = decodeVarInt(message), uuid = uuidBuffer.toString(message.subarray(Iid[1], Iid[1] + 16))
ret.id = Number(Iid[0])
ret.uuid = uuid
const skinId = decodeVarInt(message.subarray(Iid[1] + 16 + 3))
ret.skinId = Number(skinId)
return ret
}
export function encodeSSkinDlBuiltin(uuid: string | Buffer, skinId: number): Buffer {
uuid = typeof uuid == 'string' ? bufferizeUUID(uuid) : uuid
const encSkinId = encodeVarInt(skinId)
const buff = Buffer.alloc(1 + 16 + 3 + encSkinId.length)
buff.set([EaglerSkinPacketId.S_SKIN_DL_BI, ...uuid, ...Buffer.from(MAGIC_ENDING_S_SKINDL_BI), encSkinId[0]])
return buff
}
export function decodeSSkinDl(message: Buffer): DecodedSSkinDl {
const ret: DecodedSSkinDl = {
id: null,
uuid: null,
skin: null
}
const Iid = decodeVarInt(message), uuid = uuidBuffer.toString(message.subarray(Iid[1], Iid[1] + 16))
ret.id = Number(Iid[0])
ret.uuid = uuid
const skin = message.subarray(Iid[1] + 16)
ret.skin = skin
return ret
}
export function encodeSSkinDl(uuid: string | Buffer, skin: Buffer, isFetched: boolean): Buffer {
uuid = typeof uuid == 'string' ? bufferizeUUID(uuid) : uuid
// eaglercraft clients always expect a 16385 byte long byte array for the skin
if (!isFetched) skin = skin.length !== 16384 ? skin.length < 16384 ? Buffer.concat([Buffer.alloc(16384 - skin.length), skin]) : skin.subarray(16383) : skin
else skin = skin.length !== 16384 ? skin.length < 16384 ? Buffer.concat([skin, Buffer.alloc(16384 - skin.length)]) : skin.subarray(16383) : skin
const buff = Buffer.alloc(1 + 16 + 1 + skin.length)
if (!isFetched) buff.set([EaglerSkinPacketId.S_SKIN_DL,...uuid, 0xff,...skin])
else buff.set([EaglerSkinPacketId.S_SKIN_DL, ...uuid, 0x00, ...skin])
return buff
}
export function decodeCSkinReq(message: Buffer): DecodedCSkinReq {
const ret: DecodedCSkinReq = {
id: null,
uuid: null,
url: null
}
const Iid = decodeVarInt(message), uuid = uuidBuffer.toString(message.subarray(Iid[1], Iid[1] + 16))
ret.id = Number(Iid[0])
ret.uuid = uuid
const urlLen = decodeVarInt(message.subarray(Iid[1] + 16 + 1)), url = message.subarray(Iid[1] + 16 + 1 + urlLen[1]).toString()
ret.url = url
return ret
}
export function encodeCSkinReq(uuid: string | Buffer, url: string): Buffer {
uuid = typeof uuid == 'string' ? bufferizeUUID(uuid) : uuid
const urlLen = encodeVarInt(url.length), eUrl = Buffer.from(url)
const buff = Buffer.alloc(1 + 1 + 16 + urlLen.length + eUrl.length)
buff.set([EaglerSkinPacketId.C_REQ_SKIN, ...uuid, 0x00, ...urlLen, ...eUrl])
return buff
}
const SEG_SIZE = 3
function invert(buff: Buffer): Buffer {
let buffers: Buffer[] = [], i = 0
const newBuffer = Buffer.alloc(buff.length)
while (true) {
if (i >= buff.length)
break
newBuffer.set(buff.subarray(i, i + 4).reverse(), i)
i += 4
}
return newBuffer
}
async function genRGBAEagler(buff: Buffer): Promise<Buffer> {
const r = await sharp(buff).extractChannel('red').raw({ depth: 'uchar' }).toBuffer()
const g = await sharp(buff).extractChannel('green').raw({ depth: 'uchar' }).toBuffer()
const b = await sharp(buff).extractChannel('blue').raw({ depth: 'uchar' }).toBuffer()
const a = await sharp(buff).ensureAlpha().extractChannel(3).toColorspace('b-w').raw({ depth: 'uchar' }).toBuffer()
const newBuff = Buffer.alloc(64 ** 2 * 4)
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
}
async function toEaglerSkin(buff: Buffer): Promise<Buffer> {
return genRGBAEagler(buff)
}
export async function fetchSkin(url: string, process?: boolean): Promise<Buffer> {
return new Promise<Buffer>((res, rej) => {
let body = []
request({ url: url, encoding: null }, (err, response, body) => {
if (err) {
rej(err)
} else {
toEaglerSkin(body).then(buff => res(buff))
}
})
})
}
export function getPlayerWithUUID(uuid: string): ProxiedPlayer {
for (const [username, plr] of PROXY.players) {
if (plr.uuid == uuid)
return plr
}
return null
}
export async function processClientReqPacket(decodedMessage: UnpackedChannelMessage, client: ProxiedPlayer) {
if (decodedMessage.type == ChannelMessageType.SERVER)
throw new Error("Server message was passed to client message handler")
switch(decodedMessage.data[0] as EaglerSkinPacketId) {
default:
throw new Error("Unknown operation")
break
case EaglerSkinPacketId.C_REQ_SKIN:
const reqSkinPck = decodeCSkinReq(decodedMessage.data)
if (getPlayerWithUUID(reqSkinPck.uuid)) {
client.ws.send(packChannelMessage(EAGLERCRAFT_SKIN_CHANNEL_NAME, ChannelMessageType.SERVER, encodeSSkinDl(reqSkinPck.uuid, getPlayerWithUUID(reqSkinPck.uuid).skin.customSkin, false)))
} else {
try {
const skin = await fetchSkin(reqSkinPck.url)
const resPck = encodeSSkinDl(reqSkinPck.uuid, skin, true)
client.ws.send(packChannelMessage(EAGLERCRAFT_SKIN_CHANNEL_NAME, ChannelMessageType.SERVER, resPck))
} catch {}
}
break
case EaglerSkinPacketId.C_FETCH_SKIN:
const fetchSkinPlrPck = decodeCFetchSkin(decodedMessage.data)
const plr = getPlayerWithUUID(fetchSkinPlrPck.uuid)
if (plr) {
if (plr.skin.type == 'BUILTIN') {
client.ws.send(packChannelMessage(EAGLERCRAFT_SKIN_CHANNEL_NAME, ChannelMessageType.SERVER, encodeSSkinDlBuiltin(plr.uuid, plr.skin.skinId)))
} else {
client.ws.send(packChannelMessage(EAGLERCRAFT_SKIN_CHANNEL_NAME, ChannelMessageType.SERVER, encodeSSkinDl(plr.uuid, plr.skin.customSkin, false)))
}
}
}
}

7
globals.d.ts vendored
View File

@ -1,7 +0,0 @@
import { ProxyGlobals } from "./types.js"
declare global {
var PROXY: ProxyGlobals
}
export {}

View File

@ -1,95 +0,0 @@
import { readFileSync } from "fs";
import * as http from "http"
import * as https from "https"
import { WebSocketServer } from "ws";
import { BRANDING, config, NETWORK_VERSION, VERSION } from "./config.js";
import { handlePacket } from "./listener.js";
import { Logger } from "./logger.js";
import { disconnect, generateMOTDImage } from "./utils.js";
import { ChatColor, ProxiedPlayer, State } from "./types.js";
import { genUUID } from "./utils.js";
const logger = new Logger("EagXProxy")
const connectionLogger = new Logger("ConnectionHandler")
global.PROXY = {
brand: BRANDING,
version: VERSION,
MOTDVersion: NETWORK_VERSION,
serverName: config.name,
secure: false,
proxyUUID: genUUID(config.name),
MOTD: {
icon: config.motd.iconURL ? await generateMOTDImage(readFileSync(config.motd.iconURL)) : null,
motd: [config.motd.l1, config.motd.l2]
},
wsServer: null,
players: new Map(),
logger: logger,
config: config
}
let server: WebSocketServer
if (PROXY.config.security.enabled) {
logger.info(`Starting SECURE WebSocket proxy on port ${config.bindPort}...`)
if (process.env.REPL_SLUG) {
logger.warn("You appear to be running the proxy on Repl.it with encryption enabled. Please note that Repl.it by default provides encryption, and enabling encryption may or may not prevent you from connecting to the server.")
}
server = new WebSocketServer({
server: https.createServer({
key: readFileSync(config.security.key),
cert: readFileSync(config.security.cert)
}).listen(config.bindPort, config.bindHost)
})
} else {
logger.info(`Starting INSECURE WebSocket proxy on port ${config.bindPort}...`)
server = new WebSocketServer({
port: config.bindPort,
host: config.bindHost
})
}
PROXY.wsServer = server
server.addListener('connection', c => {
connectionLogger.debug(`[CONNECTION] New inbound WebSocket connection from [/${(c as any)._socket.remoteAddress}:${(c as any)._socket.remotePort}]. (${(c as any)._socket.remotePort} -> ${config.bindPort})`)
const plr = new ProxiedPlayer()
plr.ws = c
plr.ip = (c as any)._socket.remoteAddress
plr.remotePort = (c as any)._socket.remotePort
plr.state = State.PRE_HANDSHAKE
plr.queuedEaglerSkinPackets = []
c.on('message', msg => {
handlePacket(msg as Buffer, plr)
})
})
server.on('listening', () => {
logger.info(`Successfully started${config.security.enabled ? " [secure]" : ""} WebSocket proxy on port ${config.bindPort}!`)
})
process.on('uncaughtException', err => {
logger.error(`An uncaught exception was caught! Exception: ${err.stack ?? err}`)
})
process.on('unhandledRejection', err => {
logger.error(`An unhandled promise rejection was caught! Rejection: ${(err != null ? (err as any).stack : err) ?? err}`)
})
process.on('SIGTERM', () => {
logger.info("Cleaning up before exiting...")
for (const [username, plr] of PROXY.players) {
if (plr.remoteConnection != null) plr.remoteConnection.end()
disconnect(plr, ChatColor.YELLOW + "Proxy is shutting down.")
}
process.exit(0)
})
process.on('SIGINT', () => {
logger.info("Cleaning up before exiting...")
for (const [username, plr] of PROXY.players) {
if (plr.remoteConnection != null) plr.remoteConnection.end()
disconnect(plr, ChatColor.YELLOW + "Proxy is shutting down.")
}
process.exit(0)
})

View File

@ -1,40 +0,0 @@
import { EAGLERCRAFT_SKIN_CHANNEL_NAME } from "./eaglerPacketDef.js";
import { processClientReqPacket, unpackChannelMessage } from "./eaglerSkin.js";
import { Logger } from "./logger.js";
import { State, ProxiedPlayer } from "./types.js";
import { doHandshake, handleMotd } from "./utils.js";
const logger = new Logger("PacketHandler")
export function handlePacket(packet: Buffer, client: ProxiedPlayer) {
if (client.state == State.PRE_HANDSHAKE) {
if (packet.toString() === "Accept: MOTD") {
handleMotd(client)
} else if (!(client as any)._handled) {
;(client as any)._handled = true
doHandshake(client, packet)
.catch(err => {
logger.warn(`Error occurred whilst handling handshake! Error: ${err.stack ?? err}`)
})
} else if (!(client as any)._handled && packet[0] == 0x17) {
const decoded = unpackChannelMessage(packet)
if (decoded.channel == EAGLERCRAFT_SKIN_CHANNEL_NAME) {
client.queuedEaglerSkinPackets.push(decoded)
}
}
} else if (client.state == State.POST_HANDSHAKE) {
if (!client.remoteConnection || client.remoteConnection.socket.closed) {
logger.warn(`Received packet from player ${client.username} that is marked as post handshake, but is disconnected from the game server? Disconnecting due to illegal state.`)
client.ws.close()
} else {
if (packet[0] == 0x17) {
const decoded = unpackChannelMessage(packet)
if (decoded.channel == EAGLERCRAFT_SKIN_CHANNEL_NAME) {
processClientReqPacket(decoded, client)
}
} else {
client.remoteConnection.writeRaw(packet)
}
}
}
}

View File

@ -1,61 +0,0 @@
import { Chalk } from "chalk"
const color = new Chalk({ level: 2 })
let global_verbose: boolean = false
type JsonLogType = "info" | "warn" | "error" | "fatal" | "debug"
type JsonOutput = {
type: JsonLogType,
message: string
}
export function verboseLogging(newVal?: boolean) {
global_verbose = newVal ?? global_verbose ? false : true
}
function jsonLog(type: JsonLogType, message: string): string {
return JSON.stringify({
type: type,
message: message
}) + "\n"
}
export class Logger {
loggerName: string
verbose: boolean
private jsonLog: boolean = process.argv.includes("--json") || process.argv.includes("-j")
constructor(name: string, verbose?: boolean) {
this.loggerName = name
if (verbose) this.verbose = verbose
else this.verbose = global_verbose
}
info(s: string) {
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: string) {
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: string) {
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: string) {
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: string) {
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))
}
}
}

3200
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +0,0 @@
{
"dependencies": {
"@thi.ng/leb128": "^3.0.1",
"@types/axios": "^0.14.0",
"@types/node": "^18.11.16",
"@types/request": "^2.48.8",
"@types/sharp": "^0.31.0",
"@types/uuid": "^9.0.0",
"@types/ws": "^8.5.3",
"chalk": "^5.2.0",
"minecraft-protocol": "^1.36.2",
"request": "^2.88.2",
"sharp": "^0.31.2",
"tsc": "^2.0.4",
"uuid-buffer": "^1.0.3",
"ws": "^8.11.0"
},
"type": "module",
"name": "eaglerxbungee-reimpl",
"description": "Play on offline vanilla Minecraft 1.8.9 servers through EaglerX.",
"version": "1.0.0",
"main": "index.js",
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/WorldEditAxe/eaglerxbungee-reimpl.git"
},
"keywords": [
"eaglercraftx",
"eaglercraftx-bungee"
],
"author": "WorldEditAxe",
"license": "MIT",
"bugs": {
"url": "https://github.com/WorldEditAxe/eaglerxbungee-reimpl/issues"
},
"homepage": "https://github.com/WorldEditAxe/eaglerxbungee-reimpl#readme"
}

View File

@ -1,10 +0,0 @@
{
"compilerOptions": {
"module": "esnext",
"esModuleInterop": true,
"target": "es2017",
"moduleResolution": "node",
"sourceMap": false,
"outDir": "build"
}
}

159
types.ts
View File

@ -1,159 +0,0 @@
import { randomUUID } from "crypto"
import { Client } from "minecraft-protocol"
import { WebSocketServer, WebSocket } from "ws"
import { BRANDING, VERSION, NETWORK_VERSION } from "./config.js"
import { EaglerSkinPacketId, SkinId } from "./eaglerPacketDef.js"
import { Logger } from "./logger.js"
export type UUID = ReturnType<typeof randomUUID>
export enum State {
PRE_HANDSHAKE,
POST_HANDSHAKE,
DISCONNECTED
}
export type MOTD = {
icon?: Buffer, // 16384
motd: [string, string]
}
export type PlayerStats = {
max: number,
onlineCount: number
}
export type ProxyGlobals = {
brand: typeof BRANDING,
version: typeof VERSION,
MOTDVersion: typeof NETWORK_VERSION,
serverName: string,
secure: false,
proxyUUID: UUID,
MOTD: MOTD,
wsServer: WebSocketServer,
players: Map<string, ProxiedPlayer>,
logger: Logger,
config: Config
}
export type Config = {
name: string,
bindPort: number,
bindHost: string,
maxPlayers: number,
motd: {
iconURL?: string,
l1: string,
l2: string
},
server: {
host: string,
port: number
},
security: {
enabled: boolean
key: string,
cert: string
}
}
export enum ChatColor {
BLACK = "§0",
DARK_BLUE = "§1",
DARK_GREEN = "§2",
DARK_CYAN = "§3",
DARK_RED = "§4",
PURPLE = "§5",
GOLD = "§6",
GRAY = "§7",
DARK_GRAY = "§8",
BLUE = "§9",
BRIGHT_GREEN = "§a",
CYAN = "§b",
RED = "§c",
PINK = "§d",
YELLOW = "§e",
WHITE = "§f",
// text styling
OBFUSCATED = '§k',
BOLD = '§l',
STRIKETHROUGH = '§m',
UNDERLINED = '§n',
ITALIC = '§o',
RESET = '§r'
}
export type ChatExtra = {
text: string,
bold?: boolean,
italic?: boolean,
underlined?: boolean,
strikethrough?: boolean,
obfuscated?: boolean,
color?: ChatColor | 'reset'
}
export type Chat = {
text?: string,
bold?: boolean,
italic?: boolean,
underlined?: boolean,
strikethrough?: boolean,
obfuscated?: boolean,
color?: ChatColor | 'reset',
extra?: ChatExtra[]
}
export class ProxiedPlayer {
public username: string
public uuid: string
public clientBrand: string
public state: State
public ws: WebSocket
public ip: string
public remotePort: number
public remoteConnection: Client
public skin: {
type: "CUSTOM" | "BUILTIN",
skinId?: number,
customSkin?: Buffer
}
public queuedEaglerSkinPackets: UnpackedChannelMessage[]
}
export enum ChannelMessageType {
CLIENT = 0x17,
SERVER = 0x3f
}
export type UnpackedChannelMessage = {
channel: string,
data: Buffer,
type: ChannelMessageType
}
export type DecodedCFetchSkin = {
id: EaglerSkinPacketId.C_FETCH_SKIN,
uuid: UUID
}
export type DecodedSSkinFetchBuiltin = {
id: EaglerSkinPacketId.S_SKIN_DL_BI,
uuid: UUID,
skinId: SkinId
}
export type DecodedSSkinDl = {
id: EaglerSkinPacketId.S_SKIN_DL,
uuid: UUID,
skin: Buffer
}
export type DecodedCSkinReq = {
id: EaglerSkinPacketId.C_REQ_SKIN,
uuid: UUID,
url: string
}

382
utils.ts
View File

@ -1,382 +0,0 @@
import WebSocket from "ws"
import {
encodeULEB128 as encodeVarInt,
decodeULEB128 as decodeVarInt,
encodeSLEB128 as encodeSVarInt,
decodeSLEB128 as decodeSVarInt
} from "@thi.ng/leb128"
import { DisconnectReason, EAGLERCRAFT_SKIN_CHANNEL_NAME, EaglerPacketId, MAGIC_BUILTIN_SKIN_BYTES, MAGIC_ENDING_IDENTIFY_S_BYTES } from "./eaglerPacketDef.js"
import { Logger } from "./logger.js"
import { ChannelMessageType, Chat, ChatColor, ProxiedPlayer, State, UUID } from "./types.js"
import { toBuffer, toString as uuidToString } from "uuid-buffer"
import * as mc from "minecraft-protocol"
import { config } from "./config.js"
import sharp from "sharp"
import { createHash, randomUUID } from "crypto"
import { encodeSSkinDl, encodeSSkinDlBuiltin, packChannelMessage, processClientReqPacket } from "./eaglerSkin.js"
const logger = new Logger("LoginHandler")
const USERNAME_REGEX = /[^0-9^a-z^A-Z^_]/gi
export function genUUID(user: string): string {
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 uuidToString(md5Bytes)
}
export function bufferizeUUID(uuid: string): Buffer {
return toBuffer(uuid)
}
export function validateUsername(user: string): void | never {
if (user.length > 20)
throw new Error("Username is too long!")
if (!!user.match(USERNAME_REGEX))
throw new Error("Invalid username. Username can only contain alphanumeric characters, and the underscore (_) character.")
}
export function chatToPlainString(chat: Chat): string {
let ret = ''
if (chat.text != null) ret += chat.text
if (chat.extra != null) {
chat.extra.forEach(extra => {
ret += extra.text
})
}
return ret
}
export function disconnect(player: ProxiedPlayer, message: Chat | string, code?: DisconnectReason) {
if (player.state == State.POST_HANDSHAKE) {
const message_m = (typeof message == 'string' ? JSON.stringify({ text: message }) : JSON.stringify(message)) + 0x0
const messageLen = encodeVarInt(message_m.length)
const d = Buffer.alloc([0x40, ...messageLen, ...Buffer.from(message_m)].length)
d.set([0x40, ...messageLen, ...Buffer.from(message_m)])
player.ws.send(d)
player.ws.close()
} else {
const message_m = (typeof message == 'string' ? message : chatToPlainString(message))
const messageLen = encodeVarInt(message_m.length), codeEnc = encodeVarInt(code ?? DisconnectReason.CUSTOM)
const d = Buffer.alloc([0xff,...codeEnc, ...messageLen, ...Buffer.from(message_m)].length)
d.set([0xff,...codeEnc, ...messageLen, ...Buffer.from(message_m)])
player.ws.send(d)
player.ws.close()
}
}
export function awaitPacket(ws: WebSocket, id?: EaglerPacketId): Promise<Buffer> {
return new Promise<Buffer>((res, rej) => {
let resolved = false
const msgCb = (msg: any) => {
if (id != null && msg[0] == id) {
resolved = true
ws.removeEventListener('message', msgCb)
ws.removeEventListener('close', discon)
ws.setMaxListeners(ws.getMaxListeners() - 2 < 0 ? 5 : ws.getMaxListeners() - 2)
res(msg)
} else if (id == null) {
resolved = true
ws.removeEventListener('message', msgCb)
ws.removeEventListener('close', discon)
ws.setMaxListeners(ws.getMaxListeners() - 2 < 0 ? 5 : ws.getMaxListeners() - 2)
res(msg)
}
}
const discon = () => {
resolved = true
ws.removeEventListener('message', msgCb)
ws.removeEventListener('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.removeEventListener('message', msgCb)
ws.removeEventListener('close', discon)
ws.setMaxListeners(ws.getMaxListeners() - 2 < 0 ? 5 : ws.getMaxListeners() - 2)
rej("Timed out")
}, 10000)
})
}
export function loginServer(ip: string, port: number, client: ProxiedPlayer) {
return new Promise<void>((res, rej) => {
let blockedSuccessLogin = false
const mcClient = mc.createClient({
host: ip,
port: port,
auth: 'offline',
version: '1.8.8',
username: client.username
})
mcClient.on('error', err => {
mcClient.end()
rej(err)
})
mcClient.on('connect', () => {
client.remoteConnection = mcClient
mcClient.on('end', () => {
client.ws.close()
})
mcClient.on('kick_disconnect', kick => {
logger.warn(`Player ${client.username} was kicked from the server! Reason: ${kick.reason}`)
})
logger.info(`Player ${client.username} has been connected to the server.`)
res()
})
mcClient.on('raw', p => {
// block the login success packet to fix the bug that prints the UUID in chat on join
if (p[0] == 0x03 && mcClient.state == mc.states.LOGIN)
return
if (p[0] == 0x02 && blockedSuccessLogin) {
client.ws.send(p)
} else if (p[0] == 0x02) {
blockedSuccessLogin = !blockedSuccessLogin
} else {
client.ws.send(p)
}
})
})
}
export async function doHandshake(client: ProxiedPlayer, initialPacket: Buffer) {
client.ws.on('close', () => {
client.state = State.DISCONNECTED
if (client.remoteConnection) {
client.remoteConnection.end()
}
PROXY.players.delete(client.username)
logger.info(`Client [/${client.ip}:${client.remotePort}]${client.username ? ` (${client.username})` : ""} disconnected from the server.`)
})
if (PROXY.players.size + 1 > PROXY.config.maxPlayers) {
disconnect(client, ChatColor.YELLOW + "The proxy is full!", DisconnectReason.CUSTOM)
return
}
const identifyC = {
id: null,
brandingLen: null,
branding: null,
verLen: null,
ver: null
}
if (true) {
// save namespace by nesting func declarations in a if true statement
const Iid = decodeVarInt(initialPacket)
identifyC.id = Number(Iid[0])
const brandingLen = decodeVarInt(initialPacket.subarray(Iid[1] + 2))
identifyC.brandingLen = Number(brandingLen[0])
identifyC.branding = initialPacket.subarray(brandingLen[1] + Iid[1] + 2, brandingLen[1] + Iid[1] + Number(brandingLen[0]) + 2).toString()
const verLen = decodeVarInt(initialPacket.subarray(brandingLen[1] + Iid[1] + Number(brandingLen[0]) + 2))
identifyC.verLen = Number(verLen[0])
identifyC.ver = initialPacket.subarray(brandingLen[1] + Number(brandingLen[0]) + Iid[1] + verLen[1] + 2).toString()
}
if (true) {
const brandingLen = encodeVarInt(PROXY.brand.length), brand = PROXY.brand
const verLen = encodeVarInt(PROXY.version.length), version = PROXY.version
const buff = Buffer.alloc(2 + MAGIC_ENDING_IDENTIFY_S_BYTES.length + brandingLen.length + brand.length + verLen.length + version.length)
buff.set([EaglerPacketId.IDENTIFY_SERVER, 0x01, ...brandingLen, ...Buffer.from(brand), ...verLen, ...Buffer.from(version), ...Buffer.from(MAGIC_ENDING_IDENTIFY_S_BYTES)])
client.ws.send(buff)
}
client.clientBrand = identifyC.branding
const login = await awaitPacket(client.ws)
const loginP = {
id: null,
usernameLen: null,
username: null,
randomStrLen: null,
randomStr: null,
nullByte: null
}
if (login[0] === EaglerPacketId.LOGIN) {
const Iid = decodeVarInt(login)
loginP.id = Number(Iid[0])
const usernameLen = decodeVarInt(login.subarray(Iid[1]))
loginP.usernameLen = Number(usernameLen[0])
loginP.username = login.subarray(Iid[1] + usernameLen[1], Iid[1] + usernameLen[1] + loginP.usernameLen).toString()
const randomStrLen = decodeVarInt(login.subarray(Iid[1] + usernameLen[1] + loginP.usernameLen))
loginP.randomStrLen = Number(randomStrLen[0])
loginP.randomStr = login.subarray(Iid[1] + usernameLen[1] + loginP.usernameLen + randomStrLen[1], Iid[1] + usernameLen[1] + loginP.usernameLen + randomStrLen[1] + loginP.randomStrLen).toString()
client.username = loginP.username
client.uuid = genUUID(client.username)
try { validateUsername(client.username) }
catch (err) {
disconnect(client, ChatColor.RED + err.message, DisconnectReason.CUSTOM)
return
}
if (PROXY.players.has(client.username)) {
disconnect(client, `Duplicate username: ${client.username}. Please connect under a different username.`, DisconnectReason.CUSTOM)
return
}
PROXY.players.set(client.username, client)
if (true) {
const usernameLen = encodeVarInt(client.username.length), username = client.username
const uuid = bufferizeUUID(client.uuid)
const buff = Buffer.alloc(1 + usernameLen.length + username.length + uuid.length)
buff.set([EaglerPacketId.LOGIN_ACK, ...usernameLen, ...Buffer.from(username), ...Buffer.from(uuid)])
client.ws.send(buff)
if (true) {
const [skin, ready] = await Promise.all([awaitPacket(client.ws, EaglerPacketId.SKIN), awaitPacket(client.ws, EaglerPacketId.C_READY)])
if (ready[0] != 0x08) {
logger.error(`Client [/${client.ip}:${client.remotePort}] sent an unexpected packet! Disconnecting.`)
disconnect(client, ChatColor.RED + "Received bad packet.", DisconnectReason.CUSTOM)
client.ws.close()
return
}
if (true) {
const skinP = {
id: null,
skinVerLen: null,
skinVer: null, // skin_v1
type: null, // CUSTOM or BUILTIN
skinId: null,
skinDimens: null,
skin: null
}
const Iid = decodeVarInt(skin)
skinP.id = Number(Iid[0])
const skinVerLen = decodeVarInt(skin.subarray(Iid[1]))
skinP.skinVerLen = Number(skinVerLen[0])
skinP.skinVer = skin.subarray(Iid[1] + skinVerLen[1], Iid[1] + skinVerLen[1] + skinP.skinVerLen).toString()
const typebuff = skin.subarray(Iid[1] + skinVerLen[1] + skinP.skinVerLen, Iid[1] + skinVerLen[1] + skinP.skinVerLen + MAGIC_BUILTIN_SKIN_BYTES.length)
if (typebuff.compare(Buffer.from(MAGIC_BUILTIN_SKIN_BYTES)) == 0) {
skinP.type = "BUILTIN"
skinP.skinId = Number(decodeVarInt(skin.subarray(Iid[1] + skinVerLen[1] + skinP.skinVerLen + MAGIC_BUILTIN_SKIN_BYTES.length))[0])
} else {
skinP.type = "CUSTOM"
const skinSqrt = decodeVarInt(skin.subarray(Iid[1] + skinVerLen[1] + skinP.skinVerLen)), dimensions = Number(skinSqrt[0]) * Number(skinSqrt[0]) * 3
skinP.skinDimens = dimensions
skinP.skin = skin.subarray(Iid[1] + skinVerLen[1] + skinP.skinVerLen + skinSqrt[1] + 16)
if (skinP.skin.length > 16385) {
disconnect(client, ChatColor.RED + "Invalid skin received!")
return
}
}
client.skin = {
type: skinP.type,
skinId: skinP.skinId,
customSkin: skinP.skin
}
}
const buff = Buffer.alloc(1)
buff.set([EaglerPacketId.COMPLETE_HANDSHAKE])
client.ws.send(buff)
client.state = State.POST_HANDSHAKE
logger.info(`Client [/${client.ip}:${client.remotePort}] authenticated as player ${client.username} (${client.uuid}) and passed handshake. Connecting!`)
try { await loginServer(config.server.host, config.server.port, client) }
catch (err) {
logger.error(`Could not connect to remote server at [/${config.server.host}:${config.server.port}]: ${err}`)
disconnect(client, ChatColor.RED + "Failed to connect to server. Please try again later.", DisconnectReason.CUSTOM)
client.state = State.DISCONNECTED
client.ws.close()
return
}
if (client.queuedEaglerSkinPackets.length > 0) {
for (const packet of client.queuedEaglerSkinPackets) {
processClientReqPacket(packet, client)
}
}
}
}
} else {
logger.error(`Client [/${client.ip}:${client.remotePort}] sent an unexpected packet! Disconnecting.`)
disconnect(client, ChatColor.RED + "Received bad packet", DisconnectReason.CUSTOM)
client.ws.close()
}
}
export type MotdPlayer = {
name: string,
id: UUID
}
export type MotdJSONRes = {
brand: string,
cracked: true,
data: {
cache: true,
icon: boolean,
max: number,
motd: [string, string],
online: number,
players: string[],
},
name: string,
secure: false,
time: ReturnType<typeof Date.now>,
type: "motd",
uuid: ReturnType<typeof randomUUID>,
vers: string
}
// a 16384 byte array
export type MotdServerLogo = Buffer
const ICON_SQRT = 64
export function generateMOTDImage(file: Buffer): Promise<MotdServerLogo> {
return new Promise<MotdServerLogo>((res, rej) => {
sharp(file)
.resize(ICON_SQRT, 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)
})
}
export function handleMotd(player: Partial<ProxiedPlayer>) {
const names = []
for (const [username, player] of PROXY.players) {
if (names.length > 0) {
names.push(`${ChatColor.GRAY}${ChatColor.ITALIC}(and ${PROXY.players.size - names.length} more)`)
break
} else {
names.push(username)
}
}
player.ws.send(JSON.stringify({
brand: PROXY.brand,
cracked: true,
data: {
cache: true,
icon: PROXY.MOTD.icon ? true : false,
max: PROXY.config.maxPlayers,
motd: PROXY.MOTD.motd,
online: PROXY.players.size,
players: names
},
name: PROXY.serverName,
secure: false,
time: Date.now(),
type: "motd",
uuid: PROXY.proxyUUID,
vers: PROXY.MOTDVersion
} as MotdJSONRes))
if (PROXY.MOTD.icon) {
player.ws.send(PROXY.MOTD.icon)
}
player.ws.close()
}