irctest/irctest/basecontrollers.py

440 lines
15 KiB
Python
Raw Permalink Normal View History

from __future__ import annotations
import contextlib
import dataclasses
import json
2015-12-19 22:09:06 +00:00
import os
from pathlib import Path
2015-12-19 22:09:06 +00:00
import shutil
import socket
2021-02-22 18:04:23 +00:00
import subprocess
2015-12-19 22:09:06 +00:00
import tempfile
import textwrap
import time
from typing import IO, Any, Callable, Dict, Iterator, List, Optional, Set, Tuple, Type
import irctest
2015-12-19 22:09:06 +00:00
from . import authentication, tls
from .client_mock import ClientMock
from .irc_utils.filelock import FileLock
2021-08-10 16:15:45 +00:00
from .irc_utils.junkdrawer import find_hostname_and_port
from .irc_utils.message_parser import Message
from .runner import NotImplementedByController
2015-12-20 12:12:54 +00:00
2021-02-22 18:02:13 +00:00
class ProcessStopped(Exception):
"""Raised when the controlled process stopped unexpectedly"""
pass
@dataclasses.dataclass
class TestCaseControllerConfig:
"""Test-case-specific configuration passed to the controller.
This is usually used to ask controllers to enable a feature;
but should not be an issue if controllers enable it all the time."""
chathistory: bool = False
"""Whether to enable chathistory features."""
account_registration_before_connect: bool = False
"""Whether draft/account-registration should be allowed before completing
connection registration (NICK + USER + CAP END)"""
account_registration_requires_email: bool = False
"""Whether an email address must be provided when using draft/account-registration.
This does not imply servers must validate it."""
2021-05-27 03:55:21 +00:00
ergo_roleplay: bool = False
"""Whether to enable the Ergo role-play commands."""
2021-05-27 03:55:21 +00:00
ergo_config: Optional[Callable[[Dict], Any]] = None
"""Oragono-specific configuration function that alters the dict in-place
This should be used as little as possible, using the other attributes instead;
as they are work with any controller."""
2015-12-19 00:11:57 +00:00
class _BaseController:
2015-12-20 12:47:30 +00:00
"""Base class for software controllers.
A software controller is an object that handles configuring and running
a process (eg. a server or a client), as well as sending it instructions
that are not part of the IRC specification."""
2021-02-22 18:02:13 +00:00
# set by conftest.py
openssl_bin: str
supports_sts: bool
supported_sasl_mechanisms: Set[str]
proc: Optional[subprocess.Popen]
_used_ports_path = Path(tempfile.gettempdir()) / "irctest_ports.json"
_port_lock = FileLock(Path(tempfile.gettempdir()) / "irctest_ports.json.lock")
def __init__(self, test_config: TestCaseControllerConfig):
self.test_config = test_config
self.proc = None
self._own_ports: Set[Tuple[str, int]] = set()
@contextlib.contextmanager
def _used_ports(self) -> Iterator[Set[Tuple[str, int]]]:
with self._port_lock:
if not self._used_ports_path.exists():
self._used_ports_path.write_text("[]")
used_ports = {
(h, p) for (h, p) in json.loads(self._used_ports_path.read_text())
}
yield used_ports
self._used_ports_path.write_text(json.dumps(list(used_ports)))
def get_hostname_and_port(self) -> Tuple[str, int]:
with self._used_ports() as used_ports:
while True:
(hostname, port) = find_hostname_and_port()
if (hostname, port) not in used_ports:
# double-checking in self._used_ports to prevent collisions
# between controllers starting at the same time.
break
used_ports.add((hostname, port))
self._own_ports.add((hostname, port))
return (hostname, port)
def check_is_alive(self) -> None:
assert self.proc
self.proc.poll()
if self.proc.returncode is not None:
2023-07-22 20:12:44 +00:00
raise ProcessStopped(f"process returned {self.proc.returncode}")
2015-12-19 00:11:57 +00:00
def kill_proc(self) -> None:
2015-12-20 12:47:30 +00:00
"""Terminates the controlled process, waits for it to exit, and
eventually kills it."""
assert self.proc
self.proc.terminate()
try:
self.proc.wait(5)
except subprocess.TimeoutExpired:
self.proc.kill()
2015-12-20 00:48:56 +00:00
self.proc = None
2021-02-22 18:02:13 +00:00
def kill(self) -> None:
2015-12-20 12:47:30 +00:00
"""Calls `kill_proc` and cleans the configuration."""
2015-12-19 22:09:06 +00:00
if self.proc:
2015-12-20 00:48:56 +00:00
self.kill_proc()
with self._used_ports() as used_ports:
for hostname, port in list(self._own_ports):
used_ports.remove((hostname, port))
self._own_ports.remove((hostname, port))
class DirectoryBasedController(_BaseController):
"""Helper for controllers whose software configuration is based on an
arbitrary directory."""
directory: Optional[Path]
def __init__(self, test_config: TestCaseControllerConfig):
super().__init__(test_config)
self.directory = None
def kill(self) -> None:
"""Calls `kill_proc` and cleans the configuration."""
super().kill()
2015-12-19 22:09:06 +00:00
if self.directory:
shutil.rmtree(self.directory)
2021-02-22 18:02:13 +00:00
def terminate(self) -> None:
"""Stops the process gracefully, and does not clean its config."""
assert self.proc
self.proc.terminate()
self.proc.wait()
self.proc = None
def open_file(self, name: str, mode: str = "a") -> IO:
2015-12-20 12:47:30 +00:00
"""Open a file in the configuration directory."""
2015-12-19 22:09:06 +00:00
assert self.directory
if os.sep in name:
dir_ = self.directory / os.path.dirname(name)
dir_.mkdir(parents=True, exist_ok=True)
assert dir_.is_dir()
return (self.directory / name).open(mode)
2021-02-22 18:02:13 +00:00
def create_config(self) -> None:
if not self.directory:
self.directory = Path(tempfile.mkdtemp())
2015-12-19 22:09:06 +00:00
def gen_ssl(self) -> None:
assert self.directory
self.csr_path = self.directory / "ssl.csr"
self.key_path = self.directory / "ssl.key"
self.pem_path = self.directory / "ssl.pem"
self.dh_path = self.directory / "dh.pem"
2021-02-22 18:02:13 +00:00
subprocess.check_output(
[
self.openssl_bin,
"req",
"-new",
"-newkey",
"rsa",
"-nodes",
"-out",
self.csr_path,
"-keyout",
self.key_path,
"-batch",
],
stderr=subprocess.DEVNULL,
)
subprocess.check_output(
[
self.openssl_bin,
"x509",
"-req",
"-in",
self.csr_path,
"-signkey",
self.key_path,
"-out",
self.pem_path,
],
stderr=subprocess.DEVNULL,
)
with self.dh_path.open("w") as fd:
fd.write(
textwrap.dedent(
"""
-----BEGIN DH PARAMETERS-----
MIGHAoGBAJICSyQAiLj1fw8b5xELcnpqBQ+wvOyKgim4IetWOgZnRQFkTgOeoRZD
HksACRFJL/EqHxDKcy/2Ghwr2axhNxSJ+UOBmraP3WfodV/fCDPnZ+XnI9fjHsIr
rjisPMqomjXeiTB1UeAHvLUmCK4yx6lpAJsCYwJjsqkycUfHiy1bAgEC
-----END DH PARAMETERS-----
"""
)
)
2021-02-22 18:02:13 +00:00
2015-12-25 14:45:06 +00:00
2015-12-19 00:11:57 +00:00
class BaseClientController(_BaseController):
2015-12-20 12:47:30 +00:00
"""Base controller for IRC clients."""
2021-02-22 18:02:13 +00:00
def run(
self,
hostname: str,
port: int,
auth: Optional[authentication.Authentication],
tls_config: Optional[tls.TlsConfig] = None,
) -> None:
2015-12-19 00:11:57 +00:00
raise NotImplementedError()
2021-02-22 18:02:13 +00:00
2015-12-19 00:11:57 +00:00
class BaseServerController(_BaseController):
2015-12-20 12:47:30 +00:00
"""Base controller for IRC server."""
2021-02-22 18:02:13 +00:00
software_name: str # Class property
2021-02-22 18:02:13 +00:00
_port_wait_interval = 0.1
port_open = False
port: int
hostname: str
services_controller: Optional[BaseServicesController] = None
services_controller_class: Type[BaseServicesController]
extban_mute_char: Optional[str] = None
"""Character used for the 'mute' extban"""
2021-08-08 23:47:18 +00:00
nickserv = "NickServ"
sync_sleep_time = 0.0
"""How many seconds to sleep before clients synchronously get messages.
This can be 0 for servers answering all commands in order (all but Sable as of
this writing), as irctest emits a PING, waits for a PONG, and captures all messages
between the two."""
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self.faketime_enabled = False
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
faketime: Optional[str],
) -> None:
2015-12-20 12:12:54 +00:00
raise NotImplementedError()
2021-02-22 18:02:13 +00:00
def registerUser(
self,
case: irctest.cases.BaseServerTestCase, # type: ignore
username: str,
password: Optional[str] = None,
) -> None:
if self.services_controller is not None:
self.services_controller.registerUser(case, username, password)
else:
raise NotImplementedByController("account registration")
2021-02-22 18:02:13 +00:00
def wait_for_port(self) -> None:
started_at = time.time()
while not self.port_open:
self.check_is_alive()
time.sleep(self._port_wait_interval)
2020-09-13 10:47:50 +00:00
try:
2021-02-22 18:02:13 +00:00
c = socket.create_connection(("localhost", self.port), timeout=1.0)
c.setsockopt(socket.SOL_TCP, socket.TCP_NODELAY, 1)
# Make sure the server properly processes the disconnect.
# Otherwise, it may still count it in LUSER and fail tests in
# test_lusers.py (eg. this happens with Charybdis 3.5.0)
2021-08-11 16:39:19 +00:00
c.sendall(b"QUIT :chkport\r\n")
data = b""
try:
while b"chkport" not in data and b"ERROR" not in data:
data += c.recv(4096)
time.sleep(0.01)
c.send(b" ") # Triggers BrokenPipeError
except (BrokenPipeError, ConnectionResetError):
# ircu2 cuts the connection without a message if registration
# is not complete.
pass
except socket.timeout:
# irc2 just keeps it open
pass
2020-09-13 10:47:50 +00:00
c.close()
self.port_open = True
except ConnectionRefusedError:
if time.time() - started_at >= 60:
# waited for 60 seconds, giving up
raise
def wait_for_services(self) -> None:
assert self.services_controller
self.services_controller.wait_for_services()
def terminate(self) -> None:
if self.services_controller is not None:
self.services_controller.terminate() # type: ignore
super().terminate() # type: ignore
def kill(self) -> None:
if self.services_controller is not None:
self.services_controller.kill() # type: ignore
super().kill()
class BaseServicesController(_BaseController):
def __init__(
self,
test_config: TestCaseControllerConfig,
server_controller: BaseServerController,
):
super().__init__(test_config)
self.test_config = test_config
self.server_controller = server_controller
self.services_up = False
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
raise NotImplementedError("BaseServerController.run()")
def wait_for_services(self) -> None:
if self.services_up:
# Don't check again if they are already available
return
self.server_controller.wait_for_port()
c = ClientMock(name="chkNS", show_io=True)
c.connect(self.server_controller.hostname, self.server_controller.port)
c.sendLine("NICK chkNS")
c.sendLine("USER chk chk chk chk")
time.sleep(self.server_controller.sync_sleep_time)
got_end_of_motd = False
while not got_end_of_motd:
for msg in c.getMessages(synchronize=False):
if msg.command == "PING":
# Hi Unreal
c.sendLine("PONG :" + msg.params[0])
if msg.command in ("376", "422"): # RPL_ENDOFMOTD / ERR_NOMOTD
got_end_of_motd = True
timeout = time.time() + 10
while True:
2022-11-14 21:58:30 +00:00
c.sendLine(f"PRIVMSG {self.server_controller.nickserv} :help")
msgs = self.getNickServResponse(c, timeout=1)
for msg in msgs:
if msg.command == "401":
# NickServ not available yet
pass
elif msg.command in ("MODE", "221"): # RPL_UMODEIS
pass
elif msg.command == "396": # RPL_VISIBLEHOST
pass
elif msg.command == "NOTICE":
assert msg.prefix is not None
if "!" not in msg.prefix and "." in msg.prefix:
# Server notice
pass
else:
# NickServ is available
assert "nickserv" in (msg.prefix or "").lower(), msg
break
else:
assert False, f"unexpected reply from NickServ: {msg}"
else:
if time.time() > timeout:
raise Exception("Timeout while waiting for NickServ")
continue
# If we're here, it means we broke from the for loop, so NickServ
# is available and we can break again
break
c.sendLine("QUIT")
c.getMessages()
c.disconnect()
self.services_up = True
2022-11-14 21:58:30 +00:00
def getNickServResponse(self, client: Any, timeout: int = 0) -> List[Message]:
"""Wrapper aroung getMessages() that waits longer, because NickServ
is queried asynchronously."""
msgs: List[Message] = []
2022-11-14 21:58:30 +00:00
start_time = time.time()
while not msgs and (not timeout or start_time + timeout > time.time()):
time.sleep(0.05)
msgs = client.getMessages()
return msgs
def registerUser(
self,
case: irctest.cases.BaseServerTestCase, # type: ignore
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
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)
2021-08-08 23:47:18 +00:00
case.sendLine(
client,
f"PRIVMSG {self.server_controller.nickserv} "
f":REGISTER {password} foo@example.org",
)
msgs = self.getNickServResponse(case.clients[client])
if self.server_controller.software_name == "inspircd":
assert "900" in {msg.command for msg in msgs}, msgs
assert "NOTICE" in {msg.command for msg in msgs}, msgs
case.sendLine(client, "QUIT")
case.assertDisconnected(client)