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
+