From eaf0ce0f597408620f03b07292bee635924aa2d4 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 20 Nov 2022 20:27:36 +0100 Subject: [PATCH 01/18] Add IrcState.nicksToAccount To keep track of network accounts using various IRCv3 specs --- src/irclib.py | 57 +++++++++++++++++++++++++- test/test_irclib.py | 99 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 2 deletions(-) diff --git a/src/irclib.py b/src/irclib.py index 6929ebb70..dc043cc5a 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -680,6 +680,12 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): Stores the last hostmask of a seen nick. + :type: ircutils.IrcDict[str, str] + + .. attribute:: nicksToAccounts + + Stores the current network account name of a seen nick. + :type: ircutils.IrcDict[str, str] """ __firewalled__ = {'addMsg': None} @@ -689,7 +695,8 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): nicksToHostmasks=None, channels=None, capabilities_req=None, capabilities_ack=None, capabilities_nak=None, - capabilities_ls=None): + capabilities_ls=None, + nicksToAccounts=None): self.fsm = IrcStateFsm() if history is None: history = RingBuffer(conf.supybot.protocols.irc.maxHistoryLength()) @@ -697,6 +704,8 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): supported = utils.InsensitivePreservingDict() if nicksToHostmasks is None: nicksToHostmasks = ircutils.IrcDict() + if nicksToAccounts is None: + nicksToAccounts = ircutils.IrcDict() if channels is None: channels = ircutils.IrcDict() self.capabilities_req = capabilities_req or set() @@ -708,6 +717,7 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): self.history = history self.channels = channels self.nicksToHostmasks = nicksToHostmasks + self.nicksToAccounts = nicksToAccounts # Batches usually finish and are way shorter than 3600s, but # we need to: @@ -725,6 +735,7 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): self.channels.clear() self.supported.clear() self.nicksToHostmasks.clear() + self.nicksToAccounts.clear() self.capabilities_req = set() self.capabilities_ack = set() self.capabilities_nak = set() @@ -745,13 +756,16 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): def __reduce__(self): return (self.__class__, (self.history, self.supported, - self.nicksToHostmasks, self.channels)) + self.nicksToHostmasks, + self.nicksToAccounts, + self.channels)) def __eq__(self, other): return self.history == other.history and \ self.channels == other.channels and \ self.supported == other.supported and \ self.nicksToHostmasks == other.nicksToHostmasks and \ + self.nicksToAccounts == other.nicksToAccounts and \ self.batches == other.batches def __ne__(self, other): @@ -761,6 +775,7 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): ret = self.__class__() ret.history = copy.deepcopy(self.history) ret.nicksToHostmasks = copy.deepcopy(self.nicksToHostmasks) + ret.nicksToAccounts = copy.deepcopy(self.nicksToAccounts) ret.channels = copy.deepcopy(self.channels) ret.batches = copy.deepcopy(self.batches) return ret @@ -770,6 +785,8 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): self.history.append(msg) if ircutils.isUserHostmask(msg.prefix) and not msg.command == 'NICK': self.nicksToHostmasks[msg.nick] = msg.prefix + if 'account' in msg.server_tags: + self.nicksToAccounts[msg.nick] = msg.server_tags['account'] if 'batch' in msg.server_tags: batch_name = msg.server_tags['batch'] assert batch_name in self.batches, \ @@ -788,6 +805,11 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): """Returns the hostmask for a given nick.""" return self.nicksToHostmasks[nick] + def nickToAccount(self, nick): + """Returns the account for a given nick, or None if the nick is logged + out.""" + return self.nicksToAccounts[nick] + def getParentBatches(self, msg): """Given an IrcMsg, returns a list of all batches that contain it, innermost first. @@ -957,6 +979,11 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): (n, t, user, ip, host, nick, status, account, gecos) = msg.args hostmask = '%s!%s@%s' % (nick, user, host) self.nicksToHostmasks[nick] = hostmask + if account == '0': + # logged out + self.nicksToAccounts[nick] = None + else: + self.nicksToAccounts[nick] = account def do353(self, irc, msg): # NAMES reply. @@ -978,6 +1005,7 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): stripped_item = item.lstrip(prefix_chars) item_prefix = item[0:-len(stripped_item)] if ircutils.isUserHostmask(stripped_item): + # https://ircv3.net/specs/extensions/userhost-in-names nick = ircutils.nickFromHostmask(stripped_item) self.nicksToHostmasks[nick] = stripped_item name = item_prefix + nick @@ -989,11 +1017,20 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): c.modes['s'] = None def doChghost(self, irc, msg): + # https://ircv3.net/specs/extensions/chghost (user, host) = msg.args nick = msg.nick hostmask = '%s!%s@%s' % (nick, user, host) self.nicksToHostmasks[nick] = hostmask + def doAccount(self, irc, msg): + # https://ircv3.net/specs/extensions/account-notify + account = msg.args[0] + if account == '*': + self.nicksToAccounts[msg.nick] = None + else: + self.nicksToAccounts[msg.nick] = account + def doJoin(self, irc, msg): for channel in msg.args[0].split(','): if channel in self.channels: @@ -1004,6 +1041,12 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): self.channels[channel] = chan # I don't know why this assert was here. #assert msg.nick == irc.nick, msg + if 'extended-join' in self.capabilities_ack: + account = msg.args[1] + if account == '*': + self.nicksToAccounts[msg.nick] = None + else: + self.nicksToAccounts[msg.nick] = account def do367(self, irc, msg): # Example: @@ -1083,6 +1126,8 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): if msg.nick in self.nicksToHostmasks: # If we're quitting, it may not be. del self.nicksToHostmasks[msg.nick] + if msg.nick in self.nicksToAccounts: + del self.nicksToAccounts[msg.nick] def doTopic(self, irc, msg): if len(msg.args) == 1: @@ -1100,6 +1145,7 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): def doNick(self, irc, msg): newNick = msg.args[0] oldNick = msg.nick + try: if msg.user and msg.host: # Nick messages being handed out from the bot itself won't @@ -1109,6 +1155,13 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): del self.nicksToHostmasks[oldNick] except KeyError: pass + + try: + self.nicksToAccounts[newNick] = self.nicksToAccounts[oldNick] + del self.nicksToAccounts[oldNick] + except KeyError: + pass + channel_names = ircutils.IrcSet() for (name, channel) in self.channels.items(): if msg.nick in channel.users: diff --git a/test/test_irclib.py b/test/test_irclib.py index fdad88159..703d6f5fd 100644 --- a/test/test_irclib.py +++ b/test/test_irclib.py @@ -579,6 +579,105 @@ class IrcStateTestCase(SupyTestCase): command='CHGHOST', args=['bar2', 'baz2'])) self.assertEqual(st.nickToHostmask('foo'), 'foo!bar2@baz2') + def testNickToAccountBaseJoin(self): + st = irclib.IrcState() + + st.addMsg(self.irc, ircmsgs.join('#foo', prefix='foo!bar@baz')) + st.addMsg(self.irc, ircmsgs.join('#foo', prefix='bar!baz@qux')) + with self.assertRaises(KeyError): + st.nickToAccount('foo') + st.nickToAccount('bar') + + + def testNickToAccountExtendedJoin(self): + st = irclib.IrcState() + st.capabilities_ack.add('extended-join') + + st.addMsg(self.irc, ircmsgs.IrcMsg( + command='JOIN', prefix='foo!bar@baz', + args=['#foo', 'account1', 'real name1'])) + st.addMsg(self.irc, ircmsgs.IrcMsg( + command='JOIN', prefix='bar!baz@qux', + args=['#foo', 'account2', 'real name2'])) + st.addMsg(self.irc, ircmsgs.IrcMsg( + command='JOIN', prefix='baz!qux@quux', + args=['#foo', '*', 'real name3'])) + self.assertEqual(st.nickToAccount('foo'), 'account1') + self.assertEqual(st.nickToAccount('bar'), 'account2') + self.assertIsNone(st.nickToAccount('baz')) + with self.assertRaises(KeyError): + st.nickToAccount('qux') + + # QUIT erases the entry + with self.subTest("QUIT"): + st2 = st.copy() + st2.addMsg(self.irc, ircmsgs.quit(prefix='foo!bar@baz')) + with self.assertRaises(KeyError): + st2.nickToAccount('foo') + self.assertEqual(st2.nickToAccount('bar'), 'account2') + self.assertEqual(st.nickToAccount('foo'), 'account1') + self.assertEqual(st.nickToAccount('bar'), 'account2') + + # NICK moves the entry + with self.subTest("NICK"): + st2 = st.copy() + st2.addMsg(self.irc, ircmsgs.IrcMsg(prefix='foo!bar@baz', + command='NICK', args=['foo2'])) + with self.assertRaises(KeyError): + st2.nickToAccount('foo') + self.assertEqual(st2.nickToAccount('foo2'), 'account1') + self.assertEqual(st2.nickToAccount('bar'), 'account2') + self.assertEqual(st.nickToAccount('foo'), 'account1') + self.assertEqual(st.nickToAccount('bar'), 'account2') + + # NICK moves the entry (and overwrites if needed) + with self.subTest("NICK with overwrite"): + st2 = st.copy() + st2.addMsg(self.irc, ircmsgs.IrcMsg(prefix='foo!bar@baz', + command='NICK', args=['bar'])) + with self.assertRaises(KeyError): + st2.nickToAccount('foo') + self.assertEqual(st2.nickToAccount('bar'), 'account1') + self.assertEqual(st.nickToAccount('foo'), 'account1') + self.assertEqual(st.nickToAccount('bar'), 'account2') + + def testNickToAccountWho(self): + st = irclib.IrcState() + + st.addMsg(self.irc, ircmsgs.IrcMsg(command='352', # RPL_WHOREPLY + args=[self.irc.nick, '#chan', 'bar', 'baz', 'server.example', + 'foo', 'H', '0 real name'])) + with self.assertRaises(KeyError): + st.nickToAccount('foo') + + def testNickToAccountWhox(self): + st = irclib.IrcState() + + st.addMsg(self.irc, ircmsgs.IrcMsg(command='354', # RPL_WHOSPCRPL + args=[self.irc.nick, '1', 'bar', '127.0.0.1', 'baz', + 'foo', 'H', 'account1', 'real name'])) + self.assertEqual(st.nickToAccount('foo'), 'account1') + + st.addMsg(self.irc, ircmsgs.IrcMsg(command='354', # RPL_WHOSPCRPL + args=[self.irc.nick, '1', 'bar', '127.0.0.1', 'baz', + 'foo', 'H', '0', 'real name'])) + self.assertIsNone(st.nickToAccount('foo')) + + def testAccountNotify(self): + st = irclib.IrcState() + + st.addMsg(self.irc, ircmsgs.IrcMsg(prefix='foo!bar@baz', + command='ACCOUNT', args=['account1'])) + self.assertEqual(st.nickToAccount('foo'), 'account1') + + st.addMsg(self.irc, ircmsgs.IrcMsg(prefix='foo!bar@baz', + command='ACCOUNT', args=['account2'])) + self.assertEqual(st.nickToAccount('foo'), 'account2') + + st.addMsg(self.irc, ircmsgs.IrcMsg(prefix='foo!bar@baz', + command='ACCOUNT', args=['*'])) + self.assertIsNone(st.nickToAccount('foo')) + def testEq(self): state1 = irclib.IrcState() state2 = irclib.IrcState() From 5056f2e6ef18d98bc1a95b55625e423edf35ffea Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Wed, 23 Nov 2022 18:50:19 +0100 Subject: [PATCH 02/18] core & Channel: Add option --account to @kban and @iban A future commit will add support for 'account' in supybot.protocols.irc.banmask, but it is not supported for now, as that config value is also used for ignore masks --- plugins/Channel/plugin.py | 10 +++++-- plugins/Channel/test.py | 63 +++++++++++++++++++++++++++++++++++++++ src/conf.py | 14 ++++++++- src/ircutils.py | 19 ++++++++++++ test/test_ircutils.py | 54 +++++++++++++++++++++++++++++++++ 5 files changed, 157 insertions(+), 3 deletions(-) diff --git a/plugins/Channel/plugin.py b/plugins/Channel/plugin.py index 26ad62501..e6d24fcae 100644 --- a/plugins/Channel/plugin.py +++ b/plugins/Channel/plugin.py @@ -321,13 +321,16 @@ class Channel(callbacks.Plugin): --exact bans only the exact hostmask; --nick bans just the nick; --user bans just the user, and --host bans just the host You can combine the --nick, --user, and --host options as you choose. + If --account is provided and the user is logged in and the network + supports account bans, this will ban the user's account instead. is only necessary if the message isn't sent in the channel itself. """ self._ban(irc, msg, args, channel, optlist, bannedNick, expiry, reason, True) kban = wrap(kban, ['op', - getopts({'exact':'', 'nick':'', 'user':'', 'host':''}), + getopts({'exact':'', 'nick':'', 'user':'', 'host':'', + 'account': ''}), ('haveHalfop+', _('kick or ban someone')), 'nickInChannel', optional('expiry', 0), @@ -343,13 +346,16 @@ class Channel(callbacks.Plugin): don't specify a number of seconds) it will ban the person indefinitely. --exact can be used to specify an exact hostmask. You can combine the --nick, --user, and --host options as you choose. + If --account is provided and the user is logged in and the network + supports account bans, this will ban the user's account instead. is only necessary if the message isn't sent in the channel itself. """ self._ban(irc, msg, args, channel, optlist, bannedNick, expiry, None, False) iban = wrap(iban, ['op', - getopts({'exact':'', 'nick':'', 'user':'', 'host':''}), + getopts({'exact':'', 'nick':'', 'user':'', 'host':'', + 'account': ''}), ('haveHalfop+', _('ban someone')), first('nick', 'hostmask'), optional('expiry', 0)]) diff --git a/plugins/Channel/test.py b/plugins/Channel/test.py index 4295ef081..f091cae88 100644 --- a/plugins/Channel/test.py +++ b/plugins/Channel/test.py @@ -219,6 +219,69 @@ class ChannelTestCase(ChannelPluginTestCase): self.assertRegexp('kban adlkfajsdlfkjsd', 'adlkfajsdlfkjsd is not in') + def testAccountKbanNoAccount(self): + self.irc.prefix = 'something!else@somehwere.else' + self.irc.nick = 'something' + self.irc.state.supported['ACCOUNTEXTBAN'] = 'a,account' + self.irc.state.supported['EXTBAN'] = '~,abc' + def join(): + self.irc.feedMsg(ircmsgs.join( + self.channel, prefix='foobar!user@host.domain.tld')) + join() + self.irc.feedMsg(ircmsgs.op(self.channel, self.irc.nick)) + self.assertKban('kban --account --exact foobar', + 'foobar!user@host.domain.tld') + join() + self.assertKban('kban --account foobar', + 'foobar!user@host.domain.tld') + join() + self.assertKban('kban --account --host foobar', + '*!*@host.domain.tld') + + def testAccountKbanLoggedOut(self): + self.irc.prefix = 'something!else@somehwere.else' + self.irc.nick = 'something' + self.irc.state.supported['ACCOUNTEXTBAN'] = 'a,account' + self.irc.state.supported['EXTBAN'] = '~,abc' + self.irc.feedMsg(ircmsgs.IrcMsg( + prefix='foobar!user@host.domain.tld', + command='ACCOUNT', args=['*'])) + def join(): + self.irc.feedMsg(ircmsgs.join( + self.channel, prefix='foobar!user@host.domain.tld')) + join() + self.irc.feedMsg(ircmsgs.op(self.channel, self.irc.nick)) + self.assertKban('kban --account --exact foobar', + 'foobar!user@host.domain.tld') + join() + self.assertKban('kban --account foobar', + 'foobar!user@host.domain.tld') + join() + self.assertKban('kban --account --host foobar', + '*!*@host.domain.tld') + + def testAccountKbanLoggedIn(self): + self.irc.prefix = 'something!else@somehwere.else' + self.irc.nick = 'something' + self.irc.state.supported['ACCOUNTEXTBAN'] = 'a,account' + self.irc.state.supported['EXTBAN'] = '~,abc' + self.irc.feedMsg(ircmsgs.IrcMsg( + prefix='foobar!user@host.domain.tld', + command='ACCOUNT', args=['account1'])) + def join(): + self.irc.feedMsg(ircmsgs.join( + self.channel, prefix='foobar!user@host.domain.tld')) + join() + self.irc.feedMsg(ircmsgs.op(self.channel, self.irc.nick)) + self.assertKban('kban --account --exact foobar', + '~a:account1') + join() + self.assertKban('kban --account foobar', + '~a:account1') + join() + self.assertKban('kban --account --host foobar', + '~a:account1') + def testBan(self): with conf.supybot.protocols.irc.banmask.context(['exact']): self.assertNotError('ban add foo!bar@baz') diff --git a/src/conf.py b/src/conf.py index 16c554451..2848d817d 100644 --- a/src/conf.py +++ b/src/conf.py @@ -1217,7 +1217,11 @@ class Banmask(registry.SpaceSeparatedSetOfStrings): options - A list specifying which parts of the hostmask should explicitly be matched: nick, user, host. If 'exact' is given, then - only the exact hostmask will be used.""" + only the exact hostmask will be used. + If 'account' is given (and not after 'exact') and the user is + logged in and the server supports account extbans, then an account + extban is returned instead. + """ if not channel: channel = dynamic.channel if not network: @@ -1238,6 +1242,14 @@ class Banmask(registry.SpaceSeparatedSetOfStrings): bhost = host elif option == 'exact': return hostmask + elif option == 'account': + import supybot.world as world + irc = world.getIrc(network) + if irc is None: + continue + extban = ircutils.accountExtban(nick, irc) + if extban is not None: + return extban if (bnick, buser, bhost) == ('*', '*', '*') and \ ircutils.isUserHostmask(hostmask): return hostmask diff --git a/src/ircutils.py b/src/ircutils.py index da7397810..ca7c26f56 100644 --- a/src/ircutils.py +++ b/src/ircutils.py @@ -345,6 +345,25 @@ def banmask(hostmask): else: return '*!*@' + host + +def accountExtban(nick, irc): + """If 'nick' is logged in and the network supports account extbans, + returns a ban mask for it. If not, returns None.""" + if 'ACCOUNTEXTBAN' not in irc.state.supported: + return None + if 'EXTBAN' not in irc.state.supported: + return None + try: + account = irc.state.nickToAccount(nick) + except KeyError: + account = None + if account is None: + return None + account_extban = irc.state.supported['ACCOUNTEXTBAN'].split(',')[0] + extban_prefix = irc.state.supported['EXTBAN'].split(',', 1)[0] + return '%s%s:%s'% (extban_prefix, account_extban, account) + + _plusRequireArguments = 'ovhblkqeI' _minusRequireArguments = 'ovhbkqeI' def separateModes(args): diff --git a/test/test_ircutils.py b/test/test_ircutils.py index cb62358c8..a9c40a6f6 100644 --- a/test/test_ircutils.py +++ b/test/test_ircutils.py @@ -367,6 +367,60 @@ class FunctionsTestCase(SupyTestCase): '*!*@*.host.tld') self.assertEqual(ircutils.banmask('foo!bar@2001::'), '*!*@2001::*') + def testAccountExtban(self): + irc = getTestIrc() + irc.state.addMsg(irc, ircmsgs.IrcMsg( + prefix='foo!bar@baz', command='ACCOUNT', args=['account1'])) + irc.state.addMsg(irc, ircmsgs.IrcMsg( + prefix='bar!baz@qux', command='ACCOUNT', args=['*'])) + + with self.subTest('spec example'): + irc.state.supported['ACCOUNTEXTBAN'] = 'a,account' + irc.state.supported['EXTBAN'] = '~,abc' + self.assertEqual(ircutils.accountExtban('foo', irc), + '~a:account1') + self.assertIsNone(ircutils.accountExtban('bar', irc)) + self.assertIsNone(ircutils.accountExtban('baz', irc)) + + with self.subTest('InspIRCd'): + irc.state.supported['ACCOUNTEXTBAN'] = 'account,R' + irc.state.supported['EXTBAN'] = ',abcR' + self.assertEqual(ircutils.accountExtban('foo', irc), + 'account:account1') + self.assertIsNone(ircutils.accountExtban('bar', irc)) + self.assertIsNone(ircutils.accountExtban('baz', irc)) + + with self.subTest('Solanum'): + irc.state.supported['ACCOUNTEXTBAN'] = 'a' + irc.state.supported['EXTBAN'] = '$,abc' + self.assertEqual(ircutils.accountExtban('foo', irc), + '$a:account1') + self.assertIsNone(ircutils.accountExtban('bar', irc)) + self.assertIsNone(ircutils.accountExtban('baz', irc)) + + with self.subTest('UnrealIRCd'): + irc.state.supported['ACCOUNTEXTBAN'] = 'account,a' + irc.state.supported['EXTBAN'] = '~,abc' + self.assertEqual(ircutils.accountExtban('foo', irc), + '~account:account1') + self.assertIsNone(ircutils.accountExtban('bar', irc)) + self.assertIsNone(ircutils.accountExtban('baz', irc)) + + with self.subTest('no ACCOUNTEXTBAN'): + irc.state.supported.pop('ACCOUNTEXTBAN') + irc.state.supported['EXTBAN'] = '~,abc' + self.assertIsNone(ircutils.accountExtban('foo', irc)) + self.assertIsNone(ircutils.accountExtban('bar', irc)) + self.assertIsNone(ircutils.accountExtban('baz', irc)) + + with self.subTest('no EXTBAN'): + irc.state.supported['ACCOUNTEXTBAN'] = 'account,a' + irc.state.supported.pop('EXTBAN') + self.assertIsNone(ircutils.accountExtban('foo', irc)) + self.assertIsNone(ircutils.accountExtban('bar', irc)) + self.assertIsNone(ircutils.accountExtban('baz', irc)) + + def testSeparateModes(self): self.assertEqual(ircutils.separateModes(['+ooo', 'x', 'y', 'z']), [('+o', 'x'), ('+o', 'y'), ('+o', 'z')]) From fc49d17faa57a5437c20b5587fb8130c4b96945d Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Wed, 23 Nov 2022 19:22:45 +0100 Subject: [PATCH 03/18] Add support for 'account' in supybot.protocols.irc.banmask And a new method .makeExtBanmask() as an alternative to .makeBanmask(), so plugins can opt-in to extended banmasks when they support it. 'ignore' commands in Channel and anti-flood in Owner and Misc will keep using .makeBanmask() because they use them as ignore masks in ircdb. --- plugins/AutoMode/plugin.py | 3 ++- plugins/Channel/plugin.py | 8 ++++++-- plugins/Channel/test.py | 26 ++++++++++++++++++-------- src/commands.py | 10 +++++++++- src/conf.py | 29 ++++++++++++++++++++++++++--- 5 files changed, 61 insertions(+), 15 deletions(-) diff --git a/plugins/AutoMode/plugin.py b/plugins/AutoMode/plugin.py index 738aae428..a3e8db67c 100644 --- a/plugins/AutoMode/plugin.py +++ b/plugins/AutoMode/plugin.py @@ -163,7 +163,8 @@ class AutoMode(callbacks.Plugin): # We're not in the channel anymore. pass schedule.addEvent(unban, time.time()+period) - banmask =conf.supybot.protocols.irc.banmask.makeBanmask(msg.prefix) + banmask = conf.supybot.protocols.irc.banmask.makeExtBanmask( + msg.prefix, channel=channel, network=irc.network) irc.queueMsg(ircmsgs.ban(channel, banmask)) irc.queueMsg(ircmsgs.kick(channel, msg.nick)) diff --git a/plugins/Channel/plugin.py b/plugins/Channel/plugin.py index e6d24fcae..815a1ef18 100644 --- a/plugins/Channel/plugin.py +++ b/plugins/Channel/plugin.py @@ -368,7 +368,9 @@ class Channel(callbacks.Plugin): try: bannedHostmask = irc.state.nickToHostmask(target) banmaskstyle = conf.supybot.protocols.irc.banmask - banmask = banmaskstyle.makeBanmask(bannedHostmask, [o[0] for o in optlist]) + banmask = banmaskstyle.makeExtBanmask( + bannedHostmask, [o[0] for o in optlist], + channel=channel, network=irc.network) except KeyError: if not conf.supybot.protocols.irc.strictRfc() and \ target.startswith('$'): @@ -606,7 +608,9 @@ class Channel(callbacks.Plugin): c.addBan(banmask, expires) ircdb.channels.setChannel(channel, c) irc.replySuccess() - add = wrap(add, ['op', first('hostmask', 'banmask'), additional('expiry', 0)]) + add = wrap(add, ['op', + first('hostmask', 'extbanmask'), + additional('expiry', 0)]) @internationalizeDocstring def remove(self, irc, msg, args, channel, banmask): diff --git a/plugins/Channel/test.py b/plugins/Channel/test.py index f091cae88..35b71c3c8 100644 --- a/plugins/Channel/test.py +++ b/plugins/Channel/test.py @@ -273,14 +273,24 @@ class ChannelTestCase(ChannelPluginTestCase): self.channel, prefix='foobar!user@host.domain.tld')) join() self.irc.feedMsg(ircmsgs.op(self.channel, self.irc.nick)) - self.assertKban('kban --account --exact foobar', - '~a:account1') - join() - self.assertKban('kban --account foobar', - '~a:account1') - join() - self.assertKban('kban --account --host foobar', - '~a:account1') + + + for style in (['exact'], ['account', 'exact']): + with conf.supybot.protocols.irc.banmask.context(style): + self.assertKban('kban --account --exact foobar', + '~a:account1') + join() + self.assertKban('kban --account foobar', + '~a:account1') + join() + self.assertKban('kban --account --host foobar', + '~a:account1') + join() + + with conf.supybot.protocols.irc.banmask.context(['account', 'exact']): + self.assertKban('kban foobar', + '~a:account1') + join() def testBan(self): with conf.supybot.protocols.irc.banmask.context(['exact']): diff --git a/src/commands.py b/src/commands.py index f93e7ce47..2874c4692 100644 --- a/src/commands.py +++ b/src/commands.py @@ -435,7 +435,14 @@ def getBanmask(irc, msg, args, state): getChannel(irc, msg, args, state) banmaskstyle = conf.supybot.protocols.irc.banmask state.args[-1] = banmaskstyle.makeBanmask(state.args[-1], - channel=state.channel) + channel=state.channel, network=irc.network) + +def getExtBanmask(irc, msg, args, state): + getHostmask(irc, msg, args, state) + getChannel(irc, msg, args, state) + banmaskstyle = conf.supybot.protocols.irc.extbanmask + state.args[-1] = banmaskstyle.makeExtBanmask(state.args[-1], + channel=state.channel, network=irc.network) def getUser(irc, msg, args, state): try: @@ -806,6 +813,7 @@ wrappers = ircutils.IrcDict({ 'commandName': getCommandName, 'email': getEmail, 'expiry': getExpiry, + 'extbanmask': getExtBanmask, 'filename': getSomething, # XXX Check for validity. 'float': getFloat, 'glob': getGlob, diff --git a/src/conf.py b/src/conf.py index 2848d817d..975852c34 100644 --- a/src/conf.py +++ b/src/conf.py @@ -1181,7 +1181,7 @@ registerGroup(supybot.protocols, 'irc') class Banmask(registry.SpaceSeparatedSetOfStrings): __slots__ = ('__parent', '__dict__') # __dict__ is needed to set __doc__ - validStrings = ('exact', 'nick', 'user', 'host') + validStrings = ('exact', 'nick', 'user', 'host', 'account') def __init__(self, *args, **kwargs): assert self.validStrings, 'There must be some valid strings. ' \ 'This is a bug.' @@ -1215,6 +1215,31 @@ class Banmask(registry.SpaceSeparatedSetOfStrings): isn't specified via options, the value of conf.supybot.protocols.irc.banmask is used. + Unlike :meth:`makeExtBanmask`, this is guaranteed to return an + RFC1459-like mask, suitable for ircdb's ignore lists. + + options - A list specifying which parts of the hostmask should + explicitly be matched: nick, user, host. If 'exact' is given, then + only the exact hostmask will be used. + """ + if not network: + network = dynamic.irc.network + if not options: + options = supybot.protocols.irc.banmask.getSpecific( + network, channel)() + options = [option for option in options if option != 'account'] + return self.makeExtBanmask(hostmask, options, channel, network=network) + + def makeExtBanmask(self, hostmask, options=None, channel=None, *, network): + """Create a banmask from the given hostmask. If a style of banmask + isn't specified via options, the value of + conf.supybot.protocols.irc.banmask is used. + + Depending on the options and configuration, this may return a mask + in the format of an extban (eg. "~account:foobar" on UnrealIRCd). + If this is unwanted (eg. to pass to ircdb's ignore lists, use + :meth:`makeBanmask` instead) + options - A list specifying which parts of the hostmask should explicitly be matched: nick, user, host. If 'exact' is given, then only the exact hostmask will be used. @@ -1224,8 +1249,6 @@ class Banmask(registry.SpaceSeparatedSetOfStrings): """ if not channel: channel = dynamic.channel - if not network: - network = dynamic.irc.network (nick, user, host) = ircutils.splitHostmask(hostmask) bnick = '*' buser = '*' From f73fe5095e4cba1d025ec33065fcec1dfca04527 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Wed, 23 Nov 2022 20:33:48 +0100 Subject: [PATCH 04/18] Replace makeExtBanmask with makeExtBanmasks Now that we can return both account extbans and regular masks, it makes sense to ban both. Otherwise, adding 'account' to supybot.protocols.irc.banmask means we banned only the account instead of the hostmask, which arguably makes the ban weaker (/NS LOGOUT to evade) --- plugins/AutoMode/plugin.py | 4 +-- plugins/Channel/plugin.py | 52 ++++++++++++++++++++++++-------------- plugins/Channel/test.py | 28 +++++++++++++------- src/commands.py | 6 ++--- src/conf.py | 37 ++++++++++++++++++++------- 5 files changed, 85 insertions(+), 42 deletions(-) diff --git a/plugins/AutoMode/plugin.py b/plugins/AutoMode/plugin.py index a3e8db67c..f11ed3238 100644 --- a/plugins/AutoMode/plugin.py +++ b/plugins/AutoMode/plugin.py @@ -163,9 +163,9 @@ class AutoMode(callbacks.Plugin): # We're not in the channel anymore. pass schedule.addEvent(unban, time.time()+period) - banmask = conf.supybot.protocols.irc.banmask.makeExtBanmask( + banmasks = conf.supybot.protocols.irc.banmask.makeExtBanmasks( msg.prefix, channel=channel, network=irc.network) - irc.queueMsg(ircmsgs.ban(channel, banmask)) + irc.queueMsg(ircmsgs.bans(channel, banmasks)) irc.queueMsg(ircmsgs.kick(channel, msg.nick)) try: diff --git a/plugins/Channel/plugin.py b/plugins/Channel/plugin.py index 815a1ef18..5d8afa167 100644 --- a/plugins/Channel/plugin.py +++ b/plugins/Channel/plugin.py @@ -368,7 +368,7 @@ class Channel(callbacks.Plugin): try: bannedHostmask = irc.state.nickToHostmask(target) banmaskstyle = conf.supybot.protocols.irc.banmask - banmask = banmaskstyle.makeExtBanmask( + banmasks = banmaskstyle.makeExtBanmasks( bannedHostmask, [o[0] for o in optlist], channel=channel, network=irc.network) except KeyError: @@ -376,12 +376,14 @@ class Channel(callbacks.Plugin): target.startswith('$'): # Select the last part, or the whole target: bannedNick = target.split(':')[-1] - banmask = bannedHostmask = target + bannedHostmask = target + banmasks = [bannedHostmask] else: irc.error(format(_('I haven\'t seen %s.'), bannedNick), Raise=True) else: bannedNick = ircutils.nickFromHostmask(target) - banmask = bannedHostmask = target + bannedHostmask = target + banmasks = [bannedHostmask] if not irc.isNick(bannedNick): self.log.warning('%q tried to kban a non nick: %q', msg.prefix, bannedNick) @@ -398,29 +400,38 @@ class Channel(callbacks.Plugin): reason = msg.nick capability = ircdb.makeChannelCapability(channel, 'op') # Check (again) that they're not trying to make us kickban ourself. - if ircutils.hostmaskPatternEqual(banmask, irc.prefix): - if ircutils.hostmaskPatternEqual(bannedHostmask, irc.prefix): - self.log.warning('%q tried to make me kban myself.',msg.prefix) - irc.error(_('I cowardly refuse to ban myself.')) - return - else: - self.log.warning('Using exact hostmask since banmask would ' - 'ban myself.') - banmask = bannedHostmask + for banmask in banmasks: + # TODO: check account ban too + if ircutils.hostmaskPatternEqual(banmask, irc.prefix): + if ircutils.hostmaskPatternEqual(bannedHostmask, irc.prefix): + self.log.warning('%q tried to make me kban myself.',msg.prefix) + irc.error(_('I cowardly refuse to ban myself.')) + return + else: + self.log.warning('Using exact hostmask since banmask would ' + 'ban myself.') + banmasks = [bannedHostmask] # Now, let's actually get to it. Check to make sure they have # #channel,op and the bannee doesn't have #channel,op; or that the # bannee and the banner are both the same person. def doBan(): if irc.state.channels[channel].isOp(bannedNick): irc.queueMsg(ircmsgs.deop(channel, bannedNick)) - irc.queueMsg(ircmsgs.ban(channel, banmask)) + irc.queueMsg(ircmsgs.bans(channel, banmasks)) if kick: irc.queueMsg(ircmsgs.kick(channel, bannedNick, reason)) if expiry > 0: def f(): - if channel in irc.state.channels and \ - banmask in irc.state.channels[channel].bans: - irc.queueMsg(ircmsgs.unban(channel, banmask)) + if channel not in irc.state.channels: + return + remaining_banmasks = [ + banmask + for banmask in banmasks + if banmask in irc.state.channels[channel].bans + ] + if remaining_banmasks: + irc.queueMsg(ircmsgs.unbans( + channel, remaining_banmasks)) schedule.addEvent(f, expiry) if bannedNick == msg.nick: doBan() @@ -591,7 +602,7 @@ class Channel(callbacks.Plugin): hostmask = wrap(hostmask, ['op', ('haveHalfop+', _('ban someone')), 'text']) @internationalizeDocstring - def add(self, irc, msg, args, channel, banmask, expires): + def add(self, irc, msg, args, channel, banmasks, expires): """[] [] If you have the #channel,op capability, this will effect a @@ -605,11 +616,14 @@ class Channel(callbacks.Plugin): channel itself. """ c = ircdb.channels.getChannel(channel) - c.addBan(banmask, expires) + if isinstance(banmasks, str): + banmasks = [banmasks] + for banmask in banmasks: + c.addBan(banmask, expires) ircdb.channels.setChannel(channel, c) irc.replySuccess() add = wrap(add, ['op', - first('hostmask', 'extbanmask'), + first('hostmask', 'extbanmasks'), additional('expiry', 0)]) @internationalizeDocstring diff --git a/plugins/Channel/test.py b/plugins/Channel/test.py index 35b71c3c8..bd716767c 100644 --- a/plugins/Channel/test.py +++ b/plugins/Channel/test.py @@ -29,6 +29,8 @@ # POSSIBILITY OF SUCH DAMAGE. ### +import itertools + from supybot.test import * import supybot.conf as conf @@ -161,9 +163,13 @@ class ChannelTestCase(ChannelPluginTestCase): self.assertTrue(m.command == 'MODE' and m.args == (self.channel, '+v', 'bar')) - def assertKban(self, query, hostmask, **kwargs): + def assertKban(self, query, *hostmasks, **kwargs): m = self.getMsg(query, **kwargs) - self.assertEqual(m, ircmsgs.ban(self.channel, hostmask)) + self.assertIn(m, [ + ircmsgs.bans(self.channel, permutation) + for permutation in itertools.permutations(hostmasks) + ]) + m = self.getMsg(' ') self.assertEqual(m.command, 'KICK') def assertBan(self, query, hostmask, **kwargs): @@ -278,25 +284,29 @@ class ChannelTestCase(ChannelPluginTestCase): for style in (['exact'], ['account', 'exact']): with conf.supybot.protocols.irc.banmask.context(style): self.assertKban('kban --account --exact foobar', - '~a:account1') + '~a:account1', 'foobar!user@host.domain.tld') join() self.assertKban('kban --account foobar', '~a:account1') join() self.assertKban('kban --account --host foobar', - '~a:account1') + '~a:account1', '*!*@host.domain.tld') join() with conf.supybot.protocols.irc.banmask.context(['account', 'exact']): self.assertKban('kban foobar', - '~a:account1') + '~a:account1', 'foobar!user@host.domain.tld') + join() + + with conf.supybot.protocols.irc.banmask.context(['account', 'host']): + self.assertKban('kban foobar', + '~a:account1', '*!*@host.domain.tld') join() def testBan(self): with conf.supybot.protocols.irc.banmask.context(['exact']): self.assertNotError('ban add foo!bar@baz') self.assertNotError('ban remove foo!bar@baz') - orig = conf.supybot.protocols.irc.strictRfc() with conf.supybot.protocols.irc.strictRfc.context(True): # something wonky is going on here. irc.error (src/Channel.py|449) # is being called but the assert is failing @@ -322,7 +332,6 @@ class ChannelTestCase(ChannelPluginTestCase): '"foobar!*@baz" (never expires)') def testIgnore(self): - orig = conf.supybot.protocols.irc.banmask() def ignore(given, expect=None): if expect is None: expect = given @@ -330,8 +339,9 @@ class ChannelTestCase(ChannelPluginTestCase): self.assertResponse('channel ignore list', "'%s'" % expect) self.assertNotError('channel ignore remove %s' % expect) self.assertRegexp('channel ignore list', 'not currently') - ignore('foo!bar@baz', '*!*@baz') - ignore('foo!*@*') + with conf.supybot.protocols.irc.banmask.context(['host']): + ignore('foo!bar@baz', '*!*@baz') + ignore('foo!*@*') with conf.supybot.protocols.irc.banmask.context(['exact']): ignore('foo!bar@baz') ignore('foo!*@*') diff --git a/src/commands.py b/src/commands.py index 2874c4692..7da905fa4 100644 --- a/src/commands.py +++ b/src/commands.py @@ -437,11 +437,11 @@ def getBanmask(irc, msg, args, state): state.args[-1] = banmaskstyle.makeBanmask(state.args[-1], channel=state.channel, network=irc.network) -def getExtBanmask(irc, msg, args, state): +def getExtBanmasks(irc, msg, args, state): getHostmask(irc, msg, args, state) getChannel(irc, msg, args, state) banmaskstyle = conf.supybot.protocols.irc.extbanmask - state.args[-1] = banmaskstyle.makeExtBanmask(state.args[-1], + state.args[-1] = banmaskstyle.makeExtBanmasks(state.args[-1], channel=state.channel, network=irc.network) def getUser(irc, msg, args, state): @@ -813,7 +813,7 @@ wrappers = ircutils.IrcDict({ 'commandName': getCommandName, 'email': getEmail, 'expiry': getExpiry, - 'extbanmask': getExtBanmask, + 'extbanmasks': getExtBanmasks, 'filename': getSomething, # XXX Check for validity. 'float': getFloat, 'glob': getGlob, diff --git a/src/conf.py b/src/conf.py index 975852c34..7c55b188d 100644 --- a/src/conf.py +++ b/src/conf.py @@ -1215,7 +1215,7 @@ class Banmask(registry.SpaceSeparatedSetOfStrings): isn't specified via options, the value of conf.supybot.protocols.irc.banmask is used. - Unlike :meth:`makeExtBanmask`, this is guaranteed to return an + Unlike :meth:`makeExtBanmasks`, this is guaranteed to return an RFC1459-like mask, suitable for ircdb's ignore lists. options - A list specifying which parts of the hostmask should @@ -1228,10 +1228,15 @@ class Banmask(registry.SpaceSeparatedSetOfStrings): options = supybot.protocols.irc.banmask.getSpecific( network, channel)() options = [option for option in options if option != 'account'] - return self.makeExtBanmask(hostmask, options, channel, network=network) + print(hostmask, options) + masks = self.makeExtBanmasks( + hostmask, options, channel, network=network) + assert len(masks) == 1, 'Unexpected number of banmasks: %r' % masks + print(masks) + return masks[0] - def makeExtBanmask(self, hostmask, options=None, channel=None, *, network): - """Create a banmask from the given hostmask. If a style of banmask + def makeExtBanmasks(self, hostmask, options=None, channel=None, *, network): + """Create banmasks from the given hostmask. If a style of banmask isn't specified via options, the value of conf.supybot.protocols.irc.banmask is used. @@ -1256,15 +1261,22 @@ class Banmask(registry.SpaceSeparatedSetOfStrings): if not options: options = supybot.protocols.irc.banmask.getSpecific( network, channel)() + + add_star_mask = False + masks = [] + for option in options: if option == 'nick': bnick = nick + add_star_mask = True elif option == 'user': buser = user + add_star_mask = True elif option == 'host': bhost = host + add_star_mask = True elif option == 'exact': - return hostmask + masks.append(hostmask) elif option == 'account': import supybot.world as world irc = world.getIrc(network) @@ -1272,11 +1284,18 @@ class Banmask(registry.SpaceSeparatedSetOfStrings): continue extban = ircutils.accountExtban(nick, irc) if extban is not None: - return extban + masks.append(extban) + + if add_star_mask and (bnick, buser, bhost) != ('*', '*', '*'): + masks.append(ircutils.joinHostmask(bnick, buser, bhost)) + if (bnick, buser, bhost) == ('*', '*', '*') and \ - ircutils.isUserHostmask(hostmask): - return hostmask - return ircutils.joinHostmask(bnick, buser, bhost) + ircutils.isUserHostmask(hostmask) and \ + masks == []: + masks.append(hostmask) + + return masks + registerChannelValue(supybot.protocols.irc, 'banmask', Banmask(['host'], _("""Determines what will be used as the From 21cac28396941a0499e3127b4631067f45800607 Mon Sep 17 00:00:00 2001 From: Val Lorentz Date: Sun, 2 Jul 2023 07:49:24 +0200 Subject: [PATCH 05/18] s/network/services/ Co-authored-by: James Lu --- src/irclib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/irclib.py b/src/irclib.py index dc043cc5a..a6932590b 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -684,7 +684,7 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): .. attribute:: nicksToAccounts - Stores the current network account name of a seen nick. + Stores the current services account name of a seen nick. :type: ircutils.IrcDict[str, str] """ From b54dd33dbd28ef4193b3ba48d732adfda1fc3d32 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 2 Jul 2023 10:11:59 +0200 Subject: [PATCH 06/18] Explain why 'st' is checked too --- test/test_irclib.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/test_irclib.py b/test/test_irclib.py index 703d6f5fd..06a921176 100644 --- a/test/test_irclib.py +++ b/test/test_irclib.py @@ -615,6 +615,8 @@ class IrcStateTestCase(SupyTestCase): with self.assertRaises(KeyError): st2.nickToAccount('foo') self.assertEqual(st2.nickToAccount('bar'), 'account2') + + # check st isn't affected by changes to st2 self.assertEqual(st.nickToAccount('foo'), 'account1') self.assertEqual(st.nickToAccount('bar'), 'account2') @@ -627,6 +629,8 @@ class IrcStateTestCase(SupyTestCase): st2.nickToAccount('foo') self.assertEqual(st2.nickToAccount('foo2'), 'account1') self.assertEqual(st2.nickToAccount('bar'), 'account2') + + # check st isn't affected by changes to st2 self.assertEqual(st.nickToAccount('foo'), 'account1') self.assertEqual(st.nickToAccount('bar'), 'account2') @@ -638,6 +642,8 @@ class IrcStateTestCase(SupyTestCase): with self.assertRaises(KeyError): st2.nickToAccount('foo') self.assertEqual(st2.nickToAccount('bar'), 'account1') + + # check st isn't affected by changes to st2 self.assertEqual(st.nickToAccount('foo'), 'account1') self.assertEqual(st.nickToAccount('bar'), 'account2') From 53b45c1d47fd69fa2d49053797cf57241f8864b6 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 2 Jul 2023 10:13:38 +0200 Subject: [PATCH 07/18] Remove prints --- src/conf.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/conf.py b/src/conf.py index 7c55b188d..a7a922a05 100644 --- a/src/conf.py +++ b/src/conf.py @@ -1228,11 +1228,9 @@ class Banmask(registry.SpaceSeparatedSetOfStrings): options = supybot.protocols.irc.banmask.getSpecific( network, channel)() options = [option for option in options if option != 'account'] - print(hostmask, options) masks = self.makeExtBanmasks( hostmask, options, channel, network=network) assert len(masks) == 1, 'Unexpected number of banmasks: %r' % masks - print(masks) return masks[0] def makeExtBanmasks(self, hostmask, options=None, channel=None, *, network): From 448d9771f8a5d1d629ec02c13b4b35cdaf2d1313 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 2 Jul 2023 10:17:35 +0200 Subject: [PATCH 08/18] Correctly document meaning of None vs absent values in nicksToAccounts --- src/irclib.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/irclib.py b/src/irclib.py index a6932590b..a0d7f4076 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -684,9 +684,10 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): .. attribute:: nicksToAccounts - Stores the current services account name of a seen nick. + Stores the current services account name of a seen nick (or + :const:`None` for un-identified nicks) - :type: ircutils.IrcDict[str, str] + :type: ircutils.IrcDict[str, Optional[str]] """ __firewalled__ = {'addMsg': None} @@ -807,7 +808,8 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): def nickToAccount(self, nick): """Returns the account for a given nick, or None if the nick is logged - out.""" + out. Raises :exc:`KeyError` if the nick was not seen or its account is + not known yet.""" return self.nicksToAccounts[nick] def getParentBatches(self, msg): From 3018982d5ac649c5d8b0a02e34e45e1836ab7cac Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 2 Jul 2023 10:18:32 +0200 Subject: [PATCH 09/18] tests: Fix self.assertRaises(KeyError) for nickToAccount --- test/test_irclib.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_irclib.py b/test/test_irclib.py index 06a921176..85c60793d 100644 --- a/test/test_irclib.py +++ b/test/test_irclib.py @@ -586,6 +586,7 @@ class IrcStateTestCase(SupyTestCase): st.addMsg(self.irc, ircmsgs.join('#foo', prefix='bar!baz@qux')) with self.assertRaises(KeyError): st.nickToAccount('foo') + with self.assertRaises(KeyError): st.nickToAccount('bar') From d63720f2ed286e374205b57002fde0441a91185b Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 2 Jul 2023 10:19:06 +0200 Subject: [PATCH 10/18] s/masks == []/not masks/ --- src/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/conf.py b/src/conf.py index a7a922a05..7e13c1106 100644 --- a/src/conf.py +++ b/src/conf.py @@ -1289,7 +1289,7 @@ class Banmask(registry.SpaceSeparatedSetOfStrings): if (bnick, buser, bhost) == ('*', '*', '*') and \ ircutils.isUserHostmask(hostmask) and \ - masks == []: + not masks: masks.append(hostmask) return masks From 1cbf9920167bfaf43192501c2c06813afb1eaa96 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 2 Jul 2023 10:22:42 +0200 Subject: [PATCH 11/18] Swap arguments of accountExtban --- src/conf.py | 2 +- src/ircutils.py | 2 +- test/test_ircutils.py | 36 ++++++++++++++++++------------------ 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/conf.py b/src/conf.py index 7e13c1106..e47c3a4c4 100644 --- a/src/conf.py +++ b/src/conf.py @@ -1280,7 +1280,7 @@ class Banmask(registry.SpaceSeparatedSetOfStrings): irc = world.getIrc(network) if irc is None: continue - extban = ircutils.accountExtban(nick, irc) + extban = ircutils.accountExtban(irc, nick) if extban is not None: masks.append(extban) diff --git a/src/ircutils.py b/src/ircutils.py index ca7c26f56..2dbe69ce6 100644 --- a/src/ircutils.py +++ b/src/ircutils.py @@ -346,7 +346,7 @@ def banmask(hostmask): return '*!*@' + host -def accountExtban(nick, irc): +def accountExtban(irc, nick): """If 'nick' is logged in and the network supports account extbans, returns a ban mask for it. If not, returns None.""" if 'ACCOUNTEXTBAN' not in irc.state.supported: diff --git a/test/test_ircutils.py b/test/test_ircutils.py index a9c40a6f6..59363e73f 100644 --- a/test/test_ircutils.py +++ b/test/test_ircutils.py @@ -377,48 +377,48 @@ class FunctionsTestCase(SupyTestCase): with self.subTest('spec example'): irc.state.supported['ACCOUNTEXTBAN'] = 'a,account' irc.state.supported['EXTBAN'] = '~,abc' - self.assertEqual(ircutils.accountExtban('foo', irc), + self.assertEqual(ircutils.accountExtban(irc, 'foo'), '~a:account1') - self.assertIsNone(ircutils.accountExtban('bar', irc)) - self.assertIsNone(ircutils.accountExtban('baz', irc)) + self.assertIsNone(ircutils.accountExtban(irc, 'bar')) + self.assertIsNone(ircutils.accountExtban(irc, 'baz')) with self.subTest('InspIRCd'): irc.state.supported['ACCOUNTEXTBAN'] = 'account,R' irc.state.supported['EXTBAN'] = ',abcR' - self.assertEqual(ircutils.accountExtban('foo', irc), + self.assertEqual(ircutils.accountExtban(irc, 'foo'), 'account:account1') - self.assertIsNone(ircutils.accountExtban('bar', irc)) - self.assertIsNone(ircutils.accountExtban('baz', irc)) + self.assertIsNone(ircutils.accountExtban(irc, 'bar')) + self.assertIsNone(ircutils.accountExtban(irc, 'baz')) with self.subTest('Solanum'): irc.state.supported['ACCOUNTEXTBAN'] = 'a' irc.state.supported['EXTBAN'] = '$,abc' - self.assertEqual(ircutils.accountExtban('foo', irc), + self.assertEqual(ircutils.accountExtban(irc, 'foo'), '$a:account1') - self.assertIsNone(ircutils.accountExtban('bar', irc)) - self.assertIsNone(ircutils.accountExtban('baz', irc)) + self.assertIsNone(ircutils.accountExtban(irc, 'bar')) + self.assertIsNone(ircutils.accountExtban(irc, 'baz')) with self.subTest('UnrealIRCd'): irc.state.supported['ACCOUNTEXTBAN'] = 'account,a' irc.state.supported['EXTBAN'] = '~,abc' - self.assertEqual(ircutils.accountExtban('foo', irc), + self.assertEqual(ircutils.accountExtban(irc, 'foo'), '~account:account1') - self.assertIsNone(ircutils.accountExtban('bar', irc)) - self.assertIsNone(ircutils.accountExtban('baz', irc)) + self.assertIsNone(ircutils.accountExtban(irc, 'bar')) + self.assertIsNone(ircutils.accountExtban(irc, 'baz')) with self.subTest('no ACCOUNTEXTBAN'): irc.state.supported.pop('ACCOUNTEXTBAN') irc.state.supported['EXTBAN'] = '~,abc' - self.assertIsNone(ircutils.accountExtban('foo', irc)) - self.assertIsNone(ircutils.accountExtban('bar', irc)) - self.assertIsNone(ircutils.accountExtban('baz', irc)) + self.assertIsNone(ircutils.accountExtban(irc, 'foo')) + self.assertIsNone(ircutils.accountExtban(irc, 'bar')) + self.assertIsNone(ircutils.accountExtban(irc, 'baz')) with self.subTest('no EXTBAN'): irc.state.supported['ACCOUNTEXTBAN'] = 'account,a' irc.state.supported.pop('EXTBAN') - self.assertIsNone(ircutils.accountExtban('foo', irc)) - self.assertIsNone(ircutils.accountExtban('bar', irc)) - self.assertIsNone(ircutils.accountExtban('baz', irc)) + self.assertIsNone(ircutils.accountExtban(irc, 'foo')) + self.assertIsNone(ircutils.accountExtban(irc, 'bar')) + self.assertIsNone(ircutils.accountExtban(irc, 'baz')) def testSeparateModes(self): From d5af301db1b0fc3f953c453a139f9588eaeaba97 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sat, 8 Jul 2023 16:41:21 +0200 Subject: [PATCH 12/18] Avoid listing all permutations --- plugins/Channel/test.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/plugins/Channel/test.py b/plugins/Channel/test.py index bd716767c..7fd0c0b85 100644 --- a/plugins/Channel/test.py +++ b/plugins/Channel/test.py @@ -29,8 +29,6 @@ # POSSIBILITY OF SUCH DAMAGE. ### -import itertools - from supybot.test import * import supybot.conf as conf @@ -165,10 +163,10 @@ class ChannelTestCase(ChannelPluginTestCase): def assertKban(self, query, *hostmasks, **kwargs): m = self.getMsg(query, **kwargs) - self.assertIn(m, [ - ircmsgs.bans(self.channel, permutation) - for permutation in itertools.permutations(hostmasks) - ]) + self.assertEqual(m.command, "MODE", m) + self.assertEqual(m.args[0], self.channel, m) + self.assertEqual(m.args[1], "+" + "b" * len(hostmasks), m) + self.assertCountEqual(m.args[2:], hostmasks, m) m = self.getMsg(' ') self.assertEqual(m.command, 'KICK') From 21a3fa0b86b2066fb690a882c8ca2fdb6b1888ae Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Wed, 12 Jul 2023 17:24:05 +0200 Subject: [PATCH 13/18] makeExtBanmasks: Log invalid options --- src/conf.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/conf.py b/src/conf.py index e47c3a4c4..555550473 100644 --- a/src/conf.py +++ b/src/conf.py @@ -1283,6 +1283,11 @@ class Banmask(registry.SpaceSeparatedSetOfStrings): extban = ircutils.accountExtban(irc, nick) if extban is not None: masks.append(extban) + else: + from . import log + log.warning( + "Unknown mask option passed to makeExtBanmasks: %r", + option) if add_star_mask and (bnick, buser, bhost) != ('*', '*', '*'): masks.append(ircutils.joinHostmask(bnick, buser, bhost)) From cadc8f93ab68a6fad11e85c027f6abc84e6fb888 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Fri, 19 Jul 2024 13:11:56 +0200 Subject: [PATCH 14/18] Cowardly refuse to ban oneself with an account extban --- plugins/Channel/plugin.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/plugins/Channel/plugin.py b/plugins/Channel/plugin.py index 5d8afa167..65cbecc8c 100644 --- a/plugins/Channel/plugin.py +++ b/plugins/Channel/plugin.py @@ -399,9 +399,10 @@ class Channel(callbacks.Plugin): if not reason: reason = msg.nick capability = ircdb.makeChannelCapability(channel, 'op') + # Check (again) that they're not trying to make us kickban ourself. + self_account_extban = ircutils.accountExtban(irc, irc.nick) for banmask in banmasks: - # TODO: check account ban too if ircutils.hostmaskPatternEqual(banmask, irc.prefix): if ircutils.hostmaskPatternEqual(bannedHostmask, irc.prefix): self.log.warning('%q tried to make me kban myself.',msg.prefix) @@ -411,6 +412,13 @@ class Channel(callbacks.Plugin): self.log.warning('Using exact hostmask since banmask would ' 'ban myself.') banmasks = [bannedHostmask] + elif self_account_extban is not None \ + and banmask.lower() == self_account_extban.lower(): + self.log.warning('%q tried to make me kban myself.',msg.prefix) + irc.error(_('I cowardly refuse to ban myself.')) + return + + # Now, let's actually get to it. Check to make sure they have # #channel,op and the bannee doesn't have #channel,op; or that the # bannee and the banner are both the same person. From cf63674f7cf8e9c822736045875e913706732a44 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Fri, 19 Jul 2024 13:20:28 +0200 Subject: [PATCH 15/18] Fix parenthesis in docstring --- src/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/conf.py b/src/conf.py index a517d3a4c..9ba866119 100644 --- a/src/conf.py +++ b/src/conf.py @@ -1250,8 +1250,8 @@ class Banmask(registry.SpaceSeparatedSetOfStrings): Depending on the options and configuration, this may return a mask in the format of an extban (eg. "~account:foobar" on UnrealIRCd). - If this is unwanted (eg. to pass to ircdb's ignore lists, use - :meth:`makeBanmask` instead) + If this is unwanted (eg. to pass to ircdb's ignore lists), use + :meth:`makeBanmask` instead. options - A list specifying which parts of the hostmask should explicitly be matched: nick, user, host. If 'exact' is given, then From 54f7b5a5b64e0551a27dc73dc0c561052a2576e8 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Fri, 19 Jul 2024 13:34:18 +0200 Subject: [PATCH 16/18] When only --account is provided, fallback to supybot.protocols.irc.banmask before exact mask --- plugins/Channel/test.py | 24 ++++++++++++++++++++++-- src/conf.py | 11 +++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/plugins/Channel/test.py b/plugins/Channel/test.py index 7fd0c0b85..aa11982eb 100644 --- a/plugins/Channel/test.py +++ b/plugins/Channel/test.py @@ -237,7 +237,17 @@ class ChannelTestCase(ChannelPluginTestCase): 'foobar!user@host.domain.tld') join() self.assertKban('kban --account foobar', - 'foobar!user@host.domain.tld') + '*!*@host.domain.tld') + join() + with conf.supybot.protocols.irc.banmask.context(['user', 'host']): + # falls back from --account to config + self.assertKban('kban --account foobar', + '*!user@host.domain.tld') + join() + with conf.supybot.protocols.irc.banmask.context(['account']): + # falls back from --account to config, then to exact hostmask + self.assertKban('kban --account foobar', + 'foobar!user@host.domain.tld') join() self.assertKban('kban --account --host foobar', '*!*@host.domain.tld') @@ -259,7 +269,17 @@ class ChannelTestCase(ChannelPluginTestCase): 'foobar!user@host.domain.tld') join() self.assertKban('kban --account foobar', - 'foobar!user@host.domain.tld') + '*!*@host.domain.tld') + join() + with conf.supybot.protocols.irc.banmask.context(['user', 'host']): + # falls back from --account to config + self.assertKban('kban --account foobar', + '*!user@host.domain.tld') + join() + with conf.supybot.protocols.irc.banmask.context(['account']): + # falls back from --account to config, then to exact hostmask + self.assertKban('kban --account foobar', + 'foobar!user@host.domain.tld') join() self.assertKban('kban --account --host foobar', '*!*@host.domain.tld') diff --git a/src/conf.py b/src/conf.py index 9ba866119..ed5c7d4d8 100644 --- a/src/conf.py +++ b/src/conf.py @@ -1302,6 +1302,17 @@ class Banmask(registry.SpaceSeparatedSetOfStrings): if add_star_mask and (bnick, buser, bhost) != ('*', '*', '*'): masks.append(ircutils.joinHostmask(bnick, buser, bhost)) + if (bnick, buser, bhost) == ('*', '*', '*') and \ + options == ['account'] and \ + not masks: + # found no ban mask to set (because options == ['account'] and user + # is logged out?), try again with the default ban mask + options = supybot.protocols.irc.banmask.getSpecific( + network, channel)() + options = [option for option in options if option != 'account'] + return self.makeExtBanmasks( + hostmask, options=options, channel=channel, network=network) + if (bnick, buser, bhost) == ('*', '*', '*') and \ ircutils.isUserHostmask(hostmask) and \ not masks: From 917e3019bcefee6afad18dd08f8e19b914bb9749 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Fri, 19 Jul 2024 16:41:12 +0200 Subject: [PATCH 17/18] Fall back to banning host instead of exact mask This only happens on the newly introduced account extban (in case the user does not have an account, or the server does not provide accounts) so this does not change existing behavior. Falling back to the host instead of the exact mask makes it less easy to evade these bans --- plugins/Channel/test.py | 8 ++++---- src/conf.py | 7 ++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/Channel/test.py b/plugins/Channel/test.py index aa11982eb..48f3efe51 100644 --- a/plugins/Channel/test.py +++ b/plugins/Channel/test.py @@ -245,9 +245,9 @@ class ChannelTestCase(ChannelPluginTestCase): '*!user@host.domain.tld') join() with conf.supybot.protocols.irc.banmask.context(['account']): - # falls back from --account to config, then to exact hostmask + # falls back from --account to config, then to only the host self.assertKban('kban --account foobar', - 'foobar!user@host.domain.tld') + '*!*@host.domain.tld') join() self.assertKban('kban --account --host foobar', '*!*@host.domain.tld') @@ -277,9 +277,9 @@ class ChannelTestCase(ChannelPluginTestCase): '*!user@host.domain.tld') join() with conf.supybot.protocols.irc.banmask.context(['account']): - # falls back from --account to config, then to exact hostmask + # falls back from --account to config, then to only the host self.assertKban('kban --account foobar', - 'foobar!user@host.domain.tld') + '*!*@host.domain.tld') join() self.assertKban('kban --account --host foobar', '*!*@host.domain.tld') diff --git a/src/conf.py b/src/conf.py index ed5c7d4d8..f090816b3 100644 --- a/src/conf.py +++ b/src/conf.py @@ -1316,7 +1316,12 @@ class Banmask(registry.SpaceSeparatedSetOfStrings): if (bnick, buser, bhost) == ('*', '*', '*') and \ ircutils.isUserHostmask(hostmask) and \ not masks: - masks.append(hostmask) + # still no ban mask found, fallback to the host, if any + if host != '*': + masks.append(ircutils.joinHostmask('*', '*', host)) + else: + # if no host, fall back to the exact mask provided + masks.append(hostmask) return masks From be3dae35584de16e8f4c8c8faa39681048bd922b Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Fri, 19 Jul 2024 16:41:46 +0200 Subject: [PATCH 18/18] Add test the bot won't account-extban itself --- plugins/Channel/test.py | 24 ++++++++++++++++++++++++ src/test.py | 3 ++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/plugins/Channel/test.py b/plugins/Channel/test.py index 48f3efe51..bcfc52786 100644 --- a/plugins/Channel/test.py +++ b/plugins/Channel/test.py @@ -189,6 +189,30 @@ class ChannelTestCase(ChannelPluginTestCase): self.assertBan('iban $a:nyuszika7h', '$a:nyuszika7h') self.assertNotError('unban $a:nyuszika7h') + def testWontIbanItself(self): + self.irc.state.supported['ACCOUNTEXTBAN'] = 'a,account' + self.irc.state.supported['EXTBAN'] = '~,abc' + + self.irc.feedMsg(ircmsgs.join(self.channel, + prefix='foobar!user@host.domain.tld')) + self.irc.feedMsg(ircmsgs.op(self.channel, self.irc.nick)) + + # not authenticated, falls back to hostname and notices the match + self.assertError('iban --account ' + self.nick) + + self.irc.feedMsg(ircmsgs.IrcMsg(prefix=self.prefix, command='ACCOUNT', + args=['botaccount'])) + + # notices the matching account + self.assertError('iban --account ' + self.nick) + + self.irc.feedMsg(ircmsgs.IrcMsg(prefix='othernick!otheruser@otherhost', + command='ACCOUNT', + args=['botaccount'])) + + # ditto + self.assertError('iban --account othernick') + def testKban(self): self.irc.prefix = 'something!else@somehwere.else' self.irc.nick = 'something' diff --git a/src/test.py b/src/test.py index 08e43ba07..5fa48028b 100644 --- a/src/test.py +++ b/src/test.py @@ -318,7 +318,8 @@ class PluginTestCase(SupyTestCase): raise TimeoutError(query) if lastGetHelp not in m.args[1]: self.assertTrue(m.args[1].startswith('Error:'), - '%r did not error: %s' % (query, m.args[1])) + '%r did not error: %s' % + (query, ' '.join(m.args[1:]))) return m def assertSnarfError(self, query, **kwargs):