2026-04-16 15:21:24 +02:00
// Copyright (C) 2026, The Duplicati Team
2024-09-18 11:07:59 +02:00
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
2025-03-03 17:39:25 +01:00
using System.Diagnostics ;
2024-09-18 11:07:59 +02:00
using System.Net ;
2024-09-28 18:53:35 +02:00
using System.Net.Http.Headers ;
2024-09-18 11:07:59 +02:00
using System.Security.Cryptography ;
2024-09-23 23:18:36 +02:00
using System.Text.Json ;
2026-05-13 15:04:13 +02:00
using CoCoL ;
2025-03-03 17:39:25 +01:00
using Duplicati.Library.AutoUpdater ;
2024-09-18 11:07:59 +02:00
using Duplicati.Library.Logging ;
2025-06-12 10:33:13 -03:00
using Duplicati.Library.Utility ;
using Uri = System . Uri ;
2024-09-18 11:07:59 +02:00
namespace Duplicati.Library.RemoteControl ;
/// <summary>
/// Support class for keeping a connection to a remote server
/// </summary>
public class KeepRemoteConnection : IDisposable
{
2024-09-28 16:08:57 +02:00
/// <summary>
/// The protocol version to use
/// </summary>
private const int PROTOCOL_VERSION = 1 ;
2024-09-18 11:07:59 +02:00
/// <summary>
/// The log tag for messages from this class
/// </summary>
private static readonly string LogTag = Log . LogTagFromType < KeepRemoteConnection >();
2024-09-23 23:18:36 +02:00
/// <summary>
/// The interval between reconnect attempts
/// </summary>
private static readonly TimeSpan ReconnectInterval = TimeSpan . FromSeconds ( 30 );
2024-10-01 08:42:34 +02:00
/// <summary>
2024-11-19 22:20:46 +01:00
/// The interval between heartbeats
2024-10-01 08:42:34 +02:00
/// </summary>
2024-11-19 22:20:46 +01:00
private static readonly TimeSpan HeartbeatInterval = TimeSpan . FromSeconds ( 60 );
2024-10-01 08:42:34 +02:00
2024-09-18 11:07:59 +02:00
/// <summary>
2024-11-19 22:20:46 +01:00
/// The time between reconnect attempts if no response is received
2024-09-18 11:07:59 +02:00
/// </summary>
2024-11-19 22:20:46 +01:00
private static readonly TimeSpan NoResponseTimeout = HeartbeatInterval * 2 ;
2024-09-18 11:07:59 +02:00
2024-10-02 14:50:35 +02:00
/// <summary>
/// The minimum time between certificate refreshes
/// </summary>
private static readonly TimeSpan MinimumCertificateRefreshInterval = TimeSpan . FromMinutes ( 5 );
2024-09-23 23:18:36 +02:00
/// <summary>
/// The interval between certificate refreshes
/// </summary>
private static readonly TimeSpan CertificateRefreshInterval = TimeSpan . FromDays ( 7 );
2024-09-18 11:07:59 +02:00
/// <summary>
/// The client key to use for signing messages
/// </summary>
2024-09-23 23:18:36 +02:00
private static readonly RSA ClientKey = RSA . Create ( 2048 );
2024-09-18 11:07:59 +02:00
/// <summary>
/// The client ID to use for identifying the client
/// </summary>
2024-09-28 18:53:35 +02:00
private static readonly string ClientId = Guid . NewGuid (). ToString ();
2024-09-18 11:07:59 +02:00
2024-09-27 16:14:40 +02:00
/// <summary>
/// The JSON options to use for deserialization
/// </summary>
internal static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
2024-09-28 16:08:57 +02:00
PropertyNamingPolicy = JsonNamingPolicy . CamelCase ,
2024-09-27 16:14:40 +02:00
PropertyNameCaseInsensitive = true
};
2024-09-18 11:07:59 +02:00
/// <summary>
/// The stats the connection can be in
/// </summary>
public enum ConnectionState
{
/// <summary>
/// The connection is not established
/// </summary>
NotConnected ,
/// <summary>
/// We received a welcome message
/// </summary>
WelcomeReceived ,
/// <summary>
/// The connection is authenticated
/// </summary>
2024-10-02 14:50:35 +02:00
Authenticated ,
/// <summary>
/// The connection is in an error state
/// </summary>
Error
2024-09-18 11:07:59 +02:00
}
/// <summary>
/// The websocket client
/// </summary>
private readonly Websocket . Client . WebsocketClient _client ;
/// <summary>
/// The cancellation token source
/// </summary>
private readonly CancellationTokenSource _cancellationTokenSource ;
/// <summary>
/// The current state of the connection
/// </summary>
private ConnectionState _state = ConnectionState . NotConnected ;
2026-05-08 19:10:01 +02:00
/// <summary>
/// Event raised when the connection state changes
/// </summary>
public event EventHandler < ConnectionState >? StateChanged ;
/// <summary>
/// Sets the connection state and raises the StateChanged event if it changed
/// </summary>
/// <param name="newState">The new state</param>
private void SetState ( ConnectionState newState )
{
if ( _state == newState )
return ;
_state = newState ;
StateChanged ?. Invoke ( this , newState );
}
2024-09-18 11:07:59 +02:00
/// <summary>
/// The task that runs the connection
/// </summary>
private Task _runnerTask ;
2024-09-23 23:18:36 +02:00
/// <summary>
/// The currently negotiated server certificate
/// </summary>
private MiniServerCertificate ? _serverCertificate ;
2024-09-27 16:14:40 +02:00
/// <summary>
/// The public key of the server
/// </summary>
private RSA ? _serverPublicKey ;
2025-03-03 17:39:25 +01:00
/// <summary>
/// The callback to call when connecting
/// </summary>
private readonly Func < Dictionary < string , string? >, Task < Dictionary < string , string? >>> _onConnect ;
2024-09-23 23:18:36 +02:00
/// <summary>
/// The callback to call when rekeying
/// </summary>
private readonly Func < ClaimedClientData , Task > _onReKey ;
/// <summary>
2025-03-03 17:39:25 +01:00
/// The callback to call when a control message is received
/// </summary>
private readonly Func < ControlMessage , Task > _onControl ;
/// <summary>
/// The callback to call when a command message is received
2024-09-23 23:18:36 +02:00
/// </summary>
private readonly Func < CommandMessage , Task > _onMessage ;
/// <summary>
/// The current JWT token
/// </summary>
private string _token ;
/// <summary>
/// The server URL
/// </summary>
private string _serverUrl ;
/// <summary>
/// The certificate URL
/// </summary>
private string _certificateUrl ;
/// <summary>
/// The server keys
/// </summary>
private IEnumerable < MiniServerCertificate > _serverKeys ;
2024-10-03 08:46:34 +02:00
/// <summary>
/// The last time a message was received
/// </summary>
private DateTimeOffset _lastMessageReceived = DateTimeOffset . MinValue ;
2026-04-21 14:47:03 +02:00
/// <summary>
/// The time to refresh the settings by, or null if keeping a persistent connection
/// </summary>
private DateTimeOffset _refreshSettingsBy = DateTimeOffset . MinValue ;
2024-09-18 11:07:59 +02:00
/// <summary>
/// Creates a new connection to the remote server
/// </summary>
/// <param name="serverUrl">The url to use</param>
/// <param name="JWT">The JWT token to use</param>
2025-03-03 17:39:25 +01:00
/// <param name="certificateUrl">The certificate url to use</param>
2024-09-23 11:45:12 +02:00
/// <param name="serverKeys">The server keys to use</param>
2026-04-21 14:47:03 +02:00
/// <param name="refreshSettingsBy">The time to refresh the settings by, or null if keeping a persistent connection</param>
/// <param name="forceConnect">If the connection should be force enabled, ignoring re-connect delays</param>
2024-09-18 11:07:59 +02:00
/// <param name="cancellationToken">The token to cancel the connection</param>
2025-03-03 17:39:25 +01:00
/// <param name="onConnect">The callback to call when connecting</param>
/// <param name="onReKey">The callback to call when rekeying</param>
/// <param name="onControl">The callback to call when a control message is received</param>
/// <param name="onMessage">The callback to call when a command message is received</param>
private KeepRemoteConnection (
string serverUrl ,
string JWT ,
string certificateUrl ,
IEnumerable < MiniServerCertificate > serverKeys ,
2026-04-21 14:47:03 +02:00
DateTimeOffset ? refreshSettingsBy ,
bool forceConnect ,
2025-03-03 17:39:25 +01:00
CancellationToken cancellationToken ,
Func < Dictionary < string , string? >, Task < Dictionary < string , string? >>> onConnect ,
Func < ClaimedClientData , Task > onReKey ,
Func < ControlMessage , Task > onControl ,
Func < CommandMessage , Task > onMessage )
2024-09-18 11:07:59 +02:00
{
2024-09-23 23:18:36 +02:00
_serverUrl = serverUrl ;
_certificateUrl = certificateUrl ;
_token = JWT ;
_serverKeys = serverKeys ;
_cancellationTokenSource = CancellationTokenSource . CreateLinkedTokenSource ( cancellationToken );
2025-03-03 17:39:25 +01:00
_onConnect = onConnect ;
2024-09-23 23:18:36 +02:00
_onReKey = onReKey ;
2025-03-03 17:39:25 +01:00
_onControl = onControl ;
2024-09-23 23:18:36 +02:00
_onMessage = onMessage ;
2026-04-21 14:47:03 +02:00
_refreshSettingsBy = refreshSettingsBy ?? DateTimeOffset . MinValue ;
2024-09-23 23:18:36 +02:00
2024-09-18 11:07:59 +02:00
_client = new Websocket . Client . WebsocketClient ( new Uri ( serverUrl ));
2026-05-13 15:04:13 +02:00
_runnerTask = RunMainLoopAsync ( forceConnect );
2024-09-23 23:18:36 +02:00
}
/// <summary>
/// Runs the inner loop of the connection
/// </summary>
2026-04-21 14:47:03 +02:00
/// <param name="forceConnect">If the connection should be force enabled, ignoring re-connect delays</param>
2026-05-13 15:04:13 +02:00
private async Task RunMainLoopAsync ( bool forceConnect )
2024-09-23 23:18:36 +02:00
{
2024-10-01 08:42:34 +02:00
_client . ReconnectTimeout = NoResponseTimeout ;
_client . LostReconnectTimeout = ReconnectInterval ;
2024-10-03 08:46:34 +02:00
_client . IsReconnectionEnabled = true ;
2024-10-02 14:50:35 +02:00
// Set up the periodic refreshers
using var reconnectHelper = new PeriodicRefresher (
2026-05-13 15:04:13 +02:00
System . Threading . Timeout . InfiniteTimeSpan ,
2024-10-02 14:50:35 +02:00
ReconnectInterval ,
async token =>
{
await _client . Start ();
},
_cancellationTokenSource . Token );
using var heartbeatHelper = new PeriodicRefresher (
HeartbeatInterval ,
TimeSpan . FromSeconds ( 1 ),
_ =>
{
2026-04-21 14:47:03 +02:00
// Reconnect if we have disconnected (but not if auto-reconnect is disabled)
if (! _client . IsRunning && ! IsAutoReconnectDisabled ())
2024-10-03 08:46:34 +02:00
{
reconnectHelper . Signal ();
}
// If we do not get any response from the server, we should reconnect
else if (( _state == ConnectionState . Authenticated || _state == ConnectionState . WelcomeReceived ) && _lastMessageReceived . Add ( NoResponseTimeout ) < DateTimeOffset . Now )
{
2026-05-08 19:10:01 +02:00
SetState ( ConnectionState . Error );
2024-10-03 08:46:34 +02:00
Log . WriteMessage ( LogMessageType . Warning , LogTag , "WebsocketDisconnect" , "No response from server" );
2026-05-13 15:04:13 +02:00
_client . Stop ( System . Net . WebSockets . WebSocketCloseStatus . NormalClosure , "No response" ). FireAndForget ();
2024-10-03 08:46:34 +02:00
}
2024-10-02 14:50:35 +02:00
SendEnvelope ( new EnvelopedMessage ()
{
From = ClientId ,
To = "server" ,
Type = "ping" ,
ErrorMessage = null ,
Payload = null ,
MessageId = Guid . NewGuid (). ToString ()
});
return Task . CompletedTask ;
},
_cancellationTokenSource . Token );
2026-05-13 15:04:13 +02:00
using var certificateRefreshHelper = new PeriodicRefresher (
2024-10-02 14:50:35 +02:00
CertificateRefreshInterval ,
MinimumCertificateRefreshInterval ,
2026-05-13 15:04:13 +02:00
RefreshCertificatesAsync ,
2024-10-02 14:50:35 +02:00
_cancellationTokenSource . Token );
2024-09-18 11:07:59 +02:00
_client . DisconnectionHappened . Subscribe ( info =>
{
2026-05-08 19:10:01 +02:00
SetState ( ConnectionState . NotConnected );
2024-09-23 23:18:36 +02:00
_serverCertificate = null ;
2024-09-27 16:14:40 +02:00
_serverPublicKey = null ;
2026-04-21 14:47:03 +02:00
if (! IsAutoReconnectDisabled ())
{
reconnectHelper . Signal ();
Log . WriteMessage ( LogMessageType . Warning , LogTag , "WebsocketDisconnect" , "Disconnected from the server" );
}
else
{
Log . WriteMessage ( LogMessageType . Information , LogTag , "WebsocketDisconnect" , "Disconnected from the server. Auto-reconnect disabled until {0}" , _refreshSettingsBy . ToLocalTime ());
}
2024-09-18 11:07:59 +02:00
});
2026-05-13 15:04:13 +02:00
_client . MessageReceived . Subscribe ( msg => OnMessageAsync ( msg , certificateRefreshHelper , reconnectHelper ). Await ());
2024-10-02 14:50:35 +02:00
2026-05-13 15:04:13 +02:00
// Start the connection
if ( forceConnect || ! IsAutoReconnectDisabled ())
reconnectHelper . Signal ();
2024-09-18 11:07:59 +02:00
2026-05-13 15:04:13 +02:00
var t = await Task . WhenAny (
heartbeatHelper . RunLoopAsync (),
reconnectHelper . RunLoopAsync (),
certificateRefreshHelper . RunLoopAsync ()
);
await _cancellationTokenSource . CancelAsync ();
// Re-throw any exceptions
await t ;
}
private async Task OnMessageAsync ( Websocket . Client . ResponseMessage msg , PeriodicRefresher certificateRefreshHelper , PeriodicRefresher reconnectHelper )
{
// Ignore messages if we are in an error state
if ( _state == ConnectionState . Error )
return ;
_lastMessageReceived = DateTimeOffset . Now ;
if ( _state == ConnectionState . NotConnected )
Log . WriteMessage ( LogMessageType . Verbose , LogTag , "WebsocketMessage" , "Received message from server: {0}" , msg );
else // Encrypted messages are not logged, as the content has no meaning before being decrypted
Log . WriteMessage ( LogMessageType . Verbose , LogTag , "WebsocketMessage" , "Received encrypted message from server" );
try
{
if ( string . IsNullOrWhiteSpace ( msg . Text ))
throw new ProtocolViolationException ( "Empty message" );
if ( _serverCertificate == null || _serverPublicKey == null || _state == ConnectionState . NotConnected )
2024-09-18 11:07:59 +02:00
{
2026-05-13 15:04:13 +02:00
// Should be safe from replay, as the response is encrypted with the server public key
// So even a replay attack would not let the attacker know the client's token
var welcomeEnvelope = EnvelopedMessage . ForceParse ( msg . Text );
if ( welcomeEnvelope . GetMessageType () != MessageType . Welcome )
throw new ProtocolViolationException ( "Expected welcome message" );
if ( string . IsNullOrWhiteSpace ( welcomeEnvelope . Payload ))
throw new ProtocolViolationException ( "No payload in welcome message" );
var welcomeMessage = welcomeEnvelope . GetPayload < 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 == welcomeMessage . PublicKeyHash && x . Expiry > DateTimeOffset . Now );
if ( _serverCertificate == null )
{
certificateRefreshHelper . Signal ();
throw new ProtocolViolationException ( "No valid server certificate" );
}
2024-09-27 16:14:40 +02:00
2026-05-13 15:04:13 +02:00
try
{
var tmp = RSA . Create ();
tmp . ImportFromPem ( _serverCertificate . PublicKey );
_serverPublicKey = tmp ;
}
catch
2024-09-23 23:18:36 +02:00
{
2026-05-13 15:04:13 +02:00
certificateRefreshHelper . Signal ();
throw new ProtocolViolationException ( "Invalid server certificate" );
}
SetState ( ConnectionState . WelcomeReceived );
// Prepare basic metadata and allow additional metadata to be added
var metadata = await _onConnect ( new Dictionary < string , string? >() {
2025-03-03 17:39:25 +01:00
{ "client-version" , UpdaterManager . SelfVersion ?. Version ?? "0.0.0.0" },
{ "client-id" , ClientId },
{ "client-uptime" , ( DateTime . Now - Process . GetCurrentProcess (). StartTime ). ToString () },
{ "machine-name" , DataFolderManager . MachineName },
{ "machine-id" , DataFolderManager . MachineID },
{ "install-id" , DataFolderManager . InstallID },
{ "machine-os" , UpdaterManager . OperatingSystemName },
{ "package-id" , UpdaterManager . PackageTypeId },
2025-03-25 12:34:54 +01:00
{ "update-channel" , UpdaterManager . CurrentChannel . ToString () }
2025-03-03 17:39:25 +01:00
});
2026-05-13 15:04:13 +02:00
SendEnvelope (
welcomeEnvelope . RespondWith (
new AuthMessage (
_token ,
ClientKey . ExportRSAPublicKeyPem (),
UpdaterManager . SelfVersion ?. Version ?? "0.0.0.0" ,
PROTOCOL_VERSION ,
metadata
2024-09-27 16:14:40 +02:00
),
2026-05-13 15:04:13 +02:00
"auth"
),
force : true );
return ;
}
2024-09-23 23:18:36 +02:00
2026-05-13 15:04:13 +02:00
if ( _serverCertificate == null || _serverPublicKey == null || _serverCertificate . HasExpired ())
{
certificateRefreshHelper . Signal ();
throw new ProtocolViolationException ( "No valid server certificate" );
}
2024-09-23 23:18:36 +02:00
2026-05-13 15:04:13 +02:00
var envelope = TransportHelper . ParseFromEncryptedMessage ( msg . Text , ClientKey );
if ( _state == ConnectionState . WelcomeReceived )
{
if ( envelope . GetMessageType () != MessageType . Auth )
throw new ProtocolViolationException ( "Expected welcome message" );
2024-09-18 11:07:59 +02:00
2026-05-13 15:04:13 +02:00
var authMessage = envelope . GetPayload < AuthResultMessage >();
if (! authMessage . Accepted ?? false )
throw new ProtocolViolationException ( "Authentication failed" );
2024-09-18 11:07:59 +02:00
2026-05-13 15:04:13 +02:00
SetState ( ConnectionState . Authenticated );
2024-09-18 11:07:59 +02:00
2026-05-13 15:04:13 +02:00
if (( authMessage . WillReplaceToken ?? false ) && authMessage . NewToken != null )
2024-09-18 11:07:59 +02:00
{
2026-05-13 15:04:13 +02:00
_token = authMessage . NewToken ;
await InvokeReKeyAsync ();
2024-09-18 11:07:59 +02:00
}
2026-05-13 15:04:13 +02:00
}
else if ( _state == ConnectionState . Authenticated )
{
Log . WriteVerboseMessage ( LogTag , "WebsocketMessage" , "Processing message of type {0}" , envelope . GetMessageType ());
switch ( envelope . GetMessageType ())
2024-09-18 11:07:59 +02:00
{
2026-05-13 15:04:13 +02:00
case MessageType . Pong :
break ;
case MessageType . Command :
await _onMessage ( new CommandMessage (
envelope . GetPayload < CommandRequestMessage >(),
response => SendEnvelope ( envelope . RespondWith ( response ))
));
break ;
case MessageType . Control :
await _onControl ( new ControlMessage (
envelope . GetPayload < ControlRequestMessage >(),
response => SendEnvelope ( envelope . RespondWith ( response )),
refreshSettingsBy =>
{
_refreshSettingsBy = DateTimeOffset . FromUnixTimeMilliseconds ( Math . Max ( refreshSettingsBy . ToUnixTimeMilliseconds (), DateTimeOffset . UtcNow . AddSeconds ( 30 ). ToUnixTimeMilliseconds ()));
_client . Stop ( System . Net . WebSockets . WebSocketCloseStatus . NormalClosure , "Disconnect requested" ). FireAndForget ();
}
));
break ;
default :
throw new ProtocolViolationException ( "Unexpected message" );
2024-09-18 11:07:59 +02:00
}
}
2026-05-13 15:04:13 +02:00
else
2024-09-18 11:07:59 +02:00
{
2026-05-13 15:04:13 +02:00
throw new ProtocolViolationException ( "Unexpected message" );
2024-09-18 11:07:59 +02:00
}
2026-05-13 15:04:13 +02:00
}
catch ( Exception ex )
{
SetState ( ConnectionState . Error );
Log . WriteMessage ( LogMessageType . Error , LogTag , "WebsocketMessage" , ex , "Failed to process message: {0}" , msg );
2024-09-18 11:07:59 +02:00
2026-05-13 15:04:13 +02:00
await _client . Stop ( System . Net . WebSockets . WebSocketCloseStatus . NormalClosure , "Error" );
2026-04-21 14:47:03 +02:00
reconnectHelper . Signal ();
2026-05-13 15:04:13 +02:00
}
2024-10-02 14:50:35 +02:00
2024-09-18 11:07:59 +02:00
}
2024-09-25 11:35:03 +02:00
/// <summary>
/// Helper method to invoke the rekey callback
/// </summary>
/// <returns>An awaitable task</returns>
2026-05-13 15:04:13 +02:00
private Task InvokeReKeyAsync ()
2026-04-29 15:26:27 +02:00
=> _onReKey ( new ClaimedClientData ( _token , _serverUrl , _certificateUrl , _serverKeys , null , null ));
2024-09-23 23:18:36 +02:00
2024-09-18 11:07:59 +02:00
/// <summary>
/// Creates a new connection to the remote server
/// </summary>
/// <param name="serverUrl">The url to use</param>
/// <param name="JWT">The JWT to use</param>
2024-09-23 11:45:12 +02:00
/// <param name="certificateUrl">The certificate url to use</param>
2024-09-18 11:07:59 +02:00
/// <param name="serverKeys">The server keys to use</param>
2026-04-21 14:47:03 +02:00
/// <param name="refreshSettingsBy">The time to refresh settings by</param>
/// <param name="forceConnect">If the connection should be force enabled, ignoring re-connect delays</param>
2024-09-18 11:07:59 +02:00
/// <param name="cancellationToken">The token to cancel the connection</param>
2025-03-03 17:39:25 +01:00
/// <param name="onConnect">The callback to call when connecting</param>
2024-09-18 11:07:59 +02:00
/// <param name="onReKey">The callback to call when rekeying</param>
2025-03-03 17:39:25 +01:00
/// <param name="onControl">The callback to call when a control message is received</param>
/// <param name="onMessage">The callback to call when a command message is received</param>
2024-09-18 11:07:59 +02:00
/// <returns></returns>
2026-05-13 15:04:13 +02:00
public static Task StartAsync (
2025-03-03 17:39:25 +01:00
string serverUrl ,
string JWT ,
string certificateUrl ,
IEnumerable < MiniServerCertificate > serverKeys ,
2026-04-21 14:47:03 +02:00
DateTimeOffset ? refreshSettingsBy ,
bool forceConnect ,
2025-03-03 17:39:25 +01:00
CancellationToken cancellationToken ,
Func < Dictionary < string , string? >, Task < Dictionary < string , string? >>> onConnect ,
Func < ClaimedClientData , Task > onReKey ,
Func < ControlMessage , Task > onControl ,
Func < CommandMessage , Task > onMessage )
2024-09-18 11:07:59 +02:00
=> Task . Run ( async () =>
{
2026-04-21 14:47:03 +02:00
using var connection = new KeepRemoteConnection ( serverUrl , JWT , certificateUrl , serverKeys , refreshSettingsBy , forceConnect , cancellationToken , onConnect , onReKey , onControl , onMessage );
2024-09-18 11:07:59 +02:00
await connection . _runnerTask ;
});
/// <summary>
/// Gets the task representing the connection
/// </summary>
/// <returns>The task</returns>
2026-05-13 15:04:13 +02:00
public Task RunAsync ()
2024-09-18 11:07:59 +02:00
=> _runnerTask ;
/// <summary>
/// Stops the connection
/// </summary>
/// <returns>An awaitable task</returns>
2026-05-13 15:04:13 +02:00
public async Task StopAsync ()
2024-09-18 11:07:59 +02:00
{
2026-05-13 15:04:13 +02:00
await _cancellationTokenSource . CancelAsync ();
await _runnerTask ;
2024-09-18 11:07:59 +02:00
}
/// <summary>
/// Sends an enveloped message to the remote server
/// </summary>
/// <param name="envelope">The envelope to send</param>
/// <returns>True if the message was sent</returns>
2024-09-27 16:14:40 +02:00
private bool SendEnvelope ( EnvelopedMessage envelope , bool force = true )
2024-09-18 11:07:59 +02:00
{
2024-09-27 16:14:40 +02:00
if (( _state != ConnectionState . Authenticated && ! force ) || _serverPublicKey == null )
2024-09-18 11:07:59 +02:00
return false ;
2024-09-27 16:14:40 +02:00
_client . Send ( TransportHelper . CreateEncryptedMessage ( envelope with { From = ClientId }, _serverPublicKey ));
2024-09-18 11:07:59 +02:00
return true ;
}
/// <summary>
/// Sends a new command to the server
/// </summary>
/// <param name="message">The message to send</param>
/// <returns>True if the message was sent</returns>
public bool SendCommand ( CommandRequestMessage message )
{
2024-09-27 16:14:40 +02:00
if ( _state != ConnectionState . Authenticated || _serverPublicKey == null )
2024-09-18 11:07:59 +02:00
return false ;
2024-09-27 16:14:40 +02:00
_client . Send ( TransportHelper . CreateEncryptedMessage ( new EnvelopedMessage ()
2024-09-18 11:07:59 +02:00
{
From = ClientId ,
To = "server" ,
Type = "command" ,
2024-09-27 16:14:40 +02:00
MessageId = Guid . NewGuid (). ToString (),
2024-10-02 14:50:35 +02:00
Payload = JsonSerializer . Serialize ( message , options : JsonOptions ),
ErrorMessage = null
2024-09-27 16:14:40 +02:00
}, _serverPublicKey ));
2024-09-18 11:07:59 +02:00
return true ;
}
/// <summary>
/// The current state of the connection
/// </summary>
public ConnectionState State => _state ;
2026-04-21 14:47:03 +02:00
/// <summary>
/// Checks if automatic reconnection should be disabled based on the RefreshSettingsBy timestamp.
/// </summary>
/// <returns>True if automatic reconnect is disabled; otherwise, false.</returns>
public bool IsAutoReconnectDisabled ()
=> DateTimeOffset . UtcNow < _refreshSettingsBy ;
2024-09-18 11:07:59 +02:00
/// <summary>
/// Creates a new connection to the remote server
/// </summary>
/// <param name="serverUrl">The url to use</param>
/// <param name="JWT">The JWT token to use</param>
2025-03-03 17:39:25 +01:00
/// <param name="certificateUrl">The certificate url to use</param>
2024-09-23 11:45:12 +02:00
/// <param name="serverKeys">The server keys to use</param>
2026-04-21 14:47:03 +02:00
/// <param name="refreshSettingsBy">The timestamp to disable automatic reconnect</param>
/// <param name="forceConnect">If the connection should be force enabled, ignoring re-connect delays</param>
/// <param name="cancellationToken">The cancellation token to use</param>
2025-03-03 17:39:25 +01:00
/// <param name="onConnect">The callback to call when connecting</param>
2024-09-18 11:07:59 +02:00
/// <param name="onReKey">The callback to call when rekeying</param>
2025-03-03 17:39:25 +01:00
/// <param name="onControl">The callback to call when a control message is received</param>
2024-09-18 11:07:59 +02:00
/// <param name="onMessage">The callback to call when a message is received</param>
/// <returns>The connection object</returns>
2025-03-03 17:39:25 +01:00
public static KeepRemoteConnection CreateRemoteListener (
string serverUrl ,
string JWT ,
string certificateUrl ,
IEnumerable < MiniServerCertificate > serverKeys ,
2026-04-21 14:47:03 +02:00
DateTimeOffset ? refreshSettingsBy ,
bool forceConnect ,
2025-03-03 17:39:25 +01:00
CancellationToken cancellationToken ,
Func < Dictionary < string , string? >, Task < Dictionary < string , string? >>> onConnect ,
Func < ClaimedClientData , Task > onReKey ,
Func < ControlMessage , Task > onControl ,
Func < CommandMessage , Task > onMessage )
2026-04-21 14:47:03 +02:00
=> new KeepRemoteConnection ( serverUrl , JWT , certificateUrl , serverKeys , refreshSettingsBy , forceConnect , cancellationToken , onConnect , onReKey , onControl , onMessage );
2024-09-18 11:07:59 +02:00
/// <summary>
2024-10-02 14:50:35 +02:00
/// Requests a certificate refresh
2024-09-23 23:18:36 +02:00
/// </summary>
2024-10-02 14:50:35 +02:00
/// <param name="cancelToken">The cancellation token</param>
2024-09-23 23:18:36 +02:00
/// <returns>An awaitable task</returns>
2026-05-13 15:04:13 +02:00
private async Task RefreshCertificatesAsync ( CancellationToken cancelToken )
2024-09-23 23:18:36 +02:00
{
2025-06-12 10:33:13 -03:00
using var client = HttpClientHelper . CreateClient (); // We won't set infiniteTimeout and keep the default 100s timeout
var response = await client . GetAsync ( _certificateUrl , cancelToken );
2024-10-02 14:50:35 +02:00
if ( response . IsSuccessStatusCode )
2024-09-23 23:18:36 +02:00
{
2025-06-12 10:33:13 -03:00
await using var stream = await response . Content . ReadAsStreamAsync ( cancelToken );
2024-10-02 14:50:35 +02:00
var serverKeys = await JsonSerializer . DeserializeAsync < IEnumerable < MiniServerCertificate >>( stream , options : RegisterForRemote . JsonOptions , cancellationToken : cancelToken );
if ( serverKeys != null && serverKeys . Any ())
2024-09-23 23:18:36 +02:00
{
2024-10-02 14:50:35 +02:00
_serverKeys = serverKeys
. Where ( x => ! x . HasExpired () && ! string . IsNullOrWhiteSpace ( x . PublicKeyHash ) && ! string . IsNullOrWhiteSpace ( x . PublicKey ))
. ToList ();
2024-09-27 16:14:40 +02:00
2026-05-13 15:04:13 +02:00
await InvokeReKeyAsync ();
2024-09-23 23:18:36 +02:00
}
}
}
2024-09-18 11:07:59 +02:00
/// </inheritdoc>
public void Dispose ()
{
_cancellationTokenSource . Cancel ();
_client . Dispose ();
_cancellationTokenSource . Dispose ();
}
/// <summary>
/// A wrapper for allowing external code to handle a command message
/// </summary>
public sealed class CommandMessage
{
/// <summary>
/// The callback method that will receive the response
/// </summary>
private readonly Func < CommandResponseMessage , bool > _respondCommand ;
/// <summary>
/// The command request message
/// </summary>
public CommandRequestMessage CommandRequestMessage { get ; }
/// <summary>
/// Creates a new command message
/// </summary>
/// <param name="commandRequestMessage">The command request message</param>
/// <param name="respondCommand">The callback method that will receive the response</param>
public CommandMessage ( CommandRequestMessage commandRequestMessage , Func < CommandResponseMessage , bool > respondCommand )
{
CommandRequestMessage = commandRequestMessage ;
_respondCommand = respondCommand ;
}
/// <summary>
/// Responds to the command message
/// </summary>
/// <param name="response">The response to send</param>
/// <returns>True if the response was sent</returns>
public bool Respond ( CommandResponseMessage response )
=> _respondCommand ( response );
/// <summary>
/// Handles the command message with a configured http client.
/// The client must be configured with the correct base address and authorization headers.
/// </summary>
/// <param name="client">The pre-configured http client</param>
/// <returns>An awaitable task</returns>
2026-05-13 15:04:13 +02:00
public async Task HandleAsync ( HttpClient client )
2024-09-18 11:07:59 +02:00
{
2024-09-28 18:53:35 +02:00
try
{
2025-05-30 08:11:21 +02:00
Log . WriteVerboseMessage ( LogTag , "WebsocketCommand" , "Handling command {0} {1}" , CommandRequestMessage . Method , CommandRequestMessage . Path );
2024-09-28 18:53:35 +02:00
var request = new HttpRequestMessage ( new HttpMethod ( CommandRequestMessage . Method ), CommandRequestMessage . Path );
if (! string . IsNullOrWhiteSpace ( CommandRequestMessage . Body ))
request . Content = new ByteArrayContent ( Convert . FromBase64String ( CommandRequestMessage . Body ));
if ( CommandRequestMessage . Headers != null )
{
foreach ( var header in CommandRequestMessage . Headers )
{
2025-10-23 14:54:21 +02:00
if ( header . Key == "Content-Type" )
{
if ( request . Content != null )
request . Content . Headers . ContentType = new MediaTypeHeaderValue ( header . Value );
}
2024-09-28 18:53:35 +02:00
else
request . Headers . Add ( header . Key , header . Value );
}
}
var response = await client . SendAsync ( request );
var responseBody = await response . Content . ReadAsByteArrayAsync ();
var responseHeaders = response . Headers . ToDictionary ( x => x . Key , x => x . Value . First ());
Respond ( new CommandResponseMessage (( int ) response . StatusCode , responseBody == null ? null : Convert . ToBase64String ( responseBody ), responseHeaders ));
}
catch ( Exception ex )
{
Respond ( new CommandResponseMessage ( 500 , ex . Message , null ));
}
2024-09-18 11:07:59 +02:00
}
}
2025-03-03 17:39:25 +01:00
/// <summary>
/// A wrapper for allowing external code to handle a control message
/// </summary>
public sealed class ControlMessage
{
/// <summary>
/// The callback method that will receive the response
/// </summary>
private readonly Func < ControlResponseMessage , bool > _respondCommand ;
/// <summary>
2026-04-21 14:47:03 +02:00
/// The callback method to request disconnect after responding
/// </summary>
private readonly Action < DateTimeOffset >? _requestDisconnect ;
/// <summary>
2025-03-03 17:39:25 +01:00
/// The command request message
/// </summary>
public ControlRequestMessage ControlRequestMessage { get ; }
/// <summary>
/// Creates a new command message
/// </summary>
/// <param name="controlRequestMessage">The command request message</param>
/// <param name="respondCommand">The callback method that will receive the response</param>
2026-04-21 14:47:03 +02:00
/// <param name="requestDisconnect">Optional callback to request disconnect after responding</param>
public ControlMessage ( ControlRequestMessage controlRequestMessage , Func < ControlResponseMessage , bool > respondCommand , Action < DateTimeOffset >? requestDisconnect )
2025-03-03 17:39:25 +01:00
{
ControlRequestMessage = controlRequestMessage ;
_respondCommand = respondCommand ;
2026-04-21 14:47:03 +02:00
_requestDisconnect = requestDisconnect ;
2025-03-03 17:39:25 +01:00
}
/// <summary>
/// Responds to the command message
/// </summary>
/// <param name="response">The response to send</param>
/// <returns>True if the response was sent</returns>
public bool Respond ( ControlResponseMessage response )
=> _respondCommand ( response );
2026-04-21 14:47:03 +02:00
/// <summary>
/// Requests the connection to be closed after responding.
/// This is used when the server indicates the client should disconnect
/// </summary>
/// <param name="refreshSettingsBy">The time to refresh settings by</param>
public void RequestDisconnect ( DateTimeOffset refreshSettingsBy )
=> _requestDisconnect ?. Invoke ( refreshSettingsBy );
/// <summary>
/// Creates a control message for initial settings from ClaimedClientData.
/// This is used to apply settings that are returned when the machine is claimed.
/// </summary>
/// <param name="settings">The settings dictionary from ClaimedClientData.Settings</param>
/// <returns>A control message with the settings as parameters</returns>
public static ControlMessage CreateSettingsControlMessage ( Dictionary < string , string? > settings )
=> new (
new ControlRequestMessage ( ControlRequestMessage . UpdateSettingsCommand , settings ),
_ => true , // No-op response handler since there's no websocket connection for this
_ => { } // No-op disconnect handler since there's no websocket connection for this
);
2025-03-03 17:39:25 +01:00
}
2024-09-18 11:07:59 +02:00
}