diff --git a/.idea/modules.xml b/.idea/modules.xml index 5517a9dc4a..63596a0a6f 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -21,6 +21,9 @@ + + + @@ -34,4 +37,4 @@ - \ No newline at end of file + diff --git a/core/src/main/kotlin/com/r3corda/core/crypto/X509Utilities.kt b/core/src/main/kotlin/com/r3corda/core/crypto/X509Utilities.kt index 3c7164800c..0bad95ea41 100644 --- a/core/src/main/kotlin/com/r3corda/core/crypto/X509Utilities.kt +++ b/core/src/main/kotlin/com/r3corda/core/crypto/X509Utilities.kt @@ -1,6 +1,7 @@ package com.r3corda.core.crypto import com.r3corda.core.random63BitValue +import com.r3corda.core.use import org.bouncycastle.asn1.ASN1Encodable import org.bouncycastle.asn1.ASN1EncodableVector import org.bouncycastle.asn1.DERSequence @@ -16,9 +17,14 @@ import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder import org.bouncycastle.jce.provider.BouncyCastleProvider import org.bouncycastle.openssl.jcajce.JcaPEMWriter import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import org.bouncycastle.pkcs.PKCS10CertificationRequest +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder import org.bouncycastle.util.IPAddress import org.bouncycastle.util.io.pem.PemReader -import java.io.* +import java.io.ByteArrayInputStream +import java.io.FileReader +import java.io.FileWriter +import java.io.InputStream import java.math.BigInteger import java.net.InetAddress import java.nio.file.Files @@ -41,10 +47,14 @@ object X509Utilities { val ECDSA_CURVE = "secp256r1" val KEYSTORE_TYPE = "JKS" - val CA_CERT_ALIAS = "CA Cert" - val CERT_PRIVATE_KEY_ALIAS = "Server Private Key" - val ROOT_CA_CERT_PRIVATE_KEY_ALIAS = "Root CA Private Key" - val INTERMEDIATE_CA_PRIVATE_KEY_ALIAS = "Intermediate CA Private Key" + + // Aliases for private keys and certificates. + val CORDA_ROOT_CA_PRIVATE_KEY = "cordarootcaprivatekey" + val CORDA_ROOT_CA = "cordarootca" + val CORDA_INTERMEDIATE_CA_PRIVATE_KEY = "cordaintermediatecaprivatekey" + val CORDA_INTERMEDIATE_CA = "cordaintermediateca" + val CORDA_CLIENT_CA_PRIVATE_KEY = "cordaclientcaprivatekey" + val CORDA_CLIENT_CA = "cordaclientca" init { Security.addProvider(BouncyCastleProvider()) // register Bouncy Castle Crypto Provider required to sign certificates @@ -108,8 +118,15 @@ object X509Utilities { return nameBuilder.build() } + fun getX509Name(myLegalName: String, nearestCity: String, email: String): X500Name { + return X500NameBuilder(BCStyle.INSTANCE) + .addRDN(BCStyle.CN, myLegalName) + .addRDN(BCStyle.L, nearestCity) + .addRDN(BCStyle.E, email).build() + } + /** - * Helper method to either open an existing keystore for modification, or create a new blank keystore + * Helper method to either open an existing keystore for modification, or create a new blank keystore. * @param keyStoreFilePath location of KeyStore file * @param storePassword password to open the store. This does not have to be the same password as any keys stored, * but for SSL purposes this is recommended. @@ -119,13 +136,10 @@ object X509Utilities { val pass = storePassword.toCharArray() val keyStore = KeyStore.getInstance(KEYSTORE_TYPE) if (Files.exists(keyStoreFilePath)) { - val input = FileInputStream(keyStoreFilePath.toFile()) - input.use { - keyStore.load(input, pass) - } + keyStoreFilePath.use { keyStore.load(it, pass) } } else { keyStore.load(null, pass) - val output = FileOutputStream(keyStoreFilePath.toFile()) + val output = Files.newOutputStream(keyStoreFilePath) output.use { keyStore.store(output, pass) } @@ -143,10 +157,7 @@ object X509Utilities { fun loadKeyStore(keyStoreFilePath: Path, storePassword: String): KeyStore { val pass = storePassword.toCharArray() val keyStore = KeyStore.getInstance(KEYSTORE_TYPE) - val input = FileInputStream(keyStoreFilePath.toFile()) - input.use { - keyStore.load(input, pass) - } + keyStoreFilePath.use { keyStore.load(it, pass) } return keyStore } @@ -175,7 +186,7 @@ object X509Utilities { */ fun saveKeyStore(keyStore: KeyStore, keyStoreFilePath: Path, storePassword: String) { val pass = storePassword.toCharArray() - val output = FileOutputStream(keyStoreFilePath.toFile()) + val output = Files.newOutputStream(keyStoreFilePath) output.use { keyStore.store(output, pass) } @@ -189,7 +200,7 @@ object X509Utilities { * but for SSL purposes this is recommended. * @param chain the sequence of certificates starting with the public key certificate for this key and extending to the root CA cert */ - private fun KeyStore.addOrReplaceKey(alias: String, key: Key, password: CharArray, chain: Array) { + fun KeyStore.addOrReplaceKey(alias: String, key: Key, password: CharArray, chain: Array) { try { this.deleteEntry(alias) } catch (kse: KeyStoreException) { @@ -203,7 +214,7 @@ object X509Utilities { * @param alias name to record the public certificate under * @param cert certificate to store */ - private fun KeyStore.addOrReplaceCertificate(alias: String, cert: Certificate) { + fun KeyStore.addOrReplaceCertificate(alias: String, cert: Certificate) { try { this.deleteEntry(alias) } catch (kse: KeyStoreException) { @@ -224,6 +235,24 @@ object X509Utilities { return keyGen.generateKeyPair() } + /** + * Create certificate signing request using provided information. + * + * @param myLegalName The legal name of your organization. This should not be abbreviated and should include suffixes such as Inc, Corp, or LLC. + * @param nearestCity The city where your organization is located. + * @param email An email address used to contact your organization. + * @param keyPair Standard curve ECDSA KeyPair generated for TLS. + * @return The generated Certificate signing request. + */ + fun createCertificateSigningRequest(myLegalName: String, nearestCity: String, email: String, keyPair: KeyPair): PKCS10CertificationRequest { + val subject = getX509Name(myLegalName, nearestCity, email) + val signer = JcaContentSignerBuilder(SIGNATURE_ALGORITHM) + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .build(keyPair.private) + return JcaPKCS10CertificationRequestBuilder(subject, keyPair.public).build(signer) + } + + /** * Helper data class to pass around public certificate and KeyPair entities when using CA certs */ @@ -236,10 +265,10 @@ object X509Utilities { * @return A data class is returned containing the new root CA Cert and its KeyPair for signing downstream certificates. * Note the generated certificate tree is capped at max depth of 2 to be in line with commercially available certificates */ - fun createSelfSignedCACert(domain: String): CACertAndKey { + fun createSelfSignedCACert(myLegalName: String): CACertAndKey { val keyPair = generateECDSAKeyPairForSSL() - val issuer = getDevX509Name(domain) + val issuer = getDevX509Name(myLegalName) val serial = BigInteger.valueOf(random63BitValue()) val subject = issuer val pubKey = keyPair.public @@ -292,7 +321,7 @@ object X509Utilities { // Ten year certificate validity // TODO how do we manage certificate expiry, revocation and loss - val window = getCertificateValidityWindow(0, 365*10, certificateAuthority.certificate.notBefore, certificateAuthority.certificate.notAfter) + val window = getCertificateValidityWindow(0, 365 * 10, certificateAuthority.certificate.notBefore, certificateAuthority.certificate.notAfter) val builder = JcaX509v3CertificateBuilder( issuer, serial, window.first, window.second, subject, pubKey) @@ -341,7 +370,7 @@ object X509Utilities { // Ten year certificate validity // TODO how do we manage certificate expiry, revocation and loss - val window = getCertificateValidityWindow(0, 365*10, certificateAuthority.certificate.notBefore, certificateAuthority.certificate.notAfter) + val window = getCertificateValidityWindow(0, 365 * 10, certificateAuthority.certificate.notBefore, certificateAuthority.certificate.notAfter) val builder = JcaX509v3CertificateBuilder(issuer, serial, window.first, window.second, subject, publicKey) builder.addExtension(Extension.subjectKeyIdentifier, false, createSubjectKeyIdentifier(publicKey)) @@ -420,7 +449,7 @@ object X509Utilities { } /** - * Extract public and private keys from a KeyStore file assuming storage alias is know + * Extract public and private keys from a KeyStore file assuming storage alias is known. * @param keyStoreFilePath Path to load KeyStore from * @param storePassword Password to unlock the KeyStore * @param keyPassword Password to unlock the private key entries @@ -437,6 +466,32 @@ object X509Utilities { return KeyPair(certificate.publicKey, keyEntry) } + /** + * Extract public and private keys from a KeyStore file assuming storage alias is known, or + * create a new pair of keys using the provided function if the keys not exist. + * @param keyStoreFilePath Path to load KeyStore from + * @param storePassword Password to unlock the KeyStore + * @param keyPassword Password to unlock the private key entries + * @param alias The name to lookup the Key and Certificate chain from + * @param keyGenerator Function for generating new keys + * @return The KeyPair found in the KeyStore under the specified alias + */ + fun loadOrCreateKeyPairFromKeyStore(keyStoreFilePath: Path, storePassword: String, keyPassword: String, + alias: String, keyGenerator: () -> CACertAndKey): KeyPair { + val keyStore = X509Utilities.loadKeyStore(keyStoreFilePath, storePassword) + if (!keyStore.containsAlias(alias)) { + val selfSignCert = keyGenerator() + // Save to the key store. + keyStore.addOrReplaceKey(alias, selfSignCert.keypair.private, keyPassword.toCharArray(), arrayOf(selfSignCert.certificate)) + X509Utilities.saveKeyStore(keyStore, keyStoreFilePath, storePassword) + } + + val certificate = keyStore.getCertificate(alias) + val keyEntry = keyStore.getKey(alias, keyPassword.toCharArray()) + + return KeyPair(certificate.publicKey, keyEntry as PrivateKey) + } + /** * Extract public X509 certificate from a KeyStore file assuming storage alias is know * @param keyStoreFilePath Path to load KeyStore from @@ -475,9 +530,9 @@ object X509Utilities { val keypass = keyPassword.toCharArray() val keyStore = loadOrCreateKeyStore(keyStoreFilePath, storePassword) - keyStore.addOrReplaceKey(ROOT_CA_CERT_PRIVATE_KEY_ALIAS, rootCA.keypair.private, keypass, arrayOf(rootCA.certificate)) + keyStore.addOrReplaceKey(CORDA_ROOT_CA_PRIVATE_KEY, rootCA.keypair.private, keypass, arrayOf(rootCA.certificate)) - keyStore.addOrReplaceKey(INTERMEDIATE_CA_PRIVATE_KEY_ALIAS, + keyStore.addOrReplaceKey(CORDA_INTERMEDIATE_CA_PRIVATE_KEY, intermediateCA.keypair.private, keypass, arrayOf(intermediateCA.certificate, rootCA.certificate)) @@ -486,7 +541,8 @@ object X509Utilities { val trustStore = loadOrCreateKeyStore(trustStoreFilePath, trustStorePassword) - trustStore.addOrReplaceCertificate(CA_CERT_ALIAS, rootCA.certificate) + trustStore.addOrReplaceCertificate(CORDA_ROOT_CA, rootCA.certificate) + trustStore.addOrReplaceCertificate(CORDA_INTERMEDIATE_CA, intermediateCA.certificate) saveKeyStore(trustStore, trustStoreFilePath, trustStorePassword) @@ -527,10 +583,10 @@ object X509Utilities { caKeyPassword: String): KeyStore { val rootCA = X509Utilities.loadCertificateAndKey(caKeyStore, caKeyPassword, - X509Utilities.ROOT_CA_CERT_PRIVATE_KEY_ALIAS) + X509Utilities.CORDA_ROOT_CA_PRIVATE_KEY) val intermediateCA = X509Utilities.loadCertificateAndKey(caKeyStore, caKeyPassword, - X509Utilities.INTERMEDIATE_CA_PRIVATE_KEY_ALIAS) + X509Utilities.CORDA_INTERMEDIATE_CA_PRIVATE_KEY) val serverKey = X509Utilities.generateECDSAKeyPairForSSL() val host = InetAddress.getLocalHost() @@ -538,18 +594,18 @@ object X509Utilities { val serverCert = X509Utilities.createServerCert(subject, serverKey.public, intermediateCA, - if(host.canonicalHostName == host.hostName) listOf() else listOf(host.hostName), + if (host.canonicalHostName == host.hostName) listOf() else listOf(host.hostName), listOf(host.hostAddress)) val keypass = keyPassword.toCharArray() val keyStore = loadOrCreateKeyStore(keyStoreFilePath, storePassword) - keyStore.addOrReplaceKey(CERT_PRIVATE_KEY_ALIAS, + keyStore.addOrReplaceKey(CORDA_CLIENT_CA_PRIVATE_KEY, serverKey.private, keypass, arrayOf(serverCert, intermediateCA.certificate, rootCA.certificate)) - keyStore.addOrReplaceCertificate(CA_CERT_ALIAS, rootCA.certificate) + keyStore.addOrReplaceCertificate(CORDA_CLIENT_CA, serverCert) saveKeyStore(keyStore, keyStoreFilePath, storePassword) diff --git a/core/src/main/kotlin/com/r3corda/core/node/services/NetworkMapCache.kt b/core/src/main/kotlin/com/r3corda/core/node/services/NetworkMapCache.kt index 3235eca87e..ce4b8239f3 100644 --- a/core/src/main/kotlin/com/r3corda/core/node/services/NetworkMapCache.kt +++ b/core/src/main/kotlin/com/r3corda/core/node/services/NetworkMapCache.kt @@ -1,5 +1,6 @@ package com.r3corda.core.node.services +import com.google.common.annotations.VisibleForTesting import com.google.common.util.concurrent.ListenableFuture import com.r3corda.core.contracts.Contract import com.r3corda.core.crypto.Party @@ -32,6 +33,8 @@ interface NetworkMapCache { val partyNodes: List /** Tracks changes to the network map cache */ val changed: Observable + /** Future to track completion of the NetworkMapService registration. */ + val mapServiceRegistered: ListenableFuture /** * A list of nodes that advertise a regulatory service. Identifying the correct regulator for a trade is outside @@ -97,6 +100,12 @@ interface NetworkMapCache { * @param service the network map service to fetch current state from. */ fun deregisterForUpdates(net: MessagingService, service: NodeInfo): ListenableFuture + + /** + * For testing where the network map cache is manipulated marks the service as immediately ready. + */ + @VisibleForTesting + fun runWithoutMapService() } sealed class NetworkCacheError : Exception() { diff --git a/core/src/test/kotlin/com/r3corda/core/crypto/X509UtilitiesTest.kt b/core/src/test/kotlin/com/r3corda/core/crypto/X509UtilitiesTest.kt index b95ffe0d60..e046f3743a 100644 --- a/core/src/test/kotlin/com/r3corda/core/crypto/X509UtilitiesTest.kt +++ b/core/src/test/kotlin/com/r3corda/core/crypto/X509UtilitiesTest.kt @@ -97,9 +97,9 @@ class X509UtilitiesTest { // Load back generated root CA Cert and private key from keystore and check against copy in truststore val keyStore = X509Utilities.loadKeyStore(tmpKeyStore, "keystorepass") val trustStore = X509Utilities.loadKeyStore(tmpTrustStore, "trustpass") - val rootCaCert = keyStore.getCertificate(X509Utilities.ROOT_CA_CERT_PRIVATE_KEY_ALIAS) as X509Certificate - val rootCaPrivateKey = keyStore.getKey(X509Utilities.ROOT_CA_CERT_PRIVATE_KEY_ALIAS, "keypass".toCharArray()) as PrivateKey - val rootCaFromTrustStore = trustStore.getCertificate(X509Utilities.CA_CERT_ALIAS) as X509Certificate + val rootCaCert = keyStore.getCertificate(X509Utilities.CORDA_ROOT_CA_PRIVATE_KEY) as X509Certificate + val rootCaPrivateKey = keyStore.getKey(X509Utilities.CORDA_ROOT_CA_PRIVATE_KEY, "keypass".toCharArray()) as PrivateKey + val rootCaFromTrustStore = trustStore.getCertificate(X509Utilities.CORDA_ROOT_CA) as X509Certificate assertEquals(rootCaCert, rootCaFromTrustStore) rootCaCert.checkValidity(Date()) rootCaCert.verify(rootCaCert.publicKey) @@ -116,8 +116,8 @@ class X509UtilitiesTest { assertTrue { caVerifier.verify(caSignature) } // Load back generated intermediate CA Cert and private key - val intermediateCaCert = keyStore.getCertificate(X509Utilities.INTERMEDIATE_CA_PRIVATE_KEY_ALIAS) as X509Certificate - val intermediateCaCertPrivateKey = keyStore.getKey(X509Utilities.INTERMEDIATE_CA_PRIVATE_KEY_ALIAS, "keypass".toCharArray()) as PrivateKey + val intermediateCaCert = keyStore.getCertificate(X509Utilities.CORDA_INTERMEDIATE_CA_PRIVATE_KEY) as X509Certificate + val intermediateCaCertPrivateKey = keyStore.getKey(X509Utilities.CORDA_INTERMEDIATE_CA_PRIVATE_KEY, "keypass".toCharArray()) as PrivateKey intermediateCaCert.checkValidity(Date()) intermediateCaCert.verify(rootCaCert.publicKey) @@ -148,14 +148,14 @@ class X509UtilitiesTest { // Load signing intermediate CA cert val caKeyStore = X509Utilities.loadKeyStore(tmpCAKeyStore, "cakeystorepass") - val caCertAndKey = X509Utilities.loadCertificateAndKey(caKeyStore, "cakeypass", X509Utilities.INTERMEDIATE_CA_PRIVATE_KEY_ALIAS) + val caCertAndKey = X509Utilities.loadCertificateAndKey(caKeyStore, "cakeypass", X509Utilities.CORDA_INTERMEDIATE_CA_PRIVATE_KEY) // Generate server cert and private key and populate another keystore suitable for SSL X509Utilities.createKeystoreForSSL(tmpServerKeyStore, "serverstorepass", "serverkeypass", caKeyStore, "cakeypass") // Load back server certificate val serverKeyStore = X509Utilities.loadKeyStore(tmpServerKeyStore, "serverstorepass") - val serverCertAndKey = X509Utilities.loadCertificateAndKey(serverKeyStore, "serverkeypass", X509Utilities.CERT_PRIVATE_KEY_ALIAS) + val serverCertAndKey = X509Utilities.loadCertificateAndKey(serverKeyStore, "serverkeypass", X509Utilities.CORDA_CLIENT_CA_PRIVATE_KEY) serverCertAndKey.certificate.checkValidity(Date()) serverCertAndKey.certificate.verify(caCertAndKey.certificate.publicKey) diff --git a/docs/source/index.rst b/docs/source/index.rst index c34068fa09..e1372461c4 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -71,7 +71,7 @@ Read on to learn: release-process release-notes - visualiser + network-simulator codestyle building-the-docs diff --git a/docs/source/network-simulator.png b/docs/source/network-simulator.png new file mode 100644 index 0000000000..3706064338 Binary files /dev/null and b/docs/source/network-simulator.png differ diff --git a/docs/source/network-simulator.rst b/docs/source/network-simulator.rst new file mode 100644 index 0000000000..93dba16ccb --- /dev/null +++ b/docs/source/network-simulator.rst @@ -0,0 +1,41 @@ +Network Simulator +================= + +A network simulator is provided which shows traffic between nodes through the lifecycle of an interest rate swap +contract. It can optionally also show network setup, during which nodes register themselves with the network +map service and are notified of the changes to the map. The network simulator is run from the command line via Gradle: + +**Windows**:: + + gradlew.bat network-simulator:run + +**Other**:: + + ./gradlew network-simulator:run + +Interface +--------- + +.. image:: network-simulator.png + +The network simulator can be run automatically, or stepped manually through each step of the interest rate swap. The +options on the simulator window are: + +Simulate initialisation + If checked, the nodes registering with the network map is shown. Normally this setup step + is not shown, but may be of interest to understand the details of node discovery. +Run + Runs the network simulation in automatic mode, in which it progresses each step on a timed basis. Once running, + the simulation can be paused in order to manually progress it, or reset. +Next + Manually progress the simulation to the next step. +Reset + Reset the simulation (only available when paused). +Map/Circle + How the nodes are shown, by default nodes are rendered on a world map, but alternatively they can rendered + in a circle layout. + +While the simulation runs, details of the steps currently being executed are shown in a sidebar on the left hand side +of the window. + +.. TODO: Add documentation on how to use with different contracts for testing/debugging diff --git a/docs/source/visualiser.png b/docs/source/visualiser.png deleted file mode 100644 index 51a48000d0..0000000000 Binary files a/docs/source/visualiser.png and /dev/null differ diff --git a/docs/source/visualiser.rst b/docs/source/visualiser.rst deleted file mode 100644 index e5167cb025..0000000000 --- a/docs/source/visualiser.rst +++ /dev/null @@ -1,78 +0,0 @@ -.. highlight:: kotlin -.. raw:: html - - - - -Using the visualiser -==================== - -In order to assist with understanding of the state model, the repository includes a simple graph visualiser. The -visualiser is integrated with the unit test framework and the same domain specific language. It is currently very -early and the diagrams it produces are not especially beautiful. The intention is to improve it in future releases. - -.. image:: visualiser.png - -An example of how to use it can be seen in ``src/test/kotlin/contracts/CommercialPaperTests.kt``. - -Briefly, define a set of transactions in a group using the same DSL that is used in the unit tests. Here's an example -of a trade lifecycle using the commercial paper contract - -.. container:: codeset - - .. sourcecode:: kotlin - - val group: TransactionGroupDSL = transactionGroupFor() { - roots { - transaction(900.DOLLARS.CASH `owned by` ALICE label "alice's $900") - transaction(someProfits.CASH `owned by` MEGA_CORP_PUBKEY label "some profits") - } - - // Some CP is issued onto the ledger by MegaCorp. - transaction("Issuance") { - output("paper") { PAPER_1 } - arg(MEGA_CORP_PUBKEY) { CommercialPaper.Commands.Issue() } - } - - // The CP is sold to alice for her $900, $100 less than the face value. At 10% interest after only 7 days, - // that sounds a bit too good to be true! - transaction("Trade") { - input("paper") - input("alice's $900") - output("borrowed $900") { 900.DOLLARS.CASH `owned by` MEGA_CORP_PUBKEY } - output("alice's paper") { "paper".output `owned by` ALICE } - arg(ALICE) { Cash.Commands.Move() } - arg(MEGA_CORP_PUBKEY) { CommercialPaper.Commands.Move() } - } - - // Time passes, and Alice redeem's her CP for $1000, netting a $100 profit. MegaCorp has received $1200 - // as a single payment from somewhere and uses it to pay Alice off, keeping the remaining $200 as change. - transaction("Redemption", redemptionTime) { - input("alice's paper") - input("some profits") - - output("Alice's profit") { aliceGetsBack.CASH `owned by` ALICE } - output("Change") { (someProfits - aliceGetsBack).CASH `owned by` MEGA_CORP_PUBKEY } - if (!destroyPaperAtRedemption) - output { "paper".output } - - arg(MEGA_CORP_PUBKEY) { Cash.Commands.Move() } - arg(ALICE) { CommercialPaper.Commands.Redeem() } - } - } - -Now you can define a main method in your unit test class that takes the ``TransactionGroupDSL`` object and uses it: - -.. container:: codeset - - .. sourcecode:: kotlin - - CommercialPaperTests().trade().visualise() - -This will open up a window with the following features: - -* The nodes can be dragged around to try and obtain a better layout (an improved layout algorithm will be a future - feature). -* States are rendered as circles. Transactions are small blue squares. Commands are small diamonds. -* Clicking a state will open up a window that shows its fields. - diff --git a/network-simulator/build.gradle b/network-simulator/build.gradle new file mode 100644 index 0000000000..e147512b57 --- /dev/null +++ b/network-simulator/build.gradle @@ -0,0 +1,59 @@ +buildscript { + repositories { + mavenCentral() + maven { + url 'http://oss.sonatype.org/content/repositories/snapshots' + } + } + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +apply plugin: 'java' +apply plugin: 'kotlin' +apply plugin: 'application' +apply plugin: 'us.kirchmeier.capsule' + +group 'com.r3cev.prototyping' +version '1.0-SNAPSHOT' + +sourceCompatibility = 1.5 + +repositories { + mavenLocal() + mavenCentral() + jcenter() + maven { + url 'http://oss.sonatype.org/content/repositories/snapshots' + } + maven { + url 'https://dl.bintray.com/kotlin/exposed' + } +} + +applicationDefaultJvmArgs = ["-javaagent:${rootProject.configurations.quasar.singleFile}"] +mainClassName = 'com.r3cev.corda.netmap.NetworkMapVisualiserKt' + +dependencies { + compile project(":core") + compile project(":node") + compile project(":contracts") + compile rootProject + + // GraphStream: For visualisation + compile "org.graphstream:gs-core:1.3" + compile "org.graphstream:gs-ui:1.3" + + compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + testCompile group: 'junit', name: 'junit', version: '4.11' +} + +task capsule(type: FatCapsule) { + applicationClass 'com.r3cev.corda.netmap.NetworkExplorerKt' + reallyExecutable + capsuleManifest { + minJavaVersion = '1.8.0' + javaAgents = [rootProject.configurations.quasar.singleFile.name] + } +} diff --git a/network-simulator/src/main/kotlin/com/r3cev/corda/netmap/NetworkMapVisualiser.kt b/network-simulator/src/main/kotlin/com/r3cev/corda/netmap/NetworkMapVisualiser.kt new file mode 100644 index 0000000000..2cc862ab6c --- /dev/null +++ b/network-simulator/src/main/kotlin/com/r3cev/corda/netmap/NetworkMapVisualiser.kt @@ -0,0 +1,358 @@ +/* + * Copyright 2015 Distributed Ledger Group LLC. Distributed as Licensed Company IP to DLG Group Members + * pursuant to the August 7, 2015 Advisory Services Agreement and subject to the Company IP License terms + * set forth therein. + * + * All other rights reserved. + */ + +package com.r3cev.corda.netmap + +import com.r3corda.core.messaging.SingleMessageRecipient +import com.r3corda.core.then +import com.r3corda.core.utilities.ProgressTracker +import com.r3corda.testing.node.InMemoryMessagingNetwork +import com.r3corda.testing.node.MockNetwork +import com.r3corda.simulation.IRSSimulation +import com.r3corda.simulation.Simulation +import com.r3corda.node.services.network.NetworkMapService +import javafx.animation.* +import javafx.application.Application +import javafx.application.Platform +import javafx.beans.property.SimpleDoubleProperty +import javafx.beans.value.WritableValue +import javafx.geometry.Insets +import javafx.geometry.Pos +import javafx.scene.control.* +import javafx.scene.input.KeyCode +import javafx.scene.input.KeyCodeCombination +import javafx.scene.layout.* +import javafx.scene.paint.Color +import javafx.scene.shape.Circle +import javafx.scene.shape.Line +import javafx.scene.shape.Polygon +import javafx.stage.Stage +import javafx.util.Duration +import rx.Scheduler +import rx.schedulers.Schedulers +import java.nio.file.Files +import java.nio.file.Paths +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.* +import kotlin.concurrent.schedule +import kotlin.concurrent.scheduleAtFixedRate +import kotlin.system.exitProcess +import com.r3cev.corda.netmap.VisualiserViewModel.Style + +fun WritableValue.keyValue(endValue: T, interpolator: Interpolator = Interpolator.EASE_OUT) = KeyValue(this, endValue, interpolator) + +// TODO: This code is all horribly ugly. Refactor to use TornadoFX to clean it up. + +class NetworkMapVisualiser : Application() { + enum class NodeType { + BANK, SERVICE + } + + enum class RunPauseButtonLabel { + RUN, PAUSE; + + override fun toString(): String { + return name.toLowerCase().capitalize() + } + } + + sealed class RunningPausedState { + class Running(val tickTimer: TimerTask): RunningPausedState() + class Paused(): RunningPausedState() + + val buttonLabel: RunPauseButtonLabel + get() { + return when (this) { + is RunningPausedState.Running -> RunPauseButtonLabel.PAUSE + is RunningPausedState.Paused -> RunPauseButtonLabel.RUN + } + } + } + + private val view = VisualiserView() + private val viewModel = VisualiserViewModel() + + val timer = Timer() + val uiThread: Scheduler = Schedulers.from { Platform.runLater(it) } + + override fun start(stage: Stage) { + viewModel.view = view + viewModel.presentationMode = "--presentation-mode" in parameters.raw + buildScene(stage) + viewModel.displayStyle = if ("--circle" in parameters.raw) { Style.CIRCLE } else { viewModel.displayStyle } + + val simulation = viewModel.simulation + // Update the white-backgrounded label indicating what protocol step it's up to. + simulation.allProtocolSteps.observeOn(uiThread).subscribe { step: Pair -> + val (node, change) = step + val label = viewModel.nodesToWidgets[node]!!.statusLabel + if (change is ProgressTracker.Change.Position) { + // Fade in the status label if it's our first step. + if (label.text == "") { + with(FadeTransition(Duration(150.0), label)) { + fromValue = 0.0 + toValue = 1.0 + play() + } + } + label.text = change.newStep.label + if (change.newStep == ProgressTracker.DONE && change.tracker == change.tracker.topLevelTracker) { + runLater(500, -1) { + // Fade out the status label. + with(FadeTransition(Duration(750.0), label)) { + fromValue = 1.0 + toValue = 0.0 + setOnFinished { label.text = "" } + play() + } + } + } + } else if (change is ProgressTracker.Change.Rendering) { + label.text = change.ofStep.label + } + } + // Fire the message bullets between nodes. + simulation.network.messagingNetwork.sentMessages.observeOn(uiThread).subscribe { msg: InMemoryMessagingNetwork.MessageTransfer -> + val senderNode: MockNetwork.MockNode = simulation.network.addressToNode(msg.sender.myAddress) + val destNode: MockNetwork.MockNode = simulation.network.addressToNode(msg.recipients as SingleMessageRecipient) + + if (transferIsInteresting(msg)) { + viewModel.nodesToWidgets[senderNode]!!.pulseAnim.play() + viewModel.fireBulletBetweenNodes(senderNode, destNode, "bank", "bank") + } + } + // Pulse all parties in a trade when the trade completes + simulation.doneSteps.observeOn(uiThread).subscribe { nodes: Collection -> + nodes.forEach { viewModel.nodesToWidgets[it]!!.longPulseAnim.play() } + } + + stage.setOnCloseRequest { exitProcess(0) } + //stage.isMaximized = true + stage.show() + } + + fun runLater(startAfter: Int, delayBetween: Int, body: () -> Unit) { + if (delayBetween != -1) { + timer.scheduleAtFixedRate(startAfter.toLong(), delayBetween.toLong()) { + Platform.runLater { + body() + } + } + } else { + timer.schedule(startAfter.toLong()) { + Platform.runLater { + body() + } + } + } + } + + private fun buildScene(stage: Stage) { + view.stage = stage + view.setup(viewModel.runningPausedState, viewModel.displayStyle, viewModel.presentationMode) + bindSidebar() + bindTopbar() + viewModel.createNodes() + + // Spacebar advances simulation by one step. + stage.scene.accelerators[KeyCodeCombination(KeyCode.SPACE)] = Runnable { onNextInvoked() } + + reloadStylesheet(stage) + + stage.focusedProperty().addListener { value, old, new -> + if (new) { + reloadStylesheet(stage) + } + } + } + + private fun bindTopbar() { + view.resetButton.setOnAction({reset()}) + view.nextButton.setOnAction { + if (!view.simulateInitialisationCheckbox.isSelected && !viewModel.simulation.networkInitialisationFinished.isDone) { + skipNetworkInitialisation() + } else { + onNextInvoked() + } + } + viewModel.simulation.networkInitialisationFinished.then { + view.simulateInitialisationCheckbox.isVisible = false + } + view.runPauseButton.setOnAction { + val oldRunningPausedState = viewModel.runningPausedState + val newRunningPausedState = when (oldRunningPausedState) { + is NetworkMapVisualiser.RunningPausedState.Running -> { + oldRunningPausedState.tickTimer.cancel() + + view.nextButton.isDisable = false + view.resetButton.isDisable = false + + NetworkMapVisualiser.RunningPausedState.Paused() + } + is NetworkMapVisualiser.RunningPausedState.Paused -> { + val tickTimer = timer.scheduleAtFixedRate(viewModel.stepDuration.toMillis().toLong(), viewModel.stepDuration.toMillis().toLong()) { + Platform.runLater { + onNextInvoked() + } + } + + view.nextButton.isDisable = true + view.resetButton.isDisable = true + + if (!view.simulateInitialisationCheckbox.isSelected && !viewModel.simulation.networkInitialisationFinished.isDone) { + skipNetworkInitialisation() + } + + NetworkMapVisualiser.RunningPausedState.Running(tickTimer) + } + } + + view.runPauseButton.text = newRunningPausedState.buttonLabel.toString() + viewModel.runningPausedState = newRunningPausedState + } + view.styleChoice.selectionModel.selectedItemProperty() + .addListener { ov, value, newValue -> viewModel.displayStyle = newValue } + viewModel.simulation.dateChanges.observeOn(uiThread).subscribe { view.dateLabel.text = it.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) } + } + + private fun reloadStylesheet(stage: Stage) { + stage.scene.stylesheets.clear() + stage.scene.stylesheets.add(NetworkMapVisualiser::class.java.getResource("styles.css").toString()) + } + + private fun bindSidebar() { + viewModel.simulation.allProtocolSteps.observeOn(uiThread).subscribe { step: Pair -> + val (node, change) = step + + if (change is ProgressTracker.Change.Position) { + val tracker = change.tracker.topLevelTracker + if (change.newStep == ProgressTracker.DONE) { + if (change.tracker == tracker) { + // Protocol done; schedule it for removal in a few seconds. We batch them up to make nicer + // animations. + println("Protocol done for ${node.info.identity.name}") + viewModel.doneTrackers += tracker + } else { + // Subprotocol is done; ignore it. + } + } else if (!viewModel.trackerBoxes.containsKey(tracker)) { + // New protocol started up; add. + val extraLabel = viewModel.simulation.extraNodeLabels[node] + val label = if (extraLabel != null) "${node.storage.myLegalIdentity.name}: $extraLabel" else node.storage.myLegalIdentity.name + val widget = view.buildProgressTrackerWidget(label, tracker.topLevelTracker) + bindProgressTracketWidget(tracker.topLevelTracker, widget) + println("Added: ${tracker}, ${widget}") + viewModel.trackerBoxes[tracker] = widget.vbox + view.sidebar.children += widget.vbox + } + } + } + + Timer().scheduleAtFixedRate(0, 500) { + Platform.runLater { + for (tracker in viewModel.doneTrackers) { + val pane = viewModel.trackerBoxes[tracker]!! + // Slide the other tracker widgets up and over this one. + val slideProp = SimpleDoubleProperty(0.0) + slideProp.addListener { obv -> pane.padding = Insets(0.0, 0.0, slideProp.value, 0.0) } + val timeline = Timeline( + KeyFrame(Duration(250.0), + KeyValue(pane.opacityProperty(), 0.0), + KeyValue(slideProp, -pane.height - 50.0) // Subtract the bottom padding gap. + ) + ) + timeline.setOnFinished { + println("Removed: ${tracker}") + val vbox = viewModel.trackerBoxes.remove(tracker) + view.sidebar.children.remove(vbox) + } + timeline.play() + } + viewModel.doneTrackers.clear() + } + } + } + + private fun bindProgressTracketWidget(tracker: ProgressTracker, widget: TrackerWidget) { + val allSteps: List> = tracker.allSteps + tracker.changes.observeOn(uiThread).subscribe { step: ProgressTracker.Change -> + val stepHeight = widget.cursorBox.height / allSteps.size + if (step is ProgressTracker.Change.Position) { + // Figure out the index of the new step. + val curStep = allSteps.indexOfFirst { it.second == step.newStep } + // Animate the cursor to the right place. + with(TranslateTransition(Duration(350.0), widget.cursor)) { + fromY = widget.cursor.translateY + toY = (curStep * stepHeight) + 22.5 + play() + } + } else if (step is ProgressTracker.Change.Structural) { + val new = view.buildProgressTrackerWidget(widget.label.text, tracker) + val prevWidget = viewModel.trackerBoxes[step.tracker] ?: throw AssertionError("No previous widget for tracker: ${step.tracker}") + val i = (prevWidget.parent as VBox).children.indexOf(viewModel.trackerBoxes[step.tracker]) + (prevWidget.parent as VBox).children[i] = new.vbox + viewModel.trackerBoxes[step.tracker] = new.vbox + } + } + } + + var started = false + private fun startSimulation() { + if (!started) { + viewModel.simulation.start() + started = true + } + } + + private fun reset() { + viewModel.simulation.stop() + viewModel.simulation = IRSSimulation(true, false, null) + started = false + start(view.stage) + } + + private fun skipNetworkInitialisation() { + startSimulation() + while (!viewModel.simulation.networkInitialisationFinished.isDone) { + iterateSimulation() + } + } + + private fun onNextInvoked() { + if (started) { + iterateSimulation() + } else { + startSimulation() + } + } + + private fun iterateSimulation() { + // Loop until either we ran out of things to do, or we sent an interesting message. + while (true) { + val transfer: InMemoryMessagingNetwork.MessageTransfer = viewModel.simulation.iterate() ?: break + if (transferIsInteresting(transfer)) + break + else + System.err.println("skipping boring $transfer") + } + } + + private fun transferIsInteresting(transfer: InMemoryMessagingNetwork.MessageTransfer): Boolean { + // Loopback messages are boring. + if (transfer.sender.myAddress == transfer.recipients) return false + // Network map push acknowledgements are boring. + if (NetworkMapService.PUSH_ACK_PROTOCOL_TOPIC in transfer.message.topicSession.topic) return false + + return true + } +} + +fun main(args: Array) { + Application.launch(NetworkMapVisualiser::class.java, *args) +} diff --git a/network-simulator/src/main/kotlin/com/r3cev/corda/netmap/VisualiserUtils.kt b/network-simulator/src/main/kotlin/com/r3cev/corda/netmap/VisualiserUtils.kt new file mode 100644 index 0000000000..590b4e139f --- /dev/null +++ b/network-simulator/src/main/kotlin/com/r3cev/corda/netmap/VisualiserUtils.kt @@ -0,0 +1,18 @@ +package com.r3cev.corda.netmap + +import javafx.scene.paint.Color + +internal +fun colorToRgb(color: Color): String { + val builder = StringBuilder() + + builder.append("rgb(") + builder.append(Math.round(color.red * 256)) + builder.append(",") + builder.append(Math.round(color.green * 256)) + builder.append(",") + builder.append(Math.round(color.blue * 256)) + builder.append(")") + + return builder.toString() +} \ No newline at end of file diff --git a/network-simulator/src/main/kotlin/com/r3cev/corda/netmap/VisualiserView.kt b/network-simulator/src/main/kotlin/com/r3cev/corda/netmap/VisualiserView.kt new file mode 100644 index 0000000000..4dd4470aa1 --- /dev/null +++ b/network-simulator/src/main/kotlin/com/r3cev/corda/netmap/VisualiserView.kt @@ -0,0 +1,304 @@ +package com.r3cev.corda.netmap + +import com.r3corda.core.utilities.ProgressTracker +import javafx.animation.KeyFrame +import javafx.animation.Timeline +import javafx.application.Platform +import javafx.collections.FXCollections +import javafx.event.EventHandler +import javafx.geometry.Insets +import javafx.geometry.Pos +import javafx.scene.Group +import javafx.scene.Node +import javafx.scene.Scene +import javafx.scene.control.* +import javafx.scene.image.Image +import javafx.scene.image.ImageView +import javafx.scene.input.ZoomEvent +import javafx.scene.layout.* +import javafx.scene.paint.Color +import javafx.scene.shape.Polygon +import javafx.scene.text.Font +import javafx.stage.Stage +import javafx.util.Duration +import com.r3cev.corda.netmap.VisualiserViewModel.Style + +data class TrackerWidget(val vbox: VBox, val cursorBox: Pane, val label: Label, val cursor: Polygon) + +internal class VisualiserView() { + lateinit var root: Pane + lateinit var stage: Stage + lateinit var splitter: SplitPane + lateinit var sidebar: VBox + lateinit var resetButton: Button + lateinit var nextButton: Button + lateinit var runPauseButton: Button + lateinit var simulateInitialisationCheckbox: CheckBox + lateinit var styleChoice: ChoiceBox