diff --git a/.gitignore b/.gitignore index d1e6f0b..fb899c8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ __pycache__/ *.save* *.db *.pid +*.pem diff --git a/classes.py b/classes.py index 729250f..0b7b8dc 100644 --- a/classes.py +++ b/classes.py @@ -21,6 +21,7 @@ class IrcUser(): self.identified = False self.channels = set() + self.away = '' def __repr__(self): return repr(self.__dict__) diff --git a/config.yml.example b/config.yml.example index fc4453d..c091cce 100644 --- a/config.yml.example +++ b/config.yml.example @@ -53,6 +53,17 @@ servers: # PyLink might introduce a nick that is too long and cause netsplits! maxnicklen: 30 + # Toggles SSL for this network. Defaults to false if not specified, and requires the + # ssl_certfile and ssl_keyfile options to work. + # ssl: true + + # ssl_certfile: pylink-cert.pem + # ssl_keyfile: pylink-key.pem + + # Optionally, you can set this option to verify the SSL certificate + # fingerprint (SHA1) of your uplink. + # ssl_fingerprint: "e0fee1adf795c84eec4735f039503eb18d9c35cc" + ts6net: ip: 127.0.0.1 port: 7000 diff --git a/main.py b/main.py index 3bb3b3e..d377592 100755 --- a/main.py +++ b/main.py @@ -7,6 +7,8 @@ import time import sys from collections import defaultdict import threading +import ssl +import hashlib from log import log import conf @@ -73,20 +75,73 @@ class Irc(): ip = self.serverdata["ip"] port = self.serverdata["port"] while True: - log.info("Connecting to network %r on %s:%s", self.name, ip, port) self.initVars() + checks_ok = True try: + self.socket = socket.socket() + self.socket.setblocking(0) # Initial connection timeout is a lot smaller than the timeout after # we've connected; this is intentional. - self.socket = socket.create_connection((ip, port), timeout=self.pingfreq) - self.socket.setblocking(0) + self.socket.settimeout(self.pingfreq) + self.ssl = self.serverdata.get('ssl') + if self.ssl: + log.info('(%s) Attempting SSL for this connection...', self.name) + certfile = self.serverdata.get('ssl_certfile') + keyfile = self.serverdata.get('ssl_keyfile') + if certfile and keyfile: + try: + self.socket = ssl.wrap_socket(self.socket, + certfile=certfile, + keyfile=keyfile) + except OSError: + log.exception('(%s) Caught OSError trying to ' + 'initialize the SSL connection; ' + 'are "ssl_certfile" and ' + '"ssl_keyfile" set correctly?', + self.name) + checks_ok = False + else: + log.error('(%s) SSL certfile/keyfile was not set ' + 'correctly, aborting... ', self.name) + checks_ok = False + log.info("Connecting to network %r on %s:%s", self.name, ip, port) + self.socket.connect((ip, port)) self.socket.settimeout(self.pingtimeout) - self.proto.connect(self) - self.spawnMain() - log.info('(%s) Starting ping schedulers....', self.name) - self.schedulePing() - log.info('(%s) Server ready; listening for data.', self.name) - self.run() + + if self.ssl and checks_ok: + peercert = self.socket.getpeercert(binary_form=True) + sha1fp = hashlib.sha1(peercert).hexdigest() + expected_fp = self.serverdata.get('ssl_fingerprint') + if expected_fp: + if sha1fp != expected_fp: + log.error('(%s) Uplink\'s SSL certificate ' + 'fingerprint (SHA1) does not match the ' + 'one configured: expected %r, got %r; ' + 'disconnecting...', self.name, + expected_fp, sha1fp) + checks_ok = False + else: + log.info('(%s) Uplink SSL certificate fingerprint ' + '(SHA1) verified: %r', self.name, sha1fp) + else: + log.info('(%s) Uplink\'s SSL certificate fingerprint ' + 'is %r. You can enhance the security of your ' + 'link by specifying this in a "ssl_fingerprint"' + ' option in your server block.', self.name, + sha1fp) + + if checks_ok: + self.proto.connect(self) + self.spawnMain() + log.info('(%s) Starting ping schedulers....', self.name) + self.schedulePing() + log.info('(%s) Server ready; listening for data.', self.name) + self.run() + else: + log.error('(%s) A configuration error was encountered ' + 'trying to set up this connection. Please check' + ' your configuration file and try again.', + self.name) except (socket.error, classes.ProtocolError, ConnectionError) as e: log.warning('(%s) Disconnected from IRC: %s: %s', self.name, type(e).__name__, str(e)) @@ -117,8 +172,8 @@ class Irc(): log.debug('(%s) self.pingtimeout: %s', self.name, self.pingtimeout) data = self.socket.recv(2048) buf += data - if self.connected and not data: - log.warn('(%s) No data received and self.connected is not set; disconnecting!', self.name) + if self.connected.is_set() and not data: + log.warn('(%s) No data received and self.connected is set; disconnecting!', self.name) break while b'\n' in buf: line, buf = buf.split(b'\n', 1) diff --git a/plugins/relay.py b/plugins/relay.py index 35c2996..b8b2c5d 100644 --- a/plugins/relay.py +++ b/plugins/relay.py @@ -109,7 +109,8 @@ def getPrefixModes(irc, remoteirc, channel, user): for pmode in ('owner', 'admin', 'op', 'halfop', 'voice'): if pmode in remoteirc.cmodes: # Mode supported by IRCd mlist = irc.channels[channel].prefixmodes[pmode+'s'] - log.debug('(%s) getPrefixModes: checking if %r is in %r', irc.name, user, mlist) + log.debug('(%s) getPrefixModes: checking if %r is in %s list: %r', + irc.name, user, pmode, mlist) if user in mlist: modes += remoteirc.cmodes[pmode] return modes @@ -142,6 +143,9 @@ def getRemoteUser(irc, remoteirc, user, spawnIfMissing=True): host=host, realname=realname, modes=modes, ts=userobj.ts).uid remoteirc.users[u].remote = irc.name + away = userobj.away + if away: + remoteirc.proto.awayClient(remoteirc, u, away) relayusers[(irc.name, user)][remoteirc.name] = u return u @@ -158,11 +162,9 @@ def getLocalUser(irc, user, targetirc=None): # First, iterate over everyone! remoteuser = None for k, v in relayusers.items(): - log.debug('(%s) getLocalUser: processing %s, %s in relayusers', irc.name, k, v) if k[0] == irc.name: # We don't need to do anything if the target users is on # the same network as us. - log.debug('(%s) getLocalUser: skipping %s since the target network matches the source network.', irc.name, k) continue if v.get(irc.name) == user: # If the stored pseudoclient UID for the kicked user on @@ -718,9 +720,9 @@ def removeChannel(irc, channel): irc.proto.partClient(irc, user, channel, 'Channel delinked.') # Don't ever quit it either... if user != irc.pseudoclient.uid and not irc.users[user].channels: - irc.proto.quitClient(irc, user, 'Left all shared channels.') remoteuser = getLocalUser(irc, user) del relayusers[remoteuser][irc.name] + irc.proto.quitClient(irc, user, 'Left all shared channels.') @utils.add_cmd def create(irc, source, args): @@ -847,7 +849,10 @@ def delink(irc, source, args): if entry: if entry[0] == irc.name: # We own this channel. if not remotenet: - utils.msg(irc, source, "Error: you must select a network to delink, or use the 'destroy' command no remove this relay entirely.") + utils.msg(irc, source, "Error: You must select a network to " + "delink, or use the 'destroy' command to remove " + "this relay entirely (it was created on the current " + "network).") return else: for link in db[entry]['links'].copy(): @@ -933,3 +938,9 @@ def linked(irc, source, args): else: s += '(no relays yet)' utils.msg(irc, source, s) + +def handle_away(irc, numeric, command, args): + for netname, user in relayusers[(irc.name, numeric)].items(): + remoteirc = utils.networkobjects[netname] + remoteirc.proto.awayClient(remoteirc, user, args['text']) +utils.add_hook(handle_away, 'AWAY') diff --git a/protocols/inspircd.py b/protocols/inspircd.py index 995dc6c..7ac1f3c 100644 --- a/protocols/inspircd.py +++ b/protocols/inspircd.py @@ -80,7 +80,8 @@ def sjoinServer(irc, server, channel, users, ts=None): else: utils.applyModes(irc, channel, modes) ''' - modes = irc.channels[channel].modes + # Strip out list-modes, they shouldn't be ever sent in FJOIN. + modes = [m for m in irc.channels[channel].modes if m[0] not in irc.cmodes['*A']] uids = [] changedmodes = [] namelist = [] @@ -297,6 +298,16 @@ def numericServer(irc, source, numeric, text): "locally by InspIRCd servers, so there is no " "need for PyLink to send numerics directly yet.") +def awayClient(irc, source, text): + """ + + Sends an AWAY message with text from PyLink client . + can be an empty string to unset AWAY status.""" + if text: + _send(irc, source, 'AWAY %s :%s' % (int(time.time()), text)) + else: + _send(irc, source, 'AWAY') + def connect(irc): ts = irc.start_ts @@ -664,3 +675,13 @@ def handle_fname(irc, numeric, command, args): def handle_endburst(irc, numeric, command, args): return {} + +def handle_away(irc, numeric, command, args): + # <- :1MLAAAAIG AWAY 1439371390 :Auto-away + try: + ts = args[0] + irc.users[numeric].away = text = args[1] + return {'text': text, 'ts': ts} + except IndexError: # User is unsetting away status + irc.users[numeric].away = '' + return {'text': ''} diff --git a/protocols/ts6.py b/protocols/ts6.py index 6a4f4e5..7f555e5 100644 --- a/protocols/ts6.py +++ b/protocols/ts6.py @@ -231,6 +231,16 @@ def pingServer(irc, source=None, target=None): def numericServer(irc, source, numeric, target, text): _send(irc, source, '%s %s %s' % (numeric, target, text)) +def awayClient(irc, source, text): + """ + + Sends an AWAY message with text from PyLink client . + can be an empty string to unset AWAY status.""" + if text: + _send(irc, source, 'AWAY :%s' % text) + else: + _send(irc, source, 'AWAY') + def connect(irc): ts = irc.start_ts @@ -649,3 +659,13 @@ def handle_472(irc, numeric, command, args): ' desyncs, try adding the line "loadmodule "extensions/%s.so";" to ' 'your IRCd configuration.', irc.name, setter, badmode, charlist[badmode]) + +def handle_away(irc, numeric, command, args): + # <- :6ELAAAAAB AWAY :Auto-away + + try: + irc.users[numeric].away = text = args[0] + except IndexError: # User is unsetting away status + irc.users[numeric].away = text = '' + return {'text': text} + diff --git a/start-cpulimit.sh b/start-cpulimit.sh index 5c2a582..308df68 100755 --- a/start-cpulimit.sh +++ b/start-cpulimit.sh @@ -1,18 +1,17 @@ #!/usr/bin/env bash -# Shell script to start PyLink under CPUlimit, killing it if it starts abusing the CPU. +# Shell script to start PyLink under CPUlimit, throttling it if it starts abusing the CPU. # Set this to whatever you want. cpulimit --help -LIMIT=20 +LIMIT=35 # Change to the PyLink root directory. WRAPPER_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) cd "$WRAPPER_DIR" if [[ ! -z "$(which cpulimit)" ]]; then - # -k kills the PyLink daemon if it goes over $LIMIT # -z makes cpulimit exit when PyLink dies. - cpulimit -l $LIMIT -z -k ./main.py - echo "PyLink has been started (daemonized) under cpulimit, and will automatically be killed if it goes over the CPU limit of ${LIMIT}%." + cpulimit -l $LIMIT -z ./main.py + echo "PyLink has been started (daemonized) under cpulimit, and will automatically be throttled if it goes over the CPU limit of ${LIMIT}%." echo "To kill the process manually, run ./kill.sh" else echo 'cpulimit not found in $PATH! Aborting.'