diff --git a/README.md b/README.md index 55599ff..f41ef08 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Dependencies currently include: #### Supported IRCds * InspIRCd 2.0.x - module: `inspircd` -* charybdis (3.5.x / git master) - module: `ts6` +* charybdis (3.5.x / git master) - module: `ts6` (**experimental**) ### Installation diff --git a/classes.py b/classes.py index 0e956d1..729250f 100644 --- a/classes.py +++ b/classes.py @@ -1,4 +1,3 @@ -from collections import defaultdict import threading from random import randint @@ -9,7 +8,7 @@ import time class IrcUser(): def __init__(self, nick, ts, uid, ident='null', host='null', realname='PyLink dummy client', realhost='null', - ip='0.0.0.0', modes=set()): + ip='0.0.0.0'): self.nick = nick self.ts = ts self.uid = uid @@ -18,7 +17,7 @@ class IrcUser(): self.realhost = realhost self.ip = ip self.realname = realname - self.modes = modes + self.modes = set() self.identified = False self.channels = set() @@ -39,7 +38,6 @@ class IrcServer(): self.users = [] self.internal = internal self.name = name.lower() - self.has_bursted = False def __repr__(self): return repr(self.__dict__) diff --git a/config.yml.example b/config.yml.example index 3af93b8..7aae0b4 100644 --- a/config.yml.example +++ b/config.yml.example @@ -1,3 +1,8 @@ +# This is a sample configuration file for PyLink. You'll likely want to rename it to config.yml +# and begin your configuration there. + +# Note: lines starting with a "#" are comments and will be ignored. + bot: # Sets nick, user/ident, and real name. nick: pylink diff --git a/coreplugin.py b/coreplugin.py index 32871e3..5f46403 100644 --- a/coreplugin.py +++ b/coreplugin.py @@ -36,3 +36,61 @@ def handle_commands(irc, source, command, args): utils.msg(irc, source, 'Uncaught exception in command %r: %s: %s' % (cmd, type(e).__name__, str(e))) return utils.add_hook(handle_commands, 'PRIVMSG') + +# Return WHOIS replies to IRCds that use them. +def handle_whois(irc, source, command, args): + target = args['target'] + user = irc.users.get(target) + if user is None: + log.warning('(%s) Got a WHOIS request for %r from %r, but the target doesn\'t exist in irc.users!', irc.name, target, source) + f = irc.proto.numericServer + server = utils.clientToServer(irc, target) or irc.sid + nick = user.nick + sourceisOper = ('o', None) in irc.users[source].modes + # 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 :PyLink Server" % (nick, irc.serverdata['hostname'])) + # 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: + # Here, we'll want to hide secret/private channels from non-opers + # who are not in them. + c = irc.channels[chan] + if ((irc.cmodes.get('secret'), None) in c.modes or \ + (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. + 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 + 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 + # or list with two arguments: the numeric, and the text to send. + for func in utils.whois_handlers: + 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) +utils.add_hook(handle_whois, 'WHOIS') diff --git a/main.py b/main.py index 04b3b46..e7e89c5 100755 --- a/main.py +++ b/main.py @@ -61,7 +61,7 @@ class Irc(): self.sid = self.serverdata["sid"] self.botdata = conf['bot'] self.proto = proto - self.pingfreq = self.serverdata.get('pingfreq') or 10 + self.pingfreq = self.serverdata.get('pingfreq') or 30 self.pingtimeout = self.pingfreq * 2 self.initVars() @@ -84,7 +84,9 @@ class Irc(): 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() except (socket.error, classes.ProtocolError, ConnectionError) as e: log.warning('(%s) Disconnected from IRC: %s: %s', @@ -183,7 +185,8 @@ class Irc(): nick = self.botdata.get('nick') or 'PyLink' ident = self.botdata.get('ident') or 'pylink' host = self.serverdata["hostname"] - self.pseudoclient = self.proto.spawnClient(self, nick, ident, host, modes={("o", None)}) + log.info('(%s) Connected! Spawning main client %s.', self.name, nick) + 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) diff --git a/plugins/admin.py b/plugins/admin.py index 7394c01..892d4f3 100644 --- a/plugins/admin.py +++ b/plugins/admin.py @@ -49,7 +49,7 @@ def spawnclient(irc, source, args): def quit(irc, source, args): """ [] - Admin-only. Quits the PyLink client , if it exists.""" + Admin-only. Quits the PyLink client with nick , if one exists.""" checkauthenticated(irc, source) try: nick = args[0] @@ -66,7 +66,7 @@ def quit(irc, source, args): def joinclient(irc, source, args): """ ,[], etc. - Admin-only. Joins , a PyLink client, to a comma-separated list of channels.""" + Admin-only. Joins , the nick of a PyLink client, to a comma-separated list of channels.""" checkauthenticated(irc, source) try: nick = args[0] @@ -108,7 +108,7 @@ def nick(irc, source, args): def part(irc, source, args): """ ,[],... [] - Admin-only. Parts , a PyLink client, from a comma-separated list of channels.""" + Admin-only. Parts , the nick of a PyLink client, from a comma-separated list of channels.""" checkauthenticated(irc, source) try: nick = args[0] @@ -128,7 +128,7 @@ def part(irc, source, args): def kick(irc, source, args): """ [] - Admin-only. Kicks from via , where is a PyLink client.""" + Admin-only. Kicks from via , where is the nick of a PyLink client.""" checkauthenticated(irc, source) try: nick = args[0] @@ -186,7 +186,7 @@ def showchan(irc, source, args): def mode(irc, source, args): """ - Admin-only. Sets modes on .""" + Admin-only. Sets modes on from , where is the nick of a PyLink client.""" checkauthenticated(irc, source) try: modesource, target, modes = args[0], args[1], args[2:] diff --git a/plugins/commands.py b/plugins/commands.py index a7b7b75..baa910f 100644 --- a/plugins/commands.py +++ b/plugins/commands.py @@ -1,7 +1,6 @@ # commands.py: base PyLink commands import sys import os -import logging sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import utils diff --git a/plugins/relay.py b/plugins/relay.py index e47deb3..8929f61 100644 --- a/plugins/relay.py +++ b/plugins/relay.py @@ -5,7 +5,6 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import pickle import sched import threading -import time import string from collections import defaultdict @@ -15,6 +14,17 @@ from log import log dbname = "pylinkrelay.db" relayusers = defaultdict(dict) +def relayWhoisHandlers(irc, target): + user = irc.users[target] + orig = getLocalUser(irc, target) + if orig: + network, remoteuid = orig + remotenick = utils.networkobjects[network].users[remoteuid].nick + return [320, "%s :is a remote user connected via PyLink Relay. Home " + "network: %s; Home nick: %s" % (user.nick, network, + remotenick)] +utils.whois_handlers.append(relayWhoisHandlers) + def normalizeNick(irc, netname, nick, separator=None): # Block until we know the IRC network's nick length (after capabilities # are sent) @@ -97,12 +107,11 @@ def save(irc, source, args): def getPrefixModes(irc, remoteirc, channel, user): modes = '' for pmode in ('owner', 'admin', 'op', 'halfop', 'voice'): - if pmode not in remoteirc.cmodes: # Mode not supported by IRCd - continue - mlist = irc.channels[channel].prefixmodes[pmode+'s'] - log.debug('(%s) getPrefixModes: checking if %r is in %r', irc.name, user, mlist) - if user in mlist: - modes += remoteirc.cmodes[pmode] + 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) + if user in mlist: + modes += remoteirc.cmodes[pmode] return modes def getRemoteUser(irc, remoteirc, user, spawnIfMissing=True): @@ -133,7 +142,7 @@ def getRemoteUser(irc, remoteirc, user, spawnIfMissing=True): modes = getSupportedUmodes(irc, remoteirc, userobj.modes) u = remoteirc.proto.spawnClient(remoteirc, nick, ident=ident, host=host, realname=realname, - modes=modes).uid + modes=modes, ts=userobj.ts).uid remoteirc.users[u].remote = irc.name relayusers[(irc.name, user)][remoteirc.name] = u remoteirc.users[u].remote = irc.name @@ -218,6 +227,7 @@ def initializeChannel(irc, channel): all_links = db[relay]['links'].copy() all_links.update((relay,)) log.debug('(%s) initializeChannel: all_links: %s', irc.name, all_links) + # Iterate over all the remote channels linked in this relay. for link in all_links: modes = [] remotenet, remotechan = link @@ -229,20 +239,9 @@ def initializeChannel(irc, channel): rc = remoteirc.channels[remotechan] if not (remoteirc.connected and findRemoteChan(remoteirc, irc, remotechan)): continue # They aren't connected, don't bother! - for user in remoteirc.channels[remotechan].users: - # Don't spawn our pseudoclients again. - if not utils.isInternalClient(remoteirc, user): - log.debug('(%s) initializeChannel: should be joining %s/%s to %s', irc.name, user, remotenet, channel) - localuser = getRemoteUser(remoteirc, irc, user) - if localuser is None: - log.warning('(%s) got None for local user for %s/%s', irc.name, user, remotenet) - continue - userpair = (getPrefixModes(remoteirc, irc, remotechan, user), localuser) - log.debug('(%s) initializeChannel: adding %s to queued_users for %s', irc.name, userpair, channel) - queued_users.append(userpair) - if queued_users: - irc.proto.sjoinServer(irc, irc.sid, channel, queued_users, ts=rc.ts) - relayModes(remoteirc, irc, remoteirc.sid, remotechan) + # 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 # Only update the topic if it's different from what we already have, @@ -251,6 +250,7 @@ def initializeChannel(irc, channel): 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) @@ -262,7 +262,6 @@ def handle_join(irc, numeric, command, args): modes = args['modes'] ts = args['ts'] users = set(args['users']) - # users.update(irc.channels[channel].users) relayJoins(irc, channel, users, ts, modes) utils.add_hook(handle_join, 'JOIN') @@ -296,6 +295,8 @@ def handle_part(irc, numeric, command, args): for netname, user in relayusers[(irc.name, numeric)].copy().items(): remoteirc = utils.networkobjects[netname] remotechan = findRemoteChan(irc, remoteirc, channel) + if remotechan is None: + continue remoteirc.proto.partClient(remoteirc, user, remotechan, text) if not remoteirc.users[user].channels: remoteirc.proto.quitClient(remoteirc, user, 'Left all shared channels.') @@ -310,7 +311,10 @@ def handle_privmsg(irc, numeric, command, args): return sent = 0 relay = findRelay((irc.name, target)) - if utils.isChannel(target) and relay and not db[relay]['links']: + # 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! + if utils.isChannel(target) and ((relay and not db[relay]['links']) or \ + relay is None): return for netname, user in relayusers[(irc.name, numeric)].items(): remoteirc = utils.networkobjects[netname] @@ -416,6 +420,12 @@ def handle_kick(irc, source, command, args): text = "(@%s) %s" % (irc.name, text) 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] + utils.add_hook(handle_kick, 'KICK') def handle_chgclient(irc, source, command, args): @@ -434,8 +444,10 @@ def handle_chgclient(irc, source, command, args): remoteirc = utils.networkobjects[netname] try: remoteirc.proto.updateClient(remoteirc, user, field, text) - except ValueError: # IRCd doesn't support changing the field we want - logging.debug('(%s) Error raised changing field %r of %s on %s (for %s/%s)', irc.name, field, user, target, remotenet, irc.name) + except NotImplementedError: # IRCd doesn't support changing the field we want + log.debug('(%s) Ignoring changing field %r of %s on %s (for %s/%s);' + ' remote IRCd doesn\'t support it', irc.name, field, + user, target, netname, irc.name) continue for c in ('CHGHOST', 'CHGNAME', 'CHGIDENT'): @@ -446,10 +458,10 @@ whitelisted_cmodes = {'admin', 'allowinvite', 'autoop', 'ban', 'banexception', 'limit', 'moderated', 'noctcp', 'noextmsg', 'nokick', 'noknock', 'nonick', 'nonotice', 'op', 'operonly', 'opmoderated', 'owner', 'private', 'regonly', - 'regmoderated', 'secret', 'sslonly', + 'regmoderated', 'secret', 'sslonly', 'adminonly', 'stripcolor', 'topiclock', 'voice'} whitelisted_umodes = {'bot', 'hidechans', 'hideoper', 'invisible', 'oper', - 'regdeaf', 'u_stripcolor', 'servprotect', 'u_noctcp'} + 'regdeaf', 'u_stripcolor', 'u_noctcp', 'wallops'} def relayModes(irc, remoteirc, sender, channel, modes=None): remotechan = findRemoteChan(irc, remoteirc, channel) log.debug('(%s) Relay mode: remotechan for %s on %s is %s', irc.name, channel, irc.name, remotechan) @@ -590,30 +602,40 @@ utils.add_hook(handle_topic, 'TOPIC') def handle_kill(irc, numeric, command, args): target = args['target'] userdata = args['userdata'] - if numeric not in irc.users: - # A server's sending kill? Uh oh, this can't be good. - return - # We don't allow killing over the relay, so we must spawn the client. - # all over again and rejoin it to its channels. realuser = getLocalUser(irc, target) - 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]) - log.debug('(%s) handle_kill: userpair: %s, %s', irc.name, modes, realuser) - client = getRemoteUser(remoteirc, irc, realuser[1]) - irc.proto.sjoinServer(irc, irc.sid, remotechan, [(modes, client)]) - utils.msg(irc, numeric, "Your kill has to %s been blocked " - "because PyLink does not allow killing" - " users over the relay at this time." % \ - userdata.nick, notice=True) + log.debug('(%s) relay handle_kill: realuser is %r', irc.name, realuser) + # Target user was remote: + if realuser and realuser[0] != irc.name: + # We don't allow killing over the relay, so we must respawn the affected + # 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]) + 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)]) + if userdata and numeric in irc.users: + utils.msg(irc, numeric, "Your kill has to %s been blocked " + "because PyLink does not allow killing" + " users over the relay at this time." % \ + userdata.nick, notice=True) + # Target user was local. + else: + # IMPORTANT: some IRCds (charybdis) don't send explicit QUIT messages + # for locally killed clients, while others (inspircd) do! + # If we receive a user object in 'userdata' instead of None, it means + # that the KILL hasn't been handled by a preceding QUIT message. + if userdata: + handle_quit(irc, target, 'KILL', {'text': args['text']}) + utils.add_hook(handle_kill, 'KILL') def relayJoins(irc, channel, users, ts, modes): - queued_users = [] for name, remoteirc in utils.networkobjects.items(): + queued_users = [] if name == irc.name: # Don't relay things to their source network... continue @@ -622,18 +644,19 @@ def relayJoins(irc, channel, users, ts, modes): # If there is no link on our network for the user, don't # bother spawning it. 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. + 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, KeyError): # Nope, it isn't. + except AttributeError: # Nope, it isn't. pass - if utils.isInternalClient(irc, user) or user not in irc.users: - # We don't need to clone PyLink pseudoclients... That's - # meaningless. - continue 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) @@ -673,9 +696,14 @@ def removeChannel(irc, channel): for user in irc.channels[channel].users.copy(): if not utils.isInternalClient(irc, user): relayPart(irc, channel, user) + # Don't ever part the main client from any of its autojoin channels. else: + if user == irc.pseudoclient.uid and channel in \ + irc.serverdata['channels']: + continue irc.proto.partClient(irc, user, channel, 'Channel delinked.') - if not irc.users[user].channels: + # 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] @@ -853,18 +881,24 @@ def handle_disconnect(irc, numeric, command, args): utils.add_hook(handle_disconnect, "PYLINK_DISCONNECT") def handle_save(irc, numeric, command, args): - # Nick collision! Try to change our nick to the next available normalized - # nick. target = args['target'] - if utils.isInternalClient(irc, target): - realuser = getLocalUser(irc, target) - if realuser is None: - return + 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: + # Nick collision! + # It's one of our relay clients; try to fix our nick to the next + # available normalized nick. remotenet, remoteuser = realuser remoteirc = utils.networkobjects[remotenet] nick = remoteirc.users[remoteuser].nick newnick = normalizeNick(irc, remotenet, nick) irc.proto.nickClient(irc, target, newnick) + else: + # Somebody else on the network (not a PyLink client) had a nick collision; + # relay this as a nick change appropriately. + handle_nick(irc, target, 'SAVE', {'oldnick': None, 'newnick': target}) + utils.add_hook(handle_save, "SAVE") @utils.add_cmd diff --git a/protocols/inspircd.py b/protocols/inspircd.py index d33a0cf..4984c1a 100644 --- a/protocols/inspircd.py +++ b/protocols/inspircd.py @@ -10,6 +10,8 @@ from log import log from classes import * +casemapping = 'rfc1459' + # Raw commands sent from servers vary from protocol to protocol. Here, we map # non-standard names to our hook handlers, so plugins get the information they need. @@ -23,7 +25,7 @@ def _send(irc, sid, msg): irc.send(':%s %s' % (sid, msg)) def spawnClient(irc, nick, ident='null', host='null', realhost=None, modes=set(), - server=None, ip='0.0.0.0', realname=None): + server=None, ip='0.0.0.0', realname=None, ts=None): server = server or irc.sid if not utils.isInternalServer(irc, server): raise ValueError('Server %r is not a PyLink internal PseudoServer!' % server) @@ -32,12 +34,13 @@ def spawnClient(irc, nick, ident='null', host='null', realhost=None, modes=set() if server not in irc.uidgen: irc.uidgen[server] = utils.TS6UIDGenerator(server) uid = irc.uidgen[server].next_uid() - ts = int(time.time()) + ts = ts or int(time.time()) realname = realname or irc.botdata['realname'] realhost = realhost or host raw_modes = utils.joinModes(modes) u = irc.users[uid] = IrcUser(nick, ts, uid, ident=ident, host=host, realname=realname, - realhost=realhost, ip=ip, modes=modes) + realhost=realhost, ip=ip) + utils.applyModes(irc, uid, modes) irc.servers[server].users.append(uid) _send(irc, server, "UID {uid} {ts} {nick} {realhost} {host} {ident} {ip}" " {ts} {modes} + :{realname}".format(ts=ts, host=host, @@ -273,16 +276,16 @@ def updateClient(irc, numeric, field, text): Changes the field of PyLink PseudoClient .""" field = field.upper() if field == 'IDENT': - handle_fident(irc, numeric, 'PYLINK_UPDATECLIENT_IDENT', [text]) + irc.users[numeric].ident = text _send(irc, numeric, 'FIDENT %s' % text) elif field == 'HOST': - handle_fhost(irc, numeric, 'PYLINK_UPDATECLIENT_HOST', [text]) + irc.users[numeric].host = text _send(irc, numeric, 'FHOST %s' % text) elif field in ('REALNAME', 'GECOS'): - handle_fname(irc, numeric, 'PYLINK_UPDATECLIENT_GECOS', [text]) + irc.users[numeric].realname = text _send(irc, numeric, 'FNAME :%s' % text) else: - raise ValueError("Changing field %r of a client is unsupported by this protocol." % field) + raise NotImplementedError("Changing field %r of a client is unsupported by this protocol." % field) def pingServer(irc, source=None, target=None): source = source or irc.sid @@ -290,6 +293,12 @@ def pingServer(irc, source=None, target=None): if not (target is None or source is None): _send(irc, source, 'PING %s %s' % (source, target)) +def numericServer(irc, source, numeric, text): + raise NotImplementedError("Numeric sending is not yet implemented by this " + "protocol module. WHOIS requests are handled " + "locally by InspIRCd servers, so there is no " + "need for PyLink to send numerics directly yet.") + def connect(irc): ts = irc.start_ts irc.uidgen = {} @@ -318,8 +327,9 @@ def handle_privmsg(irc, source, command, args): def handle_kill(irc, source, command, args): killed = args[0] - data = irc.users[killed] - removeClient(irc, killed) + data = irc.users.get(killed) + if data: + removeClient(irc, killed) return {'target': killed, 'text': args[1], 'userdata': data} def handle_kick(irc, source, command, args): @@ -467,7 +477,7 @@ def handle_events(irc, data): # Each server message looks something like this: # :70M FJOIN #chat 1423790411 +AFPfjnt 6:5 7:5 9:5 :v,1SRAAESWE # : ... :final multi word argument - args = data.split() + args = data.split(" ") if not args: # No data?? return @@ -559,7 +569,7 @@ def handle_events(irc, data): if parsed_args is not None: return [numeric, command, parsed_args] -def spawnServer(irc, name, sid=None, uplink=None, desc='PyLink Server', endburst=True): +def spawnServer(irc, name, sid=None, uplink=None, desc='PyLink Server'): # -> :0AL SERVER test.server * 1 0AM :some silly pseudoserver uplink = uplink or irc.sid name = name.lower() @@ -578,13 +588,8 @@ def spawnServer(irc, name, sid=None, uplink=None, desc='PyLink Server', endburst raise ValueError('Invalid server name %r' % name) _send(irc, uplink, 'SERVER %s * 1 %s :%s' % (name, sid, desc)) irc.servers[sid] = IrcServer(uplink, name, internal=True) - if endburst: - endburstServer(irc, sid) - return sid - -def endburstServer(irc, sid): _send(irc, sid, 'ENDBURST') - irc.servers[sid].has_bursted = True + return sid def handle_ftopic(irc, numeric, command, args): # <- :70M FTOPIC #channel 1434510754 GLo|o|!GLolol@escape.the.dreamland.ca :Some channel topic diff --git a/protocols/ts6.py b/protocols/ts6.py index 40175c6..9b2e300 100644 --- a/protocols/ts6.py +++ b/protocols/ts6.py @@ -2,7 +2,6 @@ import time import sys import os import re -from copy import copy curdir = os.path.dirname(__file__) sys.path += [curdir, os.path.dirname(curdir)] @@ -17,13 +16,14 @@ from inspircd import handle_privmsg, handle_kill, handle_kick, handle_error, \ handle_quit, handle_nick, handle_save, handle_squit, handle_mode, handle_topic, \ handle_notice +casemapping = 'rfc1459' hook_map = {'SJOIN': 'JOIN', 'TB': 'TOPIC', 'TMODE': 'MODE', 'BMASK': 'MODE'} def _send(irc, sid, msg): irc.send(':%s %s' % (sid, msg)) def spawnClient(irc, nick, ident='null', host='null', realhost=None, modes=set(), - server=None, ip='0.0.0.0', realname=None): + server=None, ip='0.0.0.0', realname=None, ts=None): server = server or irc.sid if not utils.isInternalServer(irc, server): raise ValueError('Server %r is not a PyLink internal PseudoServer!' % server) @@ -32,20 +32,22 @@ def spawnClient(irc, nick, ident='null', host='null', realhost=None, modes=set() if server not in irc.uidgen: irc.uidgen[server] = utils.TS6UIDGenerator(server) uid = irc.uidgen[server].next_uid() - # UID: + # EUID: # parameters: nickname, hopcount, nickTS, umodes, username, - # visible hostname, IP address, UID, gecos - ts = int(time.time()) + # visible hostname, IP address, UID, real hostname, account name, gecos + ts = ts or int(time.time()) realname = realname or irc.botdata['realname'] realhost = realhost or host raw_modes = utils.joinModes(modes) u = irc.users[uid] = IrcUser(nick, ts, uid, ident=ident, host=host, realname=realname, - realhost=realhost, ip=ip, modes=modes) + realhost=realhost, ip=ip) + utils.applyModes(irc, uid, modes) irc.servers[server].users.append(uid) - _send(irc, server, "UID {nick} 1 {ts} {modes} {ident} {host} {ip} {uid} " - ":{realname}".format(ts=ts, host=host, - nick=nick, ident=ident, uid=uid, - modes=raw_modes, ip=ip, realname=realname)) + _send(irc, server, "EUID {nick} 1 {ts} {modes} {ident} {host} {ip} {uid} " + "{realhost} * :{realname}".format(ts=ts, host=host, + nick=nick, ident=ident, uid=uid, + modes=raw_modes, ip=ip, realname=realname, + realhost=realhost)) return u def joinClient(irc, client, channel): @@ -214,7 +216,7 @@ def updateClient(irc, numeric, field, text): Changes the field of PyLink PseudoClient .""" field = field.upper() if field == 'HOST': - handle_chghost(irc, numeric, 'PYLINK_UPDATECLIENT_HOST', [text]) + irc.users[numeric].host = text _send(irc, irc.sid, 'CHGHOST %s :%s' % (numeric, text)) else: raise NotImplementedError("Changing field %r of a client is unsupported by this protocol." % field) @@ -228,6 +230,9 @@ def pingServer(irc, source=None, target=None): else: _send(irc, source, 'PING %s' % source) +def numericServer(irc, source, numeric, target, text): + _send(irc, source, '%s %s %s' % (numeric, target, text)) + def connect(irc): ts = irc.start_ts irc.uidgen = {} @@ -375,8 +380,12 @@ def handle_euid(irc, numeric, command, args): irc.servers[numeric].users.append(uid) return {'uid': uid, 'ts': ts, 'nick': nick, 'realhost': realhost, 'host': host, 'ident': ident, 'ip': ip} +def handle_uid(irc, numeric, command, args): + raise ProtocolError("Servers must use EUID to send users! This is a " + "requested capability; plain UID (received) is not " + "handled by us at all!") + def handle_server(irc, numeric, command, args): - # SERVER is sent by our uplink or any other server to introduce others. # parameters: server name, hopcount, sid, server description servername = args[0].lower() try: @@ -390,6 +399,8 @@ def handle_server(irc, numeric, command, args): irc.servers[sid] = IrcServer(numeric, servername) return {'name': servername, 'sid': sid, 'text': sdesc} +handle_sid = handle_server + def handle_tmode(irc, numeric, command, args): # <- :42XAAAAAB TMODE 1437450768 #endlessvoid -c+lkC 3 agte4 channel = args[1].lower() @@ -403,7 +414,7 @@ def handle_events(irc, data): # TS6 messages: # :42X COMMAND arg1 arg2 :final long arg # :42XAAAAAA PRIVMSG #somewhere :hello! - args = data.split() + args = data.split(" ") if not args: # No data?? return @@ -428,6 +439,12 @@ def handle_events(irc, data): # According to the TS6 protocol documentation, we should send SVINFO # when we get our uplink's SERVER command. irc.send('SVINFO 6 6 0 :%s' % int(time.time())) + elif args[0] == 'SQUIT': + # What? Charybdis send this in a different format! + # <- SQUIT 00A :Remote host closed the connection + split_server = args[1] + res = handle_squit(irc, split_server, 'SQUIT', [split_server]) + irc.callHooks([split_server, 'SQUIT', res]) elif args[0] == 'CAPAB': # We only get a list of keywords here. Charybdis obviously assumes that # we know what modes it supports (indeed, this is a standard list). @@ -447,6 +464,10 @@ def handle_events(irc, data): # https://github.com/grawity/irc-docs/blob/master/server/ts6.txt#L80 chary_cmodes = { # TS6 generic modes: + # Note: charybdis +p has the effect of being both + # noknock AND private. Surprisingly, mapping it twice + # works pretty well: setting +p on a charybdis relay + # server sets +pK on an InspIRCd network. 'op': 'o', 'voice': 'v', 'ban': 'b', 'key': 'k', 'limit': 'l', 'moderated': 'm', 'noextmsg': 'n', 'noknock': 'p', 'secret': 's', 'topiclock': 't', @@ -454,7 +475,9 @@ def handle_events(irc, data): 'quiet': 'q', 'redirect': 'f', 'freetarget': 'F', 'joinflood': 'j', 'largebanlist': 'L', 'permanent': 'P', 'c_noforwards': 'Q', 'stripcolor': 'c', 'allowinvite': - 'g', 'opmoderated': 'z', + 'g', 'opmoderated': 'z', 'noctcp': 'C', + # charybdis-specific modes provided by EXTENSIONS + 'operonly': 'O', 'adminonly': 'A', 'sslonly': 'S', # Now, map all the ABCD type modes: '*A': 'beI', '*B': 'k', '*C': 'l', '*D': 'mnprst'} if 'EX' in caps: @@ -526,7 +549,7 @@ def handle_events(irc, data): if parsed_args is not None: return [numeric, command, parsed_args] -def spawnServer(irc, name, sid=None, uplink=None, desc='PyLink Server', endburst=True): +def spawnServer(irc, name, sid=None, uplink=None, desc='PyLink Server'): # -> :0AL SERVER test.server 1 0XY :some silly pseudoserver uplink = uplink or irc.sid name = name.lower() @@ -584,3 +607,23 @@ def handle_bmask(irc, numeric, command, args): modes.append(('+%s' % mode, ban)) utils.applyModes(irc, channel, modes) return {'target': channel, 'modes': modes, 'ts': ts} + +def handle_whois(irc, numeric, command, args): + # <- :42XAAAAAB WHOIS 5PYAAAAAA :pylink-devel + return {'target': args[0]} + +def handle_472(irc, numeric, command, args): + # <- :charybdis.midnight.vpn 472 GL|devel O :is an unknown mode char to me + # 472 is sent to us when one of our clients tries to set a mode the server + # doesn't support. In this case, we'll raise a warning to alert the user + # about it. + badmode = args[1] + reason = args[-1] + setter = args[0] + charlist = {'A': 'chm_adminonly', 'O': 'chm_operonly', 'S': 'chm_sslonly'} + if badmode in charlist: + log.warning('(%s) User %r attempted to set channel mode %r, but the ' + 'extension providing it isn\'t loaded! To prevent possible' + ' desyncs, try adding the line "loadmodule "extensions/%s.so";" to ' + 'your IRCd configuration.', irc.name, setter, badmode, + charlist[badmode]) diff --git a/tests/test_proto_inspircd.py b/tests/test_proto_inspircd.py index 931351a..01eac64 100644 --- a/tests/test_proto_inspircd.py +++ b/tests/test_proto_inspircd.py @@ -2,7 +2,6 @@ import sys import os sys.path += [os.getcwd(), os.path.join(os.getcwd(), 'protocols')] import unittest -import time from collections import defaultdict import inspircd @@ -72,7 +71,6 @@ class TestProtoInspIRCd(unittest.TestCase): self.assertNotIn(u, self.irc.channels['#channel'].users) self.assertNotIn(u, self.irc.users) self.assertNotIn(u, self.irc.servers[self.irc.sid].users) - pass def testKickClient(self): target = self.proto.spawnClient(self.irc, 'soccerball', 'soccerball', 'abcd').uid diff --git a/utils.py b/utils.py index 14986f1..78c96f5 100644 --- a/utils.py +++ b/utils.py @@ -12,6 +12,7 @@ command_hooks = defaultdict(list) networkobjects = {} schedulers = {} plugins = [] +whois_handlers = [] started = threading.Event() class TS6UIDGenerator(): @@ -97,8 +98,6 @@ class TS6SIDGenerator(): self.iters[pos] = iter(self.allowedchars[pos]) next(self.iters[pos]) self.increment(pos-1) - else: - print('NEXT') def next_sid(self): sid = ''.join(self.output) @@ -122,9 +121,18 @@ def add_hook(func, command): command = command.upper() command_hooks[command].append(func) +def toLower(irc, text): + if irc.proto.casemapping == 'rfc1459': + text = text.replace('{', '[') + text = text.replace('}', ']') + text = text.replace('|', '\\') + text = text.replace('~', '^') + return text.lower() + def nickToUid(irc, nick): + nick = toLower(irc, nick) for k, v in irc.users.items(): - if v.nick == nick: + if toLower(irc, v.nick) == nick: return k def clientToServer(irc, numeric): @@ -170,16 +178,18 @@ def parseModes(irc, target, args): if usermodes: log.debug('(%s) Using irc.umodes for this query: %s', irc.name, irc.umodes) supported_modes = irc.umodes + oldmodes = irc.users[target].modes else: log.debug('(%s) Using irc.cmodes for this query: %s', irc.name, irc.cmodes) supported_modes = irc.cmodes + oldmodes = irc.channels[target].modes res = [] for mode in modestring: if mode in '+-': prefix = mode else: if not prefix: - raise ValueError('Invalid query %r: mode char given without preceding prefix.' % modestring) + prefix = '+' arg = None log.debug('Current mode: %s%s; args left: %s', prefix, mode, args) try: @@ -187,6 +197,17 @@ def parseModes(irc, target, args): # Must have parameter. log.debug('Mode %s: This mode must have parameter.', mode) arg = args.pop(0) + if prefix == '-' and mode in supported_modes['*B'] and arg == '*': + # Charybdis allows unsetting +k without actually + # knowing the key by faking the argument when unsetting + # as a single "*". + # We'd need to know the real argument of +k for us to + # be able to unset the mode. + oldargs = [m[1] for m in oldmodes if m[0] == mode] + if oldargs: + # Set the arg to the old one on the channel. + arg = oldargs[0] + log.debug("Mode %s: coersing argument of '*' to %r.", mode, arg) elif mode in irc.prefixmodes and not usermodes: # We're setting a prefix mode on someone (e.g. +o user1) log.debug('Mode %s: This mode is a prefix mode.', mode)