yamrc
Version:
Yet another Minecraft RCON client
210 lines (186 loc) • 7.56 kB
text/typescript
/*
YAMRC establishes a connection to an RCON server, authenticates using a password, and sends commands to the server.
Copyright (C) 2023 Necrozma
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import net from 'net';
/**
* Represents a remote console (RCON) connection to interact with a server.
*/
export class Rcon {
// Private properties
private socket: net.Socket;
private id: number;
private callbacks: Map<number, (packet: any) => void> = new Map();
private authenticated: boolean = false;
/**
* Creates an instance of Rcon.
* @param host - The host address of the server.
* @param port - The port number to connect to.
* @param password - The RCON password for authentication.
*/
constructor(private host: string, private port: number, private password: string) {
this.socket = new net.Socket();
this.id = this.randInt32();
}
/**
* Establishes a connection to the RCON server.
* @returns A promise that resolves when the connection is established successfully.
* @throws Rejects with an error if the connection fails.
*/
public async connect() {
return new Promise<void>((resolve, reject) => {
this.socket.connect(this.port, this.host, () => {
console.log('Connected to RCON server, authenticating...');
this.auth(this.password).then(() => resolve()).catch(err => reject(err));
});
this.socket.on('error', (err) => {
reject(err);
});
this.socket.on('data', (data) => {
const packet = this.read(data);
if (packet.id === -1) {
console.log('Authentication failed: Wrong password');
reject('Authentication failed: Wrong password');
return;
}
const callback = this.callbacks.get(packet.id);
if (callback) {
callback(packet);
this.callbacks.delete(packet.id);
}
});
});
}
/**
* Closes the connection to the RCON server.
* @returns A promise that resolves when the connection is closed.
*/
public async disconnect() {
return new Promise<void>((resolve, reject) => {
this.socket.end(() => {
resolve();
});
});
}
/**
* Sends a command to the RCON server.
* @param command - The command string to be sent.
* @returns A promise that resolves with the response from the server.
* @throws Rejects with an error if authentication is required before sending commands.
*/
public async send(command: string) {
return new Promise((resolve, reject) => {
// Check if we are authenticated before sending commands
if (!this.authenticated) {
reject('Authentication required before sending commands.');
return;
}
// Generate a unique ID for the command and add a callback to the map
const id = this.id++;
this.callbacks.set(id, (data) => {
resolve(data);
});
// Create a buffer with the command and send it to the server
const buffer = this.createBuffer(id, 2, command);
this.socket.write(buffer);
});
}
/**
* Returns the current connection status.
* @returns True if connected, false otherwise.
*/
public isConnected() {
return this.socket.writable;
}
/**
* Returns the current authentication status.
* @returns True if authenticated, false otherwise.
*/
public isAuthenticated() {
return this.authenticated;
}
// Private methods
/**
* Performs authentication with the RCON server using the provided password.
* @param password - The RCON password for authentication.
* @returns A promise that resolves when authentication is successful.
* @throws Rejects with an error if authentication fails.
*/
private async auth(password: string) {
return new Promise<void>((resolve, reject) => {
const id = this.id++;
this.callbacks.set(id, (packet) => {
if (packet.id === id) {
console.log('Authentication successful');
this.authenticated = true;
resolve();
} else {
// Need to fix this, will never happen
console.log('Authentication failed: Unexpected response ID');
reject('Authentication failed: Unexpected response ID');
}
});
// Create a buffer with the password and send it to the server
const buffer = this.createBuffer(id, 3, password);
this.socket.write(buffer);
});
}
/**
* Creates a buffer to be sent over the socket.
* @param id - The packet ID.
* @param type - The packet type.
* @param body - The content/body of the packet.
* @returns A buffer containing the formatted packet.
*/
private createBuffer(id: number, type: number, body: string) {
const bodyBuffer = Buffer.from(body, 'utf8');
const buffer = Buffer.alloc(14 + bodyBuffer.length);
buffer.writeInt32LE(10 + bodyBuffer.length, 0);
buffer.writeInt32LE(id, 4);
buffer.writeInt32LE(type, 8);
buffer.write(body, 12, 'utf8');
buffer.writeInt8(0, 12 + bodyBuffer.length);
buffer.writeInt8(0, 13 + bodyBuffer.length);
return buffer;
}
/**
* Reads a packet received from the server.
* @param packet - The packet buffer received from the server.
* @returns An object representing the parsed packet.
* @throws Error if the packet is invalid.
* @author github.com/tehbeard
*/
private read(packet: Buffer) {
// Length of the rest of the packet
const length = packet.readInt32LE(0);
// Check if we have a valid packet with 2 null bytes of padding in the end
if (packet.length === 4 + length && !packet.readInt16LE(packet.length - 2)) {
// Offsets are hardcoded for speed
return {
length: length,
id: packet.readInt32LE(4),
type: packet.readInt32LE(8),
payload: packet.toString('ascii', 12, packet.length - 2)
};
} else {
throw new Error(`Invalid packet! [${packet}]`);
}
}
/**
* Generates a random 32-bit integer for use as the initial packet ID.
* @returns A random 32-bit integer.
*/
private randInt32() {
return Math.floor(Math.random() * 0xFFFFFFFF) | 0; // Use bitwise OR to limit to 32-bit range
}
}