diff --git a/beryllia/__init__.py b/beryllia/__init__.py index 4d0ab40..3531832 100644 --- a/beryllia/__init__.py +++ b/beryllia/__init__.py @@ -3,24 +3,34 @@ from json import loads as json_loads from re import compile as re_compile from shlex import split as shlex_split -from typing import Dict, List, Optional, Sequence, Tuple +from typing import Dict, List, Optional, Sequence, Set, Tuple from irctokens import build, hostmask as hostmask_parse, Hostmask, Line from ircrobots import Bot as BaseBot from ircrobots import Server as BaseServer -from ircstates.numerics import RPL_ENDOFMOTD, ERR_NOMOTD, RPL_WELCOME, RPL_YOUREOPER from ircrobots.ircv3 import Capability +from ircstates.numerics import RPL_ENDOFMOTD, ERR_NOMOTD, RPL_WELCOME, RPL_YOUREOPER +from .common import NickUserHost, User from .config import Config from .database import Database -from .database.common import NickUserHost from .database.kline import DBKLine from .normalise import RFC1459SearchNormaliser -from .util import oper_up, pretty_delta, get_statsp, get_klines -from .util import try_parse_cidr, try_parse_ip, try_parse_ts -from .util import looks_like_glob, colourise +from .util import ( + colourise, + get_klines, + get_links, + get_masktrace, + get_statsp, + looks_like_glob, + oper_up, + pretty_delta, + try_parse_cidr, + try_parse_ip, + try_parse_ts, +) from .parse.nickserv import NickServParser from .parse.snote import SnoteParser @@ -55,6 +65,8 @@ def __init__(self, bot: BaseBot, name: str, config: Config): self._config = config self._database_init: bool = False + self._links: Dict[str, Set[str]] = {} + self._users: Dict[str, User] = {} def set_throttle(self, rate: int, time: float): # turn off throttling @@ -130,9 +142,22 @@ async def line_read(self, line: Line): self._database_init = True self._nickserv = NickServParser(database) - self._snote = SnoteParser(database, self._config.rejects, self._kline_new) + self._snote = SnoteParser( + self, + database, + self._users, + self._links, + self._config.rejects, + self._kline_new, + ) elif line.command == RPL_YOUREOPER: + self._links.clear() + self._links.update(await get_links(self)) + + self._users.clear() + self._users.update(await get_masktrace(self)) + # B connections rejected due to k-line # F far cliconn # c near cliconn diff --git a/beryllia/common.py b/beryllia/common.py new file mode 100644 index 0000000..f2bfda2 --- /dev/null +++ b/beryllia/common.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from ipaddress import IPv4Address, IPv6Address +from typing import Optional, Union + + +class NickUserHost: + # nick user host + def nuh(self) -> str: + raise NotImplementedError() + + +@dataclass +class User(NickUserHost): + nickname: str + username: str + realname: str + hostname: str + account: Optional[str] + ip: Optional[Union[IPv4Address, IPv6Address]] + server: str + + def nuh(self) -> str: + return f"{self.nickname}!{self.username}@{self.hostname}" diff --git a/beryllia/database/cliconn.py b/beryllia/database/cliconn.py index 53b34e3..2bad669 100644 --- a/beryllia/database/cliconn.py +++ b/beryllia/database/cliconn.py @@ -4,13 +4,14 @@ from ipaddress import IPv4Network, IPv6Network from typing import Any, Optional, Sequence, Tuple, Union -from .common import NickUserHost, Table +from .common import Table +from ..common import User from ..normalise import SearchType from ..util import glob_to_sql, lex_glob_pattern @dataclass -class Cliconn(NickUserHost): +class Cliconn(User): nickname: str username: str realname: str diff --git a/beryllia/database/common.py b/beryllia/database/common.py index 86bc2ae..3234ecb 100644 --- a/beryllia/database/common.py +++ b/beryllia/database/common.py @@ -6,12 +6,6 @@ from ..util import CompositeString, CompositeStringText -class NickUserHost: - # nick user host - def nuh(self) -> str: - raise NotImplementedError() - - @dataclass class Table(object): pool: Pool diff --git a/beryllia/database/kline_kill.py b/beryllia/database/kline_kill.py index dcf38c4..c64f399 100644 --- a/beryllia/database/kline_kill.py +++ b/beryllia/database/kline_kill.py @@ -4,7 +4,8 @@ from ipaddress import IPv4Network, IPv6Network from typing import Any, Collection, Optional, Sequence, Tuple, Union -from .common import NickUserHost, Table +from .common import Table +from ..common import NickUserHost from ..normalise import SearchType from ..util import lex_glob_pattern, glob_to_sql diff --git a/beryllia/parse/snote.py b/beryllia/parse/snote.py index a4f4097..e27574e 100644 --- a/beryllia/parse/snote.py +++ b/beryllia/parse/snote.py @@ -10,15 +10,19 @@ Match, Optional, Pattern, + Set, Tuple, Union, ) from irctokens import Line +from ircrobots import Server from .common import IRCParser +from ..common import User from ..database import Database from ..database.cliconn import Cliconn +from ..util import get_links, get_masktrace _TYPE_HANDLER = Callable[[Any, str, Match], Awaitable[None]] _HANDLERS: List[Tuple[Pattern, _TYPE_HANDLER]] = [] @@ -37,17 +41,23 @@ def _inner(func: _TYPE_HANDLER) -> _TYPE_HANDLER: class SnoteParser(IRCParser): def __init__( self, + server: Server, database: Database, + users: Dict[str, User], + links: Dict[str, Set[str]], kline_reject_max: int, kline_new: Callable[[int], Awaitable[None]], ): super().__init__() + self._server = server self._database = database + self._users = users + self._links = links self._kline_reject_max = kline_reject_max self._kline_new = kline_new - self._cliconns: Dict[str, Tuple[int, Cliconn]] = {} + self._cliconns: Dict[str, int] = {} self._kline_waiting_exit: Dict[str, str] = {} async def handle(self, line: Line) -> None: @@ -104,7 +114,8 @@ async def _handle_cliconn(self, server: str, match: Match) -> None: datetime.utcnow(), ) cliconn_id = await self._database.cliconn.add(cliconn) - self._cliconns[nickname] = (cliconn_id, cliconn) + self._cliconns[nickname] = cliconn_id + self._users[nickname] = cliconn @_handler( r""" @@ -134,7 +145,10 @@ async def _handle_cliexit(self, server: str, match: Match) -> None: cliconn_id: Optional[int] = None if nickname in self._cliconns: - cliconn_id, _ = self._cliconns.pop(nickname) + cliconn_id = self._cliconns.pop(nickname) + + if nickname in self._users: + del self._users[nickname] await self._database.cliexit.add( cliconn_id, nickname, username, hostname, ip, reason @@ -210,12 +224,16 @@ async def _handle_klinerej(self, server: str, match: Match) -> None: ) async def _handle_nickchg(self, server: str, match: Match) -> None: old_nick = match.group("old_nick") + new_nick = match.group("new_nick") + + user = self._users.pop(old_nick) + self._users[new_nick] = user + if not old_nick in self._cliconns: return - new_nick = match.group("new_nick") - cliconn_id, cliconn = self._cliconns.pop(old_nick) - self._cliconns[new_nick] = (cliconn_id, cliconn) + cliconn_id = self._cliconns.pop(old_nick) + self._cliconns[new_nick] = cliconn_id await self._database.nick_change.add(cliconn_id, new_nick) @_handler( @@ -310,3 +328,58 @@ async def _handle_klinedel(self, server: str, match: Match) -> None: return await self._database.kline_remove.add(id, source, oper) + + @_handler( + r""" + ^ + # "*** Notice --" + \*{3}\ Notice\ -- + # " Netsplit silver.libera.chat <-> tungsten.libera.chat" + \ Netsplit\ (?P\S+)\ <->\ (?P\S+) + # " (1S 2000C) (by jess: jess)" + \ .* + $ + """ + ) + async def _handle_netsplit(self, server: str, match: Match) -> None: + server_near = match.group("near") + if not server_near in self._links: + # this should only happen when something splits during burst + return + + server_far = match.group("far") + + self._links[server_near].remove(server_far) + + servers_gone_list = [server_far] + server_i = 0 + while server_i < len(servers_gone_list): + server_gone = servers_gone_list[server_i] + servers_gone_list.extend(self._links.pop(server_gone)) + server_i += 1 + + servers_gone = set(servers_gone_list) + for nickname, user in list(self._users.items()): + if not user.server in servers_gone: + continue + + del self._users[nickname] + + @_handler( + r""" + ^ + # "*** Notice --" + \*{3}\ Notice\ -- + # " Netjoin" + \ Netjoin + # " silver.libera.chat <-> tungsten.libera.chat (1S 2000C) + \ .* + $ + """ + ) + async def _handle_netjoin(self, server: str, match: Match) -> None: + self._links.clear() + self._links.update(await get_links(self._server)) + + self._users.clear() + self._users.update(await get_masktrace(self._server)) diff --git a/beryllia/util.py b/beryllia/util.py index 0c889d1..45ce6f0 100644 --- a/beryllia/util.py +++ b/beryllia/util.py @@ -4,7 +4,7 @@ from ipaddress import ip_address, IPv4Address, IPv6Address from ipaddress import ip_network, IPv4Network, IPv6Network -from typing import Deque, List, Optional, Sequence, Set, Tuple, Union +from typing import Dict, Deque, List, Optional, Sequence, Set, Tuple, Union from ircrobots import Server from irctokens import build @@ -16,9 +16,15 @@ from aiodns import DNSResolver from aiodns.error import DNSError +from .common import User + # not in ircstates.numerics RPL_STATS = "249" RPL_ENDOFSTATS = "219" +RPL_LINKS = "364" +RPL_ENDOFLINKS = "365" +RPL_TRACEEND = "262" +RPL_ETRACE = "709" RE_OPERNAME = re.compile(r"^is opered as (\S+)(?:,|$)") @@ -150,6 +156,59 @@ async def get_klines(server: Server) -> Optional[Set[str]]: return masks +async def get_masktrace(server: Server) -> Dict[str, User]: + users: Dict[str, User] = {} + + await server.send(build("MASKTRACE", ["!*@*"])) + while True: + line = await server.wait_for( + { + Response(RPL_ETRACE, [SELF, ANY, ANY, ANY, ANY, ANY, ANY, ANY]), + Response(RPL_TRACEEND, [SELF]), + } + ) + if line.command == RPL_TRACEEND: + break + + nickname = line.params[3] + ip: Optional[Union[IPv4Address, IPv6Address]] = None + if not (ip_str := line.params[6]) == "0": + ip = ip_address(ip_str) + + user = User( + nickname, + line.params[4], + line.params[7], + line.params[5], + None, + ip, + line.params[2], + ) + users[nickname] = user + return users + + +async def get_links(server: Server) -> Dict[str, Set[str]]: + links: Dict[str, Set[str]] = {} + + await server.send(build("LINKS")) + while True: + line = await server.wait_for( + { + Response(RPL_LINKS, [SELF, ANY, ANY]), + Response(RPL_ENDOFLINKS, [SELF]), + } + ) + if line.command == RPL_ENDOFLINKS: + break + + server_far, server_near = line.params[1:3] + links[server_far] = set() + if not server_far == server_near: + links[server_near].add(server_far) + return links + + def try_parse_ip(ip: str) -> Optional[Union[IPv4Address, IPv6Address]]: try: