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/README.md b/README.md index 55599ff..33d4788 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ PyLink is an extensible, plugin-based IRC PseudoService written in Python. It ai ## Usage -**PyLink is a work in progress and thus may be very unstable**! No warranty is provided if this completely wrecks your network and causes widespread rioting throughout your user base! +**PyLink is a work in progress and thus may be very unstable**! No warranty is provided if this completely wrecks your network and causes widespread rioting amongst your users! That said, please report any bugs you find to the [issue tracker](https://github.com/GLolol/PyLink/issues). Pull requests are open if you'd like to contribute. @@ -17,8 +17,9 @@ Dependencies currently include: #### Supported IRCds -* InspIRCd 2.0.x - module: `inspircd` -* charybdis (3.5.x / git master) - module: `ts6` +* InspIRCd 2.0.x - module `inspircd` +* charybdis (3.5.x / git master) - module `ts6` +* Elemental-IRCd (6.6.x / git master) - module `ts6` ### Installation 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/coreplugin.py b/coreplugin.py index 5f46403..4872723 100644 --- a/coreplugin.py +++ b/coreplugin.py @@ -7,7 +7,7 @@ from log import log def handle_kill(irc, source, command, args): if args['target'] == irc.pseudoclient.uid: irc.spawnMain() -utils.add_hook(handle_kill, 'KILL') +utils.add_hook(handle_kill, 'KILL') # Handle KICKs to the PyLink client, rejoining the channels def handle_kick(irc, source, command, args): @@ -15,12 +15,13 @@ def handle_kick(irc, source, command, args): channel = args['channel'] if kicked == irc.pseudoclient.uid: irc.proto.joinClient(irc, irc.pseudoclient.uid, channel) -utils.add_hook(handle_kick, 'KICK') +utils.add_hook(handle_kick, 'KICK') # Handle commands sent to the PyLink client (PRIVMSG) def handle_commands(irc, source, command, args): if args['target'] == irc.pseudoclient.uid: - cmd_args = args['text'].split(' ') + text = args['text'].strip() + cmd_args = text.split(' ') cmd = cmd_args[0].lower() cmd_args = cmd_args[1:] try: diff --git a/main.py b/main.py index 3bb3b3e..f649ff2 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 @@ -17,6 +19,9 @@ import coreplugin class Irc(): def initVars(self): + self.pseudoclient = None + self.connected = threading.Event() + self.lastping = time.time() # Server, channel, and user indexes to be populated by our protocol module self.servers = {self.sid: classes.IrcServer(None, self.serverdata['hostname'], internal=True)} self.users = {} @@ -52,7 +57,6 @@ class Irc(): def __init__(self, netname, proto, conf): # Initialize some variables - self.connected = threading.Event() self.name = netname.lower() self.conf = conf self.serverdata = conf['servers'][netname] @@ -67,26 +71,78 @@ class Irc(): self.connection_thread = threading.Thread(target = self.connect) self.connection_thread.start() self.pingTimer = None - self.lastping = time.time() def connect(self): 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)) @@ -107,19 +163,21 @@ class Irc(): self.pingTimer.cancel() except: # Socket timed out during creation; ignore pass + # Internal hook signifying that a network has disconnected. self.callHooks([None, 'PYLINK_DISCONNECT', {}]) def run(self): buf = b"" data = b"" - while (time.time() - self.lastping) < self.pingtimeout: - log.debug('(%s) time_since_last_ping: %s', self.name, (time.time() - self.lastping)) - log.debug('(%s) self.pingtimeout: %s', self.name, self.pingtimeout) + while True: 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) - break + if self.connected.is_set() and not data: + log.warning('(%s) No data received and self.connected is set; disconnecting!', self.name) + return + elif (time.time() - self.lastping) > self.pingtimeout: + log.warning('(%s) Connection timed out.', self.name) + return while b'\n' in buf: line, buf = buf.split(b'\n', 1) line = line.strip(b'\r') @@ -188,9 +246,13 @@ class Irc(): ident = self.botdata.get('ident') or 'pylink' host = self.serverdata["hostname"] log.info('(%s) Connected! Spawning main client %s.', self.name, nick) + olduserobj = self.pseudoclient self.pseudoclient = self.proto.spawnClient(self, nick, ident, host, modes={("+o", None)}) for chan in self.serverdata['channels']: self.proto.joinClient(self, self.pseudoclient.uid, chan) + # PyLink internal hook called when spawnMain is called and the + # contents of Irc().pseudoclient change. + self.callHooks([self.sid, 'PYLINK_SPAWNMAIN', {'olduser': olduserobj}]) if __name__ == '__main__': log.info('PyLink starting...') diff --git a/plugins/admin.py b/plugins/admin.py index 892d4f3..57a2b3d 100644 --- a/plugins/admin.py +++ b/plugins/admin.py @@ -62,6 +62,7 @@ def quit(irc, source, args): u = utils.nickToUid(irc, nick) quitmsg = ' '.join(args[1:]) or 'Client quit' irc.proto.quitClient(irc, u, quitmsg) + irc.callHooks([u, 'PYLINK_ADMIN_QUIT', {'text': quitmsg, 'parse_as': 'QUIT'}]) def joinclient(irc, source, args): """ ,[], etc. @@ -82,6 +83,9 @@ def joinclient(irc, source, args): utils.msg(irc, source, "Error: Invalid channel name %r." % channel) return irc.proto.joinClient(irc, u, channel) + irc.callHooks([u, 'PYLINK_ADMIN_JOIN', {'channel': channel, 'users': [u], + 'modes': irc.channels[channel].modes, + 'parse_as': 'JOIN'}]) utils.add_cmd(joinclient, name='join') @utils.add_cmd @@ -103,6 +107,7 @@ def nick(irc, source, args): utils.msg(irc, source, 'Error: Invalid nickname %r.' % newnick) return irc.proto.nickClient(irc, u, newnick) + irc.callHooks([u, 'PYLINK_ADMIN_NICK', {'newnick': newnick, 'oldnick': nick, 'parse_as': 'NICK'}]) @utils.add_cmd def part(irc, source, args): @@ -123,6 +128,7 @@ def part(irc, source, args): utils.msg(irc, source, "Error: Invalid channel name %r." % channel) return irc.proto.partClient(irc, u, channel, reason) + irc.callHooks([u, 'PYLINK_ADMIN_PART', {'channels': clist, 'text': reason, 'parse_as': 'PART'}]) @utils.add_cmd def kick(irc, source, args): @@ -144,6 +150,7 @@ def kick(irc, source, args): utils.msg(irc, source, "Error: Invalid channel name %r." % channel) return irc.proto.kickClient(irc, u, channel, targetu, reason) + irc.callHooks([u, 'PYLINK_ADMIN_KICK', {'channel': channel, 'target': targetu, 'text': reason, 'parse_as': 'KICK'}]) @utils.add_cmd def showuser(irc, source, args): @@ -186,7 +193,7 @@ def showchan(irc, source, args): def mode(irc, source, args): """ - Admin-only. Sets modes on from , where is the nick of a PyLink client.""" + Admin-only. Sets modes on from , where is either the nick of a PyLink client, or the SID of a PyLink server.""" checkauthenticated(irc, source) try: modesource, target, modes = args[0], args[1], args[2:] @@ -205,6 +212,36 @@ def mode(irc, source, args): return if utils.isInternalServer(irc, modesource): irc.proto.modeServer(irc, modesource, target, parsedmodes) + irc.callHooks([modesource, 'PYLINK_ADMIN_MODE', {'target': target, 'modes': parsedmodes, 'parse_as': 'MODE'}]) else: sourceuid = utils.nickToUid(irc, modesource) irc.proto.modeClient(irc, sourceuid, target, parsedmodes) + irc.callHooks([sourceuid, 'PYLINK_ADMIN_MODE', {'target': target, 'modes': parsedmodes, 'parse_as': 'MODE'}]) + +@utils.add_cmd +def msg(irc, source, args): + """ + + Admin-only. Sends message from , where is the nick of a PyLink client.""" + checkauthenticated(irc, source) + try: + msgsource, target, text = args[0], args[1], ' '.join(args[2:]) + except IndexError: + utils.msg(irc, source, 'Error: not enough arguments. Needs 3: source nick, target, text.') + return + sourceuid = utils.nickToUid(irc, msgsource) + if not sourceuid: + utils.msg(irc, source, 'Error: unknown user %r' % msgsource) + return + if not utils.isChannel(target): + real_target = utils.nickToUid(irc, target) + if real_target is None: + utils.msg(irc, source, 'Error: unknown user %r' % target) + return + else: + real_target = target + if not text: + utils.msg(irc, source, 'Error: no text given.') + return + irc.proto.messageClient(irc, sourceuid, real_target, text) + irc.callHooks([sourceuid, 'PYLINK_ADMIN_MSG', {'target': real_target, 'text': text, 'parse_as': 'PRIVMSG'}]) diff --git a/plugins/relay.py b/plugins/relay.py index 896917b..5f2e03d 100644 --- a/plugins/relay.py +++ b/plugins/relay.py @@ -16,7 +16,9 @@ dbname = "pylinkrelay" if confname != 'pylink': dbname += '-%s' % confname dbname += '.db' + relayusers = defaultdict(dict) +spawnlocks = defaultdict(threading.Lock) def relayWhoisHandlers(irc, target): user = irc.users[target] @@ -32,7 +34,6 @@ utils.whois_handlers.append(relayWhoisHandlers) def normalizeNick(irc, netname, nick, separator=None, oldnick=''): separator = separator or irc.serverdata.get('separator') or "/" log.debug('(%s) normalizeNick: using %r as separator.', irc.name, separator) - orig_nick = nick protoname = irc.proto.__name__ maxnicklen = irc.maxnicklen @@ -59,14 +60,14 @@ def normalizeNick(irc, netname, nick, separator=None, oldnick=''): nick += suffix # FIXME: factorize while utils.nickToUid(irc, nick) or utils.nickToUid(irc, oldnick) and not \ - utils.isInternalClient(irc, utils.nickToUid(irc, nick)): + isRelayClient(irc, utils.nickToUid(irc, nick)): # The nick we want exists? Darn, create another one then, but only if # the target isn't an internal client! # Increase the separator length by 1 if the user was already tagged, # but couldn't be created due to a nick conflict. # This can happen when someone steals a relay user's nick. new_sep = separator + separator[-1] - log.debug('(%s) normalizeNick: using %r as new_sep.', irc.name, separator) + log.debug('(%s) normalizeNick: nick %r is in use; using %r as new_sep.', irc.name, nick, new_sep) nick = normalizeNick(irc, netname, orig_nick, separator=new_sep) finalLength = len(nick) assert finalLength <= maxnicklen, "Normalized nick %r went over max " \ @@ -109,7 +110,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 @@ -123,27 +125,31 @@ def getRemoteUser(irc, remoteirc, user, spawnIfMissing=True): return remoteirc.pseudoclient.uid except AttributeError: # Network hasn't been initialized yet? pass - try: - u = relayusers[(irc.name, user)][remoteirc.name] - except KeyError: - userobj = irc.users.get(user) - if userobj is None or (not spawnIfMissing) or (not remoteirc.connected.is_set()): - # The query wasn't actually a valid user, or the network hasn't - # been connected yet... Oh well! - return - nick = normalizeNick(remoteirc, irc.name, userobj.nick) - # Truncate idents at 10 characters, because TS6 won't like them otherwise! - ident = userobj.ident[:10] - # Ditto hostname at 64 chars. - host = userobj.host[:64] - realname = userobj.realname - modes = getSupportedUmodes(irc, remoteirc, userobj.modes) - u = remoteirc.proto.spawnClient(remoteirc, nick, ident=ident, - host=host, realname=realname, - modes=modes, ts=userobj.ts).uid - remoteirc.users[u].remote = irc.name - relayusers[(irc.name, user)][remoteirc.name] = u - return u + with spawnlocks[irc.name]: + try: + u = relayusers[(irc.name, user)][remoteirc.name] + except KeyError: + userobj = irc.users.get(user) + if userobj is None or (not spawnIfMissing) or (not remoteirc.connected.is_set()): + # The query wasn't actually a valid user, or the network hasn't + # been connected yet... Oh well! + return + nick = normalizeNick(remoteirc, irc.name, userobj.nick) + # Truncate idents at 10 characters, because TS6 won't like them otherwise! + ident = userobj.ident[:10] + # Ditto hostname at 64 chars. + host = userobj.host[:64] + realname = userobj.realname + modes = getSupportedUmodes(irc, remoteirc, userobj.modes) + u = remoteirc.proto.spawnClient(remoteirc, nick, ident=ident, + host=host, realname=realname, + modes=modes, ts=userobj.ts).uid + remoteirc.users[u].remote = (irc.name, user) + away = userobj.away + if away: + remoteirc.proto.awayClient(remoteirc, u, away) + relayusers[(irc.name, user)][remoteirc.name] = u + return u def getLocalUser(irc, user, targetirc=None): """ [] @@ -156,23 +162,10 @@ def getLocalUser(irc, user, targetirc=None): representing the original user on the target network, similar to what getRemoteUser() does.""" # 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 - # this network matches the target we have, set that user - # as the one we're kicking! It's a handful, but remember - # we're mapping (home network, UID) pairs to their - # respective relay pseudoclients on other networks. - remoteuser = k - log.debug('(%s) getLocalUser: found %s to correspond to %s.', irc.name, v, k) - break + try: + remoteuser = irc.users[user].remote + except (AttributeError, KeyError): + remoteuser = None log.debug('(%s) getLocalUser: remoteuser set to %r (looking up %s/%s).', irc.name, remoteuser, user, irc.name) if remoteuser: # If targetirc is given, we'll return simply the UID of the user on the @@ -221,6 +214,11 @@ def initializeChannel(irc, channel): log.debug('(%s) initializeChannel: relay pair found to be %s', irc.name, relay) queued_users = [] if relay: + # Send our users and channel modes to the other nets + log.debug('(%s) initializeChannel: joining our users: %s', irc.name, c.users) + relayJoins(irc, channel, c.users, c.ts) + irc.proto.joinClient(irc, irc.pseudoclient.uid, channel) + all_links = db[relay]['links'].copy() all_links.update((relay,)) log.debug('(%s) initializeChannel: all_links: %s', irc.name, all_links) @@ -237,37 +235,30 @@ def initializeChannel(irc, channel): if not (remoteirc.connected.is_set() and findRemoteChan(remoteirc, irc, remotechan)): continue # They aren't connected, don't bother! # Join their (remote) users and set their modes. - relayJoins(remoteirc, remotechan, rc.users, - rc.ts, rc.modes) - relayModes(irc, remoteirc, irc.sid, channel) - topic = remoteirc.channels[relay[1]].topic + relayJoins(remoteirc, remotechan, rc.users, rc.ts) + relayModes(remoteirc, irc, remoteirc.sid, remotechan, rc.modes) + relayModes(irc, remoteirc, irc.sid, channel, modes) + topic = remoteirc.channels[remotechan].topic # Only update the topic if it's different from what we already have, # and topic bursting is complete. - if remoteirc.channels[channel].topicset and topic != irc.channels[channel].topic: + if remoteirc.channels[remotechan].topicset and topic != irc.channels[channel].topic: irc.proto.topicServer(irc, irc.sid, channel, topic) - log.debug('(%s) initializeChannel: joining our users: %s', irc.name, c.users) - # After that's done, we'll send our users to them. - relayJoins(irc, channel, c.users, c.ts, c.modes) - irc.proto.joinClient(irc, irc.pseudoclient.uid, channel) - def handle_join(irc, numeric, command, args): channel = args['channel'] if not findRelay((irc.name, channel)): # No relay here, return. return - modes = args['modes'] ts = args['ts'] users = set(args['users']) - relayJoins(irc, channel, users, ts, modes) + relayJoins(irc, channel, users, ts) utils.add_hook(handle_join, 'JOIN') def handle_quit(irc, numeric, command, args): - ouruser = numeric for netname, user in relayusers[(irc.name, numeric)].copy().items(): remoteirc = utils.networkobjects[netname] remoteirc.proto.quitClient(remoteirc, user, args['text']) - del relayusers[(irc.name, ouruser)] + del relayusers[(irc.name, numeric)] utils.add_hook(handle_quit, 'QUIT') def handle_squit(irc, numeric, command, args): @@ -288,6 +279,9 @@ utils.add_hook(handle_nick, 'NICK') def handle_part(irc, numeric, command, args): channels = args['channels'] text = args['text'] + # Don't allow the PyLink client PARTing to be relayed. + if numeric == irc.pseudoclient.uid: + return for channel in channels: for netname, user in relayusers[(irc.name, numeric)].copy().items(): remoteirc = utils.networkobjects[netname] @@ -306,57 +300,55 @@ def handle_privmsg(irc, numeric, command, args): text = args['text'] if target == irc.pseudoclient.uid: return - sent = 0 relay = findRelay((irc.name, target)) - # Don't send any "you must be in common channels" if we're not part - # of a relay, or we are but there are no links! - remoteusers = relayusers[(irc.name, numeric)].items() - ''' - if utils.isChannel(target) and ((relay and not db[relay]['links']) or \ - relay is None): + remoteusers = relayusers[(irc.name, numeric)] + # HACK: Don't break on sending to @#channel or similar. + try: + prefix, target = target.split('#', 1) + except ValueError: + prefix = '' + else: + target = '#' + target + log.debug('(%s) relay privmsg: prefix is %r, target is %r', irc.name, prefix, target) + if utils.isChannel(target) and relay and numeric not in irc.channels[target].users: + # The sender must be in the target channel to send messages over the relay; + # it's the only way we can make sure they have a spawned client on ALL + # of the linked networks. This affects -n channels too; see + # https://github.com/GLolol/PyLink/issues/91 for an explanation of why. + utils.msg(irc, numeric, 'Error: You must be in %r in order to send ' + 'messages over the relay.' % target, notice=True) return - ''' - if not remoteusers: - return - for netname, user in relayusers[(irc.name, numeric)].items(): - remoteirc = utils.networkobjects[netname] - # HACK: Don't break on sending to @#channel or similar. - try: - prefix, target = target.split('#', 1) - except ValueError: - prefix = '' - else: - target = '#' + target - if utils.isChannel(target): - log.debug('(%s) relay privmsg: prefix is %r, target is %r', irc.name, prefix, target) + if utils.isChannel(target): + for netname, user in relayusers[(irc.name, numeric)].items(): + remoteirc = utils.networkobjects[netname] real_target = findRemoteChan(irc, remoteirc, target) if not real_target: continue real_target = prefix + real_target - else: - remoteuser = getLocalUser(irc, target) - if remoteuser is None: - continue - real_target = remoteuser[1] + if notice: + remoteirc.proto.noticeClient(remoteirc, user, real_target, text) + else: + remoteirc.proto.messageClient(remoteirc, user, real_target, text) + else: + remoteuser = getLocalUser(irc, target) + if remoteuser is None: + return + homenet, real_target = remoteuser + # For PMs, we must be on a common channel with the target. + # Otherwise, the sender doesn't have a client representing them + # on the remote network, and we won't have anything to send our + # messages from. + if homenet not in remoteusers.keys(): + utils.msg(irc, numeric, 'Error: you must be in a common channel ' + 'with %r in order to send messages.' % \ + irc.users[target].nick, notice=True) + return + remoteirc = utils.networkobjects[homenet] + user = getRemoteUser(irc, remoteirc, numeric, spawnIfMissing=False) if notice: remoteirc.proto.noticeClient(remoteirc, user, real_target, text) else: remoteirc.proto.messageClient(remoteirc, user, real_target, text) - sent += 1 - ''' - if not sent: - # We must be on a common channel with the target. Otherwise, the sender - # doesn't have a client representing them on the remote network, - # and we won't have anywhere to send our messages from. - # In this case, we've iterated over all networks where the sender - # has pseudoclients, and found no suitable targets to send to. - if target in irc.users: - target_s = 'a common channel with %r' % irc.users[target].nick - else: - target_s = repr(target) - utils.msg(irc, numeric, 'Error: You must be in %s in order to send messages.' % \ - target_s, notice=True) - ''' utils.add_hook(handle_privmsg, 'PRIVMSG') utils.add_hook(handle_privmsg, 'NOTICE') @@ -367,8 +359,10 @@ def handle_kick(irc, source, command, args): kicker = source kicker_modes = getPrefixModes(irc, irc, channel, kicker) relay = findRelay((irc.name, channel)) - if relay is None: + # Don't allow kicks to the PyLink client to be relayed. + if relay is None or target == irc.pseudoclient.uid: return + origuser = getLocalUser(irc, target) for name, remoteirc in utils.networkobjects.items(): if irc.name == name or not remoteirc.connected.is_set(): continue @@ -378,20 +372,21 @@ def handle_kick(irc, source, command, args): continue real_kicker = getRemoteUser(irc, remoteirc, kicker, spawnIfMissing=False) log.debug('(%s) Relay kick: real kicker for %s on %s is %s', irc.name, kicker, name, real_kicker) - if not utils.isInternalClient(irc, target): + if not isRelayClient(irc, target): log.debug('(%s) Relay kick: target %s is NOT an internal client', irc.name, target) # Both the target and kicker are external clients; i.e. # they originate from the same network. We won't have # to filter this; the uplink IRCd will handle it appropriately, # and we'll just follow. - real_target = getRemoteUser(irc, remoteirc, target) + real_target = getRemoteUser(irc, remoteirc, target, spawnIfMissing=False) log.debug('(%s) Relay kick: real target for %s is %s', irc.name, target, real_target) else: log.debug('(%s) Relay kick: target %s is an internal client, going to look up the real user', irc.name, target) real_target = getLocalUser(irc, target, targetirc=remoteirc) log.debug('(%s) Relay kick: kicker_modes are %r', irc.name, kicker_modes) if irc.name not in db[relay]['claim'] and not \ - any([mode in kicker_modes for mode in ('y', 'q', 'a', 'o', 'h')]): + (any([mode in kicker_modes for mode in ('y', 'q', 'a', 'o', 'h')]) \ + or utils.isInternalClient(irc, kicker)): log.debug('(%s) Relay kick: kicker %s is not opped... We should rejoin the target user %s', irc.name, kicker, real_target) # Home network is not in the channel's claim AND the kicker is not # opped. We won't propograte the kick then. @@ -399,13 +394,15 @@ def handle_kick(irc, source, command, args): # kick ops, admins can't kick owners, etc. modes = getPrefixModes(remoteirc, irc, remotechan, real_target) # Join the kicked client back with its respective modes. - irc.proto.sjoinServer(irc, irc.sid, remotechan, [(modes, target)]) + irc.proto.sjoinServer(irc, irc.sid, channel, [(modes, target)]) if kicker in irc.users: - utils.msg(irc, kicker, "This channel is claimed; your kick has " - "to %s been blocked because you are not " + utils.msg(irc, kicker, "This channel is claimed; your kick to " + "%s has been blocked because you are not " "(half)opped." % channel, notice=True) return + if not real_target: + continue # Propogate the kick! if real_kicker: log.debug('(%s) Relay kick: Kicking %s from channel %s via %s on behalf of %s/%s', irc.name, real_target, remotechan,real_kicker, kicker, irc.name) @@ -414,7 +411,7 @@ def handle_kick(irc, source, command, args): else: # Kick originated from a server, or the kicker isn't in any # common channels with the target relay network. - log.debug('(%s) Relay kick: Kicking %s from channel %s via %s on behalf of %s/%s', irc.name, real_target, remotechan,remoteirc.sid, kicker, irc.name) + log.debug('(%s) Relay kick: Kicking %s from channel %s via %s on behalf of %s/%s', irc.name, real_target, remotechan, remoteirc.sid, kicker, irc.name) try: if kicker in irc.servers: kname = irc.servers[kicker].name @@ -426,10 +423,14 @@ def handle_kick(irc, source, command, args): remoteirc.proto.kickServer(remoteirc, remoteirc.sid, remotechan, real_target, text) - if target != irc.pseudoclient.uid and not irc.users[target].channels: - irc.proto.quitClient(irc, target, 'Left all shared channels.') - remoteuser = getLocalUser(irc, target) - del relayusers[remoteuser][irc.name] + # If the target isn't on any channels, quit them. + if origuser and origuser[0] != remoteirc.name and not remoteirc.users[real_target].channels: + del relayusers[origuser][remoteirc.name] + remoteirc.proto.quitClient(remoteirc, real_target, 'Left all shared channels.') + + if origuser and not irc.users[target].channels: + del relayusers[origuser][irc.name] + irc.proto.quitClient(irc, target, 'Left all shared channels.') utils.add_hook(handle_kick, 'KICK') @@ -610,7 +611,7 @@ utils.add_hook(handle_topic, 'TOPIC') def handle_kill(irc, numeric, command, args): target = args['target'] userdata = args['userdata'] - realuser = getLocalUser(irc, target) + realuser = getLocalUser(irc, target) or userdata.__dict__.get('remote') log.debug('(%s) relay handle_kill: realuser is %r', irc.name, realuser) # Target user was remote: if realuser and realuser[0] != irc.name: @@ -618,15 +619,15 @@ def handle_kill(irc, numeric, command, args): # client and rejoin it to its channels. del relayusers[realuser][irc.name] remoteirc = utils.networkobjects[realuser[0]] - for channel in remoteirc.channels: - remotechan = findRemoteChan(remoteirc, irc, channel) - if remotechan: - modes = getPrefixModes(remoteirc, irc, remotechan, realuser[1]) + for remotechan in remoteirc.channels: + localchan = findRemoteChan(remoteirc, irc, remotechan) + if localchan: + modes = getPrefixModes(remoteirc, irc, localchan, realuser[1]) log.debug('(%s) relay handle_kill: userpair: %s, %s', irc.name, modes, realuser) client = getRemoteUser(remoteirc, irc, realuser[1]) - irc.proto.sjoinServer(irc, irc.sid, remotechan, [(modes, client)]) + irc.proto.sjoinServer(irc, irc.sid, localchan, [(modes, client)]) if userdata and numeric in irc.users: - utils.msg(irc, numeric, "Your kill has to %s been blocked " + utils.msg(irc, numeric, "Your kill to %s has been blocked " "because PyLink does not allow killing" " users over the relay at this time." % \ userdata.nick, notice=True) @@ -641,7 +642,17 @@ def handle_kill(irc, numeric, command, args): utils.add_hook(handle_kill, 'KILL') -def relayJoins(irc, channel, users, ts, modes): +def isRelayClient(irc, user): + try: + if irc.users[user].remote: + # Is the .remote attribute set? If so, don't relay already + # relayed clients; that'll trigger an endless loop! + return True + except (KeyError, AttributeError): # Nope, it isn't. + pass + return False + +def relayJoins(irc, channel, users, ts): for name, remoteirc in utils.networkobjects.items(): queued_users = [] if name == irc.name or not remoteirc.connected.is_set(): @@ -654,23 +665,16 @@ def relayJoins(irc, channel, users, ts, modes): continue log.debug('(%s) relayJoins: got %r for users', irc.name, users) for user in users.copy(): - if utils.isInternalClient(irc, user) or user not in irc.users: - # We don't need to clone PyLink pseudoclients... That's - # meaningless. + if isRelayClient(irc, user): + # Don't clone relay clients; that'll cause some bad, bad + # things to happen. continue - try: - if irc.users[user].remote: - # Is the .remote attribute set? If so, don't relay already - # relayed clients; that'll trigger an endless loop! - continue - except AttributeError: # Nope, it isn't. - pass log.debug('Okay, spawning %s/%s everywhere', user, irc.name) assert user in irc.users, "(%s) How is this possible? %r isn't in our user database." % (irc.name, user) u = getRemoteUser(irc, remoteirc, user) # Only join users if they aren't already joined. This prevents op floods # on charybdis from all the SJOINing. - if u not in remoteirc.channels[channel].users: + if u not in remoteirc.channels[remotechan].users: ts = irc.channels[channel].ts prefixes = getPrefixModes(irc, remoteirc, channel, user) userpair = (prefixes, u) @@ -681,7 +685,6 @@ def relayJoins(irc, channel, users, ts, modes): u, remoteirc.name, remotechan) if queued_users: remoteirc.proto.sjoinServer(remoteirc, remoteirc.sid, remotechan, queued_users, ts=ts) - relayModes(irc, remoteirc, irc.sid, channel, modes) def relayPart(irc, channel, user): for name, remoteirc in utils.networkobjects.items(): @@ -696,7 +699,7 @@ def relayPart(irc, channel, user): if remotechan is None or remoteuser is None: continue remoteirc.proto.partClient(remoteirc, remoteuser, remotechan, 'Channel delinked.') - if not remoteirc.users[remoteuser].channels: + if isRelayClient(remoteirc, remoteuser) and not remoteirc.users[remoteuser].channels: remoteirc.proto.quitClient(remoteirc, remoteuser, 'Left all shared channels.') del relayusers[(irc.name, user)][remoteirc.name] @@ -708,7 +711,7 @@ def removeChannel(irc, channel): relay = findRelay((irc.name, channel)) if relay: for user in irc.channels[channel].users.copy(): - if not utils.isInternalClient(irc, user): + if not isRelayClient(irc, user): relayPart(irc, channel, user) # Don't ever part the main client from any of its autojoin channels. else: @@ -718,9 +721,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 +850,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(): @@ -900,7 +906,7 @@ def handle_save(irc, numeric, command, args): realuser = getLocalUser(irc, target) log.debug('(%s) relay handle_save: %r got in a nick collision! Real user: %r', irc.name, target, realuser) - if utils.isInternalClient(irc, target) and realuser: + if isRelayClient(irc, target) and realuser: # Nick collision! # It's one of our relay clients; try to fix our nick to the next # available normalized nick. @@ -933,3 +939,16 @@ 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') + +def handle_spawnmain(irc, numeric, command, args): + if args['olduser']: + # Kills to the main PyLink client force reinitialization; this makes sure + # it joins all the relay channels like it's supposed to. + initializeAll(irc) +utils.add_hook(handle_spawnmain, 'PYLINK_SPAWNMAIN') diff --git a/protocols/inspircd.py b/protocols/inspircd.py index 995dc6c..652ca1a 100644 --- a/protocols/inspircd.py +++ b/protocols/inspircd.py @@ -56,10 +56,11 @@ def joinClient(irc, client, channel): if not server: log.error('(%s) Error trying to join client %r to %r (no such pseudoclient exists)', irc.name, client, channel) raise LookupError('No such PyLink PseudoClient exists.') - # One channel per line here! + # 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']] _send(irc, server, "FJOIN {channel} {ts} {modes} :,{uid}".format( ts=irc.channels[channel].ts, uid=client, channel=channel, - modes=utils.joinModes(irc.channels[channel].modes))) + modes=utils.joinModes(modes))) irc.channels[channel].users.add(client) irc.users[client].channels.add(channel) @@ -70,17 +71,21 @@ def sjoinServer(irc, server, channel, users, ts=None): log.debug('(%s) sjoinServer: got %r for users', irc.name, users) if not server: raise LookupError('No such PyLink PseudoClient exists.') - if ts is None: - ts = irc.channels[channel].ts - log.debug("sending SJOIN to %s%s with ts %s (that's %r)", channel, irc.name, ts, + orig_ts = irc.channels[channel].ts + ts = ts or orig_ts + if ts < orig_ts: + # If the TS we're sending is lower than the one that existing, clear the + # mode lists from our channel state and reset the timestamp. + log.debug('(%s) sjoinServer: resetting TS of %r from %s to %s (clearing modes)', + irc.name, channel, orig_ts, ts) + irc.channels[channel].ts = ts + irc.channels[channel].modes.clear() + for p in irc.channels[channel].prefixmodes.values(): + p.clear() + log.debug("sending SJOIN to %s%s with ts %s (that's %r)", channel, irc.name, ts, time.strftime("%c", time.localtime(ts))) - ''' TODO: handle this properly! - if modes is None: - modes = irc.channels[channel].modes - 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 = [] @@ -96,7 +101,9 @@ def sjoinServer(irc, server, channel, users, ts=None): irc.users[user].channels.add(channel) except KeyError: # Not initialized yet? log.debug("(%s) sjoinServer: KeyError trying to add %r to %r's channel list?", irc.name, channel, user) - utils.applyModes(irc, channel, changedmodes) + if ts < orig_ts: + # Only save our prefix modes in the channel state if our TS is lower than theirs. + utils.applyModes(irc, channel, changedmodes) namelist = ' '.join(namelist) _send(irc, server, "FJOIN {channel} {ts} {modes} :{users}".format( ts=ts, users=namelist, channel=channel, @@ -119,8 +126,12 @@ def removeClient(irc, numeric): Removes a client from our internal databases, regardless of whether it's one of our pseudoclients or not.""" - for v in irc.channels.values(): + for c, v in irc.channels.copy().items(): v.removeuser(numeric) + # Clear empty non-permanent channels. + if not (irc.channels[c].users or ((irc.cmodes.get('permanent'), None) in irc.channels[c].modes)): + del irc.channels[c] + sid = numeric[:3] log.debug('Removing client %s from irc.users', numeric) del irc.users[numeric] @@ -244,6 +255,8 @@ def topicClient(irc, numeric, target, text): if not utils.isInternalClient(irc, numeric): raise LookupError('No such PyLink PseudoClient exists.') _send(irc, numeric, 'TOPIC %s :%s' % (target, text)) + irc.channels[target].topic = text + irc.channels[target].topicset = True def topicServer(irc, numeric, target, text): if not utils.isInternalServer(irc, numeric): @@ -251,6 +264,8 @@ def topicServer(irc, numeric, target, text): ts = int(time.time()) servername = irc.servers[numeric].name _send(irc, numeric, 'FTOPIC %s %s %s :%s' % (target, ts, servername, text)) + irc.channels[target].topic = text + irc.channels[target].topicset = True def inviteClient(irc, numeric, target, channel): """ @@ -297,6 +312,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 @@ -349,6 +374,9 @@ def handle_part(irc, source, command, args): reason = args[1] except IndexError: reason = '' + # Clear empty non-permanent channels. + if not (irc.channels[channel].users or ((irc.cmodes.get('permanent'), None) in irc.channels[channel].modes)): + del irc.channels[channel] return {'channels': channels, 'text': reason} def handle_error(irc, numeric, command, args): @@ -367,6 +395,9 @@ def handle_fjoin(irc, servernumeric, command, args): log.debug('(%s) Setting channel TS of %s to %s from %s', irc.name, channel, their_ts, our_ts) irc.channels[channel].ts = their_ts + irc.channels[channel].modes.clear() + for p in irc.channels[channel].prefixmodes.values(): + p.clear() modestring = args[2:-1] or args[2] parsedmodes = utils.parseModes(irc, channel, modestring) utils.applyModes(irc, channel, parsedmodes) @@ -664,3 +695,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..25ffa1b 100644 --- a/protocols/ts6.py +++ b/protocols/ts6.py @@ -77,8 +77,17 @@ def sjoinServer(irc, server, channel, users, ts=None): log.debug('(%s) sjoinServer: got %r for users', irc.name, users) if not server: raise LookupError('No such PyLink PseudoClient exists.') - if ts is None: - ts = irc.channels[channel].ts + orig_ts = irc.channels[channel].ts + ts = ts or orig_ts + if ts < orig_ts: + # If the TS we're sending is lower than the one that existing, clear the + # mode lists from our channel state and reset the timestamp. + log.debug('(%s) sjoinServer: resetting TS of %r from %s to %s (clearing modes)', + irc.name, channel, orig_ts, ts) + irc.channels[channel].ts = ts + irc.channels[channel].modes.clear() + for p in irc.channels[channel].prefixmodes.values(): + p.clear() log.debug("sending SJOIN to %s%s with ts %s (that's %r)", channel, irc.name, ts, time.strftime("%c", time.localtime(ts))) modes = [m for m in irc.channels[channel].modes if m[0] not in irc.cmodes['*A']] @@ -95,6 +104,7 @@ def sjoinServer(irc, server, channel, users, ts=None): pr = irc.prefixmodes.get(prefix) if pr: prefixchars += pr + changedmodes.append(('+%s' % prefix, user)) namelist.append(prefixchars+user) uids.append(user) try: @@ -107,7 +117,9 @@ def sjoinServer(irc, server, channel, users, ts=None): ts=ts, users=namelist, channel=channel, modes=utils.joinModes(modes))) irc.channels[channel].users.update(uids) - utils.applyModes(irc, channel, changedmodes) + if ts < orig_ts: + # Only save our prefix modes in the channel state if our TS is lower than theirs. + utils.applyModes(irc, channel, changedmodes) def _sendModes(irc, numeric, target, modes, ts=None): utils.applyModes(irc, target, modes) @@ -186,6 +198,8 @@ def topicServer(irc, numeric, target, text): ts = irc.channels[target].ts servername = irc.servers[numeric].name _send(irc, numeric, 'TB %s %s %s :%s' % (target, ts, servername, text)) + irc.channels[target].topic = text + irc.channels[target].topicset = True def inviteClient(irc, numeric, target, channel): """ @@ -231,6 +245,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 @@ -358,9 +382,10 @@ def handle_part(irc, source, command, args): reason = args[1] except IndexError: reason = '' + if not (irc.channels[channel].users or ((irc.cmodes.get('permanent'), None) in irc.channels[channel].modes)): + del irc.channels[channel] return {'channels': channels, 'text': reason} - def handle_sjoin(irc, servernumeric, command, args): # parameters: channelTS, channel, simple modes, opt. mode parameters..., nicklist channel = args[1].lower() @@ -372,6 +397,9 @@ def handle_sjoin(irc, servernumeric, command, args): log.debug('(%s) Setting channel TS of %s to %s from %s', irc.name, channel, their_ts, our_ts) irc.channels[channel].ts = their_ts + irc.channels[channel].modes.clear() + for p in irc.channels[channel].prefixmodes.values(): + p.clear() modestring = args[2:-1] or args[2] parsedmodes = utils.parseModes(irc, channel, modestring) utils.applyModes(irc, channel, parsedmodes) @@ -649,3 +677,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.' diff --git a/utils.py b/utils.py index 4fc6890..db02ff4 100644 --- a/utils.py +++ b/utils.py @@ -241,7 +241,10 @@ def applyModes(irc, target, changedmodes): log.debug('(%s) Applying modes %r on %s (initial modelist: %s)', irc.name, changedmodes, target, modelist) for mode in changedmodes: # Chop off the +/- part that parseModes gives; it's meaningless for a mode list. - real_mode = (mode[0][1], mode[1]) + try: + real_mode = (mode[0][1], mode[1]) + except IndexError: + real_mode = mode if not usermodes: pmode = '' for m in ('owner', 'admin', 'op', 'halfop', 'voice'):