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;
}
}