Merge pull request #6109 from marceloduplicati/feature/serverutil-machine-readable

Added machine-readable output for serverutil commands
This commit is contained in:
marceloduplicati
2025-03-31 10:01:40 -03:00
committed by GitHub
19 changed files with 437 additions and 141 deletions
@@ -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;
@@ -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<string>("new-password", "The new password to use") {
Arity = ArgumentArity.ZeroOrOne
},
}
}
.WithHandler(CommandHandler.Create<Settings, string>(async (settings, newPassword) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor, string>(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<string, string?>() { { "password", newPassword } };
var opts = new Dictionary<string, string?> { { "password", newPassword } };
await settings.ReplaceSecrets(opts).ConfigureAwait(false);
newPassword = opts["password"]!;
}
await connection.ChangePassword(newPassword);
output.SetResult(true);
}));
}
@@ -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<bool?>(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<bool>(name: "--overwrite", description: "Flag toggling the overwriting of existing files", getDefaultValue: () => false),
new Option<bool>(name: "--unencrypted", description: "Flag toggling unencrypted export of configurations", getDefaultValue: () => false),
new Option<DirectoryInfo>(name: "--destination", description: "The folder where the backup configuration should be exported to", getDefaultValue: () => new DirectoryInfo(Directory.GetCurrentDirectory())),
new Option<DirectoryInfo>(name: "--destination", description: "The folder where the backup configuration should be exported to", getDefaultValue: () => new DirectoryInfo(Directory.GetCurrentDirectory()))
}
.WithHandler(CommandHandler.Create<Settings, string[], string?, bool?, bool, bool, DirectoryInfo>(async (settings, backups, encryptionPassphrase, exportPasswords, overwrite, unencrypted, destination) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor, string[], string?, bool?, bool, bool, DirectoryInfo>(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<string, string?>() { { "password", encryptionPassphrase } };
var opts = new Dictionary<string, string?> { { "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<dynamic> 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);
}));
}
@@ -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<Settings>(async (settings) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor>(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;
}
})
@@ -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<bool>(name: "--import-metadata", description: "Import metadata from the backup", getDefaultValue: () => false)
}
.WithHandler(CommandHandler.Create<Settings, FileInfo, string, bool>(async (settings, file, passphrase, importMetadata) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor, FileInfo, string, bool>(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<string, string?>() { { "password", passphrase } };
var opts = new Dictionary<string, string?> { { "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)
@@ -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<Settings, OutputInterceptor>(async (settings, output) =>
{
}
.WithHandler(CommandHandler.Create<Settings>(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);
}));
}
@@ -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<Settings>(async (settings) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor>(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);
}));
}
@@ -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<Settings>(async (settings) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor>(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);
})
);
}
@@ -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<Settings>(async (settings) =>
await (await settings.GetConnection()).Logout(settings))
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor>(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);
})
);
}
@@ -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<string?>("duration", description: "The duration to pause the server for", getDefaultValue: () => null) {
Arity = ArgumentArity.ZeroOrOne
},
}
}
.WithHandler(CommandHandler.Create<Settings, string?>(async (settings, duration) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor, string?>(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);
}));
}
@@ -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<Settings>(async (settings) =>
await (await settings.GetConnection()).Resume())
);
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor>(async (settings, output) =>
{
output.AppendConsoleMessage("Resuming the server...");
await (await settings.GetConnection(output)).Resume();
output.SetResult(true);
}));
}
@@ -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<int>("--poll-interval", description: "The interval in seconds to poll for backup status", getDefaultValue: () => 5) {
IsRequired = false,
IsRequired = false
},
new Option<bool>("--quiet", "Do not print progress messages") {
IsRequired = false
}
}
.WithHandler(CommandHandler.Create<Settings, string, bool, int, bool>(async (settings, backup, wait, pollinterval, quiet) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor, string, bool, int, bool>(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);
}));
}
@@ -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<Settings>(async (settings) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor>(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);
}));
}
+40 -23
View File
@@ -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
/// </summary>
/// <param name="settings">The settings to use for the connection</param>
/// <param name="obtainRefreshToken">Whether to obtain a refresh token</param>
/// <param name="console">Console messages interceptor</param>
/// <returns>The connection</returns>
public static async Task<Connection> Connect(Settings settings, bool obtainRefreshToken = false)
public static async Task<Connection> 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<string>(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<WebserverCore.Middlewares.JWTConfig>(cfg)
var signinjwt = new JWTTokenProvider(
JsonSerializer.Deserialize<JWTConfig>(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
/// <returns>The access and refresh tokens</returns>
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 }))
);
/// <summary>
@@ -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
/// <returns>The task</returns>
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
/// <returns>The server state</returns>
public async Task<ServerState> GetServerState()
{
var response = await client.GetAsync($"serverstate");
var response = await client.GetAsync("serverstate");
await EnsureSuccessStatusCodeWithParsing(response);
return await response.Content.ReadFromJsonAsync<ServerState>()
?? 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
/// </summary>
/// <param name="settings">The settings to use</param>
/// <param name="output"></param>
/// <returns>The task</returns>
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);
}
/// <summary>
@@ -539,7 +556,7 @@ public class Connection
/// <returns>The token</returns>
public async Task<string> 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;
}
@@ -0,0 +1,118 @@
using System.CommandLine.Binding;
using System.Dynamic;
using Duplicati.Library.Backend;
using Newtonsoft.Json;
namespace Duplicati.CommandLine.ServerUtil;
/// <summary>
/// Manages console output, optionally serializing it to JSON.
/// </summary>
/// <remarks>
/// 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 <paramref name="jsonOutput"/> parameter.
/// </remarks>
public sealed class OutputInterceptor(bool jsonOutput, BindingContext bindingContext)
{
private readonly DateTimeOffset _timestamp = DateTimeOffset.Now;
private readonly List<string> _commandMessages = [];
private readonly List<string> _exceptionMessages = [];
private string? _command;
private bool _success;
private readonly Dictionary<string, object?> _extendedProperties = [];
public bool JsonOutputMode { get; } = jsonOutput;
public int ExitCode { get; set; }
/// <summary>
/// Gets the binding context associated with this interceptor.
/// </summary>
public BindingContext BindingContext { get; } = bindingContext ?? throw new ArgumentNullException(nameof(bindingContext));
/// <summary>
/// Sets the command string to be intercepted and tracked.
/// </summary>
/// <param name="command">The command string to set. Must not be null.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="command"/> is null.</exception>
public void SetCommand(string command)
{
_command = command ?? throw new ArgumentNullException(nameof(command));
}
/// <summary>
/// Sets the result of the command execution.
/// </summary>
/// <param name="success">A value indicating whether the ** business rule ** was successful. On exception by definition it will be false.</param>
public void SetResult(bool success)
{
_success = success;
}
/// <summary>
/// Appends an exception message to the interceptor.
/// </summary>
/// <param name="message">The exception message to append. Ignored if null or empty.</param>
/// <remarks>
/// If JSON output is enabled, the message is stored in a list; otherwise, it is written to the console.
/// </remarks>
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);
}
/// <summary>
/// Appends a console message to the interceptor.
/// </summary>
/// <param name="message">The console message to append. Ignored if null or empty.</param>
/// <remarks>
/// If JSON output is enabled, the message is stored in a list; otherwise, it is written to the console.
/// </remarks>
public void AppendConsoleMessage(string? message)
{
if (message == null) return;
if (JsonOutputMode && !string.IsNullOrEmpty(message))
_commandMessages.Add(message);
else
Console.WriteLine(message);
}
/// <summary>
/// Serializes the intercepted data into a JSON string if JSON output is enabled.
/// </summary>
/// <returns>
/// A JSON string containing the intercepted data, or <c>null</c> if JSON output is disabled.
/// </returns>
/// <remarks>
/// The serialized result includes the timestamp, command, success status, messages, and exceptions in a structured format.
/// </remarks>
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<string, object>)result)[kvp.Key] = kvp.Value ?? string.Empty;
return JsonConvert.SerializeObject(result, Formatting.Indented);
}
}
@@ -0,0 +1,62 @@
using System.CommandLine.Binding;
using System.CommandLine.Parsing;
namespace Duplicati.CommandLine.ServerUtil;
/// <summary>
/// An abstract binder class for managing a singleton instance of <see cref="OutputInterceptor"/>.
/// </summary>
/// <remarks>
/// This class ensures that only one instance of <see cref="OutputInterceptor"/> is associated with a given <see cref="BindingContext"/>.
/// </remarks>
public abstract class OutputInterceptorBinder : BinderBase<OutputInterceptor>
{
private static OutputInterceptor? _instance;
/// <summary>
/// Gets the current instance of <see cref="OutputInterceptor"/>.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when the instance has not been initialized.</exception>
public static OutputInterceptor? Instance => _instance;
/// <summary>
/// Retrieves or creates a <see cref="OutputInterceptor"/> instance for the specified binding context.
/// </summary>
/// <param name="bindingContext">The binding context to associate with the interceptor. Must not be null.</param>
/// <returns>The existing or newly created <see cref="OutputInterceptor"/> instance.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="bindingContext"/> is null.</exception>
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;
}
/// <summary>
/// Gets the bound <see cref="OutputInterceptor"/> value for the specified binding context.
/// </summary>
/// <param name="bindingContext">The binding context to retrieve the interceptor for.</param>
/// <returns>The associated <see cref="OutputInterceptor"/> instance.</returns>
protected override OutputInterceptor GetBoundValue(BindingContext bindingContext)
{
return GetConsoleInterceptor(bindingContext);
}
/// <summary>
/// Creates a new <see cref="OutputInterceptor"/> instance with the specified binding context.
/// </summary>
/// <param name="bindingContext">The binding context to initialize the interceptor with.</param>
/// <returns>A new <see cref="OutputInterceptor"/> instance.</returns>
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;
}
}
+23 -11
View File
@@ -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
/// </summary>
/// <param name="args"></param>
/// <returns>The return code</returns>
public static Task<int> Main(string[] args)
public static async Task<int> 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);
}
}
}
+26 -9
View File
@@ -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(
/// <param name="ServerDatafolder">The server datafolder, if any</param>
private sealed record PersistedSettings(
string? RefreshToken,
System.Uri HostUrl,
Uri HostUrl,
string? ServerDatafolder
);
@@ -87,9 +89,9 @@ public sealed record Settings(
/// <param name="secretProviderPattern">The secret provider pattern to use</param>
/// <param name="acceptedHostCertificate">The SHA1 hash of the host certificate to accept</param>
/// <returns>The loaded settings</returns>
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(
/// <summary>
/// Saves the settings to the settings file
/// </summary>
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);
}
/// <summary>
/// Gets a connection to the server
/// </summary>
/// <returns>The connection</returns>
public Task<Connection> GetConnection(OutputInterceptor output)
{
return Connection.Connect(this, false, output);
}
/// <summary>
/// Loads the settings from the settings file
@@ -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<Settings>
/// <summary>
/// The host URL option.
/// </summary>
public static readonly Option<Uri> hostUrlOption = new Option<Uri>("--hosturl", description: "The host URL to use", getDefaultValue: () => new Uri($"http://{Library.Utility.Utility.IpVersionCompatibleLoopback}:8200"));
public static readonly Option<Uri> hostUrlOption = new Option<Uri>("--hosturl", description: "The host URL to use", getDefaultValue: () => new Uri($"http://{Utility.IpVersionCompatibleLoopback}:8200"));
/// <summary>
/// The server datafolder option.
/// </summary>
@@ -59,7 +63,7 @@ public class SettingsBinder : BinderBase<Settings>
/// <summary>
/// The settings encryption key option.
/// </summary>
public static readonly Option<string?> settingsEncryptionKeyOption = new Option<string?>("--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<string?> settingsEncryptionKeyOption = new Option<string?>("--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);
/// <summary>
/// The secret provider option.
@@ -79,6 +83,12 @@ public class SettingsBinder : BinderBase<Settings>
/// </summary>
public static readonly Option<string> acceptedHostCertificateOption = new Option<string>("--host-cert", description: "The SHA1 hash of the host certificate to accept. Use * for any certificate, same as --insecure (dangerous)", getDefaultValue: () => string.Empty);
/// <summary>
/// Option to wrap stdout as a json.
/// </summary>
public static readonly Option<bool> jsonOutputOption =
new Option<bool>("--json", description: "Wraps stdout as a json", getDefaultValue: () => false);
/// <summary>
/// Adds global options to the root command.
/// </summary>
@@ -97,6 +107,7 @@ public class SettingsBinder : BinderBase<Settings>
rootCommand.AddGlobalOption(secretProviderCacheOption);
rootCommand.AddGlobalOption(secretProviderPatternOption);
rootCommand.AddGlobalOption(acceptedHostCertificateOption);
rootCommand.AddGlobalOption(jsonOutputOption);
return rootCommand;
}
@@ -111,7 +122,7 @@ public class SettingsBinder : BinderBase<Settings>
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,