diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 5cb7ffa6af5a66653b2dfe5ac7a290290b97ca25..7abeb5f85dbfc69a5f896f5d84c611614204c079 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,4 +1,4 @@
-image: "k8r.eu/justjanne/quasseldroid-build-env:30-30.0.3"
+image: "k8r.eu/justjanne/quasseldroid-build-env:1eb7fa8"
 
 cache:
   key: "$CI_PROJECT_NAME"
@@ -31,6 +31,7 @@ build:
 test:
   stage: "test"
   script:
+    - "dockerd-rootless.sh"
     - "./gradlew check -x connectedCheck coberturaTestReport"
   artifacts:
     paths:
diff --git a/app/src/test/java/de/kuschku/quasseldroid/ExampleUnitTest.kt b/app/src/test/java/de/kuschku/quasseldroid/ExampleUnitTest.kt
deleted file mode 100644
index 920911ec3be3d160311679c341dbae119de781ca..0000000000000000000000000000000000000000
--- a/app/src/test/java/de/kuschku/quasseldroid/ExampleUnitTest.kt
+++ /dev/null
@@ -1,137 +0,0 @@
-package de.kuschku.quasseldroid
-
-import de.kuschku.bitflags.of
-import de.kuschku.libquassel.protocol.connection.*
-import de.kuschku.libquassel.protocol.features.FeatureSet
-import de.kuschku.libquassel.protocol.io.ChainedByteBuffer
-import de.kuschku.libquassel.protocol.io.contentToString
-import de.kuschku.libquassel.protocol.messages.handshake.ClientInit
-import de.kuschku.libquassel.protocol.serializers.HandshakeSerializers
-import de.kuschku.libquassel.protocol.serializers.handshake.ClientInitAckSerializer
-import de.kuschku.libquassel.protocol.serializers.handshake.ClientInitRejectSerializer
-import de.kuschku.libquassel.protocol.serializers.handshake.ClientInitSerializer
-import de.kuschku.libquassel.protocol.serializers.primitive.HandshakeMapSerializer
-import de.kuschku.libquassel.protocol.serializers.primitive.IntSerializer
-import de.kuschku.libquassel.protocol.serializers.primitive.UIntSerializer
-import de.kuschku.libquassel.protocol.variant.into
-import de.kuschku.quasseldroid.protocol.io.CoroutineChannel
-import kotlinx.coroutines.runBlocking
-import org.junit.Test
-import java.net.InetSocketAddress
-import java.nio.ByteBuffer
-import java.security.cert.X509Certificate
-import javax.net.ssl.SSLContext
-import javax.net.ssl.X509TrustManager
-
-class ExampleUnitTest {
-
-  @Test
-  fun testNetworking() {
-    val context = SSLContext.getInstance("TLSv1.3")
-    context.init(null, arrayOf(object : X509TrustManager {
-      override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
-        // FIXME: accept everything
-      }
-
-      override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
-        // FIXME: accept everything
-      }
-
-      override fun getAcceptedIssuers(): Array<X509Certificate> {
-        // FIXME: accept nothing
-        return emptyArray()
-      }
-    }), null)
-
-    runBlocking {
-      val connectionFeatureSet = FeatureSet.all()
-      val sizeBuffer = ByteBuffer.allocateDirect(4)
-      val sendBuffer = ChainedByteBuffer(direct = true)
-      val channel = CoroutineChannel()
-      channel.connect(InetSocketAddress("kuschku.de", 4242))
-
-      suspend fun readAmount(amount: Int? = null): Int {
-        if (amount != null) return amount
-
-        sizeBuffer.clear()
-        channel.read(sizeBuffer)
-        sizeBuffer.flip()
-        val size = IntSerializer.deserialize(sizeBuffer, connectionFeatureSet)
-        sizeBuffer.clear()
-        return size
-      }
-
-      suspend fun write(sizePrefix: Boolean = true, f: suspend (ChainedByteBuffer) -> Unit) {
-        f(sendBuffer)
-        if (sizePrefix) {
-          sizeBuffer.clear()
-          sizeBuffer.putInt(sendBuffer.size)
-          sizeBuffer.flip()
-          channel.write(sizeBuffer)
-          sizeBuffer.clear()
-        }
-        channel.write(sendBuffer)
-        channel.flush()
-        sendBuffer.clear()
-      }
-
-      suspend fun <T> read(amount: Int? = null, f: suspend (ByteBuffer) -> T): T {
-        val amount1 = readAmount(amount)
-        val messageBuffer = ByteBuffer.allocateDirect(minOf(amount1, 65 * 1024 * 1024))
-        channel.read(messageBuffer)
-        messageBuffer.flip()
-        return f(messageBuffer)
-      }
-
-      println("Writing protocol")
-      write(sizePrefix = false) {
-        ConnectionHeaderSerializer.serialize(
-          it,
-          ConnectionHeader(
-            features = ProtocolFeature.of(
-              ProtocolFeature.Compression,
-              ProtocolFeature.TLS
-            ),
-            versions = listOf(
-              ProtocolMeta(
-                0x0000u,
-                ProtocolVersion.Datastream,
-              ),
-            )
-          ),
-          connectionFeatureSet
-        )
-      }
-
-      println("Reading protocol")
-      read(4) {
-        println(ProtocolInfoSerializer.deserialize(it, connectionFeatureSet))
-        println(channel.tlsInfo.value)
-        channel.enableTLS(context)
-        println(channel.tlsInfo.value)
-        channel.enableCompression()
-      }
-      println("Writing clientInit")
-      write {
-        HandshakeMapSerializer.serialize(
-          it,
-          ClientInitSerializer.serialize(ClientInit(
-            clientVersion = "Quasseldroid test",
-            buildDate = "Never",
-            clientFeatures = connectionFeatureSet.legacyFeatures(),
-            featureList = connectionFeatureSet.featureList()
-          )),
-          connectionFeatureSet
-        )
-      }
-      read {
-        val data = HandshakeMapSerializer.deserialize(it, connectionFeatureSet)
-        println(data)
-        val msgType: String = data["MsgType"].into("")
-        val message: Any? = HandshakeSerializers[msgType]?.deserialize(data)
-        println(message)
-      }
-    }
-  }
-}
-
diff --git a/build.gradle.kts b/build.gradle.kts
index 82cc8d6930788b8c97c77e71cfed399242656215..94dc53a7b94ee3d15630df98983d3b5bd027ca51 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -43,6 +43,7 @@ allprojects {
   extra["junit4Version"] = "4.13.1"
   extra["junit5Version"] = "5.3.1"
   extra["mdcVersion"] = "1.2.1"
+  extra["testContainersVersion"] = "1.15.1"
 
   repositories {
     google()
diff --git a/libquassel/build.gradle.kts b/libquassel/build.gradle.kts
new file mode 100644
index 0000000000000000000000000000000000000000..3c3971b474ab81bc2c6f469f8320e1d2067155ef
--- /dev/null
+++ b/libquassel/build.gradle.kts
@@ -0,0 +1,38 @@
+plugins {
+  kotlin("jvm")
+  id("jacoco")
+  id("de.kuschku.coverageconverter")
+}
+
+tasks.withType<Test> {
+  useJUnitPlatform()
+}
+
+tasks.getByName<JacocoReport>("jacocoTestReport") {
+  reports {
+    sourceDirectories.from(fileTree("src/main/kotlin"))
+    classDirectories.from(fileTree("build/classes"))
+    xml.destination = File("$buildDir/reports/jacoco/report.xml")
+    html.isEnabled = true
+    xml.isEnabled = true
+    csv.isEnabled = false
+  }
+}
+
+dependencies {
+  implementation(kotlin("stdlib"))
+  implementation("org.jetbrains.kotlinx", "kotlinx-coroutines-core", "1.4.2")
+  implementation("org.jetbrains.kotlinx", "kotlinx-coroutines-test", "1.4.2")
+  api(project(":protocol"))
+
+  val junit5Version: String by project.extra
+  testImplementation("org.junit.jupiter", "junit-jupiter-api", junit5Version)
+  testImplementation("org.junit.jupiter", "junit-jupiter-params", junit5Version)
+  testRuntimeOnly("org.junit.jupiter", "junit-jupiter-engine", junit5Version)
+  val hamcrestVersion: String by project.extra
+  testImplementation("org.hamcrest", "hamcrest-library", hamcrestVersion)
+  val testContainersVersion: String by project.extra
+  testImplementation("org.testcontainers", "testcontainers", testContainersVersion)
+  testImplementation("org.testcontainers", "junit-jupiter", testContainersVersion)
+  testImplementation("org.slf4j", "slf4j-simple", "1.7.30")
+}
diff --git a/app/src/main/java/de/kuschku/quasseldroid/protocol/io/CoroutineChannel.kt b/libquassel/src/main/kotlin/de/kuschku/libquassel/io/CoroutineChannel.kt
similarity index 100%
rename from app/src/main/java/de/kuschku/quasseldroid/protocol/io/CoroutineChannel.kt
rename to libquassel/src/main/kotlin/de/kuschku/libquassel/io/CoroutineChannel.kt
diff --git a/app/src/main/java/de/kuschku/quasseldroid/protocol/io/FixedDeflaterOutputStream.kt b/libquassel/src/main/kotlin/de/kuschku/libquassel/io/FixedDeflaterOutputStream.kt
similarity index 100%
rename from app/src/main/java/de/kuschku/quasseldroid/protocol/io/FixedDeflaterOutputStream.kt
rename to libquassel/src/main/kotlin/de/kuschku/libquassel/io/FixedDeflaterOutputStream.kt
diff --git a/app/src/main/java/de/kuschku/quasseldroid/protocol/io/ReadableWrappedChannel.kt b/libquassel/src/main/kotlin/de/kuschku/libquassel/io/ReadableWrappedChannel.kt
similarity index 95%
rename from app/src/main/java/de/kuschku/quasseldroid/protocol/io/ReadableWrappedChannel.kt
rename to libquassel/src/main/kotlin/de/kuschku/libquassel/io/ReadableWrappedChannel.kt
index cc886b257e83ad7bc64310ac04c6712e13253594..f8009d9377cd73bf713961e3e85f37d1e55770a4 100644
--- a/app/src/main/java/de/kuschku/quasseldroid/protocol/io/ReadableWrappedChannel.kt
+++ b/libquassel/src/main/kotlin/de/kuschku/libquassel/io/ReadableWrappedChannel.kt
@@ -17,9 +17,8 @@
  * with this program. If not, see <http://www.gnu.org/licenses/>.
  */
 
-package de.kuschku.quasseldroid.protocol.io
+package de.kuschku.libquassel.io
 
-import android.util.Log
 import java.io.InputStream
 import java.nio.ByteBuffer
 import java.nio.channels.ReadableByteChannel
@@ -61,7 +60,7 @@ class ReadableWrappedChannel(
         }
 
         if (readData <= 0) {
-          Log.e("ReadableWrappedChannel", "Read: $readData")
+          error("[ReadableWrappedChannel] Read: $readData")
         }
 
         // read is negative if no data was read, in that case, terminate
diff --git a/app/src/main/java/de/kuschku/quasseldroid/protocol/io/StreamChannel.kt b/libquassel/src/main/kotlin/de/kuschku/libquassel/io/StreamChannel.kt
similarity index 97%
rename from app/src/main/java/de/kuschku/quasseldroid/protocol/io/StreamChannel.kt
rename to libquassel/src/main/kotlin/de/kuschku/libquassel/io/StreamChannel.kt
index 28d2697ea1cd7ceca71eebc54f9fda25e72afb5e..382be13d33b33652bab5027e646cf2c81a33be0b 100644
--- a/app/src/main/java/de/kuschku/quasseldroid/protocol/io/StreamChannel.kt
+++ b/libquassel/src/main/kotlin/de/kuschku/libquassel/io/StreamChannel.kt
@@ -19,6 +19,7 @@
 
 package de.kuschku.quasseldroid.protocol.io
 
+import de.kuschku.libquassel.io.ReadableWrappedChannel
 import de.kuschku.quasseldroid.util.TlsInfo
 import java.io.Flushable
 import java.io.InputStream
diff --git a/app/src/main/java/de/kuschku/quasseldroid/protocol/io/WritableWrappedChannel.kt b/libquassel/src/main/kotlin/de/kuschku/libquassel/io/WritableWrappedChannel.kt
similarity index 100%
rename from app/src/main/java/de/kuschku/quasseldroid/protocol/io/WritableWrappedChannel.kt
rename to libquassel/src/main/kotlin/de/kuschku/libquassel/io/WritableWrappedChannel.kt
diff --git a/app/src/main/java/de/kuschku/quasseldroid/util/TlsInfo.kt b/libquassel/src/main/kotlin/de/kuschku/libquassel/util/TlsInfo.kt
similarity index 100%
rename from app/src/main/java/de/kuschku/quasseldroid/util/TlsInfo.kt
rename to libquassel/src/main/kotlin/de/kuschku/libquassel/util/TlsInfo.kt
diff --git a/app/src/main/java/de/kuschku/quasseldroid/util/X509Helper.kt b/libquassel/src/main/kotlin/de/kuschku/libquassel/util/X509Helper.kt
similarity index 100%
rename from app/src/main/java/de/kuschku/quasseldroid/util/X509Helper.kt
rename to libquassel/src/main/kotlin/de/kuschku/libquassel/util/X509Helper.kt
diff --git a/libquassel/src/test/kotlin/de/kuschku/libquassel/EndToEndTest.kt b/libquassel/src/test/kotlin/de/kuschku/libquassel/EndToEndTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5d4454fa2ac9ada37c7ea25817fa8d02f19d577e
--- /dev/null
+++ b/libquassel/src/test/kotlin/de/kuschku/libquassel/EndToEndTest.kt
@@ -0,0 +1,169 @@
+/*
+ * Quasseldroid - Quassel client for Android
+ *
+ * Copyright (c) 2021 Janne Mareike Koschinski
+ * Copyright (c) 2021 The Quassel Project
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 3 as published
+ * by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.kuschku.libquassel
+
+import de.kuschku.bitflags.of
+import de.kuschku.libquassel.protocol.connection.*
+import de.kuschku.libquassel.protocol.features.FeatureSet
+import de.kuschku.libquassel.protocol.io.ChainedByteBuffer
+import de.kuschku.libquassel.protocol.messages.handshake.ClientInit
+import de.kuschku.libquassel.protocol.serializers.HandshakeSerializers
+import de.kuschku.libquassel.protocol.serializers.handshake.ClientInitSerializer
+import de.kuschku.libquassel.protocol.serializers.primitive.HandshakeMapSerializer
+import de.kuschku.libquassel.protocol.serializers.primitive.IntSerializer
+import de.kuschku.libquassel.protocol.variant.into
+import de.kuschku.quasseldroid.protocol.io.CoroutineChannel
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.Before
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.slf4j.LoggerFactory
+import org.testcontainers.containers.BindMode
+import org.testcontainers.containers.output.Slf4jLogConsumer
+import org.testcontainers.junit.jupiter.Container
+import org.testcontainers.junit.jupiter.Testcontainers
+import org.testcontainers.utility.MountableFile
+import java.net.InetSocketAddress
+import java.nio.ByteBuffer
+import javax.net.ssl.SSLContext
+
+@ExperimentalCoroutinesApi
+@Testcontainers
+class EndToEndTest {
+  @Container
+  val quassel = QuasselContainer()
+    .withExposedPorts(4242)
+    .withClasspathResourceMapping("/quasseltest.crt", "/quasseltest.crt", BindMode.READ_WRITE)
+    .withEnv("SSL_CERT_FILE", "/quasseltest.crt")
+    .withClasspathResourceMapping("/quasseltest.key", "/quasseltest.key", BindMode.READ_WRITE)
+    .withEnv("SSL_KEY_FILE", "/quasseltest.key")
+    .withEnv("CONFIG_FROM_ENVIRONMENT", "true")
+    .withEnv("DB_BACKEND", "SQLite")
+    .withEnv("AUTH_AUTHENTICATOR", "Database")
+
+  private val sslContext = SSLContext.getInstance("TLSv1.3").apply {
+    init(null, arrayOf(TestX509TrustManager), null)
+  }
+
+  private val connectionFeatureSet = FeatureSet.all()
+  private val sizeBuffer = ByteBuffer.allocateDirect(4)
+  private val sendBuffer = ChainedByteBuffer(direct = true)
+  private val channel = CoroutineChannel()
+
+  @BeforeEach
+  fun setUp() {
+    quassel.followOutput(Slf4jLogConsumer(LoggerFactory.getLogger(EndToEndTest::class.java)))
+  }
+
+  @Test
+  fun testConnect() = runBlocking {
+    channel.connect(InetSocketAddress(
+      quassel.host,
+      quassel.getMappedPort(4242)
+    ))
+
+    println("Writing protocol")
+    write(sizePrefix = false) {
+      ConnectionHeaderSerializer.serialize(
+        it,
+        ConnectionHeader(
+          features = ProtocolFeature.of(
+            ProtocolFeature.Compression,
+            ProtocolFeature.TLS
+          ),
+          versions = listOf(
+            ProtocolMeta(
+              0x0000u,
+              ProtocolVersion.Datastream,
+            ),
+          )
+        ),
+        connectionFeatureSet
+      )
+    }
+
+    println("Reading protocol")
+    read(4) {
+      val protocol = ProtocolInfoSerializer.deserialize(it, connectionFeatureSet)
+      println(protocol)
+      if (protocol.flags.contains(ProtocolFeature.TLS)) {
+        channel.enableTLS(sslContext)
+      }
+      if (protocol.flags.contains(ProtocolFeature.Compression)) {
+        channel.enableCompression()
+      }
+    }
+    println("Writing clientInit")
+    write {
+      HandshakeMapSerializer.serialize(
+        it,
+        ClientInitSerializer.serialize(ClientInit(
+          clientVersion = "Quasseldroid test",
+          buildDate = "Never",
+          clientFeatures = connectionFeatureSet.legacyFeatures(),
+          featureList = connectionFeatureSet.featureList()
+        )),
+        connectionFeatureSet
+      )
+    }
+    read {
+      val data = HandshakeMapSerializer.deserialize(it, connectionFeatureSet)
+      println(data)
+      val msgType: String = data["MsgType"].into("")
+      val message: Any? = HandshakeSerializers[msgType]?.deserialize(data)
+      println(message)
+    }
+  }
+
+  suspend fun readAmount(amount: Int? = null): Int {
+    if (amount != null) return amount
+
+    sizeBuffer.clear()
+    channel.read(sizeBuffer)
+    sizeBuffer.flip()
+    val size = IntSerializer.deserialize(sizeBuffer, connectionFeatureSet)
+    sizeBuffer.clear()
+    return size
+  }
+
+  suspend fun write(sizePrefix: Boolean = true, f: suspend (ChainedByteBuffer) -> Unit) {
+    f(sendBuffer)
+    if (sizePrefix) {
+      sizeBuffer.clear()
+      sizeBuffer.putInt(sendBuffer.size)
+      sizeBuffer.flip()
+      channel.write(sizeBuffer)
+      sizeBuffer.clear()
+    }
+    channel.write(sendBuffer)
+    channel.flush()
+    sendBuffer.clear()
+  }
+
+  suspend fun <T> read(amount: Int? = null, f: suspend (ByteBuffer) -> T): T {
+    val amount1 = readAmount(amount)
+    val messageBuffer = ByteBuffer.allocateDirect(minOf(amount1, 65 * 1024 * 1024))
+    channel.read(messageBuffer)
+    messageBuffer.flip()
+    return f(messageBuffer)
+  }
+}
diff --git a/libquassel/src/test/kotlin/de/kuschku/libquassel/QuasselContainer.kt b/libquassel/src/test/kotlin/de/kuschku/libquassel/QuasselContainer.kt
new file mode 100644
index 0000000000000000000000000000000000000000..903852c31741d00a15988bb64c7ab745fedb6050
--- /dev/null
+++ b/libquassel/src/test/kotlin/de/kuschku/libquassel/QuasselContainer.kt
@@ -0,0 +1,27 @@
+/*
+ * Quasseldroid - Quassel client for Android
+ *
+ * Copyright (c) 2021 Janne Mareike Koschinski
+ * Copyright (c) 2021 The Quassel Project
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 3 as published
+ * by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.kuschku.libquassel
+
+import org.testcontainers.containers.GenericContainer
+import org.testcontainers.utility.DockerImageName
+
+class QuasselContainer : GenericContainer<QuasselContainer>(
+        DockerImageName.parse("k8r.eu/justjanne/quassel-docker:latest")
+)
diff --git a/libquassel/src/test/kotlin/de/kuschku/libquassel/TestX509TrustManager.kt b/libquassel/src/test/kotlin/de/kuschku/libquassel/TestX509TrustManager.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6d838bf1146e1991a58a2c6df7d6cbc7f7f40812
--- /dev/null
+++ b/libquassel/src/test/kotlin/de/kuschku/libquassel/TestX509TrustManager.kt
@@ -0,0 +1,38 @@
+/*
+ * Quasseldroid - Quassel client for Android
+ *
+ * Copyright (c) 2021 Janne Mareike Koschinski
+ * Copyright (c) 2021 The Quassel Project
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 3 as published
+ * by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.kuschku.libquassel
+
+import java.security.cert.X509Certificate
+import javax.net.ssl.X509TrustManager
+
+object TestX509TrustManager : X509TrustManager {
+  override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
+    // FIXME: accept everything
+  }
+
+  override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
+    // FIXME: accept everything
+  }
+
+  override fun getAcceptedIssuers(): Array<X509Certificate> {
+    // FIXME: accept nothing
+    return emptyArray()
+  }
+}
diff --git a/libquassel/src/test/resources/quasseltest.crt b/libquassel/src/test/resources/quasseltest.crt
new file mode 100644
index 0000000000000000000000000000000000000000..8d0bd31ab63dc21f63718901777d5bf2264bb2ef
--- /dev/null
+++ b/libquassel/src/test/resources/quasseltest.crt
@@ -0,0 +1,30 @@
+-----BEGIN CERTIFICATE-----
+MIIFHzCCAwegAwIBAgIUEV/Rf5DceQhjul2yqHDa/ODr5+YwDQYJKoZIhvcNAQEL
+BQAwHjEcMBoGA1UEAwwTcXVhc3NlbHRlc3QuaW52YWxpZDAgFw0yMTAyMDgyMzM5
+MjRaGA8yMTIxMDExNTIzMzkyNFowHjEcMBoGA1UEAwwTcXVhc3NlbHRlc3QuaW52
+YWxpZDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAM2OZTae4BKzppB2
+nEF2e2oGLl1YGaCfqaVeQjKNS3DY8fjIG9lb6hDzE1MneoGtou7TafWvqh2u1Q8p
+aRNpcW+ftTqcUecGPuMTQHHSWjZJDocvH2vG5xM48u5DqLNUThs7eEoPis7AGpus
+7xm8wmjGokKYZb6WdXGLcOEPLb2ZSQH4MRHkAMtM1zF3/L0bYzRLil9+aQ6y0P3/
+4hRfUiy5ZkIiPS3+fJPOj/ZaABukLtffWicbeAvNDk7Jp2FvoVfXPXU14CXQaV16
+BhfCbWHamUM4d0ZWu85J8Jbon+JRfmIvg+0z4nlZaJ6a23nkO4ljL6IKdwlEUYbM
+uZ4bimCxmk2g3t27DxDBYAeudCQYQjvlA7fYf9b2tBjdvVx7BRyFk09Z+zSWKXVx
+67iL5nURY8iOY+sHF7t/D/vbDx1/nDkXZPxEl1jZlDnu2xCkM8sYgocXsfjIaW3f
+1dcGJ6lr50g69LgTBBuT2Db3Li91nAr3s+V5Urf1CcTMFipNAdeY3qN717LzKsJS
+FpjAtvUJQhPin9Is0AZkYCrDxIYZJYgQZaeDCrXRQwoS8qIhXWbgHXRPUTwgGzmS
+og62+rfwn3JDRDKOGuiYIc8uhBj0mOOV/GBegFg1cskaX+EMvdurx2jyNc6y1JOG
+WJHZ2ctMSHlkm4/vG9lEHoW43HcPAgMBAAGjUzBRMB0GA1UdDgQWBBQ4uKrImjUv
+C/TJhb03I3YJEjgjrjAfBgNVHSMEGDAWgBQ4uKrImjUvC/TJhb03I3YJEjgjrjAP
+BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQCMahqY1LVdSny9oc7B
+AGET5Y/sTdALZN/GslYWjY9iTNx4kXz6u0654onDTBHZ6c+8+dmZdWFrdPTzJuJX
+dB4HqQyY2tLhR3N44F6LRkx97klbvSscMJr3QK4344AtksbEq1BGeDCm5VeaZZJU
+A+WSNXjJC22EFR5RLftdArRquJVcwCDPWI1lf/iv/iXJPpv8Mq5qFpY1Agusi/sv
+PLO6GVV2qPADoI0NjgYCjJ2VVJQeHasFCC+izihmrTQUMc4G/JF9gHMPOLRofm32
+zMQ8LK+iWa9p+VoRbdnsWDw4Fy9s34VXuT5ijAz6KbQ8v9alQv6VfYwnZCrylyKH
+K3vWg7g1tlyMZP7ExzHfu428lVmYZbf1k7GUA+2Qu9s/jPfszwQzdDnr5dN9jCUj
+CIqLXoHZeyIc2Cxu1KMaZvkPVn2/zJUa3RNLfcl+1pGOCwaUd5lcGtdzrk/9lZwR
+zHh9nvQWPAE2r36E7CtOgy0uMIfFdNnDc1iYKU2I9075k+bo7y4zvO9gQx3dmkxU
+SCm2sKQzkScSBPtCBtsfKfN4csL5FyLevl7C5izpQqtChqLZu9XXDNVTE7jd48Wy
+VrIlBGHwGkksmYUqALI81bvBaOZ1GytAIMbRNQ8YRLIPvjuL/CorfqrR5kEKXqsd
+eZw3w5qGASg6Xk0d9OxKyjHKeQ==
+-----END CERTIFICATE-----
diff --git a/libquassel/src/test/resources/quasseltest.key b/libquassel/src/test/resources/quasseltest.key
new file mode 100644
index 0000000000000000000000000000000000000000..1a2218ebdb96f7af5e9018a7d883e85dccdd927b
--- /dev/null
+++ b/libquassel/src/test/resources/quasseltest.key
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+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
+jwfYNzNlv1pDyVGAaI8oMDXRMaZ3
+-----END PRIVATE KEY-----
diff --git a/protocol/build.gradle.kts b/protocol/build.gradle.kts
index 493af5722008bb8819ad3b0ac984da981176f59e..d665980a0106c7408c18136fb119e5d6128b64f2 100644
--- a/protocol/build.gradle.kts
+++ b/protocol/build.gradle.kts
@@ -24,13 +24,17 @@ tasks.getByName<JacocoReport>("jacocoTestReport") {
 
 dependencies {
   implementation(kotlin("stdlib"))
-  implementation("org.threeten", "threetenbp", "1.4.0")
+  api("org.threeten", "threetenbp", "1.4.0")
   api(project(":bitflags"))
   api(project(":coverage-annotations"))
 
   val junit5Version: String by project.extra
   testImplementation("org.junit.jupiter", "junit-jupiter-api", junit5Version)
+  testImplementation("org.junit.jupiter", "junit-jupiter-params", junit5Version)
   testRuntimeOnly("org.junit.jupiter", "junit-jupiter-engine", junit5Version)
   val hamcrestVersion: String by project.extra
   testImplementation("org.hamcrest", "hamcrest-library", hamcrestVersion)
+  val testContainersVersion: String by project.extra
+  testImplementation("org.testcontainers", "testcontainers", testContainersVersion)
+  testImplementation("org.testcontainers", "junit-jupiter", testContainersVersion)
 }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index eef525f65a266197615cb4ca39002a2e2d22dcaf..74ba77b6a260ca2d563ae84312cb9e05d96612cb 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -24,5 +24,6 @@ include(
   ":app",
   ":bitflags",
   ":protocol",
-  ":coverage-annotations"
+  ":coverage-annotations",
+  ":libquassel"
 )