This is an automated email from the ASF dual-hosted git repository. rzo1 pushed a commit to branch tomee-10.x in repository https://gitbox.apache.org/repos/asf/tomee.git
commit 3e834f4954036fd28f7b1000b20d603a5db04414 Author: Richard Zowalla <[email protected]> AuthorDate: Tue Oct 13 08:50:01 2026 +0200 Require a secret for stop requests on the admin port (#3087) Any local process could stop the standalone server with a single byte. The in-tree stop clients now send the secret the server writes. (cherry picked from commit 0fb057efe6e322538a8c30ddfa7401a4c0835793) --- .../org/apache/openejb/config/RemoteServer.java | 53 ++++- .../config/RemoteServerShutdownMessageTest.java | 119 +++++++++++ .../apache/openejb/loader/AdminShutdownSecret.java | 116 +++++++++++ .../openejb/loader/AdminShutdownSecretTest.java | 92 +++++++++ docs/admin/configuration/server.adoc | 2 + docs/getting-started.adoc | 5 + itests/openejb-itests-client/pom.xml | 7 + .../org/apache/openejb/test/RemoteTestServer.java | 13 +- .../org/apache/openejb/maven/plugin/ExecMojo.java | 3 +- .../apache/openejb/server/admin/AdminDaemon.java | 153 ++++++++++++++ .../java/org/apache/openejb/server/admin/Stop.java | 31 ++- .../META-INF/org.apache.openejb.cli/stop.examples | 8 + .../META-INF/org.apache.openejb.cli/stop.help | 4 + .../openejb/server/admin/AdminDaemonTest.java | 226 +++++++++++++++++++++ 14 files changed, 821 insertions(+), 11 deletions(-) diff --git a/container/openejb-core/src/main/java/org/apache/openejb/config/RemoteServer.java b/container/openejb-core/src/main/java/org/apache/openejb/config/RemoteServer.java index b75f2168c1..44236f9962 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/config/RemoteServer.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/config/RemoteServer.java @@ -18,6 +18,7 @@ package org.apache.openejb.config; import org.apache.openejb.OpenEJBRuntimeException; +import org.apache.openejb.loader.AdminShutdownSecret; import org.apache.openejb.loader.IO; import org.apache.openejb.loader.Options; import org.apache.openejb.util.JavaSecurityManagers; @@ -582,6 +583,53 @@ public class RemoteServer { } } + /** + * Tomcat gets the configured command followed by a NUL. The standalone + * admin daemon expects a stop code byte, its shutdown secret and a NUL, + * so a plain stop command gets the secret appended when one is found. + */ + byte[] shutdownMessage() { + if (!tomcat && isPlainStopCommand(command)) { + final String secret = findShutdownSecret(); + if (secret != null) { + return AdminShutdownSecret.stopMessage(command.charAt(0), secret); + } + } + + final String shutdown = command + Character.toString((char) 0); + final byte[] message = new byte[shutdown.length()]; + for (int i = 0; i < shutdown.length(); i++) { + message[i] = (byte) shutdown.charAt(i); + } + return message; + } + + /** + * The server writes the secret to the conf directory of openejb.base, + * which defaults to openejb.home. + */ + private String findShutdownSecret() { + final String configured = options.get(AdminShutdownSecret.PROPERTY, (String) null); + final String base = options.get("openejb.base", (String) null); + if (base != null) { + final String secret = AdminShutdownSecret.find(configured, new File(base)); + if (secret != null) { + return secret; + } + } + return AdminShutdownSecret.find(configured, getHome()); + } + + private static boolean isPlainStopCommand(final String command) { + if (command == null || command.isEmpty() || "QqSs".indexOf(command.charAt(0)) < 0) { + return false; + } + return command.length() == 1 + || "stop".equalsIgnoreCase(command) + || "shutdown".equalsIgnoreCase(command) + || "quit".equalsIgnoreCase(command); + } + /** * Send the shutdown message to the running server * @@ -592,10 +640,7 @@ public class RemoteServer { OutputStream stream = null; try (Socket socket = new Socket(host, portShutdown)) { stream = socket.getOutputStream(); - final String shutdown = command + Character.toString((char) 0); - for (int i = 0; i < shutdown.length(); i++) { - stream.write(shutdown.charAt(i)); - } + stream.write(shutdownMessage()); stream.flush(); } catch (final Exception e) { if (attempts > 0) { diff --git a/container/openejb-core/src/test/java/org/apache/openejb/config/RemoteServerShutdownMessageTest.java b/container/openejb-core/src/test/java/org/apache/openejb/config/RemoteServerShutdownMessageTest.java new file mode 100644 index 0000000000..9e603752d0 --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/config/RemoteServerShutdownMessageTest.java @@ -0,0 +1,119 @@ +/* + * 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.openejb.config; + +import org.apache.openejb.loader.AdminShutdownSecret; +import org.apache.openejb.loader.Files; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.Properties; + +import static org.junit.Assert.assertEquals; + +public class RemoteServerShutdownMessageTest { + + private File home; + private String oldSecret; + + @Before + public void setUp() { + home = Files.tmpdir(); + oldSecret = System.clearProperty(AdminShutdownSecret.PROPERTY); + } + + @After + public void tearDown() { + if (oldSecret != null) { + System.setProperty(AdminShutdownSecret.PROPERTY, oldSecret); + } + Files.delete(home); + } + + @Test + public void standaloneWithoutSecret() { + assertEquals("Q\0", message("Q")); + assertEquals("SHUTDOWN\0", message("SHUTDOWN")); + } + + @Test + public void standaloneWithSecretFile() throws Exception { + writeSecret("abc"); + assertEquals("Qabc\0", message("Q")); + assertEquals("Sabc\0", message("SHUTDOWN")); + assertEquals("Sabc\0", message("Stop")); + assertEquals("sabc\0", message("s")); + assertEquals("qabc\0", message("quit")); + } + + @Test + public void standaloneWithConfiguredSecret() throws Exception { + writeSecret("abc"); + final Properties properties = new Properties(); + properties.setProperty(AdminShutdownSecret.PROPERTY, "configured"); + assertEquals("Qconfigured\0", message("Q", properties)); + } + + @Test + public void standaloneWithSecretInBase() throws Exception { + final File base = Files.tmpdir(); + try { + final File conf = Files.mkdir(new File(base, "conf")); + java.nio.file.Files.write(new File(conf, AdminShutdownSecret.FILE_NAME).toPath(), "fromBase".getBytes(StandardCharsets.UTF_8)); + final Properties properties = new Properties(); + properties.setProperty("openejb.base", base.getAbsolutePath()); + assertEquals("QfromBase\0", message("Q", properties)); + } finally { + Files.delete(base); + } + } + + @Test + public void commandAlreadyCarryingTheSecret() throws Exception { + writeSecret("abc"); + assertEquals("Qmysecret\0", message("Qmysecret")); + assertEquals("custom\0", message("custom")); + } + + @Test + public void tomcatUnchanged() throws Exception { + writeSecret("abc"); + Files.mkdir(new File(home, "bin")); + java.nio.file.Files.createFile(new File(new File(home, "bin"), "catalina.sh").toPath()); + + assertEquals("SHUTDOWN\0", message("SHUTDOWN")); + assertEquals("Q\0", message("Q")); + } + + private String message(final String command) { + return message(command, new Properties()); + } + + private String message(final String command, final Properties properties) { + properties.setProperty("openejb.home", home.getAbsolutePath()); + properties.setProperty(RemoteServer.SERVER_SHUTDOWN_COMMAND, command); + return new String(new RemoteServer(properties, 1, false).shutdownMessage(), StandardCharsets.ISO_8859_1); + } + + private void writeSecret(final String secret) throws Exception { + final File conf = Files.mkdir(new File(home, "conf")); + java.nio.file.Files.write(new File(conf, AdminShutdownSecret.FILE_NAME).toPath(), secret.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/container/openejb-loader/src/main/java/org/apache/openejb/loader/AdminShutdownSecret.java b/container/openejb-loader/src/main/java/org/apache/openejb/loader/AdminShutdownSecret.java new file mode 100644 index 0000000000..9b9992cb2d --- /dev/null +++ b/container/openejb-loader/src/main/java/org/apache/openejb/loader/AdminShutdownSecret.java @@ -0,0 +1,116 @@ +/* + * 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.openejb.loader; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +/** + * Client side of the secret the standalone admin daemon expects after the + * stop request code: the stop code byte, the secret and a NUL. + * + * <p>Only depends on the JDK since it is also bundled in the executable + * jars built by the TomEE maven plugin.</p> + */ +public final class AdminShutdownSecret { + + /** + * File written in the conf directory at server startup, readable by the owner only. + */ + public static final String FILE_NAME = "admin-shutdown.secret"; + + /** + * Fixed secret, used instead of a random one generated at every start. + */ + public static final String PROPERTY = "openejb.admin.shutdown.secret"; + + /** + * Set it to false on the server to accept stop requests without a secret. + */ + public static final String REQUIRED_PROPERTY = "openejb.admin.shutdown.secret.required"; + + private AdminShutdownSecret() { + // no-op + } + + /** + * @param home the server home (or base) directory + * @return the {@link #PROPERTY} system property if set, else the content of + * {@code <home>/conf/admin-shutdown.secret}, or null if none can be read + */ + public static String find(final File home) { + return find(System.getProperty(PROPERTY), home); + } + + /** + * @param configured the configured secret, used when not blank + * @param home the server home (or base) directory, may be null + * @return the secret or null if none can be found + */ + public static String find(final String configured, final File home) { + return findInConf(configured, home == null ? null : new File(home, "conf")); + } + + /** + * @param configured the configured secret, used when not blank + * @param conf the server conf directory, may be null + * @return the secret or null if none can be found + */ + public static String findInConf(final String configured, final File conf) { + if (configured != null && !configured.trim().isEmpty()) { + return configured.trim(); + } + if (conf == null) { + return null; + } + return read(new File(conf, FILE_NAME)); + } + + /** + * @return the trimmed content of the file, or null if it is missing, unreadable or blank + */ + public static String read(final File file) { + if (file == null || !file.isFile()) { + return null; + } + try { + final String secret = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8).trim(); + return secret.isEmpty() ? null : secret; + } catch (final IOException | SecurityException e) { + return null; + } + } + + /** + * @param code the stop request code byte + * @param secret the secret, may be null when the server does not require one + * @return the stop code byte followed by the secret and a NUL + */ + public static byte[] stopMessage(final int code, final String secret) { + final ByteArrayOutputStream message = new ByteArrayOutputStream(); + message.write(code); + if (secret != null) { + final byte[] bytes = secret.getBytes(StandardCharsets.UTF_8); + message.write(bytes, 0, bytes.length); + } + message.write(0); + return message.toByteArray(); + } +} diff --git a/container/openejb-loader/src/test/java/org/apache/openejb/loader/AdminShutdownSecretTest.java b/container/openejb-loader/src/test/java/org/apache/openejb/loader/AdminShutdownSecretTest.java new file mode 100644 index 0000000000..bdd6339ef4 --- /dev/null +++ b/container/openejb-loader/src/test/java/org/apache/openejb/loader/AdminShutdownSecretTest.java @@ -0,0 +1,92 @@ +/* + * 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.openejb.loader; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class AdminShutdownSecretTest { + + private File home; + private String oldProperty; + + @Before + public void setUp() { + home = Files.tmpdir(); + oldProperty = System.clearProperty(AdminShutdownSecret.PROPERTY); + } + + @After + public void tearDown() { + if (oldProperty == null) { + System.clearProperty(AdminShutdownSecret.PROPERTY); + } else { + System.setProperty(AdminShutdownSecret.PROPERTY, oldProperty); + } + Files.delete(home); + } + + @Test + public void noSecret() { + assertNull(AdminShutdownSecret.find(home)); + assertNull(AdminShutdownSecret.find(null, null)); + assertNull(AdminShutdownSecret.find(" ", home)); + } + + @Test + public void secretFromFile() throws Exception { + writeSecretFile(" abc123\n"); + assertEquals("abc123", AdminShutdownSecret.find(home)); + assertEquals("abc123", AdminShutdownSecret.findInConf(null, new File(home, "conf"))); + } + + @Test + public void blankFileIsIgnored() throws Exception { + writeSecretFile("\n"); + assertNull(AdminShutdownSecret.find(home)); + } + + @Test + public void propertyWinsOverFile() throws Exception { + writeSecretFile("fromfile"); + assertEquals("configured", AdminShutdownSecret.find(" configured ", home)); + + System.setProperty(AdminShutdownSecret.PROPERTY, "fromsystem"); + assertEquals("fromsystem", AdminShutdownSecret.find(home)); + assertEquals("fromsystem", AdminShutdownSecret.find(null)); + } + + @Test + public void stopMessage() { + assertArrayEquals(new byte[]{'S', 'a', 'b', 0}, AdminShutdownSecret.stopMessage('S', "ab")); + assertArrayEquals(new byte[]{'Q', 0}, AdminShutdownSecret.stopMessage('Q', null)); + assertArrayEquals(new byte[]{'q', (byte) 0xC3, (byte) 0xA9, 0}, AdminShutdownSecret.stopMessage('q', "é")); + } + + private void writeSecretFile(final String content) throws Exception { + final File conf = Files.mkdir(new File(home, "conf")); + java.nio.file.Files.write(new File(conf, AdminShutdownSecret.FILE_NAME).toPath(), content.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/docs/admin/configuration/server.adoc b/docs/admin/configuration/server.adoc index 62719b22a2..6a04746655 100644 --- a/docs/admin/configuration/server.adoc +++ b/docs/admin/configuration/server.adoc @@ -58,6 +58,8 @@ |tomee.serialization.class.whitelist| string| default list of packages/classnames allowed for EJBd deserialization (blacklist wins over whitelist, needs to be set on server and client sides). Please see the description of Ejbd Transport for details. |tomee.remote.support |boolean |if true /tomee webapp is auto-deployed and EJBd is active (true by default for 1.x, false for 7.x excepted for tomee maven plugin and arquillian) |openejb.internal.beans.security.enabled |boolean |if true (default) the internal EJBs (Deployer, ConfigurationInfo, ...) can only be invoked by a caller in the tomee-admin or openejb-admin role +|openejb.admin.shutdown.secret |string |standalone server only: secret a stop request on the admin port (4200) has to carry. If not set, a random one is generated at every start and written to conf/admin-shutdown.secret, readable by the owner only. `openejb stop` reads it from there, from this property or from its `-s <secret>` option +|openejb.admin.shutdown.secret.required |boolean |standalone server only: true by default. If false, the admin port accepts stop requests without a secret |openejb.crosscontext |bool| set the cross context property on tomcat context (can be done in the traditional way if the deployment is done through the webapp discovery and not the OpenEJB Deployer EJB) |openejb.jsessionid-support |bool| remove URL from session tracking modes for this context (see jakarta.servlet.SessionTrackingMode) |openejb.myfaces.disable-default-values |bool| by default TomEE will initialize myfaces with some its default values to avoid useless logging diff --git a/docs/getting-started.adoc b/docs/getting-started.adoc index 050f84cc00..7fdbb4d34d 100644 --- a/docs/getting-started.adoc +++ b/docs/getting-started.adoc @@ -175,3 +175,8 @@ following command: ---- D:\openejb-3.0.0-SNAPSHOT>bin\openejb stop ---- + +The stop request carries the secret the server writes to +`conf/admin-shutdown.secret` at startup. To stop a server from another +machine, pass it with `bin/openejb stop -h <host> -s <secret>` or set a +fixed one on both sides with `-Dopenejb.admin.shutdown.secret=<secret>`. diff --git a/itests/openejb-itests-client/pom.xml b/itests/openejb-itests-client/pom.xml index a406746de1..ec9667dcea 100644 --- a/itests/openejb-itests-client/pom.xml +++ b/itests/openejb-itests-client/pom.xml @@ -87,6 +87,13 @@ <artifactId>jakartaee-api</artifactId> <scope>provided</scope> </dependency> + <dependency> + <groupId>${project.groupId}</groupId> + <artifactId>openejb-loader</artifactId> + <version>${project.version}</version> + <!-- provided by the container, keeps it out of the itests ear --> + <scope>provided</scope> + </dependency> <dependency> <groupId>${project.groupId}</groupId> <artifactId>openejb-itests-beans</artifactId> diff --git a/itests/openejb-itests-client/src/main/java/org/apache/openejb/test/RemoteTestServer.java b/itests/openejb-itests-client/src/main/java/org/apache/openejb/test/RemoteTestServer.java index c053c10165..2e434edd83 100644 --- a/itests/openejb-itests-client/src/main/java/org/apache/openejb/test/RemoteTestServer.java +++ b/itests/openejb-itests-client/src/main/java/org/apache/openejb/test/RemoteTestServer.java @@ -16,6 +16,8 @@ */ package org.apache.openejb.test; +import org.apache.openejb.loader.AdminShutdownSecret; + import java.io.File; import java.io.InputStream; import java.io.OutputStream; @@ -114,11 +116,14 @@ public class RemoteTestServer implements org.apache.openejb.test.TestServer { try { System.out.println("[] STOP SERVER"); - final Socket socket = new Socket("localhost", 4200); - final OutputStream out = socket.getOutputStream(); - - out.write("Stop".getBytes()); + final String openejbHome = System.getProperty("openejb.home"); + final String secret = AdminShutdownSecret.find(openejbHome == null ? null : new File(openejbHome)); + try (Socket socket = new Socket("localhost", 4200); + OutputStream out = socket.getOutputStream()) { + out.write(AdminShutdownSecret.stopMessage('S', secret)); + out.flush(); + } } catch (final Exception e) { e.printStackTrace(); } diff --git a/maven/tomee-maven-plugin/src/main/java/org/apache/openejb/maven/plugin/ExecMojo.java b/maven/tomee-maven-plugin/src/main/java/org/apache/openejb/maven/plugin/ExecMojo.java index 982e3c0eea..9a23f158a2 100644 --- a/maven/tomee-maven-plugin/src/main/java/org/apache/openejb/maven/plugin/ExecMojo.java +++ b/maven/tomee-maven-plugin/src/main/java/org/apache/openejb/maven/plugin/ExecMojo.java @@ -27,6 +27,7 @@ import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.plugins.annotations.ResolutionScope; import org.apache.openejb.OpenEJBRuntimeException; import org.apache.openejb.config.RemoteServer; +import org.apache.openejb.loader.AdminShutdownSecret; import org.apache.openejb.loader.Files; import org.apache.openejb.loader.IO; import org.apache.openejb.loader.JarLocation; @@ -205,7 +206,7 @@ public class ExecMojo extends BuildTomEEMojo { Files.FileRuntimeException.class, Files.FileDoesNotExistException.class, Files.NoopOutputStream.class, LoaderRuntimeException.class, Pipe.class, IO.class, Zips.class, JarLocation.class, - RemoteServer.class, RemoteServer.CleanUpThread.class, + RemoteServer.class, RemoteServer.CleanUpThread.class, AdminShutdownSecret.class, OpenEJBRuntimeException.class, Join.class, QuickServerXmlParser.class, Options.class, Options.NullLog.class, Options.TomEEPropertyAdapter.class, Options.NullOptions.class, Options.Log.class, JavaSecurityManagers.class)) { diff --git a/server/openejb-server/src/main/java/org/apache/openejb/server/admin/AdminDaemon.java b/server/openejb-server/src/main/java/org/apache/openejb/server/admin/AdminDaemon.java index 53a9cd1173..e00d8a69f4 100644 --- a/server/openejb-server/src/main/java/org/apache/openejb/server/admin/AdminDaemon.java +++ b/server/openejb-server/src/main/java/org/apache/openejb/server/admin/AdminDaemon.java @@ -18,20 +18,70 @@ package org.apache.openejb.server.admin; import java.io.*; import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.AclEntry; +import java.nio.file.attribute.AclEntryPermission; +import java.nio.file.attribute.AclEntryType; +import java.nio.file.attribute.AclFileAttributeView; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.SecureRandom; import java.util.*; import org.apache.openejb.client.RequestType; import org.apache.openejb.server.ServerService; import org.apache.openejb.server.ServiceException; import org.apache.openejb.server.Server; +import org.apache.openejb.loader.AdminShutdownSecret; import org.apache.openejb.loader.SystemInstance; import org.apache.openejb.util.LogCategory; import org.apache.openejb.util.Logger; public class AdminDaemon implements ServerService { + /* + * Stop requests have to carry a secret right after the request code byte. + * Unless one is configured, a random one is generated at every start and + * written to the conf directory, readable by the owner only. + */ + private static final int MAX_SECRET_LENGTH = 256; + private static final int SECRET_READ_TIMEOUT = 10000; + + private boolean secretRequired = true; + private byte[] secret; + private File secretFile; + @Override public void init(Properties props) throws Exception { + final Logger logger = Logger.getInstance(LogCategory.OPENEJB_SERVER, AdminDaemon.class); + secretRequired = SystemInstance.get().getOptions().get(AdminShutdownSecret.REQUIRED_PROPERTY, true); + if (!secretRequired) { + logger.warning(AdminShutdownSecret.REQUIRED_PROPERTY + "=false: any client able to connect to the admin port can stop this server"); + return; + } + + final String configured = SystemInstance.get().getOptions().get(AdminShutdownSecret.PROPERTY, (String) null); + if (configured != null && !configured.trim().isEmpty()) { + secret = configured.trim().getBytes(StandardCharsets.UTF_8); + return; + } + + secret = generateSecret().getBytes(StandardCharsets.UTF_8); + final File conf = SystemInstance.get().getConf(null); + if (conf == null || !conf.isDirectory()) { + logger.info("No conf directory, stop requests on the admin port will be refused"); + return; + } + final File file = new File(conf, AdminShutdownSecret.FILE_NAME); + try { + writeSecret(file, secret); + secretFile = file; + } catch (final IOException e) { + logger.warning("Unable to write " + file.getAbsolutePath() + ", stop requests on the admin port will be refused", e); + } } @Override @@ -50,6 +100,11 @@ public class AdminDaemon implements ServerService { case STOP_REQUEST_quit: case STOP_REQUEST_Stop: case STOP_REQUEST_stop: + if (!isStopAllowed(socket, in)) { + Logger.getInstance(LogCategory.OPENEJB_SERVER, AdminDaemon.class) + .warning("Refused stop request without a valid secret from " + socket.getInetAddress()); + break; + } Server server = SystemInstance.get().getComponent(Server.class); if (null != server) { server.stop(); @@ -77,6 +132,97 @@ public class AdminDaemon implements ServerService { } } + private boolean isStopAllowed(final Socket socket, final InputStream in) throws IOException { + if (!secretRequired) { + return true; + } + if (secret == null) { + return false; + } + socket.setSoTimeout(SECRET_READ_TIMEOUT); + return MessageDigest.isEqual(secret, readSecret(in)); + } + + /** + * Reads the secret sent after the request code, up to the end of the + * stream, a NUL or a line break. + */ + private static byte[] readSecret(final InputStream in) throws IOException { + final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int b; + while ((b = in.read()) != -1 && b != 0 && b != '\n' && b != '\r') { + if (buffer.size() >= MAX_SECRET_LENGTH) { + return new byte[0]; + } + buffer.write(b); + } + return buffer.toByteArray(); + } + + private static String generateSecret() { + final byte[] random = new byte[32]; + new SecureRandom().nextBytes(random); + final StringBuilder sb = new StringBuilder(random.length * 2); + for (final byte b : random) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + /** + * Writes the secret to a new file only its owner can read: POSIX + * permissions where supported, else an owner-only ACL (Windows), else + * best effort relying on the permissions of the conf directory. A + * partly written file is removed on failure. + */ + void writeSecret(final File file, final byte[] secret) throws IOException { + final Path path = file.toPath(); + Files.deleteIfExists(path); + final boolean posix = supportsPosix(path); + try { + if (posix) { + Files.createFile(path, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); + } else { + Files.createFile(path); + } + } catch (final FileAlreadyExistsException e) { + throw new IOException(file.getAbsolutePath() + " was created concurrently", e); + } + try { + if (!posix) { + restrictToOwner(file); + } + Files.write(path, secret); + } catch (final IOException | RuntimeException e) { + Files.deleteIfExists(path); + throw e; + } + } + + boolean supportsPosix(final Path path) { + return path.getFileSystem().supportedFileAttributeViews().contains("posix"); + } + + private static void restrictToOwner(final File file) throws IOException { + final AclFileAttributeView view = Files.getFileAttributeView(file.toPath(), AclFileAttributeView.class); + if (view != null) { + view.setAcl(Collections.singletonList(AclEntry.newBuilder() + .setType(AclEntryType.ALLOW) + .setPrincipal(view.getOwner()) + .setPermissions(AclEntryPermission.values()) + .build())); + return; + } + // no ACL support (FAT, ...): the conf directory permissions apply + final boolean restricted = file.setReadable(false, false) & file.setReadable(true, true) + & file.setWritable(false, false) & file.setWritable(true, true); + if (!restricted) { + Logger.getInstance(LogCategory.OPENEJB_SERVER, AdminDaemon.class) + .info("Unable to restrict the permissions of " + file.getAbsolutePath() + + ", it is only protected by the permissions of its directory"); + } + } + @Override public void service(InputStream in, OutputStream out) throws ServiceException, IOException { throw new UnsupportedOperationException("Method not implemented: service(InputStream in, OutputStream out)"); @@ -88,6 +234,13 @@ public class AdminDaemon implements ServerService { @Override public void stop() throws ServiceException { + if (secretFile != null) { + if (!secretFile.delete() && secretFile.exists()) { + Logger.getInstance(LogCategory.OPENEJB_SERVER, AdminDaemon.class) + .warning("Unable to delete " + secretFile.getAbsolutePath()); + } + secretFile = null; + } } @Override diff --git a/server/openejb-server/src/main/java/org/apache/openejb/server/admin/Stop.java b/server/openejb-server/src/main/java/org/apache/openejb/server/admin/Stop.java index 7d3329b07d..278be28574 100644 --- a/server/openejb-server/src/main/java/org/apache/openejb/server/admin/Stop.java +++ b/server/openejb-server/src/main/java/org/apache/openejb/server/admin/Stop.java @@ -17,7 +17,9 @@ package org.apache.openejb.server.admin; import org.apache.openejb.client.RequestType; +import org.apache.openejb.loader.AdminShutdownSecret; import org.apache.openejb.loader.IO; +import org.apache.openejb.loader.SystemInstance; import java.io.IOException; import java.io.InputStream; @@ -33,12 +35,22 @@ public class Stop { private static final String HELP_BASE = "META-INF/org.apache.openejb.cli/"; public static void stop(final String host, final int port) { + stop(host, port, readSecret()); + } + + public static void stop(final String host, final int port, final String secret) { try (Socket socket = new Socket(host, port); OutputStream out = socket.getOutputStream()) { - out.write(RequestType.STOP_REQUEST_Stop.getCode()); + out.write(AdminShutdownSecret.stopMessage(RequestType.STOP_REQUEST_Stop.getCode(), secret)); + out.flush(); + if (secret == null) { + System.err.println("No shutdown secret found, the server refuses the request unless " + + AdminShutdownSecret.REQUIRED_PROPERTY + "=false. Pass it with -s <secret> or -D" + + AdminShutdownSecret.PROPERTY + "=<secret>."); + } } catch (ConnectException e) { System.out.println(":: server not running ::"); } catch (Exception e) { @@ -46,6 +58,15 @@ public class Stop { } } + /** + * The configured secret if any, else the one the local server wrote at startup. + */ + private static String readSecret() { + return AdminShutdownSecret.findInConf( + SystemInstance.get().getOptions().get(AdminShutdownSecret.PROPERTY, (String) null), + SystemInstance.get().getConf(null)); + } + public void stop() { stop("localhost", 4200); } @@ -57,6 +78,8 @@ public class Stop { int port = 4200; + String secret = null; + for (int i = 0; i < args.length; i++) { if (args[i].equals("-h")) { if (args.length > i + 1) { @@ -66,6 +89,10 @@ public class Stop { if (args.length > i + 1) { port = Integer.parseInt(args[++i]); } + } else if (args[i].equals("-s")) { + if (args.length > i + 1) { + secret = args[++i]; + } } else if (args[i].equals("--help")) { printHelp(); return; @@ -75,7 +102,7 @@ public class Stop { } } - stop(host, port); + stop(host, port, secret != null ? secret : readSecret()); } catch (Exception re) { System.err.println("[EJB Server] FATAL ERROR: " + re.getMessage()); re.printStackTrace(); diff --git a/server/openejb-server/src/main/resources/META-INF/org.apache.openejb.cli/stop.examples b/server/openejb-server/src/main/resources/META-INF/org.apache.openejb.cli/stop.examples index cf5eec2a5a..1717b11f68 100644 --- a/server/openejb-server/src/main/resources/META-INF/org.apache.openejb.cli/stop.examples +++ b/server/openejb-server/src/main/resources/META-INF/org.apache.openejb.cli/stop.examples @@ -25,4 +25,12 @@ Example: -p Sends a stop request to the server running on IP 127.0.0.1 and port 8765. +Example: -s + + openejb stop -h myhost.foo.com -s 3f9c... + + Sends a stop request with the given secret. Stop requests without the + secret are refused; when run on the server machine the secret is read + from the conf/admin-shutdown.secret file the server writes at startup. + Bug Reports to <[email protected]> diff --git a/server/openejb-server/src/main/resources/META-INF/org.apache.openejb.cli/stop.help b/server/openejb-server/src/main/resources/META-INF/org.apache.openejb.cli/stop.help index ce00fe07f9..cbcbddf312 100644 --- a/server/openejb-server/src/main/resources/META-INF/org.apache.openejb.cli/stop.help +++ b/server/openejb-server/src/main/resources/META-INF/org.apache.openejb.cli/stop.help @@ -7,6 +7,10 @@ OPTIONS Default host is 127.0.0.1 -p port Stops the server running on the specified port. Default port is 4201. + -s secret Secret required by the server to accept the stop request. + Defaults to the openejb.admin.shutdown.secret property or to + the conf/admin-shutdown.secret file the server writes at + startup. OpenEJB -- EJB Container System and EJB Server. For OpenEJB updates and additional information, visit diff --git a/server/openejb-server/src/test/java/org/apache/openejb/server/admin/AdminDaemonTest.java b/server/openejb-server/src/test/java/org/apache/openejb/server/admin/AdminDaemonTest.java new file mode 100644 index 0000000000..00eca6d951 --- /dev/null +++ b/server/openejb-server/src/test/java/org/apache/openejb/server/admin/AdminDaemonTest.java @@ -0,0 +1,226 @@ +/* + * 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.openejb.server.admin; + +import org.apache.openejb.client.RequestType; +import org.apache.openejb.config.RemoteServer; +import org.apache.openejb.loader.AdminShutdownSecret; +import org.apache.openejb.loader.Files; +import org.apache.openejb.loader.IO; +import org.apache.openejb.loader.SystemInstance; +import org.apache.openejb.server.Server; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class AdminDaemonTest { + + private File base; + private ServerSocket serverSocket; + private CountingServer server; + private String oldHome; + private String oldBase; + + @Before + public void setUp() throws Exception { + base = Files.tmpdir(); + oldHome = System.setProperty("openejb.home", base.getAbsolutePath()); + oldBase = System.setProperty("openejb.base", base.getAbsolutePath()); + SystemInstance.reset(); + serverSocket = new ServerSocket(0, 10, InetAddress.getLoopbackAddress()); + } + + @After + public void tearDown() throws Exception { + serverSocket.close(); + restore("openejb.home", oldHome); + restore("openejb.base", oldBase); + SystemInstance.reset(); + Files.delete(base); + } + + private static void restore(final String key, final String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + + @Test + public void secretFileIsOwnerOnlyOnPosix() throws Exception { + Assume.assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("posix")); + newDaemon(new Properties(), true); + + final File secretFile = new File(new File(base, "conf"), AdminShutdownSecret.FILE_NAME); + assertEquals("rw-------", PosixFilePermissions.toString(java.nio.file.Files.getPosixFilePermissions(secretFile.toPath()))); + } + + @Test + public void secretWrittenWithoutPosixPermissions() throws Exception { + final AdminDaemon daemon = newDaemon(new Properties(), true, new AdminDaemon() { + @Override + boolean supportsPosix(final Path path) { + return false; + } + }); + + final File secretFile = new File(new File(base, "conf"), AdminShutdownSecret.FILE_NAME); + assertTrue(secretFile.isFile()); + assertFalse(IO.slurp(secretFile).trim().isEmpty()); + + Stop.stop(InetAddress.getLoopbackAddress().getHostAddress(), serverSocket.getLocalPort()); + accept(daemon); + assertEquals(1, server.stops.get()); + } + + @Test + public void stopRequiresGeneratedSecret() throws Exception { + final AdminDaemon daemon = newDaemon(new Properties(), true); + + final File secretFile = new File(new File(base, "conf"), AdminShutdownSecret.FILE_NAME); + assertTrue(secretFile.isFile()); + + send(daemon, new byte[]{RequestType.STOP_REQUEST_Stop.getCode()}); + assertEquals(0, server.stops.get()); + + send(daemon, "Stop".getBytes(StandardCharsets.US_ASCII)); + assertEquals(0, server.stops.get()); + + send(daemon, request('Q', IO.slurp(secretFile).trim() + "x")); + assertEquals(0, server.stops.get()); + + // the stop client reads the secret written by the server + Stop.stop(InetAddress.getLoopbackAddress().getHostAddress(), serverSocket.getLocalPort()); + accept(daemon); + assertEquals(1, server.stops.get()); + + daemon.stop(); + assertFalse(secretFile.exists()); + } + + @Test + public void remoteServerSendsGeneratedSecret() throws Exception { + final AdminDaemon daemon = newDaemon(new Properties(), true); + + final Properties properties = new Properties(); + properties.setProperty("openejb.home", base.getAbsolutePath()); + properties.setProperty(RemoteServer.SERVER_SHUTDOWN_HOST, InetAddress.getLoopbackAddress().getHostAddress()); + properties.setProperty(RemoteServer.SERVER_SHUTDOWN_PORT, Integer.toString(serverSocket.getLocalPort())); + properties.setProperty(RemoteServer.SERVER_SHUTDOWN_COMMAND, "Q"); + assertTrue(new RemoteServer(properties, 1, false).stop()); + accept(daemon); + assertEquals(1, server.stops.get()); + } + + @Test + public void configuredSecret() throws Exception { + final Properties properties = new Properties(); + properties.setProperty(AdminShutdownSecret.PROPERTY, "changeit"); + final AdminDaemon daemon = newDaemon(properties, true); + + assertFalse(new File(new File(base, "conf"), AdminShutdownSecret.FILE_NAME).exists()); + + send(daemon, request('S', "other")); + assertEquals(0, server.stops.get()); + + send(daemon, request('Q', "changeit\0")); + assertEquals(1, server.stops.get()); + } + + @Test + public void refuseStopWithoutConfDirectory() throws Exception { + final AdminDaemon daemon = newDaemon(new Properties(), false); + + send(daemon, new byte[]{RequestType.STOP_REQUEST_Stop.getCode()}); + Stop.stop(InetAddress.getLoopbackAddress().getHostAddress(), serverSocket.getLocalPort()); + accept(daemon); + assertEquals(0, server.stops.get()); + } + + @Test + public void secretCanBeDisabled() throws Exception { + final Properties properties = new Properties(); + properties.setProperty(AdminShutdownSecret.REQUIRED_PROPERTY, "false"); + final AdminDaemon daemon = newDaemon(properties, true); + + send(daemon, new byte[]{RequestType.STOP_REQUEST_stop.getCode()}); + assertEquals(1, server.stops.get()); + } + + private AdminDaemon newDaemon(final Properties properties, final boolean withConf) throws Exception { + return newDaemon(properties, withConf, new AdminDaemon()); + } + + private AdminDaemon newDaemon(final Properties properties, final boolean withConf, final AdminDaemon daemon) throws Exception { + if (withConf) { + Files.mkdir(new File(base, "conf")); + } + for (final String key : properties.stringPropertyNames()) { + SystemInstance.get().setProperty(key, properties.getProperty(key)); + } + server = new CountingServer(); + SystemInstance.get().setComponent(Server.class, server); + + daemon.init(new Properties()); + return daemon; + } + + private static byte[] request(final char code, final String secret) { + return (code + secret).getBytes(StandardCharsets.UTF_8); + } + + private void send(final AdminDaemon daemon, final byte[] request) throws Exception { + try (Socket client = new Socket(InetAddress.getLoopbackAddress(), serverSocket.getLocalPort())) { + final OutputStream out = client.getOutputStream(); + out.write(request); + out.flush(); + client.shutdownOutput(); + accept(daemon); + } + } + + private void accept(final AdminDaemon daemon) throws Exception { + daemon.service(serverSocket.accept()); + } + + private static class CountingServer extends Server { + private final AtomicInteger stops = new AtomicInteger(); + + @Override + public void stop() { + stops.incrementAndGet(); + } + } +}
