// Copyright (C) 2024, 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 Duplicati.Library.Interface; using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; namespace Duplicati.Library.Modules.Builtin { /// /// Helper module to send HTTP report messages /// public class SendHttpMessage : ReportHelper { /// /// The tag used for logging /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(); /// /// Entry describing a request to be sent /// /// The url to send to /// The verb to use /// The format to send private record SendRequestType(string Url, string Verb, ResultExportFormat Format); #region Option names /// /// Option used to specify server URL /// private const string OPTION_URL = "send-http-url"; /// /// Option used to specify server URLs for sending text reports /// private const string OPTION_URL_FORM = "send-http-form-urls"; /// /// Option used to specify server URLs for sending json reports /// private const string OPTION_URL_JSON = "send-http-json-urls"; /// /// Option used to specify report body /// private const string OPTION_MESSAGE = "send-http-message"; /// /// Option used to specify report verb /// private const string OPTION_VERB = "send-http-verb"; /// /// Option used to specify the parameter name for the message /// private const string OPTION_MESSAGE_PARAMETER_NAME = "send-http-message-parameter-name"; /// /// Option used to specify extra parameters /// private const string OPTION_EXTRA_PARAMETERS = "send-http-extra-parameters"; /// /// Option used to specify report level /// private const string OPTION_SENDLEVEL = "send-http-level"; /// /// Option used to specify if reports are sent for other operations than backups /// private const string OPTION_SENDALL = "send-http-any-operation"; /// /// Option used to specify what format the result is sent in. /// private const string OPTION_RESULT_FORMAT = "send-http-result-output-format"; /// /// Option used to set the log level /// private const string OPTION_LOG_LEVEL = "send-http-log-level"; /// /// Option used to set the log level /// private const string OPTION_LOG_FILTER = "send-http-log-filter"; /// /// Option used to set the maximum number of log lines /// private const string OPTION_MAX_LOG_LINES = "send-http-max-log-lines"; #endregion #region Option defaults /// /// The default message parameter name /// private const string DEFAULT_MESSAGE_PARAMETER_NAME = "message"; /// /// The default message body /// protected override string DEFAULT_BODY => string.Format("Duplicati %OPERATIONNAME% report for %backup-name% (%machine-id%, %backup-id%){0}{0}%RESULT%", Environment.NewLine); /// /// Don't use the subject for HTTP /// protected override string DEFAULT_SUBJECT => string.Empty; #endregion #region Private variables /// /// The HTTP text report URLs /// private List m_report_targets; /// /// The message parameter name /// private string m_messageParameterName; /// /// The message parameter name /// private string m_extraParameters; #endregion #region Implementation of IGenericModule /// /// The module key, used to activate or deactivate the module on the commandline /// public override string Key { get { return "sendhttp"; } } /// /// A localized string describing the module with a friendly name /// public override string DisplayName { get { return Strings.SendHttpMessage.DisplayName; } } /// /// A localized description of the module /// public override string Description { get { return Strings.SendHttpMessage.Description; } } /// /// A boolean value that indicates if the module should always be loaded. /// If true, the user can choose to not load the module by entering the appropriate commandline option. /// If false, the user can choose to load the module by entering the appropriate commandline option. /// public override bool LoadAsDefault { get { return true; } } /// /// Gets a list of supported commandline arguments /// public override IList SupportedCommands { get { return new List(new ICommandLineArgument[] { new CommandLineArgument(OPTION_URL, CommandLineArgument.ArgumentType.String, Strings.SendHttpMessage.SendhttpurlShort, Strings.SendHttpMessage.SendhttpurlLong), new CommandLineArgument(OPTION_MESSAGE, CommandLineArgument.ArgumentType.String, Strings.SendHttpMessage.SendhttpmessageShort, Strings.SendHttpMessage.SendhttpmessageLong, DEFAULT_BODY), new CommandLineArgument(OPTION_MESSAGE_PARAMETER_NAME, CommandLineArgument.ArgumentType.String, Strings.SendHttpMessage.SendhttpmessageparameternameShort, Strings.SendHttpMessage.SendhttpmessageparameternameLong, DEFAULT_MESSAGE_PARAMETER_NAME), new CommandLineArgument(OPTION_EXTRA_PARAMETERS, CommandLineArgument.ArgumentType.String, Strings.SendHttpMessage.SendhttpextraparametersShort, Strings.SendHttpMessage.SendhttpextraparametersLong), new CommandLineArgument(OPTION_SENDLEVEL, CommandLineArgument.ArgumentType.Enumeration, Strings.SendHttpMessage.SendhttplevelShort, Strings.SendHttpMessage.SendhttplevelLong(ParsedResultType.Success.ToString(), ParsedResultType.Warning.ToString(), ParsedResultType.Error.ToString(), ParsedResultType.Fatal.ToString(), "All"), DEFAULT_LEVEL, null, Enum.GetNames(typeof(ParsedResultType)).Union(new string[] { "All" } ).ToArray()), new CommandLineArgument(OPTION_SENDALL, CommandLineArgument.ArgumentType.Boolean, Strings.SendHttpMessage.SendhttpanyoperationShort, Strings.SendHttpMessage.SendhttpanyoperationLong), new CommandLineArgument(OPTION_VERB, CommandLineArgument.ArgumentType.String, Strings.SendHttpMessage.HttpverbShort, Strings.SendHttpMessage.HttpverbLong, "POST"), new CommandLineArgument(OPTION_LOG_LEVEL, CommandLineArgument.ArgumentType.Enumeration, Strings.ReportHelper.OptionLoglevellShort, Strings.ReportHelper.OptionLoglevelLong, DEFAULT_LOG_LEVEL.ToString(), null, Enum.GetNames(typeof(Logging.LogMessageType))), new CommandLineArgument(OPTION_LOG_FILTER, CommandLineArgument.ArgumentType.String, Strings.ReportHelper.OptionLogfilterShort, Strings.ReportHelper.OptionLogfilterLong), new CommandLineArgument(OPTION_MAX_LOG_LINES, CommandLineArgument.ArgumentType.Integer, Strings.ReportHelper.OptionmaxloglinesShort, Strings.ReportHelper.OptionmaxloglinesLong, DEFAULT_LOGLINES.ToString()), new CommandLineArgument(OPTION_RESULT_FORMAT, CommandLineArgument.ArgumentType.Enumeration, Strings.ReportHelper.ResultFormatShort, Strings.ReportHelper.ResultFormatLong(Enum.GetNames(typeof(ResultExportFormat))), DEFAULT_EXPORT_FORMAT.ToString(), null, Enum.GetNames(typeof(ResultExportFormat))), new CommandLineArgument(OPTION_URL_FORM, CommandLineArgument.ArgumentType.String, Strings.SendHttpMessage.SendhttpurlsformShort, Strings.SendHttpMessage.SendhttpurlsformLong), new CommandLineArgument(OPTION_URL_JSON, CommandLineArgument.ArgumentType.String, Strings.SendHttpMessage.SendhttpurlsjsonShort, Strings.SendHttpMessage.SendhttpurlsjsonLong), }); } } protected override string SubjectOptionName => OPTION_MESSAGE; protected override string BodyOptionName => OPTION_MESSAGE; protected override string ActionLevelOptionName => OPTION_SENDLEVEL; protected override string ActionOnAnyOperationOptionName => OPTION_SENDALL; protected override string LogLevelOptionName => OPTION_LOG_LEVEL; protected override string LogFilterOptionName => OPTION_LOG_FILTER; protected override string LogLinesOptionName => OPTION_MAX_LOG_LINES; protected override string ResultFormatOptionName => OPTION_RESULT_FORMAT; /// /// This method is the interception where the module can interact with the execution environment and modify the settings. /// /// A set of commandline options passed to Duplicati protected override bool ConfigureModule(IDictionary commandlineOptions) { var reportTargets = new List(); // Grab the legacy URL option if it exists, and add it to the appropriate list commandlineOptions.TryGetValue(OPTION_URL, out var legacy_urls); if (!string.IsNullOrEmpty(legacy_urls)) { if (!commandlineOptions.TryGetValue(OPTION_RESULT_FORMAT, out var format)) format = ResultExportFormat.Duplicati.ToString(); if (!Enum.TryParse(format, out var exportFormat)) exportFormat = ResultExportFormat.Duplicati; commandlineOptions.TryGetValue(OPTION_VERB, out var verb); if (string.IsNullOrEmpty(verb)) verb = "POST"; reportTargets.AddRange(legacy_urls.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries).Select(url => new SendRequestType(url, verb, exportFormat))); } // Get the options as passed commandlineOptions.TryGetValue(OPTION_URL_FORM, out var formurls); if (!string.IsNullOrWhiteSpace(formurls)) reportTargets.AddRange(formurls.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries).Select(url => new SendRequestType(url, "POST", ResultExportFormat.Duplicati))); commandlineOptions.TryGetValue(OPTION_URL_JSON, out var jsonurls); if (!string.IsNullOrWhiteSpace(jsonurls)) reportTargets.AddRange(jsonurls.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries).Select(url => new SendRequestType(url, "POST", ResultExportFormat.Json))); //We need at least one URL to report to if (reportTargets.Count == 0) return false; m_report_targets = reportTargets; commandlineOptions.TryGetValue(OPTION_MESSAGE_PARAMETER_NAME, out m_messageParameterName); if (string.IsNullOrEmpty(m_messageParameterName)) m_messageParameterName = DEFAULT_MESSAGE_PARAMETER_NAME; commandlineOptions.TryGetValue(OPTION_EXTRA_PARAMETERS, out m_extraParameters); return true; } #endregion private async Task SendMessage(HttpClient client, SendRequestType target, string subject, string body) { byte[] data; MediaTypeHeaderValue contenttype; if (target.Format == ResultExportFormat.Json) { contenttype = new MediaTypeHeaderValue("application/json"); data = Encoding.UTF8.GetBytes(body); } else { contenttype = new MediaTypeHeaderValue("application/x-www-form-urlencoded"); var postData = $"{m_messageParameterName}={System.Uri.EscapeDataString(body)}"; if (!string.IsNullOrEmpty(m_extraParameters)) postData += $"&{System.Uri.EscapeUriString(m_extraParameters)}"; data = Encoding.UTF8.GetBytes(postData); } var request = new HttpRequestMessage { RequestUri = new Uri(target.Url), Method = new HttpMethod(target.Verb), Content = new ByteArrayContent(data) }; request.Content.Headers.ContentType = contenttype; try { var response = await client.SendAsync(request); var responseContent = await response.Content.ReadAsStringAsync(); Logging.Log.WriteVerboseMessage(LOGTAG, "HttpResponseMessage", "HTTP Response to {0}: {1} - {2}: {3}", target.Url, ((int)response.StatusCode).ToString(), response.ReasonPhrase, responseContent ); response.EnsureSuccessStatusCode(); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "HttpResponseError", ex, "HTTP Response request failed for: {0}", target.Url); return ex; } return null; } private Dictionary m_cachedBodyResults; private string m_form_body = string.Empty; protected override string ReplaceTemplate(string input, object result, Exception exception, bool subjectline) { // No need to do the expansion as we throw away the result if (subjectline) return string.Empty; if (m_report_targets == null) return string.Empty; m_cachedBodyResults = m_report_targets .Select(x => x.Format) .Distinct() .ToDictionary( x => x, x => base.ReplaceTemplate(input, result, exception, false, x) ); return string.Empty; } protected override void SendMessage(string subject, string body) { if (m_report_targets == null || m_cachedBodyResults == null) return; using var client = new HttpClient(); Exception ex = null; foreach (var target in m_report_targets) { if (m_cachedBodyResults.TryGetValue(target.Format, out var result)) ex ??= SendMessage(client, target, subject, result).ConfigureAwait(false).GetAwaiter().GetResult(); } if (ex != null) throw ex; } } }