From dcaba2fbcd6267e84c1f48cc09bbbe5f20134a7e Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Wed, 17 Jun 2026 12:04:43 +0200 Subject: [PATCH] Store configuration with backup This PR revives the `store-task-config` option that was never shown in the user interface and extends the feature to allow more flexibility in exporting the configurations. The `Auto` setting is now on by default. For encrypted backups, this will store the backup configuration of the current backup with the backup for easy restore of a configuration. For unencrypted backups, nothing will be stored by default. To manually pick the what backup configurations are stored, the following options are also available: - None: no configuration is stored - Self: The configuration of the current backup is stored - All: The configurations of all backups are stored If the backup is not encrypted, the data is stored without any secrets (encryption keys, passphrases, passwords, api-keys, etc). To override this, the following two options are also present: - SelfWithForcedSecrets - AllWithForcedSecrets Using one of these when encryption is enabled has no additional effects, but for unecrypted backups this will include all secrets in the backups in plain-text. This fixes #6256 This fixes #3073 --- Duplicati/Library/RestAPI/Runner.cs | 162 +++++++++++++++--- .../Library/RestAPI/StoreTaskConfigMode.cs | 60 +++++++ Duplicati/Library/RestAPI/Strings.cs | 2 + Duplicati/WebserverCore/Dto/SystemInfoDto.cs | 5 + Duplicati/WebserverCore/DuplicatiWebserver.cs | 16 ++ .../Services/SystemInfoProvider.cs | 7 + 6 files changed, 224 insertions(+), 28 deletions(-) create mode 100644 Duplicati/Library/RestAPI/StoreTaskConfigMode.cs diff --git a/Duplicati/Library/RestAPI/Runner.cs b/Duplicati/Library/RestAPI/Runner.cs index 6f91bf113..019451ca0 100644 --- a/Duplicati/Library/RestAPI/Runner.cs +++ b/Duplicati/Library/RestAPI/Runner.cs @@ -184,6 +184,104 @@ namespace Duplicati.Server return new CustomRunnerTask(runner); } + /// + /// Parses the raw option value into a . + /// For backwards compatibility, boolean true is treated as , + /// boolean false as , and null/whitespace as . + /// + /// The raw option value. + /// The parsed mode. + public static StoreTaskConfigMode ParseStoreTaskConfigMode(string? rawValue) + { + if (string.IsNullOrWhiteSpace(rawValue)) + return StoreTaskConfigMode.Auto; + + // Handle boolean values for backwards compatibility, use a lambda capture to see if we hit the default case + var usedDefault = false; + var parsedBool = Utility.ParseBool(rawValue, () => { usedDefault = true; return false; }); + if (!usedDefault) + return parsedBool ? StoreTaskConfigMode.Self : StoreTaskConfigMode.None; + + return Utility.ParseEnum(rawValue, StoreTaskConfigMode.Auto); + } + + /// + /// Determines whether the backup is configured to use encryption. + /// + /// The backup options dictionary. + /// True if encryption is enabled; otherwise, false. + private static bool IsBackupEncryptionEnabled(Dictionary options) + { + // In principle, this check should be enough + if (Utility.ParseBoolOption(options, "no-encryption")) + return false; + + // But since we explicitly set the encryption module, we also check that this is set + if (string.IsNullOrWhiteSpace(options.GetValueOrDefault("encryption-module"))) + return false; + + // Also, we need a passphrase + if (string.IsNullOrWhiteSpace(options.GetValueOrDefault("passphrase"))) + return false; + + return true; + } + + /// + /// Resolves the effective store-task-config mode based on encryption settings. + /// + /// The requested mode. + /// Whether backup encryption is enabled. + /// The effective mode to use. + private static StoreTaskConfigMode ResolveEffectiveMode(StoreTaskConfigMode mode, bool encryptionEnabled) + { + if (encryptionEnabled) + { + return mode switch + { + StoreTaskConfigMode.Auto => StoreTaskConfigMode.SelfWithForcedSecrets, + StoreTaskConfigMode.Self => StoreTaskConfigMode.SelfWithForcedSecrets, + StoreTaskConfigMode.All => StoreTaskConfigMode.AllWithForcedSecrets, + _ => mode + }; + } + else + { + return mode switch + { + StoreTaskConfigMode.Auto => StoreTaskConfigMode.None, + StoreTaskConfigMode.Self => StoreTaskConfigMode.Self, + StoreTaskConfigMode.All => StoreTaskConfigMode.All, + _ => mode + }; + } + } + + /// + /// Prepares a backup for export, optionally removing sensitive information. + /// + /// The database connection. + /// The backup to export. + /// Whether to include secrets in the export. + /// The import/export structure. + private static Serializable.ImportExportStructure PrepareBackupForExport(Connection databaseConnection, IBackup backup, bool includeSecrets) + { + var exported = databaseConnection.PrepareBackupForExport(backup); + if (!includeSecrets && exported.Backup != null) + { + var clone = exported.Backup.Clone(); + clone.RemoveSensitiveInformation(); + exported = new Serializable.ImportExportStructure() + { + CreatedByVersion = exported.CreatedByVersion, + Backup = clone, + Schedule = exported.Schedule, + DisplayNames = exported.DisplayNames + }; + } + return exported; + } + public static IRunnerData CreateTask(DuplicatiOperation operation, IBackup backup, IDictionary? extraOptions = null, string[]? filterStrings = null, string[]? extraArguments = null, int pageSize = 0, int pageOffset = 0, bool returnExtended = false, bool caseSensitiveSearch = false) { return new RunnerData() @@ -670,7 +768,7 @@ namespace Duplicati.Server ApplyAdditionalTargetUrls(backup, options); // Pack in the system or task config for easy restore - if (data.Operation == DuplicatiOperation.Backup && options.ContainsKey("store-task-config")) + if (data.Operation == DuplicatiOperation.Backup) tempfolder = StoreTaskConfigAndGetTempFolder(databaseConnection, data, options); var useOutOfProcess = databaseConnection.ApplicationSettings.UseOutOfProcessController; @@ -882,39 +980,47 @@ namespace Duplicati.Server if (data.Backup == null) throw new ArgumentNullException(nameof(data.Backup)); - var all_tasks = string.Equals(options["store-task-config"], "all", StringComparison.OrdinalIgnoreCase) || string.Equals(options["store-task-config"], "*", StringComparison.OrdinalIgnoreCase); - var this_task = Utility.ParseBool(options["store-task-config"], false); + var mode = ParseStoreTaskConfigMode(options.GetValueOrDefault("store-task-config")); + var encryptionEnabled = IsBackupEncryptionEnabled(options); + var effectiveMode = ResolveEffectiveMode(mode, encryptionEnabled); options.Remove("store-task-config"); - TempFolder? tempfolder = null; - if (all_tasks || this_task) + if (effectiveMode == StoreTaskConfigMode.None) + return null; + + bool includeAllTasks = effectiveMode == StoreTaskConfigMode.All || effectiveMode == StoreTaskConfigMode.AllWithForcedSecrets; + bool includeSecrets = effectiveMode == StoreTaskConfigMode.SelfWithForcedSecrets || effectiveMode == StoreTaskConfigMode.AllWithForcedSecrets; + + var tempfolder = new TempFolder(); + var temppath = System.IO.Path.Combine(tempfolder, "task-setup.json"); + using (var tempfile = Library.Utility.TempFile.WrapExistingFile(temppath)) { - tempfolder = new TempFolder(); - var temppath = System.IO.Path.Combine(tempfolder, "task-setup.json"); - using (var tempfile = Library.Utility.TempFile.WrapExistingFile(temppath)) + IEnumerable? taskdata = null; + if (includeAllTasks) { - object? taskdata = null; - if (all_tasks) - taskdata = databaseConnection.Backups.Where(x => !x.IsTemporary).Select(x => databaseConnection.PrepareBackupForExport(databaseConnection.GetBackup(x.ID)!)); - else - taskdata = new[] { databaseConnection.PrepareBackupForExport(data.Backup) }; - - using (var fs = System.IO.File.OpenWrite(tempfile)) - using (var sw = new System.IO.StreamWriter(fs, System.Text.Encoding.UTF8)) - Serializer.SerializeJson(sw, taskdata, true); - - tempfile.Protected = true; - - options.TryGetValue("control-files", out var controlfiles); - - if (string.IsNullOrWhiteSpace(controlfiles)) - controlfiles = tempfile; - else - controlfiles += System.IO.Path.PathSeparator + tempfile; - - options["control-files"] = controlfiles; + taskdata = databaseConnection.Backups + .Where(x => !x.IsTemporary) + .Select(x => PrepareBackupForExport(databaseConnection, databaseConnection.GetBackup(x.ID)!, includeSecrets)); } + else + { + taskdata = [PrepareBackupForExport(databaseConnection, data.Backup, includeSecrets)]; + } + + using (var fs = System.IO.File.OpenWrite(tempfile)) + using (var sw = new System.IO.StreamWriter(fs, System.Text.Encoding.UTF8)) + Serializer.SerializeJson(sw, taskdata, true); + + options.TryGetValue("control-files", out var controlfiles); + + // Append this to any other control files + options["control-files"] = string.IsNullOrWhiteSpace(controlfiles) + ? controlfiles = tempfile + : controlfiles += System.IO.Path.PathSeparator + tempfile; + + // Don't delete the file now, leave it for when the folder is deleted + tempfile.Protected = true; } return tempfolder; } diff --git a/Duplicati/Library/RestAPI/StoreTaskConfigMode.cs b/Duplicati/Library/RestAPI/StoreTaskConfigMode.cs new file mode 100644 index 000000000..5f225c113 --- /dev/null +++ b/Duplicati/Library/RestAPI/StoreTaskConfigMode.cs @@ -0,0 +1,60 @@ +// Copyright (C) 2026, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +namespace Duplicati.Server; + +/// +/// Defines how the task configuration should be stored in the backup. +/// +public enum StoreTaskConfigMode +{ + /// + /// Automatically determine behavior based on encryption settings. + /// When encryption is enabled, behaves as . + /// When encryption is not enabled, behaves as . + /// + Auto, + /// + /// Include the current job's backup configuration. + /// When encryption is enabled, includes all secrets. + /// When encryption is not enabled, excludes secrets. + /// + Self, + /// + /// Include all job backup configurations. + /// When encryption is enabled, includes all secrets. + /// When encryption is not enabled, excludes secrets. + /// + All, + /// + /// Do not include any task configuration. + /// + None, + /// + /// Include the current job's backup configuration with all secrets included. + /// + SelfWithForcedSecrets, + /// + /// Include all job backup configurations with all secrets included. + /// + AllWithForcedSecrets +} + diff --git a/Duplicati/Library/RestAPI/Strings.cs b/Duplicati/Library/RestAPI/Strings.cs index 4ff1c0d26..867ba9506 100644 --- a/Duplicati/Library/RestAPI/Strings.cs +++ b/Duplicati/Library/RestAPI/Strings.cs @@ -129,6 +129,8 @@ Error message: {0}", error); } public static string ConfigureHttpsHostnamesLong { get { return LC.L(@"Comma-separated list of hostnames to include in the HTTPS certificate (used with --configure-https)"); } } public static string SuppressWelcomePageShort { get { return LC.L(@"Suppress the initial welcome page"); } } public static string SuppressWelcomePageLong { get { return LC.L(@"Suppress the welcome page that is shown when first using the web interface"); } } + public static string StoretaskconfigShort { get { return LC.L(@"Store task configuration with backup"); } } + public static string StoretaskconfigLong { get { return LC.L(@$"Controls whether the backup configuration is stored as a control file in the backup. When encryption is enabled, {StoreTaskConfigMode.Auto} stores the current job with secrets. When encryption is not enabled, {StoreTaskConfigMode.Auto} does not store anything. When encryption is not enabled {StoreTaskConfigMode.Self} and {StoreTaskConfigMode.All} store the configuration without secrets. Note that configuration is only uploaded if the backup has changed, and a configuration change alone will not be treated as a new backup version."); } } } internal static class Scheduler { diff --git a/Duplicati/WebserverCore/Dto/SystemInfoDto.cs b/Duplicati/WebserverCore/Dto/SystemInfoDto.cs index 32eac6fc2..4c408b77e 100644 --- a/Duplicati/WebserverCore/Dto/SystemInfoDto.cs +++ b/Duplicati/WebserverCore/Dto/SystemInfoDto.cs @@ -139,6 +139,11 @@ public sealed record SystemInfoDto /// public required IEnumerable Options { get; init; } + /// + /// Gets or sets the server-only options. + /// + public required IEnumerable ServerOnlyOptions { get; init; } + /// /// Gets or sets the compression modules. /// diff --git a/Duplicati/WebserverCore/DuplicatiWebserver.cs b/Duplicati/WebserverCore/DuplicatiWebserver.cs index 7b641b40a..f5b0478fa 100644 --- a/Duplicati/WebserverCore/DuplicatiWebserver.cs +++ b/Duplicati/WebserverCore/DuplicatiWebserver.cs @@ -27,6 +27,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Duplicati.Library.Interface; using Duplicati.Library.Utility; +using Duplicati.Server; using Duplicati.Server.Database; using Duplicati.WebserverCore.Abstractions; using Duplicati.WebserverCore.Dto.V2; @@ -103,6 +104,21 @@ public class DuplicatiWebserver private static readonly bool EnableSwagger = false; #endif + /// + /// Gets server-only options that are consumed by the server and not passed to the command-line client. + /// + public static ICommandLineArgument[] ServerOnlyOptions => + [ + new CommandLineArgument( + "store-task-config", + CommandLineArgument.ArgumentType.Enumeration, + Server.Strings.Program.StoretaskconfigShort, + Server.Strings.Program.StoretaskconfigLong, + StoreTaskConfigMode.Auto.ToString(), + null, + Enum.GetNames(typeof(StoreTaskConfigMode))) + ]; + /// /// The settings used for stating the server /// diff --git a/Duplicati/WebserverCore/Services/SystemInfoProvider.cs b/Duplicati/WebserverCore/Services/SystemInfoProvider.cs index 0a763f97e..182475a56 100644 --- a/Duplicati/WebserverCore/Services/SystemInfoProvider.cs +++ b/Duplicati/WebserverCore/Services/SystemInfoProvider.cs @@ -175,6 +175,11 @@ public class SystemInfoProvider(IApplicationSettings applicationSettings, Connec /// public required Library.Interface.ICommandLineArgument[] Options { get; init; } + /// + /// Gets or sets the server-only options. + /// + public required Library.Interface.ICommandLineArgument[] ServerOnlyOptions { get; init; } + /// /// Gets or sets the compression modules. /// @@ -283,6 +288,7 @@ public class SystemInfoProvider(IApplicationSettings applicationSettings, Connec NewLine = Environment.NewLine, CLRVersion = Environment.Version.ToString(), Options = Server.Serializable.ServerSettings.Options, + ServerOnlyOptions = DuplicatiWebserver.ServerOnlyOptions, CompressionModules = Server.Serializable.ServerSettings.CompressionModules, EncryptionModules = Server.Serializable.ServerSettings.EncryptionModules, BackendModules = Server.Serializable.ServerSettings.BackendModules, @@ -372,6 +378,7 @@ public class SystemInfoProvider(IApplicationSettings applicationSettings, Connec NewLine = systeminfo.NewLine, CLRVersion = systeminfo.CLRVersion, Options = systeminfo.Options, + ServerOnlyOptions = systeminfo.ServerOnlyOptions, CompressionModules = systeminfo.CompressionModules, EncryptionModules = systeminfo.EncryptionModules, BackendModules = systeminfo.BackendModules,