From 012ba9fc2f1fa0030f1b877c5b232d96b12d127b Mon Sep 17 00:00:00 2001 From: Jordyn Date: Sat, 14 Mar 2026 00:56:36 -0500 Subject: [PATCH 01/15] Move PRIVMSG parsing code to _parse_message --- main.py | 65 ++++++++++++++++++++++++++++++++------------------------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/main.py b/main.py index e0d2def..18fcf04 100755 --- a/main.py +++ b/main.py @@ -196,10 +196,26 @@ class Server(): if trailing: params.append(trailing) + # Extract possible user info from prefix + if command == "PRIVMSG": + message_source = prefix.partition("!")[0] + + # Technically the target is ourselfs, but I change it to be the other user + # so other parts of the script can easily use it to know where to send output. + if params[0] == self.nickname: + target_channel = message_source + else: + target_channel = params[0] + else: + message_source = None + target_channel = None + return { "prefix": prefix, "command": command, - "params": params + "params": params, + "message_source": message_source, + "target_channel": target_channel } # This is where we handle messages that need to be @@ -210,13 +226,6 @@ class Server(): # Handle PRIVMSG if msg["command"] == "PRIVMSG": - msg_source = msg["params"][0] - # Properly decode private messages - if msg_source == self.nickname: - source_channel = msg["prefix"].partition("!~")[0] - else: - source_channel = msg_source - # Check for command prefix and run it if we got one if msg["params"][-1].startswith(self.command_prefix): # Strip command prefix and spaces if any from input @@ -228,27 +237,27 @@ class Server(): return # Run CMDTHREAD - threading.Thread(target = self._handle_command, args = (cmd, source_channel, )).start() + threading.Thread(target = self._handle_command, args = (cmd, msg["target_channel"], )).start() # Check for bot prefix if msg["params"][-1].startswith(self.bot_prefix): - # Toggle flood protection in send thread - if "slow" in msg["params"][-1]: - if self._msg_slow_down.is_set(): - self._msg_q.put(f"PRIVMSG {source_channel} :Flood protection: Disabled\r\n".encode()) - self._msg_slow_down.clear() - else: - self._msg_q.put(f"PRIVMSG {source_channel} :Flood protection: Enabled\r\n".encode()) - self._msg_slow_down.set() + # Send message queue size if "sendq" in msg["params"][-1]: - self._msg_q.put(f"PRIVMSG {source_channel} :Send Queue Size: {self._msg_q.qsize()}\r\n".encode()) + self._msg_q.put(f"PRIVMSG {msg['target_channel']} :Send Queue Size: {self._msg_q.qsize()}\r\n".encode()) + + # Send main PID if "pid" in msg["params"][-1]: - self._msg_q.put(f"PRIVMSG {source_channel} :Bot PID: {os.getpid()}\r\n".encode()) + self._msg_q.put(f"PRIVMSG {msg['target_channel']} :Bot PID: {os.getpid()}\r\n".encode()) + + # Kill server if "die" in msg["params"][-1]: self.die() + raise SystemExit(0) + + # Send flooding statistics if "floodstats" in msg["params"][-1]: - self._msg_q.put(f"PRIVMSG {source_channel} :Sleep Time: {self._msg_time}\r\n".encode()) - self._msg_q.put(f"PRIVMSG {source_channel} :Message Count: {self._msg_count}\r\n".encode()) + self._msg_q.put(f"PRIVMSG {msg['target_channel']} :Sleep Time: {self._msg_time}\r\n".encode()) + self._msg_q.put(f"PRIVMSG {msg['target_channel']} :Message Count: {self._msg_count}\r\n".encode()) # Handle nickname already in use by appending the PID # and resending user reg @@ -260,20 +269,18 @@ class Server(): # Allow users to add bots to channel, but only if it's me if msg["command"] == "INVITE": - msg_source = msg["params"][0] - source_user = msg["prefix"].partition("!~")[0] - if source_user in self.opper_nicknames: - print(f"[RECVTHREAD] Joining channel by user command!") + if msg.source_user in self.opper_nicknames: + print(f"[RECVTHREAD] Joining channel by opper command from {msg['source_user']}!") self._msg_q.put(f"JOIN {msg['params'][-1]}\r\n".encode()) # This is where we actually run the RCE commands and pipe the output # back to IRC. # # No temp files here - def _handle_command(self, cmd, source_channel): + def _handle_command(self, cmd, target_channel): print(f"[CMDTHREAD] Running CMD {cmd}!") proc = subprocess.Popen(cmd, shell = True, stdout = subprocess.PIPE, stderr = subprocess.STDOUT, stdin = subprocess.DEVNULL, - start_new_session=False, text=False) + start_new_session=True, text=False) # Calculate maximum message size # 512 bytes is max IRC message with Date: Sat, 14 Mar 2026 03:42:04 -0500 Subject: [PATCH 02/15] Add configuration file support --- main.py | 17 ++++++++++------- skel_config.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 skel_config.py diff --git a/main.py b/main.py index 18fcf04..5ff6006 100755 --- a/main.py +++ b/main.py @@ -7,15 +7,16 @@ import subprocess import re import time import queue +import config class Server(): # Setup the IRC related things such as user details and prefixes # This function also sets up the threading events and queues - def __init__(self, realname, nickname, channel, command_prefix = "$!", bot_prefix = "$$", opper_nicknames = []): + def __init__(self, realname, nickname, channels, command_prefix = "$!", bot_prefix = "$$", opper_nicknames = []): print(f"[SERVER/MAINTHREAD] Using Server() on Python3 PID {os.getpid()}") self.realname = realname self.nickname = nickname - self.channel = channel + self.channels = channels self.opper_nicknames = opper_nicknames # Prefix for commands to execute @@ -35,7 +36,7 @@ class Server(): self._msg_time = 0 self._msg_count = 0 - print(f"[SERVER/MAINTHREAD] Using nickname {nickname} on {channel}!") + print(f"[SERVER/MAINTHREAD] Using nickname {nickname}!") # This function handles the socket bring up # Sets socket paramaters, and starts the send/recv threads @@ -145,7 +146,10 @@ class Server(): def _send_userreg(self): self._msg_q.put(f"USER {self.realname} * * :{self.nickname}\r\n".encode()) self._msg_q.put(f"NICK {self.nickname}\r\n".encode()) - self._msg_q.put(f"JOIN {self.channel}\r\n".encode()) + + for channel in self.channels: + print(f"[RECVTHREAD] Joining channel {channel}!") + self._msg_q.put(f"JOIN {channel}\r\n".encode()) # Used to strip ASCII control characters (such as terminal escape codes) # from text. Mainly to avoid unknown command errors from the IRC server. @@ -318,9 +322,8 @@ class Server(): self._msg_q.put(f"PRIVMSG {target_channel} :CMD {cmd} exited with returncode {proc.returncode}\r\n".encode()) if __name__ == "__main__": - serv = Server("username", "nickname", "##channel", opper_nicknames = ["opperhere"]) - serv.connect("IP", 6667, sock_recvbuf = 8192) - + serv = Server(**config.user, **config.bot) + serv.connect(**config.server) # Allow for the user to send raw IRC messages # These bypass the send queue diff --git a/skel_config.py b/skel_config.py new file mode 100644 index 0000000..d1beed1 --- /dev/null +++ b/skel_config.py @@ -0,0 +1,19 @@ +user = { + "realname": "setme", + "nickname": "setme", + "channels": ["##join", "##these", "##channels"] +} + +server = { + "ip": "irc.serv.net", + "port": 6667, + "sock_timeout": 60, + "sock_sendbuf": 512, + "sock_recvbuf": 512 +} + +bot = { + "command_prefix": "$!", + "bot_prefix": "$$", + "opper_nicknames": ["nicknamehere"] +} From c1d9da8ef8c9aaf0c5e77a1f2aee5ff76a076cf4 Mon Sep 17 00:00:00 2001 From: Jordyn Date: Sat, 14 Mar 2026 03:47:00 -0500 Subject: [PATCH 03/15] Improve ping support to avoid ping timeouts Specifically the ping is moved to the message throttling code so that every 10th message triggers a ping. This also allows for me to remove the multiple ping handlers. --- main.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/main.py b/main.py index 5ff6006..30a9662 100755 --- a/main.py +++ b/main.py @@ -63,7 +63,9 @@ class Server(): def die(self, msg = "Python3 Server() outta here!"): print("[SERVER/MAINTHREAD] Sending PART and QUIT") # Part all of our channels with a cool message, then quit - self._msg_q.put(f"PART {self.channel} : {msg}\r\n".encode()) + for channel in self.channels: + self._msg_q.put(f"PART {channel} : {msg}\r\n".encode()) + self._msg_q.put(f"QUIT : {msg}\r\n".encode()) print("[SERVER/MAINTHREAD] Signaling threads to quit!") @@ -79,6 +81,8 @@ class Server(): print(f"[SENDTHREAD] Sleeping for {self._msg_time} seconds on message count {self._msg_count}") time.sleep(self._msg_time) if self._msg_count == 10: + print("[SENDTHREAD] Sending ping on 10th message!") + self.sock.send(f"PING :{ip}\r\n".encode()) self._msg_count = 0 # Grab message and send it @@ -86,8 +90,7 @@ class Server(): msg = self._msg_q.get(timeout = 60) except queue.Empty: # So the thread signaler works - print("[SENDTHREAD] Timeout occurred waiting on queue... sending ping!") - self.sock.send(f"PING :{ip}\r\n".encode()) + continue print(f"[SENDTHREAD] Sending message: {msg}") try: @@ -121,10 +124,7 @@ class Server(): print("[RECVTHREAD] Message: {data}") continue except socket.timeout: - # Instead of using another thread, I just use a socket timeout to send pings - # Kills two birds with one stone as we can't wait forever due to threading Events - print("[RECVTHREAD] Timeout occurred waiting on server messages... sending ping!") - self.sock.send(f"PING :{ip}\r\n".encode()) + # So threading Events work continue # Handle IRC messages @@ -288,7 +288,7 @@ class Server(): # Calculate maximum message size # 512 bytes is max IRC message with Date: Sat, 14 Mar 2026 03:52:33 -0500 Subject: [PATCH 04/15] Fix INVITE command --- main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 30a9662..35a0cd0 100755 --- a/main.py +++ b/main.py @@ -201,7 +201,7 @@ class Server(): params.append(trailing) # Extract possible user info from prefix - if command == "PRIVMSG": + if command in ["PRIVMSG", "INVITE"]: message_source = prefix.partition("!")[0] # Technically the target is ourselfs, but I change it to be the other user @@ -273,8 +273,8 @@ class Server(): # Allow users to add bots to channel, but only if it's me if msg["command"] == "INVITE": - if msg.source_user in self.opper_nicknames: - print(f"[RECVTHREAD] Joining channel by opper command from {msg['source_user']}!") + if msg["message_source"] in self.opper_nicknames: + print(f"[RECVTHREAD] Joining channel by opper command from {msg['message_source']}!") self._msg_q.put(f"JOIN {msg['params'][-1]}\r\n".encode()) # This is where we actually run the RCE commands and pipe the output From a5134ade39fb1ed21f31ea0eeb49d0ac821099ae Mon Sep 17 00:00:00 2001 From: Jordyn Date: Sat, 14 Mar 2026 04:23:53 -0500 Subject: [PATCH 05/15] Add privmsg command for less verbose messaging _msg_q was also refactored to _send_q to better represent what it is --- main.py | 70 ++++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/main.py b/main.py index 35a0cd0..7c26c11 100755 --- a/main.py +++ b/main.py @@ -28,8 +28,8 @@ class Server(): # Used to signal threads to shutdown self._going_down = threading.Event() - # Queue used for messages to send - self._msg_q = queue.Queue() + # Queue used for IRC server send() calls + self._send_q = queue.Queue() # Rate limiting variables # Used so it can be quired by IRC @@ -64,19 +64,36 @@ class Server(): print("[SERVER/MAINTHREAD] Sending PART and QUIT") # Part all of our channels with a cool message, then quit for channel in self.channels: - self._msg_q.put(f"PART {channel} : {msg}\r\n".encode()) + self._send_q.put(f"PART {channel} : {msg}\r\n".encode()) - self._msg_q.put(f"QUIT : {msg}\r\n".encode()) + self._send_q.put(f"QUIT : {msg}\r\n".encode()) print("[SERVER/MAINTHREAD] Signaling threads to quit!") # Signal threads to quit self._going_down.set() + # Sends a message to a target (user/channel) + # The bot does NOT have to be a channel to send a message, but most channels + # disallow messages from non-JOIN'ed users. + def privmsg(self, target, message, bypass_q = False): + # Check for send thread + try: + self._send_thread + except NameError: + raise RuntimeWarning("connect() must be called before this command is used!") + + # format message + message = f"PRIVMSG {target} :{message}\r\n".encode() + + if bypass_q: + self.sock.send(message) + else: + self._send_q.put(message) + # Function used to send messages from the queue # Rate limiting is also implemented here def _send_loop(self, ip): while not self._going_down.is_set(): - self._msg_count+=1 self._msg_time = (0.15 * self._msg_count)**2 print(f"[SENDTHREAD] Sleeping for {self._msg_time} seconds on message count {self._msg_count}") time.sleep(self._msg_time) @@ -87,7 +104,11 @@ class Server(): # Grab message and send it try: - msg = self._msg_q.get(timeout = 60) + msg = self._send_q.get(timeout = 60) + + # Only increment the message count if we actually get a message, + # the server will PING us when it needs to. + self._msg_count+=1 except queue.Empty: # So the thread signaler works continue @@ -144,12 +165,12 @@ class Server(): # This isn't in the recv thread as we need to run it twice # if the nick is in use. def _send_userreg(self): - self._msg_q.put(f"USER {self.realname} * * :{self.nickname}\r\n".encode()) - self._msg_q.put(f"NICK {self.nickname}\r\n".encode()) + self._send_q.put(f"USER {self.realname} * * :{self.nickname}\r\n".encode()) + self._send_q.put(f"NICK {self.nickname}\r\n".encode()) for channel in self.channels: print(f"[RECVTHREAD] Joining channel {channel}!") - self._msg_q.put(f"JOIN {channel}\r\n".encode()) + self._send_q.put(f"JOIN {channel}\r\n".encode()) # Used to strip ASCII control characters (such as terminal escape codes) # from text. Mainly to avoid unknown command errors from the IRC server. @@ -228,6 +249,11 @@ class Server(): debug_msg = self._strip_control_chars(f"[RECVTHREAD] Got message from server! Message: {msg}") print(debug_msg) + # Handle PINGs from server + if msg["command"] == "PING": + self.sock.send(f"PONG {msg['params'][0]}\r\n".encode()) + return + # Handle PRIVMSG if msg["command"] == "PRIVMSG": # Check for command prefix and run it if we got one @@ -242,17 +268,13 @@ class Server(): # Run CMDTHREAD threading.Thread(target = self._handle_command, args = (cmd, msg["target_channel"], )).start() - - # Check for bot prefix - if msg["params"][-1].startswith(self.bot_prefix): + elif msg["params"][-1].startswith(self.bot_prefix): # Send message queue size if "sendq" in msg["params"][-1]: - self._msg_q.put(f"PRIVMSG {msg['target_channel']} :Send Queue Size: {self._msg_q.qsize()}\r\n".encode()) - + self.privmsg(msg["target_channel"], f"Send Queue Size: {self._send_q.qsize()}") # Send main PID if "pid" in msg["params"][-1]: - self._msg_q.put(f"PRIVMSG {msg['target_channel']} :Bot PID: {os.getpid()}\r\n".encode()) - + self.privmsg(msg["target_channel"], f"Bot PID: {os.getpid()}") # Kill server if "die" in msg["params"][-1]: self.die() @@ -260,22 +282,20 @@ class Server(): # Send flooding statistics if "floodstats" in msg["params"][-1]: - self._msg_q.put(f"PRIVMSG {msg['target_channel']} :Sleep Time: {self._msg_time}\r\n".encode()) - self._msg_q.put(f"PRIVMSG {msg['target_channel']} :Message Count: {self._msg_count}\r\n".encode()) - + self.privmsg(msg["target_channel"], f"Sleep Time: {self._msg_time}") + self.privmsg(msg["target_channel"], f"Message Count: {self._msg_count}") # Handle nickname already in use by appending the PID # and resending user reg - if msg["command"] == "433": + elif msg["command"] == "433": self.nickname = f"{self.nickname}-{os.getpid()}" print(f"[RECVTHREAD] Nickname already in use! Using: {self.nickname}") self._send_userreg() - # Allow users to add bots to channel, but only if it's me - if msg["command"] == "INVITE": + elif msg["command"] == "INVITE": if msg["message_source"] in self.opper_nicknames: print(f"[RECVTHREAD] Joining channel by opper command from {msg['message_source']}!") - self._msg_q.put(f"JOIN {msg['params'][-1]}\r\n".encode()) + self._send_q.put(f"JOIN {msg['params'][-1]}\r\n".encode()) # This is where we actually run the RCE commands and pipe the output # back to IRC. @@ -315,11 +335,11 @@ class Server(): line = line.replace("\r\n", "") line = line.replace("\n", "") - self._msg_q.put(b"PRIVMSG " + target_channel.encode() + b" :" + line.encode() + b"\r\n") + self.privmsg(target_channel, line) # Wait is required to fetch exit code proc.wait() - self._msg_q.put(f"PRIVMSG {target_channel} :CMD {cmd} exited with returncode {proc.returncode}\r\n".encode()) + self.privmsg(target_channel, f"CMD {cmd} exited with returncode {proc.returncode}") if __name__ == "__main__": serv = Server(**config.user, **config.bot) From 0febb315c369bd64f5a42a335d52cceb4ec31c3f Mon Sep 17 00:00:00 2001 From: Jordyn Date: Sat, 14 Mar 2026 04:32:11 -0500 Subject: [PATCH 06/15] Allow for running this bot without a valid stdin Specifically, if rawcmd's input fails, it will just wait for the going down signal. --- main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/main.py b/main.py index 7c26c11..595f590 100755 --- a/main.py +++ b/main.py @@ -356,3 +356,6 @@ if __name__ == "__main__": print("[MAINTHREAD] Could take up to 60 seconds for socket timeout!") serv.die() raise SystemExit(0) + except EOFError: + while True: + serv._going_down.wait() From 2fbc2cf05118bf2bb5f32733c27157afa1b4ff4a Mon Sep 17 00:00:00 2001 From: Jordyn Date: Sat, 14 Mar 2026 04:41:16 -0500 Subject: [PATCH 07/15] Fix BrokenPipeError Handling --- main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 595f590..68692e6 100755 --- a/main.py +++ b/main.py @@ -119,7 +119,8 @@ class Server(): except BrokenPipeError: # Can't use die because we have no connection... just quit I suppose self._going_down.set() - + raise SystemExit(1) + print("[SENDTHREAD] Quitting due to thread condition!") # This is the function used to process messages from the server From ca21c914ef982e08c345cf6da7f018c474eb563c Mon Sep 17 00:00:00 2001 From: Jordyn Date: Sat, 14 Mar 2026 04:43:40 -0500 Subject: [PATCH 08/15] Avoid 100% CPU loop during heavy usage --- main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index 68692e6..c74100d 100755 --- a/main.py +++ b/main.py @@ -120,7 +120,7 @@ class Server(): # Can't use die because we have no connection... just quit I suppose self._going_down.set() raise SystemExit(1) - + print("[SENDTHREAD] Quitting due to thread condition!") # This is the function used to process messages from the server @@ -338,9 +338,9 @@ class Server(): self.privmsg(target_channel, line) - # Wait is required to fetch exit code - proc.wait() - self.privmsg(target_channel, f"CMD {cmd} exited with returncode {proc.returncode}") + # Break if our child exits for any reason + if proc.poll(): + break if __name__ == "__main__": serv = Server(**config.user, **config.bot) From 608b8e3cad216d1e36ee4800aa5aefb803a271c4 Mon Sep 17 00:00:00 2001 From: Jordyn Date: Sat, 14 Mar 2026 18:19:17 -0500 Subject: [PATCH 09/15] Fix INVITE code again This adds it to the channel list so it gets PART'ed correctly --- main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index c74100d..5f3f60b 100755 --- a/main.py +++ b/main.py @@ -297,7 +297,8 @@ class Server(): if msg["message_source"] in self.opper_nicknames: print(f"[RECVTHREAD] Joining channel by opper command from {msg['message_source']}!") self._send_q.put(f"JOIN {msg['params'][-1]}\r\n".encode()) - + self.channels.append(msg["params"][-1]) + # This is where we actually run the RCE commands and pipe the output # back to IRC. # From e682656fe56fc2766c802b0fea194210022a0876 Mon Sep 17 00:00:00 2001 From: Jordyn Date: Sat, 14 Mar 2026 18:55:53 -0500 Subject: [PATCH 10/15] Fix die bot command to actually terminate the program Specifically, this corrects interrupting the main thread as it was waiting on input() if it is a tty. Server.die() was changed to bypass the sendq for final IRC messages for a quick termination and to interrupt the main thread with os.kill and SIGINT. This way the code exits gracefully and properly tears the connection down. --- main.py | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/main.py b/main.py index 5f3f60b..c515882 100755 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import os +import sys import socket import signal import threading @@ -61,17 +62,24 @@ class Server(): # Gracefully shuts the server down def die(self, msg = "Python3 Server() outta here!"): - print("[SERVER/MAINTHREAD] Sending PART and QUIT") + # NOOP if we're in the process of going down + if self._going_down.is_set(): + return + + print("[SERVER] Sending PART and QUIT") # Part all of our channels with a cool message, then quit for channel in self.channels: - self._send_q.put(f"PART {channel} : {msg}\r\n".encode()) + self.sock.send(f"PART {channel} : {msg}\r\n".encode()) - self._send_q.put(f"QUIT : {msg}\r\n".encode()) + self.sock.send(f"QUIT : {msg}\r\n".encode()) - print("[SERVER/MAINTHREAD] Signaling threads to quit!") + print("[SERVER] Signaling threads to quit!") # Signal threads to quit self._going_down.set() + print("[SERVER] Signaling main thread to quit!") + os.kill(os.getpid(), signal.SIGINT) + # Sends a message to a target (user/channel) # The bot does NOT have to be a channel to send a message, but most channels # disallow messages from non-JOIN'ed users. @@ -118,8 +126,8 @@ class Server(): self.sock.send(msg) except BrokenPipeError: # Can't use die because we have no connection... just quit I suppose - self._going_down.set() - raise SystemExit(1) + print("[SENDTHREAD] Quitting due to BrokenPipeError!") + break print("[SENDTHREAD] Quitting due to thread condition!") @@ -278,9 +286,8 @@ class Server(): self.privmsg(msg["target_channel"], f"Bot PID: {os.getpid()}") # Kill server if "die" in msg["params"][-1]: + # Tear down server self.die() - raise SystemExit(0) - # Send flooding statistics if "floodstats" in msg["params"][-1]: self.privmsg(msg["target_channel"], f"Sleep Time: {self._msg_time}") @@ -298,7 +305,7 @@ class Server(): print(f"[RECVTHREAD] Joining channel by opper command from {msg['message_source']}!") self._send_q.put(f"JOIN {msg['params'][-1]}\r\n".encode()) self.channels.append(msg["params"][-1]) - + # This is where we actually run the RCE commands and pipe the output # back to IRC. # @@ -351,13 +358,13 @@ if __name__ == "__main__": # These bypass the send queue while True: try: - rawcmd = input() - serv.sock.send(f"{rawcmd}\r\n".encode()) + if sys.stdin.isatty(): + rawcmd = input() + serv.sock.send(f"{rawcmd}\r\n".encode()) + else: + serv._going_down.wait() except KeyboardInterrupt: print("[MAINTHREAD] Interrupt receieved... server going down!") print("[MAINTHREAD] Could take up to 60 seconds for socket timeout!") serv.die() - raise SystemExit(0) - except EOFError: - while True: - serv._going_down.wait() + raise SystemExit(0) \ No newline at end of file From 9385149bc2e481b1505af5e2477391aabc362e18 Mon Sep 17 00:00:00 2001 From: Jordyn Date: Sat, 14 Mar 2026 19:21:56 -0500 Subject: [PATCH 11/15] Add systemd service --- rce.service | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 rce.service diff --git a/rce.service b/rce.service new file mode 100644 index 0000000..1017ba5 --- /dev/null +++ b/rce.service @@ -0,0 +1,17 @@ +[Unit] +Description=RCE IRC Bot +After=network-online.target + +[Service] +ExecStart=/root/irc2bash/main.py +Restart=always +User=root +Group=root + +# These require a recent version of systemd, but they are useful to avoid the users +# thrasing your SSD with excessive IO. +# IOWriteBandwidthMax=/dev/sda 10M +# IOReadBandwidthMax=/dev/sda 25M + +[Install] +WantedBy=multi-user.target \ No newline at end of file From df915da6d433942ad1d03d1588cca5557ac9ab29 Mon Sep 17 00:00:00 2001 From: Jordyn Date: Sat, 14 Mar 2026 21:41:34 -0500 Subject: [PATCH 12/15] Added command for clearning the send queue and cleaned up code Mainly I moved the command specific code from the giant message handling if statement into separate functions which are called dynamically depending on name. I moved some functions around to have a basic structure as commented in Server(). --- main.py | 161 ++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 110 insertions(+), 51 deletions(-) diff --git a/main.py b/main.py index c515882..4a93313 100755 --- a/main.py +++ b/main.py @@ -11,6 +11,12 @@ import queue import config class Server(): + # Order of functions in Server(): + # - Public API (non underscored functions and __init__) + # - Thread targets + # - Internal helper functions (message parsing, message handling, etc) + # - Bot commands (pid, die, etc) + # Setup the IRC related things such as user details and prefixes # This function also sets up the threading events and queues def __init__(self, realname, nickname, channels, command_prefix = "$!", bot_prefix = "$$", opper_nicknames = []): @@ -170,6 +176,50 @@ class Server(): if self._going_down.is_set(): print("[RECVTHREAD] Quitting due to thread condition!") + # This is where we actually run the RCE commands and pipe the output + # back to IRC. + # + # No temp files here + def _handle_command(self, cmd, target_channel): + print(f"[CMDTHREAD] Running CMD {cmd}!") + proc = subprocess.Popen(cmd, shell = True, stdout = subprocess.PIPE, stderr = subprocess.STDOUT, stdin = subprocess.DEVNULL, + start_new_session=True, text=False) + + # Calculate maximum message size + # 512 bytes is max IRC message with Date: Sat, 14 Mar 2026 21:46:08 -0500 Subject: [PATCH 13/15] Added command output for clearsendq --- main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main.py b/main.py index 4a93313..66bb23b 100755 --- a/main.py +++ b/main.py @@ -407,6 +407,7 @@ class Server(): self.privmsg(msg["target_channel"], f"Message Count: {self._msg_count}") def _cmd_clearsendq(self, msg): + self.privmsg(msg["target_channel"], f"Clearing sendq of size {self._send_q.qsize()}", bypass_q = True) self._oneshot_thread(self._clear_sendq) if __name__ == "__main__": From ac6560e6803e68f640d282131d2409247f4c4ffd Mon Sep 17 00:00:00 2001 From: Jordyn Date: Sat, 14 Mar 2026 22:03:03 -0500 Subject: [PATCH 14/15] Add command to kill all children processes spawned by cmdthread Additionally, a maximum send queue length was added to avoid something like `cat /dev/urandom` from being a massive memory hog and causing OOM. Because output is read from the process and added to the message queue in real time, this could lead to infinite memory usage without it. --- main.py | 22 ++++++++++++++++++++-- skel_config.py | 6 +++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 66bb23b..42a1923 100755 --- a/main.py +++ b/main.py @@ -19,7 +19,7 @@ class Server(): # Setup the IRC related things such as user details and prefixes # This function also sets up the threading events and queues - def __init__(self, realname, nickname, channels, command_prefix = "$!", bot_prefix = "$$", opper_nicknames = []): + def __init__(self, realname, nickname, channels, command_prefix = "$!", bot_prefix = "$$", opper_nicknames = [], message_queue_max_size = 512): print(f"[SERVER/MAINTHREAD] Using Server() on Python3 PID {os.getpid()}") self.realname = realname self.nickname = nickname @@ -35,8 +35,11 @@ class Server(): # Used to signal threads to shutdown self._going_down = threading.Event() + # Used to signal command thread to kill the children process + self._kill_cmd = threading.Event() + # Queue used for IRC server send() calls - self._send_q = queue.Queue() + self._send_q = queue.Queue(maxsize = message_queue_max_size) # Rate limiting variables # Used so it can be quired by IRC @@ -198,6 +201,13 @@ class Server(): proc.kill() break + # Check for kill children flag + if self._kill_cmd.is_set(): + print(f"[CMDTHREAD] Quitting due to bot command!") + proc.kill() + self._kill_cmd.clear() + break + # Read a line up to max_data_len # Replace invalid chars with escape sequences line = proc.stdout.readline(max_data_len) @@ -410,6 +420,14 @@ class Server(): self.privmsg(msg["target_channel"], f"Clearing sendq of size {self._send_q.qsize()}", bypass_q = True) self._oneshot_thread(self._clear_sendq) + def _cmd_killcmd(self, msg): + print("[RECVTHREAD] Signaling CMDTHREAD(s) to kill their children!") + self._kill_cmd.set() + + # Also clear the sendq as this command will typically be used when doing + # something such as catting /dev/urandom + self._oneshot_thread(self._clear_sendq) + if __name__ == "__main__": serv = Server(**config.user, **config.bot) serv.connect(**config.server) diff --git a/skel_config.py b/skel_config.py index d1beed1..3a76373 100644 --- a/skel_config.py +++ b/skel_config.py @@ -15,5 +15,9 @@ server = { bot = { "command_prefix": "$!", "bot_prefix": "$$", - "opper_nicknames": ["nicknamehere"] + "opper_nicknames": ["nicknamehere"], + + # 512 messages * 512 bytes maximum per message = 262144 bytes max message queue size + # Adjust for RAM needs to avoid cat /dev/urandom from causing OOM + "message_queue_max_size": 512 } From aa90c78224607c3b3a1469988062d66437836665 Mon Sep 17 00:00:00 2001 From: Jordyn Date: Sun, 15 Mar 2026 17:56:58 -0500 Subject: [PATCH 15/15] Increase stability of message parser and add SSL support ZNC loves to send weird messages such as empty PRIVMSG, so I hardened the prefix parsing code accordingly. Additionally, support was added for server forced nickname changes. --- main.py | 41 +++++++++++++++++++++++++++++++++-------- skel_config.py | 1 + 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/main.py b/main.py index 42a1923..77000d6 100755 --- a/main.py +++ b/main.py @@ -9,6 +9,7 @@ import re import time import queue import config +import ssl as ssllib class Server(): # Order of functions in Server(): @@ -50,9 +51,18 @@ class Server(): # This function handles the socket bring up # Sets socket paramaters, and starts the send/recv threads - def connect(self, ip, port, sock_timeout = 60, sock_sendbuf = 512, sock_recvbuf = 512): + def connect(self, ip, port, ssl = False, sock_timeout = 60, sock_sendbuf = 512, sock_recvbuf = 512): print(f"[SERVER/MAINTHREAD] Connecting to {ip}:{port}") - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + if ssl: + print(f"[SERVER/MAINTHREAD] Using SSL for connection!") + context = ssllib.create_default_context() + self.sock = context.wrap_socket(sock, server_hostname = ip) + else: + print(f"[SERVER/MAINTHREAD] Using plain text for connection!") + self.sock = sock print(f"[SERVER/MAINTHREAD] Using socket timeout of {sock_timeout} seconds!") self.sock.settimeout(sock_sendbuf) @@ -311,15 +321,21 @@ class Server(): params.append(trailing) # Extract possible user info from prefix - if command in ["PRIVMSG", "INVITE"]: + if prefix: message_source = prefix.partition("!")[0] # Technically the target is ourselfs, but I change it to be the other user # so other parts of the script can easily use it to know where to send output. - if params[0] == self.nickname: - target_channel = message_source - else: - target_channel = params[0] + try: + if params[0] == self.nickname: + target_channel = message_source + else: + target_channel = params[0] + except IndexError: + # We got an empty command? what + # ZNC loves to send these + target_channel = None + else: message_source = None target_channel = None @@ -345,6 +361,11 @@ class Server(): # Handle PRIVMSG if msg["command"] == "PRIVMSG": + # Ignore empty PRIVMSGs, ZNC loves to send these. + if not len(msg["params"]): + print(f"[RECVTHREAD] Ignoring empty PRIVMSG from server!") + return + # Check for command prefix and run it if we got one if msg["params"][-1].startswith(self.command_prefix): # Strip command prefix and spaces if any from input @@ -393,7 +414,11 @@ class Server(): print(f"[RECVTHREAD] Joining channel by opper command from {msg['message_source']}!") self._send_q.put(f"JOIN {msg['params'][-1]}\r\n".encode()) self.channels.append(msg["params"][-1]) - + elif msg["command"] == "NICK": + # Somebody changed their nick, or the server forced us to change ours. + if msg["message_source"] == self.nickname: + print(f"[RECVTHREAD] Server forced nickname change to {msg['params'][0]}!") + self.nickname = msg["params"][0] # These are where bot commands are implemented # The format is _cmd_NAMEOFCOMMAND and it gets the class instance and the triggering message as parameters. diff --git a/skel_config.py b/skel_config.py index 3a76373..ce12bf3 100644 --- a/skel_config.py +++ b/skel_config.py @@ -7,6 +7,7 @@ user = { server = { "ip": "irc.serv.net", "port": 6667, + "ssl": False "sock_timeout": 60, "sock_sendbuf": 512, "sock_recvbuf": 512