diff --git a/.github/workflows/appimage.yml b/.github/workflows/appimage.yml index 2d4e47b08..a1c57d91c 100644 --- a/.github/workflows/appimage.yml +++ b/.github/workflows/appimage.yml @@ -122,6 +122,7 @@ jobs: --java-options "-Dcryptomator.appVersion=\"${{ needs.get-version.outputs.semVerStr }}\"" --java-options "-Dfile.encoding=\"utf-8\"" --java-options "-Djava.net.useSystemProxies=true" + --java-options "-Dcryptomator.adminConfigPath=\"/etc/cryptomator/config.properties\"" --java-options "-Dcryptomator.logDir=\"@{userhome}/.local/share/Cryptomator/logs\"" --java-options "-Dcryptomator.pluginDir=\"@{userhome}/.local/share/Cryptomator/plugins\"" --java-options "-Dcryptomator.settingsPath=\"@{userhome}/.config/Cryptomator/settings.json:@{userhome}/.Cryptomator/settings.json\"" diff --git a/.github/workflows/mac-dmg-x64.yml b/.github/workflows/mac-dmg-x64.yml index b1677458e..d8047462e 100644 --- a/.github/workflows/mac-dmg-x64.yml +++ b/.github/workflows/mac-dmg-x64.yml @@ -128,6 +128,7 @@ jobs: --java-options "-Dapple.awt.enableTemplateImages=true" --java-options "-Dsun.java2d.metal=true" --java-options "-Dcryptomator.appVersion=\"${{ needs.get-version.outputs.semVerStr }}\"" + --java-options "-Dcryptomator.adminConfigPath=\"/Library/Application Support/Cryptomator/config.properties\"" --java-options "-Dcryptomator.logDir=\"@{userhome}/Library/Logs/Cryptomator\"" --java-options "-Dcryptomator.pluginDir=\"@{userhome}/Library/Application Support/Cryptomator/Plugins\"" --java-options "-Dcryptomator.settingsPath=\"@{userhome}/Library/Application Support/Cryptomator/settings.json\"" diff --git a/.github/workflows/mac-dmg.yml b/.github/workflows/mac-dmg.yml index 8b341b56a..9a06024a0 100644 --- a/.github/workflows/mac-dmg.yml +++ b/.github/workflows/mac-dmg.yml @@ -126,6 +126,7 @@ jobs: --java-options "-Dapple.awt.enableTemplateImages=true" --java-options "-Dsun.java2d.metal=true" --java-options "-Dcryptomator.appVersion=\"${{ needs.get-version.outputs.semVerStr }}\"" + --java-options "-Dcryptomator.adminConfigPath=\"/Library/Application Support/Cryptomator/config.properties\"" --java-options "-Dcryptomator.logDir=\"@{userhome}/Library/Logs/Cryptomator\"" --java-options "-Dcryptomator.pluginDir=\"@{userhome}/Library/Application Support/Cryptomator/Plugins\"" --java-options "-Dcryptomator.settingsPath=\"@{userhome}/Library/Application Support/Cryptomator/settings.json\"" diff --git a/.github/workflows/win-exe.yml b/.github/workflows/win-exe.yml index 749cab0c2..23846ad5d 100644 --- a/.github/workflows/win-exe.yml +++ b/.github/workflows/win-exe.yml @@ -140,6 +140,7 @@ jobs: --java-options "-Dcryptomator.appVersion=\"${{ needs.get-version.outputs.semVerStr }}\"" --java-options "-Dfile.encoding=\"utf-8\"" --java-options "-Djava.net.useSystemProxies=true" + --java-options "-Dcryptomator.adminConfigPath=\"C:/ProgramData/Cryptomator/config.properties\"" --java-options "-Dcryptomator.logDir=\"@{localappdata}/Cryptomator\"" --java-options "-Dcryptomator.pluginDir=\"@{appdata}/Cryptomator/Plugins\"" --java-options "-Dcryptomator.settingsPath=\"@{appdata}/Cryptomator/settings.json;@{userhome}/AppData/Roaming/Cryptomator/settings.json\"" diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f1135e8..6a071b2fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Changes to prior versions can be found on the [Github release page](https://gith * Mark files in-use for Hub vaults ([#4078](https://github.com/cryptomator/cryptomator/pull/4078)) * Accessibility: Adjust app to be used with a screen reader ([#547](https://github.com/cryptomator/cryptomator/issues/547)) * Show Archived Vault Dialog on unlock when Hub returns 410 ([#4081](https://github.com/cryptomator/cryptomator/pull/4081)) +* Admin configuration: Allow overwriting certain app properties by external config file ([#4105](https://github.com/cryptomator/cryptomator/pull/4105)) ### Changed * Built using JDK 25 ([#4031](https://github.com/cryptomator/cryptomator/issues/4031)) diff --git a/dist/common/cryptomator.config b/dist/common/cryptomator.config new file mode 100644 index 000000000..98c5f753f --- /dev/null +++ b/dist/common/cryptomator.config @@ -0,0 +1,8 @@ +# This is the Cryptomator administrative configuration file. +# It is a simple key-value pair file. +# Lines starting with '#' are comments and will be ignored. +# For more info, read the docs at https://docs.cryptomator.org/desktop/advanced-settings/ +# +# Example: +# Sets the plugin directory and enables plugin loading +# cryptomator.pluginDir=@{userhome}/Cryptomator/Plugins \ No newline at end of file diff --git a/dist/linux/appimage/build.sh b/dist/linux/appimage/build.sh index 99249cb35..ee72b538b 100755 --- a/dist/linux/appimage/build.sh +++ b/dist/linux/appimage/build.sh @@ -88,6 +88,7 @@ ${JAVA_HOME}/bin/jpackage \ --app-version "${VERSION}.${REVISION_NO}" \ --java-options "-Dfile.encoding=\"utf-8\"" \ --java-options "-Djava.net.useSystemProxies=true" \ + --java-options "-Dcryptomator.adminConfigPath=\"/etc/cryptomator/config.properties\"" \ --java-options "-Dcryptomator.logDir=\"@{userhome}/.local/share/Cryptomator/logs\"" \ --java-options "-Dcryptomator.pluginDir=\"@{userhome}/.local/share/Cryptomator/plugins\"" \ --java-options "-Dcryptomator.settingsPath=\"@{userhome}/.config/Cryptomator/settings.json:@{userhome}/.Cryptomator/settings.json\"" \ diff --git a/dist/linux/debian/rules b/dist/linux/debian/rules index 456a97e89..0d2242f40 100755 --- a/dist/linux/debian/rules +++ b/dist/linux/debian/rules @@ -51,6 +51,7 @@ override_dh_auto_build: --java-options "-Xmx256m" \ --java-options "-Dfile.encoding=\"utf-8\"" \ --java-options "-Djava.net.useSystemProxies=true" \ + --java-options "-Dcryptomator.adminConfigPath=\"/etc/cryptomator/config.properties\"" \ --java-options "-Dcryptomator.logDir=\"@{userhome}/.local/share/Cryptomator/logs\"" \ --java-options "-Dcryptomator.pluginDir=\"@{userhome}/.local/share/Cryptomator/plugins\"" \ --java-options "-Dcryptomator.settingsPath=\"@{userhome}/.config/Cryptomator/settings.json:@{userhome}/.Cryptomator/settings.json\"" \ diff --git a/dist/mac/dmg/build.sh b/dist/mac/dmg/build.sh index fbe81931c..438de7b9a 100755 --- a/dist/mac/dmg/build.sh +++ b/dist/mac/dmg/build.sh @@ -114,6 +114,7 @@ ${JAVA_HOME}/bin/jpackage \ --java-options "-Dapple.awt.enableTemplateImages=true" \ --java-options "-Dsun.java2d.metal=true" \ --java-options "-Dcryptomator.appVersion=\"${VERSION_NO}\"" \ + --java-options "-Dcryptomator.adminConfigPath=\"/Library/Application Support/Cryptomator/config.properties\"" \ --java-options "-Dcryptomator.logDir=\"@{userhome}/Library/Logs/${APP_NAME}\"" \ --java-options "-XX:ErrorFile=/cryptomator/cryptomator_crash.log" \ --java-options "-Dcryptomator.pluginDir=\"@{userhome}/Library/Application Support/${APP_NAME}/Plugins\"" \ diff --git a/dist/win/build.ps1 b/dist/win/build.ps1 index 6fdee1c9a..54ee8946e 100644 --- a/dist/win/build.ps1 +++ b/dist/win/build.ps1 @@ -155,6 +155,7 @@ $javaOptions = @( "--java-options", "-Djava.net.useSystemProxies=true" "--java-options", "-Dcryptomator.logDir=`"@{localappdata}/$AppName`"" "--java-options", "-XX:ErrorFile=`"C:/cryptomator/cryptomator_crash.log`"" +"--java-options", "-Dcryptomator.adminConfigPath=`"C:/ProgramData/$AppName/config.properties`"" "--java-options", "-Dcryptomator.pluginDir=`"@{appdata}/$AppName/Plugins`"" "--java-options", "-Dcryptomator.settingsPath=`"@{appdata}/$AppName/settings.json;@{userhome}/AppData/Roaming/$AppName/settings.json`"" "--java-options", "-Dcryptomator.ipcSocketPath=`"@{localappdata}/$AppName/ipc.socket`"" diff --git a/dist/win/resources/main.wxs b/dist/win/resources/main.wxs index 7269c29f5..ac5e6a3d1 100644 --- a/dist/win/resources/main.wxs +++ b/dist/win/resources/main.wxs @@ -87,7 +87,7 @@ - + @@ -99,6 +99,25 @@ + + + + + + + + + + + + + + + + + + + @@ -106,7 +125,9 @@ - + + + diff --git a/src/main/java/org/cryptomator/launcher/AdminPropertiesFactory.java b/src/main/java/org/cryptomator/launcher/AdminPropertiesFactory.java new file mode 100644 index 000000000..6a77f31cd --- /dev/null +++ b/src/main/java/org/cryptomator/launcher/AdminPropertiesFactory.java @@ -0,0 +1,97 @@ +package org.cryptomator.launcher; + +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Properties; +import java.util.Set; + +/** + * Factory to generate admin properties. + * + *

+ * Admin properties are {@link Properties} using system properties as defaults, but allow overwriting a specific set of properties with an external config file. + * Those properties are created by calling {@link #create()}. The method first reads system property {@value #ADMIN_PROP_FILE_KEY}. If it contains a path to a valid properties file, all overridable properties from the file are loaded into the returned admin properties. + *

+ * The overridable properties are: + *

+ * + * @see Properties + * @see System#getProperties() + */ +class AdminPropertiesFactory { + + private static final Logger LOG = EventualLogger.INSTANCE; + private static final long MAX_CONFIG_SIZE_BYTES = 8192; + private static final String ADMIN_PROP_FILE_KEY = "cryptomator.adminConfigPath"; + private static final Set ALLOWED_OVERRIDES = Set.of( // + "cryptomator.logDir", // + "cryptomator.pluginDir", // + "cryptomator.p12Path", // + "cryptomator.mountPointsDir", // + "cryptomator.disableUpdateCheck"); + + + /** + * Creates new {@link Properties} containing overridable properties from the admin config. + *

+ * The returned properties object uses as default the {@link System} properties. + * For a list of overridable properties, see {@link AdminPropertiesFactory} + * + * @return {@link Properties} containing overridable properties from the admin config and defaulting to system properties. + */ + static Properties create() { + var systemProps = System.getProperties(); + var adminProps = new Properties(systemProps); + + final String adminCfgPath = System.getProperty(ADMIN_PROP_FILE_KEY); + if (adminCfgPath == null) { + LOG.debug("Admin config property is not defined. Skipping."); + return adminProps; + } + var propsFromFile = loadPropertiesFromFile(Path.of(adminCfgPath)); + + for (var key : propsFromFile.stringPropertyNames()) { + if (ALLOWED_OVERRIDES.contains(key)) { + var value = propsFromFile.getProperty(key); + LOG.info("Overwriting {} with value {} from admin config.", key, value); + adminProps.setProperty(key, value); + } else { + LOG.debug("Property {} in admin config is not supported for override.", key); + } + } + return adminProps; + } + + //visible for testing + static Properties loadPropertiesFromFile(Path adminPropertiesPath) { + var adminProps = new Properties(); + try (FileChannel ch = FileChannel.open(adminPropertiesPath, StandardOpenOption.READ); // + Reader reader = Channels.newReader(ch, StandardCharsets.UTF_8)) { + if (ch.size() > MAX_CONFIG_SIZE_BYTES) { + throw new IOException("Config file %s exceeds maximum size of %d".formatted(adminPropertiesPath, MAX_CONFIG_SIZE_BYTES)); + } + adminProps.load(reader); + } catch (NoSuchFileException _) { + //NO-OP + LOG.debug("No admin properties found at {}.", adminPropertiesPath); + } catch (IOException | IllegalArgumentException e) { + LOG.warn("Failed to read administrative properties from {}. Returning empty properties.", adminPropertiesPath, e); + } + return adminProps; + } + +} diff --git a/src/main/java/org/cryptomator/launcher/Cryptomator.java b/src/main/java/org/cryptomator/launcher/Cryptomator.java index 3e0a613ca..4a6d0750d 100644 --- a/src/main/java/org/cryptomator/launcher/Cryptomator.java +++ b/src/main/java/org/cryptomator/launcher/Cryptomator.java @@ -11,9 +11,9 @@ import org.apache.commons.lang3.SystemUtils; import org.cryptomator.common.Environment; import org.cryptomator.common.ShutdownHook; import org.cryptomator.common.SubstitutingProperties; -import org.cryptomator.networking.SSLContextProvider; import org.cryptomator.ipc.IpcCommunicator; import org.cryptomator.logging.DebugMode; +import org.cryptomator.networking.SSLContextProvider; import org.cryptomator.ui.fxapp.FxApplicationComponent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,7 +35,8 @@ public class Cryptomator { private static final long STARTUP_TIME = System.currentTimeMillis(); static { - var lazyProcessedProps = new SubstitutingProperties(System.getProperties(), System.getenv()); + var adminProps = AdminPropertiesFactory.create(); + var lazyProcessedProps = new SubstitutingProperties(adminProps, System.getenv()); System.setProperties(lazyProcessedProps); CRYPTOMATOR_COMPONENT = DaggerCryptomatorComponent.factory().create(STARTUP_TIME); LOG = LoggerFactory.getLogger(Cryptomator.class); @@ -64,6 +65,7 @@ public class Cryptomator { } public static void main(String[] args) { + EventualLogger.INSTANCE.drainTo(LOG); var printVersion = Optional.ofNullable(args) // .stream() //Streams either one element (the args-array) or zero elements .flatMap(Arrays::stream) // diff --git a/src/main/java/org/cryptomator/launcher/EventualLogger.java b/src/main/java/org/cryptomator/launcher/EventualLogger.java new file mode 100644 index 000000000..fac429cfe --- /dev/null +++ b/src/main/java/org/cryptomator/launcher/EventualLogger.java @@ -0,0 +1,106 @@ +package org.cryptomator.launcher; + +import org.slf4j.Logger; +import org.slf4j.Marker; +import org.slf4j.event.DefaultLoggingEvent; +import org.slf4j.event.Level; +import org.slf4j.event.LoggingEvent; +import org.slf4j.helpers.AbstractLogger; + +import java.util.ArrayDeque; +import java.util.List; +import java.util.Objects; +import java.util.Queue; + +class EventualLogger extends AbstractLogger { + + static final EventualLogger INSTANCE = new EventualLogger(); + + private final Queue bufferedEvents = new ArrayDeque<>(); + + private EventualLogger() { + } + + synchronized void drainTo(Logger gutter) { + for (var event : bufferedEvents) { + var builder = gutter.atLevel(event.getLevel()) // + .setCause(event.getThrowable()) // + .setMessage(event.getMessage()); + event.getArguments().forEach(builder::addArgument); + Objects.requireNonNullElse(event.getMarkers(), List.of()).forEach(builder::addMarker); + builder.log(); + } + bufferedEvents.clear(); + } + + @Override + protected synchronized void handleNormalizedLoggingCall(Level level, Marker marker, String messagePattern, Object[] arguments, Throwable throwable) { + var event = new DefaultLoggingEvent(level, this); + if (marker != null) { + event.addMarker(marker); + } + event.setMessage(messagePattern); + for (var arg : Objects.requireNonNullElse(arguments, new Object[]{})) { + event.addArgument(arg); + } + event.setThrowable(throwable); + bufferedEvents.add(event); + } + + //Unclear, unused and undocumented method of slf4j, see also https://github.com/qos-ch/slf4j/discussions/348 + @Override + protected String getFullyQualifiedCallerName() { + return getClass().getCanonicalName(); + } + + + @Override + public boolean isTraceEnabled() { + return true; + } + + @Override + public boolean isTraceEnabled(Marker marker) { + return true; + } + + @Override + public boolean isDebugEnabled() { + return true; + } + + @Override + public boolean isDebugEnabled(Marker marker) { + return true; + } + + @Override + public boolean isInfoEnabled() { + return true; + } + + @Override + public boolean isInfoEnabled(Marker marker) { + return true; + } + + @Override + public boolean isWarnEnabled() { + return true; + } + + @Override + public boolean isWarnEnabled(Marker marker) { + return true; + } + + @Override + public boolean isErrorEnabled() { + return true; + } + + @Override + public boolean isErrorEnabled(Marker marker) { + return true; + } +} diff --git a/src/test/java/org/cryptomator/launcher/AdminPropertiesFactoryTest.java b/src/test/java/org/cryptomator/launcher/AdminPropertiesFactoryTest.java new file mode 100644 index 000000000..d75dc7fba --- /dev/null +++ b/src/test/java/org/cryptomator/launcher/AdminPropertiesFactoryTest.java @@ -0,0 +1,97 @@ +package org.cryptomator.launcher; + +import org.hamcrest.MatcherAssert; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.Properties; + +import static org.hamcrest.Matchers.anEmptyMap; +import static org.hamcrest.Matchers.hasEntry; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; + +public class AdminPropertiesFactoryTest { + + private static final String PROPS = """ + fruit=banana + vegetable:kärrot + method=scan寧"""; + + @Test + @DisplayName("UTF-8 is supported") + void loadUTF8Properties(@TempDir Path path) throws IOException { + var config = path.resolve("config.properties"); + try (var out = Files.newOutputStream(config, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + var bytes = PROPS.getBytes(StandardCharsets.UTF_8); + out.write(bytes); + } + + var properties = AdminPropertiesFactory.loadPropertiesFromFile(config); + Assertions.assertAll(List.of( // + () -> MatcherAssert.assertThat(properties, hasEntry("fruit", "banana")), // + () -> MatcherAssert.assertThat(properties, hasEntry("vegetable", "kärrot")), // + () -> MatcherAssert.assertThat(properties, hasEntry("method", "scan寧")))); + } + + @Test + @DisplayName("Loading not existing file returns empty properties") + void loadNotExistingFile(@TempDir Path path) { + var config = path.resolve("config.properties"); + var properties = AdminPropertiesFactory.loadPropertiesFromFile(config); + MatcherAssert.assertThat(properties, anEmptyMap()); + } + + @Test + @DisplayName("Loading invalid file returns empty properties") + void loadInvalidFile(@TempDir Path path) throws IOException { + var config = path.resolve("config.properties"); + try (var out = Files.newOutputStream(config, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + var bytes = "method=\\u2u20".getBytes(StandardCharsets.UTF_8); //only one "u" is allowed in a Unicode escape sequence + out.write(bytes); + } + + var properties = AdminPropertiesFactory.loadPropertiesFromFile(config); + MatcherAssert.assertThat(properties, anEmptyMap()); + } + + @Test + @DisplayName("Loading too big file returns empty properties") + void loadTooBigFile(@TempDir Path path) throws IOException { + var config = path.resolve("config.properties"); + try (var channel = Files.newByteChannel(config, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) { + channel.position(10_000); + channel.write(ByteBuffer.wrap("test=test".getBytes())); + } + + var properties = AdminPropertiesFactory.loadPropertiesFromFile(config); + MatcherAssert.assertThat(properties, anEmptyMap()); + } + + @Test + @DisplayName("If system properties do not contain config path, skip loading") + void skipLoadIfFilePathIsNotDefined() { + Assertions.assertNull(System.getProperty("cryptomator.adminConfigPath")); + + try (var adminPropSetterMock = mockStatic(AdminPropertiesFactory.class)) { + adminPropSetterMock.when(AdminPropertiesFactory::create).thenCallRealMethod(); + adminPropSetterMock.when(() -> AdminPropertiesFactory.loadPropertiesFromFile(any())).thenReturn(new Properties()); + + var adminProps = AdminPropertiesFactory.create(); + + adminPropSetterMock.verify(() -> AdminPropertiesFactory.loadPropertiesFromFile(any()), never()); + Assertions.assertEquals(System.getProperty("user.home"), adminProps.getProperty("user.home")); + } + } + +}