From f9f55ae5bcfe7638a92e3d5a9d03c730e32250ea Mon Sep 17 00:00:00 2001 From: Master Date: Fri, 2 Jun 2023 02:32:31 +0800 Subject: [PATCH] add miniircd --- irc/makefile | 2 + irc/miniircd.py | 1288 ++++++++++++++++++++++++ static/fixed_responses/login_static.ts | 2 +- 3 files changed, 1291 insertions(+), 1 deletion(-) create mode 100644 irc/makefile create mode 100644 irc/miniircd.py diff --git a/irc/makefile b/irc/makefile new file mode 100644 index 00000000..af10266b --- /dev/null +++ b/irc/makefile @@ -0,0 +1,2 @@ +run: + python miniircd.py --debug --ports 6699 --ssl-cert-file ..\static\certs\cert.pem --ssl-key-file ..\static\certs\key.pem \ No newline at end of file diff --git a/irc/miniircd.py b/irc/miniircd.py new file mode 100644 index 00000000..8b26aaf8 --- /dev/null +++ b/irc/miniircd.py @@ -0,0 +1,1288 @@ +#!/usr/bin/env python3 + +import logging +import os +import re +import select +import socket +import string +import sys +import tempfile +import time + +from argparse import ArgumentParser, Namespace +from datetime import datetime +from logging.handlers import RotatingFileHandler +from pathlib import Path +from typing import Any, Collection, Dict, List, Optional, Sequence, Set + +Socket = socket.socket + +VERSION = "2.2" + +class Channel: + def __init__(self, server: "Server", name: bytes) -> None: + self.server = server + self.name = name + self.members: Set["Client"] = set() + self._topic = b"" + self._key: Optional[bytes] = None + self._state_path: Optional[Path] + if self.server.state_dir: + fs_safe_name = ( + name.decode(errors="ignore") + .replace("_", "__") + .replace("/", "_") + ) + self._state_path = self.server.state_dir / fs_safe_name + self._read_state() + else: + self._state_path = None + + def add_member(self, client: "Client") -> None: + self.members.add(client) + + @property + def topic(self) -> bytes: + return self._topic + + @topic.setter + def topic(self, value: bytes) -> None: + self._topic = value + self._write_state() + + @property + def key(self) -> Optional[bytes]: + return self._key + + @key.setter + def key(self, value: bytes) -> None: + self._key = value + self._write_state() + + def remove_client(self, client: "Client") -> None: + self.members.discard(client) + if not self.members: + self.server.remove_channel(self) + + def _read_state(self) -> None: + if not (self._state_path and self._state_path.exists()): + return + data: Dict[str, Any] = {} + + exec(self._state_path.read_bytes(), {}, data) + self._topic = data.get("topic", "") + self._key = data.get("key") + + def _write_state(self) -> None: + if not self._state_path: + return + fd, path = tempfile.mkstemp(dir=self._state_path.parent) + fp = os.fdopen(fd, "w") + fp.write("topic = %r\n" % self.topic) + fp.write("key = %r\n" % self.key) + fp.close() + os.replace(path, self._state_path) + + +class Client: + __linesep_regexp = re.compile(rb"\r?\n") + # The RFC limit for nicknames is 9 characters, but what the heck. + __valid_nickname_regexp = re.compile( + rb"^[][\`_^{|}A-Za-z][][\`_^{|}A-Za-z0-9-]{0,50}$" + ) + __valid_channelname_regexp = re.compile( + rb"^[&#+!][^\x00\x07\x0a\x0d ,:]{0,50}$" + ) + + def __init__(self, server: "Server", socket: Socket) -> None: + self.server = server + self.socket = socket + # irc_lower(Channel name) --> Channel + self.channels: Dict[bytes, Channel] = {} + self.nickname = b"" + self.user = b"" + self.realname = b"" + if self.server.ipv6: + host, port, _, _ = socket.getpeername() + else: + host, port = socket.getpeername() + assert isinstance(host, str) + assert isinstance(port, int) + self.host = host.encode() + self.port = port + if self.server.cloak: + self.host = self.server.cloak.encode() + self.__timestamp = time.time() + self.__readbuffer = b"" + self.__writebuffer = b"" + self.__sent_ping = False + if self.server.password: + self.__handle_command = self.__pass_handler + else: + self.__handle_command = self.__registration_handler + self.reply(b"NOTICE * :Auth C5E20E78:*** skipping identd (disabled by server administrator)..") + + @property + def prefix(self) -> bytes: + return b"%s!%s@%s" % (self.nickname, self.user, self.host) + + def check_aliveness(self) -> None: + now = time.time() + if self.__timestamp + 180 < now: + self.disconnect("ping timeout") + return + if not self.__sent_ping and self.__timestamp + 90 < now: + if self.__handle_command == self.__command_handler: + # Registered. + self.message(b"PING :%s" % self.server.name) + self.__sent_ping = True + else: + # Not registered. + self.disconnect("ping timeout") + + def write_queue_size(self) -> int: + return len(self.__writebuffer) + + def __parse_read_buffer(self) -> None: + lines = self.__linesep_regexp.split(self.__readbuffer) + self.__readbuffer = lines[-1] + lines = lines[:-1] + for line in lines: + if not line: + # Empty line. Ignore. + continue + x = line.split(b" ", 1) + command = x[0].upper() + if len(x) == 1: + arguments = [] + elif x[1].startswith(b":"): + arguments = [x[1][1:]] + else: + y = x[1].split(b" :", 1) + arguments = y[0].split() + if len(y) == 2: + arguments.append(y[1]) + self.__handle_command(command, arguments) + + def __pass_handler( + self, command: bytes, arguments: Sequence[bytes] + ) -> None: + server = self.server + if command == b"PASS": + if len(arguments) == 0: + self.reply_461(b"PASS") + elif arguments[0] == server.password.encode(): + self.__handle_command = self.__registration_handler + else: + self.reply(b"464 :Password incorrect") + elif command == b"QUIT": + self.disconnect("Client quit") + + def __registration_handler( + self, command: bytes, arguments: Sequence[bytes] + ) -> None: + server = self.server + if command == b"NICK": + if len(arguments) < 1: + self.reply(b"431 :No nickname given") + return + nick = arguments[0] + if server.get_client(nick): + self.reply(b"433 * %s :Nickname is already in use" % nick) + # elif not self.__valid_nickname_regexp.match(nick): + # self.reply(b"432 * %s :Erroneous nickname" % nick) + else: + self.nickname = nick + server.client_changed_nickname(self, None) + elif command == b"USER": + if len(arguments) < 4: + self.reply_461(b"USER") + return + self.user = arguments[0] + self.realname = arguments[3] + elif command == b"QUIT": + self.disconnect("Client quit") + return + if self.nickname and self.user: + self.reply(b"001 %s :Hi, welcome to IRC" % self.nickname) + self.reply( + b"002 %s :Your host is %s, running version miniircd-%s" + % (self.nickname, server.name, VERSION.encode()) + ) + self.reply( + b"003 %s :This server was created sometime" % self.nickname + ) + self.reply( + b"004 %s %s miniircd-%s o o" + % (self.nickname, server.name, VERSION.encode()) + ) + self.reply( + b"005 %s ACCEPT=30 AWAYLEN=200 CALLERID=g CASEMAPPING=rfc1459 CHANLIMIT=#:20 CHANMODES=b,k,l,Dimnpst CHANNELLEN=64 CHANTYPES=# CHARSET=utf-8 ELIST=CMNTU HOSTLEN=64 KEYLEN=32 :are supported by this server.." + % self.nickname + ) + self.reply( + b"005 %s KICKLEN=255 LINELEN=512 MAXLIST=b:4096 MAXTARGETS=20 MODES=20 NAMELEN=128 NETWORK=war-irc NICKLEN=54 PREFIX=(ohv)@%s SAFELIST STATUSMSG=@%s TOPICLEN=307 USERLEN=64 :are supported by this server.." + % (self.nickname, b"%+", b"%+") + ) + self.reply( + b"005 %s USERMODES=,,s,giowx WHOX :are supported by this server.." + % self.nickname + ) + self.send_lusers() + self.send_motd() + self.reply( + b"396 %s %s :is now your displayed host.." + % (self.nickname, b"net-64o.b32.9uu0l2.IP") + ) + self.__handle_command = self.__command_handler + + def __send_names( + self, arguments: Sequence[bytes], for_join: bool = False + ) -> None: + server = self.server + valid_channel_re = self.__valid_channelname_regexp + if len(arguments) > 0: + channelnames = arguments[0].split(b",") + else: + channelnames = sorted(self.channels.keys()) + if len(arguments) > 1: + keys = arguments[1].split(b",") + else: + keys = [] + for i, channelname in enumerate(channelnames): + if for_join and irc_lower(channelname) in self.channels: + continue + if not valid_channel_re.match(channelname): + self.reply_403(channelname) + continue + channel = server.get_channel(channelname) + if channel.key is not None and ( + len(keys) <= i or channel.key != keys[i] + ): + self.reply( + b"475 %s %s :Cannot join channel (+k) - bad key" + % (self.nickname, channelname) + ) + continue + + if for_join: + channel.add_member(self) + self.channels[irc_lower(channelname)] = channel + self.message_channel(channel, b"JOIN", channelname, True) + self.channel_log(channel, b"joined", meta=True) + if channel.topic: + self.reply( + b"332 %s %s :%s" + % (self.nickname, channel.name, channel.topic) + ) + else: + self.reply( + b"331 %s %s :No topic is set" + % (self.nickname, channel.name) + ) + names_prefix = b"353 %s = %s :" % (self.nickname, channelname) + names = b"" + # Max length: reply prefix ":server_name(space)" plus CRLF in + # the end. + names_max_len = 512 - (len(server.name) + 2 + 2) + for name in sorted(x.nickname for x in channel.members): + if not names: + names = names_prefix + name + # Using >= to include the space between "names" and "name". + elif len(names) + len(name) >= names_max_len: + self.reply(names) + names = names_prefix + name + else: + names += b" " + name + if names: + self.reply(names) + self.reply( + b"366 %s %s :End of NAMES list" % (self.nickname, channelname) + ) + + def __command_handler( + self, command: bytes, arguments: Sequence[bytes] + ) -> None: + def away_handler() -> None: + pass + + def ison_handler() -> None: + if len(arguments) < 1: + self.reply_461(b"ISON") + return + nicks = arguments + online = [n for n in nicks if server.get_client(n)] + self.reply(b"303 %s :%s" % (self.nickname, b" ".join(online))) + + def join_handler() -> None: + if len(arguments) < 1: + self.reply_461(b"JOIN") + return + if arguments[0] == b"0": + for channelname, channel in self.channels.items(): + self.message_channel(channel, b"PART", channelname, True) + self.channel_log(channel, b"left", meta=True) + server.remove_member_from_channel(self, channelname) + self.channels = {} + return + self.__send_names(arguments, for_join=True) + + def list_handler() -> None: + if len(arguments) < 1: + channels = list(server.channels.values()) + else: + channels = [] + for channelname in arguments[0].split(b","): + if server.has_channel(channelname): + channels.append(server.get_channel(channelname)) + + sorted_channels = sorted(channels, key=lambda x: x.name) + for channel in sorted_channels: + self.reply( + b"322 %s %s %d :%s" + % ( + self.nickname, + channel.name, + len(channel.members), + channel.topic, + ) + ) + self.reply(b"323 %s :End of LIST" % self.nickname) + + def lusers_handler() -> None: + self.send_lusers() + + def mode_handler() -> None: + if len(arguments) < 1: + self.reply_461(b"MODE") + return + targetname = arguments[0] + if server.has_channel(targetname): + channel = server.get_channel(targetname) + if len(arguments) < 2: + if channel.key: + modes = b"+k" + if irc_lower(channel.name) in self.channels: + modes += b" %s" % channel.key + else: + modes = b"+" + self.reply( + b"324 %s %s %s" % (self.nickname, targetname, modes) + ) + return + flag = arguments[1] + if flag == b"+k": + if len(arguments) < 3: + self.reply_461(b"MODE") + return + key = arguments[2] + if irc_lower(channel.name) in self.channels: + channel.key = key + self.message_channel( + channel, + b"MODE", + b"%s +k %s" % (channel.name, key), + True, + ) + self.channel_log( + channel, b"set channel key to %s" % key, meta=True + ) + else: + self.reply( + b"442 %s :You're not on that channel" % targetname + ) + elif flag == b"-k": + if irc_lower(channel.name) in self.channels: + channel.key = None + self.message_channel( + channel, b"MODE", b"%s -k" % channel.name, True + ) + self.channel_log( + channel, b"removed channel key", meta=True + ) + else: + self.reply( + b"442 %s :You're not on that channel" % targetname + ) + else: + self.reply( + b"472 %s %s :Unknown MODE flag" % (self.nickname, flag) + ) + elif targetname == self.nickname: + if len(arguments) == 1: + self.reply(b"221 %s +" % self.nickname) + else: + self.reply(b"501 %s :Unknown MODE flag" % self.nickname) + else: + self.reply_403(targetname) + + def motd_handler() -> None: + self.send_motd() + + def names_handler() -> None: + self.__send_names(arguments) + + def nick_handler() -> None: + if len(arguments) < 1: + self.reply(b"431 :No nickname given") + return + newnick = arguments[0] + client = server.get_client(newnick) + if newnick == self.nickname: + pass + elif client and client is not self: + self.reply( + b"433 %s %s :Nickname is already in use" + % (self.nickname, newnick) + ) + elif not self.__valid_nickname_regexp.match(newnick): + self.reply( + b"432 %s %s :Erroneous Nickname" % (self.nickname, newnick) + ) + else: + for x in self.channels.values(): + self.channel_log( + x, b"changed nickname to %s" % newnick, meta=True + ) + oldnickname = self.nickname + self.nickname = newnick + server.client_changed_nickname(self, oldnickname) + self.message_related( + b":%s!%s@%s NICK %s" + % (oldnickname, self.user, self.host, self.nickname), + True, + ) + + def notice_and_privmsg_handler() -> None: + if len(arguments) == 0: + self.reply( + b"411 %s :No recipient given (%s)" + % (self.nickname, command) + ) + return + if len(arguments) == 1: + self.reply(b"412 %s :No text to send" % self.nickname) + return + targetname = arguments[0] + message = arguments[1] + client = server.get_client(targetname) + if client: + client.message( + b":%s %s %s :%s" + % (self.prefix, command, targetname, message) + ) + elif server.has_channel(targetname): + channel = server.get_channel(targetname) + self.message_channel( + channel, command, b"%s :%s" % (channel.name, message) + ) + self.channel_log(channel, message) + else: + self.reply( + b"401 %s %s :No such nick/channel" + % (self.nickname, targetname) + ) + + def part_handler() -> None: + if len(arguments) < 1: + self.reply_461(b"PART") + return + if len(arguments) > 1: + partmsg = arguments[1] + else: + partmsg = self.nickname + for channelname in arguments[0].split(b","): + if not valid_channel_re.match(channelname): + self.reply_403(channelname) + elif not irc_lower(channelname) in self.channels: + self.reply( + b"442 %s %s :You're not on that channel" + % (self.nickname, channelname) + ) + else: + channel = self.channels[irc_lower(channelname)] + self.message_channel( + channel, + b"PART", + b"%s :%s" % (channelname, partmsg), + True, + ) + self.channel_log( + channel, b"left (%s)" % partmsg, meta=True + ) + del self.channels[irc_lower(channelname)] + server.remove_member_from_channel(self, channelname) + + def ping_handler() -> None: + if len(arguments) < 1: + self.reply(b"409 %s :No origin specified" % self.nickname) + return + self.reply(b"PONG %s :%s" % (server.name, arguments[0])) + + def pong_handler() -> None: + pass + + def quit_handler() -> None: + if len(arguments) < 1: + quitmsg = self.nickname + else: + quitmsg = arguments[0] + self.disconnect(quitmsg.decode(errors="ignore")) + + def topic_handler() -> None: + if len(arguments) < 1: + self.reply_461(b"TOPIC") + return + channelname = arguments[0] + channel = self.channels.get(irc_lower(channelname)) + if channel: + if len(arguments) > 1: + newtopic = arguments[1] + channel.topic = newtopic + self.message_channel( + channel, + b"TOPIC", + b"%s :%s" % (channelname, newtopic), + True, + ) + self.channel_log( + channel, b'set topic to "%s"' % newtopic, meta=True + ) + else: + if channel.topic: + self.reply( + b"332 %s %s :%s" + % (self.nickname, channel.name, channel.topic) + ) + else: + self.reply( + b"331 %s %s :No topic is set" + % (self.nickname, channel.name) + ) + else: + self.reply(b"442 %s :You're not on that channel" % channelname) + + def wallops_handler() -> None: + if len(arguments) < 1: + self.reply_461(b"WALLOPS") + return + message = arguments[0] + for client in server.clients.values(): + client.message( + b":%s NOTICE %s :Global notice: %s" + % (self.prefix, client.nickname, message) + ) + + def who_handler() -> None: + if len(arguments) < 1: + return + targetname = arguments[0] + if server.has_channel(targetname): + channel = server.get_channel(targetname) + for member in channel.members: + self.reply( + b"352 %s %s %s %s %s %s H :0 %s" + % ( + self.nickname, + targetname, + member.user, + member.host, + server.name, + member.nickname, + member.realname, + ) + ) + self.reply( + b"315 %s %s :End of WHO list" % (self.nickname, targetname) + ) + + def whois_handler() -> None: + if len(arguments) < 1: + return + username = arguments[0] + user = server.get_client(username) + if user: + self.reply( + b"311 %s %s %s %s * :%s" + % ( + self.nickname, + user.nickname, + user.user, + user.host, + user.realname, + ) + ) + self.reply( + b"312 %s %s %s :%s" + % (self.nickname, user.nickname, server.name, server.name) + ) + self.reply( + b"319 %s %s :%s" + % ( + self.nickname, + user.nickname, + b"".join(x + b" " for x in user.channels), + ) + ) + self.reply( + b"318 %s %s :End of WHOIS list" + % (self.nickname, user.nickname) + ) + else: + self.reply( + b"401 %s %s :No such nick" % (self.nickname, username) + ) + + handler_table = { + b"AWAY": away_handler, + b"ISON": ison_handler, + b"JOIN": join_handler, + b"LIST": list_handler, + b"LUSERS": lusers_handler, + b"MODE": mode_handler, + b"MOTD": motd_handler, + b"NAMES": names_handler, + b"NICK": nick_handler, + b"NOTICE": notice_and_privmsg_handler, + b"PART": part_handler, + b"PING": ping_handler, + b"PONG": pong_handler, + b"PRIVMSG": notice_and_privmsg_handler, + b"QUIT": quit_handler, + b"TOPIC": topic_handler, + b"WALLOPS": wallops_handler, + b"WHO": who_handler, + b"WHOIS": whois_handler, + } + server = self.server + valid_channel_re = self.__valid_channelname_regexp + try: + handler_table[command]() + except KeyError: + self.reply( + b"421 %s %s :Unknown command" % (self.nickname, command) + ) + + def socket_readable_notification(self) -> None: + try: + data = self.socket.recv(2**10) + if self.server.debug: + host = self.host.decode(errors="ignore") + self.server.print_debug(f"[{host}:{self.port}] -> {data!r}") + quitmsg = "EOT" + except socket.error as e: + data = b"" + quitmsg = str(e) + if data: + self.__readbuffer += data + self.__parse_read_buffer() + self.__timestamp = time.time() + self.__sent_ping = False + else: + self.disconnect(quitmsg) + + def socket_writable_notification(self) -> None: + try: + sent = self.socket.send(self.__writebuffer) + if self.server.debug: + head = self.__writebuffer[:sent] + host = self.host.decode(errors="ignore") + self.server.print_debug(f"[{host}:{self.port}] <- {head!r}") + self.__writebuffer = self.__writebuffer[sent:] + except socket.error as x: + self.disconnect(str(x)) + + def disconnect(self, quitmsg: str) -> None: + self.message((f"ERROR :{quitmsg}").encode()) + host = self.host.decode(errors="ignore") + self.server.print_info( + f"Disconnected connection from {host}:{self.port} ({quitmsg})." + ) + self.socket.close() + self.server.remove_client(self, quitmsg.encode()) + + def message(self, msg: bytes) -> None: + self.__writebuffer += msg + b"\r\n" + + def reply(self, msg: bytes) -> None: + self.message(b":%s %s" % (self.server.name, msg)) + + def reply_403(self, channel: bytes) -> None: + self.reply(b"403 %s %s :No such channel" % (self.nickname, channel)) + + def reply_461(self, command: bytes) -> None: + nickname = self.nickname or b"*" + self.reply(b"461 %s %s :Not enough parameters" % (nickname, command)) + + def message_channel( + self, + channel: Channel, + command: bytes, + message: bytes, + include_self: bool = False, + ) -> None: + line = b":%s %s %s" % (self.prefix, command, message) + for client in channel.members: + if client != self or include_self: + client.message(line) + + def channel_log( + self, channel: Channel, message: bytes, meta: bool = False + ) -> None: + if not self.server.channel_log_dir: + return + if meta: + format_string = "[{}] * {} {}\n" + else: + format_string = "[{}] <{}> {}\n" + timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC") + channel_name = irc_lower(channel.name).decode(errors="ignore") + logname = channel_name.replace("_", "__").replace("/", "_") + logfile = self.server.channel_log_dir / f"{logname}.log" + logmsg = format_string.format( + timestamp, + self.nickname.decode(errors="replace"), + message.decode(errors="replace"), + ) + with open(logfile, "a") as fp: + fp.write(logmsg) + + def message_related(self, msg: bytes, include_self: bool = False) -> None: + clients = set() + if include_self: + clients.add(self) + for channel in self.channels.values(): + clients |= channel.members + if not include_self: + clients.discard(self) + for client in clients: + client.message(msg) + + def send_lusers(self) -> None: + self.reply( + b"251 %s :There are %d users and 0 services on 1 server" + % (self.nickname, len(self.server.clients)) + ) + self.reply( + b"252 %s %d :operator(s) online.." + % (self.nickname, 1) + ) + self.reply( + b"253 %s %d :unknown connections.." + % (self.nickname, 1) + ) + self.reply( + b"254 %s %d :channels formed.." + % (self.nickname, 1) + ) + self.reply( + b"255 %s :I have %d clients and %d servers.." + % (self.nickname, 1, 1) + ) + self.reply( + b"265 %s :Current local users: %d Max: %d.." + % (self.nickname, 1, 1) + ) + self.reply( + b"266 %s :Current global users: %d Max: %d.." + % (self.nickname, 1, 1) + ) + + def send_motd(self) -> None: + server = self.server + motdlines = server.get_motd_lines() + if motdlines: + self.reply( + b"375 %s :- %s Message of the day -" + % (self.nickname, server.name) + ) + for line in motdlines: + self.reply( + b"372 %s :- %s" % (self.nickname, line.rstrip().encode()) + ) + self.reply(b"376 %s :End of /MOTD command" % self.nickname) + else: + self.reply(b"422 %s :Message of the day file is missing..." % self.nickname) + + +class Server: + def __init__(self, args: Namespace, ports: Collection[int]) -> None: + self.ports = ports + self.password: str = args.password + self.motdfile: Path = args.motd + self.verbose: bool = args.verbose + self.ipv6: bool = args.ipv6 + self.debug: bool = args.debug + self.channel_log_dir: Path = args.channel_log_dir + self.chroot: Path = args.chroot + self.setuid: tuple[int, int] = args.setuid + self.state_dir: Path = args.state_dir + self.log_file: Path = args.log_file + self.log_max_bytes: int = args.log_max_size * 1024 * 1024 + self.log_count: int = args.log_count + self.logger: Optional[logging.Logger] = None + self.cloak: str = args.cloak + self.name: bytes = b"i1.local" + self.ssl_context: "Optional[ssl.SSLContext]" + + if args.password_file: + self.password = args.password_file.read_text().strip("\n") + + if args.ssl_key_file: + import ssl + + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_context.load_default_certs(purpose=ssl.Purpose.SERVER_AUTH) + ssl_context.load_cert_chain( + certfile=args.ssl_cert_file, keyfile=args.ssl_key_file + ) + self.ssl_context = ssl_context + else: + self.ssl_context = None + + # Find key/cert files after daemonization if path is relative: + if args.ssl_cert_file and args.ssl_cert_file.exists(): + args.ssl_cert_file = args.ssl_cert_file.resolve() + if args.ssl_key_file and args.ssl_key_file.exists(): + args.ssl_key_file = args.ssl_key_file.resolve() + # else: might exist in the chroot jail, so just continue + + if args.listen and self.ipv6: + self.address = socket.getaddrinfo( + args.listen, None, proto=socket.IPPROTO_TCP + )[0][4][0] + elif args.listen: + self.address = socket.gethostbyname(args.listen) + else: + self.address = "" + server_name_limit = 63 # From the RFC. + self.name = socket.getfqdn(self.address)[:server_name_limit].encode() + + self.channels: Dict[bytes, Channel] = {} # key: irc_lower(channelname) + self.clients: Dict[Socket, Client] = {} + self.nicknames: Dict[bytes, Client] = {} # key: irc_lower(nickname) + if self.channel_log_dir: + self.channel_log_dir.mkdir(parents=True, exist_ok=True) + if self.state_dir: + self.state_dir.mkdir(parents=True, exist_ok=True) + + def make_pid_file(self, filename: str) -> None: + try: + fd = os.open(filename, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o644) + os.write(fd, b"%i\n" % os.getpid()) + os.close(fd) + except Exception: + self.print_error(f"Could not create PID file {filename!r}") + sys.exit(1) + + def remove_pid_file(self, filename: Path) -> None: + try: + filename.unlink() + except Exception: + self.print_error(f"Could not remove PID file {filename!r}") + + def daemonize(self) -> None: + try: + pid = os.fork() + if pid > 0: + sys.exit(0) + except OSError: + sys.exit(1) + os.setsid() + try: + pid = os.fork() + if pid > 0: + self.print_info("PID: %d" % pid) + sys.exit(0) + except OSError: + sys.exit(1) + os.chdir("/") + os.umask(0) + dev_null = open("/dev/null", "r+") + os.dup2(dev_null.fileno(), sys.stdout.fileno()) + os.dup2(dev_null.fileno(), sys.stderr.fileno()) + os.dup2(dev_null.fileno(), sys.stdin.fileno()) + + def get_client(self, nickname: bytes) -> Optional[Client]: + return self.nicknames.get(irc_lower(nickname)) + + def has_channel(self, name: bytes) -> bool: + return irc_lower(name) in self.channels + + def get_channel(self, channelname: bytes) -> Channel: + if irc_lower(channelname) in self.channels: + channel = self.channels[irc_lower(channelname)] + else: + channel = Channel(self, channelname) + self.channels[irc_lower(channelname)] = channel + return channel + + def get_motd_lines(self) -> Collection[str]: + if self.motdfile: + try: + return self.motdfile.read_text().splitlines() + except IOError: + return ["Could not read MOTD file {self.motdfile!r}."] + else: + return [] + + def print_info(self, msg: str) -> None: + if self.verbose: + print(msg) + sys.stdout.flush() + if self.logger: + self.logger.info(msg) + + def print_debug(self, msg: str) -> None: + if self.debug: + print(msg) + sys.stdout.flush() + if self.logger: + self.logger.debug(msg) + + def print_error(self, msg: str) -> None: + sys.stderr.write(f"{msg}\n") + if self.logger: + self.logger.error(msg) + + def client_changed_nickname( + self, client: Client, oldnickname: Optional[bytes] + ) -> None: + if oldnickname: + del self.nicknames[irc_lower(oldnickname)] + self.nicknames[irc_lower(client.nickname)] = client + + def remove_member_from_channel( + self, client: Client, channelname: bytes + ) -> None: + if irc_lower(channelname) in self.channels: + channel = self.channels[irc_lower(channelname)] + channel.remove_client(client) + + def remove_client(self, client: Client, quitmsg: bytes) -> None: + client.message_related(b":%s QUIT :%s" % (client.prefix, quitmsg)) + for x in client.channels.values(): + client.channel_log(x, b"quit (%s)" % quitmsg, meta=True) + x.remove_client(client) + if client.nickname and irc_lower(client.nickname) in self.nicknames: + del self.nicknames[irc_lower(client.nickname)] + del self.clients[client.socket] + + def remove_channel(self, channel: Channel) -> None: + del self.channels[irc_lower(channel.name)] + + def start(self) -> None: + serversockets = [] + for port in self.ports: + s = socket.socket( + socket.AF_INET6 if self.ipv6 else socket.AF_INET, + socket.SOCK_STREAM, + ) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + s.bind((self.address, port)) + except socket.error as e: + self.print_error(f"Could not bind port {port}: {e}.") + sys.exit(1) + s.listen(5) + serversockets.append(s) + del s + self.print_info(f"Listening on port {port}.") + if self.chroot: + os.chdir(self.chroot) + os.chroot(self.chroot) + self.print_info(f"Changed root directory to {self.chroot}") + if self.setuid: + os.setgid(self.setuid[1]) + os.setuid(self.setuid[0]) + self.print_info( + f"Setting uid:gid to {self.setuid[0]}:{self.setuid[1]}" + ) + + self.init_logging() + try: + self.run(serversockets) + except Exception: + if self.logger: + self.logger.exception("Fatal exception") + raise + + def init_logging(self) -> None: + if not self.log_file: + return + + log_level = logging.INFO + if self.debug: + log_level = logging.DEBUG + self.logger = logging.getLogger("miniircd") + formatter = logging.Formatter( + "%(asctime)s - %(name)s[%(process)d] - %(levelname)s - %(message)s" + ) + fh = RotatingFileHandler( + self.log_file, + maxBytes=self.log_max_bytes, + backupCount=self.log_count, + ) + fh.setLevel(log_level) + fh.setFormatter(formatter) + self.logger.setLevel(log_level) + self.logger.addHandler(fh) + + def run(self, serversockets: List[Socket]) -> None: + last_aliveness_check = time.time() + while True: + iwtd, owtd, ewtd = select.select( + serversockets + [x.socket for x in self.clients.values()], + [ + x.socket + for x in self.clients.values() + if x.write_queue_size() > 0 + ], + [], + 10, + ) + for x in iwtd: + if x in self.clients: + self.clients[x].socket_readable_notification() + else: + self._handle_server_socket(x) + for x in owtd: + if x in self.clients: # client may have been disconnected + self.clients[x].socket_writable_notification() + now = time.time() + # if last_aliveness_check + 10 < now: + # for client in list(self.clients.values()): + # client.check_aliveness() + # last_aliveness_check = now + + def _handle_server_socket(self, server: Socket) -> None: + client, addr = server.accept() + if self.ssl_context: + try: + client = self.ssl_context.wrap_socket(client, server_side=True) + except Exception as e: + self.print_error( + f"SSL error for connection from {addr[0]}:{addr[1]}: {e}" + ) + return + try: + self.clients[client] = Client(self, client) + self.print_info(f"Accepted connection from {addr[0]}:{addr[1]}.") + except socket.error: + try: + client.close() + except Exception: + pass + + +_ircstring_translation = bytes.maketrans( + (string.ascii_lowercase.upper() + "[]\\^").encode(), + (string.ascii_lowercase + "{}|~").encode(), +) + + +def irc_lower(s: bytes) -> bytes: + return s.translate(_ircstring_translation) + + +def main() -> None: + ap = ArgumentParser( + description="miniircd is a small and limited IRC server.", + ) + ap.add_argument("--version", action="version", version=VERSION) + ap.add_argument( + "--channel-log-dir", + metavar="X", + help="store channel log in directory X", + type=Path, + ) + if os.name == "posix": + ap.add_argument( + "--chroot", + metavar="X", + help="change filesystem root to directory X after startup" + " (requires root)", + type=Path, + ) + ap.add_argument( + "--cloak", metavar="X", help="report X as the host for all clients" + ) + ap.add_argument( + "-d", "--daemon", action="store_true", help="fork and become a daemon" + ) + ap.add_argument("--ipv6", action="store_true", help="use IPv6") + ap.add_argument( + "--debug", action="store_true", help="print debug messages to stdout" + ) + ap.add_argument( + "--listen", metavar="X", help="listen on specific IP address X" + ) + ap.add_argument( + "--log-count", + metavar="X", + default=10, + type=int, + help="keep X log files; default: %(default)s", + ) + ap.add_argument( + "--log-file", metavar="X", help="store log in file X", type=Path + ) + ap.add_argument( + "--log-max-size", + metavar="X", + default=10, + type=int, + help="set maximum log file size to X MiB; default: %(default)s MiB", + ) + ap.add_argument( + "--motd", + metavar="X", + help="display file X as message of the day", + type=Path, + ) + ap.add_argument( + "--pid-file", metavar="X", help="write PID to file X", type=Path + ) + ap.add_argument( + "-p", + "--password", + metavar="X", + help="require connection password X; default: no password", + ) + ap.add_argument( + "--password-file", + metavar="X", + help=( + "require connection password stored in file X;" + " default: no password" + ), + type=Path, + ) + ap.add_argument( + "--ports", + metavar="X", + help="listen to ports X (a list separated by comma or whitespace);" + " default: 6667 or 6697 if SSL is enabled", + ) + if os.name == "posix": + ap.add_argument( + "--setuid", + metavar="U[:G]", + help="change process user (and optionally group) after startup" + " (requires root)", + ) + ap.add_argument( + "--ssl-cert-file", + metavar="FILE", + help="enable SSL with PEM certificate in FILE", + type=Path, + ) + ap.add_argument( + "--ssl-key-file", + metavar="FILE", + help="enable SSL with PEM key in FILE", + type=Path, + ) + ap.add_argument( + "-s", + "--ssl-pem-file", + metavar="FILE", + help="enable SSL with key and certificate combined in FILE", + type=Path, + ) + ap.add_argument( + "--state-dir", + metavar="X", + help="save persistent channel state (topic, key) in directory X", + type=Path, + ) + ap.add_argument( + "--verbose", + action="store_true", + help="be verbose (print some progress messages to stdout)", + ) + + args = ap.parse_args() + + if bool(args.ssl_cert_file) != bool(args.ssl_key_file): + args.error("Must specify both --ssl-cert-file and --ssl-key-file") + if args.ssl_pem_file: + if args.ssl_cert_file: + args.error( + "Cannot specify both --ssl-pem-file and --ssl-cert-file" + ) + args.ssl_cert_file = args.ssl_pem_file + args.ssl_key_file = args.ssl_pem_file + + if os.name != "posix": + args.chroot = False + args.setuid = False + if args.debug: + args.verbose = True + if args.ports is None: + if args.ssl_key_file is None: + args.ports = "6667" + else: + args.ports = "6697" + if args.chroot and os.getuid() != 0: + ap.error("Must be root to use --chroot") + if args.setuid: + from pwd import getpwnam + from grp import getgrnam + + if os.getuid() != 0: + ap.error("Must be root to use --setuid") + matches = args.setuid.split(":") + if len(matches) == 2: + args.setuid = ( + getpwnam(matches[0]).pw_uid, + getgrnam(matches[1]).gr_gid, + ) + elif len(matches) == 1: + args.setuid = ( + getpwnam(matches[0]).pw_uid, + getpwnam(matches[0]).pw_gid, + ) + else: + ap.error( + "Specify a user, or user and group separated by a colon," + " e.g. --setuid daemon, --setuid nobody:nobody" + ) + if ( + os.name == "posix" + and not args.setuid + and (os.getuid() == 0 or os.getgid() == 0) + ): + ap.error( + "Running this service as root is not recommended. Use the" + " --setuid option to switch to an unprivileged account after" + " startup. If you really intend to run as root, use" + ' "--setuid root".' + ) + + ports = [] + for port in re.split(r"[,\s]+", args.ports): + try: + ports.append(int(port)) + except ValueError: + ap.error("bad port: %r" % port) + + server = Server(args, ports) + if args.pid_file: + args.pid_file = args.pid_file.resolve() + if args.daemon: + server.daemonize() + if args.pid_file: + server.make_pid_file(args.pid_file) + try: + server.start() + except KeyboardInterrupt: + server.print_error("Interrupted.") + finally: + if args.pid_file: + server.remove_pid_file(args.pid_file) + + +if __name__ == "__main__": + main() diff --git a/static/fixed_responses/login_static.ts b/static/fixed_responses/login_static.ts index 8319186a..2be9236e 100644 --- a/static/fixed_responses/login_static.ts +++ b/static/fixed_responses/login_static.ts @@ -28,6 +28,6 @@ export const NRS = ["104.237.145.11", "139.144.69.73", "170.187.231.80", "45.33. export const DTLS = 99; -export const IRC = ["localhost"]; +export const IRC = ["localhost:6699"]; export const HUB = "https://arbiter.warframe.com/api/";