12 Commits

15 changed files with 290 additions and 63 deletions

View File

@ -19,7 +19,7 @@ jobs:
python-version: 3.11
- name: Cache dependencies
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: |
~/.cache

View File

@ -120,6 +120,8 @@ jobs:
path: ircd-hybrid
ref: 8.2.x
repository: ircd-hybrid/ircd-hybrid
- name: Install system dependencies
run: sudo apt-get install atheme-services faketime libjansson-dev
- name: Build Hybrid
run: |
cd $GITHUB_WORKSPACE/ircd-hybrid/
@ -558,7 +560,7 @@ jobs:
repository: ergochat/ergo
- uses: actions/setup-go@v3
with:
go-version: ^1.23.0
go-version: ^1.24.0
- run: go version
- name: Build Ergo
run: |

View File

@ -161,6 +161,8 @@ jobs:
path: ircd-hybrid
ref: 8.2.39
repository: ircd-hybrid/ircd-hybrid
- name: Install system dependencies
run: sudo apt-get install atheme-services faketime libjansson-dev
- name: Build Hybrid
run: |
cd $GITHUB_WORKSPACE/ircd-hybrid/
@ -636,7 +638,7 @@ jobs:
repository: ergochat/ergo
- uses: actions/setup-go@v3
with:
go-version: ^1.23.0
go-version: ^1.24.0
- run: go version
- name: Build Ergo
run: |

View File

@ -66,8 +66,13 @@ options {{
warningtimeout = 4h
}}
module {{ name = "{module_prefix}sasl" }}
module {{ name = "enc_bcrypt" }}
module {{ name = "ns_sasl" }} # since 2.1.13
module {{ name = "sasl" }} # 2.1.2 to 2.1.12
module {{ name = "m_sasl" }} # 2.0 to 2.1.1
module {{ name = "enc_sha2" }} # 2.1
module {{ name = "enc_sha256" }} # 2.0
module {{ name = "ns_cert" }}
"""
@ -123,7 +128,6 @@ class AnopeController(BaseServicesController, DirectoryBasedController):
protocol=protocol,
server_hostname=server_hostname,
server_port=server_port,
module_prefix="" if self.software_version >= (2, 1, 2) else "m_",
)
)

View File

@ -60,7 +60,7 @@ privset "omnioper" {{
privs = oper:general, oper:privs, oper:testline, oper:kill, oper:operwall, oper:message,
oper:routing, oper:kline, oper:unkline, oper:xline,
oper:resv, oper:cmodes, oper:mass_notice, oper:wallops,
oper:remoteban,
oper:remoteban, oper:local_kill,
usermode:servnotice, auspex:oper, auspex:hostname, auspex:umodes, auspex:cmodes,
oper:admin, oper:die, oper:rehash, oper:spy, oper:grant;
}};

View File

@ -19,7 +19,7 @@ TEMPLATE_CONFIG = """
<class
name="ServerOperators"
commands="WALLOPS GLOBOPS"
commands="WALLOPS GLOBOPS KILL"
privs="channels/auspex users/auspex channels/auspex servers/auspex"
>
<type

View File

@ -24,7 +24,7 @@ Y:10:90::100:512000:100.100:100.100:
I::{password_field}:::10::
# O:<TARGET Host NAME>:<Password>:<Nickname>:<Port>:<Class>:<Flags>:
O:*:operpassword:operuser::::
O:*:operpassword:operuser:::K:
"""

View File

@ -14,6 +14,7 @@ from irctest.basecontrollers import (
NotImplementedByController,
)
from irctest.cases import BaseServerTestCase
from irctest.client_mock import ClientMock
from irctest.exceptions import NoMessageException
from irctest.patma import ANYSTR
@ -496,6 +497,53 @@ class SableServicesController(BaseServicesController):
os.killpg(self.pgroup_id, signal.SIGKILL)
super().kill_proc()
def wait_for_services(self) -> None:
# by default, wait_for_services() connects a user that sends a HELP command
# to NickServ and assumes services are up when it receives a non-ERR_NOSUCHNICK
# reply.
# However, with Sable, NickServ is always up, even when services are not linked,
# so we need to check a different way. We check presence of a non-EXTERNAL
# value to the sasl capability, but LINKS would
if self.services_up:
# Don't check again if they are already available
return
self.server_controller.wait_for_port()
c = ClientMock(name="chkSvs", show_io=True)
c.connect(self.server_controller.hostname, self.server_controller.port)
c.sendLine("NICK chkSvs")
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 not self.services_up:
if time.time() > timeout:
raise Exception("Timeout while waiting for services")
c.sendLine("CAP LS 302")
msgs = self.getNickServResponse(c, timeout=1)
for msg in msgs:
if msg.command == "CAP":
pass
for token in msg.params[-1].split():
if token.startswith("sasl="):
if "PLAIN" in token.removeprefix("sasl=").split(","):
# SASL PLAIN is available, so services are linked.
self.services_up = True
break
c.sendLine("QUIT")
c.getMessages()
c.disconnect()
def get_irctest_controller_class() -> Type[SableController]:
return SableController

View File

@ -12,9 +12,8 @@ from irctest.patma import ANYSTR, StrRe
@cases.mark_services
class BouncerTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def testBouncer(self):
"""Test basic bouncer functionality."""
def setUp(self):
super().setUp()
self.controller.registerUser(self, "observer", "observerpassword")
self.controller.registerUser(self, "testuser", "mypassword")
@ -33,13 +32,27 @@ class BouncerTestCase(cases.BaseServerTestCase):
self.sendLine(2, "USER a 0 * a")
self.sendLine(2, "CAP REQ :server-time message-tags")
self.sendLine(2, "CAP END")
messages = self.getMessages(2)
welcomes = [message for message in messages if message.command == RPL_WELCOME]
self.assertEqual(len(welcomes), 1)
# should see a regburst for testnick
self.assertMessageMatch(welcomes[0], params=["testnick", ANYSTR])
self._waitForWelcome(2, "testnick")
self.joinChannel(2, "#chan")
def _waitForWelcome(self, client, nick):
"""Waits for numeric 001, and returns every message after that."""
while True:
messages = iter(self.getMessages(client, synchronize=False))
for message in messages:
if message.command == "001":
# should see a regburst for testnick
self.assertMessageMatch(message, params=[nick, ANYSTR])
messages_after_welcome = list(messages)
messages_after_welcome += self.getMessages(client)
# check there is no other 001
for message in messages_after_welcome:
self.assertNotEqual(message.command, "001")
return messages_after_welcome
def _connectClient3(self):
self.addClient()
self.sendLine(3, "CAP LS 302")
self.sendLine(3, "AUTHENTICATE PLAIN")
@ -48,49 +61,37 @@ class BouncerTestCase(cases.BaseServerTestCase):
self.sendLine(3, "USER a 0 * a")
self.sendLine(3, "CAP REQ :server-time message-tags account-tag")
self.sendLine(3, "CAP END")
messages = self.getMessages(3)
welcomes = [message for message in messages if message.command == RPL_WELCOME]
self.assertEqual(len(welcomes), 1)
# should see the *same* regburst for testnick
self.assertMessageMatch(welcomes[0], params=["testnick", ANYSTR])
messages = self._waitForWelcome(3, "testnick")
joins = [message for message in messages if message.command == "JOIN"]
# we should be automatically joined to #chan
self.assertMessageMatch(joins[0], params=["#chan"])
# disable multiclient in nickserv
self.sendLine(3, "NS SET MULTICLIENT OFF")
self.getMessages(3)
def _connectClient4(self):
# connect a client similar to 3, but without the message-tags and account-tag
# capabilities, to make sure it does not receive the associated tags
self.addClient()
self.sendLine(4, "CAP LS 302")
self.sendLine(4, "AUTHENTICATE PLAIN")
self.sendLine(4, sasl_plain_blob("testuser", "mypassword"))
self.sendLine(4, "NICK testnick")
self.sendLine(4, "USER a 0 * a")
self.sendLine(4, "CAP REQ :server-time message-tags")
self.sendLine(4, "CAP REQ server-time")
self.sendLine(4, "CAP END")
# with multiclient disabled, we should not be able to attach to the nick
messages = self.getMessages(4)
welcomes = [message for message in messages if message.command == RPL_WELCOME]
self.assertEqual(len(welcomes), 0)
errors = [
message for message in messages if message.command == ERR_NICKNAMEINUSE
]
self.assertEqual(len(errors), 1)
# should see the *same* regburst for testnick
self._waitForWelcome(4, "testnick")
self.sendLine(3, "NS SET MULTICLIENT ON")
self.getMessages(3)
self.addClient()
self.sendLine(5, "CAP LS 302")
self.sendLine(5, "AUTHENTICATE PLAIN")
self.sendLine(5, sasl_plain_blob("testuser", "mypassword"))
self.sendLine(5, "NICK testnick")
self.sendLine(5, "USER a 0 * a")
self.sendLine(5, "CAP REQ server-time")
self.sendLine(5, "CAP END")
messages = self.getMessages(5)
welcomes = [message for message in messages if message.command == RPL_WELCOME]
self.assertEqual(len(welcomes), 1)
@cases.mark_specifications("Ergo", "Sable")
def testAutomaticResumption(self):
"""Test logging into an account that already has a client joins the client's session"""
self._connectClient3()
@cases.mark_specifications("Ergo", "Sable")
def testChannelMessageFromOther(self):
"""Test that all clients attached to a session get messages sent by someone else
to a channel"""
self._connectClient3()
self._connectClient4()
self.sendLine(1, "@+clientOnlyTag=Value PRIVMSG #chan :hey")
self.getMessages(1)
@ -104,22 +105,84 @@ class BouncerTestCase(cases.BaseServerTestCase):
self.assertEqual(len(messagesforthree), 1)
messagefortwo = messagesfortwo[0]
messageforthree = messagesforthree[0]
messageforfive = self.getMessage(5)
messageforfour = self.getMessage(4)
self.assertMessageMatch(messagefortwo, params=["#chan", "hey"])
self.assertMessageMatch(messageforthree, params=["#chan", "hey"])
self.assertMessageMatch(messageforfive, params=["#chan", "hey"])
self.assertMessageMatch(messageforfour, params=["#chan", "hey"])
self.assertIn("time", messagefortwo.tags)
self.assertIn("time", messageforthree.tags)
self.assertIn("time", messageforfive.tags)
self.assertIn("time", messageforfour.tags)
# 3 has account-tag
self.assertIn("account", messageforthree.tags)
# should get same msgid
self.assertEqual(messagefortwo.tags["msgid"], messageforthree.tags["msgid"])
# 5 only has server-time, shouldn't get account or msgid tags
self.assertNotIn("account", messageforfive.tags)
self.assertNotIn("msgid", messageforfive.tags)
# 4 only has server-time, shouldn't get account or msgid tags
self.assertNotIn("account", messageforfour.tags)
self.assertNotIn("msgid", messageforfour.tags)
@cases.mark_specifications("Ergo", "Sable")
def testChannelMessageFromSelf(self):
"""Test that all clients attached to a session get messages sent by an other client
(TODO: check when the initial sender has echo-message too)"""
self._connectClient3()
self._connectClient4()
self.sendLine(2, "@+clientOnlyTag=Value PRIVMSG #chan :hey")
messagesfortwo = [
msg for msg in self.getMessages(2) if msg.command == "PRIVMSG"
]
messagesforone = [
msg for msg in self.getMessages(1) if msg.command == "PRIVMSG"
]
messagesforthree = [
msg for msg in self.getMessages(3) if msg.command == "PRIVMSG"
]
self.assertEqual(len(messagesforone), 1)
self.assertEqual(len(messagesfortwo), 0) # echo-message not enabled
self.assertEqual(len(messagesforthree), 1)
messageforone = messagesforone[0]
messageforthree = messagesforthree[0]
messageforfour = self.getMessage(4)
self.assertMessageMatch(messageforone, params=["#chan", "hey"])
self.assertMessageMatch(messageforthree, params=["#chan", "hey"])
self.assertMessageMatch(messageforfour, params=["#chan", "hey"])
self.assertIn("time", messageforone.tags)
self.assertIn("time", messageforthree.tags)
self.assertIn("time", messageforfour.tags)
# 3 has account-tag
self.assertIn("account", messageforthree.tags)
# should get same msgid
self.assertEqual(messageforone.tags["msgid"], messageforthree.tags["msgid"])
# 4 only has server-time, shouldn't get account or msgid tags
self.assertNotIn("account", messageforfour.tags)
self.assertNotIn("msgid", messageforfour.tags)
@cases.mark_specifications("Ergo", "Sable")
def testDirectMessageFromOther(self):
"""Test that all clients attached to a session get copies of messages sent
by an other client of that session directly to an other user"""
self._connectClient3()
self._connectClient4()
self.sendLine(1, "PRIVMSG testnick :this is a direct message")
self.getMessages(1)
messagefortwo = [
msg for msg in self.getMessages(2) if msg.command == "PRIVMSG"
][0]
messageforthree = [
msg for msg in self.getMessages(3) if msg.command == "PRIVMSG"
][0]
self.assertEqual(messagefortwo.params, messageforthree.params)
self.assertEqual(messagefortwo.tags["msgid"], messageforthree.tags["msgid"])
@cases.mark_specifications("Ergo", "Sable")
def testDirectMessageFromSelf(self):
"""Test that all clients attached to a session get copies of messages sent
by an other client of that session directly to an other user"""
self._connectClient3()
self._connectClient4()
# test that copies of sent messages go out to other sessions
self.sendLine(2, "PRIVMSG observer :this is a direct message")
self.getMessages(2)
messageForRecipient = [
@ -133,10 +196,20 @@ class BouncerTestCase(cases.BaseServerTestCase):
messageForRecipient.tags["msgid"], copyForOtherSession.tags["msgid"]
)
@cases.mark_specifications("Ergo", "Sable")
def testQuit(self):
"""Test that a single client of a session does not make the whole user quit
(and is generally not visible to anyone else, not even their other sessions),
until the last client quits"""
self._connectClient3()
self._connectClient4()
self.sendLine(2, "QUIT :two out")
quitLines = [msg for msg in self.getMessages(2) if msg.command == "QUIT"]
quitLines = [
msg for msg in self.getMessages(2) if msg.command in ("QUIT", "ERROR")
]
self.assertEqual(len(quitLines), 1)
self.assertMessageMatch(quitLines[0], params=[StrRe(".*two out.*")])
if quitLines[0].command == "QUIT":
self.assertMessageMatch(quitLines[0], params=[StrRe(".*two out.*")])
# neither the observer nor the other attached session should see a quit here
quitLines = [msg for msg in self.getMessages(1) if msg.command == "QUIT"]
self.assertEqual(quitLines, [])
@ -154,13 +227,39 @@ class BouncerTestCase(cases.BaseServerTestCase):
messagesforthree[0], command="PRIVMSG", params=["#chan", "hey again"]
)
self.sendLine(5, "QUIT :five out")
self.getMessages(5)
self.sendLine(4, "QUIT :four out")
self.getMessages(4)
self.sendLine(3, "QUIT :three out")
quitLines = [msg for msg in self.getMessages(3) if msg.command == "QUIT"]
quitLines = [
msg for msg in self.getMessages(3) if msg.command in ("QUIT", "ERROR")
]
self.assertEqual(len(quitLines), 1)
self.assertMessageMatch(quitLines[0], params=[StrRe(".*three out.*")])
if quitLines[0].command == "QUIT":
self.assertMessageMatch(quitLines[0], params=[StrRe(".*three out.*")])
# observer should see *this* quit
quitLines = [msg for msg in self.getMessages(1) if msg.command == "QUIT"]
self.assertEqual(len(quitLines), 1)
self.assertMessageMatch(quitLines[0], params=[StrRe(".*three out.*")])
@cases.mark_specifications("Ergo")
def testDisableAutomaticResumption(self):
# disable multiclient in nickserv
self.sendLine(2, "NS SET MULTICLIENT OFF")
self.getMessages(2)
self.addClient()
self.sendLine(3, "CAP LS 302")
self.sendLine(3, "AUTHENTICATE PLAIN")
self.sendLine(3, sasl_plain_blob("testuser", "mypassword"))
self.sendLine(3, "NICK testnick")
self.sendLine(3, "USER a 0 * a")
self.sendLine(3, "CAP REQ :server-time message-tags")
self.sendLine(3, "CAP END")
# with multiclient disabled, we should not be able to attach to the nick
messages = self.getMessages(3)
welcomes = [message for message in messages if message.command == RPL_WELCOME]
self.assertEqual(len(welcomes), 0)
errors = [
message for message in messages if message.command == ERR_NICKNAMEINUSE
]
self.assertEqual(len(errors), 1)

View File

@ -0,0 +1,67 @@
"""
The KILL command (`Modern <https://modern.ircdocs.horse/#kill-message>`__)
"""
from irctest import cases
from irctest.numerics import ERR_NOPRIVILEGES, RPL_YOUREOPER
class KillTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Modern")
@cases.xfailIfSoftware(["Sable"], "https://github.com/Libera-Chat/sable/issues/154")
def testKill(self):
self.connectClient("ircop", name="ircop")
self.connectClient("alice", name="alice")
self.connectClient("bob", name="bob")
self.sendLine("ircop", "OPER operuser operpassword")
self.assertIn(
RPL_YOUREOPER,
[m.command for m in self.getMessages("ircop")],
fail_msg="OPER failed",
)
self.sendLine("alice", "KILL bob :some arbitrary reason")
self.assertIn(
ERR_NOPRIVILEGES,
[m.command for m in self.getMessages("alice")],
fail_msg="unprivileged KILL not rejected",
)
# bob is not killed
self.getMessages("bob")
self.sendLine("alice", "KILL alice :some arbitrary reason")
self.assertIn(
ERR_NOPRIVILEGES,
[m.command for m in self.getMessages("alice")],
fail_msg="unprivileged KILL not rejected",
)
# alice is not killed
self.getMessages("alice")
# privileged KILL should succeed
self.sendLine("ircop", "KILL alice :some arbitrary reason")
self.getMessages("ircop")
self.assertDisconnected("alice")
self.sendLine("ircop", "KILL bob :some arbitrary reason")
self.getMessages("ircop")
self.assertDisconnected("bob")
@cases.mark_specifications("Ergo")
def testKillOneArgument(self):
self.connectClient("ircop", name="ircop")
self.connectClient("alice", name="alice")
self.sendLine("ircop", "OPER operuser operpassword")
self.assertIn(
RPL_YOUREOPER,
[m.command for m in self.getMessages("ircop")],
fail_msg="OPER failed",
)
# 1-argument kill command, accepted by Ergo and some implementations
self.sendLine("ircop", "KILL alice")
self.getMessages("ircop")
self.assertDisconnected("alice")

View File

@ -82,7 +82,7 @@ class SaslTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("IRCv3")
@cases.skipUnlessHasMechanism("PLAIN")
def testPlainNonAscii(self):
password = "é" * 30
password = "é" * 100
authstring = base64.b64encode(
b"\x00".join([b"foo", b"foo", password.encode()])
).decode()

View File

@ -9,6 +9,7 @@ class Specifications(enum.Enum):
RFC2812 = "RFC2812"
IRCv3 = "IRCv3" # Mark with capabilities whenever possible
Ergo = "Ergo"
SABLE = "Sable"
Ircdocs = "ircdocs"
"""Any document on ircdocs.horse (especially defs.ircdocs.horse),

View File

@ -1,5 +1,5 @@
[mypy]
python_version = 3.8
python_version = 3.9
warn_return_any = True
warn_unused_configs = True

View File

@ -8,6 +8,7 @@ markers =
modern
ircdocs
Ergo
Sable
# misc marks
strict

View File

@ -33,6 +33,9 @@ software:
devel: "8.2.x"
devel_release: null
path: ircd-hybrid
pre_deps:
- name: "Install system dependencies"
run: "sudo apt-get install atheme-services faketime libjansson-dev"
separate_build_job: true
build_script: |
cd $GITHUB_WORKSPACE/ircd-hybrid/
@ -136,7 +139,7 @@ software:
pre_deps:
- uses: actions/setup-go@v3
with:
go-version: '^1.23.0'
go-version: '^1.24.0'
- run: go version
separate_build_job: false
build_script: |