Merge pull request #6109 from marceloduplicati/feature/serverutil-machine-readable
Added machine-readable output for serverutil commands
This commit is contained in:
@@ -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);
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user