From 86429c55e93bbe9d0667893446be44343e83ee17 Mon Sep 17 00:00:00 2001 From: nd Date: Sat, 6 Apr 2024 23:38:42 +0200 Subject: [PATCH] Update * `DiscoveryConfig.GetDiscoveryConfig` is now asynchronous and named `GetDiscoveryConfigAsync` * `DomainConfig.LoginNameMapping` is now a `Dictionary` + Added `DiscoveryConfig.EmailMappings` + Added `DomainConfig.GetConfig` + Added `EmailMapping` + Added `CommandLineInterface.ParsePostfixEmailMappingsAsync` --- .../Models/DiscoveryConfig.cs | 48 +++++++++++++++- .../Models/DomainConfig.cs | 22 ++++++- .../Models/EmailMapping.cs | 57 +++++++++++++++++++ .../Controllers/DiscoveryController.cs | 11 +--- src/wan24-AutoDiscover/Program.cs | 10 ++-- .../Services/CommandLineInterface.cs | 45 ++++++++++++++- 6 files changed, 172 insertions(+), 21 deletions(-) create mode 100644 src/wan24-AutoDiscover Shared/Models/EmailMapping.cs diff --git a/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs index 909be29..ca41e9a 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; @@ -55,12 +56,17 @@ public DiscoveryConfig() { } /// public IReadOnlySet KnownProxies { get; init; } = new HashSet(); + /// + /// JSON file path which contains the email mappings list + /// + public string? EmailMappings { get; init; } + /// /// Get the discovery configuration /// /// Configuration /// Discovery configuration - public virtual IReadOnlyDictionary GetDiscoveryConfig(IConfigurationRoot config) + public virtual async Task> GetDiscoveryConfigAsync(IConfigurationRoot config) { Type discoveryType = DiscoveryType; if (!typeof(IDictionary).IsAssignableFrom(discoveryType)) @@ -82,9 +88,45 @@ public virtual IReadOnlyDictionary GetDiscoveryConfig(ICon 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 (EmailMappings is not null) + if (File.Exists(EmailMappings)) + { + Logging.WriteInfo($"Loading email mappings from \"{EmailMappings}\""); + FileStream fs = FsHelper.CreateFileStream(EmailMappings, FileMode.Open, FileAccess.Read, FileShare.Read); + await using (fs.DynamicContext()) + { + EmailMapping[] mappings = await JsonHelper.DecodeAsync(fs).DynamicContext() + ?? throw new InvalidDataException("Invalid email mappings"); + foreach(EmailMapping mapping in mappings) + { + if (!mapping.Email.Contains('@')) + continue; + string email = mapping.Email.ToLower(); + if ( + !MailAddress.TryCreate(mapping.Email, out MailAddress? emailAddress) || + (emailAddress.User.Length == 1 && (emailAddress.User[0] == '*' || emailAddress.User[0] == '@')) || + EmailMapping.GetLoginUser(mappings, email) is not string loginUser + ) + continue; + string[] emailParts = mapping.Email.ToLower().Split('@', 2); + if (emailParts.Length != 2 || DomainConfig.GetConfig(string.Empty, emailParts) is not DomainConfig domain) + continue; + if (Logging.Debug) + Logging.WriteDebug($"Mapping email address \"{email}\" to login user \"{loginUser}\""); + domain.LoginNameMapping ??= []; + domain.LoginNameMapping[email] = loginUser; + } + } + } + else + { + Logging.WriteWarning($"Email mappings file \"{EmailMappings}\" not found"); + } + return discoveryDomains.ToFrozenDictionary(); } } } diff --git a/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs b/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs index cf4604a..7c94b86 100644 --- a/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs +++ b/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs @@ -1,4 +1,5 @@ -using System.Xml; +using System.Net.Http; +using System.Xml; using wan24.ObjectValidation; namespace wan24.AutoDiscover.Models @@ -34,7 +35,7 @@ public DomainConfig() { } /// Login name mapping (key is the email address or alias, value the mapped login name) /// [RequiredIf(nameof(LoginNameMappingRequired), true)] - public IReadOnlyDictionary? LoginNameMapping { get; init; } + 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) @@ -51,5 +52,22 @@ public virtual void CreateXml(XmlDocument xml, XmlNode account, string[] emailPa { foreach (Protocol protocol in Protocols) protocol.CreateXml(xml, account, emailParts, this); } + + /// + /// Get a domain configuration which matches an email address + /// + /// Hostname + /// Splitted email parts + /// Domain configuration + public static DomainConfig? GetConfig(string host, string[] emailParts) + => !Registered.TryGetValue(emailParts[1], out DomainConfig? config) && + (host.Length == 0 || !Registered.TryGetValue(host, out config)) && + !Registered.TryGetValue( + Registered.Where(kvp => kvp.Value.AcceptedDomains?.Contains(emailParts[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..e0a055b --- /dev/null +++ b/src/wan24-AutoDiscover Shared/Models/EmailMapping.cs @@ -0,0 +1,57 @@ +using System.ComponentModel.DataAnnotations; +using wan24.ObjectValidation; + +namespace wan24.AutoDiscover.Models +{ + /// + /// Email mapping + /// + public record class EmailMapping + { + /// + /// Constructor + /// + public EmailMapping() { } + + /// + /// Emailaddress + /// + [EmailAddress] + public required string Email { get; init; } + + /// + /// Target email addresses or user names + /// + [CountLimit(1, int.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/Controllers/DiscoveryController.cs b/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs index 3254461..31a98f0 100644 --- a/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs +++ b/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs @@ -108,16 +108,7 @@ public async Task AutoDiscoverAsync() 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 - ) - ) + if (DomainConfig.GetConfig(HttpContext.Request.Host.Host,emailParts) is not DomainConfig 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() diff --git a/src/wan24-AutoDiscover/Program.cs b/src/wan24-AutoDiscover/Program.cs index 904f2ac..f2d284f 100644 --- a/src/wan24-AutoDiscover/Program.cs +++ b/src/wan24-AutoDiscover/Program.cs @@ -19,17 +19,17 @@ // Load the configuration string configFile = Path.Combine(ENV.AppFolder, "appsettings.json"); -IConfigurationRoot LoadConfig() +async Task LoadConfigAsync() { ConfigurationBuilder configBuilder = new(); configBuilder.AddJsonFile(configFile, optional: false); IConfigurationRoot config = configBuilder.Build(); DiscoveryConfig.Current = config.GetRequiredSection("DiscoveryConfig").Get() ?? throw new InvalidDataException($"Failed to get a {typeof(DiscoveryConfig)} from the \"DiscoveryConfig\" section"); - DomainConfig.Registered = DiscoveryConfig.Current.GetDiscoveryConfig(config); + DomainConfig.Registered = await DiscoveryConfig.Current.GetDiscoveryConfigAsync(config).DynamicContext(); return config; } -IConfigurationRoot config = LoadConfig(); +IConfigurationRoot config = await LoadConfigAsync().DynamicContext(); // Initialize wan24-Core await Bootstrap.Async().DynamicContext(); @@ -45,7 +45,7 @@ IConfigurationRoot LoadConfig() // Watch configuration changes using ConfigChangeEventThrottle fswThrottle = new(); -ConfigChangeEventThrottle.OnConfigChange += () => +ConfigChangeEventThrottle.OnConfigChange += async () => { try { @@ -53,7 +53,7 @@ IConfigurationRoot LoadConfig() if (File.Exists(configFile)) { Logging.WriteInfo($"Auto-reloading changed configuration from \"{configFile}\""); - LoadConfig(); + await LoadConfigAsync().DynamicContext(); } else { diff --git a/src/wan24-AutoDiscover/Services/CommandLineInterface.cs b/src/wan24-AutoDiscover/Services/CommandLineInterface.cs index c50f620..099e3fc 100644 --- a/src/wan24-AutoDiscover/Services/CommandLineInterface.cs +++ b/src/wan24-AutoDiscover/Services/CommandLineInterface.cs @@ -1,4 +1,6 @@ using System.Text; +using System.Text.RegularExpressions; +using wan24.AutoDiscover.Models; using wan24.CLI; using wan24.Core; @@ -8,8 +10,13 @@ namespace wan24.AutoDiscover.Services /// CLI API /// [CliApi("autodiscover")] - public class CommandLineInterface + public 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(); + /// /// Constructor /// @@ -27,5 +34,41 @@ public static async Task CreateSystemdServiceAsync() using (StreamWriter writer = new(stdOut, Encoding.UTF8, leaveOpen: true)) await writer.WriteLineAsync(new SystemdServiceFile().ToString().Trim()).DynamicContext(); } + + /// + /// Parse Postfix email mappings + /// + [CliApi("postfix")] + [StdIn("/etc/postfix/virtual")] + [StdOut("/home/autodiscover/postfix.json")] + public static async Task ParsePostfixEmailMappingsAsync() + { + HashSet mappings = []; + Stream stdIn = Console.OpenStandardInput(); + await using (stdIn.DynamicContext()) + { + using StreamReader reader = new(stdIn, Encoding.UTF8, leaveOpen: true); + while(await reader.ReadLineAsync().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).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(); } }