using System.Net.Http.Json; using System.Text.Json; namespace Duplicati.CommandLine.ServerUtil; /// /// Implementation of actions performed on the server. /// public class Connection { /// /// The reported backup data /// /// The ID of the backup /// The name of the backup /// The description of the backup /// The metadata of the backup public sealed record BackupEntry( string ID, string Name, string Description, Dictionary? Metadata ); /// /// The response backup data returned from the server /// /// The backup details private sealed record ResponseBackupEntry(ResponseBackupEntry.ResponseBackupDetailsEntry Backup) { /// /// The response backup details entry /// /// The ID of the backup /// The name of the backup /// The description of the backup /// The path to the local database /// The metadata of the backup public sealed record ResponseBackupDetailsEntry( string ID, string Name, string? Description, string? DBPath, Dictionary? Metadata ); /// /// Converts the response backup entry to a backup entry /// /// The backup entry public BackupEntry ToBackupEntry() => new BackupEntry(Backup.ID, Backup.Name, Backup.Description ?? "", Backup.Metadata); } /// /// The task entry /// /// The ID of the task /// The ID of the backup /// The operation of the task public sealed record TaskEntry( long TaskID, string BackupID, string Operation ); /// /// The stop level /// public enum StopLevel { /// /// Stop after the current file /// AfterCurrentFile, /// /// Stop now /// StopNow, /// /// Stop immediately /// Abort } /// /// The HTTP client used to connect to the server /// private readonly HttpClient client; /// /// Initializes a new instance of the class /// private Connection(HttpClient client) { this.client = client; } /// /// Connects to the server /// /// The settings to use for the connection /// Whether to obtain a refresh token /// The connection public static async Task Connect(Settings settings, bool obtainRefreshToken = false) { Console.WriteLine($"Connecting to {settings.HostUrl}..."); // Configure the client for requests var client = new HttpClient(new HttpClientHandler() { ServerCertificateCustomValidationCallback = settings.Insecure ? HttpClientHandler.DangerousAcceptAnyServerCertificateValidator : null }) { BaseAddress = new Uri(settings.HostUrl + "api/v1/") }; // If we already have a refresh token, try that first try { if (!string.IsNullOrWhiteSpace(settings.RefreshToken)) { var (accessToken, refreshToken) = await LoginWithRefreshToken(client, settings.RefreshToken); if (string.IsNullOrWhiteSpace(accessToken)) throw new InvalidOperationException("Failed to get access token"); if (string.IsNullOrWhiteSpace(refreshToken)) throw new InvalidOperationException("Failed to get refresh token"); (settings with { RefreshToken = refreshToken }).Save(); return CreateConnectionWithClient(client, accessToken); } } catch (Exception ex) { Console.WriteLine($"Failed to use refresh token: {ex.Message}"); } // If we can read the server database, try to create a signin token try { var opts = new Dictionary(); if (!string.IsNullOrWhiteSpace(settings.ServerDatafolder)) opts.Add("server-datafolder", settings.ServerDatafolder); if (File.Exists(Path.Combine(Server.Program.GetDataFolderPath(opts), Server.Program.SERVER_DATABASE_FILENAME))) { string? cfg = null; using (var connection = Duplicati.Server.Program.GetDatabaseConnection(opts, true)) cfg = connection.ApplicationSettings.JWTConfig; if (!string.IsNullOrWhiteSpace(cfg)) { var signinjwt = new WebserverCore.Middlewares.JWTTokenProvider( JsonSerializer.Deserialize(cfg) ?? throw new InvalidOperationException("Failed to deserialize JWTConfig") ).CreateSigninToken("server-cli"); var responseTask = client.PostAsync("auth/signin", JsonContent.Create(new { SigninToken = signinjwt, RememberMe = obtainRefreshToken })); var (accessToken, refreshToken) = await ParseAuthResponse(responseTask); if (string.IsNullOrWhiteSpace(accessToken)) throw new InvalidOperationException("Failed to get access token"); if (!string.IsNullOrWhiteSpace(refreshToken)) (settings with { RefreshToken = refreshToken }).Save(); return CreateConnectionWithClient(client, accessToken); } } else if (!string.IsNullOrWhiteSpace(settings.ServerDatafolder)) { Console.WriteLine($"No database found in {settings.ServerDatafolder}"); } } catch (Exception ex) { Console.WriteLine($"Failed to obtain a signin token: {ex.Message}"); } // Otherwise, we need a password to log in try { // Try obtaining the password from the user if (string.IsNullOrWhiteSpace(settings.Password)) settings = settings with { Password = HelperMethods.ReadPasswordFromConsole("Enter server password: ") }; if (string.IsNullOrWhiteSpace(settings.Password)) throw new UserReportedException("Password is required"); var (accessToken, refreshToken) = await LoginWithPassword(client, settings.Password, obtainRefreshToken); if (string.IsNullOrWhiteSpace(accessToken)) throw new InvalidOperationException("Failed to get access token"); if (!string.IsNullOrWhiteSpace(refreshToken)) (settings with { RefreshToken = refreshToken }).Save(); return CreateConnectionWithClient(client, accessToken); } catch (Exception ex) { client.Dispose(); throw new UserReportedException($"Failed to connect to server: {ex.Message}", ex); } } /// /// Creates the connection and adds the authorization header /// /// The HTTP client /// The access token /// The connection private static Connection CreateConnectionWithClient(HttpClient client, string accessToken) { client.DefaultRequestHeaders.Add("Authorization", $"Bearer {accessToken}"); return new Connection(client); } /// /// Logs in with a password /// /// The HTTP client /// The password to use /// Whether to obtain a refresh token /// The access and refresh tokens private static Task<(string? AccessToken, string? RefreshToken)> LoginWithPassword(HttpClient client, string password, bool obtainRefreshToken) => ParseAuthResponse( client.PostAsync($"auth/login", JsonContent.Create(new { Password = password, RememberMe = obtainRefreshToken })) ); /// /// Logs in with a refresh token /// /// The HTTP client /// The refresh token to use /// The access and refresh tokens 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}" } }, })); /// /// Parses the authentication response /// /// The response to parse /// The access and refresh tokens private static async Task<(string? AccessToken, string? RefreshToken)> ParseAuthResponse(Task responseTask) { var response = await responseTask; await EnsureSuccessStatusCodeWithParsing(response); var json = JsonSerializer.Deserialize>(response.Content.ReadAsStringAsync().Result) ?? throw new InvalidOperationException("Failed to parse response"); if (!json.TryGetValue("AccessToken", out var accessToken)) 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]; return (accessToken, refreshToken); } /// /// Pauses the server /// /// The duration to pause for /// The task public async Task Pause(string? duration) { var query = string.IsNullOrWhiteSpace(duration) ? "" : $"?duration={Uri.EscapeDataString(duration)}"; var response = await client.PostAsync($"serverstate/pause{query}", null); await EnsureSuccessStatusCodeWithParsing(response); } /// /// Resumes the server /// /// The task public async Task Resume() { var response = await client.PostAsync($"serverstate/resume", null); await EnsureSuccessStatusCodeWithParsing(response); } /// /// Lists the backups configured on the server /// /// The backups public async Task> ListBackups() { var response = await client.GetAsync("backups"); await EnsureSuccessStatusCodeWithParsing(response); return (JsonSerializer.Deserialize>(await response.Content.ReadAsStringAsync()) ?? throw new UserReportedException("Failed to parse response")) .Select(x => x.ToBackupEntry()) .ToArray(); } /// /// Gets a backup by ID /// /// The ID of the backup /// The backup public async Task GetBackup(string backupId) { var response = await client.GetAsync($"backup/{Uri.EscapeDataString(backupId)}"); await EnsureSuccessStatusCodeWithParsing(response); return (JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()) ?? throw new UserReportedException("Failed to parse response")) .ToBackupEntry(); } /// /// Runs a backup /// /// The ID of the backup /// The task public async Task RunBackup(string backupId) { var response = await client.PostAsync($"backup/{Uri.EscapeDataString(backupId)}/run", null); await EnsureSuccessStatusCodeWithParsing(response); } /// /// Lists the active tasks /// /// The tasks public async Task> ListTasks() { var response = await client.GetAsync("tasks"); await EnsureSuccessStatusCodeWithParsing(response); return JsonSerializer.Deserialize>(await response.Content.ReadAsStringAsync()) ?? throw new InvalidOperationException("Failed to parse response"); } /// /// Stops a task /// /// The ID of the task /// The level to stop at /// The task public async Task StopTask(string taskId, StopLevel level = StopLevel.AfterCurrentFile) { var levelString = level switch { StopLevel.AfterCurrentFile => "stopaftercurrentfile", StopLevel.StopNow => "stopnow", StopLevel.Abort => "abort", _ => throw new ArgumentOutOfRangeException(nameof(level)), }; var response = await client.PostAsync($"task/{Uri.EscapeDataString(taskId)}/{levelString}", null); await EnsureSuccessStatusCodeWithParsing(response); } /// /// Logs out of the server /// /// The settings to use /// The task public async Task Logout(Settings settings) { 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(); } /// /// Changes the server password /// /// The new password to use /// The task public async Task ChangePassword(string newPassword) { var response = await client.PutAsync("serversetting/server-passphrase", JsonContent.Create(newPassword)); await EnsureSuccessStatusCodeWithParsing(response); } /// /// Imports a backup /// /// The file to import /// The password to use /// Whether to import metadata /// The backup public async Task ImportBackup(string file, string? password, bool importMetadata) { var payload = JsonContent.Create(new { config = Convert.ToBase64String(await File.ReadAllBytesAsync(file)), import_metadata = importMetadata, passphrase = password, direct = true }); var response = await client.PostAsync("backups/import", payload); await EnsureSuccessStatusCodeWithParsing(response); var json = JsonSerializer.Deserialize>(await response.Content.ReadAsStringAsync()) ?? throw new UserReportedException("Failed to parse response"); if (!json.TryGetValue("Id", out var id)) throw new UserReportedException("Added backup but failed to get from response"); return await GetBackup(id); } /// /// Exports a backup /// /// The ID of the backup /// The passphrase to use, if encrypting /// Whether to include sensitive keys /// The stream with the exported backup public async Task ExportBackup(string backupId, string? passphrase, bool includeKeys) { var exportTokenResponse = await client.PostAsync("auth/issuetoken/export", null); await EnsureSuccessStatusCodeWithParsing(exportTokenResponse); var values = JsonSerializer.Deserialize>(await exportTokenResponse.Content.ReadAsStringAsync()) ?? throw new UserReportedException("Failed to parse response"); if (!values.TryGetValue("Token", out var token)) throw new UserReportedException("Failed to get export token"); var response = await client.GetAsync($"backup/{Uri.EscapeDataString(backupId)}/export?passphrase={Uri.EscapeDataString(passphrase ?? "")}&export-passwords={includeKeys}&token={token}"); await EnsureSuccessStatusCodeWithParsing(response); return await response.Content.ReadAsStreamAsync(); } /// /// The server error structure for JSON deserialization /// /// The error message /// The error code private sealed record ServerError(string Error, int Code); /// /// Ensures the response is successful or extracts an error message /// /// The message to check /// The task private static async Task EnsureSuccessStatusCodeWithParsing(HttpResponseMessage? message) { if (message is null) throw new UserReportedException("No response received"); if (message.IsSuccessStatusCode) return; var content = await message.Content.ReadAsStringAsync(); if (string.IsNullOrWhiteSpace(content)) message.EnsureSuccessStatusCode(); ServerError? errMsg = null; try { errMsg = JsonSerializer.Deserialize(content); } catch { } if (errMsg is not null) throw new UserReportedException($"Server error ({errMsg.Code}): {errMsg.Error}"); throw new UserReportedException($"Failed to parse response ({message.StatusCode}): {content}"); } }