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;
}
///