// Copyright (C) 2024, 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.Http.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 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 ?? new HttpClient(); } /// /// 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 Register(int? maxRetries = null, TimeSpan? retryInterval = null) { if (_state != States.NotStarted) throw new InvalidOperationException("Registration process has already started"); _state = States.Registering; try { _registerClientData = 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) _state = States.Registered; return _registerClientData!; } private JsonContent CreateMachineData() => JsonContent.Create(new { InstanceId = ClientInstanceId, MachineId = AutoUpdater.UpdaterManager.MachineID, InstallId = AutoUpdater.UpdaterManager.InstallID, LocalTime = DateTimeOffset.Now, Version = AutoUpdater.UpdaterManager.SelfVersion?.Version, PackageTypeId = AutoUpdater.UpdaterManager.PackageTypeId }); /// /// Registers the machine with the server /// /// The data needed to claim the machine private async Task RegisterClient() { var response = await _httpClient.PostAsync(_registrationUrl, JsonContent.Create(new { InstanceId = ClientInstanceId, MachineId = AutoUpdater.UpdaterManager.MachineID, InstallId = AutoUpdater.UpdaterManager.InstallID, LocalTime = DateTimeOffset.Now }), _cancellationTokenSource.Token); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync() ?? 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.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() ?? 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; } }