mirror of
https://github.com/corda/corda.git
synced 2024-12-19 21:17:58 +00:00
[CORDA-792] Standalone Shell (#2663)
- Existing embedded Shell connects via RPC including checking RPC user credentials (before was a direct use of CordaRPCOps): in dev mode when console terminal is enabled, node created `shell` user. - New Standalone Shell app with the same functionalities as Shell: connects to a node via RPC Client, can use SSL and run SSH server.
This commit is contained in:
parent
8fe94bca2d
commit
72074c76c7
3
.idea/compiler.xml
generated
3
.idea/compiler.xml
generated
@ -124,6 +124,9 @@
|
||||
<module name="samples_test" target="1.8" />
|
||||
<module name="sandbox_main" target="1.8" />
|
||||
<module name="sandbox_test" target="1.8" />
|
||||
<module name="shell_integrationTest" target="1.8" />
|
||||
<module name="shell_main" target="1.8" />
|
||||
<module name="shell_test" target="1.8" />
|
||||
<module name="simm-valuation-demo_integrationTest" target="1.8" />
|
||||
<module name="simm-valuation-demo_main" target="1.8" />
|
||||
<module name="simm-valuation-demo_test" target="1.8" />
|
||||
|
@ -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)
|
||||
|
@ -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'
|
||||
|
@ -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<CordaRPCOps>(
|
||||
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
|
||||
)
|
||||
|
||||
/**
|
||||
|
@ -10,4 +10,11 @@ fun createCordaRPCClientWithSsl(
|
||||
hostAndPort: NetworkHostAndPort,
|
||||
configuration: CordaRPCClientConfiguration = CordaRPCClientConfiguration.DEFAULT,
|
||||
sslConfiguration: SSLConfiguration? = null
|
||||
) = CordaRPCClient.createWithSsl(hostAndPort, configuration, sslConfiguration)
|
||||
) = 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)
|
@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
@ -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 ``<node root directory>/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
|
||||
---------------------------------------
|
||||
|
@ -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}"
|
||||
|
||||
|
@ -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
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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<String>) {
|
||||
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)
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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() }
|
@ -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<String>>(), AuthenticationPlugin<String> {
|
||||
|
||||
override fun getImplementation(): AuthenticationPlugin<String> = this
|
||||
|
||||
override fun getName(): String = "corda"
|
||||
|
||||
override fun authenticate(username: String?, credential: String?): AuthInfo {
|
||||
|
||||
if (username == null || credential == null) {
|
||||
return AuthInfo.UNSUCCESSFUL
|
||||
}
|
||||
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> = String::class.java
|
||||
}
|
@ -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
|
||||
}
|
@ -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<T>(val invocationContext: InvocationContext, val authorizingSubject: AuthorizingSubject, val block:() -> T): Thread() {
|
||||
|
||||
private var result: CompletableFuture<T> = 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<T> {
|
||||
start()
|
||||
join()
|
||||
return result
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
@ -278,7 +278,7 @@ open class InternalMockNetwork(private val cordappPackages: List<String>,
|
||||
return E2ETestKeyManagementService(identityService, keyPairs)
|
||||
}
|
||||
|
||||
override fun startShell(rpcOps: CordaRPCOps) {
|
||||
override fun startShell() {
|
||||
//No mock shell
|
||||
}
|
||||
|
||||
|
@ -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<String, UnsafeCertificate>
|
||||
@ -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())
|
||||
|
@ -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<String, String> {
|
||||
fun SSLConfiguration.useSslRpcOverrides(): Map<String, String> {
|
||||
return mapOf(
|
||||
"rpcSettings.useSsl" to "true",
|
||||
"rpcSettings.ssl.certificatesDirectory" to certificatesDirectory.toString(),
|
||||
@ -130,7 +129,7 @@ fun SslOptions.useSslRpcOverrides(): Map<String, String> {
|
||||
)
|
||||
}
|
||||
|
||||
fun SslOptions.noSslRpcOverrides(rpcAdminAddress: NetworkHostAndPort): Map<String, String> {
|
||||
fun SSLConfiguration.noSslRpcOverrides(rpcAdminAddress: NetworkHostAndPort): Map<String, String> {
|
||||
return mapOf(
|
||||
"rpcSettings.adminAddress" to rpcAdminAddress.toString(),
|
||||
"rpcSettings.useSsl" to "false",
|
||||
|
92
tools/shell/build.gradle
Normal file
92
tools/shell/build.gradle
Normal file
@ -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
|
||||
}
|
@ -0,0 +1,239 @@
|
||||
package net.corda.tools.shell
|
||||
|
||||
import com.google.common.io.Files
|
||||
import com.jcraft.jsch.ChannelExec
|
||||
import com.jcraft.jsch.JSch
|
||||
import net.corda.core.identity.CordaX500Name
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
import net.corda.core.utilities.getOrThrow
|
||||
import net.corda.node.services.Permissions
|
||||
import net.corda.node.services.Permissions.Companion.all
|
||||
import net.corda.testing.common.internal.withCertificates
|
||||
import net.corda.testing.common.internal.withKeyStores
|
||||
import net.corda.testing.core.ALICE_NAME
|
||||
import net.corda.testing.driver.DriverParameters
|
||||
import net.corda.testing.driver.driver
|
||||
import net.corda.testing.driver.internal.RandomFree
|
||||
import net.corda.testing.internal.useSslRpcOverrides
|
||||
import net.corda.testing.node.User
|
||||
import org.apache.activemq.artemis.api.core.ActiveMQNotConnectedException
|
||||
import org.apache.activemq.artemis.api.core.ActiveMQSecurityException
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.bouncycastle.util.io.Streams
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class InteractiveShellIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun `shell should not log in with invalid credentials`() {
|
||||
val user = User("u", "p", setOf())
|
||||
driver(DriverParameters(isDebug = true, startNodesInProcess = true, portAllocation = RandomFree)) {
|
||||
val nodeFuture = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user), startInSameProcess = true)
|
||||
val node = nodeFuture.getOrThrow()
|
||||
|
||||
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
|
||||
user = "fake", password = "fake",
|
||||
hostAndPort = node.rpcAddress)
|
||||
InteractiveShell.startShell(conf)
|
||||
|
||||
assertThatThrownBy { InteractiveShell.nodeInfo() }.isInstanceOf(ActiveMQSecurityException::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell should log in with valid crentials`() {
|
||||
val user = User("u", "p", setOf())
|
||||
driver {
|
||||
val nodeFuture = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user), startInSameProcess = true)
|
||||
val node = nodeFuture.getOrThrow()
|
||||
|
||||
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
|
||||
user = user.username, password = user.password,
|
||||
hostAndPort = node.rpcAddress)
|
||||
|
||||
InteractiveShell.startShell(conf)
|
||||
InteractiveShell.nodeInfo()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell should log in with ssl`() {
|
||||
val user = User("mark", "dadada", setOf(all()))
|
||||
withCertificates { server, client, createSelfSigned, createSignedBy ->
|
||||
val rootCertificate = createSelfSigned(CordaX500Name("SystemUsers/Node", "IT", "R3 London", "London", "London", "GB"))
|
||||
val markCertificate = createSignedBy(CordaX500Name("shell", "IT", "R3 London", "London", "London", "GB"), rootCertificate)
|
||||
|
||||
// truststore needs to contain root CA for how the driver works...
|
||||
server.keyStore["cordaclienttls"] = rootCertificate
|
||||
server.trustStore["cordaclienttls"] = rootCertificate
|
||||
server.trustStore["shell"] = markCertificate
|
||||
|
||||
client.keyStore["shell"] = markCertificate
|
||||
client.trustStore["cordaclienttls"] = rootCertificate
|
||||
|
||||
withKeyStores(server, client) { nodeSslOptions, clientSslOptions ->
|
||||
var successful = false
|
||||
driver(DriverParameters(isDebug = true, startNodesInProcess = true, portAllocation = RandomFree)) {
|
||||
startNode(rpcUsers = listOf(user), customOverrides = nodeSslOptions.useSslRpcOverrides()).getOrThrow().use { node ->
|
||||
|
||||
val sslConfiguration = ShellSslOptions(clientSslOptions.sslKeystore, clientSslOptions.keyStorePassword,
|
||||
clientSslOptions.trustStoreFile, clientSslOptions.trustStorePassword)
|
||||
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
|
||||
user = user.username, password = user.password,
|
||||
hostAndPort = node.rpcAddress,
|
||||
ssl = sslConfiguration)
|
||||
|
||||
InteractiveShell.startShell(conf)
|
||||
|
||||
InteractiveShell.nodeInfo()
|
||||
successful = true
|
||||
}
|
||||
}
|
||||
assertThat(successful).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell shoud not log in without ssl keystore`() {
|
||||
val user = User("mark", "dadada", setOf("ALL"))
|
||||
withCertificates { server, client, createSelfSigned, createSignedBy ->
|
||||
val rootCertificate = createSelfSigned(CordaX500Name("SystemUsers/Node", "IT", "R3 London", "London", "London", "GB"))
|
||||
val markCertificate = createSignedBy(CordaX500Name("shell", "IT", "R3 London", "London", "London", "GB"), rootCertificate)
|
||||
|
||||
// truststore needs to contain root CA for how the driver works...
|
||||
server.keyStore["cordaclienttls"] = rootCertificate
|
||||
server.trustStore["cordaclienttls"] = rootCertificate
|
||||
server.trustStore["shell"] = markCertificate
|
||||
|
||||
//client key store doesn't have "mark" certificate
|
||||
client.trustStore["cordaclienttls"] = rootCertificate
|
||||
|
||||
withKeyStores(server, client) { nodeSslOptions, clientSslOptions ->
|
||||
driver(DriverParameters(isDebug = true, startNodesInProcess = true, portAllocation = RandomFree)) {
|
||||
startNode(rpcUsers = listOf(user), customOverrides = nodeSslOptions.useSslRpcOverrides()).getOrThrow().use { node ->
|
||||
|
||||
val sslConfiguration = ShellSslOptions(clientSslOptions.sslKeystore, clientSslOptions.keyStorePassword,
|
||||
clientSslOptions.trustStoreFile, clientSslOptions.trustStorePassword)
|
||||
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
|
||||
user = user.username, password = user.password,
|
||||
hostAndPort = node.rpcAddress,
|
||||
ssl = sslConfiguration)
|
||||
|
||||
InteractiveShell.startShell(conf)
|
||||
|
||||
assertThatThrownBy { InteractiveShell.nodeInfo() }.isInstanceOf(ActiveMQNotConnectedException::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ssh runs flows via standalone shell`() {
|
||||
val user = User("u", "p", setOf(Permissions.startFlow<SSHServerTest.FlowICanRun>(),
|
||||
Permissions.invokeRpc(CordaRPCOps::registeredFlows),
|
||||
Permissions.invokeRpc(CordaRPCOps::nodeInfo)))
|
||||
driver {
|
||||
val nodeFuture = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user), startInSameProcess = true)
|
||||
val node = nodeFuture.getOrThrow()
|
||||
|
||||
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
|
||||
user = user.username, password = user.password,
|
||||
hostAndPort = node.rpcAddress,
|
||||
sshdPort = 2224)
|
||||
|
||||
InteractiveShell.startShell(conf)
|
||||
InteractiveShell.nodeInfo()
|
||||
|
||||
val session = JSch().getSession("u", "localhost", 2224)
|
||||
session.setConfig("StrictHostKeyChecking", "no")
|
||||
session.setPassword("p")
|
||||
session.connect()
|
||||
|
||||
assertTrue(session.isConnected)
|
||||
|
||||
val channel = session.openChannel("exec") as ChannelExec
|
||||
channel.setCommand("start FlowICanRun")
|
||||
channel.connect(5000)
|
||||
|
||||
assertTrue(channel.isConnected)
|
||||
|
||||
val response = String(Streams.readAll(channel.inputStream))
|
||||
|
||||
val linesWithDoneCount = response.lines().filter { line -> line.contains("Done") }
|
||||
|
||||
channel.disconnect()
|
||||
session.disconnect()
|
||||
|
||||
// There are ANSI control characters involved, so we want to avoid direct byte to byte matching.
|
||||
assertThat(linesWithDoneCount).hasSize(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ssh run flows via standalone shell over ssl to node`() {
|
||||
val user = User("mark", "dadada", setOf(Permissions.startFlow<SSHServerTest.FlowICanRun>(),
|
||||
Permissions.invokeRpc(CordaRPCOps::registeredFlows),
|
||||
Permissions.invokeRpc(CordaRPCOps::nodeInfo)/*all()*/))
|
||||
withCertificates { server, client, createSelfSigned, createSignedBy ->
|
||||
val rootCertificate = createSelfSigned(CordaX500Name("SystemUsers/Node", "IT", "R3 London", "London", "London", "GB"))
|
||||
val markCertificate = createSignedBy(CordaX500Name("shell", "IT", "R3 London", "London", "London", "GB"), rootCertificate)
|
||||
|
||||
// truststore needs to contain root CA for how the driver works...
|
||||
server.keyStore["cordaclienttls"] = rootCertificate
|
||||
server.trustStore["cordaclienttls"] = rootCertificate
|
||||
server.trustStore["shell"] = markCertificate
|
||||
|
||||
client.keyStore["shell"] = markCertificate
|
||||
client.trustStore["cordaclienttls"] = rootCertificate
|
||||
|
||||
withKeyStores(server, client) { nodeSslOptions, clientSslOptions ->
|
||||
var successful = false
|
||||
driver(DriverParameters(isDebug = true, startNodesInProcess = true, portAllocation = RandomFree)) {
|
||||
startNode(rpcUsers = listOf(user), customOverrides = nodeSslOptions.useSslRpcOverrides()).getOrThrow().use { node ->
|
||||
|
||||
val sslConfiguration = ShellSslOptions(clientSslOptions.sslKeystore, clientSslOptions.keyStorePassword,
|
||||
clientSslOptions.trustStoreFile, clientSslOptions.trustStorePassword)
|
||||
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
|
||||
user = user.username, password = user.password,
|
||||
hostAndPort = node.rpcAddress,
|
||||
ssl = sslConfiguration,
|
||||
sshdPort = 2223)
|
||||
|
||||
InteractiveShell.startShell(conf)
|
||||
InteractiveShell.nodeInfo()
|
||||
|
||||
val session = JSch().getSession("mark", "localhost", 2223)
|
||||
session.setConfig("StrictHostKeyChecking", "no")
|
||||
session.setPassword("dadada")
|
||||
session.connect()
|
||||
|
||||
assertTrue(session.isConnected)
|
||||
|
||||
val channel = session.openChannel("exec") as ChannelExec
|
||||
channel.setCommand("start FlowICanRun")
|
||||
channel.connect(5000)
|
||||
|
||||
assertTrue(channel.isConnected)
|
||||
|
||||
val response = String(Streams.readAll(channel.inputStream))
|
||||
|
||||
val linesWithDoneCount = response.lines().filter { line -> line.contains("Done") }
|
||||
|
||||
channel.disconnect()
|
||||
session.disconnect() // TODO Simon make sure to close them
|
||||
|
||||
// There are ANSI control characters involved, so we want to avoid direct byte to byte matching.
|
||||
assertThat(linesWithDoneCount).hasSize(1)
|
||||
|
||||
successful = true
|
||||
}
|
||||
}
|
||||
assertThat(successful).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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<FlowICanRun>()))
|
||||
val user = User("u", "p", setOf(startFlow<FlowICanRun>(),
|
||||
invokeRpc(CordaRPCOps::wellKnownPartyFromX500Name)))
|
||||
// The driver will automatically pick up the annotated flows below
|
||||
driver(DriverParameters(isDebug = true)) {
|
||||
val node = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user),
|
||||
@ -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)
|
||||
}
|
8
tools/shell/src/integration-test/resources/ssl.conf
Normal file
8
tools/shell/src/integration-test/resources/ssl.conf
Normal file
@ -0,0 +1,8 @@
|
||||
user=demo1
|
||||
baseDirectory="/Users/szymonsztuka/Documents/shell-config"
|
||||
hostAndPort="localhost:10006"
|
||||
sshdPort=2223
|
||||
ssl {
|
||||
keyStorePassword=password
|
||||
trustStorePassword=password
|
||||
}
|
@ -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<String> 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<String> 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
|
@ -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<Map> context, StringToMethodCallParser<CordaRPCOps> parser) {
|
@ -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<String> 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());
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package net.corda.tools.shell
|
||||
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
import net.corda.core.utilities.loggerFor
|
||||
import org.apache.activemq.artemis.api.core.ActiveMQSecurityException
|
||||
import org.crsh.auth.AuthInfo
|
||||
import org.crsh.auth.AuthenticationPlugin
|
||||
import org.crsh.plugin.CRaSHPlugin
|
||||
|
||||
class CordaAuthenticationPlugin(private val rpcOps: (username: String, credential: String) -> CordaRPCOps): CRaSHPlugin<AuthenticationPlugin<String>>(), AuthenticationPlugin<String> {
|
||||
|
||||
companion object {
|
||||
private val logger = loggerFor<CordaAuthenticationPlugin>()
|
||||
}
|
||||
|
||||
override fun getImplementation(): AuthenticationPlugin<String> = this
|
||||
|
||||
override fun getName(): String = "corda"
|
||||
|
||||
override fun authenticate(username: String?, credential: String?): AuthInfo {
|
||||
|
||||
if (username == null || credential == null) {
|
||||
return AuthInfo.UNSUCCESSFUL
|
||||
}
|
||||
try {
|
||||
val ops = rpcOps(username, credential)
|
||||
return CordaSSHAuthInfo(true, ops)
|
||||
} catch (e: ActiveMQSecurityException) {
|
||||
logger.warn(e.message)
|
||||
} catch (e: Exception) {
|
||||
logger.warn(e.message, e)
|
||||
}
|
||||
return AuthInfo.UNSUCCESSFUL
|
||||
}
|
||||
|
||||
override fun getCredentialType(): Class<String> = String::class.java
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
@ -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<Node>
|
||||
@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<Node>) {
|
||||
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<String,Any>()
|
||||
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<FlowLogic<*>> = uncheckedCast(Class.forName(matches.single()))
|
||||
val flowClazz: Class<FlowLogic<*>> = if (classLoader != null) {
|
||||
uncheckedCast(Class.forName(matches.single(), true, classLoader))
|
||||
} else {
|
||||
uncheckedCast(Class.forName(matches.single()))
|
||||
}
|
||||
try {
|
||||
// Show the progress tracker on the console until the flow completes or is interrupted with a
|
||||
// Ctrl-C keypress.
|
||||
val stateObservable = runFlowFromString({ clazz, args -> rpcOps.startTrackedFlowDynamic(clazz, *args) }, inputData, 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 <T> runFlowFromString(invoke: (Class<out FlowLogic<T>>, Array<out Any?>) -> FlowProgressHandle<T>,
|
||||
inputData: String,
|
||||
clazz: Class<out FlowLogic<T>>,
|
||||
om: ObjectMapper = yamlInputMapper): FlowProgressHandle<T> {
|
||||
om: ObjectMapper): FlowProgressHandle<T> {
|
||||
// For each constructor, attempt to parse the input data as a method call. Use the first that succeeds,
|
||||
// and keep track of the reasons we failed so we can print them out if no constructors are usable.
|
||||
val parser = StringToMethodCallParser(clazz, om)
|
||||
@ -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<String>, out: RenderPrintWriter, context: InvocationContext<out Any>, cordaRPCOps: CordaRPCOps): Any? {
|
||||
val parser = StringToMethodCallParser(CordaRPCOps::class.java, context.attributes["mapper"] as ObjectMapper)
|
||||
|
||||
fun runRPCFromString(input: List<String>, out: RenderPrintWriter, context: InvocationContext<out Any>, cordaRPCOps: CordaRPCOps, om: ObjectMapper): Any? {
|
||||
val cmd = input.joinToString(" ").trim { it <= ' ' }
|
||||
if (cmd.toLowerCase().startsWith("startflow")) {
|
||||
// The flow command provides better support and startFlow requires special handling anyway due to
|
||||
@ -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)
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -0,0 +1,110 @@
|
||||
package net.corda.tools.shell
|
||||
|
||||
import com.jcabi.manifests.Manifests
|
||||
import joptsimple.OptionException
|
||||
import net.corda.core.internal.*
|
||||
import org.fusesource.jansi.Ansi
|
||||
import org.fusesource.jansi.AnsiConsole
|
||||
import java.net.URL
|
||||
import java.net.URLClassLoader
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlin.streams.toList
|
||||
import java.io.IOException
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
|
||||
val argsParser = CommandLineOptionParser()
|
||||
val cmdlineOptions = try {
|
||||
argsParser.parse(*args)
|
||||
} catch (e: OptionException) {
|
||||
println("Invalid command line arguments: ${e.message}")
|
||||
argsParser.printHelp(System.out)
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
if (cmdlineOptions.help) {
|
||||
argsParser.printHelp(System.out)
|
||||
return
|
||||
}
|
||||
val config = try {
|
||||
cmdlineOptions.toConfig()
|
||||
} catch(e: Exception) {
|
||||
println("Configuration exception: ${e.message}")
|
||||
exitProcess(1)
|
||||
}
|
||||
StandaloneShell(config).run()
|
||||
}
|
||||
|
||||
class StandaloneShell(private val configuration: ShellConfiguration) {
|
||||
|
||||
private fun getCordappsInDirectory(cordappsDir: Path?): List<URL> =
|
||||
if (cordappsDir == null || !cordappsDir.exists()) {
|
||||
emptyList()
|
||||
} else {
|
||||
cordappsDir.list {
|
||||
it.filter { it.isRegularFile() && it.toString().endsWith(".jar") }.map { it.toUri().toURL() }.toList()
|
||||
}
|
||||
}
|
||||
|
||||
//Workaround in case console is not available
|
||||
@Throws(IOException::class)
|
||||
private fun readLine(format: String, vararg args: Any): String {
|
||||
if (System.console() != null) {
|
||||
return System.console().readLine(format, *args)
|
||||
}
|
||||
print(String.format(format, *args))
|
||||
val reader = BufferedReader(InputStreamReader(System.`in`))
|
||||
return reader.readLine()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun readPassword(format: String, vararg args: Any) =
|
||||
if (System.console() != null) System.console().readPassword(format, *args) else this.readLine(format, *args).toCharArray()
|
||||
|
||||
private fun getManifestEntry(key: String) = if (Manifests.exists(key)) Manifests.read(key) else "Unknown"
|
||||
|
||||
fun run() {
|
||||
val cordappJarPaths = getCordappsInDirectory(configuration.cordappsDirectory)
|
||||
val classLoader: ClassLoader = URLClassLoader(cordappJarPaths.toTypedArray(), javaClass.classLoader)
|
||||
with(configuration) {
|
||||
if (user.isNullOrEmpty()) {
|
||||
user = readLine("User:")
|
||||
}
|
||||
if (password.isNullOrEmpty()) {
|
||||
password = String(readPassword("Password:"))
|
||||
}
|
||||
}
|
||||
InteractiveShell.startShell(configuration, classLoader)
|
||||
try {
|
||||
//connecting to node by requesting node info to fail fast
|
||||
InteractiveShell.nodeInfo()
|
||||
} catch (e: Exception) {
|
||||
println("Cannot login to ${configuration.hostAndPort}, reason: \"${e.message}\"")
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
val exit = CountDownLatch(1)
|
||||
AnsiConsole.systemInstall()
|
||||
println(Ansi.ansi().fgBrightRed().a(
|
||||
""" ______ __""").newline().a(
|
||||
""" / ____/ _________/ /___ _""").newline().a(
|
||||
""" / / __ / ___/ __ / __ `/ """).newline().fgBrightRed().a(
|
||||
"""/ /___ /_/ / / / /_/ / /_/ /""").newline().fgBrightRed().a(
|
||||
"""\____/ /_/ \__,_/\__,_/""").reset().fgBrightDefault().bold()
|
||||
.newline().a("--- ${getManifestEntry("Corda-Vendor")} ${getManifestEntry("Corda-Release-Version")} (${getManifestEntry("Corda-Revision").take(7)}) ---")
|
||||
.newline()
|
||||
.newline().a("Standalone Shell connected to ${configuration.hostAndPort}")
|
||||
.reset())
|
||||
InteractiveShell.runLocalShell {
|
||||
exit.countDown()
|
||||
}
|
||||
configuration.sshdPort?.apply{ println("SSH server listening on port $this.") }
|
||||
|
||||
exit.await()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
@ -0,0 +1,226 @@
|
||||
package net.corda.tools.shell
|
||||
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import joptsimple.OptionParser
|
||||
import joptsimple.util.EnumConverter
|
||||
import net.corda.core.internal.div
|
||||
import net.corda.core.utilities.NetworkHostAndPort
|
||||
import net.corda.nodeapi.internal.config.parseAs
|
||||
import net.corda.tools.shell.ShellConfiguration.Companion.COMMANDS_DIR
|
||||
import org.slf4j.event.Level
|
||||
import java.io.PrintStream
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
|
||||
// NOTE: Do not use any logger in this class as args parsing is done before the logger is setup.
|
||||
class CommandLineOptionParser {
|
||||
private val optionParser = OptionParser()
|
||||
|
||||
private val configFileArg = optionParser
|
||||
.accepts("config-file", "The path to the shell configuration file, used instead of providing the rest of command line options.")
|
||||
.withOptionalArg()
|
||||
private val cordappsDirectoryArg = optionParser
|
||||
.accepts("cordpass-directory", "The path to directory containing Cordapps jars, Cordapps are require when starting flows.")
|
||||
.withOptionalArg()
|
||||
private val commandsDirectoryArg = optionParser
|
||||
.accepts("commands-directory", "The directory with additional CrAsH shell commands.")
|
||||
.withOptionalArg()
|
||||
private val hostArg = optionParser
|
||||
.acceptsAll(listOf("h","host"), "The host of the Corda node.")
|
||||
.withRequiredArg()
|
||||
private val portArg = optionParser
|
||||
.acceptsAll(listOf("p","port"), "The port of the Corda node.")
|
||||
.withRequiredArg()
|
||||
private val userArg = optionParser
|
||||
.accepts("user", "The RPC user name.")
|
||||
.withOptionalArg()
|
||||
private val passwordArg = optionParser
|
||||
.accepts("password", "The RPC user password.")
|
||||
.withOptionalArg()
|
||||
private val loggerLevel = optionParser
|
||||
.accepts("logging-level", "Enable logging at this level and higher.")
|
||||
.withRequiredArg()
|
||||
.withValuesConvertedBy(object : EnumConverter<Level>(Level::class.java) {})
|
||||
.defaultsTo(Level.INFO)
|
||||
private val sshdPortArg = optionParser
|
||||
.accepts("sshd-port", "Enables SSH server for shell.")
|
||||
.withOptionalArg()
|
||||
private val sshdHostKeyDirectoryArg = optionParser
|
||||
.accepts("sshd-hostkey-directory", "The directory with hostkey.pem file for SSH server.")
|
||||
.withOptionalArg()
|
||||
private val helpArg = optionParser
|
||||
.accepts("help")
|
||||
.forHelp()
|
||||
private val keyStorePasswordArg = optionParser
|
||||
.accepts("keystore-password", "The password to unlock the KeyStore file.")
|
||||
.withOptionalArg()
|
||||
private val keyStoreDirArg = optionParser
|
||||
.accepts("keystore-file", "The path to the KeyStore file.")
|
||||
.withOptionalArg()
|
||||
private val keyStoreTypeArg = optionParser
|
||||
.accepts("keystore-type", "The type of the KeyStore (e.g. JKS).")
|
||||
.withOptionalArg()
|
||||
private val trustStorePasswordArg = optionParser
|
||||
.accepts("truststore-password", "The password to unlock the TrustStore file.")
|
||||
.withOptionalArg()
|
||||
private val trustStoreDirArg = optionParser
|
||||
.accepts("truststore-file", "The path to the TrustStore file.")
|
||||
.withOptionalArg()
|
||||
private val trustStoreTypeArg = optionParser
|
||||
.accepts("truststore-type", "The type of the TrustStore (e.g. JKS).")
|
||||
.withOptionalArg()
|
||||
|
||||
fun parse(vararg args: String): CommandLineOptions {
|
||||
val optionSet = optionParser.parse(*args)
|
||||
return CommandLineOptions(
|
||||
configFile = optionSet.valueOf(configFileArg),
|
||||
host = optionSet.valueOf(hostArg),
|
||||
port = optionSet.valueOf(portArg),
|
||||
user = optionSet.valueOf(userArg),
|
||||
password = optionSet.valueOf(passwordArg),
|
||||
commandsDirectory = (optionSet.valueOf(commandsDirectoryArg))?.let { Paths.get(it).normalize().toAbsolutePath() },
|
||||
cordappsDirectory = (optionSet.valueOf(cordappsDirectoryArg))?.let { Paths.get(it).normalize().toAbsolutePath() },
|
||||
help = optionSet.has(helpArg),
|
||||
loggingLevel = optionSet.valueOf(loggerLevel),
|
||||
sshdPort = optionSet.valueOf(sshdPortArg),
|
||||
sshdHostKeyDirectory = (optionSet.valueOf(sshdHostKeyDirectoryArg))?.let { Paths.get(it).normalize().toAbsolutePath() },
|
||||
keyStorePassword = optionSet.valueOf(keyStorePasswordArg),
|
||||
trustStorePassword = optionSet.valueOf(trustStorePasswordArg),
|
||||
keyStoreFile = (optionSet.valueOf(keyStoreDirArg))?.let { Paths.get(it).normalize().toAbsolutePath() },
|
||||
trustStoreFile = (optionSet.valueOf(trustStoreDirArg))?.let { Paths.get(it).normalize().toAbsolutePath() },
|
||||
keyStoreType = optionSet.valueOf(keyStoreTypeArg),
|
||||
trustStoreType = optionSet.valueOf(trustStoreTypeArg))
|
||||
}
|
||||
|
||||
fun printHelp(sink: PrintStream) = optionParser.printHelpOn(sink)
|
||||
}
|
||||
|
||||
data class CommandLineOptions(val configFile: String?,
|
||||
val commandsDirectory: Path?,
|
||||
val cordappsDirectory: Path?,
|
||||
val host: String?,
|
||||
val port: String?,
|
||||
val user: String?,
|
||||
val password: String?,
|
||||
val help: Boolean,
|
||||
val loggingLevel: Level,
|
||||
val sshdPort: String?,
|
||||
val sshdHostKeyDirectory: Path?,
|
||||
val keyStorePassword: String?,
|
||||
val trustStorePassword: String?,
|
||||
val keyStoreFile: Path?,
|
||||
val trustStoreFile: Path?,
|
||||
val keyStoreType: String?,
|
||||
val trustStoreType: String?) {
|
||||
|
||||
private fun toConfigFile(): Config {
|
||||
val cmdOpts = mutableMapOf<String, Any?>()
|
||||
|
||||
commandsDirectory?.apply { cmdOpts["extensions.commands.path"] = this.toString() }
|
||||
cordappsDirectory?.apply { cmdOpts["extensions.cordapps.path"] = this.toString() }
|
||||
user?.apply { cmdOpts["node.user"] = this }
|
||||
password?.apply { cmdOpts["node.password"] = this }
|
||||
host?.apply { cmdOpts["node.addresses.rpc.host"] = this }
|
||||
port?.apply { cmdOpts["node.addresses.rpc.port"] = this }
|
||||
keyStoreFile?.apply { cmdOpts["ssl.keystore.path"] = this.toString() }
|
||||
keyStorePassword?.apply { cmdOpts["ssl.keystore.password"] = this }
|
||||
keyStoreType?.apply { cmdOpts["ssl.keystore.type"] = this }
|
||||
trustStoreFile?.apply { cmdOpts["ssl.truststore.path"] = this.toString() }
|
||||
trustStorePassword?.apply { cmdOpts["ssl.truststore.password"] = this }
|
||||
trustStoreType?.apply { cmdOpts["ssl.truststore.type"] = this }
|
||||
sshdPort?.apply {
|
||||
cmdOpts["extensions.sshd.port"] = this
|
||||
cmdOpts["extensions.sshd.enabled"] = true
|
||||
}
|
||||
sshdHostKeyDirectory?.apply { cmdOpts["extensions.sshd.hostkeypath"] = this.toString() }
|
||||
|
||||
return ConfigFactory.parseMap(cmdOpts)
|
||||
}
|
||||
|
||||
/** Return configuration parsed from an optional config file (provided by the command line option)
|
||||
* and then overridden by the command line options */
|
||||
fun toConfig(): ShellConfiguration {
|
||||
val fileConfig = configFile?.let { ConfigFactory.parseFile(Paths.get(configFile).toFile()) }
|
||||
?: ConfigFactory.empty()
|
||||
val typeSafeConfig = toConfigFile().withFallback(fileConfig).resolve()
|
||||
val shellConfigFile = typeSafeConfig.parseAs<ShellConfigurationFile.ShellConfigFile>()
|
||||
return shellConfigFile.toShellConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
/** Object representation of Shell configuration file */
|
||||
private class ShellConfigurationFile {
|
||||
data class Rpc(
|
||||
val host: String,
|
||||
val port: Int)
|
||||
|
||||
data class Addresses(
|
||||
val rpc: Rpc
|
||||
)
|
||||
|
||||
data class Node(
|
||||
val addresses: Addresses,
|
||||
val user: String?,
|
||||
val password: String?
|
||||
)
|
||||
|
||||
data class Cordapps(
|
||||
val path: String
|
||||
)
|
||||
|
||||
data class Sshd(
|
||||
val enabled: Boolean,
|
||||
val port: Int,
|
||||
val hostkeypath: String?
|
||||
)
|
||||
|
||||
data class Commands(
|
||||
val path: String
|
||||
)
|
||||
|
||||
data class Extensions(
|
||||
val cordapps: Cordapps,
|
||||
val sshd: Sshd,
|
||||
val commands: Commands?
|
||||
)
|
||||
|
||||
data class KeyStore(
|
||||
val path: String,
|
||||
val type: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
data class Ssl(
|
||||
val keystore: KeyStore,
|
||||
val truststore: KeyStore
|
||||
)
|
||||
|
||||
data class ShellConfigFile(
|
||||
val node: Node,
|
||||
val extensions: Extensions?,
|
||||
val ssl: Ssl?
|
||||
) {
|
||||
fun toShellConfiguration(): ShellConfiguration {
|
||||
|
||||
val sslOptions =
|
||||
ssl?.let {
|
||||
ShellSslOptions(
|
||||
sslKeystore = Paths.get(it.keystore.path),
|
||||
keyStorePassword = it.keystore.password,
|
||||
trustStoreFile = Paths.get(it.truststore.path),
|
||||
trustStorePassword = it.truststore.password)
|
||||
}
|
||||
|
||||
return ShellConfiguration(
|
||||
commandsDirectory = extensions?.commands?.let { Paths.get(it.path) } ?: Paths.get(".") / COMMANDS_DIR,
|
||||
cordappsDirectory = extensions?.cordapps?.let { Paths.get(it.path) },
|
||||
user = node.user ?: "",
|
||||
password = node.password ?: "",
|
||||
hostAndPort = NetworkHostAndPort(node.addresses.rpc.host, node.addresses.rpc.port),
|
||||
ssl = sslOptions,
|
||||
sshdPort = extensions?.sshd?.let { if (it.enabled) it.port else null },
|
||||
sshHostKeyDirectory = extensions?.sshd?.let { if (it.enabled && it.hostkeypath != null) Paths.get(it.hostkeypath) else null })
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
@ -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.
|
@ -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
|
@ -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<String>() {
|
||||
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) {
|
@ -0,0 +1,204 @@
|
||||
package net.corda.tools.shell
|
||||
|
||||
import net.corda.core.utilities.NetworkHostAndPort
|
||||
import org.junit.Test
|
||||
import org.slf4j.event.Level
|
||||
import java.nio.file.Paths
|
||||
import kotlin.test.assertEquals
|
||||
import java.io.File
|
||||
|
||||
class StandaloneShellArgsParserTest {
|
||||
|
||||
private val CONFIG_FILE = File(javaClass.classLoader.getResource("config.conf")!!.file)
|
||||
|
||||
@Test
|
||||
fun args_to_cmd_options() {
|
||||
|
||||
val args = arrayOf("--config-file", "/x/y/z/config.conf",
|
||||
"--commands-directory", "/x/y/commands",
|
||||
"--cordpass-directory", "/x/y/cordapps",
|
||||
"--host", "alocalhost",
|
||||
"--port", "1234",
|
||||
"--user", "demo",
|
||||
"--password", "abcd1234",
|
||||
"--logging-level", "DEBUG",
|
||||
"--sshd-port", "2223",
|
||||
"--sshd-hostkey-directory", "/x/y/ssh",
|
||||
"--help",
|
||||
"--keystore-password", "pass1",
|
||||
"--truststore-password", "pass2",
|
||||
"--keystore-file", "/x/y/keystore.jks",
|
||||
"--truststore-file", "/x/y/truststore.jks",
|
||||
"--truststore-type", "dummy",
|
||||
"--keystore-type", "JKS")
|
||||
|
||||
val expectedOptions = CommandLineOptions(configFile = "/x/y/z/config.conf",
|
||||
commandsDirectory = Paths.get("/x/y/commands"),
|
||||
cordappsDirectory = Paths.get("/x/y/cordapps"),
|
||||
host = "alocalhost",
|
||||
port = "1234",
|
||||
user = "demo",
|
||||
password = "abcd1234",
|
||||
help = true,
|
||||
loggingLevel = Level.DEBUG,
|
||||
sshdPort = "2223",
|
||||
sshdHostKeyDirectory = Paths.get("/x/y/ssh"),
|
||||
keyStorePassword = "pass1",
|
||||
trustStorePassword = "pass2",
|
||||
keyStoreFile = Paths.get("/x/y/keystore.jks"),
|
||||
trustStoreFile = Paths.get("/x/y/truststore.jks"),
|
||||
trustStoreType = "dummy",
|
||||
keyStoreType = "JKS")
|
||||
|
||||
val options = CommandLineOptionParser().parse(*args)
|
||||
|
||||
assertEquals(expectedOptions, options)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun empty_args_to_cmd_options() {
|
||||
val args = emptyArray<String>()
|
||||
|
||||
val expectedOptions = CommandLineOptions(configFile = null,
|
||||
commandsDirectory = null,
|
||||
cordappsDirectory = null,
|
||||
host = null,
|
||||
port = null,
|
||||
user = null,
|
||||
password = null,
|
||||
help = false,
|
||||
loggingLevel = Level.INFO,
|
||||
sshdPort = null,
|
||||
sshdHostKeyDirectory = null,
|
||||
keyStorePassword = null,
|
||||
trustStorePassword = null,
|
||||
keyStoreFile = null,
|
||||
trustStoreFile = null,
|
||||
trustStoreType = null,
|
||||
keyStoreType = null)
|
||||
|
||||
val options = CommandLineOptionParser().parse(*args)
|
||||
|
||||
assertEquals(expectedOptions, options)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun args_to_config() {
|
||||
|
||||
val options = CommandLineOptions(configFile = null,
|
||||
commandsDirectory = Paths.get("/x/y/commands"),
|
||||
cordappsDirectory = Paths.get("/x/y/cordapps"),
|
||||
host = "alocalhost",
|
||||
port = "1234",
|
||||
user = "demo",
|
||||
password = "abcd1234",
|
||||
help = true,
|
||||
loggingLevel = Level.DEBUG,
|
||||
sshdPort = "2223",
|
||||
sshdHostKeyDirectory = Paths.get("/x/y/ssh"),
|
||||
keyStorePassword = "pass1",
|
||||
trustStorePassword = "pass2",
|
||||
keyStoreFile = Paths.get("/x/y/keystore.jks"),
|
||||
trustStoreFile = Paths.get("/x/y/truststore.jks"),
|
||||
keyStoreType = "dummy",
|
||||
trustStoreType = "dummy"
|
||||
)
|
||||
|
||||
val expectedSsl = ShellSslOptions(sslKeystore = Paths.get("/x/y/keystore.jks"),
|
||||
keyStorePassword = "pass1",
|
||||
trustStoreFile = Paths.get("/x/y/truststore.jks"),
|
||||
trustStorePassword = "pass2")
|
||||
val expectedConfig = ShellConfiguration(
|
||||
commandsDirectory = Paths.get("/x/y/commands"),
|
||||
cordappsDirectory = Paths.get("/x/y/cordapps"),
|
||||
user = "demo",
|
||||
password = "abcd1234",
|
||||
hostAndPort = NetworkHostAndPort("alocalhost", 1234),
|
||||
ssl = expectedSsl,
|
||||
sshdPort = 2223,
|
||||
sshHostKeyDirectory = Paths.get("/x/y/ssh"),
|
||||
noLocalShell = false)
|
||||
|
||||
val config = options.toConfig()
|
||||
|
||||
assertEquals(expectedConfig, config)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acmd_options_to_config_from_file() {
|
||||
|
||||
val options = CommandLineOptions(configFile = CONFIG_FILE.absolutePath,
|
||||
commandsDirectory = null,
|
||||
cordappsDirectory = null,
|
||||
host = null,
|
||||
port = null,
|
||||
user = null,
|
||||
password = null,
|
||||
help = false,
|
||||
loggingLevel = Level.DEBUG,
|
||||
sshdPort = null,
|
||||
sshdHostKeyDirectory = null,
|
||||
keyStorePassword = null,
|
||||
trustStorePassword = null,
|
||||
keyStoreFile = null,
|
||||
trustStoreFile = null,
|
||||
keyStoreType = null,
|
||||
trustStoreType = null)
|
||||
|
||||
val expectedSsl = ShellSslOptions(sslKeystore = Paths.get("/x/y/keystore.jks"),
|
||||
keyStorePassword = "pass1",
|
||||
trustStoreFile = Paths.get("/x/y/truststore.jks"),
|
||||
trustStorePassword = "pass2")
|
||||
val expectedConfig = ShellConfiguration(
|
||||
commandsDirectory = Paths.get("/x/y/commands"),
|
||||
cordappsDirectory = Paths.get("/x/y/cordapps"),
|
||||
user = "demo",
|
||||
password = "abcd1234",
|
||||
hostAndPort = NetworkHostAndPort("alocalhost", 1234),
|
||||
ssl = expectedSsl,
|
||||
sshdPort = 2223)
|
||||
|
||||
val config = options.toConfig()
|
||||
|
||||
assertEquals(expectedConfig, config)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmd_options_override_config_from_file() {
|
||||
|
||||
val options = CommandLineOptions(configFile = CONFIG_FILE.absolutePath,
|
||||
commandsDirectory = null,
|
||||
cordappsDirectory = null,
|
||||
host = null,
|
||||
port = null,
|
||||
user = null,
|
||||
password = "blabla",
|
||||
help = false,
|
||||
loggingLevel = Level.DEBUG,
|
||||
sshdPort = null,
|
||||
sshdHostKeyDirectory = null,
|
||||
keyStorePassword = null,
|
||||
trustStorePassword = null,
|
||||
keyStoreFile = Paths.get("/x/y/cmd.jks"),
|
||||
trustStoreFile = null,
|
||||
keyStoreType = null,
|
||||
trustStoreType = null)
|
||||
|
||||
val expectedSsl = ShellSslOptions(sslKeystore = Paths.get("/x/y/cmd.jks"),
|
||||
keyStorePassword = "pass1",
|
||||
trustStoreFile = Paths.get("/x/y/truststore.jks"),
|
||||
trustStorePassword = "pass2")
|
||||
val expectedConfig = ShellConfiguration(
|
||||
commandsDirectory = Paths.get("/x/y/commands"),
|
||||
cordappsDirectory = Paths.get("/x/y/cordapps"),
|
||||
user = "demo",
|
||||
password = "blabla",
|
||||
hostAndPort = NetworkHostAndPort("alocalhost", 1234),
|
||||
ssl = expectedSsl,
|
||||
sshdPort = 2223)
|
||||
|
||||
val config = options.toConfig()
|
||||
|
||||
assertEquals(expectedConfig, config)
|
||||
}
|
||||
}
|
34
tools/shell/src/test/resources/config.conf
Normal file
34
tools/shell/src/test/resources/config.conf
Normal file
@ -0,0 +1,34 @@
|
||||
node {
|
||||
addresses {
|
||||
rpc {
|
||||
host : "alocalhost"
|
||||
port : 1234
|
||||
}
|
||||
}
|
||||
user : demo
|
||||
password : abcd1234
|
||||
}
|
||||
extensions {
|
||||
cordapps {
|
||||
path : "/x/y/cordapps"
|
||||
}
|
||||
sshd {
|
||||
enabled : "true"
|
||||
port : 2223
|
||||
}
|
||||
commands {
|
||||
path : /x/y/commands
|
||||
}
|
||||
}
|
||||
ssl {
|
||||
keystore {
|
||||
path : "/x/y/keystore.jks"
|
||||
type : "JKS"
|
||||
password : "pass1"
|
||||
}
|
||||
truststore {
|
||||
path : "/x/y/truststore.jks"
|
||||
type : "JKS"
|
||||
password : "pass2"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user