From e6c5c14f320abe583835cf8a51cab0b3716a5ea4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 28 Feb 2025 22:23:19 +0200 Subject: [PATCH] hi --- .gitignore | 34 +++++++++++ README.md | 6 ++ bun.lock | 32 +++++++++++ index.ts | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 15 +++++ test.ts | 13 +++++ tsconfig.json | 27 +++++++++ types.ts | 151 +++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 430 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bun.lock create mode 100644 index.ts create mode 100644 package.json create mode 100644 test.ts create mode 100644 tsconfig.json create mode 100644 types.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..027e20a --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# rconts + + +library for rcon + +long story short check out types.ts and index.ts for docs diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..cdb8f13 --- /dev/null +++ b/bun.lock @@ -0,0 +1,32 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "rconv2", + "dependencies": { + "tseep": "^1.3.1", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.2.4", "", { "dependencies": { "bun-types": "1.2.4" } }, "sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA=="], + + "@types/node": ["@types/node@22.13.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg=="], + + "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], + + "bun-types": ["bun-types@1.2.4", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-nDPymR207ZZEoWD4AavvEaa/KZe/qlrbMSchqpQwovPZCKc7pwMoENjEtHgMKaAjJhy+x6vfqSBA1QU3bJgs0Q=="], + + "tseep": ["tseep@1.3.1", "", {}, "sha512-ZPtfk1tQnZVyr7BPtbJ93qaAh2lZuIOpTMjhrYa4XctT8xe7t4SAW9LIxrySDuYMsfNNayE51E/WNGrNVgVicQ=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..1e5b14f --- /dev/null +++ b/index.ts @@ -0,0 +1,152 @@ +import { EventEmitter } from "tseep"; +import { PacketReader, PacketWriter } from "./types"; +import type { Socket } from "bun"; + +type Events = { + authenicated: () => void; + connected: () => void; + disconnected: () => void; + attemptingReauthenication: () => void; + response: (message: string, id: number) => void; +}; + +/** + * Simple RCON library with a custom client made specifically for Bun. Uses Bun's sockets for communication. + * Used in sophie's projects such as autowhitelist, see_leaderboard_bot and slopsite. + */ +export class Rcon extends EventEmitter { + private host: string; + private port: number; + private password?: string; + + private reconnectionTime = 1000; + private reconnectionTimeout?: Timer; + private doNotReconnect = false; + + private socket?: Socket; + + private responsePromises = new Map) => void>(); + + /** + * @param host Host URL. Supports example.com:9000 as well as example.com + * @param port Can be undefined if host is formatted as "host:port". + */ + constructor(options: { + host: string, + port?: number; + password?: string; + }) { + super(); + + if (options.host.includes(":")) { + let split = options.host.split(":"); + if (!split[1]) { + throw new Error("Port is undefined in host string") + } + if (isNaN(+split[1])) { + throw new Error("Port is invalid in host string") + } + this.host = split[0]; + this.port = +split[1]; + } else { + if (!options.port) { + throw new Error("Port is required if no port in host"); + } + this.host = options.host; + this.port = options.port; + } + this.password = options.password; + } + private async onData(data: Uint8Array) { + const packet = new PacketReader(data).read(true); + + if (packet.packetName == "SERVERDATA_AUTH_RESPONSE") { + this.emit("authenicated") + } else if (packet.packetName == "SERVERDATA_RESPONSE_VALUE") { + if (this.responsePromises.has(packet.id)) { + const promise = this.responsePromises.get(packet.id)!; + promise(packet.body); + } + } + } + + public authenicate(password: string) { + if (!this.socket) { + throw new Error("Attempted to use .authenicate while not connected!") + } + + this.socket.write(new PacketWriter({ + packetName: "SERVERDATA_AUTH", + id: 0, + type: 3, + body: password + }).build()) + } + public crash() { + this.socket?.write(new Uint8Array([0x00, 0x69, 0x42, 0x21])) + } + public close() { + this.doNotReconnect = true; + this.socket?.end(); + } + public async exec(command: string): Promise { + if (!this.socket) { + throw new Error("Attempted to use .exec while not connected!") + } + + const id = Math.floor(Math.random() * 9999); + + const promise = new Promise(async (res, rej) => { + this.responsePromises.set(id, res); + }) + + this.socket?.write(new PacketWriter({ + packetName: "SERVERDATA_EXECCOMMAND", + id: id, + type: 2, + body: command + }).build()) + + return promise; + } + + public async connect() { + await Bun.connect({ + hostname: this.host, + port: this.port, + + socket: { + data: (socket, data) => { + this.onData(data); + }, + open: (socket) => { + if (this.reconnectionTimeout) { + clearInterval(this.reconnectionTimeout); + this.reconnectionTimeout = undefined; + } + this.socket = socket; + this.emit("connected"); + if (this.password) { + this.authenicate(this.password); + } + }, + close: (socket) => { + this.socket = undefined; // obv, we disconnected so + if (!this.doNotReconnect) { + console.log(`[RCON] Reconnecting in ${this.reconnectionTime}ms.`) + this.reconnectionTimeout = setTimeout(() => { + this.reconnectionTimeout = undefined; + this.connect(); + + }, this.reconnectionTime) + } + this.emit("disconnected") + }, + drain(socket) { }, + error: (socket, error) => { + console.log(`[RCON] Error experienced.`, error) + } + }, + }); + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..7b2c409 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "rconv2", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "tseep": "^1.3.1" + } +} \ No newline at end of file diff --git a/test.ts b/test.ts new file mode 100644 index 0000000..1c84b29 --- /dev/null +++ b/test.ts @@ -0,0 +1,13 @@ +import { Rcon } from "."; + +const rcon = new Rcon({ + host: process.env.HOST + "", + password: process.env.PASSWORD + "" +}) + +rcon.on("authenicated", async () => { + console.log(await rcon.exec("whitelist list")) + rcon.close() +}) + +rcon.connect(); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..6c7d9ec --- /dev/null +++ b/types.ts @@ -0,0 +1,151 @@ +/** + * Both requests and responses are sent as TCP packets. Their payload follows the following basic structure: + * + * | Field | Type | Value | + * |:------------:|:-----------------------------------:|:-------------------:| + * | Size | 32-bit little-endian Signed Integer | Varies, see below. | + * | ID | 32-bit little-endian Signed Integer | Varies, see below. | + * | Type | 32-bit little-endian Signed Integer | Varies, see below. | + * | Body | Null-terminated ASCII String | Varies, see below. | + * | Empty String | Null-terminated ASCII String | 0x00 | + * + * The packet size field is a 32-bit little endian integer, representing the length of the request in bytes. Note that the packet size field itself is not included when determining the size of the packet, so the value of this field is always 4 less than the packet's actual length. The minimum possible value for packet size is 10: + * | Size | Containing | + * |:---------------:|:--------------------------------:| + * | 4 Bytes | ID Field | + * | 4 Bytes | Type Field | + * | At least 1 Byte | Packet body (potentially empty) | + * | 1 Bytes | Empty string terminator | + * + * The packet id field is a 32-bit little endian integer chosen by the client for each request. It may be set to any positive integer. When the server responds to the request, the response packet will have the same packet id as the original request (unless it is a failed SERVERDATA_AUTH_RESPONSE packet - see below.) It need not be unique, but if a unique packet id is assigned, it can be used to match incoming responses to their corresponding requests. + */ +export interface Packet { + packetName: string; + id: number; + type: 3 | 2 | 0; + body: string; +} +/** + * Typically, the first packet sent by the client will be a SERVERDATA_AUTH packet, which is used to authenticate the connection with the server. + * + * | Field | Contains | + * |:-----:|:---------------------------------------------------------------------------------------------------------:| + * | ID | any positive integer, chosen by the client (will be mirrored back in the server's response) | + * | Type | 3 | + * | Body | the RCON password of the server (if this matches the server's rcon_password cvar, the auth will succeed) | + * + */ +export interface SERVERDATA_AUTH extends Packet { + packetName: "SERVERDATA_AUTH"; + type: 3; +} + +/** + * This packet type represents a command issued to the server by a client. The response will vary depending on the command issued. + * + * | Field | Contains | + * |:-----:|:--------------------------------------------------------------------------------------------:| + * | ID | any positive integer, chosen by the client (will be mirrored back in the server's response) | + * | Type | 2 | + * | Body | the command to be executed on the server + * +*/ +export interface SERVERDATA_EXECCOMMAND extends Packet { + packetName: "SERVERDATA_EXECCOMMAND"; + type: 2; +} +/** + * This packet is a notification of the connection's current auth status. When the server receives an auth request, it will respond with an empty SERVERDATA_RESPONSE_VALUE, followed immediately by a SERVERDATA_AUTH_RESPONSE indicating whether authentication succeeded or failed. Note that the status code is returned in the packet id field, so when pairing the response with the original auth request, you may need to look at the packet id of the preceeding SERVERDATA_RESPONSE_VALUE. + * + * | Field | Contains | + * |:-----:|:-----------------------------------------------------------------------------------------------------:| + * | ID | If authentication was successful, the ID assigned by the request. If auth failed, -1 (0xFF FF FF FF) | + * | Type | 2 | + * | Body | Empty string (0x00) + * +*/ +export interface SERVERDATA_AUTH_RESPONSE extends Packet { + packetName: "SERVERDATA_AUTH_RESPONSE", + type: 2; +} +/** + * A SERVERDATA_RESPONSE_VALUE packet is the response to a SERVERDATA_EXECCOMMAND request. + * Also note that requests executed asynchronously can possibly send their responses out of order[1] - using a unique ID to identify and associate the responses with their requests can circumvent this issue. + * + * | Field | Contains | + * |:-----:|:-----------------------------------------------------------------------------------------:| + * | ID | The ID assigned by the original request | + * | Type | 0 | + * | Body | The server's response to the original command. May be empty string (0x00) in some cases. | + * +*/ + +export interface SERVERDATA_RESPONSE_VALUE extends Packet { + packetName: "SERVERDATA_RESPONSE_VALUE"; + type: 0; +} + +export type Packets = SERVERDATA_RESPONSE_VALUE | SERVERDATA_AUTH | SERVERDATA_EXECCOMMAND | SERVERDATA_AUTH_RESPONSE; + +export class PacketWriter { + private buffer!: Uint8Array; + private dv: DataView; + private packet!: Packets; + private body: Uint8Array; + constructor(packet: Packets) { + this.packet = packet; + this.body = new TextEncoder().encode(packet.body) + this.buffer = new Uint8Array(this.body.byteLength + 14); + this.dv = new DataView(this.buffer.buffer); + } + build() { + this.dv.setInt32(0, this.body.byteLength + 10, true); + this.dv.setInt32(4, this.packet.id, true); + this.dv.setInt32(8, this.packet.type, true); + this.buffer.set(this.body, 12); + this.dv.setInt16(12 + this.body.byteLength, 0x00, true); + + return this.buffer; + } +} + +export class PacketReader { + private buffer: Uint8Array; + private dv: DataView; + constructor(buffer: Uint8Array) { + this.buffer = buffer; + this.dv = new DataView(this.buffer.buffer); + } + + read(fromServer = true): Packets { + const size = this.dv.getInt32(0, true); + const id = this.dv.getInt32(4, true); + const type = this.dv.getInt32(8, true); + + const body = this.buffer.subarray(12, this.buffer.byteLength - 2); + const text = new TextDecoder().decode(body); + + let packet = { + packetName: "", + id, + type: 0, + body: text + } + if (type == 3) { + packet.type = 3; + packet.packetName = "SERVERDATA_AUTH"; + } else if (type == 2) { + packet.type = 2; + if (fromServer) { + packet.packetName = "SERVERDATA_AUTH_RESPONSE" + } else { + packet.packetName = "SERVERDATA_EXECCOMMAND" + } + } else if (type == 0) { + packet.type = 0; + packet.packetName = "SERVERDATA_RESPONSE_VALUE" + } + + return packet as Packets; + } +} \ No newline at end of file