diff --git a/Duplicati.sln b/Duplicati.sln
index 639de84ea..1422c16ce 100644
--- a/Duplicati.sln
+++ b/Duplicati.sln
@@ -1,4 +1,4 @@
-
+
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.1.32210.238
@@ -192,6 +192,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.WindowsMo
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.WindowsModulesLoader", "Executables\net8\Duplicati.WindowsModulesLoader\Duplicati.WindowsModulesLoader.csproj", "{006167A3-64C4-40BD-AAE4-62E911ED8CBB}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebserverCore.Client.UsageExample", "WebserverCore.Client.UsageExample\WebserverCore.Client.UsageExample.csproj", "{58EF2528-EA65-4940-8AFE-16D0C6E0E8BB}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -534,6 +536,10 @@ Global
{15315ED8-1F67-478B-AFAD-59D9E5760705}.Debug|Any CPU.Build.0 = Debug|Any CPU
{15315ED8-1F67-478B-AFAD-59D9E5760705}.Release|Any CPU.ActiveCfg = Release|Any CPU
{15315ED8-1F67-478B-AFAD-59D9E5760705}.Release|Any CPU.Build.0 = Release|Any CPU
+ {58EF2528-EA65-4940-8AFE-16D0C6E0E8BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {58EF2528-EA65-4940-8AFE-16D0C6E0E8BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {58EF2528-EA65-4940-8AFE-16D0C6E0E8BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {58EF2528-EA65-4940-8AFE-16D0C6E0E8BB}.Release|Any CPU.Build.0 = Release|Any CPU
{4F0613D6-9F06-41D8-B7E8-DEEFB88DD001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4F0613D6-9F06-41D8-B7E8-DEEFB88DD001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4F0613D6-9F06-41D8-B7E8-DEEFB88DD001}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -611,6 +617,7 @@ Global
{8DFF553E-9D2B-4E32-BE3A-74F476159580} = {566EBBDA-19A4-4056-A615-D901D57D2439}
{F760DBF2-6D4A-4934-A56C-4C0CA6758DE6} = {6B46F6B1-1898-49B8-ADA7-5CAF68EB77E3}
{15315ED8-1F67-478B-AFAD-59D9E5760705} = {D19A38DD-68F1-4EF5-BF5F-8966CE0D9A5B}
+ {58EF2528-EA65-4940-8AFE-16D0C6E0E8BB} = {15388C37-9218-4818-972E-738EEA8F1602}
{006167A3-64C4-40BD-AAE4-62E911ED8CBB} = {6B46F6B1-1898-49B8-ADA7-5CAF68EB77E3}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
diff --git a/Duplicati/WebserverCore/Client/DuplicatiServerClient.cs b/Duplicati/WebserverCore/Client/DuplicatiServerClient.cs
new file mode 100644
index 000000000..c713c302b
--- /dev/null
+++ b/Duplicati/WebserverCore/Client/DuplicatiServerClient.cs
@@ -0,0 +1,884 @@
+// Copyright (C) 2025, The Duplicati Team
+// https://duplicati.com, hello@duplicati.com
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the "Software"),
+// to deal in the Software without restriction, including without limitation
+// the rights to use, copy, modify, merge, publish, distribute, sublicense,
+// and/or sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// 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;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using Duplicati.WebserverCore.Dto;
+using Duplicati.WebserverCore.Dto.V2;
+using Duplicati.WebserverCore.Endpoints.V1.Backup;
+
+namespace Duplicati.WebserverCore.Client;
+
+///
+/// A client for interacting with the Duplicati server API, supporting both v1 and v2 endpoints.
+///
+public class DuplicatiServerClient : IDisposable
+{
+ private readonly HttpClient _httpClient;
+ private readonly string _baseUrl;
+ private readonly JsonSerializerOptions _jsonOptions;
+ private bool _disposed;
+ private readonly bool _selfOwnedHttpClient;
+ private readonly SemaphoreSlim _tokenRefreshSemaphore = new(1, 1);
+ private readonly ServerCredentialType _credentialType;
+ private readonly string _credential;
+
+ ///
+ /// Gets a value indicating whether the client is authenticated.
+ ///
+ public bool IsAuthenticated => !string.IsNullOrEmpty(_httpClient.DefaultRequestHeaders.Authorization?.Parameter);
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The base URL of the Duplicati server.
+ /// The type of credential being provided (Password or Token).
+ /// The server password or access token.
+ /// Optional HttpClient instance. If not provided, a new one will be created.
+ public DuplicatiServerClient(string baseUrl, ServerCredentialType credentialType, string credential, HttpClient? httpClient = null)
+ {
+ _selfOwnedHttpClient = httpClient is not null;
+ _baseUrl = baseUrl.TrimEnd('/');
+ _httpClient = httpClient ?? new HttpClient();
+ _jsonOptions = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true,
+ AllowTrailingCommas = true
+ };
+ _credential = credential;
+ _credentialType = credentialType;
+ }
+
+ ///
+ /// Authenticates the client with the Duplicati server using the provided credentials and acquires a bearer token.
+ ///
+ /// If a call is made to another method before calling Authenticate, it will automatically call this method to ensure the client is authenticated.
+ /// The idea of having a separate Authenticate method is to allow for explicit authentication to avoid getting a 401 result on the server log.
+ ///
+ /// The cancellation token. Optional, defaults to .
+ public async Task Authenticate(CancellationToken cancellationToken = default)
+ {
+ await RefreshTokenAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Acquires a bearer token using password-based authentication.
+ ///
+ /// The server password.
+ /// The cancellation token.
+ private async Task AcquireBearerViaPasswordAuthAsync(string password, CancellationToken cancellationToken)
+ {
+ var loginResult = await LoginV1Async(new LoginInputDto(password, true), cancellationToken).ConfigureAwait(false);
+ SetTokenAuthentication(loginResult.AccessToken);
+ }
+
+ ///
+ /// Acquires a bearer token using token-based authentication.
+ ///
+ /// The signin token.
+ /// The cancellation token.
+ private async Task AcquireBearerViaTokenAuthAsync(string token, CancellationToken cancellationToken)
+ {
+ var signinResult = await SigninV1Async(new SigninInputDto(token, true), cancellationToken).ConfigureAwait(false);
+ if (!string.IsNullOrEmpty(signinResult.AccessToken))
+ SetTokenAuthentication(signinResult.AccessToken);
+ }
+
+ ///
+ /// Sets token-based authentication.
+ ///
+ /// The authentication token.
+ public void SetTokenAuthentication(string token)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ throw new ArgumentException("Token cannot be null or empty");
+ _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ }
+
+ ///
+ /// Makes an HTTP GET request to the specified endpoint.
+ ///
+ /// The type of the response data.
+ /// The API endpoint.
+ /// The cancellation token. Optional, defaults to .
+ /// Whether to retry on 401 unauthorized responses.
+ /// The response data.
+ private async Task GetAsync(string endpoint, CancellationToken cancellationToken = default, bool retryOn401 = true)
+ {
+ return await ExecuteWithRetryAsync(async () =>
+ {
+ using var response = await _httpClient.GetAsync($"{_baseUrl}{endpoint}", cancellationToken).ConfigureAwait(false);
+ return await ProcessResponseAsync(response, cancellationToken).ConfigureAwait(false);
+ }, retryOn401, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Makes an HTTP POST request to the specified endpoint.
+ ///
+ /// The type of the response data.
+ /// The API endpoint.
+ /// The request data.
+ /// The cancellation token. Optional, defaults to .
+ /// Whether to retry on 401 unauthorized responses.
+ /// The response data.
+ private async Task PostAsync(string endpoint, object? data, CancellationToken cancellationToken = default, bool retryOn401 = true)
+ {
+ return await ExecuteWithRetryAsync(async () =>
+ {
+ var json = data != null ? JsonSerializer.Serialize(data, _jsonOptions) : string.Empty;
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var response = await _httpClient.PostAsync($"{_baseUrl}{endpoint}", content, cancellationToken).ConfigureAwait(false);
+ return await ProcessResponseAsync(response, cancellationToken).ConfigureAwait(false);
+ }, retryOn401, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Makes an HTTP PUT request to the specified endpoint.
+ ///
+ /// The type of the response data.
+ /// The API endpoint.
+ /// The request data.
+ /// The cancellation token. Optional, defaults to .
+ /// Whether to retry on 401 unauthorized responses.
+ /// The response data.
+ private async Task PutAsync(string endpoint, object? data, CancellationToken cancellationToken = default, bool retryOn401 = true)
+ {
+ return await ExecuteWithRetryAsync(async () =>
+ {
+ var json = data != null ? JsonSerializer.Serialize(data, _jsonOptions) : string.Empty;
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var response = await _httpClient.PutAsync($"{_baseUrl}{endpoint}", content, cancellationToken).ConfigureAwait(false);
+ return await ProcessResponseAsync(response, cancellationToken).ConfigureAwait(false);
+ }, retryOn401, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Makes an HTTP DELETE request to the specified endpoint.
+ ///
+ /// The type of the response data.
+ /// The API endpoint.
+ /// The cancellation token. Optional, defaults to .
+ /// Whether to retry on 401 unauthorized responses.
+ /// The response data.
+ private async Task DeleteAsync(string endpoint, CancellationToken cancellationToken = default, bool retryOn401 = true)
+ {
+ return await ExecuteWithRetryAsync(async () =>
+ {
+ using var response = await _httpClient.DeleteAsync($"{_baseUrl}{endpoint}", cancellationToken).ConfigureAwait(false);
+ return await ProcessResponseAsync(response, cancellationToken).ConfigureAwait(false);
+ }, retryOn401, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Processes an HTTP response and handles potential errors.
+ ///
+ /// The type of the response data.
+ /// The HTTP response message.
+ /// The cancellation token. Optional, defaults to .
+ /// The response data.
+ private async Task ProcessResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default)
+ {
+ if (response.StatusCode == HttpStatusCode.Unauthorized)
+ {
+ throw new UnauthorizedAccessException("Unauthorized access - token may be expired");
+ }
+
+ response.EnsureSuccessStatusCode();
+
+ if (typeof(T) == typeof(Stream))
+ {
+ // Response is a stream, read the response into it
+ var ms = new MemoryStream();
+ var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await stream.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
+ ms.Position = 0; // rewind
+ return (T)(object)ms;
+ }
+ else
+ {
+ var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ return JsonSerializer.Deserialize(content)!;
+ }
+ }
+
+ ///
+ /// Executes an HTTP request with retry logic for 401 unauthorized responses.
+ ///
+ /// The type of the response data.
+ /// The HTTP operation to execute.
+ /// Whether to retry on 401 unauthorized responses.
+ /// The cancellation token. Optional, defaults to .
+ /// The response data.
+ private async Task ExecuteWithRetryAsync(Func> operation, bool retryOn401, CancellationToken cancellationToken = default)
+ {
+ const int maxRetries = 3;
+ var attempts = 0;
+
+ while (attempts <= maxRetries)
+ {
+ try
+ {
+ return await operation().ConfigureAwait(false);
+ }
+ catch (UnauthorizedAccessException) when (retryOn401 && attempts < maxRetries)
+ {
+ attempts++;
+ await RefreshTokenAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ // This should never be reached due to the loop logic, but included for completeness
+ throw new UnauthorizedAccessException("Maximum retry attempts reached for token refresh");
+ }
+
+ ///
+ /// Refreshes the authentication token based on the credential type.
+ ///
+ /// The cancellation token. Optional, defaults to .
+ private async Task RefreshTokenAsync(CancellationToken cancellationToken = default)
+ {
+ await _tokenRefreshSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ switch (_credentialType)
+ {
+ case ServerCredentialType.Password:
+ await AcquireBearerViaPasswordAuthAsync(_credential, cancellationToken).ConfigureAwait(false);
+ break;
+ case ServerCredentialType.Token:
+ await AcquireBearerViaTokenAuthAsync(_credential, cancellationToken).ConfigureAwait(false);
+ break;
+ default:
+ throw new InvalidOperationException($"Unsupported credential type: {_credentialType}");
+ }
+ }
+ finally
+ {
+ _tokenRefreshSemaphore.Release();
+ }
+ }
+
+ // V1 Authentication Methods
+
+ ///
+ /// Performs password-based login to the Duplicati server (V1).
+ ///
+ /// The login input containing password and remember me flag.
+ /// The cancellation token. Optional, defaults to .
+ /// The access token output.
+ public async Task LoginV1Async(LoginInputDto input, CancellationToken cancellationToken = default)
+ {
+ return await PostAsync("/api/v1/auth/login", input, cancellationToken, false).ConfigureAwait(false);
+ }
+
+ ///
+ /// Performs token-based signin to the Duplicati server (V1).
+ ///
+ /// The signin input containing signin token and remember me flag.
+ /// The cancellation token. Optional, defaults to .
+ /// The access token output.
+ public async Task SigninV1Async(SigninInputDto input, CancellationToken cancellationToken = default)
+ {
+ return await PostAsync("/api/v1/auth/signin", input, cancellationToken, false).ConfigureAwait(false);
+ }
+
+ ///
+ /// Refreshes the access token using the refresh token (V1).
+ ///
+ /// The cancellation token. Optional, defaults to .
+ /// The new access token output.
+ public async Task RefreshTokenV1Async(CancellationToken cancellationToken = default)
+ {
+ return await PostAsync("/api/v1/auth/refresh", null, cancellationToken, false).ConfigureAwait(false);
+ }
+
+ ///
+ /// Issues a signin token for authentication (V1).
+ ///
+ /// The signin token input.
+ /// The cancellation token. Optional, defaults to .
+ /// The signin token output.
+ public async Task IssueSigninTokenV1Async(IssueSigninTokenInputDto input, CancellationToken cancellationToken = default)
+ {
+ return await PostAsync("/api/v1/auth/issuesignintoken", input, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Issues a single-operation token for a specific operation (V1).
+ ///
+ /// The operation name.
+ /// The cancellation token. Optional, defaults to .
+ /// The single operation token output.
+ public async Task IssueTokenV1Async(string operation, CancellationToken cancellationToken = default)
+ {
+ return await PostAsync($"/api/v1/auth/issuetoken/{operation}", null, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Issues a forever token for long-term authentication (V1).
+ ///
+ /// The cancellation token. Optional, defaults to .
+ /// The single operation token output.
+ public async Task IssueForeverTokenV1Async(CancellationToken cancellationToken = default)
+ {
+ return await PostAsync("/api/v1/auth/issue-forever-token", null, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Logs out and invalidates the refresh token (V1).
+ ///
+ /// The cancellation token. Optional, defaults to .
+ /// A task representing the asynchronous operation.
+ public async Task LogoutV1Async(CancellationToken cancellationToken = default)
+ {
+ await PostAsync