Skip to content

Commit af8af64

Browse files
committed
feat(#1699) Add support for SSL in JMX connector and resolve ephemeral ports
1 parent 1e922a5 commit af8af64

File tree

2 files changed

+309
-1
lines changed

2 files changed

+309
-1
lines changed

activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagementContext.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.io.IOException;
2020
import java.lang.management.ManagementFactory;
21+
import java.net.ServerSocket;
2122
import java.lang.reflect.InvocationTargetException;
2223
import java.lang.reflect.Method;
2324
import java.lang.reflect.Proxy;
@@ -29,6 +30,7 @@
2930
import java.rmi.server.RMIClientSocketFactory;
3031
import java.rmi.server.RMIServerSocketFactory;
3132
import java.rmi.server.UnicastRemoteObject;
33+
import java.util.HashMap;
3234
import java.util.LinkedList;
3335
import java.util.List;
3436
import java.util.Map;
@@ -555,6 +557,14 @@ protected MBeanServer createMBeanServer() throws MalformedObjectNameException, I
555557
}
556558

557559
private void createConnector(MBeanServer mbeanServer) throws IOException {
560+
// Resolve ephemeral port (0) to an actual free port, similar to tcp://localhost:0
561+
if (connectorPort == 0) {
562+
try (final ServerSocket ss = new ServerSocket(0)) {
563+
connectorPort = ss.getLocalPort();
564+
}
565+
LOG.debug("Resolved ephemeral JMX connector port to {}", connectorPort);
566+
}
567+
558568
// Resolve SSL socket factories first, so they can be shared by registry and connector
559569
final RMIClientSocketFactory csf;
560570
final RMIServerSocketFactory ssf;
@@ -607,7 +617,20 @@ private void createConnector(MBeanServer mbeanServer) throws IOException {
607617
final String serviceURL = "service:jmx:rmi://" + rmiServer + "/jndi/rmi://" +getConnectorHost()+":" + connectorPort + connectorPath;
608618
final JMXServiceURL url = new JMXServiceURL(serviceURL);
609619

610-
connectorServer = new RMIConnectorServer(url, environment, server, ManagementFactory.getPlatformMBeanServer());
620+
// When SSL is enabled, the RMIConnectorServer needs the SSL socket factory
621+
// in its environment to connect to the SSL-enabled RMI registry for JNDI binding
622+
final Map<String, Object> connectorEnv;
623+
if (csf != null) {
624+
connectorEnv = new HashMap<>();
625+
if (environment != null) {
626+
connectorEnv.putAll(environment);
627+
}
628+
connectorEnv.put("com.sun.jndi.rmi.factory.socket", csf);
629+
} else {
630+
connectorEnv = environment != null ? new HashMap<>(environment) : null;
631+
}
632+
633+
connectorServer = new RMIConnectorServer(url, connectorEnv, server, ManagementFactory.getPlatformMBeanServer());
611634
LOG.debug("Created JMXConnectorServer {}", connectorServer);
612635
}
613636

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.activemq.broker.jmx;
18+
19+
import static org.junit.Assert.assertEquals;
20+
import static org.junit.Assert.assertNull;
21+
import static org.junit.Assert.assertSame;
22+
import static org.junit.Assert.assertTrue;
23+
24+
import java.io.IOException;
25+
import java.io.InputStream;
26+
import java.nio.file.Files;
27+
import java.nio.file.Path;
28+
import java.security.KeyStore;
29+
import java.util.HashMap;
30+
import java.util.Map;
31+
32+
import javax.management.MBeanServerConnection;
33+
import javax.management.remote.JMXConnector;
34+
import javax.management.remote.JMXConnectorFactory;
35+
import javax.management.remote.JMXServiceURL;
36+
import javax.net.ssl.KeyManagerFactory;
37+
import javax.net.ssl.SSLContext;
38+
import javax.net.ssl.TrustManagerFactory;
39+
import javax.rmi.ssl.SslRMIClientSocketFactory;
40+
41+
import org.apache.activemq.broker.SslContext;
42+
import org.apache.activemq.util.Wait;
43+
import org.junit.After;
44+
import org.junit.AfterClass;
45+
import org.junit.BeforeClass;
46+
import org.junit.Test;
47+
import org.slf4j.Logger;
48+
import org.slf4j.LoggerFactory;
49+
50+
public class ManagementContextSslTest {
51+
52+
private static final Logger LOG = LoggerFactory.getLogger(ManagementContextSslTest.class);
53+
private static final String KEYSTORE_PASSWORD = "password";
54+
55+
private static Path tempDir;
56+
private static Path keystoreFile;
57+
private ManagementContext context;
58+
59+
@BeforeClass
60+
public static void createKeyStore() throws Exception {
61+
tempDir = Files.createTempDirectory("test-jmx-ssl");
62+
keystoreFile = tempDir.resolve("keystore.p12");
63+
final Process p = new ProcessBuilder(
64+
"keytool", "-genkeypair",
65+
"-keystore", keystoreFile.toString(),
66+
"-storetype", "PKCS12",
67+
"-storepass", KEYSTORE_PASSWORD,
68+
"-keypass", KEYSTORE_PASSWORD,
69+
"-alias", "test",
70+
"-keyalg", "RSA",
71+
"-keysize", "2048",
72+
"-dname", "CN=localhost,O=Test",
73+
"-validity", "1"
74+
).inheritIO().start();
75+
assertEquals("keytool should succeed", 0, p.waitFor());
76+
}
77+
78+
@AfterClass
79+
public static void cleanupKeyStore() throws Exception {
80+
if (keystoreFile != null) {
81+
Files.deleteIfExists(keystoreFile);
82+
}
83+
if (tempDir != null) {
84+
Files.deleteIfExists(tempDir);
85+
}
86+
}
87+
88+
@After
89+
public void tearDown() throws Exception {
90+
if (context != null) {
91+
context.stop();
92+
}
93+
}
94+
95+
@Test
96+
public void testSslContextProperty() {
97+
context = new ManagementContext();
98+
assertNull("sslContext should be null by default", context.getSslContext());
99+
100+
final SslContext ssl = new SslContext();
101+
context.setSslContext(ssl);
102+
assertSame("sslContext should be the one we set", ssl, context.getSslContext());
103+
}
104+
105+
@Test
106+
public void testEphemeralPortResolution() throws Exception {
107+
context = new ManagementContext();
108+
context.setCreateConnector(true);
109+
context.setConnectorPort(0);
110+
context.setConnectorHost("localhost");
111+
112+
assertEquals("Before start, port should be 0", 0, context.getConnectorPort());
113+
114+
context.start();
115+
116+
assertTrue("Connector should be started",
117+
Wait.waitFor(context::isConnectorStarted, 10_000, 100));
118+
119+
final int resolvedPort = context.getConnectorPort();
120+
assertTrue("After start, port should be resolved to a real port (got " + resolvedPort + ")",
121+
resolvedPort > 0);
122+
LOG.info("Ephemeral port resolved to {}", resolvedPort);
123+
}
124+
125+
@Test
126+
public void testEphemeralPortsAreDifferentPerInstance() throws Exception {
127+
context = new ManagementContext();
128+
context.setCreateConnector(true);
129+
context.setConnectorPort(0);
130+
context.setConnectorHost("localhost");
131+
context.start();
132+
133+
assertTrue("First connector should be started",
134+
Wait.waitFor(context::isConnectorStarted, 10_000, 100));
135+
final int port1 = context.getConnectorPort();
136+
137+
// Start a second context with ephemeral port
138+
final ManagementContext context2 = new ManagementContext();
139+
context2.setCreateConnector(true);
140+
context2.setConnectorPort(0);
141+
context2.setConnectorHost("localhost");
142+
try {
143+
context2.start();
144+
145+
assertTrue("Second connector should be started",
146+
Wait.waitFor(context2::isConnectorStarted, 10_000, 100));
147+
final int port2 = context2.getConnectorPort();
148+
149+
assertTrue("Both ports should be > 0", port1 > 0 && port2 > 0);
150+
assertTrue("Ports should be different (port1=" + port1 + ", port2=" + port2 + ")",
151+
port1 != port2);
152+
LOG.info("Two ephemeral ports: {} and {}", port1, port2);
153+
} finally {
154+
context2.stop();
155+
}
156+
}
157+
158+
@Test
159+
public void testConnectorStartsWithSsl() throws Exception {
160+
// SslRMIClientSocketFactory (used internally for JNDI binding) reads the JVM default
161+
// trust store, so system properties must be set BEFORE starting the connector
162+
final String savedTrustStore = System.getProperty("javax.net.ssl.trustStore");
163+
final String savedTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword");
164+
try {
165+
System.setProperty("javax.net.ssl.trustStore", keystoreFile.toString());
166+
System.setProperty("javax.net.ssl.trustStorePassword", KEYSTORE_PASSWORD);
167+
168+
context = createSslManagementContext();
169+
context.start();
170+
171+
assertTrue("Connector should be started",
172+
Wait.waitFor(context::isConnectorStarted, 10_000, 100));
173+
assertTrue("SSL connector port should be resolved", context.getConnectorPort() > 0);
174+
} finally {
175+
restoreSystemProperty("javax.net.ssl.trustStore", savedTrustStore);
176+
restoreSystemProperty("javax.net.ssl.trustStorePassword", savedTrustStorePassword);
177+
}
178+
}
179+
180+
@Test
181+
public void testSslJmxConnectionSucceeds() throws Exception {
182+
// SslRMIClientSocketFactory (used both for JNDI binding on the server side and for
183+
// client connections) reads the JVM default trust store via system properties.
184+
// These must be set BEFORE context.start() so the daemon thread sees them.
185+
final String savedTrustStore = System.getProperty("javax.net.ssl.trustStore");
186+
final String savedTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword");
187+
try {
188+
System.setProperty("javax.net.ssl.trustStore", keystoreFile.toString());
189+
System.setProperty("javax.net.ssl.trustStorePassword", KEYSTORE_PASSWORD);
190+
191+
context = createSslManagementContext();
192+
context.start();
193+
194+
final int port = context.getConnectorPort();
195+
assertTrue("SSL connector port should be resolved", port > 0);
196+
197+
final JMXServiceURL url = new JMXServiceURL(
198+
"service:jmx:rmi:///jndi/rmi://localhost:" + port + "/jmxrmi");
199+
final Map<String, Object> env = new HashMap<>();
200+
env.put("com.sun.jndi.rmi.factory.socket", new SslRMIClientSocketFactory());
201+
202+
// Retry connection: isConnectorStarted() can return true (via isActive()) before
203+
// the RMI server stub is fully registered in the registry
204+
assertTrue("Should connect to SSL JMX", Wait.waitFor(() -> {
205+
try (final JMXConnector connector = JMXConnectorFactory.connect(url, env)) {
206+
final MBeanServerConnection connection = connector.getMBeanServerConnection();
207+
LOG.info("Successfully connected to SSL JMX on port {}, found {} MBeans",
208+
port, connection.getMBeanCount());
209+
return connection.getMBeanCount() > 0;
210+
} catch (final Exception e) {
211+
LOG.debug("JMX SSL connection attempt failed: {}", e.getMessage());
212+
return false;
213+
}
214+
}, 10_000, 500));
215+
} finally {
216+
restoreSystemProperty("javax.net.ssl.trustStore", savedTrustStore);
217+
restoreSystemProperty("javax.net.ssl.trustStorePassword", savedTrustStorePassword);
218+
}
219+
}
220+
221+
@Test
222+
public void testConnectorStartsWithoutSsl() throws Exception {
223+
context = new ManagementContext();
224+
context.setCreateConnector(true);
225+
context.setConnectorPort(0);
226+
context.setConnectorHost("localhost");
227+
context.start();
228+
229+
final int port = context.getConnectorPort();
230+
assertTrue("Port should be resolved", port > 0);
231+
232+
final JMXServiceURL url = new JMXServiceURL(
233+
"service:jmx:rmi:///jndi/rmi://localhost:" + port + "/jmxrmi");
234+
235+
// Retry connection: isConnectorStarted() can return true (via isActive()) before
236+
// the RMI server stub is fully registered in the registry
237+
assertTrue("Should connect to non-SSL JMX", Wait.waitFor(() -> {
238+
try (final JMXConnector connector = JMXConnectorFactory.connect(url)) {
239+
final MBeanServerConnection connection = connector.getMBeanServerConnection();
240+
LOG.info("Successfully connected to non-SSL JMX on port {}", port);
241+
return connection.getMBeanCount() > 0;
242+
} catch (final IOException e) {
243+
LOG.debug("JMX connection not yet available: {}", e.getMessage());
244+
return false;
245+
}
246+
}, 10_000, 500));
247+
}
248+
249+
private ManagementContext createSslManagementContext() throws Exception {
250+
final KeyStore ks = KeyStore.getInstance("PKCS12");
251+
try (final InputStream fis = Files.newInputStream(keystoreFile)) {
252+
ks.load(fis, KEYSTORE_PASSWORD.toCharArray());
253+
}
254+
255+
final KeyManagerFactory kmf = KeyManagerFactory.getInstance(
256+
KeyManagerFactory.getDefaultAlgorithm());
257+
kmf.init(ks, KEYSTORE_PASSWORD.toCharArray());
258+
259+
final TrustManagerFactory tmf = TrustManagerFactory.getInstance(
260+
TrustManagerFactory.getDefaultAlgorithm());
261+
tmf.init(ks);
262+
263+
final SSLContext sslCtx = SSLContext.getInstance("TLS");
264+
sslCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
265+
266+
final SslContext sslContext = new SslContext();
267+
sslContext.setSSLContext(sslCtx);
268+
269+
final ManagementContext ctx = new ManagementContext();
270+
ctx.setCreateConnector(true);
271+
ctx.setConnectorPort(0);
272+
ctx.setConnectorHost("localhost");
273+
ctx.setSslContext(sslContext);
274+
275+
return ctx;
276+
}
277+
278+
private static void restoreSystemProperty(final String key, final String value) {
279+
if (value == null) {
280+
System.clearProperty(key);
281+
} else {
282+
System.setProperty(key, value);
283+
}
284+
}
285+
}

0 commit comments

Comments
 (0)