diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 6b137f68ac..da97e99e08 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -124,6 +124,9 @@ + + + diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 2d31baad9b..f2195fa5c7 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -11,6 +11,7 @@ changes to this list. * Andras Slemmer (R3) * Andrius Dagys (R3) * Andrzej Cichocki (R3) +* Andrzej Grzesik (R3) * Anthony Coates (Deutsche Bank) * Anton Semenov (Commerzbank) * Antonio Cerrato (SEB) @@ -92,7 +93,7 @@ changes to this list. * Matthijs van den Bos (ING) * Michal Kit (R3) * Micheal Hinstridge (Thoughtworks) -* Michelle Sollecito (R3) +* Michele Sollecito (R3) * Mike Hearn (R3) * Mike Reichelt (US Bank) * Mustafa Ozturk (Natixis) diff --git a/build.gradle b/build.gradle index 473e047253..a78020efbb 100644 --- a/build.gradle +++ b/build.gradle @@ -73,6 +73,7 @@ buildscript { ext.selenium_version = '3.8.1' ext.ghostdriver_version = '2.1.0' ext.eaagentloader_version = '1.0.3' + ext.jsch_version = '0.1.54' // Update 121 is required for ObjectInputFilter and at time of writing 131 was latest: ext.java8_minUpdateVersion = '131' diff --git a/client/rpc/src/main/kotlin/net/corda/client/rpc/CordaRPCClient.kt b/client/rpc/src/main/kotlin/net/corda/client/rpc/CordaRPCClient.kt index ef1eb579f2..7368f5fd88 100644 --- a/client/rpc/src/main/kotlin/net/corda/client/rpc/CordaRPCClient.kt +++ b/client/rpc/src/main/kotlin/net/corda/client/rpc/CordaRPCClient.kt @@ -73,7 +73,8 @@ data class CordaRPCClientConfiguration(val connectionMaxRetryInterval: Duration) class CordaRPCClient private constructor( hostAndPort: NetworkHostAndPort, configuration: CordaRPCClientConfiguration = CordaRPCClientConfiguration.DEFAULT, - sslConfiguration: SSLConfiguration? = null + sslConfiguration: SSLConfiguration? = null, + classLoader: ClassLoader? = null ) { @JvmOverloads constructor(hostAndPort: NetworkHostAndPort, configuration: CordaRPCClientConfiguration = CordaRPCClientConfiguration.DEFAULT) : this(hostAndPort, configuration, null) @@ -86,6 +87,15 @@ class CordaRPCClient private constructor( ): CordaRPCClient { return CordaRPCClient(hostAndPort, configuration, sslConfiguration) } + + internal fun createWithSslAndClassLoader( + hostAndPort: NetworkHostAndPort, + configuration: CordaRPCClientConfiguration = CordaRPCClientConfiguration.DEFAULT, + sslConfiguration: SSLConfiguration? = null, + classLoader: ClassLoader? = null + ): CordaRPCClient { + return CordaRPCClient(hostAndPort, configuration, sslConfiguration, classLoader) + } } init { @@ -93,7 +103,7 @@ class CordaRPCClient private constructor( effectiveSerializationEnv } catch (e: IllegalStateException) { try { - KryoClientSerializationScheme.initialiseSerialization() + KryoClientSerializationScheme.initialiseSerialization(classLoader) } catch (e: IllegalStateException) { // Race e.g. two of these constructed in parallel, ignore. } @@ -103,7 +113,7 @@ class CordaRPCClient private constructor( private val rpcClient = RPCClient( tcpTransport(ConnectionDirection.Outbound(), hostAndPort, config = sslConfiguration), configuration.toRpcClientConfiguration(), - KRYO_RPC_CLIENT_CONTEXT + if (classLoader != null) KRYO_RPC_CLIENT_CONTEXT.withClassLoader(classLoader) else KRYO_RPC_CLIENT_CONTEXT ) /** diff --git a/client/rpc/src/main/kotlin/net/corda/client/rpc/internal/CordaRPCClientUtils.kt b/client/rpc/src/main/kotlin/net/corda/client/rpc/internal/CordaRPCClientUtils.kt index d5787f5dec..7526921453 100644 --- a/client/rpc/src/main/kotlin/net/corda/client/rpc/internal/CordaRPCClientUtils.kt +++ b/client/rpc/src/main/kotlin/net/corda/client/rpc/internal/CordaRPCClientUtils.kt @@ -10,4 +10,11 @@ fun createCordaRPCClientWithSsl( hostAndPort: NetworkHostAndPort, configuration: CordaRPCClientConfiguration = CordaRPCClientConfiguration.DEFAULT, sslConfiguration: SSLConfiguration? = null -) = CordaRPCClient.createWithSsl(hostAndPort, configuration, sslConfiguration) \ No newline at end of file +) = CordaRPCClient.createWithSsl(hostAndPort, configuration, sslConfiguration) + +fun createCordaRPCClientWithSslAndClassLoader( + hostAndPort: NetworkHostAndPort, + configuration: CordaRPCClientConfiguration = CordaRPCClientConfiguration.DEFAULT, + sslConfiguration: SSLConfiguration? = null, + classLoader: ClassLoader? = null +) = CordaRPCClient.createWithSslAndClassLoader(hostAndPort, configuration, sslConfiguration, classLoader) \ No newline at end of file diff --git a/client/rpc/src/main/kotlin/net/corda/client/rpc/internal/KryoClientSerializationScheme.kt b/client/rpc/src/main/kotlin/net/corda/client/rpc/internal/KryoClientSerializationScheme.kt index 6132509e21..998ac3c927 100644 --- a/client/rpc/src/main/kotlin/net/corda/client/rpc/internal/KryoClientSerializationScheme.kt +++ b/client/rpc/src/main/kotlin/net/corda/client/rpc/internal/KryoClientSerializationScheme.kt @@ -33,18 +33,19 @@ class KryoClientSerializationScheme : AbstractKryoSerializationScheme() { companion object { /** Call from main only. */ - fun initialiseSerialization() { - nodeSerializationEnv = createSerializationEnv() + fun initialiseSerialization(classLoader: ClassLoader? = null) { + nodeSerializationEnv = createSerializationEnv(classLoader) } - fun createSerializationEnv(): SerializationEnvironment { + fun createSerializationEnv(classLoader: ClassLoader? = null): SerializationEnvironment { return SerializationEnvironmentImpl( SerializationFactoryImpl().apply { registerScheme(KryoClientSerializationScheme()) registerScheme(AMQPClientSerializationScheme(emptyList())) }, - AMQP_P2P_CONTEXT, - rpcClientContext = KRYO_RPC_CLIENT_CONTEXT) + if (classLoader != null) AMQP_P2P_CONTEXT.withClassLoader(classLoader) else AMQP_P2P_CONTEXT, + rpcClientContext = if (classLoader != null) KRYO_RPC_CLIENT_CONTEXT.withClassLoader(classLoader) else KRYO_RPC_CLIENT_CONTEXT) + } } } \ No newline at end of file diff --git a/docs/source/shell.rst b/docs/source/shell.rst index 90edacf5ff..d560467f7e 100644 --- a/docs/source/shell.rst +++ b/docs/source/shell.rst @@ -9,7 +9,7 @@ Shell .. contents:: -The Corda shell is an embedded command line that allows an administrator to control and monitor a node. It is based on +The Corda shell is an embedded or standalone command line that allows an administrator to control and monitor a node. It is based on the `CRaSH`_ shell and supports many of the same features. These features include: * Invoking any of the node's RPC methods @@ -19,11 +19,22 @@ the `CRaSH`_ shell and supports many of the same features. These features includ * Viewing JMX metrics and monitoring exports * UNIX style pipes for both text and objects, an ``egrep`` command and a command for working with columnular data +Permissions +----------- + +When accessing the shell (embedded, standalone, via SSH) RPC permissions are required. This is because the shell actually communicates +with the node using RPC calls. + +* Watching flows (``flow watch``) requires ``InvokeRpc.stateMachinesFeed`` +* Starting flows requires ``InvokeRpc.startTrackedFlowDynamic``, ``InvokeRpc.registeredFlows`` and ``InvokeRpc.wellKnownPartyFromX500Name``, as well as a + permission for the flow being started + The shell via the local terminal -------------------------------- -In development mode, the shell will display in the node's terminal window. It may be disabled by passing the -``--no-local-shell`` flag when running the node. +In development mode, the shell will display in the node's terminal window. +The shell connects to the node as 'shell' user with password 'shell' which is only available in dev mode. +It may be disabled by passing the ``--no-local-shell`` flag when running the node. The shell via SSH ----------------- @@ -42,8 +53,8 @@ By default, the SSH server is *disabled*. To enable it, a port must be configure Authentication ************** -Users log in to shell via SSH using the same credentials as for RPC. This is because the shell actually communicates -with the node using RPC calls. No RPC permissions are required to allow the connection and log in. +Users log in to shell via SSH using the same credentials as for RPC. +No RPC permissions are required to allow the connection and log in. The host key is loaded from the ``/sshkey/hostkey.pem`` file. If this file does not exist, it is generated automatically. In development mode, the seed may be specified to give the same results on the same computer @@ -69,7 +80,7 @@ Where: The RPC password will be requested after a connection is established. -:note: In development mode, restarting a node frequently may cause the host key to be regenerated. SSH usually saves +.. note:: In development mode, restarting a node frequently may cause the host key to be regenerated. SSH usually saves trusted hosts and will refuse to connect in case of a change. This check can be disabled using the ``-o StrictHostKeyChecking=no`` flag. This option should never be used in production environment! @@ -78,14 +89,99 @@ Windows Windows does not provide a built-in SSH tool. An alternative such as PuTTY should be used. -Permissions -*********** +The standalone shell +------------------------------ +The standalone shell is a standalone application interacting with a Corda node via RPC calls. +RPC node permissions are necessary for authentication and authorisation. +Certain operations, such as starting flows, require access to CordApps jars. -When accessing the shell via SSH, some additional RPC permissions are required: +Starting the standalone shell +************************* + +Run the following command from the terminal: + +Linux and MacOS +^^^^^^^^^^^^^^^ + +.. code:: bash + + ./shell [--config-file PATH | --cordpass-directory PATH --commands-directory PATH --host HOST --port PORT + --user USER --password PASSWORD --sshd-port PORT --sshd-hostkey-directory PATH --keystore-password PASSWORD + --keystore-file FILE --truststore-password PASSWORD --truststore-file FILE | --help] + +Windows +^^^^^^^ + +.. code:: bash + + shell.bat [--config-file PATH | --cordpass-directory PATH --commands-directory PATH --host HOST --port PORT + --user USER --password PASSWORD --sshd-port PORT --sshd-hostkey-directory PATH --keystore-password PASSWORD + --keystore-file FILE --truststore-password PASSWORD --truststore-file FILE | --help] + +Where: + +* ``config-file`` is the path to config file, used instead of providing the rest of command line options +* ``cordpass-directory`` is the directory containing Cordapps jars, Cordapps are require when starting flows +* ``commands-directory`` is the directory with additional CrAsH shell commands +* ``host`` is the Corda node's host +* ``port`` is the Corda node's port, specified in the ``node.conf`` file +* ``user`` is the RPC username, if not provided it will be requested at startup +* ``password`` is the RPC user password, if not provided it will be requested at startup +* ``sshd-port`` instructs the standalone shell app to start SSH server on the given port, optional +* ``sshd-hostkey-directory`` is the directory containing hostkey.pem file for SSH server +* ``keystore-password`` the password to unlock the KeyStore file containing the standalone shell certificate and private key, optional, unencrypted RPC connection without SSL will be used if the option is not provided +* ``keystore-file`` is the path to the KeyStore file +* ``truststore-password`` the password to unlock the TrustStore file containing the Corda node certificate, optional, unencrypted RPC connection without SSL will be used if the option is not provided +* ``truststore-file`` is the path to the TrustStore file +* ``help`` prints Shell help + +The format of ``config-file``: + +.. code:: bash + + node { + addresses { + rpc { + host : "localhost" + port : 10006 + } + } + } + shell { + workDir : /path/to/dir + } + extensions { + cordapps { + path : /path/to/cordapps/dir + } + sshd { + enabled : "false" + port : 2223 + } + } + ssl { + keystore { + path: "/path/to/keystore" + type: "JKS" + password: password + } + trustore { + path: "/path/to/trusttore" + type: "JKS" + password: password + } + } + user : demo + password : demo + + +Standalone Shell via SSH +------------------------------------------ +The standalone shell can embed an SSH server which redirects interactions via RPC calls to the Corda node. +To run SSH server use ``--sshd-port`` option when starting standalone shell or ``extensions.sshd`` entry in the configuration file. +For connection to SSH refer to `Connecting to the shell`_. +Certain operations (like starting Flows) will require Shell's ``--cordpass-directory`` to be configured correctly (see `Starting the standalone shell`_). -* Watching flows (``flow watch``) requires ``InvokeRpc.stateMachinesFeed`` -* Starting flows requires ``InvokeRpc.startTrackedFlowDynamic`` and ``InvokeRpc.registeredFlows``, as well as a - permission for the flow being started Interacting with the node via the shell --------------------------------------- diff --git a/node/build.gradle b/node/build.gradle index be7f3ee027..32fd8b6a10 100644 --- a/node/build.gradle +++ b/node/build.gradle @@ -65,6 +65,7 @@ dependencies { compile project(':node-api') compile project(":confidential-identities") compile project(':client:rpc') + compile project(':tools:shell') compile "net.corda.plugins:cordform-common:$gradle_plugins_version" // Log4J: logging framework (with SLF4J bindings) @@ -102,10 +103,6 @@ dependencies { exclude group: "asm" } - // Jackson support: serialisation to/from JSON, YAML, etc - compile project(':client:jackson') - compile group: 'org.json', name: 'json', version: json_version - // Coda Hale's Metrics: for monitoring of key statistics compile "io.dropwizard.metrics:metrics-core:3.1.2" @@ -150,17 +147,6 @@ dependencies { // Netty: All of it. compile "io.netty:netty-all:$netty_version" - // CRaSH: An embeddable monitoring and admin shell with support for adding new commands written in Groovy. - compile("com.github.corda.crash:crash.shell:$crash_version") { - exclude group: "org.slf4j", module: "slf4j-jdk14" - exclude group: "org.bouncycastle" - } - - compile("com.github.corda.crash:crash.connectors.ssh:$crash_version") { - exclude group: "org.slf4j", module: "slf4j-jdk14" - exclude group: "org.bouncycastle" - } - // OkHTTP: Simple HTTP library. compile "com.squareup.okhttp3:okhttp:$okhttp_version" @@ -175,9 +161,6 @@ dependencies { integrationTestCompile "junit:junit:$junit_version" integrationTestCompile "org.assertj:assertj-core:${assertj_version}" - // Jsh: Testing SSH server - integrationTestCompile group: 'com.jcraft', name: 'jsch', version: '0.1.54' - // AgentLoader: dynamic loading of JVM agents compile group: 'com.ea.agentloader', name: 'ea-agent-loader', version: "${eaagentloader_version}" diff --git a/node/src/integration-test/kotlin/net/corda/node/services/rpc/RpcSslTest.kt b/node/src/integration-test/kotlin/net/corda/node/services/rpc/RpcSslTest.kt index 253f49aa0e..8252ef114c 100644 --- a/node/src/integration-test/kotlin/net/corda/node/services/rpc/RpcSslTest.kt +++ b/node/src/integration-test/kotlin/net/corda/node/services/rpc/RpcSslTest.kt @@ -5,8 +5,8 @@ import net.corda.client.rpc.internal.createCordaRPCClientWithSsl import net.corda.core.identity.CordaX500Name import net.corda.core.utilities.getOrThrow import net.corda.node.services.Permissions.Companion.all -import net.corda.node.testsupport.withCertificates -import net.corda.node.testsupport.withKeyStores +import net.corda.testing.common.internal.withCertificates +import net.corda.testing.common.internal.withKeyStores import net.corda.testing.driver.DriverParameters import net.corda.testing.driver.PortAllocation import net.corda.testing.driver.driver diff --git a/node/src/main/kotlin/net/corda/node/internal/AbstractNode.kt b/node/src/main/kotlin/net/corda/node/internal/AbstractNode.kt index 3ea2500904..eb2b936b5e 100644 --- a/node/src/main/kotlin/net/corda/node/internal/AbstractNode.kt +++ b/node/src/main/kotlin/net/corda/node/internal/AbstractNode.kt @@ -42,6 +42,7 @@ import net.corda.node.services.FinalityHandler import net.corda.node.services.NotaryChangeHandler import net.corda.node.services.api.* import net.corda.node.services.config.* +import net.corda.node.services.config.shell.toShellConfig import net.corda.node.services.events.NodeSchedulerService import net.corda.node.services.events.ScheduledActivityObserver import net.corda.node.services.identity.PersistentIdentityService @@ -56,7 +57,6 @@ import net.corda.node.services.transactions.* import net.corda.node.services.upgrade.ContractUpgradeServiceImpl import net.corda.node.services.vault.NodeVaultService import net.corda.node.services.vault.VaultSoftLockManager -import net.corda.node.shell.InteractiveShell import net.corda.node.utilities.AffinityExecutor import net.corda.node.utilities.JVMAgentRegistry import net.corda.node.utilities.NodeBuildProperties @@ -67,6 +67,7 @@ import net.corda.nodeapi.internal.persistence.CordaPersistence import net.corda.nodeapi.internal.persistence.DatabaseConfig import net.corda.nodeapi.internal.persistence.HibernateConfiguration import net.corda.nodeapi.internal.storeLegalIdentity +import net.corda.tools.shell.InteractiveShell import org.apache.activemq.artemis.utils.ReusableLatch import org.hibernate.type.descriptor.java.JavaTypeDescriptorRegistry import org.slf4j.Logger @@ -258,7 +259,7 @@ abstract class AbstractNode(val configuration: NodeConfiguration, tokenizableServices = nodeServices + cordaServices + schedulerService registerCordappFlows(smm) _services.rpcFlows += cordappLoader.cordapps.flatMap { it.rpcFlows } - startShell(rpcOps) + startShell() Pair(StartedNodeImpl(this@AbstractNode, _services, nodeInfo, checkpointStorage, smm, attachments, network, database, rpcOps, flowStarter, notaryService), schedulerService) } networkMapUpdater = NetworkMapUpdater(services.networkMapCache, @@ -296,9 +297,12 @@ abstract class AbstractNode(val configuration: NodeConfiguration, */ protected abstract fun getRxIoScheduler(): Scheduler - open fun startShell(rpcOps: CordaRPCOps) { + open fun startShell() { if (configuration.shouldInitCrashShell()) { - InteractiveShell.startShell(configuration, rpcOps, securityManager, _services.identityService, _services.database) + if (configuration.rpcOptions.address == null) { + throw ConfigurationException("Cannot init CrashShell because node RPC address is not set (via 'rpcSettings' option).") + } + InteractiveShell.startShell(configuration.toShellConfig()) } } diff --git a/node/src/main/kotlin/net/corda/node/internal/Node.kt b/node/src/main/kotlin/net/corda/node/internal/Node.kt index 6a3b71534f..59bd348cd1 100644 --- a/node/src/main/kotlin/net/corda/node/internal/Node.kt +++ b/node/src/main/kotlin/net/corda/node/internal/Node.kt @@ -1,6 +1,7 @@ package net.corda.node.internal import com.codahale.metrics.JmxReporter +import net.corda.client.rpc.internal.KryoClientSerializationScheme import net.corda.core.concurrent.CordaFuture import net.corda.core.internal.concurrent.openFuture import net.corda.core.internal.concurrent.thenMatch @@ -26,9 +27,8 @@ import net.corda.node.internal.security.RPCSecurityManagerImpl import net.corda.node.serialization.KryoServerSerializationScheme import net.corda.node.services.api.NodePropertiesStore import net.corda.node.services.api.SchemaService -import net.corda.node.services.config.NodeConfiguration -import net.corda.node.services.config.SecurityConfiguration -import net.corda.node.services.config.VerifierType +import net.corda.node.services.config.* +import net.corda.node.services.config.shell.shellUser import net.corda.node.services.messaging.* import net.corda.node.services.rpc.ArtemisRpcBroker import net.corda.node.services.transactions.InMemoryTransactionVerifierService @@ -159,7 +159,7 @@ open class Node(configuration: NodeConfiguration, val securityManagerConfig = configuration.security?.authService ?: SecurityConfiguration.AuthService.fromUsers(configuration.rpcUsers) - securityManager = RPCSecurityManagerImpl(securityManagerConfig) + securityManager = RPCSecurityManagerImpl(if (configuration.shouldInitCrashShell()) securityManagerConfig.copyWithAdditionalUser(configuration.shellUser()) else securityManagerConfig) val serverAddress = configuration.messagingServerAddress ?: makeLocalMessageBroker(networkParameters) val rpcServerAddresses = if (configuration.rpcOptions.standAloneBroker) { @@ -373,11 +373,13 @@ open class Node(configuration: NodeConfiguration, SerializationFactoryImpl().apply { registerScheme(KryoServerSerializationScheme()) registerScheme(AMQPServerSerializationScheme(cordappLoader.cordapps)) + registerScheme(KryoClientSerializationScheme()) }, p2pContext = AMQP_P2P_CONTEXT.withClassLoader(classloader), rpcServerContext = KRYO_RPC_SERVER_CONTEXT.withClassLoader(classloader), storageContext = AMQP_STORAGE_CONTEXT.withClassLoader(classloader), - checkpointContext = KRYO_CHECKPOINT_CONTEXT.withClassLoader(classloader)) + checkpointContext = KRYO_CHECKPOINT_CONTEXT.withClassLoader(classloader), + rpcClientContext = if (configuration.shouldInitCrashShell()) KRYO_RPC_CLIENT_CONTEXT.withClassLoader(classloader) else null) //even Shell embeded in the node connects via RPC to the node } private var rpcMessagingClient: RPCMessagingClient? = null diff --git a/node/src/main/kotlin/net/corda/node/internal/NodeStartup.kt b/node/src/main/kotlin/net/corda/node/internal/NodeStartup.kt index af04a57afd..be082e1023 100644 --- a/node/src/main/kotlin/net/corda/node/internal/NodeStartup.kt +++ b/node/src/main/kotlin/net/corda/node/internal/NodeStartup.kt @@ -12,12 +12,13 @@ import net.corda.node.* import net.corda.node.services.config.NodeConfiguration import net.corda.node.services.config.NodeConfigurationImpl import net.corda.node.services.config.shouldStartLocalShell +import net.corda.node.services.config.shouldStartSSHDaemon import net.corda.node.services.transactions.bftSMaRtSerialFilter -import net.corda.node.shell.InteractiveShell import net.corda.node.utilities.registration.HTTPNetworkRegistrationService import net.corda.node.utilities.registration.NetworkRegistrationHelper import net.corda.nodeapi.internal.addShutdownHook import net.corda.nodeapi.internal.config.UnknownConfigurationKeysException +import net.corda.tools.shell.InteractiveShell import org.fusesource.jansi.Ansi import org.fusesource.jansi.AnsiConsole import org.slf4j.bridge.SLF4JBridgeHandler @@ -153,12 +154,15 @@ open class NodeStartup(val args: Array) { if (conf.shouldStartLocalShell()) { startedNode.internals.startupComplete.then { try { - InteractiveShell.runLocalShell(startedNode) + InteractiveShell.runLocalShell( {startedNode.dispose()} ) } catch (e: Throwable) { logger.error("Shell failed to start", e) } } } + if (conf.shouldStartSSHDaemon()) { + Node.printBasicNodeInfo("SSH server listening on port", conf.sshd!!.port.toString()) + } }, { th -> logger.error("Unexpected exception during registration", th) diff --git a/node/src/main/kotlin/net/corda/node/services/config/NodeConfiguration.kt b/node/src/main/kotlin/net/corda/node/services/config/NodeConfiguration.kt index 1e3807a1e7..858b243aeb 100644 --- a/node/src/main/kotlin/net/corda/node/services/config/NodeConfiguration.kt +++ b/node/src/main/kotlin/net/corda/node/services/config/NodeConfiguration.kt @@ -14,6 +14,7 @@ import net.corda.nodeapi.internal.config.SSLConfiguration import net.corda.nodeapi.internal.config.User import net.corda.nodeapi.internal.config.parseAs import net.corda.nodeapi.internal.persistence.DatabaseConfig +import net.corda.tools.shell.SSHDConfiguration import java.net.URL import java.nio.file.Path import java.time.Duration @@ -253,8 +254,6 @@ data class CertChainPolicyConfig(val role: String, private val policy: CertChain } } -data class SSHDConfiguration(val port: Int) - // Supported types of authentication/authorization data providers enum class AuthDataSourceType { // External RDBMS @@ -290,6 +289,8 @@ data class SecurityConfiguration(val authService: SecurityConfiguration.AuthServ } } + fun copyWithAdditionalUser(user: User) = AuthService(dataSource.copyWithAdditionalUser(user), id, options) + // Optional components: cache data class Options(val cache: Options.Cache?) { @@ -317,6 +318,12 @@ data class SecurityConfiguration(val authService: SecurityConfiguration.AuthServ AuthDataSourceType.DB -> require(users == null && connection != null) } } + + fun copyWithAdditionalUser(user: User) : DataSource{ + val extendedList = this.users?.toMutableList()?: mutableListOf() + extendedList.add(user) + return DataSource(this.type, this.passwordEncryption, this.connection, listOf(*extendedList.toTypedArray())) + } } companion object { diff --git a/node/src/main/kotlin/net/corda/node/services/config/SslOptions.kt b/node/src/main/kotlin/net/corda/node/services/config/SslOptions.kt index e4b6f7f9f5..6f1fd1941e 100644 --- a/node/src/main/kotlin/net/corda/node/services/config/SslOptions.kt +++ b/node/src/main/kotlin/net/corda/node/services/config/SslOptions.kt @@ -5,7 +5,6 @@ import java.nio.file.Path import java.nio.file.Paths data class SslOptions(override val certificatesDirectory: Path, override val keyStorePassword: String, override val trustStorePassword: String) : SSLConfiguration { - constructor(certificatesDirectory: String, keyStorePassword: String, trustStorePassword: String) : this(certificatesDirectory.toAbsolutePath(), keyStorePassword, trustStorePassword) fun copy(certificatesDirectory: String = this.certificatesDirectory.toString(), keyStorePassword: String = this.keyStorePassword, trustStorePassword: String = this.trustStorePassword): SslOptions = copy(certificatesDirectory = certificatesDirectory.toAbsolutePath(), keyStorePassword = keyStorePassword, trustStorePassword = trustStorePassword) } diff --git a/node/src/main/kotlin/net/corda/node/services/config/shell/ShellConfig.kt b/node/src/main/kotlin/net/corda/node/services/config/shell/ShellConfig.kt new file mode 100644 index 0000000000..00fa4e0f81 --- /dev/null +++ b/node/src/main/kotlin/net/corda/node/services/config/shell/ShellConfig.kt @@ -0,0 +1,44 @@ +package net.corda.node.services.config.shell + +import net.corda.core.internal.div +import net.corda.core.utilities.NetworkHostAndPort +import net.corda.node.services.Permissions +import net.corda.node.services.config.NodeConfiguration +import net.corda.node.services.config.shouldInitCrashShell +import net.corda.nodeapi.internal.config.User +import net.corda.tools.shell.ShellConfiguration +import net.corda.tools.shell.ShellConfiguration.Companion.COMMANDS_DIR +import net.corda.tools.shell.ShellConfiguration.Companion.CORDAPPS_DIR +import net.corda.tools.shell.ShellConfiguration.Companion.SSHD_HOSTKEY_DIR +import net.corda.tools.shell.ShellConfiguration.Companion.SSH_PORT +import net.corda.tools.shell.ShellSslOptions + + +//re-packs data to Shell specific classes +fun NodeConfiguration.toShellConfig(): ShellConfiguration { + + val sslConfiguration = if (this.rpcOptions.useSsl) { + with(this.rpcOptions.sslConfig) { + ShellSslOptions(sslKeystore, + keyStorePassword, + trustStoreFile, + trustStorePassword) + } + } else { + null + } + val localShellUser: User = localShellUser() + return ShellConfiguration( + commandsDirectory = this.baseDirectory / COMMANDS_DIR, + cordappsDirectory = this.baseDirectory.toString() / CORDAPPS_DIR, + user = localShellUser.username, + password = localShellUser.password, + hostAndPort = this.rpcOptions.address ?: NetworkHostAndPort("localhost", SSH_PORT), + ssl = sslConfiguration, + sshdPort = this.sshd?.port, + sshHostKeyDirectory = this.baseDirectory / SSHD_HOSTKEY_DIR, + noLocalShell = this.noLocalShell) +} + +private fun localShellUser() = User("shell", "shell", setOf(Permissions.all())) +fun NodeConfiguration.shellUser() = shouldInitCrashShell()?.let { localShellUser() } diff --git a/node/src/main/kotlin/net/corda/node/shell/CordaAuthenticationPlugin.kt b/node/src/main/kotlin/net/corda/node/shell/CordaAuthenticationPlugin.kt deleted file mode 100644 index 7dbdc8e52f..0000000000 --- a/node/src/main/kotlin/net/corda/node/shell/CordaAuthenticationPlugin.kt +++ /dev/null @@ -1,34 +0,0 @@ -package net.corda.node.shell - -import net.corda.core.context.Actor -import net.corda.core.context.InvocationContext -import net.corda.core.identity.CordaX500Name -import net.corda.core.messaging.CordaRPCOps -import net.corda.node.internal.security.Password -import net.corda.node.internal.security.RPCSecurityManager -import net.corda.node.internal.security.tryAuthenticate -import org.crsh.auth.AuthInfo -import org.crsh.auth.AuthenticationPlugin -import org.crsh.plugin.CRaSHPlugin - -class CordaAuthenticationPlugin(private val rpcOps: CordaRPCOps, private val securityManager: RPCSecurityManager, private val nodeLegalName: CordaX500Name) : CRaSHPlugin>(), AuthenticationPlugin { - - override fun getImplementation(): AuthenticationPlugin = this - - override fun getName(): String = "corda" - - override fun authenticate(username: String?, credential: String?): AuthInfo { - - if (username == null || credential == null) { - return AuthInfo.UNSUCCESSFUL - } - val authorizingSubject = securityManager.tryAuthenticate(username, Password(credential)) - if (authorizingSubject != null) { - val actor = Actor(Actor.Id(username), securityManager.id, nodeLegalName) - return CordaSSHAuthInfo(true, makeRPCOpsWithContext(rpcOps, InvocationContext.rpc(actor), authorizingSubject)) - } - return AuthInfo.UNSUCCESSFUL - } - - override fun getCredentialType(): Class = String::class.java -} \ No newline at end of file diff --git a/node/src/main/kotlin/net/corda/node/shell/CordaSSHAuthInfo.kt b/node/src/main/kotlin/net/corda/node/shell/CordaSSHAuthInfo.kt deleted file mode 100644 index 04bda1a4bb..0000000000 --- a/node/src/main/kotlin/net/corda/node/shell/CordaSSHAuthInfo.kt +++ /dev/null @@ -1,9 +0,0 @@ -package net.corda.node.shell - -import net.corda.core.messaging.CordaRPCOps -import net.corda.node.utilities.ANSIProgressRenderer -import org.crsh.auth.AuthInfo - -class CordaSSHAuthInfo(val successful: Boolean, val rpcOps: CordaRPCOps, val ansiProgressRenderer: ANSIProgressRenderer? = null) : AuthInfo { - override fun isSuccessful(): Boolean = successful -} \ No newline at end of file diff --git a/node/src/main/kotlin/net/corda/node/shell/RPCOpsWithContext.kt b/node/src/main/kotlin/net/corda/node/shell/RPCOpsWithContext.kt deleted file mode 100644 index 01446bd58d..0000000000 --- a/node/src/main/kotlin/net/corda/node/shell/RPCOpsWithContext.kt +++ /dev/null @@ -1,48 +0,0 @@ -package net.corda.node.shell - -import net.corda.core.context.InvocationContext -import net.corda.core.messaging.CordaRPCOps -import net.corda.core.utilities.getOrThrow -import net.corda.node.internal.security.AuthorizingSubject -import net.corda.node.services.messaging.CURRENT_RPC_CONTEXT -import net.corda.node.services.messaging.RpcAuthContext -import java.lang.reflect.InvocationTargetException -import java.lang.reflect.Proxy -import java.util.concurrent.CompletableFuture -import java.util.concurrent.Future - -fun makeRPCOpsWithContext(cordaRPCOps: CordaRPCOps, invocationContext:InvocationContext, authorizingSubject: AuthorizingSubject) : CordaRPCOps { - - return Proxy.newProxyInstance(CordaRPCOps::class.java.classLoader, arrayOf(CordaRPCOps::class.java), { _, method, args -> - RPCContextRunner(invocationContext, authorizingSubject) { - try { - method.invoke(cordaRPCOps, *(args ?: arrayOf())) - } catch (e: InvocationTargetException) { - // Unpack exception. - throw e.targetException - } - }.get().getOrThrow() - }) as CordaRPCOps -} - -private class RPCContextRunner(val invocationContext: InvocationContext, val authorizingSubject: AuthorizingSubject, val block:() -> T): Thread() { - - private var result: CompletableFuture = CompletableFuture() - - override fun run() { - CURRENT_RPC_CONTEXT.set(RpcAuthContext(invocationContext, authorizingSubject)) - try { - result.complete(block()) - } catch (e: Throwable) { - result.completeExceptionally(e) - } finally { - CURRENT_RPC_CONTEXT.remove() - } - } - - fun get(): Future { - start() - join() - return result - } -} \ No newline at end of file diff --git a/node/src/test/kotlin/net/corda/node/services/config/NodeConfigurationImplTest.kt b/node/src/test/kotlin/net/corda/node/services/config/NodeConfigurationImplTest.kt index 21899cce86..394ef4a303 100644 --- a/node/src/test/kotlin/net/corda/node/services/config/NodeConfigurationImplTest.kt +++ b/node/src/test/kotlin/net/corda/node/services/config/NodeConfigurationImplTest.kt @@ -2,6 +2,7 @@ package net.corda.node.services.config import net.corda.core.internal.div import net.corda.core.utilities.NetworkHostAndPort +import net.corda.tools.shell.SSHDConfiguration import net.corda.testing.core.ALICE_NAME import net.corda.testing.node.MockServices.Companion.makeTestDataSourceProperties import org.assertj.core.api.Assertions.assertThatThrownBy diff --git a/node/src/test/kotlin/net/corda/node/services/rpc/ArtemisRpcTests.kt b/node/src/test/kotlin/net/corda/node/services/rpc/ArtemisRpcTests.kt index 47a31bf0de..d065b0d222 100644 --- a/node/src/test/kotlin/net/corda/node/services/rpc/ArtemisRpcTests.kt +++ b/node/src/test/kotlin/net/corda/node/services/rpc/ArtemisRpcTests.kt @@ -11,12 +11,12 @@ import net.corda.node.internal.security.RPCSecurityManagerImpl import net.corda.node.services.Permissions.Companion.all import net.corda.node.services.config.CertChainPolicyConfig import net.corda.node.services.messaging.RPCMessagingClient -import net.corda.node.testsupport.withCertificates -import net.corda.node.testsupport.withKeyStores import net.corda.nodeapi.ArtemisTcpTransport.Companion.tcpTransport import net.corda.nodeapi.ConnectionDirection import net.corda.nodeapi.internal.config.SSLConfiguration import net.corda.nodeapi.internal.config.User +import net.corda.testing.common.internal.withCertificates +import net.corda.testing.common.internal.withKeyStores import net.corda.testing.core.SerializationEnvironmentRule import net.corda.testing.driver.PortAllocation import net.corda.testing.driver.internal.RandomFree diff --git a/settings.gradle b/settings.gradle index 1191e87414..84cde047f2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -35,6 +35,7 @@ include 'tools:demobench' include 'tools:loadtest' include 'tools:graphs' include 'tools:bootstrapper' +include 'tools:shell' include 'example-code' project(':example-code').projectDir = file("$settingsDir/docs/source/example-code") include 'samples:attachment-demo' diff --git a/testing/node-driver/src/main/kotlin/net/corda/testing/node/internal/InternalMockNetwork.kt b/testing/node-driver/src/main/kotlin/net/corda/testing/node/internal/InternalMockNetwork.kt index 57b8e47b19..491c8a8dca 100644 --- a/testing/node-driver/src/main/kotlin/net/corda/testing/node/internal/InternalMockNetwork.kt +++ b/testing/node-driver/src/main/kotlin/net/corda/testing/node/internal/InternalMockNetwork.kt @@ -278,7 +278,7 @@ open class InternalMockNetwork(private val cordappPackages: List, return E2ETestKeyManagementService(identityService, keyPairs) } - override fun startShell(rpcOps: CordaRPCOps) { + override fun startShell() { //No mock shell } diff --git a/node/src/test/kotlin/net/corda/node/testsupport/UnsafeCertificatesFactory.kt b/testing/test-common/src/main/kotlin/net/corda/testing/common/internal/UnsafeCertificatesFactory.kt similarity index 94% rename from node/src/test/kotlin/net/corda/node/testsupport/UnsafeCertificatesFactory.kt rename to testing/test-common/src/main/kotlin/net/corda/testing/common/internal/UnsafeCertificatesFactory.kt index bac182604e..bb35d341f1 100644 --- a/node/src/test/kotlin/net/corda/node/testsupport/UnsafeCertificatesFactory.kt +++ b/testing/test-common/src/main/kotlin/net/corda/testing/common/internal/UnsafeCertificatesFactory.kt @@ -1,8 +1,8 @@ -package net.corda.node.testsupport +package net.corda.testing.common.internal import net.corda.core.identity.CordaX500Name import net.corda.core.internal.div -import net.corda.node.services.config.SslOptions +import net.corda.nodeapi.internal.config.SSLConfiguration import net.corda.nodeapi.internal.crypto.* import org.apache.commons.io.FileUtils import sun.security.tools.keytool.CertAndKeyGen @@ -74,12 +74,13 @@ class KeyStores(val keyStore: UnsafeKeyStore, val trustStore: UnsafeKeyStore) { } } } + data class TestSslOptions(override val certificatesDirectory: Path, override val keyStorePassword: String, override val trustStorePassword: String) : SSLConfiguration - private fun sslConfiguration(directory: Path) = SslOptions(directory, keyStore.password, trustStore.password) + private fun sslConfiguration(directory: Path) = TestSslOptions(directory, keyStore.password, trustStore.password) } interface AutoClosableSSLConfiguration : AutoCloseable { - val value: SslOptions + val value: SSLConfiguration } typealias KeyStoreEntry = Pair @@ -182,7 +183,7 @@ private fun newKeyStore(type: String, password: String): KeyStore { return keyStore } -fun withKeyStores(server: KeyStores, client: KeyStores, action: (brokerSslOptions: SslOptions, clientSslOptions: SslOptions) -> Unit) { +fun withKeyStores(server: KeyStores, client: KeyStores, action: (brokerSslOptions: SSLConfiguration, clientSslOptions: SSLConfiguration) -> Unit) { val serverDir = Files.createTempDirectory(null) FileUtils.forceDeleteOnExit(serverDir.toFile()) diff --git a/testing/test-utils/src/main/kotlin/net/corda/testing/internal/InternalTestUtils.kt b/testing/test-utils/src/main/kotlin/net/corda/testing/internal/InternalTestUtils.kt index fd064cc6cf..72e4b5a9ee 100644 --- a/testing/test-utils/src/main/kotlin/net/corda/testing/internal/InternalTestUtils.kt +++ b/testing/test-utils/src/main/kotlin/net/corda/testing/internal/InternalTestUtils.kt @@ -9,7 +9,6 @@ import net.corda.core.identity.PartyAndCertificate import net.corda.core.node.NodeInfo import net.corda.core.utilities.NetworkHostAndPort import net.corda.core.utilities.loggerFor -import net.corda.node.services.config.SslOptions import net.corda.node.services.config.configureDevKeyAndTrustStores import net.corda.nodeapi.internal.config.SSLConfiguration import net.corda.nodeapi.internal.createDevNodeCa @@ -121,7 +120,7 @@ fun createDevNodeCaCertPath( /** Application of [doAnswer] that gets a value from the given [map] using the arg at [argIndex] as key. */ fun doLookup(map: Map<*, *>, argIndex: Int = 0) = doAnswer { map[it.arguments[argIndex]] } -fun SslOptions.useSslRpcOverrides(): Map { +fun SSLConfiguration.useSslRpcOverrides(): Map { return mapOf( "rpcSettings.useSsl" to "true", "rpcSettings.ssl.certificatesDirectory" to certificatesDirectory.toString(), @@ -130,7 +129,7 @@ fun SslOptions.useSslRpcOverrides(): Map { ) } -fun SslOptions.noSslRpcOverrides(rpcAdminAddress: NetworkHostAndPort): Map { +fun SSLConfiguration.noSslRpcOverrides(rpcAdminAddress: NetworkHostAndPort): Map { return mapOf( "rpcSettings.adminAddress" to rpcAdminAddress.toString(), "rpcSettings.useSsl" to "false", diff --git a/tools/shell/build.gradle b/tools/shell/build.gradle new file mode 100644 index 0000000000..73c5aafb16 --- /dev/null +++ b/tools/shell/build.gradle @@ -0,0 +1,92 @@ +apply plugin: 'kotlin' +apply plugin: 'java' +apply plugin: 'application' +apply plugin: 'net.corda.plugins.quasar-utils' + +description 'Corda Shell' + +configurations { + integrationTestCompile.extendsFrom testCompile + integrationTestRuntime.extendsFrom testRuntime +} + +sourceSets { + integrationTest { + kotlin { + compileClasspath += main.output + test.output + runtimeClasspath += main.output + test.output + srcDir file('src/integration-test/kotlin') + } + resources { + srcDir file('src/integration-test/resources') + } + } + test { + resources { + srcDir file('src/test/resources') + } + } +} + +dependencies { + compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + testCompile "org.jetbrains.kotlin:kotlin-test:$kotlin_version" + + compile project(':node-api') + compile project(':client:rpc') + + // Jackson support: serialisation to/from JSON, YAML, etc + compile project(':client:jackson') + compile group: 'org.json', name: 'json', version: json_version + + + // JOpt: for command line flags. + compile "net.sf.jopt-simple:jopt-simple:$jopt_simple_version" + + // CRaSH: An embeddable monitoring and admin shell with support for adding new commands written in Groovy. + compile("com.github.corda.crash:crash.shell:$crash_version") { + exclude group: "org.slf4j", module: "slf4j-jdk14" + exclude group: "org.bouncycastle" + } + + compile("com.github.corda.crash:crash.connectors.ssh:$crash_version") { + exclude group: "org.slf4j", module: "slf4j-jdk14" + exclude group: "org.bouncycastle" + } + + // JAnsi: for drawing things to the terminal in nicely coloured ways. + compile "org.fusesource.jansi:jansi:$jansi_version" + + // Manifests: for reading stuff from the manifest file + compile "com.jcabi:jcabi-manifests:1.1" + + // Unit testing helpers. + testCompile "junit:junit:$junit_version" + testCompile "org.assertj:assertj-core:${assertj_version}" + testCompile project(':test-utils') + testCompile project(':finance') + + // Integration test helpers + integrationTestCompile "junit:junit:$junit_version" + integrationTestCompile "org.assertj:assertj-core:${assertj_version}" + + // Jsh: Testing SSH server + integrationTestCompile "com.jcraft:jsch:$jsch_version" + + integrationTestCompile project(':node-driver') +} + +mainClassName = 'net.corda.tools.shell.StandaloneShellKt' + +jar { + baseName 'corda-shell' +} + +processResources { + from file("$rootDir/config/dev/log4j2.xml") +} + +task integrationTest(type: Test) { + testClassesDirs = sourceSets.integrationTest.output.classesDirs + classpath = sourceSets.integrationTest.runtimeClasspath +} diff --git a/tools/shell/src/integration-test/kotlin/net/corda/tools/shell/InteractiveShellIntegrationTest.kt b/tools/shell/src/integration-test/kotlin/net/corda/tools/shell/InteractiveShellIntegrationTest.kt new file mode 100644 index 0000000000..37b9be0e04 --- /dev/null +++ b/tools/shell/src/integration-test/kotlin/net/corda/tools/shell/InteractiveShellIntegrationTest.kt @@ -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(), + 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(), + 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() + } + } + } +} \ No newline at end of file diff --git a/node/src/integration-test/kotlin/net/corda/node/SSHServerTest.kt b/tools/shell/src/integration-test/kotlin/net/corda/tools/shell/SSHServerTest.kt similarity index 94% rename from node/src/integration-test/kotlin/net/corda/node/SSHServerTest.kt rename to tools/shell/src/integration-test/kotlin/net/corda/tools/shell/SSHServerTest.kt index 634b761d1f..7689bb89d9 100644 --- a/node/src/integration-test/kotlin/net/corda/node/SSHServerTest.kt +++ b/tools/shell/src/integration-test/kotlin/net/corda/tools/shell/SSHServerTest.kt @@ -1,4 +1,4 @@ -package net.corda.node +package net.corda.tools.shell import co.paralleluniverse.fibers.Suspendable import com.jcraft.jsch.ChannelExec @@ -8,9 +8,11 @@ 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.driver.DriverParameters @@ -20,7 +22,6 @@ import org.assertj.core.api.Assertions.assertThat import org.bouncycastle.util.io.Streams import org.junit.Test import java.net.ConnectException -import java.util.regex.Pattern import kotlin.test.assertTrue import kotlin.test.fail @@ -91,7 +92,8 @@ class SSHServerTest { @Test fun `ssh respects permissions`() { - val user = User("u", "p", setOf(startFlow())) + val user = User("u", "p", setOf(startFlow(), + 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), @@ -106,12 +108,10 @@ class SSHServerTest { assertTrue(session.isConnected) val channel = session.openChannel("exec") as ChannelExec - channel.setCommand("start FlowICannotRun otherParty: \"${ALICE_NAME}\"") + channel.setCommand("start FlowICannotRun otherParty: \"$ALICE_NAME\"") channel.connect() val response = String(Streams.readAll(channel.inputStream)) - val flowNameEscaped = Pattern.quote("StartFlow.${SSHServerTest::class.qualifiedName}$${FlowICannotRun::class.simpleName}") - channel.disconnect() session.disconnect() @@ -137,11 +137,17 @@ class SSHServerTest { val channel = session.openChannel("exec") as ChannelExec channel.setCommand("start FlowICanRun") - channel.connect() + 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) } diff --git a/tools/shell/src/integration-test/resources/ssl.conf b/tools/shell/src/integration-test/resources/ssl.conf new file mode 100644 index 0000000000..f8faaa8788 --- /dev/null +++ b/tools/shell/src/integration-test/resources/ssl.conf @@ -0,0 +1,8 @@ +user=demo1 +baseDirectory="/Users/szymonsztuka/Documents/shell-config" +hostAndPort="localhost:10006" +sshdPort=2223 +ssl { + keyStorePassword=password + trustStorePassword=password +} diff --git a/node/src/main/java/net/corda/node/shell/FlowShellCommand.java b/tools/shell/src/main/java/net/corda/tools/shell/FlowShellCommand.java similarity index 85% rename from node/src/main/java/net/corda/node/shell/FlowShellCommand.java rename to tools/shell/src/main/java/net/corda/tools/shell/FlowShellCommand.java index f857a7eb62..1ed76de5f0 100644 --- a/node/src/main/java/net/corda/node/shell/FlowShellCommand.java +++ b/tools/shell/src/main/java/net/corda/tools/shell/FlowShellCommand.java @@ -1,10 +1,11 @@ -package net.corda.node.shell; +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.node.utilities.ANSIProgressRenderer; -import net.corda.node.utilities.CRaSHANSIProgressRenderer; +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.*; @@ -12,7 +13,8 @@ import org.crsh.text.ui.TableElement; import java.util.*; -import static net.corda.node.shell.InteractiveShell.*; +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" + @@ -28,7 +30,7 @@ public class FlowShellCommand extends InteractiveShellCommand { @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 input ) { - startFlow(name, input, out, ops(), ansiProgressRenderer()); + startFlow(name, input, out, ops(), ansiProgressRenderer(), objectMapper()); } // TODO Limit number of flows shown option? @@ -42,13 +44,14 @@ public class FlowShellCommand extends InteractiveShellCommand { @Usage("The data to pass as input") @Argument(unquote = false) List input, RenderPrintWriter out, CordaRPCOps rpcOps, - ANSIProgressRenderer ansiProgressRenderer) { + 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) ); + runFlowByNameFragment(name, inp, out, rpcOps, ansiProgressRenderer != null ? ansiProgressRenderer : new CRaSHANSIProgressRenderer(out), om); } @Command diff --git a/node/src/main/java/net/corda/node/shell/RunShellCommand.java b/tools/shell/src/main/java/net/corda/tools/shell/RunShellCommand.java similarity index 97% rename from node/src/main/java/net/corda/node/shell/RunShellCommand.java rename to tools/shell/src/main/java/net/corda/tools/shell/RunShellCommand.java index 6875a5cdb8..c946190c05 100644 --- a/node/src/main/java/net/corda/node/shell/RunShellCommand.java +++ b/tools/shell/src/main/java/net/corda/tools/shell/RunShellCommand.java @@ -1,4 +1,4 @@ -package net.corda.node.shell; +package net.corda.tools.shell; import net.corda.core.messaging.*; import net.corda.client.jackson.*; @@ -30,7 +30,7 @@ public class RunShellCommand extends InteractiveShellCommand { return null; } - return InteractiveShell.runRPCFromString(command, out, context, ops()); + return InteractiveShell.runRPCFromString(command, out, context, ops(), objectMapper()); } private void emitHelp(InvocationContext context, StringToMethodCallParser parser) { diff --git a/node/src/main/java/net/corda/node/shell/StartShellCommand.java b/tools/shell/src/main/java/net/corda/tools/shell/StartShellCommand.java similarity index 78% rename from node/src/main/java/net/corda/node/shell/StartShellCommand.java rename to tools/shell/src/main/java/net/corda/tools/shell/StartShellCommand.java index 3d2b9953e9..2f368128c1 100644 --- a/node/src/main/java/net/corda/node/shell/StartShellCommand.java +++ b/tools/shell/src/main/java/net/corda/tools/shell/StartShellCommand.java @@ -1,9 +1,9 @@ -package net.corda.node.shell; +package net.corda.tools.shell; // A simple forwarder to the "flow start" command, for easier typing. -import net.corda.node.utilities.ANSIProgressRenderer; -import net.corda.node.utilities.CRaSHANSIProgressRenderer; +import net.corda.tools.shell.utlities.ANSIProgressRenderer; +import net.corda.tools.shell.utlities.CRaSHANSIProgressRenderer; import org.crsh.cli.*; import java.util.*; @@ -14,6 +14,6 @@ public class StartShellCommand extends InteractiveShellCommand { 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 input) { ANSIProgressRenderer ansiProgressRenderer = ansiProgressRenderer(); - FlowShellCommand.startFlow(name, input, out, ops(), ansiProgressRenderer != null ? ansiProgressRenderer : new CRaSHANSIProgressRenderer(out)); + FlowShellCommand.startFlow(name, input, out, ops(), ansiProgressRenderer != null ? ansiProgressRenderer : new CRaSHANSIProgressRenderer(out), objectMapper()); } } diff --git a/tools/shell/src/main/kotlin/net/corda/tools/shell/CordaAuthenticationPlugin.kt b/tools/shell/src/main/kotlin/net/corda/tools/shell/CordaAuthenticationPlugin.kt new file mode 100644 index 0000000000..c495292d0f --- /dev/null +++ b/tools/shell/src/main/kotlin/net/corda/tools/shell/CordaAuthenticationPlugin.kt @@ -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 { + + companion object { + private val logger = loggerFor() + } + + override fun getImplementation(): AuthenticationPlugin = 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::class.java +} \ No newline at end of file diff --git a/tools/shell/src/main/kotlin/net/corda/tools/shell/CordaSSHAuthInfo.kt b/tools/shell/src/main/kotlin/net/corda/tools/shell/CordaSSHAuthInfo.kt new file mode 100644 index 0000000000..c8202bf03d --- /dev/null +++ b/tools/shell/src/main/kotlin/net/corda/tools/shell/CordaSSHAuthInfo.kt @@ -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) + } +} \ No newline at end of file diff --git a/node/src/main/kotlin/net/corda/node/shell/FlowWatchPrintingSubscriber.kt b/tools/shell/src/main/kotlin/net/corda/tools/shell/FlowWatchPrintingSubscriber.kt similarity index 99% rename from node/src/main/kotlin/net/corda/node/shell/FlowWatchPrintingSubscriber.kt rename to tools/shell/src/main/kotlin/net/corda/tools/shell/FlowWatchPrintingSubscriber.kt index cb48f1b829..e001425b0a 100644 --- a/node/src/main/kotlin/net/corda/node/shell/FlowWatchPrintingSubscriber.kt +++ b/tools/shell/src/main/kotlin/net/corda/tools/shell/FlowWatchPrintingSubscriber.kt @@ -1,4 +1,4 @@ -package net.corda.node.shell +package net.corda.tools.shell import net.corda.core.flows.StateMachineRunId import net.corda.core.internal.concurrent.openFuture diff --git a/node/src/main/kotlin/net/corda/node/shell/InteractiveShell.kt b/tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShell.kt similarity index 81% rename from node/src/main/kotlin/net/corda/node/shell/InteractiveShell.kt rename to tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShell.kt index 8cb160b908..88620c32c6 100644 --- a/node/src/main/kotlin/net/corda/node/shell/InteractiveShell.kt +++ b/tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShell.kt @@ -1,4 +1,4 @@ -package net.corda.node.shell +package net.corda.tools.shell import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser @@ -9,11 +9,11 @@ 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.CordaX500Name import net.corda.core.identity.Party import net.corda.core.internal.* import net.corda.core.internal.concurrent.doneFuture @@ -23,18 +23,10 @@ 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.node.services.IdentityService import net.corda.core.utilities.NetworkHostAndPort -import net.corda.node.internal.Node -import net.corda.node.internal.StartedNode -import net.corda.node.internal.security.AdminSubject -import net.corda.node.internal.security.RPCSecurityManager -import net.corda.node.services.config.NodeConfiguration -import net.corda.node.services.messaging.CURRENT_RPC_CONTEXT -import net.corda.node.services.messaging.RpcAuthContext -import net.corda.node.utilities.ANSIProgressRenderer -import net.corda.node.utilities.StdoutANSIProgressRenderer -import net.corda.nodeapi.internal.persistence.CordaPersistence +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 @@ -60,6 +52,7 @@ 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 @@ -80,61 +73,98 @@ import kotlin.concurrent.thread // 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 node: StartedNode - @VisibleForTesting - internal lateinit var database: CordaPersistence - private lateinit var rpcOps: CordaRPCOps - private lateinit var securityManager: RPCSecurityManager - private lateinit var identityService: IdentityService + private lateinit var rpcOps: (username: String, credentials: String) -> CordaRPCOps + private lateinit var connection: CordaRPCOps private var shell: Shell? = null - private lateinit var nodeLegalName: CordaX500Name - + 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: NodeConfiguration, cordaRPCOps: CordaRPCOps, securityManager: RPCSecurityManager, identityService: IdentityService, database: CordaPersistence) { - this.rpcOps = cordaRPCOps - this.securityManager = securityManager - this.identityService = identityService - this.nodeLegalName = configuration.myLegalName - this.database = database - val dir = configuration.baseDirectory - val runSshDaemon = configuration.sshd != null + 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) { - val sshKeysDir = dir / "sshkey" - sshKeysDir.toFile().mkdirs() - // Enable SSH access. Note: these have to be strings, even though raw object assignments also work. - config["crash.ssh.keypath"] = (sshKeysDir / "hostkey.pem").toString() - config["crash.ssh.keygen"] = "true" - config["crash.ssh.port"] = configuration.sshd?.port.toString() + 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(dir).start(config) - - if (runSshDaemon) { - Node.printBasicNodeInfo("SSH server listening on port", configuration.sshd!!.port.toString()) - } + shell = ShellLifecycle(configuration.commandsDirectory).start(config, configuration.user, configuration.password) } - fun runLocalShell(node: StartedNode) { + 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) { - // Give whoever has local shell access administrator access to the node. - val context = RpcAuthContext(net.corda.core.context.InvocationContext.shell(), AdminSubject("SHELL_USER")) - CURRENT_RPC_CONTEXT.set(context) Emoji.renderIfSupported { jlineProcessor.run() } @@ -144,22 +174,22 @@ object InteractiveShell { jlineProcessor.closed() log.info("Command shell has exited") terminal.restore() - node.dispose() + onExit.invoke() } } - class ShellLifecycle(val dir: Path) : PluginLifeCycle() { - fun start(config: Properties): Shell { + 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 = (dir / "shell-commands").toAbsolutePath().createDirectories() + val extraCommandsPath = shellCommands.toAbsolutePath().createDirectories() val commandsFS = FS.Builder() .register("file", fileDriver) .mount("file:" + extraCommandsPath) .register("classpath", classpathDriver) - .mount("classpath:/net/corda/node/shell/") + .mount("classpath:/net/corda/tools/shell/") .mount("classpath:/crash/commands/") .build() val confFS = FS.Builder() @@ -172,25 +202,29 @@ object InteractiveShell { // 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, securityManager, nodeLegalName) + return super.getPlugins().filterNot { it is JavaLanguage } + CordaAuthenticationPlugin(rpcOps) } } - val attributes = mapOf( - "ops" to rpcOps, - "mapper" to yamlInputMapper - ) + val attributes = emptyMap() val context = PluginContext(discovery, attributes, commandsFS, confFS, classLoader) context.refresh() this.config = config start(context) - return context.getPlugin(ShellFactory::class.java).create(null, CordaSSHAuthInfo(false, makeRPCOpsWithContext(rpcOps, net.corda.core.context.InvocationContext.shell(), AdminSubject("SHELL_USER")), StdoutANSIProgressRenderer)) + connection = makeRPCOps(rpcOps, localUserName, localUserPassword) + return context.getPlugin(ShellFactory::class.java).create(null, CordaSSHAuthInfo(false, connection, StdoutANSIProgressRenderer)) } } - private val yamlInputMapper: ObjectMapper by lazy { + 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. - JacksonSupport.createInMemoryMapper(identityService, YAMLFactory(), true).apply { + return JacksonSupport.createDefaultMapper(rpcOps, YAMLFactory(), true).apply { val rpcModule = SimpleModule() rpcModule.addDeserializer(InputStream::class.java, InputStreamDeserializer) rpcModule.addDeserializer(UniqueIdentifier::class.java, UniqueIdentifierDeserializer) @@ -244,8 +278,13 @@ object InteractiveShell { * 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) { - val matches = rpcOps.registeredFlows().filter { nameFragment in it } + 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 @@ -255,11 +294,15 @@ object InteractiveShell { return } - val clazz: Class> = uncheckedCast(Class.forName(matches.single())) + val flowClazz: Class> = 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, clazz) + val stateObservable = runFlowFromString({ clazz, args -> rpcOps.startTrackedFlowDynamic(clazz, *args) }, inputData, flowClazz, om) val latch = CountDownLatch(1) ansiProgressRenderer.render(stateObservable, { latch.countDown() }) @@ -298,7 +341,7 @@ object InteractiveShell { fun runFlowFromString(invoke: (Class>, Array) -> FlowProgressHandle, inputData: String, clazz: Class>, - om: ObjectMapper = yamlInputMapper): FlowProgressHandle { + om: ObjectMapper): FlowProgressHandle { // 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) @@ -312,10 +355,8 @@ object InteractiveShell { try { // Attempt construction with the given arguments. - val args = database.transaction { - paramNamesFromConstructor = parser.paramNamesFromConstructor(ctor) - parser.parseArguments(clazz.name, paramNamesFromConstructor!!.zip(ctor.parameterTypes), inputData) - } + 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 @@ -348,9 +389,7 @@ object InteractiveShell { val (stateMachines, stateMachineUpdates) = proxy.stateMachinesFeed() val currentStateMachines = stateMachines.map { StateMachineUpdate.Added(it) } val subscriber = FlowWatchPrintingSubscriber(out) - database.transaction { - stateMachineUpdates.startWith(currentStateMachines).subscribe(subscriber) - } + stateMachineUpdates.startWith(currentStateMachines).subscribe(subscriber) var result: Any? = subscriber.future if (result is Future<*>) { if (!result.isDone) { @@ -372,9 +411,7 @@ object InteractiveShell { } @JvmStatic - fun runRPCFromString(input: List, out: RenderPrintWriter, context: InvocationContext, cordaRPCOps: CordaRPCOps): Any? { - val parser = StringToMethodCallParser(CordaRPCOps::class.java, context.attributes["mapper"] as ObjectMapper) - + fun runRPCFromString(input: List, out: RenderPrintWriter, context: InvocationContext, 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 @@ -387,7 +424,8 @@ object InteractiveShell { var result: Any? = null try { InputStreamSerializer.invokeContext = context - val call = database.transaction { parser.parse(cordaRPCOps, cmd) } + 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) diff --git a/node/src/main/kotlin/net/corda/node/shell/InteractiveShellCommand.kt b/tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShellCommand.kt similarity index 57% rename from node/src/main/kotlin/net/corda/node/shell/InteractiveShellCommand.kt rename to tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShellCommand.kt index 9278fffb95..5538838c27 100644 --- a/node/src/main/kotlin/net/corda/node/shell/InteractiveShellCommand.kt +++ b/tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShellCommand.kt @@ -1,8 +1,5 @@ -package net.corda.node.shell +package net.corda.tools.shell -import com.fasterxml.jackson.databind.ObjectMapper -import net.corda.core.messaging.CordaRPCOps -import net.corda.node.services.api.ServiceHubInternal import org.crsh.command.BaseCommand import org.crsh.shell.impl.command.CRaSHSession @@ -12,6 +9,5 @@ import org.crsh.shell.impl.command.CRaSHSession 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 services() = context.attributes["services"] as ServiceHubInternal - fun objectMapper() = context.attributes["mapper"] as ObjectMapper + fun objectMapper() = ((context.session as CRaSHSession).authInfo as CordaSSHAuthInfo).yamlInputMapper } diff --git a/tools/shell/src/main/kotlin/net/corda/tools/shell/RPCOpsWithContext.kt b/tools/shell/src/main/kotlin/net/corda/tools/shell/RPCOpsWithContext.kt new file mode 100644 index 0000000000..bca4ad47c5 --- /dev/null +++ b/tools/shell/src/main/kotlin/net/corda/tools/shell/RPCOpsWithContext.kt @@ -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 +} diff --git a/tools/shell/src/main/kotlin/net/corda/tools/shell/StandaloneShell.kt b/tools/shell/src/main/kotlin/net/corda/tools/shell/StandaloneShell.kt new file mode 100644 index 0000000000..16613e27a1 --- /dev/null +++ b/tools/shell/src/main/kotlin/net/corda/tools/shell/StandaloneShell.kt @@ -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) { + + 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 = + 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) + } +} diff --git a/tools/shell/src/main/kotlin/net/corda/tools/shell/StandaloneShellArgsParser.kt b/tools/shell/src/main/kotlin/net/corda/tools/shell/StandaloneShellArgsParser.kt new file mode 100644 index 0000000000..736b922afc --- /dev/null +++ b/tools/shell/src/main/kotlin/net/corda/tools/shell/StandaloneShellArgsParser.kt @@ -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::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() + + 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() + 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 }) + } + } +} diff --git a/node/src/main/kotlin/net/corda/node/utilities/ANSIProgressRenderer.kt b/tools/shell/src/main/kotlin/net/corda/tools/shell/utlities/ANSIProgressRenderer.kt similarity index 99% rename from node/src/main/kotlin/net/corda/node/utilities/ANSIProgressRenderer.kt rename to tools/shell/src/main/kotlin/net/corda/tools/shell/utlities/ANSIProgressRenderer.kt index 74c8e077c4..4848ff5d47 100644 --- a/node/src/main/kotlin/net/corda/node/utilities/ANSIProgressRenderer.kt +++ b/tools/shell/src/main/kotlin/net/corda/tools/shell/utlities/ANSIProgressRenderer.kt @@ -1,4 +1,4 @@ -package net.corda.node.utilities +package net.corda.tools.shell.utlities import net.corda.core.internal.Emoji import net.corda.core.messaging.FlowProgressHandle diff --git a/node/src/main/resources/net/corda/node/shell/base/login.groovy b/tools/shell/src/main/resources/net/corda/tools/shell/base/login.groovy similarity index 90% rename from node/src/main/resources/net/corda/node/shell/base/login.groovy rename to tools/shell/src/main/resources/net/corda/tools/shell/base/login.groovy index f6df32b386..4aa1823a00 100644 --- a/node/src/main/resources/net/corda/node/shell/base/login.groovy +++ b/tools/shell/src/main/resources/net/corda/tools/shell/base/login.groovy @@ -1,4 +1,4 @@ -package net.corda.node.shell.base +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. diff --git a/node/src/test/kotlin/net/corda/node/shell/CustomTypeJsonParsingTests.kt b/tools/shell/src/test/kotlin/net/corda/tools/shell/CustomTypeJsonParsingTests.kt similarity index 98% rename from node/src/test/kotlin/net/corda/node/shell/CustomTypeJsonParsingTests.kt rename to tools/shell/src/test/kotlin/net/corda/tools/shell/CustomTypeJsonParsingTests.kt index bd9eee844d..7ab1718558 100644 --- a/node/src/test/kotlin/net/corda/node/shell/CustomTypeJsonParsingTests.kt +++ b/tools/shell/src/test/kotlin/net/corda/tools/shell/CustomTypeJsonParsingTests.kt @@ -1,4 +1,4 @@ -package net.corda.node.shell +package net.corda.tools.shell import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.databind.ObjectMapper diff --git a/node/src/test/kotlin/net/corda/node/InteractiveShellTest.kt b/tools/shell/src/test/kotlin/net/corda/tools/shell/InteractiveShellTest.kt similarity index 81% rename from node/src/test/kotlin/net/corda/node/InteractiveShellTest.kt rename to tools/shell/src/test/kotlin/net/corda/tools/shell/InteractiveShellTest.kt index 79a6769d40..033bd3ba8f 100644 --- a/node/src/test/kotlin/net/corda/node/InteractiveShellTest.kt +++ b/tools/shell/src/test/kotlin/net/corda/tools/shell/InteractiveShellTest.kt @@ -1,4 +1,4 @@ -package net.corda.node +package net.corda.tools.shell import com.fasterxml.jackson.dataformat.yaml.YAMLFactory import net.corda.client.jackson.JacksonSupport @@ -11,15 +11,9 @@ 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.nodeapi.internal.persistence.DatabaseConfig -import net.corda.node.shell.InteractiveShell -import net.corda.node.internal.configureDatabase +import net.corda.node.services.identity.InMemoryIdentityService +import net.corda.testing.internal.DEV_ROOT_CA import net.corda.testing.core.TestIdentity -import net.corda.testing.node.MockServices -import net.corda.testing.node.makeTestIdentityService -import net.corda.testing.internal.rigorousMock -import org.junit.After -import org.junit.Before import org.junit.Test import rx.Observable import java.util.* @@ -30,16 +24,6 @@ class InteractiveShellTest { private val megaCorp = TestIdentity(CordaX500Name("MegaCorp", "London", "GB")) } - @Before - fun setup() { - InteractiveShell.database = configureDatabase(MockServices.makeTestDataSourceProperties(), DatabaseConfig(), rigorousMock()) - } - - @After - fun shutdown() { - InteractiveShell.database.close() - } - @Suppress("UNUSED") class FlowA(val a: String) : FlowLogic() { constructor(b: Int?) : this(b.toString()) @@ -52,7 +36,7 @@ class InteractiveShellTest { override fun call() = a } - private val ids = makeTestIdentityService(megaCorp.identity) + 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) { diff --git a/tools/shell/src/test/kotlin/net/corda/tools/shell/StandaloneShellArgsParserTest.kt b/tools/shell/src/test/kotlin/net/corda/tools/shell/StandaloneShellArgsParserTest.kt new file mode 100644 index 0000000000..4ac40cf64c --- /dev/null +++ b/tools/shell/src/test/kotlin/net/corda/tools/shell/StandaloneShellArgsParserTest.kt @@ -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() + + 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) + } +} \ No newline at end of file diff --git a/tools/shell/src/test/resources/config.conf b/tools/shell/src/test/resources/config.conf new file mode 100644 index 0000000000..9a964cec18 --- /dev/null +++ b/tools/shell/src/test/resources/config.conf @@ -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" + } + } \ No newline at end of file