Merge branch 'master' into fix/maintenance-write-path-config-persistence

This commit is contained in:
Kenneth Skovhede
2026-04-21 16:42:57 +02:00
committed by GitHub
15 changed files with 261 additions and 109 deletions
+6
View File
@@ -319,6 +319,8 @@ public static class Program
settings.JWT,
settings.CertificateUrl,
settings.ServerCertificates,
refreshSettingsBy: null,
forceConnect: true,
cts.Token,
OnConnect,
m => ReKey(applicationSettings, m, agentConfig),
@@ -450,6 +452,10 @@ public static class Program
};
settings.Save();
// If settings are present in the claim data, apply them via the control handler
if (claimedClientData.Settings != null && claimedClientData.Settings.Count > 0)
await OnControl(KeepRemoteConnection.ControlMessage.CreateSettingsControlMessage(claimedClientData.Settings));
}
}
@@ -313,14 +313,14 @@ internal class RemoteSynchronizationHandler : IDisposable
if (string.IsNullOrWhiteSpace(dest.Config.Dst))
continue;
if (!ShouldTriggerSync(i, dest))
{
Logging.Log.WriteInformationMessage(LOGTAG, "RemoteSyncSkipped", "Remote synchronization to {0} skipped due to trigger mode conditions not met.", dest);
continue;
}
try
{
if (!ShouldTriggerSync(i, dest))
{
Logging.Log.WriteInformationMessage(LOGTAG, "RemoteSyncSkipped", "Remote synchronization to {0} skipped due to trigger mode conditions not met.", dest);
continue;
}
// Update progress to show sync is in progress
m_results.OperationProgressUpdater.UpdatePhase(OperationPhase.Backup_RemoteSynchronization);
m_results.OperationProgressUpdater.UpdateRemoteSyncDestination(i + 1, m_destinations.Count);
@@ -15,8 +15,8 @@
<PackageReference Include="DnsClient" Version="1.8.0" />
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="MailKit" Version="4.12.1" />
<PackageReference Include="MimeKit" Version="4.15.1" />
<PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference Include="MimeKit" Version="4.16.0" />
</ItemGroup>
<ItemGroup>
@@ -124,6 +124,10 @@ public sealed record ControlRequestMessage(string Command, Dictionary<string, st
/// The key that contains the applied settings version
/// </summary>
public const string SettingsVersionKey = "settingsversion";
/// <summary>
/// The key that contains the refresh settings by key
/// </summary>
public const string RefreshSettingsByKey = "refreshsettingsby";
}
/// <summary>
@@ -174,6 +174,10 @@ public class KeepRemoteConnection : IDisposable
/// The last time a message was received
/// </summary>
private DateTimeOffset _lastMessageReceived = DateTimeOffset.MinValue;
/// <summary>
/// The time to refresh the settings by, or null if keeping a persistent connection
/// </summary>
private DateTimeOffset _refreshSettingsBy = DateTimeOffset.MinValue;
/// <summary>
/// Creates a new connection to the remote server
@@ -182,6 +186,8 @@ public class KeepRemoteConnection : IDisposable
/// <param name="JWT">The JWT token to use</param>
/// <param name="certificateUrl">The certificate url to use</param>
/// <param name="serverKeys">The server keys to use</param>
/// <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>
/// <param name="cancellationToken">The token to cancel the connection</param>
/// <param name="onConnect">The callback to call when connecting</param>
/// <param name="onReKey">The callback to call when rekeying</param>
@@ -192,6 +198,8 @@ public class KeepRemoteConnection : IDisposable
string JWT,
string certificateUrl,
IEnumerable<MiniServerCertificate> serverKeys,
DateTimeOffset? refreshSettingsBy,
bool forceConnect,
CancellationToken cancellationToken,
Func<Dictionary<string, string?>, Task<Dictionary<string, string?>>> onConnect,
Func<ClaimedClientData, Task> onReKey,
@@ -207,15 +215,17 @@ public class KeepRemoteConnection : IDisposable
_onReKey = onReKey;
_onControl = onControl;
_onMessage = onMessage;
_refreshSettingsBy = refreshSettingsBy ?? DateTimeOffset.MinValue;
_client = new Websocket.Client.WebsocketClient(new Uri(serverUrl));
_runnerTask = RunMainLoop();
_runnerTask = RunMainLoop(forceConnect);
}
/// <summary>
/// Runs the inner loop of the connection
/// </summary>
private async Task RunMainLoop()
/// <param name="forceConnect">If the connection should be force enabled, ignoring re-connect delays</param>
private async Task RunMainLoop(bool forceConnect)
{
_client.ReconnectTimeout = NoResponseTimeout;
_client.LostReconnectTimeout = ReconnectInterval;
@@ -236,8 +246,8 @@ public class KeepRemoteConnection : IDisposable
TimeSpan.FromSeconds(1),
_ =>
{
// Reconnect if we have disconnected
if (!_client.IsRunning)
// Reconnect if we have disconnected (but not if auto-reconnect is disabled)
if (!_client.IsRunning && !IsAutoReconnectDisabled())
{
reconnectHelper.Signal();
}
@@ -274,8 +284,15 @@ public class KeepRemoteConnection : IDisposable
_state = ConnectionState.NotConnected;
_serverCertificate = null;
_serverPublicKey = null;
reconnectHelper.Signal();
Log.WriteMessage(LogMessageType.Warning, LogTag, "WebsocketDisconnect", "Disconnected from the server");
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());
}
});
_client.MessageReceived.Subscribe(async msg =>
@@ -402,7 +419,12 @@ public class KeepRemoteConnection : IDisposable
case MessageType.Control:
await _onControl(new ControlMessage(
envelope.GetPayload<ControlRequestMessage>(),
response => SendEnvelope(envelope.RespondWith(response))
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");
}
));
break;
@@ -428,7 +450,8 @@ public class KeepRemoteConnection : IDisposable
});
// Start the connection
reconnectHelper.Signal();
if (forceConnect || !IsAutoReconnectDisabled())
reconnectHelper.Signal();
var t = await Task.WhenAny(
heartbeatHelper.RunLoopAsync(),
@@ -456,6 +479,8 @@ public class KeepRemoteConnection : IDisposable
/// <param name="JWT">The JWT to use</param>
/// <param name="certificateUrl">The certificate url to use</param>
/// <param name="serverKeys">The server keys to use</param>
/// <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>
/// <param name="cancellationToken">The token to cancel the connection</param>
/// <param name="onConnect">The callback to call when connecting</param>
/// <param name="onReKey">The callback to call when rekeying</param>
@@ -467,6 +492,8 @@ public class KeepRemoteConnection : IDisposable
string JWT,
string certificateUrl,
IEnumerable<MiniServerCertificate> serverKeys,
DateTimeOffset? refreshSettingsBy,
bool forceConnect,
CancellationToken cancellationToken,
Func<Dictionary<string, string?>, Task<Dictionary<string, string?>>> onConnect,
Func<ClaimedClientData, Task> onReKey,
@@ -474,7 +501,7 @@ public class KeepRemoteConnection : IDisposable
Func<CommandMessage, Task> onMessage)
=> Task.Run(async () =>
{
using var connection = new KeepRemoteConnection(serverUrl, JWT, certificateUrl, serverKeys, cancellationToken, onConnect, onReKey, onControl, onMessage);
using var connection = new KeepRemoteConnection(serverUrl, JWT, certificateUrl, serverKeys, refreshSettingsBy, forceConnect, cancellationToken, onConnect, onReKey, onControl, onMessage);
await connection._runnerTask;
});
@@ -537,6 +564,13 @@ public class KeepRemoteConnection : IDisposable
/// </summary>
public ConnectionState State => _state;
/// <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;
/// <summary>
/// Creates a new connection to the remote server
/// </summary>
@@ -544,6 +578,9 @@ public class KeepRemoteConnection : IDisposable
/// <param name="JWT">The JWT token to use</param>
/// <param name="certificateUrl">The certificate url to use</param>
/// <param name="serverKeys">The server keys to use</param>
/// <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>
/// <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>
@@ -554,12 +591,14 @@ public class KeepRemoteConnection : IDisposable
string JWT,
string certificateUrl,
IEnumerable<MiniServerCertificate> serverKeys,
DateTimeOffset? refreshSettingsBy,
bool forceConnect,
CancellationToken cancellationToken,
Func<Dictionary<string, string?>, Task<Dictionary<string, string?>>> onConnect,
Func<ClaimedClientData, Task> onReKey,
Func<ControlMessage, Task> onControl,
Func<CommandMessage, Task> onMessage)
=> new KeepRemoteConnection(serverUrl, JWT, certificateUrl, serverKeys, cancellationToken, onConnect, onReKey, onControl, onMessage);
=> new KeepRemoteConnection(serverUrl, JWT, certificateUrl, serverKeys, refreshSettingsBy, forceConnect, cancellationToken, onConnect, onReKey, onControl, onMessage);
/// <summary>
/// Requests a certificate refresh
@@ -678,6 +717,10 @@ public class KeepRemoteConnection : IDisposable
/// </summary>
private readonly Func<ControlResponseMessage, bool> _respondCommand;
/// <summary>
/// The callback method to request disconnect after responding
/// </summary>
private readonly Action<DateTimeOffset>? _requestDisconnect;
/// <summary>
/// The command request message
/// </summary>
public ControlRequestMessage ControlRequestMessage { get; }
@@ -687,10 +730,12 @@ public class KeepRemoteConnection : IDisposable
/// </summary>
/// <param name="controlRequestMessage">The command request message</param>
/// <param name="respondCommand">The callback method that will receive the response</param>
public ControlMessage(ControlRequestMessage controlRequestMessage, Func<ControlResponseMessage, bool> respondCommand)
/// <param name="requestDisconnect">Optional callback to request disconnect after responding</param>
public ControlMessage(ControlRequestMessage controlRequestMessage, Func<ControlResponseMessage, bool> respondCommand, Action<DateTimeOffset>? requestDisconnect)
{
ControlRequestMessage = controlRequestMessage;
_respondCommand = respondCommand;
_requestDisconnect = requestDisconnect;
}
/// <summary>
@@ -700,5 +745,26 @@ public class KeepRemoteConnection : IDisposable
/// <returns>True if the response was sent</returns>
public bool Respond(ControlResponseMessage response)
=> _respondCommand(response);
/// <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
);
}
}
@@ -133,6 +133,7 @@ public class RegisterForRemote : IDisposable
/// <param name="CertificateUrl">The URL for getting new server certificates</param>
/// <param name="ServerCertificates">The certificates for the remote server</param>
/// <param name="LocalEncryptionKey">The encryption key for the local settings</param>
/// <param name="Settings">The settings for the machine</param>
private sealed record EnvelopedClaimedClientData(
bool Success,
string StatusMessage,
@@ -140,7 +141,8 @@ public class RegisterForRemote : IDisposable
string ServerUrl,
string CertificateUrl,
IEnumerable<MiniServerCertificate> ServerCertificates,
string? LocalEncryptionKey
string? LocalEncryptionKey,
Dictionary<string, string?>? Settings
);
/// <summary>
@@ -338,7 +340,7 @@ public class RegisterForRemote : IDisposable
if (!result.Success)
throw new Exception($"Failed to claim machine: {result.StatusMessage}");
return new ClaimedClientData(result.JWT, result.ServerUrl, result.CertificateUrl, result.ServerCertificates, result.LocalEncryptionKey);
return new ClaimedClientData(result.JWT, result.ServerUrl, result.CertificateUrl, result.ServerCertificates, result.LocalEncryptionKey, result.Settings);
}
/// </inheritdoc>
@@ -47,12 +47,14 @@ public sealed record RegisterClientData(
/// <param name="CertificateUrl">The URL for getting new server certificates</param>
/// <param name="ServerCertificates">The certificates for the remote server</param>
/// <param name="LocalEncryptionKey">The encryption key for the local settings</param>
/// <param name="Settings">The settings for the machine, same as in ControlRequestMessage.Parameters</param>
public sealed record ClaimedClientData(
string JWT,
string ServerUrl,
string CertificateUrl,
IEnumerable<MiniServerCertificate> ServerCertificates,
string? LocalEncryptionKey
string? LocalEncryptionKey,
Dictionary<string, string?>? Settings = null
);
/// <summary>
+100 -80
View File
@@ -266,21 +266,26 @@ namespace Duplicati.Server.Database
lock (m_lock)
{
var tr = transaction ?? m_connection.BeginTransaction();
OverwriteAndUpdateDb(
tr,
cmd => cmd.SetCommandAndParameters(@"DELETE FROM ""Metadata"" WHERE ""BackupID"" = @Id")
.SetParameterValue("@Id", id),
values ?? new Dictionary<string, string>(),
cmd => cmd.SetCommandAndParameters(@"INSERT INTO ""Metadata"" (""BackupID"", ""Name"", ""Value"") VALUES (@BackupId, @Name, @Value)"),
(cmd, f) => cmd.SetParameterValue("@BackupId", id)
.SetParameterValue("@Name", f.Key)
.SetParameterValue("@Value", f.Value)
);
if (transaction == null)
try
{
tr.Commit();
tr.Dispose();
OverwriteAndUpdateDb(
tr,
cmd => cmd.SetCommandAndParameters(@"DELETE FROM ""Metadata"" WHERE ""BackupID"" = @Id")
.SetParameterValue("@Id", id),
values ?? new Dictionary<string, string>(),
cmd => cmd.SetCommandAndParameters(@"INSERT INTO ""Metadata"" (""BackupID"", ""Name"", ""Value"") VALUES (@BackupId, @Name, @Value)"),
(cmd, f) => cmd.SetParameterValue("@BackupId", id)
.SetParameterValue("@Name", f.Key)
.SetParameterValue("@Value", f.Value)
);
if (transaction == null)
tr.Commit();
}
finally
{
if (transaction == null)
tr.Dispose();
}
}
}
@@ -305,22 +310,27 @@ namespace Duplicati.Server.Database
lock (m_lock)
{
var tr = transaction ?? m_connection.BeginTransaction();
OverwriteAndUpdateDb(
tr,
cmd => cmd.SetCommandAndParameters(@"DELETE FROM ""Filter"" WHERE ""BackupID"" = @Id")
.SetParameterValue("@Id", id),
values,
cmd => cmd.SetCommandAndParameters(@"INSERT INTO ""Filter"" (""BackupID"", ""Order"", ""Include"", ""Expression"") VALUES (@Id, @Order, @Include, @Expression)"),
(cmd, f) => cmd.SetParameterValue("@Id", id)
.SetParameterValue("@Order", f.Order)
.SetParameterValue("@Include", f.Include)
.SetParameterValue("@Expression", f.Expression)
);
if (transaction == null)
try
{
tr.Commit();
tr.Dispose();
OverwriteAndUpdateDb(
tr,
cmd => cmd.SetCommandAndParameters(@"DELETE FROM ""Filter"" WHERE ""BackupID"" = @Id")
.SetParameterValue("@Id", id),
values,
cmd => cmd.SetCommandAndParameters(@"INSERT INTO ""Filter"" (""BackupID"", ""Order"", ""Include"", ""Expression"") VALUES (@Id, @Order, @Include, @Expression)"),
(cmd, f) => cmd.SetParameterValue("@Id", id)
.SetParameterValue("@Order", f.Order)
.SetParameterValue("@Include", f.Include)
.SetParameterValue("@Expression", f.Expression)
);
if (transaction == null)
tr.Commit();
}
finally
{
if (transaction == null)
tr.Dispose();
}
}
}
@@ -369,36 +379,41 @@ namespace Duplicati.Server.Database
lock (m_lock)
{
var tr = transaction ?? m_connection.BeginTransaction();
if (m_encryptSensitiveFields)
values = values.Select(x => new Setting
{
Filter = x.Filter,
Name = x.Name,
Value = EncryptSensitiveFields(x.Name, x.Value, m_key)
}).ToList();
OverwriteAndUpdateDb(
tr,
cmd => cmd.SetCommandAndParameters(@"DELETE FROM ""Option"" WHERE ""BackupID"" = @Id")
.SetParameterValue("@Id", id),
values,
cmd => cmd.SetCommandAndParameters(@"INSERT INTO ""Option"" (""BackupID"", ""Filter"", ""Name"", ""Value"") VALUES (@BackupId, @Filter, @Name, @Value)"),
(cmd, f) =>
{
if (IsPasswordPlaceholder(f.Value))
throw new Exception("Attempted to save a property with the placeholder password");
cmd.SetParameterValue("@BackupId", id)
.SetParameterValue("@Filter", f.Filter ?? "")
.SetParameterValue("@Name", f.Name ?? "")
.SetParameterValue("@Value", f.Value ?? "");
}
);
if (transaction == null)
try
{
tr.Commit();
tr.Dispose();
if (m_encryptSensitiveFields)
values = values.Select(x => new Setting
{
Filter = x.Filter,
Name = x.Name,
Value = EncryptSensitiveFields(x.Name, x.Value, m_key)
}).ToList();
OverwriteAndUpdateDb(
tr,
cmd => cmd.SetCommandAndParameters(@"DELETE FROM ""Option"" WHERE ""BackupID"" = @Id")
.SetParameterValue("@Id", id),
values,
cmd => cmd.SetCommandAndParameters(@"INSERT INTO ""Option"" (""BackupID"", ""Filter"", ""Name"", ""Value"") VALUES (@BackupId, @Filter, @Name, @Value)"),
(cmd, f) =>
{
if (IsPasswordPlaceholder(f.Value))
throw new Exception("Attempted to save a property with the placeholder password");
cmd.SetParameterValue("@BackupId", id)
.SetParameterValue("@Filter", f.Filter ?? "")
.SetParameterValue("@Name", f.Name ?? "")
.SetParameterValue("@Value", f.Value ?? "");
}
);
if (transaction == null)
tr.Commit();
}
finally
{
if (transaction == null)
tr.Dispose();
}
}
@@ -445,29 +460,34 @@ namespace Duplicati.Server.Database
lock (m_lock)
{
var tr = transaction ?? m_connection.BeginTransaction();
OverwriteAndUpdateDb(
tr,
cmd => cmd.SetCommandAndParameters(@"DELETE FROM ""Source"" WHERE ""BackupID"" = @Id")
.SetParameterValue("@Id", id),
values,
cmd => cmd.SetCommandAndParameters(@"INSERT INTO ""Source"" (""BackupID"", ""Path"") VALUES (@BackupId, @Path)"),
(cmd, f) =>
{
if (SourceMasking.IsSpecialSource(f) && UrlContainsPasswordPlaceholder(SourceMasking.ExtractUrl(f)))
throw new Exception("Attempted to save a source with a placeholder password");
cmd.SetParameterValue("@BackupId", id)
.SetParameterValue("@Path",
m_encryptSensitiveFields && SourceMasking.IsSpecialSource(f)
? EncryptedFieldHelper.Encrypt(f, m_key)
: f);
}
);
if (transaction == null)
try
{
tr.Commit();
tr.Dispose();
OverwriteAndUpdateDb(
tr,
cmd => cmd.SetCommandAndParameters(@"DELETE FROM ""Source"" WHERE ""BackupID"" = @Id")
.SetParameterValue("@Id", id),
values,
cmd => cmd.SetCommandAndParameters(@"INSERT INTO ""Source"" (""BackupID"", ""Path"") VALUES (@BackupId, @Path)"),
(cmd, f) =>
{
if (SourceMasking.IsSpecialSource(f) && UrlContainsPasswordPlaceholder(SourceMasking.ExtractUrl(f)))
throw new Exception("Attempted to save a source with a placeholder password");
cmd.SetParameterValue("@BackupId", id)
.SetParameterValue("@Path",
m_encryptSensitiveFields && SourceMasking.IsSpecialSource(f)
? EncryptedFieldHelper.Encrypt(f, m_key)
: f);
}
);
if (transaction == null)
tr.Commit();
}
finally
{
if (transaction == null)
tr.Dispose();
}
}
}
@@ -41,7 +41,8 @@ public interface IRemoteController
/// <summary>
/// Enable the remote controller
/// </summary>
public void Enable();
/// <param name="forceConnect">If the connection should be force enabled, ignoring re-connect delays</param>
public void Enable(bool forceConnect);
/// <summary>
/// Disable the remote controller
@@ -398,7 +398,7 @@ public class DuplicatiWebserver
});
if (connection.ApplicationSettings.RemoteControlEnabled)
app.Services.GetRequiredService<IRemoteController>().Enable();
app.Services.GetRequiredService<IRemoteController>().Enable(false);
// Preload static system info, for better first-load experience
_ = Task.Run(() => app.Services.GetRequiredService<ISystemInfoProvider>().GetSystemInfo(null));
@@ -106,7 +106,7 @@ public class RemoteControl : IEndpointV1
private static Dto.RemoteControlStatusOutput EnableRemoteControl(IRemoteControllerRegistration registration, IRemoteController remoteController)
{
remoteController.Enable();
remoteController.Enable(true);
return GetStatus(registration, remoteController);
}
@@ -81,6 +81,7 @@ public class RemoteControllerHandler(Connection connection, IHttpClientFactory h
await Task.CompletedTask;
Dictionary<string, string?>? result = null;
var refreshSettingsBy = DateTimeOffset.MinValue;
Log.WriteMessage(LogMessageType.Verbose, LOGTAG, "OnControl", "Received control message: {0}", message);
try
@@ -141,6 +142,36 @@ public class RemoteControllerHandler(Connection connection, IHttpClientFactory h
connection.AddOrUpdateBackupAndSchedule(importData.Backup, importData.Schedule);
}
try
{
// Check if we need to delay the next reconnection
var refreshSettingsByValue = message.ControlRequestMessage.Parameters.GetValueOrDefault(ControlRequestMessage.RefreshSettingsByKey);
if (!string.IsNullOrWhiteSpace(refreshSettingsByValue) && long.TryParse(refreshSettingsByValue, out var refreshSettingsByLong) && refreshSettingsByLong > 0)
{
var target = DateTimeOffset.FromUnixTimeSeconds(refreshSettingsByLong);
if (target > DateTimeOffset.UtcNow)
refreshSettingsBy = target;
// Agent keeps its own config, and does not support connection delays
if (applicationSettings.Origin != "Agent")
{
var connstr = connection.ApplicationSettings.RemoteControlConfig;
if (!string.IsNullOrWhiteSpace(connstr))
{
var prevCfg = JsonConvert.DeserializeObject<RemoteControlConfig>(connstr);
if (prevCfg != null)
connection.ApplicationSettings.RemoteControlConfig = JsonConvert.SerializeObject(
prevCfg with { RefreshSettingsBy = target }
);
}
}
}
}
catch (Exception ex)
{
Log.WriteMessage(LogMessageType.Error, LOGTAG, "OnControl", ex, "Failed to update refreshSettingsBy");
}
}
}
catch (Exception ex)
@@ -150,7 +181,14 @@ public class RemoteControllerHandler(Connection connection, IHttpClientFactory h
return;
}
// Respond first, then disconnect if needed
message.Respond(new ControlResponseMessage(true, result, null));
if (refreshSettingsBy > DateTimeOffset.UtcNow)
{
Log.WriteMessage(LogMessageType.Information, LOGTAG, "OnControl", "Requesting disconnect after responding to control message");
message.RequestDisconnect(refreshSettingsBy);
}
}
@@ -48,6 +48,10 @@ public sealed record RemoteControlConfig
/// The trusted server certificates.
/// </summary>
public required IEnumerable<MiniServerCertificate> ServerCertificates { get; init; }
/// <summary>
/// The time when the settings should be refreshed.
/// </summary>
public DateTimeOffset? RefreshSettingsBy { get; init; }
}
/// <summary>
@@ -55,7 +59,9 @@ public sealed record RemoteControlConfig
/// </summary>
/// <param name="connection">The database connection</param>
/// <param name="httpClientFactory">The HTTP client factory</param>
public class RemoteControllerRegistrationService(Connection connection, IHttpClientFactory httpClientFactory, IRemoteController remoteController) : IRemoteControllerRegistration
/// <param name="remoteController">The remote controller</param>
/// <param name="controllerHandler">The controller handler</param>
public class RemoteControllerRegistrationService(Connection connection, IHttpClientFactory httpClientFactory, IRemoteController remoteController, IRemoteControllerHandler controllerHandler) : IRemoteControllerRegistration
{
/// <summary>
/// The registration process controller this service is wrapping.
@@ -117,9 +123,13 @@ public class RemoteControllerRegistrationService(Connection connection, IHttpCli
ServerUrl = claimData.ServerUrl
});
// If settings are present in the claim data, apply them via the control handler
if (claimData.Settings != null && claimData.Settings.Count > 0)
await controllerHandler.OnControl(KeepRemoteConnection.ControlMessage.CreateSettingsControlMessage(claimData.Settings));
// Automatically connect after we are registered
if (remoteController.CanEnable)
remoteController.Enable();
remoteController.Enable(false);
});
}
@@ -141,5 +151,6 @@ public class RemoteControllerRegistrationService(Connection connection, IHttpCli
_operationCancellation?.Dispose();
_registerForRemote = null;
_operationCancellation = null;
RegistrationUrl = null;
}
}
@@ -63,7 +63,7 @@ public class RemoteControllerService(Connection connection, IRemoteControllerHan
private KeepRemoteConnection? _keepRemoteConnection;
/// </inheritdoc>
public void Enable()
public void Enable(bool forceConnect)
{
if (_keepRemoteConnection != null)
return;
@@ -79,6 +79,8 @@ public class RemoteControllerService(Connection connection, IRemoteControllerHan
config.Token,
config.CertificateUrl,
config.ServerCertificates,
config.RefreshSettingsBy,
forceConnect,
CancellationToken.None,
controllerHandler.OnConnect,
controllerHandler.ReKey,
@@ -12,7 +12,7 @@
<ProjectReference Include="../../Duplicati/Library/AutoUpdater/Duplicati.Library.AutoUpdater.csproj" />
<ProjectReference Include="../../Duplicati/Library/Utility/Duplicati.Library.Utility.csproj" />
<ProjectReference Include="../LicenseChecker/Duplicati.Proprietary.LicenseChecker.csproj" />
<PackageReference Include="MimeKit" Version="4.15.1" />
<PackageReference Include="MimeKit" Version="4.16.0" />
<PackageReference Include="jose-jwt" Version="5.2.0" />
</ItemGroup>