3
0
mirror of https://github.com/jlu5/PyLink.git synced 2024-11-23 19:19:31 +01:00
PyLink/protocols/unreal.py
2021-12-25 01:00:42 -08:00

1039 lines
47 KiB
Python

"""
unreal.py: UnrealIRCd 4.x-5.x protocol module for PyLink.
"""
import codecs
import re
import socket
import time
from pylinkirc import conf, utils
from pylinkirc.classes import *
from pylinkirc.log import log
from pylinkirc.protocols.ts6_common import TS6BaseProtocol
__all__ = ['UnrealProtocol']
SJOIN_PREFIXES = {'q': '*', 'a': '~', 'o': '@', 'h': '%', 'v': '+', 'b': '&', 'e': '"', 'I': "'"}
class UnrealProtocol(TS6BaseProtocol):
# I'm not sure what the real limit is, but the text posted at
# https://github.com/jlu5/PyLink/issues/378 suggests 427 characters.
# https://github.com/unrealircd/unrealircd/blob/4cad9cb/src/modules/m_server.c#L1260 may
# also help. (but why BUFSIZE-*80*?) -jlu5
S2S_BUFSIZE = 427
_KNOWN_CMODES = {'ban': 'b',
'banexception': 'e',
'blockcolor': 'c',
'censor': 'G',
'delayjoin': 'D',
'flood_unreal': 'f',
'invex': 'I',
'inviteonly': 'i',
'issecure': 'Z',
'key': 'k',
'limit': 'l',
'moderated': 'm',
'noctcp': 'C',
'noextmsg': 'n',
'noinvite': 'V',
'nokick': 'Q',
'noknock': 'K',
'nonick': 'N',
'nonotice': 'T',
'op': 'o',
'operonly': 'O',
'permanent': 'P',
'private': 'p',
'registered': 'r',
'regmoderated': 'M',
'regonly': 'R',
'secret': 's',
'sslonly': 'z',
'stripcolor': 'S',
'topiclock': 't',
'voice': 'v'}
_KNOWN_UMODES = {'bot': 'B',
'censor': 'G',
'cloak': 'x',
'deaf': 'd',
'filter': 'G',
'hidechans': 'p',
'hideidle': 'I',
'hideoper': 'H',
'invisible': 'i',
'noctcp': 'T',
'protected': 'q',
'regdeaf': 'R',
'registered': 'r',
'sslonlymsg': 'Z',
'servprotect': 'S',
'showwhois': 'W',
'snomask': 's',
'ssl': 'z',
'vhost': 't',
'wallops': 'w'}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.protocol_caps |= {'slash-in-nicks', 'underscore-in-hosts', 'slash-in-hosts'}
# Set our case mapping (rfc1459 maps "\" and "|" together, for example)
self.casemapping = 'ascii'
# Unreal protocol version
self.proto_ver = 4203
self.min_proto_ver = 4000
self.hook_map = {'UMODE2': 'MODE', 'SVSKILL': 'KILL', 'SVSMODE': 'MODE',
'SVS2MODE': 'MODE', 'SJOIN': 'JOIN', 'SETHOST': 'CHGHOST',
'SETIDENT': 'CHGIDENT', 'SETNAME': 'CHGNAME',
'EOS': 'ENDBURST'}
self.caps = []
self.prefixmodes = {'q': '~', 'a': '&', 'o': '@', 'h': '%', 'v': '+'}
self.needed_caps = ["VL", "SID", "CHANMODES", "NOQUIT", "SJ3", "NICKIP", "UMODE2", "SJOIN"]
# Command aliases to handlers defined in parent modules
self.handle_svskill = self.handle_kill
self.topic_burst = self.topic
### OUTGOING COMMAND FUNCTIONS
def spawn_client(self, nick, ident='null', host='null', realhost=None, modes=set(),
server=None, ip='0.0.0.0', realname=None, ts=None, opertype='IRC Operator',
manipulatable=False):
"""
Spawns a new client with the given options.
Note: No nick collision / valid nickname checks are done here; it is
up to plugins to make sure they don't introduce anything invalid.
"""
server = server or self.sid
if not self.is_internal_server(server):
raise ValueError('Server %r is not a PyLink server!' % server)
# Unreal 4.0 uses TS6-style UIDs. They don't start from AAAAAA like other IRCd's
# do, but that doesn't matter to us...
uid = self.uidgen[server].next_uid()
ts = ts or int(time.time())
realname = realname or conf.conf['pylink']['realname']
realhost = realhost or host
# Add +xt so that vHost cloaking always works.
modes = set(modes) # Ensure type safety
modes |= {('+x', None), ('+t', None)}
raw_modes = self.join_modes(modes)
u = self.users[uid] = User(self, nick, ts, uid, server, ident=ident, host=host, realname=realname,
realhost=realhost, ip=ip, manipulatable=manipulatable, opertype=opertype)
self.apply_modes(uid, modes)
self.servers[server].users.add(uid)
# UnrealIRCd requires encoding the IP by first packing it into a binary format,
# and then encoding the binary with Base64.
if ip == '0.0.0.0': # Dummy IP (for services, etc.) use a single *.
encoded_ip = '*'
else:
try: # Try encoding as IPv4 first.
binary_ip = socket.inet_pton(socket.AF_INET, ip)
except OSError:
try: # That failed, try IPv6 next.
binary_ip = socket.inet_pton(socket.AF_INET6, ip)
except OSError:
raise ValueError("Invalid IPv4 or IPv6 address %r." % ip)
# Encode in Base64.
encoded_ip = codecs.encode(binary_ip, "base64")
# Now, strip the trailing \n and decode into a string again.
encoded_ip = encoded_ip.strip().decode()
# <- :001 UID jlu5 0 1441306929 jlu5 localhost 0018S7901 0 +iowx * midnight-1C620195 fwAAAQ== :realname
self._send_with_prefix(server, "UID {nick} {hopcount} {ts} {ident} {realhost} {uid} 0 {modes} "
"{host} * {ip} :{realname}".format(ts=ts, host=host,
nick=nick, ident=ident, uid=uid,
modes=raw_modes, realname=realname,
realhost=realhost, ip=encoded_ip,
hopcount=self.servers[server].hopcount))
return u
def join(self, client, channel):
"""Joins a PyLink client to a channel."""
if not self.is_internal_client(client):
raise LookupError('No such PyLink client exists.')
# Forward this on to SJOIN, as using JOIN in Unreal S2S seems to cause TS corruption bugs.
# This seems to be what Unreal itself does anyways.
if channel not in self.channels:
prefix = 'o' # Create new channels with the first joiner as op
else:
prefix = ''
self.sjoin(self.sid, channel, [(prefix, client)])
def sjoin(self, server, channel, users, ts=None, modes=set()):
"""Sends an SJOIN for a group of users to a channel.
The sender should always be a server (SID). TS is optional, and defaults
to the one we've stored in the channel state if not given.
<users> is a list of (prefix mode, UID) pairs:
Example uses:
sjoin('100', '#test', [('', '100AAABBC'), ('o', 100AAABBB'), ('v', '100AAADDD')])
sjoin(self.sid, '#test', [('o', self.pseudoclient.uid)])
"""
# <- :001 SJOIN 1444361345 #test :*@+1JJAAAAAB %2JJAAAA4C 1JJAAAADS
server = server or self.sid
assert users, "sjoin: No users sent?"
if not server:
raise LookupError('No such PyLink server exists.')
changedmodes = set(modes or self._channels[channel].modes)
orig_ts = self._channels[channel].ts
ts = ts or orig_ts
uids = []
itemlist = []
for userpair in users:
assert len(userpair) == 2, "Incorrect format of userpair: %r" % userpair
prefixes, user = userpair
# Unreal uses slightly different prefixes in SJOIN. +q is * instead of ~,
# and +a is ~ instead of &.
# &, ", and ' are used for bursting bans.
prefixchars = ''.join([SJOIN_PREFIXES.get(prefix, '') for prefix in prefixes])
if prefixchars:
changedmodes |= {('+%s' % prefix, user) for prefix in prefixes}
itemlist.append(prefixchars+user)
uids.append(user)
try:
self.users[user].channels.add(channel)
except KeyError: # Not initialized yet?
log.debug("(%s) sjoin: KeyError trying to add %r to %r's channel list?", self.name, channel, user)
# Track simple modes separately.
simplemodes = set()
for modepair in modes:
if modepair[0][-1] in self.cmodes['*A']:
# Bans, exempts, invex get expanded to forms like "&*!*@some.host" in SJOIN.
if (modepair[0][-1], modepair[1]) in self._channels[channel].modes:
# Mode is already set; skip it.
continue
sjoin_prefix = SJOIN_PREFIXES.get(modepair[0][-1])
if sjoin_prefix:
itemlist.append(sjoin_prefix+modepair[1])
else:
simplemodes.add(modepair)
# Store the part of the SJOIN that we may reuse due to line wrapping (i.e. the sjoin
# "prefix")
sjoin_prefix = ":{sid} SJOIN {ts} {channel}".format(sid=server, ts=ts, channel=channel)
# Modes are optional; add them if they exist
if modes:
sjoin_prefix += " %s" % self.join_modes(simplemodes)
sjoin_prefix += " :"
# Wrap arguments to the max supported S2S line length to prevent cutoff
# (https://github.com/jlu5/PyLink/issues/378)
for line in utils.wrap_arguments(sjoin_prefix, itemlist, self.S2S_BUFSIZE):
self.send(line)
self._channels[channel].users.update(uids)
self.updateTS(server, channel, ts, changedmodes)
def _ping_uplink(self):
"""Sends a PING to the uplink."""
if self.sid and self.uplink:
self._send_with_prefix(self.sid, 'PING %s %s' % (self.get_friendly_name(self.sid), self.get_friendly_name(self.uplink)))
def mode(self, numeric, target, modes, ts=None):
"""
Sends mode changes from a PyLink client/server. The mode list should be
a list of (mode, arg) tuples, i.e. the format of utils.parse_modes() output.
"""
# <- :unreal.midnight.vpn MODE #test +ntCo jlu5 1444361345
if (not self.is_internal_client(numeric)) and \
(not self.is_internal_server(numeric)):
raise LookupError('No such PyLink client/server exists.')
self.apply_modes(target, modes)
if self.is_channel(target):
modes = list(modes) # Needed for indexing
# Make sure we expand any PUIDs when sending outgoing modes...
for idx, mode in enumerate(modes):
if mode[0][-1] in self.prefixmodes:
log.debug('(%s) mode: expanding PUID of mode %s', self.name, str(mode))
modes[idx] = (mode[0], self._expandPUID(mode[1]))
# The MODE command is used for channel mode changes only
ts = ts or self._channels[target].ts
# 7 characters for "MODE", the space between MODE and the target, the space between the
# target and mode list, and the space between the mode list and TS.
bufsize = self.S2S_BUFSIZE - 7
# Subtract the length of the TS and channel arguments
bufsize -= len(str(ts))
bufsize -= len(target)
# Subtract the prefix (":SID " for servers or ":SIDAAAAAA " for servers)
bufsize -= (5 if self.is_internal_server(numeric) else 11)
# There is also an (undocumented) 15 args per line limit for MODE. The target, mode
# characters, and TS take up three args, so we're left with 12 spaces for parameters.
# Any lines that go over 15 args/line has the potential of corrupting a channel's TS
# pretty badly, as the last argument gets mangled into a number:
# * *** Warning! Possible desynch: MODE for channel #test ('+bbbbbbbbbbbb *!*@0.1 *!*@1.1 *!*@2.1 *!*@3.1 *!*@4.1 *!*@5.1 *!*@6.1 *!*@7.1 *!*@8.1 *!*@9.1 *!*@10.1 *!*@11.1') has fishy timestamp (12) (from pylink.local/pylink.local)
# Thanks to kevin and Jobe for helping me debug this!
for modestring in self.wrap_modes(modes, bufsize, max_modes_per_msg=12):
self._send_with_prefix(numeric, 'MODE %s %s %s' % (target, modestring, ts))
else:
# For user modes, the only way to set modes (for non-U:Lined servers)
# is through UMODE2, which sets the modes on the caller.
# U:Lines can use SVSMODE/SVS2MODE, but I won't expect people to
# U:Line a PyLink daemon...
if not self.is_internal_client(target):
raise ProtocolError('Cannot force mode change on external clients!')
# XXX: I don't expect usermode changes to ever get cut off, but length
# checks could be added just to be safe...
joinedmodes = self.join_modes(modes)
self._send_with_prefix(target, 'UMODE2 %s' % joinedmodes)
def oper_notice(self, source, text):
"""
Send a message to all opers.
"""
self._send_with_prefix(source, 'GLOBOPS :%s' % text)
def set_server_ban(self, source, duration, user='*', host='*', reason='User banned'):
"""
Sets a server ban.
"""
# Permanent:
# <- :unreal.midnight.vpn TKL + G ident host.net james!james@localhost 0 1500303745 :no reason
# Temporary:
# <- :unreal.midnight.vpn TKL + G * everyone james!james@localhost 1500303702 1500303672 :who needs reasons, do people even read them?
assert not (user == host == '*'), "Refusing to set ridiculous ban on *@*"
if source in self.users:
# GLINEs are always forwarded from the server as far as I can tell.
real_source = self.get_server(source)
else:
real_source = source
setter = self.get_hostmask(source) if source in self.users else self.get_friendly_name(source)
currtime = int(time.time())
self._send_with_prefix(real_source, 'TKL + G %s %s %s %s %s :%s' % (user, host, setter, currtime+duration if duration != 0 else 0, currtime, reason))
def update_client(self, target, field, text):
"""Updates the ident, host, or realname of any connected client."""
field = field.upper()
if field not in ('IDENT', 'HOST', 'REALNAME', 'GECOS'):
raise NotImplementedError("Changing field %r of a client is "
"unsupported by this protocol." % field)
if self.is_internal_client(target):
# It is one of our clients, use SETIDENT/HOST/NAME.
if field == 'IDENT':
self.users[target].ident = text
self._send_with_prefix(target, 'SETIDENT %s' % text)
elif field == 'HOST':
self.users[target].host = text
self._send_with_prefix(target, 'SETHOST %s' % text)
elif field in ('REALNAME', 'GECOS'):
self.users[target].realname = text
self._send_with_prefix(target, 'SETNAME :%s' % text)
else:
# It is a client on another server, use CHGIDENT/HOST/NAME.
if field == 'IDENT':
self.users[target].ident = text
self._send_with_prefix(self.sid, 'CHGIDENT %s %s' % (target, text))
# Send hook payloads for other plugins to listen to.
self.call_hooks([self.sid, 'CHGIDENT',
{'target': target, 'newident': text}])
elif field == 'HOST':
self.users[target].host = text
self._send_with_prefix(self.sid, 'CHGHOST %s %s' % (target, text))
self.call_hooks([self.sid, 'CHGHOST',
{'target': target, 'newhost': text}])
elif field in ('REALNAME', 'GECOS'):
self.users[target].realname = text
self._send_with_prefix(self.sid, 'CHGNAME %s :%s' % (target, text))
self.call_hooks([self.sid, 'CHGNAME',
{'target': target, 'newgecos': text}])
def kill(self, source, target, reason):
"""Sends a kill from a PyLink client or server."""
if (not self.is_internal_client(source)) and \
(not self.is_internal_server(source)):
raise LookupError('No such PyLink client/server exists.')
self._send_with_prefix(source, 'KILL %s :%s' % (target, reason))
self._remove_client(target)
def knock(self, numeric, target, text):
"""Sends a KNOCK from a PyLink client."""
# KNOCKs in UnrealIRCd are actually just specially formatted NOTICEs,
# sent to all ops in a channel.
# <- :unreal.midnight.vpn NOTICE @#test :[Knock] by jlu5|!jlu5@hidden-1C620195 (test)
assert self.is_channel(target), "Can only knock on channels!"
sender = self.get_server(numeric)
s = '[Knock] by %s (%s)' % (self.get_hostmask(numeric), text)
self._send_with_prefix(sender, 'NOTICE @%s :%s' % (target, s))
### HANDLERS
def post_connect(self):
"""Initializes a connection to a server."""
ts = self.start_ts
self.prefixmodes = {'q': '~', 'a': '&', 'o': '@', 'h': '%', 'v': '+'}
# Track usages of legacy (Unreal 3.2) nicks.
self.legacy_uidgen = PUIDGenerator('U32user')
f = self.send
host = self.serverdata["hostname"]
f('PASS :%s' % self.serverdata["sendpass"])
# https://github.com/unrealircd/unrealircd/blob/2f8cb55e/doc/technical/protoctl.txt
# We support the following protocol features:
# SJOIN - supports SJOIN for user introduction
# SJ3 - extended SJOIN
# NOQUIT - QUIT messages aren't sent for all users in a netsplit
# NICKv2 - Extended NICK command, sending MODE and CHGHOST info with it
# SID - Use UIDs and SIDs (Unreal 4)
# VL - Sends version string in below SERVER message
# UMODE2 - used for users setting modes on themselves (one less argument needed)
# EAUTH - Early auth? (Unreal 4 linking protocol)
# NICKIP - Extends the NICK command used for introduction (for Unreal 3.2 servers)
# to include user IPs.
# VHP - Sends cloaked hosts of UnrealIRCd 3.2 users as the hostname. This is important
# because UnrealIRCd 3.2 only has one vHost field in its NICK command, and not two
# like UnrealIRCd 4.0 (cloaked host + displayed host). Without VHP, cloaking does
# not work for any UnrealIRCd 3.2 users.
# ESVID - Supports account names in services stamps instead of just the signon time.
# AFAIK this doesn't actually affect services' behaviour?
# EXTSWHOIS - support multiple SWHOIS lines (purely informational for us)
f('PROTOCTL SJOIN SJ3 NOQUIT NICKv2 VL UMODE2 PROTOCTL NICKIP EAUTH=%s SID=%s VHP ESVID EXTSWHOIS' % (self.serverdata["hostname"], self.sid))
sdesc = self.serverdata.get('serverdesc') or conf.conf['pylink']['serverdesc']
f('SERVER %s 1 U%s-h6e-%s :%s' % (host, self.proto_ver, self.sid, sdesc))
self._send_with_prefix(self.sid, 'EOS')
# Extban definitions
self.extbans_acting = {'quiet': '~q:',
'ban_nonick': '~n:',
'ban_nojoins': '~j:',
'filter': '~T:block:',
'filter_censor': '~T:censor:',
'msgbypass_external': '~m:external:',
'msgbypass_censor': '~m:censor:',
'msgbypass_moderated': '~m:moderated:',
# These two sort of map to InspIRCd +e S: and +e T:
'ban_stripcolor': '~m:color:',
'ban_nonotice': '~m:notice:',
'timedban_unreal': '~t:'}
self.extbans_matching = {'ban_account': '~a:',
'ban_inchannel': '~c:',
'ban_opertype': '~O:',
'ban_realname': '~r:',
'ban_account_legacy': '~R:',
'ban_certfp': '~S:'}
def handle_eos(self, numeric, command, args):
"""EOS is used to denote end of burst."""
self.servers[numeric].has_eob = True
if numeric == self.uplink:
self.connected.set()
return {}
def handle_uid(self, numeric, command, args):
# <- :001 UID jlu5 0 1441306929 jlu5 localhost 0018S7901 0 +iowx * midnight-1C620195 fwAAAQ== :realname
# <- :001 UID jlu5| 0 1441389007 jlu5 10.120.0.6 001ZO8F03 0 +iwx * 391A9CB9.26A16454.D9847B69.IP CngABg== :realname
# arguments: nick, hopcount?, ts, ident, real-host, UID, services account (0 if none), modes,
# displayed host, cloaked (+x) host, base64-encoded IP, and realname
nick = args[0]
self._check_nick_collision(nick)
ts, ident, realhost, uid, accountname, modestring, host = args[2:9]
ts = int(ts)
if host == '*':
# A single * means that there is no displayed/virtual host, and
# that it's the same as the real host
host = args[9]
# Decode UnrealIRCd's IPs, which are stored in base64-encoded network structure
raw_ip = args[10].encode() # codecs.decode only takes bytes, not str
if raw_ip == b'*': # Dummy IP (for services, etc.)
ip = '0.0.0.0'
else:
# First, decode the Base64 string into a packed binary IP address.
ip = codecs.decode(raw_ip, "base64")
try: # IPv4 address.
ip = socket.inet_ntop(socket.AF_INET, ip)
except ValueError: # IPv6 address.
ip = socket.inet_ntop(socket.AF_INET6, ip)
# HACK: make sure a leading ":" in the IPv6 address (e.g. ::1)
# doesn't cause it to be misinterpreted as the last argument
# in a line, should it be mirrored to other networks.
if ip.startswith(':'):
ip = '0' + ip
realname = args[-1]
self.users[uid] = User(self, nick, ts, uid, numeric, ident, host, realname, realhost, ip)
self.servers[numeric].users.add(uid)
# Handle user modes
parsedmodes = self.parse_modes(uid, [modestring])
self.apply_modes(uid, parsedmodes)
# The cloaked (+x) host is completely separate from the displayed host
# and real host in that it is ONLY shown if the user is +x (cloak mode
# enabled) but NOT +t (vHost set).
self.users[uid].cloaked_host = args[9]
self._check_oper_status_change(uid, parsedmodes)
if ('+x', None) not in parsedmodes:
# If +x is not set, update to use the person's real host.
self.users[uid].host = realhost
# Set the account name if present: if this is a number, set it to the user nick.
if ('+r', None) in parsedmodes and accountname.isdigit():
accountname = nick
# Track SSL/TLS status
has_ssl = self.users[uid].ssl = ('+z', None) in parsedmodes
if not accountname.isdigit():
self.call_hooks([uid, 'CLIENT_SERVICES_LOGIN', {'text': accountname}])
# parse_as is used here to prevent legacy user introduction from being confused
# with a nick change.
return {'uid': uid, 'ts': ts, 'nick': nick, 'realhost': realhost, 'host': host,
'ident': ident, 'ip': ip, 'parse_as': 'UID', 'secure': has_ssl}
def handle_pass(self, numeric, command, args):
# <- PASS :abcdefg
if args[0] != self.serverdata['recvpass']:
raise ProtocolError("RECVPASS from uplink does not match configuration!")
def handle_ping(self, numeric, command, args):
if numeric == self.uplink:
self.send('PONG %s :%s' % (self.serverdata['hostname'], args[-1]), queue=False)
def handle_server(self, numeric, command, args):
"""Handles the SERVER command, which is used for both authentication and
introducing legacy (non-SID) servers."""
# <- SERVER unreal.midnight.vpn 1 :U3999-Fhin6OoEM UnrealIRCd test server
sname = args[0]
if self.uplink not in self.servers: # We're doing authentication
for cap in self.needed_caps:
if cap not in self.caps:
raise ProtocolError("Not all required capabilities were met "
"by the remote server. Your version of UnrealIRCd "
"is probably too old! (Got: %s, needed: %s)" %
(sorted(self.caps), sorted(self.needed_caps)))
sdesc = args[-1].split(" ", 1)
# Get our protocol version. I really don't know why the version and the server
# description aren't two arguments instead of one... -jlu5
vline = sdesc[0].split('-', 1)
sdesc = " ".join(sdesc[1:])
try:
protover = int(vline[0].strip('U'))
except ValueError:
raise ProtocolError("Protocol version too old! (needs at least %s "
"(Unreal 4.x), got something invalid; "
"is VL being sent?)" % self.min_proto_ver)
if protover < self.min_proto_ver:
raise ProtocolError("Protocol version too old! (needs at least %s "
"(Unreal 4.x), got %s)" % (self.min_proto_ver, protover))
self.servers[numeric] = Server(self, None, sname, desc=sdesc)
# Prior to 4203, Unreal did not send PROTOCTL USERMODES (see handle_protoctl() )
if protover < 4203:
self.umodes.update(self._KNOWN_UMODES)
self.umodes['*D'] = ''.join(self._KNOWN_UMODES.values())
else:
# Legacy (non-SID) servers can still be introduced using the SERVER command.
# <- :services.int SERVER a.bc 2 :(H) [jlu5] a
return super().handle_server(numeric, command, args)
def handle_protoctl(self, numeric, command, args):
"""Handles protocol negotiation."""
# Make a list of all our capability names.
self.caps += [arg.split('=')[0] for arg in args]
# Unreal 4.0.x:
# <- PROTOCTL NOQUIT NICKv2 SJOIN SJOIN2 UMODE2 VL SJ3 TKLEXT TKLEXT2 NICKIP ESVID
# <- PROTOCTL CHANMODES=beI,k,l,psmntirzMQNRTOVKDdGPZSCc NICKCHARS= SID=001 MLOCK TS=1441314501 EXTSWHOIS
# Unreal 4.2.x:
# <- PROTOCTL NOQUIT NICKv2 SJOIN SJOIN2 UMODE2 VL SJ3 TKLEXT TKLEXT2 NICKIP ESVID SJSBY
# <- PROTOCTL CHANMODES=beI,kLf,l,psmntirzMQNRTOVKDdGPZSCc USERMODES=iowrsxzdHtIDZRqpWGTSB BOOTED=1574014839 PREFIX=(qaohv)~&@%+ NICKCHARS= SID=001 MLOCK TS=1574020869 EXTSWHOIS
# Unreal 5.0.0-rc1:
# <- PROTOCTL NOQUIT NICKv2 SJOIN SJOIN2 UMODE2 VL SJ3 TKLEXT TKLEXT2 NICKIP ESVID SJSBY MTAGS
# <- PROTOCTL CHANMODES=beI,kLf,lH,psmntirzMQNRTOVKDdGPZSCc USERMODES=iowrsxzdHtIDZRqpWGTSB BOOTED=1574020755 PREFIX=(qaohv)~&@%+ SID=001 MLOCK TS=1574020823 EXTSWHOIS
# <- PROTOCTL NICKCHARS= CHANNELCHARS=utf8
for cap in args:
if cap.startswith('SID'):
self.uplink = cap.split('=', 1)[1]
elif cap.startswith('CHANMODES'):
# Parse all the supported channel modes.
supported_cmodes = cap.split('=', 1)[1]
self.cmodes['*A'], self.cmodes['*B'], self.cmodes['*C'], self.cmodes['*D'] = supported_cmodes.split(',')
for namedmode, modechar in self._KNOWN_CMODES.items():
if modechar in supported_cmodes:
self.cmodes[namedmode] = modechar
elif cap.startswith('USERMODES'): # Only for protover >= 4203
self.umodes['*D'] = supported_umodes = cap.split('=', 1)[1]
for namedmode, modechar in self._KNOWN_UMODES.items():
if modechar in supported_umodes:
self.umodes[namedmode] = modechar
# Add in the supported prefix modes.
self.cmodes.update({'halfop': 'h', 'admin': 'a', 'owner': 'q',
'op': 'o', 'voice': 'v'})
def handle_join(self, numeric, command, args):
"""Handles the UnrealIRCd JOIN command."""
# <- :jlu5 JOIN #pylink,#test
if args[0] == '0':
# /join 0; part the user from all channels
oldchans = self.users[numeric].channels.copy()
log.debug('(%s) Got /join 0 from %r, channel list is %r',
self.name, numeric, oldchans)
for ch in oldchans:
self._channels[ch].users.discard(numeric)
self.users[numeric].channels.discard(ch)
return {'channels': oldchans, 'text': 'Left all channels.', 'parse_as': 'PART'}
else:
for channel in args[0].split(','):
c = self._channels[channel]
self.users[numeric].channels.add(channel)
self._channels[channel].users.add(numeric)
# Call hooks manually, because one JOIN command in UnrealIRCd can
# have multiple channels...
self.call_hooks([numeric, command, {'channel': channel, 'users': [numeric], 'modes':
c.modes, 'ts': c.ts}])
def handle_sjoin(self, numeric, command, args):
"""Handles the UnrealIRCd SJOIN command."""
# <- :001 SJOIN 1444361345 #test :001AAAAAA @001AAAAAB +001AAAAAC
# <- :001 SJOIN 1483250129 #services +nt :+001OR9V02 @*~001DH6901 &*!*@test "*!*@blah.blah '*!*@yes.no
channel = args[1]
chandata = self._channels[channel].deepcopy()
userlist = args[-1].split()
namelist = []
log.debug('(%s) handle_sjoin: got userlist %r for %r', self.name, userlist, channel)
modestring = ''
# FIXME: Implement edge-case mode conflict handling as documented here:
# https://www.unrealircd.org/files/docs/technical/serverprotocol.html#S5_1
changedmodes = set()
parsedmodes = []
try:
if args[2].startswith('+'):
modestring = args[2:-1] or args[2]
# Strip extra spaces between the mode argument and the user list, if
# there are any. XXX: report this as a bug in unreal's s2s protocol?
modestring = [m for m in modestring if m]
parsedmodes = self.parse_modes(channel, modestring)
changedmodes = set(parsedmodes)
except IndexError:
pass
for userpair in userlist:
# &, ", and ' entries are used for bursting bans:
# https://www.unrealircd.org/files/docs/technical/serverprotocol.html#S5_1
if userpair.startswith("&"):
changedmodes.add(('+b', userpair[1:]))
elif userpair.startswith('"'):
changedmodes.add(('+e', userpair[1:]))
elif userpair.startswith("'"):
changedmodes.add(('+I', userpair[1:]))
else:
# Note: don't be too zealous in matching here or we'll break with nicks
# like "[abcd]".
r = re.search(r'([~*@%+]*)(.*)', userpair)
user = r.group(2)
if not user:
# Userpair with no user? Ignore. XXX: find out how this is even possible...
# <- :002 SJOIN 1486361658 #idlerpg :@
continue
user = self._get_UID(user) # Normalize nicks to UIDs for Unreal 3.2 links
if user not in self.users:
# Work around a potential race when sending kills on join
log.debug("(%s) Ignoring user %s in SJOIN to %s, they don't exist anymore", self.name, user, channel)
continue
# Unreal uses slightly different prefixes in SJOIN. +q is * instead of ~,
# and +a is ~ instead of &.
modeprefix = (r.group(1) or '').replace("~", "&").replace("*", "~")
finalprefix = ''
log.debug('(%s) handle_sjoin: got modeprefix %r for user %r', self.name, modeprefix, user)
for m in modeprefix:
# Iterate over the mapping of prefix chars to prefixes, and
# find the characters that match.
for char, prefix in self.prefixmodes.items():
if m == prefix:
finalprefix += char
namelist.append(user)
self.users[user].channels.add(channel)
# Only merge the remote's prefix modes if their TS is smaller or equal to ours.
changedmodes |= {('+%s' % mode, user) for mode in finalprefix}
self._channels[channel].users.add(user)
our_ts = self._channels[channel].ts
their_ts = int(args[0])
self.updateTS(numeric, channel, their_ts, changedmodes)
return {'channel': channel, 'users': namelist, 'modes': parsedmodes,
'ts': their_ts, 'channeldata': chandata}
def handle_nick(self, numeric, command, args):
"""Handles NICK changes, and legacy NICK introductions from pre-4.0 servers."""
if len(args) > 2:
# Handle legacy NICK introduction here.
# I don't want to rewrite all the user introduction stuff, so I'll just reorder the arguments
# so that handle_uid can handle this instead.
# But since legacy nicks don't have any UIDs attached, we'll have to store the users
# internally using pseudo UIDs. In other words, we need to convert from this:
# <- NICK Global 3 1456843578 services novernet.com services.novernet.com 0 +ioS * :Global Noticer
# & nick hopcount timestamp username hostname server service-identifier-token :realname
# With NICKIP and VHP enabled:
# <- NICK legacy32 2 1470699865 jlu5 localhost unreal32.midnight.vpn jlu5 +iowx hidden-1C620195 AAAAAAAAAAAAAAAAAAAAAQ== :realname
# to this:
# <- :001 UID jlu5 0 1441306929 jlu5 localhost 0018S7901 0 +iowx * hidden-1C620195 fwAAAQ== :realname
log.debug('(%s) got legacy NICK args: %s', self.name, ' '.join(args))
new_args = args[:] # Clone the old args list
servername = new_args[5].lower() # Get the name of the users' server.
# Fake a UID and put it where it belongs in the new-style UID command. These take the
# NICK@COUNTER, where COUNTER is an int starting at 0 and incremented every time a new
# user joins.
fake_uid = self.legacy_uidgen.next_uid(prefix=args[0])
new_args[5] = fake_uid
# This adds a dummy cloaked host (equal the real host) to put the displayed host in the
# right position. As long as the VHP capability is respected, this will propagate +x cloaked
# hosts from UnrealIRCd 3.2 users. Otherwise, +x host cloaking won't work!
new_args.insert(-2, args[4])
log.debug('(%s) translating legacy NICK args to: %s', self.name, ' '.join(new_args))
return self.handle_uid(servername, 'UID_LEGACY', new_args)
else:
# Normal NICK change, just let ts6_common handle it.
# :70MAAAAAA NICK jlu5-devel 1434744242
return super().handle_nick(numeric, command, args)
def handle_mode(self, numeric, command, args):
# <- :unreal.midnight.vpn MODE #test +bb test!*@* *!*@bad.net
# <- :unreal.midnight.vpn MODE #test +q jlu5 1444361345
# <- :unreal.midnight.vpn MODE #test +ntCo jlu5 1444361345
# <- :unreal.midnight.vpn MODE #test +mntClfo 5 [10t]:5 jlu5 1444361345
# <- :jlu5 MODE #services +v jlu5
# This seems pretty relatively inconsistent - why do some commands have a TS at the end while others don't?
# Answer: the first syntax (MODE sent by SERVER) is used for channel bursts - according to Unreal 3.2 docs,
# the last argument should be interpreted as a timestamp ONLY if it is a number and the sender is a server.
# Ban bursting does not give any TS, nor do normal users setting modes. SAMODE is special though, it will
# send 0 as a TS argument (which should be ignored unless breaking the internal channel TS is desired).
# Also, we need to get rid of that extra space following the +f argument. :|
if self.is_channel(args[0]):
channel = args[0]
oldobj = self._channels[channel].deepcopy()
modes = [arg for arg in args[1:] if arg] # normalize whitespace
parsedmodes = self.parse_modes(channel, modes)
if parsedmodes:
if parsedmodes[0][0] == '+&':
# UnrealIRCd uses a & virtual mode to denote mode bounces, meaning that an
# attempt to set modes by us was rejected for some reason (usually due to
# timestamps). Drop the mode change to prevent mode floods.
log.debug("(%s) Received mode bounce %s in channel %s! Our TS: %s",
self.name, modes, channel, self._channels[channel].ts)
return
self.apply_modes(channel, parsedmodes)
if numeric in self.servers and args[-1].isdigit():
# Sender is a server AND last arg is number. Perform TS updates.
their_ts = int(args[-1])
if their_ts > 0:
self.updateTS(numeric, channel, their_ts)
return {'target': channel, 'modes': parsedmodes, 'channeldata': oldobj}
else:
# User mode change
target = self._get_UID(args[0])
return self._handle_umode(target, self.parse_modes(target, args[1:]))
def _check_cloak_change(self, uid, parsedmodes):
"""
Checks whether +x/-x was set in the mode query, and changes the
hostname of the user given to or from their cloaked host if True.
"""
userobj = self.users[uid]
final_modes = userobj.modes
oldhost = userobj.host
if (('+x', None) in parsedmodes and ('t', None) not in final_modes) \
or (('-t', None) in parsedmodes and ('x', None) in final_modes):
# If either:
# 1) +x is being set, and the user does NOT have +t.
# 2) -t is being set, but the user has +x set already.
# We should update the user's host to their cloaked host and send
# out a hook payload saying that the host has changed.
newhost = userobj.host = userobj.cloaked_host
elif ('-x', None) in parsedmodes or ('-t', None) in parsedmodes:
# Otherwise, if either:
# 1) -x is being set.
# 2) -t is being set, but the person doesn't have +x set already.
# (the case where the person DOES have +x is handled above)
# Restore the person's host to the uncloaked real host.
newhost = userobj.host = userobj.realhost
else:
# Nothing changed, just return.
return
if newhost != oldhost:
# Only send a payload if the old and new hosts are different.
self.call_hooks([uid, 'SETHOST',
{'target': uid, 'newhost': newhost}])
def handle_svsmode(self, numeric, command, args):
"""Handles SVSMODE, used by services for setting user modes on others."""
# <- :source SVSMODE target +usermodes
target = self._get_UID(args[0])
return self._handle_umode(target, self.parse_modes(target, args[1:]))
def handle_svs2mode(self, sender, command, args):
"""
Handles SVS2MODE, which sets services login information on the given target.
"""
# In a nutshell: check for the +d argument: if it's an integer, ignore
# it and set the user's account name to their nick. Otherwise, treat the
# parameter as the new account name (this is known as logging in AS some account,
# which is supported by atheme and Anope 2.x).
# Logging in (with account info, atheme):
# <- :NickServ SVS2MODE jlu5 +rd jlu5
# Logging in (without account info, anope 2.0?):
# <- :NickServ SVS2MODE 001WCO6YK +r
# Logging in (without account info, anope 1.8):
# Note: ignore the timestamp.
# <- :services.abc.net SVS2MODE jlu5 +rd 1470696723
# Logging out (atheme):
# <- :NickServ SVS2MODE jlu5 -r+d 0
# Logging out (anope 1.8):
# <- :services.abc.net SVS2MODE jlu5 -r+d 1
# Logging out (anope 2.0):
# <- :NickServ SVS2MODE 009EWLA03 -r
# Logging in to account from a different nick (atheme):
# Note: no +r is being set.
# <- :NickServ SVS2MODE somenick +d jlu5
# Logging in to account from a different nick (anope):
# <- :NickServ SVS2MODE 001SALZ01 +d jlu5
# <- :NickServ SVS2MODE 001SALZ01 +r
target = self._get_UID(args[0])
parsedmodes = self.parse_modes(target, args[1:])
if ('+r', None) in parsedmodes:
# Umode +r is being set (log in)
try:
# Try to get the account name (mode argument for +d)
account = args[2]
except IndexError:
# If one doesn't exist, make it the same as the nick, but only if the account name
# wasn't set already.
if not self.users[target].services_account:
account = self.get_friendly_name(target)
else:
return
else:
if account.isdigit():
# If the +d argument is a number, ignore it and set the account name to the nick.
account = self.get_friendly_name(target)
elif ('-r', None) in parsedmodes:
# Umode -r being set.
if not self.users[target].services_account:
# User already has no account; ignore.
return
account = ''
elif ('+d', None) in parsedmodes:
# Nick identification status wasn't changed, but services account was.
account = args[2]
if account == '0': # +d 0 means logout
account = ''
else:
return
self.call_hooks([target, 'CLIENT_SERVICES_LOGIN', {'text': account}])
# The internal mode +d used for services stamps clashes with the DEAF mode, so don't parse it as
# an actual mode mode parsing.
return self._handle_umode(target, [mode for mode in parsedmodes if mode[0][-1] != 'd'])
def _handle_umode(self, target, parsedmodes):
"""Internal helper function to parse umode changes."""
if not parsedmodes:
return
self.apply_modes(target, parsedmodes)
self._check_oper_status_change(target, parsedmodes)
self._check_cloak_change(target, parsedmodes)
return {'target': target, 'modes': parsedmodes}
def handle_umode2(self, source, command, args):
"""Handles UMODE2, used to set user modes on oneself."""
# <- :jlu5 UMODE2 +W
target = self._get_UID(source)
return self._handle_umode(target, self.parse_modes(target, args))
def handle_topic(self, numeric, command, args):
"""Handles the TOPIC command."""
# <- jlu5 TOPIC #services jlu5 1444699395 :weeee
# <- TOPIC #services devel.relay 1452399682 :test
channel = args[0]
topic = args[-1]
setter = args[1]
ts = args[2]
oldtopic = self._channels[channel].topic
self._channels[channel].topic = topic
self._channels[channel].topicset = True
return {'channel': channel, 'setter': setter, 'ts': ts, 'text': topic,
'oldtopic': oldtopic}
def handle_setident(self, numeric, command, args):
"""Handles SETIDENT, used for self ident changes."""
# <- :70MAAAAAB SETIDENT test
self.users[numeric].ident = newident = args[0]
return {'target': numeric, 'newident': newident}
def handle_sethost(self, numeric, command, args):
"""Handles CHGHOST, used for self hostname changes."""
# <- :70MAAAAAB SETIDENT some.host
self.users[numeric].host = newhost = args[0]
# When SETHOST or CHGHOST is used, modes +xt are implicitly set on the
# target.
self.apply_modes(numeric, [('+x', None), ('+t', None)])
return {'target': numeric, 'newhost': newhost}
def handle_setname(self, numeric, command, args):
"""Handles SETNAME, used for self real name/gecos changes."""
# <- :70MAAAAAB SETNAME :afdsafasf
self.users[numeric].realname = newgecos = args[0]
return {'target': numeric, 'newgecos': newgecos}
def handle_chgident(self, source, command, args):
"""Handles CHGIDENT, used for denoting ident changes."""
# <- :jlu5 CHGIDENT jlu5 test
target = self._get_UID(args[0])
# Bounce attempts to change fields of protected PyLink clients
if self.is_internal_client(target):
log.warning("(%s) Bouncing attempt from %s to change ident of PyLink client %s",
self.name, self.get_friendly_name(source), self.get_friendly_name(target))
self.update_client(target, 'IDENT', self.users[target].ident)
return
self.users[target].ident = newident = args[1]
return {'target': target, 'newident': newident}
def handle_chghost(self, source, command, args):
"""Handles CHGHOST, used for denoting hostname changes."""
# <- :jlu5 CHGHOST jlu5 some.host
target = self._get_UID(args[0])
# Bounce attempts to change fields of protected PyLink clients
if self.is_internal_client(target):
log.warning("(%s) Bouncing attempt from %s to change host of PyLink client %s",
self.name, self.get_friendly_name(source), self.get_friendly_name(target))
self.update_client(target, 'HOST', self.users[target].host)
return
self.users[target].host = newhost = args[1]
# When SETHOST or CHGHOST is used, modes +xt are implicitly set on the
# target.
self.apply_modes(target, [('+x', None), ('+t', None)])
return {'target': target, 'newhost': newhost}
def handle_chgname(self, source, command, args):
"""Handles CHGNAME, used for denoting real name/gecos changes."""
# <- :jlu5 CHGNAME jlu5 :afdsafasf
target = self._get_UID(args[0])
# Bounce attempts to change fields of protected PyLink clients
if self.is_internal_client(target):
log.warning("(%s) Bouncing attempt from %s to change gecos of PyLink client %s",
self.name, self.get_friendly_name(source), self.get_friendly_name(target))
self.update_client(target, 'REALNAME', self.users[target].realname)
return
self.users[target].realname = newgecos = args[1]
return {'target': target, 'newgecos': newgecos}
def handle_tsctl(self, source, command, args):
"""Handles /TSCTL alltime requests."""
# <- :jlu5 TSCTL alltime
if args[0] == 'alltime':
self._send_with_prefix(self.sid, 'NOTICE %s :*** Server=%s time()=%d' % (source, self.hostname(), time.time()))
Class = UnrealProtocol