Doorman refactoring and improve UX (#160)

* * change corda dependencies to 3.0-NETWORKMAP_SNAPSHOT
* packages move fix

* address PR issues

* * refactorings
* cleaned up network management server start up code.
* renamed a few classes
* segregate doorman and network map config and startup code.
* make `config-file` optional, default to ./networkManagement.conf

* readme.md and UX changes

* added dependency on rpc for the serilization env

* move init serilization env to main method to avoid interfering with test

* move cert path check to the storage, and remove redundant checks in NodeInfoWebService.

* minor fix

* some refactoring

* fix broken test and added steps to start the network

* address PR issues

* write root cert to pem file

* address PR issues
fix bugs in doorman where it try to transit jira ticket to done multiple times

* address PR issue

* approve request no longer throws exception when approve again, it will simply ignore, test is no longer relevant
This commit is contained in:
Patrick Kuo 2017-12-11 10:06:29 +00:00 committed by GitHub
parent b1bac9e103
commit 8af7dc977f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 820 additions and 433 deletions

View File

@ -1,8 +1,172 @@
The Doorman source code is located under `network-management/src`
# Building the binaries
## Network management server
To build a fat jar containing all the doorman code you can simply invoke
.. sourcecode:: bash
./gradlew network-management:buildDoormanJAR
```
./gradlew network-management:capsule:buildDoormanJAR
```
The built file will appear in
``network-management/build/libs/doorman-<VERSION>-capsule.jar``
```
network-management/capsule/build/libs/doorman-<VERSION>.jar
```
## HSM signing server
To build a fat jar containing all the HSM signer code you can simply invoke
```
./gradlew network-management:capsule-hsm:buildHsmJAR
```
The built file will appear in
```
network-management/capsule-hsm/build/libs/hsm-<VERSION>.jar
```
The binaries can also be obtained from artifactory after deployment in teamcity
#Configuring network management service
### Local signing
When `keystorePath` is provided in the config file, a signer will be created to handle all the signing periodically using the CA keys in the provided keystore.
The network management service can be started without a signer, the signing will be delegated to external process (e.g. HSM) connecting to the same database, the server will poll the database periodically for newly signed data and update the statuses accordingly.
Additional configuration needed for local signer:
```
#For local signing
rootStorePath = ${basedir}"/certificates/rootstore.jks"
keystorePath = ${basedir}"/certificates/caKeystore.jks"
keystorePassword = "password"
caPrivateKeyPassword = "password"
```
## Doorman Service
Doorman service can be started with the following options :
### JIRA
The doorman service can use JIRA to manage the certificate signing request approval workflow. This can be turned on by providing JIRA connection configuration in the config file.
```
doormanConfig {
jiraConfig {
address = "https://doorman-jira-host.com/"
projectCode = "TD"
username = "username"
password = "password"
doneTransitionCode = 41
}
.
.
.
}
```
### Auto approval
When `approveAll` is set to `true`, the doorman will approve all requests on receive. (*This should only be enabled in a test environment)
### Network map service
Network map service can be enabled by providing the following config:
```
networkMapConfig {
cacheTimeout = 600000
signInterval = 10000
}
```
`cacheTimeout`(ms) indicates how often the network map should poll the database for a newly signed network map. This is also added to the HTTP response header to set the node's network map update frequency.
`signInterval`(ms) this is only relevant when local signer is enabled. The signer poll the database according to the `signInterval`, and create a new network map if the collection of node info hashes is different from the current network map.
##Example config file
```
basedir = "."
host = localhost
port = 0
#For local signing
rootStorePath = ${basedir}"/certificates/rootstore.jks"
keystorePath = ${basedir}"/certificates/caKeystore.jks"
keystorePassword = "password"
caPrivateKeyPassword = "password"
# Database config
dataSourceProperties {
dataSourceClassName = org.h2.jdbcx.JdbcDataSource
"dataSource.url" = "jdbc:h2:file:"${basedir}"/persistence;DB_CLOSE_ON_EXIT=FALSE;LOCK_TIMEOUT=10000;WRITE_DELAY=0;AUTO_SERVER_PORT="${h2port}
"dataSource.user" = sa
"dataSource.password" = ""
}
h2port = 0
# Doorman config
# Comment out this section if running without doorman service
doormanConfig {
approveInterval = 10000
approveAll = false
jiraConfig {
address = "https://doorman-jira-host.com/"
projectCode = "TD"
username = "username"
password = "password"
doneTransitionCode = 41
}
}
# Network map config
# Comment out this section if running without network map service
networkMapConfig {
cacheTimeout = 600000
signInterval = 10000
}
```
# Running the network
### 1. Create keystore for local signer
If local signer is enabled, the server will look for keystores in the certificate folder on start up.
The keystores can be created using `--mode` flag.
```
java -jar doorman-<version>.jar --mode ROOT_KEYGEN
```
and
```
java -jar doorman-<version>.jar --mode CA_KEYGEN
```
A root certificate `pem` file will also be created, this will be distributed to the client via a "out-of-band" process.
Note: We will be distributing a trust store instead of the pem file in future updates.
### 2. Start Doorman service for notary registration
Start the network management server with the doorman service for initial bootstrapping. Network map service should be disabled at this point.
Comment out network map config in the config file and start the server by running :
```
java -jar doorman-<version>.jar
```
### 3. Create notary node and register with the doorman
After the doorman service is started, copy the `rootcert.pem` file to the notaries' certificates folder and start the `initial-registration` process.
### 4. Add notary identities to the network parameter
The network parameter should contain the name and public key of the newly created notaries.
Example network parameter file:
notaries : [{
name: "O=Notary A, L=Port Louis, C=MU, OU=Org Unit, CN=Service Name"
key: "GfHq2tTVk9z4eXgyWmExBB3JfHpeuYrk9jUc4zaVVSXpnW8FdCUNDhw6GRGN"
validating: true
}, {
name: "O=Notary B, L=Bali, C=ID, OU=Org Unit, CN=Service Name"
key: "GfHq2tTVk9z4eXgyEshv6vtBDjp7n76QZH5hk6VXLhk3vRTAmKcP9F9tRfPj"
validating: false
}]
minimumPlatformVersion = 1
maxMessageSize = 100
maxTransactionSize = 100
Save the parameters to `parameter.conf`
### 5. Load initial network parameters file for network map service
A network parameters file is required to start the network map service for the first time. The initial network parameters file can be loaded using the `--update-network-parameter` flag.
We can now restart the network management server with both doorman and network map service.
```
java -jar doorman-<version>.jar --update-network-parameter parameter.conf
```

View File

@ -60,6 +60,9 @@ dependencies {
compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
compile "net.corda:corda-node-api:$corda_dependency_version"
// TODO remove this when AMQP P2P serialization context is supported.
compile "net.corda:corda-rpc:$corda_dependency_version"
testCompile "net.corda:corda-node-driver:$corda_dependency_version"
testCompile "net.corda:corda-test-common:$corda_dependency_version"
@ -104,4 +107,11 @@ dependencies {
// SQL connection pooling library
compile "com.zaxxer:HikariCP:2.5.1"
// For H2 database support in persistence
compile "com.h2database:h2:$h2_version"
//TODO remove once we can put driver jar into a predefined directory
//JDBC driver can be passed to the Node at startup using setting the jarDirs property in the Node configuration file.
compile 'com.microsoft.sqlserver:mssql-jdbc:6.2.1.jre8'
}

View File

@ -12,7 +12,7 @@ configurations {
task buildHsmJAR(type: FatCapsule, dependsOn: 'jar') {
applicationClass 'com.r3.corda.networkmanage.hsm.MainKt'
archiveName "hsm-${version}-capsule.jar"
archiveName "hsm-${version}.jar"
capsuleManifest {
applicationVersion = corda_dependency_version
systemProperties['visualvm.display.name'] = 'HSM Signing Service'

View File

@ -12,7 +12,7 @@ configurations {
task buildDoormanJAR(type: FatCapsule, dependsOn: ':network-management:jar') {
applicationClass 'com.r3.corda.networkmanage.doorman.MainKt'
archiveName "doorman-${version}-capsule.jar"
archiveName "doorman-${version}.jar"
capsuleManifest {
applicationVersion = corda_dependency_version
systemProperties['visualvm.display.name'] = 'Doorman'

View File

@ -0,0 +1,26 @@
basedir = "."
host = localhost
port = 0
# Database config
dataSourceProperties {
dataSourceClassName = org.h2.jdbcx.JdbcDataSource
"dataSource.url" = "jdbc:h2:file:"${basedir}"/persistence;DB_CLOSE_ON_EXIT=FALSE;LOCK_TIMEOUT=10000;WRITE_DELAY=0;AUTO_SERVER_PORT="${h2port}
"dataSource.user" = sa
"dataSource.password" = ""
}
h2port = 0
# Doorman config
# Comment out this section if running without doorman service
doormanConfig{
approveInterval = 10000
approveAll = false
}
# Network map config
# Comment out this section if running without network map service
networkMapConfig{
cacheTimeout = 600000
signInterval = 10000
}

View File

@ -0,0 +1,33 @@
basedir = "."
host = localhost
port = 0
# Database config
dataSourceProperties {
dataSourceClassName = org.h2.jdbcx.JdbcDataSource
"dataSource.url" = "jdbc:h2:file:"${basedir}"/persistence;DB_CLOSE_ON_EXIT=FALSE;LOCK_TIMEOUT=10000;WRITE_DELAY=0;AUTO_SERVER_PORT="${h2port}
"dataSource.user" = sa
"dataSource.password" = ""
}
h2port = 0
# Doorman config
# Comment out this section if running without doorman service
doormanConfig{
approveInterval = 10000
approveAll = false
jiraConfig{
address = "https://doorman-jira-host.com/"
projectCode = "TD"
username = "username"
password = "password"
doneTransitionCode = 41
}
}
# Network map config
# Comment out this section if running without network map service
networkMapConfig{
cacheTimeout = 600000
signInterval = 10000
}

View File

@ -0,0 +1,33 @@
basedir = "."
host = localhost
port = 0
#For local signing
rootStorePath = ${basedir}"/certificates/rootstore.jks"
keystorePath = ${basedir}"/certificates/caKeystore.jks"
keystorePassword = "password"
caPrivateKeyPassword = "password"
# Database config
dataSourceProperties {
dataSourceClassName = org.h2.jdbcx.JdbcDataSource
"dataSource.url" = "jdbc:h2:file:"${basedir}"/persistence;DB_CLOSE_ON_EXIT=FALSE;LOCK_TIMEOUT=10000;WRITE_DELAY=0;AUTO_SERVER_PORT="${h2port}
"dataSource.user" = sa
"dataSource.password" = ""
}
h2port = 0
# Doorman config
# Comment out this section if running without doorman service
doormanConfig{
approveInterval = 10000
approveAll = false
}
# Network map config
# Comment out this section if running without network map service
networkMapConfig{
cacheTimeout = 600000
signInterval = 10000
}

View File

@ -1,6 +1,7 @@
basedir = "."
host = localhost
port = 0
rootStorePath = ${basedir}"/certificates/rootstore.jks"
keystorePath = ${basedir}"/certificates/caKeystore.jks"
keystorePassword = "password"
caPrivateKeyPassword = "password"
@ -13,10 +14,22 @@ dataSourceProperties {
}
h2port = 0
jiraConfig{
address = "https://doorman-jira-host.com/"
projectCode = "TD"
username = "username"
password = "password"
doneTransitionCode = 41
# Comment out this section if running without doorman service
doormanConfig{
approveInterval = 10000
approveAll = false
jiraConfig{
address = "https://doorman-jira-host.com/"
projectCode = "TD"
username = "username"
password = "password"
doneTransitionCode = 41
}
}
# Comment out this section if running without network map service
networkMapConfig{
cacheTimeout = 600000
signInterval = 10000
}

View File

@ -16,6 +16,8 @@ import net.corda.core.internal.createDirectories
import net.corda.core.node.NodeInfo
import net.corda.core.serialization.serialize
import net.corda.core.utilities.NetworkHostAndPort
import net.corda.core.utilities.minutes
import net.corda.core.utilities.seconds
import net.corda.node.services.network.NetworkMapClient
import net.corda.node.utilities.registration.HTTPNetworkRegistrationService
import net.corda.node.utilities.registration.NetworkRegistrationHelper
@ -25,7 +27,6 @@ import net.corda.testing.SerializationEnvironmentRule
import net.corda.testing.common.internal.testNetworkParameters
import net.corda.testing.testNodeConfiguration
import org.bouncycastle.cert.X509CertificateHolder
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
@ -161,15 +162,18 @@ fun makeTestDataSourceProperties(nodeName: String = SecureHash.randomSHA256().to
return props
}
fun startDoorman(intermediateCACertAndKey: CertificateAndKeyPair, rootCACert: X509CertificateHolder): DoormanServer {
fun startDoorman(intermediateCACertAndKey: CertificateAndKeyPair, rootCACert: X509CertificateHolder): NetworkManagementServer {
val signer = LocalSigner(intermediateCACertAndKey.keyPair,
arrayOf(intermediateCACertAndKey.certificate.toX509Certificate(), rootCACert.toX509Certificate()))
//Start doorman server
return startDoorman(signer)
}
fun startDoorman(localSigner: LocalSigner? = null): DoormanServer {
fun startDoorman(localSigner: LocalSigner? = null): NetworkManagementServer {
val database = configureDatabase(makeTestDataSourceProperties())
//Start doorman server
return startDoorman(NetworkHostAndPort("localhost", 0), database, true, testNetworkParameters(emptyList()), localSigner, 2, 30, null)
val server = NetworkManagementServer()
server.start(NetworkHostAndPort("localhost", 0), database, localSigner, testNetworkParameters(emptyList()), NetworkMapConfig(1.minutes.toMillis(), 1.minutes.toMillis()), DoormanConfig(true, null, 3.seconds.toMillis()))
return server
}

View File

@ -7,7 +7,8 @@ import com.nhaarman.mockito_kotlin.whenever
import com.r3.corda.networkmanage.common.persistence.configureDatabase
import com.r3.corda.networkmanage.common.utils.buildCertPath
import com.r3.corda.networkmanage.common.utils.toX509Certificate
import com.r3.corda.networkmanage.doorman.startDoorman
import com.r3.corda.networkmanage.doorman.DoormanConfig
import com.r3.corda.networkmanage.doorman.NetworkManagementServer
import com.r3.corda.networkmanage.hsm.persistence.ApprovedCertificateRequestData
import com.r3.corda.networkmanage.hsm.persistence.DBSignedCertificateRequestStorage
import com.r3.corda.networkmanage.hsm.persistence.SignedCertificateRequestStorage
@ -24,7 +25,6 @@ import net.corda.nodeapi.internal.crypto.CertificateType
import net.corda.nodeapi.internal.crypto.X509Utilities
import net.corda.nodeapi.internal.persistence.DatabaseConfig
import net.corda.testing.*
import net.corda.testing.common.internal.testNetworkParameters
import org.bouncycastle.cert.X509CertificateHolder
import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequest
import org.h2.tools.Server
@ -94,44 +94,44 @@ class SigningServiceIntegrationTest {
fun `Signing service signs approved CSRs`() {
//Start doorman server
val database = configureDatabase(makeTestDataSourceProperties())
val doorman = startDoorman(NetworkHostAndPort(HOST, 0), database, approveAll = true, approveInterval = 2, signInterval = 30, networkMapParameters = testNetworkParameters(emptyList()))
// Start Corda network registration.
val config = testNodeConfiguration(
baseDirectory = tempFolder.root.toPath(),
myLegalName = ALICE.name).also {
val doormanHostAndPort = doorman.hostAndPort
whenever(it.compatibilityZoneURL).thenReturn(URL("http://${doormanHostAndPort.host}:${doormanHostAndPort.port}"))
}
val signingServiceStorage = DBSignedCertificateRequestStorage(configureDatabase(makeTestDataSourceProperties()))
val hsmSigner = givenSignerSigningAllRequests(signingServiceStorage)
// Poll the database for approved requests
timer.scheduleAtFixedRate(0, 1.seconds.toMillis()) {
// The purpose of this tests is to validate the communication between this service and Doorman
// by the means of data in the shared database.
// Therefore the HSM interaction logic is mocked here.
try {
val approved = signingServiceStorage.getApprovedRequests()
if (approved.isNotEmpty()) {
hsmSigner.sign(approved)
timer.cancel()
}
} catch (exception: PersistenceException) {
// It may happen that Doorman DB is not created at the moment when the signing service polls it.
// This is due to the fact that schema is initialized at the time first hibernate session is established.
// Since Doorman does this at the time the first CSR arrives, which in turn happens after signing service
// startup, the very first iteration of the signing service polling fails with
// [org.hibernate.tool.schema.spi.SchemaManagementException] being thrown as the schema is missing.
NetworkManagementServer().use { server ->
server.start(NetworkHostAndPort(HOST, 0), database, networkMapServiceParameter = null, doormanServiceParameter = DoormanConfig(approveAll = true, approveInterval = 2.seconds.toMillis(), jiraConfig = null), updateNetworkParameters = null)
// Start Corda network registration.
val config = testNodeConfiguration(
baseDirectory = tempFolder.root.toPath(),
myLegalName = ALICE.name).also {
val doormanHostAndPort = server.hostAndPort
whenever(it.compatibilityZoneURL).thenReturn(URL("http://${doormanHostAndPort.host}:${doormanHostAndPort.port}"))
}
}
config.rootCaCertFile.parent.createDirectories()
X509Utilities.saveCertificateAsPEMFile(rootCACert, config.rootCaCertFile)
NetworkRegistrationHelper(config, HTTPNetworkRegistrationService(config.compatibilityZoneURL!!)).buildKeystore()
verify(hsmSigner).sign(any())
doorman.close()
val signingServiceStorage = DBSignedCertificateRequestStorage(configureDatabase(makeTestDataSourceProperties()))
val hsmSigner = givenSignerSigningAllRequests(signingServiceStorage)
// Poll the database for approved requests
timer.scheduleAtFixedRate(0, 1.seconds.toMillis()) {
// The purpose of this tests is to validate the communication between this service and Doorman
// by the means of data in the shared database.
// Therefore the HSM interaction logic is mocked here.
try {
val approved = signingServiceStorage.getApprovedRequests()
if (approved.isNotEmpty()) {
hsmSigner.sign(approved)
timer.cancel()
}
} catch (exception: PersistenceException) {
// It may happen that Doorman DB is not created at the moment when the signing service polls it.
// This is due to the fact that schema is initialized at the time first hibernate session is established.
// Since Doorman does this at the time the first CSR arrives, which in turn happens after signing service
// startup, the very first iteration of the signing service polling fails with
// [org.hibernate.tool.schema.spi.SchemaManagementException] being thrown as the schema is missing.
}
}
config.rootCaCertFile.parent.createDirectories()
X509Utilities.saveCertificateAsPEMFile(rootCACert, config.rootCaCertFile)
NetworkRegistrationHelper(config, HTTPNetworkRegistrationService(config.compatibilityZoneURL!!)).buildKeystore()
verify(hsmSigner).sign(any())
}
}
/*
@ -147,31 +147,31 @@ class SigningServiceIntegrationTest {
fun `DEMO - Create CSR and poll`() {
//Start doorman server
val database = configureDatabase(makeTestDataSourceProperties(), DatabaseConfig())
val doorman = startDoorman(NetworkHostAndPort(HOST, 0), database, approveAll = true, approveInterval = 2, signInterval = 10, networkMapParameters = testNetworkParameters(emptyList()))
thread(start = true, isDaemon = true) {
val h2ServerArgs = arrayOf("-tcpPort", H2_TCP_PORT, "-tcpAllowOthers")
Server.createTcpServer(*h2ServerArgs).start()
}
// Start Corda network registration.
(1..3).map {
thread(start = true) {
val config = testNodeConfiguration(
baseDirectory = tempFolder.root.toPath(),
myLegalName = when (it) {
1 -> ALICE.name
2 -> BOB.name
3 -> CHARLIE.name
else -> throw IllegalArgumentException("Unrecognised option")
}).also {
whenever(it.compatibilityZoneURL).thenReturn(URL("http://$HOST:${doorman.hostAndPort.port}"))
}
NetworkRegistrationHelper(config, HTTPNetworkRegistrationService(config.compatibilityZoneURL!!)).buildKeystore()
NetworkManagementServer().use { server ->
server.start(NetworkHostAndPort(HOST, 0), database, networkMapServiceParameter = null, doormanServiceParameter = DoormanConfig(approveAll = true, approveInterval = 2.seconds.toMillis(), jiraConfig = null), updateNetworkParameters = null)
thread(start = true, isDaemon = true) {
val h2ServerArgs = arrayOf("-tcpPort", H2_TCP_PORT, "-tcpAllowOthers")
Server.createTcpServer(*h2ServerArgs).start()
}
}.map { it.join() }
doorman.close()
// Start Corda network registration.
(1..3).map {
thread(start = true) {
val config = testNodeConfiguration(
baseDirectory = tempFolder.root.toPath(),
myLegalName = when (it) {
1 -> ALICE.name
2 -> BOB.name
3 -> CHARLIE.name
else -> throw IllegalArgumentException("Unrecognised option")
}).also {
whenever(it.compatibilityZoneURL).thenReturn(URL("http://$HOST:${server.hostAndPort.port}"))
}
NetworkRegistrationHelper(config, HTTPNetworkRegistrationService(config.compatibilityZoneURL!!)).buildKeystore()
}
}.map { it.join() }
}
}
}

View File

@ -1,6 +1,5 @@
package com.r3.corda.networkmanage.common.persistence
import net.corda.core.crypto.DigitalSignature
import net.corda.core.crypto.SecureHash
import net.corda.core.crypto.SignedData
import net.corda.core.node.NodeInfo

View File

@ -84,13 +84,13 @@ class PersistentCertificateRequestStorage(private val database: CordaPersistence
override fun approveRequest(requestId: String, approvedBy: String) {
return database.transaction(TransactionIsolationLevel.SERIALIZABLE) {
val request = findRequest(requestId, RequestStatus.TICKET_CREATED)
request ?: throw IllegalArgumentException("Error when approving request with id: $requestId. Request does not exist or its status is not TICKET_CREATED.")
val update = request.copy(
modifiedBy = listOf(approvedBy),
modifiedAt = Instant.now(),
status = RequestStatus.APPROVED)
session.merge(update)
findRequest(requestId, RequestStatus.TICKET_CREATED)?.let {
val update = it.copy(
modifiedBy = listOf(approvedBy),
modifiedAt = Instant.now(),
status = RequestStatus.APPROVED)
session.merge(update)
}
}
}

View File

@ -4,14 +4,16 @@ import com.r3.corda.networkmanage.common.persistence.entity.CertificateDataEntit
import com.r3.corda.networkmanage.common.persistence.entity.CertificateSigningRequestEntity
import com.r3.corda.networkmanage.common.persistence.entity.NodeInfoEntity
import com.r3.corda.networkmanage.common.utils.buildCertPath
import com.r3.corda.networkmanage.common.utils.hashString
import net.corda.core.crypto.SecureHash
import net.corda.core.crypto.SignedData
import net.corda.core.crypto.sha256
import net.corda.core.identity.CordaX500Name
import net.corda.core.node.NodeInfo
import net.corda.core.serialization.SerializedBytes
import net.corda.nodeapi.internal.persistence.CordaPersistence
import net.corda.nodeapi.internal.persistence.TransactionIsolationLevel
import java.security.cert.CertPath
import java.security.cert.X509Certificate
/**
* Database implementation of the [NetworkMapStorage] interface
@ -19,13 +21,19 @@ import java.security.cert.CertPath
class PersistentNodeInfoStorage(private val database: CordaPersistence) : NodeInfoStorage {
override fun putNodeInfo(signedNodeInfo: SignedData<NodeInfo>): SecureHash = database.transaction(TransactionIsolationLevel.SERIALIZABLE) {
val nodeInfo = signedNodeInfo.verified()
val publicKeyHash = nodeInfo.legalIdentities.first().owningKey.hashString()
val request = singleRequestWhere(CertificateDataEntity::class.java) { builder, path ->
val certPublicKeyHashEq = builder.equal(path.get<String>(CertificateDataEntity::publicKeyHash.name), publicKeyHash)
val certStatusValid = builder.equal(path.get<CertificateStatus>(CertificateDataEntity::certificateStatus.name), CertificateStatus.VALID)
builder.and(certPublicKeyHashEq, certStatusValid)
val orgName = nodeInfo.legalIdentities.first().name.organisation
// TODO: use cert extension to identify NodeCA cert when Ross's work is in.
val nodeCACert = nodeInfo.legalIdentitiesAndCerts.first().certPath.certificates.map { it as X509Certificate }
.find { CordaX500Name.build(it.issuerX500Principal).organisation != orgName && CordaX500Name.build(it.subjectX500Principal).organisation == orgName }
val request = nodeCACert?.let {
singleRequestWhere(CertificateDataEntity::class.java) { builder, path ->
val certPublicKeyHashEq = builder.equal(path.get<String>(CertificateDataEntity::publicKeyHash.name), it.publicKey.encoded.sha256().toString())
val certStatusValid = builder.equal(path.get<CertificateStatus>(CertificateDataEntity::certificateStatus.name), CertificateStatus.VALID)
builder.and(certPublicKeyHashEq, certStatusValid)
}
}
request ?: throw IllegalArgumentException("CSR data missing for provided node info: $nodeInfo")
request ?: throw IllegalArgumentException("Unknown node info, this public key is not registered with the network management service.")
/*
* Delete any previous [NodeInfoEntity] instance for this CSR
* Possibly it should be moved at the network signing process at the network signing process

View File

@ -1,5 +1,6 @@
package com.r3.corda.networkmanage.common.utils
import com.google.common.base.CaseFormat
import com.typesafe.config.Config
import com.typesafe.config.ConfigFactory
import joptsimple.ArgumentAcceptingOptionSpec
@ -33,10 +34,10 @@ fun Array<out String>.toConfigWithOptions(registerOptions: OptionParser.() -> Un
return ConfigFactory.parseMap(parser.recognizedOptions().mapValues {
val optionSpec = it.value
if (optionSpec is ArgumentAcceptingOptionSpec<*> && !optionSpec.requiresArgument() && optionSet.has(optionSpec)) true else optionSpec.value(optionSet)
}.filterValues { it != null })
}.mapKeys { it.key.toCamelcase() }.filterValues { it != null })
}
class ShowHelpException(val parser: OptionParser) : Exception()
class ShowHelpException(val parser: OptionParser, val errorMessage: String? = null) : Exception()
fun X509CertificateHolder.toX509Certificate(): X509Certificate = X509CertificateFactory().generateCertificate(encoded.inputStream())
@ -44,4 +45,10 @@ fun buildCertPath(vararg certificates: Certificate): CertPath = X509CertificateF
fun buildCertPath(certPathBytes: ByteArray): CertPath = X509CertificateFactory().delegate.generateCertPath(certPathBytes.inputStream())
fun DigitalSignature.WithKey.withCert(cert: X509Certificate): DigitalSignatureWithCert = DigitalSignatureWithCert(cert, bytes)
fun DigitalSignature.WithKey.withCert(cert: X509Certificate): DigitalSignatureWithCert = DigitalSignatureWithCert(cert, bytes)
private fun String.toCamelcase(): String {
return if (contains('_') || contains('-')) {
CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL, this.replace("-", "_"))
} else this
}

View File

@ -1,113 +1,100 @@
package com.r3.corda.networkmanage.doorman
import com.r3.corda.networkmanage.common.utils.ShowHelpException
import com.typesafe.config.Config
import com.r3.corda.networkmanage.common.utils.toConfigWithOptions
import com.typesafe.config.ConfigFactory
import com.typesafe.config.ConfigParseOptions
import joptsimple.OptionParser
import joptsimple.util.EnumConverter
import net.corda.core.internal.div
import net.corda.core.internal.isRegularFile
import net.corda.core.utilities.seconds
import net.corda.nodeapi.config.parseAs
import java.nio.file.Path
import java.nio.file.Paths
import java.util.*
data class DoormanParameters(// TODO Create a localSigning sub-config and put that there
val keystorePassword: String?,
// TODO Should be part of a localSigning sub-config
val caPrivateKeyPassword: String?,
// TODO Should be part of a localSigning sub-config
val rootKeystorePassword: String?,
// TODO Should be part of a localSigning sub-config
val rootPrivateKeyPassword: String?,
val host: String,
val port: Int,
val dataSourceProperties: Properties,
val approveAll: Boolean = false,
val databaseProperties: Properties? = null,
val jiraConfig: JiraConfig? = null,
// TODO Should be part of a localSigning sub-config
val keystorePath: Path? = null,
// TODO Should be part of a localSigning sub-config
val rootStorePath: Path? = null,
// TODO Change these to Duration in the future
val approveInterval: Long = DEFAULT_APPROVE_INTERVAL,
val signInterval: Long = DEFAULT_SIGN_INTERVAL
data class NetworkManagementServerParameters(// TODO: Move local signing to signing server.
val host: String,
val port: Int,
val dataSourceProperties: Properties,
val databaseProperties: Properties? = null,
val mode: Mode,
val doormanConfig: DoormanConfig?,
val networkMapConfig: NetworkMapConfig?,
val updateNetworkParameters: Path?,
// TODO Should be part of a localSigning sub-config
val keystorePath: Path? = null,
// TODO Should be part of a localSigning sub-config
val rootStorePath: Path? = null,
val keystorePassword: String?,
// TODO Should be part of a localSigning sub-config
val caPrivateKeyPassword: String?,
// TODO Should be part of a localSigning sub-config
val rootKeystorePassword: String?,
// TODO Should be part of a localSigning sub-config
val rootPrivateKeyPassword: String?
) {
enum class Mode {
DOORMAN, CA_KEYGEN, ROOT_KEYGEN
}
data class JiraConfig(
val address: String,
val projectCode: String,
val username: String,
val password: String,
val doneTransitionCode: Int
)
companion object {
val DEFAULT_APPROVE_INTERVAL = 5L // seconds
val DEFAULT_SIGN_INTERVAL = 5L // seconds
// TODO: Do we really need these defaults?
val DEFAULT_APPROVE_INTERVAL = 5.seconds
val DEFAULT_SIGN_INTERVAL = 5.seconds
}
}
data class CommandLineOptions(val configFile: Path,
val updateNetworkParametersFile: Path?,
val mode: DoormanParameters.Mode) {
init {
check(configFile.isRegularFile()) { "Config file $configFile does not exist" }
if (updateNetworkParametersFile != null) {
check(updateNetworkParametersFile.isRegularFile()) { "Update network parameters file $updateNetworkParametersFile does not exist" }
if (updateNetworkParameters != null) {
check(updateNetworkParameters.isRegularFile()) { "Update network parameters file $updateNetworkParameters does not exist" }
}
}
}
data class DoormanConfig(val approveAll: Boolean = false,
val jiraConfig: JiraConfig? = null,
val approveInterval: Long = NetworkManagementServerParameters.DEFAULT_APPROVE_INTERVAL.toMillis())
data class NetworkMapConfig(val cacheTimeout: Long,
// TODO: Move signing to signing server.
val signInterval: Long = NetworkManagementServerParameters.DEFAULT_SIGN_INTERVAL.toMillis())
enum class Mode {
DOORMAN, CA_KEYGEN, ROOT_KEYGEN
}
data class JiraConfig(
val address: String,
val projectCode: String,
val username: String,
val password: String,
val doneTransitionCode: Int
)
/**
* Parses the doorman command line options.
*/
fun parseCommandLine(vararg args: String): CommandLineOptions {
val optionParser = OptionParser()
val configFileArg = optionParser
.accepts("config-file", "The path to the config file")
.withRequiredArg()
.describedAs("filepath")
val updateNetworkParametersArg = optionParser
.accepts("update-network-parameters", "Update network parameters filepath. Currently only network parameters initialisation is supported.")
.withRequiredArg()
.describedAs("The new network map")
.describedAs("filepath")
val modeArg = optionParser
.accepts("mode", "Set the mode of this application")
.withRequiredArg()
.withValuesConvertedBy(object : EnumConverter<DoormanParameters.Mode>(DoormanParameters.Mode::class.java) {})
.defaultsTo(DoormanParameters.Mode.DOORMAN)
val helpOption = optionParser.acceptsAll(listOf("h", "?", "help"), "show help").forHelp()
val optionSet = optionParser.parse(*args)
// Print help and exit on help option or if there are missing options.
if (optionSet.has(helpOption) || !optionSet.has(configFileArg)) {
throw ShowHelpException(optionParser)
fun parseParameters(vararg args: String): NetworkManagementServerParameters {
val argConfig = args.toConfigWithOptions {
accepts("config-file", "The path to the config file")
.withRequiredArg()
.describedAs("filepath")
accepts("update-network-parameters", "Update network parameters filepath. Currently only network parameters initialisation is supported.")
.withRequiredArg()
.describedAs("The new network map")
.describedAs("filepath")
accepts("mode", "Set the mode of this application")
.withRequiredArg()
.defaultsTo(Mode.DOORMAN.name)
}
val configFile = Paths.get(optionSet.valueOf(configFileArg)).toAbsolutePath()
val updateNetworkParametersOptionValue = optionSet.valueOf(updateNetworkParametersArg)
val updateNetworkParameters = updateNetworkParametersOptionValue?.let {
Paths.get(it).toAbsolutePath()
val configFile = if (argConfig.hasPath("configFile")) {
Paths.get(argConfig.getString("configFile"))
} else {
Paths.get(".") / "network-management.conf"
}
check(configFile.isRegularFile()) { "Config file $configFile does not exist" }
return CommandLineOptions(configFile, updateNetworkParameters, optionSet.valueOf(modeArg))
}
/**
* Parses a configuration file, which contains all the configuration except the initial values for the network
* parameters.
*/
fun parseParameters(configFile: Path, overrides: Config = ConfigFactory.empty()): DoormanParameters {
val config = ConfigFactory
.parseFile(configFile.toFile(), ConfigParseOptions.defaults().setAllowMissing(true))
return argConfig.withFallback(ConfigFactory.parseFile(configFile.toFile(), ConfigParseOptions.defaults().setAllowMissing(true)))
.resolve()
return overrides
.withFallback(config)
.parseAs()
}

View File

@ -5,88 +5,158 @@ import com.r3.corda.networkmanage.common.persistence.*
import com.r3.corda.networkmanage.common.persistence.CertificationRequestStorage.Companion.DOORMAN_SIGNATURE
import com.r3.corda.networkmanage.common.signer.NetworkMapSigner
import com.r3.corda.networkmanage.common.utils.ShowHelpException
import com.r3.corda.networkmanage.doorman.DoormanServer.Companion.logger
import com.r3.corda.networkmanage.doorman.signer.DefaultCsrHandler
import com.r3.corda.networkmanage.doorman.signer.JiraCsrHandler
import com.r3.corda.networkmanage.doorman.signer.LocalSigner
import com.r3.corda.networkmanage.doorman.webservice.MonitoringWebService
import com.r3.corda.networkmanage.doorman.webservice.NodeInfoWebService
import com.r3.corda.networkmanage.doorman.webservice.RegistrationWebService
import net.corda.client.rpc.internal.KryoClientSerializationScheme
import net.corda.core.crypto.Crypto
import net.corda.core.identity.CordaX500Name
import net.corda.core.internal.createDirectories
import net.corda.core.internal.div
import net.corda.core.serialization.internal.SerializationEnvironmentImpl
import net.corda.core.serialization.internal.nodeSerializationEnv
import net.corda.core.utilities.NetworkHostAndPort
import net.corda.core.utilities.loggerFor
import net.corda.nodeapi.internal.NetworkParameters
import net.corda.nodeapi.internal.crypto.*
import net.corda.nodeapi.internal.persistence.CordaPersistence
import net.corda.nodeapi.internal.serialization.KRYO_P2P_CONTEXT
import net.corda.nodeapi.internal.serialization.SerializationFactoryImpl
import net.corda.nodeapi.internal.serialization.amqp.AMQPClientSerializationScheme
import org.bouncycastle.pkcs.PKCS10CertificationRequest
import org.eclipse.jetty.server.Server
import org.eclipse.jetty.server.ServerConnector
import org.eclipse.jetty.server.handler.HandlerCollection
import org.eclipse.jetty.servlet.ServletContextHandler
import org.eclipse.jetty.servlet.ServletHolder
import org.glassfish.jersey.server.ResourceConfig
import org.glassfish.jersey.servlet.ServletContainer
import java.io.Closeable
import java.net.InetSocketAddress
import java.net.URI
import java.nio.file.Path
import java.security.cert.X509Certificate
import java.time.Instant
import java.util.*
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
import kotlin.system.exitProcess
/**
* DoormanServer runs on Jetty server and provides certificate signing service via http.
* The server will require keystorePath, keystore password and key password via command line input.
* The Intermediate CA certificate,Intermediate CA private key and Root CA Certificate should use alias name specified in [X509Utilities]
*/
// TODO: Move this class to its own file.
class DoormanServer(hostAndPort: NetworkHostAndPort, private vararg val webServices: Any) : Closeable {
class NetworkManagementServer : Closeable {
private val doOnClose = mutableListOf<() -> Unit>()
lateinit var hostAndPort: NetworkHostAndPort
override fun close() = doOnClose.forEach { it() }
companion object {
val logger = loggerFor<DoormanServer>()
val serverStatus = DoormanServerStatus()
private val logger = loggerFor<NetworkManagementServer>()
}
private val server: Server = Server(InetSocketAddress(hostAndPort.host, hostAndPort.port)).apply {
handler = HandlerCollection().apply {
addHandler(buildServletContextHandler())
}
}
private fun getNetworkMapService(config: NetworkMapConfig, database: CordaPersistence, signer: LocalSigner?, updateNetworkParameters: NetworkParameters?): NodeInfoWebService {
val networkMapStorage = PersistentNetworkMapStorage(database)
val nodeInfoStorage = PersistentNodeInfoStorage(database)
val hostAndPort: NetworkHostAndPort
get() = server.connectors.mapNotNull { it as? ServerConnector }
.map { NetworkHostAndPort(it.host, it.localPort) }
.first()
override fun close() {
logger.info("Shutting down Doorman Web Services...")
server.stop()
server.join()
}
fun start() {
logger.info("Starting Doorman Web Services...")
server.start()
logger.info("Doorman Web Services started on $hostAndPort")
}
private fun buildServletContextHandler(): ServletContextHandler {
return ServletContextHandler().apply {
contextPath = "/"
val resourceConfig = ResourceConfig().apply {
// Add your API provider classes (annotated for JAX-RS) here
webServices.forEach { register(it) }
updateNetworkParameters?.let {
// Persisting new network parameters
val currentNetworkParameters = networkMapStorage.getCurrentNetworkParameters()
if (currentNetworkParameters == null) {
networkMapStorage.saveNetworkParameters(it)
} else {
throw UnsupportedOperationException("Network parameters already exist. Updating them via the file config is not supported yet.")
}
val jerseyServlet = ServletHolder(ServletContainer(resourceConfig)).apply { initOrder = 0 }// Initialise at server start
addServlet(jerseyServlet, "/*")
}
// This call will fail if parameter is null in DB.
try {
val latestParameter = networkMapStorage.getLatestNetworkParameters()
logger.info("Starting network map service with network parameters : $latestParameter")
} catch (e: NoSuchElementException) {
logger.error("No network parameter found, please upload new network parameter before starting network map service. The server will now exit.")
exitProcess(-1)
}
val networkMapSigner = if (signer != null) NetworkMapSigner(networkMapStorage, signer) else null
// Thread sign network map in case of change (i.e. a new node info has been added or a node info has been removed).
if (networkMapSigner != null) {
val scheduledExecutor = Executors.newScheduledThreadPool(1)
val signingThread = Runnable {
try {
networkMapSigner.signNetworkMap()
} catch (e: Exception) {
// Log the error and carry on.
logger.error("Error encountered when processing node info changes.", e)
}
}
scheduledExecutor.scheduleAtFixedRate(signingThread, config.signInterval, config.signInterval, TimeUnit.MILLISECONDS)
doOnClose += { scheduledExecutor.shutdown() }
}
return NodeInfoWebService(nodeInfoStorage, networkMapStorage, config)
}
private fun getDoormanService(config: DoormanConfig, database: CordaPersistence, signer: LocalSigner?, serverStatus: NetworkManagementServerStatus): RegistrationWebService {
logger.info("Starting Doorman server.")
val requestService = if (config.approveAll) {
logger.warn("Doorman server is in 'Approve All' mode, this will approve all incoming certificate signing requests.")
ApproveAllCertificateRequestStorage(PersistentCertificateRequestStorage(database))
} else {
PersistentCertificateRequestStorage(database)
}
val jiraConfig = config.jiraConfig
val requestProcessor = if (jiraConfig != null) {
val jiraWebAPI = AsynchronousJiraRestClientFactory().createWithBasicHttpAuthentication(URI(jiraConfig.address), jiraConfig.username, jiraConfig.password)
val jiraClient = JiraClient(jiraWebAPI, jiraConfig.projectCode, jiraConfig.doneTransitionCode)
JiraCsrHandler(jiraClient, requestService, DefaultCsrHandler(requestService, signer))
} else {
DefaultCsrHandler(requestService, signer)
}
val scheduledExecutor = Executors.newScheduledThreadPool(1)
val approvalThread = Runnable {
try {
serverStatus.lastRequestCheckTime = Instant.now()
// Create tickets for requests which don't have one yet.
requestProcessor.createTickets()
// Process Jira approved tickets.
requestProcessor.processApprovedRequests()
} catch (e: Exception) {
// Log the error and carry on.
logger.error("Error encountered when approving request.", e)
}
}
scheduledExecutor.scheduleAtFixedRate(approvalThread, config.approveInterval, config.approveInterval, TimeUnit.MILLISECONDS)
doOnClose += { scheduledExecutor.shutdown() }
return RegistrationWebService(requestProcessor)
}
fun start(hostAndPort: NetworkHostAndPort,
database: CordaPersistence,
signer: LocalSigner? = null,
updateNetworkParameters: NetworkParameters?,
networkMapServiceParameter: NetworkMapConfig?,
doormanServiceParameter: DoormanConfig?) {
val services = mutableListOf<Any>()
val serverStatus = NetworkManagementServerStatus()
// TODO: move signing to signing server.
networkMapServiceParameter?.let { services += getNetworkMapService(it, database, signer, updateNetworkParameters) }
doormanServiceParameter?.let { services += getDoormanService(it, database, signer, serverStatus) }
require(services.isNotEmpty()) { "No service created, please provide at least one service config." }
// TODO: use mbean to expose audit data?
services += MonitoringWebService(serverStatus)
val webServer = NetworkManagementWebServer(hostAndPort, *services.toTypedArray())
webServer.start()
doOnClose += { webServer.close() }
this.hostAndPort = webServer.hostAndPort
}
}
data class DoormanServerStatus(var serverStartTime: Instant = Instant.now(), var lastRequestCheckTime: Instant? = null)
data class NetworkManagementServerStatus(var serverStartTime: Instant = Instant.now(), var lastRequestCheckTime: Instant? = null)
/** Read password from console, do a readLine instead if console is null (e.g. when debugging in IDE). */
internal fun readPassword(fmt: String): String {
@ -121,6 +191,9 @@ fun generateRootKeyPair(rootStorePath: Path, rootKeystorePass: String?, rootPriv
rootStore.addOrReplaceKey(X509Utilities.CORDA_ROOT_CA, selfSignKey.private, rootPrivateKeyPassword.toCharArray(), arrayOf(selfSignCert))
rootStore.save(rootStorePath, rootKeystorePassword)
// TODO: remove this once we create truststore for nodes.
X509Utilities.saveCertificateAsPEMFile(selfSignCert, rootStorePath.parent / "rootcert.pem")
println("Root CA keypair and certificate stored in ${rootStorePath.toAbsolutePath()}.")
println(loadKeyStore(rootStorePath, rootKeystorePassword).getCertificate(X509Utilities.CORDA_ROOT_CA).publicKey)
}
@ -157,85 +230,8 @@ fun generateCAKeyPair(keystorePath: Path, rootStorePath: Path, rootKeystorePass:
println(loadKeyStore(keystorePath, keystorePassword).getCertificate(X509Utilities.CORDA_INTERMEDIATE_CA).publicKey)
}
// TODO: Move this method to DoormanServer.
fun startDoorman(hostAndPort: NetworkHostAndPort,
database: CordaPersistence,
approveAll: Boolean,
networkMapParameters: NetworkParameters?,
signer: LocalSigner? = null,
approveInterval: Long,
signInterval: Long,
jiraConfig: DoormanParameters.JiraConfig? = null): DoormanServer {
logger.info("Starting Doorman server.")
val requestService = if (approveAll) {
logger.warn("Doorman server is in 'Approve All' mode, this will approve all incoming certificate signing requests.")
ApproveAllCertificateRequestStorage(PersistentCertificateRequestStorage(database))
} else {
PersistentCertificateRequestStorage(database)
}
val requestProcessor = if (jiraConfig != null) {
val jiraWebAPI = AsynchronousJiraRestClientFactory().createWithBasicHttpAuthentication(URI(jiraConfig.address), jiraConfig.username, jiraConfig.password)
val jiraClient = JiraClient(jiraWebAPI, jiraConfig.projectCode, jiraConfig.doneTransitionCode)
JiraCsrHandler(jiraClient, requestService, DefaultCsrHandler(requestService, signer))
} else {
DefaultCsrHandler(requestService, signer)
}
val networkMapStorage = PersistentNetworkMapStorage(database)
val nodeInfoStorage = PersistentNodeInfoStorage(database)
if (networkMapParameters != null) {
// Persisting new network parameters
val currentNetworkParameters = networkMapStorage.getCurrentNetworkParameters()
if (currentNetworkParameters == null) {
networkMapStorage.saveNetworkParameters(networkMapParameters)
} else {
throw UnsupportedOperationException("Network parameters already exist. Updating them via the file config is not supported yet.")
}
}
val doorman = DoormanServer(hostAndPort, RegistrationWebService(requestProcessor, DoormanServer.serverStatus), NodeInfoWebService(nodeInfoStorage, networkMapStorage))
doorman.start()
val networkMapSigner = if (signer != null) NetworkMapSigner(networkMapStorage, signer) else null
// Thread process approved request periodically.
val scheduledExecutor = Executors.newScheduledThreadPool(2)
val approvalThread = Runnable {
try {
DoormanServer.serverStatus.lastRequestCheckTime = Instant.now()
// Create tickets for requests which don't have one yet.
requestProcessor.createTickets()
// Process Jira approved tickets.
requestProcessor.processApprovedRequests()
} catch (e: Exception) {
// Log the error and carry on.
DoormanServer.logger.error("Error encountered when approving request.", e)
}
}
scheduledExecutor.scheduleAtFixedRate(approvalThread, approveInterval, approveInterval, TimeUnit.SECONDS)
// Thread sign network map in case of change (i.e. a new node info has been added or a node info has been removed).
if (networkMapSigner != null) {
val signingThread = Runnable {
try {
networkMapSigner.signNetworkMap()
} catch (e: Exception) {
// Log the error and carry on.
DoormanServer.logger.error("Error encountered when processing node info changes.", e)
}
}
scheduledExecutor.scheduleAtFixedRate(signingThread, signInterval, signInterval, TimeUnit.SECONDS)
}
Runtime.getRuntime().addShutdownHook(thread(start = false) {
scheduledExecutor.shutdown()
doorman.close()
})
return doorman
}
private fun buildLocalSigner(parameters: DoormanParameters): LocalSigner? {
private fun buildLocalSigner(parameters: NetworkManagementServerParameters): LocalSigner? {
return parameters.keystorePath?.let {
// Get password from console if not in config.
val keystorePassword = parameters.keystorePassword ?: readPassword("Keystore Password: ")
@ -261,33 +257,55 @@ private class ApproveAllCertificateRequestStorage(private val delegate: Certific
fun main(args: Array<String>) {
try {
val commandLineOptions = parseCommandLine(*args)
val mode = commandLineOptions.mode
parseParameters(commandLineOptions.configFile).run {
parseParameters(*args).run {
println("Starting in $mode mode")
when (mode) {
DoormanParameters.Mode.ROOT_KEYGEN -> generateRootKeyPair(
Mode.ROOT_KEYGEN -> generateRootKeyPair(
rootStorePath ?: throw IllegalArgumentException("The 'rootStorePath' parameter must be specified when generating keys!"),
rootKeystorePassword,
rootPrivateKeyPassword)
DoormanParameters.Mode.CA_KEYGEN -> generateCAKeyPair(
Mode.CA_KEYGEN -> generateCAKeyPair(
keystorePath ?: throw IllegalArgumentException("The 'keystorePath' parameter must be specified when generating keys!"),
rootStorePath ?: throw IllegalArgumentException("The 'rootStorePath' parameter must be specified when generating keys!"),
rootKeystorePassword,
rootPrivateKeyPassword,
keystorePassword,
caPrivateKeyPassword)
DoormanParameters.Mode.DOORMAN -> {
Mode.DOORMAN -> {
initialiseSerialization()
val database = configureDatabase(dataSourceProperties)
// TODO: move signing to signing server.
val signer = buildLocalSigner(this)
val networkParameters = commandLineOptions.updateNetworkParametersFile?.let {
if (signer != null) {
println("Starting network management services with local signer.")
}
val networkManagementServer = NetworkManagementServer()
val networkParameter = updateNetworkParameters?.let {
println("Parsing network parameter from '${it.fileName}'...")
parseNetworkParametersFrom(it)
}
startDoorman(NetworkHostAndPort(host, port), database, approveAll, networkParameters, signer, approveInterval, signInterval, jiraConfig)
networkManagementServer.start(NetworkHostAndPort(host, port), database, signer, networkParameter, networkMapConfig, doormanConfig)
Runtime.getRuntime().addShutdownHook(thread(start = false) {
networkManagementServer.close()
})
}
}
}
} catch (e: ShowHelpException) {
e.errorMessage?.let(::println)
e.parser.printHelpOn(System.out)
}
}
private fun initialiseSerialization() {
val context = KRYO_P2P_CONTEXT
nodeSerializationEnv = SerializationEnvironmentImpl(
SerializationFactoryImpl().apply {
registerScheme(KryoClientSerializationScheme())
registerScheme(AMQPClientSerializationScheme())
},
context)
}

View File

@ -0,0 +1,58 @@
package com.r3.corda.networkmanage.doorman
import net.corda.core.utilities.NetworkHostAndPort
import net.corda.core.utilities.loggerFor
import org.eclipse.jetty.server.Server
import org.eclipse.jetty.server.ServerConnector
import org.eclipse.jetty.server.handler.HandlerCollection
import org.eclipse.jetty.servlet.ServletContextHandler
import org.eclipse.jetty.servlet.ServletHolder
import org.glassfish.jersey.server.ResourceConfig
import org.glassfish.jersey.servlet.ServletContainer
import java.io.Closeable
import java.net.InetSocketAddress
/**
* NetworkManagementWebServer runs on Jetty server and provides service via http.
*/
class NetworkManagementWebServer(hostAndPort: NetworkHostAndPort, private vararg val webServices: Any) : Closeable {
companion object {
val logger = loggerFor<NetworkManagementServer>()
}
private val server: Server = Server(InetSocketAddress(hostAndPort.host, hostAndPort.port)).apply {
handler = HandlerCollection().apply {
addHandler(buildServletContextHandler())
}
}
val hostAndPort: NetworkHostAndPort
get() = server.connectors.mapNotNull { it as? ServerConnector }
.map { NetworkHostAndPort(it.host, it.localPort) }
.first()
override fun close() {
logger.info("Shutting down network management web services...")
server.stop()
server.join()
}
fun start() {
logger.info("Starting network management web services...")
server.start()
logger.info("Network management web services started on $hostAndPort with ${webServices.map { it.javaClass.simpleName }}")
println("Network management web services started on $hostAndPort with ${webServices.map { it.javaClass.simpleName }}")
}
private fun buildServletContextHandler(): ServletContextHandler {
return ServletContextHandler().apply {
contextPath = "/"
val resourceConfig = ResourceConfig().apply {
// Add your API provider classes (annotated for JAX-RS) here
webServices.forEach { register(it) }
}
val jerseyServlet = ServletHolder(ServletContainer(resourceConfig)).apply { initOrder = 0 }// Initialise at server start
addServlet(jerseyServlet, "/*")
}
}
}

View File

@ -22,7 +22,7 @@ class DefaultCsrHandler(private val storage: CertificationRequestStorage, privat
.forEach { processRequest(it.requestId, it.request) }
}
override fun createTickets() { }
override fun createTickets() {}
private fun processRequest(requestId: String, request: PKCS10CertificationRequest) {
if (signer != null) {
@ -68,10 +68,18 @@ class JiraCsrHandler(private val jiraClient: JiraClient, private val storage: Ce
}
override fun processApprovedRequests() {
jiraClient.getApprovedRequests().forEach { (id, approvedBy) -> storage.approveRequest(id, approvedBy) }
val approvedRequest = jiraClient.getApprovedRequests()
approvedRequest.forEach { (id, approvedBy) -> storage.approveRequest(id, approvedBy) }
delegate.processApprovedRequests()
val signedRequests = storage.getRequests(RequestStatus.SIGNED).mapNotNull {
it.certData?.certPath?.let { certs -> it.requestId to certs }
val signedRequests = approvedRequest.mapNotNull { (id, _) ->
val request = storage.getRequest(id)
if (request != null && request.status == RequestStatus.SIGNED) {
request.certData?.certPath?.let { certs -> id to certs }
} else {
null
}
}.toMap()
jiraClient.updateSignedRequests(signedRequests)
}

View File

@ -0,0 +1,18 @@
package com.r3.corda.networkmanage.doorman.webservice
import com.r3.corda.networkmanage.doorman.NetworkManagementServerStatus
import org.codehaus.jackson.map.ObjectMapper
import javax.ws.rs.GET
import javax.ws.rs.Path
import javax.ws.rs.Produces
import javax.ws.rs.core.MediaType
import javax.ws.rs.core.Response
class MonitoringWebService(private val serverStatus: NetworkManagementServerStatus) {
@GET
@Path("status")
@Produces(MediaType.APPLICATION_JSON)
fun status(): Response {
return Response.ok(ObjectMapper().writeValueAsString(serverStatus)).build()
}
}

View File

@ -1,18 +1,23 @@
package com.r3.corda.networkmanage.doorman.webservice
import com.google.common.cache.CacheBuilder
import com.google.common.cache.CacheLoader
import com.google.common.cache.LoadingCache
import com.r3.corda.networkmanage.common.persistence.NetworkMapStorage
import com.r3.corda.networkmanage.common.persistence.NodeInfoStorage
import com.r3.corda.networkmanage.common.utils.hashString
import com.r3.corda.networkmanage.doorman.NetworkMapConfig
import com.r3.corda.networkmanage.doorman.webservice.NodeInfoWebService.Companion.NETWORK_MAP_PATH
import net.corda.core.crypto.Crypto
import net.corda.core.crypto.SecureHash
import net.corda.core.crypto.SignedData
import net.corda.core.node.NodeInfo
import net.corda.core.serialization.deserialize
import net.corda.core.serialization.serialize
import net.corda.nodeapi.internal.SignedNetworkMap
import java.io.InputStream
import java.security.InvalidKeyException
import java.security.SignatureException
import java.time.Duration
import java.util.concurrent.TimeUnit
import javax.servlet.http.HttpServletRequest
import javax.ws.rs.*
import javax.ws.rs.core.Context
@ -23,54 +28,45 @@ import javax.ws.rs.core.Response.status
@Path(NETWORK_MAP_PATH)
class NodeInfoWebService(private val nodeInfoStorage: NodeInfoStorage,
private val networkMapStorage: NetworkMapStorage) {
private val networkMapStorage: NetworkMapStorage,
private val config: NetworkMapConfig) {
companion object {
const val NETWORK_MAP_PATH = "network-map"
}
private val networkMapCache: LoadingCache<Boolean, SignedNetworkMap?> = CacheBuilder.newBuilder()
.expireAfterWrite(config.cacheTimeout, TimeUnit.MILLISECONDS)
.build(CacheLoader.from { _ ->
networkMapStorage.getCurrentNetworkMap()
})
@POST
@Path("publish")
@Consumes(MediaType.APPLICATION_OCTET_STREAM)
fun registerNode(input: InputStream): Response {
val registrationData = input.readBytes().deserialize<SignedData<NodeInfo>>()
val nodeInfo = registrationData.verified()
val certPath = nodeInfoStorage.getCertificatePath(SecureHash.parse(nodeInfo.legalIdentitiesAndCerts.first().certPath.certificates.first().publicKey.hashString()))
return if (certPath != null) {
try {
val nodeCAPubKey = certPath.certificates.first().publicKey
// Validate node public key
nodeInfo.legalIdentitiesAndCerts.forEach {
require(it.certPath.certificates.any { it.publicKey == nodeCAPubKey })
}
val digitalSignature = registrationData.sig
require(Crypto.doVerify(nodeCAPubKey, digitalSignature.bytes, registrationData.raw.bytes))
// Store the NodeInfo
nodeInfoStorage.putNodeInfo(registrationData)
ok()
} catch (e: Exception) {
// Catch exceptions thrown by signature verification.
when (e) {
is IllegalArgumentException, is InvalidKeyException, is SignatureException -> status(Response.Status.UNAUTHORIZED).entity(e.message)
// Rethrow e if its not one of the expected exception, the server will return http 500 internal error.
else -> throw e
}
return try {
// Store the NodeInfo
nodeInfoStorage.putNodeInfo(registrationData)
ok()
} catch (e: Exception) {
// Catch exceptions thrown by signature verification.
when (e) {
is IllegalArgumentException, is InvalidKeyException, is SignatureException -> status(Response.Status.UNAUTHORIZED).entity(e.message)
// Rethrow e if its not one of the expected exception, the server will return http 500 internal error.
else -> throw e
}
} else {
status(Response.Status.BAD_REQUEST).entity("Unknown node info, this public key is not registered or approved by Corda Doorman.")
}.build()
}
@GET
fun getNetworkMap(): Response {
// TODO: Cache the response?
val currentNetworkMap = networkMapStorage.getCurrentNetworkMap()
val currentNetworkMap = networkMapCache.get(true)
return if (currentNetworkMap != null) {
ok(currentNetworkMap.serialize().bytes).build()
Response.ok(currentNetworkMap.serialize().bytes).header("Cache-Control", "max-age=${Duration.ofMillis(config.cacheTimeout).seconds}")
} else {
status(Response.Status.NOT_FOUND).build()
}
status(Response.Status.NOT_FOUND)
}.build()
}
@GET
@ -78,10 +74,10 @@ class NodeInfoWebService(private val nodeInfoStorage: NodeInfoStorage,
fun getNodeInfo(@PathParam("nodeInfoHash") nodeInfoHash: String): Response {
val nodeInfo = nodeInfoStorage.getNodeInfo(SecureHash.parse(nodeInfoHash))
return if (nodeInfo != null) {
ok(nodeInfo.serialize().bytes).build()
ok(nodeInfo.serialize().bytes)
} else {
status(Response.Status.NOT_FOUND).build()
}
status(Response.Status.NOT_FOUND)
}.build()
}
@GET

View File

@ -1,13 +1,11 @@
package com.r3.corda.networkmanage.doorman.webservice
import com.r3.corda.networkmanage.common.persistence.CertificateResponse
import com.r3.corda.networkmanage.doorman.DoormanServerStatus
import com.r3.corda.networkmanage.doorman.signer.CsrHandler
import net.corda.nodeapi.internal.crypto.X509Utilities.CORDA_CLIENT_CA
import net.corda.nodeapi.internal.crypto.X509Utilities.CORDA_INTERMEDIATE_CA
import net.corda.nodeapi.internal.crypto.X509Utilities.CORDA_ROOT_CA
import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequest
import org.codehaus.jackson.map.ObjectMapper
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.util.zip.ZipEntry
@ -24,7 +22,7 @@ import javax.ws.rs.core.Response.Status.UNAUTHORIZED
* Provides functionality for asynchronous submission of certificate signing requests and retrieval of the results.
*/
@Path("certificate")
class RegistrationWebService(private val csrHandler: CsrHandler, private val serverStatus: DoormanServerStatus) {
class RegistrationWebService(private val csrHandler: CsrHandler) {
@Context lateinit var request: HttpServletRequest
/**
* Accept stream of [PKCS10CertificationRequest] from user and persists in [CertificateRequestStorage] for approval.
@ -69,11 +67,4 @@ class RegistrationWebService(private val csrHandler: CsrHandler, private val ser
is CertificateResponse.Unauthorised -> status(UNAUTHORIZED).entity(response.message)
}.build()
}
@GET
@Path("status")
@Produces(MediaType.APPLICATION_JSON)
fun status(): Response {
return ok(ObjectMapper().writeValueAsString(serverStatus)).build()
}
}

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="info">
<Properties>
<Property name="log-path">logs</Property>
<Property name="log-name">node-${hostName}</Property>
<Property name="archive">${sys:log-path}/archive</Property>
<Property name="consoleLogLevel">error</Property>
<Property name="defaultLogLevel">info</Property>
</Properties>
<ThresholdFilter level="trace"/>
<Appenders>
<Console name="Console-Appender" target="SYSTEM_OUT">
<PatternLayout pattern="%highlight{%level{length=1} %date{HH:mm:ssZ} [%t] %c{2}.%method - %msg %X%n}{INFO=white,WARN=red,FATAL=bright red}" />
</Console>
<!-- Required for printBasicInfo -->
<Console name="Console-Appender-Println" target="SYSTEM_OUT">
<PatternLayout pattern="%msg%n" />
</Console>
<!-- Will generate up to 10 log files for a given day. During every rollover it will delete
those that are older than 60 days, but keep the most recent 10 GB -->
<RollingFile name="RollingFile-Appender"
fileName="${sys:log-path}/${log-name}.log"
filePattern="${archive}/${log-name}.%date{yyyy-MM-dd}-%i.log.gz">
<PatternLayout pattern="[%-5level] %date{ISO8601}{UTC}Z [%t] %c{2}.%method - %msg %X%n"/>
<Policies>
<TimeBasedTriggeringPolicy/>
<SizeBasedTriggeringPolicy size="10MB"/>
</Policies>
<DefaultRolloverStrategy min="1" max="10">
<Delete basePath="${archive}" maxDepth="1">
<IfFileName glob="${log-name}*.log.gz"/>
<IfLastModified age="60d">
<IfAny>
<IfAccumulatedFileSize exceeds="10 GB"/>
</IfAny>
</IfLastModified>
</Delete>
</DefaultRolloverStrategy>
</RollingFile>
</Appenders>
<Loggers>
<Root level="${sys:defaultLogLevel}">
<AppenderRef ref="Console-Appender" level="${sys:consoleLogLevel}"/>
<AppenderRef ref="RollingFile-Appender" />
</Root>
<Logger name="BasicInfo" additivity="false">
<AppenderRef ref="Console-Appender-Println"/>
<AppenderRef ref="RollingFile-Appender" />
</Logger>
<Logger name="org.apache.activemq.artemis.core.server" level="error" additivity="false">
<AppenderRef ref="RollingFile-Appender"/>
</Logger>
</Loggers>
</Configuration>

View File

@ -67,26 +67,6 @@ class DBCertificateRequestStorageTest : TestBase() {
assertTrue(storage.getRequests(RequestStatus.NEW).isEmpty())
}
@Test
fun `approve request ignores subsequent approvals`() {
// Given
val (request, _) = createRequest("LegalName")
// Add request to DB.
val requestId = storage.saveRequest(request)
storage.markRequestTicketCreated(requestId)
storage.approveRequest(requestId, "ApproverA")
var thrown: Exception? = null
// When subsequent approval is performed
try {
storage.approveRequest(requestId, "ApproverB")
} catch (e: IllegalArgumentException) {
thrown = e
}
// Then check request has not been approved
assertNotNull(thrown)
}
@Test
fun `sign request`() {
val (csr, _) = createRequest("LegalName")

View File

@ -5,6 +5,7 @@ import com.typesafe.config.ConfigException
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import java.io.File
import java.lang.reflect.InvocationTargetException
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@ -16,16 +17,16 @@ class DoormanParametersTest {
@Test
fun `should fail when initial network parameters file is missing`() {
val message = assertFailsWith<IllegalStateException> {
parseCommandLine("--config-file", validConfigPath, "--update-network-parameters", "not-here")
}.message
val message = assertFailsWith<InvocationTargetException> {
parseParameters("--config-file", validConfigPath, "--update-network-parameters", "not-here")
}.targetException.message
assertThat(message).contains("Update network parameters file ")
}
@Test
fun `should fail when config file is missing`() {
val message = assertFailsWith<IllegalStateException> {
parseCommandLine("--config-file", "not-existing-file")
parseParameters("--config-file", "not-existing-file")
}.message
assertThat(message).contains("Config file ")
}
@ -33,28 +34,24 @@ class DoormanParametersTest {
@Test
fun `should throw ShowHelpException when help option is passed on the command line`() {
assertFailsWith<ShowHelpException> {
parseCommandLine("-?")
parseParameters("-?")
}
}
@Test
fun `should fail when config missing`() {
assertFailsWith<ConfigException.Missing> {
parseParameters(parseCommandLine("--config-file", invalidConfigPath).configFile)
parseParameters("--config-file", invalidConfigPath)
}
}
@Test
fun `should parse jira config correctly`() {
val parameter = parseCommandLineAndGetParameters()
val parameter = parseParameters(*validArgs).doormanConfig!!
assertEquals("https://doorman-jira-host.com/", parameter.jiraConfig?.address)
assertEquals("TD", parameter.jiraConfig?.projectCode)
assertEquals("username", parameter.jiraConfig?.username)
assertEquals("password", parameter.jiraConfig?.password)
assertEquals(41, parameter.jiraConfig?.doneTransitionCode)
}
private fun parseCommandLineAndGetParameters(): DoormanParameters {
return parseParameters(parseCommandLine(*validArgs).configFile)
}
}

View File

@ -18,6 +18,7 @@ import net.corda.core.node.NodeInfo
import net.corda.core.serialization.deserialize
import net.corda.core.serialization.serialize
import net.corda.core.utilities.NetworkHostAndPort
import net.corda.core.utilities.seconds
import net.corda.nodeapi.internal.NetworkMap
import net.corda.nodeapi.internal.SignedNetworkMap
import net.corda.nodeapi.internal.crypto.CertificateType
@ -44,6 +45,7 @@ class NodeInfoWebServiceTest {
private val intermediateCAKey = Crypto.generateKeyPair(X509Utilities.DEFAULT_TLS_SIGNATURE_SCHEME)
private val intermediateCACert = X509Utilities.createCertificate(CertificateType.INTERMEDIATE_CA, rootCACert, rootCAKey, X500Name("CN=Corda Node Intermediate CA,L=London"), intermediateCAKey.public)
private val testNetwotkMapConfig = NetworkMapConfig(10.seconds.toMillis(), 10.seconds.toMillis())
@Test
fun `submit nodeInfo`() {
// Create node info.
@ -59,41 +61,12 @@ class NodeInfoWebServiceTest {
on { getCertificatePath(any()) }.thenReturn(certPath)
}
DoormanServer(NetworkHostAndPort("localhost", 0), NodeInfoWebService(nodeInfoStorage, mock())).use {
NetworkManagementWebServer(NetworkHostAndPort("localhost", 0), NodeInfoWebService(nodeInfoStorage, mock(), testNetwotkMapConfig)).use {
it.start()
val registerURL = URL("http://${it.hostAndPort}/${NodeInfoWebService.NETWORK_MAP_PATH}/publish")
val nodeInfoAndSignature = SignedData(nodeInfo.serialize(), digitalSignature).serialize().bytes
// Post node info and signature to doorman
// Post node info and signature to doorman, this should pass without any exception.
doPost(registerURL, nodeInfoAndSignature)
verify(nodeInfoStorage, times(1)).getCertificatePath(any())
}
}
@Test
fun `submit nodeInfo with invalid signature`() {
// Create node info.
val keyPair = Crypto.generateKeyPair(X509Utilities.DEFAULT_TLS_SIGNATURE_SCHEME)
val clientCert = X509Utilities.createCertificate(CertificateType.CLIENT_CA, intermediateCACert, intermediateCAKey, CordaX500Name(organisation = "Test", locality = "London", country = "GB"), keyPair.public)
val certPath = buildCertPath(clientCert.toX509Certificate(), intermediateCACert.toX509Certificate(), rootCACert.toX509Certificate())
val nodeInfo = NodeInfo(listOf(NetworkHostAndPort("my.company.com", 1234)), listOf(PartyAndCertificate(certPath)), 1, serial = 1L)
// Create digital signature.
val attackerKeyPair = Crypto.generateKeyPair(X509Utilities.DEFAULT_TLS_SIGNATURE_SCHEME)
val digitalSignature = DigitalSignature.WithKey(attackerKeyPair.public, Crypto.doSign(attackerKeyPair.private, nodeInfo.serialize().bytes))
val nodeInfoStorage: NodeInfoStorage = mock {
on { getCertificatePath(any()) }.thenReturn(certPath)
}
DoormanServer(NetworkHostAndPort("localhost", 0), NodeInfoWebService(nodeInfoStorage, mock())).use {
it.start()
val registerURL = URL("http://${it.hostAndPort}/${NodeInfoWebService.NETWORK_MAP_PATH}/publish")
val nodeInfoAndSignature = SignedData(nodeInfo.serialize(), digitalSignature).serialize().bytes
// Post node info and signature to doorman
assertFailsWith(IOException::class) {
doPost(registerURL, nodeInfoAndSignature)
}
verify(nodeInfoStorage, times(1)).getCertificatePath(any())
}
}
@ -104,7 +77,7 @@ class NodeInfoWebServiceTest {
val networkMapStorage: NetworkMapStorage = mock {
on { getCurrentNetworkMap() }.thenReturn(SignedNetworkMap(serializedNetworkMap, intermediateCAKey.sign(serializedNetworkMap).withCert(intermediateCACert.cert)))
}
DoormanServer(NetworkHostAndPort("localhost", 0), NodeInfoWebService(mock(), networkMapStorage)).use {
NetworkManagementWebServer(NetworkHostAndPort("localhost", 0), NodeInfoWebService(mock(), networkMapStorage, testNetwotkMapConfig)).use {
it.start()
val conn = URL("http://${it.hostAndPort}/${NodeInfoWebService.NETWORK_MAP_PATH}").openConnection() as HttpURLConnection
val signedNetworkMap = conn.inputStream.readBytes().deserialize<SignedNetworkMap>()
@ -127,7 +100,7 @@ class NodeInfoWebServiceTest {
on { getNodeInfo(nodeInfoHash) }.thenReturn(SignedData(serializedNodeInfo, keyPair.sign(serializedNodeInfo)))
}
DoormanServer(NetworkHostAndPort("localhost", 0), NodeInfoWebService(nodeInfoStorage, mock())).use {
NetworkManagementWebServer(NetworkHostAndPort("localhost", 0), NodeInfoWebService(nodeInfoStorage, mock(), testNetwotkMapConfig)).use {
it.start()
val nodeInfoURL = URL("http://${it.hostAndPort}/${NodeInfoWebService.NETWORK_MAP_PATH}/node-info/$nodeInfoHash")
val conn = nodeInfoURL.openConnection()

View File

@ -42,16 +42,16 @@ class RegistrationWebServiceTest : TestBase() {
private val rootCACert = X509Utilities.createSelfSignedCACertificate(CordaX500Name(commonName = "Corda Node Root CA", locality = "London", organisation = "R3 Ltd", country = "GB"), rootCAKey)
private val intermediateCAKey = Crypto.generateKeyPair(X509Utilities.DEFAULT_TLS_SIGNATURE_SCHEME)
private val intermediateCACert = X509Utilities.createCertificate(CertificateType.INTERMEDIATE_CA, rootCACert, rootCAKey, X500Name("CN=Corda Node Intermediate CA,L=London"), intermediateCAKey.public)
private lateinit var doormanServer: DoormanServer
private lateinit var webServer: NetworkManagementWebServer
private fun startSigningServer(csrHandler: CsrHandler) {
doormanServer = DoormanServer(NetworkHostAndPort("localhost", 0), RegistrationWebService(csrHandler, DoormanServerStatus()))
doormanServer.start()
webServer = NetworkManagementWebServer(NetworkHostAndPort("localhost", 0), RegistrationWebService(csrHandler))
webServer.start()
}
@After
fun close() {
doormanServer.close()
webServer.close()
}
@Test
@ -169,7 +169,7 @@ class RegistrationWebServiceTest : TestBase() {
}
private fun submitRequest(request: PKCS10CertificationRequest): String {
val conn = URL("http://${doormanServer.hostAndPort}/certificate").openConnection() as HttpURLConnection
val conn = URL("http://${webServer.hostAndPort}/certificate").openConnection() as HttpURLConnection
conn.doOutput = true
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", MediaType.APPLICATION_OCTET_STREAM)
@ -178,7 +178,7 @@ class RegistrationWebServiceTest : TestBase() {
}
private fun pollForResponse(id: String): PollResponse {
val url = URL("http://${doormanServer.hostAndPort}/certificate/$id")
val url = URL("http://${webServer.hostAndPort}/certificate/$id")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "GET"