diff --git a/Duplicati/Library/RemoteControl/Duplicati.Library.RemoteControl.csproj b/Duplicati/Library/RemoteControl/Duplicati.Library.RemoteControl.csproj index 36fe7117a..0abbc5b57 100644 --- a/Duplicati/Library/RemoteControl/Duplicati.Library.RemoteControl.csproj +++ b/Duplicati/Library/RemoteControl/Duplicati.Library.RemoteControl.csproj @@ -9,6 +9,7 @@ + diff --git a/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs b/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs index 64eb121df..6c82729c9 100644 --- a/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs +++ b/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs @@ -19,7 +19,6 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. -using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -55,10 +54,10 @@ internal enum MessageType /// /// A message to authenticate with /// -/// The client token +/// The client token /// The client public key /// The version of the client -public record AuthMessage(string JwToken, string PublicKey, string ClientVersion); +public record AuthMessage(string Token, string PublicKey, string ClientVersion); /// /// A message authentication response @@ -78,6 +77,14 @@ internal sealed record AuthResultMessage(bool? Accepted, bool? WillReplaceToken, /// The optional headers to add public sealed record CommandRequestMessage(string Method, string Path, byte[]? Body, Dictionary? Headers); +/// +/// The welcome message from the server +/// +/// The public key hash of the server key +/// The name of the machine +/// The version of the server +public sealed record WelcomeMessage(string PublicKeyHash, string MachineName, string ServerVersion); + /// /// A message to respond to a command /// @@ -111,14 +118,6 @@ internal sealed record EnvelopedMessage /// The payload of the message /// public string? Payload { get; init; } - /// - /// The public key hash - /// - public string? PublicKeyHash { get; init; } - /// - /// The signature of the payload - /// - public string? Signature { get; init; } /// /// Parses a raw message into an envelope, throwing on error @@ -129,15 +128,6 @@ internal sealed record EnvelopedMessage public static EnvelopedMessage ForceParse(string? rawMessage) => FromString(rawMessage ?? throw new EnvelopeJsonParsingException("Invalid Json message")) ?? throw new EnvelopeJsonParsingException("Invalid Json message"); - /// - /// Parses a raw message into an envelope, returning null on error - /// - /// The raw message to parse - /// The parsed envelope or null - /// Thrown when the message is invalid - public static EnvelopedMessage? FromBytes(byte[] rawMessageBytes) - => FromString(Encoding.UTF8.GetString(rawMessageBytes)); - /// /// Parses a raw message into an envelope, returning null on error /// @@ -148,7 +138,7 @@ internal sealed record EnvelopedMessage { try { - return JsonSerializer.Deserialize(rawMessage); + return JsonSerializer.Deserialize(rawMessage, options: KeepRemoteConnection.JsonOptions); } catch (JsonException jex) { @@ -162,7 +152,7 @@ internal sealed record EnvelopedMessage /// The Json string representation of the envelope public string ToJson() { - return JsonSerializer.Serialize(this); + return JsonSerializer.Serialize(this, options: KeepRemoteConnection.JsonOptions); } /// @@ -172,91 +162,8 @@ internal sealed record EnvelopedMessage /// The parsed payload /// Thrown when the message is invalid public T GetPayload() - => JsonSerializer.Deserialize(Payload ?? throw new EnvelopeJsonParsingException("Invalid Json message")) ?? throw new EnvelopeJsonParsingException("Invalid Json message"); - - /// - /// Computes the signature of the payload - /// - /// The private key to use - /// The computed signature - public string? ComputePayloadSignature(string? pemPrivatekey) - { - if (string.IsNullOrWhiteSpace(pemPrivatekey) || (Payload is null && MessageId is null)) - return null; - - using RSA rsa = RSA.Create(); - rsa.ImportFromPem(pemPrivatekey); - return BitConverter.ToString( - rsa.SignData(Encoding.UTF8.GetBytes($"{Payload}::{MessageId}"), HashAlgorithmName.SHA256, RSASignaturePadding.Pss) - ).Replace("-", "").ToLower(); - } - - /// - /// Computes the signature of the payload - /// - /// The private key to use - /// The computed signature - public string? ComputePayloadSignature(RSA key) - { - if (key is null || Payload is null && MessageId is null) - return null; - - return BitConverter.ToString( - key.SignData(Encoding.UTF8.GetBytes($"{Payload}::{MessageId}"), HashAlgorithmName.SHA256, RSASignaturePadding.Pss) - ).Replace("-", "").ToLower(); - } - - /// - /// Validates the message signature - /// - /// The public key for the server that sent - public void ValidateSignature(string? pemPublicKey) - { - if (string.IsNullOrWhiteSpace(Signature) || string.IsNullOrWhiteSpace(pemPublicKey) || (Payload is null && MessageId is null)) - throw new EnvelopeJsonParsingException("Invalid Json message"); - - using RSA rsa = RSA.Create(); - rsa.ImportFromPem(pemPublicKey); - if (!rsa.VerifyData(Encoding.UTF8.GetBytes($"{Payload}::{MessageId}"), Convert.FromHexString(Signature), HashAlgorithmName.SHA256, RSASignaturePadding.Pss)) - throw new EnvelopeJsonParsingException("Invalid Json message"); - } - - /// - /// Creates the signature on the returned message - /// - /// The private key to use - /// The signed message - public EnvelopedMessage WithSignature(RSA key) - => this with { Signature = ComputePayloadSignature(key) }; - - /// - /// Creates a new message with a payload - /// - /// The type of payload - /// The payload to add - /// The private key to use for signing - /// The new message - public EnvelopedMessage WithPayload(T payload, string? pemPrivatekey = null) - => this with { Payload = JsonSerializer.Serialize(payload), Signature = ComputePayloadSignature(pemPrivatekey) }; - - /// - /// Creates a new envelope to respond to the current message - /// - /// The type of payload - /// The payload to add - /// The type of message to send - /// The private key to use for signing - /// The new message - public EnvelopedMessage RespondWith(T payload, string? type = null, string? pemPrivatekey = null) - => new EnvelopedMessage - { - From = To, - To = From, - Type = type ?? Type, - MessageId = MessageId, - Payload = JsonSerializer.Serialize(payload), - Signature = ComputePayloadSignature(pemPrivatekey) - }; + => JsonSerializer.Deserialize(Payload ?? throw new EnvelopeJsonParsingException("Invalid Json message"), options: KeepRemoteConnection.JsonOptions) + ?? throw new EnvelopeJsonParsingException("Invalid Json message"); /// /// Gets the type of message @@ -273,4 +180,21 @@ internal sealed record EnvelopedMessage _ => MessageType.Unknown }; } + + /// + /// Responds to the message with a payload + /// + /// The type of payload to respond with + /// The payload to respond with + /// The type of message to respond with + /// The response message + public EnvelopedMessage RespondWith(T payload, string? type = null) + => new EnvelopedMessage + { + From = To, + To = From, + Type = type ?? Type, + MessageId = MessageId, + Payload = JsonSerializer.Serialize(payload, options: KeepRemoteConnection.JsonOptions) + }; } \ No newline at end of file diff --git a/Duplicati/Library/RemoteControl/KeepRemoteConnection.cs b/Duplicati/Library/RemoteControl/KeepRemoteConnection.cs index 07a2247b6..cfd04b8f5 100644 --- a/Duplicati/Library/RemoteControl/KeepRemoteConnection.cs +++ b/Duplicati/Library/RemoteControl/KeepRemoteConnection.cs @@ -21,7 +21,6 @@ using System.Net; using System.Security.Cryptography; -using System.Text; using System.Text.Json; using Duplicati.Library.Logging; @@ -45,7 +44,7 @@ public class KeepRemoteConnection : IDisposable /// /// The interval between heartbeats /// - private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(5); + private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(15); /// /// The interval between certificate refreshes @@ -64,6 +63,15 @@ public class KeepRemoteConnection : IDisposable ? Guid.NewGuid().ToString() : AutoUpdater.UpdaterManager.MachineID; + /// + /// The JSON options to use for deserialization + /// + internal static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = null, + PropertyNameCaseInsensitive = true + }; + /// /// The stats the connection can be in /// @@ -74,10 +82,6 @@ public class KeepRemoteConnection : IDisposable /// NotConnected, /// - /// The connection is established, but not authenticated - /// - Connected, - /// /// We received a welcome message /// WelcomeReceived, @@ -100,10 +104,6 @@ public class KeepRemoteConnection : IDisposable /// private ConnectionState _state = ConnectionState.NotConnected; /// - /// The nonce challenge - /// - private string? _challenge; - /// /// The task that runs the connection /// private Task _runnerTask; @@ -111,6 +111,11 @@ public class KeepRemoteConnection : IDisposable /// The currently negotiated server certificate /// private MiniServerCertificate? _serverCertificate; + /// + /// The public key of the server + /// + private RSA? _serverPublicKey; + /// /// The time the certificate was last refreshed /// @@ -171,18 +176,16 @@ public class KeepRemoteConnection : IDisposable /// private Task RunMainLoop() { + //TODO: If we close the socket, it reconnects immediately + // casuing excessive usage _client.ReconnectTimeout = ReconnectInterval; - - _client.ReconnectionHappened.Subscribe(info => - { - _state = ConnectionState.Connected; - Log.WriteMessage(LogMessageType.Information, LogTag, "WebsocketReconnect", "Reconnected to the server"); - }); + _client.IsReconnectionEnabled = true; _client.DisconnectionHappened.Subscribe(info => { _state = ConnectionState.NotConnected; _serverCertificate = null; + _serverPublicKey = null; Log.WriteMessage(LogMessageType.Warning, LogTag, "WebsocketDisconnect", "Disconnected from the server"); }); @@ -192,42 +195,65 @@ public class KeepRemoteConnection : IDisposable try { - var envelope = EnvelopedMessage.ForceParse(msg.Text); - if (_serverCertificate == null || _state == ConnectionState.Connected) + if (string.IsNullOrWhiteSpace(msg.Text)) + throw new ProtocolViolationException("Empty message"); + + if (_serverCertificate == null || _serverPublicKey == null || _state == ConnectionState.NotConnected) { - if (envelope.GetMessageType() != MessageType.Welcome) + // 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(envelope.PublicKeyHash)) + 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 == envelope.PublicKeyHash && x.Expiry > DateTimeOffset.Now); + _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"), + "auth" + ), + force: true); + return; } - if (_serverCertificate == null) + if (_serverCertificate == null || _serverPublicKey == null || _serverCertificate.HasExpired()) { _refreshCertificates.TrySetResult(true); throw new ProtocolViolationException("No valid server certificate"); } - envelope.ValidateSignature(_serverCertificate?.PublicKey); - - if (_state == ConnectionState.Connected) - { - // TODO: The message could be a replay attack - if (envelope.GetMessageType() != MessageType.Welcome) - throw new ProtocolViolationException("Expected welcome message"); - - _state = ConnectionState.WelcomeReceived; - Log.WriteMessage(LogMessageType.Information, LogTag, "WebsocketAuthenticated", "Connected with the server"); - - SendEnvelope(envelope.RespondWith(new AuthMessage(_token, ClientKey.ExportSubjectPublicKeyInfoPem(), Library.AutoUpdater.UpdaterManager.SelfVersion.Version ?? "0.0.0"))); - } - else if (_state == ConnectionState.WelcomeReceived) + var envelope = TransportHelper.ParseFromEncryptedMessage(msg.Text, ClientKey); + if (_state == ConnectionState.WelcomeReceived) { if (envelope.GetMessageType() != MessageType.Auth) throw new ProtocolViolationException("Expected welcome message"); @@ -252,11 +278,10 @@ public class KeepRemoteConnection : IDisposable break; case MessageType.Command: - await _onMessage(new CommandMessage(envelope.GetPayload(), response => - { - SendEnvelope(envelope.RespondWith(response)); - return true; - })); + await _onMessage(new CommandMessage( + envelope.GetPayload(), + response => SendEnvelope(envelope.RespondWith(response)) + )); break; default: @@ -332,12 +357,12 @@ public class KeepRemoteConnection : IDisposable /// /// The envelope to send /// True if the message was sent - private bool SendEnvelope(EnvelopedMessage envelope) + private bool SendEnvelope(EnvelopedMessage envelope, bool force = true) { - if (_state != ConnectionState.Authenticated) + if ((_state != ConnectionState.Authenticated && !force) || _serverPublicKey == null) return false; - _client.Send((envelope with { From = ClientId }).WithSignature(ClientKey).ToJson()); + _client.Send(TransportHelper.CreateEncryptedMessage(envelope with { From = ClientId }, _serverPublicKey)); return true; } @@ -348,18 +373,17 @@ public class KeepRemoteConnection : IDisposable /// True if the message was sent public bool SendCommand(CommandRequestMessage message) { - if (_state != ConnectionState.Authenticated) + if (_state != ConnectionState.Authenticated || _serverPublicKey == null) return false; - _client.Send(new EnvelopedMessage() + _client.Send(TransportHelper.CreateEncryptedMessage(new EnvelopedMessage() { From = ClientId, To = "server", Type = "command", - MessageId = Guid.NewGuid().ToString() - } - .WithPayload(message) - .WithSignature(ClientKey).ToJson()); + MessageId = Guid.NewGuid().ToString(), + Payload = JsonSerializer.Serialize(message, options: JsonOptions) + }, _serverPublicKey)); return true; } @@ -426,11 +450,14 @@ public class KeepRemoteConnection : IDisposable if (response.IsSuccessStatusCode) { using var stream = await response.Content.ReadAsStreamAsync(_cancellationTokenSource.Token); - var serverKeys = await JsonSerializer.DeserializeAsync>(stream, cancellationToken: _cancellationTokenSource.Token); + var serverKeys = await JsonSerializer.DeserializeAsync>(stream, options: RegisterForRemote.JsonOptions, cancellationToken: _cancellationTokenSource.Token); if (serverKeys != null && serverKeys.Any()) { _lastCertificateRefresh = DateTime.Now; - _serverKeys = serverKeys; + _serverKeys = serverKeys + .Where(x => !x.HasExpired() && !string.IsNullOrWhiteSpace(x.PublicKeyHash) && !string.IsNullOrWhiteSpace(x.PublicKey)) + .ToList(); + await InvokeReKey(); } } diff --git a/Duplicati/Library/RemoteControl/RegisterForRemote.cs b/Duplicati/Library/RemoteControl/RegisterForRemote.cs index aeff3648c..344e7d9f0 100644 --- a/Duplicati/Library/RemoteControl/RegisterForRemote.cs +++ b/Duplicati/Library/RemoteControl/RegisterForRemote.cs @@ -20,6 +20,7 @@ // DEALINGS IN THE SOFTWARE. using System.Net.Http.Json; +using System.Text.Json; using Duplicati.Library.Logging; using Duplicati.Library.Utility; @@ -63,6 +64,15 @@ public class RegisterForRemote : IDisposable } } + /// + /// 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 /// @@ -223,7 +233,7 @@ public class RegisterForRemote : IDisposable }), _cancellationTokenSource.Token); response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync() + return await response.Content.ReadFromJsonAsync(options: JsonOptions, _cancellationTokenSource.Token) ?? throw new Exception("Failed to read client registration data"); } @@ -266,7 +276,7 @@ public class RegisterForRemote : IDisposable var response = await _httpClient.PostAsync(_registerClientData!.StatusLink, CreateMachineData(), _cancellationTokenSource.Token); response.EnsureSuccessStatusCode(); - var result = await response.Content.ReadFromJsonAsync() + var result = await response.Content.ReadFromJsonAsync(options: JsonOptions, _cancellationTokenSource.Token) ?? throw new Exception("Failed to read machine claim data"); if (!result.Success) diff --git a/Duplicati/Library/RemoteControl/SharedTypes.cs b/Duplicati/Library/RemoteControl/SharedTypes.cs index f588d54a0..91f06ada3 100644 --- a/Duplicati/Library/RemoteControl/SharedTypes.cs +++ b/Duplicati/Library/RemoteControl/SharedTypes.cs @@ -69,4 +69,11 @@ public sealed record MiniServerCertificate( string PublicKey, DateTimeOffset Obtained, DateTimeOffset Expiry -); \ No newline at end of file +) +{ + /// + /// Checks if the certificate has expired + /// + /// true if the certificate has expired; otherwise, false + public bool HasExpired() => DateTimeOffset.UtcNow > Expiry; +} \ No newline at end of file diff --git a/Duplicati/Library/RemoteControl/TransportHelper.cs b/Duplicati/Library/RemoteControl/TransportHelper.cs new file mode 100644 index 000000000..48783ae9c --- /dev/null +++ b/Duplicati/Library/RemoteControl/TransportHelper.cs @@ -0,0 +1,88 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Jose; + +namespace Duplicati.Library.RemoteControl; + +/// +/// Helper class for transport related functions +/// +internal static class TransportHelper +{ + /// + /// Creates a signed message in JWT format using the provided private key + /// + /// The message to sign + /// The private key to sign with + /// The signed message + public static string CreateSignedMessage(EnvelopedMessage message, RSA privateKey) + { + return JWT.Encode( + JsonSerializer.Serialize(message, options: KeepRemoteConnection.JsonOptions), + new Jwk(privateKey, false), + JwsAlgorithm.RS256, + extraHeaders: new Dictionary() + { + { "encrypted", "false" }, + { "version", "1" } + } + ); + } + + /// + /// Creates an encrypted message in JWE format using the provided public key + /// + /// The message to encrypt + /// The public key to encrypt with + /// The encrypted message + public static string CreateEncryptedMessage(EnvelopedMessage message, RSA publicKey) + { + return JWT.Encode( + JsonSerializer.Serialize(message, options: KeepRemoteConnection.JsonOptions), + new Jwk(publicKey, false), + JweAlgorithm.RSA_OAEP_256, + JweEncryption.A256CBC_HS512, + extraHeaders: new Dictionary() + { + { "encrypted", "true" }, + { "version", "1" } + } + ); + } + + /// + /// Parses a signed message using the provided public key + /// + /// The signed message to parse + /// The public key to verify with + /// The parsed message + public static EnvelopedMessage ParseFromSignedMessage(string message, RSA publicKey) + => ParsedFromEncodedMessage(message, publicKey, false); + + /// + /// Parses an encrypted message using the provided key + /// + /// The encrypted message to parse + /// The private key to decrypt with + /// The parsed message + public static EnvelopedMessage ParseFromEncryptedMessage(string message, RSA privateKey) + => ParsedFromEncodedMessage(message, privateKey, true); + + /// + /// Parses an encrypted message using the provided key + /// + /// The encrypted message to parse + /// The private key to decrypt with + /// The parsed message + private static EnvelopedMessage ParsedFromEncodedMessage(string message, RSA key, bool isPrivateKey) + { + try + { + return EnvelopedMessage.ForceParse(JWT.Decode(message, new Jwk(key, isPrivateKey))); + } + catch (Exception ex) + { + throw new InvalidOperationException("Invalid message", ex); + } + } +} diff --git a/Duplicati/Library/RestAPI/Database/ServerSettings.cs b/Duplicati/Library/RestAPI/Database/ServerSettings.cs index 50e816ed0..1eaa4c114 100644 --- a/Duplicati/Library/RestAPI/Database/ServerSettings.cs +++ b/Duplicati/Library/RestAPI/Database/ServerSettings.cs @@ -57,6 +57,7 @@ namespace Duplicati.Server.Database public const string SERVER_ALLOWED_HOSTNAMES = "allowed-hostnames"; public const string JWT_CONFIG = "jwt-config"; public const string REMOTE_CONTROL_CONFIG = "remote-control-config"; + public const string REMOTE_CONTROL_ENABLED = "remote-control-enabled"; public const string PBKDF_CONFIG = "pbkdf-config"; public const string AUTOGENERATED_PASSPHRASE = "autogenerated-passphrase"; public const string DISABLE_VISUAL_CAPTCHA = "disable-visual-captcha"; @@ -499,6 +500,17 @@ namespace Duplicati.Server.Database } } + public bool RemoteControlEnabled + { + get => Duplicati.Library.Utility.Utility.ParseBool(settings[CONST.REMOTE_CONTROL_ENABLED], false); + set + { + lock (databaseConnection.m_lock) + settings[CONST.REMOTE_CONTROL_ENABLED] = value.ToString(); + SaveSettings(); + } + } + public DateTime LastUpdateCheck { get diff --git a/Duplicati/WebserverCore/DuplicatiWebserver.cs b/Duplicati/WebserverCore/DuplicatiWebserver.cs index 68e0fcddf..ac42fbf8d 100644 --- a/Duplicati/WebserverCore/DuplicatiWebserver.cs +++ b/Duplicati/WebserverCore/DuplicatiWebserver.cs @@ -237,6 +237,9 @@ public partial class DuplicatiWebserver } }); }); + + if (connection.ApplicationSettings.RemoteControlEnabled) + App.Services.GetRequiredService().Enable(); } public Task Start(InitSettings settings) diff --git a/Duplicati/WebserverCore/Services/RemoteControllerService.cs b/Duplicati/WebserverCore/Services/RemoteControllerService.cs index c3a7fab05..3d78d1152 100644 --- a/Duplicati/WebserverCore/Services/RemoteControllerService.cs +++ b/Duplicati/WebserverCore/Services/RemoteControllerService.cs @@ -38,7 +38,7 @@ public class RemoteControllerService(Connection connection, IHttpClientFactory h /// /// Gets a value indicating whether remote control is enabled. /// - public bool IsEnabled => _keepRemoteConnection != null; + public bool IsEnabled => connection.ApplicationSettings.RemoteControlEnabled; /// /// Gets a value indicating whether remote control can be enabled. @@ -67,7 +67,7 @@ public class RemoteControllerService(Connection connection, IHttpClientFactory h /// public void Enable() { - if (IsEnabled) + if (_keepRemoteConnection != null) return; if (!CanEnable) @@ -85,6 +85,8 @@ public class RemoteControllerService(Connection connection, IHttpClientFactory h ReKey, OnMessage ); + + connection.ApplicationSettings.RemoteControlEnabled = true; } /// @@ -134,6 +136,7 @@ public class RemoteControllerService(Connection connection, IHttpClientFactory h { _keepRemoteConnection?.Dispose(); _keepRemoteConnection = null; + connection.ApplicationSettings.RemoteControlEnabled = false; } ///