mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
ASP.Net
This commit is contained in:
@@ -1,36 +1,393 @@
|
||||
/*
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017-2024 Esiur Foundation, Ahmed Kh. Zamil.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
*/
|
||||
|
||||
|
||||
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
|
||||
namespace Esiur.AspNetCore;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the Esiur runtime hosted by ASP.NET Core.
|
||||
/// </summary>
|
||||
public sealed class EsiurOptions
|
||||
{
|
||||
public class EsiurOptions
|
||||
/// <summary>The Warehouse owned by the ASP.NET Core host.</summary>
|
||||
public Warehouse Warehouse { get; set; } = new();
|
||||
|
||||
/// <summary>The EP server that accepts mapped WebSocket connections.</summary>
|
||||
public EpServer Server { get; set; } = new()
|
||||
{
|
||||
public required EpServer Server { get; set; }
|
||||
ExceptionLevel = Esiur.Core.ExceptionLevel.Code,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Warehouse path used when <see cref="Server"/> has not already been added to the
|
||||
/// Warehouse.
|
||||
/// </summary>
|
||||
public string ServerPath { get; set; } = "sys/server";
|
||||
|
||||
/// <summary>
|
||||
/// Opens the Warehouse when the host starts and closes it when the host stops.
|
||||
/// Disable only when another component explicitly owns the Warehouse lifecycle.
|
||||
/// </summary>
|
||||
public bool ManageWarehouseLifecycle { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Allows browser WebSocket requests from every Origin. Non-browser clients that do
|
||||
/// not send an Origin header are always allowed.
|
||||
/// </summary>
|
||||
public bool AllowAnyWebSocketOrigin { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public ISet<string> AllowedWebSocketOrigins { get; } =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum copied outbound WebSocket data retained per connection while the peer is
|
||||
/// under backpressure.
|
||||
/// </summary>
|
||||
public long MaximumPendingWebSocketSendBytes { get; set; } = 16 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum copied, unsent WebSocket data retained across every connection hosted by
|
||||
/// this Esiur runtime.
|
||||
/// </summary>
|
||||
public long MaximumTotalPendingWebSocketSendBytes { get; set; } = 256L * 1024 * 1024;
|
||||
|
||||
internal IList<EsiurResourceRegistration> Resources { get; } =
|
||||
new List<EsiurResourceRegistration>();
|
||||
|
||||
internal IList<Func<IServiceProvider, IAuthenticationProvider>>
|
||||
AuthenticationProviders { get; } =
|
||||
new List<Func<IServiceProvider, IAuthenticationProvider>>();
|
||||
|
||||
internal IList<Func<IServiceProvider, IEncryptionProvider>>
|
||||
EncryptionProviders { get; } =
|
||||
new List<Func<IServiceProvider, IEncryptionProvider>>();
|
||||
|
||||
internal IList<Action<EpServer>> ServerConfigurations { get; } =
|
||||
new List<Action<EpServer>>();
|
||||
|
||||
internal IList<Action<WarehouseConfiguration>> WarehouseConfigurations { get; } =
|
||||
new List<Action<WarehouseConfiguration>>();
|
||||
}
|
||||
|
||||
internal sealed record EsiurResourceRegistration(
|
||||
string Path,
|
||||
Func<IServiceProvider, IResource> Factory,
|
||||
bool ReuseExistingStore);
|
||||
|
||||
internal sealed class EsiurOptionsPostConfigure : IPostConfigureOptions<EsiurOptions>
|
||||
{
|
||||
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<EsiurOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, EsiurOptions options)
|
||||
{
|
||||
var failures = new List<string>();
|
||||
|
||||
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<string>(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<string> 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user