From fc478146f3ae8930a08371c9b1c1fe35e6a74115 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Thu, 10 Jul 2025 21:03:16 +0200 Subject: [PATCH] Update ServerUtil probing This PR updates the probing logic to not try the database if a password is supplied on the commandline. It also fixes a crash that could happen at an unwanted place, if the user does not have write access to the supplied data folder. After this, the application will still crash due to not having a place to write information, but it does not crash in the preloader logic. This also prevents creating the folder while probing for the database. This PR also fixes a case where the database could become encrypted, if the ServerUtil was providing an encryption key to an unencrypted database. Before this fix, the database would be encrypted with the key provided to ServerUtil, which would most likely cause the Server/TrayIcon to fail starting and perhaps crash. This fixes #6377 --- .../CommandLine/ServerUtil/Connection.cs | 80 ++++++++++--------- .../GUI/Duplicati.GUI.TrayIcon/Program.cs | 2 +- .../Library/AutoUpdater/DataFolderManager.cs | 23 ++++-- Duplicati/Server/Program.cs | 42 +++++----- Duplicati/UnitTest/ImportExportTests.cs | 2 +- 5 files changed, 81 insertions(+), 68 deletions(-) diff --git a/Duplicati/CommandLine/ServerUtil/Connection.cs b/Duplicati/CommandLine/ServerUtil/Connection.cs index e6ba08138..39346d029 100644 --- a/Duplicati/CommandLine/ServerUtil/Connection.cs +++ b/Duplicati/CommandLine/ServerUtil/Connection.cs @@ -193,58 +193,62 @@ public class Connection } // If we can read the server database, try to create a signin token - try + // But don't try to look at the database if the password is already set. + if (string.IsNullOrWhiteSpace(settings.Password)) { - var opts = new Dictionary(); - if (settings.Key != null) - opts["settings-encryption-key"] = settings.Key.Key; - if (File.Exists(Path.Combine(DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ProbeOnly), DataFolderManager.SERVER_DATABASE_FILENAME))) + try { - string? cfg = null; - using (var connection = Server.Program.GetDatabaseConnection(new ApplicationSettings(), opts, true)) + var opts = new Dictionary(); + if (settings.Key != null) + opts["settings-encryption-key"] = settings.Key.Key; + if (File.Exists(Path.Combine(DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ProbeOnly), DataFolderManager.SERVER_DATABASE_FILENAME))) { - cfg = connection.ApplicationSettings.JWTConfig; - if (settings.HostUrl.Scheme == "https" && connection.ApplicationSettings.ServerSSLCertificate != null && trustedCertificateHashes.Count == 0) + string? cfg = null; + using (var connection = Server.Program.GetDatabaseConnection(new ApplicationSettings(), opts, true, false)) { - var selfSignedCertHash = connection.ApplicationSettings.ServerSSLCertificate?.FirstOrDefault(x => x.HasPrivateKey)?.GetCertHashString(); - if (!string.IsNullOrWhiteSpace(selfSignedCertHash)) - trustedCertificateHashes.Add(selfSignedCertHash); + cfg = connection.ApplicationSettings.JWTConfig; + if (settings.HostUrl.Scheme == "https" && connection.ApplicationSettings.ServerSSLCertificate != null && trustedCertificateHashes.Count == 0) + { + var selfSignedCertHash = connection.ApplicationSettings.ServerSSLCertificate?.FirstOrDefault(x => x.HasPrivateKey)?.GetCertHashString(); + if (!string.IsNullOrWhiteSpace(selfSignedCertHash)) + trustedCertificateHashes.Add(selfSignedCertHash); + } + } + + if (!string.IsNullOrWhiteSpace(cfg)) + { + var signinjwt = new JWTTokenProvider( + JsonSerializer.Deserialize(cfg) + ?? throw new InvalidOperationException("Failed to deserialize JWTConfig") + ).CreateSigninToken("server-cli"); + + var responseTask = client.PostAsync("auth/signin", JsonContent.Create(new { SigninToken = signinjwt, RememberMe = obtainRefreshToken })); + var (accessToken, refreshToken) = await ParseAuthResponse(responseTask); + if (string.IsNullOrWhiteSpace(accessToken)) + throw new InvalidOperationException("Failed to get access token"); + + if (!string.IsNullOrWhiteSpace(refreshToken)) + (settings with { RefreshToken = refreshToken }).Save(console); + + return CreateConnectionWithClient(client, accessToken); } } - - if (!string.IsNullOrWhiteSpace(cfg)) + else if (!string.IsNullOrWhiteSpace(DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ProbeOnly))) { - var signinjwt = new JWTTokenProvider( - JsonSerializer.Deserialize(cfg) - ?? throw new InvalidOperationException("Failed to deserialize JWTConfig") - ).CreateSigninToken("server-cli"); - - var responseTask = client.PostAsync("auth/signin", JsonContent.Create(new { SigninToken = signinjwt, RememberMe = obtainRefreshToken })); - var (accessToken, refreshToken) = await ParseAuthResponse(responseTask); - if (string.IsNullOrWhiteSpace(accessToken)) - throw new InvalidOperationException("Failed to get access token"); - - if (!string.IsNullOrWhiteSpace(refreshToken)) - (settings with { RefreshToken = refreshToken }).Save(console); - - return CreateConnectionWithClient(client, accessToken); + if (console != null) + console.AppendConsoleMessage($"No database found in {DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ProbeOnly)}"); + else + Console.WriteLine($"No database found in {DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ProbeOnly)}"); } } - else if (!string.IsNullOrWhiteSpace(DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ProbeOnly))) + catch (Exception ex) { if (console != null) - console.AppendConsoleMessage($"No database found in {DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ProbeOnly)}"); + console.AppendConsoleMessage($"Failed to obtain a signin token: {ex.Message}"); else - Console.WriteLine($"No database found in {DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ProbeOnly)}"); + Console.WriteLine($"Failed to obtain a signin token: {ex.Message}"); } } - catch (Exception ex) - { - if (console != null) - console.AppendConsoleMessage($"Failed to obtain a signin token: {ex.Message}"); - else - Console.WriteLine($"Failed to obtain a signin token: {ex.Message}"); - } // Otherwise, we need a password to log in try diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs index 0cfc87e6c..e09a0461a 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs @@ -192,7 +192,7 @@ namespace Duplicati.GUI.TrayIcon if (File.Exists(Path.Combine(DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ReadWritePermissionSet), DataFolderManager.SERVER_DATABASE_FILENAME))) { passwordSource = PasswordSource.Database; - databaseConnection = Server.Program.GetDatabaseConnection(new ApplicationSettings(), options, true); + databaseConnection = Server.Program.GetDatabaseConnection(new ApplicationSettings(), options, true, false); if (databaseConnection != null) { diff --git a/Duplicati/Library/AutoUpdater/DataFolderManager.cs b/Duplicati/Library/AutoUpdater/DataFolderManager.cs index 09ef527f2..1b4f1c024 100644 --- a/Duplicati/Library/AutoUpdater/DataFolderManager.cs +++ b/Duplicati/Library/AutoUpdater/DataFolderManager.cs @@ -25,6 +25,7 @@ using System; using System.IO; using System.Linq; using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; using Duplicati.Library.Utility; namespace Duplicati.Library.AutoUpdater; @@ -95,9 +96,9 @@ public static class DataFolderManager { // Trigger portable mode, if the flag is set PORTABLE_MODE = ParseBoolSlim(ExtractOptionSlim(PORTABLE_MODE_OPTION)); - + string dataFolder = string.Empty; - + // The environment variable is a legacy setting var envOverride = Environment.GetEnvironmentVariable(DATAFOLDER_ENV_NAME); @@ -142,17 +143,23 @@ public static class DataFolderManager Logging.Log.WriteWarningMessage(LOGTAG, "FailedToSetPermissions", ex, "Failed to set permissions for {0}: {1}", dataFolder, ex.Message); } } - else + else if (mode == AccessMode.ReadWritePermissionSet) { - Directory.CreateDirectory(dataFolder); try { - if (mode == AccessMode.ReadWritePermissionSet) + Directory.CreateDirectory(dataFolder); + try + { SystemIO.IO_OS.DirectorySetPermissionUserRWOnly(dataFolder); + } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(LOGTAG, "FailedToSetPermissions", ex, "Failed to set permissions for {0}: {1}", dataFolder, ex.Message); + } } catch (Exception ex) { - Logging.Log.WriteWarningMessage(LOGTAG, "FailedToSetPermissions", ex, "Failed to set permissions for {0}: {1}", dataFolder, ex.Message); + throw new UserInformationException($"Failed to create data folder {dataFolder}", "FailedToCreateDataFolder", ex); } } @@ -167,7 +174,7 @@ public static class DataFolderManager if (mode == AccessMode.ReadWritePermissionSet && !File.Exists(Path.Combine(dataFolder, MACHINE_FILE))) File.WriteAllText(Path.Combine(dataFolder, MACHINE_FILE), AutoUpdateSettings.UpdateMachineFileText(InstallID)); - + return dataFolder; } @@ -227,7 +234,7 @@ public static class DataFolderManager && !value.Equals("no", StringComparison.OrdinalIgnoreCase) && !value.Equals("off", StringComparison.OrdinalIgnoreCase); } - + /// /// The unique machine installation ID /// diff --git a/Duplicati/Server/Program.cs b/Duplicati/Server/Program.cs index aa86179da..8740bec42 100644 --- a/Duplicati/Server/Program.cs +++ b/Duplicati/Server/Program.cs @@ -197,7 +197,7 @@ namespace Duplicati.Server ISchedulerService scheduler = null; try { - var connection = GetDatabaseConnection(applicationSettings, commandlineOptions, silentConsole); + var connection = GetDatabaseConnection(applicationSettings, commandlineOptions, silentConsole, true); if (!connection.ApplicationSettings.FixedInvalidBackupId) connection.FixInvalidBackupId(); @@ -736,7 +736,7 @@ namespace Duplicati.Server throw new Exception("Server invoked with --help"); } - public static Connection GetDatabaseConnection(IApplicationSettings applicationSettings, Dictionary commandlineOptions, bool silentConsole) + public static Connection GetDatabaseConnection(IApplicationSettings applicationSettings, Dictionary commandlineOptions, bool silentConsole, bool changeDbEncryption) { // Emit a warning if the database is stored in the Windows folder if (Util.IsPathUnderWindowsFolder(applicationSettings.DataFolder)) @@ -786,29 +786,26 @@ namespace Duplicati.Server if (requireDbEncryptionKey && !(hasValidEncryptionKey || disableDbEncryption)) throw new UserInformationException(Strings.Program.DatabaseEncryptionKeyRequired(EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME, DISABLE_DB_ENCRYPTION_OPTION), "RequireDbEncryptionKey"); - if (!hasValidEncryptionKey) + var hasEncryptedFields = false; + try { - try - { - var hasEncryptedFields = false; - using (var cmd = con.CreateCommand(@$"SELECT ""Value"" FROM ""Option"" WHERE ""Name"" = @Name AND ""BackupID"" = @BackupId")) - hasEncryptedFields = Library.Utility.Utility.ParseBool(cmd - .SetParameterValue("@Name", Database.ServerSettings.CONST.ENCRYPTED_FIELDS) - .SetParameterValue("@BackupId", Connection.SERVER_SETTINGS_ID).ExecuteScalar()?.ToString(), false); + using (var cmd = con.CreateCommand(@$"SELECT ""Value"" FROM ""Option"" WHERE ""Name"" = @Name AND ""BackupID"" = @BackupId")) + hasEncryptedFields = Library.Utility.Utility.ParseBool(cmd + .SetParameterValue("@Name", Database.ServerSettings.CONST.ENCRYPTED_FIELDS) + .SetParameterValue("@BackupId", Connection.SERVER_SETTINGS_ID).ExecuteScalar()?.ToString(), false); - if (hasEncryptedFields) - { - Log.WriteWarningMessage(LOGTAG, "EncryptionKeyMissing", null, Strings.Program.EncryptionKeyMissing(EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME)); - if (!silentConsole) - Console.WriteLine(Strings.Program.EncryptionKeyMissing(EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME)); - } - } - catch + if (hasEncryptedFields && !hasValidEncryptionKey) { - // Ignore errors here, as we are just checking for a potential issue - // Only negative effect is that we do not show a potentially helpful warning + Log.WriteWarningMessage(LOGTAG, "EncryptionKeyMissing", null, Strings.Program.EncryptionKeyMissing(EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME)); + if (!silentConsole) + Console.WriteLine(Strings.Program.EncryptionKeyMissing(EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME)); } } + catch + { + // Ignore errors here, as we are just checking for a potential issue + // Only negative effect is that we do not show a potentially helpful warning + } if (!hasValidEncryptionKey && !disableDbEncryption) { @@ -826,6 +823,11 @@ namespace Duplicati.Server Console.WriteLine(Strings.Program.BlacklistedEncryptionKey(EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME, DISABLE_DB_ENCRYPTION_OPTION)); } + // If the database is not encrypted, and we are not changing the encryption + // don't pass the key, as that would cause the database to be encrypted + if (!hasEncryptedFields && !changeDbEncryption && hasValidEncryptionKey) + encKey = null; + return new Connection(con, disableDbEncryption, encKey, applicationSettings.DataFolder, applicationSettings.StartOrStopUsageReporter); } diff --git a/Duplicati/UnitTest/ImportExportTests.cs b/Duplicati/UnitTest/ImportExportTests.cs index 527e3a4fb..64ba12bd0 100644 --- a/Duplicati/UnitTest/ImportExportTests.cs +++ b/Duplicati/UnitTest/ImportExportTests.cs @@ -109,7 +109,7 @@ namespace Duplicati.UnitTest } byte[] jsonByteArray; - using (var con = Program.GetDatabaseConnection(new ApplicationSettings(), advancedOptions, true)) + using (var con = Program.GetDatabaseConnection(new ApplicationSettings(), advancedOptions, true, false)) jsonByteArray = BackupImportExportHandler.ExportToJSON(con, backup, null); // The username should not have the '%40' converted to '@' since the import code