From 212e02e16e9de66cd06fee6afd81d06fe170744f Mon Sep 17 00:00:00 2001
From: MicLieg <michel.liebig99@gmail.com>
Date: Tue, 6 Feb 2024 00:58:59 +0100
Subject: [PATCH] Implemented proof of concept RCON command

---
 lgsm/modules/check.sh        |   8 +--
 lgsm/modules/command_rcon.sh |  44 +++++++++++++
 lgsm/modules/core_getopt.sh  |   6 ++
 lgsm/modules/core_modules.sh |  10 +++
 lgsm/modules/rcon.py         | 116 +++++++++++++++++++++++++++++++++++
 5 files changed, 180 insertions(+), 4 deletions(-)
 create mode 100644 lgsm/modules/command_rcon.sh
 create mode 100644 lgsm/modules/rcon.py

diff --git a/lgsm/modules/check.sh b/lgsm/modules/check.sh
index 2a63df3f3..414a17883 100644
--- a/lgsm/modules/check.sh
+++ b/lgsm/modules/check.sh
@@ -47,7 +47,7 @@ if [ "$(whoami)" != "root" ]; then
 	done
 fi
 
-allowed_commands_array=(BACKUP CONSOLE DEBUG DETAILS MAP-COMPRESSOR FASTDL MODS-INSTALL MODS-REMOVE MODS-UPDATE MONITOR POST-DETAILS RESTART START STOP TEST-ALERT CHANGE-PASSWORD UPDATE UPDATE-LGSM VALIDATE WIPE)
+allowed_commands_array=(BACKUP CONSOLE DEBUG DETAILS MAP-COMPRESSOR FASTDL MODS-INSTALL MODS-REMOVE MODS-UPDATE MONITOR POST-DETAILS RCON RESTART START STOP TEST-ALERT CHANGE-PASSWORD UPDATE UPDATE-LGSM VALIDATE WIPE)
 for allowed_command in "${allowed_commands_array[@]}"; do
 	if [ "${allowed_command}" == "${commandname}" ]; then
 		check_logs.sh
@@ -61,14 +61,14 @@ for allowed_command in "${allowed_commands_array[@]}"; do
 	fi
 done
 
-allowed_commands_array=(CONSOLE DEBUG MONITOR START STOP)
+allowed_commands_array=(CONSOLE DEBUG MONITOR RCON START STOP)
 for allowed_command in "${allowed_commands_array[@]}"; do
 	if [ "${allowed_command}" == "${commandname}" ]; then
 		check_config.sh
 	fi
 done
 
-allowed_commands_array=(DEBUG DETAILS DEV-QUERY-RAW MONITOR POST_DETAILS START STOP POST-DETAILS)
+allowed_commands_array=(DEBUG DETAILS DEV-QUERY-RAW MONITOR POST_DETAILS RCON START STOP POST-DETAILS)
 for allowed_command in "${allowed_commands_array[@]}"; do
 	if [ "${allowed_command}" == "${commandname}" ]; then
 		if [ -z "${installflag}" ]; then
@@ -86,7 +86,7 @@ for allowed_command in "${allowed_commands_array[@]}"; do
 	fi
 done
 
-allowed_commands_array=(CHANGE-PASSWORD DETAILS MONITOR START STOP UPDATE VALIDATE POST-DETAILS)
+allowed_commands_array=(CHANGE-PASSWORD DETAILS MONITOR RCON START STOP UPDATE VALIDATE POST-DETAILS)
 for allowed_command in "${allowed_commands_array[@]}"; do
 	if [ "${allowed_command}" == "${commandname}" ]; then
 		check_status.sh
diff --git a/lgsm/modules/command_rcon.sh b/lgsm/modules/command_rcon.sh
new file mode 100644
index 000000000..1d7078d90
--- /dev/null
+++ b/lgsm/modules/command_rcon.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+# LinuxGSM command_rcon.sh module
+# Author: Daniel Gibbs
+# Contributors: http://linuxgsm.com/contrib
+# Website: https://linuxgsm.com
+# Description: Send rcon commands to different gameservers.
+
+commandname="RCON"
+commandaction="Rcon"
+moduleselfname="$(basename "$(readlink -f "${BASH_SOURCE[0]}")")"
+fn_firstcommand_set
+
+check.sh
+if [ "${status}" == "0" ]; then
+	fn_print_error_nl "Server not running"
+	fn_script_log_error "Failed to access: Server not running"
+	if fn_prompt_yn "Do you want to start the server?" Y; then
+		exitbypass=1
+		command_start.sh
+	fi
+fi
+
+
+if [ -n "${userinput2}" ]; then
+	rconcommandtosend="${userinput2}"
+else
+	fn_print_header
+	fn_print_information_nl "Send a RCON command to the server."
+	echo ""
+	rconcommandtosend=$(fn_prompt_message "RCON command: ")
+fi
+
+fn_print_dots "Sending RCON command to server: \"${rconcommandtosend}\""
+
+if [ ! -f "${modulesdir}/rcon.py" ]; then
+	fn_fetch_file_github "lgsm/modules" "rcon.py" "${modulesdir}" "chmodx" "norun" "noforce" "nohash"
+fi
+
+"${modulesdir}"/rcon.py -a "${telnetip}" -p "${rconport}" -P "${rconpassword}" -c "${rconcommandtosend}" > /dev/null 2>&1
+
+fn_print_ok_nl "Sending RCON command to server: \"${rconcommandtosend}\""
+fn_script_log_pass "RCON command \"${rconcommandtosend}\" sent to server"
+
+core_exit.sh
diff --git a/lgsm/modules/core_getopt.sh b/lgsm/modules/core_getopt.sh
index cd3e57cb5..c858d5f22 100644
--- a/lgsm/modules/core_getopt.sh
+++ b/lgsm/modules/core_getopt.sh
@@ -24,6 +24,7 @@ cmd_monitor=("m;monitor" "command_monitor.sh" "Check server status and restart i
 cmd_skeleton=("sk;skeleton" "command_skeleton.sh" "Create a skeleton directory.")
 cmd_sponsor=("s;sponsor" "command_sponsor.sh" "Sponsorship options.")
 cmd_send=("sd;send" "command_send.sh" "Send command to game server console.")
+cmd_rcon=("rc;rcon" "command_rcon.sh" "Send RCON command to game server.")
 # Console servers only.
 cmd_console=("c;console" "command_console.sh" "Access server console.")
 cmd_debug=("d;debug" "command_debug.sh" "Start server directly in your terminal.")
@@ -92,6 +93,11 @@ if [ "${consoleinteract}" == "yes" ]; then
 	currentopt+=("${cmd_send[@]}")
 fi
 
+# RCON command.
+# TODO: Add RCON type to all _default.cfg files [Source RCON Protocol / Other Protocols?! / None?!]
+# TODO: then add a check in the rcon command to use the appropriate protocol
+currentopt+=("${cmd_rcon[@]}")
+
 ## Game server exclusive commands.
 
 # FastDL command.
diff --git a/lgsm/modules/core_modules.sh b/lgsm/modules/core_modules.sh
index 6fda8c2ca..6fe3c1f07 100644
--- a/lgsm/modules/core_modules.sh
+++ b/lgsm/modules/core_modules.sh
@@ -181,6 +181,11 @@ command_send.sh() {
 	fn_fetch_module
 }
 
+command_rcon.sh() {
+	modulefile="${FUNCNAME[0]}"
+	fn_fetch_module
+}
+
 # Checks
 
 check.sh() {
@@ -739,6 +744,11 @@ install_gsquery.sh() {
 	fn_fetch_module
 }
 
+install_rcon.sh() {
+	modulefile="${FUNCNAME[0]}"
+	fn_fetch_module
+}
+
 install_gslt.sh() {
 	modulefile="${FUNCNAME[0]}"
 	fn_fetch_module
diff --git a/lgsm/modules/rcon.py b/lgsm/modules/rcon.py
new file mode 100644
index 000000000..789236d09
--- /dev/null
+++ b/lgsm/modules/rcon.py
@@ -0,0 +1,116 @@
+#! /usr/bin/env python3
+# -*- coding: utf-8 -*-
+# LinuxGSM rcon.py module
+# Author: MicLieg
+# Contributors: http://linuxgsm.com/contrib
+# Website: https://linuxgsm.com
+# Description: Allows sending RCON commands to different gameservers.
+
+import argparse
+import socket
+import struct
+import sys
+
+
+class PacketTypes:
+    LOGIN = 3
+    COMMAND = 2
+
+
+class Rcon:
+
+    def __init__(self, arguments):
+        self.arguments = arguments
+        self.connection = None
+
+    def __enter__(self):
+        self.connect_to_server()
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        if self.connection:
+            self.connection.close()
+
+    @staticmethod
+    def fatal_error(error_message, error_code):
+        sys.stderr.write(f'ERROR: {error_code} {error_message}\n')
+        sys.exit(error_code)
+
+    @staticmethod
+    def exit_success(success_message=''):
+        sys.stdout.write(f'OK: {success_message}\n')
+        sys.exit(0)
+
+    def connect_to_server(self):
+        self.connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        self.connection.settimeout(self.arguments.timeout)
+
+        try:
+            self.connection.connect((self.arguments.address, self.arguments.port))
+        except socket.timeout:
+            self.fatal_error('Request timed out', 1)
+        except Exception as e:
+            self.fatal_error(f'Unable to connect: {e}', 1)
+
+    def send_packet(self, request_id, packet_type, data):
+        # Packet structure follows the Source RCON Protocol: size, request ID, type, data, two null bytes
+        packet = (
+                struct.pack('<l', request_id)
+                + struct.pack('<l', packet_type)
+                + data.encode('utf8') + b'\x00\x00'
+        )
+        try:
+            self.connection.send(struct.pack('<l', len(packet)) + packet)
+        except socket.error as e:
+            self.fatal_error(f'Failed to send packet: {e}', 2)
+
+    def receive_packet(self):
+        try:
+            response = self.connection.recv(self.arguments.buffer)
+            return response
+        except socket.error as e:
+            self.fatal_error(f'Failed to receive response: {e}', 3)
+
+    def login(self):
+        self.send_packet(1, PacketTypes.LOGIN, self.arguments.password)
+        response = self.receive_packet()
+        if response:
+            size, id_response, type_response = struct.unpack('<l', response[:4]), struct.unpack('<l', response[
+                                                                                                      4:8]), struct.unpack(
+                '<l', response[8:12])
+
+            if id_response[0] == -1:
+                self.fatal_error('Login to RCON failed', 4)
+        else:
+            self.fatal_error('No response received for login', 4)
+
+    def send_command(self):
+        self.send_packet(2, PacketTypes.COMMAND, self.arguments.command)
+        response = self.receive_packet()
+        if response:
+            response_message = response[12:-2].decode('utf-8')  # Stripping trailing null bytes
+            self.exit_success(str(response_message))
+        else:
+            self.fatal_error('No response received for command', 5)
+
+
+def parse_args():
+    parser = argparse.ArgumentParser(description='Sends RCON commands to Minecraft servers.')
+    parser.add_argument('-a', '--address', type=str, required=True, help='The server IP address.')
+    parser.add_argument('-p', '--port', type=int, required=True, help='The server port.')
+    parser.add_argument('-P', '--password', type=str, required=True, help='The RCON password.')
+    parser.add_argument('-c', '--command', type=str, required=True, help='The RCON command to send.')
+    parser.add_argument('-t', '--timeout', type=int, default=5, help='The timeout for server response.')
+    parser.add_argument('-b', '--buffer', type=int, default=4096, help='The buffer length for server response.')
+    return parser.parse_args()
+
+
+def main():
+    arguments = parse_args()
+    with Rcon(arguments) as rcon:
+        rcon.login()
+        rcon.send_command()
+
+
+if __name__ == '__main__':
+    main()