diff --git a/jmx-scraper/build.gradle.kts b/jmx-scraper/build.gradle.kts index 87c2129d8..d3c7e4b58 100644 --- a/jmx-scraper/build.gradle.kts +++ b/jmx-scraper/build.gradle.kts @@ -23,6 +23,17 @@ dependencies { testImplementation("org.junit-pioneer:junit-pioneer") } +testing { + suites { + val integrationTest by registering(JvmTestSuite::class) { + dependencies { + implementation("org.testcontainers:junit-jupiter") + implementation("org.slf4j:slf4j-simple") + } + } + } +} + tasks { shadowJar { mergeServiceFiles() @@ -40,7 +51,9 @@ tasks { withType().configureEach { dependsOn(shadowJar) + dependsOn(named("appJar")) systemProperty("shadow.jar.path", shadowJar.get().archiveFile.get().asFile.absolutePath) + systemProperty("app.jar.path", named("appJar").get().archiveFile.get().asFile.absolutePath) systemProperty("gradle.project.version", "${project.version}") } @@ -52,6 +65,14 @@ tasks { } } +tasks.register("appJar") { + from(sourceSets.get("integrationTest").output) + archiveClassifier.set("app") + manifest { + attributes["Main-Class"] = "io.opentelemetry.contrib.jmxscraper.TestApp" + } +} + // Don't publish non-shadowed jar (shadowJar is in shadowRuntimeElements) with(components["java"] as AdhocComponentWithVariants) { configurations.forEach { diff --git a/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/TestApp.java b/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/TestApp.java new file mode 100644 index 000000000..1316ca036 --- /dev/null +++ b/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/TestApp.java @@ -0,0 +1,60 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.jmxscraper; + +import java.lang.management.ManagementFactory; +import javax.management.MBeanServer; +import javax.management.ObjectName; + +@SuppressWarnings("all") +public class TestApp implements TestAppMXBean { + + public static final String APP_STARTED_MSG = "app started"; + public static final String OBJECT_NAME = "io.opentelemetry.test:name=TestApp"; + + private volatile boolean running; + + public static void main(String[] args) { + TestApp app = TestApp.start(); + while (app.isRunning()) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + } + + private TestApp() {} + + static TestApp start() { + TestApp app = new TestApp(); + MBeanServer mbs = ManagementFactory.getPlatformMBeanServer(); + try { + ObjectName objectName = new ObjectName(OBJECT_NAME); + mbs.registerMBean(app, objectName); + } catch (Exception e) { + throw new RuntimeException(e); + } + app.running = true; + System.out.println(APP_STARTED_MSG); + return app; + } + + @Override + public int getIntValue() { + return 42; + } + + @Override + public void stopApp() { + running = false; + } + + boolean isRunning() { + return running; + } +} diff --git a/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/TestAppMXBean.java b/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/TestAppMXBean.java new file mode 100644 index 000000000..11ea69905 --- /dev/null +++ b/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/TestAppMXBean.java @@ -0,0 +1,14 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.jmxscraper; + +@SuppressWarnings("unused") +public interface TestAppMXBean { + + int getIntValue(); + + void stopApp(); +} diff --git a/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/client/JmxRemoteClientTest.java b/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/client/JmxRemoteClientTest.java new file mode 100644 index 000000000..291fc9ac5 --- /dev/null +++ b/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/client/JmxRemoteClientTest.java @@ -0,0 +1,240 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.jmxscraper.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.opentelemetry.contrib.jmxscraper.TestApp; +import java.io.Closeable; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import javax.management.ObjectName; +import javax.management.remote.JMXConnector; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.shaded.com.google.errorprone.annotations.CanIgnoreReturnValue; +import org.testcontainers.utility.MountableFile; + +public class JmxRemoteClientTest { + + private static final Logger logger = LoggerFactory.getLogger(JmxRemoteClientTest.class); + + private static Network network; + + private static final List toClose = new ArrayList<>(); + + @BeforeAll + static void beforeAll() { + network = Network.newNetwork(); + toClose.add(network); + } + + @AfterAll + static void afterAll() { + for (AutoCloseable item : toClose) { + try { + item.close(); + } catch (Exception e) { + logger.warn("Error closing " + item, e); + } + } + } + + @Test + void noAuth() { + try (AppContainer app = new AppContainer().withJmxPort(9990).start()) { + testConnector(() -> JmxRemoteClient.createNew(app.getHost(), app.getPort()).connect()); + } + } + + @Test + void loginPwdAuth() { + String login = "user"; + String pwd = "t0p!Secret"; + try (AppContainer app = new AppContainer().withJmxPort(9999).withUserAuth(login, pwd).start()) { + testConnector( + () -> + JmxRemoteClient.createNew(app.getHost(), app.getPort()) + .userCredentials(login, pwd) + .connect()); + } + } + + @Test + void serverSSL() { + // TODO: test with SSL enabled as RMI registry seems to work differently with SSL + + // create keypair (public,private) + // create server keystore with private key + // configure server keystore + // + // create client truststore with public key + // can we configure to use a custom truststore ??? + // connect to server + } + + private static void testConnector(ConnectorSupplier connectorSupplier) { + try (JMXConnector connector = connectorSupplier.get()) { + assertThat(connector.getMBeanServerConnection()) + .isNotNull() + .satisfies( + connection -> { + try { + ObjectName name = new ObjectName(TestApp.OBJECT_NAME); + Object value = connection.getAttribute(name, "IntValue"); + assertThat(value).isEqualTo(42); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private interface ConnectorSupplier { + JMXConnector get() throws IOException; + } + + private static class AppContainer implements Closeable { + + private final GenericContainer appContainer; + private final Map properties; + private int port; + private String login; + private String pwd; + + private AppContainer() { + this.properties = new HashMap<>(); + + properties.put("com.sun.management.jmxremote.ssl", "false"); // TODO : + + // SSL registry : com.sun.management.jmxremote.registry.ssl + // client side ssl auth: com.sun.management.jmxremote.ssl.need.client.auth + + String appJar = System.getProperty("app.jar.path"); + assertThat(Paths.get(appJar)).isNotEmptyFile().isReadable(); + + this.appContainer = + new GenericContainer<>("openjdk:8u272-jre-slim") + .withCopyFileToContainer(MountableFile.forHostPath(appJar), "/app.jar") + .withLogConsumer(new Slf4jLogConsumer(logger)) + .withNetwork(network) + .waitingFor( + Wait.forLogMessage(TestApp.APP_STARTED_MSG + "\\n", 1) + .withStartupTimeout(Duration.ofSeconds(5))) + .withCommand("java", "-jar", "/app.jar"); + } + + @CanIgnoreReturnValue + public AppContainer withJmxPort(int port) { + this.port = port; + properties.put("com.sun.management.jmxremote.port", Integer.toString(port)); + appContainer.withExposedPorts(port); + return this; + } + + @CanIgnoreReturnValue + public AppContainer withUserAuth(String login, String pwd) { + this.login = login; + this.pwd = pwd; + return this; + } + + @CanIgnoreReturnValue + AppContainer start() { + if (pwd == null) { + properties.put("com.sun.management.jmxremote.authenticate", "false"); + } else { + properties.put("com.sun.management.jmxremote.authenticate", "true"); + + Path pwdFile = createPwdFile(login, pwd); + appContainer.withCopyFileToContainer(MountableFile.forHostPath(pwdFile), "/jmx.password"); + properties.put("com.sun.management.jmxremote.password.file", "/jmx.password"); + + Path accessFile = createAccessFile(login); + appContainer.withCopyFileToContainer(MountableFile.forHostPath(accessFile), "/jmx.access"); + properties.put("com.sun.management.jmxremote.access.file", "/jmx.access"); + } + + String confArgs = + properties.entrySet().stream() + .map( + e -> { + String s = "-D" + e.getKey(); + if (!e.getValue().isEmpty()) { + s += "=" + e.getValue(); + } + return s; + }) + .collect(Collectors.joining(" ")); + + appContainer.withEnv("JAVA_TOOL_OPTIONS", confArgs).start(); + + logger.info("Test application JMX port mapped to {}:{}", getHost(), getPort()); + + toClose.add(this); + return this; + } + + int getPort() { + return appContainer.getMappedPort(port); + } + + String getHost() { + return appContainer.getHost(); + } + + @Override + public void close() { + if (appContainer.isRunning()) { + appContainer.stop(); + } + } + + private static Path createPwdFile(String login, String pwd) { + try { + Path path = Files.createTempFile("test", ".pwd"); + writeLine(path, String.format("%s %s", login, pwd)); + return path; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static Path createAccessFile(String login) { + try { + Path path = Files.createTempFile("test", ".pwd"); + writeLine(path, String.format("%s %s", login, "readwrite")); + return path; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static void writeLine(Path path, String line) throws IOException { + line = line + "\n"; + Files.write(path, line.getBytes(StandardCharsets.UTF_8)); + } + } +} diff --git a/jmx-scraper/src/main/java/io/opentelemetry/contrib/jmxscraper/client/JmxRemoteClient.java b/jmx-scraper/src/main/java/io/opentelemetry/contrib/jmxscraper/client/JmxRemoteClient.java new file mode 100644 index 000000000..de48ebe6e --- /dev/null +++ b/jmx-scraper/src/main/java/io/opentelemetry/contrib/jmxscraper/client/JmxRemoteClient.java @@ -0,0 +1,151 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.jmxscraper.client; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.io.IOException; +import java.net.MalformedURLException; +import java.security.Provider; +import java.security.Security; +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.management.remote.JMXConnector; +import javax.management.remote.JMXConnectorFactory; +import javax.management.remote.JMXServiceURL; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.NameCallback; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.sasl.RealmCallback; + +public class JmxRemoteClient { + + private static final Logger logger = Logger.getLogger(JmxRemoteClient.class.getName()); + + private final String host; + private final int port; + @Nullable + private String userName; + @Nullable + private String password; + @Nullable + private String profile; + @Nullable + private String realm; + private boolean sslRegistry; + + private JmxRemoteClient(@Nonnull String host, int port) { + this.host = host; + this.port = port; + } + + public static JmxRemoteClient createNew(String host, int port) { + return new JmxRemoteClient(host, port); + } + + @CanIgnoreReturnValue + public JmxRemoteClient userCredentials(String userName, String password) { + this.userName = userName; + this.password = password; + return this; + } + + @CanIgnoreReturnValue + public JmxRemoteClient withRemoteProfile(String profile) { + this.profile = profile; + return this; + } + + @CanIgnoreReturnValue + public JmxRemoteClient withRealm(String realm) { + this.realm = realm; + return this; + } + + @CanIgnoreReturnValue + public JmxRemoteClient withSslRegistry() { + this.sslRegistry = true; + return this; + } + + public JMXConnector connect() throws IOException { + Map env = new HashMap<>(); + if (userName != null && password != null) { + env.put(JMXConnector.CREDENTIALS, new String[] {userName, password}); + } + + if (profile != null) { + env.put("jmx.remote.profile", profile); + } + + try { + // Not all supported versions of Java contain this Provider + Class klass = Class.forName("com.sun.security.sasl.Provider"); + Provider provider = (Provider) klass.getDeclaredConstructor().newInstance(); + Security.addProvider(provider); + + env.put( + "jmx.remote.sasl.callback.handler", + (CallbackHandler) + callbacks -> { + for (Callback callback : callbacks) { + if (callback instanceof NameCallback) { + ((NameCallback) callback).setName(userName); + } else if (callback instanceof PasswordCallback) { + char[] pwd = password == null ? null : password.toCharArray(); + ((PasswordCallback) callback).setPassword(pwd); + } else if (callback instanceof RealmCallback) { + ((RealmCallback) callback).setText(realm); + } else { + throw new UnsupportedCallbackException(callback); + } + } + }); + } catch (ReflectiveOperationException e) { + logger.log(Level.WARNING, "SASL unsupported in current environment: " + e.getMessage(), e); + } + + JMXServiceURL url = buildUrl(host, port); + try { + if (sslRegistry) { + return doConnectSslRegistry(url, env); + } else { + return doConnect(url, env); + } + } catch (IOException e) { + throw new IOException("Unable to connect to " + url.getHost() + ":" + url.getPort(), e); + } + } + + @SuppressWarnings("BanJNDI") + private static JMXConnector doConnect(JMXServiceURL url, Map env) + throws IOException { + return JMXConnectorFactory.connect(url, env); + } + + public JMXConnector doConnectSslRegistry(JMXServiceURL url, Map env) { + throw new IllegalStateException("TODO"); + } + + private static JMXServiceURL buildUrl(String host, int port) { + StringBuilder sb = new StringBuilder("service:jmx:rmi:///jndi/rmi://"); + if (host != null) { + sb.append(host); + } + sb.append(":").append(port).append("/jmxrmi"); + + try { + return new JMXServiceURL(sb.toString()); + } catch (MalformedURLException e) { + throw new IllegalArgumentException("invalid url", e); + } + } +}