diff --git a/Duplicati/CommandLine/ServerUtil/Commands/Export.cs b/Duplicati/CommandLine/ServerUtil/Commands/Export.cs new file mode 100644 index 000000000..3ee758043 --- /dev/null +++ b/Duplicati/CommandLine/ServerUtil/Commands/Export.cs @@ -0,0 +1,86 @@ +using System.CommandLine; +using System.CommandLine.NamingConventionBinder; + +namespace Duplicati.CommandLine.ServerUtil.Commands; + +public static class Export +{ + public static Command Create() => + new Command("export", "Export a backup configuration") + { + new Argument("backups", "The backup id or name to export, use 'all' to export all backups") { + Arity = ArgumentArity.OneOrMore + }, + + new Option(name: "--encryption-passphrase", description: "The passphrase to use for encrypting the backup configuration", getDefaultValue: () => null), + new Option(name: "--export-passwords", description: "Flag toggling the inclusion of sensitive values, such as passwords, defaults to true if a passphrase is supplied", getDefaultValue: () => null), + new Option(name: "--overwrite", description: "Flag toggling the overwriting of existing files", getDefaultValue: () => false), + new Option(name: "--unencrypted", description: "Flag toggling unencrypted export of configurations", getDefaultValue: () => false), + new Option(name: "--destination", description: "The folder where the backup configuration should be exported to", getDefaultValue: () => new DirectoryInfo(Directory.GetCurrentDirectory())), + } + .WithHandler(CommandHandler.Create(async (settings, backups, encryptionPassphrase, exportPasswords, overwrite, unencrypted, destination) => + { + if (!destination.Exists) + { + Console.WriteLine($"Creating destination folder {destination.FullName}"); + destination.Create(); + } + + var connection = await settings.GetConnection(); + var serverbackups = await connection.ListBackups(); + var includeAllBackups = backups.Any(x => string.Equals(x, "all", StringComparison.OrdinalIgnoreCase)); + var targetbackups = serverbackups.Where(b => includeAllBackups || backups.Any(x => b.Name.Contains(x, StringComparison.OrdinalIgnoreCase)) || backups.Contains(b.ID.ToString())).ToArray(); + if (targetbackups.Length == 0) + throw new UserReportedException($"No backups found matching: {string.Join(", ", backups)}"); + + if (unencrypted) + { + if (!string.IsNullOrWhiteSpace(encryptionPassphrase)) + throw new UserReportedException("The --unencrypted flag cannot be used with a passphrase"); + + if (!exportPasswords.HasValue) + { + Console.WriteLine("The --export-passwords flag is not set, sensitive keys will not be included in the exported file"); + exportPasswords = false; + } + else if (exportPasswords.Value) + { + Console.WriteLine("Warning: Exporting unencrypted configurations with sensitive keys included"); + } + } + else + { + if (string.IsNullOrWhiteSpace(encryptionPassphrase)) + { + encryptionPassphrase = HelperMethods.ReadPasswordFromConsole("Please provide a passphrase to encrypt the backup configuration: "); + if (string.IsNullOrWhiteSpace(encryptionPassphrase)) + throw new UserReportedException("No passphrase provided, use --unencrypted to export unencrypted configurations"); + } + + if (!exportPasswords.HasValue) + exportPasswords = true; + } + + Console.WriteLine($"Exporting {targetbackups.Length} backup{(targetbackups.Length == 1 ? "" : "s")} to {destination.FullName}"); + + foreach (var backup in targetbackups) + { + var name = backup.Name; + foreach (var c in Path.GetInvalidFileNameChars()) + name = name.Replace(c, '_'); + + var file = new FileInfo(Path.Combine(destination.FullName, $"{backup.ID}-{backup.Name}.json{(unencrypted ? "" : ".aes")}")); + if (file.Exists && !overwrite) + { + Console.WriteLine($"Skipping existing file {file.FullName}, use --overwrite to force"); + continue; + } + + using (var s = await connection.ExportBackup(backup.ID, encryptionPassphrase, exportPasswords.Value)) + using (var fs = file.Create()) + await s.CopyToAsync(fs); + + Console.WriteLine($"- Exported to {file.Name}"); + } + })); +} diff --git a/Duplicati/CommandLine/ServerUtil/Connection.cs b/Duplicati/CommandLine/ServerUtil/Connection.cs index 0738526fb..cccbe2193 100644 --- a/Duplicati/CommandLine/ServerUtil/Connection.cs +++ b/Duplicati/CommandLine/ServerUtil/Connection.cs @@ -410,6 +410,29 @@ public class Connection return await GetBackup(id); } + /// + /// Exports a backup + /// + /// The ID of the backup + /// The passphrase to use, if encrypting + /// Whether to include sensitive keys + /// The stream with the exported backup + public async Task ExportBackup(string backupId, string? passphrase, bool includeKeys) + { + var exportTokenResponse = await client.PostAsync("auth/issuetoken/export", null); + await EnsureSuccessStatusCodeWithParsing(exportTokenResponse); + var values = JsonSerializer.Deserialize>(await exportTokenResponse.Content.ReadAsStringAsync()) + ?? throw new UserReportedException("Failed to parse response"); + + if (!values.TryGetValue("Token", out var token)) + throw new UserReportedException("Failed to get export token"); + + var response = await client.GetAsync($"backup/{Uri.EscapeDataString(backupId)}/export?passphrase={Uri.EscapeDataString(passphrase ?? "")}&export-passwords={includeKeys}&token={token}"); + await EnsureSuccessStatusCodeWithParsing(response); + + return await response.Content.ReadAsStreamAsync(); + } + /// /// The server error structure for JSON deserialization /// diff --git a/Duplicati/CommandLine/ServerUtil/Program.cs b/Duplicati/CommandLine/ServerUtil/Program.cs index 2b1f0c002..486790e2d 100644 --- a/Duplicati/CommandLine/ServerUtil/Program.cs +++ b/Duplicati/CommandLine/ServerUtil/Program.cs @@ -29,6 +29,7 @@ public static class Program ChangePassword.Create(), Logout.Create(), Import.Create(), + Export.Create(), }; rootCmd = SettingsBinder.AddGlobalOptions(rootCmd); diff --git a/Duplicati/CommandLine/ServerUtil/SettingsBinder.cs b/Duplicati/CommandLine/ServerUtil/SettingsBinder.cs index 537c095f9..848af32d3 100644 --- a/Duplicati/CommandLine/ServerUtil/SettingsBinder.cs +++ b/Duplicati/CommandLine/ServerUtil/SettingsBinder.cs @@ -11,7 +11,7 @@ public class SettingsBinder : BinderBase /// /// The password option. /// - public static readonly Option passwordOption = new Option("--password", description: "The password to use", getDefaultValue: () => null); + public static readonly Option passwordOption = new Option("--password", description: "The password to use for connecting to the server", getDefaultValue: () => null); /// /// The host URL option. ///