This is an automated email from the ASF dual-hosted git repository.
rzo1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tomee.git
The following commit(s) were added to refs/heads/main by this push:
new 0fb057efe6 Require a secret for stop requests on the admin port (#3087)
0fb057efe6 is described below
commit 0fb057efe6e322538a8c30ddfa7401a4c0835793
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.
---
.../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 6508c1ebbd..ea0a2e0618 100644
--- a/docs/getting-started.adoc
+++ b/docs/getting-started.adoc
@@ -174,3 +174,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 de4fe20a6d..8e48b49ad5 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();
+ }
+ }
+}