#!/usr/bin/env python # -*- coding: utf-8 -*- # vim: ft=python et softtabstop=4 cinoptions=4 shiftwidth=4 ts=4 ai # # Copyright (C) 2003, 2011-2013 Joel Rosdahl # Copyright (C) 2014 Romain Bignon # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 # USA VERSION = "0.1" CHANNELS = ["#", "+"] import datetime import os import re import select import socket import string import sys import ssl import time from collections import defaultdict from random import choice from optparse import OptionParser class Logger(list): def __init__(self, *args, **kwargs): super(Logger, self).__init__(*args, **kwargs) self.scores = defaultdict(dict) def log_message(self, nickname): self.append((datetime.datetime.now().strftime('%H:%M:%S'), nickname)) def find(self, who, timestamp, nickname): if timestamp in self.scores[who]: return False res = (timestamp, nickname) in self self.scores[who][timestamp] = res return res class Channel(object): def __init__(self, server, name): self.server = server self.name = name self.members = set() self.topic = "" self.logger = Logger() def add_member(self, client): self.members.add(client) client.message(":%s JOIN %s" % (client.prefix, self.name)) if self.topic: client.reply("332 %s %s :%s" % (client.nickname, self.name, self.topic)) else: client.reply("331 %s %s :No topic is set" % (client.nickname, self.name)) members = [client.nickname] if self.name.startswith('+'): members += self.NICKNAMES client.reply("353 %s = %s :%s" % (client.nickname, self.name, ' '.join(members))) client.reply("366 %s %s :End of NAMES list" % (client.nickname, self.name)) def remove_client(self, client): self.members.discard(client) if not self.members: self.server.remove_channel(self) def message(self, sender, command, message, exclude=None): if isinstance(sender, Client): prefix = self.anon_prefix self.logger.log_message(sender.nickname) else: prefix = sender.name line = ":%s %s %s" % (prefix, command, message) for client in self.members: if client != exclude: client.message(line) NICKNAMES = ['Roger', 'Bertrand', 'Gilbert', 'Jocelyne', 'Hector', 'Liliane', 'Patoche'] @property def anon_prefix(self): if self.name.startswith('+'): nickname = choice(self.NICKNAMES) else: nickname = datetime.datetime.now().strftime('%H:%M:%S') return '%s!an@ymo.us' % nickname def check_registered(func): def inner(self, *args, **kwargs): if self.nickname is None: return func(self, *args, **kwargs) return inner class Client(object): PING_FREQ = 90 __linesep_regexp = re.compile(r"\r?\n") # The RFC limit for nicknames is 9 characters, but what the heck. valid_nickname_regexp = re.compile( r"^[][\`_^{|}A-Za-z][][\`_^{|}A-Za-z0-9]{0,50}$") valid_channelname_regexp = re.compile( r"^[&#+!][^\x00\x07\x0a\x0d ,:]{0,50}$") def __init__(self, server, sock): self.server = server self.sock = sock self.channels = {} self.nickname = None self.user = None self.realname = None (self.host, self.port) = sock.getpeername() self.ping_sent = False self.timestamp = time.time() self.readbuffer = '' self.writebuffer = '' @property def prefix(self): return "%s!%s@%s" % (self.nickname, self.user, self.host) @property def registered(self): return self.nickname and self.user def check_aliveness(self): now = time.time() if self.timestamp + self.PING_FREQ*2 < now: self.disconnect('Ping Timeout') return if not self.ping_sent and self.timestamp + self.PING_FREQ < now: if self.nickname is not None: self.message("PING :%s" % self.server.name) self.ping_sent = True else: self.disconnect('Ping Timeout') def socket_readable_notification(self): try: data = self.sock.recv(2 ** 10) quitmsg = "EOT" except socket.error, x: data = "" quitmsg = x if data: self.readbuffer += data self.parse_read_buffer() self.timestamp = time.time() self.ping_sent = False else: self.disconnect(quitmsg) def socket_writable_notification(self): try: sent = self.sock.send(self.writebuffer) self.writebuffer = self.writebuffer[sent:] except socket.error, x: self.disconnect(x) def disconnect(self, quitmsg): self.message("ERROR :%s" % quitmsg) self.sock.close() self.server.remove_client(self) def message(self, msg): self.writebuffer += msg + "\r\n" def reply(self, msg): self.message(":%s %s" % (self.server.name, msg)) def reply_403(self, channel): self.reply("403 %s %s :No such channel" % (self.nickname, channel)) def reply_461(self, command): nickname = self.nickname or "*" self.reply("461 %s %s :Not enough parameters" % (nickname, command)) def send_lusers(self): self.reply("251 %s :There are 0 users and %s invisible on 1 server" % (self.nickname, len(self.server.clients))) def send_motd(self): server = self.server motdlines = server.get_motd_lines() if motdlines: self.reply("375 %s :- %s Message of the day -" % (self.nickname, server.name)) for line in motdlines: self.reply("372 %s :- %s" % (self.nickname, line.rstrip())) self.reply("376 %s :End of /MOTD command" % self.nickname) else: self.reply("422 %s :MOTD File is missing" % self.nickname) def join_channel(self, channel): channel.add_member(self) self.channels[irc_lower(channel.name)] = channel def write_queue_size(self): return len(self.writebuffer) def parse_read_buffer(self): 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(" ", 1) command = x[0].upper() if len(x) == 1: arguments = [] else: if len(x[1]) > 0 and x[1][0] == ":": arguments = [x[1][1:]] else: y = string.split(x[1], " :", 1) arguments = string.split(y[0]) if len(y) == 2: arguments.append(y[1]) self.handle_command(command, arguments) def handle_command(self, command, arguments): try: func = getattr(self, 'on_%s' % command.lower()) except AttributeError: self.reply("421 %s %s :Unknown command" % (self.nickname, command)) return was_registered = self.registered func(arguments) if not was_registered and self.registered: self.reply("001 %s :Welcome to Anonymous IRC Server!" % self.nickname) self.reply("002 %s :Your host is %s, running version anonyrcd-%s" % (self.nickname, self.server.name, VERSION)) self.reply("003 %s :This server was created sometime" % self.nickname) self.reply("004 %s :%s anonyrcd-%s o o" % (self.nickname, self.server.name, VERSION)) self.send_lusers() self.send_motd() for chan in CHANNELS: self.join_channel(self.server.get_channel(chan)) def on_nick(self, args): if len(args) < 1: self.reply("431 :No nickname given") return newnick = args[0] if not self.valid_nickname_regexp.match(newnick): self.reply("432 %s %s :Erroneous Nickname" % (self.nickname, newnick)) return if self.nickname == newnick: return if self.nickname: self.message(":%s NICK %s" % (self.prefix, newnick)) self.nickname = newnick def on_user(self, args): if len(args) < 4: self.reply_461("USER") return self.user = args[0] self.realname = args[3] @check_registered def on_ison(self, _): self.reply("303 %s :" % self.nickname) @check_registered def on_list(self, args): if len(args) < 1: channels = self.server.channels.values() else: channels = [] for channelname in args[0].split(","): if self.server.has_channel(channelname): channels.append(self.server.get_channel(channelname)) channels.sort(key=lambda x: x.name) for channel in channels: self.reply("322 %s %s %d :%s" % (self.nickname, channel.name, len(channel.members), channel.topic)) self.reply("323 %s :End of LIST" % self.nickname) @check_registered def on_lusers(self, _): self.send_lusers() @check_registered def on_motd(self, _): self.send_motd() @check_registered def on_privmsg(self, args): if len(args) == 0: self.reply("411 %s :No recipient given (PRIVMSG)" % self.nickname) return if len(args) == 1: self.reply("412 %s :No text to send" % self.nickname) return targetname = args[0] message = args[1] if self.server.has_channel(targetname): channel = self.server.get_channel(targetname) channel.message(self, 'PRIVMSG', "%s :%s" % (channel.name, message), exclude=self) else: self.reply("401 %s %s :No such channel" % (self.nickname, targetname)) @check_registered def on_mode(self, args): if len(args) < 1: self.reply_461("MODE") return targetname = args[0] if self.server.has_channel(targetname): if len(args) < 2: modes = "+" self.reply("324 %s %s %s" % (self.nickname, targetname, modes)) return flag = args[1] if 'b' in flag: self.reply("368 %s %s :End of Channel Ban List" % (self.nickname, targetname)) else: self.reply("472 %s %s :Unknown MODE flag" % (self.nickname, flag)) elif targetname == self.nickname: if len(args) == 1: self.reply("221 %s +" % self.nickname) else: self.reply("501 %s :Unknown MODE flag" % self.nickname) else: self.reply_403(targetname) @check_registered def on_join(self, args): if len(args) < 1: self.reply_461("JOIN") return if args[0] == "0": for (channelname, channel) in self.channels.items(): self.message(':%s PART %s' % (self.prefix, channelname)) self.server.remove_member_from_channel(self, channelname) self.channels = {} return channelnames = args[0].split(",") for channelname in channelnames: if irc_lower(channelname) in self.channels: continue if not self.valid_channelname_regexp.match(channelname): self.reply_403(channelname) continue channel = self.server.get_channel(channelname) self.join_channel(channel) @check_registered def on_part(self, args): if len(args) < 1: self.reply_461("PART") return if len(args) > 1: partmsg = args[1] else: partmsg = self.nickname for channelname in args[0].split(","): if not self.valid_channelname_regexp.match(channelname): self.reply_403(channelname) elif not irc_lower(channelname) in self.channels: self.reply("442 %s %s :You're not on that channel" % (self.nickname, channelname)) else: self.message(":%s PART %s :%s" % (self.prefix, channelname, partmsg)) del self.channels[irc_lower(channelname)] self.server.remove_member_from_channel(self, channelname) @check_registered def on_ping(self, args): if len(args) < 1: self.reply("409 %s :No origin specified" % self.nickname) return self.reply("PONG %s :%s" % (self.server.name, args[0])) @check_registered def on_pong(self, _): pass def on_quit(self, args): if len(args) < 1: quitmsg = self.nickname else: quitmsg = args[0] self.disconnect(quitmsg) @check_registered def on_topic(self, args): if len(args) < 1: self.reply_461("TOPIC") return channelname = args[0] channel = self.channels.get(irc_lower(channelname)) if channel: if len(args) > 1: newtopic = args[1] channel.topic = newtopic channel.message(self, "TOPIC", "%s :%s" % (channelname, newtopic)) else: if channel.topic: self.reply("332 %s %s :%s" % (self.nickname, channel.name, channel.topic)) else: self.reply("331 %s %s :No topic is set" % (self.nickname, channel.name)) else: self.reply("442 %s :You're not on that channel" % channelname) @check_registered def on_who(self, args): if len(args) < 1: return targetname = args[0] self.reply("315 %s %s :End of WHO list" % (self.nickname, targetname)) @check_registered def on_whois(self, args): if len(args) < 1: return self.reply("401 %s %s :No such nick" % (self.nickname, args[0])) @check_registered def on_kick(self, args): if len(args) < 2: self.reply_461("KICK") return channelname = args[0] timestamp = args[1] nickname = args[2] channel = self.channels.get(irc_lower(channelname)) if not channel: self.reply("442 %s :You're not on that channel" % channelname) if channel.logger.find(self.nickname, timestamp, nickname): channel.message(self.server, "PRIVMSG", "%s :%s has found %s" % (channel.name, self.nickname, nickname)) else: channel.message(self.server, "PRIVMSG", "%s :%s failed to discover who is behind %s" % (channel.name, self.nickname, timestamp)) class Server(object): def __init__(self, options): self.ports = options.ports self.ssl_pem_file = options.ssl_pem_file self.motdfile = options.motd self.verbose = options.verbose self.debug = options.debug self.name = options.hostname self.channels = {} # irc_lower(Channel name) --> Channel instance. self.clients = {} # Socket --> Client instance. def daemonize(self): 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 has_channel(self, name): return irc_lower(name) in self.channels def get_channel(self, channelname): 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): if self.motdfile: try: return open(self.motdfile).readlines() except IOError: return ["Could not read MOTD file %r." % self.motdfile] else: return [] def print_info(self, msg): if self.verbose: print msg sys.stdout.flush() def print_debug(self, msg): if self.debug: print msg sys.stdout.flush() @classmethod def print_error(cls, msg): sys.stderr.write("%s\n" % msg) def remove_member_from_channel(self, client, channelname): if irc_lower(channelname) in self.channels: channel = self.channels[irc_lower(channelname)] channel.remove_client(client) def remove_client(self, client): #client.message_related(":%s QUIT :%s" % (client.prefix, quitmsg)) for x in client.channels.values(): x.remove_client(client) del self.clients[client.sock] def remove_channel(self, channel): del self.channels[irc_lower(channel.name)] def start(self): serversockets = [] for port in self.ports: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: s.bind(("", port)) except socket.error, x: self.print_error("Could not bind port %s: %s." % (port, x)) sys.exit(1) s.listen(5) serversockets.append(s) del s self.print_info("Listening on port %d." % port) last_aliveness_check = time.time() while True: (iwtd, owtd, _) = select.select( serversockets + [x.sock for x in self.clients.values()], [x.sock 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: (conn, addr) = x.accept() if self.ssl_pem_file: try: conn = ssl.wrap_socket( conn, server_side=True, certfile=self.ssl_pem_file, keyfile=self.ssl_pem_file) except ssl.SSLError as e: self.print_error( "SSL error for connection from %s:%s: %s" % ( addr[0], addr[1], e)) continue self.clients[conn] = Client(self, conn) self.print_info("Accepted connection from %s:%s." % ( addr[0], addr[1])) 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 self.clients.values(): client.check_aliveness() last_aliveness_check = now _alpha = "abcdefghijklmnopqrstuvwxyz" _ircstring_translation = string.maketrans( string.upper(_alpha) + "[]\\^", _alpha + "{}|~") def irc_lower(s): return string.translate(s, _ircstring_translation) def main(argv): op = OptionParser( version=VERSION, description="anonircd is a small and limited IRC server.") op.add_option( "-d", "--daemon", action="store_true", help="fork and become a daemon") op.add_option( "--debug", action="store_true", help="print debug messages to stdout") op.add_option( "--motd", metavar="X", help="display file X as message of the day") op.add_option( "-s", "--ssl-pem-file", metavar="FILE", help="enable SSL and use FILE as the .pem certificate+key") op.add_option( "--hostname", help="Hostname of server (default %s)" % socket.getfqdn()[:63]) op.add_option( "--ports", metavar="X", help="listen to ports X (a list separated by comma or whitespace);" " default: 6667 or 6697 if SSL is enabled") op.add_option( "--verbose", action="store_true", help="be verbose (print some progress messages to stdout)") (options, _) = op.parse_args(argv[1:]) if options.debug: options.verbose = True if options.hostname is None: options.hostname = socket.getfqdn()[:63] if options.ports is None: if options.ssl_pem_file is None: options.ports = "6667" else: options.ports = "6697" ports = [] for port in re.split(r"[,\s]+", options.ports): try: ports.append(int(port)) except ValueError: op.error("bad port: %r" % port) options.ports = ports server = Server(options) if options.daemon: server.daemonize() try: server.start() except KeyboardInterrupt: server.print_error("Interrupted.") main(sys.argv)