From be161c31e393a56f1dfba18c39e5015c02dc4820 Mon Sep 17 00:00:00 2001 From: Jean-Louis Monteiro Date: Tue, 10 Feb 2026 15:00:34 +0100 Subject: [PATCH 1/7] Add SSL support for JMX connector in ManagementContext --- .../broker/jmx/ManagementContext.java | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagementContext.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagementContext.java index 28c6f8c3d09..6fe8e1d63ea 100644 --- a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagementContext.java +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagementContext.java @@ -26,6 +26,8 @@ import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; +import java.rmi.server.RMIClientSocketFactory; +import java.rmi.server.RMIServerSocketFactory; import java.rmi.server.UnicastRemoteObject; import java.util.LinkedList; import java.util.List; @@ -50,8 +52,11 @@ import javax.management.remote.JMXServiceURL; import javax.management.remote.rmi.RMIConnectorServer; import javax.management.remote.rmi.RMIJRMPServerImpl; +import javax.rmi.ssl.SslRMIClientSocketFactory; +import javax.rmi.ssl.SslRMIServerSocketFactory; import org.apache.activemq.Service; +import org.apache.activemq.broker.SslContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; @@ -115,6 +120,7 @@ public class ManagementContext implements Service { private List suppressMBeanList; private Remote serverStub; private RMIJRMPServerImpl server; + private SslContext sslContext; public ManagementContext() { this(null); @@ -549,11 +555,28 @@ protected MBeanServer createMBeanServer() throws MalformedObjectNameException, I } private void createConnector(MBeanServer mbeanServer) throws IOException { + // Resolve SSL socket factories first, so they can be shared by registry and connector + final RMIClientSocketFactory csf; + final RMIServerSocketFactory ssf; + if (sslContext != null) { + try { + final javax.net.ssl.SSLContext ctx = sslContext.getSSLContext(); + csf = new SslRMIClientSocketFactory(); + ssf = new SslRMIServerSocketFactory(ctx, null, null, false); + LOG.info("JMX connector will use SSL from configured sslContext"); + } catch (Exception e) { + throw new IOException("Failed to initialize SSL for JMX connector", e); + } + } else { + csf = null; + ssf = null; + } + // Create the NamingService, needed by JSR 160 try { if (registry == null) { LOG.debug("Creating RMIRegistry on port {}", connectorPort); - registry = jmxRegistry(connectorPort); + registry = jmxRegistry(connectorPort, csf, ssf); } namingServiceObjectName = ObjectName.getInstance("naming:type=rmiregistry"); @@ -579,7 +602,7 @@ private void createConnector(MBeanServer mbeanServer) throws IOException { rmiServer = ""+getConnectorHost()+":" + rmiServerPort; } - server = new RMIJRMPServerImpl(connectorPort, null, null, environment); + server = new RMIJRMPServerImpl(connectorPort, csf, ssf, environment); final String serviceURL = "service:jmx:rmi://" + rmiServer + "/jndi/rmi://" +getConnectorHost()+":" + connectorPort + connectorPath; final JMXServiceURL url = new JMXServiceURL(serviceURL); @@ -659,6 +682,28 @@ public void setEnvironment(Map environment) { this.environment = environment; } + /** + * Get the SSL context used for the JMX connector. + */ + public SslContext getSslContext() { + return sslContext; + } + + /** + * Set the SSL context to use for the JMX connector. + * When configured, the JMX RMI connector will use SSL with the + * keyStore and trustStore from this context, allowing reuse of + * the broker's {@code } configuration. + * + * Example XML configuration: + *
+     * <managementContext createConnector="true" sslContext="#brokerSslContext"/>
+     * 
+ */ + public void setSslContext(SslContext sslContext) { + this.sslContext = sslContext; + } + public boolean isAllowRemoteAddressInMBeanNames() { return allowRemoteAddressInMBeanNames; } @@ -683,9 +728,11 @@ public String getSuppressMBean() { } // do not use sun.rmi.registry.RegistryImpl! it is not always easily available - private Registry jmxRegistry(final int port) throws RemoteException { + private Registry jmxRegistry(final int port, final RMIClientSocketFactory csf, final RMIServerSocketFactory ssf) throws RemoteException { final var loader = Thread.currentThread().getContextClassLoader(); - final var delegate = LocateRegistry.createRegistry(port); + final var delegate = (csf != null && ssf != null) + ? LocateRegistry.createRegistry(port, csf, ssf) + : LocateRegistry.createRegistry(port); return Registry.class.cast(Proxy.newProxyInstance( loader == null ? getSystemClassLoader() : loader, new Class[]{Registry.class}, (proxy, method, args) -> { From 5db8db0a23568c2d171c978789a51499146f89ab Mon Sep 17 00:00:00 2001 From: Jean-Louis Monteiro Date: Fri, 20 Feb 2026 00:15:00 +0100 Subject: [PATCH 2/7] feat(#1699) Add support for SSL in JMX connector and resolve ephemeral ports --- .../broker/jmx/ManagementContext.java | 25 +- .../broker/jmx/ManagementContextSslTest.java | 285 ++++++++++++++++++ 2 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagementContext.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagementContext.java index 6fe8e1d63ea..4532d6d27c6 100644 --- a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagementContext.java +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagementContext.java @@ -18,6 +18,7 @@ import java.io.IOException; import java.lang.management.ManagementFactory; +import java.net.ServerSocket; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Proxy; @@ -29,6 +30,7 @@ import java.rmi.server.RMIClientSocketFactory; import java.rmi.server.RMIServerSocketFactory; import java.rmi.server.UnicastRemoteObject; +import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -555,6 +557,14 @@ protected MBeanServer createMBeanServer() throws MalformedObjectNameException, I } private void createConnector(MBeanServer mbeanServer) throws IOException { + // Resolve ephemeral port (0) to an actual free port, similar to tcp://localhost:0 + if (connectorPort == 0) { + try (final ServerSocket ss = new ServerSocket(0)) { + connectorPort = ss.getLocalPort(); + } + LOG.debug("Resolved ephemeral JMX connector port to {}", connectorPort); + } + // Resolve SSL socket factories first, so they can be shared by registry and connector final RMIClientSocketFactory csf; final RMIServerSocketFactory ssf; @@ -607,7 +617,20 @@ private void createConnector(MBeanServer mbeanServer) throws IOException { final String serviceURL = "service:jmx:rmi://" + rmiServer + "/jndi/rmi://" +getConnectorHost()+":" + connectorPort + connectorPath; final JMXServiceURL url = new JMXServiceURL(serviceURL); - connectorServer = new RMIConnectorServer(url, environment, server, ManagementFactory.getPlatformMBeanServer()); + // When SSL is enabled, the RMIConnectorServer needs the SSL socket factory + // in its environment to connect to the SSL-enabled RMI registry for JNDI binding + final Map connectorEnv; + if (csf != null) { + connectorEnv = new HashMap<>(); + if (environment != null) { + connectorEnv.putAll(environment); + } + connectorEnv.put("com.sun.jndi.rmi.factory.socket", csf); + } else { + connectorEnv = environment != null ? new HashMap<>(environment) : null; + } + + connectorServer = new RMIConnectorServer(url, connectorEnv, server, ManagementFactory.getPlatformMBeanServer()); LOG.debug("Created JMXConnectorServer {}", connectorServer); } diff --git a/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java b/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java new file mode 100644 index 00000000000..ac4f2cfc134 --- /dev/null +++ b/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java @@ -0,0 +1,285 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.broker.jmx; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.HashMap; +import java.util.Map; + +import javax.management.MBeanServerConnection; +import javax.management.remote.JMXConnector; +import javax.management.remote.JMXConnectorFactory; +import javax.management.remote.JMXServiceURL; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import javax.rmi.ssl.SslRMIClientSocketFactory; + +import org.apache.activemq.broker.SslContext; +import org.apache.activemq.util.Wait; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ManagementContextSslTest { + + private static final Logger LOG = LoggerFactory.getLogger(ManagementContextSslTest.class); + private static final String KEYSTORE_PASSWORD = "password"; + + private static Path tempDir; + private static Path keystoreFile; + private ManagementContext context; + + @BeforeClass + public static void createKeyStore() throws Exception { + tempDir = Files.createTempDirectory("test-jmx-ssl"); + keystoreFile = tempDir.resolve("keystore.p12"); + final Process p = new ProcessBuilder( + "keytool", "-genkeypair", + "-keystore", keystoreFile.toString(), + "-storetype", "PKCS12", + "-storepass", KEYSTORE_PASSWORD, + "-keypass", KEYSTORE_PASSWORD, + "-alias", "test", + "-keyalg", "RSA", + "-keysize", "2048", + "-dname", "CN=localhost,O=Test", + "-validity", "1" + ).inheritIO().start(); + assertEquals("keytool should succeed", 0, p.waitFor()); + } + + @AfterClass + public static void cleanupKeyStore() throws Exception { + if (keystoreFile != null) { + Files.deleteIfExists(keystoreFile); + } + if (tempDir != null) { + Files.deleteIfExists(tempDir); + } + } + + @After + public void tearDown() throws Exception { + if (context != null) { + context.stop(); + } + } + + @Test + public void testSslContextProperty() { + context = new ManagementContext(); + assertNull("sslContext should be null by default", context.getSslContext()); + + final SslContext ssl = new SslContext(); + context.setSslContext(ssl); + assertSame("sslContext should be the one we set", ssl, context.getSslContext()); + } + + @Test + public void testEphemeralPortResolution() throws Exception { + context = new ManagementContext(); + context.setCreateConnector(true); + context.setConnectorPort(0); + context.setConnectorHost("localhost"); + + assertEquals("Before start, port should be 0", 0, context.getConnectorPort()); + + context.start(); + + assertTrue("Connector should be started", + Wait.waitFor(context::isConnectorStarted, 10_000, 100)); + + final int resolvedPort = context.getConnectorPort(); + assertTrue("After start, port should be resolved to a real port (got " + resolvedPort + ")", + resolvedPort > 0); + LOG.info("Ephemeral port resolved to {}", resolvedPort); + } + + @Test + public void testEphemeralPortsAreDifferentPerInstance() throws Exception { + context = new ManagementContext(); + context.setCreateConnector(true); + context.setConnectorPort(0); + context.setConnectorHost("localhost"); + context.start(); + + assertTrue("First connector should be started", + Wait.waitFor(context::isConnectorStarted, 10_000, 100)); + final int port1 = context.getConnectorPort(); + + // Start a second context with ephemeral port + final ManagementContext context2 = new ManagementContext(); + context2.setCreateConnector(true); + context2.setConnectorPort(0); + context2.setConnectorHost("localhost"); + try { + context2.start(); + + assertTrue("Second connector should be started", + Wait.waitFor(context2::isConnectorStarted, 10_000, 100)); + final int port2 = context2.getConnectorPort(); + + assertTrue("Both ports should be > 0", port1 > 0 && port2 > 0); + assertTrue("Ports should be different (port1=" + port1 + ", port2=" + port2 + ")", + port1 != port2); + LOG.info("Two ephemeral ports: {} and {}", port1, port2); + } finally { + context2.stop(); + } + } + + @Test + public void testConnectorStartsWithSsl() throws Exception { + // SslRMIClientSocketFactory (used internally for JNDI binding) reads the JVM default + // trust store, so system properties must be set BEFORE starting the connector + final String savedTrustStore = System.getProperty("javax.net.ssl.trustStore"); + final String savedTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword"); + try { + System.setProperty("javax.net.ssl.trustStore", keystoreFile.toString()); + System.setProperty("javax.net.ssl.trustStorePassword", KEYSTORE_PASSWORD); + + context = createSslManagementContext(); + context.start(); + + assertTrue("Connector should be started", + Wait.waitFor(context::isConnectorStarted, 10_000, 100)); + assertTrue("SSL connector port should be resolved", context.getConnectorPort() > 0); + } finally { + restoreSystemProperty("javax.net.ssl.trustStore", savedTrustStore); + restoreSystemProperty("javax.net.ssl.trustStorePassword", savedTrustStorePassword); + } + } + + @Test + public void testSslJmxConnectionSucceeds() throws Exception { + // SslRMIClientSocketFactory (used both for JNDI binding on the server side and for + // client connections) reads the JVM default trust store via system properties. + // These must be set BEFORE context.start() so the daemon thread sees them. + final String savedTrustStore = System.getProperty("javax.net.ssl.trustStore"); + final String savedTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword"); + try { + System.setProperty("javax.net.ssl.trustStore", keystoreFile.toString()); + System.setProperty("javax.net.ssl.trustStorePassword", KEYSTORE_PASSWORD); + + context = createSslManagementContext(); + context.start(); + + final int port = context.getConnectorPort(); + assertTrue("SSL connector port should be resolved", port > 0); + + final JMXServiceURL url = new JMXServiceURL( + "service:jmx:rmi:///jndi/rmi://localhost:" + port + "/jmxrmi"); + final Map env = new HashMap<>(); + env.put("com.sun.jndi.rmi.factory.socket", new SslRMIClientSocketFactory()); + + // Retry connection: isConnectorStarted() can return true (via isActive()) before + // the RMI server stub is fully registered in the registry + assertTrue("Should connect to SSL JMX", Wait.waitFor(() -> { + try (final JMXConnector connector = JMXConnectorFactory.connect(url, env)) { + final MBeanServerConnection connection = connector.getMBeanServerConnection(); + LOG.info("Successfully connected to SSL JMX on port {}, found {} MBeans", + port, connection.getMBeanCount()); + return connection.getMBeanCount() > 0; + } catch (final Exception e) { + LOG.debug("JMX SSL connection attempt failed: {}", e.getMessage()); + return false; + } + }, 10_000, 500)); + } finally { + restoreSystemProperty("javax.net.ssl.trustStore", savedTrustStore); + restoreSystemProperty("javax.net.ssl.trustStorePassword", savedTrustStorePassword); + } + } + + @Test + public void testConnectorStartsWithoutSsl() throws Exception { + context = new ManagementContext(); + context.setCreateConnector(true); + context.setConnectorPort(0); + context.setConnectorHost("localhost"); + context.start(); + + final int port = context.getConnectorPort(); + assertTrue("Port should be resolved", port > 0); + + final JMXServiceURL url = new JMXServiceURL( + "service:jmx:rmi:///jndi/rmi://localhost:" + port + "/jmxrmi"); + + // Retry connection: isConnectorStarted() can return true (via isActive()) before + // the RMI server stub is fully registered in the registry + assertTrue("Should connect to non-SSL JMX", Wait.waitFor(() -> { + try (final JMXConnector connector = JMXConnectorFactory.connect(url)) { + final MBeanServerConnection connection = connector.getMBeanServerConnection(); + LOG.info("Successfully connected to non-SSL JMX on port {}", port); + return connection.getMBeanCount() > 0; + } catch (final IOException e) { + LOG.debug("JMX connection not yet available: {}", e.getMessage()); + return false; + } + }, 10_000, 500)); + } + + private ManagementContext createSslManagementContext() throws Exception { + final KeyStore ks = KeyStore.getInstance("PKCS12"); + try (final InputStream fis = Files.newInputStream(keystoreFile)) { + ks.load(fis, KEYSTORE_PASSWORD.toCharArray()); + } + + final KeyManagerFactory kmf = KeyManagerFactory.getInstance( + KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, KEYSTORE_PASSWORD.toCharArray()); + + final TrustManagerFactory tmf = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); + + final SSLContext sslCtx = SSLContext.getInstance("TLS"); + sslCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); + + final SslContext sslContext = new SslContext(); + sslContext.setSSLContext(sslCtx); + + final ManagementContext ctx = new ManagementContext(); + ctx.setCreateConnector(true); + ctx.setConnectorPort(0); + ctx.setConnectorHost("localhost"); + ctx.setSslContext(sslContext); + + return ctx; + } + + private static void restoreSystemProperty(final String key, final String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } +} From 5a64f849497062179cb308dd2276cdfb55cf16c4 Mon Sep 17 00:00:00 2001 From: Jean-Louis Monteiro Date: Fri, 20 Feb 2026 00:46:24 +0100 Subject: [PATCH 3/7] Add setup and teardown for SSL context in ManagementContextSslTest --- .../broker/jmx/ManagementContextSslTest.java | 146 +++++++++--------- 1 file changed, 74 insertions(+), 72 deletions(-) diff --git a/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java b/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java index ac4f2cfc134..f970613d564 100644 --- a/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java +++ b/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java @@ -42,6 +42,7 @@ import org.apache.activemq.util.Wait; import org.junit.After; import org.junit.AfterClass; +import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; import org.slf4j.Logger; @@ -54,7 +55,11 @@ public class ManagementContextSslTest { private static Path tempDir; private static Path keystoreFile; + private static SSLContext testSslContext; private ManagementContext context; + private SSLContext savedDefaultSslContext; + private String savedTrustStore; + private String savedTrustStorePassword; @BeforeClass public static void createKeyStore() throws Exception { @@ -73,6 +78,18 @@ public static void createKeyStore() throws Exception { "-validity", "1" ).inheritIO().start(); assertEquals("keytool should succeed", 0, p.waitFor()); + + // Build a reusable SSLContext from the generated keystore + final KeyStore ks = KeyStore.getInstance("PKCS12"); + try (final InputStream fis = Files.newInputStream(keystoreFile)) { + ks.load(fis, KEYSTORE_PASSWORD.toCharArray()); + } + final KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, KEYSTORE_PASSWORD.toCharArray()); + final TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); + testSslContext = SSLContext.getInstance("TLS"); + testSslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); } @AfterClass @@ -85,11 +102,21 @@ public static void cleanupKeyStore() throws Exception { } } + @Before + public void setUp() throws Exception { + savedDefaultSslContext = SSLContext.getDefault(); + savedTrustStore = System.getProperty("javax.net.ssl.trustStore"); + savedTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword"); + } + @After public void tearDown() throws Exception { if (context != null) { context.stop(); } + SSLContext.setDefault(savedDefaultSslContext); + restoreSystemProperty("javax.net.ssl.trustStore", savedTrustStore); + restoreSystemProperty("javax.net.ssl.trustStorePassword", savedTrustStorePassword); } @Test @@ -157,65 +184,56 @@ public void testEphemeralPortsAreDifferentPerInstance() throws Exception { @Test public void testConnectorStartsWithSsl() throws Exception { - // SslRMIClientSocketFactory (used internally for JNDI binding) reads the JVM default - // trust store, so system properties must be set BEFORE starting the connector - final String savedTrustStore = System.getProperty("javax.net.ssl.trustStore"); - final String savedTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword"); - try { - System.setProperty("javax.net.ssl.trustStore", keystoreFile.toString()); - System.setProperty("javax.net.ssl.trustStorePassword", KEYSTORE_PASSWORD); - - context = createSslManagementContext(); - context.start(); + // SslRMIClientSocketFactory uses SSLSocketFactory.getDefault() which relies on + // SSLContext.getDefault(). Setting system properties alone is insufficient if the + // default SSLContext was already cached by a previous test in the same JVM. + SSLContext.setDefault(testSslContext); + System.setProperty("javax.net.ssl.trustStore", keystoreFile.toString()); + System.setProperty("javax.net.ssl.trustStorePassword", KEYSTORE_PASSWORD); + + context = createSslManagementContext(); + context.start(); - assertTrue("Connector should be started", - Wait.waitFor(context::isConnectorStarted, 10_000, 100)); - assertTrue("SSL connector port should be resolved", context.getConnectorPort() > 0); - } finally { - restoreSystemProperty("javax.net.ssl.trustStore", savedTrustStore); - restoreSystemProperty("javax.net.ssl.trustStorePassword", savedTrustStorePassword); - } + assertTrue("Connector should be started", + Wait.waitFor(context::isConnectorStarted, 10_000, 100)); + assertTrue("SSL connector port should be resolved", context.getConnectorPort() > 0); } @Test public void testSslJmxConnectionSucceeds() throws Exception { - // SslRMIClientSocketFactory (used both for JNDI binding on the server side and for - // client connections) reads the JVM default trust store via system properties. - // These must be set BEFORE context.start() so the daemon thread sees them. - final String savedTrustStore = System.getProperty("javax.net.ssl.trustStore"); - final String savedTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword"); - try { - System.setProperty("javax.net.ssl.trustStore", keystoreFile.toString()); - System.setProperty("javax.net.ssl.trustStorePassword", KEYSTORE_PASSWORD); - - context = createSslManagementContext(); - context.start(); - - final int port = context.getConnectorPort(); - assertTrue("SSL connector port should be resolved", port > 0); - - final JMXServiceURL url = new JMXServiceURL( - "service:jmx:rmi:///jndi/rmi://localhost:" + port + "/jmxrmi"); - final Map env = new HashMap<>(); - env.put("com.sun.jndi.rmi.factory.socket", new SslRMIClientSocketFactory()); - - // Retry connection: isConnectorStarted() can return true (via isActive()) before - // the RMI server stub is fully registered in the registry - assertTrue("Should connect to SSL JMX", Wait.waitFor(() -> { - try (final JMXConnector connector = JMXConnectorFactory.connect(url, env)) { - final MBeanServerConnection connection = connector.getMBeanServerConnection(); - LOG.info("Successfully connected to SSL JMX on port {}, found {} MBeans", - port, connection.getMBeanCount()); - return connection.getMBeanCount() > 0; - } catch (final Exception e) { - LOG.debug("JMX SSL connection attempt failed: {}", e.getMessage()); - return false; - } - }, 10_000, 500)); - } finally { - restoreSystemProperty("javax.net.ssl.trustStore", savedTrustStore); - restoreSystemProperty("javax.net.ssl.trustStorePassword", savedTrustStorePassword); - } + // SslRMIClientSocketFactory uses SSLSocketFactory.getDefault() which relies on + // SSLContext.getDefault(). We must set our test SSLContext as the JVM default so + // both the server-side JNDI binding (daemon thread) and client connections trust + // our self-signed certificate. System properties alone are insufficient if the + // default SSLContext was already cached by a previous test in the same JVM. + SSLContext.setDefault(testSslContext); + System.setProperty("javax.net.ssl.trustStore", keystoreFile.toString()); + System.setProperty("javax.net.ssl.trustStorePassword", KEYSTORE_PASSWORD); + + context = createSslManagementContext(); + context.start(); + + final int port = context.getConnectorPort(); + assertTrue("SSL connector port should be resolved", port > 0); + + final JMXServiceURL url = new JMXServiceURL( + "service:jmx:rmi:///jndi/rmi://localhost:" + port + "/jmxrmi"); + final Map env = new HashMap<>(); + env.put("com.sun.jndi.rmi.factory.socket", new SslRMIClientSocketFactory()); + + // Retry connection: isConnectorStarted() can return true (via isActive()) before + // the RMI server stub is fully registered in the registry + assertTrue("Should connect to SSL JMX", Wait.waitFor(() -> { + try (final JMXConnector connector = JMXConnectorFactory.connect(url, env)) { + final MBeanServerConnection connection = connector.getMBeanServerConnection(); + LOG.info("Successfully connected to SSL JMX on port {}, found {} MBeans", + port, connection.getMBeanCount()); + return connection.getMBeanCount() > 0; + } catch (final Exception e) { + LOG.debug("JMX SSL connection attempt failed: {}", e.getMessage()); + return false; + } + }, 10_000, 500)); } @Test @@ -246,25 +264,9 @@ public void testConnectorStartsWithoutSsl() throws Exception { }, 10_000, 500)); } - private ManagementContext createSslManagementContext() throws Exception { - final KeyStore ks = KeyStore.getInstance("PKCS12"); - try (final InputStream fis = Files.newInputStream(keystoreFile)) { - ks.load(fis, KEYSTORE_PASSWORD.toCharArray()); - } - - final KeyManagerFactory kmf = KeyManagerFactory.getInstance( - KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(ks, KEYSTORE_PASSWORD.toCharArray()); - - final TrustManagerFactory tmf = TrustManagerFactory.getInstance( - TrustManagerFactory.getDefaultAlgorithm()); - tmf.init(ks); - - final SSLContext sslCtx = SSLContext.getInstance("TLS"); - sslCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); - + private ManagementContext createSslManagementContext() { final SslContext sslContext = new SslContext(); - sslContext.setSSLContext(sslCtx); + sslContext.setSSLContext(testSslContext); final ManagementContext ctx = new ManagementContext(); ctx.setCreateConnector(true); From 69fe9f4e3e250edeb8f0abda6e31c91647baf6c3 Mon Sep 17 00:00:00 2001 From: Jean-Louis Monteiro Date: Fri, 20 Feb 2026 01:39:27 +0100 Subject: [PATCH 4/7] test(jmx): Enhance SSL JMX connection test with improved error handling and connection retries --- .../broker/jmx/ManagementContextSslTest.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java b/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java index f970613d564..314f3732504 100644 --- a/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java +++ b/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java @@ -28,6 +28,7 @@ import java.security.KeyStore; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import javax.management.MBeanServerConnection; import javax.management.remote.JMXConnector; @@ -213,6 +214,9 @@ public void testSslJmxConnectionSucceeds() throws Exception { context = createSslManagementContext(); context.start(); + assertTrue("Connector should be started", + Wait.waitFor(context::isConnectorStarted, 20_000, 100)); + final int port = context.getConnectorPort(); assertTrue("SSL connector port should be resolved", port > 0); @@ -220,20 +224,26 @@ public void testSslJmxConnectionSucceeds() throws Exception { "service:jmx:rmi:///jndi/rmi://localhost:" + port + "/jmxrmi"); final Map env = new HashMap<>(); env.put("com.sun.jndi.rmi.factory.socket", new SslRMIClientSocketFactory()); + final AtomicReference lastError = new AtomicReference<>(); // Retry connection: isConnectorStarted() can return true (via isActive()) before // the RMI server stub is fully registered in the registry - assertTrue("Should connect to SSL JMX", Wait.waitFor(() -> { + final boolean connected = Wait.waitFor(() -> { try (final JMXConnector connector = JMXConnectorFactory.connect(url, env)) { final MBeanServerConnection connection = connector.getMBeanServerConnection(); LOG.info("Successfully connected to SSL JMX on port {}, found {} MBeans", port, connection.getMBeanCount()); return connection.getMBeanCount() > 0; } catch (final Exception e) { + lastError.set(e); LOG.debug("JMX SSL connection attempt failed: {}", e.getMessage()); return false; } - }, 10_000, 500)); + }, 30_000, 500); + final Exception error = lastError.get(); + assertTrue("Should connect to SSL JMX" + + (error == null ? "" : " (last error: " + error + ")"), + connected); } @Test From a64a038f6a9c9099b11e2168444569e2099332fa Mon Sep 17 00:00:00 2001 From: Jean-Louis Monteiro Date: Fri, 20 Feb 2026 01:50:58 +0100 Subject: [PATCH 5/7] test(jmx): Add Subject Alternative Name (SAN) extension to SSL certificate generation --- .../org/apache/activemq/broker/jmx/ManagementContextSslTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java b/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java index 314f3732504..f7cc8438e55 100644 --- a/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java +++ b/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java @@ -76,6 +76,7 @@ public static void createKeyStore() throws Exception { "-keyalg", "RSA", "-keysize", "2048", "-dname", "CN=localhost,O=Test", + "-ext", "SAN=dns:localhost,ip:127.0.0.1", "-validity", "1" ).inheritIO().start(); assertEquals("keytool should succeed", 0, p.waitFor()); From 3ab96afce20ed86706a092a4cd6ba6d4c4cd4314 Mon Sep 17 00:00:00 2001 From: Jean-Louis Monteiro Date: Fri, 20 Feb 2026 09:52:58 +0100 Subject: [PATCH 6/7] fix(jmx): set java.rmi.server.hostname to prevent SSL handshake failure on multi-homed hosts --- .../activemq/broker/jmx/ManagementContextSslTest.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java b/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java index f7cc8438e55..57fa679d09e 100644 --- a/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java +++ b/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java @@ -61,6 +61,7 @@ public class ManagementContextSslTest { private SSLContext savedDefaultSslContext; private String savedTrustStore; private String savedTrustStorePassword; + private String savedRmiHostname; @BeforeClass public static void createKeyStore() throws Exception { @@ -109,6 +110,7 @@ public void setUp() throws Exception { savedDefaultSslContext = SSLContext.getDefault(); savedTrustStore = System.getProperty("javax.net.ssl.trustStore"); savedTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword"); + savedRmiHostname = System.getProperty("java.rmi.server.hostname"); } @After @@ -119,6 +121,7 @@ public void tearDown() throws Exception { SSLContext.setDefault(savedDefaultSslContext); restoreSystemProperty("javax.net.ssl.trustStore", savedTrustStore); restoreSystemProperty("javax.net.ssl.trustStorePassword", savedTrustStorePassword); + restoreSystemProperty("java.rmi.server.hostname", savedRmiHostname); } @Test @@ -192,6 +195,9 @@ public void testConnectorStartsWithSsl() throws Exception { SSLContext.setDefault(testSslContext); System.setProperty("javax.net.ssl.trustStore", keystoreFile.toString()); System.setProperty("javax.net.ssl.trustStorePassword", KEYSTORE_PASSWORD); + // Force RMI stubs to advertise "localhost" so SSL hostname verification + // matches the certificate SAN (dns:localhost) instead of the machine's IP. + System.setProperty("java.rmi.server.hostname", "localhost"); context = createSslManagementContext(); context.start(); @@ -211,6 +217,11 @@ public void testSslJmxConnectionSucceeds() throws Exception { SSLContext.setDefault(testSslContext); System.setProperty("javax.net.ssl.trustStore", keystoreFile.toString()); System.setProperty("javax.net.ssl.trustStorePassword", KEYSTORE_PASSWORD); + // Force RMI stubs to advertise "localhost" so SSL hostname verification + // matches the certificate SAN (dns:localhost) instead of the machine's actual IP. + // RMI normally embeds InetAddress.getLocalHost() in stubs; on a multi-homed + // machine this can be an IP not covered by the test certificate. + System.setProperty("java.rmi.server.hostname", "localhost"); context = createSslManagementContext(); context.start(); From 44e13ede075d5c96f0314624cd010a5f06650bd5 Mon Sep 17 00:00:00 2001 From: Jean-Louis Monteiro Date: Fri, 20 Feb 2026 17:27:47 +0100 Subject: [PATCH 7/7] fix(jmx): set java.rmi.server.hostname for SSL to prevent hostname verification issues on multi-homed hosts --- .../activemq/broker/jmx/ManagementContext.java | 18 +++++++++++++++++- .../broker/jmx/ManagementContextSslTest.java | 11 ----------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagementContext.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagementContext.java index 4532d6d27c6..c6356695cdd 100644 --- a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagementContext.java +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagementContext.java @@ -170,7 +170,23 @@ public void run() { try { // need to remove MDC as we must not inherit MDC in child threads causing leaks MDC.remove("activemq.broker"); - connectorServer.start(); + // When SSL is enabled, temporarily set java.rmi.server.hostname + // to connectorHost so RMI stubs embed the configured host rather + // than the machine's auto-detected IP. Without this, SSL hostname + // verification fails on multi-homed hosts because the stub carries + // an IP that is not covered by the certificate's SAN entries. + // Pre-existing user-defined values are respected and not overwritten. + final String prevRmiHostname = System.getProperty("java.rmi.server.hostname"); + if (sslContext != null && prevRmiHostname == null) { + System.setProperty("java.rmi.server.hostname", connectorHost); + } + try { + connectorServer.start(); + } finally { + if (sslContext != null && prevRmiHostname == null) { + System.clearProperty("java.rmi.server.hostname"); + } + } serverStub = server.toStub(); } finally { if (brokerName != null) { diff --git a/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java b/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java index 57fa679d09e..f7cc8438e55 100644 --- a/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java +++ b/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagementContextSslTest.java @@ -61,7 +61,6 @@ public class ManagementContextSslTest { private SSLContext savedDefaultSslContext; private String savedTrustStore; private String savedTrustStorePassword; - private String savedRmiHostname; @BeforeClass public static void createKeyStore() throws Exception { @@ -110,7 +109,6 @@ public void setUp() throws Exception { savedDefaultSslContext = SSLContext.getDefault(); savedTrustStore = System.getProperty("javax.net.ssl.trustStore"); savedTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword"); - savedRmiHostname = System.getProperty("java.rmi.server.hostname"); } @After @@ -121,7 +119,6 @@ public void tearDown() throws Exception { SSLContext.setDefault(savedDefaultSslContext); restoreSystemProperty("javax.net.ssl.trustStore", savedTrustStore); restoreSystemProperty("javax.net.ssl.trustStorePassword", savedTrustStorePassword); - restoreSystemProperty("java.rmi.server.hostname", savedRmiHostname); } @Test @@ -195,9 +192,6 @@ public void testConnectorStartsWithSsl() throws Exception { SSLContext.setDefault(testSslContext); System.setProperty("javax.net.ssl.trustStore", keystoreFile.toString()); System.setProperty("javax.net.ssl.trustStorePassword", KEYSTORE_PASSWORD); - // Force RMI stubs to advertise "localhost" so SSL hostname verification - // matches the certificate SAN (dns:localhost) instead of the machine's IP. - System.setProperty("java.rmi.server.hostname", "localhost"); context = createSslManagementContext(); context.start(); @@ -217,11 +211,6 @@ public void testSslJmxConnectionSucceeds() throws Exception { SSLContext.setDefault(testSslContext); System.setProperty("javax.net.ssl.trustStore", keystoreFile.toString()); System.setProperty("javax.net.ssl.trustStorePassword", KEYSTORE_PASSWORD); - // Force RMI stubs to advertise "localhost" so SSL hostname verification - // matches the certificate SAN (dns:localhost) instead of the machine's actual IP. - // RMI normally embeds InetAddress.getLocalHost() in stubs; on a multi-homed - // machine this can be an IP not covered by the test certificate. - System.setProperty("java.rmi.server.hostname", "localhost"); context = createSslManagementContext(); context.start();