Moved webserver to root project.

This commit is contained in:
Clinton Alexander
2017-03-30 17:34:32 +01:00
parent e3872c1469
commit da88a43e40
27 changed files with 17 additions and 17 deletions

View File

@ -0,0 +1,34 @@
package net.corda.webserver
import com.google.common.net.HostAndPort
import net.corda.core.getOrThrow
import net.corda.node.driver.addressMustBeBound
import net.corda.node.driver.addressMustNotBeBound
import net.corda.node.driver.driver
import org.junit.Test
import java.util.concurrent.Executors
class DriverTests {
companion object {
val executorService = Executors.newScheduledThreadPool(2)
fun webserverMustBeUp(webserverAddr: HostAndPort) {
addressMustBeBound(executorService, webserverAddr)
}
fun webserverMustBeDown(webserverAddr: HostAndPort) {
addressMustNotBeBound(executorService, webserverAddr)
}
}
@Test
fun `starting a node and independent web server works`() {
val addr = driver {
val node = startNode("test").getOrThrow()
val webserverAddr = startWebserver(node).getOrThrow()
webserverMustBeUp(webserverAddr)
webserverAddr
}
webserverMustBeDown(addr)
}
}

View File

@ -0,0 +1,83 @@
package net.corda.webserver
import com.typesafe.config.Config
import com.typesafe.config.ConfigFactory
import com.typesafe.config.ConfigParseOptions
import com.typesafe.config.ConfigRenderOptions
import joptsimple.OptionParser
import joptsimple.util.EnumConverter
import net.corda.core.div
import net.corda.core.utilities.loggerFor
import org.slf4j.event.Level
import java.io.PrintStream
import java.nio.file.Path
import java.nio.file.Paths
// NOTE: Do not use any logger in this class as args parsing is done before the logger is setup.
class ArgsParser {
private val optionParser = OptionParser()
// The intent of allowing a command line configurable directory and config path is to allow deployment flexibility.
// Other general configuration should live inside the config file unless we regularly need temporary overrides on the command line
private val baseDirectoryArg = optionParser
.accepts("base-directory", "The node working directory where all the files are kept")
.withRequiredArg()
.defaultsTo(".")
private val configFileArg = optionParser
.accepts("config-file", "The path to the config file")
.withRequiredArg()
.defaultsTo("node.conf")
private val loggerLevel = optionParser
.accepts("logging-level", "Enable logging at this level and higher")
.withRequiredArg()
.withValuesConvertedBy(object : EnumConverter<Level>(Level::class.java) {})
.defaultsTo(Level.INFO)
private val logToConsoleArg = optionParser.accepts("log-to-console", "If set, prints logging to the console as well as to a file.")
private val helpArg = optionParser.accepts("help").forHelp()
fun parse(vararg args: String): CmdLineOptions {
val optionSet = optionParser.parse(*args)
require(!optionSet.has(baseDirectoryArg) || !optionSet.has(configFileArg)) {
"${baseDirectoryArg.options()[0]} and ${configFileArg.options()[0]} cannot be specified together"
}
val baseDirectory = Paths.get(optionSet.valueOf(baseDirectoryArg)).normalize().toAbsolutePath()
val configFile = baseDirectory / optionSet.valueOf(configFileArg)
val help = optionSet.has(helpArg)
val loggingLevel = optionSet.valueOf(loggerLevel)
val logToConsole = optionSet.has(logToConsoleArg)
return CmdLineOptions(baseDirectory, configFile, help, loggingLevel, logToConsole)
}
fun printHelp(sink: PrintStream) = optionParser.printHelpOn(sink)
}
data class CmdLineOptions(val baseDirectory: Path,
val configFile: Path,
val help: Boolean,
val loggingLevel: Level,
val logToConsole: Boolean) {
private val log = loggerFor<CmdLineOptions>()
fun loadConfig(allowMissingConfig: Boolean = false, configOverrides: Map<String, Any?> = emptyMap()): Config {
return loadConfig(baseDirectory, configFile, allowMissingConfig, configOverrides)
}
private fun loadConfig(baseDirectory: Path,
configFile: Path = baseDirectory / "node.conf",
allowMissingConfig: Boolean = false,
configOverrides: Map<String, Any?> = emptyMap()): Config {
val parseOptions = ConfigParseOptions.defaults()
val defaultConfig = ConfigFactory.parseResources("web-reference.conf", parseOptions.setAllowMissing(false))
val appConfig = ConfigFactory.parseFile(configFile.toFile(), parseOptions.setAllowMissing(allowMissingConfig))
val overrideConfig = ConfigFactory.parseMap(configOverrides + mapOf(
// Add substitution values here
"basedir" to baseDirectory.toString())
)
val finalConfig = overrideConfig
.withFallback(appConfig)
.withFallback(defaultConfig)
.resolve()
log.info("Config:\n${finalConfig.root().render(ConfigRenderOptions.defaults())}")
return finalConfig
}
}

View File

@ -0,0 +1,69 @@
@file:JvmName("WebServer")
package net.corda.webserver
import com.typesafe.config.ConfigException
import net.corda.core.div
import net.corda.core.rootCause
import net.corda.webserver.internal.NodeWebServer
import org.slf4j.LoggerFactory
import java.lang.management.ManagementFactory
import java.net.InetAddress
import kotlin.system.exitProcess
fun main(args: Array<String>) {
val startTime = System.currentTimeMillis()
val argsParser = ArgsParser()
val cmdlineOptions = try {
argsParser.parse(*args)
} catch (ex: Exception) {
println("Unknown command line arguments: ${ex.message}")
exitProcess(1)
}
// Maybe render command line help.
if (cmdlineOptions.help) {
argsParser.printHelp(System.out)
exitProcess(0)
}
// Set up logging.
if (cmdlineOptions.logToConsole) {
// This property is referenced from the XML config file.
System.setProperty("consoleLogLevel", "info")
}
System.setProperty("log-path", (cmdlineOptions.baseDirectory / "web/logs").toString())
val log = LoggerFactory.getLogger("Main")
println("Logs can be found in ${System.getProperty("log-path")}")
val conf = try {
WebServerConfig(cmdlineOptions.baseDirectory, cmdlineOptions.loadConfig())
} catch (e: ConfigException) {
println("Unable to load the configuration file: ${e.rootCause.message}")
exitProcess(2)
}
log.info("Main class: ${WebServerConfig::class.java.protectionDomain.codeSource.location.toURI().path}")
val info = ManagementFactory.getRuntimeMXBean()
log.info("CommandLine Args: ${info.inputArguments.joinToString(" ")}")
log.info("Application Args: ${args.joinToString(" ")}")
log.info("bootclasspath: ${info.bootClassPath}")
log.info("classpath: ${info.classPath}")
log.info("VM ${info.vmName} ${info.vmVendor} ${info.vmVersion}")
log.info("Machine: ${InetAddress.getLocalHost().hostName}")
log.info("Working Directory: ${cmdlineOptions.baseDirectory}")
log.info("Starting as webserver on ${conf.webAddress}")
try {
val server = NodeWebServer(conf)
server.start()
val elapsed = (System.currentTimeMillis() - startTime) / 10 / 100.0
println("Webserver started up in $elapsed sec")
server.run()
} catch (e: Exception) {
log.error("Exception during node startup", e)
exitProcess(1)
}
}

View File

@ -0,0 +1,23 @@
package net.corda.webserver
import com.google.common.net.HostAndPort
import com.typesafe.config.Config
import net.corda.core.div
import net.corda.nodeapi.User
import net.corda.nodeapi.config.SSLConfiguration
import net.corda.nodeapi.config.getListOrElse
import net.corda.nodeapi.config.getValue
import java.nio.file.Path
/**
* [baseDirectory] is not retrieved from the config file but rather from a command line argument.
*/
class WebServerConfig(val baseDirectory: Path, val config: Config) : SSLConfiguration {
override val certificatesDirectory: Path get() = baseDirectory / "certificates"
override val keyStorePassword: String by config
override val trustStorePassword: String by config
val exportJMXto: String get() = "http"
val useHTTPS: Boolean by config
val p2pAddress: HostAndPort by config // TODO: Use RPC port instead of P2P port (RPC requires authentication, P2P does not)
val webAddress: HostAndPort by config
}

View File

@ -0,0 +1,43 @@
package net.corda.webserver.api
import net.corda.core.node.NodeInfo
import java.time.LocalDateTime
import javax.ws.rs.GET
import javax.ws.rs.Path
import javax.ws.rs.Produces
import javax.ws.rs.core.MediaType
import javax.ws.rs.core.Response
/**
* Top level interface to external interaction with the distributed ledger.
*
* Wherever a list is returned by a fetchXXX method that corresponds with an input list, that output list will have optional elements
* where a null indicates "missing" and the elements returned will be in the order corresponding with the input list.
*
*/
@Path("")
interface APIServer {
/**
* Report current UTC time as understood by the platform.
*/
@GET
@Path("servertime")
@Produces(MediaType.APPLICATION_JSON)
fun serverTime(): LocalDateTime
/**
* Report whether this node is started up or not.
*/
@GET
@Path("status")
@Produces(MediaType.TEXT_PLAIN)
fun status(): Response
/**
* Report this node's configuration and identities.
*/
@GET
@Path("info")
@Produces(MediaType.APPLICATION_JSON)
fun info(): NodeInfo
}

View File

@ -0,0 +1,21 @@
package net.corda.webserver.api
/**
* Extremely rudimentary query language which should most likely be replaced with a product.
*/
interface StatesQuery {
companion object {
fun select(criteria: Criteria): Selection {
return Selection(criteria)
}
}
// TODO make constructors private
data class Selection(val criteria: Criteria) : StatesQuery
interface Criteria {
object AllDeals : Criteria
data class Deal(val ref: String) : Criteria
}
}

View File

@ -0,0 +1,23 @@
package net.corda.webserver.internal
import net.corda.core.messaging.CordaRPCOps
import net.corda.webserver.api.*
import java.time.LocalDateTime
import java.time.ZoneId
import javax.ws.rs.core.Response
class APIServerImpl(val rpcOps: CordaRPCOps) : APIServer {
override fun serverTime(): LocalDateTime {
return LocalDateTime.ofInstant(rpcOps.currentNodeTime(), ZoneId.of("UTC"))
}
/**
* This endpoint is for polling if the webserver is serving. It will always return 200.
*/
override fun status(): Response {
return Response.ok("started").build()
}
override fun info() = rpcOps.nodeIdentity()
}

View File

@ -0,0 +1,186 @@
package net.corda.webserver.internal
import net.corda.client.rpc.CordaRPCClient
import net.corda.core.messaging.CordaRPCOps
import net.corda.core.node.CordaPluginRegistry
import net.corda.core.utilities.loggerFor
import net.corda.nodeapi.ArtemisMessagingComponent
import net.corda.webserver.WebServerConfig
import net.corda.webserver.servlets.AttachmentDownloadServlet
import net.corda.webserver.servlets.DataUploadServlet
import net.corda.webserver.servlets.ObjectMapperConfig
import net.corda.webserver.servlets.ResponseFilter
import org.apache.activemq.artemis.api.core.ActiveMQNotConnectedException
import org.eclipse.jetty.server.*
import org.eclipse.jetty.server.handler.HandlerCollection
import org.eclipse.jetty.servlet.DefaultServlet
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 org.slf4j.LoggerFactory
import java.lang.reflect.InvocationTargetException
import java.util.*
class NodeWebServer(val config: WebServerConfig) {
private companion object {
val log = loggerFor<NodeWebServer>()
val retryDelay = 1000L // Milliseconds
}
val address = config.webAddress
private var renderBasicInfoToConsole = true
private lateinit var server: Server
fun start() {
logAndMaybePrint("Starting as webserver: ${config.webAddress}")
server = initWebServer(retryConnectLocalRpc())
}
fun run() {
while (server.isRunning) {
Thread.sleep(100) // TODO: Redesign
}
}
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 (config.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 (config.useHTTPS) {
val httpsConfiguration = HttpConfiguration()
httpsConfiguration.outputBufferSize = 32768
httpsConfiguration.addCustomizer(SecureRequestCustomizer())
val sslContextFactory = SslContextFactory()
sslContextFactory.keyStorePath = config.keyStoreFile.toString()
sslContextFactory.setKeyStorePassword(config.keyStorePassword)
sslContextFactory.setKeyManagerPassword(config.keyStorePassword)
sslContextFactory.setTrustStorePath(config.trustStoreFile.toString())
sslContextFactory.setTrustStorePassword(config.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>(connector)
server.handler = handlerCollection
server.start()
log.info("Starting webserver on address $address")
return server
}
private fun buildServletContextHandler(localRpc: CordaRPCOps): ServletContextHandler {
return ServletContextHandler().apply {
contextPath = "/"
setAttribute("rpc", localRpc)
addServlet(DataUploadServlet::class.java, "/upload/*")
addServlet(AttachmentDownloadServlet::class.java, "/attachments/*")
val resourceConfig = ResourceConfig()
resourceConfig.register(ObjectMapperConfig(localRpc))
resourceConfig.register(ResponseFilter())
resourceConfig.register(APIServerImpl(localRpc))
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
}
}
private fun retryConnectLocalRpc(): CordaRPCOps {
while (true) {
try {
return connectLocalRpcAsNodeUser()
} catch (e: ActiveMQNotConnectedException) {
log.debug("Could not connect to ${config.p2pAddress} due to exception: ", e)
Thread.sleep(retryDelay)
// This error will happen if the server has yet to create the keystore
// Keep the fully qualified package name due to collisions with the Kotlin stdlib
// exception of the same name
} catch (e: java.nio.file.NoSuchFileException) {
log.debug("Tried to open a file that doesn't yet exist, retrying", e)
Thread.sleep(retryDelay)
}
}
}
private fun connectLocalRpcAsNodeUser(): CordaRPCOps {
log.info("Connecting to node at ${config.p2pAddress} as node user")
val client = CordaRPCClient(config.p2pAddress, config)
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<CordaPluginRegistry> by lazy {
ServiceLoader.load(CordaPluginRegistry::class.java).toList()
}
/** Used for useful info that we always want to show, even when not logging to the console */
fun logAndMaybePrint(description: String, info: String? = null) {
val msg = if (info == null) description else "${description.padEnd(40)}: $info"
val loggerName = if (renderBasicInfoToConsole) "BasicInfo" else "Main"
LoggerFactory.getLogger(loggerName).info(msg)
}
}

View File

@ -0,0 +1,56 @@
package net.corda.webserver.servlets
import net.corda.core.crypto.SecureHash
import net.corda.core.node.services.StorageService
import net.corda.core.utilities.loggerFor
import java.io.FileNotFoundException
import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
/**
* Allows the node administrator to either download full attachment zips, or individual files within those zips.
*
* GET /attachments/123abcdef12121 -> download the zip identified by this hash
* GET /attachments/123abcdef12121/foo.txt -> download that file specifically
*
* Files are always forced to be downloads, they may not be embedded into web pages for security reasons.
*
* TODO: See if there's a way to prevent access by JavaScript.
* TODO: Provide an endpoint that exposes attachment file listings, to make attachments browseable.
*/
class AttachmentDownloadServlet : HttpServlet() {
private val log = loggerFor<AttachmentDownloadServlet>()
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
val reqPath = req.pathInfo?.substring(1)
if (reqPath == null) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST)
return
}
try {
val hash = SecureHash.parse(reqPath.substringBefore('/'))
val storage = servletContext.getAttribute("storage") as StorageService
val attachment = storage.attachments.openAttachment(hash) ?: throw FileNotFoundException()
// Don't allow case sensitive matches inside the jar, it'd just be confusing.
val subPath = reqPath.substringAfter('/', missingDelimiterValue = "").toLowerCase()
resp.contentType = "application/octet-stream"
if (subPath == "") {
resp.addHeader("Content-Disposition", "attachment; filename=\"$hash.zip\"")
attachment.open().use { it.copyTo(resp.outputStream) }
} else {
val filename = subPath.split('/').last()
resp.addHeader("Content-Disposition", "attachment; filename=\"$filename\"")
attachment.extractFile(subPath, resp.outputStream)
}
resp.outputStream.close()
} catch(e: FileNotFoundException) {
log.warn("404 Not Found whilst trying to handle attachment download request for ${servletContext.contextPath}/$reqPath")
resp.sendError(HttpServletResponse.SC_NOT_FOUND)
return
}
}
}

View File

@ -0,0 +1,55 @@
package net.corda.webserver.servlets
import net.corda.core.messaging.CordaRPCOps
import net.corda.core.utilities.loggerFor
import org.apache.commons.fileupload.servlet.ServletFileUpload
import java.util.*
import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
/**
* Uploads to the node via the [CordaRPCOps] uploadFile interface.
*/
class DataUploadServlet: HttpServlet() {
private val log = loggerFor<DataUploadServlet>()
override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) {
@Suppress("DEPRECATION") // Bogus warning due to superclass static method being deprecated.
val isMultipart = ServletFileUpload.isMultipartContent(req)
val rpc = servletContext.getAttribute("rpc") as CordaRPCOps
if (!isMultipart) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "This end point is for data uploads only.")
return
}
val upload = ServletFileUpload()
val iterator = upload.getItemIterator(req)
val messages = ArrayList<String>()
if (!iterator.hasNext()) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Got an upload request with no files")
return
}
while (iterator.hasNext()) {
val item = iterator.next()
log.info("Receiving ${item.name}")
try {
val dataType = req.pathInfo.substring(1).substringBefore('/')
@Suppress("DEPRECATION") // TODO: Replace the use of uploadFile
messages += rpc.uploadFile(dataType, item.name, item.openStream())
log.info("${item.name} successfully accepted: ${messages.last()}")
} catch(e: RuntimeException) {
println(e)
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Got a file upload request for an unknown data type")
}
}
// Send back the hashes as a convenience for the user.
val writer = resp.writer
messages.forEach { writer.println(it) }
}
}

View File

@ -0,0 +1,17 @@
package net.corda.webserver.servlets
import com.fasterxml.jackson.databind.ObjectMapper
import net.corda.core.messaging.CordaRPCOps
import net.corda.jackson.JacksonSupport
import javax.ws.rs.ext.ContextResolver
import javax.ws.rs.ext.Provider
/**
* Primary purpose is to install Kotlin extensions for Jackson ObjectMapper so data classes work
* and to organise serializers / deserializers for java.time.* classes as necessary.
*/
@Provider
class ObjectMapperConfig(rpc: CordaRPCOps) : ContextResolver<ObjectMapper> {
val defaultObjectMapper = JacksonSupport.createDefaultMapper(rpc)
override fun getContext(type: Class<*>) = defaultObjectMapper
}

View File

@ -0,0 +1,33 @@
package net.corda.webserver.servlets
import javax.ws.rs.container.ContainerRequestContext
import javax.ws.rs.container.ContainerResponseContext
import javax.ws.rs.container.ContainerResponseFilter
import javax.ws.rs.ext.Provider
/**
* This adds headers needed for cross site scripting on API clients.
*/
@Provider
class ResponseFilter : ContainerResponseFilter {
override fun filter(requestContext: ContainerRequestContext, responseContext: ContainerResponseContext) {
val headers = responseContext.headers
/**
* TODO we need to revisit this for security reasons
*
* We don't want this scriptable from any web page anywhere, but for demo reasons
* we're making this really easy to access pending a proper security approach including
* access control and authentication at a network and software level.
*
*/
headers.add("Access-Control-Allow-Origin", "*")
if (requestContext.method == "OPTIONS") {
headers.add("Access-Control-Allow-Headers", "Content-Type,Accept,Origin")
headers.add("Access-Control-Allow-Methods", "POST,PUT,GET,OPTIONS")
}
}
}

View File

@ -0,0 +1 @@
useHTTPS = false