using Esiur.Protocol; using Esiur.Resource; using Esiur.Security.Authority; using Esiur.Security.Cryptography; using Microsoft.Extensions.Options; using System.Diagnostics.CodeAnalysis; namespace Esiur.AspNetCore; /// /// Configures the Esiur runtime hosted by ASP.NET Core. /// public sealed class EsiurOptions { /// The Warehouse owned by the ASP.NET Core host. public Warehouse Warehouse { get; set; } = new(); /// The EP server that accepts mapped WebSocket connections. public EpServer Server { get; set; } = new() { ExceptionLevel = Esiur.Core.ExceptionLevel.Code, }; /// /// Warehouse path used when has not already been added to the /// Warehouse. /// public string ServerPath { get; set; } = "sys/server"; /// /// Opens the Warehouse when the host starts and closes it when the host stops. /// Disable only when another component explicitly owns the Warehouse lifecycle. /// public bool ManageWarehouseLifecycle { get; set; } = true; /// /// Allows browser WebSocket requests from every Origin. Non-browser clients that do /// not send an Origin header are always allowed. /// public bool AllowAnyWebSocketOrigin { get; set; } /// /// Browser origins allowed to open an Esiur WebSocket. When this set is empty, only /// the request's own origin is allowed. Once configured, it becomes the complete /// browser-origin allowlist. /// public ISet AllowedWebSocketOrigins { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); /// /// Maximum copied outbound WebSocket data retained per connection while the peer is /// under backpressure. /// public long MaximumPendingWebSocketSendBytes { get; set; } = 16 * 1024 * 1024; /// /// Maximum copied, unsent WebSocket data retained across every connection hosted by /// this Esiur runtime. /// public long MaximumTotalPendingWebSocketSendBytes { get; set; } = 256L * 1024 * 1024; internal IList Resources { get; } = new List(); internal IList> AuthenticationProviders { get; } = new List>(); internal IList> EncryptionProviders { get; } = new List>(); internal IList> ServerConfigurations { get; } = new List>(); internal IList> WarehouseConfigurations { get; } = new List>(); } internal sealed record EsiurResourceRegistration( string Path, Func Factory, bool ReuseExistingStore); internal sealed class EsiurOptionsPostConfigure : IPostConfigureOptions { public void PostConfigure(string? name, EsiurOptions options) { if (EsiurConfigurationPath.TryNormalize(options.ServerPath, out var serverPath)) options.ServerPath = serverPath; if (options.Warehouse is not null) foreach (var configure in options.WarehouseConfigurations) configure(options.Warehouse.Configuration); if (options.Server is not null) { // ASP.NET hosts are remotely reachable by default. Supplied core servers can // opt back into messages/source/trace through ConfigureServer, but must not // accidentally carry the core diagnostic disclosure default into production. options.Server.ExceptionLevel &= Esiur.Core.ExceptionLevel.Code; foreach (var configure in options.ServerConfigurations) configure(options.Server); } } } internal sealed class EsiurOptionsValidator : IValidateOptions { public ValidateOptionsResult Validate(string? name, EsiurOptions options) { var failures = new List(); if (options.Warehouse is null) failures.Add("EsiurOptions.Warehouse is required."); if (options.Server is null) failures.Add("EsiurOptions.Server is required."); if (!EsiurConfigurationPath.TryNormalize(options.ServerPath, out var serverPath)) { failures.Add( "EsiurOptions.ServerPath must be a relative Warehouse path such as 'sys/server'."); } else if (!serverPath.Contains('/')) { failures.Add("EsiurOptions.ServerPath must place the server below a root store."); } var paths = new HashSet(StringComparer.Ordinal); foreach (var resource in options.Resources) { if (!EsiurConfigurationPath.TryNormalize(resource.Path, out var resourcePath)) { failures.Add($"The resource path '{resource.Path}' is invalid."); continue; } if (!paths.Add(resourcePath)) failures.Add($"The resource path '{resourcePath}' is registered more than once."); } if (serverPath is not null && paths.Contains(serverPath)) failures.Add($"The server path '{serverPath}' is also used by another resource."); if (serverPath is not null && options.Server?.Instance is null) { var rootPath = serverPath.Split('/')[0]; var existingStore = options.Warehouse?.GetStore(rootPath); if (existingStore is null && !paths.Contains(rootPath)) { failures.Add( $"ServerPath '{serverPath}' requires a root store. " + $"Call AddMemoryStore(\"{rootPath}\") or supply a Warehouse containing it."); } } if (options.Server?.Instance is { } serverInstance && options.Warehouse is not null && !ReferenceEquals(serverInstance.Warehouse, options.Warehouse)) { failures.Add("The supplied EpServer belongs to a different Warehouse."); } if (options.Server?.IsRunning == true) { failures.Add( "The supplied EpServer is already listening. Stop it before handing it to ASP.NET Core."); } if (!options.ManageWarehouseLifecycle && (options.Server?.Instance is null || options.Resources.Count > 0)) { failures.Add( "When ManageWarehouseLifecycle is false, attach and initialize the server and " + "resources before registering Esiur with ASP.NET Core."); } if (options.Server?.AllowedAuthenticationProviders is null) failures.Add("EpServer.AllowedAuthenticationProviders cannot be null."); if (options.Server?.AllowedEncryptionProviders is null) failures.Add("EpServer.AllowedEncryptionProviders cannot be null."); if (options.Server is { } authenticationServer && options.AuthenticationProviders.Count == 0 && (authenticationServer.AllowedAuthenticationProviders?.Length ?? 0) == 0) { if (!authenticationServer.AllowUnauthorizedAccess) { failures.Add( "Authentication is required, but no authentication provider is configured. " + "Call UseAuthentication(...) or explicitly enable anonymous access."); } else if (authenticationServer.RequireEncryption) { failures.Add( "Encrypted EP sessions require authentication, but no authentication " + "provider is configured. Call UseAuthentication(...)."); } } if (options.Server is { RequireEncryption: true } encryptionServer && options.EncryptionProviders.Count == 0 && (encryptionServer.AllowedEncryptionProviders?.Length ?? 0) == 0) { failures.Add( "Encryption is required, but no encryption provider is configured. " + "Call UseEncryption(...)."); } if (options.Server is { AuthenticationTimeout: var timeout } && timeout <= TimeSpan.Zero) { failures.Add("EpServer.AuthenticationTimeout must be greater than zero."); } if (options.MaximumPendingWebSocketSendBytes <= 0) { failures.Add( "EsiurOptions.MaximumPendingWebSocketSendBytes must be greater than zero."); } if (options.MaximumTotalPendingWebSocketSendBytes <= 0) { failures.Add( "EsiurOptions.MaximumTotalPendingWebSocketSendBytes must be greater than zero."); } else if (options.MaximumPendingWebSocketSendBytes > options.MaximumTotalPendingWebSocketSendBytes) { failures.Add( "MaximumPendingWebSocketSendBytes cannot exceed the host-wide send limit."); } ValidateWarehouseConfiguration(options.Warehouse?.Configuration, failures); if (options.AllowAnyWebSocketOrigin && options.AllowedWebSocketOrigins.Count > 0) { failures.Add( "Configure either AllowAnyWebSocketOrigin or AllowedWebSocketOrigins, not both."); } foreach (var origin in options.AllowedWebSocketOrigins) { if (!EsiurOrigin.TryNormalize(origin, out _)) failures.Add($"The WebSocket origin '{origin}' is not a valid HTTP(S) origin."); } return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); } private static void ValidateWarehouseConfiguration( WarehouseConfiguration? configuration, List failures) { if (configuration is null) { failures.Add("Warehouse.Configuration is required."); return; } if (configuration.Connections is null) { failures.Add("Warehouse.Configuration.Connections is required."); } else { var connections = configuration.Connections; if (connections.MaximumConnections < 0) failures.Add("MaximumConnections cannot be negative."); if (connections.MaximumConnectionsPerIpAddress < 0) failures.Add("MaximumConnectionsPerIpAddress cannot be negative."); if (connections.MaximumConnectionAttempts < 0) failures.Add("MaximumConnectionAttempts cannot be negative."); if (connections.MaximumConnectionAttemptsPerIpAddress < 0) failures.Add("MaximumConnectionAttemptsPerIpAddress cannot be negative."); if ((connections.MaximumConnectionAttempts > 0 || connections.MaximumConnectionAttemptsPerIpAddress > 0) && connections.ConnectionAttemptWindow <= TimeSpan.Zero) { failures.Add( "ConnectionAttemptWindow must be greater than zero when attempt limiting is enabled."); } } if (configuration.Parser is null) failures.Add("Warehouse.Configuration.Parser is required."); else { if (configuration.Parser.MaximumCollectionItems < 0) failures.Add("MaximumCollectionItems cannot be negative."); if (configuration.Parser.MaximumTypeMetadataDepth < 0) failures.Add("MaximumTypeMetadataDepth cannot be negative."); } if (configuration.ResourceAttachments is null) failures.Add("Warehouse.Configuration.ResourceAttachments is required."); else { if (configuration.ResourceAttachments.MaximumAttachedResourcesPerConnection < 0) failures.Add("MaximumAttachedResourcesPerConnection cannot be negative."); if (configuration.ResourceAttachments.MaximumPendingAttachmentsPerConnection < 0) failures.Add("MaximumPendingAttachmentsPerConnection cannot be negative."); } if (configuration.Encryption is null) failures.Add("Warehouse.Configuration.Encryption is required."); if (configuration.RateControl is null) failures.Add("Warehouse.Configuration.RateControl is required."); else { if (configuration.RateControl.DenialsBeforeConnectionBlock < 0) failures.Add("DenialsBeforeConnectionBlock cannot be negative."); if (configuration.RateControl.DenialsBeforeConnectionBlock > 0 && configuration.RateControl.DenialWindow <= TimeSpan.Zero) failures.Add("DenialWindow must be greater than zero when blocking is enabled."); if (configuration.RateControl.ConnectionBlockDelay < TimeSpan.Zero) failures.Add("ConnectionBlockDelay cannot be negative."); } } } internal static class EsiurConfigurationPath { public static string Normalize(string path) { if (!TryNormalize(path, out var normalized)) throw new ArgumentException( "A relative Warehouse path without empty, '.' or '..' segments is required.", nameof(path)); return normalized; } public static bool TryNormalize( string? path, [NotNullWhen(true)] out string? normalized) { normalized = null; if (string.IsNullOrWhiteSpace(path)) return false; var candidate = path.Trim().Trim('/'); if (candidate.Length == 0 || candidate.Contains('\\') || candidate.Contains('?') || candidate.Contains('#')) return false; var segments = candidate.Split('/'); if (segments.Any(segment => string.IsNullOrWhiteSpace(segment) || segment is "." or "..")) return false; normalized = string.Join('/', segments); return true; } } internal static class EsiurOrigin { public static string Normalize(string origin) { if (!TryNormalize(origin, out var normalized)) throw new ArgumentException("A valid HTTP(S) origin is required.", nameof(origin)); return normalized; } public static bool TryNormalize( string? origin, [NotNullWhen(true)] out string? normalized) { normalized = null; if (string.IsNullOrWhiteSpace(origin) || !Uri.TryCreate(origin, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) || string.IsNullOrEmpty(uri.Host) || uri.UserInfo.Length != 0 || (uri.AbsolutePath != "/" && uri.AbsolutePath.Length != 0) || uri.Query.Length != 0 || uri.Fragment.Length != 0) return false; normalized = uri.GetComponents(UriComponents.SchemeAndServer, UriFormat.UriEscaped); return true; } }