// Copyright (C) 2026, 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; using System.Diagnostics; using System.IO; using System.IO.Pipes; using System.Linq; using System.Threading.Tasks; using Duplicati.Library.Interface; using Duplicati.Library.Main.IPC.Dto; using Duplicati.Library.Utility; using StreamJsonRpc; #nullable enable namespace Duplicati.Library.Main.IPC; /// /// Client-side proxy for the controller that runs in a separate spawned process /// public class ControllerRpcProxy : IController, IDisposable, IControllerRpcCallbacks { private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType(); private Process? _process; private JsonRpc? _rpc; private readonly IMessageSink _messageSink; private readonly string _backendUrl; private readonly Dictionary _options; private JsonRpc Rpc => _rpc ?? throw new InvalidOperationException("RPC connection not established"); /// public Action? OnOperationStarted { get; set; } /// public Action? OnOperationCompleted { get; set; } /// public Task SetLastCompactAsync(DateTime lastCompact) => Rpc.InvokeAsync(nameof(IController.SetLastCompactAsync), lastCompact).WaitAsync(TimeSpan.FromSeconds(5)); /// public Task SetLastVacuumAsync(DateTime lastVacuum) => Rpc.InvokeAsync(nameof(IController.SetLastVacuumAsync), lastVacuum).WaitAsync(TimeSpan.FromSeconds(5)); /// /// Creates a new controller proxy /// private ControllerRpcProxy( string backendUrl, Dictionary options, IMessageSink messageSink) { _backendUrl = backendUrl; _options = options; _messageSink = messageSink; } /// /// Creates a new controller proxy /// /// The backend URL /// The options to pass /// The message sink to use /// The configured proxy public static async Task CreateProxyAsync(string backendUrl, Dictionary options, IMessageSink messageSink) { var proxy = new ControllerRpcProxy(backendUrl, options, messageSink); try { await proxy.StartRemoteProcessAsync().ConfigureAwait(false); // For debugging purposes, we can also start a mocked remote process // await proxy.StartMockedRemoteProcessAsync().ConfigureAwait(false); } catch { try { proxy.Dispose(); } catch { } throw; } return proxy; } /// /// Starts running the remote controller process /// /// An awaitable task private async Task StartRemoteProcessAsync() { var exePath = GetControllerExecutablePath(); if (!File.Exists(exePath)) throw new FileNotFoundException($"Controller executable not found: {exePath}"); var psi = new ProcessStartInfo(exePath, "controller-server") { UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, WorkingDirectory = Path.GetDirectoryName(exePath) }; _process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start controller process"); // Setup StreamJsonRpc over stdio var formatter = new SystemTextJsonFormatter { JsonSerializerOptions = RpcJsonOptions.Options }; var handler = new NewLineDelimitedMessageHandler( _process.StandardInput.BaseStream, _process.StandardOutput.BaseStream, formatter); _rpc = new JsonRpc(handler, this); _rpc.StartListening(); // Start stderr reader for logging _ = Task.Run(ReadStderrAsync); // Initialize the remote controller await _rpc.InvokeAsync("InitializeAsync", _backendUrl, _options).ConfigureAwait(false); } #if DEBUG /// /// Starts a mocked remote process for testing purposes. /// The code lives in the the same process, but uses the RPC interface to communicate with the server. /// /// An awaitable task private async Task StartMockedRemoteProcessAsync() { var pipeName = $"dupl-{Guid.NewGuid():N}"[..9]; var pipeServer = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); _ = Task.Run(async () => { try { await pipeServer.WaitForConnectionAsync().ConfigureAwait(false); var formatter = new SystemTextJsonFormatter { JsonSerializerOptions = RpcJsonOptions.Options }; var handler = new NewLineDelimitedMessageHandler( pipeServer, pipeServer, formatter); var server = new ControllerRpcServer(this); var rpc = new JsonRpc(handler, server); rpc.StartListening(); try { await rpc.Completion.ConfigureAwait(false); } catch { } finally { await server.DisposeAsync().ConfigureAwait(false); await pipeServer.DisposeAsync().ConfigureAwait(false); } } catch (Exception ex) { Library.Logging.Log.WriteWarningMessage(LOGTAG, "MockServerError", ex, "Mocked RPC server encountered an error"); } }); var pipeClient = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); await pipeClient.ConnectAsync().ConfigureAwait(false); var clientFormatter = new SystemTextJsonFormatter { JsonSerializerOptions = RpcJsonOptions.Options }; var clientHandler = new NewLineDelimitedMessageHandler( pipeClient, pipeClient, clientFormatter); _rpc = new JsonRpc(clientHandler, this); _rpc.StartListening(); // Initialize the remote controller await _rpc.InvokeAsync(nameof(ControllerRpcServer.InitializeAsync), _backendUrl, _options).ConfigureAwait(false); } #endif private async Task ReadStderrAsync() { try { if (_process?.StandardError == null) return; string? line; while ((line = await _process.StandardError.ReadLineAsync()) != null) { Library.Logging.Log.WriteInformationMessage(LOGTAG, "ControllerStderr", line); } } catch (Exception ex) { Library.Logging.Log.WriteWarningMessage(LOGTAG, "StderrReadError", ex, "Error reading controller stderr"); } } #if DEBUG private static string? GetControllerExecutablePath() { // Look in the source tree var baseDir = System.Reflection.Assembly.GetEntryAssembly()!.Location; // Go from ?/Executables/Project1/bin/Debug/net10/project-filename(.exe) // to ?/Executables/Duplicati.CommandLine/bin/Debug/Duplicati.CommandLine(.exe) var parts = baseDir.Split(Path.DirectorySeparatorChar); parts[parts.Length - 1] = "Duplicati.CommandLine"; if (OperatingSystem.IsWindows()) parts[parts.Length - 1] += ".exe"; // Deal with ?/Duplicati/UnitTest/bin/Debug/net10/testhost.dll if (!string.Equals(parts[parts.Length - 6], "Executables", StringComparison.OrdinalIgnoreCase)) parts[parts.Length - 6] = "Executables"; parts[parts.Length - 5] = "Duplicati.CommandLine"; var targetPath = string.Join(Path.DirectorySeparatorChar, parts); if (File.Exists(targetPath)) return targetPath; return GetControllerExecutablePathInner(); } #else private static string GetControllerExecutablePath() => GetControllerExecutablePathInner(); #endif private static string GetControllerExecutablePathInner() { var installDir = Library.AutoUpdater.UpdaterManager.INSTALLATIONDIR; var exeName = Library.AutoUpdater.PackageHelper.GetExecutableName( Library.AutoUpdater.PackageHelper.NamedExecutable.CommandLine); return Path.Combine(installDir, exeName); } #region IControllerRpcCallbacks Implementation /// /// Called by the RPC server when a log message is written /// public Task OnLogMessageAsync(LogEntryDto entry) { if (_messageSink != null && entry != null) { try { var logEntry = entry.ToLogEntry(); _messageSink.WriteMessage(logEntry); } catch (Exception ex) { Library.Logging.Log.WriteWarningMessage(LOGTAG, "LogForwardError", ex, "Error forwarding log message"); } } return Task.CompletedTask; } /// /// Called by the RPC server for backend events /// public Task OnBackendEventAsync(BackendActionType action, BackendEventType type, string path, long size) { _messageSink?.BackendEvent(action, type, path, size); return Task.CompletedTask; } /// /// Called by the RPC server when an operation starts /// public Task OnOperationStartedAsync(OperationMode operation) { // Note: The actual result object is created locally in the remote process // For simplicity, we don't proxy the full callback mechanism return Task.CompletedTask; } /// /// Called by the RPC server when an operation completes /// public Task OnOperationCompletedAsync(OperationResultDto result, ExceptionDto exception) { // The operation completion is handled by the return value of the RPC call return Task.CompletedTask; } /// /// Called by the RPC server when backend progress updates /// public Task OnBackendProgressAsync(BackendProgressDto progress) { if (_messageSink != null && progress != null) _messageSink.SetBackendProgress(new BackendProgressWrapper(progress)); return Task.CompletedTask; } /// /// Called by the RPC server when operation progress updates /// public Task OnOperationProgressAsync(OperationProgressDto progress) { if (_messageSink != null && progress != null) { if (_operationProgressWrapper == null) { _operationProgressWrapper = new OperationProgressWrapper(); _messageSink.SetOperationProgress(_operationProgressWrapper); } _operationProgressWrapper.UpdateFromDto(progress); } return Task.CompletedTask; } /// /// Called by the RPC server when the operation phase changes /// public Task OnPhaseChangedAsync(OperationPhase phase, OperationPhase previousPhase) { _operationProgressWrapper?.SetPhase(phase, previousPhase); return Task.CompletedTask; } #endregion #region IController Implementation /// public async Task BackupAsync(string[] inputsources, IFilter? filter = null) => await Rpc.InvokeAsync(nameof(IController.BackupAsync), inputsources, filter).ConfigureAwait(false); /// public async Task RestoreAsync(string[]? paths, IFilter? filter = null) => await Rpc.InvokeAsync(nameof(IController.RestoreAsync), paths, filter).ConfigureAwait(false); /// public Task ListAsync() => ListAsync(null, null); /// public Task ListAsync(string? filterstring) => ListAsync(filterstring == null ? null : new[] { filterstring }, null); /// public async Task ListAsync(IEnumerable? filterstrings, IFilter? filter) => await Rpc.InvokeAsync(nameof(IController.ListAsync), filterstrings, filter).ConfigureAwait(false); /// public async Task DeleteAsync() => await Rpc.InvokeAsync(nameof(IController.DeleteAsync)).ConfigureAwait(false); /// public async Task RepairAsync(IFilter? filter = null) => await Rpc.InvokeAsync(nameof(IController.RepairAsync), filter).ConfigureAwait(false); /// public async Task CompactAsync() => await Rpc.InvokeAsync(nameof(IController.CompactAsync)).ConfigureAwait(false); /// public async Task VacuumAsync() => await Rpc.InvokeAsync(nameof(IController.VacuumAsync)).ConfigureAwait(false); /// public async Task TestAsync(long samples = 1) => await Rpc.InvokeAsync(nameof(IController.TestAsync), samples).ConfigureAwait(false); /// public async Task ListFilesetsAsync() => await Rpc.InvokeAsync(nameof(IController.ListFilesetsAsync)).ConfigureAwait(false); /// public async Task ListFolderAsync(string[]? folders, long offset, long limit, bool extendedData) => await Rpc.InvokeAsync(nameof(IController.ListFolderAsync), folders, offset, limit, extendedData).ConfigureAwait(false); /// public async Task ListFileVersionsAsync(string[]? files, long offset, long limit) => await Rpc.InvokeAsync(nameof(IController.ListFileVersionsAsync), files, offset, limit).ConfigureAwait(false); /// public async Task SearchEntriesAsync(string[]? pathprefixes, IFilter? filter, bool caseSensitive, long offset, long limit, bool extendedData) => await Rpc.InvokeAsync(nameof(IController.SearchEntriesAsync), pathprefixes, filter, caseSensitive, offset, limit, extendedData).ConfigureAwait(false); /// public async Task CreateLogDatabaseAsync(string targetpath) => await Rpc.InvokeAsync(nameof(IController.CreateLogDatabaseAsync), targetpath).ConfigureAwait(false); /// public async Task ListRemoteAsync() => await Rpc.InvokeAsync(nameof(IController.ListRemoteAsync)).ConfigureAwait(false); /// public async Task DeleteAllRemoteFilesAsync() => await Rpc.InvokeAsync(nameof(IController.DeleteAllRemoteFilesAsync)).ConfigureAwait(false); /// public async Task SetLocksAsync() => await Rpc.InvokeAsync(nameof(IController.SetLocksAsync)).ConfigureAwait(false); /// public async Task ReadLockInfoAsync() => await Rpc.InvokeAsync(nameof(IController.ReadLockInfoAsync)).ConfigureAwait(false); /// public async Task UpdateDatabaseWithVersionsAsync(IFilter? filter = null) => await Rpc.InvokeAsync(nameof(IController.UpdateDatabaseWithVersionsAsync), filter).ConfigureAwait(false); /// public async Task PurgeFilesAsync(IFilter? filter) => await Rpc.InvokeAsync(nameof(IController.PurgeFilesAsync), filter).ConfigureAwait(false); /// public async Task ListBrokenFilesAsync(IFilter? filter, Func? callbackhandler = null) { // Note: callbacks for progress are not supported in the RPC model return await Rpc.InvokeAsync(nameof(IController.ListBrokenFilesAsync), filter).ConfigureAwait(false); } /// public async Task PurgeBrokenFilesAsync(IFilter? filter) => await Rpc.InvokeAsync(nameof(IController.PurgeBrokenFilesAsync), filter).ConfigureAwait(false); /// public async Task SendMailAsync() => await Rpc.InvokeAsync(nameof(IController.SendMailAsync)).ConfigureAwait(false); /// public async Task SystemInfoAsync() => await Rpc.InvokeAsync(nameof(IController.SystemInfoAsync)).ConfigureAwait(false); /// public async Task TestFilterAsync(string[] paths, IFilter? filter = null) => await Rpc.InvokeAsync(nameof(IController.TestFilterAsync), paths, filter).ConfigureAwait(false); /// public async Task ListChangesAsync(string baseVersion, string targetVersion, IEnumerable? filterstrings = null, IFilter? filter = null, Action>>? callback = null) => await Rpc.InvokeAsync(nameof(IController.ListChangesAsync), baseVersion, targetVersion, filterstrings, filter).ConfigureAwait(false); /// public async Task ListAffectedAsync(List? args, Action? callback = null) => await Rpc.InvokeAsync(nameof(IController.ListAffectedAsync), args).ConfigureAwait(false); /// public async Task RestoreControlFilesAsync(IEnumerable? files = null, IFilter? filter = null) => await Rpc.InvokeAsync(nameof(IController.RestoreControlFilesAsync), files, filter).ConfigureAwait(false); /// public async Task ListControlFilesAsync(IEnumerable? filterstrings, IFilter? filter) => await Rpc.InvokeAsync(nameof(IController.ListControlFilesAsync), filterstrings, filter).ConfigureAwait(false); /// public async Task AbortAsync() { if (_rpc != null) { try { await _rpc.InvokeAsync(nameof(IController.AbortAsync)).WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); } catch { // Ignore RPC errors during abort } } // Force kill the process try { if (_process != null && !_process.HasExited) { _process.Kill(); _process.WaitForExit(5000); } } catch { // Process may already be dead } } /// public async Task StopAsync() { if (_rpc != null) await _rpc.InvokeAsync(nameof(IController.StopAsync)).ConfigureAwait(false); } /// public async Task PauseAsync(bool alsoTransfers) { if (_rpc != null) await _rpc.InvokeAsync(nameof(IController.PauseAsync), alsoTransfers).ConfigureAwait(false); } /// public async Task ResumeAsync() { if (_rpc != null) await _rpc.InvokeAsync(nameof(IController.ResumeAsync)).ConfigureAwait(false); } /// public async Task SetThrottleSpeedsAsync(long maxUploadPrSecond, long maxDownloadPrSecond) { if (_rpc != null) await _rpc.InvokeAsync(nameof(IController.SetThrottleSpeedsAsync), maxUploadPrSecond, maxDownloadPrSecond).ConfigureAwait(false); } /// public async Task SetSecretProviderAsync(ISecretProvider? secretProvider) { if (_rpc != null) await _rpc.InvokeAsync(nameof(IController.SetSecretProviderAsync), secretProvider).ConfigureAwait(false); } /// public Task AppendSinkAsync(IMessageSink sink) { // In out-of-process mode, additional sinks cannot be added dynamically // This is a limitation of the RPC model Library.Logging.Log.WriteWarningMessage(LOGTAG, "AppendSinkNotSupported", null, "AppendSink is not supported in out-of-process mode"); return Task.CompletedTask; } #endregion #region IDisposable /// public void Dispose() { try { if (_rpc != null) { try { _rpc.InvokeAsync("DisposeAsync").WaitAsync(TimeSpan.FromSeconds(5)).Await(); } catch { // Ignore disposal errors } _rpc.Dispose(); } } catch { } try { if (_process != null && !_process.HasExited) { _process.Kill(); _process.WaitForExit(5000); } } catch { } _process?.Dispose(); } #endregion private OperationProgressWrapper? _operationProgressWrapper; /// /// Wrapper for backend progress DTO that implements IBackendProgress /// private class BackendProgressWrapper : IBackendProgress { private readonly BackendProgressDto _dto; public BackendProgressWrapper(BackendProgressDto dto) => _dto = dto; public BackendActionProgress[] GetActiveTransfers() => _dto.Transfers?.Select(t => new BackendActionProgress(t.Action, t.Path, t.Size, t.Progress, t.BytesPerSecond, t.IsBlocking)).ToArray() ?? []; } /// /// Wrapper for operation progress DTO that implements IOperationProgress /// private class OperationProgressWrapper : IOperationProgress { private OperationPhase _phase; private float _progress; private string? _currentFilename; private long _currentFileSize; private long _currentFileOffset; private bool _currentFileComplete; private long _filesProcessed; private long _fileSizeProcessed; private long _fileCount; private long _fileSize; private bool _countingFiles; private int _remoteSyncDestinationIndex; private int _remoteSyncDestinationCount; public event PhaseChangedDelegate? PhaseChanged; public void UpdateFromDto(OperationProgressDto dto) { _phase = dto.Phase; _progress = dto.Progress; _filesProcessed = dto.FilesProcessed; _fileSizeProcessed = dto.FileSizeProcessed; _fileCount = dto.FileCount; _fileSize = dto.FileSize; _countingFiles = dto.CountingFiles; _currentFilename = dto.CurrentFilename; _currentFileSize = dto.CurrentFileSize; _currentFileOffset = dto.CurrentFileOffset; _currentFileComplete = dto.CurrentFileComplete; _remoteSyncDestinationIndex = dto.RemoteSyncDestinationIndex; _remoteSyncDestinationCount = dto.RemoteSyncDestinationCount; } public void SetPhase(OperationPhase phase, OperationPhase previousPhase) { _phase = phase; PhaseChanged?.Invoke(phase, previousPhase); } public void UpdateOverall(out OperationPhase phase, out float progress, out long filesprocessed, out long filesizeprocessed, out long filecount, out long filesize, out bool countingfiles) { phase = _phase; progress = _progress; filesprocessed = _filesProcessed; filesizeprocessed = _fileSizeProcessed; filecount = _fileCount; filesize = _fileSize; countingfiles = _countingFiles; } public void UpdateFile(out string filename, out long filesize, out long fileoffset, out bool filecomplete) { filename = _currentFilename ?? string.Empty; filesize = _currentFileSize; fileoffset = _currentFileOffset; filecomplete = _currentFileComplete; } public void UpdateRemoteSyncDestination(out int destinationIndex, out int destinationCount) { destinationIndex = _remoteSyncDestinationIndex; destinationCount = _remoteSyncDestinationCount; } } }