Version bump for release.
[irker.git] / irkerd
diff --git a/irkerd b/irkerd
index 3f64ef97a9d4194552aa7231f78de61c81459619..d9505b378ae1bfa0ce7c48f15b5b8f848ae9cf5b 100755 (executable)
--- a/irkerd
+++ b/irkerd
@@ -19,6 +19,7 @@ Requires Python 2.7, or:
 * 2.6 with the argparse package installed.
 """
 
+from __future__ import unicode_literals
 from __future__ import with_statement
 
 # These things might need tuning
@@ -39,28 +40,46 @@ CONNECTION_MAX = 200                # To avoid hitting a thread limit
 
 # No user-serviceable parts below this line
 
-version = "2.6"
+version = "2.9"
 
-import Queue
-import SocketServer
 import argparse
 import logging
+import logging.handlers
 import json
+import os
+try:  # Python 3
+    import queue
+except ImportError:  # Python 2
+    import Queue as queue
 import random
 import re
 import select
 import signal
 import socket
+try:  # Python 3
+    import socketserver
+except ImportError:  # Python 2
+    import SocketServer as socketserver
+import ssl
 import sys
 import threading
 import time
-import urlparse
+import traceback
+try:  # Python 3
+    import urllib.parse as urllib_parse
+except ImportError:  # Python 2
+    import urlparse as urllib_parse
 
 
 LOG = logging.getLogger(__name__)
 LOG.setLevel(logging.ERROR)
 LOG_LEVELS = ['critical', 'error', 'warning', 'info', 'debug']
 
+try:  # Python 2
+    UNICODE_TYPE = unicode
+except NameError:  # Python 3
+    UNICODE_TYPE = str
+
 
 # Sketch of implementation:
 #
@@ -95,7 +114,7 @@ LOG_LEVELS = ['critical', 'error', 'warning', 'info', 'debug']
 # same problem - there is little point in reliable delivery to a relay
 # that is down or unreliable.
 #
-# This code uses only NICK, JOIN, PART, MODE, PRIVMSG, USER, and QUIT. 
+# This code uses only NICK, JOIN, PART, MODE, PRIVMSG, USER, and QUIT.
 # It is strictly compliant to RFC1459, except for the interpretation and
 # use of the DEAF and CHANLIMIT and (obsolete) MAXCHANNELS features.
 #
@@ -115,7 +134,7 @@ class IRCError(Exception):
     pass
 
 
-class InvalidRequest (ValueError):
+class InvalidRequest(ValueError):
     "An invalid JSON request"
     pass
 
@@ -176,17 +195,17 @@ class IRCClient():
 
 class LineBufferedStream():
     "Line-buffer a read stream."
-    crlf_re = re.compile(b'\r?\n')
+    _crlf_re = re.compile(b'\r?\n')
 
     def __init__(self):
-        self.buffer = ''
+        self.buffer = b''
 
     def append(self, newbytes):
         self.buffer += newbytes
 
     def lines(self):
         "Iterate over lines in the buffer."
-        lines = LineBufferedStream.crlf_re.split(self.buffer)
+        lines = self._crlf_re.split(self.buffer)
         self.buffer = lines.pop()
         return iter(lines)
 
@@ -214,8 +233,40 @@ class IRCServerConnection():
         self.master = master
         self.socket = None
 
-    def connect(self, target, nickname,
-                password=None, username=None, ircname=None):
+    def _wrap_socket(self, socket, target, certfile=None, cafile=None,
+                     protocol=ssl.PROTOCOL_TLSv1):
+        try:  # Python 3.2 and greater
+            ssl_context = ssl.SSLContext(protocol)
+        except AttributeError:  # Python < 3.2
+            self.socket = ssl.wrap_socket(
+                socket, certfile=certfile, cert_reqs=ssl.CERT_REQUIRED,
+                ssl_version=protocol, ca_certs=cafile)
+        else:
+            ssl_context.verify_mode = ssl.CERT_REQUIRED
+            if cafile:
+                ssl_context.load_verify_locations(cafile=cafile)
+            else:
+                ssl_context.set_default_verify_paths()
+            kwargs = {}
+            if ssl.HAS_SNI:
+                kwargs['server_hostname'] = target.servername
+            self.socket = ssl_context.wrap_socket(socket, **kwargs)
+        return self.socket
+
+    def _check_hostname(self, target):
+        if hasattr(ssl, 'match_hostname'):  # Python >= 3.2
+            cert = self.socket.getpeercert()
+            try:
+                ssl.match_hostname(cert, target.servername)
+            except ssl.CertificateError as e:
+                raise IRCServerConnectionError(
+                    'Invalid SSL/TLS certificate: %s' % e)
+        else:  # Python < 3.2
+            LOG.warning(
+                'cannot check SSL/TLS hostname with Python %s' % sys.version)
+
+    def connect(self, target, nickname, username=None, realname=None,
+                **kwargs):
         LOG.debug("connect(server=%r, port=%r, nickname=%r, ...)" % (
             target.servername, target.port, nickname))
         if self.socket is not None:
@@ -228,15 +279,22 @@ class IRCServerConnection():
         self.nickname = nickname
         try:
             self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            if target.ssl:
+                self.socket = self._wrap_socket(
+                    socket=self.socket, target=target, **kwargs)
             self.socket.bind(('', 0))
             self.socket.connect((target.servername, target.port))
         except socket.error as err:
             raise IRCServerConnectionError("Couldn't connect to socket: %s" % err)
 
-        if password:
-            self.ship("PASS " + password)
+        if target.ssl:
+            self._check_hostname(target=target)
+        if target.password:
+            self.ship("PASS " + target.password)
         self.nick(self.nickname)
-        self.user(username=username or ircname, realname=ircname or nickname)
+        self.user(
+            username=target.username or username or 'irker',
+            realname=realname or 'irker relaying client')
         return self
 
     def close(self):
@@ -261,6 +319,8 @@ class IRCServerConnection():
         self.buffer.append(incoming)
 
         for line in self.buffer:
+            if not isinstance(line, UNICODE_TYPE):
+                line = UNICODE_TYPE(line, 'utf-8')
             LOG.debug("FROM: %s" % line)
 
             if not line:
@@ -389,14 +449,14 @@ class Connection:
         self.channels_joined = {}
         self.channel_limits = {}
         # The consumer thread
-        self.queue = Queue.Queue()
+        self.queue = queue.Queue()
         self.thread = None
     def nickname(self, n=None):
         "Return a name for the nth server connection."
         if n is None:
             n = self.nick_trial
         if self.nick_needs_number:
-            return (self.nick_template % n)
+            return self.nick_template % n
         else:
             return self.nick_template
     def handle_ping(self):
@@ -511,15 +571,14 @@ class Connection:
                             self.connection.connect(
                                 target=self.target,
                                 nickname=self.nickname(),
-                                username="irker",
-                                ircname="irker relaying client",
                                 **self.kwargs)
                             self.status = "handshaking"
                             LOG.info("XMIT_TTL bump (%s connection) at %s" % (
                                 self.target, time.asctime()))
                             self.last_xmit = time.time()
                             self.last_ping = time.time()
-                        except IRCServerConnectionError:
+                        except IRCServerConnectionError as e:
+                            LOG.error(e)
                             self.status = "expired"
                             break
                 elif self.status == "handshaking":
@@ -565,8 +624,8 @@ class Connection:
                                 LOG.warning((
                                     "irclib rejected a message to %s on %s "
                                     "because: %s") % (
-                                    channel, self.target, str(err)))
-                                LOG.debug(err.format_exc())
+                                    channel, self.target, UNICODE_TYPE(err)))
+                                LOG.debug(traceback.format_exc())
                             time.sleep(ANTI_FLOOD_DELAY)
                     self.last_xmit = self.channels_joined[channel] = time.time()
                     LOG.info("XMIT_TTL bump (%s transmission) at %s" % (
@@ -580,7 +639,7 @@ class Connection:
             LOG.error("exception %s in thread for %s" % (e, self.target))
             # Maybe this should have its own status?
             self.status = "expired"
-            LOG.debug(e.format_exc())
+            LOG.debug(traceback.format_exc())
         finally:
             try:
                 # Make sure we don't leave any zombies behind
@@ -612,13 +671,16 @@ class Target():
     "Represent a transmission target."
     def __init__(self, url):
         self.url = url
-        # Pre-2.6 Pythons don't recognize irc: as a valid URL prefix.
-        url = url.replace("irc://", "http://")
-        parsed = urlparse.urlparse(url)
-        irchost, _, ircport = parsed.netloc.partition(':')
-        if not ircport:
-            ircport = 6667
-        self.servername = irchost
+        parsed = urllib_parse.urlparse(url)
+        self.ssl = parsed.scheme == 'ircs'
+        if self.ssl:
+            default_ircport = 6697
+        else:
+            default_ircport = 6667
+        self.username = parsed.username
+        self.password = parsed.password
+        self.servername = parsed.hostname
+        self.port = parsed.port or default_ircport
         # IRC channel names are case-insensitive.  If we don't smash
         # case here we may run into problems later. There was a bug
         # observed on irc.rizon.net where an irkerd user specified #Channel,
@@ -637,7 +699,6 @@ class Target():
         self.key = ""
         if parsed.query:
             self.key = re.sub("^key=", "", parsed.query)
-        self.port = int(ircport)
 
     def __str__(self):
         "Represent this instance as a string"
@@ -679,7 +740,7 @@ class Dispatcher:
                 if age < time.time() - CHANNEL_TTL:
                     ancients.append((connection, chan, age))
         if ancients:
-            ancients.sort(key=lambda x: x[2]) 
+            ancients.sort(key=lambda x: x[2])
             (found_connection, drop_channel, _drop_age) = ancients[0]
             found_connection.part(drop_channel, "scavenged by irkerd")
             del found_connection.channels_joined[drop_channel]
@@ -748,7 +809,7 @@ class Irker:
                     m = int(lump[12:])
                     for pref in "#&+":
                         cxt.channel_limits[pref] = m
-                    LOG.info("%s maxchannels is %d" % (connection.server, m))
+                    LOG.info("%s maxchannels is %d" % (connection.target, m))
                 elif lump.startswith("CHANLIMIT=#:"):
                     limits = lump[10:].split(",")
                     try:
@@ -795,10 +856,10 @@ class Irker:
                 "malformed request - 'to' or 'privmsg' missing: %r" % request)
         channels = request['to']
         message = request['privmsg']
-        if not isinstance(channels, (list, basestring)):
+        if not isinstance(channels, (list, UNICODE_TYPE)):
             raise InvalidRequest(
                 "malformed request - unexpected channel type: %r" % channels)
-        if not isinstance(message, basestring):
+        if not isinstance(message, UNICODE_TYPE):
             raise InvalidRequest(
                 "malformed request - unexpected message type: %r" % message)
         if not isinstance(channels, list):
@@ -806,14 +867,14 @@ class Irker:
         targets = []
         for url in channels:
             try:
-                if not isinstance(url, basestring):
+                if not isinstance(url, UNICODE_TYPE):
                     raise InvalidRequest(
                         "malformed request - URL has unexpected type: %r" %
                         url)
                 target = Target(url)
                 target.validate()
             except InvalidRequest as e:
-                LOG.error(str(e))
+                LOG.error(UNICODE_TYPE(e))
             else:
                 targets.append(target)
         return (targets, message)
@@ -850,33 +911,52 @@ class Irker:
                             key=lambda name: self.servers[name].last_xmit())
                         del self.servers[oldest]
         except InvalidRequest as e:
-            LOG.error(str(e))
+            LOG.error(UNICODE_TYPE(e))
         except ValueError:
             self.logerr("can't recognize JSON on input: %r" % line)
         except RuntimeError:
             self.logerr("wildly malformed JSON blew the parser stack.")
 
-class IrkerTCPHandler(SocketServer.StreamRequestHandler):
+class IrkerTCPHandler(socketserver.StreamRequestHandler):
     def handle(self):
         while True:
             line = self.rfile.readline()
             if not line:
                 break
-            irker.handle(line.strip())
+            if not isinstance(line, UNICODE_TYPE):
+                line = UNICODE_TYPE(line, 'utf-8')
+            irker.handle(line=line.strip())
 
-class IrkerUDPHandler(SocketServer.BaseRequestHandler):
+class IrkerUDPHandler(socketserver.BaseRequestHandler):
     def handle(self):
-        data = self.request[0].strip()
+        line = self.request[0].strip()
         #socket = self.request[1]
-        irker.handle(data)
+        if not isinstance(line, UNICODE_TYPE):
+            line = UNICODE_TYPE(line, 'utf-8')
+        irker.handle(line=line.strip())
 
+def in_background():
+    "Is this process running in background?"
+    try:
+        return os.getpgrp() != os.tcgetpgrp(1)
+    except OSError:
+        return True
 
 if __name__ == '__main__':
     parser = argparse.ArgumentParser(
         description=__doc__.strip().splitlines()[0])
     parser.add_argument(
-        '-d', '--log-level', metavar='LEVEL', choices=LOG_LEVELS,
+        '-c', '--ca-file', metavar='PATH',
         help='file of trusted certificates for SSL/TLS')
+    parser.add_argument(
+        '-e', '--cert-file', metavar='PATH',
+        help='pem file used to authenticate to the server')
+    parser.add_argument(
+        '-d', '--log-level', metavar='LEVEL', choices=LOG_LEVELS,
+        help='how much to log to the log file (one of %(choices)s)')
+    parser.add_argument(
+        '-H', '--host', metavar='ADDRESS', default=HOST,
+        help='IP address to listen on')
     parser.add_argument(
         '-l', '--log-file', metavar='PATH',
         help='file for saving captured message traffic')
@@ -887,14 +967,24 @@ if __name__ == '__main__':
         '-p', '--password', metavar='PASSWORD',
         help='NickServ password')
     parser.add_argument(
-        '-i', '--immediate', action='store_const', const=True,
-        help='disconnect after sending each message')
+        '-i', '--immediate', metavar='IRC-URL',
+        help=(
+            'send a single message to IRC-URL and exit.  The message is the '
+            'first positional argument.'))
     parser.add_argument(
         '-V', '--version', action='version',
         version='%(prog)s {0}'.format(version))
+    parser.add_argument(
+        'message', metavar='MESSAGE', nargs='?',
+        help='message for --immediate mode')
     args = parser.parse_args()
 
-    handler = logging.StreamHandler()
+    if not args.log_file and in_background():
+        handler = logging.handlers.SysLogHandler(address='/dev/log',
+                                                 facility='daemon')
+    else:
+        handler = logging.StreamHandler()
+
     LOG.addHandler(handler)
     if args.log_level:
         log_level = getattr(logging, args.log_level.upper())
@@ -905,17 +995,30 @@ if __name__ == '__main__':
         nick_template=args.nick,
         nick_needs_number=re.search('%.*d', args.nick),
         password=args.password,
+        cafile=args.ca_file,
+        certfile=args.cert_file,
         )
     LOG.info("irkerd version %s" % version)
     if args.immediate:
+        if not args.message:
+            LOG.error(
+                '--immediate set (%r), but message argument not given' % (
+                args.immediate))
+            raise SystemExit(1)
         irker.irc.add_event_handler("quit", lambda _c, _e: sys.exit(0))
-        irker.handle('{"to":"%s","privmsg":"%s"}' % (immediate, arguments[0]), quit_after=True)
+        irker.handle('{"to":"%s","privmsg":"%s"}' % (
+            args.immediate, args.message), quit_after=True)
         irker.irc.spin()
     else:
+        if args.message:
+            LOG.error(
+                'message argument given (%r), but --immediate not set' % (
+                args.message))
+            raise SystemExit(1)
         irker.thread_launch()
         try:
-            tcpserver = SocketServer.TCPServer((HOST, PORT), IrkerTCPHandler)
-            udpserver = SocketServer.UDPServer((HOST, PORT), IrkerUDPHandler)
+            tcpserver = socketserver.TCPServer((args.host, PORT), IrkerTCPHandler)
+            udpserver = socketserver.UDPServer((args.host, PORT), IrkerUDPHandler)
             for server in [tcpserver, udpserver]:
                 server = threading.Thread(target=server.serve_forever)
                 server.setDaemon(True)