Added feature to keep the remote control setting, and reconnect on startup.
Updated message types to fit the procotol.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user