From fa257738e105bcb6f409c0b4a1b0d6b779f76c23 Mon Sep 17 00:00:00 2001 From: Clinton Alexander Date: Mon, 12 Dec 2016 16:45:59 +0000 Subject: [PATCH] Added webserver project. --- .../kotlin/net/corda/node/internal/Node.kt | 56 +++--- node/webserver/build.gradle | 53 ++++++ .../kotlin/net/corda/node/webserver/Main.kt | 48 +++++ .../net/corda/node/webserver/WebServer.kt | 176 ++++++++++++++++++ settings.gradle | 1 + 5 files changed, 305 insertions(+), 29 deletions(-) create mode 100644 node/webserver/build.gradle create mode 100644 node/webserver/src/main/kotlin/net/corda/node/webserver/Main.kt create mode 100644 node/webserver/src/main/kotlin/net/corda/node/webserver/WebServer.kt diff --git a/node/src/main/kotlin/net/corda/node/internal/Node.kt b/node/src/main/kotlin/net/corda/node/internal/Node.kt index 2fe41bbfb8..f2a9fe6856 100644 --- a/node/src/main/kotlin/net/corda/node/internal/Node.kt +++ b/node/src/main/kotlin/net/corda/node/internal/Node.kt @@ -111,7 +111,7 @@ class Node(override val configuration: FullNodeConfiguration, // serialisation/deserialisation work. override val serverThread = AffinityExecutor.ServiceAffinityExecutor("Node thread", 1) - lateinit var webServer: Server + //lateinit var webServer: Server var messageBroker: ArtemisMessagingServer? = null // Avoid the lock being garbage collected. We don't really need to release it as the OS will do so for us @@ -314,34 +314,32 @@ class Node(override val configuration: FullNodeConfiguration, override fun start(): Node { alreadyRunningNodeCheck() super.start() - - // Only start the service API requests once the network map registration is successfully complete - networkMapRegistrationFuture.success { - // This needs to be in a seperate thread so that we can reply to our own request to become RPC clients - thread(name = "WebServer") { - try { - webServer = initWebServer(connectLocalRpcAsNodeUser()) - } catch(ex: Exception) { - // TODO: We need to decide if this is a fatal error, given the API is unavailable, or whether the API - // is not critical and we continue anyway. - log.error("Web server startup failed", ex) - } - // Begin exporting our own metrics via JMX. - JmxReporter. - forRegistry(services.monitoringService.metrics). - inDomain("net.corda"). - createsObjectNamesWith { type, domain, name -> - // Make the JMX hierarchy a bit better organised. - val category = name.substringBefore('.') - val subName = name.substringAfter('.', "") - if (subName == "") - ObjectName("$domain:name=$category") - else - ObjectName("$domain:type=$category,name=$subName") - }. - build(). - start() - } + // Only start the service API requests once the network map registration is complete + thread(name = "WebServer") { + networkMapRegistrationFuture.getOrThrow() + // TODO: Remove when cleanup + //try { + // webServer = initWebServer(connectLocalRpcAsNodeUser()) + //} catch(ex: Exception) { + // // TODO: We need to decide if this is a fatal error, given the API is unavailable, or whether the API + // // is not critical and we continue anyway. + // log.error("Web server startup failed", ex) + //} + // Begin exporting our own metrics via JMX. + JmxReporter. + forRegistry(services.monitoringService.metrics). + inDomain("net.corda"). + createsObjectNamesWith { type, domain, name -> + // Make the JMX hierarchy a bit better organised. + val category = name.substringBefore('.') + val subName = name.substringAfter('.', "") + if (subName == "") + ObjectName("$domain:name=$category") + else + ObjectName("$domain:type=$category,name=$subName") + }. + build(). + start() } shutdownThread = thread(start = false) { diff --git a/node/webserver/build.gradle b/node/webserver/build.gradle new file mode 100644 index 0000000000..a94e63462b --- /dev/null +++ b/node/webserver/build.gradle @@ -0,0 +1,53 @@ +apply plugin: 'kotlin' +apply plugin: 'java' +apply plugin: 'net.corda.plugins.quasar-utils' +apply plugin: 'net.corda.plugins.publish-utils' + +description 'Corda Webserver' + +repositories { + mavenLocal() + mavenCentral() + maven { + url 'http://oss.sonatype.org/content/repositories/snapshots' + } + jcenter() + maven { + url 'https://dl.bintray.com/kotlin/exposed' + } +} + +dependencies { + compile project(':core') + compile project(':node') + + compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + // Web stuff: for HTTP[S] servlets + compile "org.eclipse.jetty:jetty-servlet:${jetty_version}" + compile "org.eclipse.jetty:jetty-webapp:${jetty_version}" + compile "javax.servlet:javax.servlet-api:3.1.0" + compile "org.jolokia:jolokia-agent-war:2.0.0-M1" + compile "commons-fileupload:commons-fileupload:1.3.2" + + // Jersey for JAX-RS implementation for use in Jetty + compile "org.glassfish.jersey.core:jersey-server:${jersey_version}" + compile "org.glassfish.jersey.containers:jersey-container-servlet-core:${jersey_version}" + compile "org.glassfish.jersey.containers:jersey-container-jetty-http:${jersey_version}" + // NOTE there is a Jackson version clash between jersey-media-json-jackson (v2.5.4) and jackson-module-kotlin (v.2.5.5) + // Have not found an Issue in the issue tracker for Jersey for this issue + compile ("org.glassfish.jersey.media:jersey-media-json-jackson:${jersey_version}") { + exclude group: 'com.fasterxml.jackson.core', module: 'jackson-annotations' + exclude group: 'com.fasterxml.jackson.core', module: 'jackson-databind' + exclude group: 'com.fasterxml.jackson.core', module: 'jackson-core' + } + compile ("com.fasterxml.jackson.module:jackson-module-kotlin:${jackson_version}") { + exclude group: 'com.fasterxml.jackson.core', module: 'jackson-annotations' + } + compile "com.fasterxml.jackson.core:jackson-annotations:${jackson_version}" +} + +task run(type: JavaExec) { + classpath = sourceSets.main.runtimeClasspath + main = 'net.corda.node.webserver.MainKt' +} \ No newline at end of file diff --git a/node/webserver/src/main/kotlin/net/corda/node/webserver/Main.kt b/node/webserver/src/main/kotlin/net/corda/node/webserver/Main.kt new file mode 100644 index 0000000000..f6501fc2eb --- /dev/null +++ b/node/webserver/src/main/kotlin/net/corda/node/webserver/Main.kt @@ -0,0 +1,48 @@ +package net.corda.node.webserver + +import com.google.common.net.HostAndPort +import net.corda.node.driver.driver +import net.corda.node.services.User +import net.corda.node.services.config.ConfigHelper +import net.corda.node.services.config.FullNodeConfiguration +import java.nio.file.Paths + +fun main(args: Array) { + driver { + val node = startNode().get() + WebServer(node.nodeInfo, node.config).start() + } +} + +fun generateNodeConfiguration(): FullNodeConfiguration { + val messagingAddress = 10002 + val apiAddress = HostAndPort.fromString("localhost:10003") + val name = "webserver-test" + val rpcUsers = listOf() + + val baseDirectory = Paths.get("build/webserver") + val configOverrides = mapOf( + "myLegalName" to name, + "basedir" to baseDirectory.normalize().toString(), + "artemisAddress" to messagingAddress.toString(), + "webAddress" to apiAddress.toString(), + "extraAdvertisedServiceIds" to listOf(), + "networkMapAddress" to "", + "useTestClock" to false, + "rpcUsers" to rpcUsers.map { + mapOf( + "user" to it.username, + "password" to it.password, + "permissions" to it.permissions + ) + } + ) + + val config = ConfigHelper.loadConfig( + baseDirectoryPath = baseDirectory, + allowMissingConfig = true, + configOverrides = configOverrides + ) + + return FullNodeConfiguration(config) +} \ No newline at end of file diff --git a/node/webserver/src/main/kotlin/net/corda/node/webserver/WebServer.kt b/node/webserver/src/main/kotlin/net/corda/node/webserver/WebServer.kt new file mode 100644 index 0000000000..90271dd5eb --- /dev/null +++ b/node/webserver/src/main/kotlin/net/corda/node/webserver/WebServer.kt @@ -0,0 +1,176 @@ +package net.corda.node.webserver + +import com.google.common.net.HostAndPort +import com.typesafe.config.Config +import net.corda.core.messaging.CordaRPCOps +import net.corda.core.node.CordaPluginRegistry +import net.corda.core.node.NodeInfo +import net.corda.core.utilities.loggerFor +import net.corda.node.internal.Node +import net.corda.node.services.config.FullNodeConfiguration +import net.corda.node.services.config.NodeSSLConfiguration +import net.corda.node.services.messaging.ArtemisMessagingComponent +import net.corda.node.services.messaging.CordaRPCClient +import net.corda.node.servlets.AttachmentDownloadServlet +import net.corda.node.servlets.DataUploadServlet +import net.corda.node.servlets.ResponseFilter +import org.eclipse.jetty.server.* +import org.eclipse.jetty.server.handler.HandlerCollection +import org.eclipse.jetty.servlet.DefaultServlet +import org.eclipse.jetty.servlet.FilterHolder +import org.eclipse.jetty.servlet.ServletContextHandler +import org.eclipse.jetty.servlet.ServletHolder +import org.eclipse.jetty.util.ssl.SslContextFactory +import org.eclipse.jetty.webapp.WebAppContext +import org.glassfish.jersey.server.ResourceConfig +import org.glassfish.jersey.server.ServerProperties +import org.glassfish.jersey.servlet.ServletContainer +import java.lang.reflect.InvocationTargetException +import java.net.InetAddress +import java.nio.file.Path +import java.util.* +import javax.servlet.DispatcherType + +class WebServer(val nodeInfo: NodeInfo, val configuration: Config) { + private companion object { + val log = loggerFor() + } + + private val address = HostAndPort.fromString(configuration.getString("webAddress")) + private val sslConfig = object : NodeSSLConfiguration { + override val keyStorePassword: String + get() = throw UnsupportedOperationException() + override val trustStorePassword: String + get() = throw UnsupportedOperationException() + override val certificatesPath: Path + get() = throw UnsupportedOperationException() + + } + + fun start() { + initWebServer(connectLocalRpcAsNodeUser()) + } + + private fun initWebServer(localRpc: CordaRPCOps): Server { + // Note that the web server handlers will all run concurrently, and not on the node thread. + val handlerCollection = HandlerCollection() + + // Export JMX monitoring statistics and data over REST/JSON. + if (configuration.getString("exportJMXto").split(',').contains("http")) { + val classpath = System.getProperty("java.class.path").split(System.getProperty("path.separator")) + val warpath = classpath.firstOrNull { it.contains("jolokia-agent-war-2") && it.endsWith(".war") } + if (warpath != null) { + handlerCollection.addHandler(WebAppContext().apply { + // Find the jolokia WAR file on the classpath. + contextPath = "/monitoring/json" + setInitParameter("mimeType", "application/json") + war = warpath + }) + } else { + log.warn("Unable to locate Jolokia WAR on classpath") + } + } + + // API, data upload and download to services (attachments, rates oracles etc) + handlerCollection.addHandler(buildServletContextHandler(localRpc)) + + val server = Server() + + val connector = if (configuration.getBoolean("useHTTPS")) { + val httpsConfiguration = HttpConfiguration() + httpsConfiguration.outputBufferSize = 32768 + httpsConfiguration.addCustomizer(SecureRequestCustomizer()) + val sslContextFactory = SslContextFactory() + sslContextFactory.keyStorePath = sslConfig.keyStorePath.toString() + sslContextFactory.setKeyStorePassword(sslConfig.keyStorePassword) + sslContextFactory.setKeyManagerPassword(sslConfig.keyStorePassword) + sslContextFactory.setTrustStorePath(sslConfig.trustStorePath.toString()) + sslContextFactory.setTrustStorePassword(sslConfig.trustStorePassword) + sslContextFactory.setExcludeProtocols("SSL.*", "TLSv1", "TLSv1.1") + sslContextFactory.setIncludeProtocols("TLSv1.2") + sslContextFactory.setExcludeCipherSuites(".*NULL.*", ".*RC4.*", ".*MD5.*", ".*DES.*", ".*DSS.*") + sslContextFactory.setIncludeCipherSuites(".*AES.*GCM.*") + val sslConnector = ServerConnector(server, SslConnectionFactory(sslContextFactory, "http/1.1"), HttpConnectionFactory(httpsConfiguration)) + sslConnector.port = address.port + sslConnector + } else { + val httpConfiguration = HttpConfiguration() + httpConfiguration.outputBufferSize = 32768 + val httpConnector = ServerConnector(server, HttpConnectionFactory(httpConfiguration)) + httpConnector.port = address.port + httpConnector + } + server.connectors = arrayOf(connector) + + server.handler = handlerCollection + //runOnStop += Runnable { server.stop() } + server.start() + log.info("Embedded web server is listening on", "http://${InetAddress.getLocalHost().hostAddress}:${connector.port}/") + return server + } + + private fun buildServletContextHandler(localRpc: CordaRPCOps): ServletContextHandler { + return ServletContextHandler().apply { + contextPath = "/" + //setAttribute("node", this@Node) + addServlet(DataUploadServlet::class.java, "/upload/*") + addServlet(AttachmentDownloadServlet::class.java, "/attachments/*") + + val resourceConfig = ResourceConfig() + // Add your API provider classes (annotated for JAX-RS) here + // TODO: Remove this at cleanup time + //resourceConfig.register(Config(services)) + resourceConfig.register(ResponseFilter()) + // TODO: Move the API out of node and to here. + //resourceConfig.register(api) + + val webAPIsOnClasspath = pluginRegistries.flatMap { x -> x.webApis } + for (webapi in webAPIsOnClasspath) { + log.info("Add plugin web API from attachment $webapi") + val customAPI = try { + webapi.apply(localRpc) + } catch (ex: InvocationTargetException) { + log.error("Constructor $webapi threw an error: ", ex.targetException) + continue + } + resourceConfig.register(customAPI) + } + + val staticDirMaps = pluginRegistries.map { x -> x.staticServeDirs } + val staticDirs = staticDirMaps.flatMap { it.keys }.zip(staticDirMaps.flatMap { it.values }) + staticDirs.forEach { + val staticDir = ServletHolder(DefaultServlet::class.java) + staticDir.setInitParameter("resourceBase", it.second) + staticDir.setInitParameter("dirAllowed", "true") + staticDir.setInitParameter("pathInfoOnly", "true") + addServlet(staticDir, "/web/${it.first}/*") + } + + // Give the app a slightly better name in JMX rather than a randomly generated one and enable JMX + resourceConfig.addProperties(mapOf(ServerProperties.APPLICATION_NAME to "node.api", + ServerProperties.MONITORING_STATISTICS_MBEANS_ENABLED to "true")) + + val container = ServletContainer(resourceConfig) + val jerseyServlet = ServletHolder(container) + addServlet(jerseyServlet, "/api/*") + jerseyServlet.initOrder = 0 // Initialise at server start + + // Wrap all API calls in a database transaction. + // TODO: Remove this when cleaning up + //val filterHolder = FilterHolder(Node.DatabaseTransactionFilter(database)) + //addFilter(filterHolder, "/api/*", EnumSet.of(DispatcherType.REQUEST)) + //addFilter(filterHolder, "/upload/*", EnumSet.of(DispatcherType.REQUEST)) + } + } + + private fun connectLocalRpcAsNodeUser(): CordaRPCOps { + val client = CordaRPCClient(HostAndPort.fromString(nodeInfo.address.toString()), sslConfig) + client.start(ArtemisMessagingComponent.NODE_USER, ArtemisMessagingComponent.NODE_USER) + return client.proxy() + } + + /** Fetch CordaPluginRegistry classes registered in META-INF/services/net.corda.core.node.CordaPluginRegistry files that exist in the classpath */ + val pluginRegistries: List by lazy { + ServiceLoader.load(CordaPluginRegistry::class.java).toList() + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index f20919b9d1..6052ab671e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,6 +6,7 @@ include 'finance:isolated' include 'core' include 'node' include 'node:capsule' +include 'node:webserver' include 'client' include 'experimental' include 'experimental:sandbox'