mirror of
https://github.com/corda/corda.git
synced 2024-12-19 04:57:58 +00:00
Non-ssl artemis acceptor for RPC connection. (#271)
* New non-ssl acceptor in artemis server for RPC connection.
This commit is contained in:
parent
ce2da918c8
commit
f0d82e4918
@ -12,10 +12,10 @@ import net.corda.flows.CashIssueFlow
|
||||
import net.corda.flows.CashPaymentFlow
|
||||
import net.corda.node.internal.Node
|
||||
import net.corda.node.services.User
|
||||
import net.corda.node.services.config.configureTestSSL
|
||||
import net.corda.node.services.messaging.CordaRPCClient
|
||||
import net.corda.node.services.startFlowPermission
|
||||
import net.corda.node.services.transactions.ValidatingNotaryService
|
||||
import net.corda.testing.configureTestSSL
|
||||
import net.corda.testing.node.NodeBasedTest
|
||||
import org.apache.activemq.artemis.api.core.ActiveMQSecurityException
|
||||
import org.assertj.core.api.Assertions.assertThatExceptionOfType
|
||||
@ -37,7 +37,7 @@ class CordaRPCClientTest : NodeBasedTest() {
|
||||
@Before
|
||||
fun setUp() {
|
||||
node = startNode("Alice", rpcUsers = listOf(rpcUser), advertisedServices = setOf(ServiceInfo(ValidatingNotaryService.type))).getOrThrow()
|
||||
client = CordaRPCClient(node.configuration.artemisAddress, configureTestSSL())
|
||||
client = CordaRPCClient(node.configuration.rpcAddress!!)
|
||||
}
|
||||
|
||||
@After
|
||||
|
@ -25,8 +25,6 @@ import net.corda.flows.CashPaymentFlow
|
||||
import net.corda.node.driver.DriverBasedTest
|
||||
import net.corda.node.driver.driver
|
||||
import net.corda.node.services.User
|
||||
import net.corda.node.services.config.configureTestSSL
|
||||
import net.corda.node.services.messaging.ArtemisMessagingComponent
|
||||
import net.corda.node.services.network.NetworkMapService
|
||||
import net.corda.node.services.startFlowPermission
|
||||
import net.corda.node.services.transactions.SimpleNotaryService
|
||||
@ -57,9 +55,10 @@ class NodeMonitorModelTest : DriverBasedTest() {
|
||||
)
|
||||
val aliceNodeFuture = startNode("Alice", rpcUsers = listOf(cashUser))
|
||||
val notaryNodeFuture = startNode("Notary", advertisedServices = setOf(ServiceInfo(SimpleNotaryService.type)))
|
||||
|
||||
aliceNode = aliceNodeFuture.getOrThrow().nodeInfo
|
||||
notaryNode = notaryNodeFuture.getOrThrow().nodeInfo
|
||||
val aliceNodeHandle = aliceNodeFuture.getOrThrow()
|
||||
val notaryNodeHandle = notaryNodeFuture.getOrThrow()
|
||||
aliceNode = aliceNodeHandle.nodeInfo
|
||||
notaryNode = notaryNodeHandle.nodeInfo
|
||||
newNode = { nodeName -> startNode(nodeName).getOrThrow().nodeInfo }
|
||||
val monitor = NodeMonitorModel()
|
||||
|
||||
@ -70,7 +69,7 @@ class NodeMonitorModelTest : DriverBasedTest() {
|
||||
vaultUpdates = monitor.vaultUpdates.bufferUntilSubscribed()
|
||||
networkMapUpdates = monitor.networkMap.bufferUntilSubscribed()
|
||||
|
||||
monitor.register(ArtemisMessagingComponent.toHostAndPort(aliceNode.address), configureTestSSL(), cashUser.username, cashUser.password)
|
||||
monitor.register(aliceNodeHandle.configuration.rpcAddress!!, cashUser.username, cashUser.password)
|
||||
rpc = monitor.proxyObservable.value!!
|
||||
runTest()
|
||||
}
|
||||
|
@ -52,8 +52,8 @@ class NodeMonitorModel {
|
||||
* Register for updates to/from a given vault.
|
||||
* TODO provide an unsubscribe mechanism
|
||||
*/
|
||||
fun register(nodeHostAndPort: HostAndPort, sslConfig: SSLConfiguration, username: String, password: String) {
|
||||
val client = CordaRPCClient(nodeHostAndPort, sslConfig){
|
||||
fun register(nodeHostAndPort: HostAndPort, username: String, password: String) {
|
||||
val client = CordaRPCClient(nodeHostAndPort){
|
||||
maxRetryInterval = 10.seconds.toMillis()
|
||||
}
|
||||
client.start(username, password)
|
||||
|
@ -6,6 +6,7 @@ import net.corda.node.services.messaging.ArtemisMessagingComponent.Companion.RPC
|
||||
import net.corda.testing.messaging.SimpleMQClient
|
||||
import org.apache.activemq.artemis.api.config.ActiveMQDefaultConfiguration
|
||||
import org.apache.activemq.artemis.api.core.ActiveMQClusterSecurityException
|
||||
import org.apache.activemq.artemis.api.core.ActiveMQNotConnectedException
|
||||
import org.apache.activemq.artemis.api.core.ActiveMQSecurityException
|
||||
import org.assertj.core.api.Assertions.assertThatExceptionOfType
|
||||
import org.junit.Test
|
||||
@ -14,6 +15,9 @@ import org.junit.Test
|
||||
* Runs the security tests with the attacker pretending to be a node on the network.
|
||||
*/
|
||||
class MQSecurityAsNodeTest : MQSecurityTest() {
|
||||
override fun createAttacker(): SimpleMQClient {
|
||||
return clientTo(alice.configuration.artemisAddress)
|
||||
}
|
||||
|
||||
override fun startAttacker(attacker: SimpleMQClient) {
|
||||
attacker.start(PEER_USER, PEER_USER) // Login as a peer
|
||||
@ -47,4 +51,20 @@ class MQSecurityAsNodeTest : MQSecurityTest() {
|
||||
attacker.start()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login to a non ssl port as a node user`() {
|
||||
val attacker = clientTo(alice.configuration.rpcAddress!!, sslConfiguration = null)
|
||||
assertThatExceptionOfType(ActiveMQSecurityException::class.java).isThrownBy {
|
||||
attacker.start(NODE_USER, NODE_USER)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login to a non ssl port as a peer user`() {
|
||||
val attacker = clientTo(alice.configuration.rpcAddress!!, sslConfiguration = null)
|
||||
assertThatExceptionOfType(ActiveMQSecurityException::class.java).isThrownBy {
|
||||
attacker.start(PEER_USER, PEER_USER) // Login as a peer
|
||||
}
|
||||
}
|
||||
}
|
@ -2,14 +2,35 @@ package net.corda.services.messaging
|
||||
|
||||
import net.corda.node.services.User
|
||||
import net.corda.testing.messaging.SimpleMQClient
|
||||
import org.apache.activemq.artemis.api.core.ActiveMQSecurityException
|
||||
import org.assertj.core.api.Assertions.assertThatExceptionOfType
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Runs the security tests with the attacker being a valid RPC user of Alice.
|
||||
*/
|
||||
class MQSecurityAsRPCTest : MQSecurityTest() {
|
||||
override fun createAttacker(): SimpleMQClient {
|
||||
return clientTo(alice.configuration.rpcAddress!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send message on logged in user's RPC address`() {
|
||||
val user1Queue = loginToRPCAndGetClientQueue()
|
||||
assertSendAttackFails(user1Queue)
|
||||
}
|
||||
|
||||
override val extraRPCUsers = listOf(User("evil", "pass", permissions = emptySet()))
|
||||
|
||||
override fun startAttacker(attacker: SimpleMQClient) {
|
||||
attacker.loginToRPC(extraRPCUsers[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login to a ssl port as a RPC user`() {
|
||||
val attacker = clientTo(alice.configuration.artemisAddress)
|
||||
assertThatExceptionOfType(ActiveMQSecurityException::class.java).isThrownBy {
|
||||
attacker.loginToRPC(extraRPCUsers[0], enableSSL = true)
|
||||
}
|
||||
}
|
||||
}
|
@ -14,7 +14,6 @@ import net.corda.core.utilities.unwrap
|
||||
import net.corda.node.internal.Node
|
||||
import net.corda.node.services.User
|
||||
import net.corda.node.services.config.SSLConfiguration
|
||||
import net.corda.node.services.config.configureTestSSL
|
||||
import net.corda.node.services.messaging.ArtemisMessagingComponent.Companion.CLIENTS_PREFIX
|
||||
import net.corda.node.services.messaging.ArtemisMessagingComponent.Companion.INTERNAL_PREFIX
|
||||
import net.corda.node.services.messaging.ArtemisMessagingComponent.Companion.NETWORK_MAP_QUEUE
|
||||
@ -24,6 +23,7 @@ import net.corda.node.services.messaging.ArtemisMessagingComponent.Companion.PEE
|
||||
import net.corda.node.services.messaging.ArtemisMessagingComponent.Companion.RPC_QUEUE_REMOVALS_QUEUE
|
||||
import net.corda.node.services.messaging.ArtemisMessagingComponent.Companion.RPC_REQUESTS_QUEUE
|
||||
import net.corda.node.services.messaging.CordaRPCClientImpl
|
||||
import net.corda.testing.configureTestSSL
|
||||
import net.corda.testing.messaging.SimpleMQClient
|
||||
import net.corda.testing.node.NodeBasedTest
|
||||
import org.apache.activemq.artemis.api.core.ActiveMQNonExistentQueueException
|
||||
@ -49,12 +49,14 @@ abstract class MQSecurityTest : NodeBasedTest() {
|
||||
@Before
|
||||
fun start() {
|
||||
alice = startNode("Alice", rpcUsers = extraRPCUsers + rpcUser).getOrThrow()
|
||||
attacker = clientTo(alice.configuration.artemisAddress)
|
||||
attacker = createAttacker()
|
||||
startAttacker(attacker)
|
||||
}
|
||||
|
||||
open val extraRPCUsers: List<User> get() = emptyList()
|
||||
|
||||
abstract fun createAttacker(): SimpleMQClient
|
||||
|
||||
abstract fun startAttacker(attacker: SimpleMQClient)
|
||||
|
||||
@After
|
||||
@ -112,12 +114,6 @@ abstract class MQSecurityTest : NodeBasedTest() {
|
||||
assertConsumeAttackFails(user1Queue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send message on logged in user's RPC address`() {
|
||||
val user1Queue = loginToRPCAndGetClientQueue()
|
||||
assertSendAttackFails(user1Queue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create queue for valid RPC user`() {
|
||||
val user1Queue = "$CLIENTS_PREFIX${rpcUser.username}.rpc.${random63BitValue()}"
|
||||
@ -152,26 +148,26 @@ abstract class MQSecurityTest : NodeBasedTest() {
|
||||
assertAllQueueCreationAttacksFail(randomQueue)
|
||||
}
|
||||
|
||||
fun clientTo(target: HostAndPort, config: SSLConfiguration = configureTestSSL()): SimpleMQClient {
|
||||
val client = SimpleMQClient(target, config)
|
||||
fun clientTo(target: HostAndPort, sslConfiguration: SSLConfiguration? = configureTestSSL()): SimpleMQClient {
|
||||
val client = SimpleMQClient(target, sslConfiguration)
|
||||
clients += client
|
||||
return client
|
||||
}
|
||||
|
||||
fun loginToRPC(target: HostAndPort, rpcUser: User): SimpleMQClient {
|
||||
val client = clientTo(target)
|
||||
val client = clientTo(target, null)
|
||||
client.loginToRPC(rpcUser)
|
||||
return client
|
||||
}
|
||||
|
||||
fun SimpleMQClient.loginToRPC(rpcUser: User): CordaRPCOps {
|
||||
start(rpcUser.username, rpcUser.password)
|
||||
fun SimpleMQClient.loginToRPC(rpcUser: User, enableSSL: Boolean = false): CordaRPCOps {
|
||||
start(rpcUser.username, rpcUser.password, enableSSL)
|
||||
val clientImpl = CordaRPCClientImpl(session, ReentrantLock(), rpcUser.username)
|
||||
return clientImpl.proxyFor(CordaRPCOps::class.java, timeout = 1.seconds)
|
||||
}
|
||||
|
||||
fun loginToRPCAndGetClientQueue(): String {
|
||||
val rpcClient = loginToRPC(alice.configuration.artemisAddress, rpcUser)
|
||||
val rpcClient = loginToRPC(alice.configuration.rpcAddress!!, rpcUser)
|
||||
val clientQueueQuery = SimpleString("$CLIENTS_PREFIX${rpcUser.username}.rpc.*")
|
||||
return rpcClient.session.addressQuery(clientQueueQuery).queueNames.single().toString()
|
||||
}
|
||||
|
@ -105,7 +105,7 @@ data class NodeHandle(
|
||||
val configuration: FullNodeConfiguration,
|
||||
val process: Process
|
||||
) {
|
||||
fun rpcClientToNode(): CordaRPCClient = CordaRPCClient(configuration.artemisAddress, configuration)
|
||||
fun rpcClientToNode(): CordaRPCClient = CordaRPCClient(configuration.rpcAddress!!)
|
||||
}
|
||||
|
||||
sealed class PortAllocation {
|
||||
@ -343,7 +343,8 @@ open class DriverDSL(
|
||||
override fun startNode(providedName: String?, advertisedServices: Set<ServiceInfo>,
|
||||
rpcUsers: List<User>, customOverrides: Map<String, Any?>): ListenableFuture<NodeHandle> {
|
||||
val messagingAddress = portAllocation.nextHostAndPort()
|
||||
val apiAddress = portAllocation.nextHostAndPort()
|
||||
val rpcAddress = portAllocation.nextHostAndPort()
|
||||
val webAddress = portAllocation.nextHostAndPort()
|
||||
val debugPort = if (isDebug) debugPortAllocation.nextPort() else null
|
||||
val name = providedName ?: "${pickA(name)}-${messagingAddress.port}"
|
||||
|
||||
@ -351,7 +352,8 @@ open class DriverDSL(
|
||||
val configOverrides = mapOf(
|
||||
"myLegalName" to name,
|
||||
"artemisAddress" to messagingAddress.toString(),
|
||||
"webAddress" to apiAddress.toString(),
|
||||
"rpcAddress" to rpcAddress.toString(),
|
||||
"webAddress" to webAddress.toString(),
|
||||
"extraAdvertisedServiceIds" to advertisedServices.map { it.toString() },
|
||||
"networkMapService" to mapOf(
|
||||
"address" to networkMapAddress.toString(),
|
||||
|
@ -22,11 +22,7 @@ import net.corda.node.services.config.FullNodeConfiguration
|
||||
import net.corda.node.services.messaging.ArtemisMessagingComponent.NetworkMapAddress
|
||||
import net.corda.node.services.messaging.ArtemisMessagingServer
|
||||
import net.corda.node.services.messaging.NodeMessagingClient
|
||||
import net.corda.node.services.transactions.PersistentUniquenessProvider
|
||||
import net.corda.node.services.transactions.RaftUniquenessProvider
|
||||
import net.corda.node.services.transactions.RaftValidatingNotaryService
|
||||
import net.corda.node.services.transactions.BFTSmartUniquenessProvider
|
||||
import net.corda.node.services.transactions.BFTValidatingNotaryService
|
||||
import net.corda.node.services.transactions.*
|
||||
import net.corda.node.utilities.AffinityExecutor
|
||||
import net.corda.node.utilities.databaseTransaction
|
||||
import org.jetbrains.exposed.sql.Database
|
||||
@ -130,7 +126,7 @@ class Node(override val configuration: FullNodeConfiguration,
|
||||
|
||||
val serverAddress = with(configuration) {
|
||||
messagingServerAddress ?: {
|
||||
messageBroker = ArtemisMessagingServer(this, artemisAddress, services.networkMapCache, userService)
|
||||
messageBroker = ArtemisMessagingServer(this, artemisAddress, rpcAddress, services.networkMapCache, userService)
|
||||
artemisAddress
|
||||
}()
|
||||
}
|
||||
|
@ -15,7 +15,6 @@ import net.corda.core.div
|
||||
import net.corda.core.exists
|
||||
import net.corda.core.utilities.loggerFor
|
||||
import java.net.URL
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.time.Instant
|
||||
@ -49,6 +48,9 @@ object ConfigHelper {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
operator fun <T> Config.getValue(receiver: Any, metadata: KProperty<*>): T {
|
||||
if (metadata.returnType.isMarkedNullable && !hasPath(metadata.name)) {
|
||||
return null as T
|
||||
}
|
||||
return when (metadata.returnType.javaType) {
|
||||
String::class.java -> getString(metadata.name) as T
|
||||
Int::class.java -> getInt(metadata.name) as T
|
||||
@ -100,7 +102,7 @@ inline fun <reified T : Any> Config.getListOrElse(path: String, default: Config.
|
||||
*/
|
||||
fun NodeConfiguration.configureWithDevSSLCertificate() = configureDevKeyAndTrustStores(myLegalName)
|
||||
|
||||
private fun SSLConfiguration.configureDevKeyAndTrustStores(myLegalName: String) {
|
||||
fun SSLConfiguration.configureDevKeyAndTrustStores(myLegalName: String) {
|
||||
certificatesDirectory.createDirectories()
|
||||
if (!trustStoreFile.exists()) {
|
||||
javaClass.classLoader.getResourceAsStream("net/corda/node/internal/certificates/cordatruststore.jks").copyTo(trustStoreFile)
|
||||
@ -112,15 +114,3 @@ private fun SSLConfiguration.configureDevKeyAndTrustStores(myLegalName: String)
|
||||
X509Utilities.createKeystoreForSSL(keyStoreFile, keyStorePassword, keyStorePassword, caKeyStore, "cordacadevkeypass", myLegalName)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Move this to CoreTestUtils.kt once we can pry this from the explorer
|
||||
@JvmOverloads
|
||||
fun configureTestSSL(legalName: String = "Mega Corp."): SSLConfiguration = object : SSLConfiguration {
|
||||
override val certificatesDirectory = Files.createTempDirectory("certs")
|
||||
override val keyStorePassword: String get() = "cordacadevpass"
|
||||
override val trustStorePassword: String get() = "trustpass"
|
||||
|
||||
init {
|
||||
configureDevKeyAndTrustStores(legalName)
|
||||
}
|
||||
}
|
||||
|
@ -65,13 +65,14 @@ class FullNodeConfiguration(override val baseDirectory: Path, val config: Config
|
||||
}
|
||||
val useHTTPS: Boolean by config
|
||||
val artemisAddress: HostAndPort by config
|
||||
val rpcAddress: HostAndPort? by config
|
||||
val webAddress: HostAndPort by config
|
||||
// TODO This field is slightly redundant as artemisAddress is sufficient to hold the address of the node's MQ broker.
|
||||
// Instead this should be a Boolean indicating whether that broker is an internal one started by the node or an external one
|
||||
val messagingServerAddress: HostAndPort? by config.getOrElse { null }
|
||||
val messagingServerAddress: HostAndPort? by config
|
||||
val extraAdvertisedServiceIds: List<String> = config.getListOrElse<String>("extraAdvertisedServiceIds") { emptyList() }
|
||||
val useTestClock: Boolean by config.getOrElse { false }
|
||||
val notaryNodeAddress: HostAndPort? by config.getOrElse { null }
|
||||
val notaryNodeAddress: HostAndPort? by config
|
||||
val notaryClusterAddresses: List<HostAndPort> = config
|
||||
.getListOrElse<String>("notaryClusterAddresses") { emptyList() }
|
||||
.map { HostAndPort.fromString(it) }
|
||||
|
@ -21,7 +21,7 @@ import java.security.KeyStore
|
||||
/**
|
||||
* The base class for Artemis services that defines shared data structures and SSL transport configuration.
|
||||
*/
|
||||
abstract class ArtemisMessagingComponent() : SingletonSerializeAsToken() {
|
||||
abstract class ArtemisMessagingComponent : SingletonSerializeAsToken() {
|
||||
companion object {
|
||||
init {
|
||||
System.setProperty("org.jboss.logging.provider", "slf4j")
|
||||
@ -85,6 +85,7 @@ abstract class ArtemisMessagingComponent() : SingletonSerializeAsToken() {
|
||||
fun asPeer(peerIdentity: CompositeKey, hostAndPort: HostAndPort): NodeAddress {
|
||||
return NodeAddress("$PEERS_PREFIX${peerIdentity.toBase58String()}", hostAndPort)
|
||||
}
|
||||
|
||||
fun asService(serviceIdentity: CompositeKey, hostAndPort: HostAndPort): NodeAddress {
|
||||
return NodeAddress("$SERVICES_PREFIX${serviceIdentity.toBase58String()}", hostAndPort)
|
||||
}
|
||||
@ -134,7 +135,7 @@ abstract class ArtemisMessagingComponent() : SingletonSerializeAsToken() {
|
||||
}
|
||||
}
|
||||
|
||||
protected fun tcpTransport(direction: ConnectionDirection, host: String, port: Int): TransportConfiguration {
|
||||
protected fun tcpTransport(direction: ConnectionDirection, host: String, port: Int, enableSSL: Boolean = true): TransportConfiguration {
|
||||
val config = config
|
||||
val options = mutableMapOf<String, Any?>(
|
||||
// Basic TCP target details
|
||||
@ -148,7 +149,7 @@ abstract class ArtemisMessagingComponent() : SingletonSerializeAsToken() {
|
||||
TransportConstants.PROTOCOLS_PROP_NAME to "CORE,AMQP"
|
||||
)
|
||||
|
||||
if (config != null) {
|
||||
if (config != null && enableSSL) {
|
||||
config.keyStoreFile.expectedOnDefaultFileSystem()
|
||||
config.trustStoreFile.expectedOnDefaultFileSystem()
|
||||
val tlsOptions = mapOf<String, Any?>(
|
||||
|
@ -81,7 +81,8 @@ import javax.security.cert.X509Certificate
|
||||
*/
|
||||
@ThreadSafe
|
||||
class ArtemisMessagingServer(override val config: NodeConfiguration,
|
||||
val myHostPort: HostAndPort,
|
||||
val artemisHostPort: HostAndPort,
|
||||
val rpcHostPort: HostAndPort?,
|
||||
val networkMapCache: NetworkMapCache,
|
||||
val userService: RPCUserService) : ArtemisMessagingComponent() {
|
||||
companion object {
|
||||
@ -139,7 +140,10 @@ class ArtemisMessagingServer(override val config: NodeConfiguration,
|
||||
registerPostQueueDeletionCallback { address, qName -> log.debug { "Queue deleted: $qName for $address" } }
|
||||
}
|
||||
activeMQServer.start()
|
||||
printBasicNodeInfo("Node listening on address", myHostPort.toString())
|
||||
printBasicNodeInfo("Node listening on address", artemisHostPort.toString())
|
||||
if (rpcHostPort != null) {
|
||||
printBasicNodeInfo("Node RPC service listening on address", rpcHostPort.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun createArtemisConfig(): Configuration = ConfigurationImpl().apply {
|
||||
@ -147,7 +151,11 @@ class ArtemisMessagingServer(override val config: NodeConfiguration,
|
||||
bindingsDirectory = (artemisDir / "bindings").toString()
|
||||
journalDirectory = (artemisDir / "journal").toString()
|
||||
largeMessagesDirectory = (artemisDir / "large-messages").toString()
|
||||
acceptorConfigurations = setOf(tcpTransport(Inbound, "0.0.0.0", myHostPort.port))
|
||||
val acceptors = mutableSetOf(tcpTransport(Inbound, "0.0.0.0", artemisHostPort.port))
|
||||
if (rpcHostPort != null) {
|
||||
acceptors.add(tcpTransport(Inbound, "0.0.0.0", rpcHostPort.port, enableSSL = false))
|
||||
}
|
||||
acceptorConfigurations = acceptors
|
||||
// Enable built in message deduplication. Note we still have to do our own as the delayed commits
|
||||
// and our own definition of commit mean that the built in deduplication cannot remove all duplicates.
|
||||
idCacheSize = 2000 // Artemis Default duplicate cache size i.e. a guess
|
||||
@ -397,8 +405,7 @@ private class VerifyingNettyConnector(configuration: MutableMap<String, Any>?,
|
||||
threadPool: Executor?,
|
||||
scheduledThreadPool: ScheduledExecutorService?,
|
||||
protocolManager: ClientProtocolManager?) :
|
||||
NettyConnector(configuration, handler, listener, closeExecutor, threadPool, scheduledThreadPool, protocolManager)
|
||||
{
|
||||
NettyConnector(configuration, handler, listener, closeExecutor, threadPool, scheduledThreadPool, protocolManager) {
|
||||
private val server = configuration?.get(ArtemisMessagingServer::class.java.name) as? ArtemisMessagingServer
|
||||
private val expectedCommonName = configuration?.get(ArtemisMessagingComponent.VERIFY_PEER_COMMON_NAME) as? String
|
||||
|
||||
@ -480,15 +487,15 @@ class NodeLoginModule : LoginModule {
|
||||
|
||||
val username = nameCallback.name ?: throw FailedLoginException("Username not provided")
|
||||
val password = String(passwordCallback.password ?: throw FailedLoginException("Password not provided"))
|
||||
val certificates = certificateCallback.certificates
|
||||
|
||||
log.info("Processing login for $username")
|
||||
|
||||
val validatedUser = if (username == PEER_USER || username == NODE_USER) {
|
||||
val certificates = certificateCallback.certificates ?: throw FailedLoginException("No TLS?")
|
||||
authenticateNode(certificates, username)
|
||||
} else {
|
||||
// Otherwise assume they're an RPC user
|
||||
authenticateRpcUser(password, username)
|
||||
val validatedUser = when (determineUserRole(certificates, username)) {
|
||||
PEER_ROLE -> authenticatePeer(certificates)
|
||||
NODE_ROLE -> authenticateNode(certificates)
|
||||
RPC_ROLE -> authenticateRpcUser(password, username)
|
||||
else -> throw FailedLoginException("Peer does not belong on our network")
|
||||
}
|
||||
principals += UserPrincipal(validatedUser)
|
||||
|
||||
@ -496,22 +503,22 @@ class NodeLoginModule : LoginModule {
|
||||
return loginSucceeded
|
||||
}
|
||||
|
||||
private fun authenticateNode(certificates: Array<X509Certificate>, username: String): String {
|
||||
private fun authenticateNode(certificates: Array<X509Certificate>): String {
|
||||
val peerCertificate = certificates.first()
|
||||
val role = if (username == NODE_USER) {
|
||||
if (peerCertificate.publicKey != ourPublicKey) {
|
||||
throw FailedLoginException("Only the node can login as $NODE_USER")
|
||||
}
|
||||
NODE_ROLE
|
||||
} else {
|
||||
principals += RolePrincipal(NODE_ROLE)
|
||||
return peerCertificate.subjectDN.name
|
||||
}
|
||||
|
||||
private fun authenticatePeer(certificates: Array<X509Certificate>): String {
|
||||
val theirRootCAPublicKey = certificates.last().publicKey
|
||||
if (theirRootCAPublicKey != ourRootCAPublicKey) {
|
||||
throw FailedLoginException("Peer does not belong on our network. Their root CA: $theirRootCAPublicKey")
|
||||
}
|
||||
PEER_ROLE // This enables the peer to send to our P2P address
|
||||
}
|
||||
principals += RolePrincipal(role)
|
||||
return peerCertificate.subjectDN.name
|
||||
principals += RolePrincipal(PEER_ROLE)
|
||||
return certificates.first().subjectDN.name
|
||||
}
|
||||
|
||||
private fun authenticateRpcUser(password: String, username: String): String {
|
||||
@ -526,6 +533,18 @@ class NodeLoginModule : LoginModule {
|
||||
return username
|
||||
}
|
||||
|
||||
private fun determineUserRole(certificates: Array<X509Certificate>?, username: String): String? {
|
||||
return if (username == PEER_USER || username == NODE_USER) {
|
||||
certificates ?: throw FailedLoginException("No TLS?")
|
||||
if (username == PEER_USER) PEER_ROLE else NODE_ROLE
|
||||
} else if (certificates == null) {
|
||||
// Assume they're an RPC user if its from a non-ssl connection
|
||||
RPC_ROLE
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun commit(): Boolean {
|
||||
val result = loginSucceeded
|
||||
if (result) {
|
||||
|
@ -24,10 +24,10 @@ import javax.annotation.concurrent.ThreadSafe
|
||||
* useful tasks. See the documentation for [proxy] or review the docsite to learn more about how this API works.
|
||||
*
|
||||
* @param host The hostname and messaging port of the node.
|
||||
* @param config If specified, the SSL configuration to use. If not specified, SSL will be disabled and the node will not be authenticated, nor will RPC traffic be encrypted.
|
||||
* @param config If specified, the SSL configuration to use. If not specified, SSL will be disabled and the node will only be authenticated on non-SSL RPC port, the RPC traffic with not be encrypted when SSL is disabled.
|
||||
*/
|
||||
@ThreadSafe
|
||||
class CordaRPCClient(val host: HostAndPort, override val config: SSLConfiguration?, val serviceConfigurationOverride: (ServerLocator.() -> Unit)? = null) : Closeable, ArtemisMessagingComponent() {
|
||||
class CordaRPCClient(val host: HostAndPort, override val config: SSLConfiguration? = null, val serviceConfigurationOverride: (ServerLocator.() -> Unit)? = null) : Closeable, ArtemisMessagingComponent() {
|
||||
private companion object {
|
||||
val log = loggerFor<CordaRPCClient>()
|
||||
}
|
||||
|
@ -47,6 +47,7 @@ class ArtemisMessagingTests {
|
||||
@Rule @JvmField val temporaryFolder = TemporaryFolder()
|
||||
|
||||
val hostAndPort = freeLocalHostAndPort()
|
||||
val rpcHostAndPort = freeLocalHostAndPort()
|
||||
val topic = "platform.self"
|
||||
val identity = generateKeyPair()
|
||||
|
||||
@ -230,8 +231,8 @@ class ArtemisMessagingTests {
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMessagingServer(local: HostAndPort = hostAndPort): ArtemisMessagingServer {
|
||||
return ArtemisMessagingServer(config, local, networkMapCache, userService).apply {
|
||||
private fun createMessagingServer(local: HostAndPort = hostAndPort, rpc: HostAndPort = rpcHostAndPort): ArtemisMessagingServer {
|
||||
return ArtemisMessagingServer(config, local, rpc, networkMapCache, userService).apply {
|
||||
config.configureWithDevSSLCertificate()
|
||||
messagingServer = this
|
||||
}
|
||||
|
@ -9,7 +9,6 @@ import net.corda.core.messaging.startFlow
|
||||
import net.corda.core.serialization.OpaqueBytes
|
||||
import net.corda.core.transactions.SignedTransaction
|
||||
import net.corda.flows.IssuerFlow.IssuanceRequester
|
||||
import net.corda.node.services.config.configureTestSSL
|
||||
import net.corda.node.services.messaging.CordaRPCClient
|
||||
import net.corda.testing.http.HttpApi
|
||||
|
||||
@ -26,11 +25,12 @@ class BankOfCordaClientApi(val hostAndPort: HostAndPort) {
|
||||
val api = HttpApi.fromHostAndPort(hostAndPort, apiRoot)
|
||||
return api.postJson("issue-asset-request", params)
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC API
|
||||
*/
|
||||
fun requestRPCIssue(params: IssueRequestParams): SignedTransaction {
|
||||
val client = CordaRPCClient(hostAndPort, configureTestSSL())
|
||||
val client = CordaRPCClient(hostAndPort)
|
||||
// TODO: privileged security controls required
|
||||
client.start("bankUser", "test")
|
||||
val proxy = client.proxy()
|
||||
|
@ -55,7 +55,7 @@ class IRSDemoTest : IntegrationTestCategory {
|
||||
}
|
||||
|
||||
fun getFixingDateObservable(config: FullNodeConfiguration): BlockingObservable<LocalDate?> {
|
||||
val client = CordaRPCClient(config.artemisAddress, config)
|
||||
val client = CordaRPCClient(config.rpcAddress!!)
|
||||
client.start("user", "password")
|
||||
val proxy = client.proxy()
|
||||
val vaultUpdates = proxy.vaultAndUpdates().second
|
||||
|
@ -27,7 +27,7 @@ class TraderDemoTest : NodeBasedTest() {
|
||||
).getOrThrow()
|
||||
|
||||
val (nodeARpc, nodeBRpc) = listOf(nodeA, nodeB).map {
|
||||
val client = CordaRPCClient(it.configuration.artemisAddress, it.configuration)
|
||||
val client = CordaRPCClient(it.configuration.rpcAddress!!)
|
||||
client.start(demoUser[0].username, demoUser[0].password).proxy()
|
||||
}
|
||||
|
||||
|
@ -18,6 +18,8 @@ import net.corda.core.utilities.DUMMY_NOTARY_KEY
|
||||
import net.corda.node.internal.AbstractNode
|
||||
import net.corda.node.internal.NetworkMapInfo
|
||||
import net.corda.node.services.config.NodeConfiguration
|
||||
import net.corda.node.services.config.SSLConfiguration
|
||||
import net.corda.node.services.config.configureDevKeyAndTrustStores
|
||||
import net.corda.node.services.statemachine.FlowStateMachineImpl
|
||||
import net.corda.node.utilities.AddOrRemove.ADD
|
||||
import net.corda.testing.node.MockIdentityService
|
||||
@ -25,6 +27,7 @@ import net.corda.testing.node.MockServices
|
||||
import net.corda.testing.node.makeTestDataSourceProperties
|
||||
import java.net.ServerSocket
|
||||
import java.net.URL
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.security.KeyPair
|
||||
import java.util.*
|
||||
@ -161,3 +164,14 @@ data class TestNodeConfiguration(
|
||||
override val certificateSigningService: URL = URL("http://localhost")) : NodeConfiguration
|
||||
|
||||
fun Config.getHostAndPort(name: String) = HostAndPort.fromString(getString(name))
|
||||
|
||||
@JvmOverloads
|
||||
fun configureTestSSL(legalName: String = "Mega Corp."): SSLConfiguration = object : SSLConfiguration {
|
||||
override val certificatesDirectory = Files.createTempDirectory("certs")
|
||||
override val keyStorePassword: String get() = "cordacadevpass"
|
||||
override val trustStorePassword: String get() = "trustpass"
|
||||
|
||||
init {
|
||||
configureDevKeyAndTrustStores(legalName)
|
||||
}
|
||||
}
|
||||
|
@ -2,22 +2,22 @@ package net.corda.testing.messaging
|
||||
|
||||
import com.google.common.net.HostAndPort
|
||||
import net.corda.node.services.config.SSLConfiguration
|
||||
import net.corda.node.services.config.configureTestSSL
|
||||
import net.corda.node.services.messaging.ArtemisMessagingComponent
|
||||
import net.corda.node.services.messaging.ArtemisMessagingComponent.ConnectionDirection.Outbound
|
||||
import net.corda.testing.configureTestSSL
|
||||
import org.apache.activemq.artemis.api.core.client.*
|
||||
|
||||
/**
|
||||
* As the name suggests this is a simple client for connecting to MQ brokers.
|
||||
*/
|
||||
class SimpleMQClient(val target: HostAndPort,
|
||||
override val config: SSLConfiguration = configureTestSSL("SimpleMQClient")) : ArtemisMessagingComponent() {
|
||||
override val config: SSLConfiguration? = configureTestSSL("SimpleMQClient")) : ArtemisMessagingComponent() {
|
||||
lateinit var sessionFactory: ClientSessionFactory
|
||||
lateinit var session: ClientSession
|
||||
lateinit var producer: ClientProducer
|
||||
|
||||
fun start(username: String? = null, password: String? = null) {
|
||||
val tcpTransport = tcpTransport(Outbound(), target.hostText, target.port)
|
||||
fun start(username: String? = null, password: String? = null, enableSSL: Boolean = true) {
|
||||
val tcpTransport = tcpTransport(Outbound(), target.hostText, target.port, enableSSL)
|
||||
val locator = ActiveMQClient.createServerLocatorWithoutHA(tcpTransport).apply {
|
||||
isBlockOnNonDurableSend = true
|
||||
threadPoolMaxSize = 1
|
||||
|
@ -121,6 +121,7 @@ abstract class NodeBasedTest {
|
||||
configOverrides = mapOf(
|
||||
"myLegalName" to legalName,
|
||||
"artemisAddress" to freeLocalHostAndPort().toString(),
|
||||
"rpcAddress" to freeLocalHostAndPort().toString(),
|
||||
"extraAdvertisedServiceIds" to advertisedServices.map { it.toString() },
|
||||
"rpcUsers" to rpcUsers.map {
|
||||
mapOf(
|
||||
|
@ -23,14 +23,14 @@ import kotlin.concurrent.thread
|
||||
* This is a bare-bones node which can only send and receive messages. It doesn't register with a network map service or
|
||||
* any other such task that would make it functionable in a network and thus left to the user to do so manually.
|
||||
*/
|
||||
class SimpleNode(val config: NodeConfiguration, val address: HostAndPort = freeLocalHostAndPort()) : AutoCloseable {
|
||||
class SimpleNode(val config: NodeConfiguration, val address: HostAndPort = freeLocalHostAndPort(), rpcAddress: HostAndPort = freeLocalHostAndPort()) : AutoCloseable {
|
||||
|
||||
private val databaseWithCloseable: Pair<Closeable, Database> = configureDatabase(config.dataSourceProperties)
|
||||
val database: Database get() = databaseWithCloseable.second
|
||||
val userService = RPCUserServiceImpl(config)
|
||||
val identity: KeyPair = generateKeyPair()
|
||||
val executor = ServiceAffinityExecutor(config.myLegalName, 1)
|
||||
val broker = ArtemisMessagingServer(config, address, InMemoryNetworkMapCache(), userService)
|
||||
val broker = ArtemisMessagingServer(config, address, rpcAddress, InMemoryNetworkMapCache(), userService)
|
||||
val networkMapRegistrationFuture: SettableFuture<Unit> = SettableFuture.create<Unit>()
|
||||
val net = databaseTransaction(database) {
|
||||
NodeMessagingClient(
|
||||
|
@ -14,7 +14,6 @@ import net.corda.client.model.Models
|
||||
import net.corda.client.model.observableValue
|
||||
import net.corda.core.contracts.GBP
|
||||
import net.corda.core.contracts.USD
|
||||
import net.corda.core.messaging.startFlow
|
||||
import net.corda.core.node.services.ServiceInfo
|
||||
import net.corda.core.node.services.ServiceType
|
||||
import net.corda.explorer.model.CordaViewModel
|
||||
@ -28,7 +27,6 @@ import net.corda.flows.IssuerFlow.IssuanceRequester
|
||||
import net.corda.node.driver.PortAllocation
|
||||
import net.corda.node.driver.driver
|
||||
import net.corda.node.services.User
|
||||
import net.corda.node.services.messaging.ArtemisMessagingComponent
|
||||
import net.corda.node.services.startFlowPermission
|
||||
import net.corda.node.services.transactions.SimpleNotaryService
|
||||
import org.apache.commons.lang.SystemUtils
|
||||
@ -141,7 +139,7 @@ fun main(args: Array<String>) {
|
||||
val issuerNodeUSD = issuerUSD.get()
|
||||
|
||||
arrayOf(notaryNode, aliceNode, bobNode, issuerNodeGBP, issuerNodeUSD).forEach {
|
||||
println("${it.nodeInfo.legalIdentity} started on ${ArtemisMessagingComponent.toHostAndPort(it.nodeInfo.address)}")
|
||||
println("${it.nodeInfo.legalIdentity} started on ${it.configuration.rpcAddress}")
|
||||
}
|
||||
|
||||
val parser = OptionParser("S")
|
||||
|
@ -30,9 +30,6 @@ class SettingsModel(path: Path = Paths.get("conf")) : Component(), Observable {
|
||||
private var username: String by config
|
||||
private var reportingCurrency: Currency by config
|
||||
private var fullscreen: Boolean by config
|
||||
private var certificatesDir: Path by config
|
||||
private var keyStorePassword: String by config
|
||||
private var trustStorePassword: String by config
|
||||
|
||||
// Create observable Properties.
|
||||
val reportingCurrencyProperty = writableConfigProperty(SettingsModel::reportingCurrency)
|
||||
@ -41,10 +38,6 @@ class SettingsModel(path: Path = Paths.get("conf")) : Component(), Observable {
|
||||
val portProperty = writableConfigProperty(SettingsModel::port)
|
||||
val usernameProperty = writableConfigProperty(SettingsModel::username)
|
||||
val fullscreenProperty = writableConfigProperty(SettingsModel::fullscreen)
|
||||
val certificatesDirProperty = writableConfigProperty(SettingsModel::certificatesDir)
|
||||
// TODO : We should encrypt all passwords in config file.
|
||||
val keyStorePasswordProperty = writableConfigProperty(SettingsModel::keyStorePassword)
|
||||
val trustStorePasswordProperty = writableConfigProperty(SettingsModel::trustStorePassword)
|
||||
|
||||
init {
|
||||
load()
|
||||
|
@ -5,16 +5,16 @@ import de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon
|
||||
import de.jensd.fx.glyphs.fontawesome.FontAwesomeIconView
|
||||
import javafx.beans.property.SimpleIntegerProperty
|
||||
import javafx.scene.control.*
|
||||
import javafx.stage.FileChooser
|
||||
import net.corda.client.fxutils.map
|
||||
import net.corda.client.model.NodeMonitorModel
|
||||
import net.corda.client.model.objectProperty
|
||||
import net.corda.core.exists
|
||||
import net.corda.explorer.model.SettingsModel
|
||||
import net.corda.node.services.config.SSLConfiguration
|
||||
import net.corda.node.services.config.configureTestSSL
|
||||
import org.controlsfx.dialog.ExceptionDialog
|
||||
import tornadofx.*
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class LoginView : View() {
|
||||
@ -26,7 +26,6 @@ class LoginView : View() {
|
||||
private val passwordTextField by fxid<PasswordField>()
|
||||
private val rememberMeCheckBox by fxid<CheckBox>()
|
||||
private val fullscreenCheckBox by fxid<CheckBox>()
|
||||
private val certificateButton by fxid<Button>()
|
||||
private val portProperty = SimpleIntegerProperty()
|
||||
|
||||
private val rememberMe by objectProperty(SettingsModel::rememberMeProperty)
|
||||
@ -34,9 +33,6 @@ class LoginView : View() {
|
||||
private val host by objectProperty(SettingsModel::hostProperty)
|
||||
private val port by objectProperty(SettingsModel::portProperty)
|
||||
private val fullscreen by objectProperty(SettingsModel::fullscreenProperty)
|
||||
private val certificatesDir by objectProperty(SettingsModel::certificatesDirProperty)
|
||||
private val keyStorePasswordProperty by objectProperty(SettingsModel::keyStorePasswordProperty)
|
||||
private val trustStorePasswordProperty by objectProperty(SettingsModel::trustStorePasswordProperty)
|
||||
|
||||
fun login() {
|
||||
val status = Dialog<LoginStatus>().apply {
|
||||
@ -46,7 +42,7 @@ class LoginView : View() {
|
||||
ButtonBar.ButtonData.OK_DONE -> try {
|
||||
root.isDisable = true
|
||||
// TODO : Run this async to avoid UI lockup.
|
||||
getModel<NodeMonitorModel>().register(HostAndPort.fromParts(hostTextField.text, portProperty.value), configureSSL(), usernameTextField.text, passwordTextField.text)
|
||||
getModel<NodeMonitorModel>().register(HostAndPort.fromParts(hostTextField.text, portProperty.value), usernameTextField.text, passwordTextField.text)
|
||||
if (!rememberMe.value) {
|
||||
username.value = ""
|
||||
host.value = ""
|
||||
@ -79,18 +75,6 @@ class LoginView : View() {
|
||||
if (status != LoginStatus.loggedIn) login()
|
||||
}
|
||||
|
||||
private fun configureSSL(): SSLConfiguration {
|
||||
val sslConfig = object : SSLConfiguration {
|
||||
override val certificatesDirectory: Path get() = certificatesDir.get()
|
||||
override val keyStorePassword: String get() = keyStorePasswordProperty.get()
|
||||
override val trustStorePassword: String get() = trustStorePasswordProperty.get()
|
||||
}
|
||||
// TODO : Don't use dev certificates.
|
||||
return if (sslConfig.keyStoreFile.exists()) sslConfig else configureTestSSL().apply {
|
||||
alert(Alert.AlertType.WARNING, "", "KeyStore not found in certificates directory.\nDEV certificates will be used by default.")
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Restrict text field to Integer only.
|
||||
portTextField.textFormatter = intFormatter().apply { portProperty.bind(this.valueProperty()) }
|
||||
@ -99,44 +83,6 @@ class LoginView : View() {
|
||||
usernameTextField.textProperty().bindBidirectional(username)
|
||||
hostTextField.textProperty().bindBidirectional(host)
|
||||
portTextField.textProperty().bindBidirectional(port)
|
||||
certificateButton.setOnAction {
|
||||
Dialog<ButtonType>().apply {
|
||||
title = "Certificates Settings"
|
||||
initOwner(root.scene.window)
|
||||
dialogPane.content = gridpane {
|
||||
vgap = 10.0
|
||||
hgap = 5.0
|
||||
row("Certificates Directory :") {
|
||||
textfield {
|
||||
prefWidth = 400.0
|
||||
textProperty().bind(certificatesDir.map(Path::toString))
|
||||
isEditable = false
|
||||
}
|
||||
button {
|
||||
graphic = FontAwesomeIconView(FontAwesomeIcon.FOLDER_OPEN_ALT)
|
||||
maxHeight = Double.MAX_VALUE
|
||||
setOnAction {
|
||||
chooseDirectory(owner = dialogPane.scene.window) {
|
||||
initialDirectoryProperty().bind(certificatesDir.map(Path::toFile))
|
||||
}?.let {
|
||||
certificatesDir.set(it.toPath())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
row("KeyStore Password :") { passwordfield(keyStorePasswordProperty) }
|
||||
row("TrustStore Password :") { passwordfield(trustStorePasswordProperty) }
|
||||
}
|
||||
dialogPane.buttonTypes.addAll(ButtonType.APPLY, ButtonType.CANCEL)
|
||||
}.showAndWait().get().let {
|
||||
when (it) {
|
||||
ButtonType.APPLY -> getModel<SettingsModel>().commit()
|
||||
// Discard changes.
|
||||
else -> getModel<SettingsModel>().load()
|
||||
}
|
||||
}
|
||||
}
|
||||
certificateButton.tooltip("Certificate Configuration")
|
||||
}
|
||||
|
||||
private enum class LoginStatus {
|
||||
|
@ -26,23 +26,15 @@
|
||||
<Label text="Corda Node :" GridPane.halignment="RIGHT"/>
|
||||
<TextField fx:id="hostTextField" promptText="Host" GridPane.columnIndex="1"/>
|
||||
<TextField fx:id="portTextField" prefWidth="100" promptText="Port" GridPane.columnIndex="2"/>
|
||||
<Button id="certificateButton" fx:id="certificateButton" GridPane.columnIndex="3" styleClass="certificateButton">
|
||||
<padding>
|
||||
<Insets right="6"/>
|
||||
</padding>
|
||||
<graphic>
|
||||
<FontAwesomeIconView styleClass="certificateIcon" glyphName="LOCK" glyphSize="20"/>
|
||||
</graphic>
|
||||
</Button>
|
||||
<Label text="Username :" GridPane.rowIndex="1" GridPane.halignment="RIGHT"/>
|
||||
<TextField fx:id="usernameTextField" promptText="Username" GridPane.columnIndex="1"
|
||||
GridPane.columnSpan="3" GridPane.rowIndex="1"/>
|
||||
GridPane.columnSpan="2" GridPane.rowIndex="1"/>
|
||||
|
||||
<Label text="Password :" GridPane.rowIndex="2" GridPane.halignment="RIGHT"/>
|
||||
<PasswordField fx:id="passwordTextField" promptText="Password" GridPane.columnIndex="1"
|
||||
GridPane.columnSpan="3" GridPane.rowIndex="2"/>
|
||||
GridPane.columnSpan="2" GridPane.rowIndex="2"/>
|
||||
|
||||
<HBox spacing="20" GridPane.columnIndex="1" GridPane.rowIndex="3" GridPane.columnSpan="3">
|
||||
<HBox spacing="20" GridPane.columnIndex="1" GridPane.rowIndex="3" GridPane.columnSpan="2">
|
||||
<CheckBox fx:id="rememberMeCheckBox" text="Remember me"/>
|
||||
<CheckBox fx:id="fullscreenCheckBox" text="Fullscreen mode"/>
|
||||
</HBox>
|
||||
|
Loading…
Reference in New Issue
Block a user