#!/usr/bin/env python3
"""
irkerd - a simple IRC multiplexer daemon

Listens for JSON objects of the form {'to':<irc-url>, 'privmsg':<text>}
and relays messages to IRC channels. Each request must be followed by
a newline.

The <text> must be a string.  The value of the 'to' attribute can be a
string containing an IRC URL (e.g. 'irc://chat.freenet.net/botwar') or
a list of such strings; in the latter case the message is broadcast to
all listed channels.  Note that the channel portion of the URL need
*not* have a leading '#' unless the channel name itself does.

Design and code by Eric S. Raymond <esr@thyrsus.com>. See the project
resource page at <http://www.catb.org/~esr/irker/>.

Requires Python 3.

"""
# SPDX-License-Identifier: BSD-2-Clause

# These things might need tuning

HOST = "localhost"
HOST6 = "localhost"
PORT = 6659

PROXY_TYPE = None  # Use proxy if set 1: SOCKS4, 2: SOCKS5, 3: HTTP
PROXY_HOST = ""
PROXY_PORT = 1080

XMIT_TTL = 3 * 60 * 60  # Time to live, seconds from last transmit
PING_TTL = 15 * 60  # Time to live, seconds from last PING
HANDSHAKE_TTL = 60  # Time to live, seconds from nick transmit
CHANNEL_TTL = 3 * 60 * 60  # Time to live, seconds from last transmit
DISCONNECT_TTL = 24 * 60 * 60  # Time to live, seconds from last connect
UNSEEN_TTL = 60  # Time to live, seconds since first request
CHANNEL_MAX = 18  # Max channels open per socket (default)
ANTI_FLOOD_DELAY = 1.0  # Anti-flood delay after transmissions, seconds
ANTI_BUZZ_DELAY = 0.09  # Anti-buzz delay after queue-empty check
CONNECTION_MAX = 200  # To avoid hitting a thread limit
RECONNECT_DELAY = 3  # Don't spam servers with connection attempts
MAX_REQUEST_BYTES = 65535  # Hard cap on incoming request size

# No user-serviceable parts below this line

# pylint: disable=too-many-lines,invalid-name,missing-function-docstring,missing-class-docstring,redefined-outer-name,logging-not-lazy,too-many-arguments,too-many-branches,too-many-instance-attributes,attribute-defined-outside-init,raise-missing-from,no-else-return,no-else-break,too-many-statements,too-many-nested-blocks,consider-using-f-string,redundant-u-string-prefix,broad-exception-caught,too-many-locals

version = "2.25"

# pylint: disable=wrong-import-position
import argparse
import logging
import logging.handlers
import json
import os
import os.path

import queue
import random
import re
import select
import signal
import socket

try:
    import socks

    socks_on = True
except ImportError:
    socks_on = False
import socketserver
import ssl
import sys
import threading
import time
import traceback

import urllib.parse as urllib_parse


LOG = logging.getLogger(__name__)
LOG.setLevel(logging.ERROR)
LOG_LEVELS = ["critical", "error", "warning", "info", "debug"]


# Sketch of implementation:
#
# One Irker object manages multiple IRC sessions.  It holds a map of
# Dispatcher objects, one per (server, port) combination, which are
# responsible for routing messages to one of any number of Connection
# objects that do the actual socket conversations.  The reason for the
# Dispatcher layer is that IRC daemons limit the number of channels a
# client (that is, from the daemon's point of view, a socket) can be
# joined to, so each session to a server needs a flock of Connection
# instances each with its own socket.
#
# Connections are timed out and removed when either they haven't seen a
# PING for a while (indicating that the server may be stalled or down)
# or there has been no message traffic to them for a while, or
# even if the queue is nonempty but efforts to connect have failed for
# a long time.
#
# There are multiple threads. One accepts incoming traffic from all
# servers.  Each Connection also has a consumer thread and a
# thread-safe message queue.  The program main appends messages to
# queues as JSON requests are received; the consumer threads try to
# ship them to servers.  When a socket write stalls, it only blocks an
# individual consumer thread; if it stalls long enough, the session
# will be timed out. This solves the biggest problem with a
# single-threaded implementation, which is that you can't count on a
# single stalled write not hanging all other traffic - you're at the
# mercy of the length of the buffers in the TCP/IP layer.
#
# Message delivery is thus not reliable in the face of network stalls,
# but this was considered acceptable because IRC (notoriously) has the
# 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.
# It is strictly compliant to RFC1459, except for the interpretation and
# use of the DEAF and CHANLIMIT and (obsolete) MAXCHANNELS features.
#
# CHANLIMIT is as described in the Internet RFC draft
# draft-brocklesby-irc-isupport-03 at <http://www.mirc.com/isupport.html>.
# The ",isnick" feature is as described in
# <http://ftp.ics.uci.edu/pub/ietf/uri/draft-mirashi-url-irc-01.txt>.

# Historical note: the IRCClient and IRCServerConnection classes
# (~270LOC) replace the overweight, overcomplicated 3KLOC mass of
# irclib code that irker formerly used as a service library.  They
# still look similar to parts of irclib because I contributed to that
# code before giving up on it.


class IRCError(Exception):
    "An IRC exception"
    # pylint: disable=unnecessary-pass
    pass


class InvalidRequest(ValueError):
    "An invalid JSON request"
    # pylint: disable=unnecessary-pass
    pass


class ThreadingTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
    "Threaded TCP server for concurrent client handling"
    allow_reuse_address = True
    daemon_threads = True


class TCP6Server(socketserver.ThreadingMixIn, socketserver.TCPServer):
    "Threaded TCP server that supports IPv6"
    address_family = socket.AF_INET6
    allow_reuse_address = True
    daemon_threads = True


class ReusableUDPServer(socketserver.UDPServer):
    "UDP server with address reuse enabled"
    allow_reuse_address = True


class UDP6Server(socketserver.UDPServer):
    "UDP server that supports IPv6"
    address_family = socket.AF_INET6
    allow_reuse_address = True


class IRCClient:
    "An IRC client session to one or more servers."

    def __init__(self):
        self.mutex = threading.RLock()
        self.server_connections = []
        self.event_handlers = {}
        self.add_event_handler("ping", lambda c, e: c.ship("PONG %s" % e.target))
        self.drops = 0

    def newserver(self):
        "Initialize a new server-connection object."
        conn = IRCServerConnection(self)
        with self.mutex:
            self.server_connections.append(conn)
        return conn

    def spin(self, immediate=False, timeout=0.2):
        "Spin processing data from connections forever."
        # Outer loop should specifically *not* be mutex-locked.
        # Otherwise no other thread would ever be able to change
        # the shared state of an IRC object running this function.
        while True:
            nextsleep = 0
            with self.mutex:
                connected = [
                    x
                    for x in self.server_connections
                    if x is not None and x.socket is not None
                ]
                sockets = [x.socket for x in connected]
                if sockets:
                    connmap = {c.socket.fileno(): c for c in connected}
                    (insocks, _o, _e) = select.select(sockets, [], [], timeout)
                    for s in insocks:
                        try:
                            connmap[s.fileno()].consume()
                        except UnicodeDecodeError as e:
                            LOG.warning("%s: invalid encoding (%s)", self, e)
                else:
                    nextsleep = timeout
            if immediate and self.drops > 0:
                break
            time.sleep(nextsleep)

    def add_event_handler(self, event, handler):
        "Set a handler to be called later."
        with self.mutex:
            event_handlers = self.event_handlers.setdefault(event, [])
            event_handlers.append(handler)

    def handle_event(self, connection, event):
        with self.mutex:
            h = self.event_handlers
            th = h.get("all_events", []) + h.get(event.type, [])
            for handler in th:
                handler(connection, event)

    def drop_connection(self, connection):
        with self.mutex:
            self.server_connections.remove(connection)
            self.drops += 1


class LineBufferedStream:
    "Line-buffer a read stream."
    _crlf_re = re.compile(b"\r?\n")

    def __init__(self):
        self.buffer = b""

    def append(self, newbytes):
        self.buffer += newbytes

    def lines(self):
        "Iterate over lines in the buffer."
        lines = self._crlf_re.split(self.buffer)
        self.buffer = lines.pop()
        return iter(lines)

    def __iter__(self):
        return self.lines()


class IRCServerConnectionError(IRCError):
    pass


class IRCServerConnection:
    command_re = re.compile(
        "^(:(?P<prefix>[^ ]+) +)?(?P<command>[^ ]+)( *(?P<argument> .+))?"
    )
    # The full list of numeric-to-event mappings is in Perl's Net::IRC.
    # We only need to ensure that if some ancient server throws numerics
    # for the ones we actually want to catch, they're mapped.
    codemap = {
        "001": "welcome",
        "005": "featurelist",
        "324": "mode",
        # "404": "cannotsendtochan",
        "432": "erroneusnickname",
        "433": "nicknameinuse",
        "436": "nickcollision",
        "437": "unavailresource",
        "900": "saslloggedin",
        "903": "saslsuccess",
        "904": "saslfail",
        "905": "sasltoolong",
        "906": "saslaborted",
        "907": "saslalready",
        "908": "saslnamefail",
    }

    def __init__(self, master):
        self.master = master
        self.socket = None
        self.sasl_enabled = False
        self.sasl_in_progress = False
        self.sasl_complete = False
        self.sasl_failed = False
        self.sasl_mechanism = None
        self.sasl_username = None
        self.sasl_password = None
        self.capabilities = {}

    def _reset_registration_state(self):
        self.sasl_enabled = False
        self.sasl_in_progress = False
        self.sasl_complete = False
        self.sasl_failed = False
        self.sasl_mechanism = None
        self.sasl_username = None
        self.sasl_password = None
        self.capabilities = {}

    def _finish_cap_negotiation(self):
        self.ship("CAP END")

    def _split_ircv3_capabilities(self, capability_blob):
        capabilities = {}
        for token in capability_blob.split():
            if "=" in token:
                name, value = token.split("=", 1)
            else:
                name, value = token, None
            capabilities[name] = value
        return capabilities

    def _encode_sasl_plain_payload(self):
        authcid = self.sasl_username or self.nickname
        password = self.sasl_password or ""
        payload = "\0".join((authcid, authcid, password)).encode("utf-8")
        return base64.b64encode(payload).decode("ascii")

    def _send_authenticate_payload(self, payload):
        if not payload:
            self.ship("AUTHENTICATE +")
            return
        chunk_size = 400
        for offset in range(0, len(payload), chunk_size):
            self.ship("AUTHENTICATE " + payload[offset : offset + chunk_size])
        if len(payload) % chunk_size == 0:
            self.ship("AUTHENTICATE +")

    def _begin_sasl_authentication(self):
        if not self.sasl_enabled or self.sasl_complete or self.sasl_failed:
            return
        mechanism = (self.sasl_mechanism or "").upper()
        if not mechanism:
            self.sasl_failed = True
            self._finish_cap_negotiation()
            return
        self.sasl_in_progress = True
        self.ship("AUTHENTICATE " + mechanism)

    def _handle_cap(self, _conn, event):
        if len(event.arguments) < 2:
            return
        subcommand = event.arguments[0].upper()
        capability_blob = event.arguments[-1]
        if subcommand == "LS":
            self.capabilities = self._split_ircv3_capabilities(capability_blob)
            if "sasl" in self.capabilities and self.sasl_enabled:
                self.ship("CAP REQ :sasl")
            else:
                self._finish_cap_negotiation()
        elif subcommand == "ACK":
            acked = self._split_ircv3_capabilities(capability_blob)
            if "sasl" in acked and self.sasl_enabled:
                self._begin_sasl_authentication()
            else:
                self._finish_cap_negotiation()
        elif subcommand == "NAK":
            if self.sasl_enabled:
                self.sasl_failed = True
            self._finish_cap_negotiation()

    def _handle_authenticate(self, _conn, event):
        if not self.sasl_in_progress:
            return
        if event.target != "+":
            return
        mechanism = (self.sasl_mechanism or "").upper()
        if mechanism == "PLAIN":
            self._send_authenticate_payload(self._encode_sasl_plain_payload())
        elif mechanism == "EXTERNAL":
            self.ship("AUTHENTICATE +")
        else:
            self.ship("AUTHENTICATE *")
            self.sasl_failed = True
            self.sasl_in_progress = False
            self._finish_cap_negotiation()

    def _handle_sasl_success(self, _conn, _event):
        self.sasl_complete = True
        self.sasl_in_progress = False
        self._finish_cap_negotiation()

    def _handle_sasl_failure(self, _conn, _event):
        self.sasl_failed = True
        self.sasl_in_progress = False
        self._finish_cap_negotiation()

    def _wrap_socket(self, socket, target, certfile=None, cafile=None):
        ssl_context = ssl.create_default_context(cafile=cafile)
        if certfile:
            ssl_context.load_cert_chain(certfile)
        self.socket = ssl_context.wrap_socket(
            socket,
            server_hostname=target.servername,
        )
        return self.socket

    def connect(
        self,
        target,
        nickname,
        username=None,
        realname=None,
        timeout=5.0,
        sasl_username=None,
        sasl_password=None,
        sasl_mechanism=None,
        **kwargs
    ):
        LOG.debug(
            "connect(server=%r, port=%r, nickname=%r, ...)"
            % (target.servername, target.port, nickname)
        )
        if self.socket is not None:
            self.disconnect("Changing servers")

        self.buffer = LineBufferedStream()
        self.event_handlers = {}
        self.real_server_name = ""
        self.target = target
        self.nickname = nickname
        self._reset_registration_state()
        if sasl_mechanism:
            self.sasl_enabled = True
            self.sasl_mechanism = sasl_mechanism.upper()
            self.sasl_username = sasl_username or target.username or nickname
            self.sasl_password = sasl_password
            self.event_handlers["cap"] = [self._handle_cap]
            self.event_handlers["authenticate"] = [self._handle_authenticate]
            self.event_handlers["saslloggedin"] = [self._handle_sasl_success]
            self.event_handlers["saslsuccess"] = [self._handle_sasl_success]
            self.event_handlers["saslfail"] = [self._handle_sasl_failure]
            self.event_handlers["sasltoolong"] = [self._handle_sasl_failure]
            self.event_handlers["saslaborted"] = [self._handle_sasl_failure]
            self.event_handlers["saslalready"] = [self._handle_sasl_failure]
            self.event_handlers["saslnamefail"] = [self._handle_sasl_failure]

        err = None
        for res in socket.getaddrinfo(
            target.servername, target.port, 0, socket.SOCK_STREAM
        ):
            af, socktype, proto, _, sa = res
            try:
                if socks_on and PROXY_TYPE:
                    self.socket = socks.socksocket(af, socktype, proto)
                    self.socket.set_proxy(PROXY_TYPE, PROXY_HOST, PROXY_PORT)
                else:
                    self.socket = socket.socket(af, socktype, proto)
                if target.ssl:
                    self.socket = self._wrap_socket(
                        socket=self.socket, target=target, **kwargs
                    )
                # Do not explicitly bind the outbound socket to an
                # ephemeral local address here. The default connect()
                # behavior chooses an appropriate local address family
                # automatically, while bind(("", 0)) is not portable for
                # AF_INET6 sockets on all platforms.
                self.socket.settimeout(timeout)
                self.socket.connect(sa)
                self.socket.settimeout(None)

                # Break explicitly a reference cycle
                err = None
                break

            except socket.error as _:
                err = _
                if self.socket is not None:
                    self.socket.close()
                    self.socket = None

        if self.socket is None:
            err = socket.error("getaddrinfo returns an empty list")

        if err is not None:
            try:
                raise IRCServerConnectionError("Couldn't connect to socket: %s" % err)
            finally:
                # Break explicitly a reference cycle
                err = None
        if self.sasl_enabled:
            self.ship("CAP LS 302")
        if target.password:
            self.ship("PASS " + target.password)
        self.nick(self.nickname)
        self.user(
            username=target.username or username or "irker",
            realname=realname or "irker relaying client",
        )
        return self

    def close(self):
        # Without this thread lock, there is a window during which
        # select() can find a closed socket, leading to an EBADF error.
        with self.master.mutex:
            self.disconnect("Closing object")
            self.master.drop_connection(self)

    def consume(self):
        try:
            incoming = self.socket.recv(16384)
        except socket.error:
            # Server hung up on us.
            self.disconnect("Connection reset by peer")
            return
        if not incoming:
            # Dead air also indicates a connection reset.
            self.disconnect("Connection reset by peer")
            return

        self.buffer.append(incoming)

        for line in self.buffer:
            if not isinstance(line, str):
                line = line.decode("utf-8", errors="replace")
                LOG.debug("FROM: %s" % line)

            if not line:
                continue

            prefix = None
            command = None
            arguments = []
            self.handle_event(
                Event("every_raw_message", self.real_server_name, None, [line])
            )

            m = IRCServerConnection.command_re.match(line)
            if not m:
                LOG.warning("unparseable IRC line from %s: %r", self.target, line)
                continue

            if m.group("prefix"):
                prefix = m.group("prefix")
                if not self.real_server_name:
                    self.real_server_name = prefix
            if m.group("command"):
                command = m.group("command").lower()
            if m.group("argument"):
                a = m.group("argument").split(" :", 1)
                arguments = a[0].split()
                if len(a) == 2:
                    arguments.append(a[1])

            command = IRCServerConnection.codemap.get(command, command)
            target = None
            if command in ["privmsg", "notice"]:
                if not arguments:
                    LOG.warning(
                        "ignoring %s with no target from %s: %r",
                        command,
                        self.target,
                        line,
                    )
                    continue
                target = arguments.pop(0)
            elif command == "quit":
                if arguments:
                    arguments = [arguments[0]]
                else:
                    arguments = []
            elif command == "ping":
                if not arguments:
                    LOG.warning(
                        "ignoring ping with no argument from %s: %r", self.target, line
                    )
                    continue
                target = arguments[0]
            else:
                if arguments:
                    target = arguments[0]
                    arguments = arguments[1:]
                else:
                    arguments = []

            LOG.debug(
                "command: %s, source: %s, target: %s, arguments: %s"
                % (command, prefix, target, arguments)
            )
            self.handle_event(Event(command, prefix, target, arguments))

    def handle_event(self, event):
        self.master.handle_event(self, event)
        if event.type in self.event_handlers:
            for fn in self.event_handlers[event.type]:
                fn(self, event)

    def is_connected(self):
        return self.socket is not None

    def disconnect(self, message=""):
        if self.socket is None:
            return
        # Don't send a QUIT here - causes infinite loop!
        try:
            self.socket.shutdown(socket.SHUT_WR)
            self.socket.close()
        except socket.error:
            pass
        del self.socket
        self.socket = None
        self.handle_event(Event("disconnect", self.target.server(), "", [message]))

    def join(self, channel, key=""):
        self.ship("JOIN %s%s" % (channel, (key and (" " + key))))

    def mode(self, target, command):
        self.ship("MODE %s %s" % (target, command))

    def nick(self, newnick):
        self.ship("NICK " + newnick)

    def part(self, channel, message=""):
        cmd_parts = ["PART", channel]
        if message:
            cmd_parts.append(message)
        self.ship(" ".join(cmd_parts))

    def privmsg(self, target, text):
        self.ship("PRIVMSG %s :%s" % (target, text))

    def quit(self, message=""):
        self.ship("QUIT" + (message and (" :" + message)))

    def user(self, username, realname):
        self.ship("USER %s 0 * :%s" % (username, realname))

    def ship(self, string):
        "Ship a command to the server, appending CR/LF"
        try:
            self.socket.sendall(string.encode("utf-8") + b"\r\n")
            LOG.debug("TO: %s" % string)
        except socket.error:
            self.disconnect("Connection reset by peer.")


# pylint: disable=useless-object-inheritance,too-few-public-methods
class Event(object):
    def __init__(self, evtype, source, target, arguments=None):
        self.type = evtype
        self.source = source
        self.target = target
        if arguments is None:
            arguments = []
        self.arguments = arguments


def is_channel(string):
    return string and string[0] in "#&+!"


def validate_nick_template(template):
    "Return whether the nick template requires a numeric suffix, or raise ValueError."
    needs_number = bool(re.search("%.*d", template))
    if needs_number:
        try:
            template % 0
        except (TypeError, ValueError) as e:
            raise ValueError("invalid nick template %r: %s" % (template, e))
    elif "%" in template:
        raise ValueError(
            "invalid nick template %r: contains %% formatting but no numeric placeholder"
            % template
        )
    return needs_number


class Connection:
    def __init__(
        self,
        irker,
        target,
        nick_template,
        nick_needs_number=False,
        password=None,
        sasl_username=None,
        sasl_password=None,
        sasl_mechanism=None,
        **kwargs
    ):
        self.irker = irker
        self.target = target
        self.nick_template = nick_template
        self.nick_needs_number = nick_needs_number
        self.password = password
        self.sasl_username = sasl_username
        self.sasl_password = sasl_password
        self.sasl_mechanism = sasl_mechanism
        self.kwargs = kwargs
        self.nick_trial = None
        self.connection = None
        self.status = None
        self.last_xmit = time.time()
        self.last_ping = time.time()
        self.reconnect_at = 0
        self.channels_joined = {}
        self.channel_limits = {}
        # self.channel_needs_join = {}	# Set on receipt of cannotsendtochan, not yet used
        self.channel_mode_queried = set()
        self.lock = threading.RLock()
        # The consumer thread
        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
        else:
            return self.nick_template

    def handle_ping(self):
        "Register the fact that the server has pinged this connection."
        with self.lock:
            self.last_ping = time.time()

    def handle_welcome(self):
        "The server says we're OK, with a non-conflicting nick."
        with self.lock:
            self.status = "ready"
            connection = self.connection
        LOG.info("nick %s accepted" % self.nickname())
        # With SASL, authentication already happened during CAP negotiation.
        # Only fall back to NickServ identify when SASL is not in use.
        if self.password and connection is not None and not self.sasl_mechanism:
            connection.privmsg("nickserv", "identify %s" % self.password)

    def handle_badnick(self):
        "The server says our nick is ill-formed or has a conflict."
        with self.lock:
            LOG.info("nick %s rejected" % self.nickname())
            if self.nick_needs_number:
                # Randomness prevents a malicious user or bot from
                # anticipating the next trial name in order to block us
                # from completing the handshake.
                self.nick_trial += random.randint(1, 3)
                self.last_xmit = time.time()
                connection = self.connection
                nickname = self.nickname()
                disconnect_reason = None
            else:
                # Without a numeric fallback, the handshake cannot
                # progress. Fail fast and schedule a reconnect instead of
                # remaining stuck in handshaking until HANDSHAKE_TTL
                # expires.
                connection = self.connection
                nickname = None
                self.connection = None
                self.status = "disconnected"
                self.reconnect_at = time.time() + RECONNECT_DELAY
                disconnect_reason = "nickname rejected"
        if connection is not None and nickname is not None:
            connection.nick(nickname)
        elif connection is not None and disconnect_reason is not None:
            try:
                connection.disconnect(disconnect_reason)
            except Exception:
                pass

    def handle_disconnect(self):
        "Server disconnected us for flooding or some other reason."
        with self.lock:
            self.connection = None
            if self.status != "expired":
                self.status = "disconnected"
                # Avoid flooding the server if it disconnects
                # immediately on successful login.
                self.reconnect_at = time.time() + RECONNECT_DELAY

    def handle_kick(self, outof):
        "We've been kicked."
        with self.lock:
            self.status = "handshaking"
            try:
                del self.channels_joined[outof]
            except KeyError:
                LOG.error(
                    "irkerd: kicked by %s from %s that's not joined"
                    % (self.target, outof)
                )
        qcopy = []
        while not self.queue.empty():
            (channel, message, key) = self.queue.get()
            if channel != outof:
                qcopy.append((channel, message, key))
        for channel, message, key in qcopy:
            self.queue.put((channel, message, key))
        self.status = "ready"

    # def handle_cannotsendtochan(self, outof):
    #    "Joinless message send refused."
    #    self.channel_needs_join.add(outof)
    def handle_mode(self, outof, arg):
        "Mode reply."
        # Stub - not yet used
        LOG.info("MODE source %s has mode %s" % (outof, arg))

    def send(self, channel, message):
        for segment in message.split("\n"):
            # Truncate conservatively using UTF-8 byte length, not character
            # count. IRC's line limit is byte-oriented.
            # 500 = 512 - CRLF - 'PRIVMSG ' - ' :'
            max_bytes = 500 - len(channel.encode("utf-8"))
            encoded = segment.encode("utf-8")
            if len(encoded) > max_bytes:
                encoded = encoded[:max_bytes]
                # Avoid ending on a partial UTF-8 sequence.
                while encoded:
                    try:
                        segment = encoded.decode("utf-8")
                        break
                    except UnicodeDecodeError:
                        encoded = encoded[:-1]
                else:
                    segment = ""
            try:
                self.connection.privmsg(channel, segment)
            except ValueError as err:
                LOG.warning(
                    ("rejected a message to %s on %s " "because: %s")
                    % (channel, self.target, str(err))
                )
                LOG.debug(traceback.format_exc())
            time.sleep(ANTI_FLOOD_DELAY)
        with self.lock:
            self.last_xmit = time.time()
        LOG.info(
            "XMIT_TTL bump (%s transmission) at %s" % (self.target, time.asctime())
        )

    def enqueue(self, channel, message, key, quit_after=False):
        "Enque a message for transmission."
        with self.lock:
            if self.thread is None or not self.thread.is_alive():
                self.status = "unseen"
                self.thread = threading.Thread(target=self.dequeue, daemon=True)
                self.thread.start()
        self.queue.put((channel, message, key))
        if quit_after:
            self.queue.put((channel, None, key))

    def dequeue(self):
        "Try to ship pending messages from the queue."
        # pylint:disable=broad-except
        try:
            while True:
                # We want to be kind to the IRC servers and not hold unused
                # sockets open forever, so they have a time-to-live.  The
                # loop is coded this particular way so that we can drop
                # the actual server connection when its time-to-live
                # expires, then reconnect and resume transmission if the
                # queue fills up again.
                if self.queue.empty():
                    # Queue is empty, at some point we want to time out
                    # the connection rather than holding a socket open in
                    # the server forever.
                    now = time.time()
                    xmit_timeout = now > self.last_xmit + XMIT_TTL
                    ping_timeout = now > self.last_ping + PING_TTL
                    if self.status == "disconnected":
                        # If the queue is empty, we can drop this connection.
                        self.status = "expired"
                        break
                    elif xmit_timeout or ping_timeout:
                        LOG.info(
                            (
                                "timing out connection to %s at %s "
                                "(ping_timeout=%s, xmit_timeout=%s)"
                            )
                            % (self.target, time.asctime(), ping_timeout, xmit_timeout)
                        )
                        with self.irker.irc.mutex:
                            conn = self.connection
                            self.connection = None
                            if conn is not None:
                                conn.context = None
                                conn.quit("transmission timeout")
                        self.status = "disconnected"
                    else:
                        # Prevent this thread from hogging the CPU by pausing
                        # for just a little bit after the queue-empty check.
                        # As long as this is less that the duration of a human
                        # reflex arc it is highly unlikely any human will ever
                        # notice.
                        time.sleep(ANTI_BUZZ_DELAY)
                elif (
                    self.status == "disconnected"
                    and time.time() > self.last_xmit + DISCONNECT_TTL
                ):
                    # Queue is nonempty, but the IRC server might be
                    # down. Letting failed connections retain queue
                    # space forever would be a memory leak.
                    self.status = "expired"
                    break
                elif not self.connection and self.status != "expired":
                    # Queue is nonempty but server isn't connected.
                    if time.time() < self.reconnect_at:
                        time.sleep(ANTI_BUZZ_DELAY)
                        continue
                    with self.irker.irc.mutex:
                        self.connection = self.irker.irc.newserver()
                        self.connection.context = self
                        # Try to avoid colliding with other instances
                        self.nick_trial = random.randint(1, 990)
                        self.channels_joined = {}
                        try:
                            # This will throw
                            # IRCServerConnectionError on failure
                            self.connection.connect(
                                target=self.target,
                                nickname=self.nickname(),
                                sasl_username=self.sasl_username,
                                sasl_password=self.sasl_password,
                                sasl_mechanism=self.sasl_mechanism,
                                **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 as e:
                            LOG.error("irkerd: %s" % e)
                            # Treat connect failures as transient unless the
                            # connection has been disconnected long enough to
                            # age out under DISCONNECT_TTL. Mark the
                            # connection for retry instead of expiring it
                            # immediately.
                            with self.irker.irc.mutex:
                                conn = self.connection
                                self.connection = None
                                if conn is not None:
                                    conn.context = None
                                    try:
                                        conn.disconnect("connect failed")
                                    except Exception:
                                        pass
                            self.status = "disconnected"
                            self.reconnect_at = time.time() + RECONNECT_DELAY
                            continue
                elif self.status == "handshaking":
                    if time.time() > self.last_xmit + HANDSHAKE_TTL:
                        self.status = "expired"
                        break
                    else:
                        # Don't buzz on the empty-queue test while we're
                        # handshaking
                        time.sleep(ANTI_BUZZ_DELAY)
                elif (
                    self.status == "unseen"
                    and time.time() > self.last_xmit + UNSEEN_TTL
                ):
                    # Nasty people could attempt a denial-of-service
                    # attack by flooding us with requests with invalid
                    # servernames. We guard against this by rapidly
                    # expiring connections that have a nonempty queue but
                    # have never had a successful open.
                    self.status = "expired"
                    break
                elif self.status == "ready":
                    (channel, message, key) = self.queue.get()
                    if channel not in self.channels_joined:
                        self.connection.join(channel, key=key)
                        LOG.info("joining %s on %s." % (channel, self.target))
                        self.channels_joined[channel] = time.time()
                    # None is magic - it's a request to quit the server
                    if message is None:
                        self.connection.quit()
                    # An empty message might be used as a keepalive or
                    # to join a channel for logging, so suppress the
                    # privmsg send unless there is actual traffic.
                    elif message:
                        self.send(channel, message)
                    self.queue.task_done()
                elif self.status == "expired":
                    LOG.error("irkerd: we're expired but still running! This is a bug.")
                    break
        except Exception as e:
            LOG.error("irkerd: exception %s in thread for %s" % (e, self.target))
            # Maybe this should have its own status?
            self.status = "expired"
            LOG.debug(traceback.format_exc())
        finally:
            # Make sure we don't leave any zombies behind
            if self.connection:
                self.connection.close()

    def live(self):
        "Should this connection not be scavenged?"
        with self.lock:
            return self.status != "expired"

    def joined_to(self, channel):
        "Is this connection joined to the specified channel?"
        with self.lock:
            return channel in self.channels_joined

    def accepting(self, channel):
        "Can this connection accept a join of this channel?"
        with self.lock:
            if self.channel_limits:
                match_count = 0
                for already in self.channels_joined:
                    # This obscure code is because the RFCs allow separate limits
                    # by channel type (indicated by the first character of the name)
                    # a feature that is almost never actually used.
                    if already[0] == channel[0]:
                        match_count += 1
                return match_count < self.channel_limits.get(channel[0], CHANNEL_MAX)
            else:
                return len(self.channels_joined) < CHANNEL_MAX


class Target:
    "Represent a transmission target."

    def __init__(self, url):
        self.url = url
        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,
        # got kicked, and irkerd crashed because the server returned
        # "#channel" in the notification that our kick handler saw.
        self.channel = parsed.path.lstrip("/").lower()
        if parsed.fragment:
            self.channel += "#" + parsed.fragment
        isnick = self.channel.endswith(",isnick")
        if isnick:
            self.channel = self.channel[:-7]
        if self.channel and not isnick and self.channel[0] not in "#&+":
            self.channel = "#" + self.channel
        # support both channel?secret and channel?key=secret
        self.key = ""
        if parsed.query:
            self.key = re.sub("^key=", "", parsed.query)

    def __str__(self):
        "Represent this instance as a string"
        return self.servername or self.url or repr(self)

    def validate(self):
        "Raise InvalidRequest if the URL is missing a critical component"
        if not self.servername:
            raise InvalidRequest("target URL missing a servername: %r" % self.url)
        if not self.channel:
            raise InvalidRequest("target URL missing a channel: %r" % self.url)

    def server(self):
        "Return a hashable tuple representing the destination server."
        return (self.servername, self.port)


class Dispatcher:
    "Manage connections to a particular server-port combination."

    def __init__(self, irker, **kwargs):
        self.irker = irker
        self.kwargs = kwargs
        self.connections = []
        self.lock = threading.RLock()

    def close(self):
        "Expire this dispatcher and actively close any live IRC sockets."
        with self.lock:
            connections = list(self.connections)
        for conn in connections:
            with conn.lock:
                conn.status = "expired"
                connection = conn.connection
            if connection is not None:
                with self.irker.irc.mutex:
                    try:
                        connection.context = None
                    except AttributeError:
                        pass
                    try:
                        connection.close()
                    except Exception as e:
                        LOG.error(
                            "irkerd: error closing connection to %s: %s"
                            % (conn.target, e)
                        )
                        LOG.debug(traceback.format_exc())
                    finally:
                        with conn.lock:
                            if conn.connection is connection:
                                conn.connection = None

    def dispatch(self, channel, message, key, quit_after=False):
        "Dispatch messages for our server-port combination."
        with self.lock:
            # First, check if there is room for another channel
            # on any of our existing connections.
            self.connections = [x for x in self.connections if x.live()]
            connections = list(self.connections)
            eligibles = [x for x in connections if x.joined_to(channel)] or [
                x for x in connections if x.accepting(channel)
            ]
            if eligibles:
                chosen = eligibles[0]
            else:
                # All connections are full up. Look for one old enough to be
                # scavenged.
                ancients = []
                cutoff = time.time() - CHANNEL_TTL
                for connection in connections:
                    with connection.lock:
                        joined_items = list(connection.channels_joined.items())
                    for chan, age in joined_items:
                        if age < cutoff:
                            ancients.append((connection, chan, age))
                if ancients:
                    ancients.sort(key=lambda x: x[2])
                    (found_connection, drop_channel, _drop_age) = ancients[0]
                    with found_connection.lock:
                        live_connection = found_connection.connection
                        if drop_channel in found_connection.channels_joined:
                            del found_connection.channels_joined[drop_channel]
                    if live_connection is not None:
                        live_connection.part(drop_channel, "scavenged by irkerd")
                    chosen = found_connection
                else:
                    # All existing channels had recent activity
                    chosen = Connection(self.irker, **self.kwargs)
                    self.connections.append(chosen)
        chosen.enqueue(channel, message, key, quit_after)

    def live(self):
        "Does this server-port combination have any live connections?"
        with self.lock:
            self.connections = [x for x in self.connections if x.live()]
            return len(self.connections) > 0

    def pending(self):
        "Return all connections with pending traffic."
        with self.lock:
            return [x for x in self.connections if not x.queue.empty()]

    def last_xmit(self):
        "Return the time of the most recent transmission."
        with self.lock:
            connections = list(self.connections)
        if not connections:
            return 0
        return max(x.last_xmit for x in connections)


class Irker:
    "Persistent IRC multiplexer."

    def __init__(self, logfile=None, **kwargs):
        self.logfile = logfile
        self.kwargs = kwargs
        self.irc = IRCClient()
        self.irc.add_event_handler("ping", self._handle_ping)
        self.irc.add_event_handler("welcome", self._handle_welcome)
        self.irc.add_event_handler("erroneusnickname", self._handle_badnick)
        self.irc.add_event_handler("nicknameinuse", self._handle_badnick)
        self.irc.add_event_handler("nickcollision", self._handle_badnick)
        self.irc.add_event_handler("unavailresource", self._handle_badnick)
        self.irc.add_event_handler("featurelist", self._handle_features)
        self.irc.add_event_handler("disconnect", self._handle_disconnect)
        self.irc.add_event_handler("kick", self._handle_kick)
        # self.irc.add_event_handler("cannotsendtochan", self._handle_cannotsendtochan)
        self.irc.add_event_handler("mode", self._handle_mode)
        self.irc.add_event_handler("every_raw_message", self._handle_every_raw_message)
        self.servers = {}
        self.servers_lock = threading.RLock()

    def thread_launch(self):
        thread = threading.Thread(target=self.irc.spin, daemon=True)
        # self.irc._thread = thread
        thread.start()

    def _handle_ping(self, connection, _event):
        "PING arrived, bump the last-received time for the connection."
        if connection.context:
            connection.context.handle_ping()

    def _handle_welcome(self, connection, _event):
        "Welcome arrived, nick accepted for this connection."
        if connection.context:
            connection.context.handle_welcome()

    def _handle_badnick(self, connection, _event):
        "Nick not accepted for this connection."
        if connection.context:
            connection.context.handle_badnick()

    def _handle_features(self, connection, event):
        "Determine if and how we can set deaf mode."
        if connection.context:
            cxt = connection.context
            arguments = event.arguments
            for lump in arguments:
                if lump.startswith("DEAF="):
                    if not self.logfile:
                        connection.mode(cxt.nickname(), "+" + lump[5:])
                elif lump.startswith("MAXCHANNELS="):
                    try:
                        m = int(lump[12:])
                        for pref in "#&+":
                            cxt.channel_limits[pref] = m
                        LOG.info("%s maxchannels is %d" % (connection.target, m))
                    except ValueError:
                        LOG.error("irkerd: ill-formed MAXCHANNELS property")
                elif lump.startswith("CHANLIMIT=#:"):
                    limits = lump[10:].split(",")
                    try:
                        for token in limits:
                            (prefixes, limit) = token.split(":")
                            limit = int(limit)
                            for c in prefixes:
                                cxt.channel_limits[c] = limit
                        LOG.info(
                            "%s channel limit map is %s"
                            % (connection.target, cxt.channel_limits)
                        )
                    except ValueError:
                        LOG.error("irkerd: ill-formed CHANLIMIT property")

    def _handle_disconnect(self, connection, _event):
        "Server hung up the connection."
        LOG.info("server %s disconnected" % connection.target)
        connection.close()
        if connection.context:
            connection.context.handle_disconnect()

    def _handle_kick(self, connection, event):
        "Server hung up the connection."
        target = event.target
        LOG.info("irker has been kicked from %s on %s" % (target, connection.target))
        if connection.context:
            connection.context.handle_kick(target)

    # def _handle_cannotsendtochan(self, connection, event):
    #    "Server refused message send without channel join"
    #    target = event.target
    #    LOG.info("joinless message refusal from %s on %s" % (
    #        target, connection.target))
    #    if connection.context:
    #        connection.context.handle_cannotsendtochan(target)
    def _handle_mode(self, connection, event):
        "Process mode reply."
        LOG.info(
            "mode reply %s on %s with %s"
            % (event.target, connection.target, event.arguments)
        )
        if connection.context and event.arguments:
            connection.context.handle_mode(event.target, event.arguments[0])

    def _handle_every_raw_message(self, _connection, event):
        "Log all messages when in watcher mode."
        if self.logfile:
            with open(self.logfile, "ab") as logfp:
                message = "%03f|%s|%s\n" % (
                    time.time(),
                    event.source,
                    event.arguments[0],
                )
                logfp.write(message.encode("utf-8"))

    def pending(self):
        "Do we have any pending message traffic?"
        with self.servers_lock:
            return [k for (k, v) in self.servers.items() if v.pending()]

    def _parse_request(self, line):
        "Request-parsing helper for the handle() method"
        request = json.loads(line.strip())
        if not isinstance(request, dict):
            raise InvalidRequest("request is not a JSON dictionary: %r" % request)
        if "to" not in request or "privmsg" not in request:
            raise InvalidRequest(
                "malformed request - 'to' or 'privmsg' missing: %r" % request
            )
        channels = request["to"]
        message = request["privmsg"]
        if not isinstance(channels, (list, str)):
            raise InvalidRequest(
                "malformed request - unexpected channel type: %r" % channels
            )
        if not isinstance(message, str):
            raise InvalidRequest(
                "malformed request - unexpected message type: %r" % message
            )
        if not isinstance(channels, list):
            channels = [channels]
        targets = []
        for url in channels:
            try:
                if not isinstance(url, str):
                    raise InvalidRequest(
                        "malformed request - URL has unexpected type: %r" % url
                    )
                target = Target(url)
                target.validate()
            except InvalidRequest as e:
                LOG.error("irkerd: " + str(e))
            else:
                targets.append(target)
        return (targets, message)

    def handle(self, line, quit_after=False):
        "Perform a JSON relay request."
        try:
            targets, message = self._parse_request(line=line)
            for target in targets:
                with self.servers_lock:
                    # GC dispatchers with no active connections
                    servernames = list(self.servers)
                    for servername in servernames:
                        if not self.servers[servername].live():
                            del self.servers[servername]

                    # If we might be pushing a resource limit even
                    # after garbage collection, remove a session.  The
                    # goal here is to head off DoS attacks that aim at
                    # exhausting thread space or file descriptors.
                    # The cost is that attempts to DoS this service
                    # will cause lots of join/leave spam as we
                    # scavenge old channels after connecting to new
                    # ones. The particular method used for selecting a
                    # session to be terminated doesn't matter much; we
                    # choose the one longest idle on the assumption
                    # that message activity is likely to be clumpy.
                    if (
                        target.server() not in self.servers
                        and len(self.servers) >= CONNECTION_MAX
                    ):
                        oldest = min(
                            self.servers.keys(),
                            key=lambda name: self.servers[name].last_xmit(),
                        )
                        self.servers[oldest].close()
                        del self.servers[oldest]

                    if target.server() not in self.servers:
                        self.servers[target.server()] = Dispatcher(
                            self, target=target, **self.kwargs
                        )
                    dispatcher = self.servers[target.server()]

                dispatcher.dispatch(
                    target.channel, message, target.key, quit_after=quit_after
                )
        except InvalidRequest as e:
            LOG.error("irkerd: " + str(e))
        except ValueError:
            LOG.error("irkerd: " + "can't recognize JSON on input: %r" % line)
        except RuntimeError:
            LOG.error("irkerd: " + "wildly malformed JSON blew the parser stack.")


# Shuts up pylint.
# This is set up after option processing.
irker = None


def _drain_to_newline(rfile):
    "Consume bytes until a newline or EOF after an oversized request."
    while True:
        chunk = rfile.readline(4096)
        if not chunk or chunk.endswith(b"\n"):
            break


class IrkerTCPHandler(socketserver.StreamRequestHandler):
    def handle(self):
        while True:
            line = self.rfile.readline(MAX_REQUEST_BYTES + 1)
            if not line:
                break
            oversized = len(line) > MAX_REQUEST_BYTES
            if oversized and not line.endswith(b"\n"):
                _drain_to_newline(self.rfile)
            if oversized:
                LOG.error("irkerd: oversized TCP request discarded")
                continue
            if not isinstance(line, str):
                line = line.decode("utf-8", errors="replace")
            irker.handle(line=line.strip())


class IrkerUDPHandler(socketserver.BaseRequestHandler):
    def handle(self):
        line = self.request[0].strip()
        # socket = self.request[1]
        if len(line) > MAX_REQUEST_BYTES:
            LOG.error("irkerd: oversized UDP request discarded")
            return
        if not isinstance(line, str):
            line = line.decode("utf-8", errors="replace")
        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])
    password_group = parser.add_mutually_exclusive_group()
    parser.add_argument(
        "-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(
        "-H6",
        "--host6",
        metavar="ADDRESS",
        default=HOST6,
        help="IPv6 address to listen on",
    )
    parser.add_argument(
        "-l",
        "--log-file",
        metavar="PATH",
        help="file for saving captured message traffic",
    )
    parser.add_argument(
        "-n",
        "--nick",
        metavar="NAME",
        default="irker%03d",
        help="nickname (optionally with a '%%.*d' server connection marker)",
    )
    password_group.add_argument(
        "-p", "--password", metavar="PASSWORD", help="NickServ password"
    )
    password_group.add_argument(
        "-P",
        "--password-file",
        metavar="PATH",
        type=argparse.FileType("r"),
        help="NickServ password from file",
    )
    sasl_password_group = parser.add_mutually_exclusive_group()
    parser.add_argument(
        "--sasl-username",
        metavar="USERNAME",
        help="SASL account name (defaults to nickname if SASL is enabled)",
    )
    sasl_password_group.add_argument(
        "--sasl-password",
        metavar="PASSWORD",
        help="SASL password",
    )
    sasl_password_group.add_argument(
        "--sasl-password-file",
        metavar="PATH",
        type=argparse.FileType("r"),
        help="SASL password from file",
    )
    parser.add_argument(
        "--sasl-mechanism",
        metavar="MECHANISM",
        choices=["PLAIN", "EXTERNAL"],
        help="SASL mechanism to use",
    )
    parser.add_argument(
        "-t",
        "--timeout",
        metavar="TIMEOUT",
        type=float,
        default=5.0,
        help="connection timeout in seconds (default: 5.0)",
    )
    parser.add_argument(
        "-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()

    if not args.log_file and in_background():
        # There's a case for falling back to address = ('localhost', 514)
        # But some systems (including OS X) disable this for security reasons.
        handler = logging.handlers.SysLogHandler(facility="daemon")
    else:
        handler = logging.StreamHandler()

    LOG.addHandler(handler)
    if args.log_level:
        log_level = getattr(logging, args.log_level.upper())
        LOG.setLevel(log_level)

    if args.password_file:
        with args.password_file as f:
            # IRC passwords must be at most 128 bytes, and cannot contain a \n
            args.password = f.read(128).split("\n")[0].strip()

    if args.sasl_password_file:
        with args.sasl_password_file as f:
            # IRC passwords must be at most 128 bytes, and cannot contain a \n
            args.sasl_password = f.read(128).split("\n")[0].strip()

    if args.sasl_mechanism == "PLAIN" and args.sasl_password is None:
        parser.error(
            "--sasl-mechanism PLAIN requires --sasl-password or --sasl-password-file"
        )

    if args.sasl_password and not args.sasl_mechanism:
        parser.error("--sasl-password requires --sasl-mechanism")
    if args.sasl_username and not args.sasl_mechanism:
        parser.error("--sasl-username requires --sasl-mechanism")

    try:
        nick_needs_number = validate_nick_template(args.nick)
    except ValueError as e:
        parser.error(str(e))

    irker = Irker(
        logfile=args.log_file,
        nick_template=args.nick,
        nick_needs_number=nick_needs_number,
        password=args.password,
        sasl_username=args.sasl_username,
        sasl_password=args.sasl_password,
        sasl_mechanism=args.sasl_mechanism,
        cafile=args.ca_file,
        certfile=args.cert_file,
        timeout=args.timeout,
    )
    LOG.info("irkerd version %s" % version)
    if args.immediate:
        if not args.message:
            # We want newline to become '\n' and tab to become '\t';
            # the JSON decoder will undo these transformations.
            # This will also encode backslash, backspace, formfeed,
            # and high-half characters, which might produce unexpected
            # results on output.
            args.message = sys.stdin.read().encode("unicode_escape").decode("ascii")
        irker.irc.add_event_handler("quit", lambda _c, _e: sys.exit(0))
        payload = json.dumps({"to": args.immediate, "privmsg": args.message})
        irker.handle(
            payload,
            quit_after=True,
        )
        irker.irc.spin(immediate=True)
    else:
        if args.message:
            LOG.error(
                "irkerd: message argument given (%r), but --immediate not set"
                % (args.message)
            )
            raise SystemExit(1)
        irker.thread_launch()
        try:
            tcpserver = ThreadingTCPServer((args.host, PORT), IrkerTCPHandler)
            udpserver = ReusableUDPServer((args.host, PORT), IrkerUDPHandler)
            tcp6server = TCP6Server((args.host6, PORT), IrkerTCPHandler)
            udp6server = UDP6Server((args.host6, PORT), IrkerUDPHandler)
            for server in [tcpserver, udpserver, tcp6server, udp6server]:
                server = threading.Thread(target=server.serve_forever, daemon=True)
                server.start()
            try:
                signal.pause()
            except KeyboardInterrupt:
                raise SystemExit(1)
        except socket.error as e:
            LOG.error("irkerd: server launch failed: %r\n" % e)

# end
