2026-01-08 14:44:26 +01:00
// Copyright (C) 2025, The Duplicati Team
// 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.
using System ;
using System.Collections.Generic ;
2026-01-08 18:38:52 +01:00
using System.Data ;
2026-01-08 14:44:26 +01:00
using System.Linq ;
using System.Text.RegularExpressions ;
using Duplicati.Library.Interface ;
2026-01-08 19:03:40 +01:00
using Duplicati.Library.Main.Database ;
2026-01-08 18:38:52 +01:00
using Duplicati.Library.SQLiteHelper ;
2026-01-08 14:44:26 +01:00
using Duplicati.Library.Utility ;
using RemoteSynchronization ;
namespace Duplicati.Library.Modules.Builtin ;
2026-01-08 18:38:52 +01:00
/// <summary>
/// Trigger modes for remote synchronization.
/// </summary>
public enum RemoteSyncTriggerMode
{
/// <summary>
/// Trigger after every successful backup.
/// </summary>
Inline ,
/// <summary>
/// Trigger based on a schedule.
/// </summary>
Scheduled ,
/// <summary>
/// Trigger after a certain number of backups.
/// </summary>
Counting
}
2026-01-08 15:46:41 +01:00
/// <summary>
/// Module for synchronizing backup data to a remote destination after a successful backup operation.
/// </summary>
2026-01-08 14:44:26 +01:00
public class RemoteSynchronizationModule : IGenericCallbackModule
{
private static readonly string LOGTAG = Logging . Log . LogTagFromType < RemoteSynchronizationModule >();
private static readonly Regex ARGREGEX = new Regex (
@"(?<arg>(?<=\s|^)(""(?<value>[^""\\]*(?:\\.[^""\\]*)*)""|'(?<value>[^'\\]*(?:\\.[^'\\]*)*)'|(?<value>[^\s]+))\s?)" ,
RegexOptions . Compiled | RegexOptions . ExplicitCapture
);
private const string OPTION_BACKEND_DST = "remote-sync-dst" ;
private const string OPTION_FORCE = "remote-sync-force" ;
private const string OPTION_RETENTION = "remote-sync-retention" ;
2026-01-08 15:28:41 +01:00
private const string OPTION_BACKEND_RETRIES = "remote-sync-backend-retries" ;
2026-01-08 14:44:26 +01:00
private const string OPTION_RETRY = "remote-sync-retry" ;
2026-01-08 18:38:52 +01:00
private const string OPTION_MODE = "remote-sync-mode" ;
private const string OPTION_SCHEDULE = "remote-sync-schedule" ;
private const string OPTION_COUNT = "remote-sync-count" ;
2026-01-08 14:44:26 +01:00
private IReadOnlyDictionary < string , string > m_options = new Dictionary < string , string >();
private string m_source ;
private string m_destination ;
private string m_operationName ;
private bool m_enabled ;
2026-01-08 18:38:52 +01:00
private RemoteSyncTriggerMode m_mode = RemoteSyncTriggerMode . Inline ;
private TimeSpan m_schedule = TimeSpan . Zero ;
private int m_count = 0 ;
2026-01-08 14:44:26 +01:00
2026-01-08 15:46:41 +01:00
/// <summary>
/// Gets the key identifier for this module.
/// </summary>
2026-01-08 14:44:26 +01:00
public string Key => "remotesync" ;
2026-01-08 15:46:41 +01:00
/// <summary>
/// Gets the display name for this module.
/// </summary>
2026-01-08 14:44:26 +01:00
public string DisplayName => Strings . RemoteSynchronization . DisplayName ;
2026-01-08 15:46:41 +01:00
/// <summary>
/// Gets the description of this module.
/// </summary>
2026-01-08 14:44:26 +01:00
public string Description => Strings . RemoteSynchronization . Description ;
2026-01-08 15:46:41 +01:00
/// <summary>
/// Gets whether this module should be loaded by default.
/// </summary>
2026-01-08 14:44:26 +01:00
public bool LoadAsDefault => true ;
2026-01-08 15:46:41 +01:00
/// <summary>
/// Gets the list of supported command line arguments.
/// </summary>
2026-01-08 14:44:26 +01:00
public IList < ICommandLineArgument > SupportedCommands =>
[
new CommandLineArgument(OPTION_BACKEND_DST, CommandLineArgument.ArgumentType.String, Strings.RemoteSynchronization.BackendDestinationShort, Strings.RemoteSynchronization.BackendDestinationLong),
new CommandLineArgument(OPTION_FORCE, CommandLineArgument.ArgumentType.Boolean, Strings.RemoteSynchronization.ForceShort, Strings.RemoteSynchronization.ForceLong, "false"),
new CommandLineArgument(OPTION_RETENTION, CommandLineArgument.ArgumentType.Boolean, Strings.RemoteSynchronization.RetentionShort, Strings.RemoteSynchronization.RetentionLong, "false"),
2026-01-08 15:28:41 +01:00
new CommandLineArgument(OPTION_BACKEND_RETRIES, CommandLineArgument.ArgumentType.Integer, Strings.RemoteSynchronization.BackendRetriesShort, Strings.RemoteSynchronization.BackendRetriesLong, "3"),
2026-01-08 14:44:26 +01:00
new CommandLineArgument(OPTION_RETRY, CommandLineArgument.ArgumentType.Integer, Strings.RemoteSynchronization.RetryShort, Strings.RemoteSynchronization.RetryLong, "3"),
2026-01-08 18:38:52 +01:00
new CommandLineArgument(OPTION_MODE, CommandLineArgument.ArgumentType.Enumeration, Strings.RemoteSynchronization.ModeShort, Strings.RemoteSynchronization.ModeLong, "inline", null, ["inline", "scheduled", "counting"] ),
new CommandLineArgument ( OPTION_SCHEDULE , CommandLineArgument . ArgumentType . Timespan , Strings . RemoteSynchronization . ScheduleShort , Strings . RemoteSynchronization . ScheduleLong ),
new CommandLineArgument ( OPTION_COUNT , CommandLineArgument . ArgumentType . Integer , Strings . RemoteSynchronization . CountShort , Strings . RemoteSynchronization . CountLong ),
2026-01-08 14:44:26 +01:00
];
2026-01-08 15:46:41 +01:00
/// <summary>
/// Configures the module with the provided command line options.
/// </summary>
/// <param name="commandlineOptions">The command line options dictionary.</param>
2026-01-08 14:44:26 +01:00
public void Configure ( IDictionary < string , string > commandlineOptions )
{
m_options = commandlineOptions . AsReadOnly ();
commandlineOptions . TryGetValue ( OPTION_BACKEND_DST , out m_destination );
m_enabled = ! string . IsNullOrWhiteSpace ( m_destination );
2026-01-08 18:38:52 +01:00
if ( commandlineOptions . TryGetValue ( OPTION_MODE , out var modeStr ) && ! string . IsNullOrWhiteSpace ( modeStr ))
{
if ( Enum . TryParse < RemoteSyncTriggerMode >( modeStr , true , out var mode ))
m_mode = mode ;
}
if ( commandlineOptions . TryGetValue ( OPTION_SCHEDULE , out var scheduleStr ) && ! string . IsNullOrWhiteSpace ( scheduleStr ))
{
if ( TimeSpan . TryParse ( scheduleStr , out var schedule ))
m_schedule = schedule ;
}
if ( commandlineOptions . TryGetValue ( OPTION_COUNT , out var countStr ) && int . TryParse ( countStr , out var count ))
m_count = count ;
2026-01-08 14:44:26 +01:00
}
2026-01-08 15:46:41 +01:00
/// <summary>
/// Called when an operation starts.
/// </summary>
/// <param name="operationname">The name of the operation.</param>
/// <param name="remoteurl">The remote URL.</param>
/// <param name="localpath">The local paths.</param>
2026-01-08 14:44:26 +01:00
public void OnStart ( string operationname , ref string remoteurl , ref string [] localpath )
{
if (! m_enabled )
return ;
m_operationName = operationname ;
if ( string . IsNullOrWhiteSpace ( m_source ))
m_source = remoteurl ;
}
2026-01-08 15:46:41 +01:00
/// <summary>
/// Called when an operation finishes.
/// </summary>
/// <param name="result">The results of the operation.</param>
/// <param name="exception">Any exception that occurred during the operation.</param>
2026-01-08 14:44:26 +01:00
public void OnFinish ( IBasicResults result , Exception exception )
{
if (! m_enabled )
return ;
if (! string . Equals ( m_operationName , "Backup" , StringComparison . OrdinalIgnoreCase ))
return ;
if ( exception != null )
{
Logging . Log . WriteWarningMessage ( LOGTAG , "RemoteSyncSkipped" , exception , "Remote synchronization skipped due to operation failure." );
return ;
}
if ( result != null && ( result . ParsedResult == ParsedResultType . Error || result . ParsedResult == ParsedResultType . Fatal ))
{
Logging . Log . WriteWarningMessage ( LOGTAG , "RemoteSyncSkipped" , null , "Remote synchronization skipped because backup reported errors." );
return ;
}
if ( string . IsNullOrWhiteSpace ( m_source ) || string . IsNullOrWhiteSpace ( m_destination ))
{
Logging . Log . WriteWarningMessage ( LOGTAG , "RemoteSyncMissingBackends" , null , "Remote synchronization skipped because source or destination is missing." );
return ;
}
2026-01-08 18:38:52 +01:00
if (! ShouldTriggerSync ())
{
Logging . Log . WriteInformationMessage ( LOGTAG , "RemoteSyncSkipped" , "Remote synchronization skipped due to trigger mode conditions not met." );
return ;
}
RecordSyncOperation ();
2026-01-08 14:44:26 +01:00
var args = BuildArguments ();
try
{
var exitCode = RemoteSynchronizationRunner . RunAsync ( args ). ConfigureAwait ( false ). GetAwaiter (). GetResult ();
if ( exitCode != 0 )
Logging . Log . WriteErrorMessage ( LOGTAG , "RemoteSyncFailed" , null , "Remote synchronization failed with exit code {0}." , exitCode );
}
catch ( Exception ex )
{
Logging . Log . WriteErrorMessage ( LOGTAG , "RemoteSyncFailed" , ex , "Remote synchronization failed: {0}" , ex . Message );
}
}
2026-01-08 18:38:52 +01:00
/// <summary>
/// Checks if remote synchronization should be triggered based on the configured mode.
/// </summary>
/// <returns>True if synchronization should be triggered.</returns>
private bool ShouldTriggerSync ()
{
if (! m_options . TryGetValue ( "dbpath" , out var dbpath ) || string . IsNullOrWhiteSpace ( dbpath ))
return false ;
switch ( m_mode )
{
case RemoteSyncTriggerMode . Inline :
return true ;
case RemoteSyncTriggerMode . Scheduled :
2026-01-08 19:03:40 +01:00
{
using var db = SQLiteLoader . LoadConnection ( dbpath );
using var cmd = db . CreateCommand ( @"
SELECT ""Timestamp""
FROM ""Operation ""
WHERE ""Description"" = 'Remote Synchronization'
ORDER BY ""Timestamp"" DESC
LIMIT 1
" );
// Find last remote sync operation
var lastSync = cmd . ExecuteScalar ();
if ( lastSync is null )
return true ; // No previous sync, trigger
var lastSyncTime = Utility . Utility . EPOCH . AddSeconds (( long ) lastSync );
var now = DateTime . UtcNow ;
return ( now - lastSyncTime ) >= m_schedule ;
}
2026-01-08 18:38:52 +01:00
case RemoteSyncTriggerMode . Counting :
2026-01-08 19:03:40 +01:00
{
using var db = SQLiteLoader . LoadConnection ( dbpath );
using var cmd = db . CreateCommand ( @"
SELECT COUNT(*)
FROM ""Operation""
WHERE ""Description"" = 'Backup'
AND ""Timestamp"" > COALESCE(
(
SELECT ""Timestamp""
FROM ""Operation""
WHERE ""Description"" = 'Remote Synchronization'
ORDER BY ""Timestamp"" DESC LIMIT 1
),
0
)" );
// Count backup operations since last sync
var count = ( long )( cmd . ExecuteScalar () ?? 0L );
return count >= m_count ;
}
2026-01-08 18:38:52 +01:00
default :
return false ;
}
}
/// <summary>
/// Records a remote synchronization operation in the database.
/// </summary>
private void RecordSyncOperation ()
{
if (! m_options . TryGetValue ( "dbpath" , out var dbpath ) || string . IsNullOrWhiteSpace ( dbpath ))
return ;
using var db = SQLiteLoader . LoadConnection ( dbpath );
2026-01-08 19:03:40 +01:00
using var transaction = db . BeginTransaction ();
using var cmd = db . CreateCommand ( @"
INSERT INTO ""Operation"" (
""Description"", ""Timestamp""
)
VALUES (
'Remote Synchronization',
@timestamp
)" )
. SetTransaction ( transaction )
. AddNamedParameter ( "@timestamp" , Utility . Utility . NormalizeDateTimeToEpochSeconds ( DateTime . UtcNow ));
2026-01-08 18:38:52 +01:00
cmd . ExecuteNonQuery ();
2026-01-08 19:03:40 +01:00
transaction . Commit ();
2026-01-08 18:38:52 +01:00
}
2026-01-08 15:46:41 +01:00
/// <summary>
/// Builds the arguments for the remote synchronization command.
/// </summary>
/// <returns>An array of command line arguments.</returns>
2026-01-08 14:44:26 +01:00
private string [] BuildArguments ()
{
2026-01-08 15:28:41 +01:00
string [] args = [
2026-01-08 14:44:26 +01:00
m_source ,
2026-01-08 15:28:41 +01:00
m_destination ,
.. AddOption ( OPTION_BACKEND_RETRIES , "--backend-retries" , []),
.. AddOption ( OPTION_FORCE , "--force" , []),
.. AddOption ( OPTION_RETENTION , "--retention" , []),
.. AddOption ( OPTION_RETRY , "--retry" , []),
// Hardcoded defaults for automatic operation
"--auto-create-folders" ,
"--backend-retry-delay" , "1000" ,
"--backend-retry-with-exponential-backoff" ,
"--confirm" , // Automatic, no prompt
];
return args ;
2026-01-08 14:44:26 +01:00
}
2026-01-08 15:46:41 +01:00
/// <summary>
/// Adds an option to the command line arguments if it is specified.
/// </summary>
/// <param name="optionKey">The key of the option in the options dictionary.</param>
/// <param name="toolOption">The command line flag for the option.</param>
/// <param name="defaultvalue">The default value if the option is not specified.</param>
/// <returns>An array of strings representing the option and its value, or the default value.</returns>
2026-01-08 15:28:41 +01:00
private string [] AddOption ( string optionKey , string toolOption , string [] defaultvalue )
2026-01-08 14:44:26 +01:00
{
if (! m_options . TryGetValue ( optionKey , out var value ))
2026-01-08 15:28:41 +01:00
return defaultvalue ;
2026-01-08 14:44:26 +01:00
if ( string . IsNullOrWhiteSpace ( value ))
2026-01-08 15:28:41 +01:00
return defaultvalue ;
2026-01-08 14:44:26 +01:00
2026-01-08 15:28:41 +01:00
return [ toolOption , value ];
2026-01-08 14:44:26 +01:00
}
}