This is an automated email from the ASF dual-hosted git repository. kao pushed a commit to branch 3.7.x in repository https://gitbox.apache.org/repos/asf/james-project.git
commit e3afb128cfb5ff2bc22ad3a45d22cffa47cf2848 Author: Tung Van TRAN <[email protected]> AuthorDate: Mon Feb 13 07:36:58 2023 +0700 JAMES-3881 Set a JMX password (cherry picked from commit 57874c0a41fbdbf9e96740583cf2337766a5c816) --- .../cli/JmxSecurityServerIntegrationTest.java | 115 +++++++++++++++++++++ .../main/java/org/apache/james/cli/ServerCmd.java | 39 ++++++- .../apache/james/cli/probe/impl/JmxConnection.java | 39 ++++++- .../java/org/apache/james/cli/ServerCmdTest.java | 59 +++++++++++ .../docs/modules/ROOT/pages/configure/jmx.adoc | 34 ++++++ server/apps/memory-app/pom.xml | 1 + .../org/apache/james/TemporaryJamesServer.java | 106 +++++++++++++++++++ .../org/apache/james/modules/server/JMXServer.java | 71 ++++++++++++- .../james/modules/server/JmxConfiguration.java | 5 + 9 files changed, 462 insertions(+), 7 deletions(-) diff --git a/server/apps/cli-integration-tests/src/test/java/org/apache/james/cli/JmxSecurityServerIntegrationTest.java b/server/apps/cli-integration-tests/src/test/java/org/apache/james/cli/JmxSecurityServerIntegrationTest.java new file mode 100644 index 0000000000..089016684e --- /dev/null +++ b/server/apps/cli-integration-tests/src/test/java/org/apache/james/cli/JmxSecurityServerIntegrationTest.java @@ -0,0 +1,115 @@ +/**************************************************************** + * 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.james.cli; + +import static org.apache.james.MemoryJamesServerMain.IN_MEMORY_SERVER_AGGREGATE_MODULE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.List; + +import org.apache.commons.io.IOUtils; +import org.apache.james.GuiceJamesServer; +import org.apache.james.TemporaryJamesServer; +import org.apache.james.cli.util.OutputCapture; +import org.apache.james.data.UsersRepositoryModuleChooser; +import org.apache.james.mailbox.store.search.ListeningMessageSearchIndex; +import org.apache.james.modules.data.MemoryUsersRepositoryModule; +import org.apache.james.modules.server.JMXServerModule; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.google.common.collect.ImmutableList; + +class JmxSecurityServerIntegrationTest { + + private static final List<String> BASE_CONFIGURATION_FILE_NAMES = ImmutableList.of("dnsservice.xml", + "dnsservice.xml", + "imapserver.xml", + "jwt_publickey", + "lmtpserver.xml", + "mailetcontainer.xml", + "mailrepositorystore.xml", + "managesieveserver.xml", + "pop3server.xml", + "smtpserver.xml"); + + private GuiceJamesServer jamesServer; + + @BeforeEach + void beforeEach(@TempDir Path workingPath) throws Exception { + TemporaryJamesServer temporaryJamesServer = new TemporaryJamesServer(workingPath.toFile(), BASE_CONFIGURATION_FILE_NAMES); + writeFile(workingPath + "/conf/jmx.properties", "jmx.address=127.0.0.1\n" + + "jmx.port=9999\n"); + writeFile(workingPath + "/conf/jmxremote.password", "james-admin pass1\n"); + writeFile(workingPath + "/conf/jmxremote.access", "james-admin readwrite\n"); + + jamesServer = temporaryJamesServer.getJamesServer() + .combineWith(IN_MEMORY_SERVER_AGGREGATE_MODULE) + .combineWith(new UsersRepositoryModuleChooser(new MemoryUsersRepositoryModule()) + .chooseModules(UsersRepositoryModuleChooser.Implementation.DEFAULT)) + .overrideWith(new JMXServerModule(), + binder -> binder.bind(ListeningMessageSearchIndex.class).toInstance(mock(ListeningMessageSearchIndex.class))); + jamesServer.start(); + + } + + @AfterEach + void afterEach() { + if (jamesServer != null && jamesServer.isStarted()) { + jamesServer.stop(); + } + } + + @Test + void jamesCliShouldFailWhenNotGiveAuthCredential() throws Exception { + OutputCapture outputCapture = new OutputCapture(); + + assertThatThrownBy(() -> ServerCmd.executeAndOutputToStream(new String[]{"-h", "127.0.0.1", "-p", "9999", "listdomains"}, outputCapture.getPrintStream())) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("Authentication failed! Credentials required"); + } + + @Test + void jamesCliShouldWorkWhenGiveAuthCredential() throws Exception { + OutputCapture outputCapture = new OutputCapture(); + ServerCmd.executeAndOutputToStream(new String[]{"-h", "127.0.0.1", "-p", "9999", "-username", "james-admin", "-password", "pass1", + "listdomains"}, outputCapture.getPrintStream()); + + assertThat(outputCapture.getContent()).contains("localhost"); + } + + private void writeFile(String fileNamePath, String data) { + File passwordFile = new File(fileNamePath); + try (OutputStream outputStream = new FileOutputStream(passwordFile)) { + IOUtils.write(data, outputStream, StandardCharsets.UTF_8); + } catch (IOException ignored) { + } + } +} diff --git a/server/apps/cli/src/main/java/org/apache/james/cli/ServerCmd.java b/server/apps/cli/src/main/java/org/apache/james/cli/ServerCmd.java index 29b9ff42f3..77b3aa1496 100644 --- a/server/apps/cli/src/main/java/org/apache/james/cli/ServerCmd.java +++ b/server/apps/cli/src/main/java/org/apache/james/cli/ServerCmd.java @@ -18,13 +18,16 @@ ****************************************************************/ package org.apache.james.cli; +import java.io.File; import java.io.IOException; import java.io.PrintStream; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collection; import java.util.Map; import java.util.Map.Entry; import java.util.Optional; +import java.util.StringTokenizer; import java.util.concurrent.TimeUnit; import java.util.function.Function; @@ -34,6 +37,7 @@ import org.apache.commons.cli.DefaultParser; import org.apache.commons.cli.HelpFormatter; import org.apache.commons.cli.Options; import org.apache.commons.cli.ParseException; +import org.apache.commons.io.FileUtils; import org.apache.james.cli.exceptions.InvalidArgumentNumberException; import org.apache.james.cli.exceptions.JamesCliException; import org.apache.james.cli.exceptions.MissingCommandException; @@ -72,6 +76,10 @@ public class ServerCmd { public static final String PORT_OPT_LONG = "port"; public static final String PORT_OPT_SHORT = "p"; + public static final String JMX_USERNAME_OPT = "username"; + public static final String JMX_PASSWORD_OPT = "password"; + public static final String JMX_PASSWORD_FILE_PATH_DEFAULT = System.getProperty("user.home") + "/conf/jmxremote.password"; + private static final String DEFAULT_HOST = "127.0.0.1"; private static final int DEFAULT_PORT = 9999; private static final Logger LOG = LoggerFactory.getLogger(ServerCmd.class); @@ -79,7 +87,9 @@ public class ServerCmd { private static Options createOptions() { return new Options() .addOption(HOST_OPT_SHORT, HOST_OPT_LONG, true, "node hostname or ip address") - .addOption(PORT_OPT_SHORT, PORT_OPT_LONG, true, "remote jmx agent port number"); + .addOption(PORT_OPT_SHORT, PORT_OPT_LONG, true, "remote jmx agent port number") + .addOption(JMX_USERNAME_OPT, JMX_USERNAME_OPT, true, "remote jmx username") + .addOption(JMX_PASSWORD_OPT, JMX_PASSWORD_OPT, true, "remote jmx password"); } /** @@ -111,7 +121,8 @@ public class ServerCmd { public static void executeAndOutputToStream(String[] args, PrintStream printStream) throws Exception { Stopwatch stopWatch = Stopwatch.createStarted(); CommandLine cmd = parseCommandLine(args); - JmxConnection jmxConnection = new JmxConnection(getHost(cmd), getPort(cmd)); + JmxConnection jmxConnection = new JmxConnection(getHost(cmd), getPort(cmd), getAuthCredential(cmd, JMX_PASSWORD_FILE_PATH_DEFAULT)); + CmdType cmdType = new ServerCmd( new JmxDataProbe().connect(jmxConnection), new JmxMailboxProbe().connect(jmxConnection), @@ -155,6 +166,30 @@ public class ServerCmd { return host; } + @VisibleForTesting + static Optional<JmxConnection.AuthCredential> getAuthCredential(CommandLine cmd, String jmxPasswordFilePath) { + return getAuthCredentialFromCommandLine(cmd) + .or(() -> getAuthCredentialFromJmxPasswordFile(jmxPasswordFilePath)); + } + + static Optional<JmxConnection.AuthCredential> getAuthCredentialFromCommandLine(CommandLine cmd) { + String username = cmd.getOptionValue(JMX_USERNAME_OPT); + String password = cmd.getOptionValue(JMX_PASSWORD_OPT); + if (Strings.isNullOrEmpty(username) || Strings.isNullOrEmpty(password)) { + return Optional.empty(); + } + return Optional.of(new JmxConnection.AuthCredential(username, password)); + } + + static Optional<JmxConnection.AuthCredential> getAuthCredentialFromJmxPasswordFile(String jmxPasswordFilePath) { + try { + StringTokenizer stringTokenizer = new StringTokenizer(FileUtils.readLines(new File(jmxPasswordFilePath), StandardCharsets.US_ASCII).get(0), " "); + return Optional.of(new JmxConnection.AuthCredential(stringTokenizer.nextToken(), stringTokenizer.nextToken())); + } catch (Exception e) { + return Optional.empty(); + } + } + @VisibleForTesting static int getPort(CommandLine cmd) throws ParseException { String portNum = cmd.getOptionValue(PORT_OPT_LONG); diff --git a/server/apps/cli/src/main/java/org/apache/james/cli/probe/impl/JmxConnection.java b/server/apps/cli/src/main/java/org/apache/james/cli/probe/impl/JmxConnection.java index ed416f39f5..a6762b1054 100644 --- a/server/apps/cli/src/main/java/org/apache/james/cli/probe/impl/JmxConnection.java +++ b/server/apps/cli/src/main/java/org/apache/james/cli/probe/impl/JmxConnection.java @@ -20,6 +20,9 @@ package org.apache.james.cli.probe.impl; import java.io.Closeable; import java.io.IOException; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; import javax.management.MBeanServerConnection; import javax.management.MBeanServerInvocationHandler; @@ -29,20 +32,50 @@ import javax.management.remote.JMXConnector; import javax.management.remote.JMXConnectorFactory; import javax.management.remote.JMXServiceURL; +import com.google.common.collect.ImmutableMap; + public class JmxConnection implements Closeable { + public static class AuthCredential { + String username; + String password; + + public AuthCredential(String username, String password) { + this.username = username; + this.password = password; + } + + @Override + public final boolean equals(Object o) { + if (o instanceof AuthCredential) { + AuthCredential that = (AuthCredential) o; + return Objects.equals(this.username, that.username) + && Objects.equals(this.password, that.password); + } + return false; + } + + @Override + public final int hashCode() { + return Objects.hash(username, password); + } + } + private static final String fmtUrl = "service:jmx:rmi:///jndi/rmi://%s:%d/jmxrmi"; private static final int defaultPort = 9999; public static JmxConnection defaultJmxConnection(String host) throws IOException { - return new JmxConnection(host, defaultPort); + return new JmxConnection(host, defaultPort, Optional.empty()); } private final JMXConnector jmxConnector; - public JmxConnection(String host, int port) throws IOException { + public JmxConnection(String host, int port, Optional<AuthCredential> authCredential) throws IOException { JMXServiceURL jmxUrl = new JMXServiceURL(String.format(fmtUrl, host, port)); - jmxConnector = JMXConnectorFactory.connect(jmxUrl, null); + Map<String, ?> env = authCredential + .map(credential -> ImmutableMap.of("jmx.remote.credentials", new String[]{credential.username, credential.password})) + .orElse(ImmutableMap.of()); + jmxConnector = JMXConnectorFactory.connect(jmxUrl, env); } @Override diff --git a/server/apps/cli/src/test/java/org/apache/james/cli/ServerCmdTest.java b/server/apps/cli/src/test/java/org/apache/james/cli/ServerCmdTest.java index 0b8d025dda..5f169ad3d3 100644 --- a/server/apps/cli/src/test/java/org/apache/james/cli/ServerCmdTest.java +++ b/server/apps/cli/src/test/java/org/apache/james/cli/ServerCmdTest.java @@ -25,14 +25,24 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; +import java.util.Optional; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.ParseException; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.RandomStringUtils; import org.apache.james.cli.exceptions.InvalidArgumentNumberException; import org.apache.james.cli.exceptions.MissingCommandException; import org.apache.james.cli.exceptions.UnrecognizedCommandException; +import org.apache.james.cli.probe.impl.JmxConnection; import org.apache.james.cli.probe.impl.JmxDataProbe; import org.apache.james.cli.probe.impl.JmxMailboxProbe; import org.apache.james.cli.probe.impl.JmxQuotaProbe; @@ -48,6 +58,7 @@ import org.apache.james.mailbox.model.SerializableQuotaLimitValue; import org.apache.james.rrt.lib.MappingsImpl; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import com.google.common.collect.ImmutableList; @@ -1200,4 +1211,52 @@ class ServerCmdTest { .isInstanceOf(IllegalArgumentException.class); } + @Test + void getAuthCredentialShouldReturnEmptyWhenNotGiven(@TempDir Path tempDir) throws Exception { + String[] arguments = {"-h", "127.0.0.1", "-p", "99999", "command", "arg1", "arg2", "arg3"}; + CommandLine commandLine = ServerCmd.parseCommandLine(arguments); + + assertThat(ServerCmd.getAuthCredential(commandLine, tempDir.toString())) + .isEmpty(); + } + + @Test + void getAuthCredentialShouldReturnValueWhenGivenViaCommandLine(@TempDir Path tempDir) throws Exception { + String[] arguments = {"-h", "127.0.0.1", "-p", "99999", "-username", "james-admin", "-password", "123456", "command", "arg1", "arg2", "arg3"}; + CommandLine commandLine = ServerCmd.parseCommandLine(arguments); + + assertThat(ServerCmd.getAuthCredential(commandLine, tempDir.toString())) + .isEqualTo(Optional.of(new JmxConnection.AuthCredential("james-admin", "123456"))); + } + + @Test + void getAuthCredentialShouldReturnValueWhenGivenViaJmxPasswordFile(@TempDir Path tempDir) throws Exception { + String[] arguments = {"-h", "127.0.0.1", "-p", "99999", "command", "arg1", "arg2", "arg3"}; + CommandLine commandLine = ServerCmd.parseCommandLine(arguments); + + File passwordFile = new File(tempDir.toString() + "/jmxremote.password"); + try (OutputStream outputStream = new FileOutputStream(passwordFile)) { + IOUtils.write("james-admin1 pass2\n", outputStream, StandardCharsets.UTF_8); + } catch (IOException ignored) { + } + + assertThat(ServerCmd.getAuthCredential(commandLine, passwordFile.getPath())) + .isEqualTo(Optional.of(new JmxConnection.AuthCredential("james-admin1", "pass2"))); + } + + @Test + void getAuthCredentialShouldPreferCommandlineValue(@TempDir Path tempDir) throws Exception { + String[] arguments = {"-h", "127.0.0.1", "-p", "99999", "-username", "james-admin", "-password", "123456", "command", "arg1", "arg2", "arg3"}; + CommandLine commandLine = ServerCmd.parseCommandLine(arguments); + + File passwordFile = new File(tempDir.toString() + "/jmxremote.password"); + try (OutputStream outputStream = new FileOutputStream(passwordFile)) { + IOUtils.write("james-admin1 pass2\n", outputStream, StandardCharsets.UTF_8); + } catch (IOException ignored) { + } + + assertThat(ServerCmd.getAuthCredential(commandLine, tempDir.toString())) + .isEqualTo(Optional.of(new JmxConnection.AuthCredential("james-admin", "123456"))); + } + } diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/jmx.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/jmx.adoc index 695128588b..996b8a6ebd 100644 --- a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/jmx.adoc +++ b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/jmx.adoc @@ -22,3 +22,37 @@ in GIT to get some examples and hints. To access from a remote location, it has been reported that `-Dcom.sun.management.jmxremote.ssl=false` is needed as a JVM argument. + + +== JMX Security + +In order to set up JMX authentication, we need to put `jmxremote.password` and `jmxremote.access` file +to `/conf` directory. + +- `jmxremote.password`: define the username and password, that will be used by the client (here is james-cli) + +File's content example: +``` +james-admin pass1 +``` + +- `jmxremote.access`: define the pair of username and access permission + +File's content example: +``` +james-admin readWrite +``` + +When James runs with option `-Djames.jmx.credential.generation=true`, James will automatically generate `jmxremote.password` if the file does not exist. +Then the default username is `james-admin` and a random password. + +=== James-cli + +When the JMX server starts with authentication configuration, it will require the client need provide username/password for bypass. +To do that, we need set arguments `-username` and `-password` for the command request. + +Command example: +``` +james-cli -h 127.0.0.1 -p 9999 -username james-admin -password pass1 listdomains +``` + diff --git a/server/apps/memory-app/pom.xml b/server/apps/memory-app/pom.xml index 1e1729e469..1736218be8 100644 --- a/server/apps/memory-app/pom.xml +++ b/server/apps/memory-app/pom.xml @@ -297,6 +297,7 @@ <jvmFlag>-Dworking.directory=/root/</jvmFlag> <!-- Prevents Logjam (CVE-2015-4000) --> <jvmFlag>-Djdk.tls.ephemeralDHKeySize=2048</jvmFlag> + <jvmFlag>-Djames.jmx.credential.generation=true</jvmFlag> </jvmFlags> <creationTime>USE_CURRENT_TIMESTAMP</creationTime> <volumes> diff --git a/server/container/guice/common/src/main/java/org/apache/james/TemporaryJamesServer.java b/server/container/guice/common/src/main/java/org/apache/james/TemporaryJamesServer.java new file mode 100644 index 0000000000..231d3586c2 --- /dev/null +++ b/server/container/guice/common/src/main/java/org/apache/james/TemporaryJamesServer.java @@ -0,0 +1,106 @@ +/**************************************************************** + * 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.james; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +import org.apache.commons.io.IOUtils; +import org.apache.james.server.core.configuration.Configuration; + +import com.google.common.collect.ImmutableList; + +public class TemporaryJamesServer { + private static final List<String> CONFIGURATION_FILE_NAMES = ImmutableList.of( + "dnsservice.xml", + "domainlist.xml", + "imapserver.xml", + "keystore", + "listeners.xml", + "lmtpserver.xml", + "mailetcontainer.xml", + "mailrepositorystore.xml", + "managesieveserver.xml", + "pop3server.xml", + "smtpserver.xml", + "usersrepository.xml"); + + private final Configuration configuration; + private final File configurationFolder; + private final List<String> configurationFileNames; + private GuiceJamesServer jamesServer; + + public TemporaryJamesServer(File workingDir) { + this(workingDir, CONFIGURATION_FILE_NAMES); + } + + public TemporaryJamesServer(File workingDir, List<String> configurationFileNames) { + this.configurationFileNames = configurationFileNames; + Configuration configuration = Configuration.builder().workingDirectory(workingDir).build(); + configurationFolder = workingDir.toPath().resolve("conf").toFile(); + if (!configurationFolder.exists()) { + configurationFolder.mkdir(); + } + copyResources(Paths.get(configurationFolder.getAbsolutePath())); + this.configuration = configuration; + } + + public GuiceJamesServer getJamesServer() { + if (jamesServer == null) { + jamesServer = GuiceJamesServer.forConfiguration(configuration); + } + return jamesServer; + } + + public void appendConfigurationFile(String configurationData, String configurationFileName) throws IOException { + try (OutputStream outputStream = new FileOutputStream(Paths.get(configurationFolder.getAbsolutePath(), configurationFileName).toFile())) { + IOUtils.write(configurationData, outputStream, StandardCharsets.UTF_8); + } + } + + private void copyResources(Path resourcesFolder) { + configurationFileNames + .forEach(resourceName -> copyResource(resourcesFolder, resourceName)); + } + + public static void copyResource(Path resourcesFolder, String resourceName) { + var resolvedResource = resourcesFolder.resolve(resourceName); + try (OutputStream outputStream = new FileOutputStream(resolvedResource.toFile())) { + URL resource = ClassLoader.getSystemClassLoader().getResource(resourceName); + if (resource != null) { + try (InputStream stream = resource.openStream()) { + stream.transferTo(outputStream); + } + } else { + throw new RuntimeException("Failed to load configuration resource " + resourceName); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/container/guice/jmx/src/main/java/org/apache/james/modules/server/JMXServer.java b/server/container/guice/jmx/src/main/java/org/apache/james/modules/server/JMXServer.java index e1ffb3ca2f..badd87f9b3 100644 --- a/server/container/guice/jmx/src/main/java/org/apache/james/modules/server/JMXServer.java +++ b/server/container/guice/jmx/src/main/java/org/apache/james/modules/server/JMXServer.java @@ -19,11 +19,25 @@ package org.apache.james.modules.server; +import static org.apache.james.modules.server.JmxConfiguration.ACCESS_FILE_NAME; +import static org.apache.james.modules.server.JmxConfiguration.JMX_CREDENTIAL_GENERATION_ENABLE_DEFAULT_VALUE; +import static org.apache.james.modules.server.JmxConfiguration.JMX_CREDENTIAL_GENERATION_ENABLE_PROPERTY_KEY; +import static org.apache.james.modules.server.JmxConfiguration.PASSWORD_FILE_NAME; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; import java.lang.management.ManagementFactory; import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; import java.rmi.registry.LocateRegistry; import java.util.HashSet; import java.util.Map; +import java.util.Optional; import java.util.Set; import javax.annotation.PreDestroy; @@ -34,7 +48,11 @@ import javax.management.remote.JMXConnectorServer; import javax.management.remote.JMXConnectorServerFactory; import javax.management.remote.JMXServiceURL; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.james.filesystem.api.JamesDirectoriesProvider; import org.apache.james.lifecycle.api.Startable; +import org.apache.james.util.FunctionalUtils; import org.apache.james.util.RestrictingRMISocketFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -48,13 +66,17 @@ public class JMXServer implements Startable { private final JmxConfiguration jmxConfiguration; private final Set<String> registeredKeys; private final Object lock; + private final String jmxPasswordFilePath; + private final String jmxAccessFilePath; private JMXConnectorServer jmxConnectorServer; private boolean isStarted; private RestrictingRMISocketFactory restrictingRMISocketFactory; @Inject - public JMXServer(JmxConfiguration jmxConfiguration) { + public JMXServer(JmxConfiguration jmxConfiguration, JamesDirectoriesProvider directoriesProvider) { this.jmxConfiguration = jmxConfiguration; + this.jmxPasswordFilePath = directoriesProvider.getConfDirectory() + PASSWORD_FILE_NAME; + this.jmxAccessFilePath = directoriesProvider.getConfDirectory() + ACCESS_FILE_NAME; isStarted = false; registeredKeys = new HashSet<>(); lock = new Object(); @@ -98,8 +120,14 @@ public class JMXServer implements Startable { + ":" + jmxConfiguration.getHost().getPort() + "/jmxrmi"; restrictingRMISocketFactory = new RestrictingRMISocketFactory(jmxConfiguration.getHost().getHostName()); LocateRegistry.createRegistry(jmxConfiguration.getHost().getPort(), restrictingRMISocketFactory, restrictingRMISocketFactory); + generateJMXPasswordFileIfNeed(); + + Map<String, String> environment = Optional.of(existJmxPasswordFile()) + .filter(FunctionalUtils.identityPredicate()) + .map(hasJmxPasswordFile -> ImmutableMap.of("jmx.remote.x.password.file", jmxPasswordFilePath, + "jmx.remote.x.access.file", jmxAccessFilePath)) + .orElse(ImmutableMap.of()); - Map<String, ?> environment = ImmutableMap.of(); jmxConnectorServer = JMXConnectorServerFactory.newJMXConnectorServer(new JMXServiceURL(serviceURL), environment, ManagementFactory.getPlatformMBeanServer()); @@ -126,4 +154,43 @@ public class JMXServer implements Startable { } } + private void generateJMXPasswordFileIfNeed() { + if (Boolean.parseBoolean(System.getProperty(JMX_CREDENTIAL_GENERATION_ENABLE_PROPERTY_KEY, JMX_CREDENTIAL_GENERATION_ENABLE_DEFAULT_VALUE)) + && !existJmxPasswordFile()) { + generateJMXPasswordFile(); + } + } + + private boolean existJmxPasswordFile() { + return Files.exists(Path.of(jmxPasswordFilePath)) && Files.exists(Path.of(jmxAccessFilePath)); + } + + private void generateJMXPasswordFile() { + File passwordFile = new File(jmxPasswordFilePath); + if (!passwordFile.exists()) { + try (OutputStream outputStream = new FileOutputStream(passwordFile)) { + String randomPassword = RandomStringUtils.random(10, true, true); + IOUtils.write(JmxConfiguration.JAMES_ADMIN_USER_DEFAULT + " " + randomPassword + "\n", outputStream, StandardCharsets.UTF_8); + setPermissionOwnerOnly(passwordFile); + LOGGER.info("Generated JMX password file: " + passwordFile.getPath()); + } catch (IOException e) { + throw new RuntimeException("Error when creating JMX password file: " + passwordFile.getPath(), e); + } + } + + File accessFile = new File(jmxAccessFilePath); + if (!accessFile.exists()) { + try (OutputStream outputStream = new FileOutputStream(accessFile)) { + IOUtils.write(JmxConfiguration.JAMES_ADMIN_USER_DEFAULT + " readwrite\n", outputStream, StandardCharsets.UTF_8); + setPermissionOwnerOnly(accessFile); + LOGGER.info("Generated JMX access file: " + accessFile.getPath()); + } catch (IOException e) { + throw new RuntimeException("Error when creating JMX access file: " + accessFile.getPath(), e); + } + } + } + + private void setPermissionOwnerOnly(File file) throws IOException { + Files.setPosixFilePermissions(file.toPath(), Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + } } diff --git a/server/container/guice/jmx/src/main/java/org/apache/james/modules/server/JmxConfiguration.java b/server/container/guice/jmx/src/main/java/org/apache/james/modules/server/JmxConfiguration.java index 9bbebd3a28..c833990e20 100644 --- a/server/container/guice/jmx/src/main/java/org/apache/james/modules/server/JmxConfiguration.java +++ b/server/container/guice/jmx/src/main/java/org/apache/james/modules/server/JmxConfiguration.java @@ -34,6 +34,11 @@ public class JmxConfiguration { public static final String LOCALHOST = "localhost"; public static final int DEFAULT_PORT = 9999; public static final boolean ENABLED = true; + public static final String JMX_CREDENTIAL_GENERATION_ENABLE_PROPERTY_KEY = "james.jmx.credential.generation"; + public static final String JMX_CREDENTIAL_GENERATION_ENABLE_DEFAULT_VALUE = "false"; + public static final String PASSWORD_FILE_NAME = "jmxremote.password"; + public static final String ACCESS_FILE_NAME = "jmxremote.access"; + public static final String JAMES_ADMIN_USER_DEFAULT = "james-admin"; public static final JmxConfiguration DEFAULT_CONFIGURATION = new JmxConfiguration(ENABLED, Optional.of(Host.from(LOCALHOST, DEFAULT_PORT))); public static final JmxConfiguration DISABLED = new JmxConfiguration(!ENABLED, Optional.empty()); --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
