diff --git a/bin/catalina.bat b/bin/catalina.bat index 70601b6799b2..2938c2eae8ed 100755 --- a/bin/catalina.bat +++ b/bin/catalina.bat @@ -263,6 +263,7 @@ if ""%1"" == ""run"" goto doRun if ""%1"" == ""start"" goto doStart if ""%1"" == ""stop"" goto doStop if ""%1"" == ""configtest"" goto doConfigTest +if ""%1"" == ""config-validate"" goto doConfigValidate if ""%1"" == ""version"" goto doVersion echo Usage: catalina ( commands ... ) @@ -273,6 +274,7 @@ echo run Start Catalina in the current window echo start Start Catalina in a separate window echo stop Stop Catalina echo configtest Run a basic syntax check on server.xml +echo config-validate Run configuration validators with detailed output echo version What version of tomcat are you running? goto end @@ -300,7 +302,19 @@ goto execCmd :doConfigTest shift -set ACTION=configtest +rem Check if --validate-only argument is present +if ""%1"" == ""--validate-only"" ( + set ACTION=config-validate + shift +) else ( + set ACTION=configtest +) +set CATALINA_OPTS= +goto execCmd + +:doConfigValidate +shift +set ACTION=config-validate set CATALINA_OPTS= goto execCmd diff --git a/bin/catalina.sh b/bin/catalina.sh index ee679ad0c6e0..807e08cd08aa 100755 --- a/bin/catalina.sh +++ b/bin/catalina.sh @@ -549,18 +549,35 @@ elif [ "$1" = "stop" ] ; then elif [ "$1" = "configtest" ] ; then + # Check if --validate-only argument is present + if [ "$2" = "--validate-only" ] ; then + COMMAND="config-validate" + else + COMMAND="configtest" + fi + eval "\"$_RUNJAVA\"" $LOGGING_MANAGER "$JAVA_OPTS" \ -classpath "\"$CLASSPATH\"" \ -Dcatalina.base="\"$CATALINA_BASE\"" \ -Dcatalina.home="\"$CATALINA_HOME\"" \ -Djava.io.tmpdir="\"$CATALINA_TMPDIR\"" \ - org.apache.catalina.startup.Bootstrap configtest + org.apache.catalina.startup.Bootstrap "$COMMAND" result=$? - if [ $result -ne 0 ]; then + if [ $result -ne 0 ] && [ "$COMMAND" = "configtest" ]; then echo "Configuration error detected!" fi exit $result +elif [ "$1" = "config-validate" ] ; then + + eval "\"$_RUNJAVA\"" $LOGGING_MANAGER "$JAVA_OPTS" \ + -classpath "\"$CLASSPATH\"" \ + -Dcatalina.base="\"$CATALINA_BASE\"" \ + -Dcatalina.home="\"$CATALINA_HOME\"" \ + -Djava.io.tmpdir="\"$CATALINA_TMPDIR\"" \ + org.apache.catalina.startup.Bootstrap config-validate + exit $? + elif [ "$1" = "version" ] ; then eval "\"$_RUNJAVA\"" "$JAVA_OPTS" \ diff --git a/java/org/apache/catalina/startup/Bootstrap.java b/java/org/apache/catalina/startup/Bootstrap.java index 6d169f0a1a05..d4286926b145 100644 --- a/java/org/apache/catalina/startup/Bootstrap.java +++ b/java/org/apache/catalina/startup/Bootstrap.java @@ -275,9 +275,25 @@ public void init() throws Exception { * Load daemon. */ private void load(String[] arguments) throws Exception { + invokeCatalinaMethod("load", arguments); + } + + /** + * Load configuration only without initializing server. + * Used for validation without binding to ports. + */ + private void loadConfigOnly(String[] arguments) throws Exception { + invokeCatalinaMethod("loadConfigOnly", arguments); + } - // Call the load() method - String methodName = "load"; + /** + * Helper method to invoke a Catalina method via reflection with optional arguments. + * + * @param methodName the name of the method to invoke + * @param arguments optional arguments to pass to the method + * @throws Exception if the method invocation fails + */ + private void invokeCatalinaMethod(String methodName, String[] arguments) throws Exception { Object[] param; Class[] paramTypes; if (arguments == null || arguments.length == 0) { @@ -296,7 +312,6 @@ private void load(String[] arguments) throws Exception { method.invoke(catalinaDaemon, param); } - /** * getServer() for configtest */ @@ -307,6 +322,18 @@ private Object getServer() throws Exception { return method.invoke(catalinaDaemon); } + /** + * Run configuration validation tests. + * + * @return exit code (0 = success, 1 = errors found) + * @throws Exception Fatal validation error + */ + public int configtest() throws Exception { + Method method = catalinaDaemon.getClass().getMethod("configtest"); + Integer exitCode = (Integer) method.invoke(catalinaDaemon); + return exitCode != null ? exitCode.intValue() : 1; + } + // ----------------------------------------------------------- Main Program @@ -482,6 +509,14 @@ public static void main(String[] args) { } System.exit(0); break; + case "config-validate": + daemon.loadConfigOnly(args); + if (null == daemon.getServer()) { + System.exit(1); + } + int exitCode = daemon.configtest(); + System.exit(exitCode); + break; default: log.warn("Bootstrap: command \"" + command + "\" does not exist."); break; @@ -492,12 +527,26 @@ public static void main(String[] args) { if (throwable instanceof InvocationTargetException && throwable.getCause() != null) { throwable = throwable.getCause(); } + + if (isStartupAbort(throwable)) { + System.exit(1); + } + handleThrowable(throwable); log.error("Error running command", throwable); System.exit(1); } } + public static boolean isStartupAbort(Throwable t) { + while (t != null) { + if ("org.apache.catalina.startup.StartupAbortException".equals(t.getClass().getName())) { + return true; + } + t = t.getCause(); + } + return false; + } /** * Obtain the name of configured home (binary) directory. Note that home and base may be the same (and are by diff --git a/java/org/apache/catalina/startup/Catalina.java b/java/org/apache/catalina/startup/Catalina.java index 0585e1574518..12386efba86d 100644 --- a/java/org/apache/catalina/startup/Catalina.java +++ b/java/org/apache/catalina/startup/Catalina.java @@ -343,6 +343,9 @@ protected boolean arguments(String[] args) { } else if (arg.equals("configtest")) { isGenerateCode = false; // NOOP + } else if (arg.equals("config-validate")) { + isGenerateCode = false; + // NOOP } else if (arg.equals("stop")) { isGenerateCode = false; // NOOP @@ -673,17 +676,34 @@ public void stopServer(String[] arguments) { } + /** + * Load configuration without initializing the server. + * Used for configuration validation without binding to ports. + */ + public void loadConfigOnly() { + loadInternal(false); + } + /** * Start a new server instance. */ public void load() { + loadInternal(true); + } + /** + * Internal load method that handles both config-only and full initialization. + * + * @param initServer if true, initialize the server and bind to ports; + * if false, only parse configuration + */ + private void loadInternal(boolean initServer) { if (loaded) { return; } loaded = true; - long t1 = System.nanoTime(); + long t1 = initServer ? System.nanoTime() : 0; // Before digester - it may be needed initNaming(); @@ -699,23 +719,34 @@ public void load() { getServer().setCatalinaHome(Bootstrap.getCatalinaHomeFile()); getServer().setCatalinaBase(Bootstrap.getCatalinaBaseFile()); - // Stream redirection - initStreams(); + if (initServer) { + // Stream redirection + initStreams(); - // Start the new server - try { - getServer().init(); - } catch (LifecycleException e) { - if (throwOnInitFailure) { - throw new Error(e); - } else { - log.error(sm.getString("catalina.initError"), e); + // Start the new server + try { + getServer().init(); + } catch (LifecycleException e) { + Throwable t = e; + while (t.getCause() != null && t.getCause() != t) { + t = t.getCause(); + } + + if (t instanceof StartupAbortException) { + throw (StartupAbortException) t; + } + + if (throwOnInitFailure) { + throw new Error(e); + } else { + log.error(sm.getString("catalina.initError"), e); + } } - } - if (log.isInfoEnabled()) { - log.info(sm.getString("catalina.init", - Long.toString(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t1)))); + if (log.isInfoEnabled()) { + log.info(sm.getString("catalina.init", + Long.toString(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t1)))); + } } } @@ -729,6 +760,23 @@ public void load(String[] args) { if (arguments(args)) { load(); } + } catch (Exception e) { + if (e instanceof StartupAbortException) { + throw (StartupAbortException) e; + } + e.printStackTrace(System.out); + } + } + + /* + * Load configuration only using arguments + */ + public void loadConfigOnly(String[] args) { + + try { + if (arguments(args)) { + loadConfigOnly(); + } } catch (Exception e) { e.printStackTrace(System.out); } @@ -857,6 +905,69 @@ protected void usage() { } + /** + * Run configuration validation tests. + * + * @return exit code (0 = success, 1 = errors found) + */ + public int configtest() { + if (server == null) { + return 1; + } + + try { + + // Run validators + org.apache.catalina.startup.validator.ValidatorRegistry registry = + new org.apache.catalina.startup.validator.ValidatorRegistry(); + org.apache.catalina.startup.validator.ValidationResult result = + registry.validate(server); + + // Print results + System.out.println(); + System.out.println("Configuration Validation Results"); + System.out.println("================================="); + System.out.println(); + + if (!result.hasFindings()) { + System.out.println("No issues found. Configuration is valid."); + return 0; + } + + // Group findings by severity + java.util.List findings = + result.getFindings(); + + for (org.apache.catalina.startup.validator.ValidationResult.Finding finding : findings) { + System.out.println(finding.toString()); + } + + System.out.println(); + System.out.println("Summary: " + result.getErrorCount() + " error(s), " + + result.getWarningCount() + " warning(s), " + + result.getInfoCount() + " info message(s)"); + System.out.println(); + + if (result.getErrorCount() > 0) { + System.out.println("Configuration test FAILED."); + System.out.println("Configuration error detected!"); + return 1; + } else { + if (result.getWarningCount() > 0) { + System.out.println("Configuration test PASSED (with warnings)."); + } else { + System.out.println("Configuration test PASSED."); + } + return 0; + } + + } catch (Exception e) { + log.error(sm.getString("catalina.configTestError"), e); + e.printStackTrace(); + return 1; + } + } + protected void initStreams() { // Replace System.out and System.err with a custom PrintStream diff --git a/java/org/apache/catalina/startup/StartupAbortException.java b/java/org/apache/catalina/startup/StartupAbortException.java new file mode 100644 index 000000000000..850afd6cb69f --- /dev/null +++ b/java/org/apache/catalina/startup/StartupAbortException.java @@ -0,0 +1,37 @@ +/* + * 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.catalina.startup; + +import java.io.Serial; + +/** + * Exception used to abort startup due to validation failures without producing a noisy stack trace. + */ +public class StartupAbortException extends RuntimeException { + @Serial + private static final long serialVersionUID = 1L; + + public StartupAbortException(String message) { + super(message); + } + + @Override + public synchronized Throwable fillInStackTrace() { + return this; + } +} diff --git a/java/org/apache/catalina/startup/validator/LocalStrings.properties b/java/org/apache/catalina/startup/validator/LocalStrings.properties new file mode 100644 index 000000000000..e79a9bb8e9ae --- /dev/null +++ b/java/org/apache/catalina/startup/validator/LocalStrings.properties @@ -0,0 +1,32 @@ +# 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. + +# StartupValidationListener messages +startupValidationListener.notServer=StartupValidationListener can only be attached to a Server +startupValidationListener.starting=Running configuration validation checks... +startupValidationListener.abortingOnErrors=Aborting startup due to {0} configuration errors +startupValidationListener.complete=Configuration validation complete: {0} errors, {1} warnings, {2} info messages + +# PortValidator messages +portValidator.shutdownDisabled=Shutdown port is disabled (port < 0) +portValidator.shutdownCommandDefault=Shutdown command is set to default value "SHUTDOWN" on port {0}. Consider using a custom shutdown command. +portValidator.invalidPort=Invalid port number: {0} +portValidator.privilegedPort=Port {0} requires root/administrator privileges (current user: {1}) +portValidator.portInUse=Port {0} is already in use +portValidator.shutdownPortInUse=Shutdown port {0} is already in use +portValidator.duplicatePort=Port {0} is already configured for: {1} +portValidator.invalidAddress=Invalid bind address: {0} +portValidator.ajpMissingSecret=AJP connector is missing required ''secret'' attribute for secure operation +portValidator.ajpListeningAll=AJP connector is listening on all interfaces (0.0.0.0). Consider binding to localhost or specific IP. diff --git a/java/org/apache/catalina/startup/validator/PortValidator.java b/java/org/apache/catalina/startup/validator/PortValidator.java new file mode 100644 index 000000000000..a6b386a32eb3 --- /dev/null +++ b/java/org/apache/catalina/startup/validator/PortValidator.java @@ -0,0 +1,235 @@ +/* + * 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.catalina.startup.validator; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.UnknownHostException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.apache.catalina.Server; +import org.apache.catalina.Service; +import org.apache.catalina.connector.Connector; + +/** + * Validates port configuration for all connectors and the shutdown port. + * + *

Checks performed: + *

+ */ +public class PortValidator { + + private static final int PRIVILEGED_PORT_THRESHOLD = 1024; + private static final int MAX_PORT_VALUE = 65535; + private static final String SHUTDOWN_DISABLED = "SHUTDOWN"; + + /** + * Validates port configuration for all connectors and the shutdown port. + * + * @param server the server to validate + * @param result the validation result to populate + */ + public void validate(Server server, ValidationResult result) { + if (server == null) { + return; + } + + Map portUsage = new HashMap<>(); + Set inUsePorts = new HashSet<>(); + + // Check shutdown port + validateShutdownPort(server, result, portUsage); + + // Check all connector ports + for (Service service : server.findServices()) { + for (Connector connector : service.findConnectors()) { + validateConnectorPort(connector, service.getName(), result, portUsage, inUsePorts); + } + } + } + + private void validateShutdownPort(Server server, ValidationResult result, + Map portUsage) { + int shutdownPort = server.getPort(); + String shutdownCommand = server.getShutdown(); + + // Check if shutdown is disabled + if (shutdownPort < 0) { + result.addInfo("portValidator.shutdownDisabled"); + return; + } + + // Check for insecure default shutdown command + if (SHUTDOWN_DISABLED.equals(shutdownCommand)) { + result.addWarning("Shutdown Port", "portValidator.shutdownCommandDefault", + String.valueOf(shutdownPort)); + } + + // Validate port number + if (shutdownPort > MAX_PORT_VALUE) { + result.addError("Shutdown Port", "portValidator.invalidPort", + String.valueOf(shutdownPort)); + return; + } + + // Check for privileged port + if (shutdownPort < PRIVILEGED_PORT_THRESHOLD && !isRunningAsRoot()) { + result.addError("Shutdown Port", "portValidator.privilegedPort", + String.valueOf(shutdownPort), getCurrentUser()); + } + + // Check if port is in use + if (!isPortAvailable(shutdownPort, null)) { + result.addError("Shutdown Port", "portValidator.shutdownPortInUse", + String.valueOf(shutdownPort)); + } + + portUsage.put(shutdownPort, "Shutdown Port"); + } + + private void validateConnectorPort(Connector connector, String serviceName, + ValidationResult result, Map portUsage, Set inUsePorts) { + int port = connector.getPort(); + String protocol = connector.getProtocol(); + Object addressObj = connector.getProperty("address"); + InetAddress bindAddress = null; + + // Handle both String and InetAddress types + if (addressObj instanceof InetAddress) { + bindAddress = (InetAddress) addressObj; + } else if (addressObj != null && !addressObj.toString().isEmpty()) { + try { + bindAddress = InetAddress.getByName(addressObj.toString()); + } catch (UnknownHostException e) { + result.addError(String.format("Port %d (%s, %s)", port, protocol, serviceName), + "portValidator.invalidAddress", addressObj.toString()); + return; + } + } + + String location = String.format("Port %d (%s, %s)", port, protocol, serviceName); + + // Validate port number + if (port < 0) { + result.addError(location, "portValidator.invalidPort", String.valueOf(port)); + return; + } + + if (port > MAX_PORT_VALUE) { + result.addError(location, "portValidator.invalidPort", String.valueOf(port)); + return; + } + + // Check for duplicate ports in configuration + if (portUsage.containsKey(port)) { + result.addError(location, "portValidator.duplicatePort", + String.valueOf(port), portUsage.get(port)); + return; + } + + portUsage.put(port, location); + + // Check for privileged port + if (port < PRIVILEGED_PORT_THRESHOLD && !isRunningAsRoot()) { + result.addError(location, "portValidator.privilegedPort", + String.valueOf(port), getCurrentUser()); + } + + // Check if port is in use (avoid checking the same port multiple times) + if (!inUsePorts.contains(port)) { + if (!isPortAvailable(port, bindAddress)) { + result.addError(location, "portValidator.portInUse", + String.valueOf(port)); + inUsePorts.add(port); + } + } + + // Check for insecure AJP configuration + if (protocol.toLowerCase().contains("ajp")) { + validateAjpConnector(connector, location, result); + } + } + + private void validateAjpConnector(Connector connector, String location, ValidationResult result) { + Object secretObj = connector.getProperty("secret"); + String secret = secretObj != null ? secretObj.toString() : null; + Object addressObj = connector.getProperty("address"); + String address = addressObj != null ? addressObj.toString() : null; + + // Check for missing secret (security risk) + if (secret == null || secret.isEmpty()) { + result.addWarning(location, "portValidator.ajpMissingSecret"); + } + + // Warn if listening on all interfaces + if (address == null || address.isEmpty() || "0.0.0.0".equals(address)) { + result.addWarning(location, "portValidator.ajpListeningAll"); + } + } + + /** + * Checks if a port is available by attempting to bind to it. + * + *

Note: This check is subject to a time-of-check-time-of-use (TOCTOU) race + * condition. Another process may bind to the port between this check and when Tomcat + * attempts to bind. This is an inherent limitation of port availability checking and + * cannot be fully eliminated. The check is still useful for catching obvious conflicts + * early, but the application must handle bind failures gracefully at runtime. + * + * @param port the port number to check + * @param address the address to bind to, or null for all interfaces + * @return true if the port is available, false if already in use + */ + private boolean isPortAvailable(int port, InetAddress address) { + try (ServerSocket socket = new ServerSocket(port, 1, address)) { + socket.setReuseAddress(true); + return true; + } catch (IOException e) { + return false; + } + } + + private boolean isRunningAsRoot() { + String osName = System.getProperty("os.name"); + if (osName != null && osName.toLowerCase().contains("windows")) { + // Windows doesn't have the same privileged port restriction + return true; + } + + String user = getCurrentUser(); + return "root".equals(user); + } + + private String getCurrentUser() { + String user = System.getProperty("user.name"); + if (user == null || user.isEmpty()) { + user = "unknown"; + } + return user; + } + +} diff --git a/java/org/apache/catalina/startup/validator/StartupValidationListener.java b/java/org/apache/catalina/startup/validator/StartupValidationListener.java new file mode 100644 index 000000000000..4c9fc2c48d6e --- /dev/null +++ b/java/org/apache/catalina/startup/validator/StartupValidationListener.java @@ -0,0 +1,160 @@ +/* + * 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.catalina.startup.validator; + +import org.apache.catalina.Lifecycle; +import org.apache.catalina.LifecycleEvent; +import org.apache.catalina.LifecycleListener; +import org.apache.catalina.Server; +import org.apache.catalina.startup.StartupAbortException; +import org.apache.juli.logging.Log; +import org.apache.juli.logging.LogFactory; +import org.apache.tomcat.util.res.StringManager; + +/** + * A lifecycle listener that runs configuration validators during server startup + * as a pre-flight check. This allows catching configuration errors and aborting + * startup for issues that may not normally stop the server. + * + *

The listener runs at the BEFORE_INIT_EVENT, which occurs after the server + * configuration has been parsed but before the server attempts to bind to any ports. + * This ensures that port availability checks and other validations can run without + * interference from the server itself. + * + *

Configuration options (set as listener attributes): + *

    + *
  • abortOnError - If true, abort startup if validation errors are found. + * Default: false
  • + *
  • logWarnings - If true, log warnings to the console/logs. + * Default: true
  • + *
  • logInfo - If true, log informational messages. + * Default: false
  • + *
+ * + *

Example listener configuration with options: + *

+ * <Listener className="org.apache.catalina.startup.validator.StartupValidationListener"
+ *           abortOnError="true"
+ *           logWarnings="true"
+ *           logInfo="false" />
+ * 
+ */ +public class StartupValidationListener implements LifecycleListener { + + private static final Log log = LogFactory.getLog(StartupValidationListener.class); + private static final StringManager sm = StringManager.getManager(StartupValidationListener.class); + + private boolean abortOnError = false; + private boolean logWarnings = true; + private boolean logInfo = false; + + /** + * Sets whether to abort startup if validation errors are found. + * + * @param abortOnError true to abort on errors + */ + public void setAbortOnError(boolean abortOnError) { + this.abortOnError = abortOnError; + } + + /** + * Sets whether to log warning messages. + * + * @param logWarnings true to log warnings + */ + public void setLogWarnings(boolean logWarnings) { + this.logWarnings = logWarnings; + } + + /** + * Sets whether to log informational messages. + * + * @param logInfo true to log info messages + */ + public void setLogInfo(boolean logInfo) { + this.logInfo = logInfo; + } + + @Override + public void lifecycleEvent(LifecycleEvent event) { + // Run before init() to check ports before they're bound, + // return for any other events. + if (!Lifecycle.BEFORE_INIT_EVENT.equals(event.getType())) { + return; + } + + if (!(event.getLifecycle() instanceof Server)) { + log.warn(sm.getString("startupValidationListener.notServer")); + return; + } + + Server server = (Server) event.getLifecycle(); + + if (log.isInfoEnabled()) { + log.info(sm.getString("startupValidationListener.starting")); + } + + // Init registry and validate server config + ValidatorRegistry registry = new ValidatorRegistry(); + ValidationResult result = registry.validate(server); + + // Log findings + logFindings(result); + + // Should we abort now? + if (abortOnError && result.getErrorCount() > 0) { + String message = sm.getString("startupValidationListener.abortingOnErrors", + String.valueOf(result.getErrorCount())); + log.error(message); + throw new StartupAbortException(message); + } + + if (log.isInfoEnabled()) { + log.info(sm.getString("startupValidationListener.complete", + String.valueOf(result.getErrorCount()), + String.valueOf(result.getWarningCount()), + String.valueOf(result.getInfoCount()))); + } + } + + private void logFindings(ValidationResult result) { + for (ValidationResult.Finding finding : result.getFindings()) { + switch (finding.getSeverity()) { + case ERROR: + log.error(formatFinding(finding)); + break; + case WARNING: + if (logWarnings) { + log.warn(formatFinding(finding)); + } + break; + case INFO: + if (logInfo && log.isInfoEnabled()) { + log.info(formatFinding(finding)); + } + break; + } + } + } + + private String formatFinding(ValidationResult.Finding finding) { + if (finding.getLocation() != null) { + return finding.getLocation() + ": " + finding.getMessage(); + } + return finding.getMessage(); + } +} diff --git a/java/org/apache/catalina/startup/validator/ValidationResult.java b/java/org/apache/catalina/startup/validator/ValidationResult.java new file mode 100644 index 000000000000..d505f3f66399 --- /dev/null +++ b/java/org/apache/catalina/startup/validator/ValidationResult.java @@ -0,0 +1,216 @@ +/* + * 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.catalina.startup.validator; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import org.apache.tomcat.util.res.StringManager; + +/** + * Accumulates validation findings (errors, warnings, and informational messages) + * from configuration validators. + */ +public class ValidationResult { + + private final List findings = new ArrayList<>(); + private final StringManager sm; + + /** + * Severity levels for validation findings. + */ + public enum Severity { + ERROR, + WARNING, + INFO + } + + public ValidationResult(StringManager sm) { + this.sm = sm; + } + + /** + * Adds an error finding. Errors indicate configuration problems that + * will likely cause runtime failures. + * + * @param messageKey the resource bundle key for the error message + * @param args optional arguments for message formatting + */ + public void addError(String messageKey, Object... args) { + addFinding(Severity.ERROR, messageKey, args); + } + + /** + * Adds an error finding with a specific location reference. + * + * @param location the location (e.g., "server.xml:42" or "Port 8080") + * @param messageKey the resource bundle key for the error message + * @param args optional arguments for message formatting + */ + public void addError(String location, String messageKey, Object... args) { + addFinding(Severity.ERROR, location, messageKey, args); + } + + /** + * Adds a warning finding. Warnings indicate potentially problematic + * configurations that may cause issues. + * + * @param messageKey the resource bundle key for the warning message + * @param args optional arguments for message formatting + */ + public void addWarning(String messageKey, Object... args) { + addFinding(Severity.WARNING, messageKey, args); + } + + /** + * Adds a warning finding with a specific location reference. + * + * @param location the location (e.g., "server.xml:42" or "Port 8080") + * @param messageKey the resource bundle key for the warning message + * @param args optional arguments for message formatting + */ + public void addWarning(String location, String messageKey, Object... args) { + addFinding(Severity.WARNING, location, messageKey, args); + } + + /** + * Adds an informational finding. Info messages provide useful information + * about the configuration without indicating problems. + * + * @param messageKey the resource bundle key for the info message + * @param args optional arguments for message formatting + */ + public void addInfo(String messageKey, Object... args) { + addFinding(Severity.INFO, messageKey, args); + } + + /** + * Adds an informational finding with a specific location reference. + * + * @param location the location (e.g., "server.xml:42" or "Port 8080") + * @param messageKey the resource bundle key for the info message + * @param args optional arguments for message formatting + */ + public void addInfo(String location, String messageKey, Object... args) { + addFinding(Severity.INFO, location, messageKey, args); + } + + private void addFinding(Severity severity, String messageKey, Object... args) { + addFinding(severity, null, messageKey, args); + } + + private void addFinding(Severity severity, String location, String messageKey, Object... args) { + Objects.requireNonNull(severity, "severity cannot be null"); + Objects.requireNonNull(messageKey, "messageKey cannot be null"); + String message = sm.getString(messageKey, args); + findings.add(new Finding(severity, location, message)); + } + + /** + * Returns all validation findings. + * + * @return an unmodifiable list of findings + */ + public List getFindings() { + return Collections.unmodifiableList(findings); + } + + /** + * Returns the count of error findings. + * + * @return the number of errors + */ + public int getErrorCount() { + return (int) findings.stream().filter(f -> f.severity == Severity.ERROR).count(); + } + + /** + * Returns the count of warning findings. + * + * @return the number of warnings + */ + public int getWarningCount() { + return (int) findings.stream().filter(f -> f.severity == Severity.WARNING).count(); + } + + /** + * Returns the count of informational findings. + * + * @return the number of info messages + */ + public int getInfoCount() { + return (int) findings.stream().filter(f -> f.severity == Severity.INFO).count(); + } + + /** + * Returns whether validation passed (no errors). + * + * @return true if there are no errors + */ + public boolean isSuccess() { + return getErrorCount() == 0; + } + + /** + * Returns whether there are any findings at all. + * + * @return true if there are any findings + */ + public boolean hasFindings() { + return !findings.isEmpty(); + } + + /** + * Represents a single validation finding. + */ + public static class Finding { + private final Severity severity; + private final String location; + private final String message; + + public Finding(Severity severity, String location, String message) { + this.severity = severity; + this.location = location; + this.message = message; + } + + public Severity getSeverity() { + return severity; + } + + public String getLocation() { + return location; + } + + public String getMessage() { + return message; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append('[').append(severity).append(']'); + if (location != null) { + sb.append(' ').append(location).append(':'); + } + sb.append(' ').append(message); + return sb.toString(); + } + } +} diff --git a/java/org/apache/catalina/startup/validator/ValidatorRegistry.java b/java/org/apache/catalina/startup/validator/ValidatorRegistry.java new file mode 100644 index 000000000000..7dc939777177 --- /dev/null +++ b/java/org/apache/catalina/startup/validator/ValidatorRegistry.java @@ -0,0 +1,55 @@ +/* + * 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.catalina.startup.validator; + +import org.apache.catalina.Server; +import org.apache.juli.logging.Log; +import org.apache.juli.logging.LogFactory; +import org.apache.tomcat.util.res.StringManager; + +/** + * Registry for managing execution of configuration validators. + */ +public class ValidatorRegistry { + + private static final Log log = LogFactory.getLog(ValidatorRegistry.class); + private static final StringManager sm = StringManager.getManager(ValidatorRegistry.class); + + /** + * Validates the server configuration with all enabled Validators. + * + * @param server the server to validate + * @return the validation result + */ + public ValidationResult validate(Server server) { + ValidationResult result = new ValidationResult(sm); + + // Run the port validator + PortValidator portValidator = new PortValidator(); + try { + if (log.isDebugEnabled()) { + log.debug("Running port configuration validation..."); + } + portValidator.validate(server, result); + } catch (Exception e) { + log.warn("Error during port validation", e); + result.addWarning("Validator failed: " + e.getMessage()); + } + + return result; + } +} diff --git a/res/bnd/catalina.jar.tmp.bnd b/res/bnd/catalina.jar.tmp.bnd index ff1294201acc..cc7a45ad2f52 100644 --- a/res/bnd/catalina.jar.tmp.bnd +++ b/res/bnd/catalina.jar.tmp.bnd @@ -34,6 +34,7 @@ Export-Package: \ org.apache.catalina.servlets,\ org.apache.catalina.session,\ org.apache.catalina.startup,\ + org.apache.catalina.startup.validator,\ org.apache.catalina.users,\ org.apache.catalina.util,\ org.apache.catalina.valves,\ diff --git a/test/org/apache/catalina/startup/validator/TestConfigValidate.java b/test/org/apache/catalina/startup/validator/TestConfigValidate.java new file mode 100644 index 000000000000..88a30b1fb56e --- /dev/null +++ b/test/org/apache/catalina/startup/validator/TestConfigValidate.java @@ -0,0 +1,126 @@ +/* + * 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.catalina.startup.validator; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import org.apache.catalina.startup.Bootstrap; +import org.apache.catalina.startup.Catalina; + +/** + * Integration tests for the config-validate command and loadConfigOnly functionality. + */ +public class TestConfigValidate { + + private Bootstrap bootstrap; + private Catalina catalina; + + @Before + public void setUp() { + bootstrap = new Bootstrap(); + catalina = new Catalina(); + } + + @Test + public void testLoadConfigOnlyDoesNotBindPorts() throws Exception { + // loadConfigOnly should parse config without calling init() + // This means it won't bind to ports + catalina.loadConfigOnly(); + + // Verify server was loaded + Assert.assertNotNull("Server should be loaded", catalina.getServer()); + } + + @Test + public void testLoadConfigOnlyWithArguments() throws Exception { + // Test loadConfigOnly(String[] args) variant + String[] args = new String[] {"start"}; + catalina.loadConfigOnly(args); + + Assert.assertNotNull("Server should be loaded", catalina.getServer()); + } + + @Test + public void testConfigValidateCommand() throws Exception { + // Test that config-validate command works through Bootstrap + // This is an integration test that verifies the full flow, but + // we can't easily test main() because of System.exit() calls. + bootstrap.init(); + + // Verify bootstrap initialized correctly + Assert.assertNotNull("Bootstrap should be initialized", bootstrap); + } + + @Test + public void testValidatorRegistryWithNullServer() { + // Ensure validator handles null server gracefully + ValidatorRegistry registry = new ValidatorRegistry(); + ValidationResult result = registry.validate(null); + + Assert.assertNotNull("Result should not be null", result); + Assert.assertEquals("Should have no errors for null server", 0, result.getErrorCount()); + } + + @Test + public void testValidationResultCounts() { + ValidatorRegistry registry = new ValidatorRegistry(); + ValidationResult result = new ValidationResult( + org.apache.tomcat.util.res.StringManager.getManager( + "org.apache.catalina.startup.validator")); + + // Add some test findings + result.addError("portValidator.invalidPort", "99999"); + result.addWarning("portValidator.ajpMissingSecret"); + result.addInfo("portValidator.shutdownDisabled"); + + Assert.assertEquals("Should have 1 error", 1, result.getErrorCount()); + Assert.assertEquals("Should have 1 warning", 1, result.getWarningCount()); + Assert.assertEquals("Should have 1 info", 1, result.getInfoCount()); + Assert.assertFalse("Validation should not be successful with errors", result.isSuccess()); + Assert.assertTrue("Should have findings", result.hasFindings()); + } + + @Test + public void testValidationResultWithLocation() { + ValidationResult result = new ValidationResult( + org.apache.tomcat.util.res.StringManager.getManager( + "org.apache.catalina.startup.validator")); + + result.addError("Port 8080", "portValidator.portInUse", "8080"); + + Assert.assertEquals("Should have 1 error", 1, result.getErrorCount()); + + ValidationResult.Finding finding = result.getFindings().get(0); + Assert.assertEquals("Location should match", "Port 8080", finding.getLocation()); + Assert.assertEquals("Severity should be ERROR", ValidationResult.Severity.ERROR, finding.getSeverity()); + } + + @Test + public void testLoadVsLoadConfigOnly() throws Exception { + // Verify that load() and loadConfigOnly() handle the same config parsing + // but load() additionally calls init() + Catalina catalinaConfigOnly = new Catalina(); + + // Should parse the configuration + catalinaConfigOnly.loadConfigOnly(); + + // Should have a server + Assert.assertNotNull("Config-only server should exist", catalinaConfigOnly.getServer()); + } +} diff --git a/test/org/apache/catalina/startup/validator/TestPortValidator.java b/test/org/apache/catalina/startup/validator/TestPortValidator.java new file mode 100644 index 000000000000..e9e572298e68 --- /dev/null +++ b/test/org/apache/catalina/startup/validator/TestPortValidator.java @@ -0,0 +1,164 @@ +/* + * 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.catalina.startup.validator; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import org.apache.catalina.connector.Connector; +import org.apache.catalina.core.StandardServer; +import org.apache.catalina.core.StandardService; +import org.apache.tomcat.util.res.StringManager; + +/** + * Tests for the validation framework with the PortValidator. + */ +public class TestPortValidator { + + private static final StringManager sm = + StringManager.getManager("org.apache.catalina.startup.validator"); + + private ValidatorRegistry registry; + private ValidationResult result; + private StandardServer server; + private StandardService service; + + @Before + public void setUp() { + registry = new ValidatorRegistry(); + server = new StandardServer(); + service = new StandardService(); + service.setName("Catalina"); + server.addService(service); + } + + @Test + public void testValidConfiguration() { + // Use a high port unlikely to be in use + Connector connector = new Connector(); + connector.setPort(54321); + service.addConnector(connector); + + result = registry.validate(server); + + // Should have no errors + Assert.assertEquals("Should have no errors for valid config", + 0, result.getErrorCount()); + } + + @Test + public void testInvalidPortNumber() { + Connector connector = new Connector(); + connector.setPort(70000); // Invalid: > 65535 + service.addConnector(connector); + + result = registry.validate(server); + + Assert.assertTrue("Should have error for invalid port", + result.getErrorCount() > 0); + } + + @Test + public void testDuplicatePorts() { + Connector connector1 = new Connector(); + connector1.setPort(8080); + service.addConnector(connector1); + + Connector connector2 = new Connector(); + connector2.setPort(8080); + service.addConnector(connector2); + + result = registry.validate(server); + + boolean hasDuplicateError = false; + for (ValidationResult.Finding finding : result.getFindings()) { + if (finding.getMessage().contains("already configured")) { + hasDuplicateError = true; + } + } + + Assert.assertTrue("Should detect duplicate port", hasDuplicateError); + } + + @Test + public void testDefaultShutdownCommand() { + server.setPort(8005); + server.setShutdown("SHUTDOWN"); // Default command + + Connector connector = new Connector(); + connector.setPort(54322); + service.addConnector(connector); + + result = registry.validate(server); + + boolean hasWarning = false; + for (ValidationResult.Finding finding : result.getFindings()) { + if (finding.getSeverity() == ValidationResult.Severity.WARNING && + finding.getMessage().contains("SHUTDOWN")) { + hasWarning = true; + } + } + + Assert.assertTrue("Should warn about default shutdown command", hasWarning); + } + + @Test + public void testNullServer() { + // Should not crash with null server + result = registry.validate(null); + Assert.assertNotNull(result); + } + + @Test + public void testAjpConnectorMissingSecret() { + Connector connector = new Connector("AJP/1.3"); + connector.setPort(8009); + service.addConnector(connector); + + result = registry.validate(server); + + boolean hasAjpWarning = false; + for (ValidationResult.Finding finding : result.getFindings()) { + if (finding.getMessage().contains("secret")) { + hasAjpWarning = true; + } + } + + Assert.assertTrue("Should warn about missing AJP secret", hasAjpWarning); + } + + @Test + public void testMultipleServices() { + StandardService service2 = new StandardService(); + service2.setName("Service2"); + server.addService(service2); + + Connector connector1 = new Connector(); + connector1.setPort(8080); + service.addConnector(connector1); + + Connector connector2 = new Connector(); + connector2.setPort(8081); + service2.addConnector(connector2); + + result = registry.validate(server); + + // Should validate both services without crashing + Assert.assertNotNull(result); + } +}