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
This commit is contained in:
Kenneth Skovhede
2026-06-17 12:04:43 +02:00
parent 8663f55e8b
commit dcaba2fbcd
6 changed files with 224 additions and 28 deletions
+134 -28
View File
@@ -184,6 +184,104 @@ namespace Duplicati.Server
return new CustomRunnerTask(runner);
}
/// <summary>
/// Parses the raw option value into a <see cref="StoreTaskConfigMode"/>.
/// For backwards compatibility, boolean true is treated as <see cref="StoreTaskConfigMode.Self"/>,
/// boolean false as <see cref="StoreTaskConfigMode.None"/>, and null/whitespace as <see cref="StoreTaskConfigMode.Auto"/>.
/// </summary>
/// <param name="rawValue">The raw option value.</param>
/// <returns>The parsed mode.</returns>
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);
}
/// <summary>
/// Determines whether the backup is configured to use encryption.
/// </summary>
/// <param name="options">The backup options dictionary.</param>
/// <returns>True if encryption is enabled; otherwise, false.</returns>
private static bool IsBackupEncryptionEnabled(Dictionary<string, string?> 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;
}
/// <summary>
/// Resolves the effective store-task-config mode based on encryption settings.
/// </summary>
/// <param name="mode">The requested mode.</param>
/// <param name="encryptionEnabled">Whether backup encryption is enabled.</param>
/// <returns>The effective mode to use.</returns>
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
};
}
}
/// <summary>
/// Prepares a backup for export, optionally removing sensitive information.
/// </summary>
/// <param name="databaseConnection">The database connection.</param>
/// <param name="backup">The backup to export.</param>
/// <param name="includeSecrets">Whether to include secrets in the export.</param>
/// <returns>The import/export structure.</returns>
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<string, string?>? 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<Serializable.ImportExportStructure>? 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;
}
@@ -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;
/// <summary>
/// Defines how the task configuration should be stored in the backup.
/// </summary>
public enum StoreTaskConfigMode
{
/// <summary>
/// Automatically determine behavior based on encryption settings.
/// When encryption is enabled, behaves as <see cref="Self"/>.
/// When encryption is not enabled, behaves as <see cref="None"/>.
/// </summary>
Auto,
/// <summary>
/// Include the current job's backup configuration.
/// When encryption is enabled, includes all secrets.
/// When encryption is not enabled, excludes secrets.
/// </summary>
Self,
/// <summary>
/// Include all job backup configurations.
/// When encryption is enabled, includes all secrets.
/// When encryption is not enabled, excludes secrets.
/// </summary>
All,
/// <summary>
/// Do not include any task configuration.
/// </summary>
None,
/// <summary>
/// Include the current job's backup configuration with all secrets included.
/// </summary>
SelfWithForcedSecrets,
/// <summary>
/// Include all job backup configurations with all secrets included.
/// </summary>
AllWithForcedSecrets
}
+2
View File
@@ -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
{
@@ -139,6 +139,11 @@ public sealed record SystemInfoDto
/// </summary>
public required IEnumerable<Library.Interface.ICommandLineArgument> Options { get; init; }
/// <summary>
/// Gets or sets the server-only options.
/// </summary>
public required IEnumerable<Library.Interface.ICommandLineArgument> ServerOnlyOptions { get; init; }
/// <summary>
/// Gets or sets the compression modules.
/// </summary>
@@ -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
/// <summary>
/// Gets server-only options that are consumed by the server and not passed to the command-line client.
/// </summary>
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)))
];
/// <summary>
/// The settings used for stating the server
/// </summary>
@@ -175,6 +175,11 @@ public class SystemInfoProvider(IApplicationSettings applicationSettings, Connec
/// </summary>
public required Library.Interface.ICommandLineArgument[] Options { get; init; }
/// <summary>
/// Gets or sets the server-only options.
/// </summary>
public required Library.Interface.ICommandLineArgument[] ServerOnlyOptions { get; init; }
/// <summary>
/// Gets or sets the compression modules.
/// </summary>
@@ -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,