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>
2026-01-08 19:22:44 +01:00
/// Module for synchronizing backup data to remote destinations after a successful backup operation.
2026-01-08 15:46:41 +01:00
/// </summary>
2026-01-08 14:44:26 +01:00
public class RemoteSynchronizationModule : IGenericCallbackModule
{
private static readonly string LOGTAG = Logging . Log . LogTagFromType < RemoteSynchronizationModule >();
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 ;
2026-01-08 19:22:44 +01:00
private List < string > m_destinations = [];
2026-01-08 14:44:26 +01:00
private string m_operationName ;
private bool m_enabled ;
2026-01-08 19:22:44 +01:00
private List < RemoteSyncTriggerMode > m_modes = [];
private List < TimeSpan > m_schedules = [];
private List < int > m_counts = [];
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 ();
2026-01-08 19:22:44 +01:00
m_destinations =
commandlineOptions . TryGetValue ( OPTION_BACKEND_DST , out var dstStr )
&& ! string . IsNullOrWhiteSpace ( dstStr )
? [.. dstStr
. Split ( ',' )
. Select ( s => s . Trim ())
. Where ( s => ! string . IsNullOrWhiteSpace ( s ))
2026-01-09 10:50:09 +01:00
]
2026-01-08 19:22:44 +01:00
: [];
2026-01-09 10:50:09 +01:00
m_enabled = m_destinations . Count != 0 ;
2026-01-08 18:38:52 +01:00
if ( commandlineOptions . TryGetValue ( OPTION_MODE , out var modeStr ) && ! string . IsNullOrWhiteSpace ( modeStr ))
{
2026-01-08 19:22:44 +01:00
m_modes = [.. modeStr
. Split ( ',' )
. Select ( s => s . Trim ())
. Select ( s => Enum . TryParse < RemoteSyncTriggerMode >( s , true , out var mode )
? mode : RemoteSyncTriggerMode . Inline )
];
}
else
{
m_modes = [];
2026-01-08 18:38:52 +01:00
}
2026-01-09 10:51:29 +01:00
// Fill rest of the modes with default values, if the list is shorter than destinations
m_modes . AddRange ( Enumerable . Repeat ( RemoteSyncTriggerMode . Inline , m_destinations . Count - m_modes . Count ));
2026-01-08 18:38:52 +01:00
if ( commandlineOptions . TryGetValue ( OPTION_SCHEDULE , out var scheduleStr ) && ! string . IsNullOrWhiteSpace ( scheduleStr ))
{
2026-01-08 19:22:44 +01:00
m_schedules = [.. scheduleStr
. Split ( ',' )
. Select ( s => s . Trim ())
. Select ( s => TimeSpan . TryParse ( s , out var ts )
? ts : TimeSpan . Zero )
];
}
else
{
m_schedules = [];
}
2026-01-09 10:51:29 +01:00
// Fill rest of the schedules with default values, if the list is shorter than destinations
m_schedules . AddRange ( Enumerable . Repeat ( TimeSpan . Zero , m_destinations . Count - m_schedules . Count ));
2026-01-08 19:22:44 +01:00
if ( commandlineOptions . TryGetValue ( OPTION_COUNT , out var countStr ) && ! string . IsNullOrWhiteSpace ( countStr ))
{
m_counts = [.. countStr
. Split ( ',' )
. Select ( s => s . Trim ())
. Select ( s => int . TryParse ( s , out var c ) ? c : 0 )
];
}
else
{
2026-01-09 10:50:09 +01:00
m_counts = [];
2026-01-08 18:38:52 +01:00
}
2026-01-09 10:51:29 +01:00
// Fill rest of the counts with default values, if the list is shorter than destinations
m_counts . AddRange ( Enumerable . Repeat ( 0 , m_destinations . Count - m_counts . Count ));
2026-01-08 18:38:52 +01:00
2026-01-08 19:22:44 +01:00
// Validate parameter lengths
var destCount = m_destinations . Count ;
if ( m_modes . Count != 0 && m_modes . Count != destCount )
Logging . Log . WriteWarningMessage ( LOGTAG , "RemoteSyncConfigMismatch" , null , "Number of modes ({0}) does not match number of destinations ({1}). Using defaults for missing values." , m_modes . Count , destCount );
if ( m_schedules . Count != 0 && m_schedules . Count != destCount )
Logging . Log . WriteWarningMessage ( LOGTAG , "RemoteSyncConfigMismatch" , null , "Number of schedules ({0}) does not match number of destinations ({1}). Using defaults for missing values." , m_schedules . Count , destCount );
if ( m_counts . Count != 0 && m_counts . Count != destCount )
Logging . Log . WriteWarningMessage ( LOGTAG , "RemoteSyncConfigMismatch" , null , "Number of counts ({0}) does not match number of destinations ({1}). Using defaults for missing values." , m_counts . Count , destCount );
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 ;
}
2026-01-08 19:22:44 +01:00
if ( string . IsNullOrWhiteSpace ( m_source ) || m_destinations . Count == 0 )
2026-01-08 14:44:26 +01:00
{
2026-01-08 19:22:44 +01:00
Logging . Log . WriteWarningMessage ( LOGTAG , "RemoteSyncMissingBackends" , null , "Remote synchronization skipped because source or destinations are missing." );
2026-01-08 14:44:26 +01:00
return ;
}
2026-01-08 19:22:44 +01:00
for ( int i = 0 ; i < m_destinations . Count ; i ++)
2026-01-08 18:38:52 +01:00
{
2026-01-08 19:22:44 +01:00
var dest = m_destinations [ i ];
if ( string . IsNullOrWhiteSpace ( dest ))
continue ;
2026-01-08 18:38:52 +01:00
2026-01-08 19:22:44 +01:00
if (! ShouldTriggerSync ( i ))
{
Logging . Log . WriteInformationMessage ( LOGTAG , "RemoteSyncSkipped" , "Remote synchronization to {0} skipped due to trigger mode conditions not met." , dest );
continue ;
}
2026-01-08 18:38:52 +01:00
2026-01-08 19:22:44 +01:00
RecordSyncOperation ( i );
var args = BuildArguments ( dest );
try
{
var exitCode = RemoteSynchronizationRunner . RunAsync ( args ). ConfigureAwait ( false ). GetAwaiter (). GetResult ();
if ( exitCode != 0 )
Logging . Log . WriteErrorMessage ( LOGTAG , "RemoteSyncFailed" , null , "Remote synchronization to {0} failed with exit code {1}." , dest , exitCode );
}
catch ( Exception ex )
{
Logging . Log . WriteErrorMessage ( LOGTAG , "RemoteSyncFailed" , ex , "Remote synchronization to {0} failed: {1}" , dest , ex . Message );
}
2026-01-08 14:44:26 +01:00
}
}
2026-01-08 18:38:52 +01:00
/// <summary>
2026-01-08 19:22:44 +01:00
/// Checks if remote synchronization should be triggered for the specified destination based on the configured mode.
2026-01-08 18:38:52 +01:00
/// </summary>
2026-01-08 19:22:44 +01:00
/// <param name="index">The index of the destination in the list.</param>
2026-01-08 18:38:52 +01:00
/// <returns>True if synchronization should be triggered.</returns>
2026-01-08 19:22:44 +01:00
private bool ShouldTriggerSync ( int index )
2026-01-08 18:38:52 +01:00
{
if (! m_options . TryGetValue ( "dbpath" , out var dbpath ) || string . IsNullOrWhiteSpace ( dbpath ))
return false ;
2026-01-08 19:22:44 +01:00
var mode = index < m_modes . Count ? m_modes [ index ] : RemoteSyncTriggerMode . Inline ;
var schedule = index < m_schedules . Count ? m_schedules [ index ] : TimeSpan . Zero ;
var count = index < m_counts . Count ? m_counts [ index ] : 0 ;
var description = $"Rsync {index}" ;
switch ( mode )
2026-01-08 18:38:52 +01:00
{
case RemoteSyncTriggerMode . Inline :
return true ;
case RemoteSyncTriggerMode . Scheduled :
2026-01-08 19:03:40 +01:00
{
using var db = SQLiteLoader . LoadConnection ( dbpath );
2026-01-08 20:27:28 +01:00
using var cmd = db . CreateCommand ();
cmd . CommandText = @"
2026-01-08 19:03:40 +01:00
SELECT ""Timestamp""
2026-01-08 19:22:44 +01:00
FROM ""Operation""
WHERE ""Description"" = @description
2026-01-08 19:03:40 +01:00
ORDER BY ""Timestamp"" DESC
LIMIT 1
2026-01-08 20:27:28 +01:00
" ;
2026-01-08 19:22:44 +01:00
cmd . AddNamedParameter ( "@description" , description );
2026-01-08 19:03:40 +01:00
var lastSync = cmd . ExecuteScalar ();
if ( lastSync is null )
2026-01-08 19:22:44 +01:00
return true ;
2026-01-08 19:03:40 +01:00
var lastSyncTime = Utility . Utility . EPOCH . AddSeconds (( long ) lastSync );
var now = DateTime . UtcNow ;
2026-01-08 19:22:44 +01:00
return ( now - lastSyncTime ) >= schedule ;
2026-01-08 19:03:40 +01:00
}
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 );
2026-01-08 20:27:28 +01:00
using var cmd = db . CreateCommand ();
cmd . CommandText = @"
2026-01-08 19:03:40 +01:00
SELECT COUNT(*)
FROM ""Operation""
WHERE ""Description"" = 'Backup'
AND ""Timestamp"" > COALESCE(
(
SELECT ""Timestamp""
FROM ""Operation""
2026-01-08 19:22:44 +01:00
WHERE ""Description"" = @description
2026-01-08 19:03:40 +01:00
ORDER BY ""Timestamp"" DESC LIMIT 1
),
0
2026-01-08 20:27:28 +01:00
)" ;
2026-01-08 19:22:44 +01:00
cmd . AddNamedParameter ( "@description" , description );
2026-01-08 19:03:40 +01:00
2026-01-08 19:22:44 +01:00
var backupCount = ( long )( cmd . ExecuteScalar () ?? 0L );
return backupCount >= count ;
2026-01-08 19:03:40 +01:00
}
2026-01-08 18:38:52 +01:00
default :
return false ;
}
}
/// <summary>
/// Records a remote synchronization operation in the database.
/// </summary>
2026-01-08 19:22:44 +01:00
/// <param name="index">The index of the destination.</param>
private void RecordSyncOperation ( int index )
2026-01-08 18:38:52 +01:00
{
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 ();
2026-01-08 20:27:28 +01:00
using var cmd = db . CreateCommand ();
cmd . CommandText = @"
2026-01-08 19:03:40 +01:00
INSERT INTO ""Operation"" (
""Description"", ""Timestamp""
)
VALUES (
2026-01-08 19:22:44 +01:00
@description,
2026-01-08 19:03:40 +01:00
@timestamp
2026-01-08 20:27:28 +01:00
)" ;
cmd . SetTransaction ( transaction );
cmd . AddNamedParameter ( "@description" , $"Rsync {index}" );
cmd . 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>
2026-01-08 19:22:44 +01:00
/// <param name="dest">The destination backend string.</param>
2026-01-08 15:46:41 +01:00
/// <returns>An array of command line arguments.</returns>
2026-01-08 19:22:44 +01:00
private string [] BuildArguments ( string dest )
2026-01-08 14:44:26 +01:00
{
2026-01-08 15:28:41 +01:00
string [] args = [
2026-01-08 14:44:26 +01:00
m_source ,
2026-01-08 19:22:44 +01:00
dest ,
2026-01-08 15:28:41 +01:00
.. 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
}
}