// 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.Text; using Duplicati.Library.Utility; using Duplicati.Server; using Duplicati.WebserverCore.Abstractions; namespace Duplicati.WebserverCore.Services; public class CommandlineRunService(IQueueRunnerService queueRunnerService, ILogWriteHandler logWriteHandler) : ICommandlineRunService { private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType(); private class LogWriter(ActiveRun target, ILogWriteHandler logWriteHandler) : TextWriter { private readonly StringBuilder sb = new StringBuilder(); private int m_newlinechars = 0; public override Encoding Encoding => Encoding.UTF8; public override void Write(char value) { lock (target.Lock) { sb.Append(value); if (NewLine[m_newlinechars] == value) { m_newlinechars++; if (m_newlinechars == NewLine.Length) WriteLine(string.Empty); } else m_newlinechars = 0; } } public override void WriteLine(string? value) { value ??= string.Empty; lock (target.Lock) { target.LastAccess = DateTime.Now; //Avoid writing the log if it does not exist if (target.IsLogDisposed) { logWriteHandler.WriteMessage(new Library.Logging.LogEntry("Attempted to write message after closing: {0}", new object[] { value }, Library.Logging.LogMessageType.Warning, LOGTAG, "CommandLineOutputAfterLogClosed", null)); return; } try { if (sb.Length != 0) { target.Log.Add(sb + value); sb.Length = 0; m_newlinechars = 0; } else { target.Log.Add(value); } } catch (Exception ex) { // This can happen on a very unlucky race where IsLogDisposed is set right after the check logWriteHandler.WriteMessage(new Library.Logging.LogEntry("Failed to forward commandline message: {0}", new object[] { value }, Library.Logging.LogMessageType.Warning, LOGTAG, "CommandLineOutputAfterLogClosed", ex)); } } } } private class ActiveRun : ICommandlineRunService.IActiveRun { public string ID { get; } = Guid.NewGuid().ToString(); public DateTime LastAccess { get; set; } = DateTime.Now; public readonly Library.Utility.FileBackedStringList Log = new Library.Utility.FileBackedStringList(); public Runner.IRunnerData? Task; public LogWriter? Writer; public object Lock { get; } = new object(); public bool Finished { get; set; } = false; public bool Started { get; set; } = false; public bool IsLogDisposed { get; set; } = false; public IEnumerable GetLog() => Log; public void Abort() { var tt = this.Task; if (tt != null) tt.Abort(); } } private readonly Dictionary m_activeItems = new Dictionary(); private Task? m_cleanupTask; public ICommandlineRunService.IActiveRun? GetActiveRun(string id) { ActiveRun? t; lock (m_activeItems) m_activeItems.TryGetValue(id, out t); if (t != null) lock (t.Lock) t.LastAccess = DateTime.Now; return t; } public string StartTask(string[] args) { var k = new ActiveRun(); k.Writer = new LogWriter(k, logWriteHandler); m_activeItems[k.ID] = k; StartCleanupTask(); k.Task = Runner.CreateCustomTask((sink) => { try { k.Started = true; // Expand sources for supporting %DOCUMENTS% and similar if (args.Length > 2 && args[0].Equals("backup", StringComparison.OrdinalIgnoreCase)) { var options = args.Skip(1).Where(x => x.StartsWith("--", StringComparison.OrdinalIgnoreCase)) .ToArray(); var allargs = args.Skip(1) .Where(x => !x.StartsWith("--", StringComparison.OrdinalIgnoreCase)); if (allargs.Count() > 1) { var sources = allargs .Skip(1) .Select(SpecialFolders.ExpandEnvironmentVariables) .WhereNotNullOrWhiteSpace() .ToArray(); args = [args[0], allargs.First(), .. sources, .. options]; } } var code = CommandLine.Program.RunCommandLine(k.Writer, k.Writer, c => { k.Task!.SetController(c); c.AppendSink(sink); }, args); k.Writer.WriteLine("Return code: {0}", code); } catch (Exception ex) { var rx = ex; if (rx is System.Reflection.TargetInvocationException) rx = rx.InnerException; if (rx is Library.Interface.UserInformationException) k.Log.Add(rx.Message); else k.Log.Add(rx?.ToString() ?? "null exception?"); throw rx ?? new Exception("null exception?"); } finally { k.Finished = true; } }); queueRunnerService.AddTask(k.Task); return k.ID; } private void StartCleanupTask() { if (m_cleanupTask == null || m_cleanupTask.IsCompleted || m_cleanupTask.IsFaulted || m_cleanupTask.IsCanceled) m_cleanupTask = RunCleanupAsync(); } private async Task RunCleanupAsync() { while (m_activeItems.Count > 0) { var oldest = m_activeItems.Values .OrderBy(x => x.LastAccess) .FirstOrDefault(); if (oldest != null) { // If the task has finished, we just wait a little to allow the UI to pick it up var timeout = oldest.Finished ? TimeSpan.FromMinutes(5) : TimeSpan.FromDays(1); if (DateTime.Now - oldest.LastAccess > timeout) { oldest.IsLogDisposed = true; m_activeItems.Remove(oldest.ID); oldest.Log.Dispose(); // Fix all expired, or stop running continue; } } await Task.Delay(TimeSpan.FromMinutes(1)).ConfigureAwait(false); } } }