commit 052db244e6970ece5978b61667e07415b77d3e15 Author: RuanFernandes Date: Wed Jun 25 05:41:15 2025 -0300 First diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8358965 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +ACCOUNT=graal1234 +PASSWORD=RQWUEIBAS +# The following are optional +# Remove the # to enable + +# Discord Webhook to send logs from RC to Discord +#RC_LOG_DISCORD_WEBHOOK= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a30b15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +package-lock.json +.env \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..f023574 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "tabWidth": 4, + "trailingComma": "all", + "singleQuote": true +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7b26db0 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "ruan-graal-rc", + "version": "1.0.0", + "description": "", + "license": "ISC", + "author": "Ruan Fernandes", + "type": "commonjs", + "main": "index.js", + "scripts": { + "ndmon": "nodemon src/index.ts" + }, + "devDependencies": { + "@types/node": "^22.13.14", + "typescript": "^5.8.2" + }, + "dependencies": { + "axios": "^1.8.4", + "chalk": "^5.4.1", + "compressjs": "^1.0.3", + "dotenv": "^16.4.7" + } +} diff --git a/src/FileBrowser.ts b/src/FileBrowser.ts new file mode 100644 index 0000000..d5fedad --- /dev/null +++ b/src/FileBrowser.ts @@ -0,0 +1,203 @@ +import { FolderRights } from './FolderRights'; +import { GBufferReader, GBufferWriter } from './GBuffer'; +import { PromiseManager } from './PromiseManager'; +import { RCOutgoingPacket } from './misc/packet'; +import { RemoteControl } from './RemoteControl'; +import * as types from './types'; + +export interface FileBrowser { + cd(folder: string): PromiseLike; + get(file: string): PromiseLike; + put(file: string, content: Buffer): void; +} + +type FileStructure = { + name: string; + size: number; + modTime: number; + buffer: Buffer[]; +}; + +type FileData = { [name: string]: FileStructure }; + +export class RCFileBrowser implements FileBrowser { + private folderRights: FolderRights; + private promiseManager: PromiseManager; + private fileData: FileData = {}; + private active: string = ''; + + constructor(private readonly rc: RemoteControl) { + this.folderRights = new FolderRights(); + this.promiseManager = new PromiseManager(); + } + + ///////// + + setFolderRights(content: string) { + this.folderRights.setFolderRights(content); + } + + setFolderFiles(reader: GBufferReader, folderName: string) { + const dirList: types.DirectoryListing = { + directory: folderName, + fileList: this.folderRights.getSubFolders(folderName), + }; + + while (reader.bytesLeft) { + reader.readUInt8(); // skip space + + const reader2 = reader.readGBuffer(); + dirList.fileList.push({ + name: reader2.readGString(), + type: types.FSEntryType.File, + permissions: reader2.readGString(), + fileSize: reader2.readGULong(), + modTime: reader2.readGULong(), + }); + } + + this.promiseManager.resolvePromise('dir://' + folderName, dirList); + } + + startLargeFile(fileName: string) { + this.fileData[fileName] = { + name: fileName, + size: 0, + modTime: 0, + buffer: [], + }; + + this.active = fileName; + } + + setActiveFileSize(size: number) { + if (this.active in this.fileData) { + this.fileData[this.active].size = size; + } + } + + appendFileContent(fileName: string, modTime: number, buffer: Buffer) { + if (fileName in this.fileData) { + this.fileData[fileName].modTime = modTime; + this.fileData[fileName].buffer.push(buffer); + return; + } + + this.startLargeFile(fileName); + this.setActiveFileSize(buffer.length); + + this.fileData[fileName].modTime = modTime; + this.fileData[fileName].buffer.push(buffer); + + this.finishLargeFile(fileName); + } + + finishLargeFile(fileName: string) { + if (fileName in this.fileData) { + this.promiseManager.resolvePromise( + fileName, + Buffer.concat(this.fileData[fileName].buffer), + ); + delete this.fileData[fileName]; + } + + if (fileName == this.active) { + this.active = ''; + } + } + + fileDownloadFailed(fileName: string) { + if (fileName in this.fileData) { + this.promiseManager.rejectPromise(fileName, 'File download failed'); + delete this.fileData[fileName]; + } + + if (fileName == this.active) { + this.active = ''; + } + } + + //////////////////// + + cd(folder: string): Promise { + if (!folder) { + return new Promise((resolve) => { + resolve({ + directory: '/', + fileList: this.folderRights.getSubFolders(), + }); + }); + } + + if (!folder.endsWith('/')) { + folder += '/'; + } + + this.rc.socket?.sendData( + this.rc.socket?.sendPacket( + RCOutgoingPacket.PLI_RC_FILEBROWSER_CD, + Buffer.from(folder), + ), + ); + return this.promiseManager.createPromise('dir://' + folder); + } + + put(fileName: string, content: Buffer): void { + if (content.length > this.rc.maxUploadFileSize) { + throw new Error('File is too large!'); + } + + const largeFileThreshold = 32000; + const isLargeFile = content.length > largeFileThreshold; + + if (isLargeFile) { + this.rc.socket?.sendData( + this.rc.socket?.sendPacket( + RCOutgoingPacket.PLI_RC_LARGEFILESTART, + Buffer.from(fileName), + ), + ); + } + + const reader = GBufferReader.from(content); + while (reader.bytesLeft) { + const cur = reader.read(largeFileThreshold); + const dataSize = 3 + fileName.length + cur.length; // char(PLI_RC_FILEBROWSER_UP) + len(file) + file + cur + '\n' + const writer = GBufferWriter.create(4 + dataSize); // len(data) + '\n' + + writer.writeGUInt24(dataSize); + writer.writeUInt8(0xa); + + writer.writeGUInt8(RCOutgoingPacket.PLI_RC_FILEBROWSER_UP); + writer.writeGString(fileName); + writer.writeBuffer(cur); + writer.writeUInt8(0xa); + + this.rc.socket?.sendData( + this.rc.socket?.sendPacket( + RCOutgoingPacket.PLI_RAWDATA, + writer.buffer, + ), + ); + } + + if (isLargeFile) { + this.rc.socket?.sendData( + this.rc.socket?.sendPacket( + RCOutgoingPacket.PLI_RC_LARGEFILEEND, + Buffer.from(fileName), + ), + ); + } + } + + get(fileName: string): PromiseLike { + this.rc.socket?.sendData( + this.rc.socket?.sendPacket( + RCOutgoingPacket.PLI_RC_FILEBROWSER_DOWN, + Buffer.from(fileName), + ), + ); + return this.promiseManager.createPromise(fileName); + } +} diff --git a/src/FolderRights.ts b/src/FolderRights.ts new file mode 100644 index 0000000..5c2f2c8 --- /dev/null +++ b/src/FolderRights.ts @@ -0,0 +1,73 @@ +import * as types from './types'; +import * as utils from './utils'; + +type FolderRightsData = { + folder: string; + permissions: string; + match: string; +}; + +type FolderRightsMap = { [name: string]: FolderRightsData }; + +export class FolderRights { + private folderRightsText: string = ''; + private folderRights: FolderRightsMap = {}; + + public setFolderRights(rights: string) { + const re = /^([r]?[w]?) (.*)\/(.*)$/gm; + const matches = rights.matchAll(re); + + this.folderRightsText = ''; + this.folderRights = {}; + + for (const match of matches) { + let [, perm, folder, filematch] = match; + + this.folderRights[folder] = { + folder: folder, + permissions: perm, + match: filematch, + }; + + this.folderRightsText += folder + '\n'; + } + } + + /** + * Get immediate subfolders based on the users folder rights + * + * @param folder + * @returns + */ + public getSubFolders(folder: string = ''): types.FSEntries[] { + if (folder && !folder.endsWith('/')) { + folder += '/'; + } + + const escapedFolder = utils.escapeRegExp(folder); + const regex = new RegExp(`^${escapedFolder}([^\/\n]+)`, 'gm'); + const matches = this.folderRightsText.matchAll(regex); + + const entries: { [key: string]: types.FSEntries } = {}; + + for (const match of matches) { + const curFolder = match[1]; + + if (!(curFolder in entries)) { + const folderPath = folder + curFolder; + + if (folderPath in this.folderRights) { + entries[curFolder] = { + name: curFolder, + type: types.FSEntryType.Directory, + permissions: this.folderRights[folderPath].permissions, + fileSize: 0, + modTime: 0, + }; + } + } + } + + return Object.values(entries); + } +} diff --git a/src/GBuffer.ts b/src/GBuffer.ts new file mode 100644 index 0000000..d6055b0 --- /dev/null +++ b/src/GBuffer.ts @@ -0,0 +1,267 @@ +const DEFAULT_BUFFER_SIZE = 32; + +function decodeBits(b: Buffer) { + let res = 0; + for (let i = 0; i < b.length; i++) { + res <<= 7; + res |= b[i] - 32; + } + + return res; +} + +function encodeBits(v: number, b: number[]) { + for (let i = b.length; i > 0; i--) { + b[i - 1] = (v & 0x7f) + 32; + v >>= 7; + } +} + +/** + * Creates a BufferReader over an existing buffer, does not copy the buffer + * + * Allows easily reading packets + */ +export class GBufferReader { + private buffer: Buffer; + private readPosition: number = 0; + + public static from(buf: Buffer): GBufferReader { + return new GBufferReader(buf); + } + + protected constructor(buf: Buffer) { + this.buffer = buf; + } + + public get bytesLeft(): number { + return Math.max(0, this.buffer.length - this.readPosition); + } + + public reset(): void { + this.readPosition = 0; + } + + public seek(idx: number): void { + this.readPosition = Math.min(this.buffer.length, Math.max(0, idx)); + } + + public readInt8(): number { + let res = this.buffer.readInt8(this.readPosition); + this.readPosition += 1; + return res; + } + + public readUInt8(): number { + let res = this.buffer.readUInt8(this.readPosition); + this.readPosition += 1; + return res; + } + + public readUInt16BE(): number { + let res = this.buffer.readUInt16BE(this.readPosition); + this.readPosition += 2; + return res; + } + + public readGUInt8(): number { + let res = decodeBits( + this.buffer.slice(this.readPosition, this.readPosition + 1), + ); + // let res = this.buffer.readUInt8(this.readPosition) - 32; + this.readPosition += 1; + return res; + } + + public readGUInt16(): number { + let res = decodeBits( + this.buffer.slice(this.readPosition, this.readPosition + 2), + ); + this.readPosition += 2; + return res; + } + + public readGUInt24(): number { + let res = decodeBits( + this.buffer.slice(this.readPosition, this.readPosition + 3), + ); + this.readPosition += 3; + return res; + } + + public readGUInt32(): number { + let res = decodeBits( + this.buffer.slice(this.readPosition, this.readPosition + 4), + ); + this.readPosition += 4; + return res; + } + + public readGULong(): number { + let res = decodeBits( + this.buffer.slice(this.readPosition, this.readPosition + 5), + ); + this.readPosition += 5; + return res; + } + + public read(len: number): Buffer { + const newReadPosition = Math.min( + this.buffer.length, + this.readPosition + len, + ); + let res = this.buffer.slice(this.readPosition, newReadPosition); + this.readPosition = newReadPosition; + return res; + } + + public readChars(len: number): string { + return this.read(len).toString('latin1'); + } + + public readGBuffer(len?: number): GBufferReader { + if (!len) { + len = this.readGUInt8(); + } + + return new GBufferReader(this.read(len)); + } + + public readGString(): string { + return this.readChars(this.readGUInt8()); + } +} + +/** + * Dynamically-allocated buffer, used for writing packet data + */ +export class GBufferWriter { + private internalBuffer: Buffer; + private writePosition: number; + + constructor(capacity: number) { + this.internalBuffer = Buffer.allocUnsafe(capacity); + this.writePosition = 0; + } + + public static create( + capacity: number = DEFAULT_BUFFER_SIZE, + ): GBufferWriter { + return new GBufferWriter(capacity); + } + + public get buffer(): Buffer { + return this.internalBuffer.slice(0, this.writePosition); + } + + public get capacity(): number { + return this.internalBuffer.length; + } + + public get length(): number { + return this.writePosition; + } + + public clear() { + this.writePosition = 0; + } + + public resize(minimum?: number): boolean { + minimum = minimum || this.internalBuffer.length; + if (minimum < this.internalBuffer.length) return false; + + minimum *= 2; + + let newbuf = Buffer.allocUnsafe(minimum); + newbuf.set(this.internalBuffer); + this.internalBuffer = newbuf; + return true; + } + + public writeBuffer(buf: Buffer): void { + const newSize = this.length + buf.length; + if (newSize >= this.capacity) this.resize(newSize); + + this.internalBuffer.set(buf, this.writePosition); + this.writePosition = newSize; + } + + public writeChars(buf: string): void { + const newSize = this.length + buf.length; + if (newSize >= this.capacity) this.resize(newSize); + + this.internalBuffer.write(buf, this.writePosition, 'latin1'); + this.writePosition = newSize; + } + + public writeUInt8(v: number): void { + if (this.length + 1 >= this.capacity) this.resize(); + + this.internalBuffer.writeUInt8(v, this.writePosition); + this.writePosition += 1; + } + + public writeGUInt8(v: number): void { + if (this.length + 1 >= this.capacity) this.resize(); + + let b = [0]; + encodeBits(Math.min(v, 223), b); + + for (let i = 0; i < b.length; i++) + this.internalBuffer.writeUInt8(b[i], this.writePosition + i); + this.writePosition += b.length; + + // v = Math.min(v, 223); + // this.internalBuffer.writeUInt8(v + 32, this.writePosition); + // this.writePosition += 1; + } + + public writeGUInt16(v: number): void { + if (this.length + 2 >= this.capacity) this.resize(); + + let b = [0, 0]; + encodeBits(Math.min(v, 28767), b); + + for (let i = 0; i < b.length; i++) + this.internalBuffer.writeUInt8(b[i], this.writePosition + i); + this.writePosition += b.length; + } + + public writeGUInt24(v: number): void { + if (this.length + 3 >= this.capacity) this.resize(); + + let b = [0, 0, 0]; + encodeBits(Math.min(v, 3682399), b); + + for (let i = 0; i < b.length; i++) + this.internalBuffer.writeUInt8(b[i], this.writePosition + i); + this.writePosition += b.length; + } + + public writeGUInt32(v: number): void { + if (this.length + 4 >= this.capacity) this.resize(); + + let b = [0, 0, 0, 0]; + encodeBits(Math.min(v, 471347295), b); + + for (let i = 0; i < b.length; i++) + this.internalBuffer.writeUInt8(b[i], this.writePosition + i); + this.writePosition += b.length; + } + + public writeGULong(v: number): void { + if (this.length + 5 >= this.capacity) this.resize(); + + let b = [0, 0, 0, 0, 0]; + encodeBits(Math.min(v, 0xffffffff), b); + + for (let i = 0; i < b.length; i++) + this.internalBuffer.writeUInt8(b[i], this.writePosition + i); + this.writePosition += b.length; + } + + public writeGString(str: string) { + this.writeGUInt8(str.length); + this.writeChars(str); + } +} diff --git a/src/GProtocol.ts b/src/GProtocol.ts new file mode 100644 index 0000000..7579387 --- /dev/null +++ b/src/GProtocol.ts @@ -0,0 +1,164 @@ +import zlib = require('zlib'); + +// @ts-ignore +import compressjs = require('compressjs'); + +export enum ProtocolGen { + Gen1 = 0, + Gen2 = 1, + Gen3 = 2, + Gen4 = 3, + Gen5 = 4, +} + +enum CompressionType { + NONE = 0x02, + ZLIB = 0x04, + BZ2 = 0x06, +} + +enum Direction { + Outgoing = 0, + Incoming = 1, +} + +const start_iterator = [0, 0, 0x04a80b38, 0x4a80b38, 0x4a80b38, 0]; + +function getIteratorLimit(compressionType: CompressionType): number { + return compressionType == CompressionType.NONE ? 0x0c : 0x04; +} + +export class GProtocol { + key: number = 0; + iterator: Uint32Array = new Uint32Array(2); + gen: ProtocolGen = ProtocolGen.Gen1; + + public static generateKey(): number { + return Math.floor(Math.random() * 128); + } + + public static initialize(gen: ProtocolGen, key?: number): GProtocol { + key = key || GProtocol.generateKey(); + return new GProtocol(gen, key); + } + + protected constructor(gen: ProtocolGen, key: number) { + this.key = key; + this.gen = gen; + this.iterator[0] = this.iterator[1] = start_iterator[gen]; + } + + apply(compressionType: CompressionType, dir: Direction, buf: Buffer) { + let limit = getIteratorLimit(compressionType); + + for (let i = 0; i < buf.length; i++) { + if (i % 4 == 0) { + if (limit <= 0) break; + + limit -= 1; + + this.iterator[dir] = + Math.imul(this.iterator[dir], 0x8088405) + this.key; + } + + buf[i] ^= (this.iterator[dir] >> ((i % 4) * 8)) & 0xff; + } + } + + decrypt(buf: Buffer): Buffer { + switch (this.gen) { + case ProtocolGen.Gen1: + return buf; + + case ProtocolGen.Gen2: + return zlib.inflateSync(buf); + + case ProtocolGen.Gen3: { + buf = zlib.inflateSync(buf); + + this.iterator[Direction.Incoming] *= 0x8088405; + this.iterator[Direction.Incoming] += this.key; + let removePos = + (this.iterator[Direction.Incoming] & 0x0ffff) % buf.length; + + let newBuf = Buffer.allocUnsafe(buf.length - 1); + buf.copy(newBuf, 0, 0, removePos); + buf.copy(newBuf, removePos + 1, removePos + 1); + return buf; + } + + case ProtocolGen.Gen4: + case ProtocolGen.Gen5: { + let compressionType = CompressionType.BZ2; + if (this.gen == ProtocolGen.Gen5) + compressionType = buf.readUInt8(); + + this.apply(compressionType, Direction.Incoming, buf.slice(1)); + + switch (compressionType) { + case CompressionType.NONE: + buf = buf.slice(1); + break; + + case CompressionType.ZLIB: + buf = zlib.inflateSync(buf.slice(1)); + break; + + case CompressionType.BZ2: + buf = Buffer.from( + compressjs.Bzip2.decompressFile(buf.slice(1)), + ); + break; + } + + return buf; + } + } + } + + encrypt(buf: Buffer): Buffer { + switch (this.gen) { + case ProtocolGen.Gen1: + return buf; + + case ProtocolGen.Gen2: + return zlib.deflateSync(buf); + + case ProtocolGen.Gen3: + buf = zlib.deflateSync(buf); + this.iterator[Direction.Outgoing] *= 0x8088405; + this.iterator[Direction.Outgoing] += this.key; + let removePos = + (this.iterator[Direction.Outgoing] & 0x0ffff) % buf.length; + + let newBuf = Buffer.allocUnsafe(buf.length - 1); + buf.copy(newBuf, 0, 0, removePos); + buf.copy(newBuf, removePos + 1, removePos + 1); + return buf; + + case ProtocolGen.Gen4: + case ProtocolGen.Gen5: { + let compressionType = CompressionType.BZ2; + + // Forcing zlib over bz2 due to the bz2 implementation being coded in + // javascript rather than a native node module. + if (this.gen == ProtocolGen.Gen5) + compressionType = CompressionType.ZLIB; + + switch (compressionType) { + case CompressionType.ZLIB: + buf = zlib.deflateSync(buf); + break; + + case CompressionType.BZ2: + buf = compressjs.Bzip2.compressFile(buf); + break; + } + + this.apply(compressionType, Direction.Outgoing, buf); + + return Buffer.concat([Buffer.from([compressionType]), buf]); + } + } + } +} diff --git a/src/GSocket.ts b/src/GSocket.ts new file mode 100644 index 0000000..e21801d --- /dev/null +++ b/src/GSocket.ts @@ -0,0 +1,159 @@ +import net = require('net'); +import { GProtocol, ProtocolGen } from './GProtocol'; +import { PacketHandler, PacketTable } from './PacketTable'; + +export type ConnectHandler = () => void; +export type DisconnectHandler = (err?: Error) => void; + +export interface SocketConfigurartion { + connectCallback?: ConnectHandler; + disconnectCallback?: DisconnectHandler; + packetTable?: PacketTable; +} + +export class GSocket { + private buffer: Buffer; + private enc: GProtocol | null = null; + private sock: net.Socket | null = null; + private packetHandler: PacketTable; + private config: SocketConfigurartion; + + public rawBytesAhead: number = 0; + + protected constructor(cfg: SocketConfigurartion) { + this.buffer = Buffer.allocUnsafe(0); + this.config = cfg; + this.packetHandler = cfg?.packetTable || new PacketTable(); + } + + public static connect( + host: string, + port: number, + cfg?: SocketConfigurartion, + ): GSocket { + const newSock = new GSocket(cfg || {}); + newSock.setProtocol(ProtocolGen.Gen2); + newSock.connect(host, port); + return newSock; + } + + // Expose PacketHandler methods + public on(id: number, callback: PacketHandler) { + return this.packetHandler.on(id, callback); + } + + private connect(host: string, port: number) { + this.sock = new net.Socket(); + this.sock.setTimeout(10000); + + this.sock.connect(port, host, this.config.connectCallback); + + this.sock.on('data', (data: Buffer) => { + this.buffer = Buffer.concat([this.buffer, data]); + + while (this.buffer.length > 0) { + const readLen = this.buffer.readUInt16BE(); + if (this.buffer.length < readLen + 2) break; + + this.processData(this.buffer.slice(2, 2 + readLen)); + this.buffer = this.buffer.slice(2 + readLen); + } + }); + + this.sock.on('close', () => { + console.log(`[SOCKET CLOSE] ${host}:${port}`); + + this.config.disconnectCallback?.(); + }); + + this.sock.on('error', (error) => { + console.log(`[SOCKET ERROR] ${host}:${port}`); + + this.disconnect(); + }); + + this.sock.on('timeout', () => { + console.log(`[SOCKET TIMED OUT] ${host}:${port}`); + }); + } + + public disconnect() { + if (this.sock) { + this.sock.destroy(); + this.sock = null; + } + } + + public setProtocol(protocol: ProtocolGen, key?: number) { + this.enc = GProtocol.initialize(protocol, key); + } + + public sendData(buf: Buffer) { + if (this.enc) buf = this.enc.encrypt(buf); + + let nb = Buffer.allocUnsafe(buf.length + 2); + buf.copy(nb, 2); + nb.writeUInt16BE(buf.length, 0); + + this.sock?.write(nb); + } + + public sendPacket(id: number, buf?: Buffer) { + const buffers = [Buffer.from([id + 32])]; + + if (buf) buffers.push(Buffer.from(buf)); + + // Append new line to packets + if (!buf || buf[buf.length - 1] != 0xa) + buffers.push(Buffer.from([0xa])); + + return Buffer.concat(buffers); + } + + private processData(buf: Buffer) { + if (this.enc) buf = this.enc.decrypt(buf); + + let offset = 0; + while (offset < buf.length) { + let idx; + + // Read data from buffer, terminating at '\n' or rawBytesAhead if defined + if (this.rawBytesAhead > 0) { + if (this.rawBytesAhead > buf.length - offset) { + break; + } + + idx = offset + this.rawBytesAhead; + } else { + idx = buf.indexOf('\n', offset); + if (idx === -1) { + break; + } + } + + // Copy data into a new buffer + let b = Buffer.allocUnsafe(idx - offset); + buf.copy(b, 0, offset, idx); + + if (this.rawBytesAhead > 0) { + // Reset raw + this.rawBytesAhead = 0; + offset = idx; + } else { + // Skip passed the newline + offset = idx + 1; + } + + // Process the packet + let packetId = b.readUInt8(); + this.processPacket(packetId - 32, b.slice(1)); + } + } + + private processPacket(id: number, buf: Buffer) { + let handlers = this.packetHandler.getCallbacks(id); + for (let handle of handlers) { + handle(id, buf); + } + } +} diff --git a/src/Logger.ts b/src/Logger.ts new file mode 100644 index 0000000..cf5e8d1 --- /dev/null +++ b/src/Logger.ts @@ -0,0 +1,23 @@ +import chalk from 'chalk'; + +export class Logger { + public static log(...args: unknown[]): void { + console.log(chalk.blue('[LOG]'), ...args); + } + + public static error(...args: unknown[]): void { + console.error(chalk.red('[ERROR]'), ...args); + } + + public static warn(...args: unknown[]): void { + console.warn(chalk.yellow('[WARN]'), ...args); + } + + public static info(...args: unknown[]): void { + console.info(chalk.green('[INFO]'), ...args); + } + + public static debug(...args: unknown[]): void { + console.debug(chalk.cyan('[DEBUG]'), ...args); + } +} diff --git a/src/NpcControl.ts b/src/NpcControl.ts new file mode 100644 index 0000000..67d4384 --- /dev/null +++ b/src/NpcControl.ts @@ -0,0 +1,476 @@ +import { GBufferReader, GBufferWriter } from './GBuffer'; +import { ProtocolGen } from './GProtocol'; +import { GSocket } from './GSocket'; +import { PacketTable } from './PacketTable'; +import { PromiseManager } from './PromiseManager'; +import { gtokenize, guntokenize } from './utils'; +import { NPC, NPCManager, NPCPropID } from './misc/npcs'; +import { NCIncomingPacket, NCOutgoingPacket } from './misc/packet'; +import { NCEvents, NCInterface, ServerlistConfig } from './types'; + +enum UriConstants { + NpcPrefix = 'npcserver://npcs/', + ScriptPrefix = 'npcserver://scripts/', + WeaponPrefix = 'npcserver://weapons/', + WeaponList = 'npcserver://weapons', + LevelList = 'npcserver://levellist', +} + +enum ErrorMsg { + NotFound = 'Resource not found', +} + +interface NpcControlConfig { + host: string; + port: number; +} + +export class NPCControl implements NCInterface { + private sock?: GSocket; + private readonly packetTable: PacketTable; + private readonly eventHandler: NCEvents; + + private promiseMngr: PromiseManager = new PromiseManager(); + private npcMngr: NPCManager = new NPCManager(); + private classList: Set = new Set(); + + public get classes(): Set { + return this.classList; + } + + public get npcs(): NPC[] { + return this.npcMngr.npcs; + } + + constructor( + private readonly config: ServerlistConfig, + ncConfig: NpcControlConfig, + eventHandler: NCEvents, + ) { + this.eventHandler = eventHandler; + this.packetTable = this.initializeHandlers(); + this.connect(ncConfig.host, ncConfig.port); + } + + public connect(host: string, port: number): boolean { + if (this.sock) { + return false; + } + + this.sock = GSocket.connect(host, port, { + connectCallback: () => this.onConnect(), + disconnectCallback: () => this.onDisconnect(), + packetTable: this.packetTable, + }); + + return true; + } + + public disconnect(): void { + this.sock?.disconnect(); + } + + //////////////////// + + private onConnect(): void { + console.log('[NC] Connected!'); + + if (!this.sock) { + console.log('[NC] no sock?'); + return; + } + + // Send login packet + let nb = GBufferWriter.create(); + nb.writeChars('NCL21075'); + nb.writeGString(this.config.account); + nb.writeGString(this.config.password); + this.sock.sendData(this.sock.sendPacket(3, nb.buffer)); + + this.sock.setProtocol(ProtocolGen.Gen3, 0); + + // The only proof that you passed verification, is the fact that you are + // still connected to the server. + this.eventHandler.onNCConnected?.(); + } + + private onDisconnect(): void { + console.log('[NC] Disconnected!'); + + if (!this.sock) { + console.log('[NC] no sock?'); + return; + } + + this.eventHandler.onNCDisconnected?.(); + } + + //////////////////// + + requestLevelList(): Promise { + this.sock?.sendData( + this.sock.sendPacket(NCOutgoingPacket.PLI_NC_LEVELLISTGET), + ); + return this.promiseMngr.createPromise(UriConstants.LevelList); + } + + deleteWeapon(name: string): void { + this.sock?.sendData( + this.sock.sendPacket( + NCOutgoingPacket.PLI_NC_WEAPONDELETE, + Buffer.from(name), + ), + ); + } + + requestWeaponList(): Promise> { + this.sock?.sendData( + this.sock.sendPacket(NCOutgoingPacket.PLI_NC_WEAPONLISTGET), + ); + return this.promiseMngr.createPromise(UriConstants.WeaponList); + } + + requestWeapon(name: string): Promise<[string, string]> { + this.sock?.sendData( + this.sock.sendPacket( + NCOutgoingPacket.PLI_NC_WEAPONGET, + Buffer.from(name), + ), + ); + return this.promiseMngr.createPromise(UriConstants.WeaponPrefix + name); + } + + setWeaponScript(name: string, image: string, script: string): void { + // Weapons are sent by replacing newlines with \xa7 character + script = script.replace(/\n/g, 'ยง'); + script = script.replace(/\r/g, ''); + + const writer = GBufferWriter.create( + 1 + name.length + 1 + image.length + script.length, + ); + writer.writeGString(name); + writer.writeGString(image); + writer.writeChars(script); + this.sock?.sendData( + this.sock.sendPacket( + NCOutgoingPacket.PLI_NC_WEAPONADD, + writer.buffer, + ), + ); + } + + deleteNpc(name: string): void { + throw new Error('Method not implemented.'); + } + + requestNpcAttributes(name: string): Promise { + const npcObject = this.npcMngr.findNPC(name); + if (npcObject) { + const nb = GBufferWriter.create(3); + nb.writeGUInt24(npcObject.id); + this.sock?.sendData( + this.sock.sendPacket(NCOutgoingPacket.PLI_NC_NPCGET, nb.buffer), + ); + return this.promiseMngr.createPromise( + UriConstants.NpcPrefix + name + '.attrs', + ); + } + + return Promise.reject(ErrorMsg.NotFound); + } + + requestNpcFlags(name: string): Promise { + const npcObject = this.npcMngr.findNPC(name); + if (npcObject) { + const nb = GBufferWriter.create(3); + nb.writeGUInt24(npcObject.id); + this.sock?.sendData( + this.sock.sendPacket( + NCOutgoingPacket.PLI_NC_NPCFLAGSGET, + nb.buffer, + ), + ); + return this.promiseMngr.createPromise( + UriConstants.NpcPrefix + name + '.flags', + ); + } + + return Promise.reject(ErrorMsg.NotFound); + } + + requestNpcScript(name: string): Promise { + const npcObject = this.npcMngr.findNPC(name); + if (npcObject) { + const nb = GBufferWriter.create(3); + nb.writeGUInt24(npcObject.id); + this.sock?.sendData( + this.sock.sendPacket( + NCOutgoingPacket.PLI_NC_NPCSCRIPTGET, + nb.buffer, + ), + ); + return this.promiseMngr.createPromise( + UriConstants.NpcPrefix + name + '.script', + ); + } + + return Promise.reject(ErrorMsg.NotFound); + } + + setNpcFlags(name: string, script: string): void { + const npcObject = this.npcMngr.findNPC(name); + if (npcObject) { + const nb = GBufferWriter.create(3); + nb.writeGUInt24(npcObject.id); + nb.writeChars(gtokenize(script)); + this.sock?.sendData( + this.sock.sendPacket( + NCOutgoingPacket.PLI_NC_NPCFLAGSSET, + nb.buffer, + ), + ); + } + } + + setNpcScript(name: string, script: string): void { + const npcObject = this.npcMngr.findNPC(name); + if (npcObject) { + const nb = GBufferWriter.create(3); + nb.writeGUInt24(npcObject.id); + nb.writeChars(gtokenize(script)); + this.sock?.sendData( + this.sock.sendPacket( + NCOutgoingPacket.PLI_NC_NPCSCRIPTSET, + nb.buffer, + ), + ); + } + } + + deleteClass(name: string): void { + this.sock?.sendData( + this.sock.sendPacket( + NCOutgoingPacket.PLI_NC_CLASSDELETE, + Buffer.from(name), + ), + ); + } + + requestClass(name: string): Promise { + this.sock?.sendData( + this.sock.sendPacket( + NCOutgoingPacket.PLI_NC_CLASSEDIT, + Buffer.from(name), + ), + ); + return this.promiseMngr.createPromise(UriConstants.ScriptPrefix + name); + } + + setClassScript(name: string, script: string): void { + const nb = GBufferWriter.create(); + nb.writeGString(name); + nb.writeChars(gtokenize(script)); + this.sock?.sendData( + this.sock.sendPacket(NCOutgoingPacket.PLI_NC_CLASSADD, nb.buffer), + ); + } + + private initializeHandlers(): PacketTable { + const packetTable = new PacketTable(); + + packetTable.setDefault((id: number, packet: Buffer): void => { + if (id !== 42) { + console.log( + `[NC] Unhandled Packet (${id}): ${packet + .toString() + .replace(/\r/g, '')}`, + ); + } + }); + + packetTable.on( + NCIncomingPacket.PLO_NEWWORLDTIME, + (id: number, packet: Buffer): void => { + const reader = GBufferReader.from(packet); + const serverTime = reader.readGUInt32(); + }, + ); + + packetTable.on( + NCIncomingPacket.PLO_RCCHAT, + (id: number, packet: Buffer): void => { + const msg = packet.toString(); + this.eventHandler.onNCChat?.(msg); + }, + ); + + packetTable.on( + NCIncomingPacket.PLO_NC_LEVELLIST, + (id: number, packet: Buffer): void => { + const levelList = guntokenize(packet.toString()); + this.promiseMngr.resolvePromise( + UriConstants.LevelList, + levelList, + ); + }, + ); + + packetTable.on( + NCIncomingPacket.PLO_NC_NPCATTRIBUTES, + (id: number, packet: Buffer): void => { + const text = guntokenize(packet.toString()); + const name = text + .substring( + 'Variable dump from npc '.length + 1, + text.indexOf('\n'), + ) + .trimEnd(); + + this.promiseMngr.resolvePromise( + UriConstants.NpcPrefix + name + '.attrs', + text, + ); + }, + ); + + packetTable.on( + NCIncomingPacket.PLO_NC_NPCADD, + (id: number, packet: Buffer): void => { + const reader = GBufferReader.from(packet); + + const npcId = reader.readGUInt24(); + const npcObj = this.npcMngr.getNpc(npcId); + npcObj.setProps(reader); + + this.eventHandler.onNpcAdded?.( + npcObj.props[NPCPropID.NPCPROP_NAME] as string, + ); + // console.log(`NPC Added: ${npcObj.props[NPCPropID.NPCPROP_NAME]}`); + }, + ); + + packetTable.on( + NCIncomingPacket.PLO_NC_NPCDELETE, + (id: number, packet: Buffer): void => { + const reader = GBufferReader.from(packet); + + const npcId = reader.readGUInt24(); + // this.npcMngr.deleteNpc(npcId); + + const npcObj = this.npcMngr.getNpc(npcId); + const npcName = npcObj.props[NPCPropID.NPCPROP_NAME] as string; + + if (this.npcMngr.deleteNpc(npcId)) { + this.eventHandler.onNpcDeleted?.(npcName); + console.log(`Delete npc ${npcName}`); + } + }, + ); + + packetTable.on( + NCIncomingPacket.PLO_NC_NPCSCRIPT, + (id: number, packet: Buffer): void => { + const reader = GBufferReader.from(packet); + const npcId = reader.readGUInt24(); + const text = guntokenize(reader.readChars(reader.bytesLeft)); + + const npcObj = this.npcMngr.getNpc(npcId); + if (npcObj) { + const npcName = npcObj.props[ + NPCPropID.NPCPROP_NAME + ] as string; + this.promiseMngr.resolvePromise( + UriConstants.NpcPrefix + npcName + '.script', + text, + ); + } + }, + ); + + packetTable.on( + NCIncomingPacket.PLO_NC_NPCFLAGS, + (id: number, packet: Buffer): void => { + const reader = GBufferReader.from(packet); + const npcId = reader.readGUInt24(); + const text = guntokenize(reader.readChars(reader.bytesLeft)); + + const npcObj = this.npcMngr.getNpc(npcId); + if (npcObj) { + const npcName = npcObj.props[ + NPCPropID.NPCPROP_NAME + ] as string; + this.promiseMngr.resolvePromise( + UriConstants.NpcPrefix + npcName + '.flags', + text, + ); + } + }, + ); + + packetTable.on( + NCIncomingPacket.PLO_NC_CLASSGET, + (id: number, packet: Buffer): void => { + const reader = GBufferReader.from(packet); + const name = reader.readGString(); + const script = guntokenize(reader.readChars(reader.bytesLeft)); + + this.promiseMngr.resolvePromise( + UriConstants.ScriptPrefix + name, + script, + ); + }, + ); + + packetTable.on( + NCIncomingPacket.PLO_NC_CLASSADD, + (id: number, packet: Buffer): void => { + const className = packet.toString(); + this.classList.add(className); + }, + ); + + packetTable.on( + NCIncomingPacket.PLO_NC_CLASSDELETE, + (id: number, packet: Buffer): void => { + const className = packet.toString(); + this.classList.delete(className); + }, + ); + + packetTable.on( + NCIncomingPacket.PLO_NC_WEAPONGET, + (id: number, packet: Buffer): void => { + const reader = GBufferReader.from(packet); + const name = reader.readGString(); + const image = reader.readGString(); + + let script = reader.readChars(reader.bytesLeft); + script = script.replace(/\xa7/g, '\n'); + + this.promiseMngr.resolvePromise<[string, string]>( + UriConstants.WeaponPrefix + name, + [image, script], + ); + }, + ); + + packetTable.on( + NCIncomingPacket.PLO_NC_WEAPONLISTGET, + (id: number, packet: Buffer): void => { + const weaponList: Set = new Set(); + + const reader = GBufferReader.from(packet); + while (reader.bytesLeft) { + weaponList.add(reader.readGString()); + } + + this.promiseMngr.resolvePromise( + UriConstants.WeaponList, + weaponList, + ); + }, + ); + + return packetTable; + } +} diff --git a/src/PacketTable.ts b/src/PacketTable.ts new file mode 100644 index 0000000..b9d07d9 --- /dev/null +++ b/src/PacketTable.ts @@ -0,0 +1,32 @@ +export type PacketHandler = (id: number, packet: Buffer) => void; + +export class PacketTable { + static readonly ALL_HANDLER = -1; + static readonly DEFAULT_HANDLER = -2; + + private handlers: PacketHandler[][] = []; + + public getCallbacks(id: number): PacketHandler[] { + let callbacks = (this.handlers[PacketTable.ALL_HANDLER] || []).concat( + this.handlers[id] || [], + ); + if (callbacks.length == 0) + callbacks = this.handlers[PacketTable.DEFAULT_HANDLER] || callbacks; + + return callbacks; + } + + public setCatchAll(callback: PacketHandler) { + this.on(-1, callback); + } + + public setDefault(callback: PacketHandler) { + this.on(-2, callback); + } + + public on(id: number, callback: PacketHandler) { + if (!(id in this.handlers)) this.handlers[id] = []; + + this.handlers[id].push(callback); + } +} diff --git a/src/PromiseManager.ts b/src/PromiseManager.ts new file mode 100644 index 0000000..599071a --- /dev/null +++ b/src/PromiseManager.ts @@ -0,0 +1,44 @@ +const timedOutMessage: string = 'Timed out'; + +export interface PromiseData { + resolve(val: T | PromiseLike): void; + reject(reason?: any): void; +} + +export class PromiseManager { + private promiseData: { [uri: string]: PromiseData } = {}; + + createPromise(uri: string, timeout: number = 10): Promise { + return new Promise((resolve, reject) => { + this.promiseData[uri] = { + resolve: resolve, + reject: reject, + }; + + if (timeout) { + setTimeout(() => reject(timedOutMessage), timeout * 1000); + } + }); + } + + resolvePromise(uri: string, data: Type): void { + if (uri in this.promiseData) { + this.promiseData[uri].resolve(data); + delete this.promiseData[uri]; + } + } + + rejectPromise(uri: string, data: Type): void { + if (uri in this.promiseData) { + this.promiseData[uri].reject(data); + delete this.promiseData[uri]; + } + } + + reset() { + for (const val of Object.values(this.promiseData)) { + val.reject(); + } + this.promiseData = {}; + } +} diff --git a/src/RemoteControl.ts b/src/RemoteControl.ts new file mode 100644 index 0000000..de7bf51 --- /dev/null +++ b/src/RemoteControl.ts @@ -0,0 +1,274 @@ +import { networkInterfaces } from 'os'; +import { FileBrowser, RCFileBrowser } from './FileBrowser'; +import { GBufferWriter } from './GBuffer'; +import { GProtocol, ProtocolGen } from './GProtocol'; +import { GSocket } from './GSocket'; +import { Logger } from './Logger'; +import { NPCControl } from './NpcControl'; +import { PacketTable } from './PacketTable'; +import { PromiseManager } from './PromiseManager'; +import { + NCEvents, + NCInterface, + RCEvents, + RCInterface, + ServerEntry, + ServerlistConfig, +} from './types'; +import { hashCode } from './utils'; +import { PlayerProperties, RCOutgoingPacket } from './misc/packet'; + +enum UriConstants { + FolderConfig = 'gserver://config/folderconfig', + ServerFlags = 'gserver://config/serverflags', + ServerOptions = 'gserver://config/serveroptions', +} + +interface RemoteControlEvents extends NCEvents, RCEvents {} + +export class RemoteControl implements RCInterface { + private readonly config: ServerlistConfig; + public readonly server: ServerEntry; + private readonly packetTable: PacketTable; + private readonly fileBrowser: RCFileBrowser; + private readonly promiseManager: PromiseManager = new PromiseManager(); + private readonly eventHandler: RemoteControlEvents; + + private sock?: GSocket; + private npcControl?: NPCControl; + private disconnectMsg?: string; + private maxUploadSize = 0; + + public get socket(): GSocket | undefined { + return this.sock; + } + + public get FileBrowser(): FileBrowser { + return this.fileBrowser; + } + + public get NpcControl(): NCInterface | undefined { + return this.npcControl; + } + + public get maxUploadFileSize(): number { + return this.maxUploadSize; + } + + constructor( + config: ServerlistConfig, + server: ServerEntry, + eventHandler: RemoteControlEvents, + ) { + this.config = config; + this.server = server; + this.eventHandler = eventHandler; + this.fileBrowser = new RCFileBrowser(this); + + this.packetTable = this.initializeHandlers(); + this.connect(server.ip, server.port); + } + + public isConnected(): boolean { + return !!this.sock; + } + + public connect(host: string, port: number): boolean { + if (this.sock) return false; + + this.sock = GSocket.connect(host, port, { + connectCallback: () => this.onConnect(), + disconnectCallback: (err?: Error) => this.onDisconnect(err), + packetTable: this.packetTable, + }); + + return true; + } + + private onConnect(): void { + Logger.log( + `RC Connected to ${this.server.ip}:${this.server.port} (${this.server.name})`, + ); + + if (!this.sock) return; + + this.sendLoginPacket(); + } + + private onDisconnect(err?: Error): void { + this.sock = undefined; + + if (this.npcControl) { + this.npcControl.disconnect(); + this.npcControl = undefined; + } + + this.eventHandler.onRCDisconnected?.( + this, + this.disconnectMsg || err?.message, + ); + + Logger.warn('RC Disconnected'); + } + + private requestNpcServer(): void { + const writer = GBufferWriter.create(2 + 'location'.length); + writer.writeGUInt16(2); // 2 = NpcServer + writer.writeChars('location'); + this.sock?.sendData( + this.sock?.sendPacket(RCOutgoingPacket.PLI_RC_FILEBROWSER_START), + ); + } + + private connectToNpcServer(host: string, port: number): NPCControl | null { + if (this.npcControl) { + this.npcControl.disconnect(); + this.npcControl = undefined; + } + + this.npcControl = new NPCControl( + this.config, + { + host: host, + port: port, + }, + this.eventHandler, + ); + + return this.npcControl; + } + + requestFolderConfig(): Promise { + this.sock?.sendData( + this.sock.sendPacket(RCOutgoingPacket.PLI_RC_FOLDERCONFIGGET), + ); + return this.promiseManager.createPromise(UriConstants.FolderConfig); + } + + requestServerFlags(): Promise { + this.sock?.sendData( + this.sock.sendPacket(RCOutgoingPacket.PLI_RC_SERVERFLAGSGET), + ); + return this.promiseManager.createPromise(UriConstants.ServerFlags); + } + + requestServerOptions(): Promise { + this.sock?.sendData( + this.sock.sendPacket(RCOutgoingPacket.PLI_RC_SERVEROPTIONSGET), + ); + return this.promiseManager.createPromise(UriConstants.ServerOptions); + } + + sendRCChat(msg: string): void { + this.sock?.sendData( + this.sock.sendPacket(RCOutgoingPacket.PLI_RCCHAT, Buffer.from(msg)), + ); + } + + setNickName(name: string): void { + const writer = GBufferWriter.create(2 + name.length); + writer.writeGUInt8(PlayerProperties.PLPROP_NICKNAME); + writer.writeGString(name); + this.sock?.sendData( + this.sock.sendPacket( + RCOutgoingPacket.PLI_PLAYERPROPS, + writer.buffer, + ), + ); + } + + initializeHandlers(): PacketTable { + const packetTable = new PacketTable(); + + packetTable.setDefault((id: number, packet: Buffer): void => { + // Logger.error( + // `[RC] Unhandled Packet (${id}): ${packet + // .toString() + // .replace(/\r/g, '')}`, + // ); + }); + + // RC Messages + packetTable.on(74, (id: number, packet: Buffer): void => { + // console.log(`[RC] ${packet.toString()}`); + this.eventHandler.onRCEventMsg?.(packet.toString()); + }); + + // This is just a heartbeat packet, we can ignore it + packetTable.on(42, (): void => {}); + + return packetTable; + } + + private sendLoginPacket(): void { + if (!this.sock) return; + + const key = GProtocol.generateKey(); + const writer = GBufferWriter.create(); + + writer.writeGUInt8(key); + writer.writeChars('GSERV025'); + writer.writeGString(this.config.account); + writer.writeGString(this.config.password); + writer.writeChars('mac,'); + + const getComputerCode = (computerHash: number) => { + const hexCode = [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + ]; + + let stringBuilder = ''; + for (let i = 0; i < 32; i++) { + stringBuilder += hexCode[Math.abs(computerHash % 16)]; + computerHash *= 31; + } + + return stringBuilder; + }; + const getComputerHash = (): number => { + const nets = networkInterfaces(); + const interfaces = Object.values(nets).flat().filter(Boolean); + const macAddress = interfaces.find( + (iface) => iface && iface.mac !== '00:00:00:00:00:00', + )?.mac; + + return macAddress + ? hashCode(macAddress.split(':').join('')) + : Math.random() * 100000; + }; + + let computerCodeString; + const computerHash = getComputerHash(); + computerCodeString = getComputerCode( + computerHash + hashCode(this.config.account), + ); + + writer.writeChars(computerCodeString); + writer.writeChars(','); + writer.writeChars(computerCodeString); + writer.writeChars(','); + writer.writeChars('""'); + + this.sock.sendData(this.sock.sendPacket(6, writer.buffer)); + this.sock.setProtocol(ProtocolGen.Gen5, key); + } + + public disconnect(): void { + this.sock?.disconnect(); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..60a9cb2 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,61 @@ +import { config } from 'dotenv'; +import { ServerEntry, ServerlistConfig } from './types'; +import { Serverlister } from './serverLister'; +import { Logger } from './Logger'; +import { RemoteControl } from './RemoteControl'; +import axios from 'axios'; + +const env = config({ path: './.env' }); + +async function main() { + const config: ServerlistConfig = { + host: 'listserver.graalonline.com', + port: 14922, + account: env.parsed?.ACCOUNT || '', + password: env.parsed?.PASSWORD || '', + nickname: 'Ruan', + }; + + try { + const servers = await Serverlister.request(config); + const zodiacDev = servers.find((s: ServerEntry) => s.name === 'Zodiac'); + + if (!zodiacDev) { + Logger.error('Zodiac Dev server not found.'); + return; + } + + new RemoteControl(config, zodiacDev, { + onRCConnected: (instance) => { + Logger.debug('Connected to RC'); + }, + onRCDisconnected: (instance, text) => { + Logger.warn('Disconnected from RC:', text); + }, + onRCChat: (text) => { + Logger.info('RC Chat:', text); + }, + onRCEventMsg: (text) => { + if (!env.parsed?.RC_LOG_DISCORD_WEBHOOK) return; + try { + axios + .post(env.parsed?.RC_LOG_DISCORD_WEBHOOK, { + content: '`' + text + '`', + }) + .catch((error) => { + Logger.error( + 'Error sending message to Discord:', + error, + ); + }); + } catch (error) { + Logger.error('Error:', error); + } + }, + }); + } catch (error) { + console.error('Error:', error); + } +} + +main(); diff --git a/src/misc/npcs.ts b/src/misc/npcs.ts new file mode 100644 index 0000000..247115a --- /dev/null +++ b/src/misc/npcs.ts @@ -0,0 +1,78 @@ +import { GBufferReader } from '../GBuffer'; + +type PropData = number | string; + +export enum NPCPropID { + NPCPROP_ID = 17, + NPCPROP_SCRIPTER = 49, + NPCPROP_NAME = 50, + NPCPROP_TYPE = 51, + NPCPROP_CURLEVEL = 52, +} + +export class NPCManager { + private readonly npcList: { [key: number]: NPC } = {}; + + public get npcs(): NPC[] { + return Object.values(this.npcList); //.filter((v) => NPCPropID.NPCPROP_NAME in v.props); + } + + public deleteNpc(id: number): boolean { + if (id in this.npcList) { + delete this.npcList[id]; + return true; + } + + return false; + } + + public getNpc(id: number, create: boolean = true): NPC { + if (create && !(id in this.npcList)) { + this.npcList[id] = new NPC(id); + } + + return this.npcList[id] || undefined; + } + + public findNPC(name: string): NPC | undefined { + for (const [k, v] of Object.entries(this.npcList)) { + if (v.getProp(NPCPropID.NPCPROP_NAME) === name) { + return v; + } + } + } +} + +export class NPC { + props: { [key: number]: PropData } = {}; + + constructor(public readonly id: number) { + this.props[NPCPropID.NPCPROP_ID] = id; + } + + getProp(id: number): PropData { + return this.props[id]; + } + + setProps(reader: GBufferReader) { + while (reader.bytesLeft) { + const propId: NPCPropID = reader.readGUInt8(); + switch (propId) { + case NPCPropID.NPCPROP_ID: + this.props[propId] = reader.readGUInt16(); + break; + + case NPCPropID.NPCPROP_SCRIPTER: + case NPCPropID.NPCPROP_NAME: + case NPCPropID.NPCPROP_TYPE: + case NPCPropID.NPCPROP_CURLEVEL: + this.props[propId] = reader.readGString(); + break; + + default: + console.log(`Unhandled NPC Property: ${NPCPropID}`); + return; + } + } + } +} diff --git a/src/misc/packet.ts b/src/misc/packet.ts new file mode 100644 index 0000000..1c1f64d --- /dev/null +++ b/src/misc/packet.ts @@ -0,0 +1,139 @@ +export enum RCIncomingPacket { + PLO_DISCMESSAGE = 16, + PLO_SIGNATURE = 25, + PLO_FILESENDFAILED = 30, + PLO_NEWWORLDTIME = 42, + PLO_RC_ACCOUNTADD = 50, // Deprecated. Codr note: Unhandled by 6.037. + PLO_RC_ACCOUNTSTATUS = 51, // Deprecated. Codr note: Unhandled by 6.037. + PLO_RC_ACCOUNTNAME = 52, // Deprecated. Codr note: Unhandled by 6.037. + PLO_RC_ACCOUNTDEL = 53, // Deprecated. Codr note: Unhandled by 6.037. + PLO_RC_ACCOUNTPROPS = 54, // Deprecated. Codr note: Unhandled by 6.037. + PLO_ADDPLAYER = 55, // Unhandled by 5.07+. -Codr + PLO_DELPLAYER = 56, + PLO_RC_ACCOUNTPROPSGET = 57, // Deprecated. Codr note: Unhandled by 6.037. + PLO_RC_ACCOUNTCHANGE = 58, // Deprecated. Codr note: Unhandled by 6.037. + PLO_RC_PLAYERPROPSCHANGE = 59, // Deprecated. Codr note: Unhandled by 6.037. + PLO_UNKNOWN60 = 60, // Unhandled by 5.07+. -Codr + PLO_RC_SERVERFLAGSGET = 61, // Unhandled by 5.07+. -Codr + PLO_RC_PLAYERRIGHTSGET = 62, // Unhandled by 5.07+. -Codr + PLO_RC_PLAYERCOMMENTSGET = 63, // Unhandled by 5.07+. -Codr + PLO_RC_PLAYERBANGET = 64, // Unhandled by 5.07+. -Codr + PLO_RC_FILEBROWSER_DIRLIST = 65, // Unhandled by 5.07+. -Codr + PLO_RC_FILEBROWSER_DIR = 66, // Unhandled by 5.07+. -Codr + PLO_RC_FILEBROWSER_MESSAGE = 67, + PLO_LARGEFILESTART = 68, + PLO_LARGEFILEEND = 69, + PLO_RC_ACCOUNTLISTGET = 70, // Unhandled by 5.07+. -Codr + PLO_RC_PLAYERPROPS = 71, // Deprecated. Codr note: Unhandled by 6.037. + PLO_RC_PLAYERPROPSGET = 72, // Unhandled by 5.07+. -Codr + PLO_RC_ACCOUNTGET = 73, + PLO_RCCHAT = 74, + PLO_RC_SERVEROPTIONSGET = 76, + PLO_RC_FOLDERCONFIGGET = 77, + PLO_NPCSERVERADDR = 79, + PLO_LARGEFILESIZE = 84, + PLO_RAWDATA = 100, + PLO_FILE = 102, + PLO_RC_MAXUPLOADFILESIZE = 103, +} + +export enum RCOutgoingPacket { + PLI_PLAYERPROPS = 2, + PLI_RAWDATA = 50, + PLI_RC_SERVEROPTIONSGET = 51, + PLI_RC_SERVEROPTIONSSET = 52, + PLI_RC_FOLDERCONFIGGET = 53, + PLI_RC_FOLDERCONFIGSET = 54, + PLI_RC_RESPAWNSET = 55, + PLI_RC_HORSELIFESET = 56, + PLI_RC_APINCREMENTSET = 57, + PLI_RC_BADDYRESPAWNSET = 58, + PLI_RC_PLAYERPROPSGET = 59, + PLI_RC_PLAYERPROPSSET = 60, + PLI_RC_DISCONNECTPLAYER = 61, + PLI_RC_UPDATELEVELS = 62, + PLI_RC_ADMINMESSAGE = 63, + PLI_RC_PRIVADMINMESSAGE = 64, + PLI_RC_LISTRCS = 65, + PLI_RC_DISCONNECTRC = 66, + PLI_RC_APPLYREASON = 67, + PLI_RC_SERVERFLAGSGET = 68, + PLI_RC_SERVERFLAGSSET = 69, + PLI_RC_ACCOUNTADD = 70, + PLI_RC_ACCOUNTDEL = 71, + PLI_RC_ACCOUNTLISTGET = 72, + PLI_RC_PLAYERPROPSGET2 = 73, + PLI_RC_PLAYERPROPSGET3 = 74, + PLI_RC_PLAYERPROPSRESET = 75, + PLI_RC_PLAYERPROPSSET2 = 76, + PLI_RC_ACCOUNTGET = 77, + PLI_RC_ACCOUNTSET = 78, + PLI_RCCHAT = 79, + PLI_RC_WARPPLAYER = 82, + PLI_RC_PLAYERRIGHTSGET = 83, + PLI_RC_PLAYERRIGHTSSET = 84, + PLI_RC_PLAYERCOMMENTSGET = 85, + PLI_RC_PLAYERCOMMENTSSET = 86, + PLI_RC_PLAYERBANGET = 87, + PLI_RC_PLAYERBANSET = 88, + PLI_RC_FILEBROWSER_START = 89, + PLI_RC_FILEBROWSER_CD = 90, + PLI_RC_FILEBROWSER_END = 91, + PLI_RC_FILEBROWSER_DOWN = 92, + PLI_RC_FILEBROWSER_UP = 93, + PLI_NPCSERVERQUERY = 94, + PLI_RC_FILEBROWSER_MOVE = 96, + PLI_RC_FILEBROWSER_DELETE = 97, + PLI_RC_FILEBROWSER_RENAME = 98, + PLI_RC_LARGEFILESTART = 155, + PLI_RC_LARGEFILEEND = 156, + PLI_RC_FOLDERDELETE = 160, + PLI_RC_UNKNOWN162 = 162 +} + +export enum NCIncomingPacket { + PLO_NEWWORLDTIME = 42, + PLO_RCCHAT = 74, + PLO_NC_CONTROL = 78, + PLO_NC_LEVELLIST = 80, + PLO_NC_NPCATTRIBUTES = 157, + PLO_NC_NPCADD = 158, + PLO_NC_NPCDELETE = 159, + PLO_NC_NPCSCRIPT = 160, + PLO_NC_NPCFLAGS = 161, + PLO_NC_CLASSGET = 162, + PLO_NC_CLASSADD = 163, + PLO_NC_LEVELDUMP = 164, + PLO_NC_WEAPONLISTGET = 167, + PLO_NC_CLASSDELETE = 188, + PLO_NC_WEAPONGET = 192 +} + +export enum NCOutgoingPacket { + PLI_NC_NPCGET = 103, + PLI_NC_NPCDELETE = 104, + PLI_NC_NPCRESET = 105, + PLI_NC_NPCSCRIPTGET = 106, + PLI_NC_NPCWARP = 107, + PLI_NC_NPCFLAGSGET = 108, + PLI_NC_NPCSCRIPTSET = 109, + PLI_NC_NPCFLAGSSET = 110, + PLI_NC_NPCADD = 111, + PLI_NC_CLASSEDIT = 112, + PLI_NC_CLASSADD = 113, + PLI_NC_LOCALNPCSGET = 114, + PLI_NC_WEAPONLISTGET = 115, + PLI_NC_WEAPONGET = 116, + PLI_NC_WEAPONADD = 117, + PLI_NC_WEAPONDELETE = 118, + PLI_NC_CLASSDELETE = 119, + PLI_NC_LEVELLISTGET = 150, + PLI_NC_LEVELLISTSET = 151, +} + +export enum PlayerProperties { + PLPROP_NICKNAME = 0, + PLPROP_ID = 14, + PLPROP_ADDITFLAGS = 33, + PLPROP_PSTATUSMSG = 53 +} \ No newline at end of file diff --git a/src/serverLister.ts b/src/serverLister.ts new file mode 100644 index 0000000..8a11759 --- /dev/null +++ b/src/serverLister.ts @@ -0,0 +1,159 @@ +import { GBufferReader, GBufferWriter } from './GBuffer'; +import { GProtocol, ProtocolGen } from './GProtocol'; +import { GSocket } from './GSocket'; +import { PacketTable } from './PacketTable'; +import { ServerCategory, ServerEntry, ServerlistConfig } from './types'; + +function determineServerType(serverName: string): [string, ServerCategory] { + switch (serverName.substring(0, 2)) { + case 'H ': + return [serverName.substring(2).trimLeft(), ServerCategory.hosted]; + + case 'P ': + return [serverName.substring(2).trimLeft(), ServerCategory.gold]; + + case 'U ': + return [serverName.substring(2).trimLeft(), ServerCategory.hidden]; + + case '3 ': + return [serverName.substring(2).trimLeft(), ServerCategory.g3d]; + } + + return [serverName, ServerCategory.classic]; +} + +export class Serverlister { + private readonly config: ServerlistConfig = { + host: 'listserver.graal.in', + port: 14922, + account: '', + password: '', + nickname: 'unknown', + }; + + private packetTable: PacketTable; + + private sock?: GSocket; + + private resolvePromise: + | ((value: ServerEntry[] | PromiseLike) => void) + | undefined; + // private rejectPromise: ((reason?: any) => void) | undefined; + + constructor(config: Partial) { + this.config = { ...this.config, ...config }; + this.packetTable = this.initializeHandlers(); + } + + public static request( + config: Partial, + ): Promise { + return new Promise(function (resolve, reject) { + const serverList = new Serverlister(config); + serverList.resolvePromise = resolve; + + serverList.packetTable.on(4, (id: number, packet: Buffer): void => { + const discMsg = packet.toString(); + reject(discMsg); + }); + + serverList.sock = GSocket.connect( + serverList.config.host, + serverList.config.port, + { + connectCallback: () => serverList.sendLoginPacket(), + disconnectCallback: (err: any) => reject(err), + packetTable: serverList.packetTable, + }, + ); + }); + } + + private disconnect() { + if (this.sock) { + this.sock.disconnect(); + this.sock = undefined; + } + } + + private sendLoginPacket(): void { + if (!this.sock) return; + + let nb = GBufferWriter.create(); + + // Handshake packet + let someKey = GProtocol.generateKey(); + nb.writeGUInt8(someKey); + nb.writeChars('G3D30123'); + nb.writeChars('rc2'); + this.sock.sendData(this.sock.sendPacket(7, nb.buffer)); + this.sock.setProtocol(ProtocolGen.Gen5, someKey); + + // Send credentials + nb.clear(); + nb.writeGString(this.config.account); + nb.writeGString(this.config.password); + + this.sock.sendData(this.sock.sendPacket(1, nb.buffer)); + } + + private initializeHandlers() { + const packetTable = new PacketTable(); + + // packetTable.setCatchAll((id: number, packet: Buffer): void => { + // console.log(`Unhandled Packet (${id}): `, packet); + // }); + + packetTable.on(0, (id: number, packet: Buffer): void => { + let internalBuf = GBufferReader.from(packet); + let serverCount = internalBuf.readGUInt8(); + + let servers = []; + for (let i = 0; i < serverCount; i++) { + internalBuf.readGUInt8(); + + const serverName = internalBuf.readGString(); + const serverTypeData = determineServerType(serverName); + + const entry: ServerEntry = { + name: serverTypeData[0], + category: serverTypeData[1], + language: internalBuf.readGString(), + description: internalBuf.readGString(), + url: internalBuf.readGString(), + version: internalBuf.readGString(), + pcount: ~~internalBuf.readGString(), + ip: internalBuf.readGString(), + port: ~~internalBuf.readGString(), + }; + + servers.push(entry); + } + + if (this.resolvePromise) { + this.resolvePromise(servers); + this.resolvePromise = undefined; + + this.disconnect(); + } + }); + + // packetTable.on(2, (id: number, packet: Buffer): void => { + // let statusMsg = packet.toString(); + // console.log(`Status Msg: ${statusMsg}`); + // }); + + // packetTable.on(3, (id: number, packet: Buffer): void => { + // let siteUrl = packet.toString(); + // console.log(`Website: ${siteUrl}`); + // }); + + // packetTable.on(4, (id: number, packet: Buffer): void => { + // let discMsg = packet.toString(); + // console.log(`Disconnected for ${discMsg}`); + // this.rejectPromise(discMsg); + // }); + + return packetTable; + } +} diff --git a/src/tempCodeRunnerFile.ts b/src/tempCodeRunnerFile.ts new file mode 100644 index 0000000..4af04bb --- /dev/null +++ b/src/tempCodeRunnerFile.ts @@ -0,0 +1,5 @@ + Logger.error( + `[RC] Unhandled Packet (${id}): ${packet + .toString() + .replace(/\r/g, '')}`, + ); \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..9216d6a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,102 @@ +import { NPC } from './misc/npcs'; + +export enum ServerCategory { + classic = 0, + gold = 1, + hosted = 2, + hidden = 3, + g3d = 4, +} + +export interface ServerEntry { + name: string; + category: ServerCategory; + language: string; + description: string; + url: string; + version: string; + pcount: number; + ip: string; + port: number; +} + +export interface ServerlistConfig { + host: string; + port: number; + account: string; + password: string; + nickname: string; +} + +export interface RCEvents { + onRCConnected?(instance: RCInterface): void; + onRCDisconnected?(instance: RCInterface, text?: string): void; + onRCChat?(text: string): void; + onFileBrowserMsg?(text: string): void; + onRCEventMsg?(text: string): void; +} + +export interface RCInterface { + get maxUploadFileSize(): number; + + sendRCChat(text: string): void; + setNickName(name: string): void; + + requestFolderConfig(): Promise; + requestServerFlags(): Promise; + requestServerOptions(): Promise; + + // setFolderConfig(text: string): void; + // setServerFlags(text: string): void; + // setServerOptions(text: string): void; +} + +export interface NCEvents { + onNCConnected?(): void; + onNCDisconnected?(text?: string): void; + onNCChat?(text: string): void; + + onNpcAdded?(name: string): void; + onNpcDeleted?(name: string): void; +} + +export interface NCInterface { + get classes(): Set; + get npcs(): NPC[]; + + requestLevelList(): Promise; + + deleteWeapon(name: string): void; + requestWeaponList(): Promise>; + requestWeapon(name: string): Promise<[string, string]>; + setWeaponScript(name: string, image: string, script: string): void; + + deleteNpc(name: string): void; + requestNpcAttributes(name: string): Promise; + requestNpcFlags(name: string): Promise; + requestNpcScript(name: string): Promise; + setNpcFlags(name: string, script: string): void; + setNpcScript(name: string, script: string): void; + + deleteClass(name: string): void; + requestClass(name: string): Promise; + setClassScript(name: string, script: string): void; +} + +export enum FSEntryType { + File, + Directory, +} + +export interface FSEntries { + name: string; + type: FSEntryType; + permissions: string; + fileSize: number; + modTime: number; +} + +export type DirectoryListing = { + directory: string; + fileList: FSEntries[]; +}; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..0131dd8 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,227 @@ +export function hashCode(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; // Convert to 32bit integer + } + return hash; +} + +export function escapeRegExp(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function graal_tokenize(string: string, sep = ' ') { + let separator = sep[0]; + let insideQuote = false; + let stringList = []; + let currentString = ''; + + let stringLength = string.length; + for (let i = 0; i < stringLength; i++) { + switch (string[i]) { + case separator: { + if (!insideQuote) { + stringList.push(currentString); + currentString = ''; + } else currentString += string[i]; + + break; + } + + case '"': { + insideQuote = !insideQuote; + break; + } + + case '\\': { + if (i + 1 < stringLength) { + switch (string[i + 1]) { + case '"': + case '\\': + i++; + + default: + currentString += string[i]; + break; + } + } else currentString += string[i]; + break; + } + + default: { + currentString += string[i]; + break; + } + } + } + + stringList.push(currentString); + return stringList; +} + +/** + * Takes a string as input, and returns an escaped string replacing new lines + * with commas and escaping slashes, and quotes + * + * @param buffer + * @returns + */ +export function gtokenize(buffer: string): string { + let output = ''; + + // Remove carriage return characters from the string + buffer = buffer.replace(/\r/g, ''); + + // Split the buffer by newlines, keeping empty values as well + const elements = buffer.split('\n', -1); + + for (let el of elements) { + if (el.length > 0) { + let complex = false; + for (const ch of el) { + if (ch < '!' || ch > '~' || ch == ',' || ch == '/') { + complex = true; + break; + } + } + + if (el.trim().length == 0) { + complex = true; + } + + if (complex) { + el = el.replace(/\\/g, '\\\\'); + el = el.replace(/\"/g, '""'); + output += '"' + el + '",'; + } else output += el + ','; + } else output += ','; + } + + return output.slice(0, -1); +} + +export function guntokenize(buffer: string): string { + let output = ''; + let is_paren = false; + + // Check to see if we are starting with a quotation mark. + let i = 0; + if (buffer[0] == '"') { + is_paren = true; + ++i; + } + + // Untokenize. + for (; i < buffer.length; ++i) { + // If we encounter a comma not inside a quoted string, we are encountering + // a new index. Replace the comma with a newline. + if (buffer[i] == ',' && !is_paren) { + output += '\n'; + + // Ignore whitespace. + while (i + 1 < buffer.length && buffer[i + 1] == ' ') ++i; + + // Check to see if the next string is quoted. + if (i + 1 < buffer.length && buffer[i + 1] == '"') { + is_paren = true; + ++i; + } + } + // We need to handle quotation marks as they have different behavior in quoted strings. + else if (buffer[i] == '"') { + // If we are encountering a quotation mark in a quoted string, we are either + // ending the quoted string or escaping a quotation mark. + if (is_paren) { + if (i + 1 < buffer.length) { + // Escaping a quotation mark. + if (buffer[i + 1] == '"') { + output += '"'; + ++i; + } + // Ending the quoted string. + else if (buffer[i + 1] == ',') is_paren = false; + } + } + // A quotation mark in a non-quoted string. + else output += buffer[i]; + } + // Unescape '\' character + else if (buffer[i] == '\\') { + if (i + 1 < buffer.length && buffer[i + 1] == '\\') { + output += '\\'; + i++; + } + } + // Anything else gets put to the output. + else output += buffer[i]; + } + + return output; +} + +export function gCommaStrTokens(buffer: string) { + let retData: string[] = []; + + // CString line; + let line: string = ''; + let is_paren = false; + + // // Check to see if we are starting with a quotation mark. + let i = 0; + if (buffer[0] == '"') { + is_paren = true; + ++i; + } + + // // Untokenize. + for (; i < buffer.length; ++i) { + // If we encounter a comma not inside a quoted string, we are encountering + // a new index. Replace the comma with a newline. + if (buffer[i] == ',' && !is_paren) { + retData.push(line); + line = ''; + + // Ignore whitespace. + while (i + 1 < buffer.length && buffer[i + 1] == ' ') ++i; + + // Check to see if the next string is quoted. + if (i + 1 < buffer.length && buffer[i + 1] == '"') { + is_paren = true; + ++i; + } + } + // We need to handle quotation marks as they have different behavior in quoted strings. + else if (buffer[i] == '"') { + // If we are encountering a quotation mark in a quoted string, we are either + // ending the quoted string or escaping a quotation mark. + if (is_paren) { + if (i + 1 < buffer.length) { + // Escaping a quotation mark. + if (buffer[i + 1] == '"') { + line += '"'; + ++i; + } + // Ending the quoted string. + else if (buffer[i + 1] == ',') is_paren = false; + } + } + // A quotation mark in a non-quoted string. + else line += buffer[i]; + } + // Unescape '\' character + else if (buffer[i] == '\\') { + if (i + 1 < buffer.length) { + if (buffer[i + 1] == '\\') { + line += '\\'; + i++; + } + } + } + // Anything else gets put to the output. + else line += buffer[i]; + } + + if (is_paren || line.length > 0) retData.push(line); + return retData; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8f93cb6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "rootDir": "src", + "outDir": "dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +}