// 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; using System.Security.Cryptography; using System.Text.Json; using Duplicati.Library.Logging; namespace Duplicati.Library.RemoteControl; /// /// Support class for keeping a connection to a remote server /// public class KeepRemoteConnection : IDisposable { /// /// The protocol version to use /// private const int PROTOCOL_VERSION = 1; /// /// The log tag for messages from this class /// private static readonly string LogTag = Log.LogTagFromType(); /// /// The interval between reconnect attempts /// private static readonly TimeSpan ReconnectInterval = TimeSpan.FromSeconds(30); /// /// The interval between heartbeats /// private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(15); /// /// The interval between certificate refreshes /// private static readonly TimeSpan CertificateRefreshInterval = TimeSpan.FromDays(7); /// /// The client key to use for signing messages /// private static readonly RSA ClientKey = RSA.Create(2048); /// /// The client ID to use for identifying the client /// private static readonly string ClientId = string.IsNullOrWhiteSpace(AutoUpdater.UpdaterManager.MachineID) ? Guid.NewGuid().ToString() : AutoUpdater.UpdaterManager.MachineID; /// /// The JSON options to use for deserialization /// internal static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; /// /// The stats the connection can be in /// public enum ConnectionState { /// /// The connection is not established /// NotConnected, /// /// We received a welcome message /// WelcomeReceived, /// /// The connection is authenticated /// Authenticated } /// /// The websocket client /// private readonly Websocket.Client.WebsocketClient _client; /// /// The cancellation token source /// private readonly CancellationTokenSource _cancellationTokenSource; /// /// The current state of the connection /// private ConnectionState _state = ConnectionState.NotConnected; /// /// The task that runs the connection /// private Task _runnerTask; /// /// The currently negotiated server certificate /// private MiniServerCertificate? _serverCertificate; /// /// The public key of the server /// private RSA? _serverPublicKey; /// /// The time the certificate was last refreshed /// private DateTime _lastCertificateRefresh = DateTime.UnixEpoch; /// /// Task for requesting certificate refresh /// private TaskCompletionSource _refreshCertificates = new TaskCompletionSource(); /// /// The callback to call when rekeying /// private readonly Func _onReKey; /// /// The callback to call when a message is received /// private readonly Func _onMessage; /// /// The current JWT token /// private string _token; /// /// The server URL /// private string _serverUrl; /// /// The certificate URL /// private string _certificateUrl; /// /// The server keys /// private IEnumerable _serverKeys; /// /// Creates a new connection to the remote server /// /// The url to use /// The JWT token to use /// The server keys to use /// The token to cancel the connection private KeepRemoteConnection(string serverUrl, string JWT, string certificateUrl, IEnumerable serverKeys, CancellationToken cancellationToken, Func onReKey, Func onMessage) { _serverUrl = serverUrl; _certificateUrl = certificateUrl; _token = JWT; _serverKeys = serverKeys; _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); _onReKey = onReKey; _onMessage = onMessage; _client = new Websocket.Client.WebsocketClient(new Uri(serverUrl)); _runnerTask = RunMainLoop(); } /// /// Runs the inner loop of the connection /// private Task RunMainLoop() { //TODO: If we close the socket, it reconnects immediately // casuing excessive usage _client.ReconnectTimeout = ReconnectInterval; _client.IsReconnectionEnabled = true; _client.DisconnectionHappened.Subscribe(info => { _state = ConnectionState.NotConnected; _serverCertificate = null; _serverPublicKey = null; Log.WriteMessage(LogMessageType.Warning, LogTag, "WebsocketDisconnect", "Disconnected from the server"); }); _client.MessageReceived.Subscribe(async msg => { Log.WriteMessage(LogMessageType.Information, LogTag, "WebsocketMessage", "Received message from server: {0}", msg); try { if (string.IsNullOrWhiteSpace(msg.Text)) throw new ProtocolViolationException("Empty message"); if (_serverCertificate == null || _serverPublicKey == null || _state == ConnectionState.NotConnected) { // Should be safe from replay, as the response is encrypted with the server public key // So even a replay attack would not let the attacker know the client's token var welcomeEnvelope = EnvelopedMessage.ForceParse(msg.Text); if (welcomeEnvelope.GetMessageType() != MessageType.Welcome) throw new ProtocolViolationException("Expected welcome message"); if (string.IsNullOrWhiteSpace(welcomeEnvelope.Payload)) throw new ProtocolViolationException("No payload in welcome message"); var welcomeMessage = welcomeEnvelope.GetPayload() ?? throw new ProtocolViolationException("Invalid welcome message"); if (string.IsNullOrWhiteSpace(welcomeMessage.PublicKeyHash)) throw new ProtocolViolationException("No public key hash in welcome message"); _serverCertificate = _serverKeys.FirstOrDefault(x => x.PublicKeyHash == welcomeMessage.PublicKeyHash && x.Expiry > DateTimeOffset.Now); if (_serverCertificate == null) { _refreshCertificates.TrySetResult(true); throw new ProtocolViolationException("No valid server certificate"); } try { var tmp = RSA.Create(); tmp.ImportFromPem(_serverCertificate.PublicKey); _serverPublicKey = tmp; } catch { _refreshCertificates.TrySetResult(true); throw new ProtocolViolationException("Invalid server certificate"); } _state = ConnectionState.WelcomeReceived; SendEnvelope( welcomeEnvelope.RespondWith( new AuthMessage( _token, ClientKey.ExportRSAPublicKeyPem(), AutoUpdater.UpdaterManager.SelfVersion?.Version ?? "0.0.0", PROTOCOL_VERSION ), "auth" ), force: true); return; } if (_serverCertificate == null || _serverPublicKey == null || _serverCertificate.HasExpired()) { _refreshCertificates.TrySetResult(true); throw new ProtocolViolationException("No valid server certificate"); } var envelope = TransportHelper.ParseFromEncryptedMessage(msg.Text, ClientKey); if (_state == ConnectionState.WelcomeReceived) { if (envelope.GetMessageType() != MessageType.Auth) throw new ProtocolViolationException("Expected welcome message"); var authMessage = envelope.GetPayload(); if (!authMessage.Accepted ?? false) throw new ProtocolViolationException("Authentication failed"); _state = ConnectionState.Authenticated; if ((authMessage.WillReplaceToken ?? false) && authMessage.NewToken != null) { _token = authMessage.NewToken; await InvokeReKey(); } } else if (_state == ConnectionState.Authenticated) { switch (envelope.GetMessageType()) { case MessageType.Pong: break; case MessageType.Command: await _onMessage(new CommandMessage( envelope.GetPayload(), response => SendEnvelope(envelope.RespondWith(response)) )); break; default: throw new ProtocolViolationException("Unexpected message"); } } else { throw new ProtocolViolationException("Unexpected message"); } } catch (Exception ex) { Log.WriteMessage(LogMessageType.Error, LogTag, "WebsocketMessage", ex, "Failed to process message: {0}", msg); // TODO: This leaks if we keep getting exceptions _client.Reconnect(); } }); return Task.WhenAny( _client.Start(), RunHeartbeatLoop(), RunCertificateRefreshLoop() ); } /// /// Helper method to invoke the rekey callback /// /// An awaitable task private Task InvokeReKey() => _onReKey(new ClaimedClientData(_token, _serverUrl, _certificateUrl, _serverKeys, null)); /// /// Creates a new connection to the remote server /// /// The url to use /// The JWT to use /// The certificate url to use /// The server keys to use /// The token to cancel the connection /// The callback to call when rekeying /// The callback to call when a message is received /// public static Task Start(string serverUrl, string JWT, string certificateUrl, IEnumerable serverKeys, CancellationToken cancellationToken, Func onReKey, Func onMessage) => Task.Run(async () => { using var connection = new KeepRemoteConnection(serverUrl, JWT, certificateUrl, serverKeys, cancellationToken, onReKey, onMessage); await connection._runnerTask; }); /// /// Gets the task representing the connection /// /// The task public Task Run() => _runnerTask; /// /// Stops the connection /// /// An awaitable task public Task Stop() { _cancellationTokenSource.Cancel(); return _runnerTask; } /// /// Sends an enveloped message to the remote server /// /// The envelope to send /// True if the message was sent private bool SendEnvelope(EnvelopedMessage envelope, bool force = true) { if ((_state != ConnectionState.Authenticated && !force) || _serverPublicKey == null) return false; _client.Send(TransportHelper.CreateEncryptedMessage(envelope with { From = ClientId }, _serverPublicKey)); return true; } /// /// Sends a new command to the server /// /// The message to send /// True if the message was sent public bool SendCommand(CommandRequestMessage message) { if (_state != ConnectionState.Authenticated || _serverPublicKey == null) return false; _client.Send(TransportHelper.CreateEncryptedMessage(new EnvelopedMessage() { From = ClientId, To = "server", Type = "command", MessageId = Guid.NewGuid().ToString(), Payload = JsonSerializer.Serialize(message, options: JsonOptions) }, _serverPublicKey)); return true; } /// /// The current state of the connection /// public ConnectionState State => _state; /// /// Creates a new connection to the remote server /// /// The url to use /// The JWT token to use /// /// The server keys to use /// The callback to call when rekeying /// The callback to call when a message is received /// The token to cancel the connection /// The connection object public static KeepRemoteConnection CreateRemoteListener(string serverUrl, string JWT, string certificateUrl, IEnumerable serverKeys, CancellationToken cancellationToken, Func onReKey, Func onMessage) => new KeepRemoteConnection(serverUrl, JWT, certificateUrl, serverKeys, cancellationToken, onReKey, onMessage); /// /// Sends a heartbeat message to the server /// /// The client to send the message with /// The token to cancel the heartbeat /// An awaitable task private async Task RunHeartbeatLoop() { while (!_cancellationTokenSource.Token.IsCancellationRequested) { await Task.Delay(HeartbeatInterval, _cancellationTokenSource.Token); SendEnvelope(new EnvelopedMessage() { From = ClientId, To = "server", Type = "ping", MessageId = Guid.NewGuid().ToString() }); } } /// /// Runs a loop that refreshes the server certificates /// /// An awaitable task private async Task RunCertificateRefreshLoop() { while (!_cancellationTokenSource.Token.IsCancellationRequested) { var t = await Task.WhenAny(_refreshCertificates.Task, Task.Delay(CertificateRefreshInterval, _cancellationTokenSource.Token)); if (_cancellationTokenSource.Token.IsCancellationRequested) return; if (t == _refreshCertificates.Task) Interlocked.Exchange(ref _refreshCertificates, new TaskCompletionSource()); if (_lastCertificateRefresh.AddMinutes(5) < DateTime.Now) { using var client = new HttpClient(); var response = await client.GetAsync(_certificateUrl); if (response.IsSuccessStatusCode) { using var stream = await response.Content.ReadAsStreamAsync(_cancellationTokenSource.Token); var serverKeys = await JsonSerializer.DeserializeAsync>(stream, options: RegisterForRemote.JsonOptions, cancellationToken: _cancellationTokenSource.Token); if (serverKeys != null && serverKeys.Any()) { _lastCertificateRefresh = DateTime.Now; _serverKeys = serverKeys .Where(x => !x.HasExpired() && !string.IsNullOrWhiteSpace(x.PublicKeyHash) && !string.IsNullOrWhiteSpace(x.PublicKey)) .ToList(); await InvokeReKey(); } } } } } /// public void Dispose() { _cancellationTokenSource.Cancel(); _client.Dispose(); _cancellationTokenSource.Dispose(); } /// /// A wrapper for allowing external code to handle a command message /// public sealed class CommandMessage { /// /// The callback method that will receive the response /// private readonly Func _respondCommand; /// /// The command request message /// public CommandRequestMessage CommandRequestMessage { get; } /// /// Creates a new command message /// /// The command request message /// The callback method that will receive the response public CommandMessage(CommandRequestMessage commandRequestMessage, Func respondCommand) { CommandRequestMessage = commandRequestMessage; _respondCommand = respondCommand; } /// /// Responds to the command message /// /// The response to send /// True if the response was sent public bool Respond(CommandResponseMessage response) => _respondCommand(response); /// /// Handles the command message with a configured http client. /// The client must be configured with the correct base address and authorization headers. /// /// The pre-configured http client /// An awaitable task public async Task Handle(HttpClient client) { var request = new HttpRequestMessage(new HttpMethod(CommandRequestMessage.Method), CommandRequestMessage.Path); if (!string.IsNullOrWhiteSpace(CommandRequestMessage.Body)) request.Content = new ByteArrayContent(Convert.FromBase64String(CommandRequestMessage.Body)); if (CommandRequestMessage.Headers != null) foreach (var header in CommandRequestMessage.Headers) request.Headers.Add(header.Key, header.Value); var response = await client.SendAsync(request); var responseBody = await response.Content.ReadAsByteArrayAsync(); var responseHeaders = response.Headers.ToDictionary(x => x.Key, x => x.Value.First()); Respond(new CommandResponseMessage((int)response.StatusCode, responseBody == null ? null : Convert.ToBase64String(responseBody), responseHeaders)); } } }