Merge remote-tracking branch 'remotes/open/master' into merges/CORDA-792

# Conflicts:
#	.idea/compiler.xml
#	build.gradle
#	node/src/integration-test/kotlin/net/corda/node/services/rpc/RpcSslTest.kt
#	node/src/main/kotlin/net/corda/node/internal/NodeStartup.kt
#	node/src/main/kotlin/net/corda/node/shell/CordaAuthenticationPlugin.kt
#	node/src/main/kotlin/net/corda/node/shell/CordaSSHAuthInfo.kt
#	node/src/main/kotlin/net/corda/node/shell/RPCOpsWithContext.kt
#	node/src/test/kotlin/net/corda/node/services/config/NodeConfigurationImplTest.kt
#	settings.gradle
#	testing/test-common/src/main/kotlin/net/corda/testing/common/internal/UnsafeCertificatesFactory.kt
#	tools/shell/src/integration-test/kotlin/net/corda/tools/shell/SSHServerTest.kt
#	tools/shell/src/main/java/net/corda/tools/shell/FlowShellCommand.java
#	tools/shell/src/main/java/net/corda/tools/shell/RunShellCommand.java
#	tools/shell/src/main/java/net/corda/tools/shell/StartShellCommand.java
#	tools/shell/src/main/kotlin/net/corda/tools/shell/FlowWatchPrintingSubscriber.kt
#	tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShell.kt
#	tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShellCommand.kt
#	tools/shell/src/main/kotlin/net/corda/tools/shell/utlities/ANSIProgressRenderer.kt
#	tools/shell/src/main/resources/net/corda/tools/shell/base/login.groovy
#	tools/shell/src/test/kotlin/net/corda/tools/shell/CustomTypeJsonParsingTests.kt
#	tools/shell/src/test/kotlin/net/corda/tools/shell/InteractiveShellTest.kt
This commit is contained in:
szymonsztuka
2018-03-07 16:49:00 +00:00
54 changed files with 1388 additions and 359 deletions

View File

@ -0,0 +1,239 @@
package net.corda.tools.shell
import com.google.common.io.Files
import com.jcraft.jsch.ChannelExec
import com.jcraft.jsch.JSch
import net.corda.core.identity.CordaX500Name
import net.corda.core.messaging.CordaRPCOps
import net.corda.core.utilities.getOrThrow
import net.corda.node.services.Permissions
import net.corda.node.services.Permissions.Companion.all
import net.corda.testing.common.internal.withCertificates
import net.corda.testing.common.internal.withKeyStores
import net.corda.testing.core.ALICE_NAME
import net.corda.testing.driver.DriverParameters
import net.corda.testing.driver.driver
import net.corda.testing.driver.internal.RandomFree
import net.corda.testing.internal.useSslRpcOverrides
import net.corda.testing.node.User
import org.apache.activemq.artemis.api.core.ActiveMQNotConnectedException
import org.apache.activemq.artemis.api.core.ActiveMQSecurityException
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.bouncycastle.util.io.Streams
import org.junit.Test
import kotlin.test.assertTrue
class InteractiveShellIntegrationTest {
@Test
fun `shell should not log in with invalid credentials`() {
val user = User("u", "p", setOf())
driver(DriverParameters(isDebug = true, startNodesInProcess = true, portAllocation = RandomFree)) {
val nodeFuture = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user), startInSameProcess = true)
val node = nodeFuture.getOrThrow()
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
user = "fake", password = "fake",
hostAndPort = node.rpcAddress)
InteractiveShell.startShell(conf)
assertThatThrownBy { InteractiveShell.nodeInfo() }.isInstanceOf(ActiveMQSecurityException::class.java)
}
}
@Test
fun `shell should log in with valid crentials`() {
val user = User("u", "p", setOf())
driver {
val nodeFuture = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user), startInSameProcess = true)
val node = nodeFuture.getOrThrow()
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
user = user.username, password = user.password,
hostAndPort = node.rpcAddress)
InteractiveShell.startShell(conf)
InteractiveShell.nodeInfo()
}
}
@Test
fun `shell should log in with ssl`() {
val user = User("mark", "dadada", setOf(all()))
withCertificates { server, client, createSelfSigned, createSignedBy ->
val rootCertificate = createSelfSigned(CordaX500Name("SystemUsers/Node", "IT", "R3 London", "London", "London", "GB"))
val markCertificate = createSignedBy(CordaX500Name("shell", "IT", "R3 London", "London", "London", "GB"), rootCertificate)
// truststore needs to contain root CA for how the driver works...
server.keyStore["cordaclienttls"] = rootCertificate
server.trustStore["cordaclienttls"] = rootCertificate
server.trustStore["shell"] = markCertificate
client.keyStore["shell"] = markCertificate
client.trustStore["cordaclienttls"] = rootCertificate
withKeyStores(server, client) { nodeSslOptions, clientSslOptions ->
var successful = false
driver(DriverParameters(isDebug = true, startNodesInProcess = true, portAllocation = RandomFree)) {
startNode(rpcUsers = listOf(user), customOverrides = nodeSslOptions.useSslRpcOverrides()).getOrThrow().use { node ->
val sslConfiguration = ShellSslOptions(clientSslOptions.sslKeystore, clientSslOptions.keyStorePassword,
clientSslOptions.trustStoreFile, clientSslOptions.trustStorePassword)
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
user = user.username, password = user.password,
hostAndPort = node.rpcAddress,
ssl = sslConfiguration)
InteractiveShell.startShell(conf)
InteractiveShell.nodeInfo()
successful = true
}
}
assertThat(successful).isTrue()
}
}
}
@Test
fun `shell shoud not log in without ssl keystore`() {
val user = User("mark", "dadada", setOf("ALL"))
withCertificates { server, client, createSelfSigned, createSignedBy ->
val rootCertificate = createSelfSigned(CordaX500Name("SystemUsers/Node", "IT", "R3 London", "London", "London", "GB"))
val markCertificate = createSignedBy(CordaX500Name("shell", "IT", "R3 London", "London", "London", "GB"), rootCertificate)
// truststore needs to contain root CA for how the driver works...
server.keyStore["cordaclienttls"] = rootCertificate
server.trustStore["cordaclienttls"] = rootCertificate
server.trustStore["shell"] = markCertificate
//client key store doesn't have "mark" certificate
client.trustStore["cordaclienttls"] = rootCertificate
withKeyStores(server, client) { nodeSslOptions, clientSslOptions ->
driver(DriverParameters(isDebug = true, startNodesInProcess = true, portAllocation = RandomFree)) {
startNode(rpcUsers = listOf(user), customOverrides = nodeSslOptions.useSslRpcOverrides()).getOrThrow().use { node ->
val sslConfiguration = ShellSslOptions(clientSslOptions.sslKeystore, clientSslOptions.keyStorePassword,
clientSslOptions.trustStoreFile, clientSslOptions.trustStorePassword)
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
user = user.username, password = user.password,
hostAndPort = node.rpcAddress,
ssl = sslConfiguration)
InteractiveShell.startShell(conf)
assertThatThrownBy { InteractiveShell.nodeInfo() }.isInstanceOf(ActiveMQNotConnectedException::class.java)
}
}
}
}
}
@Test
fun `ssh runs flows via standalone shell`() {
val user = User("u", "p", setOf(Permissions.startFlow<SSHServerTest.FlowICanRun>(),
Permissions.invokeRpc(CordaRPCOps::registeredFlows),
Permissions.invokeRpc(CordaRPCOps::nodeInfo)))
driver {
val nodeFuture = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user), startInSameProcess = true)
val node = nodeFuture.getOrThrow()
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
user = user.username, password = user.password,
hostAndPort = node.rpcAddress,
sshdPort = 2224)
InteractiveShell.startShell(conf)
InteractiveShell.nodeInfo()
val session = JSch().getSession("u", "localhost", 2224)
session.setConfig("StrictHostKeyChecking", "no")
session.setPassword("p")
session.connect()
assertTrue(session.isConnected)
val channel = session.openChannel("exec") as ChannelExec
channel.setCommand("start FlowICanRun")
channel.connect(5000)
assertTrue(channel.isConnected)
val response = String(Streams.readAll(channel.inputStream))
val linesWithDoneCount = response.lines().filter { line -> line.contains("Done") }
channel.disconnect()
session.disconnect()
// There are ANSI control characters involved, so we want to avoid direct byte to byte matching.
assertThat(linesWithDoneCount).hasSize(1)
}
}
@Test
fun `ssh run flows via standalone shell over ssl to node`() {
val user = User("mark", "dadada", setOf(Permissions.startFlow<SSHServerTest.FlowICanRun>(),
Permissions.invokeRpc(CordaRPCOps::registeredFlows),
Permissions.invokeRpc(CordaRPCOps::nodeInfo)/*all()*/))
withCertificates { server, client, createSelfSigned, createSignedBy ->
val rootCertificate = createSelfSigned(CordaX500Name("SystemUsers/Node", "IT", "R3 London", "London", "London", "GB"))
val markCertificate = createSignedBy(CordaX500Name("shell", "IT", "R3 London", "London", "London", "GB"), rootCertificate)
// truststore needs to contain root CA for how the driver works...
server.keyStore["cordaclienttls"] = rootCertificate
server.trustStore["cordaclienttls"] = rootCertificate
server.trustStore["shell"] = markCertificate
client.keyStore["shell"] = markCertificate
client.trustStore["cordaclienttls"] = rootCertificate
withKeyStores(server, client) { nodeSslOptions, clientSslOptions ->
var successful = false
driver(DriverParameters(isDebug = true, startNodesInProcess = true, portAllocation = RandomFree)) {
startNode(rpcUsers = listOf(user), customOverrides = nodeSslOptions.useSslRpcOverrides()).getOrThrow().use { node ->
val sslConfiguration = ShellSslOptions(clientSslOptions.sslKeystore, clientSslOptions.keyStorePassword,
clientSslOptions.trustStoreFile, clientSslOptions.trustStorePassword)
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
user = user.username, password = user.password,
hostAndPort = node.rpcAddress,
ssl = sslConfiguration,
sshdPort = 2223)
InteractiveShell.startShell(conf)
InteractiveShell.nodeInfo()
val session = JSch().getSession("mark", "localhost", 2223)
session.setConfig("StrictHostKeyChecking", "no")
session.setPassword("dadada")
session.connect()
assertTrue(session.isConnected)
val channel = session.openChannel("exec") as ChannelExec
channel.setCommand("start FlowICanRun")
channel.connect(5000)
assertTrue(channel.isConnected)
val response = String(Streams.readAll(channel.inputStream))
val linesWithDoneCount = response.lines().filter { line -> line.contains("Done") }
channel.disconnect()
session.disconnect() // TODO Simon make sure to close them
// There are ANSI control characters involved, so we want to avoid direct byte to byte matching.
assertThat(linesWithDoneCount).hasSize(1)
successful = true
}
}
assertThat(successful).isTrue()
}
}
}
}

View File

@ -0,0 +1,198 @@
/*
* R3 Proprietary and Confidential
*
* Copyright (c) 2018 R3 Limited. All rights reserved.
*
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
*
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
*/
package net.corda.tools.shell
import co.paralleluniverse.fibers.Suspendable
import com.jcraft.jsch.ChannelExec
import com.jcraft.jsch.JSch
import com.jcraft.jsch.JSchException
import net.corda.core.flows.FlowLogic
import net.corda.core.flows.InitiatingFlow
import net.corda.core.flows.StartableByRPC
import net.corda.core.identity.Party
import net.corda.core.messaging.CordaRPCOps
import net.corda.core.utilities.ProgressTracker
import net.corda.core.utilities.getOrThrow
import net.corda.core.utilities.unwrap
import net.corda.node.services.Permissions.Companion.invokeRpc
import net.corda.node.services.Permissions.Companion.startFlow
import net.corda.testing.core.ALICE_NAME
import net.corda.testing.core.DUMMY_NOTARY_NAME
import net.corda.testing.driver.DriverParameters
import net.corda.testing.driver.driver
import net.corda.testing.internal.IntegrationTest
import net.corda.testing.internal.IntegrationTestSchemas
import net.corda.testing.internal.toDatabaseSchemaName
import net.corda.testing.node.User
import org.assertj.core.api.Assertions.assertThat
import org.bouncycastle.util.io.Streams
import org.junit.ClassRule
import org.junit.Test
import java.net.ConnectException
import kotlin.test.assertTrue
import kotlin.test.fail
class SSHServerTest : IntegrationTest() {
companion object {
@ClassRule @JvmField
val databaseSchemas = IntegrationTestSchemas(ALICE_NAME.toDatabaseSchemaName(), DUMMY_NOTARY_NAME.toDatabaseSchemaName())
}
@Test()
fun `ssh server does not start be default`() {
val user = User("u", "p", setOf())
// The driver will automatically pick up the annotated flows below
driver {
val node = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user))
node.getOrThrow()
val session = JSch().getSession("u", "localhost", 2222)
session.setConfig("StrictHostKeyChecking", "no")
session.setPassword("p")
try {
session.connect()
fail()
} catch (e:JSchException) {
assertTrue(e.cause is ConnectException)
}
}
}
@Test
fun `ssh server starts when configured`() {
val user = User("u", "p", setOf())
// The driver will automatically pick up the annotated flows below
driver {
val node = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user),
customOverrides = mapOf("sshd" to mapOf("port" to 2222)))
node.getOrThrow()
val session = JSch().getSession("u", "localhost", 2222)
session.setConfig("StrictHostKeyChecking", "no")
session.setPassword("p")
session.connect()
assertTrue(session.isConnected)
}
}
@Test
fun `ssh server verify credentials`() {
val user = User("u", "p", setOf())
// The driver will automatically pick up the annotated flows below
driver {
val node = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user),
customOverrides = mapOf("sshd" to mapOf("port" to 2222)))
node.getOrThrow()
val session = JSch().getSession("u", "localhost", 2222)
session.setConfig("StrictHostKeyChecking", "no")
session.setPassword("p_is_bad_password")
try {
session.connect()
fail("Server should reject invalid credentials")
} catch (e: JSchException) {
//There is no specialized exception for this
assertTrue(e.message == "Auth fail")
}
}
}
@Test
fun `ssh respects permissions`() {
val user = User("u", "p", setOf(startFlow<FlowICanRun>(),
invokeRpc(CordaRPCOps::wellKnownPartyFromX500Name)))
// The driver will automatically pick up the annotated flows below
driver(DriverParameters(isDebug = true)) {
val node = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user),
customOverrides = mapOf("sshd" to mapOf("port" to 2222)))
node.getOrThrow()
val session = JSch().getSession("u", "localhost", 2222)
session.setConfig("StrictHostKeyChecking", "no")
session.setPassword("p")
session.connect()
assertTrue(session.isConnected)
val channel = session.openChannel("exec") as ChannelExec
channel.setCommand("start FlowICannotRun otherParty: \"$ALICE_NAME\"")
channel.connect()
val response = String(Streams.readAll(channel.inputStream))
channel.disconnect()
session.disconnect()
assertThat(response).matches("(?s)User not authorized to perform RPC call .*")
}
}
@Test
fun `ssh runs flows`() {
val user = User("u", "p", setOf(startFlow<FlowICanRun>()))
// The driver will automatically pick up the annotated flows below
driver(DriverParameters(isDebug = true)) {
val node = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user),
customOverrides = mapOf("sshd" to mapOf("port" to 2222)))
node.getOrThrow()
val session = JSch().getSession("u", "localhost", 2222)
session.setConfig("StrictHostKeyChecking", "no")
session.setPassword("p")
session.connect()
assertTrue(session.isConnected)
val channel = session.openChannel("exec") as ChannelExec
channel.setCommand("start FlowICanRun")
channel.connect(5000)
assertTrue(channel.isConnected)
val response = String(Streams.readAll(channel.inputStream))
val linesWithDoneCount = response.lines().filter { line -> line.contains("Done") }
channel.disconnect()
session.disconnect()
// There are ANSI control characters involved, so we want to avoid direct byte to byte matching.
assertThat(linesWithDoneCount).size().isGreaterThanOrEqualTo(1)
}
}
@StartableByRPC
@InitiatingFlow
class FlowICanRun : FlowLogic<String>() {
private val HELLO_STEP = ProgressTracker.Step("Hello")
@Suspendable
override fun call(): String {
progressTracker?.currentStep = HELLO_STEP
return "bambam"
}
override val progressTracker: ProgressTracker? = ProgressTracker(HELLO_STEP)
}
@StartableByRPC
@InitiatingFlow
class FlowICannotRun(val otherParty: Party) : FlowLogic<String>() {
@Suspendable
override fun call(): String = initiateFlow(otherParty).receive<String>().unwrap { it }
override val progressTracker: ProgressTracker? = ProgressTracker()
}
}

View File

@ -0,0 +1,8 @@
user=demo1
baseDirectory="/Users/szymonsztuka/Documents/shell-config"
hostAndPort="localhost:10006"
sshdPort=2223
ssl {
keyStorePassword=password
trustStorePassword=password
}

View File

@ -0,0 +1,74 @@
/*
* R3 Proprietary and Confidential
*
* Copyright (c) 2018 R3 Limited. All rights reserved.
*
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
*
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
*/
package net.corda.tools.shell;
// See the comments at the top of run.java
import com.fasterxml.jackson.databind.ObjectMapper;
import net.corda.core.messaging.CordaRPCOps;
import net.corda.tools.shell.utlities.ANSIProgressRenderer;
import net.corda.tools.shell.utlities.CRaSHANSIProgressRenderer;
import org.crsh.cli.*;
import org.crsh.command.*;
import org.crsh.text.*;
import org.crsh.text.ui.TableElement;
import java.util.*;
import static net.corda.tools.shell.InteractiveShell.runFlowByNameFragment;
import static net.corda.tools.shell.InteractiveShell.runStateMachinesView;
@Man(
"Allows you to start flows, list the ones available and to watch flows currently running on the node.\n\n" +
"Starting flow is the primary way in which you command the node to change the ledger.\n\n" +
"This command is generic, so the right way to use it depends on the flow you wish to start. You can use the 'flow start'\n" +
"command with either a full class name, or a substring of the class name that's unambiguous. The parameters to the \n" +
"flow constructors (the right one is picked automatically) are then specified using the same syntax as for the run command."
)
public class FlowShellCommand extends InteractiveShellCommand {
@Command
@Usage("Start a (work)flow on the node. This is how you can change the ledger.")
public void start(
@Usage("The class name of the flow to run, or an unambiguous substring") @Argument String name,
@Usage("The data to pass as input") @Argument(unquote = false) List<String> input
) {
startFlow(name, input, out, ops(), ansiProgressRenderer(), objectMapper());
}
// TODO Limit number of flows shown option?
@Command
@Usage("watch information about state machines running on the node with result information")
public void watch(InvocationContext<TableElement> context) throws Exception {
runStateMachinesView(out, ops());
}
static void startFlow(@Usage("The class name of the flow to run, or an unambiguous substring") @Argument String name,
@Usage("The data to pass as input") @Argument(unquote = false) List<String> input,
RenderPrintWriter out,
CordaRPCOps rpcOps,
ANSIProgressRenderer ansiProgressRenderer,
ObjectMapper om) {
if (name == null) {
out.println("You must pass a name for the flow, see 'man flow'", Color.red);
return;
}
String inp = input == null ? "" : String.join(" ", input).trim();
runFlowByNameFragment(name, inp, out, rpcOps, ansiProgressRenderer != null ? ansiProgressRenderer : new CRaSHANSIProgressRenderer(out), om);
}
@Command
@Usage("list flows that user can start")
public void list(InvocationContext<String> context) throws Exception {
for (String name : ops().registeredFlows()) {
context.provide(name + System.lineSeparator());
}
}
}

View File

@ -0,0 +1,66 @@
/*
* R3 Proprietary and Confidential
*
* Copyright (c) 2018 R3 Limited. All rights reserved.
*
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
*
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
*/
package net.corda.tools.shell;
import net.corda.core.messaging.*;
import net.corda.client.jackson.*;
import org.crsh.cli.*;
import org.crsh.command.*;
import java.util.*;
// Note that this class cannot be converted to Kotlin because CRaSH does not understand InvocationContext<Map<?, ?>> which
// is the closest you can get in Kotlin to raw types.
public class RunShellCommand extends InteractiveShellCommand {
@Command
@Man(
"Runs a method from the CordaRPCOps interface, which is the same interface exposed to RPC clients.\n\n" +
"You can learn more about what commands are available by typing 'run' just by itself, or by\n" +
"consulting the developer guide at https://docs.corda.net/api/kotlin/corda/net.corda.core.messaging/-corda-r-p-c-ops/index.html"
)
@Usage("runs a method from the CordaRPCOps interface on the node.")
public Object main(
InvocationContext<Map> context,
@Usage("The command to run") @Argument(unquote = false) List<String> command
) {
StringToMethodCallParser<CordaRPCOps> parser = new StringToMethodCallParser<>(CordaRPCOps.class, objectMapper());
if (command == null) {
emitHelp(context, parser);
return null;
}
return InteractiveShell.runRPCFromString(command, out, context, ops(), objectMapper());
}
private void emitHelp(InvocationContext<Map> context, StringToMethodCallParser<CordaRPCOps> parser) {
// Sends data down the pipeline about what commands are available. CRaSH will render it nicely.
// Each element we emit is a map of column -> content.
Map<String, String> cmdsAndArgs = parser.getAvailableCommands();
for (Map.Entry<String, String> entry : cmdsAndArgs.entrySet()) {
// Skip these entries as they aren't really interesting for the user.
if (entry.getKey().equals("startFlowDynamic")) continue;
if (entry.getKey().equals("getProtocolVersion")) continue;
// Use a LinkedHashMap to ensure that the Command column comes first.
Map<String, String> m = new LinkedHashMap<>();
m.put("Command", entry.getKey());
m.put("Parameter types", entry.getValue());
try {
context.provide(m);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}

View File

@ -0,0 +1,29 @@
/*
* R3 Proprietary and Confidential
*
* Copyright (c) 2018 R3 Limited. All rights reserved.
*
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
*
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
*/
package net.corda.tools.shell;
// A simple forwarder to the "flow start" command, for easier typing.
import net.corda.tools.shell.utlities.ANSIProgressRenderer;
import net.corda.tools.shell.utlities.CRaSHANSIProgressRenderer;
import org.crsh.cli.*;
import java.util.*;
public class StartShellCommand extends InteractiveShellCommand {
@Command
@Man("An alias for 'flow start'. Example: \"start Yo target: Some other company\"")
public void main(@Usage("The class name of the flow to run, or an unambiguous substring") @Argument String name,
@Usage("The data to pass as input") @Argument(unquote = false) List<String> input) {
ANSIProgressRenderer ansiProgressRenderer = ansiProgressRenderer();
FlowShellCommand.startFlow(name, input, out, ops(), ansiProgressRenderer != null ? ansiProgressRenderer : new CRaSHANSIProgressRenderer(out), objectMapper());
}
}

View File

@ -0,0 +1,37 @@
package net.corda.tools.shell
import net.corda.core.messaging.CordaRPCOps
import net.corda.core.utilities.loggerFor
import org.apache.activemq.artemis.api.core.ActiveMQSecurityException
import org.crsh.auth.AuthInfo
import org.crsh.auth.AuthenticationPlugin
import org.crsh.plugin.CRaSHPlugin
class CordaAuthenticationPlugin(private val rpcOps: (username: String, credential: String) -> CordaRPCOps): CRaSHPlugin<AuthenticationPlugin<String>>(), AuthenticationPlugin<String> {
companion object {
private val logger = loggerFor<CordaAuthenticationPlugin>()
}
override fun getImplementation(): AuthenticationPlugin<String> = this
override fun getName(): String = "corda"
override fun authenticate(username: String?, credential: String?): AuthInfo {
if (username == null || credential == null) {
return AuthInfo.UNSUCCESSFUL
}
try {
val ops = rpcOps(username, credential)
return CordaSSHAuthInfo(true, ops)
} catch (e: ActiveMQSecurityException) {
logger.warn(e.message)
} catch (e: Exception) {
logger.warn(e.message, e)
}
return AuthInfo.UNSUCCESSFUL
}
override fun getCredentialType(): Class<String> = String::class.java
}

View File

@ -0,0 +1,15 @@
package net.corda.tools.shell
import com.fasterxml.jackson.databind.ObjectMapper
import net.corda.core.messaging.CordaRPCOps
import net.corda.tools.shell.InteractiveShell.createYamlInputMapper
import net.corda.tools.shell.utlities.ANSIProgressRenderer
import org.crsh.auth.AuthInfo
class CordaSSHAuthInfo(val successful: Boolean, val rpcOps: CordaRPCOps, val ansiProgressRenderer: ANSIProgressRenderer? = null) : AuthInfo {
override fun isSuccessful(): Boolean = successful
val yamlInputMapper: ObjectMapper by lazy {
createYamlInputMapper(rpcOps)
}
}

View File

@ -0,0 +1,136 @@
/*
* R3 Proprietary and Confidential
*
* Copyright (c) 2018 R3 Limited. All rights reserved.
*
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
*
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
*/
package net.corda.tools.shell
import net.corda.core.flows.StateMachineRunId
import net.corda.core.internal.concurrent.openFuture
import net.corda.core.context.InvocationContext
import net.corda.core.messaging.StateMachineUpdate
import net.corda.core.messaging.StateMachineUpdate.Added
import net.corda.core.messaging.StateMachineUpdate.Removed
import net.corda.core.transactions.SignedTransaction
import net.corda.core.utilities.Try
import org.crsh.text.Color
import org.crsh.text.Decoration
import org.crsh.text.RenderPrintWriter
import org.crsh.text.ui.LabelElement
import org.crsh.text.ui.Overflow
import org.crsh.text.ui.RowElement
import org.crsh.text.ui.TableElement
import rx.Subscriber
class FlowWatchPrintingSubscriber(private val toStream: RenderPrintWriter) : Subscriber<Any>() {
private val indexMap = HashMap<StateMachineRunId, Int>()
private val table = createStateMachinesTable()
val future = openFuture<Unit>()
init {
// The future is public and can be completed by something else to indicate we don't wish to follow
// anymore (e.g. the user pressing Ctrl-C).
future.then { unsubscribe() }
}
@Synchronized
override fun onCompleted() {
// The observable of state machines will never complete.
future.set(Unit)
}
@Synchronized
override fun onNext(t: Any?) {
if (t is StateMachineUpdate) {
toStream.cls()
createStateMachinesRow(t)
toStream.print(table)
toStream.println("Waiting for completion or Ctrl-C ... ")
toStream.flush()
}
}
@Synchronized
override fun onError(e: Throwable) {
toStream.println("Observable completed with an error")
future.setException(e)
}
private fun stateColor(update: StateMachineUpdate): Color {
return when (update) {
is Added -> Color.blue
is Removed -> if (update.result.isSuccess) Color.green else Color.red
}
}
private fun createStateMachinesTable(): TableElement {
val table = TableElement(1, 2, 1, 2).overflow(Overflow.HIDDEN).rightCellPadding(1)
val header = RowElement(true).add("Id", "Flow name", "Initiator", "Status").style(Decoration.bold.fg(Color.black).bg(Color.white))
table.add(header)
return table
}
// TODO Add progress tracker?
private fun createStateMachinesRow(smmUpdate: StateMachineUpdate) {
when (smmUpdate) {
is Added -> {
table.add(RowElement().add(
LabelElement(formatFlowId(smmUpdate.id)),
LabelElement(formatFlowName(smmUpdate.stateMachineInfo.flowLogicClassName)),
LabelElement(formatInvocationContext(smmUpdate.stateMachineInfo.invocationContext)),
LabelElement("In progress")
).style(stateColor(smmUpdate).fg()))
indexMap[smmUpdate.id] = table.rows.size - 1
}
is Removed -> {
val idx = indexMap[smmUpdate.id]
if (idx != null) {
val oldRow = table.rows[idx]
val flowNameLabel = oldRow.getCol(1) as LabelElement
val flowInitiatorLabel = oldRow.getCol(2) as LabelElement
table.rows[idx] = RowElement().add(
LabelElement(formatFlowId(smmUpdate.id)),
LabelElement(flowNameLabel.value),
LabelElement(flowInitiatorLabel.value),
LabelElement(formatFlowResult(smmUpdate.result))
).style(stateColor(smmUpdate).fg())
}
}
}
}
private fun formatFlowName(flowName: String): String {
val camelCaseRegex = Regex("(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
val name = flowName.split('.', '$').last()
// Split CamelCase and get rid of "flow" at the end if present.
return camelCaseRegex.split(name).filter { it.compareTo("Flow", true) != 0 }.joinToString(" ")
}
private fun formatFlowId(flowId: StateMachineRunId): String {
return flowId.toString().removeSurrounding("[", "]")
}
private fun formatInvocationContext(context: InvocationContext): String {
return context.principal().name
}
private fun formatFlowResult(flowResult: Try<*>): String {
fun successFormat(value: Any?): String {
return when (value) {
is SignedTransaction -> "Tx ID: " + value.id.toString()
is kotlin.Unit -> "No return value"
null -> "No return value"
else -> value.toString()
}
}
return when (flowResult) {
is Try.Success -> successFormat(flowResult.value)
is Try.Failure -> flowResult.exception.message ?: flowResult.exception.toString()
}
}
}

View File

@ -0,0 +1,635 @@
/*
* R3 Proprietary and Confidential
*
* Copyright (c) 2018 R3 Limited. All rights reserved.
*
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
*
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
*/
package net.corda.tools.shell
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.*
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.google.common.io.Closeables
import net.corda.client.jackson.JacksonSupport
import net.corda.client.jackson.StringToMethodCallParser
import net.corda.client.rpc.PermissionException
import net.corda.client.rpc.internal.createCordaRPCClientWithSslAndClassLoader
import net.corda.core.CordaException
import net.corda.core.concurrent.CordaFuture
import net.corda.core.contracts.UniqueIdentifier
import net.corda.core.flows.FlowLogic
import net.corda.core.identity.Party
import net.corda.core.internal.*
import net.corda.core.internal.concurrent.doneFuture
import net.corda.core.internal.concurrent.openFuture
import net.corda.core.messaging.CordaRPCOps
import net.corda.core.messaging.DataFeed
import net.corda.core.messaging.FlowProgressHandle
import net.corda.core.messaging.StateMachineUpdate
import net.corda.core.node.NodeInfo
import net.corda.core.utilities.NetworkHostAndPort
import net.corda.nodeapi.internal.config.SSLConfiguration
import net.corda.tools.shell.utlities.ANSIProgressRenderer
import net.corda.tools.shell.utlities.StdoutANSIProgressRenderer
import org.crsh.command.InvocationContext
import org.crsh.console.jline.JLineProcessor
import org.crsh.console.jline.TerminalFactory
import org.crsh.console.jline.console.ConsoleReader
import org.crsh.lang.impl.java.JavaLanguage
import org.crsh.plugin.CRaSHPlugin
import org.crsh.plugin.PluginContext
import org.crsh.plugin.PluginLifeCycle
import org.crsh.plugin.ServiceLoaderDiscovery
import org.crsh.shell.Shell
import org.crsh.shell.ShellFactory
import org.crsh.shell.impl.command.ExternalResolver
import org.crsh.text.Color
import org.crsh.text.RenderPrintWriter
import org.crsh.util.InterruptHandler
import org.crsh.util.Utils
import org.crsh.vfs.FS
import org.crsh.vfs.spi.file.FileMountFactory
import org.crsh.vfs.spi.url.ClassPathMountFactory
import org.json.JSONObject
import org.slf4j.LoggerFactory
import rx.Observable
import rx.Subscriber
import java.io.*
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.UndeclaredThrowableException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.*
import java.util.concurrent.CountDownLatch
import java.util.concurrent.ExecutionException
import java.util.concurrent.Future
import kotlin.concurrent.thread
// TODO: Add command history.
// TODO: Command completion.
// TODO: Do something sensible with commands that return a future.
// TODO: Configure default renderers, send objects down the pipeline, add commands to do json/xml/yaml outputs.
// TODO: Add a command to view last N lines/tail/control log4j2 loggers.
// TODO: Review or fix the JVM commands which have bitrotted and some are useless.
// TODO: Get rid of the 'java' command, it's kind of worthless.
// TODO: Fix up the 'dashboard' command which has some rendering issues.
// TODO: Resurrect or reimplement the mail plugin.
// TODO: Make it notice new shell commands added after the node started.
data class SSHDConfiguration(val port: Int) {
companion object {
internal const val INVALID_PORT_FORMAT = "Invalid port: %s"
private const val MISSING_PORT_FORMAT = "Missing port: %s"
/**
* Parses a string of the form port into a [SSHDConfiguration].
* @throws IllegalArgumentException if the port is missing or the string is garbage.
*/
@JvmStatic
fun parse(str: String): SSHDConfiguration {
require(!str.isNullOrBlank()) { SSHDConfiguration.MISSING_PORT_FORMAT.format(str) }
val port = try {
str.toInt()
} catch (ex: NumberFormatException) {
throw IllegalArgumentException("Port syntax is invalid, expected port")
}
return SSHDConfiguration(port)
}
}
init {
require(port in (0..0xffff)) { INVALID_PORT_FORMAT.format(port) }
}
}
data class ShellSslOptions(override val sslKeystore: Path, override val keyStorePassword: String, override val trustStoreFile:Path, override val trustStorePassword: String) : SSLConfiguration {
override val certificatesDirectory: Path get() = Paths.get("")
}
data class ShellConfiguration(
val commandsDirectory: Path,
val cordappsDirectory: Path? = null,
var user: String = "",
var password: String = "",
val hostAndPort: NetworkHostAndPort,
val ssl: ShellSslOptions? = null,
val sshdPort: Int? = null,
val sshHostKeyDirectory: Path? = null,
val noLocalShell: Boolean = false) {
companion object {
const val SSH_PORT = 2222
const val COMMANDS_DIR = "shell-commands"
const val CORDAPPS_DIR = "cordapps"
const val SSHD_HOSTKEY_DIR = "ssh"
}
}
object InteractiveShell {
private val log = LoggerFactory.getLogger(javaClass)
private lateinit var rpcOps: (username: String, credentials: String) -> CordaRPCOps
private lateinit var connection: CordaRPCOps
private var shell: Shell? = null
private var classLoader: ClassLoader? = null
/**
* Starts an interactive shell connected to the local terminal. This shell gives administrator access to the node
* internals.
*/
fun startShell(configuration: ShellConfiguration, classLoader: ClassLoader? = null) {
rpcOps = { username: String, credentials: String ->
val client = createCordaRPCClientWithSslAndClassLoader(hostAndPort = configuration.hostAndPort,
sslConfiguration = configuration.ssl, classLoader = classLoader)
client.start(username, credentials).proxy
}
InteractiveShell.classLoader = classLoader
val runSshDaemon = configuration.sshdPort != null
val config = Properties()
if (runSshDaemon) {
// Enable SSH access. Note: these have to be strings, even though raw object assignments also work.
config["crash.ssh.port"] = configuration.sshdPort?.toString()
config["crash.auth"] = "corda"
configuration.sshHostKeyDirectory?.apply {
val sshKeysDir = configuration.sshHostKeyDirectory
sshKeysDir.toFile().mkdirs()
config["crash.ssh.keypath"] = (sshKeysDir / "hostkey.pem").toString()
config["crash.ssh.keygen"] = "true"
}
}
ExternalResolver.INSTANCE.addCommand("run", "Runs a method from the CordaRPCOps interface on the node.", RunShellCommand::class.java)
ExternalResolver.INSTANCE.addCommand("flow", "Commands to work with flows. Flows are how you can change the ledger.", FlowShellCommand::class.java)
ExternalResolver.INSTANCE.addCommand("start", "An alias for 'flow start'", StartShellCommand::class.java)
shell = ShellLifecycle(configuration.commandsDirectory).start(config, configuration.user, configuration.password)
}
fun runLocalShell(onExit: () -> Unit = {}) {
val terminal = TerminalFactory.create()
val consoleReader = ConsoleReader("Corda", FileInputStream(FileDescriptor.`in`), System.out, terminal)
val jlineProcessor = JLineProcessor(terminal.isAnsiSupported, shell, consoleReader, System.out)
InterruptHandler { jlineProcessor.interrupt() }.install()
thread(name = "Command line shell processor", isDaemon = true) {
Emoji.renderIfSupported {
jlineProcessor.run()
}
}
thread(name = "Command line shell terminator", isDaemon = true) {
// Wait for the shell to finish.
jlineProcessor.closed()
log.info("Command shell has exited")
terminal.restore()
onExit.invoke()
}
}
class ShellLifecycle(private val shellCommands: Path) : PluginLifeCycle() {
fun start(config: Properties, localUserName: String = "", localUserPassword: String = ""): Shell {
val classLoader = this.javaClass.classLoader
val classpathDriver = ClassPathMountFactory(classLoader)
val fileDriver = FileMountFactory(Utils.getCurrentDirectory())
val extraCommandsPath = shellCommands.toAbsolutePath().createDirectories()
val commandsFS = FS.Builder()
.register("file", fileDriver)
.mount("file:" + extraCommandsPath)
.register("classpath", classpathDriver)
.mount("classpath:/net/corda/tools/shell/")
.mount("classpath:/crash/commands/")
.build()
val confFS = FS.Builder()
.register("classpath", classpathDriver)
.mount("classpath:/crash")
.build()
val discovery = object : ServiceLoaderDiscovery(classLoader) {
override fun getPlugins(): Iterable<CRaSHPlugin<*>> {
// Don't use the Java language plugin (we may not have tools.jar available at runtime), this
// will cause any commands using JIT Java compilation to be suppressed. In CRaSH upstream that
// is only the 'jmx' command.
return super.getPlugins().filterNot { it is JavaLanguage } + CordaAuthenticationPlugin(rpcOps)
}
}
val attributes = emptyMap<String,Any>()
val context = PluginContext(discovery, attributes, commandsFS, confFS, classLoader)
context.refresh()
this.config = config
start(context)
connection = makeRPCOps(rpcOps, localUserName, localUserPassword)
return context.getPlugin(ShellFactory::class.java).create(null, CordaSSHAuthInfo(false, connection, StdoutANSIProgressRenderer))
}
}
fun nodeInfo() = try {
connection.nodeInfo()
} catch (e: UndeclaredThrowableException) {
throw e.cause ?: e
}
fun createYamlInputMapper(rpcOps: CordaRPCOps): ObjectMapper {
// Return a standard Corda Jackson object mapper, configured to use YAML by default and with extra
// serializers.
return JacksonSupport.createDefaultMapper(rpcOps, YAMLFactory(), true).apply {
val rpcModule = SimpleModule()
rpcModule.addDeserializer(InputStream::class.java, InputStreamDeserializer)
rpcModule.addDeserializer(UniqueIdentifier::class.java, UniqueIdentifierDeserializer)
rpcModule.addDeserializer(UUID::class.java, UUIDDeserializer)
registerModule(rpcModule)
}
}
private object NodeInfoSerializer : JsonSerializer<NodeInfo>() {
override fun serialize(nodeInfo: NodeInfo, gen: JsonGenerator, serializers: SerializerProvider) {
val json = JSONObject()
json["addresses"] = nodeInfo.addresses.map { address -> address.serialise() }
json["legalIdentities"] = nodeInfo.legalIdentities.map { address -> address.serialise() }
json["platformVersion"] = nodeInfo.platformVersion
json["serial"] = nodeInfo.serial
gen.writeRaw(json.toString())
}
private fun NetworkHostAndPort.serialise() = this.toString()
private fun Party.serialise() = JSONObject().put("name", this.name)
private operator fun JSONObject.set(key: String, value: Any?): JSONObject {
return put(key, value)
}
}
private fun createOutputMapper(): ObjectMapper {
return JacksonSupport.createNonRpcMapper().apply {
// Register serializers for stateful objects from libraries that are special to the RPC system and don't
// make sense to print out to the screen. For classes we own, annotations can be used instead.
val rpcModule = SimpleModule()
rpcModule.addSerializer(Observable::class.java, ObservableSerializer)
rpcModule.addSerializer(InputStream::class.java, InputStreamSerializer)
rpcModule.addSerializer(NodeInfo::class.java, NodeInfoSerializer)
registerModule(rpcModule)
disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
enable(SerializationFeature.INDENT_OUTPUT)
}
}
// TODO: This should become the default renderer rather than something used specifically by commands.
private val outputMapper by lazy { createOutputMapper() }
/**
* Called from the 'flow' shell command. Takes a name fragment and finds a matching flow, or prints out
* the list of options if the request is ambiguous. Then parses [inputData] as constructor arguments using
* the [runFlowFromString] method and starts the requested flow. Ctrl-C can be used to cancel.
*/
@JvmStatic
fun runFlowByNameFragment(nameFragment: String, inputData: String, output: RenderPrintWriter, rpcOps: CordaRPCOps, ansiProgressRenderer: ANSIProgressRenderer, om: ObjectMapper) {
val matches = try {
rpcOps.registeredFlows().filter { nameFragment in it }
} catch (e: PermissionException) {
output.println(e.message ?: "Access denied", Color.red)
return
}
if (matches.isEmpty()) {
output.println("No matching flow found, run 'flow list' to see your options.", Color.red)
return
} else if (matches.size > 1) {
output.println("Ambiguous name provided, please be more specific. Your options are:")
matches.forEachIndexed { i, s -> output.println("${i + 1}. $s", Color.yellow) }
return
}
val flowClazz: Class<FlowLogic<*>> = if (classLoader != null) {
uncheckedCast(Class.forName(matches.single(), true, classLoader))
} else {
uncheckedCast(Class.forName(matches.single()))
}
try {
// Show the progress tracker on the console until the flow completes or is interrupted with a
// Ctrl-C keypress.
val stateObservable = runFlowFromString({ clazz, args -> rpcOps.startTrackedFlowDynamic(clazz, *args) }, inputData, flowClazz, om)
val latch = CountDownLatch(1)
ansiProgressRenderer.render(stateObservable, { latch.countDown() })
try {
// Wait for the flow to end and the progress tracker to notice. By the time the latch is released
// the tracker is done with the screen.
latch.await()
} catch (e: InterruptedException) {
// TODO: When the flow framework allows us to kill flows mid-flight, do so here.
}
} catch (e: NoApplicableConstructor) {
output.println("No matching constructor found:", Color.red)
e.errors.forEach { output.println("- $it", Color.red) }
} catch (e: PermissionException) {
output.println(e.message ?: "Access denied", Color.red)
} finally {
InputStreamDeserializer.closeAll()
}
}
class NoApplicableConstructor(val errors: List<String>) : CordaException(this.toString()) {
override fun toString() = (listOf("No applicable constructor for flow. Problems were:") + errors).joinToString(System.lineSeparator())
}
// TODO: This utility is generally useful and might be better moved to the node class, or an RPC, if we can commit to making it stable API.
/**
* Given a [FlowLogic] class and a string in one-line Yaml form, finds an applicable constructor and starts
* the flow, returning the created flow logic. Useful for lightweight invocation where text is preferable
* to statically typed, compiled code.
*
* See the [StringToMethodCallParser] class to learn more about limitations and acceptable syntax.
*
* @throws NoApplicableConstructor if no constructor could be found for the given set of types.
*/
@Throws(NoApplicableConstructor::class)
fun <T> runFlowFromString(invoke: (Class<out FlowLogic<T>>, Array<out Any?>) -> FlowProgressHandle<T>,
inputData: String,
clazz: Class<out FlowLogic<T>>,
om: ObjectMapper): FlowProgressHandle<T> {
// For each constructor, attempt to parse the input data as a method call. Use the first that succeeds,
// and keep track of the reasons we failed so we can print them out if no constructors are usable.
val parser = StringToMethodCallParser(clazz, om)
val errors = ArrayList<String>()
for (ctor in clazz.constructors) {
var paramNamesFromConstructor: List<String>? = null
fun getPrototype(): List<String> {
val argTypes = ctor.parameterTypes.map { it.simpleName }
return paramNamesFromConstructor!!.zip(argTypes).map { (name, type) -> "$name: $type" }
}
try {
// Attempt construction with the given arguments.
paramNamesFromConstructor = parser.paramNamesFromConstructor(ctor)
val args = parser.parseArguments(clazz.name, paramNamesFromConstructor!!.zip(ctor.parameterTypes), inputData)
if (args.size != ctor.parameterTypes.size) {
errors.add("${getPrototype()}: Wrong number of arguments (${args.size} provided, ${ctor.parameterTypes.size} needed)")
continue
}
val flow = ctor.newInstance(*args) as FlowLogic<*>
if (flow.progressTracker == null) {
errors.add("A flow must override the progress tracker in order to be run from the shell")
continue
}
return invoke(clazz, args)
} catch (e: StringToMethodCallParser.UnparseableCallException.MissingParameter) {
errors.add("${getPrototype()}: missing parameter ${e.paramName}")
} catch (e: StringToMethodCallParser.UnparseableCallException.TooManyParameters) {
errors.add("${getPrototype()}: too many parameters")
} catch (e: StringToMethodCallParser.UnparseableCallException.ReflectionDataMissing) {
val argTypes = ctor.parameterTypes.map { it.simpleName }
errors.add("$argTypes: <constructor missing parameter reflection data>")
} catch (e: StringToMethodCallParser.UnparseableCallException) {
val argTypes = ctor.parameterTypes.map { it.simpleName }
errors.add("$argTypes: ${e.message}")
}
}
throw NoApplicableConstructor(errors)
}
// TODO Filtering on error/success when we will have some sort of flow auditing, for now it doesn't make much sense.
@JvmStatic
fun runStateMachinesView(out: RenderPrintWriter, rpcOps: CordaRPCOps): Any? {
val proxy = rpcOps
val (stateMachines, stateMachineUpdates) = proxy.stateMachinesFeed()
val currentStateMachines = stateMachines.map { StateMachineUpdate.Added(it) }
val subscriber = FlowWatchPrintingSubscriber(out)
stateMachineUpdates.startWith(currentStateMachines).subscribe(subscriber)
var result: Any? = subscriber.future
if (result is Future<*>) {
if (!result.isDone) {
out.cls()
out.println("Waiting for completion or Ctrl-C ... ")
out.flush()
}
try {
result = result.get()
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
} catch (e: ExecutionException) {
throw e.rootCause
} catch (e: InvocationTargetException) {
throw e.rootCause
}
}
return result
}
@JvmStatic
fun runRPCFromString(input: List<String>, out: RenderPrintWriter, context: InvocationContext<out Any>, cordaRPCOps: CordaRPCOps, om: ObjectMapper): Any? {
val cmd = input.joinToString(" ").trim { it <= ' ' }
if (cmd.toLowerCase().startsWith("startflow")) {
// The flow command provides better support and startFlow requires special handling anyway due to
// the generic startFlow RPC interface which offers no type information with which to parse the
// string form of the command.
out.println("Please use the 'flow' command to interact with flows rather than the 'run' command.", Color.yellow)
return null
}
var result: Any? = null
try {
InputStreamSerializer.invokeContext = context
val parser = StringToMethodCallParser(CordaRPCOps::class.java, om)
val call = parser.parse(cordaRPCOps, cmd)
result = call.call()
if (result != null && result !is kotlin.Unit && result !is Void) {
result = printAndFollowRPCResponse(result, out)
}
if (result is Future<*>) {
if (!result.isDone) {
out.println("Waiting for completion or Ctrl-C ... ")
out.flush()
}
try {
result = result.get()
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
} catch (e: ExecutionException) {
throw e.rootCause
} catch (e: InvocationTargetException) {
throw e.rootCause
}
}
} catch (e: StringToMethodCallParser.UnparseableCallException) {
out.println(e.message, Color.red)
out.println("Please try 'man run' to learn what syntax is acceptable")
} catch (e: Exception) {
out.println("RPC failed: ${e.rootCause}", Color.red)
} finally {
InputStreamSerializer.invokeContext = null
InputStreamDeserializer.closeAll()
}
return result
}
private fun printAndFollowRPCResponse(response: Any?, out: PrintWriter): CordaFuture<Unit> {
val mapElement: (Any?) -> String = { element -> outputMapper.writerWithDefaultPrettyPrinter().writeValueAsString(element) }
val mappingFunction: (Any?) -> String = { value ->
if (value is Collection<*>) {
value.joinToString(",${System.lineSeparator()} ", "[${System.lineSeparator()} ", "${System.lineSeparator()}]") { element ->
mapElement(element)
}
} else {
mapElement(value)
}
}
return maybeFollow(response, mappingFunction, out)
}
private class PrintingSubscriber(private val printerFun: (Any?) -> String, private val toStream: PrintWriter) : Subscriber<Any>() {
private var count = 0
val future = openFuture<Unit>()
init {
// The future is public and can be completed by something else to indicate we don't wish to follow
// anymore (e.g. the user pressing Ctrl-C).
future.then { unsubscribe() }
}
@Synchronized
override fun onCompleted() {
toStream.println("Observable has completed")
future.set(Unit)
}
@Synchronized
override fun onNext(t: Any?) {
count++
toStream.println("Observation $count: " + printerFun(t))
toStream.flush()
}
@Synchronized
override fun onError(e: Throwable) {
toStream.println("Observable completed with an error")
e.printStackTrace(toStream)
future.setException(e)
}
}
private fun maybeFollow(response: Any?, printerFun: (Any?) -> String, out: PrintWriter): CordaFuture<Unit> {
// Match on a couple of common patterns for "important" observables. It's tough to do this in a generic
// way because observables can be embedded anywhere in the object graph, and can emit other arbitrary
// object graphs that contain yet more observables. So we just look for top level responses that follow
// the standard "track" pattern, and print them until the user presses Ctrl-C
if (response == null) return doneFuture(Unit)
if (response is DataFeed<*, *>) {
out.println("Snapshot:")
out.println(printerFun(response.snapshot))
out.flush()
out.println("Updates:")
return printNextElements(response.updates, printerFun, out)
}
if (response is Observable<*>) {
return printNextElements(response, printerFun, out)
}
out.println(printerFun(response))
return doneFuture(Unit)
}
private fun printNextElements(elements: Observable<*>, printerFun: (Any?) -> String, out: PrintWriter): CordaFuture<Unit> {
val subscriber = PrintingSubscriber(printerFun, out)
uncheckedCast(elements).subscribe(subscriber)
return subscriber.future
}
//region Extra serializers
//
// These serializers are used to enable the user to specify objects that aren't natural data containers in the shell,
// and for the shell to print things out that otherwise wouldn't be usefully printable.
private object ObservableSerializer : JsonSerializer<Observable<*>>() {
override fun serialize(value: Observable<*>, gen: JsonGenerator, serializers: SerializerProvider) {
gen.writeString("(observable)")
}
}
// A file name is deserialized to an InputStream if found.
object InputStreamDeserializer : JsonDeserializer<InputStream>() {
// Keep track of them so we can close them later.
private val streams = Collections.synchronizedSet(HashSet<InputStream>())
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): InputStream {
val stream = object : BufferedInputStream(Files.newInputStream(Paths.get(p.text))) {
override fun close() {
super.close()
streams.remove(this)
}
}
streams += stream
return stream
}
fun closeAll() {
// Clone the set with toList() here so each closed stream can be removed from the set inside close().
streams.toList().forEach { Closeables.closeQuietly(it) }
}
}
// An InputStream found in a response triggers a request to the user to provide somewhere to save it.
private object InputStreamSerializer : JsonSerializer<InputStream>() {
var invokeContext: InvocationContext<*>? = null
override fun serialize(value: InputStream, gen: JsonGenerator, serializers: SerializerProvider) {
try {
val toPath = invokeContext!!.readLine("Path to save stream to (enter to ignore): ", true)
if (toPath == null || toPath.isBlank()) {
gen.writeString("<not saved>")
} else {
val path = Paths.get(toPath)
value.copyTo(path)
gen.writeString("<saved to: ${path.toAbsolutePath()}>")
}
} finally {
try {
value.close()
} catch (e: IOException) {
// Ignore.
}
}
}
}
/**
* String value deserialized to [UniqueIdentifier].
* Any string value used as [UniqueIdentifier.externalId].
* If string contains underscore(i.e. externalId_uuid) then split with it.
* Index 0 as [UniqueIdentifier.externalId]
* Index 1 as [UniqueIdentifier.id]
* */
object UniqueIdentifierDeserializer : JsonDeserializer<UniqueIdentifier>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): UniqueIdentifier {
//Check if externalId and UUID may be separated by underscore.
if (p.text.contains("_")) {
val ids = p.text.split("_")
//Create UUID object from string.
val uuid: UUID = UUID.fromString(ids[1])
//Create UniqueIdentifier object using externalId and UUID.
return UniqueIdentifier(ids[0], uuid)
}
//Any other string used as externalId.
return UniqueIdentifier.fromString(p.text)
}
}
/**
* String value deserialized to [UUID].
* */
object UUIDDeserializer : JsonDeserializer<UUID>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): UUID {
//Create UUID object from string.
return UUID.fromString(p.text)
}
}
//endregion
}

View File

@ -0,0 +1,23 @@
/*
* R3 Proprietary and Confidential
*
* Copyright (c) 2018 R3 Limited. All rights reserved.
*
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
*
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
*/
package net.corda.tools.shell
import org.crsh.command.BaseCommand
import org.crsh.shell.impl.command.CRaSHSession
/**
* Simply extends CRaSH BaseCommand to add easy access to the RPC ops class.
*/
open class InteractiveShellCommand : BaseCommand() {
fun ops() = ((context.session as CRaSHSession).authInfo as CordaSSHAuthInfo).rpcOps
fun ansiProgressRenderer() = ((context.session as CRaSHSession).authInfo as CordaSSHAuthInfo).ansiProgressRenderer
fun objectMapper() = ((context.session as CRaSHSession).authInfo as CordaSSHAuthInfo).yamlInputMapper
}

View File

@ -0,0 +1,21 @@
package net.corda.tools.shell
import net.corda.core.messaging.CordaRPCOps
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Proxy
fun makeRPCOps(getCordaRPCOps: (username: String, credential: String) -> CordaRPCOps, username: String, credential: String): CordaRPCOps {
val cordaRPCOps: CordaRPCOps by lazy {
getCordaRPCOps(username, credential)
}
return Proxy.newProxyInstance(CordaRPCOps::class.java.classLoader, arrayOf(CordaRPCOps::class.java), { _, method, args ->
try {
method.invoke(cordaRPCOps, *(args ?: arrayOf()))
} catch (e: InvocationTargetException) {
// Unpack exception.
throw e.targetException
}
}
) as CordaRPCOps
}

View File

@ -0,0 +1,110 @@
package net.corda.tools.shell
import com.jcabi.manifests.Manifests
import joptsimple.OptionException
import net.corda.core.internal.*
import org.fusesource.jansi.Ansi
import org.fusesource.jansi.AnsiConsole
import java.net.URL
import java.net.URLClassLoader
import java.nio.file.Path
import java.util.concurrent.CountDownLatch
import kotlin.streams.toList
import java.io.IOException
import java.io.BufferedReader
import java.io.InputStreamReader
import kotlin.system.exitProcess
fun main(args: Array<String>) {
val argsParser = CommandLineOptionParser()
val cmdlineOptions = try {
argsParser.parse(*args)
} catch (e: OptionException) {
println("Invalid command line arguments: ${e.message}")
argsParser.printHelp(System.out)
exitProcess(1)
}
if (cmdlineOptions.help) {
argsParser.printHelp(System.out)
return
}
val config = try {
cmdlineOptions.toConfig()
} catch(e: Exception) {
println("Configuration exception: ${e.message}")
exitProcess(1)
}
StandaloneShell(config).run()
}
class StandaloneShell(private val configuration: ShellConfiguration) {
private fun getCordappsInDirectory(cordappsDir: Path?): List<URL> =
if (cordappsDir == null || !cordappsDir.exists()) {
emptyList()
} else {
cordappsDir.list {
it.filter { it.isRegularFile() && it.toString().endsWith(".jar") }.map { it.toUri().toURL() }.toList()
}
}
//Workaround in case console is not available
@Throws(IOException::class)
private fun readLine(format: String, vararg args: Any): String {
if (System.console() != null) {
return System.console().readLine(format, *args)
}
print(String.format(format, *args))
val reader = BufferedReader(InputStreamReader(System.`in`))
return reader.readLine()
}
@Throws(IOException::class)
private fun readPassword(format: String, vararg args: Any) =
if (System.console() != null) System.console().readPassword(format, *args) else this.readLine(format, *args).toCharArray()
private fun getManifestEntry(key: String) = if (Manifests.exists(key)) Manifests.read(key) else "Unknown"
fun run() {
val cordappJarPaths = getCordappsInDirectory(configuration.cordappsDirectory)
val classLoader: ClassLoader = URLClassLoader(cordappJarPaths.toTypedArray(), javaClass.classLoader)
with(configuration) {
if (user.isNullOrEmpty()) {
user = readLine("User:")
}
if (password.isNullOrEmpty()) {
password = String(readPassword("Password:"))
}
}
InteractiveShell.startShell(configuration, classLoader)
try {
//connecting to node by requesting node info to fail fast
InteractiveShell.nodeInfo()
} catch (e: Exception) {
println("Cannot login to ${configuration.hostAndPort}, reason: \"${e.message}\"")
exitProcess(1)
}
val exit = CountDownLatch(1)
AnsiConsole.systemInstall()
println(Ansi.ansi().fgBrightRed().a(
""" ______ __""").newline().a(
""" / ____/ _________/ /___ _""").newline().a(
""" / / __ / ___/ __ / __ `/ """).newline().fgBrightRed().a(
"""/ /___ /_/ / / / /_/ / /_/ /""").newline().fgBrightRed().a(
"""\____/ /_/ \__,_/\__,_/""").reset().fgBrightDefault().bold()
.newline().a("--- ${getManifestEntry("Corda-Vendor")} ${getManifestEntry("Corda-Release-Version")} (${getManifestEntry("Corda-Revision").take(7)}) ---")
.newline()
.newline().a("Standalone Shell connected to ${configuration.hostAndPort}")
.reset())
InteractiveShell.runLocalShell {
exit.countDown()
}
configuration.sshdPort?.apply{ println("SSH server listening on port $this.") }
exit.await()
exitProcess(0)
}
}

View File

@ -0,0 +1,226 @@
package net.corda.tools.shell
import com.typesafe.config.Config
import com.typesafe.config.ConfigFactory
import joptsimple.OptionParser
import joptsimple.util.EnumConverter
import net.corda.core.internal.div
import net.corda.core.utilities.NetworkHostAndPort
import net.corda.nodeapi.internal.config.parseAs
import net.corda.tools.shell.ShellConfiguration.Companion.COMMANDS_DIR
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 CommandLineOptionParser {
private val optionParser = OptionParser()
private val configFileArg = optionParser
.accepts("config-file", "The path to the shell configuration file, used instead of providing the rest of command line options.")
.withOptionalArg()
private val cordappsDirectoryArg = optionParser
.accepts("cordpass-directory", "The path to directory containing Cordapps jars, Cordapps are require when starting flows.")
.withOptionalArg()
private val commandsDirectoryArg = optionParser
.accepts("commands-directory", "The directory with additional CrAsH shell commands.")
.withOptionalArg()
private val hostArg = optionParser
.acceptsAll(listOf("h","host"), "The host of the Corda node.")
.withRequiredArg()
private val portArg = optionParser
.acceptsAll(listOf("p","port"), "The port of the Corda node.")
.withRequiredArg()
private val userArg = optionParser
.accepts("user", "The RPC user name.")
.withOptionalArg()
private val passwordArg = optionParser
.accepts("password", "The RPC user password.")
.withOptionalArg()
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 sshdPortArg = optionParser
.accepts("sshd-port", "Enables SSH server for shell.")
.withOptionalArg()
private val sshdHostKeyDirectoryArg = optionParser
.accepts("sshd-hostkey-directory", "The directory with hostkey.pem file for SSH server.")
.withOptionalArg()
private val helpArg = optionParser
.accepts("help")
.forHelp()
private val keyStorePasswordArg = optionParser
.accepts("keystore-password", "The password to unlock the KeyStore file.")
.withOptionalArg()
private val keyStoreDirArg = optionParser
.accepts("keystore-file", "The path to the KeyStore file.")
.withOptionalArg()
private val keyStoreTypeArg = optionParser
.accepts("keystore-type", "The type of the KeyStore (e.g. JKS).")
.withOptionalArg()
private val trustStorePasswordArg = optionParser
.accepts("truststore-password", "The password to unlock the TrustStore file.")
.withOptionalArg()
private val trustStoreDirArg = optionParser
.accepts("truststore-file", "The path to the TrustStore file.")
.withOptionalArg()
private val trustStoreTypeArg = optionParser
.accepts("truststore-type", "The type of the TrustStore (e.g. JKS).")
.withOptionalArg()
fun parse(vararg args: String): CommandLineOptions {
val optionSet = optionParser.parse(*args)
return CommandLineOptions(
configFile = optionSet.valueOf(configFileArg),
host = optionSet.valueOf(hostArg),
port = optionSet.valueOf(portArg),
user = optionSet.valueOf(userArg),
password = optionSet.valueOf(passwordArg),
commandsDirectory = (optionSet.valueOf(commandsDirectoryArg))?.let { Paths.get(it).normalize().toAbsolutePath() },
cordappsDirectory = (optionSet.valueOf(cordappsDirectoryArg))?.let { Paths.get(it).normalize().toAbsolutePath() },
help = optionSet.has(helpArg),
loggingLevel = optionSet.valueOf(loggerLevel),
sshdPort = optionSet.valueOf(sshdPortArg),
sshdHostKeyDirectory = (optionSet.valueOf(sshdHostKeyDirectoryArg))?.let { Paths.get(it).normalize().toAbsolutePath() },
keyStorePassword = optionSet.valueOf(keyStorePasswordArg),
trustStorePassword = optionSet.valueOf(trustStorePasswordArg),
keyStoreFile = (optionSet.valueOf(keyStoreDirArg))?.let { Paths.get(it).normalize().toAbsolutePath() },
trustStoreFile = (optionSet.valueOf(trustStoreDirArg))?.let { Paths.get(it).normalize().toAbsolutePath() },
keyStoreType = optionSet.valueOf(keyStoreTypeArg),
trustStoreType = optionSet.valueOf(trustStoreTypeArg))
}
fun printHelp(sink: PrintStream) = optionParser.printHelpOn(sink)
}
data class CommandLineOptions(val configFile: String?,
val commandsDirectory: Path?,
val cordappsDirectory: Path?,
val host: String?,
val port: String?,
val user: String?,
val password: String?,
val help: Boolean,
val loggingLevel: Level,
val sshdPort: String?,
val sshdHostKeyDirectory: Path?,
val keyStorePassword: String?,
val trustStorePassword: String?,
val keyStoreFile: Path?,
val trustStoreFile: Path?,
val keyStoreType: String?,
val trustStoreType: String?) {
private fun toConfigFile(): Config {
val cmdOpts = mutableMapOf<String, Any?>()
commandsDirectory?.apply { cmdOpts["extensions.commands.path"] = this.toString() }
cordappsDirectory?.apply { cmdOpts["extensions.cordapps.path"] = this.toString() }
user?.apply { cmdOpts["node.user"] = this }
password?.apply { cmdOpts["node.password"] = this }
host?.apply { cmdOpts["node.addresses.rpc.host"] = this }
port?.apply { cmdOpts["node.addresses.rpc.port"] = this }
keyStoreFile?.apply { cmdOpts["ssl.keystore.path"] = this.toString() }
keyStorePassword?.apply { cmdOpts["ssl.keystore.password"] = this }
keyStoreType?.apply { cmdOpts["ssl.keystore.type"] = this }
trustStoreFile?.apply { cmdOpts["ssl.truststore.path"] = this.toString() }
trustStorePassword?.apply { cmdOpts["ssl.truststore.password"] = this }
trustStoreType?.apply { cmdOpts["ssl.truststore.type"] = this }
sshdPort?.apply {
cmdOpts["extensions.sshd.port"] = this
cmdOpts["extensions.sshd.enabled"] = true
}
sshdHostKeyDirectory?.apply { cmdOpts["extensions.sshd.hostkeypath"] = this.toString() }
return ConfigFactory.parseMap(cmdOpts)
}
/** Return configuration parsed from an optional config file (provided by the command line option)
* and then overridden by the command line options */
fun toConfig(): ShellConfiguration {
val fileConfig = configFile?.let { ConfigFactory.parseFile(Paths.get(configFile).toFile()) }
?: ConfigFactory.empty()
val typeSafeConfig = toConfigFile().withFallback(fileConfig).resolve()
val shellConfigFile = typeSafeConfig.parseAs<ShellConfigurationFile.ShellConfigFile>()
return shellConfigFile.toShellConfiguration()
}
}
/** Object representation of Shell configuration file */
private class ShellConfigurationFile {
data class Rpc(
val host: String,
val port: Int)
data class Addresses(
val rpc: Rpc
)
data class Node(
val addresses: Addresses,
val user: String?,
val password: String?
)
data class Cordapps(
val path: String
)
data class Sshd(
val enabled: Boolean,
val port: Int,
val hostkeypath: String?
)
data class Commands(
val path: String
)
data class Extensions(
val cordapps: Cordapps,
val sshd: Sshd,
val commands: Commands?
)
data class KeyStore(
val path: String,
val type: String,
val password: String
)
data class Ssl(
val keystore: KeyStore,
val truststore: KeyStore
)
data class ShellConfigFile(
val node: Node,
val extensions: Extensions?,
val ssl: Ssl?
) {
fun toShellConfiguration(): ShellConfiguration {
val sslOptions =
ssl?.let {
ShellSslOptions(
sslKeystore = Paths.get(it.keystore.path),
keyStorePassword = it.keystore.password,
trustStoreFile = Paths.get(it.truststore.path),
trustStorePassword = it.truststore.password)
}
return ShellConfiguration(
commandsDirectory = extensions?.commands?.let { Paths.get(it.path) } ?: Paths.get(".") / COMMANDS_DIR,
cordappsDirectory = extensions?.cordapps?.let { Paths.get(it.path) },
user = node.user ?: "",
password = node.password ?: "",
hostAndPort = NetworkHostAndPort(node.addresses.rpc.host, node.addresses.rpc.port),
ssl = sslOptions,
sshdPort = extensions?.sshd?.let { if (it.enabled) it.port else null },
sshHostKeyDirectory = extensions?.sshd?.let { if (it.enabled && it.hostkeypath != null) Paths.get(it.hostkeypath) else null })
}
}
}

View File

@ -0,0 +1,273 @@
/*
* R3 Proprietary and Confidential
*
* Copyright (c) 2018 R3 Limited. All rights reserved.
*
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
*
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
*/
package net.corda.tools.shell.utlities
import net.corda.core.internal.Emoji
import net.corda.core.messaging.FlowProgressHandle
import org.apache.logging.log4j.LogManager
import org.apache.logging.log4j.core.LogEvent
import org.apache.logging.log4j.core.LoggerContext
import org.apache.logging.log4j.core.appender.AbstractOutputStreamAppender
import org.apache.logging.log4j.core.appender.ConsoleAppender
import org.apache.logging.log4j.core.appender.OutputStreamManager
import org.crsh.text.RenderPrintWriter
import org.fusesource.jansi.Ansi
import org.fusesource.jansi.AnsiConsole
import org.fusesource.jansi.AnsiOutputStream
import rx.Subscription
abstract class ANSIProgressRenderer {
private var subscriptionIndex: Subscription? = null
private var subscriptionTree: Subscription? = null
protected var usingANSI = false
protected var checkEmoji = false
protected var treeIndex: Int = 0
protected var tree: List<Pair<Int,String>> = listOf()
private var installedYet = false
private var onDone: () -> Unit = {}
// prevMessagePrinted is just for non-ANSI mode.
private var prevMessagePrinted: String? = null
// prevLinesDraw is just for ANSI mode.
protected var prevLinesDrawn = 0
private fun done(error: Throwable?) {
if (error == null) _render(null)
draw(true, error)
onDone()
}
fun render(flowProgressHandle: FlowProgressHandle<*>, onDone: () -> Unit = {}) {
this.onDone = onDone
_render(flowProgressHandle)
}
protected abstract fun printLine(line:String)
protected abstract fun printAnsi(ansi:Ansi)
protected abstract fun setup()
private fun _render(flowProgressHandle: FlowProgressHandle<*>?) {
subscriptionIndex?.unsubscribe()
subscriptionTree?.unsubscribe()
treeIndex = 0
tree = listOf()
if (!installedYet) {
setup()
installedYet = true
}
prevMessagePrinted = null
prevLinesDrawn = 0
draw(true)
flowProgressHandle?.apply {
stepsTreeIndexFeed?.apply {
treeIndex = snapshot
subscriptionIndex = updates.subscribe({
treeIndex = it
draw(true)
}, { done(it) }, { done(null) })
}
stepsTreeFeed?.apply {
tree = snapshot
subscriptionTree = updates.subscribe({
tree = it
draw(true)
}, { done(it) }, { done(null) })
}
}
}
@Synchronized protected fun draw(moveUp: Boolean, error: Throwable? = null) {
if (!usingANSI) {
val currentMessage = tree.getOrNull(treeIndex)?.second
if (currentMessage != null && currentMessage != prevMessagePrinted) {
printLine(currentMessage)
prevMessagePrinted = currentMessage
}
return
}
fun printingBody() {
// Handle the case where the number of steps in a progress tracker is changed during execution.
val ansi = Ansi()
if (prevLinesDrawn > 0 && moveUp)
ansi.cursorUp(prevLinesDrawn)
// Put a blank line between any logging and us.
ansi.eraseLine()
ansi.newline()
if (tree.isEmpty()) return
var newLinesDrawn = 1 + renderLevel(ansi, error != null)
if (error != null) {
ansi.a("${Emoji.skullAndCrossbones} ${error.message}")
ansi.eraseLine(Ansi.Erase.FORWARD)
ansi.newline()
newLinesDrawn++
}
if (newLinesDrawn < prevLinesDrawn) {
// If some steps were removed from the progress tracker, we don't want to leave junk hanging around below.
val linesToClear = prevLinesDrawn - newLinesDrawn
repeat(linesToClear) {
ansi.eraseLine()
ansi.newline()
}
ansi.cursorUp(linesToClear)
}
prevLinesDrawn = newLinesDrawn
printAnsi(ansi)
}
if (checkEmoji) {
Emoji.renderIfSupported(::printingBody)
} else {
printingBody()
}
}
// Returns number of lines rendered.
private fun renderLevel(ansi: Ansi, error: Boolean): Int {
with(ansi) {
var lines = 0
for ((index, step) in tree.withIndex()) {
val marker = when {
index < treeIndex -> "${Emoji.greenTick} "
treeIndex == tree.lastIndex -> "${Emoji.greenTick} "
index == treeIndex -> "${Emoji.rightArrow} "
error -> "${Emoji.noEntry} "
else -> " " // Not reached yet.
}
a(" ".repeat(step.first))
a(marker)
val active = index == treeIndex
if (active) bold()
a(step.second)
if (active) boldOff()
eraseLine(Ansi.Erase.FORWARD)
newline()
lines++
}
return lines
}
}
}
class CRaSHANSIProgressRenderer(val renderPrintWriter:RenderPrintWriter) : ANSIProgressRenderer() {
override fun printLine(line: String) {
renderPrintWriter.println(line)
}
override fun printAnsi(ansi: Ansi) {
renderPrintWriter.print(ansi)
renderPrintWriter.flush()
}
override fun setup() {
// We assume SSH always use ANSI.
usingANSI = true
}
}
/**
* Knows how to render a [FlowProgressHandle] to the terminal using coloured, emoji-fied output. Useful when writing small
* command line tools, demos, tests etc. Just call [draw] method and it will go ahead and start drawing
* if the terminal supports it. Otherwise it just prints out the name of the step whenever it changes.
*
* When a progress tracker is on the screen, it takes over the bottom part and reconfigures logging so that, assuming
* 1 log event == 1 line, the progress tracker is always glued to the bottom and logging scrolls above it.
*
* TODO: More thread safety
*/
object StdoutANSIProgressRenderer : ANSIProgressRenderer() {
override fun setup() {
AnsiConsole.systemInstall()
checkEmoji = true
// This line looks weird as hell because the magic code to decide if we really have a TTY or not isn't
// actually exposed anywhere as a function (weak sauce). So we have to rely on our knowledge of jansi
// implementation details.
usingANSI = AnsiConsole.wrapOutputStream(System.out) !is AnsiOutputStream
if (usingANSI) {
// This super ugly code hacks into log4j and swaps out its console appender for our own. It's a bit simpler
// than doing things the official way with a dedicated plugin, etc, as it avoids mucking around with all
// the config XML and lifecycle goop.
val manager = LogManager.getContext(false) as LoggerContext
val consoleAppender = manager.configuration.appenders.values.filterIsInstance<ConsoleAppender>().single { it.name == "Console-Appender" }
val scrollingAppender = object : AbstractOutputStreamAppender<OutputStreamManager>(
consoleAppender.name, consoleAppender.layout, consoleAppender.filter,
consoleAppender.ignoreExceptions(), true, consoleAppender.manager) {
override fun append(event: LogEvent) {
// We lock on the renderer to avoid threads that are logging to the screen simultaneously messing
// things up. Of course this slows stuff down a bit, but only whilst this little utility is in use.
// Eventually it will be replaced with a real GUI and we can delete all this.
synchronized(StdoutANSIProgressRenderer) {
if (tree.isNotEmpty()) {
val ansi = Ansi.ansi()
repeat(prevLinesDrawn) { ansi.eraseLine().cursorUp(1).eraseLine() }
System.out.print(ansi)
System.out.flush()
}
super.append(event)
if (tree.isNotEmpty())
draw(false)
}
}
}
scrollingAppender.start()
manager.configuration.appenders[consoleAppender.name] = scrollingAppender
val loggerConfigs = manager.configuration.loggers.values
for (config in loggerConfigs) {
val appenderRefs = config.appenderRefs
val consoleAppenders = config.appenders.filter { it.value is ConsoleAppender }.keys
consoleAppenders.forEach { config.removeAppender(it) }
appenderRefs.forEach { config.addAppender(manager.configuration.appenders[it.ref], it.level, it.filter) }
}
manager.updateLoggers()
}
}
override fun printLine(line:String) {
System.out.println(line)
}
override fun printAnsi(ansi: Ansi) {
// Need to force a flush here in order to ensure stderr/stdout sync up properly.
System.out.print(ansi)
System.out.flush()
}
}

View File

@ -0,0 +1,25 @@
/*
* R3 Proprietary and Confidential
*
* Copyright (c) 2018 R3 Limited. All rights reserved.
*
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
*
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
*/
package net.corda.tools.shell.base
// Note that this file MUST be in a sub-directory called "base" relative to the path
// given in the configuration code in InteractiveShell.
welcome = """
Welcome to the Corda interactive shell.
Useful commands include 'help' to see what is available, and 'bye' to shut down the node.
"""
prompt = { ->
return "${new Date()}>>> "
}

View File

@ -0,0 +1,82 @@
/*
* R3 Proprietary and Confidential
*
* Copyright (c) 2018 R3 Limited. All rights reserved.
*
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
*
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
*/
package net.corda.tools.shell
import com.fasterxml.jackson.databind.JsonMappingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.kotlin.readValue
import net.corda.core.contracts.UniqueIdentifier
import org.junit.Before
import org.junit.Test
import java.util.*
import kotlin.test.assertEquals
class CustomTypeJsonParsingTests {
lateinit var objectMapper: ObjectMapper
//Dummy classes for testing.
data class State(val linearId: UniqueIdentifier) {
constructor() : this(UniqueIdentifier("required-for-json-deserializer"))
}
data class UuidState(val uuid: UUID) {
//Default constructor required for json deserializer.
constructor() : this(UUID.randomUUID())
}
@Before
fun setup() {
objectMapper = ObjectMapper()
val simpleModule = SimpleModule()
simpleModule.addDeserializer(UniqueIdentifier::class.java, InteractiveShell.UniqueIdentifierDeserializer)
simpleModule.addDeserializer(UUID::class.java, InteractiveShell.UUIDDeserializer)
objectMapper.registerModule(simpleModule)
}
@Test
fun `Deserializing UniqueIdentifier by parsing string`() {
val id = "26b37265-a1fd-4c77-b2e0-715917ef619f"
val json = """{"linearId":"$id"}"""
val state = objectMapper.readValue<State>(json)
assertEquals(id, state.linearId.id.toString())
}
@Test
fun `Deserializing UniqueIdentifier by parsing string with underscore`() {
val json = """{"linearId":"extkey564_26b37265-a1fd-4c77-b2e0-715917ef619f"}"""
val state = objectMapper.readValue<State>(json)
assertEquals("extkey564", state.linearId.externalId)
assertEquals("26b37265-a1fd-4c77-b2e0-715917ef619f", state.linearId.id.toString())
}
@Test(expected = JsonMappingException::class)
fun `Deserializing by parsing string contain invalid uuid with underscore`() {
val json = """{"linearId":"extkey564_26b37265-a1fd-4c77-b2e0"}"""
objectMapper.readValue<State>(json)
}
@Test
fun `Deserializing UUID by parsing string`() {
val json = """{"uuid":"26b37265-a1fd-4c77-b2e0-715917ef619f"}"""
val state = objectMapper.readValue<UuidState>(json)
assertEquals("26b37265-a1fd-4c77-b2e0-715917ef619f", state.uuid.toString())
}
@Test(expected = JsonMappingException::class)
fun `Deserializing UUID by parsing invalid uuid string`() {
val json = """{"uuid":"26b37265-a1fd-4c77-b2e0"}"""
objectMapper.readValue<UuidState>(json)
}
}

View File

@ -0,0 +1,92 @@
/*
* R3 Proprietary and Confidential
*
* Copyright (c) 2018 R3 Limited. All rights reserved.
*
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
*
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
*/
package net.corda.tools.shell
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import net.corda.client.jackson.JacksonSupport
import net.corda.core.contracts.Amount
import net.corda.core.crypto.SecureHash
import net.corda.core.flows.FlowLogic
import net.corda.core.flows.StateMachineRunId
import net.corda.core.identity.CordaX500Name
import net.corda.core.identity.Party
import net.corda.core.internal.concurrent.openFuture
import net.corda.core.messaging.FlowProgressHandleImpl
import net.corda.core.utilities.ProgressTracker
import net.corda.node.services.identity.InMemoryIdentityService
import net.corda.testing.internal.DEV_ROOT_CA
import net.corda.testing.core.TestIdentity
import org.junit.Test
import rx.Observable
import java.util.*
import kotlin.test.assertEquals
class InteractiveShellTest {
companion object {
private val megaCorp = TestIdentity(CordaX500Name("MegaCorp", "London", "GB"))
}
@Suppress("UNUSED")
class FlowA(val a: String) : FlowLogic<String>() {
constructor(b: Int?) : this(b.toString())
constructor(b: Int?, c: String) : this(b.toString() + c)
constructor(amount: Amount<Currency>) : this(amount.toString())
constructor(pair: Pair<Amount<Currency>, SecureHash.SHA256>) : this(pair.toString())
constructor(party: Party) : this(party.name.toString())
override val progressTracker = ProgressTracker()
override fun call() = a
}
private val ids = InMemoryIdentityService(arrayOf(megaCorp.identity), DEV_ROOT_CA.certificate)
private val om = JacksonSupport.createInMemoryMapper(ids, YAMLFactory())
private fun check(input: String, expected: String) {
var output: String? = null
InteractiveShell.runFlowFromString( { clazz, args ->
val instance = clazz.getConstructor(*args.map { it!!::class.java }.toTypedArray()).newInstance(*args) as FlowA
output = instance.a
val future = openFuture<String>()
future.set("ABC")
FlowProgressHandleImpl(StateMachineRunId.createRandom(), future, Observable.just("Some string"))
}, input, FlowA::class.java, om)
assertEquals(expected, output!!, input)
}
@Test
fun flowStartSimple() {
check("a: Hi there", "Hi there")
check("b: 12", "12")
check("b: 12, c: Yo", "12Yo")
}
@Test
fun flowStartWithComplexTypes() = check("amount: £10", "10.00 GBP")
@Test
fun flowStartWithNestedTypes() = check(
"pair: { first: $100.12, second: df489807f81c8c8829e509e1bcb92e6692b9dd9d624b7456435cb2f51dc82587 }",
"($100.12, df489807f81c8c8829e509e1bcb92e6692b9dd9d624b7456435cb2f51dc82587)"
)
@Test(expected = InteractiveShell.NoApplicableConstructor::class)
fun flowStartNoArgs() = check("", "")
@Test(expected = InteractiveShell.NoApplicableConstructor::class)
fun flowMissingParam() = check("c: Yo", "")
@Test(expected = InteractiveShell.NoApplicableConstructor::class)
fun flowTooManyParams() = check("b: 12, c: Yo, d: Bar", "")
@Test
fun party() = check("party: \"${megaCorp.name}\"", megaCorp.name.toString())
}

View File

@ -0,0 +1,204 @@
package net.corda.tools.shell
import net.corda.core.utilities.NetworkHostAndPort
import org.junit.Test
import org.slf4j.event.Level
import java.nio.file.Paths
import kotlin.test.assertEquals
import java.io.File
class StandaloneShellArgsParserTest {
private val CONFIG_FILE = File(javaClass.classLoader.getResource("config.conf")!!.file)
@Test
fun args_to_cmd_options() {
val args = arrayOf("--config-file", "/x/y/z/config.conf",
"--commands-directory", "/x/y/commands",
"--cordpass-directory", "/x/y/cordapps",
"--host", "alocalhost",
"--port", "1234",
"--user", "demo",
"--password", "abcd1234",
"--logging-level", "DEBUG",
"--sshd-port", "2223",
"--sshd-hostkey-directory", "/x/y/ssh",
"--help",
"--keystore-password", "pass1",
"--truststore-password", "pass2",
"--keystore-file", "/x/y/keystore.jks",
"--truststore-file", "/x/y/truststore.jks",
"--truststore-type", "dummy",
"--keystore-type", "JKS")
val expectedOptions = CommandLineOptions(configFile = "/x/y/z/config.conf",
commandsDirectory = Paths.get("/x/y/commands"),
cordappsDirectory = Paths.get("/x/y/cordapps"),
host = "alocalhost",
port = "1234",
user = "demo",
password = "abcd1234",
help = true,
loggingLevel = Level.DEBUG,
sshdPort = "2223",
sshdHostKeyDirectory = Paths.get("/x/y/ssh"),
keyStorePassword = "pass1",
trustStorePassword = "pass2",
keyStoreFile = Paths.get("/x/y/keystore.jks"),
trustStoreFile = Paths.get("/x/y/truststore.jks"),
trustStoreType = "dummy",
keyStoreType = "JKS")
val options = CommandLineOptionParser().parse(*args)
assertEquals(expectedOptions, options)
}
@Test
fun empty_args_to_cmd_options() {
val args = emptyArray<String>()
val expectedOptions = CommandLineOptions(configFile = null,
commandsDirectory = null,
cordappsDirectory = null,
host = null,
port = null,
user = null,
password = null,
help = false,
loggingLevel = Level.INFO,
sshdPort = null,
sshdHostKeyDirectory = null,
keyStorePassword = null,
trustStorePassword = null,
keyStoreFile = null,
trustStoreFile = null,
trustStoreType = null,
keyStoreType = null)
val options = CommandLineOptionParser().parse(*args)
assertEquals(expectedOptions, options)
}
@Test
fun args_to_config() {
val options = CommandLineOptions(configFile = null,
commandsDirectory = Paths.get("/x/y/commands"),
cordappsDirectory = Paths.get("/x/y/cordapps"),
host = "alocalhost",
port = "1234",
user = "demo",
password = "abcd1234",
help = true,
loggingLevel = Level.DEBUG,
sshdPort = "2223",
sshdHostKeyDirectory = Paths.get("/x/y/ssh"),
keyStorePassword = "pass1",
trustStorePassword = "pass2",
keyStoreFile = Paths.get("/x/y/keystore.jks"),
trustStoreFile = Paths.get("/x/y/truststore.jks"),
keyStoreType = "dummy",
trustStoreType = "dummy"
)
val expectedSsl = ShellSslOptions(sslKeystore = Paths.get("/x/y/keystore.jks"),
keyStorePassword = "pass1",
trustStoreFile = Paths.get("/x/y/truststore.jks"),
trustStorePassword = "pass2")
val expectedConfig = ShellConfiguration(
commandsDirectory = Paths.get("/x/y/commands"),
cordappsDirectory = Paths.get("/x/y/cordapps"),
user = "demo",
password = "abcd1234",
hostAndPort = NetworkHostAndPort("alocalhost", 1234),
ssl = expectedSsl,
sshdPort = 2223,
sshHostKeyDirectory = Paths.get("/x/y/ssh"),
noLocalShell = false)
val config = options.toConfig()
assertEquals(expectedConfig, config)
}
@Test
fun acmd_options_to_config_from_file() {
val options = CommandLineOptions(configFile = CONFIG_FILE.absolutePath,
commandsDirectory = null,
cordappsDirectory = null,
host = null,
port = null,
user = null,
password = null,
help = false,
loggingLevel = Level.DEBUG,
sshdPort = null,
sshdHostKeyDirectory = null,
keyStorePassword = null,
trustStorePassword = null,
keyStoreFile = null,
trustStoreFile = null,
keyStoreType = null,
trustStoreType = null)
val expectedSsl = ShellSslOptions(sslKeystore = Paths.get("/x/y/keystore.jks"),
keyStorePassword = "pass1",
trustStoreFile = Paths.get("/x/y/truststore.jks"),
trustStorePassword = "pass2")
val expectedConfig = ShellConfiguration(
commandsDirectory = Paths.get("/x/y/commands"),
cordappsDirectory = Paths.get("/x/y/cordapps"),
user = "demo",
password = "abcd1234",
hostAndPort = NetworkHostAndPort("alocalhost", 1234),
ssl = expectedSsl,
sshdPort = 2223)
val config = options.toConfig()
assertEquals(expectedConfig, config)
}
@Test
fun cmd_options_override_config_from_file() {
val options = CommandLineOptions(configFile = CONFIG_FILE.absolutePath,
commandsDirectory = null,
cordappsDirectory = null,
host = null,
port = null,
user = null,
password = "blabla",
help = false,
loggingLevel = Level.DEBUG,
sshdPort = null,
sshdHostKeyDirectory = null,
keyStorePassword = null,
trustStorePassword = null,
keyStoreFile = Paths.get("/x/y/cmd.jks"),
trustStoreFile = null,
keyStoreType = null,
trustStoreType = null)
val expectedSsl = ShellSslOptions(sslKeystore = Paths.get("/x/y/cmd.jks"),
keyStorePassword = "pass1",
trustStoreFile = Paths.get("/x/y/truststore.jks"),
trustStorePassword = "pass2")
val expectedConfig = ShellConfiguration(
commandsDirectory = Paths.get("/x/y/commands"),
cordappsDirectory = Paths.get("/x/y/cordapps"),
user = "demo",
password = "blabla",
hostAndPort = NetworkHostAndPort("alocalhost", 1234),
ssl = expectedSsl,
sshdPort = 2223)
val config = options.toConfig()
assertEquals(expectedConfig, config)
}
}

View File

@ -0,0 +1,34 @@
node {
addresses {
rpc {
host : "alocalhost"
port : 1234
}
}
user : demo
password : abcd1234
}
extensions {
cordapps {
path : "/x/y/cordapps"
}
sshd {
enabled : "true"
port : 2223
}
commands {
path : /x/y/commands
}
}
ssl {
keystore {
path : "/x/y/keystore.jks"
type : "JKS"
password : "pass1"
}
truststore {
path : "/x/y/truststore.jks"
type : "JKS"
password : "pass2"
}
}