CORDA-3024 Rename the webserver (#5489)

This commit is contained in:
Tudor Malene
2019-09-26 10:20:49 +01:00
committed by Shams Asari
parent f945e57c17
commit 298d8ba69c
38 changed files with 42 additions and 42 deletions

View File

@ -0,0 +1,40 @@
@file:Suppress("DEPRECATION")
package net.corda.webserver
import net.corda.core.utilities.NetworkHostAndPort
import net.corda.core.utilities.getOrThrow
import net.corda.testing.core.DUMMY_BANK_A_NAME
import net.corda.testing.driver.DriverParameters
import net.corda.testing.driver.WebserverHandle
import net.corda.testing.node.internal.addressMustBeBound
import net.corda.testing.node.internal.addressMustNotBeBound
import net.corda.testing.driver.driver
import org.junit.Test
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
class WebserverDriverTests {
companion object {
private val executorService: ScheduledExecutorService = Executors.newScheduledThreadPool(2)
fun webserverMustBeUp(webserverHandle: WebserverHandle) {
addressMustBeBound(executorService, webserverHandle.listenAddress, webserverHandle.process)
}
fun webserverMustBeDown(webserverAddr: NetworkHostAndPort) {
addressMustNotBeBound(executorService, webserverAddr)
}
}
@Test
fun `starting a node and independent web server works`() {
val addr = driver(DriverParameters(notarySpecs = emptyList())) {
val node = startNode(providedName = DUMMY_BANK_A_NAME).getOrThrow()
val webserverHandle = startWebserver(node).getOrThrow()
webserverMustBeUp(webserverHandle)
webserverHandle.listenAddress
}
webserverMustBeDown(addr)
}
}

View File

@ -0,0 +1,229 @@
// Due to Capsule being in the default package, which cannot be imported, this caplet
// must also be in the default package. When using Kotlin there are a whole host of exceptions
// trying to construct this from Capsule, so it is written in Java.
import com.typesafe.config.*;
import sun.misc.Signal;
import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Stream;
public class CordaWebserverCaplet extends Capsule {
private Config nodeConfig = null;
private String baseDir = null;
protected CordaWebserverCaplet(Capsule pred) {
super(pred);
}
private Config parseConfigFile(List<String> args) {
this.baseDir = getBaseDirectory(args);
String config = getOption(args, "--config-file");
File configFile = (config == null) ? new File(baseDir, "node.conf") : new File(config);
try {
ConfigParseOptions parseOptions = ConfigParseOptions.defaults().setAllowMissing(false);
Config defaultConfig = ConfigFactory.parseResources("reference.conf", parseOptions);
Config baseDirectoryConfig = ConfigFactory.parseMap(Collections.singletonMap("baseDirectory", baseDir));
Config nodeConfig = ConfigFactory.parseFile(configFile, parseOptions);
return baseDirectoryConfig.withFallback(nodeConfig).withFallback(defaultConfig).resolve();
} catch (ConfigException e) {
log(LOG_DEBUG, e);
return ConfigFactory.empty();
}
}
File getConfigFile(List<String> args, String baseDir) {
String config = getOptionMultiple(args, Arrays.asList("--config-file", "-f"));
return (config == null || config.equals("")) ? new File(baseDir, "node.conf") : new File(config);
}
String getBaseDirectory(List<String> args) {
String baseDir = getOptionMultiple(args, Arrays.asList("--base-directory", "-b"));
return Paths.get((baseDir == null) ? "." : baseDir).toAbsolutePath().normalize().toString();
}
private String getOptionMultiple(List<String> args, List<String> possibleOptions) {
String result = null;
for(String option: possibleOptions) {
result = getOption(args, option);
if (result != null) break;
}
return result;
}
private String getOption(List<String> args, String option) {
final String lowerCaseOption = option.toLowerCase();
int index = 0;
for (String arg : args) {
if (arg.toLowerCase().equals(lowerCaseOption)) {
if (index < args.size() - 1 && !args.get(index + 1).startsWith("-")) {
return args.get(index + 1);
} else {
return null;
}
}
if (arg.toLowerCase().startsWith(lowerCaseOption)) {
if (arg.length() > option.length() && arg.substring(option.length(), option.length() + 1).equals("=")) {
return arg.substring(option.length() + 1);
} else {
return null;
}
}
index++;
}
return null;
}
@Override
protected ProcessBuilder prelaunch(List<String> jvmArgs, List<String> args) {
checkJavaVersion();
nodeConfig = parseConfigFile(args);
return super.prelaunch(jvmArgs, args);
}
// Add working directory variable to capsules string replacement variables.
@Override
protected String getVarValue(String var) {
if (var.equals("baseDirectory")) {
return baseDir;
} else {
return super.getVarValue(var);
}
}
/**
* Overriding the Caplet classpath generation via the intended interface in Capsule.
*/
@Override
@SuppressWarnings("unchecked")
protected <T> T attribute(Map.Entry<String, T> attr) {
// Equality is used here because Capsule never instantiates these attributes but instead reuses the ones
// defined as public static final fields on the Capsule class, therefore referential equality is safe.
if (ATTR_APP_CLASS_PATH == attr) {
T cp = super.attribute(attr);
File cordappsDir = new File(baseDir, "cordapps");
// Create cordapps directory if it doesn't exist.
if (!checkIfCordappDirExists(cordappsDir)) {
// If it fails, just return the existing class path. The main Corda jar will detect the error and fail gracefully.
return cp;
}
augmentClasspath((List<Path>) cp, cordappsDir);
try {
List<String> jarDirs = nodeConfig.getStringList("jarDirs");
log(LOG_VERBOSE, "Configured JAR directories = " + jarDirs);
for (String jarDir : jarDirs) {
augmentClasspath((List<Path>) cp, new File(jarDir));
}
} catch (ConfigException.Missing e) {
// Ignore since it's ok to be Missing. Other errors would be unexpected.
} catch (ConfigException e) {
log(LOG_QUIET, e);
}
return cp;
} else if (ATTR_JVM_ARGS == attr) {
// Read JVM args from the config if specified, else leave alone.
List<String> jvmArgs = new ArrayList<>((List<String>) super.attribute(attr));
try {
List<String> configJvmArgs = nodeConfig.getStringList("custom.jvmArgs");
jvmArgs.clear();
jvmArgs.addAll(configJvmArgs);
log(LOG_VERBOSE, "Configured JVM args = " + jvmArgs);
} catch (ConfigException.Missing e) {
// Ignore since it's ok to be Missing. Other errors would be unexpected.
} catch (ConfigException e) {
log(LOG_QUIET, e);
}
return (T) jvmArgs;
} else if (ATTR_SYSTEM_PROPERTIES == attr) {
// Add system properties, if specified, from the config.
Map<String, String> systemProps = new LinkedHashMap<>((Map<String, String>) super.attribute(attr));
try {
Config overrideSystemProps = nodeConfig.getConfig("systemProperties");
log(LOG_VERBOSE, "Configured system properties = " + overrideSystemProps);
for (Map.Entry<String, ConfigValue> entry : overrideSystemProps.entrySet()) {
systemProps.put(entry.getKey(), entry.getValue().unwrapped().toString());
}
} catch (ConfigException.Missing e) {
// Ignore since it's ok to be Missing. Other errors would be unexpected.
} catch (ConfigException e) {
log(LOG_QUIET, e);
}
return (T) systemProps;
} else return super.attribute(attr);
}
private void augmentClasspath(List<Path> classpath, File dir) {
try {
if (dir.exists()) {
// The following might return null if the directory is not there (we check this already) or if an I/O error occurs.
for (File file : dir.listFiles()) {
addToClasspath(classpath, file);
}
} else {
log(LOG_VERBOSE, "Directory to add in Classpath was not found " + dir.getAbsolutePath());
}
} catch (SecurityException | NullPointerException e) {
log(LOG_QUIET, e);
}
}
private static void checkJavaVersion() {
String version = System.getProperty("java.version");
if (version == null || Stream.of("1.8", "11").noneMatch(version::startsWith)) {
System.err.printf("Error: Unsupported Java version %s; currently only version 1.8 or 11 is supported.\n", version);
System.exit(1);
}
}
private Boolean checkIfCordappDirExists(File dir) {
try {
if (!dir.mkdir() && !dir.exists()) { // It is unlikely to enter this if-branch, but just in case.
logOnFailedCordappDir();
return false;
}
}
catch (SecurityException | NullPointerException e) {
logOnFailedCordappDir();
return false;
}
return true;
}
private void logOnFailedCordappDir() {
log(LOG_VERBOSE, "Cordapps dir could not be created");
}
private void addToClasspath(List<Path> classpath, File file) {
try {
if (file.canRead()) {
if (file.isFile() && isJAR(file)) {
classpath.add(file.toPath().toAbsolutePath());
} else if (file.isDirectory()) { // Search in nested folders as well. TODO: check for circular symlinks.
augmentClasspath(classpath, file);
}
} else {
log(LOG_VERBOSE, "File or directory to add in Classpath could not be read " + file.getAbsolutePath());
}
} catch (SecurityException | NullPointerException e) {
log(LOG_QUIET, e);
}
}
@Override
protected void liftoff() {
super.liftoff();
Signal.handle(new Signal("INT"), signal -> {
// Disable Ctrl-C for this process, so the child process can handle it in the shell instead.
});
}
private Boolean isJAR(File file) {
return file.getName().toLowerCase().endsWith(".jar");
}
}

View File

@ -0,0 +1,82 @@
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.internal.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("web-server.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) {
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
"baseDirectory" to baseDirectory.toString())
)
val finalConfig = overrideConfig
.withFallback(appConfig)
.withFallback(defaultConfig)
.resolve()
val log = loggerFor<CmdLineOptions>() // I guess this is lazy so it happens after logging init.
log.info("Config:\n${finalConfig.root().render(ConfigRenderOptions.defaults())}")
return finalConfig
}
}

View File

@ -0,0 +1,78 @@
@file:JvmName("WebServer")
package net.corda.webserver
import com.typesafe.config.ConfigException
import net.corda.core.internal.div
import net.corda.core.internal.errors.AddressBindingException
import net.corda.core.internal.location
import net.corda.core.internal.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 / "logs/web").toString())
val log = LoggerFactory.getLogger("Main")
println("This Corda-specific web server is deprecated and will be removed in future.")
println("Please switch to a regular web framework like Spring, J2EE or Play Framework.")
println()
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.location.toURI().path}")
val info = ManagementFactory.getRuntimeMXBean()
log.info("CommandLine Args: ${info.inputArguments.joinToString(" ")}")
log.info("Application Args: ${args.joinToString(" ")}")
// JDK 11 (bootclasspath no longer supported from JDK 9)
if (info.isBootClassPathSupported) 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: AddressBindingException) {
log.error(e.message)
exitProcess(1)
} catch (e: Exception) {
log.error("Exception during webserver startup", e)
exitProcess(1)
}
}

View File

@ -0,0 +1,45 @@
package net.corda.webserver
import com.typesafe.config.Config
import net.corda.core.internal.div
import net.corda.core.utilities.NetworkHostAndPort
import net.corda.nodeapi.internal.config.User
import net.corda.nodeapi.internal.config.getValue
import net.corda.nodeapi.internal.config.parseAs
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) {
val keyStorePath: String by config
val keyStorePassword: String by config
val trustStorePath: String by config
val trustStorePassword: String by config
val useHTTPS: Boolean by config
val myLegalName: String by config
val rpcAddress: NetworkHostAndPort by lazy {
if (config.hasPath("rpcSettings.address")) {
return@lazy NetworkHostAndPort.parse(config.getConfig("rpcSettings").getString("address"))
}
if (config.hasPath("rpcAddress")) {
return@lazy NetworkHostAndPort.parse(config.getString("rpcAddress"))
}
throw Exception("Missing rpc address property. Either 'rpcSettings' or 'rpcAddress' must be specified.")
}
val webAddress: NetworkHostAndPort by config
val runAs: User
init {
// TODO: replace with credentials supplied by a user
val users = if (config.hasPath("rpcUsers")) {
// TODO: remove this once config format is updated
config.getConfigList("rpcUsers")
} else {
config.getConfigList("security.authService.dataSource.users")
}
runAs = users.first().parseAs()
}
}

View File

@ -0,0 +1,94 @@
package net.corda.webserver.api
import net.corda.core.contracts.ContractState
import net.corda.core.contracts.StateAndRef
import net.corda.core.identity.Party
import net.corda.core.utilities.NetworkHostAndPort
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 addresses.
*/
@GET
@Path("addresses")
@Produces(MediaType.APPLICATION_JSON)
fun addresses(): List<NetworkHostAndPort>
/**
* Report this node's legal identities.
*/
@GET
@Path("identities")
@Produces(MediaType.APPLICATION_JSON)
fun identities(): List<Party>
/**
* Report this node's platform version.
*/
@GET
@Path("platformversion")
@Produces(MediaType.APPLICATION_JSON)
fun platformVersion(): Int
/**
* Report the peers on the network.
*/
@GET
@Path("peers")
@Produces(MediaType.APPLICATION_JSON)
fun peers(): List<Party>
/**
* Report the notaries on the network.
*/
@GET
@Path("notaries")
@Produces(MediaType.APPLICATION_JSON)
fun notaries(): List<Party>
/**
* Report this node's registered flows.
*/
@GET
@Path("flows")
@Produces(MediaType.APPLICATION_JSON)
fun flows(): List<String>
/**
* Report this node's vault states.
*/
@GET
@Path("states")
@Produces(MediaType.APPLICATION_JSON)
fun states(): List<StateAndRef<ContractState>>
}

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.converters
import net.corda.core.identity.CordaX500Name
import net.corda.core.internal.uncheckedCast
import java.lang.reflect.Type
import javax.ws.rs.ext.ParamConverter
import javax.ws.rs.ext.ParamConverterProvider
import javax.ws.rs.ext.Provider
object CordaX500NameConverter : ParamConverter<CordaX500Name> {
override fun toString(value: CordaX500Name) = value.toString()
override fun fromString(value: String) = CordaX500Name.parse(value)
}
@Provider
object CordaConverterProvider : ParamConverterProvider {
override fun <T : Any> getConverter(rawType: Class<T>, genericType: Type?, annotations: Array<out Annotation>?): ParamConverter<T>? {
if (rawType == CordaX500Name::class.java) {
return uncheckedCast(CordaX500NameConverter)
}
return null
}
}

View File

@ -0,0 +1,36 @@
package net.corda.webserver.internal
import net.corda.core.contracts.ContractState
import net.corda.core.messaging.CordaRPCOps
import net.corda.core.messaging.vaultQueryBy
import net.corda.webserver.api.APIServer
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 addresses() = rpcOps.nodeInfo().addresses
override fun identities() = rpcOps.nodeInfo().legalIdentities
override fun platformVersion() = rpcOps.nodeInfo().platformVersion
override fun peers() = rpcOps.networkMapSnapshot().flatMap { it.legalIdentities }
override fun notaries() = rpcOps.notaryIdentities()
override fun flows() = rpcOps.registeredFlows()
override fun states() = rpcOps.vaultQueryBy<ContractState>().states
}

View File

@ -0,0 +1,19 @@
package net.corda.webserver.internal
import net.corda.core.utilities.loggerFor
import javax.ws.rs.core.Response
import javax.ws.rs.ext.ExceptionMapper
import javax.ws.rs.ext.Provider
// Provides basic exception logging to all APIs
@Provider
class AllExceptionMapper : ExceptionMapper<Exception> {
companion object {
private val logger = loggerFor<APIServerImpl>() // XXX: Really?
}
override fun toResponse(exception: Exception?): Response {
logger.error("Unhandled exception in API", exception)
return Response.status(500).build()
}
}

View File

@ -0,0 +1,191 @@
package net.corda.webserver.internal
import com.google.common.html.HtmlEscapers.htmlEscaper
import io.netty.channel.unix.Errors
import net.corda.client.jackson.JacksonSupport
import net.corda.client.rpc.CordaRPCClientConfiguration
import net.corda.client.rpc.internal.ReconnectingCordaRPCOps
import net.corda.core.internal.errors.AddressBindingException
import net.corda.core.messaging.CordaRPCOps
import net.corda.core.utilities.contextLogger
import net.corda.webserver.WebServerConfig
import net.corda.webserver.converters.CordaConverterProvider
import net.corda.webserver.services.WebServerPluginRegistry
import net.corda.webserver.servlets.*
import org.eclipse.jetty.server.*
import org.eclipse.jetty.server.handler.ErrorHandler
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.glassfish.jersey.server.ResourceConfig
import org.glassfish.jersey.server.ServerProperties
import org.glassfish.jersey.servlet.ServletContainer
import org.slf4j.LoggerFactory
import java.io.IOException
import java.io.Writer
import java.lang.reflect.InvocationTargetException
import java.net.BindException
import java.util.*
import javax.servlet.http.HttpServletRequest
class NodeWebServer(val config: WebServerConfig) {
private companion object {
private val log = contextLogger()
const 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(reconnectingCordaRPCOps())
}
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()
// 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())
@Suppress("DEPRECATION")
val sslContextFactory = SslContextFactory()
sslContextFactory.keyStorePath = config.keyStorePath
sslContextFactory.setKeyStorePassword(config.keyStorePassword)
sslContextFactory.setKeyManagerPassword(config.keyStorePassword)
sslContextFactory.setTrustStorePath(config.trustStorePath)
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
try {
server.start()
} catch (e: IOException) {
if (e is BindException || e is Errors.NativeIoException && e.message?.contains("Address already in use") == true) {
throw AddressBindingException(address)
} else {
throw e
}
}
log.info("Starting webserver on address $address")
return server
}
private fun buildServletContextHandler(localRpc: CordaRPCOps): ServletContextHandler {
val safeLegalName = htmlEscaper().escape(config.myLegalName)
return ServletContextHandler().apply {
contextPath = "/"
errorHandler = object : ErrorHandler() {
@Throws(IOException::class)
override fun writeErrorPageHead(request: HttpServletRequest, writer: Writer, code: Int, message: String) {
writer.write("<meta http-equiv=\"Content-Type\" content=\"text/html;charset=utf-8\"/>\n")
writer.write("<title>Corda $safeLegalName : Error $code</title>\n")
}
@Throws(IOException::class)
override fun writeErrorPageMessage(request: HttpServletRequest, writer: Writer, code: Int, message: String, uri: String) {
writer.write("<h1>Corda $safeLegalName</h1>\n")
super.writeErrorPageMessage(request, writer, code, message, uri)
}
}
setAttribute("rpc", localRpc)
addServlet(DataUploadServlet::class.java, "/upload/*")
addServlet(AttachmentDownloadServlet::class.java, "/attachments/*")
val rpcObjectMapper = pluginRegistries.fold(JacksonSupport.createDefaultMapper(localRpc)) { om, plugin ->
plugin.customizeJSONSerialization(om)
om
}
val resourceConfig = ResourceConfig()
.register(ObjectMapperConfig(rpcObjectMapper))
.register(ResponseFilter())
.register(CordaConverterProvider)
.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 filteredPlugins = pluginRegistries.filterNot {
it.javaClass.name.startsWith("net.corda.node.") ||
it.javaClass.name.startsWith("net.corda.core.") ||
it.javaClass.name.startsWith("net.corda.nodeapi.")
}
val infoServlet = ServletHolder(CorDappInfoServlet(filteredPlugins, localRpc))
addServlet(infoServlet, "")
val container = ServletContainer(resourceConfig)
val jerseyServlet = ServletHolder(container)
addServlet(jerseyServlet, "/api/*")
jerseyServlet.initOrder = 0 // Initialise at server start
}
}
private fun reconnectingCordaRPCOps() = ReconnectingCordaRPCOps(config.rpcAddress, config.runAs.username , config.runAs.password, CordaRPCClientConfiguration.DEFAULT, null, javaClass.classLoader)
/** Fetch WebServerPluginRegistry classes registered in META-INF/services/net.corda.webserver.services.WebServerPluginRegistry files that exist in the classpath */
val pluginRegistries: List<WebServerPluginRegistry> by lazy {
ServiceLoader.load(WebServerPluginRegistry::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,30 @@
package net.corda.webserver.services
import com.fasterxml.jackson.databind.ObjectMapper
import net.corda.core.messaging.CordaRPCOps
import java.util.function.Function
/**
* Implement this interface on a class advertised in a META-INF/services/net.corda.webserver.services.WebServerPluginRegistry file
* to create web API to connect to Corda node via RPC.
*/
interface WebServerPluginRegistry {
/**
* List of lambdas returning JAX-RS objects. They may only depend on the RPC interface, as the webserver lives
* in a process separate from the node itself.
*/
val webApis: List<Function<CordaRPCOps, out Any>> get() = emptyList()
/**
* Map of static serving endpoints to the matching resource directory. All endpoints will be prefixed with "/web" and postfixed with "\*.
* Resource directories can be either on disk directories (especially when debugging) in the form "a/b/c". Serving from a JAR can
* be specified with: javaClass.getResource("<folder-in-jar>").toExternalForm()
*/
val staticServeDirs: Map<String, String> get() = emptyMap()
/**
* Optionally register extra JSON serializers to the default ObjectMapper provider
* @param om The [ObjectMapper] to register custom types against.
*/
fun customizeJSONSerialization(om: ObjectMapper) {}
}

View File

@ -0,0 +1,66 @@
package net.corda.webserver.servlets
import net.corda.core.internal.extractFile
import net.corda.core.crypto.SecureHash
import net.corda.core.messaging.CordaRPCOps
import net.corda.core.utilities.contextLogger
import java.io.FileNotFoundException
import java.io.IOException
import java.util.jar.JarInputStream
import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import javax.ws.rs.core.HttpHeaders
import javax.ws.rs.core.MediaType
/**
* 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 browsable.
*/
class AttachmentDownloadServlet : HttpServlet() {
companion object {
private val log = contextLogger()
}
@Throws(IOException::class)
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 rpc = servletContext.getAttribute("rpc") as CordaRPCOps
val attachment = rpc.openAttachment(hash)
// Don't allow case sensitive matches inside the jar, it'd just be confusing.
val subPath = reqPath.substringAfter('/', missingDelimiterValue = "").toLowerCase()
resp.contentType = MediaType.APPLICATION_OCTET_STREAM
if (subPath.isEmpty()) {
resp.addHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$hash.zip\"")
attachment.use { it.copyTo(resp.outputStream) }
} else {
val filename = subPath.split('/').last()
resp.addHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$filename\"")
JarInputStream(attachment).use { it.extractFile(subPath, resp.outputStream) }
}
// Closing the output stream commits our response. We cannot change the status code after this.
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,88 @@
package net.corda.webserver.servlets
import kotlinx.html.*
import kotlinx.html.stream.appendHTML
import net.corda.core.messaging.CordaRPCOps
import net.corda.webserver.services.WebServerPluginRegistry
import org.glassfish.jersey.server.model.Resource
import org.glassfish.jersey.server.model.ResourceMethod
import java.io.IOException
import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
/**
* Dumps some data about the installed CorDapps.
* TODO: Add registered flow initiators.
*/
class CorDappInfoServlet(val plugins: List<WebServerPluginRegistry>, val rpc: CordaRPCOps) : HttpServlet() {
@Throws(IOException::class)
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
resp.writer.appendHTML().html {
head {
title { +"Installed CorDapps" }
}
body {
h2 { +"Installed CorDapps" }
if (plugins.isEmpty()) {
p { +"No installed custom CorDapps." }
} else {
plugins.forEach { plugin ->
h3 { +plugin::class.java.name }
if (plugin.webApis.isNotEmpty()) {
div {
plugin.webApis.forEach { api ->
val resource = Resource.from(api.apply(rpc)::class.java)
p { +"${resource.name}:" }
val endpoints = processEndpoints("", resource, mutableListOf())
ul {
endpoints.forEach {
li { a(it.uri) { +"${it.method}\t${it.text}" } }
}
}
}
}
}
if (plugin.staticServeDirs.isNotEmpty()) {
div {
p { +"Static web content:" }
ul {
plugin.staticServeDirs.keys.forEach {
li { a("web/$it") { +it } }
}
}
}
}
}
}
}
}
}
data class Endpoint(val method: String, val uri: String, val text: String)
/**
* Recursively enumerate and record all of the end-points listed in the API implementations.
*/
private fun processEndpoints(uriPrefix: String, resource: Resource, endpoints: MutableList<Endpoint>): List<Endpoint> {
val resources = arrayListOf<Resource>()
val path = if (resource.path != null) "$uriPrefix/${resource.path}" else uriPrefix
resources.addAll(resource.childResources)
for (method in resource.allMethods) {
if (method.type == ResourceMethod.JaxrsType.SUB_RESOURCE_LOCATOR) {
resources.add(Resource.from(resource.resourceLocator.invocable.definitionMethod.returnType))
} else {
endpoints.add(Endpoint(method.httpMethod, "api$path", resource.path))
}
}
resources.forEach {
processEndpoints(path, it, endpoints)
}
return endpoints
}
}

View File

@ -0,0 +1,63 @@
package net.corda.webserver.servlets
import net.corda.core.messaging.CordaRPCOps
import net.corda.core.utilities.contextLogger
import org.apache.commons.fileupload.servlet.ServletFileUpload
import java.io.IOException
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() {
companion object {
private val log = contextLogger()
}
@Throws(IOException::class)
override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) {
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
}
fun reportError(message: String) {
println(message) // Show in webserver window.
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, message)
}
while (iterator.hasNext()) {
val item = iterator.next()
log.info("Receiving ${item.name}")
val dataType = req.pathInfo.substring(1).substringBefore('/')
if (dataType != "attachment") {
reportError("Got a file upload request for an unknown data type $dataType")
continue
}
try {
messages += rpc.uploadAttachment(item.openStream()).toString()
} catch (e: RuntimeException) {
reportError(e.toString())
continue
}
log.info("${item.name} successfully accepted: ${messages.last()}")
}
// Send back the hashes as a convenience for the user.
val writer = resp.writer
messages.forEach { writer.println(it) }
}
}

View File

@ -0,0 +1,14 @@
package net.corda.webserver.servlets
import com.fasterxml.jackson.databind.ObjectMapper
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(val defaultObjectMapper: ObjectMapper) : ContextResolver<ObjectMapper> {
override fun getContext(type: Class<*>) = defaultObjectMapper
}

View File

@ -0,0 +1,33 @@
package net.corda.webserver.servlets
import java.io.IOException
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 {
@Throws(IOException::class)
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,3 @@
useHTTPS = false
keyStorePassword = "cordacadevpass"
trustStorePassword = "trustpass"