// 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 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 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 ?? 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,
PackageTypeId = AutoUpdater.UpdaterManager.PackageTypeId,
LocalTime = DateTimeOffset.Now,
}), _cancellationTokenSource.Token);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(options: JsonOptions, _cancellationTokenSource.Token)
?? 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(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;
}
}