Merge pull request #1 from jcjordyn130/main

Rewrite main.py to be multithreaded and more stable
This commit is contained in:
a-bad-dev 2026-03-14 02:14:40 -03:00 committed by GitHub
commit 1bb79d2679
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

426
main.py Normal file → Executable file
View file

@ -1,138 +1,326 @@
import os, socket, _thread, time
#!/usr/bin/env python3
import os
import socket
import signal
import threading
import subprocess
import re
import time
import queue
class IRC2BASH:
ip: str = "" # up to the user to set these values
port: int = 0
name: str = ""
nick: str = ""
chan: str = ""
send = lambda string: IRC2BASH.sock.send(f"{string}\r\n".encode("utf-8"))
server = (ip, port)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
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 = "$$"):
print(f"[SERVER/MAINTHREAD] Using Server() on Python3 PID {os.getpid()}")
self.realname = realname
self.nickname = nickname
self.channel = channel
def main() -> None:
IRC2BASH.sock.connect(IRC2BASH.server)
# Prefix for commands to execute
self.command_prefix = command_prefix
time.sleep(5) # wait for server to connect
IRC2BASH.send(f"USER {IRC2BASH.nick} * * :{IRC2BASH.name}")
time.sleep(1) # wait for server to process
IRC2BASH.send(f"NICK {IRC2BASH.nick}")
time.sleep(5) # wait for server to process
IRC2BASH.send(f"JOIN {IRC2BASH.chan}")
time.sleep(1)
# Prefix for internal commands
self.bot_prefix = bot_prefix
_thread.start_new_thread(IRC2BASH.receive_messages, (IRC2BASH.sock,))
# Used to signal threads to shutdown
self._going_down = threading.Event()
while True: # ping the server every minute so the connection stays alive
time.sleep(60)
IRC2BASH.send(f"PING :{IRC2BASH.ip}")
# Queue used for messages to send
self._msg_q = queue.Queue()
def run_command(command: str) -> None:
os.system(f"/bin/bash -c \"{command}\" >/tmp/.command 2>&1")
# Rate limiting variables
# Used so it can be quired by IRC
self._msg_time = 0
self._msg_count = 0
try:
with open("/tmp/.command", "r") as f:
output = f.read()
output = output.strip()
print(output)
output = output.split("\n")
for line in output:
IRC2BASH.send(f"PRIVMSG {IRC2BASH.chan} :{line}")
time.sleep(0.75)
print(f"[SERVER/MAINTHREAD] Using nickname {nickname} on {channel}!")
except UnicodeDecodeError:
print(f"UnicodeDecodeError: invalid bytes, caused by: {command_noescapes}")
IRC2BASH.send(f"PRIVMSG {IRC2BASH.chan} :Invalid bytes in response.")
# 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())
self._msg_q.put(f"JOIN {self.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)
return {
"prefix": prefix,
"command": command,
"params": params
}
# 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":
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
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, source_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()
if "sendq" in msg["params"][-1]:
self._msg_q.put(f"PRIVMSG {source_channel} :Send Queue Size: {self._msg_q.qsize()}\r\n".encode())
if "pid" in msg["params"][-1]:
self._msg_q.put(f"PRIVMSG {source_channel} :Bot PID: {os.getpid()}\r\n".encode())
if "die" in msg["params"][-1]:
self.die()
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())
# 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":
msg_source = msg["params"][0]
source_user = msg["prefix"].partition("!~")[0]
if source_user == "jcjordyn120":
print(f"[RECVTHREAD] Joining channel by user command!")
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):
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)
# Calculate maximum message size
# 512 bytes is max IRC message with <IRCv3 and no cap neg
msg_without_data = b"PRIVMSG " + self.channel.encode() + b" :" + b"\r\n"
max_data_len = 512 - len(msg_without_data)
print(f"[CMDTHREAD] Reading from Popen pipe with len = {max_data_len}!")
def receive_messages(sock) -> None:
while True:
data = sock.recv(1024)
if not data:
# Check for quitting flag
if self._going_down.is_set():
print("[CMDTHREAD] Quitting due to thread condition!")
proc.kill()
break
response = data.decode("utf-8")
response = response.strip()
response = response[1:]
response = response.split(" ")
# Read a line up to max_data_len
# Replace invalid chars with escape sequences
line = proc.stdout.readline(max_data_len)
line = line.decode("utf8", errors = "backslashreplace")
#
# attempt to find a username in part #1
#
# Check for empty data
if not line:
break
try:
username = response[0]
username = username.split("!")
username = username[0]
except Exception:
username = ""
#
# attempt to find the channel name
#
# Remove carraige return to avoid confusing IRC server
line = line.replace("\r", "")
try:
if response[1] == "PRIVMSG" and response[2] == IRC2BASH.chan: # correct channel
channel = response[2]
else:
channel = ""
except Exception:
channel = ""
# Strip rouge newlines from data
line = line.replace("\r\n", "")
line = line.replace("\n", "")
#
# attempt to parse the actual message
#
self._msg_q.put(b"PRIVMSG " + source_channel.encode() + b" :" + line.encode() + b"\r\n")
try:
message = response[3:]
message = " ".join(message)
message = message.strip()
message = message[1:]
except Exception:
message = ""
#
# see if the message is a command
#
try:
if message.startswith("$ "):
command = message[2:]
else:
command = ""
except Exception:
command = ""
#
# we now (probably) have the command :D, time to run it
#
if username != "" and channel != "" and message != "" and command != "":
#
# escape quotation marks and backslashes
#
command_list = []
command_noescapes = command
for letter in command:
match letter:
case "\\":
command_list.append("\\\\")
case "\"":
command_list.append("\\\"")
case _:
command_list.append(letter)
command = "".join(comand_list)
print(f"{channel}: <{username}> {command}")
_thread.start_new_thread(IRC2BASH.run_command, (command,))
# Wait is required to fetch exit code
proc.wait()
self._msg_q.put(f"PRIVMSG {source_channel} :CMD {cmd} exited with returncode {proc.returncode}\r\n".encode())
if __name__ == "__main__":
IRC2BASH.main()
serv = Server("RCE", "rce-is-fun-jcj", "##jcj2")
serv.connect("tantalum.libera.chat", 6667, sock_recvbuf = 8192)
# Allow for the user to send raw IRC messages
# These bypass the send queue
while True:
try:
rawcmd = input()
serv.sock.send(f"{rawcmd}\r\n".encode())
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)