Run Atheme with InspIRCd, to enable tests depending on SASL

This commit is contained in:
2021-06-27 14:42:13 +02:00
committed by Val Lorentz
parent 48eeeb7312
commit 7ee3c562d1
18 changed files with 307 additions and 13 deletions

View File

@ -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:

View File

@ -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

View File

@ -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(

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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:
<bind address="{hostname}" port="{port}" type="clients">
{ssl_config}
<connect allow="*"
resolvehostnames="no" # Faster
recvq="40960" # Needs to be larger than a valid message with tags
timeout="10" # So tests don't hang too long
{password_field}>
# Services:
<bind address="{services_hostname}" port="{services_port}" type="servers">
<link name="services.example.org"
ipaddr="{services_hostname}"
port="{services_port}"
allowmask="*"
recvpass="password"
sendpass="password"
>
<module name="spanningtree">
<module name="services_account">
<module name="svshold"> # Atheme raises a warning when missing
<sasl requiressl="no"
target="services.example.org">
# Protocol:
<module name="cap">
<module name="ircv3">
<module name="ircv3_accounttag">
<module name="ircv3_batch">
<module name="ircv3_capnotify">
<module name="ircv3_ctctags">
@ -24,11 +50,11 @@ TEMPLATE_CONFIG = """
<module name="monitor">
<module name="m_muteban"> # for testing mute extbans
<module name="namesx"> # For multi-prefix
<connect allow="*"
resolvehostnames="no" # Faster
recvq="40960" # Needs to be larger than a valid message with tags
{password_field}>
<module name="sasl">
# Misc:
<log method="file" type="*" level="debug" target="/tmp/ircd-{port}.log">
<server name="irc.example.com" description="testnet" id="000" network="testnet">
"""
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

View File

@ -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,

View File

@ -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")

View File

@ -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."""

View File

@ -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)

View File

@ -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(

View File

@ -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")

View File

@ -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):

View File

@ -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",

View File

@ -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"""

View File

@ -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)