From 1e95f4b3df87d731b64492659a899ea24b1b627f Mon Sep 17 00:00:00 2001 From: James Lu Date: Sat, 22 Aug 2015 21:00:29 -0700 Subject: [PATCH 1/9] main: Fix ImportError logging if a required library, and not the actual module is missing The old message showed "the plugin/file could not be found" even if it's a library that couldn't be imported. "No module named X" is too broad to check for here! --- main.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/main.py b/main.py index f649ff2..776cc72 100755 --- a/main.py +++ b/main.py @@ -278,10 +278,10 @@ if __name__ == '__main__': pl = imp.load_source(plugin, moduleinfo[1]) utils.plugins.append(pl) except ImportError as e: - if str(e).startswith('No module named'): - log.error('Failed to load plugin %r: the plugin could not be found.', plugin) + if str(e) == ('No module named %r' % plugin): + log.error('Failed to load plugin %r: The plugin could not be found.', plugin) else: - log.error('Failed to load plugin %r: import error %s', plugin, str(e)) + log.error('Failed to load plugin %r: ImportError: %s', plugin, str(e)) else: if hasattr(pl, 'main'): log.debug('Calling main() function of plugin %r', pl) @@ -293,10 +293,10 @@ if __name__ == '__main__': moduleinfo = imp.find_module(protoname, protocols_folder) proto = imp.load_source(protoname, moduleinfo[1]) except ImportError as e: - if str(e).startswith('No module named'): - log.critical('Failed to load protocol module %r: the file could not be found.', protoname) + if str(e) == ('No module named %r' % protoname): + log.critical('Failed to load protocol module %r: The file could not be found.', protoname) else: - log.critical('Failed to load protocol module: import error %s', protoname, str(e)) + log.critical('Failed to load protocol module: ImportError: %s', protoname, str(e)) sys.exit(2) else: utils.networkobjects[network] = Irc(network, proto, conf.conf) From 20474dabac1b4a0acca9dec9f6485dd8258e19de Mon Sep 17 00:00:00 2001 From: James Lu Date: Sat, 22 Aug 2015 20:51:50 -0700 Subject: [PATCH 2/9] relay: fix many bugs with nick collision handling, and add flood prevention for fixing nicks Closes #85. Nick collisions caused by internal clients are handled fine now, including the following cases: - Abusing spawnClient to create a user with the same nick as a relay client, but with a lower TS. - When both an UID (e.g. 42XAAAAAA) and a tagged UID (_42XAAAAAA) exist on the same network (the two will both try to take the same nick of _42XAAAAAA/net over the relay). The case where changing NICK from a long cut-off nick to another long cut-off nick is also mitigated. somelongnick/net won't show nick changes to somelongnic//net if the old and new nicks have give the same normalized relay nick. This introduces a new dependency, expiringdict, from https://pypi.python.org/pypi/expiringdict/1.1.2, which is used as a cache for flood prevention. --- README.md | 3 ++- plugins/relay.py | 54 ++++++++++++++++++++++++++++++++---------------- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 33d4788..a4216fa 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Dependencies currently include: * Python 3.4+ * PyYAML (`pip install pyyaml` or `apt-get install python3-yaml`) +* *For the relay plugin only*: expiringdict (`pip install expiringdict`/`apt-get install python3-expiringdict`) #### Supported IRCds @@ -23,7 +24,7 @@ Dependencies currently include: ### Installation -1) Rename `config.yml.example` to `config.yml` and configure your instance there. Not all options are properly implemented yet, and the configuration schema isn't finalized yet - this means your configuration may break in an update! +1) Rename `config.yml.example` to `config.yml` and configure your instance there. Not all options are properly implemented yet, and the configuration schema isn't finalized yet - this means that your configuration may break in an update! 2) Run `main.py` from the command line. diff --git a/plugins/relay.py b/plugins/relay.py index 5b1d641..f96712f 100644 --- a/plugins/relay.py +++ b/plugins/relay.py @@ -8,6 +8,8 @@ import threading import string from collections import defaultdict +from expiringdict import ExpiringDict + import utils from log import log from conf import confname @@ -19,6 +21,7 @@ dbname += '.db' relayusers = defaultdict(dict) spawnlocks = defaultdict(threading.Lock) +savecache = ExpiringDict(max_len=5, max_age_seconds=10) def relayWhoisHandlers(irc, target): user = irc.users[target] @@ -31,41 +34,46 @@ def relayWhoisHandlers(irc, target): remotenick)] utils.whois_handlers.append(relayWhoisHandlers) -def normalizeNick(irc, netname, nick, separator=None, oldnick=''): +def normalizeNick(irc, netname, nick, separator=None, uid=''): 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 if not protoname.startswith(('insp', 'unreal')): - # Charybdis doesn't allow / in usernames, and will quit with - # a protocol violation if there is one. + # Charybdis doesn't allow / in usernames, and will SQUIT with + # a protocol violation if it sees one. separator = separator.replace('/', '|') nick = nick.replace('/', '|') if nick.startswith(tuple(string.digits)): # On TS6 IRCds, nicks that start with 0-9 are only allowed if # they match the UID of the originating server. Otherwise, you'll - # get nasty protocol violations! + # get nasty protocol violation SQUITs! nick = '_' + nick tagnicks = True suffix = separator + netname nick = nick[:maxnicklen] - # Maximum allowed length of a nickname. + # Maximum allowed length of a nickname, minus the obligatory /network tag. allowedlength = maxnicklen - len(suffix) - # If a nick is too long, the real nick portion must be cut off, but the - # /network suffix must remain the same. + # If a nick is too long, the real nick portion will be cut off, but the + # /network suffix MUST remain the same. nick = nick[:allowedlength] nick += suffix - # FIXME: factorize - while utils.nickToUid(irc, nick) or utils.nickToUid(irc, oldnick) and not \ - 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. + + # The nick we want exists? Darn, create another one then. + # 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. + + # However, if the user is changing from, say, a long, cut-off nick to another long, + # cut-off nick, we don't need to check for duplicates and tag the nick twice. + + # somecutoffnick/net would otherwise be erroneous NICK'ed to somecutoffnic//net, + # even though there would be no collision because the old and new nicks are from + # the same client. + while utils.nickToUid(irc, nick) and utils.nickToUid(irc, nick) != uid: new_sep = separator + separator[-1] 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) @@ -271,7 +279,7 @@ utils.add_hook(handle_squit, 'SQUIT') def handle_nick(irc, numeric, command, args): for netname, user in relayusers[(irc.name, numeric)].items(): remoteirc = utils.networkobjects[netname] - newnick = normalizeNick(remoteirc, irc.name, args['newnick']) + newnick = normalizeNick(remoteirc, irc.name, args['newnick'], uid=user) if remoteirc.users[user].nick != newnick: remoteirc.proto.nickClient(remoteirc, user, newnick) utils.add_hook(handle_nick, 'NICK') @@ -912,8 +920,18 @@ def handle_save(irc, numeric, command, args): remotenet, remoteuser = realuser remoteirc = utils.networkobjects[remotenet] nick = remoteirc.users[remoteuser].nick - newnick = normalizeNick(irc, remotenet, nick, oldnick=args['oldnick']) - irc.proto.nickClient(irc, target, newnick) + # Limit how many times we can attempt to fix our nick, to prevent + # floods and such. + if savecache.setdefault(target, 0) <= 5: + newnick = normalizeNick(irc, remotenet, nick) + log.info('(%s) SAVE received for relay client %r (%s), fixing nick to %s', + irc.name, target, nick, newnick) + irc.proto.nickClient(irc, target, newnick) + else: + log.warning('(%s) SAVE received for relay client %r (%s), not ' + 'fixing nick again due to 5 failed attempts in ' + 'the last 10 seconds!', irc.name, target, nick) + savecache[target] += 1 else: # Somebody else on the network (not a PyLink client) had a nick collision; # relay this as a nick change appropriately. From 713ba1469b264ed8f6ebdfa8179c7a63503dcfa6 Mon Sep 17 00:00:00 2001 From: James Lu Date: Sat, 22 Aug 2015 21:43:25 -0700 Subject: [PATCH 3/9] relay: log blocked KILLs/KICKs (Closes #95) --- plugins/relay.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/plugins/relay.py b/plugins/relay.py index f96712f..0fef139 100644 --- a/plugins/relay.py +++ b/plugins/relay.py @@ -404,9 +404,16 @@ def handle_kick(irc, source, command, args): # Join the kicked client back with its respective modes. irc.proto.sjoinServer(irc, irc.sid, channel, [(modes, target)]) if kicker in irc.users: + log.info('(%s) Blocked KICK (reason %r) from %s to relay client %s/%s on %s.', + irc.name, args['text'], irc.users[source].nick, + remoteirc.users[real_target].nick, remoteirc.name, channel) utils.msg(irc, kicker, "This channel is claimed; your kick to " "%s has been blocked because you are not " "(half)opped." % channel, notice=True) + else: + log.info('(%s) Blocked KICK (reason %r) from server %s to relay client %s/%s on %s.', + irc.name, args['text'], irc.servers[source].name, + remoteirc.users[real_target].nick, remoteirc.name, channel) return if not real_target: @@ -634,10 +641,17 @@ def handle_kill(irc, numeric, command, args): client = getRemoteUser(remoteirc, irc, realuser[1]) irc.proto.sjoinServer(irc, irc.sid, localchan, [(modes, client)]) if userdata and numeric in irc.users: + log.info('(%s) Blocked KILL (reason %r) from %s to relay client %s/%s.', + irc.name, args['text'], irc.users[numeric].nick, + remoteirc.users[realuser[1]].nick, realuser[0]) 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) + else: + log.info('(%s) Blocked KILL (reason %r) from server %s to relay client %s/%s.', + irc.name, args['text'], irc.servers[numeric].name, + remoteirc.users[realuser[1]].nick, realuser[0]) # Target user was local. else: # IMPORTANT: some IRCds (charybdis) don't send explicit QUIT messages From 8f09c356723c5a1c5ddebd7e21a488a39164f9ca Mon Sep 17 00:00:00 2001 From: James Lu Date: Sun, 23 Aug 2015 21:15:49 -0700 Subject: [PATCH 4/9] Revert "example conf: remove unused sidrange option" This reverts commit d533ce3d2847a648c8dcdd1fb5d42428cc550155. --- config.yml.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config.yml.example b/config.yml.example index c091cce..390c5c8 100644 --- a/config.yml.example +++ b/config.yml.example @@ -31,6 +31,12 @@ servers: # The first char must be a digit [0-9], and the remaining two chars may be letters [A-Z] or digits. sid: "0AL" + # SID range - the range of SIDs PyLink is allowed to use to generate server IDs. On TS6, + # this should be a combination of digits, letters, and #'s. Each # denotes a range (0-9A-Z) + # of characters that can be used by PyLink. You will want to make sure no other servers + # are using this range. There must be at least one # in the entry. + sidrange: "8##" + # Autojoin channels channels: ["#pylink"] From 694e7b87f67961b894e3e40b18531990d507f611 Mon Sep 17 00:00:00 2001 From: James Lu Date: Mon, 24 Aug 2015 18:14:35 -0700 Subject: [PATCH 5/9] various: make the server description configurable Two options for this: bot::serverdesc, and server::::serverdesc, with the latter taking precedence if present. --- config.yml.example | 4 ++++ coreplugin.py | 2 +- protocols/inspircd.py | 5 +++-- protocols/ts6.py | 3 ++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/config.yml.example b/config.yml.example index 390c5c8..27c8e0f 100644 --- a/config.yml.example +++ b/config.yml.example @@ -8,6 +8,10 @@ bot: nick: pylink user: pylink realname: PyLink Service Client + + # Server description (shown in /links, /whois, etc.) + serverdesc: PyLink Server + # Console log verbosity: see https://docs.python.org/3/library/logging.html#logging-levels loglevel: DEBUG diff --git a/coreplugin.py b/coreplugin.py index 4872723..26f2740 100644 --- a/coreplugin.py +++ b/coreplugin.py @@ -52,7 +52,7 @@ def handle_whois(irc, source, command, args): # 311: sends nick!user@host information f(irc, server, 311, source, "%s %s %s * :%s" % (nick, user.ident, user.host, user.realname)) # 312: sends the server the target is on, and the name - f(irc, server, 312, source, "%s %s :PyLink Server" % (nick, irc.serverdata['hostname'])) + f(irc, server, 312, source, "%s %s :%s" % (nick, irc.serverdata['hostname'], irc.serverdata.get('serverdesc') or irc.botdata['serverdesc'])) # 313: sends a string denoting the target's operator privilege; # we'll only send it if the user has umode +o. if ('o', None) in user.modes: diff --git a/protocols/inspircd.py b/protocols/inspircd.py index 652ca1a..d71e4af 100644 --- a/protocols/inspircd.py +++ b/protocols/inspircd.py @@ -329,8 +329,9 @@ def connect(irc): f('CAPAB START 1202') f('CAPAB CAPABILITIES :PROTOCOL=1202') f('CAPAB END') - f('SERVER {host} {Pass} 0 {sid} :PyLink Service'.format(host=irc.serverdata["hostname"], - Pass=irc.serverdata["sendpass"], sid=irc.sid)) + f('SERVER {host} {Pass} 0 {sid} :{sdesc}'.format(host=irc.serverdata["hostname"], + Pass=irc.serverdata["sendpass"], sid=irc.sid, + sdesc=irc.serverdata.get('serverdesc') or irc.botdata['serverdesc'])) f(':%s BURST %s' % (irc.sid, ts)) f(':%s ENDBURST' % (irc.sid)) diff --git a/protocols/ts6.py b/protocols/ts6.py index 25ffa1b..b9f88b5 100644 --- a/protocols/ts6.py +++ b/protocols/ts6.py @@ -345,7 +345,8 @@ def connect(irc): # and allows sending CHGHOST without ENCAP. f('CAPAB :QS ENCAP EX CHW IE KNOCK SAVE SERVICES TB EUID') - f('SERVER %s 0 :PyLink Service' % irc.serverdata["hostname"]) + f('SERVER %s 0 :%s' % (irc.serverdata["hostname"], + irc.serverdata.get('serverdesc') or irc.botdata['serverdesc'])) def handle_ping(irc, source, command, args): # PING: From af02af6b4a41b83464179160499ed7aad719e8d6 Mon Sep 17 00:00:00 2001 From: James Lu Date: Mon, 24 Aug 2015 18:26:02 -0700 Subject: [PATCH 6/9] coreplugin: send WHOIS lines in a more standard order --- coreplugin.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/coreplugin.py b/coreplugin.py index 26f2740..53ef998 100644 --- a/coreplugin.py +++ b/coreplugin.py @@ -38,7 +38,7 @@ def handle_commands(irc, source, command, args): return utils.add_hook(handle_commands, 'PRIVMSG') -# Return WHOIS replies to IRCds that use them. +# Handle WHOIS queries, for IRCds that send them across servers (charybdis, UnrealIRCd; NOT InspIRCd). def handle_whois(irc, source, command, args): target = args['target'] user = irc.users.get(target) @@ -51,16 +51,6 @@ def handle_whois(irc, source, command, args): # https://www.alien.net.au/irc/irc2numerics.html # 311: sends nick!user@host information f(irc, server, 311, source, "%s %s %s * :%s" % (nick, user.ident, user.host, user.realname)) - # 312: sends the server the target is on, and the name - f(irc, server, 312, source, "%s %s :%s" % (nick, irc.serverdata['hostname'], irc.serverdata.get('serverdesc') or irc.botdata['serverdesc'])) - # 313: sends a string denoting the target's operator privilege; - # we'll only send it if the user has umode +o. - if ('o', None) in user.modes: - f(irc, server, 313, source, "%s :is an IRC Operator" % nick) - # 379: RPL_WHOISMODES, used by UnrealIRCd and InspIRCd. - # Only shown to opers! - if sourceisOper: - f(irc, server, 379, source, '%s :is using modes %s' % (nick, utils.joinModes(user.modes))) # 319: RPL_WHOISCHANNELS, shows channel list public_chans = [] for chan in user.channels: @@ -75,12 +65,23 @@ def handle_whois(irc, source, command, args): public_chans.append(chan) if public_chans: f(irc, server, 319, source, '%s :%s' % (nick, ' '.join(public_chans))) - # 317: shows idle and signon time. Though we don't track the user's real - # idle time; we just return 0. - # 317 GL GL 15 1437632859 :seconds idle, signon time + # 312: sends the server the target is on, and its server description. + f(irc, server, 312, source, "%s %s :%s" % (nick, irc.serverdata['hostname'], + irc.serverdata.get('serverdesc') or irc.botdata['serverdesc'])) + # 313: sends a string denoting the target's operator privilege, + # only if they have umode +o. + if ('o', None) in user.modes: + f(irc, server, 313, source, "%s :is an IRC Operator" % nick) + # 379: RPL_WHOISMODES, used by UnrealIRCd and InspIRCd. + # Only show this to opers! + if sourceisOper: + f(irc, server, 379, source, '%s :is using modes %s' % (nick, utils.joinModes(user.modes))) + # 317: shows idle and signon time. However, we don't track the user's real + # idle time, so we simply return 0. + # <- 317 GL GL 15 1437632859 :seconds idle, signon time f(irc, server, 317, source, "%s 0 %s :seconds idle, signon time" % (nick, user.ts)) try: - # Iterate over plugin-created WHOIS handlers. They return a tuple + # Iterate over custom plugin WHOIS handlers. They return a tuple # or list with two arguments: the numeric, and the text to send. for func in utils.whois_handlers: res = func(irc, target) From 05be3e6c176d67a7d67dab5db4fbd702ae108ff2 Mon Sep 17 00:00:00 2001 From: James Lu Date: Mon, 24 Aug 2015 18:37:39 -0700 Subject: [PATCH 7/9] pr/: add prefixmodes in sjoinServer if TS <= theirs The old comparison of our TS < theirs was wrong, because modes are merged (not dropped) when two channels of the same TS join. --- protocols/inspircd.py | 4 ++-- protocols/ts6.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/protocols/inspircd.py b/protocols/inspircd.py index d71e4af..299deb0 100644 --- a/protocols/inspircd.py +++ b/protocols/inspircd.py @@ -101,8 +101,8 @@ 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) - if ts < orig_ts: - # Only save our prefix modes in the channel state if our TS is lower than theirs. + if ts <= orig_ts: + # Only save our prefix modes in the channel state if our TS is lower than or equal to theirs. utils.applyModes(irc, channel, changedmodes) namelist = ' '.join(namelist) _send(irc, server, "FJOIN {channel} {ts} {modes} :{users}".format( diff --git a/protocols/ts6.py b/protocols/ts6.py index b9f88b5..a7e7b4c 100644 --- a/protocols/ts6.py +++ b/protocols/ts6.py @@ -117,8 +117,8 @@ def sjoinServer(irc, server, channel, users, ts=None): ts=ts, users=namelist, channel=channel, modes=utils.joinModes(modes))) irc.channels[channel].users.update(uids) - if ts < orig_ts: - # Only save our prefix modes in the channel state if our TS is lower than theirs. + if ts <= orig_ts: + # Only save our prefix modes in the channel state if our TS is lower than or equal to theirs. utils.applyModes(irc, channel, changedmodes) def _sendModes(irc, numeric, target, modes, ts=None): From 0d497a8f72a6153816c8c23cd2896c9b780facc6 Mon Sep 17 00:00:00 2001 From: James Lu Date: Mon, 24 Aug 2015 18:37:46 -0700 Subject: [PATCH 8/9] coreplugin: show channel prefixes in WHOIS reply --- coreplugin.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/coreplugin.py b/coreplugin.py index 53ef998..6a2b163 100644 --- a/coreplugin.py +++ b/coreplugin.py @@ -61,7 +61,11 @@ def handle_whois(irc, source, command, args): (irc.cmodes.get('private'), None) in c.modes) \ and not (sourceisOper or source in c.users): continue - # TODO: show prefix modes like a regular IRCd does. + # Show prefix modes like a regular IRCd does. + for prefixmode, prefixchar in irc.prefixmodes.items(): + modename = [mname for mname, char in irc.cmodes.items() if char == prefixmode] + if modename and target in c.prefixmodes[modename[0]+'s']: + chan = prefixchar + chan public_chans.append(chan) if public_chans: f(irc, server, 319, source, '%s :%s' % (nick, ' '.join(public_chans))) From 7e449aaadad278688722536f9c44f5dcb626dcdc Mon Sep 17 00:00:00 2001 From: James Lu Date: Mon, 24 Aug 2015 18:38:58 -0700 Subject: [PATCH 9/9] coreplugin: Don't stop iterating if one WHOIS handler errors --- coreplugin.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/coreplugin.py b/coreplugin.py index 6a2b163..5a04eda 100644 --- a/coreplugin.py +++ b/coreplugin.py @@ -84,19 +84,18 @@ def handle_whois(irc, source, command, args): # idle time, so we simply return 0. # <- 317 GL GL 15 1437632859 :seconds idle, signon time f(irc, server, 317, source, "%s 0 %s :seconds idle, signon time" % (nick, user.ts)) - try: - # Iterate over custom plugin WHOIS handlers. They return a tuple - # or list with two arguments: the numeric, and the text to send. - for func in utils.whois_handlers: + for func in utils.whois_handlers: + # Iterate over custom plugin WHOIS handlers. They return a tuple + # or list with two arguments: the numeric, and the text to send. + try: res = func(irc, target) if res: num, text = res f(irc, server, num, source, text) - except Exception as e: - # Again, we wouldn't want this to crash our service, in case - # something goes wrong! - log.exception('Error caught in WHOIS handler: %s', e) - finally: - # 318: End of WHOIS. - f(irc, server, 318, source, "%s :End of /WHOIS list" % nick) + except Exception as e: + # Again, we wouldn't want this to crash our service, in case + # something goes wrong! + log.exception('(%s) Error caught in WHOIS handler: %s', irc.name, e) + # 318: End of WHOIS. + f(irc, server, 318, source, "%s :End of /WHOIS list" % nick) utils.add_hook(handle_whois, 'WHOIS')