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 @@ - +