From 0649e23ae65271dfbd6e187ea49b3240f977679c Mon Sep 17 00:00:00 2001 From: shrapnelnet Date: Thu, 13 Aug 2026 11:35:45 +0100 Subject: [PATCH 1/7] bundle ngircd config with test resources --- .../com/shr4pnel/ferretirc/IrcClientTest.kt | 10 +++- src/test/resources/ngircd.conf | 55 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 src/test/resources/ngircd.conf diff --git a/src/test/kotlin/com/shr4pnel/ferretirc/IrcClientTest.kt b/src/test/kotlin/com/shr4pnel/ferretirc/IrcClientTest.kt index edad587..85a75d1 100644 --- a/src/test/kotlin/com/shr4pnel/ferretirc/IrcClientTest.kt +++ b/src/test/kotlin/com/shr4pnel/ferretirc/IrcClientTest.kt @@ -29,12 +29,16 @@ class IrcClientTest { @BeforeAll fun setup() { val logFile = File("src/test/resources/logs/ngircd.log") + val ngircdConfigPath = javaClass.classLoader.getResource("ngircd.conf")!!.path logFile.createNewFile() processes = ProcessBuilder.startPipeline( listOf( - ProcessBuilder("ngircd", "-dn").redirectErrorStream(true), - ProcessBuilder("ts", "(%H:%M:%S)").redirectErrorStream(true), - ProcessBuilder("sed", "-ue", "s/\\[[^][]*\\]//g").redirectOutput(logFile) + // Run IRC server in [-d] debug log mode, with [-n] no daemon, using config [-f] file at path + ProcessBuilder("ngircd", "-dnf", ngircdConfigPath).redirectErrorStream(true), + // Remove unhelpful default ngircd info [PID. relative time, GID?] + ProcessBuilder("sed", "-ue", "s/\\[[^][]*\\]//g"), + // Add relative timestamp + ProcessBuilder("ts", "-s", "%H:%M:%.S").redirectOutput(logFile) ) ) client.scope.launch { diff --git a/src/test/resources/ngircd.conf b/src/test/resources/ngircd.conf new file mode 100644 index 0000000..969e989 --- /dev/null +++ b/src/test/resources/ngircd.conf @@ -0,0 +1,55 @@ +[GLOBAL] + Name = localhost.host + AdminInfo1 = FerretIRC Testing Server + AdminInfo2 = United Kingdom + AdminEMail = admin@shr4pnel.com + HelpFile = /usr/share/doc/ngircd/Commands.txt + ;Info = ngIRCd 28 + Listen = 127.0.0.1 + ;MotdFile = + MotdPhrase = "happy hunting!" + ;Network = + ;Password = + ;PidFile = + Ports = 6667 + ServerGID = 1000 + ServerUID = 1000 + +[LIMITS] + ConnectRetry = 60 + IdleTimeout = 0 + MaxConnections = 0 + MaxConnectionsIP = 5 + MaxJoins = 10 + MaxNickLength = 9 + MaxPenaltyTime = 0 + MaxListSize = 100 + PingTimeout = 120 + PongTimeout = 20 + +[OPTIONS] + AllowedChannelTypes = #&+ + AllowRemoteOper = no + ChrootDir = + CloakHost = + CloakHostModeX = +; CloakHostSalt = koxDpiu[UIF8gF8oHsmtpF8Z?ftyyC(Y + CloakUserToNick = no + ConnectIPv4 = yes + ConnectIPv6 = yes + DefaultChannelModes = + DefaultUserModes = + DNS = no +; IncludeDir = /etc/ngircd.conf.d + MorePrivacy = no + NoticeBeforeRegistration = no + OperCanUseMode = no + OperChanPAutoOp = yes + OperServerMode = no + PAM = no + PAMIsOptional = yes + PAMServiceName = ngircd + RequireAuthPing = no + ScrubCTCP = no + SyslogFacility = local5 + WebircPassword = From 128f2087dd4b0756edaa2fb0c8dea7114e958efc Mon Sep 17 00:00:00 2001 From: shrapnelnet Date: Fri, 14 Aug 2026 18:36:04 +0100 Subject: [PATCH 2/7] Enable gradle configuration cache --- gradle.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/gradle.properties b/gradle.properties index 7fc6f1f..36704cb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,2 @@ kotlin.code.style=official +org.gradle.configuration-cache=true \ No newline at end of file From 8c3a39521475c2b046524df923342c5a41169431 Mon Sep 17 00:00:00 2001 From: shrapnelnet Date: Fri, 14 Aug 2026 18:37:09 +0100 Subject: [PATCH 3/7] Add OPER, DIE client messages --- .../com/shr4pnel/ferretirc/net/messages/ClientMessage.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/kotlin/com/shr4pnel/ferretirc/net/messages/ClientMessage.kt b/src/main/kotlin/com/shr4pnel/ferretirc/net/messages/ClientMessage.kt index 1231bb1..6146b26 100644 --- a/src/main/kotlin/com/shr4pnel/ferretirc/net/messages/ClientMessage.kt +++ b/src/main/kotlin/com/shr4pnel/ferretirc/net/messages/ClientMessage.kt @@ -63,6 +63,15 @@ sealed class ClientMessage(val strName: String) { override fun toWireIntermediate() = "PASS $password" } + class Oper(val name: String, val password: String): ClientMessage("OPER") { + override fun toWireIntermediate() = "OPER $name $password" + + } + + class Die(): ClientMessage("DIE") { + override fun toWireIntermediate() = "DIE" + } + class UNIMPLEMENTED() : ClientMessage("") { override fun toWireIntermediate() = "" } From f7a2e9c6b6bef37a2962970934dd15798787480a Mon Sep 17 00:00:00 2001 From: shrapnelnet Date: Fri, 14 Aug 2026 18:38:23 +0100 Subject: [PATCH 4/7] Add MODE, PRIVMSG, NOTICE server messages --- .../com/shr4pnel/ferretirc/net/messages/ServerMessage.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/kotlin/com/shr4pnel/ferretirc/net/messages/ServerMessage.kt b/src/main/kotlin/com/shr4pnel/ferretirc/net/messages/ServerMessage.kt index 4eec7a4..e224b10 100644 --- a/src/main/kotlin/com/shr4pnel/ferretirc/net/messages/ServerMessage.kt +++ b/src/main/kotlin/com/shr4pnel/ferretirc/net/messages/ServerMessage.kt @@ -3,5 +3,8 @@ package com.shr4pnel.ferretirc.net.messages sealed class ServerMessage { class Cap() : ServerMessage() class Pong(val token: String? = null) : ServerMessage() + class Mode(val operatorName: String, val pMask: String): ServerMessage() + class PrivMsg(val targets: List, message: String): ServerMessage() + class Notice(val targets: List, message: String): ServerMessage() class UNIMPLEMENTED(val msg: String) : ServerMessage() } \ No newline at end of file From 0b5a370e6e2f546057b4f5cf92f605ba0ff290da Mon Sep 17 00:00:00 2001 From: shrapnelnet Date: Fri, 14 Aug 2026 18:39:50 +0100 Subject: [PATCH 5/7] Listen for new servermessages in MessageParser --- .../shr4pnel/ferretirc/net/MessageParser.kt | 35 +++++++++++++++++-- .../shr4pnel/ferretirc/net/MessageWriter.kt | 2 +- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/shr4pnel/ferretirc/net/MessageParser.kt b/src/main/kotlin/com/shr4pnel/ferretirc/net/MessageParser.kt index 2679c69..badb9e3 100644 --- a/src/main/kotlin/com/shr4pnel/ferretirc/net/MessageParser.kt +++ b/src/main/kotlin/com/shr4pnel/ferretirc/net/MessageParser.kt @@ -2,6 +2,8 @@ package com.shr4pnel.ferretirc.net import com.shr4pnel.ferretirc.net.messages.ServerMessage import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.http.parameters +import io.ktor.network.selector.SelectInterest import io.ktor.util.toUpperCasePreservingASCIIRules import kotlinx.coroutines.channels.Channel @@ -10,22 +12,49 @@ class MessageParser(val incoming: Channel) { val incomingParsedMessages = Channel() private companion object MessageBuilder { + val logger = KotlinLogging.logger("MessageParser.MessageBuilder") + fun getTrailingParameterIndex(params: List) = params.indexOfLast { it.startsWith(":") } + sealed interface Command { fun toServerMessage(): ServerMessage class NamedCommand(val name: String, val parameters: List) : Command { override fun toServerMessage(): ServerMessage { + logger.debug { "Attempting to parse ${name.toUpperCasePreservingASCIIRules()}, with params $parameters" } + return when (name.toUpperCasePreservingASCIIRules()) { // PONG :PREFIX COMMAND HOSTNAME :TOKEN "PONG" -> { - val tokenIndex = parameters.indexOfLast { it.contains(":") } + val tokenIndex = getTrailingParameterIndex(parameters) if (tokenIndex != -1) ServerMessage.Pong( parameters.subList(tokenIndex, parameters.size).joinToString(" ").removePrefix(":") ) else ServerMessage.Pong() } + // MODE :PREFIX NICK :MODES + "MODE" -> { + ServerMessage.Mode(parameters.first(), parameters.last().removePrefix(":")) // TODO MODE, CHANMODE, LOCALMODE + } - else -> ServerMessage.UNIMPLEMENTED("$name ${parameters.joinToString(" ")}") + "NOTICE", "PRIVMSG" -> { + val cmd = name.toUpperCasePreservingASCIIRules() + val trailingIndex = getTrailingParameterIndex(parameters) + if (trailingIndex < 1) { + logger.warn { "Received malformed $cmd. Returning unimplemented as fallback." } + return ServerMessage.UNIMPLEMENTED("$cmd ${parameters.joinToString(" ")}") + } + val targets = parameters.subList(0, trailingIndex - 1) + if (cmd == "NOTICE") + ServerMessage.Notice(targets, "") + else + ServerMessage.PrivMsg(targets, "") + + } + + else -> { + logger.warn { "$name left unparsed" } + ServerMessage.UNIMPLEMENTED("$name ${parameters.joinToString(" ")}") + } } } } @@ -76,7 +105,7 @@ class MessageParser(val incoming: Channel) { suspend fun start() { for (msg in incoming) { - logger.debug { msg } +// logger.debug { msg } incomingParsedMessages.send(build(msg)) } } diff --git a/src/main/kotlin/com/shr4pnel/ferretirc/net/MessageWriter.kt b/src/main/kotlin/com/shr4pnel/ferretirc/net/MessageWriter.kt index f797815..21d339c 100644 --- a/src/main/kotlin/com/shr4pnel/ferretirc/net/MessageWriter.kt +++ b/src/main/kotlin/com/shr4pnel/ferretirc/net/MessageWriter.kt @@ -17,7 +17,7 @@ class MessageWriter(socket: Socket, scope: CoroutineScope) : MessageIO(socket, s send = socket.openWriteChannel() for (msg in outgoingMessages) { - logger.debug { msg.toWireIntermediate() } + logger.debug { "Sending: ${msg.toWireIntermediate()}" } send.writeFully(msg.toWire()) send.flush() } From 06d89db5b52c06332db5c6329eeb8b6996ba5479 Mon Sep 17 00:00:00 2001 From: shrapnelnet Date: Fri, 14 Aug 2026 18:41:27 +0100 Subject: [PATCH 6/7] Add OPERATOR section to NGIRCD test config --- src/test/resources/ngircd.conf | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/test/resources/ngircd.conf b/src/test/resources/ngircd.conf index 969e989..33f1c91 100644 --- a/src/test/resources/ngircd.conf +++ b/src/test/resources/ngircd.conf @@ -53,3 +53,7 @@ ScrubCTCP = no SyslogFacility = local5 WebircPassword = + +[OPERATOR] +Name = shr4p +Password = password \ No newline at end of file From f7943e4289ee1b8520a025bbcf7ad2f16a2850e1 Mon Sep 17 00:00:00 2001 From: shrapnelnet Date: Fri, 14 Aug 2026 18:42:21 +0100 Subject: [PATCH 7/7] Add operator test --- .../com/shr4pnel/ferretirc/IrcClientTest.kt | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/test/kotlin/com/shr4pnel/ferretirc/IrcClientTest.kt b/src/test/kotlin/com/shr4pnel/ferretirc/IrcClientTest.kt index 85a75d1..fccbf61 100644 --- a/src/test/kotlin/com/shr4pnel/ferretirc/IrcClientTest.kt +++ b/src/test/kotlin/com/shr4pnel/ferretirc/IrcClientTest.kt @@ -5,7 +5,6 @@ import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import com.shr4pnel.ferretirc.net.messages.ClientMessage as Message import org.junit.jupiter.api.AfterAll @@ -15,8 +14,6 @@ import java.io.File import java.lang.ProcessBuilder import kotlin.test.assertEquals import kotlin.test.assertNotNull -import kotlin.time.Duration.Companion.microseconds -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class IrcClientTest { @@ -52,7 +49,12 @@ class IrcClientTest { @JvmStatic @AfterAll - fun shutdown() = processes.forEach { it.destroy() } + fun shutdown() = runBlocking { + logger.info { "Closing server." } + client.queueMessage(Message.Die()) + client.waitForNext() + processes.forEach { it.destroy() } + } } @Test @@ -74,5 +76,18 @@ class IrcClientTest { } assertNotNull(pong, "Reached timeout while waiting for PONG") assertEquals(token, pong.token, "Token in PING did not match PONG") + logger.info { "Received PING" } + } + + @Test + fun oper() = runBlocking { + client.queueMessage(Message.Oper("shr4p", "password")) + val mode = withTimeoutOrNull(1.seconds) { + client.waitForNext() + } + assertNotNull(mode, "Timed out waiting for OPER MODE response") + assertEquals("shr4p", mode.operatorName, "Received incorrect operator name in MODE") + assertEquals("+o", mode.pMask, "Received unexpected mask in MODE") + logger.info { "Received MODE with mask ${mode.pMask}" } } }