Added feature to keep the remote control setting, and reconnect on startup.

Updated message types to fit the procotol.
This commit is contained in:
Kenneth Skovhede
2024-09-27 16:14:40 +02:00
parent 1272d44a63
commit 227b8d6dca
9 changed files with 239 additions and 164 deletions
@@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="jose-jwt" Version="5.0.0" />
<PackageReference Include="Websocket.Client" Version="5.1.2" />
</ItemGroup>
@@ -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
/// <summary>
/// A message to authenticate with
/// </summary>
/// <param name="JwToken">The client token</param>
/// <param name="Token">The client token</param>
/// <param name="PublicKey">The client public key</param>
/// <param name="Version">The version of the client</param>
public record AuthMessage(string JwToken, string PublicKey, string ClientVersion);
public record AuthMessage(string Token, string PublicKey, string ClientVersion);
/// <summary>
/// A message authentication response
@@ -78,6 +77,14 @@ internal sealed record AuthResultMessage(bool? Accepted, bool? WillReplaceToken,
/// <param name="Headers">The optional headers to add</param>
public sealed record CommandRequestMessage(string Method, string Path, byte[]? Body, Dictionary<string, string>? Headers);
/// <summary>
/// The welcome message from the server
/// </summary>
/// <param name="PublicKeyHash">The public key hash of the server key</param>
/// <param name="MachineName">The name of the machine</param>
/// <param name="ServerVersion">The version of the server</param>
public sealed record WelcomeMessage(string PublicKeyHash, string MachineName, string ServerVersion);
/// <summary>
/// A message to respond to a command
/// </summary>
@@ -111,14 +118,6 @@ internal sealed record EnvelopedMessage
/// The payload of the message
/// </summary>
public string? Payload { get; init; }
/// <summary>
/// The public key hash
/// </summary>
public string? PublicKeyHash { get; init; }
/// <summary>
/// The signature of the payload
/// </summary>
public string? Signature { get; init; }
/// <summary>
/// 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");
/// <summary>
/// Parses a raw message into an envelope, returning null on error
/// </summary>
/// <param name="rawMessageBytes">The raw message to parse</param>
/// <returns>The parsed envelope or null</returns>
/// <exception cref="EnvelopeJsonParsingException">Thrown when the message is invalid</exception>
public static EnvelopedMessage? FromBytes(byte[] rawMessageBytes)
=> FromString(Encoding.UTF8.GetString(rawMessageBytes));
/// <summary>
/// Parses a raw message into an envelope, returning null on error
/// </summary>
@@ -148,7 +138,7 @@ internal sealed record EnvelopedMessage
{
try
{
return JsonSerializer.Deserialize<EnvelopedMessage>(rawMessage);
return JsonSerializer.Deserialize<EnvelopedMessage>(rawMessage, options: KeepRemoteConnection.JsonOptions);
}
catch (JsonException jex)
{
@@ -162,7 +152,7 @@ internal sealed record EnvelopedMessage
/// <returns>The Json string representation of the envelope</returns>
public string ToJson()
{
return JsonSerializer.Serialize(this);
return JsonSerializer.Serialize(this, options: KeepRemoteConnection.JsonOptions);
}
/// <summary>
@@ -172,91 +162,8 @@ internal sealed record EnvelopedMessage
/// <returns>The parsed payload</returns>
/// <exception cref="EnvelopeJsonParsingException">Thrown when the message is invalid</exception>
public T GetPayload<T>()
=> JsonSerializer.Deserialize<T>(Payload ?? throw new EnvelopeJsonParsingException("Invalid Json message")) ?? throw new EnvelopeJsonParsingException("Invalid Json message");
/// <summary>
/// Computes the signature of the payload
/// </summary>
/// <param name="pemPrivatekey">The private key to use</param>
/// <returns>The computed signature</returns>
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();
}
/// <summary>
/// Computes the signature of the payload
/// </summary>
/// <param name="key">The private key to use</param>
/// <returns>The computed signature</returns>
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();
}
/// <summary>
/// Validates the message signature
/// </summary>
/// <param name="pemPublicKey">The public key for the server that sent</param>
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");
}
/// <summary>
/// Creates the signature on the returned message
/// </summary>
/// <param name="key">The private key to use</param>
/// <returns>The signed message</returns>
public EnvelopedMessage WithSignature(RSA key)
=> this with { Signature = ComputePayloadSignature(key) };
/// <summary>
/// Creates a new message with a payload
/// </summary>
/// <typeparam name="T">The type of payload</typeparam>
/// <param name="payload">The payload to add</param>
/// <param name="pemPrivatekey">The private key to use for signing</param>
/// <returns>The new message</returns>
public EnvelopedMessage WithPayload<T>(T payload, string? pemPrivatekey = null)
=> this with { Payload = JsonSerializer.Serialize(payload), Signature = ComputePayloadSignature(pemPrivatekey) };
/// <summary>
/// Creates a new envelope to respond to the current message
/// </summary>
/// <typeparam name="T">The type of payload</typeparam>
/// <param name="payload">The payload to add</param>
/// <param name="type">The type of message to send</param>
/// <param name="pemPrivatekey">The private key to use for signing</param>
/// <returns>The new message</returns>
public EnvelopedMessage RespondWith<T>(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<T>(Payload ?? throw new EnvelopeJsonParsingException("Invalid Json message"), options: KeepRemoteConnection.JsonOptions)
?? throw new EnvelopeJsonParsingException("Invalid Json message");
/// <summary>
/// Gets the type of message
@@ -273,4 +180,21 @@ internal sealed record EnvelopedMessage
_ => MessageType.Unknown
};
}
/// <summary>
/// Responds to the message with a payload
/// </summary>
/// <typeparam name="T">The type of payload to respond with</typeparam>
/// <param name="payload">The payload to respond with</param>
/// <param name="type">The type of message to respond with</param>
/// <returns>The response message</returns>
public EnvelopedMessage RespondWith<T>(T payload, string? type = null)
=> new EnvelopedMessage
{
From = To,
To = From,
Type = type ?? Type,
MessageId = MessageId,
Payload = JsonSerializer.Serialize(payload, options: KeepRemoteConnection.JsonOptions)
};
}
@@ -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
/// <summary>
/// The interval between heartbeats
/// </summary>
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(5);
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(15);
/// <summary>
/// The interval between certificate refreshes
@@ -64,6 +63,15 @@ public class KeepRemoteConnection : IDisposable
? Guid.NewGuid().ToString()
: AutoUpdater.UpdaterManager.MachineID;
/// <summary>
/// The JSON options to use for deserialization
/// </summary>
internal static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = null,
PropertyNameCaseInsensitive = true
};
/// <summary>
/// The stats the connection can be in
/// </summary>
@@ -74,10 +82,6 @@ public class KeepRemoteConnection : IDisposable
/// </summary>
NotConnected,
/// <summary>
/// The connection is established, but not authenticated
/// </summary>
Connected,
/// <summary>
/// We received a welcome message
/// </summary>
WelcomeReceived,
@@ -100,10 +104,6 @@ public class KeepRemoteConnection : IDisposable
/// </summary>
private ConnectionState _state = ConnectionState.NotConnected;
/// <summary>
/// The nonce challenge
/// </summary>
private string? _challenge;
/// <summary>
/// The task that runs the connection
/// </summary>
private Task _runnerTask;
@@ -111,6 +111,11 @@ public class KeepRemoteConnection : IDisposable
/// The currently negotiated server certificate
/// </summary>
private MiniServerCertificate? _serverCertificate;
/// <summary>
/// The public key of the server
/// </summary>
private RSA? _serverPublicKey;
/// <summary>
/// The time the certificate was last refreshed
/// </summary>
@@ -171,18 +176,16 @@ public class KeepRemoteConnection : IDisposable
/// </summary>
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<WelcomeMessage>()
?? 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<CommandRequestMessage>(), response =>
{
SendEnvelope(envelope.RespondWith(response));
return true;
}));
await _onMessage(new CommandMessage(
envelope.GetPayload<CommandRequestMessage>(),
response => SendEnvelope(envelope.RespondWith(response))
));
break;
default:
@@ -332,12 +357,12 @@ public class KeepRemoteConnection : IDisposable
/// </summary>
/// <param name="envelope">The envelope to send</param>
/// <returns>True if the message was sent</returns>
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
/// <returns>True if the message was sent</returns>
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<IEnumerable<MiniServerCertificate>>(stream, cancellationToken: _cancellationTokenSource.Token);
var serverKeys = await JsonSerializer.DeserializeAsync<IEnumerable<MiniServerCertificate>>(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();
}
}
@@ -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
}
}
/// <summary>
/// The Json options to use for serialization
/// </summary>
internal static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
/// <summary>
/// The states that the process can be in
/// </summary>
@@ -223,7 +233,7 @@ public class RegisterForRemote : IDisposable
}), _cancellationTokenSource.Token);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<RegisterClientData>()
return await response.Content.ReadFromJsonAsync<RegisterClientData>(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<EnvelopedClaimedClientData>()
var result = await response.Content.ReadFromJsonAsync<EnvelopedClaimedClientData>(options: JsonOptions, _cancellationTokenSource.Token)
?? throw new Exception("Failed to read machine claim data");
if (!result.Success)
@@ -69,4 +69,11 @@ public sealed record MiniServerCertificate(
string PublicKey,
DateTimeOffset Obtained,
DateTimeOffset Expiry
);
)
{
/// <summary>
/// Checks if the certificate has expired
/// </summary>
/// <returns><c>true</c> if the certificate has expired; otherwise, <c>false</c></returns>
public bool HasExpired() => DateTimeOffset.UtcNow > Expiry;
}
@@ -0,0 +1,88 @@
using System.Security.Cryptography;
using System.Text.Json;
using Jose;
namespace Duplicati.Library.RemoteControl;
/// <summary>
/// Helper class for transport related functions
/// </summary>
internal static class TransportHelper
{
/// <summary>
/// Creates a signed message in JWT format using the provided private key
/// </summary>
/// <param name="message">The message to sign</param>
/// <param name="privateKey">The private key to sign with</param>
/// <returns>The signed message</returns>
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<string, object>()
{
{ "encrypted", "false" },
{ "version", "1" }
}
);
}
/// <summary>
/// Creates an encrypted message in JWE format using the provided public key
/// </summary>
/// <param name="message">The message to encrypt</param>
/// <param name="publicKey">The public key to encrypt with</param>
/// <returns>The encrypted message</returns>
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<string, object>()
{
{ "encrypted", "true" },
{ "version", "1" }
}
);
}
/// <summary>
/// Parses a signed message using the provided public key
/// </summary>
/// <param name="message">The signed message to parse</param>
/// <param name="publicKey">The public key to verify with</param>
/// <returns>The parsed message</returns>
public static EnvelopedMessage ParseFromSignedMessage(string message, RSA publicKey)
=> ParsedFromEncodedMessage(message, publicKey, false);
/// <summary>
/// Parses an encrypted message using the provided key
/// </summary>
/// <param name="message">The encrypted message to parse</param>
/// <param name="privateKey">The private key to decrypt with</param>
/// <returns>The parsed message</returns>
public static EnvelopedMessage ParseFromEncryptedMessage(string message, RSA privateKey)
=> ParsedFromEncodedMessage(message, privateKey, true);
/// <summary>
/// Parses an encrypted message using the provided key
/// </summary>
/// <param name="message">The encrypted message to parse</param>
/// <param name="privateKey">The private key to decrypt with</param>
/// <returns>The parsed message</returns>
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);
}
}
}
@@ -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
@@ -237,6 +237,9 @@ public partial class DuplicatiWebserver
}
});
});
if (connection.ApplicationSettings.RemoteControlEnabled)
App.Services.GetRequiredService<IRemoteController>().Enable();
}
public Task Start(InitSettings settings)
@@ -38,7 +38,7 @@ public class RemoteControllerService(Connection connection, IHttpClientFactory h
/// <summary>
/// Gets a value indicating whether remote control is enabled.
/// </summary>
public bool IsEnabled => _keepRemoteConnection != null;
public bool IsEnabled => connection.ApplicationSettings.RemoteControlEnabled;
/// <summary>
/// Gets a value indicating whether remote control can be enabled.
@@ -67,7 +67,7 @@ public class RemoteControllerService(Connection connection, IHttpClientFactory h
/// </inheritdoc>
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;
}
/// <summary>
@@ -134,6 +136,7 @@ public class RemoteControllerService(Connection connection, IHttpClientFactory h
{
_keepRemoteConnection?.Dispose();
_keepRemoteConnection = null;
connection.ApplicationSettings.RemoteControlEnabled = false;
}
/// </inheritdoc>