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-12 12:52:56 +01:00
using System.IO ;
2026-01-08 14:44:26 +01:00
using System.Linq ;
2026-01-12 12:52:56 +01:00
using System.Text.Json ;
2026-01-08 14:44:26 +01:00
using System.Text.RegularExpressions ;
2026-01-12 12:52:56 +01:00
using System.Threading ;
2026-01-08 14:44:26 +01:00
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 ;
2026-01-12 12:52:56 +01:00
#nullable enable
2026-01-08 14:44:26 +01:00
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>
2026-01-28 05:30:42 +01:00
/// Trigger based on a time interval.
2026-01-08 18:38:52 +01:00
/// </summary>
2026-01-28 05:30:42 +01:00
Interval ,
2026-01-08 18:38:52 +01:00
/// <summary>
/// Trigger after a certain number of backups.
/// </summary>
Counting
}
2026-01-12 12:52:56 +01:00
/// <summary>
/// Configuration for a single remote synchronization destination.
/// </summary>
public record RemoteSyncDestinationConfig (
RemoteSynchronization . Config Config ,
RemoteSyncTriggerMode Mode = RemoteSyncTriggerMode . Inline ,
2026-01-28 05:30:42 +01:00
TimeSpan ? Interval = null ,
2026-01-28 09:11:08 +01:00
int? Count = null
2026-01-12 12:52:56 +01:00
);
2026-01-28 09:11:08 +01:00
public record RemoteSyncDestinationConfigRaw (
bool AutoCreateFolders = true ,
int BackendRetries = 3 ,
int BackendRetryDelay = 1000 ,
bool BackendRetryWithExponentialBackoff = true ,
bool DryRun = false ,
bool Force = false ,
string LogFile = "" ,
string LogLevel = "" ,
bool ParseArgumentsOnly = false ,
bool Progress = false ,
bool Retention = false ,
int Retry = 3 ,
bool VerifyContents = false ,
bool VerifyGetAfterPut = false
)
{
public string? Url { get ; init ; }
public List < string > DstOptions { get ; init ; } = [];
public List < string > GlobalOptions { get ; init ; } = [];
public List < string > SrcOptions { get ; init ; } = [];
public string? Mode { get ; init ; }
public string? Interval { get ; init ; }
public int? Count { get ; init ; }
};
public record TopLevelRemoteSyncConfig (
bool SyncOnWarnings = true
)
{
public required List < RemoteSyncDestinationConfigRaw > Destinations { get ; init ; }
}
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 >();
2026-01-12 12:52:56 +01:00
private const string OPTION_JSON_CONFIG = "remote-sync-json-config" ;
private string? m_dbpath ;
private List < RemoteSyncDestinationConfig > m_destinations = [];
2026-01-08 14:44:26 +01:00
private bool m_enabled ;
2026-01-12 12:52:56 +01:00
private string? m_operationName ;
private string? m_source ;
2026-01-09 12:10:42 +01:00
private bool m_syncOnWarnings = true ;
2026-01-12 12:52:56 +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 =>
[
2026-01-12 12:52:56 +01:00
new CommandLineArgument(OPTION_JSON_CONFIG, CommandLineArgument.ArgumentType.String, "JSON configuration for remote synchronization", "JSON string or file path containing remote synchronization configuration"),
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 )
{
2026-01-12 12:52:56 +01:00
// Default is no valid JSON config provided, which disables the module
m_enabled = false ;
m_destinations = [];
2026-01-08 18:38:52 +01:00
2026-01-12 12:52:56 +01:00
if ( commandlineOptions . TryGetValue ( "dbpath" , out var dbpath ))
m_dbpath = dbpath ;
2026-01-08 19:22:44 +01:00
2026-01-12 12:52:56 +01:00
// Check if JSON config is provided
if ( commandlineOptions . TryGetValue ( OPTION_JSON_CONFIG , out var jsonConfigStr ) && ! string . IsNullOrWhiteSpace ( jsonConfigStr ))
2026-01-08 19:22:44 +01:00
{
2026-01-12 12:52:56 +01:00
string jsonContent ;
if ( jsonConfigStr . TrimStart (). StartsWith ( "{" ))
{
// It's a JSON string
jsonContent = jsonConfigStr ;
}
else
{
// It's a file path
try
{
jsonContent = File . ReadAllText ( jsonConfigStr );
}
catch ( Exception ex )
{
Logging . Log . WriteErrorMessage ( LOGTAG , "RemoteSyncJsonFileReadError" , ex , "Failed to read JSON configuration file '{0}': {1}" , jsonConfigStr , ex . Message );
return ;
}
}
try
{
2026-01-28 09:54:32 +01:00
var deserializeOpts = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true ,
PropertyNamingPolicy = JsonNamingPolicy . KebabCaseLower
};
2026-01-28 09:11:08 +01:00
var toplevel = JsonSerializer . Deserialize < TopLevelRemoteSyncConfig >( jsonContent , deserializeOpts );
2026-01-12 12:52:56 +01:00
2026-01-28 09:11:08 +01:00
if ( toplevel is null )
2026-01-12 12:52:56 +01:00
{
2026-01-19 15:10:47 +01:00
Logging . Log . WriteErrorMessage ( LOGTAG , "RemoteSyncJsonParseError" , null , "Failed to parse JSON configuration: top-level object is null." );
return ;
2026-01-12 12:52:56 +01:00
}
2026-01-28 09:11:08 +01:00
m_syncOnWarnings = toplevel . SyncOnWarnings ;
if ( toplevel . Destinations . Count > 0 )
2026-01-12 12:52:56 +01:00
{
2026-01-28 09:11:08 +01:00
foreach ( var destination in toplevel . Destinations )
2026-01-12 12:52:56 +01:00
{
2026-01-28 09:11:08 +01:00
var loglevel = string . IsNullOrWhiteSpace ( destination . LogLevel ) ?
( commandlineOptions . TryGetValue ( "log-file-log-level" , out var logLevel ) ? logLevel : "Information" )
: destination . LogLevel ;
var mode = ! string . IsNullOrWhiteSpace ( destination . Mode ) && Enum . TryParse < RemoteSyncTriggerMode >( destination . Mode , true , out var parsedMode ) ? parsedMode : RemoteSyncTriggerMode . Inline ;
TimeSpan ? interval_parsed ;
try
{
interval_parsed = string . IsNullOrWhiteSpace ( destination . Interval ) ? null : Duplicati . Library . Utility . Timeparser . ParseTimeSpan ( destination . Interval );
}
catch ( Exception ex )
{
var defaulting_string = string . Empty ;
if ( mode == RemoteSyncTriggerMode . Interval )
{
mode = RemoteSyncTriggerMode . Inline ;
defaulting_string = "; defaulting to inline mode" ;
}
interval_parsed = null ;
Logging . Log . WriteWarningMessage ( LOGTAG , "RemoteSyncInvalidInterval" , ex , "Invalid interval format '{0}' for remote synchronization destination{1}" , destination . Interval , defaulting_string );
}
if ( string . IsNullOrWhiteSpace ( destination . Mode ))
{
if ( interval_parsed . HasValue && destination . Count . HasValue )
{
Logging . Log . WriteWarningMessage ( LOGTAG , "RemoteSyncBothIntervalAndCount" , null , "Both interval and count specified for remote synchronization destination without explicit mode; defaulting to interval mode." );
mode = RemoteSyncTriggerMode . Interval ;
}
else if ( interval_parsed . HasValue )
{
mode = RemoteSyncTriggerMode . Interval ;
}
else if ( destination . Count . HasValue )
{
mode = RemoteSyncTriggerMode . Counting ;
}
}
2026-01-19 15:10:47 +01:00
2026-01-12 12:52:56 +01:00
m_destinations . Add ( new (
2026-01-28 09:11:08 +01:00
Config : new (
Src : "" ,
Dst : destination . Url ?? "" ,
AutoCreateFolders : destination . AutoCreateFolders ,
BackendRetries : destination . BackendRetries ,
BackendRetryDelay : destination . BackendRetryDelay ,
BackendRetryWithExponentialBackoff : destination . BackendRetryWithExponentialBackoff ,
Confirm : true ,
DryRun : destination . DryRun ,
DstOptions : destination . DstOptions ,
Force : destination . Force ,
GlobalOptions : destination . GlobalOptions ,
LogFile : destination . LogFile ,
LogLevel : loglevel ,
ParseArgumentsOnly : destination . ParseArgumentsOnly ,
Progress : destination . Progress ,
Retention : destination . Retention ,
Retry : destination . Retry ,
SrcOptions : destination . SrcOptions ,
VerifyContents : destination . VerifyContents ,
VerifyGetAfterPut : destination . VerifyGetAfterPut
),
Mode : mode ,
Interval : interval_parsed ,
Count : destination . Count
2026-01-12 12:52:56 +01:00
));
}
m_enabled = true ;
}
else
{
2026-01-28 09:11:08 +01:00
Logging . Log . WriteWarningMessage ( LOGTAG , "RemoteSyncJsonEmptyDestinations" , null , "JSON configuration is missing entries in the 'destinations' array." );
2026-01-12 12:52:56 +01:00
m_enabled = false ;
return ;
}
}
catch ( Exception ex )
{
Logging . Log . WriteErrorMessage ( LOGTAG , "RemoteSyncJsonParseError" , ex , "Failed to parse JSON configuration: {0}" , ex . Message );
}
2026-01-08 18:38:52 +01:00
}
2026-01-12 12:52:56 +01:00
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-09 12:10:42 +01:00
if ( result != null && result . ParsedResult == ParsedResultType . Warning && ! m_syncOnWarnings )
{
Logging . Log . WriteInformationMessage ( LOGTAG , "RemoteSyncSkipped" , "Remote synchronization skipped because backup reported warnings and sync on warnings is disabled." );
return ;
}
2026-01-13 06:48:03 +01:00
if ( string . IsNullOrWhiteSpace ( m_source ))
{
Logging . Log . WriteWarningMessage ( LOGTAG , "RemoteSyncMissingBackends" , null , "Remote synchronization skipped because source is missing." );
return ;
}
if ( m_destinations . Count == 0 )
2026-01-08 14:44:26 +01:00
{
2026-01-13 06:48:03 +01:00
Logging . Log . WriteWarningMessage ( LOGTAG , "RemoteSyncNoDestinations" , null , "Remote synchronization skipped because no destinations are configured." );
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 ];
2026-01-12 12:52:56 +01:00
if ( string . IsNullOrWhiteSpace ( dest . Config . Dst ))
2026-01-08 19:22:44 +01:00
continue ;
2026-01-08 18:38:52 +01:00
2026-01-12 12:52:56 +01:00
if (! ShouldTriggerSync ( i , dest ))
2026-01-08 19:22:44 +01:00
{
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
try
{
2026-01-13 06:48:03 +01:00
var config = dest . Config with { Src = m_source ! };
var exitCode = RemoteSynchronizationRunner . Run ( config , CancellationToken . None ). ConfigureAwait ( false ). GetAwaiter (). GetResult ();
2026-01-28 09:11:08 +01:00
if ( exitCode == 0 )
RecordSyncOperation ( i );
else
2026-01-08 19:22:44 +01:00
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-12 12:52:56 +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-12 12:52:56 +01:00
private bool ShouldTriggerSync ( int index , RemoteSyncDestinationConfig dest )
2026-01-08 18:38:52 +01:00
{
2026-01-12 12:52:56 +01:00
if ( index < 0 || index >= m_destinations . Count )
2026-01-08 18:38:52 +01:00
return false ;
2026-01-08 19:22:44 +01:00
var description = $"Rsync {index}" ;
2026-01-12 12:52:56 +01:00
switch ( dest . Mode )
2026-01-08 18:38:52 +01:00
{
case RemoteSyncTriggerMode . Inline :
return true ;
2026-01-28 05:30:42 +01:00
case RemoteSyncTriggerMode . Interval :
2026-01-08 19:03:40 +01:00
{
2026-01-12 12:52:56 +01:00
using var db = SQLiteLoader . LoadConnection ( m_dbpath !);
2026-01-08 20:27:28 +01:00
using var cmd = db . CreateCommand ();
2026-01-13 06:48:54 +01:00
2026-01-08 20:27:28 +01:00
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-28 05:30:42 +01:00
return ( now - lastSyncTime ) >= dest . Interval ;
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
{
2026-01-12 12:52:56 +01:00
using var db = SQLiteLoader . LoadConnection ( m_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""
2026-01-13 06:48:54 +01:00
WHERE ""Description"" = @description
" ;
cmd . AddNamedParameter ( "@description" , description );
var syncCount = ( long )( cmd . ExecuteScalar () ?? 0L );
if ( syncCount == 0 )
return true ;
cmd . Parameters . Clear ();
cmd . CommandText = @"
SELECT COUNT(*)
FROM ""Operation""
2026-01-08 19:03:40 +01:00
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 );
2026-01-12 12:52:56 +01:00
return backupCount >= dest . 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
{
2026-01-09 10:56:55 +01:00
// Validate index
if ( index < 0 || index >= m_destinations . Count )
{
Logging . Log . WriteWarningMessage ( LOGTAG , "RemoteSyncRecordInvalidIndex" , null , "Cannot record remote synchronization operation: invalid index {0}." , index );
return ;
}
2026-01-12 12:52:56 +01:00
if ( string . IsNullOrWhiteSpace ( m_dbpath ))
2026-01-08 18:38:52 +01:00
return ;
2026-01-12 12:52:56 +01:00
using var db = SQLiteLoader . LoadConnection ( m_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 14:44:26 +01:00
}