diff --git a/Duplicati/Agent/Program.cs b/Duplicati/Agent/Program.cs
index fdc415620..08b41f513 100644
--- a/Duplicati/Agent/Program.cs
+++ b/Duplicati/Agent/Program.cs
@@ -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));
}
}
diff --git a/Duplicati/Library/Main/Operation/RemoteSynchronizationHandler.cs b/Duplicati/Library/Main/Operation/RemoteSynchronizationHandler.cs
index 1ca294581..3b9965c97 100644
--- a/Duplicati/Library/Main/Operation/RemoteSynchronizationHandler.cs
+++ b/Duplicati/Library/Main/Operation/RemoteSynchronizationHandler.cs
@@ -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);
diff --git a/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj b/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj
index 85047a1fc..869701334 100644
--- a/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj
+++ b/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj
@@ -15,8 +15,8 @@
-
-
+
+
diff --git a/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs b/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs
index 919b28b4e..541dba7c1 100644
--- a/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs
+++ b/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs
@@ -124,6 +124,10 @@ public sealed record ControlRequestMessage(string Command, Dictionary
public const string SettingsVersionKey = "settingsversion";
+ ///
+ /// The key that contains the refresh settings by key
+ ///
+ public const string RefreshSettingsByKey = "refreshsettingsby";
}
///
diff --git a/Duplicati/Library/RemoteControl/KeepRemoteConnection.cs b/Duplicati/Library/RemoteControl/KeepRemoteConnection.cs
index 247dae380..97b2f8eb1 100644
--- a/Duplicati/Library/RemoteControl/KeepRemoteConnection.cs
+++ b/Duplicati/Library/RemoteControl/KeepRemoteConnection.cs
@@ -174,6 +174,10 @@ public class KeepRemoteConnection : IDisposable
/// The last time a message was received
///
private DateTimeOffset _lastMessageReceived = DateTimeOffset.MinValue;
+ ///
+ /// The time to refresh the settings by, or null if keeping a persistent connection
+ ///
+ private DateTimeOffset _refreshSettingsBy = DateTimeOffset.MinValue;
///
/// Creates a new connection to the remote server
@@ -182,6 +186,8 @@ public class KeepRemoteConnection : IDisposable
/// The JWT token to use
/// The certificate url to use
/// The server keys to use
+ /// The time to refresh the settings by, or null if keeping a persistent connection
+ /// If the connection should be force enabled, ignoring re-connect delays
/// The token to cancel the connection
/// The callback to call when connecting
/// The callback to call when rekeying
@@ -192,6 +198,8 @@ public class KeepRemoteConnection : IDisposable
string JWT,
string certificateUrl,
IEnumerable serverKeys,
+ DateTimeOffset? refreshSettingsBy,
+ bool forceConnect,
CancellationToken cancellationToken,
Func, Task>> onConnect,
Func 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);
}
///
/// Runs the inner loop of the connection
///
- private async Task RunMainLoop()
+ /// If the connection should be force enabled, ignoring re-connect delays
+ 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(),
- 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
/// The JWT to use
/// The certificate url to use
/// The server keys to use
+ /// The time to refresh settings by
+ /// If the connection should be force enabled, ignoring re-connect delays
/// The token to cancel the connection
/// The callback to call when connecting
/// The callback to call when rekeying
@@ -467,6 +492,8 @@ public class KeepRemoteConnection : IDisposable
string JWT,
string certificateUrl,
IEnumerable serverKeys,
+ DateTimeOffset? refreshSettingsBy,
+ bool forceConnect,
CancellationToken cancellationToken,
Func, Task>> onConnect,
Func onReKey,
@@ -474,7 +501,7 @@ public class KeepRemoteConnection : IDisposable
Func 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
///
public ConnectionState State => _state;
+ ///
+ /// Checks if automatic reconnection should be disabled based on the RefreshSettingsBy timestamp.
+ ///
+ /// True if automatic reconnect is disabled; otherwise, false.
+ public bool IsAutoReconnectDisabled()
+ => DateTimeOffset.UtcNow < _refreshSettingsBy;
+
///
/// Creates a new connection to the remote server
///
@@ -544,6 +578,9 @@ public class KeepRemoteConnection : IDisposable
/// The JWT token to use
/// The certificate url to use
/// The server keys to use
+ /// The timestamp to disable automatic reconnect
+ /// If the connection should be force enabled, ignoring re-connect delays
+ /// The cancellation token to use
/// The callback to call when connecting
/// The callback to call when rekeying
/// The callback to call when a control message is received
@@ -554,12 +591,14 @@ public class KeepRemoteConnection : IDisposable
string JWT,
string certificateUrl,
IEnumerable serverKeys,
+ DateTimeOffset? refreshSettingsBy,
+ bool forceConnect,
CancellationToken cancellationToken,
Func, Task>> onConnect,
Func onReKey,
Func onControl,
Func 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);
///
/// Requests a certificate refresh
@@ -678,6 +717,10 @@ public class KeepRemoteConnection : IDisposable
///
private readonly Func _respondCommand;
///
+ /// The callback method to request disconnect after responding
+ ///
+ private readonly Action? _requestDisconnect;
+ ///
/// The command request message
///
public ControlRequestMessage ControlRequestMessage { get; }
@@ -687,10 +730,12 @@ public class KeepRemoteConnection : IDisposable
///
/// The command request message
/// The callback method that will receive the response
- public ControlMessage(ControlRequestMessage controlRequestMessage, Func respondCommand)
+ /// Optional callback to request disconnect after responding
+ public ControlMessage(ControlRequestMessage controlRequestMessage, Func respondCommand, Action? requestDisconnect)
{
ControlRequestMessage = controlRequestMessage;
_respondCommand = respondCommand;
+ _requestDisconnect = requestDisconnect;
}
///
@@ -700,5 +745,26 @@ public class KeepRemoteConnection : IDisposable
/// True if the response was sent
public bool Respond(ControlResponseMessage response)
=> _respondCommand(response);
+
+ ///
+ /// Requests the connection to be closed after responding.
+ /// This is used when the server indicates the client should disconnect
+ ///
+ /// The time to refresh settings by
+ public void RequestDisconnect(DateTimeOffset refreshSettingsBy)
+ => _requestDisconnect?.Invoke(refreshSettingsBy);
+
+ ///
+ /// Creates a control message for initial settings from ClaimedClientData.
+ /// This is used to apply settings that are returned when the machine is claimed.
+ ///
+ /// The settings dictionary from ClaimedClientData.Settings
+ /// A control message with the settings as parameters
+ public static ControlMessage CreateSettingsControlMessage(Dictionary 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
+ );
}
}
diff --git a/Duplicati/Library/RemoteControl/RegisterForRemote.cs b/Duplicati/Library/RemoteControl/RegisterForRemote.cs
index 3278b1a66..9d17fcf26 100644
--- a/Duplicati/Library/RemoteControl/RegisterForRemote.cs
+++ b/Duplicati/Library/RemoteControl/RegisterForRemote.cs
@@ -133,6 +133,7 @@ public class RegisterForRemote : IDisposable
/// The URL for getting new server certificates
/// The certificates for the remote server
/// The encryption key for the local settings
+ /// The settings for the machine
private sealed record EnvelopedClaimedClientData(
bool Success,
string StatusMessage,
@@ -140,7 +141,8 @@ public class RegisterForRemote : IDisposable
string ServerUrl,
string CertificateUrl,
IEnumerable ServerCertificates,
- string? LocalEncryptionKey
+ string? LocalEncryptionKey,
+ Dictionary? Settings
);
///
@@ -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);
}
///
diff --git a/Duplicati/Library/RemoteControl/SharedTypes.cs b/Duplicati/Library/RemoteControl/SharedTypes.cs
index 9de5a9f6f..643c8b334 100644
--- a/Duplicati/Library/RemoteControl/SharedTypes.cs
+++ b/Duplicati/Library/RemoteControl/SharedTypes.cs
@@ -47,12 +47,14 @@ public sealed record RegisterClientData(
/// The URL for getting new server certificates
/// The certificates for the remote server
/// The encryption key for the local settings
+/// The settings for the machine, same as in ControlRequestMessage.Parameters
public sealed record ClaimedClientData(
string JWT,
string ServerUrl,
string CertificateUrl,
IEnumerable ServerCertificates,
- string? LocalEncryptionKey
+ string? LocalEncryptionKey,
+ Dictionary? Settings = null
);
///
diff --git a/Duplicati/Library/RestAPI/Database/Connection.cs b/Duplicati/Library/RestAPI/Database/Connection.cs
index d1791323e..d081c1f40 100644
--- a/Duplicati/Library/RestAPI/Database/Connection.cs
+++ b/Duplicati/Library/RestAPI/Database/Connection.cs
@@ -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(),
- 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(),
+ 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();
}
}
}
diff --git a/Duplicati/WebserverCore/Abstractions/IRemoteController.cs b/Duplicati/WebserverCore/Abstractions/IRemoteController.cs
index c0ef9f86e..ac28c7098 100644
--- a/Duplicati/WebserverCore/Abstractions/IRemoteController.cs
+++ b/Duplicati/WebserverCore/Abstractions/IRemoteController.cs
@@ -41,7 +41,8 @@ public interface IRemoteController
///
/// Enable the remote controller
///
- public void Enable();
+ /// If the connection should be force enabled, ignoring re-connect delays
+ public void Enable(bool forceConnect);
///
/// Disable the remote controller
diff --git a/Duplicati/WebserverCore/DuplicatiWebserver.cs b/Duplicati/WebserverCore/DuplicatiWebserver.cs
index 89a49eb83..4ad90829d 100644
--- a/Duplicati/WebserverCore/DuplicatiWebserver.cs
+++ b/Duplicati/WebserverCore/DuplicatiWebserver.cs
@@ -398,7 +398,7 @@ public class DuplicatiWebserver
});
if (connection.ApplicationSettings.RemoteControlEnabled)
- app.Services.GetRequiredService().Enable();
+ app.Services.GetRequiredService().Enable(false);
// Preload static system info, for better first-load experience
_ = Task.Run(() => app.Services.GetRequiredService().GetSystemInfo(null));
diff --git a/Duplicati/WebserverCore/Endpoints/V1/RemoteControl.cs b/Duplicati/WebserverCore/Endpoints/V1/RemoteControl.cs
index 045b2fd91..de11adf49 100644
--- a/Duplicati/WebserverCore/Endpoints/V1/RemoteControl.cs
+++ b/Duplicati/WebserverCore/Endpoints/V1/RemoteControl.cs
@@ -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);
}
diff --git a/Duplicati/WebserverCore/Services/RemoteControllerHandler.cs b/Duplicati/WebserverCore/Services/RemoteControllerHandler.cs
index 450676527..cc0800a5a 100644
--- a/Duplicati/WebserverCore/Services/RemoteControllerHandler.cs
+++ b/Duplicati/WebserverCore/Services/RemoteControllerHandler.cs
@@ -81,6 +81,7 @@ public class RemoteControllerHandler(Connection connection, IHttpClientFactory h
await Task.CompletedTask;
Dictionary? 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(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);
+ }
}
diff --git a/Duplicati/WebserverCore/Services/RemoteControllerRegistrationService.cs b/Duplicati/WebserverCore/Services/RemoteControllerRegistrationService.cs
index ca660188c..1e48e9879 100644
--- a/Duplicati/WebserverCore/Services/RemoteControllerRegistrationService.cs
+++ b/Duplicati/WebserverCore/Services/RemoteControllerRegistrationService.cs
@@ -48,6 +48,10 @@ public sealed record RemoteControlConfig
/// The trusted server certificates.
///
public required IEnumerable ServerCertificates { get; init; }
+ ///
+ /// The time when the settings should be refreshed.
+ ///
+ public DateTimeOffset? RefreshSettingsBy { get; init; }
}
///
@@ -55,7 +59,9 @@ public sealed record RemoteControlConfig
///
/// The database connection
/// The HTTP client factory
-public class RemoteControllerRegistrationService(Connection connection, IHttpClientFactory httpClientFactory, IRemoteController remoteController) : IRemoteControllerRegistration
+/// The remote controller
+/// The controller handler
+public class RemoteControllerRegistrationService(Connection connection, IHttpClientFactory httpClientFactory, IRemoteController remoteController, IRemoteControllerHandler controllerHandler) : IRemoteControllerRegistration
{
///
/// 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;
}
}
diff --git a/Duplicati/WebserverCore/Services/RemoteControllerService.cs b/Duplicati/WebserverCore/Services/RemoteControllerService.cs
index b0e9516ac..94c941fb7 100644
--- a/Duplicati/WebserverCore/Services/RemoteControllerService.cs
+++ b/Duplicati/WebserverCore/Services/RemoteControllerService.cs
@@ -63,7 +63,7 @@ public class RemoteControllerService(Connection connection, IRemoteControllerHan
private KeepRemoteConnection? _keepRemoteConnection;
///
- 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,
diff --git a/proprietary/Office365/Duplicati.Proprietary.Office365.csproj b/proprietary/Office365/Duplicati.Proprietary.Office365.csproj
index 94d125c72..8a43f494b 100644
--- a/proprietary/Office365/Duplicati.Proprietary.Office365.csproj
+++ b/proprietary/Office365/Duplicati.Proprietary.Office365.csproj
@@ -12,7 +12,7 @@
-
+