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();
+        }
+    }
+}

Reply via email to