Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement parcel delivery #39

Merged
merged 5 commits into from
Aug 22, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ dependencies {

implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")

api('tech.relaycorp:relaynet:1.35.0')
api('tech.relaycorp:relaynet:1.36.0')

// Handshake nonce signatures
implementation("org.bouncycastle:bcpkix-jdk15on:1.66")

// HTTP + WebSockets handling
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
testImplementation("io.ktor:ktor-client-mock:$ktorVersion")
testImplementation("io.ktor:ktor-client-mock-jvm:$ktorVersion")
Expand Down
48 changes: 45 additions & 3 deletions src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ package tech.relaycorp.poweb

import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.features.ResponseException
import io.ktor.client.features.websocket.DefaultClientWebSocketSession
import io.ktor.client.features.websocket.WebSockets
import io.ktor.client.features.websocket.webSocket
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.http.ContentType
import io.ktor.http.cio.websocket.CloseReason
import io.ktor.http.cio.websocket.Frame
import io.ktor.http.cio.websocket.close
import io.ktor.http.cio.websocket.readBytes
import io.ktor.http.content.ByteArrayContent
import io.ktor.util.KtorExperimentalAPI
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -47,13 +51,49 @@ public class PoWebClient internal constructor(
install(WebSockets)
}

private val urlScheme = if (useTls) "https" else "http"
private val wsScheme = if (useTls) "wss" else "ws"

internal val baseURL: String = "$urlScheme://$hostName:$port/v1"

/**
* Close the underlying connection to the server (if any).
*/
override fun close(): Unit = ktorClient.close()

/**
* Deliver a parcel.
*
* @param parcelSerialized The serialization of the parcel
*/
@Throws(
ServerConnectionException::class,
ServerBindingException::class,
RefusedParcelException::class,
ClientBindingException::class
)
public suspend fun deliverParcel(parcelSerialized: ByteArray) {
try {
ktorClient.post<Unit>("$baseURL/parcels") {
body = ByteArrayContent(parcelSerialized, PARCEL_CONTENT_TYPE)
}
} catch (exc: ConnectException) {
throw ServerConnectionException("Failed to connect to $baseURL", exc)
} catch (exc: ResponseException) {
val status = exc.response!!.status
when (status.value) {
403 -> throw RefusedParcelException("Parcel was refused by the server ($status)")
in 400..499 -> throw ClientBindingException(
"The server reports that the client violated binding ($status)"
)
in 500..599 -> throw ServerConnectionException(
"The server was unable to fulfil the request ($status)"
)
else -> throw ServerBindingException("Received unexpected status ($status)")
}
}
}

/**
* Collect parcels on behalf of the specified nodes.
*
Expand All @@ -62,7 +102,7 @@ public class PoWebClient internal constructor(
*/
@Throws(
ServerConnectionException::class,
InvalidServerMessageException::class,
ServerBindingException::class,
NonceSignerException::class
)
public suspend fun collectParcels(
Expand Down Expand Up @@ -114,7 +154,7 @@ public class PoWebClient internal constructor(
webSocketSession.close(
CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Invalid parcel delivery")
)
throw InvalidServerMessageException("Received invalid message from server", exc)
throw ServerBindingException("Received invalid message from server", exc)
}
val collector = ParcelCollection(delivery.parcelSerialized, trustedCertificates) {
webSocketSession.outgoing.send(Frame.Text(delivery.deliveryId))
Expand Down Expand Up @@ -145,6 +185,8 @@ public class PoWebClient internal constructor(
private const val DEFAULT_LOCAL_PORT = 276
private const val DEFAULT_REMOTE_PORT = 443

private val PARCEL_CONTENT_TYPE = ContentType("application", "vnd.relaynet.parcel")

/**
* Connect to a private gateway from a private endpoint.
*
Expand Down Expand Up @@ -173,7 +215,7 @@ private suspend fun DefaultClientWebSocketSession.handshake(nonceSigners: Array<
Challenge.deserialize(challengeRaw.readBytes())
} catch (exc: InvalidChallengeException) {
close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, ""))
throw InvalidServerMessageException("Server sent an invalid handshake challenge", exc)
throw ServerBindingException("Server sent an invalid handshake challenge", exc)
}
val nonceSignatures = nonceSigners.map { it.sign(challenge.nonce) }.toTypedArray()
val response = Response(nonceSignatures)
Expand Down
28 changes: 23 additions & 5 deletions src/main/kotlin/tech/relaycorp/poweb/PoWebException.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package tech.relaycorp.poweb

public abstract class PoWebException internal constructor(
message: String,
message: String?,
cause: Throwable? = null
) : Exception(message, cause)

/**
* Base class for connectivity errors and errors caused by the server.
*/
public sealed class ServerException(message: String, cause: Throwable?) :
public abstract class ServerException internal constructor(message: String, cause: Throwable?) :
PoWebException(message, cause)

/**
Expand All @@ -20,14 +20,32 @@ public class ServerConnectionException(message: String, cause: Throwable? = null
ServerException(message, cause)

/**
* The server sent an invalid message.
* The server sent an invalid message or behaved in any other way that violates the PoWeb binding.
*
* The server didn't adhere to the protocol. Retrying later is unlikely to make a difference.
*/
public class InvalidServerMessageException(message: String, cause: Throwable) :
public class ServerBindingException(message: String, cause: Throwable? = null) :
ServerException(message, cause)

/**
* The server refused to accept a parcel.
*/
public class RefusedParcelException(message: String) : PoWebException(message)

/**
* Base class for exceptions (supposedly) caused by the client.
*/
public abstract class ClientException internal constructor(message: String) :
PoWebException(message)

/**
* The server claims that the client is violating the PoWeb binding.
*
* Retrying later is unlikely to make a difference.
*/
public class ClientBindingException(message: String) : ClientException(message)

/**
* The client made a mistake while specifying the nonce signer(s).
*/
public class NonceSignerException(message: String) : PoWebException(message)
public class NonceSignerException(message: String) : ClientException(message)
4 changes: 2 additions & 2 deletions src/test/kotlin/tech/relaycorp/poweb/ParcelCollectionTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ class ParcelCollectionTest : WebSocketTestCase() {
setListenerActions(SendTextMessageAction("Not a valid challenge"))

client.use {
val exception = assertThrows<InvalidServerMessageException> {
val exception = assertThrows<ServerBindingException> {
runBlocking { client.collectParcels(arrayOf(signer)).first() }
}

Expand Down Expand Up @@ -202,7 +202,7 @@ class ParcelCollectionTest : WebSocketTestCase() {
setListenerActions(ChallengeAction(nonce), SendTextMessageAction("invalid"))

client.use {
val exception = assertThrows<InvalidServerMessageException> {
val exception = assertThrows<ServerBindingException> {
runBlocking { client.collectParcels(arrayOf(signer)).toList() }
}

Expand Down
195 changes: 195 additions & 0 deletions src/test/kotlin/tech/relaycorp/poweb/ParcelDeliveryTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package tech.relaycorp.poweb

import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.MockRequestHandler
import io.ktor.client.engine.mock.respond
import io.ktor.client.engine.mock.respondError
import io.ktor.client.engine.mock.respondOk
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.content.OutgoingContent
import io.ktor.util.KtorExperimentalAPI
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.net.ConnectException
import kotlin.test.assertEquals
import kotlin.test.assertTrue

@ExperimentalCoroutinesApi
@KtorExperimentalAPI
class ParcelDeliveryTest {
private val parcelSerialized = "Let's say I'm the serialization of a parcel".toByteArray()

@Test
fun `Request should be made with HTTP POST`() = runBlockingTest {
var method: HttpMethod? = null
val client = makeClient { request ->
method = request.method
respondOk()
}

client.use { client.deliverParcel(parcelSerialized) }

assertEquals(HttpMethod.Post, method)
}

@Test
fun `Endpoint should be the one for parcels`() = runBlockingTest {
var endpointURL: String? = null
val client = makeClient { request ->
endpointURL = request.url.toString()
respondOk()
}

client.use { client.deliverParcel(parcelSerialized) }

assertEquals("${client.baseURL}/parcels", endpointURL)
}

@Test
fun `Request content type should be the appropriate value`() = runBlockingTest {
var contentType: String? = null
val client = makeClient { request ->
contentType = request.body.contentType.toString()
respondOk()
}

client.use { client.deliverParcel(parcelSerialized) }

assertEquals("application/vnd.relaynet.parcel", contentType)
}

@Test
fun `Request body should be the parcel serialized`() = runBlockingTest {
var requestBody: ByteArray? = null
val client = makeClient { request ->
assertTrue(request.body is OutgoingContent.ByteArrayContent)
requestBody = (request.body as OutgoingContent.ByteArrayContent).bytes()
respondOk()
}

client.use { client.deliverParcel(parcelSerialized) }

assertEquals(parcelSerialized.asList(), requestBody?.asList())
}

@Test
fun `HTTP 20X should be regarded a successful delivery`() = runBlockingTest {
val client = makeClient { respond("", HttpStatusCode.Accepted) }

client.use { client.deliverParcel(parcelSerialized) }
}

@Test
fun `HTTP 30X responses should be regarded protocol violations by the server`() {
val client = makeClient { respond("", HttpStatusCode.Found) }

client.use {
val exception = assertThrows<ServerBindingException> {
runBlockingTest { client.deliverParcel(parcelSerialized) }
}

assertEquals(
"Received unexpected status (${HttpStatusCode.Found})",
exception.message
)
}
}

@Test
fun `HTTP 403 should throw a RefusedParcelException`() {
val client = makeClient { respondError(HttpStatusCode.Forbidden) }

client.use {
val exception = assertThrows<RefusedParcelException> {
runBlockingTest { client.deliverParcel(parcelSerialized) }
}

assertEquals(
"Parcel was refused by the server (${HttpStatusCode.Forbidden})",
exception.message
)
}
}

@Test
@Disabled
fun `RefusedParcelException should include the error message if present`() {
val client = makeClient { respondError(HttpStatusCode.Forbidden) }

client.use {
val exception = assertThrows<RefusedParcelException> {
runBlockingTest { client.deliverParcel(parcelSerialized) }
}

assertEquals(
"Received unexpected status code (${HttpStatusCode.Found.value})",
exception.message
)
}
}

@Test
fun `Other 40X responses should be regarded protocol violations by the client`() {
val client = makeClient { respondError(HttpStatusCode.BadRequest) }

client.use {
val exception = assertThrows<ClientBindingException> {
runBlockingTest { client.deliverParcel(parcelSerialized) }
}

assertEquals(
"The server reports that the client violated binding " +
"(${HttpStatusCode.BadRequest})",
exception.message
)
}
}

@Test
fun `HTTP 50X responses should throw a ServerConnectionException`() {
val client = makeClient { respondError(HttpStatusCode.BadGateway) }

client.use {
val exception = assertThrows<ServerConnectionException> {
runBlockingTest { client.deliverParcel(parcelSerialized) }
}

assertEquals(
"The server was unable to fulfil the request (${HttpStatusCode.BadGateway})",
exception.message
)
}
}

@Test
fun `TCP connection issues should throw a ServerConnectionException`() {
val nonRouteableIPAddress = "192.0.2.1"
// Use a real client to try to open an actual network connection
val client = PoWebClient.initRemote(nonRouteableIPAddress)

client.use {
val exception = assertThrows<ServerConnectionException> {
runBlocking { client.deliverParcel(parcelSerialized) }
}

assertEquals("Failed to connect to ${client.baseURL}", exception.message)
assertTrue(exception.cause is ConnectException)
}
}

private fun makeClient(handler: MockRequestHandler): PoWebClient {
val poWebClient = PoWebClient.initLocal()
poWebClient.ktorClient = HttpClient(MockEngine) {
engine {
addHandler(handler)
}
}
return poWebClient
}
}
Loading