From 01728e5a47c0592013772b8b8fe528a986260767 Mon Sep 17 00:00:00 2001 From: Patrick Kuo Date: Mon, 23 Oct 2017 11:46:24 +0100 Subject: [PATCH] Network map service REST API wrapper (#1907) * Network map client - WIP * Java doc and doc for doc site * remove javax.ws dependency * NetworkParameter -> NetworkParameters * move network map client to node * Fix jetty test dependencies * NetworkParameter -> NetworkParameters * Address PR issues * Address PR issues and unit test fix * Address PR issues --- docs/source/network-map.rst | 38 +++++ node/build.gradle | 11 ++ .../node/services/network/NetworkMapClient.kt | 70 ++++++++ .../network/HTTPNetworkMapClientTest.kt | 154 ++++++++++++++++++ 4 files changed, 273 insertions(+) create mode 100644 docs/source/network-map.rst create mode 100644 node/src/main/kotlin/net/corda/node/services/network/NetworkMapClient.kt create mode 100644 node/src/test/kotlin/net/corda/node/services/network/HTTPNetworkMapClientTest.kt diff --git a/docs/source/network-map.rst b/docs/source/network-map.rst new file mode 100644 index 0000000000..d8ee82a2cf --- /dev/null +++ b/docs/source/network-map.rst @@ -0,0 +1,38 @@ +Network Map +=========== + +Protocol Design +--------------- +The node info publishing protocol: + +* Create a ``NodeInfo`` object, and sign it to create a ``SignedData`` object. TODO: We will need list of signatures in ``SignedData`` to support multiple node identities in the future. + +* Serialise the signed data and POST the data to the network map server. + +* The network map server validates the signature and acknowledges the registration with a HTTP 200 response, it will return HTTP 400 "Bad Request" if the data failed validation or if the public key wasn't registered with the network. + +* The network map server will sign and distribute the new network map periodically. + +Node side network map update protocol: + +* The Corda node will query the network map service periodically according to the ``Expires`` attribute in the HTTP header. + +* The network map service returns a signed ``NetworkMap`` object, containing list of node info hashes and the network parameters hashes. + +* The node updates its local copy of ``NodeInfos`` if it is different from the newly downloaded ``NetworkMap``. + +Network Map service REST API: + ++----------------+-----------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------+ +| Request method | Path | Description | ++================+===================================+========================================================================================================================================================+ +| POST | /api/network-map/publish | Publish new ``NodeInfo`` to the network map service, the legal identity in ``NodeInfo`` must match with the identity registered with the doorman. | ++----------------+-----------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------+ +| GET | /api/network-map | Retrieve ``NetworkMap`` from the server, the ``NetworkMap`` object contains list of node info hashes and NetworkParameters hash. | ++----------------+-----------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------+ +| GET | /api/network-map/node-info/{hash} | Retrieve ``NodeInfo`` object with the same hash. | ++----------------+-----------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------+ +| GET | /api/network-map/parameters/{hash}| Retrieve ``NetworkParameters`` object with the same hash. | ++----------------+-----------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------+ + +TODO: Access control of the network map will be added in the future. \ No newline at end of file diff --git a/node/build.gradle b/node/build.gradle index 72f45ea297..7d3313d454 100644 --- a/node/build.gradle +++ b/node/build.gradle @@ -182,6 +182,17 @@ dependencies { smokeTestCompile project(':smoke-test-utils') smokeTestCompile "org.assertj:assertj-core:${assertj_version}" smokeTestCompile "junit:junit:$junit_version" + + // Jetty dependencies for NetworkMapClient test. + // Web stuff: for HTTP[S] servlets + testCompile "org.eclipse.jetty:jetty-servlet:${jetty_version}" + testCompile "org.eclipse.jetty:jetty-webapp:${jetty_version}" + testCompile "javax.servlet:javax.servlet-api:3.1.0" + + // Jersey for JAX-RS implementation for use in Jetty + testCompile "org.glassfish.jersey.core:jersey-server:${jersey_version}" + testCompile "org.glassfish.jersey.containers:jersey-container-servlet-core:${jersey_version}" + testCompile "org.glassfish.jersey.containers:jersey-container-jetty-http:${jersey_version}" } task integrationTest(type: Test) { diff --git a/node/src/main/kotlin/net/corda/node/services/network/NetworkMapClient.kt b/node/src/main/kotlin/net/corda/node/services/network/NetworkMapClient.kt new file mode 100644 index 0000000000..2aba89084d --- /dev/null +++ b/node/src/main/kotlin/net/corda/node/services/network/NetworkMapClient.kt @@ -0,0 +1,70 @@ +package net.corda.node.services.network + +import com.fasterxml.jackson.databind.ObjectMapper +import net.corda.core.crypto.SecureHash +import net.corda.core.crypto.SignedData +import net.corda.core.node.NodeInfo +import net.corda.core.serialization.deserialize +import net.corda.core.serialization.serialize +import java.net.HttpURLConnection +import java.net.URL + +interface NetworkMapClient { + /** + * Publish node info to network map service. + */ + fun publish(signedNodeInfo: SignedData) + + /** + * Retrieve [NetworkMap] from the network map service containing list of node info hashes and network parameter hash. + */ + // TODO: Use NetworkMap object when available. + fun getNetworkMap(): List + + /** + * Retrieve [NodeInfo] from network map service using the node info hash. + */ + fun getNodeInfo(nodeInfoHash: SecureHash): NodeInfo? + + // TODO: Implement getNetworkParameter when its available. + //fun getNetworkParameter(networkParameterHash: SecureHash): NetworkParameter +} + +class HTTPNetworkMapClient(private val networkMapUrl: String) : NetworkMapClient { + override fun publish(signedNodeInfo: SignedData) { + val publishURL = URL("$networkMapUrl/publish") + val conn = publishURL.openConnection() as HttpURLConnection + conn.doOutput = true + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/octet-stream") + conn.outputStream.write(signedNodeInfo.serialize().bytes) + when (conn.responseCode) { + HttpURLConnection.HTTP_OK -> return + HttpURLConnection.HTTP_UNAUTHORIZED -> throw IllegalArgumentException(conn.errorStream.bufferedReader().readLine()) + else -> throw IllegalArgumentException("Unexpected response code ${conn.responseCode}, response error message: '${conn.errorStream.bufferedReader().readLines()}'") + } + } + + override fun getNetworkMap(): List { + val conn = URL(networkMapUrl).openConnection() as HttpURLConnection + + return when (conn.responseCode) { + HttpURLConnection.HTTP_OK -> { + val response = conn.inputStream.bufferedReader().use { it.readLine() } + ObjectMapper().readValue(response, List::class.java).map { SecureHash.parse(it.toString()) } + } + else -> throw IllegalArgumentException("Unexpected response code ${conn.responseCode}, response error message: '${conn.errorStream.bufferedReader().readLines()}'") + } + } + + override fun getNodeInfo(nodeInfoHash: SecureHash): NodeInfo? { + val nodeInfoURL = URL("$networkMapUrl/$nodeInfoHash") + val conn = nodeInfoURL.openConnection() as HttpURLConnection + + return when (conn.responseCode) { + HttpURLConnection.HTTP_OK -> conn.inputStream.readBytes().deserialize() + HttpURLConnection.HTTP_NOT_FOUND -> null + else -> throw IllegalArgumentException("Unexpected response code ${conn.responseCode}, response error message: '${conn.errorStream.bufferedReader().readLines()}'") + } + } +} \ No newline at end of file diff --git a/node/src/test/kotlin/net/corda/node/services/network/HTTPNetworkMapClientTest.kt b/node/src/test/kotlin/net/corda/node/services/network/HTTPNetworkMapClientTest.kt new file mode 100644 index 0000000000..058762bb7f --- /dev/null +++ b/node/src/test/kotlin/net/corda/node/services/network/HTTPNetworkMapClientTest.kt @@ -0,0 +1,154 @@ +package net.corda.node.services.network + +import com.fasterxml.jackson.databind.ObjectMapper +import net.corda.core.crypto.* +import net.corda.core.identity.CordaX500Name +import net.corda.core.identity.PartyAndCertificate +import net.corda.core.node.NodeInfo +import net.corda.core.serialization.deserialize +import net.corda.core.serialization.serialize +import net.corda.core.utilities.NetworkHostAndPort +import net.corda.node.utilities.CertificateType +import net.corda.node.utilities.X509Utilities +import net.corda.testing.TestDependencyInjectionBase +import org.assertj.core.api.Assertions.assertThat +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.cert.X509CertificateHolder +import org.eclipse.jetty.server.Server +import org.eclipse.jetty.server.ServerConnector +import org.eclipse.jetty.server.handler.HandlerCollection +import org.eclipse.jetty.servlet.ServletContextHandler +import org.eclipse.jetty.servlet.ServletHolder +import org.glassfish.jersey.server.ResourceConfig +import org.glassfish.jersey.servlet.ServletContainer +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.net.InetSocketAddress +import java.security.cert.CertPath +import java.security.cert.Certificate +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import javax.ws.rs.* +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response +import javax.ws.rs.core.Response.ok +import kotlin.test.assertEquals + +class HTTPNetworkMapClientTest : TestDependencyInjectionBase() { + private lateinit var server: Server + + private lateinit var networkMapClient: NetworkMapClient + private val rootCAKey = Crypto.generateKeyPair(X509Utilities.DEFAULT_TLS_SIGNATURE_SCHEME) + private val rootCACert = X509Utilities.createSelfSignedCACertificate(CordaX500Name(commonName = "Corda Node Root CA", organisation = "R3 LTD", locality = "London", country = "GB"), rootCAKey) + private val intermediateCAKey = Crypto.generateKeyPair(X509Utilities.DEFAULT_TLS_SIGNATURE_SCHEME) + private val intermediateCACert = X509Utilities.createCertificate(CertificateType.INTERMEDIATE_CA, rootCACert, rootCAKey, X500Name("CN=Corda Node Intermediate CA,L=London"), intermediateCAKey.public) + + @Before + fun setUp() { + server = Server(InetSocketAddress("localhost", 0)).apply { + handler = HandlerCollection().apply { + addHandler(ServletContextHandler().apply { + contextPath = "/" + val resourceConfig = ResourceConfig().apply { + // Add your API provider classes (annotated for JAX-RS) here + register(MockNetworkMapServer()) + } + val jerseyServlet = ServletHolder(ServletContainer(resourceConfig)).apply { initOrder = 0 }// Initialise at server start + addServlet(jerseyServlet, "/api/*") + }) + } + } + server.start() + + while (!server.isStarted) { + Thread.sleep(100) + } + + val hostAndPort = server.connectors.mapNotNull { it as? ServerConnector }.first() + networkMapClient = HTTPNetworkMapClient("http://${hostAndPort.host}:${hostAndPort.localPort}/api/network-map") + } + + @After + fun tearDown() { + server.stop() + } + + @Test + fun `registered node is added to the network map`() { + // Create node info. + val signedNodeInfo = createNodeInfo("Test1") + val nodeInfo = signedNodeInfo.verified() + + networkMapClient.publish(signedNodeInfo) + + val nodeInfoHash = nodeInfo.serialize().sha256() + + assertThat(networkMapClient.getNetworkMap()).containsExactly(nodeInfoHash) + assertEquals(nodeInfo, networkMapClient.getNodeInfo(nodeInfoHash)) + + val signedNodeInfo2 = createNodeInfo("Test2") + val nodeInfo2 = signedNodeInfo2.verified() + networkMapClient.publish(signedNodeInfo2) + + val nodeInfoHash2 = nodeInfo2.serialize().sha256() + assertThat(networkMapClient.getNetworkMap()).containsExactly(nodeInfoHash, nodeInfoHash2) + assertEquals(nodeInfo2, networkMapClient.getNodeInfo(nodeInfoHash2)) + } + + private fun createNodeInfo(organisation: String): SignedData { + val keyPair = Crypto.generateKeyPair(X509Utilities.DEFAULT_TLS_SIGNATURE_SCHEME) + val clientCert = X509Utilities.createCertificate(CertificateType.CLIENT_CA, intermediateCACert, intermediateCAKey, CordaX500Name(organisation = organisation, locality = "London", country = "GB"), keyPair.public) + val certPath = buildCertPath(clientCert.toX509Certificate(), intermediateCACert.toX509Certificate(), rootCACert.toX509Certificate()) + val nodeInfo = NodeInfo(listOf(NetworkHostAndPort("my.$organisation.com", 1234)), listOf(PartyAndCertificate(certPath)), 1, serial = 1L) + + // Create digital signature. + val digitalSignature = DigitalSignature.WithKey(keyPair.public, Crypto.doSign(keyPair.private, nodeInfo.serialize().bytes)) + + return SignedData(nodeInfo.serialize(), digitalSignature) + } +} + +@Path("network-map") +// This is a stub implementation of the network map rest API. +internal class MockNetworkMapServer { + private val nodeInfos = mutableMapOf() + @POST + @Path("publish") + @Consumes(MediaType.APPLICATION_OCTET_STREAM) + fun publishNodeInfo(input: InputStream): Response { + val registrationData = input.readBytes().deserialize>() + val nodeInfo = registrationData.verified() + val nodeInfoHash = nodeInfo.serialize().sha256() + nodeInfos.put(nodeInfoHash, nodeInfo) + return ok().build() + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + fun getNetworkMap(): Response { + return Response.ok(ObjectMapper().writeValueAsString(nodeInfos.keys.map { it.toString() })).build() + } + + @GET + @Path("{var}") + @Produces(MediaType.APPLICATION_OCTET_STREAM) + fun getNodeInfo(@PathParam("var") nodeInfoHash: String): Response { + val nodeInfo = nodeInfos[SecureHash.parse(nodeInfoHash)] + return if (nodeInfo != null) { + Response.ok(nodeInfo.serialize().bytes) + } else { + Response.status(Response.Status.NOT_FOUND) + }.build() + } +} + +private fun buildCertPath(vararg certificates: Certificate): CertPath { + return CertificateFactory.getInstance("X509").generateCertPath(certificates.asList()) +} + +private fun X509CertificateHolder.toX509Certificate(): X509Certificate { + return CertificateFactory.getInstance("X509").generateCertificate(ByteArrayInputStream(encoded)) as X509Certificate +} \ No newline at end of file