// 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.Globalization; using System.Net.Http.Json; using System.Text.Json; using Duplicati.Library.Logging; using Duplicati.Library.Utility; namespace Duplicati.Library.RemoteControl; /// /// Implementation of the remote control enrollment process /// public class RegisterForRemote : IDisposable { /// /// The log tag for messages from this class /// private static readonly string LogTag = Log.LogTagFromType(); /// /// The interval between retries when registering the machine /// private static readonly TimeSpan ClientRegisterRetryInterval = TimeSpan.FromSeconds(30); /// /// The maximum number of retries when registering the machine /// private static readonly int ClientRegisterMaxRetries = 10; /// /// A random ID for this client instance /// private static readonly string ClientInstanceId = Guid.NewGuid().ToString(); /// /// The default URL to register the machine with /// public static string DefaultRegisterationUrl { get { var envvalue = Environment.GetEnvironmentVariable("DUPLICATI_REMOTE_CONTROL_URL"); return string.IsNullOrWhiteSpace(envvalue) ? "https://api.duplicati.com/remotecontrol/register" : envvalue; } } /// /// The default URL to register the machine with /// public static string DefaultDashboardUrl { get { var envvalue = Environment.GetEnvironmentVariable("DUPLICATI_DASHBOARD_URL"); return string.IsNullOrWhiteSpace(envvalue) ? "https://app.duplicati.com/app/machines" : envvalue; } } /// /// The Json options to use for serialization /// internal static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; /// /// The states that the process can be in /// public enum States { /// /// The process has not started /// NotStarted, /// /// The machine is being registered /// Registering, /// /// The machine has been registered /// Registered, /// /// The machine is waiting for the user to claim it /// WaitingForClaim, /// /// The machine has been claimed /// Claimed, /// /// The process has failed /// Failed, /// /// The process has been disposed /// Disposed } /// /// Data returned when the machine is claimed /// /// True if the claim was successful /// The status message for the claim /// The JWT token for the machine /// The URL for the remote server /// The URL for getting new server certificates /// The certificates for the remote server /// The encryption key for the local settings private sealed record EnvelopedClaimedClientData( bool Success, string StatusMessage, string JWT, string ServerUrl, string CertificateUrl, IEnumerable ServerCertificates, string? LocalEncryptionKey ); /// /// The current state of the registration process /// private States _state; /// /// The URL to register the machine with /// private readonly string _registrationUrl; /// /// The cancellation token source for the process /// private readonly CancellationTokenSource _cancellationTokenSource; /// /// The HTTP client to use for requests /// private readonly HttpClient _httpClient; /// /// The data returned when registering the machine /// private RegisterClientData? _registerClientData; /// /// The data returned when claiming the machine /// private ClaimedClientData? _claimedClientData; /// /// Creates a new instance of the registration process /// /// The URL to register the machine with /// The HTTP client to use for requests /// The cancellation token to use for the process public RegisterForRemote(string registrationUrl, HttpClient? httpClient, CancellationToken cancellationToken) { _state = States.NotStarted; _registrationUrl = registrationUrl; _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); _httpClient = httpClient ?? HttpClientHelper.CreateClient(); // Timeout for _httpClient will be kept at default 100s (Which is already a long one for registration) } /// /// The current state of the registration process /// public States State => _state; /// /// Starts the registration process and returns the data needed to claim the machine /// /// The maximum number of retries when registering the machine /// The interval between retries when registering the machine /// The data needed to claim the machine /// Thrown if the class is not in the correct state /// Thrown if the machine could not be registered public async Task<(RegisterClientData? RegistrationData, ClaimedClientData? ClaimedData)> Register(int? maxRetries = null, TimeSpan? retryInterval = null) { if (_state != States.NotStarted) throw new InvalidOperationException("Registration process has already started"); _state = States.Registering; try { (_registerClientData, _claimedClientData) = await RetryHelper.Retry(() => RegisterClient(), maxRetries ?? ClientRegisterMaxRetries, retryInterval ?? ClientRegisterRetryInterval, _cancellationTokenSource.Token); } catch (Exception ex) { if (_state != States.Disposed) _state = States.Failed; Log.WriteMessage(LogMessageType.Error, LogTag, "ClientRegistrationFailed", ex, $"Failed to register machine with server: {_registrationUrl}"); throw; } if (_state != States.Disposed) { if (_claimedClientData != null) _state = States.Claimed; else _state = States.Registered; } return (_registerClientData, _claimedClientData); } /// /// Creates the machine data content for registration /// /// The JSON content for the machine data private JsonContent CreateMachineData() { var basics = new Dictionary { { "instanceId", ClientInstanceId }, { "machineId", AutoUpdater.DataFolderManager.MachineID }, { "machineName", AutoUpdater.DataFolderManager.MachineName }, { "installId", AutoUpdater.DataFolderManager.InstallID }, { "localTime", DateTimeOffset.Now.ToString("o", CultureInfo.InvariantCulture) }, { "version", AutoUpdater.UpdaterManager.SelfVersion?.Version }, { "packageTypeId", AutoUpdater.UpdaterManager.PackageTypeId }, { "operatingSystem", AutoUpdater.UpdaterManager.OperatingSystemName } }; // Pass any query parameters from the registration URL into the JSON body // so they url can override the defaults if needed var uri = new System.Uri(_registrationUrl); var query = System.Web.HttpUtility.ParseQueryString(uri.Query); foreach (var key in query.AllKeys) if (key != null && !string.IsNullOrWhiteSpace(query[key])) basics[key] = query[key]; return JsonContent.Create(basics); } /// /// Registers the machine with the server /// /// The data needed to claim the machine private async Task<(RegisterClientData?, ClaimedClientData?)> RegisterClient() { var response = await _httpClient.PostAsync(_registrationUrl, CreateMachineData(), _cancellationTokenSource.Token); response.EnsureSuccessStatusCode(); var raw = await response.Content.ReadAsStringAsync(_cancellationTokenSource.Token); if (string.IsNullOrWhiteSpace(raw)) throw new Exception("Failed to read client registration data"); var doc = JsonDocument.Parse(raw); if (doc.RootElement.TryGetProperty("jwt", out var _) && doc.RootElement.TryGetProperty("serverUrl", out var _)) { return JsonSerializer.Deserialize(raw, JsonOptions) switch { { } data => (null, data), null => throw new Exception("Failed to read client claimed data") }; } if (doc.RootElement.TryGetProperty("claimLink", out var _) && doc.RootElement.TryGetProperty("statusLink", out var _)) { return JsonSerializer.Deserialize(raw, JsonOptions) switch { { } data => (data, null), null => throw new Exception("Failed to read client registration data") }; } throw new Exception("Failed to read client registration data"); } /// /// Claims the machine with the server /// /// The settings for the machine /// Thrown if the class is not in the correct state /// Thrown if the machine could not be registered public async Task Claim() { if (_state == States.Claimed) return _claimedClientData ?? throw new InvalidOperationException("Claimed data is null"); if (_state != States.Registered) throw new InvalidOperationException("Resgistration process is not in the registered state"); _state = States.WaitingForClaim; _cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(Math.Min(3600, Math.Max(1, _registerClientData!.MaxLifetimeSeconds)))); try { _claimedClientData = await RetryHelper.Retry(() => CheckClientClaimed(), Math.Min(100, Math.Max(1, _registerClientData!.MaxRetries)), TimeSpan.FromSeconds(_registerClientData!.RetrySeconds), _cancellationTokenSource.Token); } catch (Exception ex) { if (_state != States.Disposed) _state = States.Failed; Log.WriteMessage(LogMessageType.Error, LogTag, "ClientClaimFailed", ex, "Failed to claim machine with server"); throw; } _state = States.Claimed; return _claimedClientData; } /// /// Checks if the machine has been claimed /// /// The data for the claimed machine /// Thrown if the machine could not be claimed private async Task CheckClientClaimed() { var response = await _httpClient.PostAsync(_registerClientData!.StatusLink, CreateMachineData(), _cancellationTokenSource.Token); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync(options: JsonOptions, _cancellationTokenSource.Token) ?? throw new Exception("Failed to read machine claim data"); if (!result.Success) throw new Exception($"Failed to claim machine: {result.StatusMessage}"); return new ClaimedClientData(result.JWT, result.ServerUrl, result.CertificateUrl, result.ServerCertificates, result.LocalEncryptionKey); } /// public void Dispose() { _cancellationTokenSource.Cancel(); _httpClient.Dispose(); _state = States.Disposed; } }