Retool accessbot for OFTC

A number of changes are needed to fit accessbot to OFTC's RBAC-style
permissions model and services syntax expectations. Most
importantly, access list entries now use role names for graduated
access tiers (member, chanop, master) rather than fine-grained
option flags. In order to avoid future confusion, switch variable
names and configuration keys to reflect that these are access levels
rather than masks.

While we're at it, skip setting the channel mlock if the result
would be a no-op, so that we don't unnecessarily spam the ircd with
pointless writes.

Also add a bunch of inline comments so I can more easily remember
the subtle nuances I spent a lot of time figuring out.

Change-Id: Id11598fc42672359e1abef7b70cc23100b16ab12
Depends-on: https://review.opendev.org/792843
This commit is contained in:
Jeremy Stanley 2021-05-25 21:06:20 +00:00
parent 88139ef622
commit 258e8e8585
1 changed files with 77 additions and 81 deletions

View File

@ -34,7 +34,6 @@ class SetAccess(irc.client.SimpleIRCClient):
def __init__(self, config, noop, nick, password, server, port): def __init__(self, config, noop, nick, password, server, port):
irc.client.SimpleIRCClient.__init__(self) irc.client.SimpleIRCClient.__init__(self)
self.identify_msg_cap = False
self.config = config self.config = config
self.nick = nick self.nick = nick
self.password = password self.password = password
@ -44,6 +43,7 @@ class SetAccess(irc.client.SimpleIRCClient):
self.channels = [x['name'] for x in self.config['channels']] self.channels = [x['name'] for x in self.config['channels']]
self.current_channel = None self.current_channel = None
self.current_list = [] self.current_list = []
self.current_mode = ''
self.changes = [] self.changes = []
self.identified = False self.identified = False
if self.port == 6697: if self.port == 6697:
@ -56,30 +56,19 @@ class SetAccess(irc.client.SimpleIRCClient):
def on_disconnect(self, connection, event): def on_disconnect(self, connection, event):
sys.exit(0) sys.exit(0)
def on_welcome(self, c, e):
self.identify_msg_cap = False
self.log.debug("Requesting identify-msg capability")
c.cap('REQ', 'identify-msg')
c.cap('END')
def on_cap(self, c, e):
self.log.debug("Received cap response %s" % repr(e.arguments))
if e.arguments[0] == 'ACK' and 'identify-msg' in e.arguments[1]:
self.log.debug("identify-msg cap acked")
self.identify_msg_cap = True
self.log.debug("Identifying to nickserv")
c.privmsg("nickserv", "identify %s " % self.password)
def on_privnotice(self, c, e): def on_privnotice(self, c, e):
if not self.identify_msg_cap:
self.log.debug("Ignoring message because identify-msg "
"cap not enabled")
return
nick = e.source.split('!')[0] nick = e.source.split('!')[0]
auth = e.arguments[0][0] msg = e.arguments[0]
msg = e.arguments[0][1:] if nick == 'NickServ' and not self.identified:
if auth == '+' and nick == 'NickServ' and not self.identified: if msg.startswith('authenticate yourself to services'):
if msg.startswith('You are now identified'): self.log.debug("Identifying to nickserv")
# TODO (fungi): We should protect against sending our
# password to a false NickServ, perhaps with
# https://www.oftc.net/NickServ/CertFP/ or eventually
# SASL once the ircd implements that
c.privmsg("nickserv", "identify %s " % self.password)
return
elif msg.startswith('You are successfully identified'):
self.identified = True self.identified = True
# Prejoin and set ourselves as op in these channels, # Prejoin and set ourselves as op in these channels,
# to facilitate +f forwarding. # to facilitate +f forwarding.
@ -88,8 +77,10 @@ class SetAccess(irc.client.SimpleIRCClient):
c.privmsg("chanserv", "op #%s" % channel) c.privmsg("chanserv", "op #%s" % channel)
self.advance() self.advance()
return return
if auth != '+' or nick != 'ChanServ': else:
self.log.debug("Ignoring message from unauthenticated " return
if nick not in ('ChanServ', 'NickServ'):
self.log.debug("Ignoring message from non-ChanServ "
"user %s" % nick) "user %s" % nick)
return return
self.failed = False self.failed = False
@ -99,95 +90,78 @@ class SetAccess(irc.client.SimpleIRCClient):
ret = {} ret = {}
alumni = [] alumni = []
mode = '' mode = ''
level = ''
channel = None channel = None
for c in self.config['channels']: for c in self.config['channels']:
if c['name'] == channel_name: if c['name'] == channel_name:
channel = c channel = c
if channel is None: if channel is None:
raise Exception("Unknown channel %s" % (channel_name,)) raise Exception("Unknown channel %s" % (channel_name,))
mask = '' for key, value in (list(self.config['global'].items()) +
for access, nicks in (list(self.config['global'].items()) +
list(channel.items())): list(channel.items())):
if access == 'mask': if key == 'alumni':
mask = self.config['access'].get(nicks) alumni += value
continue continue
if access == 'alumni': if key == 'mode':
alumni += nicks mode = value
continue continue
if access == 'mode':
mode = nicks
continue
flags = self.config['access'].get(access)
if flags is None:
continue
for nick in nicks:
ret[nick] = flags
return mask, ret, alumni, mode
def _get_access_change(self, current, target, mask): # If we get this far, we assume the key is an access
remove = '' # level matching an entry in the access list
add = '' level = self.config['access'].get(key)
change = '' if level is None:
for x in current: # Skip if this doesn't match a defined access level
if x in '+-':
continue continue
if target: for nick in value:
if x not in target: ret[nick] = level
remove += x return ret, alumni, mode
else:
if x not in mask: def _get_access_change(self, current, target):
remove += x if current != target:
for x in target: return target
if x in '+-':
continue
if x not in current:
add += x
if remove:
change += '-' + remove
if add:
change += '+' + add
return change
def _get_access_changes(self): def _get_access_changes(self):
mask, target, alumni, mode = self._get_access_list(self.current_channel) target, alumni, mode = self._get_access_list(
self.log.debug("Mask for %s: %s" % (self.current_channel, mask)) self.current_channel)
self.log.debug("Target for %s: %s" % (self.current_channel, target)) self.log.debug("Target #%s ACL: %s" % (self.current_channel, target))
all_nicks = set() all_nicks = set()
global_alumni = self.config.get('alumni', {}) global_alumni = self.config.get('alumni', {})
global_mode = self.config.get('mode', '') global_mode = self.config.get('mode', '')
current = {} current = {}
changes = [] changes = []
for nick, flags, msg in self.current_list: for nick, level, msg in self.current_list:
if nick in global_alumni or nick in alumni : if nick in global_alumni or nick in alumni :
self.log.debug("%s is an alumni; removing access", nick) self.log.debug("%s is an alumni; removing access", nick)
changes.append('access #%s del %s' % (self.current_channel, nick)) changes.append('access #%s del %s' % (self.current_channel, nick))
continue continue
all_nicks.add(nick) all_nicks.add(nick)
current[nick] = flags current[nick] = level
for nick in target.keys(): for nick in target.keys():
all_nicks.add(nick) all_nicks.add(nick)
for nick in all_nicks: for nick in all_nicks:
change = self._get_access_change(current.get(nick, ''), change = self._get_access_change(current.get(nick, ''),
target.get(nick, ''), mask) target.get(nick, ''))
if change: if change:
changes.append('access #%s add %s %s' % (self.current_channel, changes.append('access #%s add %s %s' % (self.current_channel,
nick, change)) nick, change))
# Set the mode. Note we always just hard-set the mode for # Set the mode if what we want differs from what's already there.
# simplicity (per the man page mlock always clears and sets # Channel mode overrides global mode.
# anyway). Channel mode overrides global mode.
#
# Note for +f you need to be op in the target channel; see
# op_channel option.
if not mode and global_mode: if not mode and global_mode:
mode = global_mode mode = global_mode
self.log.debug("Setting mode to : %s" % mode) if not mode:
if mode: mode = '+'
if sorted(mode) != sorted(self.current_mode):
self.log.debug("Current mode for #%s is %s, replacing with %s" % (
self.current_channel, self.current_mode, mode))
changes.append('set #%s mlock %s' % (self.current_channel, mode)) changes.append('set #%s mlock %s' % (self.current_channel, mode))
return changes return changes
def advance(self, msg=None): def advance(self, msg=None):
# Some service responses include a number of embedded 0x02 bytes
if msg:
msg = msg.replace('\x02', '')
if self.changes: if self.changes:
if self.noop: if self.noop:
for change in self.changes: for change in self.changes:
@ -204,19 +178,41 @@ class SetAccess(irc.client.SimpleIRCClient):
self.connection.quit() self.connection.quit()
return return
self.current_channel = self.channels.pop() self.current_channel = self.channels.pop()
# Clear the mode string before we request it, so if we get
# no response we won't have the modes from an earlier channel
self.current_mode = ''
# Sending a set mlock with no value prompts the service to
# respond with the current mlock value so we can compare
# against it later
self.connection.privmsg('chanserv', 'set #%s mlock' %
self.current_channel)
# Clear the access list before we request it, so if we get
# no response we won't have the list from an earlier channel
self.current_list = [] self.current_list = []
self.connection.privmsg('chanserv', 'access list #%s' % self.connection.privmsg('chanserv', 'access #%s list' %
self.current_channel) self.current_channel)
time.sleep(1) time.sleep(1)
return return
if msg.startswith('End of'): # We tokenize every server message, and perform some rough
# heuristics in order to determine what kind of response we're
# dealing with and whether it's something we know how to parse
parts = msg.split()
# If the third word look like an access level, assume this is
# an access list entry and that the second word is a
# corresponding nick
if parts[2] in ('MASTER', 'CHANOP', 'MEMBER'):
self.current_list.append((parts[1], parts[2], msg))
# If the message starts with "MLOCK is SET to" then assume the
# fifth word is the channel's mode string
elif msg.startswith('MLOCK is SET to'):
self.current_mode = parts[4]
# If the message starts with "End of" then assume this marks
# the end of an access list
elif msg.startswith('End of'):
self.changes = self._get_access_changes() self.changes = self._get_access_changes()
self.current_channel = None self.current_channel = None
self.advance() self.advance()
return return
parts = msg.split()
if parts[2].startswith('+'):
self.current_list.append((parts[1], parts[2], msg))
def main(): def main():