From 9b9f2048e0bec875c9c826dc1acf60dddb8efe7d Mon Sep 17 00:00:00 2001 From: nd Date: Sat, 6 Apr 2024 19:15:30 +0200 Subject: [PATCH 01/14] Update --- .../Models/DiscoveryConfig.cs | 10 ++-- .../Models/DomainConfig.cs | 17 +++++-- .../Models/Protocol.cs | 51 ++++++++++++++++--- src/wan24-AutoDiscover/Program.cs | 20 ++++---- 4 files changed, 75 insertions(+), 23 deletions(-) diff --git a/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs index eed1362..909be29 100644 --- a/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs +++ b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs @@ -5,7 +5,6 @@ using System.Net; using System.Text.Json.Serialization; using wan24.Core; -using wan24.ObjectValidation; namespace wan24.AutoDiscover.Models { @@ -19,6 +18,11 @@ public record class DiscoveryConfig /// public DiscoveryConfig() { } + /// + /// Current configuration + /// + public static DiscoveryConfig Current { get; set; } = null!; + /// /// Logfile path /// @@ -49,14 +53,14 @@ public DiscoveryConfig() { } /// /// Known http proxies /// - public HashSet KnownProxies { get; init; } = []; + public IReadOnlySet KnownProxies { get; init; } = new HashSet(); /// /// Get the discovery configuration /// /// Configuration /// Discovery configuration - public FrozenDictionary GetDiscoveryConfig(IConfigurationRoot config) + public virtual IReadOnlyDictionary GetDiscoveryConfig(IConfigurationRoot config) { Type discoveryType = DiscoveryType; if (!typeof(IDictionary).IsAssignableFrom(discoveryType)) diff --git a/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs b/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs index 71595fd..cf4604a 100644 --- a/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs +++ b/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs @@ -22,13 +22,24 @@ public DomainConfig() { } /// Accepted domain names /// [ItemRegularExpression(@"^[a-z|-|\.]{1,256}$")] - public HashSet? AcceptedDomains { get; init; } + public IReadOnlyList? AcceptedDomains { get; init; } /// /// Protocols /// [CountLimit(1, int.MaxValue)] - public required virtual HashSet Protocols { get; init; } + 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 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; } /// /// Create XML @@ -38,7 +49,7 @@ public DomainConfig() { } /// Splitted email parts public virtual void CreateXml(XmlDocument xml, XmlNode account, string[] emailParts) { - foreach (Protocol protocol in Protocols) protocol.CreateXml(xml, account, emailParts); + foreach (Protocol protocol in Protocols) protocol.CreateXml(xml, account, emailParts, this); } } } diff --git a/src/wan24-AutoDiscover Shared/Models/Protocol.cs b/src/wan24-AutoDiscover Shared/Models/Protocol.cs index 4dad89e..c1671d8 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 @@ -59,9 +60,7 @@ public Protocol() { } /// /// 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 @@ -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 /// @@ -107,7 +117,8 @@ public Protocol() { } /// XML /// Account node /// Splitted email parts - public virtual void CreateXml(XmlDocument xml, XmlNode account, string[] emailParts) + /// Domain + public virtual void CreateXml(XmlDocument xml, XmlNode account, string[] emailParts, DomainConfig domain) { XmlNode protocol = account.AppendChild(xml.CreateElement(PROTOCOL_NODE_NAME, Constants.RESPONSE_NS))!; foreach (KeyValuePair kvp in new Dictionary() @@ -115,7 +126,7 @@ public virtual void CreateXml(XmlDocument xml, XmlNode account, string[] emailPa {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(xml, account, emailParts, domain, this) }, {SPA_NODE_NAME, SPA ? ON : OFF }, {SSL_NODE_NAME, SSL ? ON : OFF }, {AUTHREQUIRED_NODE_NAME, AuthRequired ? ON : OFF } @@ -124,13 +135,39 @@ public virtual void CreateXml(XmlDocument xml, XmlNode account, string[] emailPa } /// - /// 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, DomainConfig domain, Protocol protocol); + + /// + /// Default 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 static string DefaultLoginName(XmlDocument xml, XmlNode account, string[] emailParts, DomainConfig domain, Protocol protocol) + { + string emailAddress = string.Join('@', emailParts), + res = protocol.LoginNameIsEmailAlias + ? emailParts[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/Program.cs b/src/wan24-AutoDiscover/Program.cs index e638315..04ac90e 100644 --- a/src/wan24-AutoDiscover/Program.cs +++ b/src/wan24-AutoDiscover/Program.cs @@ -11,25 +11,25 @@ await Bootstrap.Async().DynamicContext(); Translation.Current = Translation.Dummy; Settings.AppId = "wan24-AutoDiscover"; - Settings.ProcessId = "webservice"; + Settings.ProcessId = "cli"; Logging.Logger = new VividConsoleLogger(); - CliApi.HelpHeader = "wan24-AutoDiscover"; + CliApi.HelpHeader = "wan24-AutoDiscover - (c) 2024 Andreas Zimmermann, wan24.de"; return await CliApi.RunAsync(args, exportedApis: [typeof(CliHelpApi), typeof(CommandLineInterface)]).DynamicContext(); } // Load the configuration string configFile = Path.Combine(ENV.AppFolder, "appsettings.json"); -(IConfigurationRoot Config, DiscoveryConfig Discovery) LoadConfig() +IConfigurationRoot LoadConfig() { 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 = DiscoveryConfig.Current.GetDiscoveryConfig(config); + return config; } -(IConfigurationRoot config, DiscoveryConfig discovery) = LoadConfig(); +IConfigurationRoot config = LoadConfig(); // Initialize wan24-Core await Bootstrap.Async().DynamicContext(); @@ -37,7 +37,7 @@ Settings.AppId = "wan24-AutoDiscover"; Settings.ProcessId = "webservice"; Settings.LogLevel = config.GetValue("Logging:LogLevel:Default"); -Logging.Logger = discovery.LogFile is string logFile && !string.IsNullOrWhiteSpace(logFile) +Logging.Logger = DiscoveryConfig.Current.LogFile is string logFile && !string.IsNullOrWhiteSpace(logFile) ? await FileLogger.CreateAsync(logFile, next: new VividConsoleLogger()).DynamicContext() : new VividConsoleLogger(); ErrorHandling.ErrorHandler = (e) => Logging.WriteError($"{e.Info}: {e.Exception}"); @@ -108,14 +108,14 @@ void ReloadConfig(object sender, FileSystemEventArgs e) if (ENV.IsLinux) builder.Logging.AddSystemdConsole(); builder.Services.AddControllers(); -builder.Services.AddSingleton(typeof(XmlDocumentInstances), services => new XmlDocumentInstances(capacity: discovery.PreForkResponses)) +builder.Services.AddSingleton(typeof(XmlDocumentInstances), services => new XmlDocumentInstances(capacity: DiscoveryConfig.Current.PreForkResponses)) .AddHostedService(services => services.GetRequiredService()) .AddExceptionHandler() .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(); From 320859a90f2747e3a0ad4d812d18547c6f2ff300 Mon Sep 17 00:00:00 2001 From: nd Date: Sat, 6 Apr 2024 19:27:31 +0200 Subject: [PATCH 02/14] Update README.md * `DiscoveryConfig.GetDiscoveryConfig` returns an `IReadOnlyDictionary` now * `DiscoveryConfig.KnownProxies` is an `IReadOnlySet` now * `DomainConfig.AcceptedDomains` and `DomainConfig.Protocols` is an `IReadOnlyList` now * `Protocol.LoginName_Delegate` changed * `Protocol.CreateXml` changed + Added `DiscoveryConfig.Current` + `DiscoveryConfig.GetDiscoveryConfig` is virtual now + Added `DomainConfig.LoginNameMapping` + Added `DomainConfig.LoginNameMappingRequired` + Added `Protocol.DefaultLoginName` + Added `Protocol.LoginNameMapping` + Added `Protocol.LoginNameMappingRequired` --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 649a0da..7bb6221 100644 --- a/README.md +++ b/README.md @@ -217,3 +217,11 @@ things for you: ```bash dotnet wan24AutoDiscover.dll autodiscover systemd > /etc/systemd/system/autodiscover.service ``` + +## 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. From 2574740f008ac10d36ad743ee95815abf15f917c Mon Sep 17 00:00:00 2001 From: nd Date: Sat, 6 Apr 2024 19:52:36 +0200 Subject: [PATCH 03/14] Update + Added Aspire support --- src/wan24-AutoDiscover.AppHost/Program.cs | 5 + .../Properties/launchSettings.json | 16 +++ .../appsettings.Development.json | 8 ++ .../appsettings.json | 9 ++ .../wan24-AutoDiscover.AppHost.csproj | 19 +++ .../Extensions.cs | 118 ++++++++++++++++++ .../wan24-AutoDiscover.ServiceDefaults.csproj | 24 ++++ src/wan24-AutoDiscover.sln | 12 ++ src/wan24-AutoDiscover/Program.cs | 2 + .../wan24-AutoDiscover.csproj | 1 + 10 files changed, 214 insertions(+) create mode 100644 src/wan24-AutoDiscover.AppHost/Program.cs create mode 100644 src/wan24-AutoDiscover.AppHost/Properties/launchSettings.json create mode 100644 src/wan24-AutoDiscover.AppHost/appsettings.Development.json create mode 100644 src/wan24-AutoDiscover.AppHost/appsettings.json create mode 100644 src/wan24-AutoDiscover.AppHost/wan24-AutoDiscover.AppHost.csproj create mode 100644 src/wan24-AutoDiscover.ServiceDefaults/Extensions.cs create mode 100644 src/wan24-AutoDiscover.ServiceDefaults/wan24-AutoDiscover.ServiceDefaults.csproj 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..8d1bd09 --- /dev/null +++ b/src/wan24-AutoDiscover.AppHost/wan24-AutoDiscover.AppHost.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + enable + enable + true + + + + + + + + + + + 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..80f9ee9 --- /dev/null +++ b/src/wan24-AutoDiscover.ServiceDefaults/wan24-AutoDiscover.ServiceDefaults.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + + + diff --git a/src/wan24-AutoDiscover.sln b/src/wan24-AutoDiscover.sln index 6849790..6622467 100644 --- a/src/wan24-AutoDiscover.sln +++ b/src/wan24-AutoDiscover.sln @@ -7,6 +7,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "wan24-AutoDiscover", "wan24 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "wan24-AutoDiscover Shared", "wan24-AutoDiscover Shared\wan24-AutoDiscover Shared.csproj", "{610B6034-2404-4EBA-80E1-92102CE9E5B4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +25,14 @@ Global {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 + {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 + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/wan24-AutoDiscover/Program.cs b/src/wan24-AutoDiscover/Program.cs index 04ac90e..904f2ac 100644 --- a/src/wan24-AutoDiscover/Program.cs +++ b/src/wan24-AutoDiscover/Program.cs @@ -103,6 +103,7 @@ void ReloadConfig(object sender, FileSystemEventArgs e) // 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) @@ -123,6 +124,7 @@ void ReloadConfig(object sender, FileSystemEventArgs e) { await using (app.DynamicContext()) { + app.MapDefaultEndpoints(); app.UseForwardedHeaders(); if (app.Environment.IsDevelopment()) { diff --git a/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj b/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj index dbad146..00c146a 100644 --- a/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj +++ b/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj @@ -17,6 +17,7 @@ + From 29090ddf10963fdc9b77bfcba5de517b1375d4a9 Mon Sep 17 00:00:00 2001 From: nd Date: Sat, 6 Apr 2024 19:58:01 +0200 Subject: [PATCH 04/14] Update dotnet.yml --- .github/workflows/dotnet.yml | 2 ++ 1 file changed, 2 insertions(+) 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 From 86429c55e93bbe9d0667893446be44343e83ee17 Mon Sep 17 00:00:00 2001 From: nd Date: Sat, 6 Apr 2024 23:38:42 +0200 Subject: [PATCH 05/14] 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(); } } From 9114f12af0d71301baf58dcadf718e0265fefc40 Mon Sep 17 00:00:00 2001 From: nd Date: Sat, 6 Apr 2024 23:53:10 +0200 Subject: [PATCH 06/14] Update --- README.md | 32 +++++++++++++++++++++++ src/wan24-AutoDiscover/Program.cs | 42 +++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/README.md b/README.md index 7bb6221..ed8ccc7 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,12 @@ things for you: 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 +``` + ## Login name mapping If the login name isn't the email address or the alias of the given email @@ -225,3 +231,29 @@ 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. diff --git a/src/wan24-AutoDiscover/Program.cs b/src/wan24-AutoDiscover/Program.cs index f2d284f..de34396 100644 --- a/src/wan24-AutoDiscover/Program.cs +++ b/src/wan24-AutoDiscover/Program.cs @@ -99,6 +99,48 @@ void ReloadConfig(object sender, FileSystemEventArgs e) }; fsw.Changed += ReloadConfig; fsw.Created += ReloadConfig; +string? emailMappings = DiscoveryConfig.Current.EmailMappings is null + ? null + : Path.GetFullPath(DiscoveryConfig.Current.EmailMappings); +void ReloadEmailMappings(object sender, FileSystemEventArgs e) +{ + try + { + Logging.WriteDebug($"Detected email mappings change {e.ChangeType}"); + if (File.Exists(emailMappings)) + { + if (fswThrottle.IsThrottling) + { + Logging.WriteTrace("Skipping email mappings change event due too many events"); + } + else if (fswThrottle.Raise()) + { + Logging.WriteTrace("Email mappings change event has been raised"); + } + } + else + { + Logging.WriteTrace($"Email mappings file \"{emailMappings}\" doesn't exist"); + } + } + catch (Exception ex) + { + Logging.WriteWarning($"Failed to handle email mappings change of \"{emailMappings}\": {ex}"); + } +} +using FileSystemWatcher? emailMappingsFsw = emailMappings is null + ? null + : new(Path.GetDirectoryName(emailMappings)!, Path.GetFileName(emailMappings)) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime, + IncludeSubdirectories = false, + EnableRaisingEvents = true + }; +if (emailMappingsFsw is not null) +{ + emailMappingsFsw.Changed += ReloadEmailMappings; + emailMappingsFsw.Created += ReloadEmailMappings; +} // Build and run the app Logging.WriteInfo("Autodiscovery service app startup"); From a0b97be2ffc554e5d3cd0459dd27f6c3027f293b Mon Sep 17 00:00:00 2001 From: nd Date: Sat, 6 Apr 2024 23:56:02 +0200 Subject: [PATCH 07/14] Update --- src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs | 2 +- src/wan24-AutoDiscover/appsettings.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs index ca41e9a..89fbf36 100644 --- a/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs +++ b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs @@ -92,7 +92,7 @@ public virtual async Task> GetDiscover Enumerable.Range(0, discovery.Count).Select(i => new KeyValuePair((string)keys[i], (DomainConfig)values[i])) ); // Apply email mappings - if (EmailMappings is not null) + if (!string.IsNullOrWhiteSpace(EmailMappings)) if (File.Exists(EmailMappings)) { Logging.WriteInfo($"Loading email mappings from \"{EmailMappings}\""); diff --git a/src/wan24-AutoDiscover/appsettings.json b/src/wan24-AutoDiscover/appsettings.json index ab662e5..d41879e 100644 --- a/src/wan24-AutoDiscover/appsettings.json +++ b/src/wan24-AutoDiscover/appsettings.json @@ -19,6 +19,7 @@ "PreForkResponses": 10, "DiscoveryType": null, "KnownProxies": [], + "EmailMappings": null, "Discovery": { "localhost": { "AcceptedDomains": [ From f0ac2eb181b886adc9922059157b459f1ed16166 Mon Sep 17 00:00:00 2001 From: nd Date: Mon, 8 Apr 2024 19:28:17 +0200 Subject: [PATCH 08/14] Update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit + Added `DiscoveryConfig.WatchEmailMappings` + Added `DiscoveryConfig.WatchFiles` + Added `DiscoveryConfig.PreReloadCommand´ --- .../Models/DiscoveryConfig.cs | 68 ++++++--- .../wan24-AutoDiscover Shared.csproj | 2 +- src/wan24-AutoDiscover/Program.cs | 134 ++++++++---------- .../Services/ConfigChangeEventThrottle.cs | 31 ---- .../wan24-AutoDiscover.csproj | 2 +- 5 files changed, 105 insertions(+), 132 deletions(-) delete mode 100644 src/wan24-AutoDiscover/Services/ConfigChangeEventThrottle.cs diff --git a/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs index 89fbf36..b9a8635 100644 --- a/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs +++ b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs @@ -61,6 +61,21 @@ public DiscoveryConfig() { } /// 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 + /// + public string[]? WatchFiles { get; init; } + + /// + /// Command to execute (and optional arguments) before reloading the configuration when any file changed + /// + public string[]? PreReloadCommand { get; init; } + /// /// Get the discovery configuration /// @@ -78,12 +93,12 @@ public virtual async Task> GetDiscover 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); @@ -95,36 +110,45 @@ public virtual async Task> GetDiscover if (!string.IsNullOrWhiteSpace(EmailMappings)) if (File.Exists(EmailMappings)) { - Logging.WriteInfo($"Loading email mappings from \"{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()) - { - EmailMapping[] mappings = await JsonHelper.DecodeAsync(fs).DynamicContext() + mappings = await JsonHelper.DecodeAsync(fs).DynamicContext() ?? throw new InvalidDataException("Invalid email mappings"); - foreach(EmailMapping mapping in 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 (!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; + 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}\" not found"); + Logging.WriteWarning($"Email mappings file {EmailMappings.ToQuotedLiteral()} not found"); } return discoveryDomains.ToFrozenDictionary(); } diff --git a/src/wan24-AutoDiscover Shared/wan24-AutoDiscover Shared.csproj b/src/wan24-AutoDiscover Shared/wan24-AutoDiscover Shared.csproj index 85a7e98..70948f1 100644 --- a/src/wan24-AutoDiscover Shared/wan24-AutoDiscover Shared.csproj +++ b/src/wan24-AutoDiscover Shared/wan24-AutoDiscover Shared.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/wan24-AutoDiscover/Program.cs b/src/wan24-AutoDiscover/Program.cs index de34396..2517444 100644 --- a/src/wan24-AutoDiscover/Program.cs +++ b/src/wan24-AutoDiscover/Program.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.HttpLogging; using Microsoft.AspNetCore.HttpOverrides; +using System.Diagnostics; using wan24.AutoDiscover.Models; using wan24.AutoDiscover.Services; using wan24.CLI; @@ -18,9 +19,11 @@ } // Load the configuration +using SemaphoreSync configSync = new(); string configFile = Path.Combine(ENV.AppFolder, "appsettings.json"); async Task LoadConfigAsync() { + using SemaphoreSyncContext ssc = await configSync.SyncContextAsync().DynamicContext(); ConfigurationBuilder configBuilder = new(); configBuilder.AddJsonFile(configFile, optional: false); IConfigurationRoot config = configBuilder.Build(); @@ -44,18 +47,19 @@ async Task LoadConfigAsync() Logging.WriteInfo($"Using configuration \"{configFile}\""); // Watch configuration changes -using ConfigChangeEventThrottle fswThrottle = new(); -ConfigChangeEventThrottle.OnConfigChange += async () => +using MultiFileSystemEvents fsw = new();//TODO Throttle only the main service events +fsw.OnEvents += async (s, e) => { try { - Logging.WriteDebug("Handling configuration change"); + if (Logging.Debug) + Logging.WriteDebug("Handling configuration change"); if (File.Exists(configFile)) { Logging.WriteInfo($"Auto-reloading changed configuration from \"{configFile}\""); await LoadConfigAsync().DynamicContext(); } - else + else if(Logging.Trace) { Logging.WriteTrace($"Configuration file \"{configFile}\" doesn't exist"); } @@ -65,82 +69,57 @@ async Task LoadConfigAsync() 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) - { - Logging.WriteTrace("Skipping configuration change event due too many events"); - } - else if (fswThrottle.Raise()) - { - Logging.WriteTrace("Configuration change event has been raised"); - } - } - else - { - Logging.WriteTrace($"Configuration file \"{configFile}\" doesn't exist"); - } - } - catch (Exception ex) - { - Logging.WriteWarning($"Failed to handle configuration change of \"{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; -string? emailMappings = DiscoveryConfig.Current.EmailMappings is null - ? null - : Path.GetFullPath(DiscoveryConfig.Current.EmailMappings); -void ReloadEmailMappings(object sender, FileSystemEventArgs e) -{ - try +fsw.Add(new(ENV.AppFolder, "appsettings.json", NotifyFilters.LastWrite | NotifyFilters.CreationTime, throttle: 250, recursive: false)); +if (DiscoveryConfig.Current.WatchEmailMappings && DiscoveryConfig.Current.EmailMappings is not null) + fsw.Add(new( + Path.GetDirectoryName(Path.GetFullPath(DiscoveryConfig.Current.EmailMappings))!, + Path.GetFileName(DiscoveryConfig.Current.EmailMappings), + NotifyFilters.LastWrite | NotifyFilters.CreationTime, + throttle: 250, + recursive: false, + FileSystemEventTypes.Changes | FileSystemEventTypes.Created | FileSystemEventTypes.Deleted + )); +if (DiscoveryConfig.Current.WatchFiles is not null) + foreach (string file in DiscoveryConfig.Current.WatchFiles) { - Logging.WriteDebug($"Detected email mappings change {e.ChangeType}"); - if (File.Exists(emailMappings)) - { - if (fswThrottle.IsThrottling) - { - Logging.WriteTrace("Skipping email mappings change event due too many events"); - } - else if (fswThrottle.Raise()) + FileSystemEvents fse = new( + Path.GetDirectoryName(Path.GetFullPath(file))!, + Path.GetFileName(file), + NotifyFilters.LastWrite | NotifyFilters.CreationTime, + throttle: 250, + recursive: false, + FileSystemEventTypes.Changes | FileSystemEventTypes.Created | FileSystemEventTypes.Deleted + ); + if (DiscoveryConfig.Current.PreReloadCommand is not null && DiscoveryConfig.Current.PreReloadCommand.Length > 0) + fse.OnEvents += async (s, e) => { - Logging.WriteTrace("Email mappings change event has been raised"); - } - } - else - { - Logging.WriteTrace($"Email mappings file \"{emailMappings}\" doesn't exist"); - } - } - catch (Exception ex) - { - Logging.WriteWarning($"Failed to handle email mappings change of \"{emailMappings}\": {ex}"); + try + { + Logging.WriteInfo($"Executing pre-reload command on detected {file.ToQuotedLiteral()} change {string.Join('|', e.Arguments.Select(a => a.ChangeType.ToString()).Distinct())}"); + using Process proc = new(); + proc.StartInfo.FileName = DiscoveryConfig.Current.PreReloadCommand[0]; + if (DiscoveryConfig.Current.PreReloadCommand.Length > 1) + proc.StartInfo.ArgumentList.AddRange(DiscoveryConfig.Current.PreReloadCommand[1..]); + proc.StartInfo.UseShellExecute = true; + proc.StartInfo.CreateNoWindow = true; + proc.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; + proc.Start(); + await proc.WaitForExitAsync().DynamicContext(); + if (proc.ExitCode != 0) + Logging.WriteWarning($"Pre-reload command exit code was #{proc.ExitCode}"); + } + catch(Exception ex) + { + Logging.WriteError($"Pre-reload command execution failed exceptional: {ex}"); + } + finally + { + if (Logging.Trace) + Logging.WriteTrace("Pre-reload command execution done"); + } + }; + fsw.Add(fse); } -} -using FileSystemWatcher? emailMappingsFsw = emailMappings is null - ? null - : new(Path.GetDirectoryName(emailMappings)!, Path.GetFileName(emailMappings)) - { - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime, - IncludeSubdirectories = false, - EnableRaisingEvents = true - }; -if (emailMappingsFsw is not null) -{ - emailMappingsFsw.Changed += ReloadEmailMappings; - emailMappingsFsw.Created += ReloadEmailMappings; -} // Build and run the app Logging.WriteInfo("Autodiscovery service app startup"); @@ -153,6 +132,7 @@ void ReloadEmailMappings(object sender, FileSystemEventArgs e) builder.Services.AddControllers(); builder.Services.AddSingleton(typeof(XmlDocumentInstances), services => new XmlDocumentInstances(capacity: DiscoveryConfig.Current.PreForkResponses)) .AddHostedService(services => services.GetRequiredService()) + .AddHostedService(services => fsw) .AddExceptionHandler() .AddHttpLogging(options => options.LoggingFields = HttpLoggingFields.RequestPropertiesAndHeaders); builder.Services.Configure(options => 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/wan24-AutoDiscover.csproj b/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj index 00c146a..04b612f 100644 --- a/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj +++ b/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj @@ -12,7 +12,7 @@ - + From a9bfc474f7eafb44c4060504d7715e14d41afdcc Mon Sep 17 00:00:00 2001 From: nd Date: Tue, 9 Apr 2024 21:08:23 +0200 Subject: [PATCH 09/14] Update + Added `VersionInfo` + Added CLI API `autodiscover version`, which is the default now (previous was `systemd`) + Added CLI API `autodiscover upgrade` + Added CLI API `autodiscover post-upgrade` (for internal use) + Added automatic online upgrade + Added `Trunk` build configuration --- README.md | 95 +++- latest-release.txt | 1 + .../Models/DiscoveryConfig.cs | 9 +- .../Properties/Resources.Designer.cs | 72 +++ .../Properties/Resources.resx | 123 ++++++ src/wan24-AutoDiscover Shared/VersionInfo.cs | 18 + .../wan24-AutoDiscover Shared.csproj | 25 +- .../wan24-AutoDiscover.AppHost.csproj | 1 + .../wan24-AutoDiscover.ServiceDefaults.csproj | 1 + src/wan24-AutoDiscover.sln | 31 +- src/wan24-AutoDiscover/Program.cs | 107 +++-- .../Services/CommandLineInterface.Postfix.cs | 57 +++ .../Services/CommandLineInterface.Upgrade.cs | 413 ++++++++++++++++++ .../Services/CommandLineInterface.cs | 61 +-- src/wan24-AutoDiscover/appsettings.json | 3 + src/wan24-AutoDiscover/latest-release.txt | 1 + .../wan24-AutoDiscover.csproj | 26 +- 17 files changed, 936 insertions(+), 108 deletions(-) create mode 100644 latest-release.txt create mode 100644 src/wan24-AutoDiscover Shared/Properties/Resources.Designer.cs create mode 100644 src/wan24-AutoDiscover Shared/Properties/Resources.resx create mode 100644 src/wan24-AutoDiscover Shared/VersionInfo.cs create mode 100644 src/wan24-AutoDiscover/Services/CommandLineInterface.Postfix.cs create mode 100644 src/wan24-AutoDiscover/Services/CommandLineInterface.Upgrade.cs create mode 100644 src/wan24-AutoDiscover/latest-release.txt diff --git a/README.md b/README.md index ed8ccc7..cfa1781 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,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` @@ -224,6 +224,40 @@ dotnet wan24AutoDiscover.dll autodiscover systemd > /etc/systemd/system/autodisc 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. + +With user interaction: + +```bash +dotnet wan24AutoDiscover.dll autodiscover 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 @@ -256,4 +290,57 @@ Then you can add the `postix.json` to your `appsettings.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. +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 + +For that 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. 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/Models/DiscoveryConfig.cs b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs index b9a8635..d9574cf 100644 --- a/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs +++ b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs @@ -72,7 +72,7 @@ public DiscoveryConfig() { } public string[]? WatchFiles { get; init; } /// - /// Command to execute (and optional arguments) before reloading the configuration when any file changed + /// Command to execute (and optional arguments) before reloading the configuration /// public string[]? PreReloadCommand { get; init; } @@ -80,8 +80,9 @@ public DiscoveryConfig() { } /// Get the discovery configuration /// /// Configuration + /// Cancellation token /// Discovery configuration - public virtual async Task> GetDiscoveryConfigAsync(IConfigurationRoot config) + public virtual async Task> GetDiscoveryConfigAsync(IConfigurationRoot config, CancellationToken cancellationToken = default) { Type discoveryType = DiscoveryType; if (!typeof(IDictionary).IsAssignableFrom(discoveryType)) @@ -98,7 +99,7 @@ public virtual async Task> GetDiscover 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 from the DiscoveryConfig:Discovery section"); + ?? 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); @@ -114,7 +115,7 @@ public virtual async Task> GetDiscover FileStream fs = FsHelper.CreateFileStream(EmailMappings, FileMode.Open, FileAccess.Read, FileShare.Read); EmailMapping[] mappings; await using (fs.DynamicContext()) - mappings = await JsonHelper.DecodeAsync(fs).DynamicContext() + mappings = await JsonHelper.DecodeAsync(fs, cancellationToken).DynamicContext() ?? throw new InvalidDataException("Invalid email mappings"); foreach(EmailMapping mapping in mappings) { 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 70948f1..14243c6 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/wan24-AutoDiscover.AppHost.csproj b/src/wan24-AutoDiscover.AppHost/wan24-AutoDiscover.AppHost.csproj index 8d1bd09..3b19f44 100644 --- a/src/wan24-AutoDiscover.AppHost/wan24-AutoDiscover.AppHost.csproj +++ b/src/wan24-AutoDiscover.AppHost/wan24-AutoDiscover.AppHost.csproj @@ -6,6 +6,7 @@ enable enable true + Debug;Release;Trunk diff --git a/src/wan24-AutoDiscover.ServiceDefaults/wan24-AutoDiscover.ServiceDefaults.csproj b/src/wan24-AutoDiscover.ServiceDefaults/wan24-AutoDiscover.ServiceDefaults.csproj index 80f9ee9..a67d9bc 100644 --- a/src/wan24-AutoDiscover.ServiceDefaults/wan24-AutoDiscover.ServiceDefaults.csproj +++ b/src/wan24-AutoDiscover.ServiceDefaults/wan24-AutoDiscover.ServiceDefaults.csproj @@ -5,6 +5,7 @@ enable enable true + Debug;Release;Trunk diff --git a/src/wan24-AutoDiscover.sln b/src/wan24-AutoDiscover.sln index 6622467..b569976 100644 --- a/src/wan24-AutoDiscover.sln +++ b/src/wan24-AutoDiscover.sln @@ -5,34 +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("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "wan24-AutoDiscover.AppHost", "wan24-AutoDiscover.AppHost\wan24-AutoDiscover.AppHost.csproj", "{697D3342-956F-4C77-93A9-1C80833EF5F5}" +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/Program.cs b/src/wan24-AutoDiscover/Program.cs index 2517444..5d00bac 100644 --- a/src/wan24-AutoDiscover/Program.cs +++ b/src/wan24-AutoDiscover/Program.cs @@ -1,52 +1,57 @@ using Microsoft.AspNetCore.HttpLogging; using Microsoft.AspNetCore.HttpOverrides; using System.Diagnostics; +using wan24.AutoDiscover; using wan24.AutoDiscover.Models; using wan24.AutoDiscover.Services; using wan24.CLI; using wan24.Core; +// Global cancellation token source +using CancellationTokenSource cts = new(); + // Run the CLI API -if (args.Length > 0) +if (args.Length > 0 && !args[0].StartsWith('-')) { - await Bootstrap.Async().DynamicContext(); + CliConfig.Apply(new(args)); + await Bootstrap.Async(cancellationToken: cts.Token).DynamicContext(); Translation.Current = Translation.Dummy; Settings.AppId = "wan24-AutoDiscover"; Settings.ProcessId = "cli"; - Logging.Logger = new VividConsoleLogger(); - CliApi.HelpHeader = "wan24-AutoDiscover - (c) 2024 Andreas Zimmermann, wan24.de"; - return await CliApi.RunAsync(args, exportedApis: [typeof(CliHelpApi), typeof(CommandLineInterface)]).DynamicContext(); + Logging.Logger ??= new VividConsoleLogger(); + CliApi.HelpHeader = $"wan24-AutoDiscover {VersionInfo.Current} - (c) 2024 Andreas Zimmermann, wan24.de"; + return await CliApi.RunAsync(args, cts.Token, [typeof(CliHelpApi), typeof(CommandLineInterface)]).DynamicContext(); } // Load the configuration -using SemaphoreSync configSync = new(); string configFile = Path.Combine(ENV.AppFolder, "appsettings.json"); async Task LoadConfigAsync() { - using SemaphoreSyncContext ssc = await configSync.SyncContextAsync().DynamicContext(); 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 = await DiscoveryConfig.Current.GetDiscoveryConfigAsync(config).DynamicContext(); + DomainConfig.Registered = await DiscoveryConfig.Current.GetDiscoveryConfigAsync(config, cts.Token).DynamicContext(); return config; } IConfigurationRoot config = await LoadConfigAsync().DynamicContext(); // Initialize wan24-Core +CliConfig.Apply(new(args)); 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 = DiscoveryConfig.Current.LogFile is string logFile && !string.IsNullOrWhiteSpace(logFile) +Logging.Logger ??= DiscoveryConfig.Current.LogFile is string logFile && !string.IsNullOrWhiteSpace(logFile) ? await FileLogger.CreateAsync(logFile, next: new VividConsoleLogger()).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 SemaphoreSync configSync = new(); using MultiFileSystemEvents fsw = new();//TODO Throttle only the main service events fsw.OnEvents += async (s, e) => { @@ -54,6 +59,39 @@ async Task LoadConfigAsync() { if (Logging.Debug) Logging.WriteDebug("Handling configuration change"); + if (configSync.IsSynchronized) + { + Logging.WriteWarning("Can't handle configuration change, because another handler is still processing (configuration reload takes too long)"); + return; + } + 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.WriteInfo("Executing pre-reload command on detected configuration change"); + using Process proc = new(); + proc.StartInfo.FileName = DiscoveryConfig.Current.PreReloadCommand[0]; + if (DiscoveryConfig.Current.PreReloadCommand.Length > 1) + proc.StartInfo.ArgumentList.AddRange(DiscoveryConfig.Current.PreReloadCommand[1..]); + proc.StartInfo.UseShellExecute = true; + proc.StartInfo.CreateNoWindow = true; + proc.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; + proc.Start(); + await proc.WaitForExitAsync(cts.Token).DynamicContext(); + if (proc.ExitCode != 0) + Logging.WriteWarning($"Pre-reload command exit code was #{proc.ExitCode}"); + } + catch (Exception ex) + { + Logging.WriteError($"Pre-reload command execution failed exceptional: {ex}"); + } + finally + { + if (Logging.Trace) + Logging.WriteTrace("Pre-reload command execution done"); + } + // Reload configuration if (File.Exists(configFile)) { Logging.WriteInfo($"Auto-reloading changed configuration from \"{configFile}\""); @@ -68,6 +106,11 @@ async Task LoadConfigAsync() { Logging.WriteWarning($"Failed to reload configuration from \"{configFile}\": {ex}"); } + finally + { + if (Logging.Trace) + Logging.WriteTrace($"Auto-reloading changed configuration from \"{configFile}\" done"); + } }; fsw.Add(new(ENV.AppFolder, "appsettings.json", NotifyFilters.LastWrite | NotifyFilters.CreationTime, throttle: 250, recursive: false)); if (DiscoveryConfig.Current.WatchEmailMappings && DiscoveryConfig.Current.EmailMappings is not null) @@ -75,51 +118,20 @@ async Task LoadConfigAsync() Path.GetDirectoryName(Path.GetFullPath(DiscoveryConfig.Current.EmailMappings))!, Path.GetFileName(DiscoveryConfig.Current.EmailMappings), NotifyFilters.LastWrite | NotifyFilters.CreationTime, - throttle: 250, + throttle: 250,//TODO Throttle only the main service events recursive: false, FileSystemEventTypes.Changes | FileSystemEventTypes.Created | FileSystemEventTypes.Deleted )); if (DiscoveryConfig.Current.WatchFiles is not null) foreach (string file in DiscoveryConfig.Current.WatchFiles) - { - FileSystemEvents fse = new( + fsw.Add(new( Path.GetDirectoryName(Path.GetFullPath(file))!, Path.GetFileName(file), NotifyFilters.LastWrite | NotifyFilters.CreationTime, - throttle: 250, + throttle: 250,//TODO Throttle only the main service events recursive: false, FileSystemEventTypes.Changes | FileSystemEventTypes.Created | FileSystemEventTypes.Deleted - ); - if (DiscoveryConfig.Current.PreReloadCommand is not null && DiscoveryConfig.Current.PreReloadCommand.Length > 0) - fse.OnEvents += async (s, e) => - { - try - { - Logging.WriteInfo($"Executing pre-reload command on detected {file.ToQuotedLiteral()} change {string.Join('|', e.Arguments.Select(a => a.ChangeType.ToString()).Distinct())}"); - using Process proc = new(); - proc.StartInfo.FileName = DiscoveryConfig.Current.PreReloadCommand[0]; - if (DiscoveryConfig.Current.PreReloadCommand.Length > 1) - proc.StartInfo.ArgumentList.AddRange(DiscoveryConfig.Current.PreReloadCommand[1..]); - proc.StartInfo.UseShellExecute = true; - proc.StartInfo.CreateNoWindow = true; - proc.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; - proc.Start(); - await proc.WaitForExitAsync().DynamicContext(); - if (proc.ExitCode != 0) - Logging.WriteWarning($"Pre-reload command exit code was #{proc.ExitCode}"); - } - catch(Exception ex) - { - Logging.WriteError($"Pre-reload command execution failed exceptional: {ex}"); - } - finally - { - if (Logging.Trace) - Logging.WriteTrace("Pre-reload command execution done"); - } - }; - fsw.Add(fse); - } + )); // Build and run the app Logging.WriteInfo("Autodiscovery service app startup"); @@ -165,7 +177,7 @@ async Task LoadConfigAsync() } app.MapControllers(); Logging.WriteInfo("Autodiscovery service app starting"); - await app.RunAsync().DynamicContext(); + await app.RunAsync(cts.Token).DynamicContext(); Logging.WriteInfo("Autodiscovery service app quitting"); } } @@ -176,6 +188,7 @@ async Task LoadConfigAsync() } 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..1e503ce --- /dev/null +++ b/src/wan24-AutoDiscover/Services/CommandLineInterface.Upgrade.cs @@ -0,0 +1,413 @@ +using Spectre.Console; +using System.ComponentModel; +using System.Diagnostics; +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 (!System.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(version); + return 2; + } + // Confirm upgrade + if (!noUserInteraction) + { + AnsiConsole.WriteLine($"[{CliApiInfo.HighlightColor}]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 != "yes") + { + Logging.WriteInfo("Upgrade cancelled by user"); + return 0; + } + } + // Perform the upgrade + bool deleteTempDir = true; + 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($"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 setup"); + return 1; + } + } + // Execute pre-command + if (preCommand is not null && preCommand.Length > 0) + { + Logging.WriteInfo("Executing pre-update command"); + using Process proc = new(); + proc.StartInfo.FileName = preCommand[0]; + if (preCommand.Length > 1) + proc.StartInfo.ArgumentList.AddRange(preCommand[1..]); + proc.StartInfo.UseShellExecute = true; + proc.StartInfo.CreateNoWindow = true; + proc.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; + proc.Start(); + await proc.WaitForExitAsync(cancellationToken).DynamicContext(); + if (proc.ExitCode != 0) + { + Logging.WriteError($"Pre-update command failed to execute with exit code #{proc.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 + 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 (File.Exists(targetFn)) + { + 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"); + using (Process proc = new()) + { + proc.StartInfo.FileName = "dotnet"; + proc.StartInfo.ArgumentList.AddRange( + "wan24AutoDiscover.dll", + "autodiscover", + "post-upgrade", + VersionInfo.Current.ToString(), + version + ); + proc.StartInfo.UseShellExecute = true; + proc.StartInfo.CreateNoWindow = true; + proc.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; + proc.Start(); + await proc.WaitForExitAsync(cancellationToken).DynamicContext(); + if (proc.ExitCode != 0) + { + Logging.WriteError($"Post-upgrade acion failed to execute with exit code #{proc.ExitCode}"); + return 1; + } + } + // Execute post-command + if (postCommand is not null && postCommand.Length > 0) + { + Logging.WriteInfo("Executing post-update command"); + using Process proc = new(); + proc.StartInfo.FileName = postCommand[0]; + if (postCommand.Length > 1) + proc.StartInfo.ArgumentList.AddRange(postCommand[1..]); + proc.StartInfo.UseShellExecute = true; + proc.StartInfo.CreateNoWindow = true; + proc.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; + proc.Start(); + await proc.WaitForExitAsync(cancellationToken).DynamicContext(); + if (proc.ExitCode != 0) + { + Logging.WriteError($"Post-update command failed to execute with exit code #{proc.ExitCode}"); + return 1; + } + } + Logging.WriteInfo("wan24-AutoDiscover upgrade done"); + deleteTempDir = true; + return 0; + } + catch (Exception ex) + { + if (deleteTempDir) + { + Logging.WriteError($"Update failed: {ex}"); + } + else + { + Logging.WriteError($"Update failed (won't delete temporary folder \"{tempDir}\" 'cause is 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 099e3fc..7af3353 100644 --- a/src/wan24-AutoDiscover/Services/CommandLineInterface.cs +++ b/src/wan24-AutoDiscover/Services/CommandLineInterface.cs @@ -1,6 +1,5 @@ -using System.Text; -using System.Text.RegularExpressions; -using wan24.AutoDiscover.Models; +using System.ComponentModel; +using System.Text; using wan24.CLI; using wan24.Core; @@ -10,13 +9,10 @@ namespace wan24.AutoDiscover.Services /// CLI API /// [CliApi("autodiscover")] - public partial class CommandLineInterface + [DisplayText("wan24-AutoDiscover API")] + [Description("wan24-AutoDiscover CLI API methods")] + 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(); - /// /// Constructor /// @@ -25,50 +21,25 @@ 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(); - } - - /// - /// 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(); + await writer.WriteLineAsync(new SystemdServiceFile().ToString().Trim().AsMemory(), cancellationToken).DynamicContext(); } /// - /// Regular expression to match a Postfix email mapping ($1 contains the email address, $2 contains the comma separated targets) + /// Version /// - /// Regular expression - [GeneratedRegex(@"^\s*([^\*\@#][^\s]+)\s*([^\s]+)\s*$", RegexOptions.Compiled)] - private static partial Regex RX_POSTFIX_Generator(); + [CliApi("version", IsDefault = true)] + [DisplayText("Version")] + [Description("Display the current version string and exit")] + public static void Version() => Console.WriteLine(VersionInfo.Current.ToString()); } } diff --git a/src/wan24-AutoDiscover/appsettings.json b/src/wan24-AutoDiscover/appsettings.json index d41879e..16db4c6 100644 --- a/src/wan24-AutoDiscover/appsettings.json +++ b/src/wan24-AutoDiscover/appsettings.json @@ -20,6 +20,9 @@ "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 04b612f..dffd1a7 100644 --- a/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj +++ b/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -7,17 +7,35 @@ wan24.AutoDiscover wan24AutoDiscover True + Debug;Release;Trunk + + + + + + + + - - - + + + + + + + + + Always + + + From ac824f5924ae754d8690e977a988bb7288459528 Mon Sep 17 00:00:00 2001 From: nd Date: Wed, 10 Apr 2024 20:31:19 +0200 Subject: [PATCH 10/14] Update + Added request body length limit of 1.024 bytes --- README.md | 13 ++- .../Models/DiscoveryConfig.cs | 12 ++- .../Models/DomainConfig.cs | 15 ++- .../Models/EmailMapping.cs | 10 +- .../Models/Protocol.cs | 12 +-- .../Controllers/DiscoveryController.cs | 97 +++++++++++-------- src/wan24-AutoDiscover/Program.cs | 51 ++++------ .../Services/ExceptionHandler.cs | 20 +++- .../Services/XmlDocumentInstances.cs | 8 +- 9 files changed, 131 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index cfa1781..f467136 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 @@ -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) @@ -240,13 +245,13 @@ dotnet wan24AutoDiscover.dll autodiscover upgrade -checkOnly **NOTE**: The command will exit with code #2, if an update is available online. -With user interaction: +Upgrade with user interaction: ```bash dotnet wan24AutoDiscover.dll autodiscover upgrade ``` -Without user interaction: +Upgrade without user interaction: ```bash dotnet wan24AutoDiscover.dll autodiscover upgrade -noUserInteraction diff --git a/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs index d9574cf..640f9bf 100644 --- a/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs +++ b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs @@ -6,18 +6,24 @@ using System.Net.Mail; using System.Text.Json.Serialization; using wan24.Core; +using wan24.ObjectValidation; 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 @@ -46,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"); diff --git a/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs b/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs index 7c94b86..6f24407 100644 --- a/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs +++ b/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs @@ -1,5 +1,4 @@ -using System.Net.Http; -using System.Xml; +using System.Xml; using wan24.ObjectValidation; namespace wan24.AutoDiscover.Models @@ -7,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) @@ -48,7 +47,7 @@ public DomainConfig() { } /// XML /// Account node /// Splitted email parts - public virtual void CreateXml(XmlDocument xml, XmlNode account, string[] emailParts) + public virtual void CreateXml(XmlDocument xml, XmlNode account, ReadOnlyMemory emailParts) { foreach (Protocol protocol in Protocols) protocol.CreateXml(xml, account, emailParts, this); } @@ -59,11 +58,11 @@ public virtual void CreateXml(XmlDocument xml, XmlNode account, string[] emailPa /// Hostname /// Splitted email parts /// Domain configuration - public static DomainConfig? GetConfig(string host, string[] emailParts) - => !Registered.TryGetValue(emailParts[1], out DomainConfig? config) && + 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[1], StringComparer.OrdinalIgnoreCase) ?? false) + Registered.Where(kvp => kvp.Value.AcceptedDomains?.Contains(emailParts.Span[1], StringComparer.OrdinalIgnoreCase) ?? false) .Select(kvp => kvp.Key) .FirstOrDefault() ?? string.Empty, out config) diff --git a/src/wan24-AutoDiscover Shared/Models/EmailMapping.cs b/src/wan24-AutoDiscover Shared/Models/EmailMapping.cs index e0a055b..bff8e3a 100644 --- a/src/wan24-AutoDiscover Shared/Models/EmailMapping.cs +++ b/src/wan24-AutoDiscover Shared/Models/EmailMapping.cs @@ -6,12 +6,12 @@ namespace wan24.AutoDiscover.Models /// /// Email mapping /// - public record class EmailMapping + public record class EmailMapping : ValidatableRecordBase { /// /// Constructor /// - public EmailMapping() { } + public EmailMapping() : base() { } /// /// Emailaddress @@ -39,7 +39,8 @@ public EmailMapping() { } return loginName; HashSet seen = [email]; Queue emails = []; - foreach (string target in mapping.Targets) emails.Enqueue(target.ToLower()); + foreach (string target in mapping.Targets) + emails.Enqueue(target.ToLower()); while(emails.TryDequeue(out string? target)) { if ( @@ -49,7 +50,8 @@ public EmailMapping() { } continue; if (targetMapping.Targets.FirstOrDefault(t => !t.Contains('@')) is string targetLoginName) return targetLoginName; - foreach (string subTarget in targetMapping.Targets) emails.Enqueue(subTarget.ToLower()); + 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 c1671d8..8ecd9d2 100644 --- a/src/wan24-AutoDiscover Shared/Models/Protocol.cs +++ b/src/wan24-AutoDiscover Shared/Models/Protocol.cs @@ -9,7 +9,7 @@ namespace wan24.AutoDiscover.Models /// /// Protocol (POX) /// - public record class Protocol + public record class Protocol : ValidatableRecordBase { /// /// Protocol node name @@ -55,7 +55,7 @@ public record class Protocol /// /// Constructor /// - public Protocol() { } + public Protocol() : base() { } /// /// Login name getter delegate @@ -118,7 +118,7 @@ public Protocol() { } /// Account node /// Splitted email parts /// Domain - public virtual void CreateXml(XmlDocument xml, XmlNode account, string[] emailParts, DomainConfig domain) + public virtual void CreateXml(XmlDocument xml, XmlNode account, ReadOnlyMemory emailParts, DomainConfig domain) { XmlNode protocol = account.AppendChild(xml.CreateElement(PROTOCOL_NODE_NAME, Constants.RESPONSE_NS))!; foreach (KeyValuePair kvp in new Dictionary() @@ -143,7 +143,7 @@ public virtual void CreateXml(XmlDocument xml, XmlNode account, string[] emailPa /// Domain /// Protocol /// Login name - public delegate string LoginName_Delegate(XmlDocument xml, XmlNode account, string[] emailParts, DomainConfig domain, Protocol protocol); + public delegate string LoginName_Delegate(XmlDocument xml, XmlNode account, ReadOnlyMemory emailParts, DomainConfig domain, Protocol protocol); /// /// Default login name resolver @@ -154,11 +154,11 @@ public virtual void CreateXml(XmlDocument xml, XmlNode account, string[] emailPa /// Domain /// Protocol /// Login name - public static string DefaultLoginName(XmlDocument xml, XmlNode account, string[] emailParts, DomainConfig domain, Protocol protocol) + public static string DefaultLoginName(XmlDocument xml, XmlNode account, ReadOnlyMemory emailParts, DomainConfig domain, Protocol protocol) { string emailAddress = string.Join('@', emailParts), res = protocol.LoginNameIsEmailAlias - ? emailParts[0] + ? emailParts.Span[0] : emailAddress; string? loginName = null; return (protocol.LoginNameMapping?.TryGetValue(emailAddress, out loginName) ?? false) || diff --git a/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs b/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs index 31a98f0..9d78f10 100644 --- a/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs +++ b/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Mvc; using System.Net; using System.Net.Mail; -using System.Text; using System.Xml; +using System.Xml.XPath; using wan24.AutoDiscover.Models; using wan24.AutoDiscover.Services; using wan24.Core; @@ -20,17 +20,9 @@ namespace wan24.AutoDiscover.Controllers public class DiscoveryController(XmlDocumentInstances responses) : ControllerBase() { /// - /// Request XML email address node XPath selector + /// Max. request length in bytes /// - private const string EMAIL_NODE_XPATH = "//*[local-name()='EMailAddress']"; - /// - /// Request XML acceptable response node XPath selector - /// - private const string ACCEPTABLE_RESPONSE_SCHEMA_NODE_XPATH = "//*[local-name()='AcceptableResponseSchema']"; - /// - /// Response XML account node XPath selector - /// - private const string ACCOUNT_NODE_XPATH = $"//*[local-name()='{ACCOUNT_NODE_NAME}']"; + private const int MAX_REQUEST_LEN = 1024; /// /// XML response MIME type /// @@ -74,43 +66,68 @@ public class DiscoveryController(XmlDocumentInstances responses) : ControllerBas private readonly XmlDocumentInstances Responses = responses; /// - /// Auto discovery (POX) + /// XPath request query + /// + private static readonly XPathExpression RequestQuery = XPathExpression.Compile("/*[local-name()='Autodiscover']/*[local-name()='Request']"); + /// + /// XPath schema query + /// + private static readonly XPathExpression SchemaQuery = XPathExpression.Compile("./*[local-name()='AcceptableResponseSchema']"); + /// + /// XPath email query + /// + private static readonly XPathExpression EmailQuery = XPathExpression.Compile("./*[local-name()='EMailAddress']"); + + /// + /// Autodiscover (POX request body required) /// - /// XML response - [HttpPost, Route("autodiscover.xml")] + /// POX response + [HttpPost, Route("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 + XPathNavigator requestNavigator, + requestNode, + acceptableResponseSchema, + emailNode; + using (MemoryPoolStream ms = new()) { - 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); + Stream requestBody = HttpContext.Request.Body; + await using (requestBody.DynamicContext()) + await requestBody.CopyToAsync(ms, bufferSize: MAX_REQUEST_LEN, HttpContext.RequestAborted).DynamicContext(); + ms.Position = 0; + try + { + requestNavigator = new XPathDocument(ms).CreateNavigator(); + } + catch (XmlException ex) + { + throw new BadHttpRequestException("Invalid XML in request", ex); + } } - 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); + requestNode = requestNavigator.SelectSingleNode(RequestQuery) + ?? throw new BadHttpRequestException("Missing request node in request"); + acceptableResponseSchema = requestNode.SelectSingleNode(SchemaQuery) + ?? throw new BadHttpRequestException("Missing acceptable response schema node in request"); + emailNode = requestNode.SelectSingleNode(EmailQuery) + ?? throw new BadHttpRequestException("Missing email address node in request"); + if (acceptableResponseSchema.Value.Trim() != Constants.RESPONSE_NS) + throw new BadHttpRequestException("Unsupported acceptable response schema in request"); + string emailAddress = emailNode.Value.Trim().ToLower(); + if (Logging.Trace) + Logging.WriteTrace($"POX request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort} email address {emailAddress.ToQuotedLiteral()}"); + string[] emailParts = emailAddress.Split('@', 2); 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}"); + // Generate the response + if (Logging.Trace) + Logging.WriteTrace($"Creating POX response for \"{emailAddress}\" request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort}"); XmlDocument xml = await Responses.GetOneAsync(HttpContext.RequestAborted).DynamicContext(); - 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); + 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]}"); + config.CreateXml(xml, xml.FirstChild?.FirstChild?.FirstChild ?? throw new InvalidProgramException("Missing response XML account node"), emailParts); + if (Logging.Trace) + Logging.WriteTrace($"POX response XML body to {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort}: {xml.OuterXml}"); return new() { Content = xml.OuterXml, diff --git a/src/wan24-AutoDiscover/Program.cs b/src/wan24-AutoDiscover/Program.cs index 5d00bac..571b8e2 100644 --- a/src/wan24-AutoDiscover/Program.cs +++ b/src/wan24-AutoDiscover/Program.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.HttpLogging; using Microsoft.AspNetCore.HttpOverrides; -using System.Diagnostics; using wan24.AutoDiscover; using wan24.AutoDiscover.Models; using wan24.AutoDiscover.Services; @@ -44,15 +43,15 @@ async Task LoadConfigAsync() Settings.AppId = "wan24-AutoDiscover"; Settings.ProcessId = "service"; Settings.LogLevel = config.GetValue("Logging:LogLevel:Default"); -Logging.Logger ??= DiscoveryConfig.Current.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($"wan24-AutoDiscover {VersionInfo.Current} Using configuration \"{configFile}\""); // Watch configuration changes using SemaphoreSync configSync = new(); -using MultiFileSystemEvents fsw = new();//TODO Throttle only the main service events +using MultiFileSystemEvents fsw = new(throttle: 250); fsw.OnEvents += async (s, e) => { try @@ -61,7 +60,7 @@ async Task LoadConfigAsync() Logging.WriteDebug("Handling configuration change"); if (configSync.IsSynchronized) { - Logging.WriteWarning("Can't handle configuration change, because another handler is still processing (configuration reload takes too long)"); + Logging.WriteWarning("Can't handle configuration change, because another handler is still processing (configuration reload takes too long!)"); return; } using SemaphoreSyncContext ssc = await configSync.SyncContextAsync(cts.Token).DynamicContext(); @@ -70,32 +69,27 @@ async Task LoadConfigAsync() try { Logging.WriteInfo("Executing pre-reload command on detected configuration change"); - using Process proc = new(); - proc.StartInfo.FileName = DiscoveryConfig.Current.PreReloadCommand[0]; - if (DiscoveryConfig.Current.PreReloadCommand.Length > 1) - proc.StartInfo.ArgumentList.AddRange(DiscoveryConfig.Current.PreReloadCommand[1..]); - proc.StartInfo.UseShellExecute = true; - proc.StartInfo.CreateNoWindow = true; - proc.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; - proc.Start(); - await proc.WaitForExitAsync(cts.Token).DynamicContext(); - if (proc.ExitCode != 0) - Logging.WriteWarning($"Pre-reload command exit code was #{proc.ExitCode}"); + 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"); } catch (Exception ex) { Logging.WriteError($"Pre-reload command execution failed exceptional: {ex}"); } - finally - { - if (Logging.Trace) - Logging.WriteTrace("Pre-reload command execution done"); - } // 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 if(Logging.Trace) { @@ -104,23 +98,17 @@ async Task LoadConfigAsync() } catch (Exception ex) { - Logging.WriteWarning($"Failed to reload configuration from \"{configFile}\": {ex}"); - } - finally - { - if (Logging.Trace) - Logging.WriteTrace($"Auto-reloading changed configuration from \"{configFile}\" done"); + Logging.WriteError($"Failed to reload configuration from \"{configFile}\": {ex}"); } }; fsw.Add(new(ENV.AppFolder, "appsettings.json", NotifyFilters.LastWrite | NotifyFilters.CreationTime, throttle: 250, recursive: false)); -if (DiscoveryConfig.Current.WatchEmailMappings && DiscoveryConfig.Current.EmailMappings is not null) +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, - throttle: 250,//TODO Throttle only the main service events recursive: false, - FileSystemEventTypes.Changes | FileSystemEventTypes.Created | FileSystemEventTypes.Deleted + events: FileSystemEventTypes.Changes | FileSystemEventTypes.Created | FileSystemEventTypes.Deleted )); if (DiscoveryConfig.Current.WatchFiles is not null) foreach (string file in DiscoveryConfig.Current.WatchFiles) @@ -128,9 +116,8 @@ async Task LoadConfigAsync() Path.GetDirectoryName(Path.GetFullPath(file))!, Path.GetFileName(file), NotifyFilters.LastWrite | NotifyFilters.CreationTime, - throttle: 250,//TODO Throttle only the main service events recursive: false, - FileSystemEventTypes.Changes | FileSystemEventTypes.Created | FileSystemEventTypes.Deleted + events: FileSystemEventTypes.Changes | FileSystemEventTypes.Created | FileSystemEventTypes.Deleted )); // Build and run the app diff --git a/src/wan24-AutoDiscover/Services/ExceptionHandler.cs b/src/wan24-AutoDiscover/Services/ExceptionHandler.cs index e688946..fabdf03 100644 --- a/src/wan24-AutoDiscover/Services/ExceptionHandler.cs +++ b/src/wan24-AutoDiscover/Services/ExceptionHandler.cs @@ -9,26 +9,38 @@ namespace wan24.AutoDiscover.Services /// public sealed class ExceptionHandler : IExceptionHandler { + /// + /// Internal server error code + /// + private const int INTERNAL_SERVER_ERROR_CODE = (int)HttpStatusCode.InternalServerError; + + /// + /// Internal server error message bytes + /// + private static readonly byte[] InternalServerErrorMessage = "Internal server error".GetBytes(); + /// /// Constructor /// public ExceptionHandler() { } /// - public ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { 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; + await httpContext.Response.BodyWriter.WriteAsync((badRequest.Message ?? "Bad request").GetBytes(), cancellationToken).DynamicContext(); } 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_CODE; + await httpContext.Response.BodyWriter.WriteAsync(InternalServerErrorMessage, cancellationToken).DynamicContext(); } - return ValueTask.FromResult(true); + return true; } } } diff --git a/src/wan24-AutoDiscover/Services/XmlDocumentInstances.cs b/src/wan24-AutoDiscover/Services/XmlDocumentInstances.cs index 5d20da4..4e8c57b 100644 --- a/src/wan24-AutoDiscover/Services/XmlDocumentInstances.cs +++ b/src/wan24-AutoDiscover/Services/XmlDocumentInstances.cs @@ -13,11 +13,6 @@ namespace wan24.AutoDiscover.Services /// Capacity public sealed class XmlDocumentInstances(in int capacity) : InstancePool(capacity, CreateXmlDocument) { - /// - /// Constructor - /// - public XmlDocumentInstances() : this(capacity: 100) { } - /// /// Create an /// @@ -25,7 +20,8 @@ public XmlDocumentInstances() : this(capacity: 100) { } /// private static XmlDocument CreateXmlDocument(IInstancePool pool) { - if (Logging.Trace) Logging.WriteTrace("Pre-forking a new POX XML response"); + 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))! From 9b0db88cdfbb569fb82f980b6c8e2031d2f216cf Mon Sep 17 00:00:00 2001 From: nd Date: Wed, 10 Apr 2024 21:06:23 +0200 Subject: [PATCH 11/14] Update Program.cs --- src/wan24-AutoDiscover/Program.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/wan24-AutoDiscover/Program.cs b/src/wan24-AutoDiscover/Program.cs index 571b8e2..7cc34a3 100644 --- a/src/wan24-AutoDiscover/Program.cs +++ b/src/wan24-AutoDiscover/Program.cs @@ -19,7 +19,9 @@ Settings.ProcessId = "cli"; Logging.Logger ??= new VividConsoleLogger(); CliApi.HelpHeader = $"wan24-AutoDiscover {VersionInfo.Current} - (c) 2024 Andreas Zimmermann, wan24.de"; - return await CliApi.RunAsync(args, cts.Token, [typeof(CliHelpApi), typeof(CommandLineInterface)]).DynamicContext(); + AboutApi.Info = "(c) 2024 Andreas Zimmermann, wan24.de"; + AboutApi.Version = VersionInfo.Current; + return await CliApi.RunAsync(args, cts.Token, [typeof(CliHelpApi), typeof(CommandLineInterface), typeof(AboutApi)]).DynamicContext(); } // Load the configuration From b0274308d82ecdf571e148fd062ea73c2e183b25 Mon Sep 17 00:00:00 2001 From: nd Date: Thu, 11 Apr 2024 21:50:53 +0200 Subject: [PATCH 12/14] Update + Added `XmlResponse` + Pre-forking controller results also + Added `DiscoveryConfig:StreamPoolCapacity` to `appsettings.json` + Added CLI API `about` --- README.md | 22 ++- src/wan24-AutoDiscover Shared/Constants.cs | 32 +++++ .../Models/DiscoveryConfig.cs | 9 ++ .../Models/DomainConfig.cs | 9 +- .../Models/EmailMapping.cs | 2 +- .../Models/Protocol.cs | 21 ++- src/wan24-AutoDiscover Shared/XmlResponse.cs | 59 +++++++++ .../Controllers/DiscoveryController.cs | 125 +++++++++--------- src/wan24-AutoDiscover/Program.cs | 44 +++--- .../Services/CommandLineInterface.Upgrade.cs | 87 ++++++------ .../Services/CommandLineInterface.cs | 8 -- .../Services/ExceptionHandler.cs | 31 ++++- .../Services/MemoryPoolStreamPool.cs | 26 ++++ .../Services/XmlDocumentInstances.cs | 34 ----- .../Services/XmlResponseInstances.cs | 11 ++ src/wan24-AutoDiscover/appsettings.json | 1 + 16 files changed, 326 insertions(+), 195 deletions(-) create mode 100644 src/wan24-AutoDiscover Shared/XmlResponse.cs create mode 100644 src/wan24-AutoDiscover/Services/MemoryPoolStreamPool.cs delete mode 100644 src/wan24-AutoDiscover/Services/XmlDocumentInstances.cs create mode 100644 src/wan24-AutoDiscover/Services/XmlResponseInstances.cs diff --git a/README.md b/README.md index f467136..8f253b3 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ example: "AllowedHosts": "*", "DiscoveryConfig": { "PreForkResponses": 10, + "StreamPoolCapacity": 10, "KnownProxies": [ "127.0.0.1" ], @@ -328,8 +329,8 @@ are recommended: 1. Stop the service before installing the newer version 1. Start the service after installing the newer version -For that the sheduled auto-upgrade task should execute this command on a -Debian Linux server, for example: +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 @@ -349,3 +350,20 @@ 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/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 640f9bf..644b623 100644 --- a/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs +++ b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs @@ -42,6 +42,12 @@ public DiscoveryConfig() : base() { } [Range(1, int.MaxValue)] public int PreForkResponses { get; init; } = 10; + /// + /// Stream pool capacity + /// + [Range(1, int.MaxValue)] + public int StreamPoolCapacity { get; init; } = 10; + /// /// Dicovery configuration type name /// @@ -65,6 +71,7 @@ public DiscoveryConfig() : base() { } /// /// JSON file path which contains the email mappings list /// + [StringLength(short.MaxValue, MinimumLength = 1)] public string? EmailMappings { get; init; } /// @@ -75,11 +82,13 @@ public DiscoveryConfig() : base() { } /// /// 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; } /// diff --git a/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs b/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs index 6f24407..4c4be5a 100644 --- a/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs +++ b/src/wan24-AutoDiscover Shared/Models/DomainConfig.cs @@ -21,13 +21,13 @@ public DomainConfig() : base() { } /// /// Accepted domain names /// - [ItemRegularExpression(@"^[a-z|-|\.]{1,256}$")] + [CountLimit(1, int.MaxValue), ItemRegularExpression(@"^[a-z|-|\.]{1,256}$")] public IReadOnlyList? AcceptedDomains { get; init; } /// /// Protocols /// - [CountLimit(1, int.MaxValue)] + [CountLimit(1, byte.MaxValue)] public required IReadOnlyList Protocols { get; init; } /// @@ -45,11 +45,10 @@ public DomainConfig() : base() { } /// Create XML /// /// XML - /// Account node /// Splitted email parts - public virtual void CreateXml(XmlDocument xml, XmlNode account, ReadOnlyMemory emailParts) + public virtual void CreateXml(XmlWriter xml, ReadOnlyMemory emailParts) { - foreach (Protocol protocol in Protocols) protocol.CreateXml(xml, account, emailParts, this); + foreach (Protocol protocol in Protocols) protocol.CreateXml(xml, emailParts, this); } /// diff --git a/src/wan24-AutoDiscover Shared/Models/EmailMapping.cs b/src/wan24-AutoDiscover Shared/Models/EmailMapping.cs index bff8e3a..d685439 100644 --- a/src/wan24-AutoDiscover Shared/Models/EmailMapping.cs +++ b/src/wan24-AutoDiscover Shared/Models/EmailMapping.cs @@ -22,7 +22,7 @@ public EmailMapping() : base() { } /// /// Target email addresses or user names /// - [CountLimit(1, int.MaxValue)] + [CountLimit(1, int.MaxValue), ItemStringLength(byte.MaxValue)] public required IReadOnlyList Targets { get; init; } /// diff --git a/src/wan24-AutoDiscover Shared/Models/Protocol.cs b/src/wan24-AutoDiscover Shared/Models/Protocol.cs index 8ecd9d2..91e55f0 100644 --- a/src/wan24-AutoDiscover Shared/Models/Protocol.cs +++ b/src/wan24-AutoDiscover Shared/Models/Protocol.cs @@ -65,7 +65,7 @@ public Protocol() : base() { } /// /// Type /// - [Required] + [Required, StringLength(byte.MaxValue, MinimumLength = 1)] public required string Type { get; init; } /// @@ -115,46 +115,43 @@ public Protocol() : base() { } /// Create XML /// /// XML - /// Account node /// Splitted email parts /// Domain - public virtual void CreateXml(XmlDocument xml, XmlNode account, ReadOnlyMemory emailParts, DomainConfig 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, domain, 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 resolver /// - /// XML - /// Account node /// Splitted email parts /// Domain /// Protocol /// Login name - public delegate string LoginName_Delegate(XmlDocument xml, XmlNode account, ReadOnlyMemory emailParts, DomainConfig domain, Protocol protocol); + public delegate string LoginName_Delegate(ReadOnlyMemory emailParts, DomainConfig domain, Protocol protocol); /// /// Default login name resolver /// - /// XML - /// Account node /// Splitted email parts /// Domain /// Protocol /// Login name - public static string DefaultLoginName(XmlDocument xml, XmlNode account, ReadOnlyMemory emailParts, DomainConfig domain, Protocol protocol) + public static string DefaultLoginName(ReadOnlyMemory emailParts, DomainConfig domain, Protocol protocol) { string emailAddress = string.Join('@', emailParts), res = protocol.LoginNameIsEmailAlias diff --git a/src/wan24-AutoDiscover Shared/XmlResponse.cs b/src/wan24-AutoDiscover Shared/XmlResponse.cs new file mode 100644 index 0000000..2ea05be --- /dev/null +++ b/src/wan24-AutoDiscover Shared/XmlResponse.cs @@ -0,0 +1,59 @@ +using System.Xml; +using wan24.Core; + +namespace wan24.AutoDiscover +{ + /// + /// XML response + /// + public class XmlResponse : DisposableBase + { + /// + /// Constructor + /// + public XmlResponse() : base(asyncDisposing: false) + { + XmlOutput = new(bufferSize: 1024) + { + AggressiveReadBlocking = false + }; + XML = XmlWriter.Create(XmlOutput); + 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/Controllers/DiscoveryController.cs b/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs index 9d78f10..466ac7e 100644 --- a/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs +++ b/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs @@ -16,54 +16,26 @@ namespace wan24.AutoDiscover.Controllers /// Constructor /// /// Responses + /// Stream pool [ApiController, Route("autodiscover")] - public class DiscoveryController(XmlDocumentInstances responses) : ControllerBase() + public sealed class DiscoveryController(XmlResponseInstances responses, MemoryPoolStreamPool streamPool) : ControllerBase() { /// /// Max. request length in bytes /// - private const int MAX_REQUEST_LEN = 1024; + private const int MAX_REQUEST_LEN = byte.MaxValue << 1; /// - /// XML response MIME type - /// - private const string XML_MIME_TYPE = "application/xml"; - /// - /// Auto discovery XML namespace - /// - 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 + /// OK http status code /// - public const string ACCOUNT_NODE_NAME = "Account"; + private const int OK_STATUS_CODE = (int)HttpStatusCode.OK; /// - /// AccountType node name + /// Bad request http status code /// - public const string ACCOUNTTYPE_NODE_NAME = "AccountType"; + private const int BAD_REQUEST_STATUS_CODE = (int)HttpStatusCode.BadRequest; /// - /// Account type - /// - public const string ACCOUNTTYPE = "email"; - /// - /// Action node name - /// - public const string ACTION_NODE_NAME = "Action"; - /// - /// Action - /// - public const string ACTION = "settings"; - - /// - /// Responses + /// XML response MIME type /// - private readonly XmlDocumentInstances Responses = responses; + private const string XML_MIME_TYPE = "application/xml"; /// /// XPath request query @@ -78,27 +50,41 @@ public class DiscoveryController(XmlDocumentInstances responses) : ControllerBas /// private static readonly XPathExpression EmailQuery = XPathExpression.Compile("./*[local-name()='EMailAddress']"); + /// + /// Responses + /// + private readonly XmlResponseInstances Responses = responses; + /// + /// Stream pool + /// + private readonly MemoryPoolStreamPool StreamPool = streamPool; + /// /// Autodiscover (POX request body required) /// /// POX response - [HttpPost, Route("autodiscover.xml"), Consumes(XML_MIME_TYPE, IsOptional = false), RequestSizeLimit(MAX_REQUEST_LEN), Produces(XML_MIME_TYPE)] - public async Task AutoDiscoverAsync() + [HttpPost("autodiscover.xml"), Consumes(XML_MIME_TYPE, IsOptional = false), RequestSizeLimit(MAX_REQUEST_LEN), Produces(XML_MIME_TYPE)] + public async Task AutoDiscoverAsync() { + if (Logging.Trace) + Logging.WriteTrace($"POX request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort}"); // Validate the request and try getting the email address - XPathNavigator requestNavigator, - requestNode, - acceptableResponseSchema, - emailNode; - using (MemoryPoolStream ms = new()) + XPathNavigator requestNavigator,// Whole request XML + requestNode,// Request node + acceptableResponseSchema,// AcceptableResponseSchema node + emailNode;// EMailAddress node + using (RentedObject rentedStream = new(StreamPool) + { + Reset = true + }) { Stream requestBody = HttpContext.Request.Body; await using (requestBody.DynamicContext()) - await requestBody.CopyToAsync(ms, bufferSize: MAX_REQUEST_LEN, HttpContext.RequestAborted).DynamicContext(); - ms.Position = 0; + await requestBody.CopyToAsync(rentedStream.Object, bufferSize: MAX_REQUEST_LEN, HttpContext.RequestAborted).DynamicContext(); + rentedStream.Object.Position = 0; try { - requestNavigator = new XPathDocument(ms).CreateNavigator(); + requestNavigator = new XPathDocument(rentedStream.Object).CreateNavigator(); } catch (XmlException ex) { @@ -113,27 +99,42 @@ public async Task AutoDiscoverAsync() ?? throw new BadHttpRequestException("Missing email address node in request"); if (acceptableResponseSchema.Value.Trim() != Constants.RESPONSE_NS) throw new BadHttpRequestException("Unsupported acceptable response schema in request"); - string emailAddress = emailNode.Value.Trim().ToLower(); + string emailAddress = emailNode.Value.Trim().ToLower();// Full email address (lower case) if (Logging.Trace) - Logging.WriteTrace($"POX request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort} email address {emailAddress.ToQuotedLiteral()}"); - string[] emailParts = emailAddress.Split('@', 2); + 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"); + throw new BadHttpRequestException("Invalid email address in request"); // Generate the response + using XmlResponse xml = await Responses.GetOneAsync(HttpContext.RequestAborted).DynamicContext();// Response XML + if (DomainConfig.GetConfig(HttpContext.Request.Host.Host, emailParts) is not DomainConfig config) + { + await BadRequestAsync($"Unknown domain name {HttpContext.Request.Host.Host} / {emailParts[1]}".GetBytes()).DynamicContext(); + return; + } if (Logging.Trace) Logging.WriteTrace($"Creating POX response for \"{emailAddress}\" request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort}"); - XmlDocument xml = await Responses.GetOneAsync(HttpContext.RequestAborted).DynamicContext(); - 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]}"); - config.CreateXml(xml, xml.FirstChild?.FirstChild?.FirstChild ?? throw new InvalidProgramException("Missing response XML account node"), emailParts); + HttpContext.Response.StatusCode = OK_STATUS_CODE; + HttpContext.Response.ContentType = XML_MIME_TYPE; + await HttpContext.Response.StartAsync(HttpContext.RequestAborted).DynamicContext(); + Task sendXmlOutput = xml.XmlOutput.CopyToAsync(HttpContext.Response.Body, HttpContext.RequestAborted); + config.CreateXml(xml.XML, emailParts); + xml.FinalizeXmlOutput(); + await sendXmlOutput.DynamicContext(); + await HttpContext.Response.CompleteAsync().DynamicContext(); if (Logging.Trace) - Logging.WriteTrace($"POX response XML body to {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort}: {xml.OuterXml}"); - return new() - { - Content = xml.OuterXml, - ContentType = XML_MIME_TYPE, - StatusCode = (int)HttpStatusCode.OK - }; + Logging.WriteTrace($"POX response for \"{emailAddress}\" request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort} sent"); + } + + /// + /// Respond with a bad request message + /// + /// Message + private async Task BadRequestAsync(ReadOnlyMemory message) + { + HttpContext.Response.StatusCode = BAD_REQUEST_STATUS_CODE; + HttpContext.Response.ContentType = ExceptionHandler.TEXT_MIME_TYPE; + await HttpContext.Response.Body.WriteAsync(message, HttpContext.RequestAborted).DynamicContext(); } } } diff --git a/src/wan24-AutoDiscover/Program.cs b/src/wan24-AutoDiscover/Program.cs index 7cc34a3..6b8f7f9 100644 --- a/src/wan24-AutoDiscover/Program.cs +++ b/src/wan24-AutoDiscover/Program.cs @@ -6,21 +6,22 @@ using wan24.CLI; using wan24.Core; -// Global cancellation token source +// 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 && !args[0].StartsWith('-')) { - CliConfig.Apply(new(args)); - await Bootstrap.Async(cancellationToken: cts.Token).DynamicContext(); - Translation.Current = Translation.Dummy; - Settings.AppId = "wan24-AutoDiscover"; Settings.ProcessId = "cli"; Logging.Logger ??= new VividConsoleLogger(); CliApi.HelpHeader = $"wan24-AutoDiscover {VersionInfo.Current} - (c) 2024 Andreas Zimmermann, wan24.de"; - AboutApi.Info = "(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(); } @@ -39,16 +40,11 @@ async Task LoadConfigAsync() IConfigurationRoot config = await LoadConfigAsync().DynamicContext(); // Initialize wan24-Core -CliConfig.Apply(new(args)); -await Bootstrap.Async().DynamicContext(); -Translation.Current = Translation.Dummy; -Settings.AppId = "wan24-AutoDiscover"; Settings.ProcessId = "service"; Settings.LogLevel = config.GetValue("Logging:LogLevel:Default"); 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($"wan24-AutoDiscover {VersionInfo.Current} Using configuration \"{configFile}\""); // Watch configuration changes @@ -114,13 +110,14 @@ async Task LoadConfigAsync() )); if (DiscoveryConfig.Current.WatchFiles is not null) foreach (string file in DiscoveryConfig.Current.WatchFiles) - fsw.Add(new( - Path.GetDirectoryName(Path.GetFullPath(file))!, - Path.GetFileName(file), - NotifyFilters.LastWrite | NotifyFilters.CreationTime, - recursive: false, - events: FileSystemEventTypes.Changes | FileSystemEventTypes.Created | FileSystemEventTypes.Deleted - )); + 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"); @@ -131,8 +128,10 @@ async Task LoadConfigAsync() if (ENV.IsLinux) builder.Logging.AddSystemdConsole(); builder.Services.AddControllers(); -builder.Services.AddSingleton(typeof(XmlDocumentInstances), services => new XmlDocumentInstances(capacity: DiscoveryConfig.Current.PreForkResponses)) - .AddHostedService(services => services.GetRequiredService()) +builder.Services.AddSingleton(typeof(XmlResponseInstances), services => new XmlResponseInstances(capacity: DiscoveryConfig.Current.PreForkResponses)) + .AddSingleton(typeof(MemoryPoolStreamPool), services => new MemoryPoolStreamPool(capacity: DiscoveryConfig.Current.StreamPoolCapacity)) + .AddSingleton(cts) + .AddHostedService(services => services.GetRequiredService()) .AddHostedService(services => fsw) .AddExceptionHandler() .AddHttpLogging(options => options.LoggingFields = HttpLoggingFields.RequestPropertiesAndHeaders); @@ -147,6 +146,11 @@ async Task LoadConfigAsync() { await using (app.DynamicContext()) { + app.Lifetime.ApplicationStopping.Register(() => + { + Logging.WriteInfo("Autodiscovery service app shutdown"); + cts.Cancel(); + }); app.MapDefaultEndpoints(); app.UseForwardedHeaders(); if (app.Environment.IsDevelopment()) diff --git a/src/wan24-AutoDiscover/Services/CommandLineInterface.Upgrade.cs b/src/wan24-AutoDiscover/Services/CommandLineInterface.Upgrade.cs index 1e503ce..df95d8b 100644 --- a/src/wan24-AutoDiscover/Services/CommandLineInterface.Upgrade.cs +++ b/src/wan24-AutoDiscover/Services/CommandLineInterface.Upgrade.cs @@ -1,6 +1,5 @@ using Spectre.Console; using System.ComponentModel; -using System.Diagnostics; using System.IO.Compression; using System.Text; using wan24.CLI; @@ -80,7 +79,7 @@ public static async Task UpgradeAsync( } } // Check if an upgrade is possible - if (!System.Version.TryParse(version, out Version? latest)) + if (!Version.TryParse(version, out Version? latest)) { Logging.WriteError("Failed to parse received online version information"); return 1; @@ -93,25 +92,25 @@ public static async Task UpgradeAsync( } if (checkOnly) { - Console.WriteLine(version); + Console.WriteLine(latest.ToString()); return 2; } // Confirm upgrade if (!noUserInteraction) { - AnsiConsole.WriteLine($"[{CliApiInfo.HighlightColor}]You can read the release notes online: [link]{REPOSITORY_URI}[/][/]"); + 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 != "yes") + if (confirmation.Length != 0 && !confirmation.Equals("yes", StringComparison.OrdinalIgnoreCase)) { Logging.WriteInfo("Upgrade cancelled by user"); return 0; } } // Perform the upgrade - bool deleteTempDir = true; + 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()); @@ -168,7 +167,7 @@ public static async Task UpgradeAsync( string release = (await File.ReadAllTextAsync(fn, cancellationToken).DynamicContext()).Trim(); if (release != version) { - Logging.WriteError($"Release mismatch: {release.MaxLength(byte.MaxValue).ToQuotedLiteral()}/{version}"); + Logging.WriteError($"Download release mismatch: {release.MaxLength(byte.MaxValue).ToQuotedLiteral()}/{version}"); return 1; } else if (Logging.Trace) @@ -178,7 +177,7 @@ public static async Task UpgradeAsync( } else { - Logging.WriteError("Missing release information in update setup"); + Logging.WriteError("Missing release information in update"); return 1; } } @@ -186,18 +185,14 @@ public static async Task UpgradeAsync( if (preCommand is not null && preCommand.Length > 0) { Logging.WriteInfo("Executing pre-update command"); - using Process proc = new(); - proc.StartInfo.FileName = preCommand[0]; - if (preCommand.Length > 1) - proc.StartInfo.ArgumentList.AddRange(preCommand[1..]); - proc.StartInfo.UseShellExecute = true; - proc.StartInfo.CreateNoWindow = true; - proc.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; - proc.Start(); - await proc.WaitForExitAsync(cancellationToken).DynamicContext(); - if (proc.ExitCode != 0) + 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 #{proc.ExitCode}"); + Logging.WriteError($"Pre-update command failed to execute with exit code #{exitCode}"); return 1; } } @@ -268,6 +263,7 @@ await transaction.ExecuteAsync( } ); // Open/create the target file + bool exists = File.Exists(targetFn); Stream target = FsHelper.CreateFileStream( targetFn, FileMode.OpenOrCreate, @@ -278,7 +274,7 @@ await transaction.ExecuteAsync( await using (target.DynamicContext()) { // Create a backup of the existing file, first - if (File.Exists(targetFn)) + if (exists) { deleteTempDir = false; string backupFn = $"{file}.backup"; @@ -303,24 +299,21 @@ await transaction.ExecuteAsync( } // Execute post-upgrade Logging.WriteInfo("Executing post-update command"); - using (Process proc = new()) { - proc.StartInfo.FileName = "dotnet"; - proc.StartInfo.ArgumentList.AddRange( - "wan24AutoDiscover.dll", - "autodiscover", - "post-upgrade", - VersionInfo.Current.ToString(), - version - ); - proc.StartInfo.UseShellExecute = true; - proc.StartInfo.CreateNoWindow = true; - proc.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; - proc.Start(); - await proc.WaitForExitAsync(cancellationToken).DynamicContext(); - if (proc.ExitCode != 0) + 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 #{proc.ExitCode}"); + Logging.WriteError($"Post-upgrade acion failed to execute with exit code #{exitCode}"); return 1; } } @@ -328,18 +321,14 @@ await transaction.ExecuteAsync( if (postCommand is not null && postCommand.Length > 0) { Logging.WriteInfo("Executing post-update command"); - using Process proc = new(); - proc.StartInfo.FileName = postCommand[0]; - if (postCommand.Length > 1) - proc.StartInfo.ArgumentList.AddRange(postCommand[1..]); - proc.StartInfo.UseShellExecute = true; - proc.StartInfo.CreateNoWindow = true; - proc.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; - proc.Start(); - await proc.WaitForExitAsync(cancellationToken).DynamicContext(); - if (proc.ExitCode != 0) + 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 #{proc.ExitCode}"); + Logging.WriteError($"Post-update command failed to execute with exit code #{exitCode}"); return 1; } } @@ -351,11 +340,11 @@ await transaction.ExecuteAsync( { if (deleteTempDir) { - Logging.WriteError($"Update failed: {ex}"); + Logging.WriteError($"Update failed (temporary folder will be removed): {ex}"); } else { - Logging.WriteError($"Update failed (won't delete temporary folder \"{tempDir}\" 'cause is may contain backup files): {ex}"); + Logging.WriteError($"Update failed (won't delete temporary folder \"{tempDir}\" 'cause it may contain backup files): {ex}"); } return 1; } diff --git a/src/wan24-AutoDiscover/Services/CommandLineInterface.cs b/src/wan24-AutoDiscover/Services/CommandLineInterface.cs index 7af3353..0e78633 100644 --- a/src/wan24-AutoDiscover/Services/CommandLineInterface.cs +++ b/src/wan24-AutoDiscover/Services/CommandLineInterface.cs @@ -33,13 +33,5 @@ public static async Task CreateSystemdServiceAsync(CancellationToken cancellatio using (StreamWriter writer = new(stdOut, Encoding.UTF8, leaveOpen: true)) await writer.WriteLineAsync(new SystemdServiceFile().ToString().Trim().AsMemory(), cancellationToken).DynamicContext(); } - - /// - /// Version - /// - [CliApi("version", IsDefault = true)] - [DisplayText("Version")] - [Description("Display the current version string and exit")] - public static void Version() => Console.WriteLine(VersionInfo.Current.ToString()); } } diff --git a/src/wan24-AutoDiscover/Services/ExceptionHandler.cs b/src/wan24-AutoDiscover/Services/ExceptionHandler.cs index fabdf03..e72111a 100644 --- a/src/wan24-AutoDiscover/Services/ExceptionHandler.cs +++ b/src/wan24-AutoDiscover/Services/ExceptionHandler.cs @@ -12,12 +12,24 @@ public sealed class ExceptionHandler : IExceptionHandler /// /// Internal server error code /// - private const int INTERNAL_SERVER_ERROR_CODE = (int)HttpStatusCode.InternalServerError; + 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"; /// /// 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 @@ -27,6 +39,8 @@ public ExceptionHandler() { } /// public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { + CancellationTokenSource cts = httpContext.RequestServices.GetRequiredService(); + httpContext.Response.ContentType = TEXT_MIME_TYPE; if (exception is BadHttpRequestException badRequest) { if (Logging.Trace) @@ -34,10 +48,23 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e httpContext.Response.StatusCode = badRequest.StatusCode; await httpContext.Response.BodyWriter.WriteAsync((badRequest.Message ?? "Bad request").GetBytes(), cancellationToken).DynamicContext(); } + 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; + await httpContext.Response.BodyWriter.WriteAsync(MaintenanceMessage, cancellationToken).DynamicContext(); + } 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 = INTERNAL_SERVER_ERROR_CODE; + httpContext.Response.StatusCode = INTERNAL_SERVER_ERROR_STATUS_CODE; await httpContext.Response.BodyWriter.WriteAsync(InternalServerErrorMessage, cancellationToken).DynamicContext(); } return true; diff --git a/src/wan24-AutoDiscover/Services/MemoryPoolStreamPool.cs b/src/wan24-AutoDiscover/Services/MemoryPoolStreamPool.cs new file mode 100644 index 0000000..c115be3 --- /dev/null +++ b/src/wan24-AutoDiscover/Services/MemoryPoolStreamPool.cs @@ -0,0 +1,26 @@ +using wan24.Core; + +namespace wan24.AutoDiscover.Services +{ + /// + /// pool + /// + public sealed class MemoryPoolStreamPool : DisposableObjectPool + { + /// + /// Constructor + /// + /// Capacity + public MemoryPoolStreamPool(in int capacity) + : base( + capacity, + () => + { + if (Logging.Trace) + Logging.WriteTrace("Creating a new memory pool stream"); + return new(); + } + ) + => ResetOnRent = false; + } +} diff --git a/src/wan24-AutoDiscover/Services/XmlDocumentInstances.cs b/src/wan24-AutoDiscover/Services/XmlDocumentInstances.cs deleted file mode 100644 index 4e8c57b..0000000 --- a/src/wan24-AutoDiscover/Services/XmlDocumentInstances.cs +++ /dev/null @@ -1,34 +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) - { - /// - /// 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/Services/XmlResponseInstances.cs b/src/wan24-AutoDiscover/Services/XmlResponseInstances.cs new file mode 100644 index 0000000..1db9a92 --- /dev/null +++ b/src/wan24-AutoDiscover/Services/XmlResponseInstances.cs @@ -0,0 +1,11 @@ +using wan24.Core; + +namespace wan24.AutoDiscover.Services +{ + /// + /// instances + /// + public sealed class XmlResponseInstances(in int capacity) : InstancePool(capacity) + { + } +} diff --git a/src/wan24-AutoDiscover/appsettings.json b/src/wan24-AutoDiscover/appsettings.json index 16db4c6..6f350fb 100644 --- a/src/wan24-AutoDiscover/appsettings.json +++ b/src/wan24-AutoDiscover/appsettings.json @@ -17,6 +17,7 @@ "DiscoveryConfig": { "LogFile": null, "PreForkResponses": 10, + "StreamPoolCapacity": 10, "DiscoveryType": null, "KnownProxies": [], "EmailMappings": null, From 3fbccf1e2d643a4e4fe4dae101de5cdb1d6e6a23 Mon Sep 17 00:00:00 2001 From: nd Date: Thu, 11 Apr 2024 22:16:47 +0200 Subject: [PATCH 13/14] Update --- README.md | 1 - .../Models/DiscoveryConfig.cs | 6 -- .../Controllers/DiscoveryController.cs | 76 ++++++++----------- src/wan24-AutoDiscover/Program.cs | 1 - .../Services/MemoryPoolStreamPool.cs | 26 ------- src/wan24-AutoDiscover/appsettings.json | 1 - 6 files changed, 33 insertions(+), 78 deletions(-) delete mode 100644 src/wan24-AutoDiscover/Services/MemoryPoolStreamPool.cs diff --git a/README.md b/README.md index 8f253b3..aef3198 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,6 @@ example: "AllowedHosts": "*", "DiscoveryConfig": { "PreForkResponses": 10, - "StreamPoolCapacity": 10, "KnownProxies": [ "127.0.0.1" ], diff --git a/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs index 644b623..edf23f2 100644 --- a/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs +++ b/src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs @@ -42,12 +42,6 @@ public DiscoveryConfig() : base() { } [Range(1, int.MaxValue)] public int PreForkResponses { get; init; } = 10; - /// - /// Stream pool capacity - /// - [Range(1, int.MaxValue)] - public int StreamPoolCapacity { get; init; } = 10; - /// /// Dicovery configuration type name /// diff --git a/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs b/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs index 466ac7e..4f24b8c 100644 --- a/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs +++ b/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs @@ -2,7 +2,6 @@ using System.Net; using System.Net.Mail; using System.Xml; -using System.Xml.XPath; using wan24.AutoDiscover.Models; using wan24.AutoDiscover.Services; using wan24.Core; @@ -16,9 +15,8 @@ namespace wan24.AutoDiscover.Controllers /// Constructor /// /// Responses - /// Stream pool [ApiController, Route("autodiscover")] - public sealed class DiscoveryController(XmlResponseInstances responses, MemoryPoolStreamPool streamPool) : ControllerBase() + public sealed class DiscoveryController(XmlResponseInstances responses) : ControllerBase() { /// /// Max. request length in bytes @@ -36,28 +34,28 @@ public sealed class DiscoveryController(XmlResponseInstances responses, MemoryPo /// XML response MIME type /// private const string XML_MIME_TYPE = "application/xml"; + /// + /// EMailAddress node name + /// + private const string EMAIL_NODE_NAME = "EMailAddress"; /// - /// XPath request query + /// Missing email address message /// - private static readonly XPathExpression RequestQuery = XPathExpression.Compile("/*[local-name()='Autodiscover']/*[local-name()='Request']"); + private static readonly byte[] MissingEmailMessage = "Missing email address in request".GetBytes(); /// - /// XPath schema query + /// Invalid email address message /// - private static readonly XPathExpression SchemaQuery = XPathExpression.Compile("./*[local-name()='AcceptableResponseSchema']"); + private static readonly byte[] InvalidEmailMessage = "Invalid email address in request".GetBytes(); /// - /// XPath email query + /// Unknown domain name message /// - private static readonly XPathExpression EmailQuery = XPathExpression.Compile("./*[local-name()='EMailAddress']"); + private static readonly byte[] UnknownDomainMessage = "Unknown domain name".GetBytes(); /// /// Responses /// private readonly XmlResponseInstances Responses = responses; - /// - /// Stream pool - /// - private readonly MemoryPoolStreamPool StreamPool = streamPool; /// /// Autodiscover (POX request body required) @@ -66,50 +64,40 @@ public sealed class DiscoveryController(XmlResponseInstances responses, MemoryPo [HttpPost("autodiscover.xml"), Consumes(XML_MIME_TYPE, IsOptional = false), RequestSizeLimit(MAX_REQUEST_LEN), Produces(XML_MIME_TYPE)] public async Task AutoDiscoverAsync() { + // Validate the request and try getting the email address if (Logging.Trace) Logging.WriteTrace($"POX request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort}"); - // Validate the request and try getting the email address - XPathNavigator requestNavigator,// Whole request XML - requestNode,// Request node - acceptableResponseSchema,// AcceptableResponseSchema node - emailNode;// EMailAddress node - using (RentedObject rentedStream = new(StreamPool) + string? emailAddress = null;// Full email address + using (MemoryPoolStream ms = new()) { - Reset = true - }) - { - Stream requestBody = HttpContext.Request.Body; - await using (requestBody.DynamicContext()) - await requestBody.CopyToAsync(rentedStream.Object, bufferSize: MAX_REQUEST_LEN, HttpContext.RequestAborted).DynamicContext(); - rentedStream.Object.Position = 0; - try - { - requestNavigator = new XPathDocument(rentedStream.Object).CreateNavigator(); - } - catch (XmlException ex) + await HttpContext.Request.Body.CopyToAsync(ms, HttpContext.RequestAborted).DynamicContext(); + ms.Position = 0; + using XmlReader xmlRequest = XmlReader.Create(ms); + while (xmlRequest.Read()) { - throw new BadHttpRequestException("Invalid XML in request", ex); + if (xmlRequest.Name != EMAIL_NODE_NAME) continue; + emailAddress = xmlRequest.ReadElementContentAsString(); + break; } } - requestNode = requestNavigator.SelectSingleNode(RequestQuery) - ?? throw new BadHttpRequestException("Missing request node in request"); - acceptableResponseSchema = requestNode.SelectSingleNode(SchemaQuery) - ?? throw new BadHttpRequestException("Missing acceptable response schema node in request"); - emailNode = requestNode.SelectSingleNode(EmailQuery) - ?? throw new BadHttpRequestException("Missing email address node in request"); - if (acceptableResponseSchema.Value.Trim() != Constants.RESPONSE_NS) - throw new BadHttpRequestException("Unsupported acceptable response schema in request"); - string emailAddress = emailNode.Value.Trim().ToLower();// Full email address (lower case) + if(emailAddress is null) + { + await BadRequestAsync(MissingEmailMessage).DynamicContext(); + return; + } 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 in request"); + { + await BadRequestAsync(InvalidEmailMessage).DynamicContext(); + return; + } // Generate the response using XmlResponse xml = await Responses.GetOneAsync(HttpContext.RequestAborted).DynamicContext();// Response XML if (DomainConfig.GetConfig(HttpContext.Request.Host.Host, emailParts) is not DomainConfig config) { - await BadRequestAsync($"Unknown domain name {HttpContext.Request.Host.Host} / {emailParts[1]}".GetBytes()).DynamicContext(); + await BadRequestAsync(UnknownDomainMessage).DynamicContext(); return; } if (Logging.Trace) @@ -132,6 +120,8 @@ public async Task AutoDiscoverAsync() /// Message private async Task BadRequestAsync(ReadOnlyMemory 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; await HttpContext.Response.Body.WriteAsync(message, HttpContext.RequestAborted).DynamicContext(); diff --git a/src/wan24-AutoDiscover/Program.cs b/src/wan24-AutoDiscover/Program.cs index 6b8f7f9..f1b942a 100644 --- a/src/wan24-AutoDiscover/Program.cs +++ b/src/wan24-AutoDiscover/Program.cs @@ -129,7 +129,6 @@ async Task LoadConfigAsync() builder.Logging.AddSystemdConsole(); builder.Services.AddControllers(); builder.Services.AddSingleton(typeof(XmlResponseInstances), services => new XmlResponseInstances(capacity: DiscoveryConfig.Current.PreForkResponses)) - .AddSingleton(typeof(MemoryPoolStreamPool), services => new MemoryPoolStreamPool(capacity: DiscoveryConfig.Current.StreamPoolCapacity)) .AddSingleton(cts) .AddHostedService(services => services.GetRequiredService()) .AddHostedService(services => fsw) diff --git a/src/wan24-AutoDiscover/Services/MemoryPoolStreamPool.cs b/src/wan24-AutoDiscover/Services/MemoryPoolStreamPool.cs deleted file mode 100644 index c115be3..0000000 --- a/src/wan24-AutoDiscover/Services/MemoryPoolStreamPool.cs +++ /dev/null @@ -1,26 +0,0 @@ -using wan24.Core; - -namespace wan24.AutoDiscover.Services -{ - /// - /// pool - /// - public sealed class MemoryPoolStreamPool : DisposableObjectPool - { - /// - /// Constructor - /// - /// Capacity - public MemoryPoolStreamPool(in int capacity) - : base( - capacity, - () => - { - if (Logging.Trace) - Logging.WriteTrace("Creating a new memory pool stream"); - return new(); - } - ) - => ResetOnRent = false; - } -} diff --git a/src/wan24-AutoDiscover/appsettings.json b/src/wan24-AutoDiscover/appsettings.json index 6f350fb..16db4c6 100644 --- a/src/wan24-AutoDiscover/appsettings.json +++ b/src/wan24-AutoDiscover/appsettings.json @@ -17,7 +17,6 @@ "DiscoveryConfig": { "LogFile": null, "PreForkResponses": 10, - "StreamPoolCapacity": 10, "DiscoveryType": null, "KnownProxies": [], "EmailMappings": null, From 80bcb28109a35f703a8bb8edcd7238da071489d5 Mon Sep 17 00:00:00 2001 From: nd Date: Sat, 13 Apr 2024 07:52:27 +0200 Subject: [PATCH 14/14] Update --- .../wan24-AutoDiscover Shared.csproj | 2 +- .../Controllers/DiscoveryController.cs | 73 ++++++++++++------- src/wan24-AutoDiscover/Program.cs | 14 ++-- .../Services/ExceptionHandler.cs | 19 +++-- .../Services/XmlResponseInstances.cs | 11 --- .../XmlResponse.cs | 22 +++++- .../wan24-AutoDiscover.csproj | 4 +- 7 files changed, 87 insertions(+), 58 deletions(-) delete mode 100644 src/wan24-AutoDiscover/Services/XmlResponseInstances.cs rename src/{wan24-AutoDiscover Shared => wan24-AutoDiscover}/XmlResponse.cs (74%) diff --git a/src/wan24-AutoDiscover Shared/wan24-AutoDiscover Shared.csproj b/src/wan24-AutoDiscover Shared/wan24-AutoDiscover Shared.csproj index 14243c6..fab8a58 100644 --- a/src/wan24-AutoDiscover Shared/wan24-AutoDiscover Shared.csproj +++ b/src/wan24-AutoDiscover Shared/wan24-AutoDiscover Shared.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs b/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs index 4f24b8c..8187373 100644 --- a/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs +++ b/src/wan24-AutoDiscover/Controllers/DiscoveryController.cs @@ -16,7 +16,7 @@ namespace wan24.AutoDiscover.Controllers /// /// Responses [ApiController, Route("autodiscover")] - public sealed class DiscoveryController(XmlResponseInstances responses) : ControllerBase() + public sealed class DiscoveryController(InstancePool responses) : ControllerBase() { /// /// Max. request length in bytes @@ -40,22 +40,26 @@ public sealed class DiscoveryController(XmlResponseInstances responses) : Contro private const string EMAIL_NODE_NAME = "EMailAddress"; /// - /// Missing email address message + /// Invalid request XML message bytes /// - private static readonly byte[] MissingEmailMessage = "Missing email address in request".GetBytes(); + private static readonly byte[] InvalidRequestMessage = "Invalid Request XML".GetBytes(); /// - /// Invalid email address message + /// Missing email address message bytes /// - private static readonly byte[] InvalidEmailMessage = "Invalid email address in request".GetBytes(); + private static readonly byte[] MissingEmailMessage = "Missing Email Address".GetBytes(); /// - /// Unknown domain name message + /// Invalid email address message bytes /// - private static readonly byte[] UnknownDomainMessage = "Unknown domain name".GetBytes(); + private static readonly byte[] InvalidEmailMessage = "Invalid Email Address".GetBytes(); + /// + /// Unknown domain name message bytes + /// + private static readonly byte[] UnknownDomainMessage = "Unknown Domain Name".GetBytes(); /// /// Responses /// - private readonly XmlResponseInstances Responses = responses; + private readonly InstancePool Responses = responses; /// /// Autodiscover (POX request body required) @@ -72,17 +76,28 @@ public async Task AutoDiscoverAsync() { await HttpContext.Request.Body.CopyToAsync(ms, HttpContext.RequestAborted).DynamicContext(); ms.Position = 0; - using XmlReader xmlRequest = XmlReader.Create(ms); - while (xmlRequest.Read()) + try { - if (xmlRequest.Name != EMAIL_NODE_NAME) continue; - emailAddress = xmlRequest.ReadElementContentAsString(); - break; + 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) { - await BadRequestAsync(MissingEmailMessage).DynamicContext(); + RespondBadRequest(MissingEmailMessage); return; } if (Logging.Trace) @@ -90,41 +105,45 @@ public async Task AutoDiscoverAsync() string[] emailParts = emailAddress.Split('@', 2);// @ splitted email alias and domain name if (emailParts.Length != 2 || !MailAddress.TryCreate(emailAddress, out _)) { - await BadRequestAsync(InvalidEmailMessage).DynamicContext(); + RespondBadRequest(InvalidEmailMessage); return; } // Generate the response - using XmlResponse xml = await Responses.GetOneAsync(HttpContext.RequestAborted).DynamicContext();// Response XML if (DomainConfig.GetConfig(HttpContext.Request.Host.Host, emailParts) is not DomainConfig config) { - await BadRequestAsync(UnknownDomainMessage).DynamicContext(); + 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; - await HttpContext.Response.StartAsync(HttpContext.RequestAborted).DynamicContext(); - Task sendXmlOutput = xml.XmlOutput.CopyToAsync(HttpContext.Response.Body, HttpContext.RequestAborted); - config.CreateXml(xml.XML, emailParts); - xml.FinalizeXmlOutput(); - await sendXmlOutput.DynamicContext(); - await HttpContext.Response.CompleteAsync().DynamicContext(); - if (Logging.Trace) - Logging.WriteTrace($"POX response for \"{emailAddress}\" request from {HttpContext.Connection.RemoteIpAddress}:{HttpContext.Connection.RemotePort} sent"); + 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 async Task BadRequestAsync(ReadOnlyMemory 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; - await HttpContext.Response.Body.WriteAsync(message, HttpContext.RequestAborted).DynamicContext(); + HttpContext.Response.Body = new MemoryStream(message); } } } diff --git a/src/wan24-AutoDiscover/Program.cs b/src/wan24-AutoDiscover/Program.cs index f1b942a..bfc3988 100644 --- a/src/wan24-AutoDiscover/Program.cs +++ b/src/wan24-AutoDiscover/Program.cs @@ -45,7 +45,7 @@ async Task LoadConfigAsync() Logging.Logger ??= !string.IsNullOrWhiteSpace(DiscoveryConfig.Current.LogFile) ? await FileLogger.CreateAsync(DiscoveryConfig.Current.LogFile, next: new VividConsoleLogger(), cancellationToken: cts.Token).DynamicContext() : new VividConsoleLogger(); -Logging.WriteInfo($"wan24-AutoDiscover {VersionInfo.Current} Using configuration \"{configFile}\""); +Logging.WriteInfo($"wan24-AutoDiscover {VersionInfo.Current} using configuration \"{configFile}\""); // Watch configuration changes using SemaphoreSync configSync = new(); @@ -128,11 +128,11 @@ async Task LoadConfigAsync() if (ENV.IsLinux) builder.Logging.AddSystemdConsole(); builder.Services.AddControllers(); -builder.Services.AddSingleton(typeof(XmlResponseInstances), services => new XmlResponseInstances(capacity: DiscoveryConfig.Current.PreForkResponses)) +builder.Services.AddExceptionHandler() + .AddSingleton(typeof(InstancePool), services => new InstancePool(capacity: DiscoveryConfig.Current.PreForkResponses)) .AddSingleton(cts) - .AddHostedService(services => services.GetRequiredService()) + .AddHostedService(services => services.GetRequiredService>()) .AddHostedService(services => fsw) - .AddExceptionHandler() .AddHttpLogging(options => options.LoggingFields = HttpLoggingFields.RequestPropertiesAndHeaders); builder.Services.Configure(options => { @@ -150,7 +150,8 @@ async Task LoadConfigAsync() Logging.WriteInfo("Autodiscovery service app shutdown"); cts.Cancel(); }); - app.MapDefaultEndpoints(); + app.UseExceptionHandler(builder => { });// .NET 8 bugfix :( + app.MapDefaultEndpoints();// Aspire app.UseForwardedHeaders(); if (app.Environment.IsDevelopment()) { @@ -158,8 +159,7 @@ async Task LoadConfigAsync() 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"); diff --git a/src/wan24-AutoDiscover/Services/ExceptionHandler.cs b/src/wan24-AutoDiscover/Services/ExceptionHandler.cs index e72111a..9f6e037 100644 --- a/src/wan24-AutoDiscover/Services/ExceptionHandler.cs +++ b/src/wan24-AutoDiscover/Services/ExceptionHandler.cs @@ -22,14 +22,18 @@ public sealed class ExceptionHandler : IExceptionHandler /// 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(); + private static readonly byte[] InternalServerErrorMessage = "Internal Server Error".GetBytes(); /// /// Maintenance message bytes /// - private static readonly byte[] MaintenanceMessage = "Temporary not available".GetBytes(); + private static readonly byte[] MaintenanceMessage = "Temporary Not Available".GetBytes(); /// /// Constructor @@ -37,8 +41,9 @@ public sealed class ExceptionHandler : IExceptionHandler public ExceptionHandler() { } /// - public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + 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) @@ -46,7 +51,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e if (Logging.Trace) 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; - await httpContext.Response.BodyWriter.WriteAsync((badRequest.Message ?? "Bad request").GetBytes(), cancellationToken).DynamicContext(); + httpContext.Response.Body = new MemoryStream(badRequest.Message is null? BadRequestMessage : badRequest.Message.GetBytes()); } else if (exception is OperationCanceledException) { @@ -59,15 +64,15 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e 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; - await httpContext.Response.BodyWriter.WriteAsync(MaintenanceMessage, cancellationToken).DynamicContext(); + 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 = INTERNAL_SERVER_ERROR_STATUS_CODE; - await httpContext.Response.BodyWriter.WriteAsync(InternalServerErrorMessage, cancellationToken).DynamicContext(); + httpContext.Response.Body = new MemoryStream(InternalServerErrorMessage); } - return true; + return ValueTask.FromResult(true); } } } diff --git a/src/wan24-AutoDiscover/Services/XmlResponseInstances.cs b/src/wan24-AutoDiscover/Services/XmlResponseInstances.cs deleted file mode 100644 index 1db9a92..0000000 --- a/src/wan24-AutoDiscover/Services/XmlResponseInstances.cs +++ /dev/null @@ -1,11 +0,0 @@ -using wan24.Core; - -namespace wan24.AutoDiscover.Services -{ - /// - /// instances - /// - public sealed class XmlResponseInstances(in int capacity) : InstancePool(capacity) - { - } -} diff --git a/src/wan24-AutoDiscover Shared/XmlResponse.cs b/src/wan24-AutoDiscover/XmlResponse.cs similarity index 74% rename from src/wan24-AutoDiscover Shared/XmlResponse.cs rename to src/wan24-AutoDiscover/XmlResponse.cs index 2ea05be..101b011 100644 --- a/src/wan24-AutoDiscover Shared/XmlResponse.cs +++ b/src/wan24-AutoDiscover/XmlResponse.cs @@ -1,4 +1,5 @@ -using System.Xml; +using System.Text; +using System.Xml; using wan24.Core; namespace wan24.AutoDiscover @@ -8,16 +9,31 @@ namespace wan24.AutoDiscover /// 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(bufferSize: 1024) + XmlOutput = new(BUFFER_SIZE) { AggressiveReadBlocking = false }; - XML = XmlWriter.Create(XmlOutput); + 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); diff --git a/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj b/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj index dffd1a7..715b9b3 100644 --- a/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj +++ b/src/wan24-AutoDiscover/wan24-AutoDiscover.csproj @@ -20,8 +20,8 @@ - - + +