diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 8882759..827b4fc 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -20,6 +20,8 @@ jobs: uses: actions/setup-dotnet@v3 with: dotnet-version: 8.0.x + - name: Install .NET Aspire workload + run: dotnet workload install aspire - name: Restore dependencies run: dotnet restore ./src/wan24-AutoDiscover.sln --ignore-failed-sources - name: Build lib diff --git a/README.md b/README.md index 649a0da..aef3198 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,12 @@ Exchange POX autodiscover standard, which allows email clients to receive automatic configuration information for an email account. It was created using .NET 8 and ASP.NET. You find a published release build -for each published release on GitHub as ZIP download for self-hosting. +for each published release on GitHub as ZIP file download for self-hosting. + +The webservice is designed for working with dynamic MTA configurations and +tries to concentrate on the basics for fast request handling and response. All +required informations will be held in memory, so no database or filesystem +access is required for request handling. ## Usage @@ -20,9 +25,9 @@ For example on a Debian Linux server: ```bash mkdir /home/autodiscover cd /home/autodiscover -wget https://github.com/nd1012/wan24-AutoDiscover/releases/download/v1.0.0/wan24-AutoDiscover.v1.0.0.zip -unzip wan24-AutoDiscover.v1.0.0.zip -rm wan24-AutoDiscover.v1.0.0.zip +wget https://github.com/nd1012/wan24-AutoDiscover/releases/download/v1.1.0/wan24-AutoDiscover.v1.1.0.zip +unzip wan24-AutoDiscover.v1.1.0.zip +rm wan24-AutoDiscover.v1.1.0.zip ``` ### `appsettings.json` @@ -103,7 +108,7 @@ For serving a request, the `DomainConfig` will be looked up Any unmatched `DomainConfig` will cause a `Bad request` http response. -Documentation references: +Find the online documentation of the used types here: - [`DiscoveryConfig`](https://nd1012.github.io/wan24-AutoDiscover/api/wan24.AutoDiscover.Models.DiscoveryConfig.html) - [`DomainConfig`](https://nd1012.github.io/wan24-AutoDiscover/api/wan24.AutoDiscover.Models.DomainConfig.html) @@ -217,3 +222,147 @@ things for you: ```bash dotnet wan24AutoDiscover.dll autodiscover systemd > /etc/systemd/system/autodiscover.service ``` + +#### Parse a Postfix virtual configuration file + +```bash +dotnet wan24AutoDiscover.dll autodiscover postfix < /etc/postfix/virtual > /home/autodiscover/postfix.json +``` + +#### Display version number + +```bash +dotnet wan24AutoDiscover.dll autodiscover version +``` + +#### Upgrade online + +Check for an available newer version only: + +```bash +dotnet wan24AutoDiscover.dll autodiscover upgrade -checkOnly +``` + +**NOTE**: The command will exit with code #2, if an update is available online. + +Upgrade with user interaction: + +```bash +dotnet wan24AutoDiscover.dll autodiscover upgrade +``` + +Upgrade without user interaction: + +```bash +dotnet wan24AutoDiscover.dll autodiscover upgrade -noUserInteraction +``` + +#### Display detailed CLI API usage instructions + +```bash +dotnet wan24AutoDiscover.dll help -details +``` + +## Login name mapping + +If the login name isn't the email address or the alias of the given email +address, you can create a login name mapping per domain and/or protocol, by +defining a mapping from the email address or alias to the login name. During +lookup the protocol mapping and then the domain mapping will be used by trying +the email address and then the alias as key. + +### Automatic email address to login user mapping with Postfix + +If your Postfix virtual email mappings are stored in a hash text file, you can +create an email mapping from is using + +```bash +dotnet wan24AutoDiscover.dll autodiscover postfix < /etc/postfix/virtual > /home/autodiscover/postfix.json +``` + +Then you can add the `postix.json` to your `appsettings.json`: + +```json +{ + ... + "DiscoveryConfig": { + ... + "EmailMappings": "/home/autodiscover/postfix.json", + ... + } +} +``` + +The configuration will be reloaded, if the `postfix.json` file changed, so be +sure to re-create the `postfix.json` file as soon as the `virtual` file was +changed. If you don't want that, set `WatchEmailMappings` to `false`. + +### Additionally watched files + +You can set a list of additionally watched file paths to `WatchFiles` in your +`appsettings.json` file. When any file was changed, the configuration will be +reloaded. + +### Pre-reload command execution + +To execute a command before reloading a changed configration, set the +`PreReloadCommand` value in your `appsettings.json` like this: + +```json +{ + ... + "DiscoveryConfig": { + ... + "PreReloadCommand": ["/command/to/execute", "argument1", "argument2", ...], + ... + } +} +``` + +## Automatic online upgrades + +You can upgrade `wan24-AutoDiscover` online and automatic. For this some steps +are recommended: + +1. Create sheduled task for auto-upgrade (daily, for example) +1. Stop the service before installing the newer version +1. Start the service after installing the newer version + +The sheduled auto-upgrade task should execute this command on a Debian Linux +server, for example: + +```bash +dotnet /home/autodiscover/wan24AutoDiscover.dll autodiscover upgrade -noUserInteraction --preCommand systemctl stop autodiscover --postCommand systemctl start autodiscover +``` + +If the upgrade download failed, nothing will happen - the upgrade won't be +installed only and being re-tried at the next sheduled auto-upgrade time. + +If the upgrade installation failed, the post-upgrade command won't be +executed, and the autodiscover service won't run. This'll give you the chance +to investigate the broken upgrade and optional restore the running version +manually. + +**CAUTION**: The auto-upgrade is being performed using the GitHub repository. +There are no security checks at present - so if the repository was hacked, you +could end up with upgrading to a compromised software which could harm your +system! + +The upgrade setup should be done in less than a second, if everything was fine. + +## Manual upgrade + +1. Download the latest release ZIP file from GitHub +1. Extract the ZIP file to a temporary folder +1. Stop the autodiscover service, if running +1. Create a backup of your current installation +1. Copy all extracted files/folders excluding `appsettings.json` to your +installation folder +1. Remove files/folders that are no longer required and perform additional +upgrade steps, which are required for the new release (see upgrade +instructions) +1. Start the autodiscover service +1. Delete the previously created backup and the temporary folder + +These steps are being executed during an automatic upgrade like described +above also. diff --git a/latest-release.txt b/latest-release.txt new file mode 100644 index 0000000..1cc5f65 --- /dev/null +++ b/latest-release.txt @@ -0,0 +1 @@ +1.1.0 \ No newline at end of file diff --git a/src/wan24-AutoDiscover Shared/Constants.cs b/src/wan24-AutoDiscover Shared/Constants.cs index d26c6a7..b8d2d0e 100644 --- a/src/wan24-AutoDiscover Shared/Constants.cs +++ b/src/wan24-AutoDiscover Shared/Constants.cs @@ -5,9 +5,41 @@ /// public static class Constants { + /// + /// Auto discovery XML namespace + /// + public const string AUTO_DISCOVER_NS = "http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006"; /// /// POX response node XML namespace /// public const string RESPONSE_NS = "https://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a"; + /// + /// Autodiscover node name + /// + public const string AUTODISCOVER_NODE_NAME = "Autodiscover"; + /// + /// Response node name + /// + public const string RESPONSE_NODE_NAME = "Response"; + /// + /// Account node name + /// + public const string ACCOUNT_NODE_NAME = "Account"; + /// + /// AccountType node name + /// + public const string ACCOUNTTYPE_NODE_NAME = "AccountType"; + /// + /// Account type + /// + public const string ACCOUNTTYPE = "email"; + /// + /// Action node name + /// + public const string ACTION_NODE_NAME = "Action"; + /// + /// Action + /// + public const string ACTION = "settings"; } } diff --git a/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs index eed1362..edf23f2 100644 --- a/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs +++ b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs @@ -3,6 +3,7 @@ using System.Collections.Frozen; using System.ComponentModel.DataAnnotations; using System.Net; +using System.Net.Mail; using System.Text.Json.Serialization; using wan24.Core; using wan24.ObjectValidation; @@ -12,12 +13,22 @@ namespace wan24.AutoDiscover.Models /// /// Discovery configuration /// - public record class DiscoveryConfig + public record class DiscoveryConfig : ValidatableRecordBase { + /// + /// Discovery configuration type + /// + protected Type? _DiscoveryType = null; + /// /// Constructor /// - public DiscoveryConfig() { } + public DiscoveryConfig() : base() { } + + /// + /// Current configuration + /// + public static DiscoveryConfig Current { get; set; } = null!; /// /// Logfile path @@ -41,7 +52,7 @@ public DiscoveryConfig() { } /// Discovery configuration type /// [JsonIgnore] - public Type DiscoveryType => DiscoveryTypeName is null + public virtual Type DiscoveryType => _DiscoveryType ??= string.IsNullOrWhiteSpace(DiscoveryTypeName) ? typeof(Dictionary) : TypeHelper.Instance.GetType(DiscoveryTypeName) ?? throw new InvalidDataException($"Discovery type {DiscoveryTypeName.ToQuotedLiteral()} not found"); @@ -49,14 +60,38 @@ public DiscoveryConfig() { } /// /// Known http proxies /// - public HashSet KnownProxies { get; init; } = []; + public IReadOnlySet KnownProxies { get; init; } = new HashSet(); + + /// + /// JSON file path which contains the email mappings list + /// + [StringLength(short.MaxValue, MinimumLength = 1)] + public string? EmailMappings { get; init; } + + /// + /// Watch email mappings list file changes for reloading the configuration? + /// + public bool WatchEmailMappings { get; init; } = true; + + /// + /// Additional file paths to watch for an automatic configuration reload + /// + [CountLimit(1, byte.MaxValue), ItemStringLength(short.MaxValue)] + public string[]? WatchFiles { get; init; } + + /// + /// Command to execute (and optional arguments) before reloading the configuration + /// + [CountLimit(1, byte.MaxValue), ItemStringLength(short.MaxValue)] + public string[]? PreReloadCommand { get; init; } /// /// Get the discovery configuration /// /// Configuration + /// Cancellation token /// Discovery configuration - public FrozenDictionary GetDiscoveryConfig(IConfigurationRoot config) + public virtual async Task> GetDiscoveryConfigAsync(IConfigurationRoot config, CancellationToken cancellationToken = default) { Type discoveryType = DiscoveryType; if (!typeof(IDictionary).IsAssignableFrom(discoveryType)) @@ -68,19 +103,64 @@ public FrozenDictionary GetDiscoveryConfig(IConfigurationR if (gt.Length != 2) throw new InvalidDataException($"Discovery type must be a generic type with two type arguments"); if (gt[0] != typeof(string)) - throw new InvalidDataException($"Discovery types first generic type argument must be a {typeof(string)}"); + throw new InvalidDataException($"Discovery types first generic type argument must be {typeof(string)}"); if (!typeof(DomainConfig).IsAssignableFrom(gt[1])) throw new InvalidDataException($"Discovery types second generic type argument must be a {typeof(DomainConfig)}"); // Parse the discovery configuration IDictionary discovery = config.GetRequiredSection("DiscoveryConfig:Discovery").Get(discoveryType) as IDictionary - ?? throw new InvalidDataException("Failed to get discovery configuration"); + ?? throw new InvalidDataException("Failed to get discovery configuration from the \"DiscoveryConfig:Discovery section\""); object[] keys = new object[discovery.Count], values = new object[discovery.Count]; discovery.Keys.CopyTo(keys, index: 0); discovery.Values.CopyTo(values, index: 0); - return new Dictionary( + Dictionary discoveryDomains = new( Enumerable.Range(0, discovery.Count).Select(i => new KeyValuePair((string)keys[i], (DomainConfig)values[i])) - ).ToFrozenDictionary(); + ); + // Apply email mappings + if (!string.IsNullOrWhiteSpace(EmailMappings)) + if (File.Exists(EmailMappings)) + { + Logging.WriteInfo($"Loading email mappings from {EmailMappings.ToQuotedLiteral()}"); + FileStream fs = FsHelper.CreateFileStream(EmailMappings, FileMode.Open, FileAccess.Read, FileShare.Read); + EmailMapping[] mappings; + await using (fs.DynamicContext()) + mappings = await JsonHelper.DecodeAsync(fs, cancellationToken).DynamicContext() + ?? throw new InvalidDataException("Invalid email mappings"); + foreach(EmailMapping mapping in mappings) + { + if (!mapping.Email.Contains('@')) + { + if (Logging.Debug) + Logging.WriteDebug($"Skipping invalid email address {mapping.Email.ToQuotedLiteral()}"); + continue; + } + string email = mapping.Email.ToLower(); + string[] emailParts = email.Split('@', 2); + if ( + emailParts.Length != 2 || + !MailAddress.TryCreate(email, out MailAddress? emailAddress) || + (emailAddress.User.Length == 1 && (emailAddress.User[0] == '*' || emailAddress.User[0] == '@')) || + EmailMapping.GetLoginUser(mappings, email) is not string loginUser || + DomainConfig.GetConfig(string.Empty, emailParts) is not DomainConfig domain + ) + { + if (Logging.Debug) + Logging.WriteDebug($"Mapping email address {email.ToQuotedLiteral()} to login user failed, because it seems to be a redirection to an external target, or no matching domain configuration was found"); + continue; + } + if (Logging.Debug) + Logging.WriteDebug($"Mapping email address {email.ToQuotedLiteral()} to login user {loginUser.ToQuotedLiteral()}"); + domain.LoginNameMapping ??= []; + if (Logging.Debug && domain.LoginNameMapping.ContainsKey(email)) + Logging.WriteDebug($"Overwriting existing email address {email.ToQuotedLiteral()} mapping"); + domain.LoginNameMapping[email] = loginUser; + } + } + else + { + Logging.WriteWarning($"Email mappings file {EmailMappings.ToQuotedLiteral()} not found"); + } + return discoveryDomains.ToFrozenDictionary(); } } } diff --git a/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs b/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs index 71595fd..4c4be5a 100644 --- a/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs +++ b/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs @@ -6,12 +6,12 @@ namespace wan24.AutoDiscover.Models /// /// Domain configuration /// - public record class DomainConfig + public record class DomainConfig : ValidatableRecordBase { /// /// Constructor /// - public DomainConfig() { } + public DomainConfig() : base() { } /// /// Registered domains (key is the served domain name) @@ -21,24 +21,51 @@ public DomainConfig() { } /// /// Accepted domain names /// - [ItemRegularExpression(@"^[a-z|-|\.]{1,256}$")] - public HashSet? AcceptedDomains { get; init; } + [CountLimit(1, int.MaxValue), ItemRegularExpression(@"^[a-z|-|\.]{1,256}$")] + public IReadOnlyList? AcceptedDomains { get; init; } /// /// Protocols /// - [CountLimit(1, int.MaxValue)] - public required virtual HashSet Protocols { get; init; } + [CountLimit(1, byte.MaxValue)] + public required IReadOnlyList Protocols { get; init; } + + /// + /// Login name mapping (key is the email address or alias, value the mapped login name) + /// + [RequiredIf(nameof(LoginNameMappingRequired), true)] + public Dictionary? LoginNameMapping { get; set; } + + /// + /// If a successfule login name mapping is required (if no mapping was possible, the email address will be used as login name) + /// + public bool LoginNameMappingRequired { get; init; } /// /// Create XML /// /// XML - /// Account node /// Splitted email parts - public virtual void CreateXml(XmlDocument xml, XmlNode account, string[] emailParts) + public virtual void CreateXml(XmlWriter xml, ReadOnlyMemory emailParts) { - foreach (Protocol protocol in Protocols) protocol.CreateXml(xml, account, emailParts); + foreach (Protocol protocol in Protocols) protocol.CreateXml(xml, emailParts, this); } + + /// + /// Get a domain configuration which matches an email address + /// + /// Hostname + /// Splitted email parts + /// Domain configuration + public static DomainConfig? GetConfig(string host, ReadOnlyMemory emailParts) + => !Registered.TryGetValue(emailParts.Span[1], out DomainConfig? config) && + (host.Length == 0 || !Registered.TryGetValue(host, out config)) && + !Registered.TryGetValue( + Registered.Where(kvp => kvp.Value.AcceptedDomains?.Contains(emailParts.Span[1], StringComparer.OrdinalIgnoreCase) ?? false) + .Select(kvp => kvp.Key) + .FirstOrDefault() ?? string.Empty, + out config) + ? null + : config; } } diff --git a/src/wan24-AutoDiscover Shared/Models/EmailMapping.cs b/src/wan24-AutoDiscover Shared/Models/EmailMapping.cs new file mode 100644 index 0000000..d685439 --- /dev/null +++ b/src/wan24-AutoDiscover Shared/Models/EmailMapping.cs @@ -0,0 +1,59 @@ +using System.ComponentModel.DataAnnotations; +using wan24.ObjectValidation; + +namespace wan24.AutoDiscover.Models +{ + /// + /// Email mapping + /// + public record class EmailMapping : ValidatableRecordBase + { + /// + /// Constructor + /// + public EmailMapping() : base() { } + + /// + /// Emailaddress + /// + [EmailAddress] + public required string Email { get; init; } + + /// + /// Target email addresses or user names + /// + [CountLimit(1, int.MaxValue), ItemStringLength(byte.MaxValue)] + public required IReadOnlyList Targets { get; init; } + + /// + /// Get the login user from email mappings for an email address + /// + /// Mappings + /// Email address + /// Login user + public static string? GetLoginUser(IEnumerable mappings, string email) + { + if (mappings.FirstOrDefault(m => m.Email.Equals(email, StringComparison.OrdinalIgnoreCase)) is not EmailMapping mapping) + return null; + if (mapping.Targets.FirstOrDefault(t => !t.Contains('@')) is string loginName) + return loginName; + HashSet seen = [email]; + Queue emails = []; + foreach (string target in mapping.Targets) + emails.Enqueue(target.ToLower()); + while(emails.TryDequeue(out string? target)) + { + if ( + !seen.Add(target) || + mappings.FirstOrDefault(m => m.Email.Equals(email, StringComparison.OrdinalIgnoreCase)) is not EmailMapping targetMapping + ) + continue; + if (targetMapping.Targets.FirstOrDefault(t => !t.Contains('@')) is string targetLoginName) + return targetLoginName; + foreach (string subTarget in targetMapping.Targets) + emails.Enqueue(subTarget.ToLower()); + } + return null; + } + } +} diff --git a/src/wan24-AutoDiscover Shared/Models/Protocol.cs b/src/wan24-AutoDiscover Shared/Models/Protocol.cs index 4dad89e..91e55f0 100644 --- a/src/wan24-AutoDiscover Shared/Models/Protocol.cs +++ b/src/wan24-AutoDiscover Shared/Models/Protocol.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Xml; +using wan24.ObjectValidation; // https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/protocol-pox @@ -8,7 +9,7 @@ namespace wan24.AutoDiscover.Models /// /// Protocol (POX) /// - public record class Protocol + public record class Protocol : ValidatableRecordBase { /// /// Protocol node name @@ -54,19 +55,17 @@ public record class Protocol /// /// Constructor /// - public Protocol() { } + public Protocol() : base() { } /// /// Login name getter delegate /// - public static LoginName_Delegate LoginName { get; set; } = (xml, account, emailParts, protocol) => protocol.LoginNameIsEmailAlias - ? emailParts[0] - : string.Join('@', emailParts); + public static LoginName_Delegate LoginName { get; set; } = DefaultLoginName; /// /// Type /// - [Required] + [Required, StringLength(byte.MaxValue, MinimumLength = 1)] public required string Type { get; init; } /// @@ -81,6 +80,17 @@ public Protocol() { } [Range(1, ushort.MaxValue)] public int Port { get; init; } + /// + /// Login name mapping (key is the email address or alias, value the mapped login name) + /// + [RequiredIf(nameof(LoginNameMappingRequired), true)] + public IReadOnlyDictionary? LoginNameMapping { get; init; } + + /// + /// If a successfule login name mapping is required (if no mapping was possible, the email address will be used as login name) + /// + public bool LoginNameMappingRequired { get; init; } + /// /// If the login name is the alias of the email address /// @@ -105,32 +115,56 @@ public Protocol() { } /// Create XML /// /// XML - /// Account node /// Splitted email parts - public virtual void CreateXml(XmlDocument xml, XmlNode account, string[] emailParts) + /// Domain + public virtual void CreateXml(XmlWriter xml, ReadOnlyMemory emailParts, DomainConfig domain) { - XmlNode protocol = account.AppendChild(xml.CreateElement(PROTOCOL_NODE_NAME, Constants.RESPONSE_NS))!; + xml.WriteStartElement(PROTOCOL_NODE_NAME); foreach (KeyValuePair kvp in new Dictionary() { {TYPE_NODE_NAME, Type }, {SERVER_NODE_NAME, Server }, {PORT_NODE_NAME, Port.ToString() }, - {LOGINNAME_NODE_NAME, LoginName(xml, account, emailParts, this) }, + {LOGINNAME_NODE_NAME, LoginName(emailParts, domain, this) }, {SPA_NODE_NAME, SPA ? ON : OFF }, {SSL_NODE_NAME, SSL ? ON : OFF }, {AUTHREQUIRED_NODE_NAME, AuthRequired ? ON : OFF } }) - protocol.AppendChild(xml.CreateElement(kvp.Key, Constants.RESPONSE_NS))!.InnerText = kvp.Value; + xml.WriteElementString(kvp.Key, kvp.Value); + xml.WriteEndElement(); + xml.Flush(); } /// - /// Delegate for a login name getter + /// Delegate for a login name resolver /// - /// XML - /// Account node /// Splitted email parts + /// Domain /// Protocol /// Login name - public delegate string LoginName_Delegate(XmlDocument xml, XmlNode account, string[] emailParts, Protocol protocol); + public delegate string LoginName_Delegate(ReadOnlyMemory emailParts, DomainConfig domain, Protocol protocol); + + /// + /// Default login name resolver + /// + /// Splitted email parts + /// Domain + /// Protocol + /// Login name + public static string DefaultLoginName(ReadOnlyMemory emailParts, DomainConfig domain, Protocol protocol) + { + string emailAddress = string.Join('@', emailParts), + res = protocol.LoginNameIsEmailAlias + ? emailParts.Span[0] + : emailAddress; + string? loginName = null; + return (protocol.LoginNameMapping?.TryGetValue(emailAddress, out loginName) ?? false) || + (protocol.LoginNameIsEmailAlias && (protocol.LoginNameMapping?.TryGetValue(res, out loginName) ?? false)) || + (domain.LoginNameMapping?.TryGetValue(emailAddress, out loginName) ?? false) || + (protocol.LoginNameIsEmailAlias && (domain.LoginNameMapping?.TryGetValue(res, out loginName) ?? false)) || + (loginName is null && (domain.LoginNameMappingRequired || protocol.LoginNameMappingRequired)) + ? loginName ?? emailAddress + : res; + } } } diff --git a/src/wan24-AutoDiscover Shared/Properties/Resources.Designer.cs b/src/wan24-AutoDiscover Shared/Properties/Resources.Designer.cs new file mode 100644 index 0000000..e0904e6 --- /dev/null +++ b/src/wan24-AutoDiscover Shared/Properties/Resources.Designer.cs @@ -0,0 +1,72 @@ +//------------------------------------------------------------------------------ +// +// Dieser Code wurde von einem Tool generiert. +// Laufzeitversion:4.0.30319.42000 +// +// Änderungen an dieser Datei können falsches Verhalten verursachen und gehen verloren, wenn +// der Code erneut generiert wird. +// +//------------------------------------------------------------------------------ + +namespace wan24.AutoDiscover.Properties { + using System; + + + /// + /// Eine stark typisierte Ressourcenklasse zum Suchen von lokalisierten Zeichenfolgen usw. + /// + // Diese Klasse wurde von der StronglyTypedResourceBuilder automatisch generiert + // -Klasse über ein Tool wie ResGen oder Visual Studio automatisch generiert. + // Um einen Member hinzuzufügen oder zu entfernen, bearbeiten Sie die .ResX-Datei und führen dann ResGen + // mit der /str-Option erneut aus, oder Sie erstellen Ihr VS-Projekt neu. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Gibt die zwischengespeicherte ResourceManager-Instanz zurück, die von dieser Klasse verwendet wird. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("wan24.AutoDiscover.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Überschreibt die CurrentUICulture-Eigenschaft des aktuellen Threads für alle + /// Ressourcenzuordnungen, die diese stark typisierte Ressourcenklasse verwenden. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Sucht eine lokalisierte Zeichenfolge, die 1.1.0 ähnelt. + /// + internal static string VERSION { + get { + return ResourceManager.GetString("VERSION", resourceCulture); + } + } + } +} diff --git a/src/wan24-AutoDiscover Shared/Properties/Resources.resx b/src/wan24-AutoDiscover Shared/Properties/Resources.resx new file mode 100644 index 0000000..70dbabc --- /dev/null +++ b/src/wan24-AutoDiscover Shared/Properties/Resources.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 1.1.0 + + \ No newline at end of file diff --git a/src/wan24-AutoDiscover Shared/VersionInfo.cs b/src/wan24-AutoDiscover Shared/VersionInfo.cs new file mode 100644 index 0000000..e9a9e88 --- /dev/null +++ b/src/wan24-AutoDiscover Shared/VersionInfo.cs @@ -0,0 +1,18 @@ +namespace wan24.AutoDiscover +{ + /// + /// Version information + /// + public static class VersionInfo + { + /// + /// Current version + /// + private static Version? _Current = null; + + /// + /// Current version + /// + public static Version Current => _Current ??= new(Properties.Resources.VERSION); + } +} diff --git a/src/wan24-AutoDiscover Shared/wan24-AutoDiscover Shared.csproj b/src/wan24-AutoDiscover Shared/wan24-AutoDiscover Shared.csproj index 85a7e98..fab8a58 100644 --- a/src/wan24-AutoDiscover Shared/wan24-AutoDiscover Shared.csproj +++ b/src/wan24-AutoDiscover Shared/wan24-AutoDiscover Shared.csproj @@ -7,12 +7,33 @@ enable wan24AutoDiscoverShared True + Debug;Release;Trunk - - + + + + + + + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + diff --git a/src/wan24-AutoDiscover.AppHost/Program.cs b/src/wan24-AutoDiscover.AppHost/Program.cs new file mode 100644 index 0000000..948ba6b --- /dev/null +++ b/src/wan24-AutoDiscover.AppHost/Program.cs @@ -0,0 +1,5 @@ +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddProject("wan24-autodiscover"); + +builder.Build().Run(); diff --git a/src/wan24-AutoDiscover.AppHost/Properties/launchSettings.json b/src/wan24-AutoDiscover.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..86cc6cc --- /dev/null +++ b/src/wan24-AutoDiscover.AppHost/Properties/launchSettings.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15065", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16171" + } + } + } +} diff --git a/src/wan24-AutoDiscover.AppHost/appsettings.Development.json b/src/wan24-AutoDiscover.AppHost/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/wan24-AutoDiscover.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/wan24-AutoDiscover.AppHost/appsettings.json b/src/wan24-AutoDiscover.AppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/src/wan24-AutoDiscover.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/wan24-AutoDiscover.AppHost/wan24-AutoDiscover.AppHost.csproj b/src/wan24-AutoDiscover.AppHost/wan24-AutoDiscover.AppHost.csproj new file mode 100644 index 0000000..3b19f44 --- /dev/null +++ b/src/wan24-AutoDiscover.AppHost/wan24-AutoDiscover.AppHost.csproj @@ -0,0 +1,20 @@ + + + + Exe + net8.0 + enable + enable + true + Debug;Release;Trunk + + + + + + + + + + + diff --git a/src/wan24-AutoDiscover.ServiceDefaults/Extensions.cs b/src/wan24-AutoDiscover.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..f444496 --- /dev/null +++ b/src/wan24-AutoDiscover.ServiceDefaults/Extensions.cs @@ -0,0 +1,118 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.UseServiceDiscovery(); + }); + + return builder; + } + + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddProcessInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + if (builder.Environment.IsDevelopment()) + { + // We want to view all traces in development + tracing.SetSampler(new AlwaysOnSampler()); + } + + tracing.AddAspNetCoreInstrumentation() + .AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.Configure(logging => logging.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); + } + + // Uncomment the following lines to enable the Prometheus exporter (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) + // builder.Services.AddOpenTelemetry() + // .WithMetrics(metrics => metrics.AddPrometheusExporter()); + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Uncomment the following line to enable the Prometheus endpoint (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) + // app.MapPrometheusScrapingEndpoint(); + + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + + return app; + } +} diff --git a/src/wan24-AutoDiscover.ServiceDefaults/wan24-AutoDiscover.ServiceDefaults.csproj b/src/wan24-AutoDiscover.ServiceDefaults/wan24-AutoDiscover.ServiceDefaults.csproj new file mode 100644 index 0000000..a67d9bc --- /dev/null +++ b/src/wan24-AutoDiscover.ServiceDefaults/wan24-AutoDiscover.ServiceDefaults.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + true + Debug;Release;Trunk + + + + + + + + + + + + + + + + + diff --git a/src/wan24-AutoDiscover.sln b/src/wan24-AutoDiscover.sln index 6849790..b569976 100644 --- a/src/wan24-AutoDiscover.sln +++ b/src/wan24-AutoDiscover.sln @@ -5,22 +5,61 @@ VisualStudioVersion = 17.10.34707.107 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "wan24-AutoDiscover", "wan24-AutoDiscover\wan24-AutoDiscover.csproj", "{91087847-7A9C-4120-8A91-27CDF44E21E7}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "wan24-AutoDiscover Shared", "wan24-AutoDiscover Shared\wan24-AutoDiscover Shared.csproj", "{610B6034-2404-4EBA-80E1-92102CE9E5B4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "wan24-AutoDiscover Shared", "wan24-AutoDiscover Shared\wan24-AutoDiscover Shared.csproj", "{610B6034-2404-4EBA-80E1-92102CE9E5B4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "wan24-AutoDiscover.AppHost", "wan24-AutoDiscover.AppHost\wan24-AutoDiscover.AppHost.csproj", "{697D3342-956F-4C77-93A9-1C80833EF5F5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "wan24-AutoDiscover.ServiceDefaults", "wan24-AutoDiscover.ServiceDefaults\wan24-AutoDiscover.ServiceDefaults.csproj", "{F506B56D-8CF8-4C42-A842-6B4EBD4E58B1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wan24-Core", "..\..\wan24-Core\src\Wan24-Core\Wan24-Core.csproj", "{08E6E5D6-4B4A-46DB-9BD0-E649ABF456D0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ObjectValidation", "..\..\ObjectValidation\src\ObjectValidation\ObjectValidation.csproj", "{6DD8879F-DBF9-4C20-BF4E-139A7DD3ED48}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "wan24-CLI", "..\..\wan24-CLI\src\wan24-CLI\wan24-CLI.csproj", "{D0D4CCB2-22DE-47BB-9FA8-5D0FD4DA20A3}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU + Trunk|Any CPU = Trunk|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {91087847-7A9C-4120-8A91-27CDF44E21E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {91087847-7A9C-4120-8A91-27CDF44E21E7}.Debug|Any CPU.Build.0 = Debug|Any CPU {91087847-7A9C-4120-8A91-27CDF44E21E7}.Release|Any CPU.ActiveCfg = Release|Any CPU {91087847-7A9C-4120-8A91-27CDF44E21E7}.Release|Any CPU.Build.0 = Release|Any CPU + {91087847-7A9C-4120-8A91-27CDF44E21E7}.Trunk|Any CPU.ActiveCfg = Trunk|Any CPU + {91087847-7A9C-4120-8A91-27CDF44E21E7}.Trunk|Any CPU.Build.0 = Trunk|Any CPU {610B6034-2404-4EBA-80E1-92102CE9E5B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {610B6034-2404-4EBA-80E1-92102CE9E5B4}.Debug|Any CPU.Build.0 = Debug|Any CPU {610B6034-2404-4EBA-80E1-92102CE9E5B4}.Release|Any CPU.ActiveCfg = Release|Any CPU {610B6034-2404-4EBA-80E1-92102CE9E5B4}.Release|Any CPU.Build.0 = Release|Any CPU + {610B6034-2404-4EBA-80E1-92102CE9E5B4}.Trunk|Any CPU.ActiveCfg = Trunk|Any CPU + {610B6034-2404-4EBA-80E1-92102CE9E5B4}.Trunk|Any CPU.Build.0 = Trunk|Any CPU + {697D3342-956F-4C77-93A9-1C80833EF5F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {697D3342-956F-4C77-93A9-1C80833EF5F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {697D3342-956F-4C77-93A9-1C80833EF5F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {697D3342-956F-4C77-93A9-1C80833EF5F5}.Release|Any CPU.Build.0 = Release|Any CPU + {697D3342-956F-4C77-93A9-1C80833EF5F5}.Trunk|Any CPU.ActiveCfg = Trunk|Any CPU + {697D3342-956F-4C77-93A9-1C80833EF5F5}.Trunk|Any CPU.Build.0 = Trunk|Any CPU + {F506B56D-8CF8-4C42-A842-6B4EBD4E58B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F506B56D-8CF8-4C42-A842-6B4EBD4E58B1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F506B56D-8CF8-4C42-A842-6B4EBD4E58B1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F506B56D-8CF8-4C42-A842-6B4EBD4E58B1}.Release|Any CPU.Build.0 = Release|Any CPU + {F506B56D-8CF8-4C42-A842-6B4EBD4E58B1}.Trunk|Any CPU.ActiveCfg = Trunk|Any CPU + {F506B56D-8CF8-4C42-A842-6B4EBD4E58B1}.Trunk|Any CPU.Build.0 = Trunk|Any CPU + {08E6E5D6-4B4A-46DB-9BD0-E649ABF456D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08E6E5D6-4B4A-46DB-9BD0-E649ABF456D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08E6E5D6-4B4A-46DB-9BD0-E649ABF456D0}.Trunk|Any CPU.ActiveCfg = Trunk|Any CPU + {08E6E5D6-4B4A-46DB-9BD0-E649ABF456D0}.Trunk|Any CPU.Build.0 = Trunk|Any CPU + {6DD8879F-DBF9-4C20-BF4E-139A7DD3ED48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6DD8879F-DBF9-4C20-BF4E-139A7DD3ED48}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6DD8879F-DBF9-4C20-BF4E-139A7DD3ED48}.Trunk|Any CPU.ActiveCfg = Trunk|Any CPU + {6DD8879F-DBF9-4C20-BF4E-139A7DD3ED48}.Trunk|Any CPU.Build.0 = Trunk|Any CPU + {D0D4CCB2-22DE-47BB-9FA8-5D0FD4DA20A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0D4CCB2-22DE-47BB-9FA8-5D0FD4DA20A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0D4CCB2-22DE-47BB-9FA8-5D0FD4DA20A3}.Trunk|Any CPU.ActiveCfg = Trunk|Any CPU + {D0D4CCB2-22DE-47BB-9FA8-5D0FD4DA20A3}.Trunk|Any CPU.Build.0 = Trunk|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs b/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs index 3254461..8187373 100644 --- a/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs +++ b/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using System.Net; using System.Net.Mail; -using System.Text; using System.Xml; using wan24.AutoDiscover.Models; using wan24.AutoDiscover.Services; @@ -17,115 +16,134 @@ namespace wan24.AutoDiscover.Controllers /// /// Responses [ApiController, Route("autodiscover")] - public class DiscoveryController(XmlDocumentInstances responses) : ControllerBase() + public sealed class DiscoveryController(InstancePool responses) : ControllerBase() { /// - /// Request XML email address node XPath selector + /// Max. request length in bytes /// - private const string EMAIL_NODE_XPATH = "//*[local-name()='EMailAddress']"; + private const int MAX_REQUEST_LEN = byte.MaxValue << 1; /// - /// Request XML acceptable response node XPath selector + /// OK http status code /// - private const string ACCEPTABLE_RESPONSE_SCHEMA_NODE_XPATH = "//*[local-name()='AcceptableResponseSchema']"; + private const int OK_STATUS_CODE = (int)HttpStatusCode.OK; /// - /// Response XML account node XPath selector + /// Bad request http status code /// - private const string ACCOUNT_NODE_XPATH = $"//*[local-name()='{ACCOUNT_NODE_NAME}']"; + private const int BAD_REQUEST_STATUS_CODE = (int)HttpStatusCode.BadRequest; /// /// XML response MIME type /// private const string XML_MIME_TYPE = "application/xml"; /// - /// Auto discovery XML namespace + /// EMailAddress node name /// - public const string AUTO_DISCOVER_NS = "http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006"; - /// - /// Autodiscover node name - /// - public const string AUTODISCOVER_NODE_NAME = "Autodiscover"; - /// - /// Response node name - /// - public const string RESPONSE_NODE_NAME = "Response"; - /// - /// Account node name - /// - public const string ACCOUNT_NODE_NAME = "Account"; + private const string EMAIL_NODE_NAME = "EMailAddress"; + /// - /// AccountType node name + /// Invalid request XML message bytes /// - public const string ACCOUNTTYPE_NODE_NAME = "AccountType"; + private static readonly byte[] InvalidRequestMessage = "Invalid Request XML".GetBytes(); /// - /// Account type + /// Missing email address message bytes /// - public const string ACCOUNTTYPE = "email"; + private static readonly byte[] MissingEmailMessage = "Missing Email Address".GetBytes(); /// - /// Action node name + /// Invalid email address message bytes /// - public const string ACTION_NODE_NAME = "Action"; + private static readonly byte[] InvalidEmailMessage = "Invalid Email Address".GetBytes(); /// - /// Action + /// Unknown domain name message bytes /// - public const string ACTION = "settings"; + private static readonly byte[] UnknownDomainMessage = "Unknown Domain Name".GetBytes(); /// /// Responses /// - private readonly XmlDocumentInstances Responses = responses; + private readonly InstancePool Responses = responses; /// - /// Auto discovery (POX) + /// Autodiscover (POX request body required) /// - /// XML response - [HttpPost, Route("autodiscover.xml")] - public async Task AutoDiscoverAsync() + /// POX response + [HttpPost("autodiscover.xml"), Consumes(XML_MIME_TYPE, IsOptional = false), RequestSizeLimit(MAX_REQUEST_LEN), Produces(XML_MIME_TYPE)] + public async Task AutoDiscoverAsync() { - // Try getting the requested email address from the request - XmlDocument requestXml = new(); - Stream requestBody = HttpContext.Request.Body; - await using (requestBody.DynamicContext()) - using (StreamReader reader = new(requestBody, Encoding.UTF8, leaveOpen: true)) + // Validate the request and try getting the email address + if (Logging.Trace) + Logging.WriteTrace($"POX request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort}"); + string? emailAddress = null;// Full email address + using (MemoryPoolStream ms = new()) + { + await HttpContext.Request.Body.CopyToAsync(ms, HttpContext.RequestAborted).DynamicContext(); + ms.Position = 0; + try + { + using XmlReader requestXml = XmlReader.Create(ms); + while (requestXml.Read()) + { + if (!requestXml.Name.Equals(EMAIL_NODE_NAME, StringComparison.OrdinalIgnoreCase)) + continue; + emailAddress = requestXml.ReadElementContentAsString(); + break; + } + } + catch(XmlException ex) + { + if (Logging.Debug) + Logging.WriteDebug($"Parsing POX request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort} failed: {ex}"); + RespondBadRequest(InvalidRequestMessage); + return; + } + } + if(emailAddress is null) { - string requestXmlString = await reader.ReadToEndAsync(HttpContext.RequestAborted).DynamicContext(); - if (Logging.Debug) - Logging.WriteDebug($"POX request XML body from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort}: {requestXmlString.ToQuotedLiteral()}"); - requestXml.LoadXml(requestXmlString); + RespondBadRequest(MissingEmailMessage); + return; } - if ( - requestXml.SelectSingleNode(ACCEPTABLE_RESPONSE_SCHEMA_NODE_XPATH) is XmlNode acceptableResponseSchema && - acceptableResponseSchema.InnerText.Trim() != Constants.RESPONSE_NS - ) - throw new BadHttpRequestException($"Unsupported response schema {acceptableResponseSchema.InnerText.ToQuotedLiteral()}"); - if (requestXml.SelectSingleNode(EMAIL_NODE_XPATH) is not XmlNode emailNode) - throw new BadHttpRequestException("Missing email address in request"); - string emailAddress = emailNode.InnerText.Trim().ToLower(); - if (Logging.Debug) - Logging.WriteDebug($"POX request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort} email address {emailAddress.ToQuotedLiteral()}"); - string[] emailParts = emailAddress.Split('@', 2); + if (Logging.Trace) + Logging.WriteTrace($"POX request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort} for email address {emailAddress.ToQuotedLiteral()}"); + string[] emailParts = emailAddress.Split('@', 2);// @ splitted email alias and domain name if (emailParts.Length != 2 || !MailAddress.TryCreate(emailAddress, out _)) - throw new BadHttpRequestException("Invalid email address"); - // Generate discovery response - if (Logging.Debug) - Logging.WriteDebug($"Creating POX response for {emailAddress.ToQuotedLiteral()} request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort}"); - XmlDocument xml = await Responses.GetOneAsync(HttpContext.RequestAborted).DynamicContext(); - if ( - !DomainConfig.Registered.TryGetValue(emailParts[1], out DomainConfig? config) && - !DomainConfig.Registered.TryGetValue(HttpContext.Request.Host.Host, out config) && - !DomainConfig.Registered.TryGetValue( - DomainConfig.Registered.Where(kvp => kvp.Value.AcceptedDomains?.Contains(emailParts[1], StringComparer.OrdinalIgnoreCase) ?? false) - .Select(kvp => kvp.Key) - .FirstOrDefault() ?? string.Empty, - out config - ) - ) - throw new BadHttpRequestException($"Unknown request domain name \"{HttpContext.Request.Host.Host}\"/{emailParts[1].ToQuotedLiteral()}"); - config.CreateXml(xml, xml.SelectSingleNode(ACCOUNT_NODE_XPATH) ?? throw new InvalidProgramException("Missing response XML account node"), emailParts); - return new() { - Content = xml.OuterXml, - ContentType = XML_MIME_TYPE, - StatusCode = (int)HttpStatusCode.OK - }; + RespondBadRequest(InvalidEmailMessage); + return; + } + // Generate the response + if (DomainConfig.GetConfig(HttpContext.Request.Host.Host, emailParts) is not DomainConfig config) + { + RespondBadRequest(UnknownDomainMessage); + return; + } + if (Logging.Trace) + Logging.WriteTrace($"Creating POX response for \"{emailAddress}\" request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort}"); + HttpContext.Response.StatusCode = OK_STATUS_CODE; + HttpContext.Response.ContentType = XML_MIME_TYPE; + using XmlResponse responseXml = await Responses.GetOneAsync(HttpContext.RequestAborted).DynamicContext();// Response XML + Task sendXmlOutput = responseXml.XmlOutput.CopyToAsync(HttpContext.Response.Body, HttpContext.RequestAborted); + try + { + config.CreateXml(responseXml.XML, emailParts); + responseXml.FinalizeXmlOutput(); + } + finally + { + await sendXmlOutput.DynamicContext(); + if (Logging.Trace) + Logging.WriteTrace($"POX response for \"{emailAddress}\" request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort} sent"); + } + } + + /// + /// Respond with a bad request message + /// + /// Message + private void RespondBadRequest(byte[] message) + { + if (Logging.Trace) + Logging.WriteTrace($"Invalid POX request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort}: \"{message.ToUtf8String()}\""); + HttpContext.Response.StatusCode = BAD_REQUEST_STATUS_CODE; + HttpContext.Response.ContentType = ExceptionHandler.TEXT_MIME_TYPE; + HttpContext.Response.Body = new MemoryStream(message); } } } diff --git a/src/wan24-AutoDiscover/Program.cs b/src/wan24-AutoDiscover/Program.cs index e638315..bfc3988 100644 --- a/src/wan24-AutoDiscover/Program.cs +++ b/src/wan24-AutoDiscover/Program.cs @@ -1,121 +1,143 @@ using Microsoft.AspNetCore.HttpLogging; using Microsoft.AspNetCore.HttpOverrides; +using wan24.AutoDiscover; using wan24.AutoDiscover.Models; using wan24.AutoDiscover.Services; using wan24.CLI; using wan24.Core; +// Bootstrapping +using CancellationTokenSource cts = new(); +CliConfig.Apply(new(args)); +await Bootstrap.Async(cancellationToken: cts.Token).DynamicContext(); +Translation.Current = Translation.Dummy; +ErrorHandling.ErrorHandler = (e) => Logging.WriteError($"{e.Info}: {e.Exception}"); +Settings.AppId = "wan24-AutoDiscover"; + // Run the CLI API -if (args.Length > 0) +if (args.Length > 0 && !args[0].StartsWith('-')) { - await Bootstrap.Async().DynamicContext(); - Translation.Current = Translation.Dummy; - Settings.AppId = "wan24-AutoDiscover"; - Settings.ProcessId = "webservice"; - Logging.Logger = new VividConsoleLogger(); - CliApi.HelpHeader = "wan24-AutoDiscover"; - return await CliApi.RunAsync(args, exportedApis: [typeof(CliHelpApi), typeof(CommandLineInterface)]).DynamicContext(); + Settings.ProcessId = "cli"; + Logging.Logger ??= new VividConsoleLogger(); + CliApi.HelpHeader = $"wan24-AutoDiscover {VersionInfo.Current} - (c) 2024 Andreas Zimmermann, wan24.de"; + AboutApi.Version = VersionInfo.Current; + AboutApi.Info = "(c) 2024 Andreas Zimmermann, wan24.de"; + return await CliApi.RunAsync(args, cts.Token, [typeof(CliHelpApi), typeof(CommandLineInterface), typeof(AboutApi)]).DynamicContext(); } // Load the configuration string configFile = Path.Combine(ENV.AppFolder, "appsettings.json"); -(IConfigurationRoot Config, DiscoveryConfig Discovery) LoadConfig() +async Task LoadConfigAsync() { ConfigurationBuilder configBuilder = new(); configBuilder.AddJsonFile(configFile, optional: false); IConfigurationRoot config = configBuilder.Build(); - DiscoveryConfig discovery = config.GetRequiredSection("DiscoveryConfig").Get() + DiscoveryConfig.Current = config.GetRequiredSection("DiscoveryConfig").Get() ?? throw new InvalidDataException($"Failed to get a {typeof(DiscoveryConfig)} from the \"DiscoveryConfig\" section"); - DomainConfig.Registered = discovery.GetDiscoveryConfig(config); - return (config, discovery); + DomainConfig.Registered = await DiscoveryConfig.Current.GetDiscoveryConfigAsync(config, cts.Token).DynamicContext(); + return config; } -(IConfigurationRoot config, DiscoveryConfig discovery) = LoadConfig(); +IConfigurationRoot config = await LoadConfigAsync().DynamicContext(); // Initialize wan24-Core -await Bootstrap.Async().DynamicContext(); -Translation.Current = Translation.Dummy; -Settings.AppId = "wan24-AutoDiscover"; -Settings.ProcessId = "webservice"; +Settings.ProcessId = "service"; Settings.LogLevel = config.GetValue("Logging:LogLevel:Default"); -Logging.Logger = discovery.LogFile is string logFile && !string.IsNullOrWhiteSpace(logFile) - ? await FileLogger.CreateAsync(logFile, next: new VividConsoleLogger()).DynamicContext() +Logging.Logger ??= !string.IsNullOrWhiteSpace(DiscoveryConfig.Current.LogFile) + ? await FileLogger.CreateAsync(DiscoveryConfig.Current.LogFile, next: new VividConsoleLogger(), cancellationToken: cts.Token).DynamicContext() : new VividConsoleLogger(); -ErrorHandling.ErrorHandler = (e) => Logging.WriteError($"{e.Info}: {e.Exception}"); -Logging.WriteInfo($"Using configuration \"{configFile}\""); +Logging.WriteInfo($"wan24-AutoDiscover {VersionInfo.Current} using configuration \"{configFile}\""); // Watch configuration changes -using ConfigChangeEventThrottle fswThrottle = new(); -ConfigChangeEventThrottle.OnConfigChange += () => +using SemaphoreSync configSync = new(); +using MultiFileSystemEvents fsw = new(throttle: 250); +fsw.OnEvents += async (s, e) => { try { - Logging.WriteDebug("Handling configuration change"); - if (File.Exists(configFile)) + if (Logging.Debug) + Logging.WriteDebug("Handling configuration change"); + if (configSync.IsSynchronized) { - Logging.WriteInfo($"Auto-reloading changed configuration from \"{configFile}\""); - LoadConfig(); + Logging.WriteWarning("Can't handle configuration change, because another handler is still processing (configuration reload takes too long!)"); + return; } - else - { - Logging.WriteTrace($"Configuration file \"{configFile}\" doesn't exist"); - } - } - catch (Exception ex) - { - Logging.WriteWarning($"Failed to reload configuration from \"{configFile}\": {ex}"); - } -}; -void ReloadConfig(object sender, FileSystemEventArgs e) -{ - try - { - Logging.WriteDebug($"Detected configuration change {e.ChangeType}"); - if (File.Exists(configFile)) - { - if (fswThrottle.IsThrottling) + using SemaphoreSyncContext ssc = await configSync.SyncContextAsync(cts.Token).DynamicContext(); + // Pre-reload command + if (DiscoveryConfig.Current.PreReloadCommand is not null && DiscoveryConfig.Current.PreReloadCommand.Length > 0) + try { - Logging.WriteTrace("Skipping configuration change event due too many events"); + Logging.WriteInfo("Executing pre-reload command on detected configuration change"); + int exitCode = await ProcessHelper.GetExitCodeAsync( + DiscoveryConfig.Current.PreReloadCommand[0], + cancellationToken: cts.Token, + args: [.. DiscoveryConfig.Current.PreReloadCommand[1..]] + ).DynamicContext(); + if (exitCode != 0) + Logging.WriteWarning($"Pre-reload command exit code was #{exitCode}"); + if (Logging.Trace) + Logging.WriteTrace("Pre-reload command execution done"); } - else if (fswThrottle.Raise()) + catch (Exception ex) { - Logging.WriteTrace("Configuration change event has been raised"); + Logging.WriteError($"Pre-reload command execution failed exceptional: {ex}"); } + // Reload configuration + if (File.Exists(configFile)) + { + Logging.WriteInfo($"Auto-reloading changed configuration from \"{configFile}\""); + await LoadConfigAsync().DynamicContext(); + if (Logging.Trace) + Logging.WriteTrace($"Auto-reloading changed configuration from \"{configFile}\" done"); } - else + else if(Logging.Trace) { Logging.WriteTrace($"Configuration file \"{configFile}\" doesn't exist"); } } catch (Exception ex) { - Logging.WriteWarning($"Failed to handle configuration change of \"{configFile}\": {ex}"); + Logging.WriteError($"Failed to reload configuration from \"{configFile}\": {ex}"); } -} -using FileSystemWatcher fsw = new(ENV.AppFolder, "appsettings.json") -{ - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime, - IncludeSubdirectories = false, - EnableRaisingEvents = true }; -fsw.Changed += ReloadConfig; -fsw.Created += ReloadConfig; +fsw.Add(new(ENV.AppFolder, "appsettings.json", NotifyFilters.LastWrite | NotifyFilters.CreationTime, throttle: 250, recursive: false)); +if (DiscoveryConfig.Current.WatchEmailMappings && !string.IsNullOrWhiteSpace(DiscoveryConfig.Current.EmailMappings)) + fsw.Add(new( + Path.GetDirectoryName(Path.GetFullPath(DiscoveryConfig.Current.EmailMappings))!, + Path.GetFileName(DiscoveryConfig.Current.EmailMappings), + NotifyFilters.LastWrite | NotifyFilters.CreationTime, + recursive: false, + events: FileSystemEventTypes.Changes | FileSystemEventTypes.Created | FileSystemEventTypes.Deleted + )); +if (DiscoveryConfig.Current.WatchFiles is not null) + foreach (string file in DiscoveryConfig.Current.WatchFiles) + if (!string.IsNullOrWhiteSpace(file)) + fsw.Add(new( + Path.GetDirectoryName(Path.GetFullPath(file))!, + Path.GetFileName(file), + NotifyFilters.LastWrite | NotifyFilters.CreationTime, + recursive: false, + events: FileSystemEventTypes.Changes | FileSystemEventTypes.Created | FileSystemEventTypes.Deleted + )); // Build and run the app Logging.WriteInfo("Autodiscovery service app startup"); WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); builder.Logging.ClearProviders() .AddConsole(); if (ENV.IsLinux) builder.Logging.AddSystemdConsole(); builder.Services.AddControllers(); -builder.Services.AddSingleton(typeof(XmlDocumentInstances), services => new XmlDocumentInstances(capacity: discovery.PreForkResponses)) - .AddHostedService(services => services.GetRequiredService()) - .AddExceptionHandler() +builder.Services.AddExceptionHandler() + .AddSingleton(typeof(InstancePool), services => new InstancePool(capacity: DiscoveryConfig.Current.PreForkResponses)) + .AddSingleton(cts) + .AddHostedService(services => services.GetRequiredService>()) + .AddHostedService(services => fsw) .AddHttpLogging(options => options.LoggingFields = HttpLoggingFields.RequestPropertiesAndHeaders); builder.Services.Configure(options => { options.ForwardLimit = 2; - options.KnownProxies.AddRange(discovery.KnownProxies); + options.KnownProxies.AddRange(DiscoveryConfig.Current.KnownProxies); options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; }); WebApplication app = builder.Build(); @@ -123,6 +145,13 @@ void ReloadConfig(object sender, FileSystemEventArgs e) { await using (app.DynamicContext()) { + app.Lifetime.ApplicationStopping.Register(() => + { + Logging.WriteInfo("Autodiscovery service app shutdown"); + cts.Cancel(); + }); + app.UseExceptionHandler(builder => { });// .NET 8 bugfix :( + app.MapDefaultEndpoints();// Aspire app.UseForwardedHeaders(); if (app.Environment.IsDevelopment()) { @@ -130,8 +159,7 @@ void ReloadConfig(object sender, FileSystemEventArgs e) Logging.WriteTrace("Using development environment"); app.UseHttpLogging(); } - app.UseExceptionHandler(builder => { });// .NET 8 bugfix :( - if (!app.Environment.IsDevelopment()) + else { if (Logging.Trace) Logging.WriteTrace("Using production environment"); @@ -141,7 +169,7 @@ void ReloadConfig(object sender, FileSystemEventArgs e) } app.MapControllers(); Logging.WriteInfo("Autodiscovery service app starting"); - await app.RunAsync().DynamicContext(); + await app.RunAsync(cts.Token).DynamicContext(); Logging.WriteInfo("Autodiscovery service app quitting"); } } @@ -152,6 +180,7 @@ void ReloadConfig(object sender, FileSystemEventArgs e) } finally { + cts.Cancel(); Logging.WriteInfo("Autodiscovery service app exit"); } return 0; diff --git a/src/wan24-AutoDiscover/Services/CommandLineInterface.Postfix.cs b/src/wan24-AutoDiscover/Services/CommandLineInterface.Postfix.cs new file mode 100644 index 0000000..4f6634e --- /dev/null +++ b/src/wan24-AutoDiscover/Services/CommandLineInterface.Postfix.cs @@ -0,0 +1,57 @@ +using System.ComponentModel; +using System.Text; +using System.Text.RegularExpressions; +using wan24.AutoDiscover.Models; +using wan24.CLI; +using wan24.Core; + +namespace wan24.AutoDiscover.Services +{ + // Postfix + public sealed partial class CommandLineInterface + { + /// + /// Regular expression to match a Postfix email mapping ($1 contains the email address, $2 contains the comma separated targets) + /// + private static readonly Regex RX_POSTFIX = RX_POSTFIX_Generator(); + + /// + /// Parse Postfix email mappings + /// + /// Cancellation token + [CliApi("postfix")] + [DisplayText("Postfix email mapping")] + [Description("Parse the email mapping from a Postfix hash text file")] + [StdIn("/etc/postfix/virtual")] + [StdOut("/home/autodiscover/postfix.json")] + public static async Task ParsePostfixEmailMappingsAsync(CancellationToken cancellationToken = default) + { + HashSet mappings = []; + Stream stdIn = Console.OpenStandardInput(); + await using (stdIn.DynamicContext()) + { + using StreamReader reader = new(stdIn, Encoding.UTF8, leaveOpen: true); + while (await reader.ReadLineAsync(cancellationToken).DynamicContext() is string line) + { + if (!RX_POSTFIX.IsMatch(line)) continue; + string[] info = RX_POSTFIX.Replace(line, "$1\t$2").Split('\t', 2); + mappings.Add(new() + { + Email = info[0].ToLower(), + Targets = new List((from target in info[1].Split(',') select target.Trim()).Distinct()) + }); + } + } + Stream stdOut = Console.OpenStandardOutput(); + await using (stdOut.DynamicContext()) + await JsonHelper.EncodeAsync(mappings, stdOut, prettify: true, cancellationToken).DynamicContext(); + } + + /// + /// Regular expression to match a Postfix email mapping ($1 contains the email address, $2 contains the comma separated targets) + /// + /// Regular expression + [GeneratedRegex(@"^\s*([^\*\@#][^\s]+)\s*([^\s]+)\s*$", RegexOptions.Compiled)] + private static partial Regex RX_POSTFIX_Generator(); + } +} diff --git a/src/wan24-AutoDiscover/Services/CommandLineInterface.Upgrade.cs b/src/wan24-AutoDiscover/Services/CommandLineInterface.Upgrade.cs new file mode 100644 index 0000000..df95d8b --- /dev/null +++ b/src/wan24-AutoDiscover/Services/CommandLineInterface.Upgrade.cs @@ -0,0 +1,402 @@ +using Spectre.Console; +using System.ComponentModel; +using System.IO.Compression; +using System.Text; +using wan24.CLI; +using wan24.Core; + +namespace wan24.AutoDiscover.Services +{ + // Upgrade + public sealed partial class CommandLineInterface + { + /// + /// wan24-AutoDiscover repository URI + /// + private const string REPOSITORY_URI = "https://github.com/nd1012/wan24-AutoDiscover"; + /// + /// wan24-AutoDiscover repository URI for raw content + /// + private const string REPOSITORY_URI_RAW_CONTENT = "https://raw.githubusercontent.com/nd1012/wan24-AutoDiscover/main"; + + /// + /// Upgrade wan24-AutoDiscover online + /// + /// Pre-command + /// Post-command + /// No user interaction? + /// Check for update only + /// Cancellation token + /// Exit code + [CliApi("upgrade")] + [DisplayText("wan24-AutoDiscover upgrade")] + [Description("Find updates of wan24-AutoDiscover online and upgrade the installation")] + [ExitCode(code: 2, "A newer version is available online (used when the \"-checkOnly\" flag was given)")] + public static async Task UpgradeAsync( + + [CliApi(Example = "/command/to/execute")] + [DisplayText("Pre-command")] + [Description("Command to execute before running the upgrade setup")] + string[]? preCommand = null, + + [CliApi(Example = "/command/to/execute")] + [DisplayText("Post-command")] + [Description("Command to execute after running the upgrade setup")] + string[]? postCommand = null, + + [CliApi] + [DisplayText("No user interaction")] + [Description("Add this flag to disable user interaction and process automatic")] + bool noUserInteraction = false, + + [CliApi] + [DisplayText("Check only")] + [Description("Exit with code #2, if there's a newer version available")] + bool checkOnly = false, + + CancellationToken cancellationToken = default + ) + { + using HttpClient http = new(); + // Get latest version information + string version; + { + string uri = $"{REPOSITORY_URI_RAW_CONTENT}/latest-release.txt"; + if (Logging.Trace) + Logging.WriteTrace($"Loading latest version information from \"{uri}\""); + using HttpRequestMessage request = new(HttpMethod.Get, uri); + using HttpResponseMessage response = await http.SendAsync(request, cancellationToken).DynamicContext(); + if (!response.IsSuccessStatusCode) + { + Logging.WriteError($"Failed to download latest version information from \"{uri}\" - http status code is {response.StatusCode} (#{(int)response.StatusCode})"); + return 1; + } + Stream versionInfo = response.Content.ReadAsStream(cancellationToken); + await using (versionInfo.DynamicContext()) + { + using StreamReader reader = new(versionInfo, Encoding.UTF8, leaveOpen: true); + version = (await reader.ReadToEndAsync(cancellationToken).DynamicContext()).Trim(); + } + } + // Check if an upgrade is possible + if (!Version.TryParse(version, out Version? latest)) + { + Logging.WriteError("Failed to parse received online version information"); + return 1; + } + Logging.WriteInfo($"Current version is {VersionInfo.Current}, online version is {latest}"); + if (VersionInfo.Current >= latest) + { + Logging.WriteInfo("No update found - exit"); + return 0; + } + if (checkOnly) + { + Console.WriteLine(latest.ToString()); + return 2; + } + // Confirm upgrade + if (!noUserInteraction) + { + AnsiConsole.WriteLine($"[{CliApiInfo.HighlightColor} on {CliApiInfo.BackGroundColor}]You can read the release notes online: [link]{REPOSITORY_URI}[/][/]"); + string confirmation = AnsiConsole.Prompt( + new TextPrompt($"[{CliApiInfo.HighlightColor} on {CliApiInfo.BackGroundColor}]Perform the upgrade to version \"{version}\" now?[/] (type [{CliApiInfo.HighlightColor} on {CliApiInfo.BackGroundColor}]\"yes\"[/] or [{CliApiInfo.HighlightColor} on {CliApiInfo.BackGroundColor}]\"no\"[/] and hit enter - default is \"yes\")") + .AllowEmpty() + ); + if (confirmation.Length != 0 && !confirmation.Equals("yes", StringComparison.OrdinalIgnoreCase)) + { + Logging.WriteInfo("Upgrade cancelled by user"); + return 0; + } + } + // Perform the upgrade + bool deleteTempDir = true;// Temporary folder won't be deleted, if there was a problem during upgrade, and files have been modified already + string tempDir = Path.Combine(Settings.TempFolder, Guid.NewGuid().ToString()); + while (Directory.Exists(tempDir)) + tempDir = Path.Combine(Settings.TempFolder, Guid.NewGuid().ToString()); + try + { + if (Logging.Trace) + Logging.WriteTrace($"Using temporary folder \"{tempDir}\""); + FsHelper.CreateFolder(tempDir); + // Download and extract the setup + { + string uri = $"{REPOSITORY_URI}/releases/download/v{version}/wan24-AutoDiscover.v{version}.zip", + fn = Path.Combine(tempDir, "update.zip"); + Logging.WriteInfo($"Downloading update from \"{uri}\" to \"{fn}\""); + using HttpRequestMessage request = new(HttpMethod.Get, uri); + using HttpResponseMessage response = await http.SendAsync(request, cancellationToken).DynamicContext(); + if (!response.IsSuccessStatusCode) + { + Logging.WriteError($"Failed to download latest version from {uri} - http status code is {response.StatusCode} (#{(int)response.StatusCode})"); + return 1; + } + Stream? targetZip = null; + Stream updateZip = response.Content.ReadAsStream(cancellationToken); + await using (updateZip.DynamicContext()) + try + { + targetZip = FsHelper.CreateFileStream(fn, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, overwrite: false); + await updateZip.CopyToAsync(targetZip, cancellationToken).DynamicContext(); + } + catch + { + if (targetZip is not null) await targetZip.DisposeAsync().DynamicContext(); + throw; + } + await using (targetZip.DynamicContext()) + { + Logging.WriteInfo($"Extracting \"{fn}\""); + targetZip.Position = 0; + using ZipArchive zip = new(targetZip, ZipArchiveMode.Read, leaveOpen: true); + zip.ExtractToDirectory(tempDir, overwriteFiles: false); + } + if (Logging.Trace) + Logging.WriteTrace($"Deleting downloaded update"); + File.Delete(fn); + fn = Path.Combine(tempDir, "appsettings.json"); + if (File.Exists(fn)) + { + if (Logging.Trace) + Logging.WriteTrace($"Deleting appsettings.json from update"); + File.Delete(fn); + } + fn = Path.Combine(tempDir, "latest-release.txt"); + if (File.Exists(fn)) + { + string release = (await File.ReadAllTextAsync(fn, cancellationToken).DynamicContext()).Trim(); + if (release != version) + { + Logging.WriteError($"Download release mismatch: {release.MaxLength(byte.MaxValue).ToQuotedLiteral()}/{version}"); + return 1; + } + else if (Logging.Trace) + { + Logging.WriteTrace("Update release confirmed"); + } + } + else + { + Logging.WriteError("Missing release information in update"); + return 1; + } + } + // Execute pre-command + if (preCommand is not null && preCommand.Length > 0) + { + Logging.WriteInfo("Executing pre-update command"); + int exitCode = await ProcessHelper.GetExitCodeAsync( + preCommand[0], + cancellationToken: cancellationToken, + args: [.. preCommand[1..]] + ).DynamicContext(); + if (exitCode != 0) + { + Logging.WriteError($"Pre-update command failed to execute with exit code #{exitCode}"); + return 1; + } + } + // Install the update + Logging.WriteInfo("Installing the update"); + HashSet backupFiles = []; + Transaction transaction = new(); + await using (transaction.DynamicContext()) + { + // Rollback which restores backed up files + await transaction.ExecuteAsync( + () => Task.CompletedTask, + async (ta, ret, ct) => + { + Logging.WriteWarning("Restoring backup up files during rollback of a failed transaction"); + foreach (string file in backupFiles) + { + if (ct.IsCancellationRequested) + { + Logging.WriteWarning("Rollback action cancellation has been requested - stop restoring backed up files"); + return; + } + Logging.WriteWarning($"Restoring backup \"{file}\""); + if (!File.Exists(file)) + { + Logging.WriteWarning("Backup file not found - possibly because there was a problem when creating the backup of the file which was going to be overwritten"); + continue; + } + Stream source = FsHelper.CreateFileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read); + await using (source.DynamicContext()) + { + string targetFn = Path.Combine(ENV.AppFolder, file[tempDir.Length..]); + Stream target = FsHelper.CreateFileStream(targetFn, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, overwrite: true); + await using (target.DynamicContext()) + await source.CopyToAsync(target, CancellationToken.None).DynamicContext(); + } + } + Logging.WriteInfo("All backup files have been restored during rollback of a failed transaction"); + }, + CancellationToken.None + ).DynamicContext(); + // Copy new files + foreach (string file in FsHelper.FindFiles(tempDir)) + { + string targetFn = Path.Combine(ENV.AppFolder, file[tempDir.Length..]), + targetDir = Path.GetDirectoryName(targetFn)!; + if (Logging.Trace) + Logging.WriteTrace($"Copy file \"{file}\" to \"{targetFn}\""); + Stream source = FsHelper.CreateFileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read); + await using (source.DynamicContext()) + { + // Ensure an existing target folder + if (!Directory.Exists(targetDir)) + transaction.Execute( + () => FsHelper.CreateFolder(targetDir), + (ta, ret) => + { + if (Directory.Exists(targetDir)) + try + { + Logging.WriteWarning($"Deleting previously created folder \"{targetDir}\" during rollback of a failed transaction"); + Directory.Delete(targetDir, recursive: true); + } + catch (Exception ex) + { + Logging.WriteWarning($"Failed to delete previously created folder \"{targetDir}\" during rollback: {ex}"); + } + } + ); + // Open/create the target file + bool exists = File.Exists(targetFn); + Stream target = FsHelper.CreateFileStream( + targetFn, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + overwrite: false + ); + await using (target.DynamicContext()) + { + // Create a backup of the existing file, first + if (exists) + { + deleteTempDir = false; + string backupFn = $"{file}.backup"; + if (Logging.Trace) + Logging.WriteTrace($"Create backup of existing file \"{targetFn}\" to \"{backupFn}\""); + backupFiles.Add(targetFn); + Stream backup = FsHelper.CreateFileStream(backupFn, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None); + await using (backup.DynamicContext()) + { + target.Position = 0; + await target.CopyToAsync(backup, cancellationToken).DynamicContext(); + } + target.SetLength(0); + } + // Copy the (new) file contents + await source.CopyToAsync(target, cancellationToken).DynamicContext(); + } + } + } + // Signal success to the encapsulating transaction + transaction.Commit(); + } + // Execute post-upgrade + Logging.WriteInfo("Executing post-update command"); + { + int exitCode = await ProcessHelper.GetExitCodeAsync( + "dotnet", + cancellationToken: cancellationToken, + args: [ + "wan24AutoDiscover.dll", + "autodiscover", + "post-upgrade", + VersionInfo.Current.ToString(), + version + ] + ).DynamicContext(); + if (exitCode != 0) + { + Logging.WriteError($"Post-upgrade acion failed to execute with exit code #{exitCode}"); + return 1; + } + } + // Execute post-command + if (postCommand is not null && postCommand.Length > 0) + { + Logging.WriteInfo("Executing post-update command"); + int exitCode = await ProcessHelper.GetExitCodeAsync( + postCommand[0], + cancellationToken: cancellationToken, + args: [.. postCommand[1..]] + ).DynamicContext(); + if (exitCode != 0) + { + Logging.WriteError($"Post-update command failed to execute with exit code #{exitCode}"); + return 1; + } + } + Logging.WriteInfo("wan24-AutoDiscover upgrade done"); + deleteTempDir = true; + return 0; + } + catch (Exception ex) + { + if (deleteTempDir) + { + Logging.WriteError($"Update failed (temporary folder will be removed): {ex}"); + } + else + { + Logging.WriteError($"Update failed (won't delete temporary folder \"{tempDir}\" 'cause it may contain backup files): {ex}"); + } + return 1; + } + finally + { + if (deleteTempDir && Directory.Exists(tempDir)) + try + { + Directory.Delete(tempDir, recursive: true); + } + catch (Exception ex) + { + Logging.WriteError($"Failed to delete temporary folder \"{tempDir}\": {ex}"); + } + } + } + + /// + /// Post-upgrade actions + /// + /// Previous version + /// Current version + /// Cancellation token + [CliApi("post-upgrade")] + [DisplayText("Post-upgrade")] + [Description("Perform post-upgrade actions (used internal)")] + public static async Task PostUpgradeAsync( + + [CliApi(keyLessOffset: 0, Example = "1.0.0")] + [DisplayText("Previous version")] + [Description("Version number of the previous installation")] + string previousVersion, + + [CliApi(keyLessOffset: 1, Example = "2.0.0")] + [DisplayText("Current version")] + [Description("Expected current version number after the update was installed")] + string currentVersion, + + CancellationToken cancellationToken = default + ) + { + Version previous = new(previousVersion), + current = new(currentVersion); + // Validate previous version number + if (previous >= current) + throw new InvalidProgramException("Invalid previous version number"); + // Validate current version number + if (current != VersionInfo.Current) + throw new InvalidProgramException($"Current version is {VersionInfo.Current} - upgrade version was {currentVersion.ToQuotedLiteral()}"); + // Validate release information + if (currentVersion != await File.ReadAllTextAsync(Path.Combine(ENV.AppFolder, "latest-release.txt"), cancellationToken).DynamicContext()) + throw new InvalidProgramException("Release information mismatch"); + } + } +} diff --git a/src/wan24-AutoDiscover/Services/CommandLineInterface.cs b/src/wan24-AutoDiscover/Services/CommandLineInterface.cs index c50f620..0e78633 100644 --- a/src/wan24-AutoDiscover/Services/CommandLineInterface.cs +++ b/src/wan24-AutoDiscover/Services/CommandLineInterface.cs @@ -1,4 +1,5 @@ -using System.Text; +using System.ComponentModel; +using System.Text; using wan24.CLI; using wan24.Core; @@ -8,7 +9,9 @@ namespace wan24.AutoDiscover.Services /// CLI API /// [CliApi("autodiscover")] - public class CommandLineInterface + [DisplayText("wan24-AutoDiscover API")] + [Description("wan24-AutoDiscover CLI API methods")] + public sealed partial class CommandLineInterface { /// /// Constructor @@ -18,14 +21,17 @@ public CommandLineInterface() { } /// /// Create service information /// - [CliApi("systemd", IsDefault = true)] + /// Cancellation token + [CliApi("systemd")] + [DisplayText("systemd service")] + [Description("Create a systemd service file")] [StdOut("/etc/systemd/system/autodiscover.service")] - public static async Task CreateSystemdServiceAsync() + public static async Task CreateSystemdServiceAsync(CancellationToken cancellationToken = default) { Stream stdOut = Console.OpenStandardOutput(); await using (stdOut.DynamicContext()) using (StreamWriter writer = new(stdOut, Encoding.UTF8, leaveOpen: true)) - await writer.WriteLineAsync(new SystemdServiceFile().ToString().Trim()).DynamicContext(); + await writer.WriteLineAsync(new SystemdServiceFile().ToString().Trim().AsMemory(), cancellationToken).DynamicContext(); } } } diff --git a/src/wan24-AutoDiscover/Services/ConfigChangeEventThrottle.cs b/src/wan24-AutoDiscover/Services/ConfigChangeEventThrottle.cs deleted file mode 100644 index bf523d3..0000000 --- a/src/wan24-AutoDiscover/Services/ConfigChangeEventThrottle.cs +++ /dev/null @@ -1,31 +0,0 @@ -using wan24.Core; - -namespace wan24.AutoDiscover.Services -{ - /// - /// Configuration changed event throttle - /// - public sealed class ConfigChangeEventThrottle : EventThrottle - { - /// - /// Constructor - /// - public ConfigChangeEventThrottle() : base(timeout: 300) { } - - /// - protected override void HandleEvent(in DateTime raised, in int raisedCount) => RaiseOnConfigChange(); - - /// - /// Delegate for the event - /// - public delegate void ConfigChange_Delegate(); - /// - /// Raised on configuration changes - /// - public static event ConfigChange_Delegate? OnConfigChange; - /// - /// Raise the event - /// - private static void RaiseOnConfigChange() => OnConfigChange?.Invoke(); - } -} diff --git a/src/wan24-AutoDiscover/Services/ExceptionHandler.cs b/src/wan24-AutoDiscover/Services/ExceptionHandler.cs index e688946..9f6e037 100644 --- a/src/wan24-AutoDiscover/Services/ExceptionHandler.cs +++ b/src/wan24-AutoDiscover/Services/ExceptionHandler.cs @@ -9,6 +9,32 @@ namespace wan24.AutoDiscover.Services /// public sealed class ExceptionHandler : IExceptionHandler { + /// + /// Internal server error code + /// + private const int INTERNAL_SERVER_ERROR_STATUS_CODE = (int)HttpStatusCode.InternalServerError; + /// + /// Maintenance code + /// + private const int MAINTENANCE_STATUS_CODE = (int)HttpStatusCode.ServiceUnavailable; + /// + /// Text MIME type + /// + public const string TEXT_MIME_TYPE = "text/plain"; + + /// + /// Bad request message bytes + /// + private static readonly byte[] BadRequestMessage = "Bad Request".GetBytes(); + /// + /// Internal server error message bytes + /// + private static readonly byte[] InternalServerErrorMessage = "Internal Server Error".GetBytes(); + /// + /// Maintenance message bytes + /// + private static readonly byte[] MaintenanceMessage = "Temporary Not Available".GetBytes(); + /// /// Constructor /// @@ -17,16 +43,34 @@ public ExceptionHandler() { } /// public ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { + if (httpContext.Response.HasStarted) return ValueTask.FromResult(false); + CancellationTokenSource cts = httpContext.RequestServices.GetRequiredService(); + httpContext.Response.ContentType = TEXT_MIME_TYPE; if (exception is BadHttpRequestException badRequest) { if (Logging.Trace) - Logging.WriteTrace($"http handling bas request exception for {httpContext.Connection.RemoteIpAddress}:{httpContext.Connection.RemotePort} request to \"{httpContext.Request.Method} {httpContext.Request.Path}\": {exception}"); + Logging.WriteTrace($"http handling bad request exception for {httpContext.Connection.RemoteIpAddress}:{httpContext.Connection.RemotePort} request to \"{httpContext.Request.Method} {httpContext.Request.Path}\": {exception}"); httpContext.Response.StatusCode = badRequest.StatusCode; + httpContext.Response.Body = new MemoryStream(badRequest.Message is null? BadRequestMessage : badRequest.Message.GetBytes()); + } + else if (exception is OperationCanceledException) + { + if (cts.IsCancellationRequested) + { + Logging.WriteInfo($"http handling operation canceled exception due shutdown for {httpContext.Connection.RemoteIpAddress}:{httpContext.Connection.RemotePort} request to \"{httpContext.Request.Method} {httpContext.Request.Path}\""); + } + else + { + Logging.WriteWarning($"http handling operation canceled exception for {httpContext.Connection.RemoteIpAddress}:{httpContext.Connection.RemotePort} request to \"{httpContext.Request.Method} {httpContext.Request.Path}\": {exception}"); + } + httpContext.Response.StatusCode = MAINTENANCE_STATUS_CODE; + httpContext.Response.Body = new MemoryStream(MaintenanceMessage); } else { Logging.WriteError($"http handling exception for {httpContext.Connection.RemoteIpAddress}:{httpContext.Connection.RemotePort} request to \"{httpContext.Request.Method} {httpContext.Request.Path}\": {exception}"); - httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + httpContext.Response.StatusCode = INTERNAL_SERVER_ERROR_STATUS_CODE; + httpContext.Response.Body = new MemoryStream(InternalServerErrorMessage); } return ValueTask.FromResult(true); } diff --git a/src/wan24-AutoDiscover/Services/XmlDocumentInstances.cs b/src/wan24-AutoDiscover/Services/XmlDocumentInstances.cs deleted file mode 100644 index 5d20da4..0000000 --- a/src/wan24-AutoDiscover/Services/XmlDocumentInstances.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Xml; -using wan24.AutoDiscover.Controllers; -using wan24.Core; - -namespace wan24.AutoDiscover.Services -{ - /// - /// response instance pool - /// - /// - /// Constructor - /// - /// Capacity - public sealed class XmlDocumentInstances(in int capacity) : InstancePool(capacity, CreateXmlDocument) - { - /// - /// Constructor - /// - public XmlDocumentInstances() : this(capacity: 100) { } - - /// - /// Create an - /// - /// Pool - /// - private static XmlDocument CreateXmlDocument(IInstancePool pool) - { - if (Logging.Trace) Logging.WriteTrace("Pre-forking a new POX XML response"); - XmlDocument xml = new(); - XmlNode account = xml.AppendChild(xml.CreateNode(XmlNodeType.Element, DiscoveryController.AUTODISCOVER_NODE_NAME, DiscoveryController.AUTO_DISCOVER_NS))! - .AppendChild(xml.CreateNode(XmlNodeType.Element, DiscoveryController.RESPONSE_NODE_NAME, Constants.RESPONSE_NS))! - .AppendChild(xml.CreateElement(DiscoveryController.ACCOUNT_NODE_NAME, Constants.RESPONSE_NS))!; - account.AppendChild(xml.CreateElement(DiscoveryController.ACCOUNTTYPE_NODE_NAME, Constants.RESPONSE_NS))!.InnerText = DiscoveryController.ACCOUNTTYPE; - account.AppendChild(xml.CreateElement(DiscoveryController.ACTION_NODE_NAME, Constants.RESPONSE_NS))!.InnerText = DiscoveryController.ACTION; - return xml; - } - } -} diff --git a/src/wan24-AutoDiscover/XmlResponse.cs b/src/wan24-AutoDiscover/XmlResponse.cs new file mode 100644 index 0000000..101b011 --- /dev/null +++ b/src/wan24-AutoDiscover/XmlResponse.cs @@ -0,0 +1,75 @@ +using System.Text; +using System.Xml; +using wan24.Core; + +namespace wan24.AutoDiscover +{ + /// + /// XML response + /// + public class XmlResponse : DisposableBase + { + /// + /// Buffer size in bytes + /// + private const int BUFFER_SIZE = 1024; + + /// + /// XML writer settings + /// + private static readonly XmlWriterSettings Settings = new() + { + Indent = false, + Encoding = Encoding.UTF8, + OmitXmlDeclaration = true + }; + + /// + /// Constructor + /// + public XmlResponse() : base(asyncDisposing: false) + { + XmlOutput = new(BUFFER_SIZE) + { + AggressiveReadBlocking = false + }; + XML = XmlWriter.Create(XmlOutput, Settings); + XML.WriteStartElement(Constants.AUTODISCOVER_NODE_NAME, Constants.AUTO_DISCOVER_NS); + XML.WriteStartElement(Constants.RESPONSE_NODE_NAME, Constants.RESPONSE_NS); + XML.WriteStartElement(Constants.ACCOUNT_NODE_NAME); + XML.WriteElementString(Constants.ACCOUNTTYPE_NODE_NAME, Constants.ACCOUNTTYPE); + XML.WriteElementString(Constants.ACTION_NODE_NAME, Constants.ACTION); + XML.Flush(); + } + + /// + /// XML + /// + public XmlWriter XML { get; } + + /// + /// XML output + /// + public BlockingBufferStream XmlOutput { get; } + + /// + /// Finalize + /// + public virtual void FinalizeXmlOutput() + { + EnsureUndisposed(); + XML.WriteEndElement(); + XML.WriteEndElement(); + XML.WriteEndElement(); + XML.Flush(); + XmlOutput.IsEndOfFile = true; + } + + /// + protected override void Dispose(bool disposing) + { + XML.Dispose(); + XmlOutput.Dispose(); + } + } +} diff --git a/src/wan24-AutoDiscover/appsettings.json b/src/wan24-AutoDiscover/appsettings.json index ab662e5..16db4c6 100644 --- a/src/wan24-AutoDiscover/appsettings.json +++ b/src/wan24-AutoDiscover/appsettings.json @@ -19,6 +19,10 @@ "PreForkResponses": 10, "DiscoveryType": null, "KnownProxies": [], + "EmailMappings": null, + "WatchEmailMappings": true, + "WatchFiles": null, + "PreReloadCommand": null, "Discovery": { "localhost": { "AcceptedDomains": [ diff --git a/src/wan24-AutoDiscover/latest-release.txt b/src/wan24-AutoDiscover/latest-release.txt new file mode 100644 index 0000000..1cc5f65 --- /dev/null +++ b/src/wan24-AutoDiscover/latest-release.txt @@ -0,0 +1 @@ +1.1.0 \ No newline at end of file diff --git a/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj b/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj index dbad146..715b9b3 100644 --- a/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj +++ b/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -7,16 +7,35 @@ wan24.AutoDiscover wan24AutoDiscover True + Debug;Release;Trunk + + + + + + + + - - - + + + + + + + + + + + + Always +