mirror of
https://github.com/jlu5/PyLink.git
synced 2024-11-01 17:29:21 +01:00
4f0ca6367c
len(":SIDAAAAAA ") is 11, not 9
950 lines
44 KiB
Python
950 lines
44 KiB
Python
"""
|
|
unreal.py: UnrealIRCd 4.0 protocol module for PyLink.
|
|
"""
|
|
|
|
import time
|
|
import codecs
|
|
import socket
|
|
import re
|
|
|
|
from pylinkirc import utils
|
|
from pylinkirc.classes import *
|
|
from pylinkirc.log import log
|
|
from pylinkirc.protocols.ts6_common import *
|
|
|
|
SJOIN_PREFIXES = {'q': '*', 'a': '~', 'o': '@', 'h': '%', 'v': '+', 'b': '&', 'e': '"', 'I': "'"}
|
|
|
|
# I'm not sure what the real limit is, but the text posted at
|
|
# https://github.com/GLolol/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*?) -GL
|
|
S2S_BUFSIZE = 427
|
|
|
|
class UnrealProtocol(TS6BaseProtocol):
|
|
def __init__(self, irc):
|
|
super().__init__(irc)
|
|
# Set our case mapping (rfc1459 maps "\" and "|" together, for example)
|
|
self.casemapping = 'ascii'
|
|
self.proto_ver = 4000
|
|
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.irc.prefixmodes = {'q': '~', 'a': '&', 'o': '@', 'h': '%', 'v': '+'}
|
|
|
|
self.needed_caps = ["VL", "SID", "CHANMODES", "NOQUIT", "SJ3", "NICKIP", "UMODE2", "SJOIN"]
|
|
|
|
# Some command aliases
|
|
self.handle_svskill = self.handle_kill
|
|
|
|
def _expandPUID(self, uid):
|
|
"""
|
|
Returns the outgoing nick for the given UID. For PUIDs (used to store UID-less
|
|
3.2 users), this will change the PUID given to the actual user's nick,
|
|
so that that the older IRCds can understand it.
|
|
"""
|
|
if uid in self.irc.users and '@' in uid:
|
|
# UID exists and has a @ in it, meaning it's a PUID (orignick@counter style).
|
|
# Return this user's nick accordingly.
|
|
nick = self.irc.users[uid].nick
|
|
log.debug('(%s) Mangling target PUID %s to nick %s', self.irc.name, uid, nick)
|
|
return nick
|
|
return uid
|
|
|
|
### OUTGOING COMMAND FUNCTIONS
|
|
def spawnClient(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.irc.sid
|
|
if not self.irc.isInternalServer(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 self.irc.botdata['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.irc.joinModes(modes)
|
|
u = self.irc.users[uid] = IrcUser(nick, ts, uid, server, ident=ident, host=host, realname=realname,
|
|
realhost=realhost, ip=ip, manipulatable=manipulatable, opertype=opertype)
|
|
self.irc.applyModes(uid, modes)
|
|
self.irc.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 GL 0 1441306929 gl localhost 0018S7901 0 +iowx * midnight-1C620195 fwAAAQ== :realname
|
|
self._send(server, "UID {nick} 0 {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))
|
|
|
|
return u
|
|
|
|
def join(self, client, channel):
|
|
"""Joins a PyLink client to a channel."""
|
|
channel = self.irc.toLower(channel)
|
|
if not self.irc.isInternalClient(client):
|
|
raise LookupError('No such PyLink client exists.')
|
|
self._send(client, "JOIN %s" % channel)
|
|
self.irc.channels[channel].users.add(client)
|
|
self.irc.users[client].channels.add(channel)
|
|
|
|
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.irc.sid, '#test', [('o', self.irc.pseudoclient.uid)])
|
|
"""
|
|
# <- :001 SJOIN 1444361345 #test :*@+1JJAAAAAB %2JJAAAA4C 1JJAAAADS
|
|
channel = self.irc.toLower(channel)
|
|
server = server or self.irc.sid
|
|
assert users, "sjoin: No users sent?"
|
|
if not server:
|
|
raise LookupError('No such PyLink server exists.')
|
|
|
|
changedmodes = set(modes or self.irc.channels[channel].modes)
|
|
orig_ts = self.irc.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.irc.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.irc.name, channel, user)
|
|
|
|
# Track simple modes separately.
|
|
simplemodes = set()
|
|
for modepair in modes:
|
|
if modepair[0][-1] in self.irc.cmodes['*A']:
|
|
# Bans, exempts, invex get expanded to forms like "&*!*@some.host" in SJOIN.
|
|
|
|
if (modepair[0][-1], modepair[1]) in self.irc.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.irc.joinModes(simplemodes)
|
|
|
|
sjoin_prefix += " :"
|
|
# Wrap arguments to the max supported S2S line length to prevent cutoff
|
|
# (https://github.com/GLolol/PyLink/issues/378)
|
|
for line in utils.wrapArguments(sjoin_prefix, itemlist, S2S_BUFSIZE):
|
|
self.irc.send(line)
|
|
|
|
self.irc.channels[channel].users.update(uids)
|
|
|
|
self.updateTS(server, channel, ts, changedmodes)
|
|
|
|
def ping(self, source=None, target=None):
|
|
"""Sends a PING to a target server. Periodic PINGs are sent to our uplink
|
|
automatically by the Irc() internals; plugins shouldn't have to use this."""
|
|
source = source or self.irc.sid
|
|
target = target or self.irc.uplink
|
|
if not (target is None or source is None):
|
|
self._send(source, 'PING %s %s' % (self.irc.servers[source].name, self.irc.servers[target].name))
|
|
|
|
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.parseModes() output.
|
|
"""
|
|
# <- :unreal.midnight.vpn MODE #test +ntCo GL 1444361345
|
|
|
|
if (not self.irc.isInternalClient(numeric)) and \
|
|
(not self.irc.isInternalServer(numeric)):
|
|
raise LookupError('No such PyLink client/server exists.')
|
|
|
|
self.irc.applyModes(target, modes)
|
|
|
|
if utils.isChannel(target):
|
|
# The MODE command is used for channel mode changes only
|
|
ts = ts or self.irc.channels[self.irc.toLower(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 = 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.irc.isInternalServer(numeric) else 11)
|
|
|
|
for modestring in self.irc.wrapModes(modes, bufsize):
|
|
self._send(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.irc.isInternalClient(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.irc.joinModes(modes)
|
|
self._send(target, 'UMODE2 %s' % joinedmodes)
|
|
|
|
def topicBurst(self, numeric, target, text):
|
|
"""Sends a TOPIC change from a PyLink server."""
|
|
if not self.irc.isInternalServer(numeric):
|
|
raise LookupError('No such PyLink server exists.')
|
|
self._send(numeric, 'TOPIC %s :%s' % (target, text))
|
|
self.irc.channels[target].topic = text
|
|
self.irc.channels[target].topicset = True
|
|
|
|
def updateClient(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.irc.isInternalClient(target):
|
|
# It is one of our clients, use SETIDENT/HOST/NAME.
|
|
if field == 'IDENT':
|
|
self.irc.users[target].ident = text
|
|
self._send(target, 'SETIDENT %s' % text)
|
|
elif field == 'HOST':
|
|
self.irc.users[target].host = text
|
|
self._send(target, 'SETHOST %s' % text)
|
|
elif field in ('REALNAME', 'GECOS'):
|
|
self.irc.users[target].realname = text
|
|
self._send(target, 'SETNAME :%s' % text)
|
|
else:
|
|
# It is a client on another server, use CHGIDENT/HOST/NAME.
|
|
if field == 'IDENT':
|
|
self.irc.users[target].ident = text
|
|
self._send(self.irc.sid, 'CHGIDENT %s %s' % (target, text))
|
|
|
|
# Send hook payloads for other plugins to listen to.
|
|
self.irc.callHooks([self.irc.sid, 'CHGIDENT',
|
|
{'target': target, 'newident': text}])
|
|
|
|
elif field == 'HOST':
|
|
self.irc.users[target].host = text
|
|
self._send(self.irc.sid, 'CHGHOST %s %s' % (target, text))
|
|
|
|
self.irc.callHooks([self.irc.sid, 'CHGHOST',
|
|
{'target': target, 'newhost': text}])
|
|
|
|
elif field in ('REALNAME', 'GECOS'):
|
|
self.irc.users[target].realname = text
|
|
self._send(self.irc.sid, 'CHGNAME %s :%s' % (target, text))
|
|
|
|
self.irc.callHooks([self.irc.sid, 'CHGNAME',
|
|
{'target': target, 'newgecos': text}])
|
|
|
|
def invite(self, numeric, target, channel):
|
|
"""Sends an INVITE from a PyLink client.."""
|
|
if not self.irc.isInternalClient(numeric):
|
|
raise LookupError('No such PyLink client exists.')
|
|
self._send(numeric, 'INVITE %s %s' % (target, channel))
|
|
|
|
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 GL|!gl@hidden-1C620195 (test)
|
|
assert utils.isChannel(target), "Can only knock on channels!"
|
|
sender = self.irc.getServer(numeric)
|
|
s = '[Knock] by %s (%s)' % (self.irc.getHostmask(numeric), text)
|
|
self._send(sender, 'NOTICE @%s :%s' % (target, s))
|
|
|
|
### HANDLERS
|
|
|
|
def connect(self):
|
|
"""Initializes a connection to a server."""
|
|
ts = self.irc.start_ts
|
|
self.irc.prefixmodes = {'q': '~', 'a': '&', 'o': '@', 'h': '%', 'v': '+'}
|
|
|
|
# Track usages of legacy (Unreal 3.2) nicks.
|
|
self.legacy_uidgen = utils.PUIDGenerator('U32user')
|
|
|
|
self.irc.umodes.update({'deaf': 'd', 'invisible': 'i', 'hidechans': 'p',
|
|
'protected': 'q', 'registered': 'r',
|
|
'snomask': 's', 'vhost': 't', 'wallops': 'w',
|
|
'bot': 'B', 'cloak': 'x', 'ssl': 'z',
|
|
'filter': 'G', 'hideoper': 'H', 'hideidle': 'I',
|
|
'regdeaf': 'R', 'servprotect': 'S',
|
|
'noctcp': 'T', 'showwhois': 'W',
|
|
'*A': '', '*B': '', '*C': '', '*D': 'dipqrstwBxzGHIRSTW'})
|
|
|
|
f = self.irc.send
|
|
host = self.irc.serverdata["hostname"]
|
|
|
|
f('PASS :%s' % self.irc.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?
|
|
f('PROTOCTL SJOIN SJ3 NOQUIT NICKv2 VL UMODE2 PROTOCTL NICKIP EAUTH=%s SID=%s VHP ESVID' % (self.irc.serverdata["hostname"], self.irc.sid))
|
|
sdesc = self.irc.serverdata.get('serverdesc') or self.irc.botdata['serverdesc']
|
|
f('SERVER %s 1 U%s-h6e-%s :%s' % (host, self.proto_ver, self.irc.sid, sdesc))
|
|
f('NETINFO 1 %s %s * 0 0 0 :%s' % (self.irc.start_ts, self.proto_ver, self.irc.serverdata.get("netname", self.irc.name)))
|
|
self._send(self.irc.sid, 'EOS')
|
|
|
|
def handle_eos(self, numeric, command, args):
|
|
"""EOS is used to denote end of burst."""
|
|
return {}
|
|
|
|
def handle_uid(self, numeric, command, args):
|
|
# <- :001 UID GL 0 1441306929 gl localhost 0018S7901 0 +iowx * midnight-1C620195 fwAAAQ== :realname
|
|
# <- :001 UID GL| 0 1441389007 gl 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.checkCollision(nick)
|
|
ts, ident, realhost, uid, accountname, modestring, host = args[2:9]
|
|
|
|
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.irc.users[uid] = IrcUser(nick, ts, uid, numeric, ident, host, realname, realhost, ip)
|
|
self.irc.servers[numeric].users.add(uid)
|
|
|
|
# Handle user modes
|
|
parsedmodes = self.irc.parseModes(uid, [modestring])
|
|
self.irc.applyModes(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.irc.users[uid].cloaked_host = args[9]
|
|
|
|
if ('+o', None) in parsedmodes:
|
|
# If +o being set, call the CLIENT_OPERED internal hook.
|
|
self.irc.callHooks([uid, 'CLIENT_OPERED', {'text': 'IRC Operator'}])
|
|
|
|
if ('+x', None) not in parsedmodes:
|
|
# If +x is not set, update to use the person's real host.
|
|
self.irc.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
|
|
|
|
if not accountname.isdigit():
|
|
self.irc.callHooks([uid, 'CLIENT_SERVICES_LOGIN', {'text': accountname}])
|
|
|
|
return {'uid': uid, 'ts': ts, 'nick': nick, 'realhost': realhost, 'host': host, 'ident': ident, 'ip': ip}
|
|
|
|
def handle_pass(self, numeric, command, args):
|
|
# <- PASS :abcdefg
|
|
if args[0] != self.irc.serverdata['recvpass']:
|
|
raise ProtocolError("Error: RECVPASS from uplink does not match configuration!")
|
|
|
|
def handle_ping(self, numeric, command, args):
|
|
if numeric == self.irc.uplink:
|
|
self.irc.send('PONG %s :%s' % (self.irc.serverdata['hostname'], args[-1]))
|
|
|
|
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 numeric == self.irc.uplink and not self.irc.connected.is_set(): # 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... -GLolol
|
|
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.irc.servers[numeric] = IrcServer(None, sname, desc=sdesc)
|
|
|
|
# Set irc.connected to True, meaning that protocol negotiation passed.
|
|
log.debug('(%s) self.irc.connected set!', self.irc.name)
|
|
self.irc.connected.set()
|
|
else:
|
|
# Legacy (non-SID) servers can still be introduced using the SERVER command.
|
|
# <- :services.int SERVER a.bc 2 :(H) [GL] a
|
|
servername = args[0].lower()
|
|
sdesc = args[-1]
|
|
self.irc.servers[servername] = IrcServer(numeric, servername, desc=sdesc)
|
|
return {'name': servername, 'sid': None, 'text': sdesc}
|
|
|
|
def handle_sid(self, numeric, command, args):
|
|
"""Handles the SID command, used for introducing remote servers by our uplink."""
|
|
# <- SID services.int 2 00A :Shaltúre IRC Services
|
|
sname = args[0].lower()
|
|
sid = args[2]
|
|
sdesc = args[-1]
|
|
self.irc.servers[sid] = IrcServer(numeric, sname, desc=sdesc)
|
|
return {'name': sname, 'sid': sid, 'text': sdesc}
|
|
|
|
def handle_squit(self, numeric, command, args):
|
|
"""Handles the SQUIT command."""
|
|
# <- SQUIT services.int :Read error
|
|
# Convert the server name to a SID...
|
|
args[0] = self._getSid(args[0])
|
|
# Then, use the SQUIT handler in TS6BaseProtocol as usual.
|
|
return super().handle_squit(numeric, 'SQUIT', args)
|
|
|
|
def handle_protoctl(self, numeric, command, args):
|
|
"""Handles protocol negotiation."""
|
|
|
|
cmodes = {'noknock': 'K', 'limit': 'l', 'registered': 'r', 'flood_unreal': 'f',
|
|
'censor': 'G', 'noextmsg': 'n', 'invex': 'I', 'permanent': 'P',
|
|
'sslonly': 'z', 'operonly': 'O', 'moderated': 'm', 'blockcolor': 'c',
|
|
'regmoderated': 'M', 'noctcp': 'C', 'secret': 's', 'ban': 'b',
|
|
'nokick': 'Q', 'private': 'p', 'stripcolor': 'S', 'key': 'k',
|
|
'op': 'o', 'voice': 'v', 'regonly': 'R', 'noinvite': 'V',
|
|
'banexception': 'e', 'nonick': 'N', 'issecure': 'Z', 'topiclock': 't',
|
|
'nonotice': 'T', 'delayjoin': 'D', 'inviteonly': 'i'}
|
|
|
|
# Make a list of all our capability names.
|
|
self.caps += [arg.split('=')[0] for arg in args]
|
|
|
|
# <- 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
|
|
for cap in args:
|
|
if cap.startswith('SID'):
|
|
self.irc.uplink = cap.split('=', 1)[1]
|
|
elif cap.startswith('CHANMODES'):
|
|
# Parse all the supported channel modes.
|
|
supported_cmodes = cap.split('=', 1)[1]
|
|
self.irc.cmodes['*A'], self.irc.cmodes['*B'], self.irc.cmodes['*C'], self.irc.cmodes['*D'] = supported_cmodes.split(',')
|
|
for namedmode, modechar in cmodes.items():
|
|
if modechar in supported_cmodes:
|
|
self.irc.cmodes[namedmode] = modechar
|
|
self.irc.cmodes['*B'] += 'f' # Add +f to the list too, dunno why it isn't there.
|
|
|
|
# Add in the supported prefix modes.
|
|
self.irc.cmodes.update({'halfop': 'h', 'admin': 'a', 'owner': 'q',
|
|
'op': 'o', 'voice': 'v'})
|
|
|
|
def handle_privmsg(self, source, command, args):
|
|
# Convert nicks to UIDs, where they exist.
|
|
target = self._getUid(args[0])
|
|
# We use lowercase channels internally, but uppercase UIDs.
|
|
if utils.isChannel(target):
|
|
target = self.irc.toLower(target)
|
|
return {'target': target, 'text': args[1]}
|
|
handle_notice = handle_privmsg
|
|
|
|
def handle_join(self, numeric, command, args):
|
|
"""Handles the UnrealIRCd JOIN command."""
|
|
# <- :GL JOIN #pylink,#test
|
|
if args[0] == '0':
|
|
# /join 0; part the user from all channels
|
|
oldchans = self.irc.users[numeric].channels.copy()
|
|
log.debug('(%s) Got /join 0 from %r, channel list is %r',
|
|
self.irc.name, numeric, oldchans)
|
|
for ch in oldchans:
|
|
self.irc.channels[ch].users.discard(numeric)
|
|
self.irc.users[numeric].channels.discard(ch)
|
|
return {'channels': oldchans, 'text': 'Left all channels.', 'parse_as': 'PART'}
|
|
|
|
else:
|
|
for channel in args[0].split(','):
|
|
# Normalize channel case.
|
|
channel = self.irc.toLower(channel)
|
|
|
|
c = self.irc.channels[channel]
|
|
|
|
self.irc.users[numeric].channels.add(channel)
|
|
self.irc.channels[channel].users.add(numeric)
|
|
# Call hooks manually, because one JOIN command in UnrealIRCd can
|
|
# have multiple channels...
|
|
self.irc.callHooks([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 = self.irc.toLower(args[1])
|
|
chandata = self.irc.channels[channel].deepcopy()
|
|
userlist = args[-1].split()
|
|
|
|
namelist = []
|
|
log.debug('(%s) handle_sjoin: got userlist %r for %r', self.irc.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.irc.parseModes(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:
|
|
r = re.search(r'([^\w]*)(.*)', userpair)
|
|
user = r.group(2)
|
|
user = self._getUid(user) # Normalize nicks to UIDs for Unreal 3.2 links
|
|
# Unreal uses slightly different prefixes in SJOIN. +q is * instead of ~,
|
|
# and +a is ~ instead of &.
|
|
modeprefix = (r.group(1) or '').replace("~", "&").replace("*", "~")
|
|
finalprefix = ''
|
|
assert user, 'Failed to get the UID from %r; our regex needs updating?' % userpair
|
|
log.debug('(%s) handle_sjoin: got modeprefix %r for user %r', self.irc.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.irc.prefixmodes.items():
|
|
if m == prefix:
|
|
finalprefix += char
|
|
namelist.append(user)
|
|
self.irc.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.irc.channels[channel].users.add(user)
|
|
|
|
our_ts = self.irc.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 GL32 2 1470699865 gl localhost unreal32.midnight.vpn GL +iowx hidden-1C620195 AAAAAAAAAAAAAAAAAAAAAQ== :realname
|
|
# to this:
|
|
# <- :001 UID GL 0 1441306929 gl localhost 0018S7901 0 +iowx * hidden-1C620195 fwAAAQ== :realname
|
|
log.debug('(%s) got legacy NICK args: %s', self.irc.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.irc.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 GL-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 GL 1444361345
|
|
# <- :unreal.midnight.vpn MODE #test +ntCo GL 1444361345
|
|
# <- :unreal.midnight.vpn MODE #test +mntClfo 5 [10t]:5 GL 1444361345
|
|
# <- :GL MODE #services +v GL
|
|
|
|
# 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 utils.isChannel(args[0]):
|
|
channel = self.irc.toLower(args[0])
|
|
oldobj = self.irc.channels[channel].deepcopy()
|
|
|
|
modes = [arg for arg in args[1:] if arg] # normalize whitespace
|
|
parsedmodes = self.irc.parseModes(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.irc.name, modes, channel, self.irc.channels[channel].ts)
|
|
return
|
|
|
|
self.irc.applyModes(channel, parsedmodes)
|
|
|
|
if numeric in self.irc.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: pass those on to handle_umode2()
|
|
self.handle_umode2(numeric, 'MODE', args[1:])
|
|
|
|
def checkCloakChange(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.irc.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.irc.callHooks([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._getUid(args[0])
|
|
modes = args[1:]
|
|
|
|
parsedmodes = self.irc.parseModes(target, modes)
|
|
self.irc.applyModes(target, parsedmodes)
|
|
|
|
# If +x/-x is being set, update cloaked host info.
|
|
self.checkCloakChange(target, parsedmodes)
|
|
|
|
return {'target': target, 'modes': parsedmodes}
|
|
|
|
def handle_svs2mode(self, sender, command, args):
|
|
"""
|
|
Handles SVS2MODE, which sets services login information on the given target.
|
|
"""
|
|
# Once again this syntax is inconsistent and poorly documented. +d sets a
|
|
# "services stamp" that some services packages use as an account name field,
|
|
# while others simply use for tracking the login time? In a nutshell: check
|
|
# for the +d argument: if it's an integer, ignore it and set accountname to
|
|
# the user's nick. Otherwise, treat the parameter as a nick.
|
|
|
|
# Logging in (with account info, atheme):
|
|
# <- :NickServ SVS2MODE GL +rd GL
|
|
|
|
# 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 GLolol +rd 1470696723
|
|
|
|
# Logging out (atheme):
|
|
# <- :NickServ SVS2MODE GL -r+d 0
|
|
|
|
# Logging out (anope 1.8):
|
|
# <- :services.abc.net SVS2MODE GLolol -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 GL
|
|
|
|
# Logging in to account from a different nick (anope):
|
|
# <- :NickServ SVS2MODE 001SALZ01 +d GL
|
|
# <- :NickServ SVS2MODE 001SALZ01 +r
|
|
|
|
target = self._getUid(args[0])
|
|
parsedmodes = self.irc.parseModes(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.irc.users[target].services_account:
|
|
account = self.irc.getFriendlyName(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.irc.getFriendlyName(target)
|
|
|
|
elif ('-r', None) in parsedmodes:
|
|
# Umode -r being set.
|
|
|
|
if not self.irc.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.irc.callHooks([target, 'CLIENT_SERVICES_LOGIN', {'text': account}])
|
|
|
|
def handle_umode2(self, numeric, command, args):
|
|
"""Handles UMODE2, used to set user modes on oneself."""
|
|
# <- :GL UMODE2 +W
|
|
parsedmodes = self.irc.parseModes(numeric, args)
|
|
self.irc.applyModes(numeric, parsedmodes)
|
|
|
|
if ('+o', None) in parsedmodes:
|
|
# If +o being set, call the CLIENT_OPERED internal hook.
|
|
self.irc.callHooks([numeric, 'CLIENT_OPERED', {'text': 'IRC Operator'}])
|
|
|
|
self.checkCloakChange(numeric, parsedmodes)
|
|
|
|
return {'target': numeric, 'modes': parsedmodes}
|
|
|
|
def handle_topic(self, numeric, command, args):
|
|
"""Handles the TOPIC command."""
|
|
# <- GL TOPIC #services GL 1444699395 :weeee
|
|
# <- TOPIC #services devel.relay 1452399682 :test
|
|
channel = self.irc.toLower(args[0])
|
|
topic = args[-1]
|
|
setter = args[1]
|
|
ts = args[2]
|
|
|
|
oldtopic = self.irc.channels[channel].topic
|
|
self.irc.channels[channel].topic = topic
|
|
self.irc.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.irc.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.irc.users[numeric].host = newhost = args[0]
|
|
|
|
# When SETHOST or CHGHOST is used, modes +xt are implicitly set on the
|
|
# target.
|
|
self.irc.applyModes(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.irc.users[numeric].realname = newgecos = args[0]
|
|
return {'target': numeric, 'newgecos': newgecos}
|
|
|
|
def handle_chgident(self, numeric, command, args):
|
|
"""Handles CHGIDENT, used for denoting ident changes."""
|
|
# <- :GL CHGIDENT GL test
|
|
target = self._getUid(args[0])
|
|
self.irc.users[target].ident = newident = args[1]
|
|
return {'target': target, 'newident': newident}
|
|
|
|
def handle_chghost(self, numeric, command, args):
|
|
"""Handles CHGHOST, used for denoting hostname changes."""
|
|
# <- :GL CHGHOST GL some.host
|
|
target = self._getUid(args[0])
|
|
self.irc.users[target].host = newhost = args[1]
|
|
|
|
# When SETHOST or CHGHOST is used, modes +xt are implicitly set on the
|
|
# target.
|
|
self.irc.applyModes(target, [('+x', None), ('+t', None)])
|
|
|
|
return {'target': target, 'newhost': newhost}
|
|
|
|
def handle_chgname(self, numeric, command, args):
|
|
"""Handles CHGNAME, used for denoting real name/gecos changes."""
|
|
# <- :GL CHGNAME GL :afdsafasf
|
|
target = self._getUid(args[0])
|
|
self.irc.users[target].realname = newgecos = args[1]
|
|
return {'target': target, 'newgecos': newgecos}
|
|
|
|
def handle_invite(self, numeric, command, args):
|
|
"""Handles incoming INVITEs."""
|
|
# <- :GL INVITE PyLink-devel :#a
|
|
target = self._getUid(args[0])
|
|
channel = self.irc.toLower(args[1])
|
|
# We don't actually need to process this; it's just something plugins/hooks can use
|
|
return {'target': target, 'channel': channel}
|
|
|
|
def handle_kill(self, numeric, command, args):
|
|
"""Handles incoming KILLs."""
|
|
# <- :GL| KILL GLolol :hidden-1C620195!GL| (test)
|
|
# Use ts6_common's handle_kill, but coerse UIDs to nicks first.
|
|
|
|
new_args = [self._getUid(args[0])]
|
|
new_args.extend(args[1:])
|
|
|
|
return super().handle_kill(numeric, command, new_args)
|
|
|
|
def handle_tsctl(self, source, command, args):
|
|
"""Handles /TSCTL alltime requests."""
|
|
# <- :GL TSCTL alltime
|
|
|
|
if args[0] == 'alltime':
|
|
# XXX: We override notice() here because that abstraction doesn't allow messages from servers.
|
|
self._send(self.irc.sid, 'NOTICE %s :*** Server=%s time()=%d' % (source, self.irc.hostname(), time.time()))
|
|
|
|
Class = UnrealProtocol
|