From 7568ade1f844e5c66544685147d1f27593609b17 Mon Sep 17 00:00:00 2001 From: Finnegan's Owner <44065187+pacmano1@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:19:01 -0600 Subject: [PATCH 1/3] Load mirth.properties.d drop-in configuration overrides on startup Files named *.properties under conf/mirth.properties.d are applied on top of mirth.properties in lexical filename order, later files winning. The server never writes to the directory: migration and password re-encryption operate on a separate file-backed configuration, so drop-in values are never baked into mirth.properties. Signed-off-by: Finnegan's Owner <44065187+pacmano1@users.noreply.github.com> --- server/conf/mirth.properties | 4 + server/conf/mirth.properties.d/README | 19 +++ .../java/com/mirth/connect/server/Mirth.java | 4 + .../DefaultConfigurationController.java | 30 +++-- .../server/extprops/DropInProperties.java | 111 ++++++++++++++++++ .../server/extprops/ExtensionStatuses.java | 1 + .../server/launcher/MirthLauncher.java | 2 + .../server/servlets/WebStartServlet.java | 4 +- .../connect/server/util/ResourceUtil.java | 19 +++ .../util/javascript/JavaScriptScopeUtil.java | 4 + .../server/extprops/DropInPropertiesTest.java | 104 ++++++++++++++++ 11 files changed, 294 insertions(+), 8 deletions(-) create mode 100644 server/conf/mirth.properties.d/README create mode 100644 server/src/main/java/com/mirth/connect/server/extprops/DropInProperties.java create mode 100644 server/src/test/java/com/mirth/connect/server/extprops/DropInPropertiesTest.java diff --git a/server/conf/mirth.properties b/server/conf/mirth.properties index cbc3a21cec..c64eef613c 100644 --- a/server/conf/mirth.properties +++ b/server/conf/mirth.properties @@ -1,4 +1,8 @@ # Mirth Connect configuration file +# +# The server rewrites this file during version migration and password encryption. +# To keep customizations out of it, put them in files under conf/mirth.properties.d +# instead. See conf/mirth.properties.d/README. # directories dir.appdata = appdata diff --git a/server/conf/mirth.properties.d/README b/server/conf/mirth.properties.d/README new file mode 100644 index 0000000000..62ecb09ba8 --- /dev/null +++ b/server/conf/mirth.properties.d/README @@ -0,0 +1,19 @@ +Files in this directory whose names end in .properties are loaded on top of +conf/mirth.properties at startup, in lexical filename order. Later files override +earlier ones, and any file here overrides mirth.properties itself. + +Put your local customizations here, for example: + + 10-database.properties + 20-ports.properties + +The server never writes to this directory, unlike mirth.properties, which it +rewrites during version migration and password encryption. That also means +properties the server encrypts and writes back (database.password, +database-readonly.password, keystore.storepass, keystore.keypass) should stay in +mirth.properties: if you override one of them here, your plaintext value keeps +taking precedence and will not be encrypted. + +Files are parsed with java.util.Properties semantics. If a key appears more than +once, the last occurrence wins; there are no multi-valued properties. For list +properties such as https.client.protocols, use a single comma-separated value. diff --git a/server/src/main/java/com/mirth/connect/server/Mirth.java b/server/src/main/java/com/mirth/connect/server/Mirth.java index 5e8cacfcf0..68c8e33cd0 100644 --- a/server/src/main/java/com/mirth/connect/server/Mirth.java +++ b/server/src/main/java/com/mirth/connect/server/Mirth.java @@ -59,6 +59,7 @@ import com.mirth.connect.server.controllers.MigrationController; import com.mirth.connect.server.controllers.ScriptController; import com.mirth.connect.server.controllers.UsageController; +import com.mirth.connect.server.extprops.DropInProperties; import com.mirth.connect.server.controllers.UserController; import com.mirth.connect.server.logging.JuliToLog4JService; import com.mirth.connect.server.logging.LogOutputStream; @@ -187,7 +188,10 @@ public boolean initResources() { try { mirthPropertiesStream = ResourceUtil.getResourceStream(this.getClass(), "mirth.properties"); mirthProperties = PropertiesConfigurationUtil.create(mirthPropertiesStream); + mirthProperties = DropInProperties.overlay(mirthProperties, ResourceUtil.getMirthPropertiesDropInDirectory()); } catch (Exception e) { + // Reset so a failed drop-in overlay aborts startup instead of booting with a partial configuration + mirthProperties = PropertiesConfigurationUtil.create(); logger.error("could not load mirth.properties", e); } finally { IOUtils.closeQuietly(mirthPropertiesStream); diff --git a/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java b/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java index bfee2eddbf..a77345805d 100644 --- a/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java +++ b/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java @@ -119,6 +119,7 @@ import com.mirth.connect.plugins.directoryresource.DirectoryResourceProperties; import com.mirth.connect.server.ExtensionLoader; import com.mirth.connect.server.mybatis.KeyValuePair; +import com.mirth.connect.server.extprops.DropInProperties; import com.mirth.connect.server.tools.ClassPathResource; import com.mirth.connect.server.util.DatabaseUtil; import com.mirth.connect.server.util.PasswordRequirementsChecker; @@ -166,6 +167,9 @@ public class DefaultConfigurationController extends ConfigurationController { private static PropertiesConfiguration versionConfig = PropertiesConfigurationUtil.create(); private static FileBasedConfigurationBuilder mirthConfigBuilder = PropertiesConfigurationUtil.createBuilder(); protected static PropertiesConfiguration mirthConfig = PropertiesConfigurationUtil.create(); + // The file-backed configuration that saveMirthConfig() writes. Kept separate from mirthConfig + // so that values from mirth.properties.d drop-in files are never baked into mirth.properties. + protected static PropertiesConfiguration mirthFileConfig = mirthConfig; private static EncryptionSettings encryptionConfig; private static DatabaseSettings databaseConfig; private static String apiBypassword; @@ -229,15 +233,19 @@ public void initialize() { try { // Delimiter parsing disabled by default so getString() returns the whole property, even if there are commas mirthConfigBuilder = PropertiesConfigurationUtil.createBuilder(new File(ClassPathResource.getResourceURI("mirth.properties"))); - mirthConfig = mirthConfigBuilder.getConfiguration(); + mirthFileConfig = mirthConfigBuilder.getConfiguration(); + // Also assign the read view now so anything reading during migration sees real values + mirthConfig = mirthFileConfig; - MigrationController.getInstance().migrateConfiguration(mirthConfig); + MigrationController.getInstance().migrateConfiguration(mirthFileConfig); try { mirthConfigBuilder.save(); } catch (ConfigurationException e) { logger.error("Unable to update mirth.properties version during migration.", e); } + mirthConfig = DropInProperties.overlay(mirthFileConfig, ResourceUtil.getMirthPropertiesDropInDirectory()); + // load the server version versionPropertiesStream = ResourceUtil.getResourceStream(this.getClass(), "version.properties"); versionConfig = PropertiesConfigurationUtil.create(versionPropertiesStream); @@ -1245,11 +1253,11 @@ public void initializeSecuritySettings() { */ if (Arrays.equals(keyStorePassword, DEFAULT_STOREPASS.toCharArray()) && Arrays.equals(keyPassword, DEFAULT_STOREPASS.toCharArray())) { String keyStorePasswordStr = generateNewPassword(); - mirthConfig.setProperty("keystore.storepass", keyStorePasswordStr); + setMirthConfigProperty("keystore.storepass", keyStorePasswordStr); keyStorePassword = keyStorePasswordStr.toCharArray(); String keyPasswordStr = generateNewPassword(); - mirthConfig.setProperty("keystore.keypass", keyPasswordStr); + setMirthConfigProperty("keystore.keypass", keyPasswordStr); keyPassword = keyPasswordStr.toCharArray(); saveMirthConfig(); @@ -1329,18 +1337,26 @@ private void updateDatabasePassword(Encryptor encryptor, String password, boolea if (!StringUtils.startsWith(encryptedPassword, "{")) { // Re-encrypt if still using the old-style format - mirthConfig.setProperty(readOnly ? DatabaseConstants.DATABASE_READONLY_PASSWORD : DatabaseConstants.DATABASE_PASSWORD, EncryptionSettings.ENCRYPTION_PREFIX + encryptor.encrypt(decryptedPassword)); + setMirthConfigProperty(readOnly ? DatabaseConstants.DATABASE_READONLY_PASSWORD : DatabaseConstants.DATABASE_PASSWORD, EncryptionSettings.ENCRYPTION_PREFIX + encryptor.encrypt(decryptedPassword)); saveMirthConfig(); } } else if (StringUtils.isNotBlank(password)) { // encrypt the password and write it back to the file String encryptedPassword = EncryptionSettings.ENCRYPTION_PREFIX + encryptor.encrypt(password); - mirthConfig.setProperty(readOnly ? DatabaseConstants.DATABASE_READONLY_PASSWORD : DatabaseConstants.DATABASE_PASSWORD, encryptedPassword); + setMirthConfigProperty(readOnly ? DatabaseConstants.DATABASE_READONLY_PASSWORD : DatabaseConstants.DATABASE_PASSWORD, encryptedPassword); saveMirthConfig(); } } + private void setMirthConfigProperty(String key, Object value) { + mirthConfig.setProperty(key, value); + + if (mirthFileConfig != mirthConfig) { + mirthFileConfig.setProperty(key, value); + } + } + private void saveMirthConfig() throws FileNotFoundException, ConfigurationException { /* * Save using a FileOutputStream so that the file will be saved to the proper location, even @@ -1350,7 +1366,7 @@ private void saveMirthConfig() throws FileNotFoundException, ConfigurationExcept OutputStream os = new FileOutputStream(new File(confDir, "mirth.properties")); try { - PropertiesConfigurationUtil.saveTo(mirthConfig, os); + PropertiesConfigurationUtil.saveTo(mirthFileConfig, os); } finally { ResourceUtil.closeResourceQuietly(os); } diff --git a/server/src/main/java/com/mirth/connect/server/extprops/DropInProperties.java b/server/src/main/java/com/mirth/connect/server/extprops/DropInProperties.java new file mode 100644 index 0000000000..5061372282 --- /dev/null +++ b/server/src/main/java/com/mirth/connect/server/extprops/DropInProperties.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.server.extprops; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Properties; + +import org.apache.commons.configuration2.ConfigurationUtils; +import org.apache.commons.configuration2.PropertiesConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; + +/** + * Applies drop-in override files from a mirth.properties.d directory on top of an already-loaded + * configuration. Files directly under the directory whose names end in ".properties" are applied + * in lexical filename order: later files override earlier ones, and all of them override the base + * configuration. Merge semantics come from java.util.Properties.load(), where the last occurrence + * of a key wins. + * + * This class lives in the extprops package because it is shared by the launcher jar and the server + * jar, and must not reference classes outside this package and the launcher's classpath. + */ +public class DropInProperties { + + /** + * Loads drop-in files into the given properties, overriding any existing values. Files that + * cannot be read are logged and skipped. + */ + public static void load(Properties properties, File dropInDir, LoggerWrapper logger) { + File[] files = listDropInFiles(dropInDir); + + if (files == null) { + if (isUnlistableDirectory(dropInDir)) { + logger.error("Unable to list drop-in properties directory: " + dropInDir.getPath()); + } + return; + } + + for (File file : files) { + try (InputStream is = new FileInputStream(file)) { + properties.load(is); + } catch (IOException e) { + logger.error("Unable to read drop-in properties file: " + file.getPath(), e); + } + } + } + + /** + * Returns a configuration with any drop-in files applied on top of the given base + * configuration. The base configuration is never modified; it is returned as-is when there are + * no drop-in files. + */ + public static PropertiesConfiguration overlay(PropertiesConfiguration base, File dropInDir) throws ConfigurationException { + File[] files = listDropInFiles(dropInDir); + + if (files == null) { + if (isUnlistableDirectory(dropInDir)) { + throw new ConfigurationException("Unable to list drop-in properties directory: " + dropInDir.getPath()); + } + return base; + } + + if (files.length == 0) { + return base; + } + + Properties dropIns = new Properties(); + + for (File file : files) { + try (InputStream is = new FileInputStream(file)) { + dropIns.load(is); + } catch (IOException e) { + throw new ConfigurationException("Unable to read drop-in properties file: " + file.getPath(), e); + } + } + + PropertiesConfiguration merged = new PropertiesConfiguration(); + ConfigurationUtils.copy(base, merged); + + for (String key : dropIns.stringPropertyNames()) { + merged.setProperty(key, dropIns.getProperty(key)); + } + + return merged; + } + + private static File[] listDropInFiles(File dropInDir) { + File[] files = dropInDir == null ? null : dropInDir.listFiles(file -> file.isFile() && file.getName().endsWith(".properties")); + + if (files != null) { + Arrays.sort(files); + } + + return files; + } + + /** listFiles() returns null both for a missing directory and an unlistable one; only the latter deserves noise. */ + private static boolean isUnlistableDirectory(File dropInDir) { + return dropInDir != null && dropInDir.isDirectory(); + } +} diff --git a/server/src/main/java/com/mirth/connect/server/extprops/ExtensionStatuses.java b/server/src/main/java/com/mirth/connect/server/extprops/ExtensionStatuses.java index f205956e67..60148be8c1 100644 --- a/server/src/main/java/com/mirth/connect/server/extprops/ExtensionStatuses.java +++ b/server/src/main/java/com/mirth/connect/server/extprops/ExtensionStatuses.java @@ -55,6 +55,7 @@ private ExtensionStatuses() { } finally { IOUtils.closeQuietly(is); } + DropInProperties.load(mirthProperties, new File("./conf/mirth.properties.d"), logger); } catch (Exception e) { logger.error("Unable to read mirth.properties.", e); } diff --git a/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java b/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java index 33458f2e08..60aa34e513 100644 --- a/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java +++ b/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java @@ -33,6 +33,7 @@ import org.w3c.dom.Element; import org.w3c.dom.NodeList; +import com.mirth.connect.server.extprops.DropInProperties; import com.mirth.connect.server.extprops.ExtensionStatuses; import com.mirth.connect.server.extprops.LoggerWrapper; @@ -77,6 +78,7 @@ public static void main(String[] args) { try (FileInputStream inputStream = new FileInputStream(new File(MIRTH_PROPERTIES_FILE))) { mirthProperties.load(inputStream); + DropInProperties.load(mirthProperties, new File(MIRTH_PROPERTIES_FILE + ".d"), logger); includeCustomLib = mirthProperties.getProperty(PROPERTY_INCLUDE_CUSTOM_LIB); createAppdataDir(mirthProperties); } catch (Exception e) { diff --git a/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java b/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java index 8bb847055b..6c3dc2e983 100644 --- a/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java +++ b/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java @@ -49,6 +49,7 @@ import com.mirth.connect.server.controllers.ConfigurationController; import com.mirth.connect.server.controllers.ControllerFactory; import com.mirth.connect.server.controllers.ExtensionController; +import com.mirth.connect.server.extprops.DropInProperties; import com.mirth.connect.server.tools.ClassPathResource; import com.mirth.connect.server.util.ResourceUtil; import com.mirth.connect.util.MirthSSLUtil; @@ -363,8 +364,9 @@ protected PropertiesConfiguration getMirthProperties() throws FileNotFoundExcept InputStream mirthPropsIs = null; try { - mirthPropsIs = ResourceUtil.getResourceStream(getClass(), "mirth.properties"); + mirthPropsIs = ResourceUtil.getResourceStream(getClass(), "mirth.properties"); mirthProperties = PropertiesConfigurationUtil.create(mirthPropsIs); + mirthProperties = DropInProperties.overlay(mirthProperties, ResourceUtil.getMirthPropertiesDropInDirectory()); } finally { ResourceUtil.closeResourceQuietly(mirthPropsIs); } diff --git a/server/src/main/java/com/mirth/connect/server/util/ResourceUtil.java b/server/src/main/java/com/mirth/connect/server/util/ResourceUtil.java index 6bcf506e4c..e01a79624b 100644 --- a/server/src/main/java/com/mirth/connect/server/util/ResourceUtil.java +++ b/server/src/main/java/com/mirth/connect/server/util/ResourceUtil.java @@ -10,12 +10,31 @@ package com.mirth.connect.server.util; import java.io.Closeable; +import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.net.URI; + +import com.mirth.connect.server.tools.ClassPathResource; public class ResourceUtil { + + /** + * Returns the mirth.properties.d drop-in directory next to the mirth.properties resolved from + * the classpath, or null when mirth.properties does not resolve to a file on disk. + */ + public static File getMirthPropertiesDropInDirectory() { + URI uri = ClassPathResource.getResourceURI("mirth.properties"); + + if (uri != null && "file".equals(uri.getScheme())) { + return new File(new File(uri).getParentFile(), "mirth.properties.d"); + } + + return null; + } + /** * Returns a resource as a stream by checking: * diff --git a/server/src/main/java/com/mirth/connect/server/util/javascript/JavaScriptScopeUtil.java b/server/src/main/java/com/mirth/connect/server/util/javascript/JavaScriptScopeUtil.java index 3eb537eb9d..502ceb33e7 100644 --- a/server/src/main/java/com/mirth/connect/server/util/javascript/JavaScriptScopeUtil.java +++ b/server/src/main/java/com/mirth/connect/server/util/javascript/JavaScriptScopeUtil.java @@ -33,6 +33,8 @@ import com.mirth.connect.donkey.model.message.Message; import com.mirth.connect.donkey.model.message.RawMessage; import com.mirth.connect.server.controllers.ConfigurationController; +import com.mirth.connect.server.extprops.DropInProperties; +import com.mirth.connect.server.extprops.LoggerWrapper; import com.mirth.connect.server.transformers.InvalidTransformedDataException; import com.mirth.connect.server.userutil.AlertSender; import com.mirth.connect.server.userutil.Attachment; @@ -44,6 +46,7 @@ import com.mirth.connect.server.userutil.VMRouter; import com.mirth.connect.server.util.GlobalChannelVariableStoreFactory; import com.mirth.connect.server.util.GlobalVariableStore; +import com.mirth.connect.server.util.ResourceUtil; import com.mirth.connect.server.util.TemplateValueReplacer; import com.mirth.connect.userutil.ImmutableConnectorMessage; import com.mirth.connect.userutil.ImmutableMessage; @@ -62,6 +65,7 @@ public class JavaScriptScopeUtil { * it in interpretive mode. See MIRTH-1627 for more information. */ Properties properties = PropertyLoader.loadProperties("mirth"); + DropInProperties.load(properties, ResourceUtil.getMirthPropertiesDropInDirectory(), new LoggerWrapper(logger)); if (MapUtils.isNotEmpty(properties) && properties.containsKey("rhino.optimizationlevel")) { logger.debug("set Rhino context optimization level: " + rhinoOptimizationLevel); diff --git a/server/src/test/java/com/mirth/connect/server/extprops/DropInPropertiesTest.java b/server/src/test/java/com/mirth/connect/server/extprops/DropInPropertiesTest.java new file mode 100644 index 0000000000..aa710da72f --- /dev/null +++ b/server/src/test/java/com/mirth/connect/server/extprops/DropInPropertiesTest.java @@ -0,0 +1,104 @@ +package com.mirth.connect.server.extprops; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Properties; + +import org.apache.commons.configuration2.PropertiesConfiguration; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class DropInPropertiesTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void overlayReturnsBaseWhenDirIsNull() throws Exception { + PropertiesConfiguration base = new PropertiesConfiguration(); + assertSame(base, DropInProperties.overlay(base, null)); + } + + @Test + public void overlayReturnsBaseWhenDirIsMissing() throws Exception { + PropertiesConfiguration base = new PropertiesConfiguration(); + assertSame(base, DropInProperties.overlay(base, new File(tempFolder.getRoot(), "missing.d"))); + } + + @Test + public void overlayIgnoresNonPropertiesEntries() throws Exception { + File dir = tempFolder.newFolder(); + writeFile(dir, "README", "not a properties file"); + new File(dir, "subdir.properties").mkdir(); + + PropertiesConfiguration base = new PropertiesConfiguration(); + assertSame(base, DropInProperties.overlay(base, dir)); + } + + @Test + public void overlayOverridesAndAddsWithoutModifyingBase() throws Exception { + PropertiesConfiguration base = new PropertiesConfiguration(); + base.setProperty("a", "base"); + base.setProperty("b", "kept"); + + File dir = tempFolder.newFolder(); + writeFile(dir, "10-first.properties", "a = first\nc = added\n"); + writeFile(dir, "20-second.properties", "a = second\n"); + + PropertiesConfiguration merged = DropInProperties.overlay(base, dir); + + assertEquals("second", merged.getString("a")); + assertEquals("kept", merged.getString("b")); + assertEquals("added", merged.getString("c")); + assertEquals("base", base.getString("a")); + assertFalse(base.containsKey("c")); + } + + @Test + public void overlayKeepsCommaValuesWhole() throws Exception { + PropertiesConfiguration base = new PropertiesConfiguration(); + + File dir = tempFolder.newFolder(); + writeFile(dir, "10-list.properties", "https.client.protocols = TLSv1.3,TLSv1.2\n"); + + PropertiesConfiguration merged = DropInProperties.overlay(base, dir); + + assertEquals("TLSv1.3,TLSv1.2", merged.getString("https.client.protocols")); + } + + @Test + public void loadOverridesInLexicalOrder() throws Exception { + Properties properties = new Properties(); + properties.setProperty("a", "base"); + + File dir = tempFolder.newFolder(); + writeFile(dir, "20-second.properties", "a = second\n"); + writeFile(dir, "10-first.properties", "a = first\nb = added\n"); + + DropInProperties.load(properties, dir, new LoggerWrapper(null)); + + assertEquals("second", properties.getProperty("a")); + assertEquals("added", properties.getProperty("b")); + } + + @Test + public void loadIsNoOpWhenDirIsMissing() { + Properties properties = new Properties(); + properties.setProperty("a", "base"); + + DropInProperties.load(properties, new File(tempFolder.getRoot(), "missing.d"), new LoggerWrapper(null)); + + assertEquals("base", properties.getProperty("a")); + assertEquals(1, properties.size()); + } + + private void writeFile(File dir, String name, String content) throws Exception { + Files.write(new File(dir, name).toPath(), content.getBytes(StandardCharsets.ISO_8859_1)); + } +} From 3f9eea192c9812daab7ff317f700ff66ff11e173 Mon Sep 17 00:00:00 2001 From: Finnegan's Owner <44065187+pacmano1@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:56:23 -0600 Subject: [PATCH 2/3] Ship a mirth.properties.example sample in the drop-in directory Replaces the README: the sample carries the documentation as comments and is ignored by the loader until copied to a *.properties name. Also corrects the mirth.properties header comment - the server does not rewrite this file in normal operation; the drop-in directory exists for upgrade hygiene. Signed-off-by: Finnegan's Owner <44065187+pacmano1@users.noreply.github.com> --- server/conf/mirth.properties | 6 +++--- server/conf/mirth.properties.d/README | 19 ----------------- .../mirth.properties.example | 21 +++++++++++++++++++ 3 files changed, 24 insertions(+), 22 deletions(-) delete mode 100644 server/conf/mirth.properties.d/README create mode 100644 server/conf/mirth.properties.d/mirth.properties.example diff --git a/server/conf/mirth.properties b/server/conf/mirth.properties index c64eef613c..96157d3695 100644 --- a/server/conf/mirth.properties +++ b/server/conf/mirth.properties @@ -1,8 +1,8 @@ # Mirth Connect configuration file # -# The server rewrites this file during version migration and password encryption. -# To keep customizations out of it, put them in files under conf/mirth.properties.d -# instead. See conf/mirth.properties.d/README. +# Put local customizations in files under conf/mirth.properties.d instead of +# editing this file, so a new release's mirth.properties can be dropped in +# without hand-merging. See conf/mirth.properties.d/mirth.properties.example. # directories dir.appdata = appdata diff --git a/server/conf/mirth.properties.d/README b/server/conf/mirth.properties.d/README deleted file mode 100644 index 62ecb09ba8..0000000000 --- a/server/conf/mirth.properties.d/README +++ /dev/null @@ -1,19 +0,0 @@ -Files in this directory whose names end in .properties are loaded on top of -conf/mirth.properties at startup, in lexical filename order. Later files override -earlier ones, and any file here overrides mirth.properties itself. - -Put your local customizations here, for example: - - 10-database.properties - 20-ports.properties - -The server never writes to this directory, unlike mirth.properties, which it -rewrites during version migration and password encryption. That also means -properties the server encrypts and writes back (database.password, -database-readonly.password, keystore.storepass, keystore.keypass) should stay in -mirth.properties: if you override one of them here, your plaintext value keeps -taking precedence and will not be encrypted. - -Files are parsed with java.util.Properties semantics. If a key appears more than -once, the last occurrence wins; there are no multi-valued properties. For list -properties such as https.client.protocols, use a single comma-separated value. diff --git a/server/conf/mirth.properties.d/mirth.properties.example b/server/conf/mirth.properties.d/mirth.properties.example new file mode 100644 index 0000000000..9dcb9b5181 --- /dev/null +++ b/server/conf/mirth.properties.d/mirth.properties.example @@ -0,0 +1,21 @@ +# Copy this file to a name ending in .properties (for example 10-local.properties) +# to activate it. Files in this directory named *.properties are loaded on top of +# conf/mirth.properties at startup, in lexical filename order: later files +# override earlier ones, and any file here overrides mirth.properties itself. +# +# Keeping customizations here instead of in mirth.properties means an upgrade +# can replace mirth.properties without hand-merging your changes back in. +# +# Exception: database.password and database-readonly.password are encrypted in +# place in mirth.properties; a value here stays plaintext. keystore.storepass +# and keystore.keypass are auto-generated on first boot only when BOTH are +# still the shipped defaults - overriding just one here leaves the other at +# its published default. Leave these four in mirth.properties. +# +# Files are parsed with java.util.Properties semantics. If a key appears more +# than once, the last occurrence wins; there are no multi-valued properties. For +# list properties such as https.client.protocols, use a single comma-separated +# value. + +# http.port = 8081 +# https.port = 8444 From 0a23598f0d16c0952cb381423892c176816c0529 Mon Sep 17 00:00:00 2001 From: Finnegan's Owner <44065187+pacmano1@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:24:49 -0600 Subject: [PATCH 3/3] Share one merged configuration and fail closed on a bad drop-in file Add ConfigurationController.getPropertiesConfiguration(), which returns mirth.properties with the conf/mirth.properties.d drop-ins already applied. Mirth and WebStartServlet now read that single shared copy instead of each loading and merging the file themselves. The returned configuration is owned by the controller and must be treated as read-only; the controller writes to it during startup (for example when it generates keystore passwords), and consumers only read it. A drop-in file that cannot be read or parsed now fails closed. Previously overlay() threw during controller initialization, where the exception was swallowed and the server came up on the base configuration with the drop-ins silently dropped; if a drop-in was hardening a setting, that reverts to the weaker base value. Catch the failure, record it, and refuse to start with a clear message. The launcher's early readers stay lenient and skip a bad file with a warning, matching how they already treat mirth.properties. A malformed \uXXXX escape is treated the same as an unreadable file. Add a regression test that a write to the merged configuration does not leak back into the base file saved to mirth.properties. Signed-off-by: Finnegan's Owner <44065187+pacmano1@users.noreply.github.com> --- .../java/com/mirth/connect/server/Mirth.java | 28 ++++++++--------- .../controllers/ConfigurationController.java | 30 +++++++++++++++++++ .../DefaultConfigurationController.java | 26 ++++++++++++++-- .../server/extprops/DropInProperties.java | 11 +++++-- .../server/servlets/WebStartServlet.java | 14 ++------- .../server/extprops/DropInPropertiesTest.java | 20 +++++++++++++ 6 files changed, 98 insertions(+), 31 deletions(-) diff --git a/server/src/main/java/com/mirth/connect/server/Mirth.java b/server/src/main/java/com/mirth/connect/server/Mirth.java index 68c8e33cd0..ae37b1dba7 100644 --- a/server/src/main/java/com/mirth/connect/server/Mirth.java +++ b/server/src/main/java/com/mirth/connect/server/Mirth.java @@ -59,7 +59,6 @@ import com.mirth.connect.server.controllers.MigrationController; import com.mirth.connect.server.controllers.ScriptController; import com.mirth.connect.server.controllers.UsageController; -import com.mirth.connect.server.extprops.DropInProperties; import com.mirth.connect.server.controllers.UserController; import com.mirth.connect.server.logging.JuliToLog4JService; import com.mirth.connect.server.logging.LogOutputStream; @@ -183,18 +182,16 @@ public void run() { * @return true if the resources required by the server have been successfully loaded */ public boolean initResources() { - InputStream mirthPropertiesStream = null; - - try { - mirthPropertiesStream = ResourceUtil.getResourceStream(this.getClass(), "mirth.properties"); - mirthProperties = PropertiesConfigurationUtil.create(mirthPropertiesStream); - mirthProperties = DropInProperties.overlay(mirthProperties, ResourceUtil.getMirthPropertiesDropInDirectory()); - } catch (Exception e) { - // Reset so a failed drop-in overlay aborts startup instead of booting with a partial configuration - mirthProperties = PropertiesConfigurationUtil.create(); - logger.error("could not load mirth.properties", e); - } finally { - IOUtils.closeQuietly(mirthPropertiesStream); + // Read the shared, drop-in-merged copy the configuration controller already loaded, rather + // than reloading and re-merging mirth.properties here. + mirthProperties = configurationController.getPropertiesConfiguration(); + + // Refuse to start if configuration could not be loaded cleanly (e.g. an unreadable + // mirth.properties.d drop-in file) rather than run with a silently reverted configuration. + String configurationLoadError = configurationController.getConfigurationLoadError(); + if (configurationLoadError != null) { + logger.error("Refusing to start: " + configurationLoadError); + return false; } InputStream versionPropertiesStream = null; @@ -225,7 +222,10 @@ public void startup() { configurationController.initializeSecuritySettings(); configurationController.initializeDatabaseSettings(); - // Refresh the in-memory config in case the configuration controller changed it + // Pull in any settings the controller changed during initialization (e.g. generated keystore + // passwords). With the default controller mirthProperties is already the controller's own + // configuration, so this is a no-op; a controller that hands out a separate configuration is + // refreshed here. configurationController.updatePropertiesConfiguration(mirthProperties); try { diff --git a/server/src/main/java/com/mirth/connect/server/controllers/ConfigurationController.java b/server/src/main/java/com/mirth/connect/server/controllers/ConfigurationController.java index e4a9ce88c3..2a9d06ccbe 100644 --- a/server/src/main/java/com/mirth/connect/server/controllers/ConfigurationController.java +++ b/server/src/main/java/com/mirth/connect/server/controllers/ConfigurationController.java @@ -9,6 +9,7 @@ package com.mirth.connect.server.controllers; +import java.io.InputStream; import java.util.Calendar; import java.util.List; import java.util.Locale; @@ -21,6 +22,7 @@ import com.mirth.commons.encryption.Digester; import com.mirth.commons.encryption.Encryptor; import com.mirth.connect.client.core.ControllerException; +import com.mirth.connect.client.core.PropertiesConfigurationUtil; import com.mirth.connect.model.ChannelDependency; import com.mirth.connect.model.ChannelMetadata; import com.mirth.connect.model.ChannelTag; @@ -32,6 +34,8 @@ import com.mirth.connect.model.ServerConfiguration; import com.mirth.connect.model.ServerSettings; import com.mirth.connect.model.UpdateSettings; +import com.mirth.connect.server.extprops.DropInProperties; +import com.mirth.connect.server.util.ResourceUtil; import com.mirth.connect.util.ConfigurationProperty; import com.mirth.connect.util.ConnectionTestResponse; @@ -79,6 +83,32 @@ public static ConfigurationController getInstance() { */ public abstract void updatePropertiesConfiguration(PropertiesConfiguration config); + /** + * Returns the loaded mirth.properties configuration, with any conf/mirth.properties.d drop-in + * overrides applied. Server-side consumers should read from this shared copy rather than loading + * and merging the file themselves. The returned configuration is owned by the controller; + * consumers must treat it as read-only, as the controller writes to it during startup (for + * example, when it generates keystore passwords). This default loads on demand; + * DefaultConfigurationController overrides it to return the copy it already holds. + */ + public PropertiesConfiguration getPropertiesConfiguration() { + try (InputStream is = ResourceUtil.getResourceStream(getClass(), "mirth.properties")) { + return DropInProperties.overlay(PropertiesConfigurationUtil.create(is), ResourceUtil.getMirthPropertiesDropInDirectory()); + } catch (Exception e) { + return PropertiesConfigurationUtil.create(); + } + } + + /** + * Returns a message describing a fatal configuration problem that must prevent the server from + * starting, such as an unreadable conf/mirth.properties.d drop-in file, or null when + * configuration loaded cleanly. The default reports no error; DefaultConfigurationController + * overrides it. + */ + public String getConfigurationLoadError() { + return null; + } + /** * Returns the default encryptor. * diff --git a/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java b/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java index a77345805d..fa1b5f85d5 100644 --- a/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java +++ b/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java @@ -118,8 +118,8 @@ import com.mirth.connect.model.converters.ObjectXMLSerializer; import com.mirth.connect.plugins.directoryresource.DirectoryResourceProperties; import com.mirth.connect.server.ExtensionLoader; -import com.mirth.connect.server.mybatis.KeyValuePair; import com.mirth.connect.server.extprops.DropInProperties; +import com.mirth.connect.server.mybatis.KeyValuePair; import com.mirth.connect.server.tools.ClassPathResource; import com.mirth.connect.server.util.DatabaseUtil; import com.mirth.connect.server.util.PasswordRequirementsChecker; @@ -170,6 +170,9 @@ public class DefaultConfigurationController extends ConfigurationController { // The file-backed configuration that saveMirthConfig() writes. Kept separate from mirthConfig // so that values from mirth.properties.d drop-in files are never baked into mirth.properties. protected static PropertiesConfiguration mirthFileConfig = mirthConfig; + // Set when a mirth.properties.d drop-in file cannot be read, so the server refuses to start + // rather than run with a silently reverted configuration. Null when configuration loaded cleanly. + private static volatile String configurationLoadError; private static EncryptionSettings encryptionConfig; private static DatabaseSettings databaseConfig; private static String apiBypassword; @@ -244,7 +247,16 @@ public void initialize() { logger.error("Unable to update mirth.properties version during migration.", e); } - mirthConfig = DropInProperties.overlay(mirthFileConfig, ResourceUtil.getMirthPropertiesDropInDirectory()); + try { + mirthConfig = DropInProperties.overlay(mirthFileConfig, ResourceUtil.getMirthPropertiesDropInDirectory()); + } catch (ConfigurationException e) { + // Fail closed: a broken drop-in file must stop startup rather than silently reverting + // to the base configuration, which could serve weaker settings than the administrator + // intended. mirthConfig stays at the base config and getConfigurationLoadError() reports + // the problem so the server refuses to start. + configurationLoadError = "Unable to load conf/mirth.properties.d drop-in configuration: " + e.getMessage(); + logger.error(configurationLoadError, e); + } // load the server version versionPropertiesStream = ResourceUtil.getResourceStream(this.getClass(), "version.properties"); @@ -1454,6 +1466,16 @@ public void migrateKeystore() { } } + @Override + public PropertiesConfiguration getPropertiesConfiguration() { + return mirthConfig; + } + + @Override + public String getConfigurationLoadError() { + return configurationLoadError; + } + @Override public void updatePropertiesConfiguration(PropertiesConfiguration config) { config.copy(mirthConfig); diff --git a/server/src/main/java/com/mirth/connect/server/extprops/DropInProperties.java b/server/src/main/java/com/mirth/connect/server/extprops/DropInProperties.java index 5061372282..bc9b031525 100644 --- a/server/src/main/java/com/mirth/connect/server/extprops/DropInProperties.java +++ b/server/src/main/java/com/mirth/connect/server/extprops/DropInProperties.java @@ -49,7 +49,8 @@ public static void load(Properties properties, File dropInDir, LoggerWrapper log for (File file : files) { try (InputStream is = new FileInputStream(file)) { properties.load(is); - } catch (IOException e) { + } catch (IOException | IllegalArgumentException e) { + // IllegalArgumentException covers a malformed \\uXXXX escape in Properties.load() logger.error("Unable to read drop-in properties file: " + file.getPath(), e); } } @@ -58,7 +59,8 @@ public static void load(Properties properties, File dropInDir, LoggerWrapper log /** * Returns a configuration with any drop-in files applied on top of the given base * configuration. The base configuration is never modified; it is returned as-is when there are - * no drop-in files. + * no drop-in files. A drop-in file that cannot be read or parsed throws a ConfigurationException + * so that the caller can fail closed rather than start with a partially applied configuration. */ public static PropertiesConfiguration overlay(PropertiesConfiguration base, File dropInDir) throws ConfigurationException { File[] files = listDropInFiles(dropInDir); @@ -79,7 +81,10 @@ public static PropertiesConfiguration overlay(PropertiesConfiguration base, File for (File file : files) { try (InputStream is = new FileInputStream(file)) { dropIns.load(is); - } catch (IOException e) { + } catch (IOException | IllegalArgumentException e) { + // IllegalArgumentException covers a malformed \\uXXXX escape in Properties.load(). + // A file that cannot be read or parsed is fatal here so a broken drop-in fails startup + // rather than silently reverting to the base configuration. throw new ConfigurationException("Unable to read drop-in properties file: " + file.getPath(), e); } } diff --git a/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java b/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java index 6c3dc2e983..a4b3d2db37 100644 --- a/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java +++ b/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java @@ -49,7 +49,6 @@ import com.mirth.connect.server.controllers.ConfigurationController; import com.mirth.connect.server.controllers.ControllerFactory; import com.mirth.connect.server.controllers.ExtensionController; -import com.mirth.connect.server.extprops.DropInProperties; import com.mirth.connect.server.tools.ClassPathResource; import com.mirth.connect.server.util.ResourceUtil; import com.mirth.connect.util.MirthSSLUtil; @@ -360,17 +359,8 @@ private String getDigest(File directory, String filePath) throws Exception { } protected PropertiesConfiguration getMirthProperties() throws FileNotFoundException, ConfigurationException { - PropertiesConfiguration mirthProperties = PropertiesConfigurationUtil.create(); - - InputStream mirthPropsIs = null; - try { - mirthPropsIs = ResourceUtil.getResourceStream(getClass(), "mirth.properties"); - mirthProperties = PropertiesConfigurationUtil.create(mirthPropsIs); - mirthProperties = DropInProperties.overlay(mirthProperties, ResourceUtil.getMirthPropertiesDropInDirectory()); - } finally { - ResourceUtil.closeResourceQuietly(mirthPropsIs); - } - return mirthProperties; + // Read the shared, drop-in-merged copy rather than reloading the file on every request. + return configurationController.getPropertiesConfiguration(); } private String getContextPathProp(PropertiesConfiguration mirthProperties) { diff --git a/server/src/test/java/com/mirth/connect/server/extprops/DropInPropertiesTest.java b/server/src/test/java/com/mirth/connect/server/extprops/DropInPropertiesTest.java index aa710da72f..97d7ce6db4 100644 --- a/server/src/test/java/com/mirth/connect/server/extprops/DropInPropertiesTest.java +++ b/server/src/test/java/com/mirth/connect/server/extprops/DropInPropertiesTest.java @@ -60,6 +60,26 @@ public void overlayOverridesAndAddsWithoutModifyingBase() throws Exception { assertFalse(base.containsKey("c")); } + @Test + public void overlayResultIsIndependentOfBase() throws Exception { + PropertiesConfiguration base = new PropertiesConfiguration(); + base.setProperty("a", "base"); + + File dir = tempFolder.newFolder(); + writeFile(dir, "10-first.properties", "b = dropin\n"); + + PropertiesConfiguration merged = DropInProperties.overlay(base, dir); + + // Writing to the merged result (as the controller does when it re-encrypts a password or + // generates a keystore password) must not leak back into the base configuration that + // saveMirthConfig() writes out to mirth.properties. + merged.setProperty("a", "changed"); + merged.setProperty("c", "new"); + + assertEquals("base", base.getString("a")); + assertFalse(base.containsKey("c")); + } + @Test public void overlayKeepsCommaValuesWhole() throws Exception { PropertiesConfiguration base = new PropertiesConfiguration();