From d15e398e819075782ad85d8d94d8585c11344071 Mon Sep 17 00:00:00 2001 From: "Marcelo C." Date: Fri, 28 Mar 2025 16:08:45 -0300 Subject: [PATCH] Added machine-readable output for serverutil commands This comes from suggestion on issue https://github.com/duplicati/duplicati/issues/6056 The chosen argument was --json which produces a json that wraps the result with extended properties such as in example: `` { "Timestamp": "2025-03-28T15:47:01.3368160-03:00", "UnixTimestamp": 1743187621, "Command": "import", "Success": true, "ExitCode": 0, "Messages": [ "Importing backup configuration from ../2-firstbacku.json", "Connecting to http://127.0.0.1:8200/...", "No database found in../data/", "Imported \"firstbackup (5)\" with ID 8" ], "Exceptions": [], "Imported": { "Id": "8", "Name": "firstbackup (5)" } } `` The documentation will reflect all commands and schemas as this makes its way into Canary --- .../ServerUtil/CommandExtensions.cs | 1 + .../ServerUtil/Commands/ChangePassword.cs | 16 ++- .../CommandLine/ServerUtil/Commands/Export.cs | 37 +++--- .../CommandLine/ServerUtil/Commands/Health.cs | 22 ++-- .../CommandLine/ServerUtil/Commands/Import.cs | 16 ++- .../ServerUtil/Commands/IssueForeverToken.cs | 26 ++-- .../ServerUtil/Commands/ListBackups.cs | 28 +++-- .../CommandLine/ServerUtil/Commands/Login.cs | 11 +- .../CommandLine/ServerUtil/Commands/Logout.cs | 10 +- .../CommandLine/ServerUtil/Commands/Pause.cs | 17 +-- .../CommandLine/ServerUtil/Commands/Resume.cs | 10 +- .../ServerUtil/Commands/RunBackup.cs | 19 +-- .../ServerUtil/Commands/ServerStatus.cs | 36 ++++-- .../CommandLine/ServerUtil/Connection.cs | 63 ++++++---- .../ServerUtil/OutputInterceptor.cs | 118 ++++++++++++++++++ .../ServerUtil/OutputInterceptorBinder.cs | 62 +++++++++ Duplicati/CommandLine/ServerUtil/Program.cs | 34 +++-- Duplicati/CommandLine/ServerUtil/Settings.cs | 35 ++++-- .../CommandLine/ServerUtil/SettingsBinder.cs | 17 ++- 19 files changed, 437 insertions(+), 141 deletions(-) create mode 100644 Duplicati/CommandLine/ServerUtil/OutputInterceptor.cs create mode 100644 Duplicati/CommandLine/ServerUtil/OutputInterceptorBinder.cs diff --git a/Duplicati/CommandLine/ServerUtil/CommandExtensions.cs b/Duplicati/CommandLine/ServerUtil/CommandExtensions.cs index 07db65432..46a63e42d 100644 --- a/Duplicati/CommandLine/ServerUtil/CommandExtensions.cs +++ b/Duplicati/CommandLine/ServerUtil/CommandExtensions.cs @@ -18,6 +18,7 @@ // 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. + using System.CommandLine; using System.CommandLine.Invocation; diff --git a/Duplicati/CommandLine/ServerUtil/Commands/ChangePassword.cs b/Duplicati/CommandLine/ServerUtil/Commands/ChangePassword.cs index 4ac7bd71d..0d99b8277 100644 --- a/Duplicati/CommandLine/ServerUtil/Commands/ChangePassword.cs +++ b/Duplicati/CommandLine/ServerUtil/Commands/ChangePassword.cs @@ -18,9 +18,9 @@ // 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. + using System.CommandLine; using System.CommandLine.NamingConventionBinder; -using Duplicati.Library.Main; namespace Duplicati.CommandLine.ServerUtil.Commands; @@ -31,26 +31,32 @@ public static class ChangePassword { new Argument("new-password", "The new password to use") { Arity = ArgumentArity.ZeroOrOne - }, + } } - .WithHandler(CommandHandler.Create(async (settings, newPassword) => + .WithHandler(CommandHandler.Create(async (settings, output, newPassword) => { // Ask for previous password first, if needed - var connection = await settings.GetConnection(); + var connection = await settings.GetConnection(output); if (string.IsNullOrWhiteSpace(newPassword)) + { + if (output.JsonOutputMode) + throw new UserReportedException("No password provided with json mode."); + newPassword = HelperMethods.ReadPasswordFromConsole("Please provide the new password: "); + } if (string.IsNullOrWhiteSpace(newPassword)) throw new UserReportedException("No password provided"); if (settings.SecretProvider != null) { - var opts = new Dictionary() { { "password", newPassword } }; + var opts = new Dictionary { { "password", newPassword } }; await settings.ReplaceSecrets(opts).ConfigureAwait(false); newPassword = opts["password"]!; } await connection.ChangePassword(newPassword); + output.SetResult(true); })); } diff --git a/Duplicati/CommandLine/ServerUtil/Commands/Export.cs b/Duplicati/CommandLine/ServerUtil/Commands/Export.cs index 5c8dd0e0f..c6c618d99 100644 --- a/Duplicati/CommandLine/ServerUtil/Commands/Export.cs +++ b/Duplicati/CommandLine/ServerUtil/Commands/Export.cs @@ -18,6 +18,7 @@ // 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. + using System.CommandLine; using System.CommandLine.NamingConventionBinder; @@ -36,17 +37,17 @@ public static class Export 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())), + 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) => + .WithHandler(CommandHandler.Create(async (settings, output, backups, encryptionPassphrase, exportPasswords, overwrite, unencrypted, destination) => { if (!destination.Exists) { - Console.WriteLine($"Creating destination folder {destination.FullName}"); + output.AppendConsoleMessage($"Creating destination folder {destination.FullName}"); destination.Create(); } - var connection = await settings.GetConnection(); + var connection = await settings.GetConnection(output); 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(); @@ -60,18 +61,20 @@ public static class Export if (!exportPasswords.HasValue) { - Console.WriteLine("The --export-passwords flag is not set, sensitive keys will not be included in the exported file"); + output.AppendConsoleMessage("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"); + output.AppendConsoleMessage("Warning: Exporting unencrypted configurations with sensitive keys included"); } } else { if (string.IsNullOrWhiteSpace(encryptionPassphrase)) { + if (output.JsonOutputMode) + throw new UserReportedException("No passphrase provided in json mode, cannot proceed"); 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"); @@ -79,17 +82,18 @@ public static class Export if (settings.SecretProvider != null) { - var opts = new Dictionary() { { "password", encryptionPassphrase } }; + var opts = new Dictionary { { "password", encryptionPassphrase } }; await settings.ReplaceSecrets(opts).ConfigureAwait(false); encryptionPassphrase = opts["password"]!; } - if (!exportPasswords.HasValue) - exportPasswords = true; + exportPasswords ??= true; } - Console.WriteLine($"Exporting {targetbackups.Length} backup{(targetbackups.Length == 1 ? "" : "s")} to {destination.FullName}"); + output.AppendConsoleMessage($"Exporting {targetbackups.Length} backup{(targetbackups.Length == 1 ? "" : "s")} to {destination.FullName}"); + List exportedBackups = []; + foreach (var backup in targetbackups) { var name = backup.Name; @@ -99,15 +103,18 @@ public static class Export 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"); + output.AppendConsoleMessage($"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 using (var s = await connection.ExportBackup(backup.ID, encryptionPassphrase, exportPasswords.Value)) + await using (var fs = file.Create()) await s.CopyToAsync(fs); - - Console.WriteLine($"- Exported to {file.Name}"); + exportedBackups.Add(new { Id = backup.ID, Name = backup.Name, File = file.FullName }); + output.AppendConsoleMessage($"- Exported to {file.Name}"); } + output.AppendCustomObject("ExportedBackups", exportedBackups); + output.SetResult(true); + })); } diff --git a/Duplicati/CommandLine/ServerUtil/Commands/Health.cs b/Duplicati/CommandLine/ServerUtil/Commands/Health.cs index abea401d1..4e8af0364 100644 --- a/Duplicati/CommandLine/ServerUtil/Commands/Health.cs +++ b/Duplicati/CommandLine/ServerUtil/Commands/Health.cs @@ -18,6 +18,7 @@ // 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. + using System.CommandLine; using System.CommandLine.NamingConventionBinder; @@ -27,30 +28,31 @@ public static class Health { public static Command Create() => new Command("health", "Checks the server health endpoint") - .WithHandler(CommandHandler.Create(async (settings) => + .WithHandler(CommandHandler.Create(async (settings, output) => { - using var client = new HttpClient(new HttpClientHandler() + using var client = new HttpClient(new HttpClientHandler { ServerCertificateCustomValidationCallback = settings.Insecure ? HttpClientHandler.DangerousAcceptAnyServerCertificateValidator : null - }) - { - BaseAddress = settings.HostUrl, - Timeout = TimeSpan.FromSeconds(10) - }; + }); + client.BaseAddress = settings.HostUrl; + client.Timeout = TimeSpan.FromSeconds(10); try { var response = await client.GetAsync("health"); response.EnsureSuccessStatusCode(); - - Console.WriteLine("Server is healthy"); + output.SetResult(true); + output.AppendCustomObject("healthy", true); + output.AppendConsoleMessage("Server is healthy"); return 0; } catch (HttpRequestException) { - Console.WriteLine("Server is unhealthy"); + output.AppendConsoleMessage("Server is unhealthy"); + output.AppendCustomObject("healthy", false); + output.SetResult(false); return 1; } }) diff --git a/Duplicati/CommandLine/ServerUtil/Commands/Import.cs b/Duplicati/CommandLine/ServerUtil/Commands/Import.cs index dfdf36eb2..0206e2e0d 100644 --- a/Duplicati/CommandLine/ServerUtil/Commands/Import.cs +++ b/Duplicati/CommandLine/ServerUtil/Commands/Import.cs @@ -18,6 +18,7 @@ // 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. + using System.CommandLine; using System.CommandLine.NamingConventionBinder; @@ -36,14 +37,17 @@ public static class Import }, new Option(name: "--import-metadata", description: "Import metadata from the backup", getDefaultValue: () => false) } - .WithHandler(CommandHandler.Create(async (settings, file, passphrase, importMetadata) => + .WithHandler(CommandHandler.Create(async (settings, output, file, passphrase, importMetadata) => { if (!file.Exists) throw new UserReportedException($"File {file.FullName} does not exist"); - Console.WriteLine($"Importing backup configuration from {file.FullName}"); + output.AppendConsoleMessage($"Importing backup configuration from {file.FullName}"); if (IsEncrypted(file)) { + if (output.JsonOutputMode) + throw new UserReportedException("No password provided with json mode."); + if (string.IsNullOrWhiteSpace(passphrase)) passphrase = HelperMethods.ReadPasswordFromConsole("The file is encrypted. Please provide the encryption password: "); @@ -52,16 +56,18 @@ public static class Import if (settings.SecretProvider != null) { - var opts = new Dictionary() { { "password", passphrase } }; + var opts = new Dictionary { { "password", passphrase } }; await settings.ReplaceSecrets(opts).ConfigureAwait(false); passphrase = opts["password"]!; } } - var connection = await settings.GetConnection(); + var connection = await settings.GetConnection(output); var result = await connection.ImportBackup(file.FullName, passphrase, importMetadata); - Console.WriteLine($"Imported \"{result.Name}\" with ID {result.ID}"); + output.AppendConsoleMessage($"Imported \"{result.Name}\" with ID {result.ID}"); + output.AppendCustomObject( "Imported",new {Id = result.ID, Name = result.Name}); + output.SetResult(true); })); private static bool IsEncrypted(FileInfo file) diff --git a/Duplicati/CommandLine/ServerUtil/Commands/IssueForeverToken.cs b/Duplicati/CommandLine/ServerUtil/Commands/IssueForeverToken.cs index 60606369a..87ab595ee 100644 --- a/Duplicati/CommandLine/ServerUtil/Commands/IssueForeverToken.cs +++ b/Duplicati/CommandLine/ServerUtil/Commands/IssueForeverToken.cs @@ -18,6 +18,7 @@ // 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. + using System.CommandLine; using System.CommandLine.NamingConventionBinder; @@ -26,19 +27,18 @@ namespace Duplicati.CommandLine.ServerUtil.Commands; public static class IssueForeverToken { public static Command Create() => - new Command("issue-forever-token", "Issues a long-lived access token") + new Command("issue-forever-token", "Issues a long-lived access token").WithHandler(CommandHandler.Create(async (settings, output) => { - } - .WithHandler(CommandHandler.Create(async (settings) => - { - var token = await (await settings.GetConnection()).CreateForeverToken(); - Console.WriteLine("Token issued with a lifetime of 10 years."); - Console.WriteLine("Make sure you disable the forever token API on the server, to avoid generating new tokens."); - Console.WriteLine(); - Console.WriteLine($"If you need to revoke the token, you can reset the JWT signing keys by restarting the server with the command '--{"reset-jwt-config"}=true', or the environment variable '{"DUPLICATI__RESET_JWT_CONFIG=true"}'."); - Console.WriteLine(); - Console.WriteLine("The issued token is:"); - Console.WriteLine($"Authorization: Bearer {token}"); - Console.WriteLine(); + var token = await (await settings.GetConnection(output)).CreateForeverToken(); + output.AppendConsoleMessage("Token issued with a lifetime of 10 years."); + output.AppendConsoleMessage("Make sure you disable the forever token API on the server, to avoid generating new tokens."); + output.AppendConsoleMessage(string.Empty); + output.AppendConsoleMessage($"If you need to revoke the token, you can reset the JWT signing keys by restarting the server with the command '--{"reset-jwt-config"}=true', or the environment variable '{"DUPLICATI__RESET_JWT_CONFIG=true"}'."); + output.AppendConsoleMessage(string.Empty); + output.AppendConsoleMessage("The issued token is:"); + output.AppendConsoleMessage($"Authorization: Bearer {token}"); + output.AppendConsoleMessage(string.Empty); + output.AppendCustomObject( "Token" ,new { Token = token });; + output.SetResult(true); })); } diff --git a/Duplicati/CommandLine/ServerUtil/Commands/ListBackups.cs b/Duplicati/CommandLine/ServerUtil/Commands/ListBackups.cs index e45bda8e6..225b11d94 100644 --- a/Duplicati/CommandLine/ServerUtil/Commands/ListBackups.cs +++ b/Duplicati/CommandLine/ServerUtil/Commands/ListBackups.cs @@ -18,6 +18,7 @@ // 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. + using System.CommandLine; using System.CommandLine.NamingConventionBinder; @@ -27,22 +28,25 @@ public static class ListBackups { public static Command Create() => new Command("list-backups", "List all backups") - .WithHandler(CommandHandler.Create(async (settings) => + .WithHandler(CommandHandler.Create(async (settings, output) => { - var bks = await (await settings.GetConnection()).ListBackups(); + var bks = await (await settings.GetConnection(output)).ListBackups(); - if (!bks.Any()) + var backupEntries = bks as Connection.BackupEntry[] ?? bks.ToArray(); + if (backupEntries.Any()) { - Console.WriteLine("No backups found"); - return; + foreach (var bk in backupEntries) + { + output.AppendConsoleMessage($"{bk.ID}: {bk.Name}"); + if (!string.IsNullOrEmpty(bk.Description)) + output.AppendConsoleMessage($" {bk.Description}"); + output.AppendConsoleMessage(string.Empty); + } + output.AppendCustomObject("Backups", backupEntries.Select(id => new { Id = id.ID, Name = id.Name }).ToArray()); } + else + output.AppendConsoleMessage("No backups found"); - foreach (var bk in bks) - { - Console.WriteLine($"{bk.ID}: {bk.Name}"); - if (!string.IsNullOrEmpty(bk.Description)) - Console.WriteLine($" {bk.Description}"); - Console.WriteLine(); - } + output.SetResult(true); })); } diff --git a/Duplicati/CommandLine/ServerUtil/Commands/Login.cs b/Duplicati/CommandLine/ServerUtil/Commands/Login.cs index 0241de8c8..a03dcfa5e 100644 --- a/Duplicati/CommandLine/ServerUtil/Commands/Login.cs +++ b/Duplicati/CommandLine/ServerUtil/Commands/Login.cs @@ -18,6 +18,7 @@ // 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. + using System.CommandLine; using System.CommandLine.NamingConventionBinder; @@ -27,12 +28,12 @@ public static class Login { public static Command Create() => new Command("login", "Logs in to the server") - .WithHandler(CommandHandler.Create(async (settings) => + .WithHandler(CommandHandler.Create(async (settings, output) => { - Console.WriteLine("Logging in to the server"); - await Connection.Connect(settings, true); - - Console.WriteLine("Logged in, persistent token saved"); + output.AppendConsoleMessage("Logging in to the server"); + await Connection.Connect(settings, true, output); + output.AppendConsoleMessage("Logged in, persistent token saved"); + output.SetResult(true); }) ); } diff --git a/Duplicati/CommandLine/ServerUtil/Commands/Logout.cs b/Duplicati/CommandLine/ServerUtil/Commands/Logout.cs index 4b189996e..8e1b6f046 100644 --- a/Duplicati/CommandLine/ServerUtil/Commands/Logout.cs +++ b/Duplicati/CommandLine/ServerUtil/Commands/Logout.cs @@ -18,6 +18,7 @@ // 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. + using System.CommandLine; using System.CommandLine.NamingConventionBinder; @@ -27,7 +28,12 @@ public static class Logout { public static Command Create() => new Command("logout", "Logs out of the server") - .WithHandler(CommandHandler.Create(async (settings) => - await (await settings.GetConnection()).Logout(settings)) + .WithHandler(CommandHandler.Create(async (settings, output) => + { + output.AppendConsoleMessage("Logging out of the server..."); + await (await settings.GetConnection(output)).Logout(settings, output); + // If no exception we presume success + output.SetResult(true); + }) ); } diff --git a/Duplicati/CommandLine/ServerUtil/Commands/Pause.cs b/Duplicati/CommandLine/ServerUtil/Commands/Pause.cs index a194cba9f..1d7c51af3 100644 --- a/Duplicati/CommandLine/ServerUtil/Commands/Pause.cs +++ b/Duplicati/CommandLine/ServerUtil/Commands/Pause.cs @@ -18,6 +18,7 @@ // 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. + using System.CommandLine; using System.CommandLine.NamingConventionBinder; @@ -30,15 +31,17 @@ public static class Pause { new Argument("duration", description: "The duration to pause the server for", getDefaultValue: () => null) { Arity = ArgumentArity.ZeroOrOne - }, + } } - .WithHandler(CommandHandler.Create(async (settings, duration) => + .WithHandler(CommandHandler.Create(async (settings, output, duration) => { - if (string.IsNullOrWhiteSpace(duration)) - Console.WriteLine("Pausing the server indefinitely"); - else - Console.WriteLine($"Pausing the server for {duration}"); + output.AppendConsoleMessage(string.IsNullOrWhiteSpace(duration) + ? "Pausing the server indefinitely" + : $"Pausing the server for {duration}"); - await (await settings.GetConnection()).Pause(duration); + await (await settings.GetConnection(output)).Pause(duration); + + // If no exception we presume success + output.SetResult(true); })); } diff --git a/Duplicati/CommandLine/ServerUtil/Commands/Resume.cs b/Duplicati/CommandLine/ServerUtil/Commands/Resume.cs index 81747dabb..2e28b7cb8 100644 --- a/Duplicati/CommandLine/ServerUtil/Commands/Resume.cs +++ b/Duplicati/CommandLine/ServerUtil/Commands/Resume.cs @@ -18,6 +18,7 @@ // 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. + using System.CommandLine; using System.CommandLine.NamingConventionBinder; @@ -27,7 +28,10 @@ public static class Resume { public static Command Create() => new Command("resume", "Resumes the server") - .WithHandler(CommandHandler.Create(async (settings) => - await (await settings.GetConnection()).Resume()) - ); + .WithHandler(CommandHandler.Create(async (settings, output) => + { + output.AppendConsoleMessage("Resuming the server..."); + await (await settings.GetConnection(output)).Resume(); + output.SetResult(true); + })); } diff --git a/Duplicati/CommandLine/ServerUtil/Commands/RunBackup.cs b/Duplicati/CommandLine/ServerUtil/Commands/RunBackup.cs index e6dfcabe7..70fc00fa9 100644 --- a/Duplicati/CommandLine/ServerUtil/Commands/RunBackup.cs +++ b/Duplicati/CommandLine/ServerUtil/Commands/RunBackup.cs @@ -18,6 +18,7 @@ // 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. + using System.CommandLine; using System.CommandLine.NamingConventionBinder; @@ -35,18 +36,18 @@ public static class RunBackup IsRequired = false }, new Option("--poll-interval", description: "The interval in seconds to poll for backup status", getDefaultValue: () => 5) { - IsRequired = false, + IsRequired = false }, new Option("--quiet", "Do not print progress messages") { IsRequired = false } } - .WithHandler(CommandHandler.Create(async (settings, backup, wait, pollinterval, quiet) => + .WithHandler(CommandHandler.Create(async (settings, output, backup, wait, pollinterval, quiet) => { if (pollinterval < 1) throw new UserReportedException("Poll interval must be at least 1 second"); - var connection = await settings.GetConnection(); + var connection = await settings.GetConnection(output); var matchingBackup = (await connection.ListBackups()) .FirstOrDefault(b => string.Equals(b.Name, backup, StringComparison.OrdinalIgnoreCase) || string.Equals(b.ID, backup)); @@ -55,21 +56,23 @@ public static class RunBackup throw new UserReportedException("No backup found with supplied ID or name"); if (!quiet) - Console.WriteLine($"Running backup {matchingBackup.Name} (ID: {matchingBackup.ID})"); + output.AppendConsoleMessage($"Running backup {matchingBackup.Name} (ID: {matchingBackup.ID})"); + await connection.RunBackup(matchingBackup.ID); if (wait) { if (!quiet) - Console.WriteLine("Waiting for backup to finish..."); - await connection.WaitForBackup(matchingBackup.ID, TimeSpan.FromSeconds(pollinterval), (msg) => + output.AppendConsoleMessage("Waiting for backup to finish..."); + await connection.WaitForBackup(matchingBackup.ID, TimeSpan.FromSeconds(pollinterval), msg => { if (!quiet) - Console.WriteLine($"[{DateTime.Now}]: {msg}"); + output.AppendConsoleMessage($"[{DateTime.Now}]: {msg}"); }); if (!quiet) - Console.WriteLine("Backup finished"); + output.AppendConsoleMessage("Backup finished"); } + output.SetResult(true); })); } diff --git a/Duplicati/CommandLine/ServerUtil/Commands/ServerStatus.cs b/Duplicati/CommandLine/ServerUtil/Commands/ServerStatus.cs index 95d7df210..8bd568642 100644 --- a/Duplicati/CommandLine/ServerUtil/Commands/ServerStatus.cs +++ b/Duplicati/CommandLine/ServerUtil/Commands/ServerStatus.cs @@ -18,6 +18,7 @@ // 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. + using System.CommandLine; using System.CommandLine.NamingConventionBinder; @@ -27,23 +28,38 @@ public static class ServerStatus { public static Command Create() => new Command("status", "Gets the server status") - .WithHandler(CommandHandler.Create(async (settings) => + .WithHandler(CommandHandler.Create(async (settings, output) => { - var state = await (await settings.GetConnection()).GetServerState(); - Console.WriteLine($"Server state: {state.ProgramState}"); + var state = await (await settings.GetConnection(output)).GetServerState(); + + output.AppendConsoleMessage($"Server state: {state.ProgramState}"); + output.AppendCustomObject("ServerState", state.ProgramState); + if (state.ActiveTask != null) - Console.WriteLine($"Active task: [Task {state.ActiveTask.Item1}]: BackupId = {state.ActiveTask.Item2}"); + { + output.AppendConsoleMessage( + $"Active task: [Task {state.ActiveTask.Item1}]: BackupId = {state.ActiveTask.Item2}"); + output.AppendCustomObject("ActiveTask", + new { Task = state.ActiveTask.Item1, BackupId = state.ActiveTask.Item2 }); + } else - Console.WriteLine("Active task: None"); + { + output.AppendConsoleMessage("Active task: None"); + output.AppendCustomObject("ActiveTask", null); + } if (state.SchedulerQueueIds.Any()) { - Console.WriteLine("Scheduled tasks:"); - foreach (var id in state.SchedulerQueueIds) - Console.WriteLine($" [Task {id.Item1}]: BackupId = {id.Item2}"); + output.AppendConsoleMessage("Scheduled tasks:"); + foreach (var (taskId, backupId) in state.SchedulerQueueIds) output.AppendConsoleMessage($" [Task {taskId}]: BackupId = {backupId}"); + output.AppendCustomObject("SchedulerTasks", state.SchedulerQueueIds.Select(id => new { Task = id.Item1, BackupId = id.Item2 }).ToArray()); } else - Console.WriteLine("Scheduler tasks: Empty"); + { + output.AppendConsoleMessage("Scheduler tasks: Empty"); + output.AppendCustomObject("SchedulerTasks", null); + } + + output.SetResult(true); })); - } diff --git a/Duplicati/CommandLine/ServerUtil/Connection.cs b/Duplicati/CommandLine/ServerUtil/Connection.cs index 92d05aee6..1760de385 100644 --- a/Duplicati/CommandLine/ServerUtil/Connection.cs +++ b/Duplicati/CommandLine/ServerUtil/Connection.cs @@ -18,10 +18,12 @@ // 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. + using System.Net.Http.Json; using System.Net.Security; using System.Text.Json; using Duplicati.Library.AutoUpdater; +using Duplicati.WebserverCore.Middlewares; namespace Duplicati.CommandLine.ServerUtil; @@ -135,17 +137,22 @@ public class Connection /// /// The settings to use for the connection /// Whether to obtain a refresh token + /// Console messages interceptor /// The connection - public static async Task Connect(Settings settings, bool obtainRefreshToken = false) + public static async Task Connect(Settings settings, bool obtainRefreshToken = false, OutputInterceptor? console = null) { - Console.WriteLine($"Connecting to {settings.HostUrl}..."); + + if (console != null) + console.AppendConsoleMessage($"Connecting to {settings.HostUrl}..."); + else + Console.WriteLine($"Connecting to {settings.HostUrl}..."); var trustedCertificateHashes = new HashSet(StringComparer.OrdinalIgnoreCase); if (!string.IsNullOrWhiteSpace(settings.AcceptedHostCertificate)) - trustedCertificateHashes.UnionWith(settings.AcceptedHostCertificate.Split(new char[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + trustedCertificateHashes.UnionWith(settings.AcceptedHostCertificate.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); // Configure the client for requests - var client = new HttpClient(new HttpClientHandler() + var client = new HttpClient(new HttpClientHandler { ServerCertificateCustomValidationCallback = settings.Insecure || trustedCertificateHashes.Contains("*") ? HttpClientHandler.DangerousAcceptAnyServerCertificateValidator @@ -156,7 +163,7 @@ public class Connection if (cert == null) return false; return trustedCertificateHashes.Contains(cert.GetCertHashString()); - }), + }) }) { BaseAddress = new Uri(settings.HostUrl + "api/v1/") @@ -173,13 +180,16 @@ public class Connection if (string.IsNullOrWhiteSpace(refreshToken)) throw new InvalidOperationException("Failed to get refresh token"); - (settings with { RefreshToken = refreshToken }).Save(); + (settings with { RefreshToken = refreshToken }).Save(console); return CreateConnectionWithClient(client, accessToken); } } catch (Exception ex) { - Console.WriteLine($"Failed to use refresh token: {ex.Message}"); + if (console != null) + console.AppendExceptionMessage($"Failed to use refresh token: {ex.Message}"); + else + Console.WriteLine($"Failed to use refresh token: {ex.Message}"); } // If we can read the server database, try to create a signin token @@ -191,7 +201,7 @@ public class Connection if (File.Exists(Path.Combine(DataFolderManager.DATAFOLDER, DataFolderManager.SERVER_DATABASE_FILENAME))) { string? cfg = null; - using (var connection = Duplicati.Server.Program.GetDatabaseConnection(opts, true)) + using (var connection = Server.Program.GetDatabaseConnection(opts, true)) { cfg = connection.ApplicationSettings.JWTConfig; if (settings.HostUrl.Scheme == "https" && connection.ApplicationSettings.ServerSSLCertificate != null && trustedCertificateHashes.Count == 0) @@ -204,8 +214,8 @@ public class Connection if (!string.IsNullOrWhiteSpace(cfg)) { - var signinjwt = new WebserverCore.Middlewares.JWTTokenProvider( - JsonSerializer.Deserialize(cfg) + var signinjwt = new JWTTokenProvider( + JsonSerializer.Deserialize(cfg) ?? throw new InvalidOperationException("Failed to deserialize JWTConfig") ).CreateSigninToken("server-cli"); @@ -215,19 +225,25 @@ public class Connection throw new InvalidOperationException("Failed to get access token"); if (!string.IsNullOrWhiteSpace(refreshToken)) - (settings with { RefreshToken = refreshToken }).Save(); + (settings with { RefreshToken = refreshToken }).Save(console); return CreateConnectionWithClient(client, accessToken); } } else if (!string.IsNullOrWhiteSpace(DataFolderManager.DATAFOLDER)) { - Console.WriteLine($"No database found in {DataFolderManager.DATAFOLDER}"); + if (console != null) + console.AppendConsoleMessage($"No database found in {DataFolderManager.DATAFOLDER}"); + else + Console.WriteLine($"No database found in {DataFolderManager.DATAFOLDER}"); } } catch (Exception ex) { - Console.WriteLine($"Failed to obtain a signin token: {ex.Message}"); + 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 @@ -245,7 +261,7 @@ public class Connection throw new InvalidOperationException("Failed to get access token"); if (!string.IsNullOrWhiteSpace(refreshToken)) - (settings with { RefreshToken = refreshToken }).Save(); + (settings with { RefreshToken = refreshToken }).Save(console); return CreateConnectionWithClient(client, accessToken); } @@ -277,7 +293,7 @@ public class Connection /// The access and refresh tokens private static Task<(string AccessToken, string? RefreshToken)> LoginWithPassword(HttpClient client, string password, bool obtainRefreshToken) => ParseAuthResponse( - client.PostAsync($"auth/login", JsonContent.Create(new { Password = password, RememberMe = obtainRefreshToken })) + client.PostAsync("auth/login", JsonContent.Create(new { Password = password, RememberMe = obtainRefreshToken })) ); /// @@ -289,7 +305,7 @@ public class Connection private static Task<(string AccessToken, string? RefreshToken)> LoginWithRefreshToken(HttpClient client, string refreshToken) => ParseAuthResponse(client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "auth/refresh") { - Headers = { { "Cookie", $"RefreshToken_{client.BaseAddress!.Port}={refreshToken}" } }, + Headers = { { "Cookie", $"RefreshToken_{client.BaseAddress!.Port}={refreshToken}" } } })); @@ -309,7 +325,7 @@ public class Connection throw new InvalidOperationException("Failed to get access token"); response.Headers.TryGetValues("Set-Cookie", out var cookies); - var refreshToken = cookies?.SelectMany(c => c.Split(';')).FirstOrDefault(c => c.StartsWith($"RefreshToken_"))?.Split('=', 2)[1]; + var refreshToken = cookies?.SelectMany(c => c.Split(';')).FirstOrDefault(c => c.StartsWith("RefreshToken_"))?.Split('=', 2)[1]; return (accessToken, refreshToken); } @@ -332,7 +348,7 @@ public class Connection /// The task public async Task Resume() { - var response = await client.PostAsync($"serverstate/resume", null); + var response = await client.PostAsync("serverstate/resume", null); await EnsureSuccessStatusCodeWithParsing(response); } @@ -383,7 +399,7 @@ public class Connection /// The server state public async Task GetServerState() { - var response = await client.GetAsync($"serverstate"); + var response = await client.GetAsync("serverstate"); await EnsureSuccessStatusCodeWithParsing(response); return await response.Content.ReadFromJsonAsync() ?? throw new InvalidDataException("Failed to parse server response"); @@ -451,7 +467,7 @@ public class Connection StopLevel.AfterCurrentFile => "stopaftercurrentfile", StopLevel.StopNow => "stopnow", StopLevel.Abort => "abort", - _ => throw new ArgumentOutOfRangeException(nameof(level)), + _ => throw new ArgumentOutOfRangeException(nameof(level)) }; var response = await client.PostAsync($"task/{Uri.EscapeDataString(taskId)}/{levelString}", null); await EnsureSuccessStatusCodeWithParsing(response); @@ -461,15 +477,16 @@ public class Connection /// Logs out of the server /// /// The settings to use + /// /// The task - public async Task Logout(Settings settings) + public async Task Logout(Settings settings, OutputInterceptor output) { var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "auth/refresh/logout") { Headers = { { "Cookie", $"RefreshToken_{client.BaseAddress!.Port}={settings.RefreshToken}" } } }); await EnsureSuccessStatusCodeWithParsing(response); - (settings with { RefreshToken = null }).Save(); + (settings with { RefreshToken = null }).Save(output); } /// @@ -539,7 +556,7 @@ public class Connection /// The token public async Task CreateForeverToken() { - var (accessToken, _) = await ParseAuthResponse(client.PostAsync($"auth/issue-forever-token", null)); + var (accessToken, _) = await ParseAuthResponse(client.PostAsync("auth/issue-forever-token", null)); return accessToken; } diff --git a/Duplicati/CommandLine/ServerUtil/OutputInterceptor.cs b/Duplicati/CommandLine/ServerUtil/OutputInterceptor.cs new file mode 100644 index 000000000..69edad11c --- /dev/null +++ b/Duplicati/CommandLine/ServerUtil/OutputInterceptor.cs @@ -0,0 +1,118 @@ +using System.CommandLine.Binding; +using System.Dynamic; +using Duplicati.Library.Backend; +using Newtonsoft.Json; + +namespace Duplicati.CommandLine.ServerUtil; + +/// +/// Manages console output, optionally serializing it to JSON. +/// +/// +/// This class captures command execution details, messages, and exceptions, providing flexibility to either output them to the console +/// or serialize them into a JSON format based on the parameter. +/// +public sealed class OutputInterceptor(bool jsonOutput, BindingContext bindingContext) +{ + private readonly DateTimeOffset _timestamp = DateTimeOffset.Now; + private readonly List _commandMessages = []; + private readonly List _exceptionMessages = []; + private string? _command; + private bool _success; + private readonly Dictionary _extendedProperties = []; + public bool JsonOutputMode { get; } = jsonOutput; + public int ExitCode { get; set; } + + /// + /// Gets the binding context associated with this interceptor. + /// + public BindingContext BindingContext { get; } = bindingContext ?? throw new ArgumentNullException(nameof(bindingContext)); + + /// + /// Sets the command string to be intercepted and tracked. + /// + /// The command string to set. Must not be null. + /// Thrown when is null. + public void SetCommand(string command) + { + _command = command ?? throw new ArgumentNullException(nameof(command)); + } + + /// + /// Sets the result of the command execution. + /// + /// A value indicating whether the ** business rule ** was successful. On exception by definition it will be false. + public void SetResult(bool success) + { + _success = success; + } + + /// + /// Appends an exception message to the interceptor. + /// + /// The exception message to append. Ignored if null or empty. + /// + /// If JSON output is enabled, the message is stored in a list; otherwise, it is written to the console. + /// + public void AppendExceptionMessage(string? message) + { + if (string.IsNullOrEmpty(message)) return; + + if (JsonOutputMode) + { + _exceptionMessages.Add(message); + } + else + { + Console.WriteLine(message); + } + } + + public void AppendCustomObject(string keyName, object? customObject) + { + _extendedProperties.Add(keyName, customObject); + } + + /// + /// Appends a console message to the interceptor. + /// + /// The console message to append. Ignored if null or empty. + /// + /// If JSON output is enabled, the message is stored in a list; otherwise, it is written to the console. + /// + public void AppendConsoleMessage(string? message) + { + if (message == null) return; + + if (JsonOutputMode && !string.IsNullOrEmpty(message)) + _commandMessages.Add(message); + else + Console.WriteLine(message); + } + + /// + /// Serializes the intercepted data into a JSON string if JSON output is enabled. + /// + /// + /// A JSON string containing the intercepted data, or null if JSON output is disabled. + /// + /// + /// The serialized result includes the timestamp, command, success status, messages, and exceptions in a structured format. + /// + public string? GetSerializedResult() + { + if (!JsonOutputMode) return null; + + dynamic result = new ExpandoObject(); + result.Timestamp = _timestamp.ToString("O"); + result.UnixTimestamp = _timestamp.ToUnixTimeSeconds(); + result.Command = _command; + result.Success = _success; + result.ExitCode = ExitCode; + result.Messages = _commandMessages.AsReadOnly(); + result.Exceptions = _exceptionMessages.AsReadOnly(); + foreach (var kvp in _extendedProperties) ((IDictionary)result)[kvp.Key] = kvp.Value ?? string.Empty; + + return JsonConvert.SerializeObject(result, Formatting.Indented); + } +} \ No newline at end of file diff --git a/Duplicati/CommandLine/ServerUtil/OutputInterceptorBinder.cs b/Duplicati/CommandLine/ServerUtil/OutputInterceptorBinder.cs new file mode 100644 index 000000000..96d0aced7 --- /dev/null +++ b/Duplicati/CommandLine/ServerUtil/OutputInterceptorBinder.cs @@ -0,0 +1,62 @@ +using System.CommandLine.Binding; +using System.CommandLine.Parsing; + +namespace Duplicati.CommandLine.ServerUtil; + +/// +/// An abstract binder class for managing a singleton instance of . +/// +/// +/// This class ensures that only one instance of is associated with a given . +/// +public abstract class OutputInterceptorBinder : BinderBase +{ + private static OutputInterceptor? _instance; + + /// + /// Gets the current instance of . + /// + /// Thrown when the instance has not been initialized. + public static OutputInterceptor? Instance => _instance; + + /// + /// Retrieves or creates a instance for the specified binding context. + /// + /// The binding context to associate with the interceptor. Must not be null. + /// The existing or newly created instance. + /// Thrown when is null. + public static OutputInterceptor GetConsoleInterceptor(BindingContext bindingContext) + { + ArgumentNullException.ThrowIfNull(bindingContext, nameof(bindingContext)); + + if (_instance is not null && ReferenceEquals(_instance.BindingContext, bindingContext)) + { + return _instance; + } + + _instance = CreateInterceptor(bindingContext); + return _instance; + } + + /// + /// Gets the bound value for the specified binding context. + /// + /// The binding context to retrieve the interceptor for. + /// The associated instance. + protected override OutputInterceptor GetBoundValue(BindingContext bindingContext) + { + return GetConsoleInterceptor(bindingContext); + } + + /// + /// Creates a new instance with the specified binding context. + /// + /// The binding context to initialize the interceptor with. + /// A new instance. + private static OutputInterceptor CreateInterceptor(BindingContext bindingContext) + { + var interceptor = new OutputInterceptor(bindingContext.ParseResult.Tokens.Any(x => x is { Type: TokenType.Option, Value: "--json" }), bindingContext); + interceptor.SetCommand(bindingContext.ParseResult.CommandResult.Command.Name); + return interceptor; + } +} \ No newline at end of file diff --git a/Duplicati/CommandLine/ServerUtil/Program.cs b/Duplicati/CommandLine/ServerUtil/Program.cs index 2ba212806..c879e228f 100644 --- a/Duplicati/CommandLine/ServerUtil/Program.cs +++ b/Duplicati/CommandLine/ServerUtil/Program.cs @@ -18,10 +18,12 @@ // 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. + using System.CommandLine; using System.CommandLine.Builder; using System.CommandLine.Parsing; using Duplicati.CommandLine.ServerUtil.Commands; +using Duplicati.Library.AutoUpdater; using Duplicati.Library.Utility; namespace Duplicati.CommandLine.ServerUtil; @@ -36,9 +38,9 @@ public static class Program /// /// /// The return code - public static Task Main(string[] args) + public static async Task Main(string[] args) { - Library.AutoUpdater.PreloadSettingsLoader.ConfigurePreloadSettings(ref args, Library.AutoUpdater.PackageHelper.NamedExecutable.ServerUtil); + PreloadSettingsLoader.ConfigurePreloadSettings(ref args, PackageHelper.NamedExecutable.ServerUtil); var rootCmd = new RootCommand("Server CLI tool for Duplicati") { @@ -53,36 +55,46 @@ public static class Program Export.Create(), Health.Create(), IssueForeverToken.Create(), - ServerStatus.Create(), + ServerStatus.Create() }; rootCmd = SettingsBinder.AddGlobalOptions(rootCmd); - return new CommandLineBuilder(rootCmd) + return await new CommandLineBuilder(rootCmd) .UseDefaults() .UseExceptionHandler((ex, context) => { + OutputInterceptorBinder.Instance?.SetResult(false); + if (ex is UserReportedException ure) { - Console.WriteLine(ure.Message); + OutputInterceptorBinder.Instance?.AppendExceptionMessage(ure.Message); context.ExitCode = 2; } else { - Console.WriteLine(ex.ToString()); + OutputInterceptorBinder.Instance?.AppendExceptionMessage(ex.ToString()); context.ExitCode = 1; } + + if (OutputInterceptorBinder.Instance != null) + { + OutputInterceptorBinder.Instance.ExitCode = context.ExitCode; + Console.WriteLine(OutputInterceptorBinder.Instance.GetSerializedResult()); + } }) .AddMiddleware(async (context, next) => { // Inject settings with custom binder - if (context.ParseResult.CommandResult?.Command is Command cmd) + if (context.ParseResult.CommandResult.Command is { } cmd) context.BindingContext.AddService(_ => SettingsBinder.GetSettings(context.BindingContext)); - + + context.BindingContext.AddService(_ => OutputInterceptorBinder.GetConsoleInterceptor(context.BindingContext)); + await next(context); + Console.WriteLine(OutputInterceptorBinder.Instance?.GetSerializedResult()); }) .UseAdditionalHelpAliases() - .Build() - .InvokeAsync(args); + .Build().InvokeAsync(args); } -} +} \ No newline at end of file diff --git a/Duplicati/CommandLine/ServerUtil/Settings.cs b/Duplicati/CommandLine/ServerUtil/Settings.cs index 1771f524c..971b517e8 100644 --- a/Duplicati/CommandLine/ServerUtil/Settings.cs +++ b/Duplicati/CommandLine/ServerUtil/Settings.cs @@ -18,6 +18,7 @@ // 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. + using System.Text.Json; using Duplicati.Library.AutoUpdater; using Duplicati.Library.DynamicLoader; @@ -25,7 +26,8 @@ using Duplicati.Library.Encryption; using Duplicati.Library.Interface; using Duplicati.Library.Main; using Duplicati.Library.Utility; -using Utility = Duplicati.Library.Main.Utility; +using Uri = System.Uri; +using Utility = Duplicati.Library.Utility.Utility; namespace Duplicati.CommandLine.ServerUtil; @@ -44,7 +46,7 @@ namespace Duplicati.CommandLine.ServerUtil; public sealed record Settings( string? Password, string? RefreshToken, - System.Uri HostUrl, + Uri HostUrl, string SettingsFile, bool Insecure, EncryptedFieldHelper.KeyInstance? Key, @@ -61,7 +63,7 @@ public sealed record Settings( /// The server datafolder, if any private sealed record PersistedSettings( string? RefreshToken, - System.Uri HostUrl, + Uri HostUrl, string? ServerDatafolder ); @@ -87,9 +89,9 @@ public sealed record Settings( /// The secret provider pattern to use /// The SHA1 hash of the host certificate to accept /// The loaded settings - public static Settings Load(string? password, System.Uri? hostUrl, string settingsFile, bool insecure, string? settingsPassphrase, string? secretProvider, SecretProviderHelper.CachingLevel secretProviderCache, string secretProviderPattern, string? acceptedHostCertificate) + public static Settings Load(string? password, Uri? hostUrl, string settingsFile, bool insecure, string? settingsPassphrase, string? secretProvider, SecretProviderHelper.CachingLevel secretProviderCache, string secretProviderPattern, string? acceptedHostCertificate) { - hostUrl ??= new System.Uri($"http://{Library.Utility.Utility.IpVersionCompatibleLoopback}:8200"); + hostUrl ??= new Uri($"http://{Utility.IpVersionCompatibleLoopback}:8200"); ISecretProvider? secretInstance = null; if (!string.IsNullOrWhiteSpace(secretProvider)) @@ -107,7 +109,7 @@ public sealed record Settings( }; var args = new[] { hostUrl }; - secretInstance = SecretProviderHelper.ApplySecretProviderAsync(args, [], opts, Library.Utility.TempFolder.SystemTempPath, null, CancellationToken.None).Await(); + secretInstance = SecretProviderHelper.ApplySecretProviderAsync(args, [], opts, TempFolder.SystemTempPath, null, CancellationToken.None).Await(); // Read back transformed values hostUrl = args[0]; @@ -152,18 +154,24 @@ public sealed record Settings( /// /// Saves the settings to the settings file /// - public void Save() + public void Save(OutputInterceptor? output = null) { var thisKey = Key; if (!string.IsNullOrWhiteSpace(RefreshToken)) { if (Key == null) { - Console.WriteLine("Warning: The encryption key is missing, saving login token without encryption"); + if (output != null) + output.AppendConsoleMessage("Warning: The encryption key is missing, saving login token without encryption"); + else + Console.WriteLine("Warning: The encryption key is missing, saving login token without encryption"); } else if (Key?.IsBlacklisted ?? false) { - Console.WriteLine("Warning: The current encryption key is blacklisted and cannot be used, saving login token without encryption"); + if (output != null) + output.AppendConsoleMessage("Warning: The current encryption key is blacklisted and cannot be used, saving login token without encryption"); + else + Console.WriteLine("Warning: The current encryption key is blacklisted and cannot be used, saving login token without encryption"); thisKey = null; } @@ -189,6 +197,15 @@ public sealed record Settings( { return Connection.Connect(this); } + + /// + /// Gets a connection to the server + /// + /// The connection + public Task GetConnection(OutputInterceptor output) + { + return Connection.Connect(this, false, output); + } /// /// Loads the settings from the settings file diff --git a/Duplicati/CommandLine/ServerUtil/SettingsBinder.cs b/Duplicati/CommandLine/ServerUtil/SettingsBinder.cs index b97b7378b..b0e40d5d0 100644 --- a/Duplicati/CommandLine/ServerUtil/SettingsBinder.cs +++ b/Duplicati/CommandLine/ServerUtil/SettingsBinder.cs @@ -18,10 +18,14 @@ // 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. + using System.CommandLine; using System.CommandLine.Binding; using Duplicati.Library.AutoUpdater; +using Duplicati.Library.Encryption; using Duplicati.Library.Main; +using Uri = System.Uri; +using Utility = Duplicati.Library.Utility.Utility; namespace Duplicati.CommandLine.ServerUtil; @@ -37,7 +41,7 @@ public class SettingsBinder : BinderBase /// /// The host URL option. /// - public static readonly Option hostUrlOption = new Option("--hosturl", description: "The host URL to use", getDefaultValue: () => new Uri($"http://{Library.Utility.Utility.IpVersionCompatibleLoopback}:8200")); + public static readonly Option hostUrlOption = new Option("--hosturl", description: "The host URL to use", getDefaultValue: () => new Uri($"http://{Utility.IpVersionCompatibleLoopback}:8200")); /// /// The server datafolder option. /// @@ -59,7 +63,7 @@ public class SettingsBinder : BinderBase /// /// The settings encryption key option. /// - public static readonly Option settingsEncryptionKeyOption = new Option("--settings-encryption-key", description: $"The encryption key to use for the settings file. Can also be supplied with environment variable {Library.Encryption.EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME}", getDefaultValue: () => null); + public static readonly Option settingsEncryptionKeyOption = new Option("--settings-encryption-key", description: $"The encryption key to use for the settings file. Can also be supplied with environment variable {EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME}", getDefaultValue: () => null); /// /// The secret provider option. @@ -79,6 +83,12 @@ public class SettingsBinder : BinderBase /// public static readonly Option acceptedHostCertificateOption = new Option("--host-cert", description: "The SHA1 hash of the host certificate to accept. Use * for any certificate, same as --insecure (dangerous)", getDefaultValue: () => string.Empty); + /// + /// Option to wrap stdout as a json. + /// + public static readonly Option jsonOutputOption = + new Option("--json", description: "Wraps stdout as a json", getDefaultValue: () => false); + /// /// Adds global options to the root command. /// @@ -97,6 +107,7 @@ public class SettingsBinder : BinderBase rootCommand.AddGlobalOption(secretProviderCacheOption); rootCommand.AddGlobalOption(secretProviderPatternOption); rootCommand.AddGlobalOption(acceptedHostCertificateOption); + rootCommand.AddGlobalOption(jsonOutputOption); return rootCommand; } @@ -111,7 +122,7 @@ public class SettingsBinder : BinderBase bindingContext.ParseResult.GetValueForOption(hostUrlOption), bindingContext.ParseResult.GetValueForOption(settingsFileOption)?.FullName ?? "settings.json", bindingContext.ParseResult.GetValueForOption(insecureOption), - bindingContext.ParseResult.GetValueForOption(settingsEncryptionKeyOption) ?? Environment.GetEnvironmentVariable(Library.Encryption.EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME), + bindingContext.ParseResult.GetValueForOption(settingsEncryptionKeyOption) ?? Environment.GetEnvironmentVariable(EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME), bindingContext.ParseResult.GetValueForOption(secretProviderOption), bindingContext.ParseResult.GetValueForOption(secretProviderCacheOption), bindingContext.ParseResult.GetValueForOption(secretProviderPatternOption) ?? SecretProviderHelper.DEFAULT_PATTERN,