From 7ee3c562d1d4846dda5a5e85b30a9d9684cda642 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 27 Jun 2021 14:42:13 +0200 Subject: [PATCH] Run Atheme with InspIRCd, to enable tests depending on SASL --- .github/workflows/inspircd.yml | 3 + irctest/basecontrollers.py | 5 + irctest/cases.py | 4 + irctest/controllers/atheme_services.py | 182 +++++++++++++++++++++ irctest/controllers/charybdis.py | 3 + irctest/controllers/ergo.py | 8 + irctest/controllers/hybrid.py | 1 + irctest/controllers/inspircd.py | 55 ++++++- irctest/controllers/mammon.py | 1 + irctest/server_tests/test_account_tag.py | 2 + irctest/server_tests/test_bouncer.py | 2 + irctest/server_tests/test_chathistory.py | 2 + irctest/server_tests/test_confusables.py | 2 + irctest/server_tests/test_extended_join.py | 2 + irctest/server_tests/test_regressions.py | 10 +- irctest/server_tests/test_sasl.py | 34 ++++ irctest/server_tests/test_user_commands.py | 2 + irctest/server_tests/test_znc_playback.py | 2 + 18 files changed, 307 insertions(+), 13 deletions(-) create mode 100644 irctest/controllers/atheme_services.py diff --git a/.github/workflows/inspircd.yml b/.github/workflows/inspircd.yml index 8cec2cc..c3499f2 100644 --- a/.github/workflows/inspircd.yml +++ b/.github/workflows/inspircd.yml @@ -31,6 +31,9 @@ jobs: python -m pip install --upgrade pip pip install pytest -r requirements.txt + - name: Install atheme + run: sudo apt-get install atheme-services + - name: Checkout InspIRCd uses: actions/checkout@v2 with: diff --git a/irctest/basecontrollers.py b/irctest/basecontrollers.py index c383fcc..f4bd2a4 100644 --- a/irctest/basecontrollers.py +++ b/irctest/basecontrollers.py @@ -178,6 +178,7 @@ class BaseServerController(_BaseController): _port_wait_interval = 0.1 port_open = False port: int + hostname: str def run( self, @@ -186,6 +187,7 @@ class BaseServerController(_BaseController): *, password: Optional[str], ssl: bool, + run_services: bool, valid_metadata_keys: Optional[Set[str]], invalid_metadata_keys: Optional[Set[str]], ) -> None: @@ -219,3 +221,6 @@ class BaseServerController(_BaseController): self.port_open = True except Exception: continue + + def wait_for_services(self) -> None: + pass diff --git a/irctest/cases.py b/irctest/cases.py index 7ca7f86..dedcedf 100644 --- a/irctest/cases.py +++ b/irctest/cases.py @@ -458,6 +458,7 @@ class BaseServerTestCase( valid_metadata_keys: Set[str] = set() invalid_metadata_keys: Set[str] = set() server_support: Optional[Dict[str, Optional[str]]] + run_services = False def setUp(self) -> None: super().setUp() @@ -470,6 +471,7 @@ class BaseServerTestCase( valid_metadata_keys=self.valid_metadata_keys, invalid_metadata_keys=self.invalid_metadata_keys, ssl=self.ssl, + run_services=self.run_services, ) self.clients: Dict[TClientName, client_mock.ClientMock] = {} @@ -484,6 +486,8 @@ class BaseServerTestCase( """Connects a client to the server and adds it to the dict. If 'name' is not given, uses the lowest unused non-negative integer.""" self.controller.wait_for_port() + if self.run_services: + self.controller.wait_for_services() if not name: new_name: int = ( max( diff --git a/irctest/controllers/atheme_services.py b/irctest/controllers/atheme_services.py new file mode 100644 index 0000000..9454092 --- /dev/null +++ b/irctest/controllers/atheme_services.py @@ -0,0 +1,182 @@ +import os +import subprocess +import time +from typing import IO, Any, List, Optional + +try: + from typing import Protocol +except ImportError: + # Python < 3.8 + from typing_extensions import Protocol # type: ignore + +import irctest +from irctest.basecontrollers import DirectoryBasedController +import irctest.cases +from irctest.client_mock import ClientMock +from irctest.irc_utils.message_parser import Message +import irctest.runner + +TEMPLATE_CONFIG = """ +loadmodule "modules/protocol/inspircd"; +loadmodule "modules/backend/opensex"; +loadmodule "modules/crypto/pbkdf2"; + +loadmodule "modules/nickserv/main"; +loadmodule "modules/nickserv/cert"; +loadmodule "modules/nickserv/register"; +loadmodule "modules/nickserv/verify"; + +loadmodule "modules/saslserv/authcookie"; +#loadmodule "modules/saslserv/ecdh-x25519-challenge"; +loadmodule "modules/saslserv/ecdsa-nist256p-challenge"; +loadmodule "modules/saslserv/external"; +loadmodule "modules/saslserv/plain"; +#loadmodule "modules/saslserv/scram"; + +serverinfo {{ + name = "services.example.org"; + desc = "Atheme IRC Services"; + numeric = "00A"; + netname = "testnet"; + adminname = "no admin"; + adminemail = "no-admin@example.org"; + registeremail = "registration@example.org"; + auth = none; // Disable email check +}}; + +general {{ + commit_interval = 5; +}}; + +uplink "irc.example.com" {{ + host = "{server_hostname}"; + port = {server_port}; + send_password = "password"; + receive_password = "password"; +}}; + +saslserv {{ + nick = "SaslServ"; +}}; +""" + + +class _Controller(Protocol): + # Magic class to make mypy accept AthemeServices as a mixin without actually + # inheriting. + directory: Optional[str] + hostname: str + port: int + services_proc: subprocess.Popen + + def wait_for_port(self) -> None: + ... + + def open_file(self, name: str, mode: str = "a") -> IO: + ... + + def getNickServResponse(self, client: Any) -> List[Message]: + ... + + +class AthemeServices(DirectoryBasedController): + """Mixin for server controllers that rely on Atheme""" + + def __init__(self, *args, **kwargs): # type: ignore + super().__init__(*args, **kwargs) + self.services_proc = None + + def run_services(self: _Controller, server_hostname: str, server_port: int) -> None: + with self.open_file("services.conf") as fd: + fd.write( + TEMPLATE_CONFIG.format( + server_hostname=server_hostname, + server_port=server_port, + ) + ) + + assert self.directory + self.services_proc = subprocess.Popen( + [ + "atheme-services", + "-n", # don't fork + "-c", + os.path.join(self.directory, "services.conf"), + "-l", + f"/tmp/services-{server_port}.log", + "-p", + os.path.join(self.directory, "services.pid"), + "-D", + self.directory, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + def kill_proc(self) -> None: + super().kill_proc() + if self.services_proc is not None: + self.services_proc.kill() + self.services_proc = None + + def wait_for_services(self: _Controller) -> None: + self.wait_for_port() + + c = ClientMock(name="chkNS", show_io=True) + c.connect(self.hostname, self.port) + c.sendLine("NICK chkNS") + c.sendLine("USER chk chk chk chk") + c.getMessages(synchronize=False) + + msgs: List[Message] = [] + while not msgs: + c.sendLine("PRIVMSG NickServ :HELP") + msgs = self.getNickServResponse(c) + if msgs[0].command == "401": + # NickServ not available yet + pass + elif msgs[0].command == "NOTICE": + # NickServ is available + assert "nickserv" in (msgs[0].prefix or "").lower(), msgs + else: + assert False, f"unexpected reply from NickServ: {msgs[0]}" + + c.sendLine("QUIT") + c.getMessages() + c.disconnect() + + def getNickServResponse(self, client: Any) -> List[Message]: + """Wrapper aroung getMessages() that waits longer, because NickServ + is queried asynchronously.""" + msgs: List[Message] = [] + while not msgs: + time.sleep(0.05) + msgs = client.getMessages() + return msgs + + def registerUser( + self, + case: irctest.cases.BaseServerTestCase, + username: str, + password: Optional[str] = None, + ) -> None: + if not case.run_services: + raise ValueError( + "Attempted to register a nick, but `run_services` it not True." + ) + assert password + if len(password.encode()) > 288: + # It's hardcoded at compile-time :( + # https://github.com/atheme/atheme/blob/4fa0e03bd3ce2cb6041a339f308616580c5aac29/include/atheme/constants.h#L51 + raise irctest.runner.NotImplementedByController("Passwords over 288 bytes") + client = case.addClient(show_io=True) + case.sendLine(client, "NICK " + username) + case.sendLine(client, "USER r e g :user") + while case.getRegistrationMessage(client).command != "001": + pass + case.getMessages(client) + case.sendLine(client, f"PRIVMSG NickServ :REGISTER {password} foo@example.org") + msgs = self.getNickServResponse(case.clients[client]) + assert "900" in {msg.command for msg in msgs}, msgs + case.sendLine(client, "QUIT") + case.assertDisconnected(client) diff --git a/irctest/controllers/charybdis.py b/irctest/controllers/charybdis.py index cb078b4..d604922 100644 --- a/irctest/controllers/charybdis.py +++ b/irctest/controllers/charybdis.py @@ -59,6 +59,7 @@ class CharybdisController(BaseServerController, DirectoryBasedController): *, password: Optional[str], ssl: bool, + run_services: bool, valid_metadata_keys: Optional[Set[str]] = None, invalid_metadata_keys: Optional[Set[str]] = None, ) -> None: @@ -66,6 +67,8 @@ class CharybdisController(BaseServerController, DirectoryBasedController): raise NotImplementedByController( "Defining valid and invalid METADATA keys." ) + if run_services: + raise NotImplementedByController("Registration services") assert self.proc is None self.create_config() self.port = port diff --git a/irctest/controllers/ergo.py b/irctest/controllers/ergo.py index db5f42d..58dfa22 100644 --- a/irctest/controllers/ergo.py +++ b/irctest/controllers/ergo.py @@ -151,6 +151,7 @@ class ErgoController(BaseServerController, DirectoryBasedController): *, password: Optional[str], ssl: bool, + run_services: bool, valid_metadata_keys: Optional[Set[str]] = None, invalid_metadata_keys: Optional[Set[str]] = None, restricted_metadata_keys: Optional[Set[str]] = None, @@ -214,6 +215,13 @@ class ErgoController(BaseServerController, DirectoryBasedController): # XXX: Move this somewhere else when # https://github.com/ircv3/ircv3-specifications/pull/152 becomes # part of the specification + if not case.run_services: + # Ergo does not actually need this, but other controllers do, so we + # are checking it here as well for tests that aren't tested with other + # controllers. + raise ValueError( + "Attempted to register a nick, but `run_services` it not True." + ) client = case.addClient(show_io=False) case.sendLine(client, "CAP LS 302") case.sendLine(client, "NICK " + username) diff --git a/irctest/controllers/hybrid.py b/irctest/controllers/hybrid.py index 49f3375..99b3963 100644 --- a/irctest/controllers/hybrid.py +++ b/irctest/controllers/hybrid.py @@ -56,6 +56,7 @@ class HybridController(BaseServerController, DirectoryBasedController): *, password: Optional[str], ssl: bool, + run_services: bool, valid_metadata_keys: Optional[Set[str]] = None, invalid_metadata_keys: Optional[Set[str]] = None, ) -> None: diff --git a/irctest/controllers/inspircd.py b/irctest/controllers/inspircd.py index 5b5d49f..fccdb54 100644 --- a/irctest/controllers/inspircd.py +++ b/irctest/controllers/inspircd.py @@ -7,12 +7,38 @@ from irctest.basecontrollers import ( DirectoryBasedController, NotImplementedByController, ) +from irctest.controllers.atheme_services import AthemeServices +from irctest.irc_utils.junkdrawer import find_hostname_and_port TEMPLATE_CONFIG = """ +# Clients: {ssl_config} + + +# Services: + + + + + # Atheme raises a warning when missing + + +# Protocol: + @@ -24,11 +50,11 @@ TEMPLATE_CONFIG = """ # for testing mute extbans # For multi-prefix - + + +# Misc: + """ TEMPLATE_SSL_CONFIG = """ @@ -37,10 +63,12 @@ TEMPLATE_SSL_CONFIG = """ """ -class InspircdController(BaseServerController, DirectoryBasedController): +class InspircdController( + AthemeServices, BaseServerController, DirectoryBasedController +): software_name = "InspIRCd" - supported_sasl_mechanisms: Set[str] = set() - supports_str = False + supported_sasl_mechanisms = {"PLAIN"} + supports_sts = False def create_config(self) -> None: super().create_config() @@ -54,6 +82,7 @@ class InspircdController(BaseServerController, DirectoryBasedController): *, password: Optional[str], ssl: bool, + run_services: bool, valid_metadata_keys: Optional[Set[str]] = None, invalid_metadata_keys: Optional[Set[str]] = None, restricted_metadata_keys: Optional[Set[str]] = None, @@ -64,8 +93,12 @@ class InspircdController(BaseServerController, DirectoryBasedController): ) assert self.proc is None self.port = port + self.hostname = hostname self.create_config() + (services_hostname, services_port) = find_hostname_and_port() + password_field = 'password="{}"'.format(password) if password else "" + if ssl: self.gen_ssl() ssl_config = TEMPLATE_SSL_CONFIG.format( @@ -73,11 +106,14 @@ class InspircdController(BaseServerController, DirectoryBasedController): ) else: ssl_config = "" + with self.open_file("server.conf") as fd: fd.write( TEMPLATE_CONFIG.format( hostname=hostname, port=port, + services_hostname=services_hostname, + services_port=services_port, password_field=password_field, ssl_config=ssl_config, ) @@ -93,6 +129,11 @@ class InspircdController(BaseServerController, DirectoryBasedController): stdout=subprocess.DEVNULL, ) + if run_services: + self.run_services( + server_hostname=services_hostname, server_port=services_port + ) + def get_irctest_controller_class() -> Type[InspircdController]: return InspircdController diff --git a/irctest/controllers/mammon.py b/irctest/controllers/mammon.py index 57cdc35..c437139 100644 --- a/irctest/controllers/mammon.py +++ b/irctest/controllers/mammon.py @@ -88,6 +88,7 @@ class MammonController(BaseServerController, DirectoryBasedController): *, password: Optional[str], ssl: bool, + run_services: bool, valid_metadata_keys: Optional[Set[str]] = None, invalid_metadata_keys: Optional[Set[str]] = None, restricted_metadata_keys: Optional[Set[str]] = None, diff --git a/irctest/server_tests/test_account_tag.py b/irctest/server_tests/test_account_tag.py index bd3fa9c..c107807 100644 --- a/irctest/server_tests/test_account_tag.py +++ b/irctest/server_tests/test_account_tag.py @@ -6,6 +6,8 @@ from irctest import cases class AccountTagTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): + run_services = True + def connectRegisteredClient(self, nick): self.addClient() self.sendLine(2, "CAP LS 302") diff --git a/irctest/server_tests/test_bouncer.py b/irctest/server_tests/test_bouncer.py index 1fcea23..6d60bd2 100644 --- a/irctest/server_tests/test_bouncer.py +++ b/irctest/server_tests/test_bouncer.py @@ -5,6 +5,8 @@ from irctest.patma import ANYSTR, StrRe class Bouncer(cases.BaseServerTestCase): + run_services = True + @cases.mark_specifications("Ergo") def testBouncer(self): """Test basic bouncer functionality.""" diff --git a/irctest/server_tests/test_chathistory.py b/irctest/server_tests/test_chathistory.py index e97566f..9108ce1 100644 --- a/irctest/server_tests/test_chathistory.py +++ b/irctest/server_tests/test_chathistory.py @@ -34,6 +34,8 @@ def validate_chathistory_batch(msgs): class ChathistoryTestCase(cases.BaseServerTestCase): + run_services = True + @staticmethod def config() -> cases.TestCaseControllerConfig: return cases.TestCaseControllerConfig(chathistory=True) diff --git a/irctest/server_tests/test_confusables.py b/irctest/server_tests/test_confusables.py index faacc23..bfe55c6 100644 --- a/irctest/server_tests/test_confusables.py +++ b/irctest/server_tests/test_confusables.py @@ -3,6 +3,8 @@ from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME class ConfusablesTestCase(cases.BaseServerTestCase): + run_services = True + @staticmethod def config() -> cases.TestCaseControllerConfig: return cases.TestCaseControllerConfig( diff --git a/irctest/server_tests/test_extended_join.py b/irctest/server_tests/test_extended_join.py index a408c89..69eea8a 100644 --- a/irctest/server_tests/test_extended_join.py +++ b/irctest/server_tests/test_extended_join.py @@ -6,6 +6,8 @@ from irctest import cases class MetadataTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): + run_services = True + def connectRegisteredClient(self, nick): self.addClient() self.sendLine(2, "CAP LS 302") diff --git a/irctest/server_tests/test_regressions.py b/irctest/server_tests/test_regressions.py index 96d16c7..6ccc3f8 100644 --- a/irctest/server_tests/test_regressions.py +++ b/irctest/server_tests/test_regressions.py @@ -150,18 +150,16 @@ class RegressionsTestCase(cases.BaseServerTestCase): self.addClient(2) self.sendLine(2, "NICK alice") self.sendLine(2, "USER u s e r") - replies = set(msg.command for msg in self.getMessages(2)) - self.assertNotIn(ERR_NICKNAMEINUSE, replies) - self.assertIn(RPL_WELCOME, replies) + m = self.getRegistrationMessage(2) + self.assertMessageMatch(m, command=RPL_WELCOME) self.sendLine(2, "QUIT") self.assertDisconnected(2) self.addClient(3) self.sendLine(3, "NICK ALICE") self.sendLine(3, "USER u s e r") - replies = set(msg.command for msg in self.getMessages(3)) - self.assertNotIn(ERR_NICKNAMEINUSE, replies) - self.assertIn(RPL_WELCOME, replies) + m = self.getRegistrationMessage(3) + self.assertMessageMatch(m, command=RPL_WELCOME) @cases.mark_specifications("RFC1459") def testNickReleaseUnregistered(self): diff --git a/irctest/server_tests/test_sasl.py b/irctest/server_tests/test_sasl.py index c24f436..b4b37c3 100644 --- a/irctest/server_tests/test_sasl.py +++ b/irctest/server_tests/test_sasl.py @@ -5,11 +5,15 @@ from irctest.patma import ANYSTR class RegistrationTestCase(cases.BaseServerTestCase): + run_services = True + def testRegistration(self): self.controller.registerUser(self, "testuser", "mypassword") class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): + run_services = True + @cases.mark_specifications("IRCv3") @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") def testPlain(self): @@ -50,6 +54,34 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): fail_msg="Unexpected reply to correct SASL authentication: {msg}", ) + @cases.mark_specifications("IRCv3") + @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") + def testPlainNonAscii(self): + password = "é" * 100 + authstring = base64.b64encode( + b"\x00".join([b"foo", b"foo", password.encode()]) + ).decode() + self.controller.registerUser(self, "foo", password) + self.addClient() + self.requestCapabilities(1, ["sasl"], skip_if_cap_nak=False) + self.sendLine(1, "AUTHENTICATE PLAIN") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, + command="AUTHENTICATE", + params=["+"], + fail_msg="Sent “AUTHENTICATE PLAIN”, server should have " + "replied with “AUTHENTICATE +”, but instead sent: {msg}", + ) + self.sendLine(1, "AUTHENTICATE " + authstring) + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, + command="900", + params=[ANYSTR, ANYSTR, "foo", ANYSTR], + fail_msg="Unexpected reply to correct SASL authentication: {msg}", + ) + @cases.mark_specifications("IRCv3") @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") def testPlainNoAuthzid(self): @@ -127,6 +159,8 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): self.requestCapabilities(1, ["sasl"], skip_if_cap_nak=False) self.sendLine(1, "AUTHENTICATE FOO") m = self.getRegistrationMessage(1) + while m.command == "908": # RPL_SASLMECHS + m = self.getRegistrationMessage(1) self.assertMessageMatch( m, command="904", diff --git a/irctest/server_tests/test_user_commands.py b/irctest/server_tests/test_user_commands.py index adac1f7..abfd5a3 100644 --- a/irctest/server_tests/test_user_commands.py +++ b/irctest/server_tests/test_user_commands.py @@ -14,6 +14,8 @@ from irctest.numerics import ( class WhoisTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): + run_services = True + @cases.mark_specifications("RFC2812") def testWhoisUser(self): """Test basic WHOIS behavior""" diff --git a/irctest/server_tests/test_znc_playback.py b/irctest/server_tests/test_znc_playback.py index bf3420b..a742ecc 100644 --- a/irctest/server_tests/test_znc_playback.py +++ b/irctest/server_tests/test_znc_playback.py @@ -14,6 +14,8 @@ def extract_playback_privmsgs(messages): class ZncPlaybackTestCase(cases.BaseServerTestCase): + run_services = True + @staticmethod def config() -> cases.TestCaseControllerConfig: return cases.TestCaseControllerConfig(chathistory=True)