mirror of
https://github.com/WorldEditAxe/eaglerproxy.git
synced 2024-11-22 21:46:04 -08:00
rewrite public release
This commit is contained in:
parent
9784b72bd0
commit
6d1ef55775
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules
|
||||||
|
build
|
71
README.md
Normal file
71
README.md
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
# EaglerProxy
|
||||||
|
A standalone reimplementation of EaglercraftX's bungee plugin written in TypeScript, with plugin support.
|
||||||
|
|
||||||
|
*Working for latest EaglercraftX client version as of `6/6/2023`*
|
||||||
|
## Known Issues
|
||||||
|
* Skins may not render/display properly at all.
|
||||||
|
## Installing and Running
|
||||||
|
This assumes that you have [Node.js](https://nodejs.org/en) LTS or higher installed on your computer, and that you have basic Git and CLI (command line) knowledge.
|
||||||
|
|
||||||
|
1. Clone/download this repository.
|
||||||
|
2. Modify and configure `config.ts` to your liking.
|
||||||
|
3. Install TypeScript and required dependencies (`npm i -g typescript` and `npm i`).
|
||||||
|
4. Compile the TypeScript code into normal JavaScript code (`tsc`).
|
||||||
|
5. Go into the `build` directory, and run `node launcher.js`.
|
||||||
|
## Plugins
|
||||||
|
As of right now, there only exists one plugin: EagProxyAAS (read below for more information).
|
||||||
|
### EagProxyAAS
|
||||||
|
EagProxyAAS aims to allow any Eaglercraft client to connect to a normal 1.8.9 Minecraft server (includes Hypixel), provided that players own a legitimate Minecraft Java copy.
|
||||||
|
#### IMPORTANT: READ ME BEFORE USING
|
||||||
|
It is highly suggested that you use [Resent Client](https://reslauncher.vercel.app/) if you aren't already. It provides better performance, FPS, and will allow for a more stable connection to servers.
|
||||||
|
|
||||||
|
**IMPORTANT:** Although both Resent Client and the vanilla Eaglercraft client are safe modified copies of Minecraft AOT-compiled to JavaScript, I cannot guarantee that you **will not get flagged by all anticheats.** While gameplay and testing has shown to be relatively stable and free of anticheat flags, more testing is needed to derive a conclusion on whether or not using EaglerProxy with EagProxyAAS is safe.
|
||||||
|
|
||||||
|
EaglerProxy and EagProxyAAS:
|
||||||
|
* is compatible with EaglercraftX and uses its handshake system,
|
||||||
|
* intercepts and reads Minecraft packet traffic between you and the server on the other end (necessary for functionality),
|
||||||
|
* only uses login data to authenticate with vanilla Minecraft servers,
|
||||||
|
* and is open source and safe to use.
|
||||||
|
|
||||||
|
EaglerProxy and EagProxyAAS does NOT:
|
||||||
|
* include any Microsoft/Mojang code,
|
||||||
|
* ship with offline (cracked) support by default,
|
||||||
|
* store or otherwise use authentication data for any other purpose as listed on the README,
|
||||||
|
* Unmodified versions will not maliciously handle your login data, although a modified version has the ability to do so. Only use trusted and unmodified versions of both this plugin and proxy.
|
||||||
|
* and intentionally put your account at risk.
|
||||||
|
|
||||||
|
Remember, open source software is never 100% safe. Read what you run on your computer.
|
||||||
|
|
||||||
|
##### Expectations
|
||||||
|
The built-in plugin serves as a demonstration of what can be done with plugins. Below is a list of what to expect from this demo.
|
||||||
|
* Expect server and world switching to take anywhere from 5 seconds to over 20.
|
||||||
|
* Not much can be done to resolve this issue. Issues related to this will likely be closed and marked as invalid.
|
||||||
|
* It is important that you refrain from moving your mouse and typing on your keyboard during this period. Doing so will increase your chance of timing out, or being unexpectedly kicked with the "End of stream" error.
|
||||||
|
* Expect the game to be unplayable (1-2 FPS at worst, maybe 30 FPS at best).
|
||||||
|
* This is not something fixable on my behalf, as Eaglercraft itself has a history of being slow and laggy. Despite improvments made to the game in attempt to increase performance, Eaglercraft still remains slow and barely playable.
|
||||||
|
* Try turning down your video settings to off/the lowest setting allowed. Unfullscreening and making your browser window smaller may result in higher FPS.
|
||||||
|
* Expect to be flagged by anticheats.
|
||||||
|
* While testing has shown the proxy and plugin to be relatively safe to play on, it is not guaranteed that you will not get flagged and banned on every single server out there.
|
||||||
|
* Tested servers: Hypixel
|
||||||
|
### Plugin Development
|
||||||
|
### Disclaimer
|
||||||
|
The proxy's software utilizes its own plugin API written in JavaScript, rather than BungeeCord's plugin API. For this reason, plugins written for the official BungeeCord plugin will **not** work on this proxy. Below are some instructions for making your very own EaglerProxy plugin.
|
||||||
|
*Refer to `src/plugins/EagProxyAAS` for an example plugin.*
|
||||||
|
Each and every EaglerProxy plugin consists of two parts:
|
||||||
|
|
||||||
|
* an entry point JavaScript file (this file is ran when the plugin is loaded)
|
||||||
|
* a `metadata.json` metadata file
|
||||||
|
|
||||||
|
Below is a breakdown of everything inside of `metadata.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Example Plugin",
|
||||||
|
"id": "examplePlugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"entry_point": "index.js",
|
||||||
|
"requirements": [{ id: "otherPlugin", version: "1.0.0" }],
|
||||||
|
"incompatibilities": [{ id: "someOtherPlugin", version: "2.0.0" }],
|
||||||
|
"load_after": ["otherPlugin"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
As of right now, there exists no API reference. Please refer to the preinstalled plugin for details regarding API usage.
|
2
example_plugin/index.ts
Normal file
2
example_plugin/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
const logger = new PLUGIN_MANAGER.Logger("ExamplePlugin")
|
||||||
|
logger.info("Hi!")
|
9
example_plugin/metadata.json
Normal file
9
example_plugin/metadata.json
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "Example Plugin",
|
||||||
|
"id": "examplePlugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"entry_point": "index.js",
|
||||||
|
"requirements": [],
|
||||||
|
"incompatibilities": [],
|
||||||
|
"load_after": []
|
||||||
|
}
|
2895
package-lock.json
generated
Normal file
2895
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "eaglerproxy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A flexible, easy expandable EaglercraftX 1.8.9 proxy written in TypeScript.",
|
||||||
|
"main": "launcher.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "WorldEditAxe, q13x",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@thi.ng/leb128": "^3.0.5",
|
||||||
|
"@types/node": "^18.11.18",
|
||||||
|
"@types/semver": "^7.3.13",
|
||||||
|
"@types/sharp": "^0.31.1",
|
||||||
|
"@types/ws": "^8.5.4",
|
||||||
|
"chalk": "^5.2.0",
|
||||||
|
"dotenv": "^16.0.3",
|
||||||
|
"minecraft-protocol": "^1.40.2",
|
||||||
|
"parse-domain": "^7.0.1",
|
||||||
|
"prismarine-block": "^1.16.3",
|
||||||
|
"prismarine-chunk": "^1.33.0",
|
||||||
|
"prismarine-registry": "^1.6.0",
|
||||||
|
"semver": "^7.3.8",
|
||||||
|
"sharp": "^0.31.3",
|
||||||
|
"ws": "^8.12.0"
|
||||||
|
},
|
||||||
|
"type": "module"
|
||||||
|
}
|
33
src/config.ts
Normal file
33
src/config.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
// 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.<ENV name>
|
||||||
|
|
||||||
|
import { Config } from "./launcher_types.js";
|
||||||
|
|
||||||
|
export const config: Config = {
|
||||||
|
bridge: {
|
||||||
|
enabled: false,
|
||||||
|
motd: null
|
||||||
|
},
|
||||||
|
adapter: {
|
||||||
|
name: "EaglerProxy",
|
||||||
|
bindHost: "0.0.0.0",
|
||||||
|
bindPort: 8080,
|
||||||
|
maxConcurrentClients: 20,
|
||||||
|
skinUrlWhitelist: undefined,
|
||||||
|
motd: true ? "FORWARD" : {
|
||||||
|
iconURL: "logo.png",
|
||||||
|
l1: "yes",
|
||||||
|
l2: "no"
|
||||||
|
},
|
||||||
|
origins: {
|
||||||
|
allowOfflineDownloads: true,
|
||||||
|
originWhitelist: null,
|
||||||
|
originBlacklist: null
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: "no",
|
||||||
|
port: 46625
|
||||||
|
},
|
||||||
|
tls: undefined
|
||||||
|
}
|
||||||
|
}
|
13
src/globals.d.ts
vendored
Normal file
13
src/globals.d.ts
vendored
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { Proxy } from "./proxy/Proxy.js";
|
||||||
|
import Packet from "./proxy/Packet.js";
|
||||||
|
import { Config } from "./launcher_types.js";
|
||||||
|
import { PluginManager } from "./proxy/pluginLoader/PluginManager.js";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var CONFIG: Config
|
||||||
|
var PROXY: Proxy
|
||||||
|
var PLUGIN_MANAGER: PluginManager
|
||||||
|
var PACKET_REGISTRY: Map<number, Packet & {
|
||||||
|
class: any
|
||||||
|
}>
|
||||||
|
}
|
27
src/launcher.ts
Normal file
27
src/launcher.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
import process from "process";
|
||||||
|
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";
|
||||||
|
|
||||||
|
const logger = new Logger("Launcher")
|
||||||
|
let proxy: Proxy
|
||||||
|
|
||||||
|
global.CONFIG = config
|
||||||
|
|
||||||
|
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
|
40
src/launcher_types.ts
Normal file
40
src/launcher_types.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
export type Config = {
|
||||||
|
bridge: BridgeOptions,
|
||||||
|
adapter: AdapterOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BridgeOptions = {
|
||||||
|
enabled: boolean,
|
||||||
|
motd: 'FORWARD' | {
|
||||||
|
iconURL?: string,
|
||||||
|
l1: string,
|
||||||
|
l2?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdapterOptions = {
|
||||||
|
name: "EaglerProxy",
|
||||||
|
bindHost: string,
|
||||||
|
bindPort: number,
|
||||||
|
maxConcurrentClients: 20,
|
||||||
|
skinUrlWhitelist?: string[],
|
||||||
|
origins: {
|
||||||
|
allowOfflineDownloads: boolean,
|
||||||
|
originWhitelist: string[],
|
||||||
|
originBlacklist: string[]
|
||||||
|
},
|
||||||
|
motd: 'FORWARD' | {
|
||||||
|
iconURL?: string,
|
||||||
|
l1: string,
|
||||||
|
l2?: string
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: string,
|
||||||
|
port: number
|
||||||
|
},
|
||||||
|
tls?: {
|
||||||
|
enabled: boolean,
|
||||||
|
key: null,
|
||||||
|
cert: null
|
||||||
|
}
|
||||||
|
}
|
63
src/logger.ts
Normal file
63
src/logger.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verboseLogging(process.env.DEBUG != null && process.env.DEBUG != "false" ? true : false)
|
11
src/meta.ts
Normal file
11
src/meta.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
const f = Object.freeze
|
||||||
|
|
||||||
|
// bridge meta
|
||||||
|
export const BRIDGE_VERSION: Readonly<number> = f(1)
|
||||||
|
|
||||||
|
// adapter meta
|
||||||
|
export const PROXY_BRANDING: Readonly<string> = f("EaglercraftXBungee")
|
||||||
|
export const PROXY_VERSION: Readonly<string> = f("1.0.6")
|
||||||
|
|
||||||
|
export const NETWORK_VERSION: Readonly<number> = f(0x03)
|
||||||
|
export const VANILLA_PROTOCOL_VERSION: Readonly<number> = f(47)
|
64
src/plugins/EagProxyAAS/auth.ts
Normal file
64
src/plugins/EagProxyAAS/auth.ts
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import { randomUUID } from "crypto"
|
||||||
|
import EventEmitter from "events"
|
||||||
|
import pauth from "prismarine-auth"
|
||||||
|
import debug from "debug"
|
||||||
|
|
||||||
|
const { Authflow, Titles } = pauth;
|
||||||
|
const Enums = PLUGIN_MANAGER.Enums
|
||||||
|
|
||||||
|
export type ServerDeviceCodeResponse = {
|
||||||
|
user_code: string
|
||||||
|
device_code: string
|
||||||
|
verification_uri: string
|
||||||
|
expires_in: number
|
||||||
|
interval: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class InMemoryCache {
|
||||||
|
private cache = {}
|
||||||
|
async getCached () {
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
async setCached (value) {
|
||||||
|
this.cache = value
|
||||||
|
}
|
||||||
|
async setCachedPartial (value) {
|
||||||
|
this.cache = {
|
||||||
|
...this.cache,
|
||||||
|
...value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auth(): EventEmitter {
|
||||||
|
const emitter = new EventEmitter()
|
||||||
|
const userIdentifier = randomUUID()
|
||||||
|
const flow = new Authflow(userIdentifier, ({ username, cacheName }) => new InMemoryCache(), {
|
||||||
|
authTitle: Titles.MinecraftNintendoSwitch,
|
||||||
|
flow: 'live',
|
||||||
|
deviceType: "Nintendo"
|
||||||
|
}, code => {
|
||||||
|
console.log = () => {}
|
||||||
|
emitter.emit('code', code)
|
||||||
|
})
|
||||||
|
flow.getMinecraftJavaToken({ fetchProfile: true })
|
||||||
|
.then(async data => {
|
||||||
|
const _data = (await (flow as any).mca.cache.getCached()).mca
|
||||||
|
if (data.profile == null || (data.profile as any).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
|
||||||
|
}
|
4
src/plugins/EagProxyAAS/config.ts
Normal file
4
src/plugins/EagProxyAAS/config.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export const config = {
|
||||||
|
bindInternalServerPort: 25569,
|
||||||
|
bindInternalServerIp: "127.0.0.1"
|
||||||
|
}
|
57
src/plugins/EagProxyAAS/index.ts
Normal file
57
src/plugins/EagProxyAAS/index.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import metadata from "./metadata.json" assert { type: "json" }
|
||||||
|
import { config } from "./config.js"
|
||||||
|
import { createServer } from "minecraft-protocol"
|
||||||
|
import { ClientState, ConnectionState, ServerGlobals } from "./types.js"
|
||||||
|
import { handleConnect, setSG } from "./utils.js"
|
||||||
|
|
||||||
|
const PluginManager = PLUGIN_MANAGER
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
const logger = new Logger("EaglerProxyAAS")
|
||||||
|
logger.info(`Starting ${metadata.name} v${metadata.version}...`)
|
||||||
|
logger.info(`(internal server port: ${config.bindInternalServerPort}, internal server IP: ${config.bindInternalServerPort})`)
|
||||||
|
|
||||||
|
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: ServerGlobals = {
|
||||||
|
server: server,
|
||||||
|
players: new Map()
|
||||||
|
}
|
||||||
|
setSG(sGlobals)
|
||||||
|
|
||||||
|
server.on('login', client => {
|
||||||
|
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: ClientState = {
|
||||||
|
state: ConnectionState.AUTH,
|
||||||
|
gameClient: client,
|
||||||
|
token: null,
|
||||||
|
lastStatusUpdate: null
|
||||||
|
}
|
||||||
|
sGlobals.players.set(client.username, cs)
|
||||||
|
handleConnect(cs)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
logger.info("Redirecting backend server IP... (this is required for the plugin to function)")
|
||||||
|
CONFIG.adapter.server = {
|
||||||
|
host: config.bindInternalServerIp,
|
||||||
|
port: config.bindInternalServerPort
|
||||||
|
}
|
27
src/plugins/EagProxyAAS/metadata.json
Normal file
27
src/plugins/EagProxyAAS/metadata.json
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "EaglerProxy as a Service",
|
||||||
|
"id": "eagpaas",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"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": []
|
||||||
|
}
|
45
src/plugins/EagProxyAAS/types.ts
Normal file
45
src/plugins/EagProxyAAS/types.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import { Client, Server } from "minecraft-protocol"
|
||||||
|
|
||||||
|
export type ServerGlobals = {
|
||||||
|
server: Server,
|
||||||
|
players: Map<string, ClientState>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientState = {
|
||||||
|
state: ConnectionState,
|
||||||
|
gameClient: Client,
|
||||||
|
token?: string,
|
||||||
|
lastStatusUpdate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ConnectionState {
|
||||||
|
AUTH,
|
||||||
|
SUCCESS,
|
||||||
|
DISCONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
255
src/plugins/EagProxyAAS/utils.ts
Normal file
255
src/plugins/EagProxyAAS/utils.ts
Normal file
|
@ -0,0 +1,255 @@
|
||||||
|
import { ServerGlobals } 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 { Client } from "minecraft-protocol"
|
||||||
|
import { ClientState, ConnectionState } from "./types.js"
|
||||||
|
import { auth, ServerDeviceCodeResponse } from "./auth.js"
|
||||||
|
|
||||||
|
const { Vec3 } = vec3 as any
|
||||||
|
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 as any).default('1.8.8'), LOGIN_CHUNK = generateSpawnChunk().dump()
|
||||||
|
const logger = new PLUGIN_MANAGER.Logger("PlayerHandler")
|
||||||
|
|
||||||
|
let SERVER: ServerGlobals = null
|
||||||
|
|
||||||
|
export function setSG(svr: ServerGlobals) {
|
||||||
|
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: ClientState) {
|
||||||
|
client.gameClient.write('login', {
|
||||||
|
entityId: 1,
|
||||||
|
gameMode: 2,
|
||||||
|
dimension: 0,
|
||||||
|
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: Client, filter: (msg: string) => boolean): Promise<string> {
|
||||||
|
return new Promise<string>((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: Client, msg: string) {
|
||||||
|
client.write('chat', {
|
||||||
|
message: JSON.stringify({ text: msg }),
|
||||||
|
position: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendMessageWarning(client: Client, msg: string) {
|
||||||
|
client.write('chat', {
|
||||||
|
message: JSON.stringify({
|
||||||
|
text: msg,
|
||||||
|
color: 'yellow'
|
||||||
|
}),
|
||||||
|
position: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendMessageLogin(client: Client, url: string, token: string) {
|
||||||
|
client.write('chat', {
|
||||||
|
message: JSON.stringify({
|
||||||
|
text: "Please go to ",
|
||||||
|
color: Enums.ChatColor.RESET,
|
||||||
|
extra: [
|
||||||
|
{
|
||||||
|
text: url,
|
||||||
|
color: 'gold',
|
||||||
|
clickEvent: {
|
||||||
|
action: "open_url",
|
||||||
|
value: url
|
||||||
|
},
|
||||||
|
hoverEvent: {
|
||||||
|
action: "show_text",
|
||||||
|
value: Enums.ChatColor.GOLD + "Click to open me in a new window!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: " and login via the code "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: token,
|
||||||
|
color: 'gold'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
position: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateState(client: Client, newState: 'AUTH' | 'SERVER', uri?: string, code?: string) {
|
||||||
|
switch(newState) {
|
||||||
|
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 <ip> [port]`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onConnect(client: ClientState) {
|
||||||
|
try {
|
||||||
|
client.state = ConnectionState.AUTH
|
||||||
|
client.lastStatusUpdate = Date.now()
|
||||||
|
|
||||||
|
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, `WARNING: It is highly suggested that you turn down settings and use Resent Client, as gameplay tends to be very laggy and unplayable on low powered devices.`)
|
||||||
|
await new Promise(res => setTimeout(res, 2000))
|
||||||
|
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 authHandler = auth(), codeCallback = (code: ServerDeviceCodeResponse) => {
|
||||||
|
updateState(client.gameClient, 'AUTH', code.verification_uri, code.user_code)
|
||||||
|
sendMessageLogin(client.gameClient, code.verification_uri, code.user_code)
|
||||||
|
}
|
||||||
|
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 => {
|
||||||
|
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 <ip> [port]${Enums.ChatColor.RESET}.`)
|
||||||
|
let host: string, port: number
|
||||||
|
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 <ip> [port]${Enums.ChatColor.RESET}.`)
|
||||||
|
else if (parsed.length > 3 && isNaN(parseInt(parsed[2]))) sendMessage(client.gameClient, `A valid port number has to be passed! ${Enums.ChatColor.GOLD}/join <ip> [port]${Enums.ChatColor.RESET}.`)
|
||||||
|
else {
|
||||||
|
host = parsed[1]
|
||||||
|
if (parsed.length > 3) port = parseInt(parsed[2])
|
||||||
|
port = port ?? 25565
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await PLUGIN_MANAGER.proxy.players.get(client.gameClient.username).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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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(): Chunk.PCChunk {
|
||||||
|
const chunk = new (Chunk.default(REGISTRY))(null) as Chunk.PCChunk
|
||||||
|
chunk.initialize(() => new McBlock(REGISTRY.blocksByName.air.id, REGISTRY.biomesByName.plains.id, 0))
|
||||||
|
chunk.setBlock(new Vec3(8, 64, 8), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.plains.id, 0))
|
||||||
|
chunk.setBlock(new Vec3(8, 67, 8), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.plains.id, 0))
|
||||||
|
chunk.setBlock(new Vec3(7, 65, 8), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.plains.id, 0))
|
||||||
|
chunk.setBlock(new Vec3(7, 66, 8), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.plains.id, 0))
|
||||||
|
chunk.setBlock(new Vec3(9, 65, 8), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.plains.id, 0))
|
||||||
|
chunk.setBlock(new Vec3(9, 66, 8), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.plains.id, 0))
|
||||||
|
chunk.setBlock(new Vec3(8, 65, 7), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.plains.id, 0))
|
||||||
|
chunk.setBlock(new Vec3(8, 66, 7), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.plains.id, 0))
|
||||||
|
chunk.setBlock(new Vec3(8, 65, 9), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.plains.id, 0))
|
||||||
|
chunk.setBlock(new Vec3(8, 66, 9), new McBlock(REGISTRY.blocksByName.barrier.id, REGISTRY.biomesByName.plains.id, 0))
|
||||||
|
chunk.setSkyLight(new Vec3(8, 66, 8), 15)
|
||||||
|
return chunk
|
||||||
|
}
|
147
src/proxy/BungeeUtil.ts
Normal file
147
src/proxy/BungeeUtil.ts
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
import { Logger } from "../logger.js"
|
||||||
|
import mcp, { states } from "minecraft-protocol"
|
||||||
|
|
||||||
|
const { createSerializer, createDeserializer } = mcp
|
||||||
|
|
||||||
|
export namespace BungeeUtil {
|
||||||
|
export class PacketUUIDTranslator {
|
||||||
|
public serverSidePlayerUUID: string
|
||||||
|
public clientSidePlayerUUID: string
|
||||||
|
|
||||||
|
static readonly CAST_UUID_SERVER: string[] = [
|
||||||
|
'update_attributes',
|
||||||
|
'named_entity_spawn',
|
||||||
|
// drop this packet (twitch.tv integration not available anymore)
|
||||||
|
'player_info'
|
||||||
|
]
|
||||||
|
static readonly CAST_UUID_CLIENT: string[] = [
|
||||||
|
'spectate'
|
||||||
|
]
|
||||||
|
|
||||||
|
private _logger: Logger
|
||||||
|
private _serverSerializer: any
|
||||||
|
private _clientSerializer: any
|
||||||
|
private _clientDeserializer: any
|
||||||
|
private _serverDeserializer: any
|
||||||
|
|
||||||
|
constructor(ssPlayerUUID: string, csPlayerUUID: string) {
|
||||||
|
this.serverSidePlayerUUID = ssPlayerUUID
|
||||||
|
this.clientSidePlayerUUID = csPlayerUUID
|
||||||
|
this._logger = new Logger("PacketTranslator")
|
||||||
|
this._serverSerializer = createSerializer({
|
||||||
|
state: states.PLAY,
|
||||||
|
isServer: true,
|
||||||
|
version: "1.8.8",
|
||||||
|
customPackets: null
|
||||||
|
})
|
||||||
|
this._clientSerializer = createSerializer({
|
||||||
|
state: states.PLAY,
|
||||||
|
isServer: false,
|
||||||
|
version: "1.8.8",
|
||||||
|
customPackets: null
|
||||||
|
})
|
||||||
|
this._clientDeserializer = createDeserializer({
|
||||||
|
state: states.PLAY,
|
||||||
|
isServer: false,
|
||||||
|
version: "1.8.8",
|
||||||
|
customPackets: null
|
||||||
|
})
|
||||||
|
this._serverDeserializer = createDeserializer({
|
||||||
|
state: states.PLAY,
|
||||||
|
isServer: true,
|
||||||
|
version: "1.8.8",
|
||||||
|
customPackets: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public onClientWrite(packet: Buffer): Buffer /* write to server */ {
|
||||||
|
const { name, params } = this._serverDeserializer.parsePacketBuffer(packet).data
|
||||||
|
return this._clientSerializer.createPacketBuffer(this._translatePacketClient(params, { name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
public onServerWrite(packet: any, meta: any): Buffer /* write to client */ {
|
||||||
|
return this._serverSerializer.createPacketBuffer(this._translatePacketServer(packet, meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
private _translatePacketClient(packet: any, meta: any): any | null {
|
||||||
|
if (PacketUUIDTranslator.CAST_UUID_CLIENT.some(id => id == meta.name)) {
|
||||||
|
if (meta.name == 'spectate') {
|
||||||
|
if (packet.target == this.clientSidePlayerUUID) {
|
||||||
|
packet.target = this.serverSidePlayerUUID
|
||||||
|
} else if (packet.target == this.serverSidePlayerUUID) {
|
||||||
|
packet.target = this.clientSidePlayerUUID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: meta.name,
|
||||||
|
params: packet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _translatePacketServer(packet: any, meta: any): any | null {
|
||||||
|
if (PacketUUIDTranslator.CAST_UUID_SERVER.some(id => id == meta.name)) {
|
||||||
|
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 {
|
||||||
|
name: meta.name,
|
||||||
|
params: packet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRespawnSequence(login: any, serializer: any): [Buffer, Buffer] {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDimSets(loginDim: number): [number, number] {
|
||||||
|
return [
|
||||||
|
loginDim == -1 ? 0 : loginDim == 0 ? -1 : loginDim == 1 ? 0 : 0,
|
||||||
|
loginDim
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
50
src/proxy/Chat.ts
Normal file
50
src/proxy/Chat.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import { Enums } from "./Enums.js"
|
||||||
|
|
||||||
|
export namespace Chat {
|
||||||
|
export type ChatExtra = {
|
||||||
|
text: string,
|
||||||
|
bold?: boolean,
|
||||||
|
italic?: boolean,
|
||||||
|
underlined?: boolean,
|
||||||
|
strikethrough?: boolean,
|
||||||
|
obfuscated?: boolean,
|
||||||
|
color?: Enums.ChatColor | 'reset'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Chat = {
|
||||||
|
text?: string,
|
||||||
|
bold?: boolean,
|
||||||
|
italic?: boolean,
|
||||||
|
underlined?: boolean,
|
||||||
|
strikethrough?: boolean,
|
||||||
|
obfuscated?: boolean,
|
||||||
|
color?: Enums.ChatColor | 'reset',
|
||||||
|
extra?: ChatExtra[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chatToPlainString(chat: Chat): string {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const ccValues = Object.values(Enums.ChatColor)
|
||||||
|
const ccKeys = Object.keys(Enums.ChatColor).map(str => str.toLowerCase())
|
||||||
|
|
||||||
|
function resolveColor(colorStr: string) {
|
||||||
|
return Object.values(Enums.ChatColor)[ccKeys.indexOf(colorStr.toLowerCase())] ?? colorStr
|
||||||
|
}
|
||||||
|
}
|
13
src/proxy/Constants.ts
Normal file
13
src/proxy/Constants.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import * as meta from "../meta.js"
|
||||||
|
|
||||||
|
export namespace Constants {
|
||||||
|
export const EAGLERCRAFT_SKIN_CHANNEL_NAME: string = "EAG|Skins-1.8"
|
||||||
|
export const MAGIC_ENDING_SERVER_SKIN_DOWNLOAD_BUILTIN: number[] = [0x00, 0x00, 0x00]
|
||||||
|
export const MAGIC_ENDING_CLIENT_UPLOAD_SKIN_BUILTIN: number[] = [0x00, 0x05, 0x01, 0x00, 0x00, 0x00]
|
||||||
|
export const EAGLERCRAFT_SKIN_CUSTOM_LENGTH = 64 ** 2 * 4
|
||||||
|
|
||||||
|
export const JOIN_SERVER_PACKET = 0x01
|
||||||
|
export const PLAYER_LOOK_PACKET = 0x08
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UPGRADE_REQUIRED_RESPONSE = `<!DOCTYPE html><!-- Served by ${meta.PROXY_BRANDING} (version: ${meta.PROXY_VERSION}) --><html> <head> <title>EaglerProxy landing page</title> <style> :root { font-family: "Arial" } code { padding: 3px 10px 3px 10px; border-radius: 5px; font-family: monospace; background-color: #1a1a1a; color: white; } </style> <script type="text/javascript"> window.addEventListener('load', () => { document.getElementById("connect-url").innerHTML = window.location.href.replace(window.location.protocol, window.location.protocol == "https:" ? "wss://" : "ws://"); }); </script> </head> <body> <h1>426 - Upgrade Required</h1> <p>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: <code id="connect-url">loading...</code> (connect from any recent EaglercraftX client via Multiplayer > Direct Connect)</p> </body></html>`
|
236
src/proxy/EaglerSkins.ts
Normal file
236
src/proxy/EaglerSkins.ts
Normal file
|
@ -0,0 +1,236 @@
|
||||||
|
import { Constants } from "./Constants.js"
|
||||||
|
import { Enums } from "./Enums.js"
|
||||||
|
import { MineProtocol } from "./Protocol.js"
|
||||||
|
import { Util } from "./Util.js"
|
||||||
|
import sharp from "sharp"
|
||||||
|
import { Proxy } from "./Proxy.js"
|
||||||
|
import { Player } from "./Player.js"
|
||||||
|
import { CSChannelMessagePacket } from "./packets/channel/CSChannelMessage.js"
|
||||||
|
import { SCChannelMessagePacket } from "./packets/channel/SCChannelMessage.js"
|
||||||
|
import { Logger } from "../logger.js"
|
||||||
|
|
||||||
|
// TODO: convert all functions to use MineProtocol's UUID manipulation functions
|
||||||
|
|
||||||
|
export namespace EaglerSkins {
|
||||||
|
export type ClientFetchEaglerSkin = {
|
||||||
|
id: Enums.EaglerSkinPacketId.CFetchSkinEaglerPlayerReq,
|
||||||
|
uuid: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerFetchSkinResultBuiltIn = {
|
||||||
|
id: Enums.EaglerSkinPacketId.SFetchSkinBuiltInRes,
|
||||||
|
uuid: string,
|
||||||
|
skinId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerFetchSkinResultCustom = {
|
||||||
|
id: Enums.EaglerSkinPacketId.SFetchSkinRes,
|
||||||
|
uuid: string,
|
||||||
|
skin: Util.BoundedBuffer<typeof Constants.EAGLERCRAFT_SKIN_CUSTOM_LENGTH>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientDownloadSkinRequest = {
|
||||||
|
id: Enums.EaglerSkinPacketId.CFetchSkinReq,
|
||||||
|
uuid: string,
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadSkin(skinUrl: string): Promise<Buffer> {
|
||||||
|
const url = new URL(skinUrl)
|
||||||
|
if (url.protocol != "https:" && url.protocol != "http:")
|
||||||
|
throw new Error("Invalid skin URL protocol!")
|
||||||
|
return new Promise<Buffer>(async (res, rej) => {
|
||||||
|
const skin = await fetch(skinUrl)
|
||||||
|
if (skin.status != 200) {
|
||||||
|
rej(`Tried to fetch ${skinUrl}, got HTTP ${skin.status} instead!`)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
res(Buffer.from(await skin.arrayBuffer()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readClientDownloadSkinRequestPacket(message: Buffer): ClientDownloadSkinRequest {
|
||||||
|
const ret: ClientDownloadSkinRequest = {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeClientDownloadSkinRequestPacket(uuid: string | Buffer, url: string): Buffer {
|
||||||
|
return Buffer.concat([
|
||||||
|
[Enums.EaglerSkinPacketId.CFetchSkinReq],
|
||||||
|
MineProtocol.writeUUID(uuid),
|
||||||
|
[0x0],
|
||||||
|
MineProtocol.writeString(url)
|
||||||
|
].map(arr => arr instanceof Uint8Array ? arr : Buffer.from(arr)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readServerFetchSkinResultBuiltInPacket(message: Buffer): ServerFetchSkinResultBuiltIn {
|
||||||
|
const ret: ServerFetchSkinResultBuiltIn = {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeServerFetchSkinResultBuiltInPacket(uuid: string | Buffer, skinId: number): Buffer {
|
||||||
|
uuid = typeof uuid == 'string' ? Util.uuidStringToBuffer(uuid) : uuid
|
||||||
|
console.log(1)
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([Enums.EaglerSkinPacketId.SFetchSkinBuiltInRes]),
|
||||||
|
uuid as Buffer,
|
||||||
|
Buffer.from([
|
||||||
|
skinId >> 24,
|
||||||
|
skinId >> 16,
|
||||||
|
skinId >> 8,
|
||||||
|
skinId & 0xFF
|
||||||
|
])
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readServerFetchSkinResultCustomPacket(message: Buffer): ServerFetchSkinResultCustom {
|
||||||
|
const ret: ServerFetchSkinResultCustom = {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: fix bug where some people are missing left arm and leg
|
||||||
|
export function writeServerFetchSkinResultCustomPacket(uuid: string | Buffer, skin: Buffer, downloaded: boolean): Buffer {
|
||||||
|
uuid = typeof uuid == 'string' ? Util.uuidStringToBuffer(uuid) : uuid
|
||||||
|
return Buffer.concat([
|
||||||
|
[Enums.EaglerSkinPacketId.SFetchSkinRes],
|
||||||
|
uuid,
|
||||||
|
!downloaded ? [0x01] : [0x01], // TODO: if buggy, use 0xff instead
|
||||||
|
skin.subarray(0, Constants.EAGLERCRAFT_SKIN_CUSTOM_LENGTH)
|
||||||
|
].map(arr => arr instanceof Uint8Array ? arr : Buffer.from(arr)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readClientFetchEaglerSkinPacket(buff: Buffer): ClientFetchEaglerSkin {
|
||||||
|
const ret: ClientFetchEaglerSkin = {
|
||||||
|
id: null,
|
||||||
|
uuid: null
|
||||||
|
}
|
||||||
|
const id = MineProtocol.readVarInt(buff),
|
||||||
|
uuid = MineProtocol.readUUID(id.newBuffer)
|
||||||
|
ret.id = id.value
|
||||||
|
ret.uuid = uuid.value
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeClientFetchEaglerSkin(uuid: string | Buffer, url: string): Buffer {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toEaglerSkin(image: Buffer): Promise<Util.BoundedBuffer<typeof Constants.EAGLERCRAFT_SKIN_CUSTOM_LENGTH>> {
|
||||||
|
const r = await sharp(image).extractChannel('red').raw({ depth: 'uchar' }).toBuffer()
|
||||||
|
const g = await sharp(image).extractChannel('green').raw({ depth: 'uchar' }).toBuffer()
|
||||||
|
const b = await sharp(image).extractChannel('blue').raw({ depth: 'uchar' }).toBuffer()
|
||||||
|
const a = await sharp(image).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
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SkinServer {
|
||||||
|
public allowedSkinDomains: string[]
|
||||||
|
public proxy: Proxy
|
||||||
|
private _logger: Logger
|
||||||
|
|
||||||
|
constructor(proxy: Proxy, allowedSkinDomains?: string[]) {
|
||||||
|
this.allowedSkinDomains = allowedSkinDomains ?? ['textures.minecraft.net']
|
||||||
|
this.proxy = proxy ?? PROXY
|
||||||
|
this._logger = new Logger("SkinServer")
|
||||||
|
this._logger.info("Started EaglercraftX skin server.")
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handleRequest(packet: CSChannelMessagePacket, caller: Player) {
|
||||||
|
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!")
|
||||||
|
switch(packet.data[0] as Enums.EaglerSkinPacketId) {
|
||||||
|
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 {
|
||||||
|
const fetched = await EaglerSkins.downloadSkin(parsedPacket_1.url),
|
||||||
|
processed = await EaglerSkins.toEaglerSkin(fetched),
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EaglerSkin {
|
||||||
|
owner: Player
|
||||||
|
type: Enums.SkinType
|
||||||
|
// update this over time
|
||||||
|
builtInSkin?: Util.Range<0, 23>
|
||||||
|
skin?: Util.BoundedBuffer<typeof Constants.EAGLERCRAFT_SKIN_CUSTOM_LENGTH>
|
||||||
|
}
|
||||||
|
}
|
71
src/proxy/Enums.ts
Normal file
71
src/proxy/Enums.ts
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
export namespace Enums {
|
||||||
|
export enum PacketId {
|
||||||
|
CSLoginPacket = 0x01,
|
||||||
|
SCIdentifyPacket = 0x02,
|
||||||
|
SCDisconnectPacket = 0xff,
|
||||||
|
SCChannelMessagePacket = 0x3f,
|
||||||
|
CSChannelMessagePacket = 0x17,
|
||||||
|
CSUsernamePacket = 0x04,
|
||||||
|
SCSyncUuidPacket = 0x05,
|
||||||
|
CSSetSkinPacket = 0x07,
|
||||||
|
CSReadyPacket = 0x08,
|
||||||
|
SCReadyPacket = 0x09
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ChannelMessageType {
|
||||||
|
CLIENT = 0x17,
|
||||||
|
SERVER = 0x3f
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum EaglerSkinPacketId {
|
||||||
|
CFetchSkinEaglerPlayerReq = 0x03,
|
||||||
|
SFetchSkinBuiltInRes = 0x04,
|
||||||
|
SFetchSkinRes = 0x05,
|
||||||
|
CFetchSkinReq = 0x06
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ClientState {
|
||||||
|
PRE_HANDSHAKE = "PRE_HANDSHAKE",
|
||||||
|
POST_HANDSHAKE = "POST_HANDSHAKE",
|
||||||
|
DISCONNECTED = "DISCONNECTED"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PacketBounds {
|
||||||
|
C = "C",
|
||||||
|
S = "S"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SkinType {
|
||||||
|
BUILTIN,
|
||||||
|
CUSTOM
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ChatColor {
|
||||||
|
AQUA = "§b",
|
||||||
|
BLACK = "§0",
|
||||||
|
DARK_BLUE = "§1",
|
||||||
|
DARK_GREEN = "§2",
|
||||||
|
DARK_CYAN = "§3",
|
||||||
|
DARK_RED = "§4",
|
||||||
|
PURPLE = "§5",
|
||||||
|
GOLD = "§6",
|
||||||
|
GRAY = "§7",
|
||||||
|
GREEN = "§a",
|
||||||
|
DARK_GRAY = "§8",
|
||||||
|
BLUE = "§9",
|
||||||
|
BRIGHT_GREEN = "§a",
|
||||||
|
LIGHT_PURPLE = "§d",
|
||||||
|
CYAN = "§b",
|
||||||
|
RED = "§c",
|
||||||
|
PINK = "§d",
|
||||||
|
YELLOW = "§e",
|
||||||
|
WHITE = "§f",
|
||||||
|
// text styling
|
||||||
|
OBFUSCATED = '§k',
|
||||||
|
BOLD = '§l',
|
||||||
|
STRIKETHROUGH = '§m',
|
||||||
|
UNDERLINED = '§n',
|
||||||
|
ITALIC = '§o',
|
||||||
|
RESET = '§r'
|
||||||
|
}
|
||||||
|
}
|
132
src/proxy/Motd.ts
Normal file
132
src/proxy/Motd.ts
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import pkg, { NewPingResult } from 'minecraft-protocol';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { PROXY_BRANDING, PROXY_VERSION } from '../meta.js';
|
||||||
|
import { Config } from '../launcher_types.js'
|
||||||
|
import { Chat } from './Chat.js';
|
||||||
|
const { ping } = pkg
|
||||||
|
|
||||||
|
export namespace Motd {
|
||||||
|
const ICON_SQRT = 64
|
||||||
|
const IMAGE_DATA_PREPEND = "data:image/png;base64,"
|
||||||
|
|
||||||
|
export class MOTD {
|
||||||
|
public jsonMotd: JSONMotd
|
||||||
|
public image?: Buffer
|
||||||
|
|
||||||
|
constructor(motd: JSONMotd, image?: Buffer) {
|
||||||
|
this.jsonMotd = motd
|
||||||
|
this.image = image
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async generateMOTDFromPing(host: string, port: number): Promise<MOTD> {
|
||||||
|
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 as NewPingResult
|
||||||
|
let image: Buffer
|
||||||
|
|
||||||
|
if (newPingRes.favicon != null) {
|
||||||
|
if (!newPingRes.favicon.startsWith(IMAGE_DATA_PREPEND)) throw new Error("Invalid MOTD image!")
|
||||||
|
image = await this.generateEaglerMOTDImage(Buffer.from(newPingRes.favicon.substring(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}`
|
||||||
|
}, image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async generateMOTDFromConfig(config: Config['adapter']): Promise<MOTD> {
|
||||||
|
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}`
|
||||||
|
})
|
||||||
|
if (config.motd.iconURL != null) {
|
||||||
|
motd.image = await this.generateEaglerMOTDImage(config.motd.iconURL)
|
||||||
|
}
|
||||||
|
return motd
|
||||||
|
} else throw new Error("MOTD is set to be forwarded in the config!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: fix not working
|
||||||
|
public static generateEaglerMOTDImage(file: string | Buffer): Promise<Buffer> {
|
||||||
|
return new Promise<Buffer>((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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public toBuffer(): [string, Buffer] {
|
||||||
|
return [
|
||||||
|
JSON.stringify(this.jsonMotd),
|
||||||
|
this.image
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JSONMotd = {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
35
src/proxy/Packet.ts
Normal file
35
src/proxy/Packet.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { readdir } from "fs/promises"
|
||||||
|
import { dirname, join, resolve } from "path"
|
||||||
|
import { fileURLToPath, pathToFileURL } from "url"
|
||||||
|
import { Enums } from "./Enums.js"
|
||||||
|
import { Util } from "./Util.js"
|
||||||
|
|
||||||
|
export default interface Packet {
|
||||||
|
packetId: Enums.PacketId
|
||||||
|
type: "packet"
|
||||||
|
boundTo: Enums.PacketBounds
|
||||||
|
sentAfterHandshake: boolean
|
||||||
|
|
||||||
|
serialize: () => Buffer
|
||||||
|
deserialize: (packet: Buffer) => this
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPackets(dir?: string): Promise<Map<Enums.PacketId, Packet & { class: any }>> {
|
||||||
|
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: Packet
|
||||||
|
try { e = new (val as any)() }
|
||||||
|
catch {}
|
||||||
|
if (e != null && e.type == 'packet') {
|
||||||
|
;(e as any).class = val
|
||||||
|
packetRegistry.set(e.packetId, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return packetRegistry
|
||||||
|
}
|
262
src/proxy/Player.ts
Normal file
262
src/proxy/Player.ts
Normal file
|
@ -0,0 +1,262 @@
|
||||||
|
import EventEmitter from "events"
|
||||||
|
import pkg, { Client, ClientOptions, createClient, states } from "minecraft-protocol"
|
||||||
|
import { WebSocket } from "ws"
|
||||||
|
import { Logger } from "../logger.js"
|
||||||
|
import { Chat } from "./Chat.js"
|
||||||
|
import { Constants } from "./Constants.js"
|
||||||
|
import { Enums } from "./Enums.js"
|
||||||
|
import Packet from "./Packet.js"
|
||||||
|
import SCDisconnectPacket from "./packets/SCDisconnectPacket.js"
|
||||||
|
import { MineProtocol } from "./Protocol.js"
|
||||||
|
import { EaglerSkins } from "./EaglerSkins.js"
|
||||||
|
import { Util } from "./Util.js"
|
||||||
|
import { BungeeUtil } from "./BungeeUtil.js"
|
||||||
|
|
||||||
|
const { createSerializer, createDeserializer } = pkg
|
||||||
|
|
||||||
|
export class Player extends EventEmitter {
|
||||||
|
public ws: WebSocket
|
||||||
|
public username?: string
|
||||||
|
public skin?: EaglerSkins.EaglerSkin
|
||||||
|
public uuid?: string
|
||||||
|
public state?: Enums.ClientState = Enums.ClientState.PRE_HANDSHAKE
|
||||||
|
public serverConnection?: Client
|
||||||
|
|
||||||
|
private _switchingServers: boolean = false
|
||||||
|
private _logger: Logger
|
||||||
|
private _alreadyConnected: boolean = false
|
||||||
|
private _serializer: any
|
||||||
|
private _deserializer: any
|
||||||
|
private _kickMessage: string
|
||||||
|
|
||||||
|
constructor(ws: WebSocket, playerName?: string, serverConnection?: Client) {
|
||||||
|
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._serializer = createSerializer({
|
||||||
|
state: states.PLAY,
|
||||||
|
isServer: true,
|
||||||
|
version: "1.8.9",
|
||||||
|
customPackets: null
|
||||||
|
})
|
||||||
|
this._deserializer = createDeserializer({
|
||||||
|
state: states.PLAY,
|
||||||
|
isServer: false,
|
||||||
|
version: "1.8.9",
|
||||||
|
customPackets: null
|
||||||
|
})
|
||||||
|
// this._serializer.pipe(this.ws)
|
||||||
|
}
|
||||||
|
|
||||||
|
public 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: Buffer) => {
|
||||||
|
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: Packet, err: boolean
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public write(packet: Packet) {
|
||||||
|
this.ws.send(packet.serialize())
|
||||||
|
}
|
||||||
|
|
||||||
|
public async read(packetId?: Enums.PacketId, filter?: (packet: Packet) => boolean): Promise<Packet> {
|
||||||
|
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: Packet, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
public disconnect(message: Chat.Chat | string) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async connect(options: ClientOptions) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async switchServers(options: ClientOptions) {
|
||||||
|
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.`)
|
||||||
|
this._switchingServers = true
|
||||||
|
this.ws.send(this._serializer.createPacketBuffer({
|
||||||
|
name: 'chat',
|
||||||
|
params: {
|
||||||
|
message: `${Enums.ChatColor.GRAY}Switching servers...`,
|
||||||
|
position: 1
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
this.ws.send(this._serializer.createPacketBuffer({
|
||||||
|
name: 'playerlist_header',
|
||||||
|
params: {
|
||||||
|
header: JSON.stringify({
|
||||||
|
text: ""
|
||||||
|
}),
|
||||||
|
footer: JSON.stringify({
|
||||||
|
text: ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
this.serverConnection.end()
|
||||||
|
this.serverConnection = createClient(Object.assign({
|
||||||
|
version: '1.8.9',
|
||||||
|
keepAlive: false,
|
||||||
|
hideErrors: false
|
||||||
|
}, options))
|
||||||
|
await this._bindListenersMineClient(this.serverConnection, true)
|
||||||
|
this.emit('switchServer', this.serverConnection, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _bindListenersMineClient(client: Client, switchingServers?: boolean) {
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.on('error', errListener)
|
||||||
|
client.on('end', reason => {
|
||||||
|
if (!this._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
|
||||||
|
}
|
||||||
|
if (!stream) {
|
||||||
|
if (switchingServers) {
|
||||||
|
if (meta.name == 'login' && meta.state == states.PLAY && uuid) {
|
||||||
|
const pckSeq = BungeeUtil.getRespawnSequence(packet, this._serializer)
|
||||||
|
this.ws.send(this._serializer.createPacketBuffer({
|
||||||
|
name: "login",
|
||||||
|
params: packet
|
||||||
|
}))
|
||||||
|
pckSeq.forEach(p => this.ws.send(p))
|
||||||
|
stream = true
|
||||||
|
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.ws.send(this._serializer.createPacketBuffer({
|
||||||
|
name: "login",
|
||||||
|
params: packet
|
||||||
|
}))
|
||||||
|
stream = true
|
||||||
|
res(null)
|
||||||
|
} else if (meta.name == 'success' && meta.state == states.LOGIN && !uuid) {
|
||||||
|
uuid = packet.uuid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.ws.send(this._serializer.createPacketBuffer({
|
||||||
|
name: meta.name,
|
||||||
|
params: packet
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.ws.on('message', listener)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerEvents {
|
||||||
|
'switchServer': (connection: Client, player: Player) => void,
|
||||||
|
'joinServer': (connection: Client, player: Player) => void,
|
||||||
|
// for vanilla game packets, bind to connection object instead
|
||||||
|
'proxyPacket': (packet: Packet, player: Player) => void,
|
||||||
|
'vanillaPacket': (packet: Packet & { cancel: boolean }, origin: 'CLIENT' | 'SERVER', player: Player) => Packet & { cancel: boolean },
|
||||||
|
'disconnect': (player: Player) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface Player {
|
||||||
|
on<U extends keyof PlayerEvents>(
|
||||||
|
event: U, listener: PlayerEvents[U]
|
||||||
|
): this;
|
||||||
|
|
||||||
|
emit<U extends keyof PlayerEvents>(
|
||||||
|
event: U, ...args: Parameters<PlayerEvents[U]>
|
||||||
|
): boolean;
|
||||||
|
}
|
78
src/proxy/Protocol.ts
Normal file
78
src/proxy/Protocol.ts
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import {
|
||||||
|
encodeULEB128 as _encodeVarInt,
|
||||||
|
decodeULEB128 as _decodeVarInt
|
||||||
|
} from "@thi.ng/leb128"
|
||||||
|
import { Enums } from "./Enums.js"
|
||||||
|
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 namespace MineProtocol {
|
||||||
|
export type ReadResult<T> = {
|
||||||
|
value: T,
|
||||||
|
// the new buffer, but with the bytes being read being completely removed
|
||||||
|
// very useful when it comes to chaining
|
||||||
|
newBuffer: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UUID = string
|
||||||
|
|
||||||
|
export function writeVarInt(int: number): Buffer {
|
||||||
|
return Buffer.from(_encodeVarInt(int))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readVarInt(buff: Buffer, offset?: number): ReadResult<number> {
|
||||||
|
buff = offset ? buff.subarray(offset) : buff
|
||||||
|
const read = _decodeVarInt(buff), len = read[1]
|
||||||
|
return {
|
||||||
|
// potential oversight?
|
||||||
|
value: Number(read[0]),
|
||||||
|
newBuffer: buff.subarray(len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeString(str: string): Buffer {
|
||||||
|
const bufferized = Buffer.from(str, 'utf8'), len = writeVarInt(bufferized.length)
|
||||||
|
return Buffer.concat([len, bufferized])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readString(buff: Buffer, offset?: number): ReadResult<string> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _readShort = (a: number, b: number) => a << 8 | b << 0
|
||||||
|
|
||||||
|
export function readShort(buff: Buffer, offset?: number): ReadResult<number> {
|
||||||
|
buff = offset ? buff.subarray(offset) : buff
|
||||||
|
return {
|
||||||
|
value: _readShort(buff[0], buff[1]),
|
||||||
|
newBuffer: buff.subarray(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeShort(num: number): Buffer {
|
||||||
|
const alloc = Buffer.alloc(2)
|
||||||
|
alloc.writeInt16BE(num)
|
||||||
|
return alloc
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readUUID(buff: Buffer, offset?: number): ReadResult<string> {
|
||||||
|
buff = offset ? buff.subarray(offset) : buff
|
||||||
|
return {
|
||||||
|
value: Util.uuidBufferToString(buff.subarray(0, 16)),
|
||||||
|
newBuffer: buff.subarray(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeUUID(uuid: string | Buffer): Buffer {
|
||||||
|
return typeof uuid == 'string' ? Util.uuidStringToBuffer(uuid) : uuid
|
||||||
|
}
|
||||||
|
}
|
337
src/proxy/Proxy.ts
Normal file
337
src/proxy/Proxy.ts
Normal file
|
@ -0,0 +1,337 @@
|
||||||
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
|
import { Config } from "../launcher_types.js";
|
||||||
|
import { Logger } from "../logger.js";
|
||||||
|
import Packet, { loadPackets } from "./Packet.js";
|
||||||
|
import * as http from "http"
|
||||||
|
import * as https from "https"
|
||||||
|
import { readFile } from "fs/promises";
|
||||||
|
import { Duplex } from "stream";
|
||||||
|
import { parseDomain, ParseResultType } from "parse-domain"
|
||||||
|
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 { CSUsernamePacket } from "./packets/CSUsernamePacket.js";
|
||||||
|
import { SCSyncUuidPacket } from "./packets/SCSyncUuidPacket.js";
|
||||||
|
import { SCReadyPacket } from "./packets/SCReadyPacket.js";
|
||||||
|
import { Chalk } from "chalk";
|
||||||
|
import EventEmitter from "events";
|
||||||
|
import { MineProtocol } from "./Protocol.js";
|
||||||
|
import { EaglerSkins } from "./EaglerSkins.js";
|
||||||
|
import { CSSetSkinPacket } from "./packets/CSSetSkinPacket.js";
|
||||||
|
import { CSChannelMessagePacket } from "./packets/channel/CSChannelMessage.js";
|
||||||
|
import { Constants, UPGRADE_REQUIRED_RESPONSE } from "./Constants.js";
|
||||||
|
import { PluginManager } from "./pluginLoader/PluginManager.js";
|
||||||
|
|
||||||
|
let instanceCount = 0
|
||||||
|
const chalk = new Chalk({ level: 2 })
|
||||||
|
|
||||||
|
export class Proxy extends EventEmitter {
|
||||||
|
public packetRegistry: Map<number, Packet & {
|
||||||
|
class: any
|
||||||
|
}>
|
||||||
|
public players = new Map<string, Player>()
|
||||||
|
public pluginManager: PluginManager
|
||||||
|
public config: Config['adapter']
|
||||||
|
public wsServer: WebSocketServer
|
||||||
|
public httpServer: http.Server
|
||||||
|
public skinServer: EaglerSkins.SkinServer
|
||||||
|
public broadcastMotd?: Motd.MOTD
|
||||||
|
|
||||||
|
private _logger: Logger
|
||||||
|
private initalHandlerLogger: Logger
|
||||||
|
|
||||||
|
private loaded: boolean
|
||||||
|
|
||||||
|
constructor(config: Config['adapter'], pluginManager: 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 as any)._info = this.initalHandlerLogger.info
|
||||||
|
this.initalHandlerLogger.info = (msg: string) => {
|
||||||
|
;(this.initalHandlerLogger as any)._info(`${chalk.blue("[InitialHandler]")} ${msg}`)
|
||||||
|
}
|
||||||
|
;(this.initalHandlerLogger as any)._warn = this.initalHandlerLogger.warn
|
||||||
|
this.initalHandlerLogger.warn = (msg: string) => {
|
||||||
|
;(this.initalHandlerLogger as any)._warn(`${chalk.blue("[InitialHandler]")} ${msg}`)
|
||||||
|
}
|
||||||
|
;(this.initalHandlerLogger as any)._error = this.initalHandlerLogger.error
|
||||||
|
this.initalHandlerLogger.error = (msg: string) => {
|
||||||
|
;(this.initalHandlerLogger as any)._error(`${chalk.blue("[InitialHandler]")} ${msg}`)
|
||||||
|
}
|
||||||
|
;(this.initalHandlerLogger as any)._fatal = this.initalHandlerLogger.fatal
|
||||||
|
this.initalHandlerLogger.fatal = (msg: string) => {
|
||||||
|
;(this.initalHandlerLogger as any)._fatal(`${chalk.blue("[InitialHandler]")} ${msg}`)
|
||||||
|
}
|
||||||
|
;(this.initalHandlerLogger as any)._debug = this.initalHandlerLogger.debug
|
||||||
|
this.initalHandlerLogger.debug = (msg: string) => {
|
||||||
|
;(this.initalHandlerLogger as any)._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}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public 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 EaglerSkins.SkinServer(this, this.config.skinUrlWhitelist)
|
||||||
|
global.PACKET_REGISTRY = this.packetRegistry
|
||||||
|
if (this.config.motd == 'FORWARD') {
|
||||||
|
this._pollServer(this.config.server.host, this.config.server.port)
|
||||||
|
} else {
|
||||||
|
// TODO: motd
|
||||||
|
const broadcastMOTD = await Motd.MOTD.generateMOTDFromConfig(this.config)
|
||||||
|
;(broadcastMOTD as any)._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}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.pluginManager.emit('proxyFinishLoading', this, this.pluginManager)
|
||||||
|
this._logger.info(`Started WebSocket server and binded to ${this.config.bindHost} on port ${this.config.bindPort}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleNonWSRequest(req: http.IncomingMessage, res: http.ServerResponse, config: Config['adapter']) {
|
||||||
|
res.setHeader("Content-Type", "text/html")
|
||||||
|
.writeHead(426)
|
||||||
|
.end(UPGRADE_REQUIRED_RESPONSE)
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly LOGIN_TIMEOUT = 30000
|
||||||
|
|
||||||
|
private async _handleWSConnection(ws: WebSocket) {
|
||||||
|
const firstPacket = await Util.awaitPacket(ws)
|
||||||
|
let player: Player, handled: boolean
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!handled) {
|
||||||
|
this.initalHandlerLogger.warn(`Disconnecting client ${player ? player.username ?? `[/${(ws as any)._socket.remoteAddress}:${(ws as any)._socket.remotePort}` : `[/${(ws as any)._socket.remoteAddress}:${(ws as any)._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.broadcastMotd) {
|
||||||
|
if ((this.broadcastMotd as any)._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 {
|
||||||
|
player = new Player(ws)
|
||||||
|
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: CSUsernamePacket = await player.read(Enums.PacketId.CSUsernamePacket) as any
|
||||||
|
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) as CSSetSkinPacket]),
|
||||||
|
skin = prom[1],
|
||||||
|
obj = new EaglerSkins.EaglerSkin()
|
||||||
|
obj.owner = player
|
||||||
|
obj.type = skin.skinType as any
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _bindListenersToPlayer(player: 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: CSChannelMessagePacket = packet as any
|
||||||
|
if (msg.channel == Constants.EAGLERCRAFT_SKIN_CHANNEL_NAME) {
|
||||||
|
await this.skinServer.handleRequest(msg, player)
|
||||||
|
}
|
||||||
|
} 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 readonly POLL_INTERVAL: number = 10000
|
||||||
|
|
||||||
|
private _pollServer(host: string, port: number, interval?: number) {
|
||||||
|
;(async () => {
|
||||||
|
while (true) {
|
||||||
|
const motd = await Motd.MOTD.generateMOTDFromPing(host, port)
|
||||||
|
.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))
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleWSConnectionReq(req: http.IncomingMessage, socket: Duplex, head: Buffer) {
|
||||||
|
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)) }
|
||||||
|
catch (err) {
|
||||||
|
this._logger.error(`Error was caught whilst trying to handle WebSocket connection request! Error: ${err.stack ?? err}`)
|
||||||
|
socket.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fetchUserByUUID(uuid: MineProtocol.UUID): Player | null {
|
||||||
|
for (const [username, player] of this.players) {
|
||||||
|
if (player.uuid == uuid)
|
||||||
|
return player
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProxyEvents {
|
||||||
|
'playerConnect': (player: Player) => void,
|
||||||
|
'playerDisconnect': (player: Player) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface Proxy {
|
||||||
|
on<U extends keyof ProxyEvents>(
|
||||||
|
event: U, listener: ProxyEvents[U]
|
||||||
|
): this;
|
||||||
|
|
||||||
|
emit<U extends keyof ProxyEvents>(
|
||||||
|
event: U, ...args: Parameters<ProxyEvents[U]>
|
||||||
|
): boolean;
|
||||||
|
}
|
198
src/proxy/Util.ts
Normal file
198
src/proxy/Util.ts
Normal file
|
@ -0,0 +1,198 @@
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import {
|
||||||
|
encodeULEB128,
|
||||||
|
decodeULEB128,
|
||||||
|
} from "@thi.ng/leb128"
|
||||||
|
import { Chat } from "./Chat.js";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
|
import { Enums } from "./Enums.js";
|
||||||
|
import { Player } from "./Player.js";
|
||||||
|
import * as http from "http"
|
||||||
|
import { Config } from "../launcher_types.js";
|
||||||
|
import { parseDomain, ParseResultType } from "parse-domain";
|
||||||
|
import { access, readdir } from "fs/promises";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
export namespace Util {
|
||||||
|
export const encodeVarInt: typeof encodeULEB128 = encodeULEB128
|
||||||
|
export const decodeVarInt: typeof decodeULEB128 = decodeULEB128
|
||||||
|
|
||||||
|
// annotation for range
|
||||||
|
// b = beginning, e = end
|
||||||
|
export type Range<B, E> = number
|
||||||
|
|
||||||
|
export type BoundedBuffer<S extends number> = Buffer
|
||||||
|
|
||||||
|
const USERNAME_REGEX = /[^0-9^a-z^A-Z^_]/gi
|
||||||
|
|
||||||
|
export function generateUUIDFromPlayer(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 uuidBufferToString(md5Bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// excerpt from uuid-buffer
|
||||||
|
|
||||||
|
export function uuidStringToBuffer(uuid: string): Buffer {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uuidBufferToString(buffer: Buffer): string {
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function awaitPacket(ws: WebSocket, filter?: (msg: Buffer) => boolean): Promise<Buffer> {
|
||||||
|
return new Promise<Buffer>((res, rej) => {
|
||||||
|
let resolved = false
|
||||||
|
const msgCb = (msg: any) => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUsername(user: string): void | never {
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areDomainsEqual(d1: string, d2: string): boolean {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* _getFiles(dir: string) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recursiveFileSearch(dir: string): Promise<string[]> {
|
||||||
|
const ents = []
|
||||||
|
for await (const f of _getFiles(dir)) {
|
||||||
|
ents.push(f)
|
||||||
|
}
|
||||||
|
return ents
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fsExists(path: string): Promise<boolean> {
|
||||||
|
try { await access(path) }
|
||||||
|
catch (err) {
|
||||||
|
if (err.code == 'ENOENT')
|
||||||
|
return false
|
||||||
|
else return true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlayerPosition = {
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
z: number,
|
||||||
|
yaw: number,
|
||||||
|
pitch: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PositionPacket = {
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
z: number,
|
||||||
|
yaw: number,
|
||||||
|
pitch: number,
|
||||||
|
flags: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatePositionPacket(currentPos: PlayerPosition, newPos: PositionPacket): PositionPacket {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
49
src/proxy/packets/CSLoginPacket.ts
Normal file
49
src/proxy/packets/CSLoginPacket.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { NETWORK_VERSION, VANILLA_PROTOCOL_VERSION } from "../../meta.js";
|
||||||
|
import { Enums } from "../Enums.js";
|
||||||
|
import Packet from "../Packet.js";
|
||||||
|
import { MineProtocol } from "../Protocol.js";
|
||||||
|
|
||||||
|
export default class CSLoginPacket implements Packet {
|
||||||
|
packetId: Enums.PacketId = Enums.PacketId.CSLoginPacket
|
||||||
|
type: "packet" = "packet"
|
||||||
|
boundTo = Enums.PacketBounds.S
|
||||||
|
sentAfterHandshake = false
|
||||||
|
|
||||||
|
networkVersion = NETWORK_VERSION
|
||||||
|
gameVersion = VANILLA_PROTOCOL_VERSION
|
||||||
|
brand: string
|
||||||
|
version: string
|
||||||
|
username: string
|
||||||
|
|
||||||
|
private _getMagicSeq(): Buffer {
|
||||||
|
return Buffer.concat([
|
||||||
|
[0x02, 0x00, 0x02, 0x00, 0x02, 0x00],
|
||||||
|
[this.networkVersion],
|
||||||
|
[0x00, 0x01, 0x00],
|
||||||
|
[this.gameVersion]
|
||||||
|
].map(arr => Buffer.from(arr)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public 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))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
public deserialize(packet: Buffer) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
17
src/proxy/packets/CSReadyPacket.ts
Normal file
17
src/proxy/packets/CSReadyPacket.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { Enums } from "../Enums.js";
|
||||||
|
import Packet from "../Packet.js";
|
||||||
|
|
||||||
|
export class CSReadyPacket implements Packet {
|
||||||
|
packetId: Enums.PacketId = Enums.PacketId.CSReadyPacket
|
||||||
|
type: "packet" = "packet"
|
||||||
|
boundTo = Enums.PacketBounds.S
|
||||||
|
sentAfterHandshake = false
|
||||||
|
|
||||||
|
public serialize() {
|
||||||
|
return Buffer.from([this.packetId])
|
||||||
|
}
|
||||||
|
|
||||||
|
public deserialize(packet: Buffer) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
60
src/proxy/packets/CSSetSkinPacket.ts
Normal file
60
src/proxy/packets/CSSetSkinPacket.ts
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import { Constants } from "../Constants.js";
|
||||||
|
import { Enums } from "../Enums.js";
|
||||||
|
import Packet from "../Packet.js";
|
||||||
|
import { MineProtocol } from "../Protocol.js";
|
||||||
|
|
||||||
|
export class CSSetSkinPacket implements Packet {
|
||||||
|
packetId: Enums.PacketId = Enums.PacketId.CSSetSkinPacket
|
||||||
|
type: "packet" = "packet"
|
||||||
|
boundTo: Enums.PacketBounds = Enums.PacketBounds.S
|
||||||
|
sentAfterHandshake: boolean = false
|
||||||
|
|
||||||
|
version: string | 'skin_v1' = 'skin_v1'
|
||||||
|
skinType: Omit<Enums.SkinType, 'NOT_LOADED'>
|
||||||
|
skinDimensions?: number
|
||||||
|
skin?: Buffer
|
||||||
|
skinId?: number
|
||||||
|
|
||||||
|
public 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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public deserialize(packet: Buffer) {
|
||||||
|
packet = packet.subarray(1)
|
||||||
|
const version = MineProtocol.readString(packet)
|
||||||
|
let skinType: Enums.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
29
src/proxy/packets/CSUsernamePacket.ts
Normal file
29
src/proxy/packets/CSUsernamePacket.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { Enums } from "../Enums.js";
|
||||||
|
import Packet from "../Packet.js";
|
||||||
|
import { MineProtocol } from "../Protocol.js";
|
||||||
|
|
||||||
|
export class CSUsernamePacket implements Packet {
|
||||||
|
packetId: Enums.PacketId = Enums.PacketId.CSUsernamePacket
|
||||||
|
type: "packet" = "packet"
|
||||||
|
boundTo = Enums.PacketBounds.S
|
||||||
|
sentAfterHandshake = false
|
||||||
|
|
||||||
|
username: string
|
||||||
|
static readonly DEFAULT = "default"
|
||||||
|
|
||||||
|
public serialize() {
|
||||||
|
return Buffer.concat([
|
||||||
|
[this.packetId],
|
||||||
|
MineProtocol.writeString(this.username),
|
||||||
|
MineProtocol.writeString(CSUsernamePacket.DEFAULT),
|
||||||
|
[0x0]
|
||||||
|
].map(arr => arr instanceof Uint8Array ? arr : Buffer.from(arr)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public deserialize(packet: Buffer) {
|
||||||
|
packet = packet.subarray(1)
|
||||||
|
const username = MineProtocol.readString(packet)
|
||||||
|
this.username = username.value
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
30
src/proxy/packets/SCDisconnectPacket.ts
Normal file
30
src/proxy/packets/SCDisconnectPacket.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { Chat } from "../Chat.js";
|
||||||
|
import { Enums } from "../Enums.js";
|
||||||
|
import Packet from "../Packet.js";
|
||||||
|
import { MineProtocol } from "../Protocol.js";
|
||||||
|
|
||||||
|
export default class SCDisconnectPacket implements Packet {
|
||||||
|
packetId: Enums.PacketId = Enums.PacketId.SCDisconnectPacket
|
||||||
|
type: "packet" = "packet"
|
||||||
|
boundTo = Enums.PacketBounds.C
|
||||||
|
sentAfterHandshake = false
|
||||||
|
static readonly REASON = 0x8
|
||||||
|
|
||||||
|
reason: string | Chat.Chat
|
||||||
|
|
||||||
|
public 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)))
|
||||||
|
}
|
||||||
|
public deserialize(packet: Buffer) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
40
src/proxy/packets/SCIdentifyPacket.ts
Normal file
40
src/proxy/packets/SCIdentifyPacket.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { NETWORK_VERSION, PROXY_BRANDING, PROXY_VERSION, VANILLA_PROTOCOL_VERSION } from "../../meta.js";
|
||||||
|
import { Enums } from "../Enums.js";
|
||||||
|
import Packet from "../Packet.js";
|
||||||
|
import { MineProtocol } from "../Protocol.js";
|
||||||
|
|
||||||
|
export default class SCIdentifyPacket implements Packet {
|
||||||
|
packetId: Enums.PacketId = Enums.PacketId.SCIdentifyPacket
|
||||||
|
type: "packet" = "packet"
|
||||||
|
boundTo = Enums.PacketBounds.C
|
||||||
|
sentAfterHandshake = false
|
||||||
|
|
||||||
|
protocolVer = NETWORK_VERSION
|
||||||
|
gameVersion = VANILLA_PROTOCOL_VERSION
|
||||||
|
branding = PROXY_BRANDING
|
||||||
|
version = PROXY_VERSION
|
||||||
|
|
||||||
|
public 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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public deserialize(packet: Buffer) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
17
src/proxy/packets/SCReadyPacket.ts
Normal file
17
src/proxy/packets/SCReadyPacket.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { Enums } from "../Enums.js";
|
||||||
|
import Packet from "../Packet.js";
|
||||||
|
|
||||||
|
export class SCReadyPacket implements Packet {
|
||||||
|
packetId: Enums.PacketId = Enums.PacketId.SCReadyPacket
|
||||||
|
type: "packet" = "packet"
|
||||||
|
boundTo = Enums.PacketBounds.C
|
||||||
|
sentAfterHandshake = false
|
||||||
|
|
||||||
|
public serialize() {
|
||||||
|
return Buffer.from([this.packetId])
|
||||||
|
}
|
||||||
|
|
||||||
|
public deserialize(packet: Buffer) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
31
src/proxy/packets/SCSyncUuidPacket.ts
Normal file
31
src/proxy/packets/SCSyncUuidPacket.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import { Enums } from "../Enums.js";
|
||||||
|
import Packet from "../Packet.js";
|
||||||
|
import { MineProtocol } from "../Protocol.js";
|
||||||
|
import { Util } from "../Util.js";
|
||||||
|
|
||||||
|
export class SCSyncUuidPacket implements Packet {
|
||||||
|
packetId: Enums.PacketId = Enums.PacketId.SCSyncUuidPacket
|
||||||
|
type: "packet" = "packet"
|
||||||
|
boundTo = Enums.PacketBounds.C
|
||||||
|
sentAfterHandshake = false
|
||||||
|
|
||||||
|
username: string
|
||||||
|
uuid: string
|
||||||
|
|
||||||
|
public serialize() {
|
||||||
|
return Buffer.concat([
|
||||||
|
[this.packetId],
|
||||||
|
MineProtocol.writeString(this.username),
|
||||||
|
Util.uuidStringToBuffer(this.uuid)
|
||||||
|
].map(arr => arr instanceof Uint8Array ? arr : Buffer.from(arr)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public deserialize(packet: Buffer) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
31
src/proxy/packets/channel/CSChannelMessage.ts
Normal file
31
src/proxy/packets/channel/CSChannelMessage.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import { Enums } from "../../Enums.js";
|
||||||
|
import Packet from "../../Packet.js";
|
||||||
|
import { MineProtocol } from "../../Protocol.js";
|
||||||
|
|
||||||
|
export class CSChannelMessagePacket implements Packet {
|
||||||
|
packetId: Enums.PacketId = Enums.PacketId.CSChannelMessagePacket
|
||||||
|
type: "packet" = "packet"
|
||||||
|
boundTo = Enums.PacketBounds.S
|
||||||
|
sentAfterHandshake = true
|
||||||
|
|
||||||
|
readonly messageType: Enums.ChannelMessageType = Enums.ChannelMessageType.CLIENT
|
||||||
|
channel: string
|
||||||
|
data: Buffer
|
||||||
|
|
||||||
|
public serialize() {
|
||||||
|
return Buffer.concat([
|
||||||
|
[this.packetId],
|
||||||
|
MineProtocol.writeString(this.channel),
|
||||||
|
this.data
|
||||||
|
].map(arr => arr instanceof Uint8Array ? arr : Buffer.from(arr)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public deserialize(packet: Buffer) {
|
||||||
|
packet = packet.subarray(1)
|
||||||
|
const channel = MineProtocol.readString(packet),
|
||||||
|
data = channel.newBuffer
|
||||||
|
this.channel = channel.value
|
||||||
|
this.data = data
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
31
src/proxy/packets/channel/SCChannelMessage.ts
Normal file
31
src/proxy/packets/channel/SCChannelMessage.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import { Enums } from "../../Enums.js";
|
||||||
|
import Packet from "../../Packet.js";
|
||||||
|
import { MineProtocol } from "../../Protocol.js";
|
||||||
|
|
||||||
|
export class SCChannelMessagePacket implements Packet {
|
||||||
|
packetId: Enums.PacketId = Enums.PacketId.SCChannelMessagePacket
|
||||||
|
type: "packet" = "packet"
|
||||||
|
boundTo = Enums.PacketBounds.C
|
||||||
|
sentAfterHandshake = true
|
||||||
|
|
||||||
|
readonly messageType: Enums.ChannelMessageType = Enums.ChannelMessageType.SERVER
|
||||||
|
channel: string
|
||||||
|
data: Buffer
|
||||||
|
|
||||||
|
public serialize() {
|
||||||
|
return Buffer.concat([
|
||||||
|
[this.packetId],
|
||||||
|
MineProtocol.writeString(this.channel),
|
||||||
|
this.data
|
||||||
|
].map(arr => arr instanceof Uint8Array ? arr : Buffer.from(arr)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public deserialize(packet: Buffer) {
|
||||||
|
packet = packet.subarray(1)
|
||||||
|
const channel = MineProtocol.readString(packet),
|
||||||
|
data = channel.newBuffer
|
||||||
|
this.channel = channel.value
|
||||||
|
this.data = data
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
68
src/proxy/pluginLoader/PluginLoaderTypes.ts
Normal file
68
src/proxy/pluginLoader/PluginLoaderTypes.ts
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
export namespace PluginLoaderTypes {
|
||||||
|
/**
|
||||||
|
* ## SemVer
|
||||||
|
* Abstract typing to define a semantic version string. Refer to https://semver.org/ for more details.
|
||||||
|
*/
|
||||||
|
export type SemVer = string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ## SemVerReq
|
||||||
|
* Abstract typing to define a semantic version requirement. Refer to https://semver.org/ for more details.
|
||||||
|
*/
|
||||||
|
export type SemVerReq = string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ## PluginMetadata
|
||||||
|
* Data structure of the JSON plugin metadata stored in `metadata.json`. Below is an example plugin metadata object.
|
||||||
|
* @example
|
||||||
|
* {
|
||||||
|
* name: "ExamplePlugin",
|
||||||
|
* version: "1.0.0"
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @property {string} name - The name of the plugin. Spaces are allowed, and this will be shown to the end user.
|
||||||
|
* @property {string} id - The internal ID of the plugin. Spaces are not allowed, and any ID conflicts will cause the proxy to not load.
|
||||||
|
* @property {PluginLoaderTypes.SemVer} version - The version of the plugin. Must follow SemVer guidelines.
|
||||||
|
* @property {string} entry_point - Reference to the entry point JS file of the plugin. Is relative to the file of the `metadata.json`.
|
||||||
|
* @property {PluginLoaderTypes.PluginRequirement[]} requirements - The plugin requirement(s) of the plugin. Proxy will not load if any requirement cannot be satisfied.
|
||||||
|
* @property {string[]} load_after - Defines what plugin(s) to be loaded first before this plugin is loaded.
|
||||||
|
*/
|
||||||
|
export type PluginMetadata = {
|
||||||
|
name: string,
|
||||||
|
id: string,
|
||||||
|
version: SemVer,
|
||||||
|
entry_point: string,
|
||||||
|
requirements: PluginRequirement[],
|
||||||
|
incompatibilities: PluginRequirement[],
|
||||||
|
load_after: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ## PluginMetadataPathed
|
||||||
|
* Internal typing. Provides a path to the plugin metadata file.
|
||||||
|
*/
|
||||||
|
export type PluginMetadataPathed = PluginMetadata & { path: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ## PluginLoadOrder
|
||||||
|
* Internal typing. Provides a loading order for plugin loading.
|
||||||
|
*/
|
||||||
|
export type PluginLoadOrder = string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ## PluginRequirement
|
||||||
|
* A plugin requirement used to define dependencies for a specific plugin.
|
||||||
|
* Semantic versions may be used for the attribute `version`, and you can
|
||||||
|
* use `eaglerproxy` to define a requirement for the proxy version.
|
||||||
|
* @example
|
||||||
|
* {
|
||||||
|
* id: "eaglerproxy"
|
||||||
|
* }
|
||||||
|
* @property {string} id - The ID of the plugin to be used as a requirement.
|
||||||
|
* @property {PluginLoaderTypes.SemVerReq} version - The SemVer requirement for the requirement.
|
||||||
|
*/
|
||||||
|
export type PluginRequirement = {
|
||||||
|
id: string,
|
||||||
|
version: SemVerReq | 'any'
|
||||||
|
}
|
||||||
|
}
|
266
src/proxy/pluginLoader/PluginManager.ts
Normal file
266
src/proxy/pluginLoader/PluginManager.ts
Normal file
|
@ -0,0 +1,266 @@
|
||||||
|
import { Stats } from "fs";
|
||||||
|
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 { Proxy } from "../Proxy.js";
|
||||||
|
import { Util } from "../Util.js";
|
||||||
|
import { PluginLoaderTypes } from "./PluginLoaderTypes.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 "../EaglerSkins.js";
|
||||||
|
import { BungeeUtil } from "../BungeeUtil.js";
|
||||||
|
|
||||||
|
export class PluginManager extends EventEmitter {
|
||||||
|
public plugins: Map<string, { exports: any, metadata: PluginLoaderTypes.PluginMetadataPathed }>
|
||||||
|
public proxy: Proxy
|
||||||
|
|
||||||
|
public Logger: typeof Logger = Logger
|
||||||
|
public Enums: typeof Enums = Enums
|
||||||
|
public Chat: typeof Chat = Chat
|
||||||
|
public Constants: typeof Constants = Constants
|
||||||
|
public Motd: typeof Motd = Motd
|
||||||
|
public Player: typeof Player = Player
|
||||||
|
public MineProtocol: typeof MineProtocol = MineProtocol
|
||||||
|
public EaglerSkins: typeof EaglerSkins = EaglerSkins
|
||||||
|
public Util: typeof Util = Util
|
||||||
|
public BungeeUtil: typeof BungeeUtil = BungeeUtil
|
||||||
|
|
||||||
|
private _loadDir: string
|
||||||
|
private _logger: Logger
|
||||||
|
|
||||||
|
constructor(loadDir: string) {
|
||||||
|
super()
|
||||||
|
this.setMaxListeners(0)
|
||||||
|
this._loadDir = loadDir
|
||||||
|
this.plugins = new Map()
|
||||||
|
this.Logger = Logger
|
||||||
|
this._logger = new this.Logger('PluginManager')
|
||||||
|
}
|
||||||
|
|
||||||
|
public 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}`)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _findPlugins(dir: string): Promise<Map<string, PluginLoaderTypes.PluginMetadataPathed>> {
|
||||||
|
const ret: Map<string, PluginLoaderTypes.PluginMetadataPathed> = 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))])) as [string, Stats][]
|
||||||
|
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: PluginLoaderTypes.PluginMetadata
|
||||||
|
try {
|
||||||
|
const file = await fs.readFile(metadataPath)
|
||||||
|
metadata = JSON.parse(file.toString())
|
||||||
|
// do some type checking
|
||||||
|
if (typeof metadata.name != 'string')
|
||||||
|
throw new TypeError("<metadata>.name is either null or not of a string type!")
|
||||||
|
if (typeof metadata.id != 'string')
|
||||||
|
throw new TypeError("<metadata>.id is either null or not of a string type!")
|
||||||
|
if ((/ /gm).test(metadata.id))
|
||||||
|
throw new Error(`<metadata>.id contains whitespace!`)
|
||||||
|
if (!semver.valid(metadata.version))
|
||||||
|
throw new Error("<metadata>.version is either null, not a string, or is not a valid SemVer!")
|
||||||
|
if (typeof metadata.entry_point != 'string')
|
||||||
|
throw new TypeError("<metadata>.entry_point is either null or not a string!")
|
||||||
|
if (!metadata.entry_point.endsWith('.js'))
|
||||||
|
throw new Error(`<metadata>.entry_point (${metadata.entry_point}) references a non-JavaScript file!`)
|
||||||
|
if (!await Util.fsExists(pathUtil.resolve(path, metadata.entry_point)))
|
||||||
|
throw new Error(`<metadata>.entry_point (${metadata.entry_point}) references a non-existent file!`)
|
||||||
|
if (metadata.requirements instanceof Array == false)
|
||||||
|
throw new TypeError("<metadata>.requirements is either null or not an array!")
|
||||||
|
for (const requirement of metadata.requirements as PluginLoaderTypes.PluginMetadata["requirements"]) {
|
||||||
|
if (typeof requirement != 'object' || requirement == null)
|
||||||
|
throw new TypeError(`<metadata>.requirements[${(metadata.requirements as any).indexOf(requirement)}] is either null or not an object!`)
|
||||||
|
if (typeof requirement.id != 'string')
|
||||||
|
throw new TypeError(`<metadata>.requirements[${(metadata.requirements as any).indexOf(requirement)}].id is either null or not a string!`)
|
||||||
|
if (/ /gm.test(requirement.id))
|
||||||
|
throw new TypeError(`<metadata>.requirements[${(metadata.requirements as any).indexOf(requirement)}].id contains whitespace!`)
|
||||||
|
if (semver.validRange(requirement.version) == null && requirement.version != 'any')
|
||||||
|
throw new TypeError(`<metadata>.requirements[${(metadata.requirements as any).indexOf(requirement)}].version is either null or not a valid SemVer!`)
|
||||||
|
}
|
||||||
|
if (metadata.load_after instanceof Array == false)
|
||||||
|
throw new TypeError("<metadata>.load_after is either null or not an array!")
|
||||||
|
for (const loadReq of metadata.load_after as string[]) {
|
||||||
|
if (typeof loadReq != 'string')
|
||||||
|
throw new TypeError(`<metadata>.load_after[${(metadata.load_after as any).indexOf(loadReq)}] is either null, or not a valid ID!`)
|
||||||
|
if (/ /gm.test(loadReq))
|
||||||
|
throw new TypeError(`<metadata>.load_after[${(metadata.load_after as any).indexOf(loadReq)}] contains whitespace!`)
|
||||||
|
}
|
||||||
|
if (metadata.incompatibilities instanceof Array == false)
|
||||||
|
throw new TypeError("<metadata>.incompatibilities is either null or not an array!")
|
||||||
|
for (const incompatibility of metadata.incompatibilities as PluginLoaderTypes.PluginMetadata["requirements"]) {
|
||||||
|
if (typeof incompatibility != 'object' || incompatibility == null)
|
||||||
|
throw new TypeError(`<metadata>.incompatibilities[${(metadata.load_after as any).indexOf(incompatibility)}] is either null or not an object!`)
|
||||||
|
if (typeof incompatibility.id != 'string')
|
||||||
|
throw new TypeError(`<metadata>.incompatibilities[${(metadata.load_after as any).indexOf(incompatibility)}].id is either null or not a string!`)
|
||||||
|
if (/ /gm.test(incompatibility.id))
|
||||||
|
throw new TypeError(`<metadata>.incompatibilities[${(metadata.load_after as any).indexOf(incompatibility)}].id contains whitespace!`)
|
||||||
|
if (semver.validRange(incompatibility.version) == null)
|
||||||
|
throw new TypeError(`<metadata>.incompatibilities[${(metadata.load_after as any).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
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _validatePluginList(plugins: Map<string, PluginLoaderTypes.PluginMetadataPathed>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getLoadOrder(plugins: Map<string, PluginLoaderTypes.PluginMetadataPathed>): PluginLoaderTypes.PluginLoadOrder {
|
||||||
|
let order = [], lastPlugin: any
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _loadPlugins(plugins: Map<string, PluginLoaderTypes.PluginMetadataPathed>, order: PluginLoaderTypes.PluginLoadOrder): Promise<number> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginManagerEvents {
|
||||||
|
'pluginLoad': (name: string, plugin: any) => void,
|
||||||
|
'pluginsFinishLoading': (manager: PluginManager) => void,
|
||||||
|
'proxyFinishLoading': (proxy: Proxy, manager: PluginManager) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface PluginManager {
|
||||||
|
on<U extends keyof PluginManagerEvents>(
|
||||||
|
event: U, listener: PluginManagerEvents[U]
|
||||||
|
): this;
|
||||||
|
|
||||||
|
emit<U extends keyof PluginManagerEvents>(
|
||||||
|
event: U, ...args: Parameters<PluginManagerEvents[U]>
|
||||||
|
): boolean;
|
||||||
|
|
||||||
|
once<U extends keyof PluginManagerEvents>(
|
||||||
|
event: U, listener: PluginManagerEvents[U]
|
||||||
|
): this;
|
||||||
|
}
|
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "esnext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"target": "es2017",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"sourceMap": false,
|
||||||
|
"outDir": "build",
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.json",
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"hooks": [
|
||||||
|
"copy-files"
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user