diff --git a/server/conf/mirth.properties b/server/conf/mirth.properties index cbc3a21cec..96157d3695 100644 --- a/server/conf/mirth.properties +++ b/server/conf/mirth.properties @@ -1,4 +1,8 @@ # Mirth Connect configuration file +# +# 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/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 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)); + } +}