123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501 |
- # -*- coding: utf-8 -*-
- """
- irc/server.py
- This server has basic support for:
- * Connecting
- * Channels
- * Nicknames
- * Public/private messages
- It is MISSING support for notably:
- * Server linking
- * Modes (user and channel)
- * Proper error reporting
- * Basically everything else
- It is mostly useful as a testing tool or perhaps for building something like a
- private proxy on. Do NOT use it in any kind of production code or anything that
- will ever be connected to by the public.
- """
- #
- # Very simple hacky ugly IRC server.
- #
- # Todo:
- # - Encode format for each message and reply with events.codes['needmoreparams']
- # - starting server when already started doesn't work properly. PID file is not changed, no error messsage is displayed.
- # - Delete channel if last user leaves.
- # - [ERROR] <socket.error instance at 0x7f9f203dfb90> (better error msg required)
- # - Empty channels are left behind
- # - No Op assigned when new channel is created.
- # - User can /join multiple times (doesn't add more to channel, does say 'joined')
- # - PING timeouts
- # - Allow all numerical commands.
- # - Users can send commands to channels they are not in (PART)
- # Not Todo (Won't be supported)
- # - Server linking.
- from __future__ import print_function, absolute_import
- import argparse
- import logging
- import socket
- import select
- import re
- import six
- from six.moves import socketserver
- import jaraco.logging
- import irc.client
- from . import events
- from . import buffer
- SRV_WELCOME = "Welcome to {__name__} v{irc.client.VERSION}.".format(**locals())
- log = logging.getLogger(__name__)
- class IRCError(Exception):
- """
- Exception thrown by IRC command handlers to notify client of a
- server/client error.
- """
- def __init__(self, code, value):
- self.code = code
- self.value = value
- def __str__(self):
- return repr(self.value)
- @classmethod
- def from_name(cls, name, value):
- return cls(events.codes[name], value)
- class IRCChannel(object):
- """
- An IRC channel.
- """
- def __init__(self, name, topic='No topic'):
- self.name = name
- self.topic_by = 'Unknown'
- self.topic = topic
- self.clients = set()
- class IRCClient(socketserver.BaseRequestHandler):
- """
- IRC client connect and command handling. Client connection is handled by
- the ``handle`` method which sets up a two-way communication with the client.
- It then handles commands sent by the client by dispatching them to the
- handle_ methods.
- """
- class Disconnect(BaseException): pass
- def __init__(self, request, client_address, server):
- self.user = None
- self.host = client_address # Client's hostname / ip.
- self.realname = None # Client's real name
- self.nick = None # Client's currently registered nickname
- self.send_queue = [] # Messages to send to client (strings)
- self.channels = {} # Channels the client is in
- # On Python 2, use old, clunky syntax to call parent init
- if six.PY2:
- socketserver.BaseRequestHandler.__init__(self, request,
- client_address, server)
- return
- super().__init__(request, client_address, server)
- def handle(self):
- log.info('Client connected: %s', self.client_ident())
- self.buffer = buffer.LineBuffer()
- try:
- while True:
- self._handle_one()
- except self.Disconnect:
- self.request.close()
- def _handle_one(self):
- """
- Handle one read/write cycle.
- """
- ready_to_read, ready_to_write, in_error = select.select(
- [self.request], [self.request], [self.request], 0.1)
- if in_error:
- raise self.Disconnect()
- # Write any commands to the client
- while self.send_queue and ready_to_write:
- msg = self.send_queue.pop(0)
- self._send(msg)
- # See if the client has any commands for us.
- if ready_to_read:
- self._handle_incoming()
- def _handle_incoming(self):
- try:
- data = self.request.recv(1024)
- except Exception:
- raise self.Disconnect()
- if not data:
- raise self.Disconnect()
- self.buffer.feed(data)
- for line in self.buffer:
- line = line.decode('utf-8')
- self._handle_line(line)
- def _handle_line(self, line):
- try:
- log.debug('from %s: %s' % (self.client_ident(), line))
- command, sep, params = line.partition(' ')
- handler = getattr(self, 'handle_%s' % command.lower(), None)
- if not handler:
- _tmpl = 'No handler for command: %s. Full line: %s'
- log.info(_tmpl % (command, line))
- raise IRCError.from_name('unknowncommand',
- '%s :Unknown command' % command)
- response = handler(params)
- except AttributeError as e:
- log.error(six.text_type(e))
- raise
- except IRCError as e:
- response = ':%s %s %s' % (self.server.servername, e.code, e.value)
- log.error(response)
- except Exception as e:
- response = ':%s ERROR %r' % (self.server.servername, e)
- log.error(response)
- raise
- if response:
- self._send(response)
- def _send(self, msg):
- log.debug('to %s: %s', self.client_ident(), msg)
- self.request.send(msg.encode('utf-8') + b'\r\n')
- def handle_nick(self, params):
- """
- Handle the initial setting of the user's nickname and nick changes.
- """
- nick = params
- # Valid nickname?
- if re.search('[^a-zA-Z0-9\-\[\]\'`^{}_]', nick):
- raise IRCError.from_name('erroneusnickname', ':%s' % nick)
- if self.server.clients.get(nick, None) == self:
- # Already registered to user
- return
- if nick in self.server.clients:
- # Someone else is using the nick
- raise IRCError.from_name('nicknameinuse', 'NICK :%s' % (nick))
- if not self.nick:
- # New connection and nick is available; register and send welcome
- # and MOTD.
- self.nick = nick
- self.server.clients[nick] = self
- response = ':%s %s %s :%s' % (self.server.servername,
- events.codes['welcome'], self.nick, SRV_WELCOME)
- self.send_queue.append(response)
- response = ':%s 376 %s :End of MOTD command.' % (
- self.server.servername, self.nick)
- self.send_queue.append(response)
- return
- # Nick is available. Change the nick.
- message = ':%s NICK :%s' % (self.client_ident(), nick)
- self.server.clients.pop(self.nick)
- self.nick = nick
- self.server.clients[self.nick] = self
- # Send a notification of the nick change to all the clients in the
- # channels the client is in.
- for channel in self.channels.values():
- self._send_to_others(message, channel)
- # Send a notification of the nick change to the client itself
- return message
- def handle_user(self, params):
- """
- Handle the USER command which identifies the user to the server.
- """
- params = params.split(' ', 3)
- if len(params) != 4:
- raise IRCError.from_name('needmoreparams',
- 'USER :Not enough parameters')
- user, mode, unused, realname = params
- self.user = user
- self.mode = mode
- self.realname = realname
- return ''
- def handle_ping(self, params):
- """
- Handle client PING requests to keep the connection alive.
- """
- response = ':{self.server.servername} PONG :{self.server.servername}'
- return response.format(**locals())
- def handle_join(self, params):
- """
- Handle the JOINing of a user to a channel. Valid channel names start
- with a # and consist of a-z, A-Z, 0-9 and/or '_'.
- """
- channel_names = params.split(' ', 1)[0] # Ignore keys
- for channel_name in channel_names.split(','):
- r_channel_name = channel_name.strip()
- # Valid channel name?
- if not re.match('^#([a-zA-Z0-9_])+$', r_channel_name):
- raise IRCError.from_name('nosuchchannel',
- '%s :No such channel' % r_channel_name)
- # Add user to the channel (create new channel if not exists)
- channel = self.server.channels.setdefault(r_channel_name,
- IRCChannel(r_channel_name))
- channel.clients.add(self)
- # Add channel to user's channel list
- self.channels[channel.name] = channel
- # Send the topic
- response_join = ':%s TOPIC %s :%s' % (channel.topic_by,
- channel.name, channel.topic)
- self.send_queue.append(response_join)
- # Send join message to everybody in the channel, including yourself
- # and send user list of the channel back to the user.
- response_join = ':%s JOIN :%s' % (self.client_ident(),
- r_channel_name)
- for client in channel.clients:
- client.send_queue.append(response_join)
- nicks = [client.nick for client in channel.clients]
- _vals = (self.server.servername, self.nick, channel.name,
- ' '.join(nicks))
- response_userlist = ':%s 353 %s = %s :%s' % _vals
- self.send_queue.append(response_userlist)
- _vals = self.server.servername, self.nick, channel.name
- response = ':%s 366 %s %s :End of /NAMES list' % _vals
- self.send_queue.append(response)
- def handle_privmsg(self, params):
- """
- Handle sending a private message to a user or channel.
- """
- target, sep, msg = params.partition(' ')
- if not msg:
- raise IRCError.from_name('needmoreparams',
- 'PRIVMSG :Not enough parameters')
- message = ':%s PRIVMSG %s %s' % (self.client_ident(), target, msg)
- if target.startswith('#') or target.startswith('$'):
- # Message to channel. Check if the channel exists.
- channel = self.server.channels.get(target)
- if not channel:
- raise IRCError.from_name('nosuchnick', 'PRIVMSG :%s' % target)
- if not channel.name in self.channels:
- # The user isn't in the channel.
- raise IRCError.from_name('cannotsendtochan',
- '%s :Cannot send to channel' % channel.name)
- self._send_to_others(message, channel)
- else:
- # Message to user
- client = self.server.clients.get(target, None)
- if not client:
- raise IRCError.from_name('nosuchnick', 'PRIVMSG :%s' % target)
- client.send_queue.append(message)
- def _send_to_others(self, message, channel):
- """
- Send the message to all clients in the specified channel except for
- self.
- """
- other_clients = [client for client in channel.clients
- if not client == self]
- for client in other_clients:
- client.send_queue.append(message)
- def handle_topic(self, params):
- """
- Handle a topic command.
- """
- channel_name, sep, topic = params.partition(' ')
- channel = self.server.channels.get(channel_name)
- if not channel:
- raise IRCError.from_name('nosuchnick', 'PRIVMSG :%s' % channel_name)
- if not channel.name in self.channels:
- # The user isn't in the channel.
- raise IRCError.from_name('cannotsendtochan',
- '%s :Cannot send to channel' % channel.name)
- if topic:
- channel.topic = topic.lstrip(':')
- channel.topic_by = self.nick
- message = ':%s TOPIC %s :%s' % (self.client_ident(), channel_name,
- channel.topic)
- return message
- def handle_part(self, params):
- """
- Handle a client parting from channel(s).
- """
- for pchannel in params.split(','):
- if pchannel.strip() in self.server.channels:
- # Send message to all clients in all channels user is in, and
- # remove the user from the channels.
- channel = self.server.channels.get(pchannel.strip())
- response = ':%s PART :%s' % (self.client_ident(), pchannel)
- if channel:
- for client in channel.clients:
- client.send_queue.append(response)
- channel.clients.remove(self)
- self.channels.pop(pchannel)
- else:
- _vars = self.server.servername, pchannel, pchannel
- response = ':%s 403 %s :%s' % _vars
- self.send_queue.append(response)
- def handle_quit(self, params):
- """
- Handle the client breaking off the connection with a QUIT command.
- """
- response = ':%s QUIT :%s' % (self.client_ident(), params.lstrip(':'))
- # Send quit message to all clients in all channels user is in, and
- # remove the user from the channels.
- for channel in self.channels.values():
- for client in channel.clients:
- client.send_queue.append(response)
- channel.clients.remove(self)
- def handle_dump(self, params):
- """
- Dump internal server information for debugging purposes.
- """
- print("Clients:", self.server.clients)
- for client in self.server.clients.values():
- print(" ", client)
- for channel in client.channels.values():
- print(" ", channel.name)
- print("Channels:", self.server.channels)
- for channel in self.server.channels.values():
- print(" ", channel.name, channel)
- for client in channel.clients:
- print(" ", client.nick, client)
- def client_ident(self):
- """
- Return the client identifier as included in many command replies.
- """
- return irc.client.NickMask.from_params(self.nick, self.user,
- self.server.servername)
- def finish(self):
- """
- The client conection is finished. Do some cleanup to ensure that the
- client doesn't linger around in any channel or the client list, in case
- the client didn't properly close the connection with PART and QUIT.
- """
- log.info('Client disconnected: %s', self.client_ident())
- response = ':%s QUIT :EOF from client' % self.client_ident()
- for channel in self.channels.values():
- if self in channel.clients:
- # Client is gone without properly QUITing or PARTing this
- # channel.
- for client in channel.clients:
- client.send_queue.append(response)
- channel.clients.remove(self)
- if self.nick:
- self.server.clients.pop(self.nick)
- log.info('Connection finished: %s', self.client_ident())
- def __repr__(self):
- """
- Return a user-readable description of the client
- """
- return '<%s %s!%s@%s (%s)>' % (
- self.__class__.__name__,
- self.nick,
- self.user,
- self.host[0],
- self.realname,
- )
- class IRCServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
- daemon_threads = True
- allow_reuse_address = True
- channels = {}
- "Existing channels (IRCChannel instances) by channel name"
- clients = {}
- "Connected clients (IRCClient instances) by nick name"
- def __init__(self, *args, **kwargs):
- self.servername = 'localhost'
- self.channels = {}
- self.clients = {}
- if six.PY2:
- socketserver.TCPServer.__init__(self, *args, **kwargs)
- return
- super().__init__(*args, **kwargs)
- def get_args():
- parser = argparse.ArgumentParser()
- parser.add_argument("-a", "--address", dest="listen_address",
- default='127.0.0.1', help="IP on which to listen")
- parser.add_argument("-p", "--port", dest="listen_port", default=6667,
- type=int, help="Port on which to listen")
- jaraco.logging.add_arguments(parser)
- return parser.parse_args()
- def main():
- options = get_args()
- jaraco.logging.setup(options)
- log.info("Starting irc.server")
- try:
- bind_address = options.listen_address, options.listen_port
- ircserver = IRCServer(bind_address, IRCClient)
- _tmpl = 'Listening on {listen_address}:{listen_port}'
- log.info(_tmpl.format(**vars(options)))
- ircserver.serve_forever()
- except socket.error as e:
- log.error(repr(e))
- raise SystemExit(-2)
- if __name__ == "__main__":
- main()
|