This commit is contained in:
2026-07-17 02:19:05 +03:00
parent 4cd9f928ff
commit dbd5715578
42 changed files with 6000 additions and 866 deletions
@@ -0,0 +1,110 @@
using Esiur.Core;
using Esiur.Resource;
using Esiur.Security.Authority;
using Esiur.Security.Authority.Providers;
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace Esiur.AspNetCore;
internal sealed class CallbackPasswordAuthenticationProvider
: PasswordAuthenticationProvider
{
private const int CredentialLength = 32;
private const int SaltLength = 32;
private const int MaximumIdentityBytes = 512;
private const int MaximumDomainBytes = 512;
private readonly Func<string, string, PasswordHash?> findCredential;
private readonly byte[] dummyVerifier =
RandomNumberGenerator.GetBytes(CredentialLength);
private readonly byte[] dummySalt =
RandomNumberGenerator.GetBytes(SaltLength);
public CallbackPasswordAuthenticationProvider(
Func<string, string, PasswordHash?> findCredential)
{
this.findCredential = findCredential;
}
public override PasswordHash GetHostedAccountCredential(
string identity,
string domain)
{
identity ??= string.Empty;
domain ??= string.Empty;
// Match PPAP's identity bounds before invoking application code or allocating
// dummy-HMAC input for attacker-controlled handshake values.
if (Encoding.UTF8.GetByteCount(identity) > MaximumIdentityBytes
|| Encoding.UTF8.GetByteCount(domain) > MaximumDomainBytes)
return default;
var credential = findCredential(identity, domain);
if (credential is null)
return CreateDummyCredential(identity, domain);
if (credential.Value.Hash is null || credential.Value.Salt is null)
{
throw new InvalidOperationException(
"A password credential must contain both a hash and a salt.");
}
if (credential.Value.Hash.Length != CredentialLength
|| credential.Value.Salt.Length != SaltLength)
{
throw new InvalidOperationException(
$"A {ProtocolName} credential must contain a "
+ $"{CredentialLength}-byte hash and a {SaltLength}-byte salt.");
}
return new PasswordHash(
(byte[])credential.Value.Hash.Clone(),
(byte[])credential.Value.Salt.Clone());
}
private PasswordHash CreateDummyCredential(string identity, string domain)
{
// A single dummy salt shared by every missing account would itself identify
// unknown users. Derive account-shaped material from provider-local random
// secrets instead: the result is stable for retries of the same identity,
// unlinkable across identities, and never aliases the provider-held secrets.
var context = EncodeIdentity(identity, domain);
try
{
return new PasswordHash(
HMACSHA256.HashData(dummyVerifier, context),
HMACSHA256.HashData(dummySalt, context));
}
finally
{
CryptographicOperations.ZeroMemory(context);
}
}
private static byte[] EncodeIdentity(string identity, string domain)
{
var identityBytes = Encoding.UTF8.GetBytes(identity ?? string.Empty);
var domainBytes = Encoding.UTF8.GetBytes(domain ?? string.Empty);
var context = new byte[
sizeof(int) + domainBytes.Length + sizeof(int) + identityBytes.Length];
BinaryPrimitives.WriteInt32LittleEndian(
context.AsSpan(0, sizeof(int)),
domainBytes.Length);
domainBytes.CopyTo(context, sizeof(int));
var identityLengthOffset = sizeof(int) + domainBytes.Length;
BinaryPrimitives.WriteInt32LittleEndian(
context.AsSpan(identityLengthOffset, sizeof(int)),
identityBytes.Length);
identityBytes.CopyTo(context, identityLengthOffset + sizeof(int));
return context;
}
public override AsyncReply<bool> Login(Session session) => new(true);
public override AsyncReply<bool> Logout(Session session) => new(true);
}
@@ -1,34 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>Esiur distributed resource framework support for ASP.Net</Description>
<Description>ASP.NET Core hosting and endpoint routing for the Esiur distributed resource framework</Description>
<Copyright>Ahmed Kh. Zamil</Copyright>
<PackageProjectUrl>http://www.esiur.com</PackageProjectUrl>
<PackageProjectUrl>https://www.esiur.com</PackageProjectUrl>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<Version>1.0.0</Version>
<Version>3.0.0</Version>
<Authors>Ahmed Kh. Zamil</Authors>
<Company>Esiur Foundation</Company>
<PackageProjectUrl>http://www.esiur.com</PackageProjectUrl>
<RepositoryUrl>https://github.com/esiur/esiur-dotnet/</RepositoryUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Title>Esiur ASP.Net Middleware</Title>
<PathMap>$(MSBuildProjectDirectory)=/_/Esiur.AspNetCore</PathMap>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Title>Esiur for ASP.NET Core</Title>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Libraries\Esiur\Esiur.csproj" />
<ProjectReference Include="..\..\Libraries\Esiur\Esiur.csproj"
IncludeAssets="all"
PrivateAssets="none" />
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
@@ -0,0 +1,282 @@
using Esiur.Protocol;
using Esiur.Resource;
using Esiur.Security.Authority;
using Esiur.Security.Authority.Providers;
using Esiur.Security.Cryptography;
using Esiur.Stores;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Esiur.AspNetCore;
/// <summary>
/// Fluent registration surface for an Esiur runtime hosted by ASP.NET Core.
/// </summary>
public sealed class EsiurBuilder
{
internal EsiurBuilder(IServiceCollection services) => Services = services;
/// <summary>The application service collection.</summary>
public IServiceCollection Services { get; }
/// <summary>Uses an existing Warehouse. The ASP.NET host owns its lifecycle by default.</summary>
public EsiurBuilder UseWarehouse(Warehouse warehouse, bool manageLifecycle = true)
{
ArgumentNullException.ThrowIfNull(warehouse);
Services.Configure<EsiurOptions>(options =>
{
options.Warehouse = warehouse;
options.ManageWarehouseLifecycle = manageLifecycle;
});
return this;
}
/// <summary>
/// Uses an existing EP server. <paramref name="serverPath"/> is used only when the
/// server is not already attached to the Warehouse.
/// </summary>
public EsiurBuilder UseServer(EpServer server, string serverPath = "sys/server")
{
ArgumentNullException.ThrowIfNull(server);
var path = EsiurConfigurationPath.Normalize(serverPath);
Services.Configure<EsiurOptions>(options =>
{
options.Server = server;
options.ServerPath = path;
});
return this;
}
/// <summary>Configures the final EP server, including externally supplied servers.</summary>
public EsiurBuilder ConfigureServer(Action<EpServer> configure)
{
ArgumentNullException.ThrowIfNull(configure);
Services.Configure<EsiurOptions>(options => options.ServerConfigurations.Add(configure));
return this;
}
/// <summary>
/// Explicitly allows unauthenticated EP sessions. Use this only for resources whose
/// authorization policy is safe for anonymous callers.
/// </summary>
public EsiurBuilder AllowAnonymous()
{
Services.Configure<EsiurOptions>(options =>
options.ServerConfigurations.Add(server =>
server.AllowUnauthorizedAccess = true));
return this;
}
/// <summary>Rejects EP sessions that do not negotiate authenticated encryption.</summary>
public EsiurBuilder RequireEncryption()
{
Services.Configure<EsiurOptions>(options =>
options.ServerConfigurations.Add(server => server.RequireEncryption = true));
return this;
}
/// <summary>
/// Includes remote exception messages in addition to stable error codes. Prefer the
/// code-only default in production because messages can contain application details.
/// </summary>
public EsiurBuilder IncludeExceptionMessages()
{
Services.Configure<EsiurOptions>(options =>
options.ServerConfigurations.Add(server =>
server.ExceptionLevel |= Esiur.Core.ExceptionLevel.Code
| Esiur.Core.ExceptionLevel.Message));
return this;
}
/// <summary>Limits copied outbound WebSocket data retained per connection.</summary>
public EsiurBuilder LimitPendingWebSocketSendBytes(long maximumBytes)
{
if (maximumBytes <= 0)
throw new ArgumentOutOfRangeException(nameof(maximumBytes));
Services.Configure<EsiurOptions>(options =>
options.MaximumPendingWebSocketSendBytes = maximumBytes);
return this;
}
/// <summary>Limits copied outbound WebSocket data retained by the whole Esiur host.</summary>
public EsiurBuilder LimitTotalPendingWebSocketSendBytes(long maximumBytes)
{
if (maximumBytes <= 0)
throw new ArgumentOutOfRangeException(nameof(maximumBytes));
Services.Configure<EsiurOptions>(options =>
options.MaximumTotalPendingWebSocketSendBytes = maximumBytes);
return this;
}
/// <summary>Configures the final Warehouse runtime limits.</summary>
public EsiurBuilder ConfigureWarehouse(Action<WarehouseConfiguration> configure)
{
ArgumentNullException.ThrowIfNull(configure);
Services.Configure<EsiurOptions>(options => options.WarehouseConfigurations.Add(configure));
return this;
}
/// <summary>Adds an in-memory root store.</summary>
public EsiurBuilder AddMemoryStore(string path = "sys")
=> AddResourceRegistration(
path,
_ => new MemoryStore(),
reuseExistingStore: true);
/// <summary>Adds a resource constructed through ASP.NET Core dependency injection.</summary>
public EsiurBuilder AddResource<TResource>(string path)
where TResource : class, IResource
=> AddResourceRegistration(
path,
services => ActivatorUtilities.CreateInstance<TResource>(services),
reuseExistingStore: false);
/// <summary>Adds an existing resource instance.</summary>
public EsiurBuilder AddResource<TResource>(string path, TResource resource)
where TResource : class, IResource
{
ArgumentNullException.ThrowIfNull(resource);
return AddResourceRegistration(path, _ => resource, reuseExistingStore: false);
}
/// <summary>Adds a resource created by an application service factory.</summary>
public EsiurBuilder AddResource<TResource>(
string path,
Func<IServiceProvider, TResource> factory)
where TResource : class, IResource
{
ArgumentNullException.ThrowIfNull(factory);
return AddResourceRegistration(
path,
services => factory(services)
?? throw new InvalidOperationException(
$"The resource factory for '{path}' returned null."),
reuseExistingStore: false);
}
/// <summary>
/// Registers and allows an authentication provider under its default protocol name.
/// </summary>
public EsiurBuilder UseAuthentication(IAuthenticationProvider provider)
{
ArgumentNullException.ThrowIfNull(provider);
return UseAuthentication(_ => provider);
}
/// <summary>
/// Registers and allows a dependency-injected authentication provider.
/// </summary>
public EsiurBuilder UseAuthentication<TProvider>()
where TProvider : class, IAuthenticationProvider
{
Services.TryAddSingleton<TProvider>();
return UseAuthentication(
services => services.GetRequiredService<TProvider>());
}
/// <summary>
/// Registers and allows an authentication provider factory under the provider's
/// default protocol name.
/// </summary>
public EsiurBuilder UseAuthentication(
Func<IServiceProvider, IAuthenticationProvider> factory)
{
ArgumentNullException.ThrowIfNull(factory);
Services.Configure<EsiurOptions>(options =>
options.AuthenticationProviders.Add(factory));
return this;
}
/// <summary>
/// Adds server-side password authentication without requiring a custom provider
/// class. The synchronous lookup should read a cached, precomputed Esiur password
/// credential and return <see langword="null"/> for an unknown account.
/// </summary>
public EsiurBuilder UsePasswordAuthentication(
Func<string, string, PasswordHash?> findCredential)
{
ArgumentNullException.ThrowIfNull(findCredential);
return UsePasswordAuthentication(
(_, identity, domain) => findCredential(identity, domain));
}
/// <summary>
/// Adds server-side password authentication with access to the application service
/// provider. Resolve only singleton/cached services because authentication providers
/// are host-scoped and credential lookup is synchronous.
/// </summary>
public EsiurBuilder UsePasswordAuthentication(
Func<IServiceProvider, string, string, PasswordHash?> findCredential)
{
ArgumentNullException.ThrowIfNull(findCredential);
return UseAuthentication(services =>
new CallbackPasswordAuthenticationProvider(
(identity, domain) => findCredential(services, identity, domain)));
}
/// <summary>Registers and allows an encryption provider under its default name.</summary>
public EsiurBuilder UseEncryption(IEncryptionProvider provider)
{
ArgumentNullException.ThrowIfNull(provider);
return UseEncryption(_ => provider);
}
/// <summary>Registers and allows a dependency-injected encryption provider.</summary>
public EsiurBuilder UseEncryption<TProvider>()
where TProvider : class, IEncryptionProvider
{
Services.TryAddSingleton<TProvider>();
return UseEncryption(
services => services.GetRequiredService<TProvider>());
}
/// <summary>
/// Registers and allows an encryption provider factory under the provider's default
/// protocol name.
/// </summary>
public EsiurBuilder UseEncryption(
Func<IServiceProvider, IEncryptionProvider> factory)
{
ArgumentNullException.ThrowIfNull(factory);
Services.Configure<EsiurOptions>(options =>
options.EncryptionProviders.Add(factory));
return this;
}
/// <summary>Allows browser WebSocket requests from the specified origins.</summary>
public EsiurBuilder AllowWebSocketOrigins(params string[] origins)
{
ArgumentNullException.ThrowIfNull(origins);
if (origins.Length == 0)
throw new ArgumentException("At least one origin is required.", nameof(origins));
var normalized = origins.Select(EsiurOrigin.Normalize).ToArray();
Services.Configure<EsiurOptions>(options =>
{
foreach (var origin in normalized)
options.AllowedWebSocketOrigins.Add(origin);
});
return this;
}
/// <summary>Allows browser WebSocket requests from every origin.</summary>
public EsiurBuilder AllowAnyWebSocketOrigin()
{
Services.Configure<EsiurOptions>(options => options.AllowAnyWebSocketOrigin = true);
return this;
}
private EsiurBuilder AddResourceRegistration(
string path,
Func<IServiceProvider, IResource> factory,
bool reuseExistingStore)
{
var normalizedPath = EsiurConfigurationPath.Normalize(path);
Services.Configure<EsiurOptions>(options =>
options.Resources.Add(
new EsiurResourceRegistration(normalizedPath, factory, reuseExistingStore)));
return this;
}
}
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace Esiur.AspNetCore;
/// <summary>Maps Esiur WebSocket endpoints into ASP.NET Core endpoint routing.</summary>
public static class EsiurEndpointRouteBuilderExtensions
{
/// <summary>
/// Maps an EP WebSocket endpoint. The returned builder supports ASP.NET Core endpoint
/// conventions such as authorization and rate limiting.
/// </summary>
public static IEndpointConventionBuilder MapEsiur(
this IEndpointRouteBuilder endpoints,
string pattern = "/esiur")
{
ArgumentNullException.ThrowIfNull(endpoints);
if (string.IsNullOrWhiteSpace(pattern))
throw new ArgumentException("An endpoint route pattern is required.", nameof(pattern));
return endpoints.Map(
pattern,
context => context.RequestServices
.GetRequiredService<EsiurWebSocketEndpoint>()
.HandleAsync(context))
.WithDisplayName("Esiur EP WebSocket");
}
}
@@ -0,0 +1,480 @@
using Esiur.Protocol;
using Esiur.Core;
using Esiur.Resource;
using Esiur.Security.Authority;
using Esiur.Security.Cryptography;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Esiur.AspNetCore;
internal sealed class EsiurHostedService : IHostedService
{
private readonly EsiurRuntime runtime;
private readonly IServiceProvider services;
private readonly IHostApplicationLifetime applicationLifetime;
private readonly ILogger<EsiurHostedService> logger;
private readonly TimeSpan shutdownTimeout;
private readonly List<IResource> attachedResources = new();
private readonly List<(string Name, IAuthenticationProvider Provider)>
registeredAuthenticationProviders = new();
private readonly List<(string Name, IEncryptionProvider Provider)>
registeredEncryptionProviders = new();
private CancellationTokenRegistration stoppingRegistration;
private bool lifecycleStarted;
private bool serverStateCaptured;
private bool originalEnableTcpListener;
private string[] originalAllowedAuthenticationProviders = Array.Empty<string>();
private string[] originalAllowedEncryptionProviders = Array.Empty<string>();
public EsiurHostedService(
EsiurRuntime runtime,
IServiceProvider services,
IHostApplicationLifetime applicationLifetime,
IOptions<HostOptions> hostOptions,
ILogger<EsiurHostedService> logger)
{
this.runtime = runtime;
this.services = services;
this.applicationLifetime = applicationLifetime;
shutdownTimeout = hostOptions.Value.ShutdownTimeout;
this.logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
var options = runtime.Options;
var warehouse = runtime.Warehouse;
var server = runtime.Server;
cancellationToken.ThrowIfCancellationRequested();
if (!options.ManageWarehouseLifecycle && !warehouse.IsOpen)
throw new InvalidOperationException(
"The externally managed Warehouse must be open before Esiur starts.");
if (options.ManageWarehouseLifecycle && warehouse.IsOpen)
throw new InvalidOperationException(
"The Warehouse is already open. Use manageLifecycle: false when another component owns it.");
try
{
CaptureServerState(server);
// An ASP.NET endpoint owns the transport. The EP resource still owns admission,
// authentication and all protocol state, but it must not expose a second TCP port.
server.EnableTcpListener = false;
RegisterAuthenticationProviders(
options,
warehouse,
server,
registeredAuthenticationProviders);
RegisterEncryptionProviders(
options,
warehouse,
server,
registeredEncryptionProviders);
ValidateAllowedProviders(warehouse, server);
foreach (var registration in options.Resources
.OrderBy(resource => resource.Path.Count(character => character == '/')))
{
cancellationToken.ThrowIfCancellationRequested();
if (registration.ReuseExistingStore
&& !registration.Path.Contains('/')
&& warehouse.GetStore(registration.Path) is not null)
continue;
var resource = registration.Factory(services)
?? throw new InvalidOperationException(
$"The resource factory for '{registration.Path}' returned null.");
if (resource.Instance is not null)
{
if (ReferenceEquals(resource.Instance.Warehouse, warehouse)
&& string.Equals(
resource.Instance.Link,
registration.Path,
StringComparison.Ordinal))
continue;
throw new InvalidOperationException(
$"The resource for '{registration.Path}' is already attached to a Warehouse.");
}
await warehouse.Put(registration.Path, resource);
attachedResources.Add(resource);
}
if (server.Instance is null)
{
await warehouse.Put(options.ServerPath, server);
attachedResources.Add(server);
}
else if (!ReferenceEquals(server.Instance.Warehouse, warehouse))
{
throw new InvalidOperationException(
"The configured EpServer belongs to a different Warehouse.");
}
if (options.ManageWarehouseLifecycle)
{
cancellationToken.ThrowIfCancellationRequested();
// Mark ownership immediately before Open so startup rollback closes a
// partially initialized Warehouse, but provider/resource validation
// failures before Open do not terminate an unopened user graph.
lifecycleStarted = true;
if (!await warehouse.Open())
throw new InvalidOperationException(
"The Warehouse is already open. Use manageLifecycle: false when another component owns it.");
cancellationToken.ThrowIfCancellationRequested();
}
stoppingRegistration = applicationLifetime.ApplicationStopping.Register(
static state =>
{
var hostedService = (EsiurHostedService)state!;
hostedService.runtime.StopTransport();
},
this);
if (!runtime.TryMarkReady(applicationLifetime.ApplicationStopping))
throw new OperationCanceledException(
"The ASP.NET Core host stopped while Esiur was starting.",
applicationLifetime.ApplicationStopping);
logger.LogInformation(
"Esiur is ready on mapped ASP.NET Core WebSocket endpoints using Warehouse server {ServerPath}.",
server.Instance?.Link ?? options.ServerPath);
}
catch
{
stoppingRegistration.Dispose();
runtime.StopTransport();
var cleanupFinished = !options.ManageWarehouseLifecycle || !lifecycleStarted;
if (options.ManageWarehouseLifecycle && lifecycleStarted)
{
try
{
// Startup cancellation must not cancel rollback immediately. Give
// partially initialized resources an independent, bounded window
// to terminate before detaching them.
using var cleanupCancellation = new CancellationTokenSource();
cleanupCancellation.CancelAfter(shutdownTimeout);
await CloseWarehouseAsync(warehouse, cleanupCancellation.Token);
cleanupFinished = true;
}
catch (OperationCanceledException)
{
logger.LogError(
"Esiur startup rollback exceeded the host shutdown timeout; " +
"resources remain attached to avoid racing their termination callbacks.");
}
catch (Exception cleanupException)
{
// Warehouse.Close completed with an error and released its lifecycle
// gate, so registrations can still be detached safely.
cleanupFinished = true;
logger.LogError(
cleanupException,
"Esiur failed to clean up after a startup failure.");
}
}
if (cleanupFinished)
{
lifecycleStarted = false;
CleanupHostRegistrations(warehouse, "a startup failure");
}
throw;
}
}
public async Task StopAsync(CancellationToken cancellationToken)
{
stoppingRegistration.Dispose();
// ApplicationStopping normally initiates this before Kestrel waits for mapped
// WebSocket requests. Calling it again keeps direct hosted-service shutdown safe.
runtime.StopTransport();
var cleanupFinished = !runtime.Options.ManageWarehouseLifecycle || !lifecycleStarted;
OperationCanceledException? shutdownCancellation = null;
try
{
await runtime.DrainTransportAsync(cancellationToken);
}
catch (OperationCanceledException exception)
when (cancellationToken.IsCancellationRequested)
{
shutdownCancellation = exception;
}
catch (Exception exception)
{
// Transport errors are already observed per socket. Continue Warehouse
// termination even if an unexpected adapter failure escaped the drain.
logger.LogError(exception, "Esiur transport shutdown failed.");
}
if (runtime.Options.ManageWarehouseLifecycle && lifecycleStarted)
{
var closeTask = CloseWarehouseAsync(runtime.Warehouse);
if (shutdownCancellation is null)
{
try
{
await closeTask.WaitAsync(cancellationToken);
cleanupFinished = true;
}
catch (OperationCanceledException exception)
when (cancellationToken.IsCancellationRequested)
{
shutdownCancellation = exception;
}
catch (Exception exception)
{
// Warehouse.Close settles every resource before propagating errors.
cleanupFinished = true;
logger.LogError(exception, "Esiur failed to shut down cleanly.");
}
}
if (shutdownCancellation is not null && !cleanupFinished)
{
// The host token no longer provides cleanup time. Continue the already
// started close under an independent bounded deadline so cancellation
// cannot strand a live Warehouse/provider graph.
using var cleanupCancellation = new CancellationTokenSource();
cleanupCancellation.CancelAfter(shutdownTimeout);
try
{
await closeTask.WaitAsync(cleanupCancellation.Token);
cleanupFinished = true;
}
catch (OperationCanceledException)
when (cleanupCancellation.IsCancellationRequested)
{
logger.LogError(
"Esiur Warehouse cleanup exceeded the independent shutdown timeout; " +
"registrations remain attached until termination settles.");
}
catch (Exception exception)
{
cleanupFinished = true;
logger.LogError(exception, "Esiur failed to shut down cleanly.");
}
}
}
if (cleanupFinished)
{
lifecycleStarted = false;
CleanupHostRegistrations(runtime.Warehouse, "host shutdown");
}
if (shutdownCancellation is not null)
{
logger.LogWarning(
"Esiur transport or Warehouse shutdown exceeded the host shutdown deadline.");
throw shutdownCancellation;
}
}
private void CaptureServerState(EpServer server)
{
if (serverStateCaptured)
throw new InvalidOperationException("The Esiur hosted runtime is already started.");
originalEnableTcpListener = server.EnableTcpListener;
originalAllowedAuthenticationProviders =
server.AllowedAuthenticationProviders ?? Array.Empty<string>();
originalAllowedEncryptionProviders =
server.AllowedEncryptionProviders ?? Array.Empty<string>();
serverStateCaptured = true;
}
private void CleanupHostRegistrations(Warehouse warehouse, string operation)
{
for (var index = attachedResources.Count - 1; index >= 0; index--)
{
try
{
if (attachedResources[index].Instance is not null)
warehouse.Remove(attachedResources[index]);
}
catch (Exception cleanupException)
{
logger.LogError(
cleanupException,
"Esiur failed to detach a resource after {Operation}.",
operation);
}
}
attachedResources.Clear();
foreach (var registration in registeredAuthenticationProviders)
warehouse.UnregisterAuthenticationProvider(
registration.Name,
registration.Provider);
registeredAuthenticationProviders.Clear();
foreach (var registration in registeredEncryptionProviders)
warehouse.UnregisterEncryptionProvider(
registration.Name,
registration.Provider);
registeredEncryptionProviders.Clear();
RestoreServerState();
}
private void RestoreServerState()
{
if (!serverStateCaptured)
return;
runtime.Server.EnableTcpListener = originalEnableTcpListener;
runtime.Server.AllowedAuthenticationProviders =
originalAllowedAuthenticationProviders;
runtime.Server.AllowedEncryptionProviders = originalAllowedEncryptionProviders;
serverStateCaptured = false;
}
private void RegisterAuthenticationProviders(
EsiurOptions options,
Warehouse warehouse,
EpServer server,
List<(string Name, IAuthenticationProvider Provider)> registeredProviders)
{
var allowed = new HashSet<string>(
server.AllowedAuthenticationProviders,
StringComparer.Ordinal);
foreach (var factory in options.AuthenticationProviders)
{
var provider = factory(services)
?? throw new InvalidOperationException(
"An Esiur authentication provider factory returned null.");
var name = ResolveProviderName(provider.DefaultName, "authentication");
var existing = warehouse.TryGetAuthenticationProvider(name);
if (existing is null)
{
warehouse.RegisterAuthenticationProvider(name, provider);
registeredProviders.Add((name, provider));
}
else if (!ReferenceEquals(existing, provider))
throw new InvalidOperationException(
$"A different authentication provider named '{name}' is already registered.");
allowed.Add(name);
}
server.AllowedAuthenticationProviders = allowed.ToArray();
}
private void RegisterEncryptionProviders(
EsiurOptions options,
Warehouse warehouse,
EpServer server,
List<(string Name, IEncryptionProvider Provider)> registeredProviders)
{
var allowed = new HashSet<string>(
server.AllowedEncryptionProviders,
StringComparer.Ordinal);
foreach (var factory in options.EncryptionProviders)
{
var provider = factory(services)
?? throw new InvalidOperationException(
"An Esiur encryption provider factory returned null.");
var name = ResolveProviderName(provider.DefaultName, "encryption");
var existing = warehouse.TryGetEncryptionProvider(name);
if (existing is null)
{
warehouse.RegisterEncryptionProvider(name, provider);
registeredProviders.Add((name, provider));
}
else if (!ReferenceEquals(existing, provider))
throw new InvalidOperationException(
$"A different encryption provider named '{name}' is already registered.");
allowed.Add(name);
}
server.AllowedEncryptionProviders = allowed.ToArray();
}
private static void ValidateAllowedProviders(Warehouse warehouse, EpServer server)
{
foreach (var name in server.AllowedAuthenticationProviders)
{
if (warehouse.TryGetAuthenticationProvider(name) is null)
throw new InvalidOperationException(
$"Allowed authentication provider '{name}' is not registered with the Warehouse.");
}
foreach (var name in server.AllowedEncryptionProviders)
{
if (warehouse.TryGetEncryptionProvider(name) is null)
throw new InvalidOperationException(
$"Allowed encryption provider '{name}' is not registered with the Warehouse.");
}
if (!server.AllowUnauthorizedAccess
&& server.AllowedAuthenticationProviders.Length == 0)
{
throw new InvalidOperationException(
"Authentication is required, but no authentication provider is allowed.");
}
if (server.RequireEncryption
&& server.AllowedAuthenticationProviders.Length == 0)
{
throw new InvalidOperationException(
"Encrypted EP sessions require an allowed authentication provider.");
}
if (server.RequireEncryption && server.AllowedEncryptionProviders.Length == 0)
{
throw new InvalidOperationException(
"Encryption is required, but no encryption provider is allowed.");
}
}
private static string ResolveProviderName(
string defaultName,
string providerKind)
{
if (string.IsNullOrWhiteSpace(defaultName)
|| !string.Equals(defaultName, defaultName.Trim(), StringComparison.Ordinal))
throw new InvalidOperationException(
$"The {providerKind} provider does not define a valid protocol name.");
return defaultName;
}
private static Task<bool> CloseWarehouseAsync(Warehouse warehouse)
{
var completion = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
var reply = warehouse.Close();
reply.Then(
result => completion.TrySetResult(result),
nameof(CloseWarehouseAsync),
string.Empty,
0);
reply.Error(exception => completion.TrySetException(exception));
return completion.Task;
}
private static Task<bool> CloseWarehouseAsync(
Warehouse warehouse,
CancellationToken cancellationToken)
=> CloseWarehouseAsync(warehouse).WaitAsync(cancellationToken);
}
@@ -1,75 +0,0 @@
/*
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.Net.Sockets;
using Esiur.Protocol;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net.WebSockets;
namespace Esiur.AspNetCore
{
public class EsiurMiddleware
{
readonly EpServer server;
readonly RequestDelegate next;
readonly ILoggerFactory loggerFactory;
public async Task InvokeAsync(HttpContext context)
{
var buffer = new ArraySegment<byte>(new byte[10240]);
if (context.WebSockets.IsWebSocketRequest
&& context.WebSockets.WebSocketRequestedProtocols.Contains("EP"))
{
var webSocket = await context.WebSockets.AcceptWebSocketAsync("EP");
var socket = new FrameworkWebSocket(webSocket);
var EPConnection = new EpConnection();
server.Add(EPConnection);
EPConnection.Assign(socket);
socket.Begin();
// @TODO: Change this
while (webSocket.State == WebSocketState.Open)
await Task.Delay(500);
}
else
{
await next(context);
}
}
public EsiurMiddleware(RequestDelegate next, IOptions<EsiurOptions> options, ILoggerFactory loggerFactory)
{
this.server = options.Value.Server;
this.loggerFactory = loggerFactory;
this.next = next;
}
}
}
@@ -1,43 +0,0 @@
/*
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 Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Options;
namespace Esiur.AspNetCore
{
public static class EsiurMiddlewareExtensions
{
public static IApplicationBuilder UseEsiur(this IApplicationBuilder app, EsiurOptions options)
{
ArgumentNullException.ThrowIfNull(app);
ArgumentNullException.ThrowIfNull(options);
return app.UseMiddleware<EsiurMiddleware>(Options.Create(options));
}
}
}
+387 -30
View File
@@ -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;
}
}
@@ -0,0 +1,167 @@
using Esiur.Protocol;
using Esiur.Net.Sockets;
using Esiur.Resource;
using Microsoft.Extensions.Options;
namespace Esiur.AspNetCore;
internal sealed class EsiurRuntime : IPendingSendBudget
{
private readonly object lifecycleLock = new();
private readonly HashSet<FrameworkWebSocket> stoppingSockets = new();
private bool ready;
private long pendingSendBytes;
public EsiurRuntime(IOptions<EsiurOptions> options)
{
Options = options.Value;
Warehouse = Options.Warehouse;
Server = Options.Server;
AllowedOrigins = Options.AllowedWebSocketOrigins
.Select(EsiurOrigin.Normalize)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
public EsiurOptions Options { get; }
public Warehouse Warehouse { get; }
public EpServer Server { get; }
public IReadOnlySet<string> AllowedOrigins { get; }
public bool IsReady
{
get
{
lock (lifecycleLock)
return ready;
}
}
public bool TryMarkReady(CancellationToken applicationStopping)
{
lock (lifecycleLock)
{
if (applicationStopping.IsCancellationRequested)
return false;
ready = true;
return true;
}
}
public bool TryAdd(EpConnection connection)
{
lock (lifecycleLock)
return ready && Server.TryAdd(connection);
}
public void StopTransport()
{
// Admission and the Stop snapshot share this lock. A request that passed the
// initial HTTP readiness check therefore cannot add a connection after shutdown
// has already taken the server's connection snapshot.
lock (lifecycleLock)
{
ready = false;
CaptureActiveWebSockets();
Server.Stop();
}
}
public async Task DrainTransportAsync(CancellationToken cancellationToken)
{
FrameworkWebSocket[] sockets;
lock (lifecycleLock)
{
CaptureActiveWebSockets();
sockets = stoppingSockets.ToArray();
}
foreach (var socket in sockets)
_ = socket.CloseAsync();
try
{
// Normal shutdown waits for both physical closure and protocol cleanup.
// A blocked application callback is bounded by the host stop token.
await Task.WhenAll(sockets.Select(ObserveShutdownAsync))
.WaitAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// A host deadline is terminal. Abort any peer that did not finish the
// close handshake so no transport can outlive provider/resource cleanup.
foreach (var socket in sockets)
if (!socket.Completion.IsCompleted)
socket.Destroy();
await Task.WhenAll(sockets.Select(ObserveCompletionAsync));
throw;
}
finally
{
lock (lifecycleLock)
foreach (var socket in sockets)
stoppingSockets.Remove(socket);
}
}
private void CaptureActiveWebSockets()
{
foreach (var connection in Server.Connections.ToArray())
if (connection.Socket is FrameworkWebSocket socket)
stoppingSockets.Add(socket);
}
private static async Task ObserveCompletionAsync(FrameworkWebSocket socket)
{
try { await socket.Completion; }
catch { }
}
private static async Task ObserveShutdownAsync(FrameworkWebSocket socket)
{
await ObserveCompletionAsync(socket);
await socket.CloseNotification;
}
bool IPendingSendBudget.TryReserve(int byteCount)
{
var limit = Options.MaximumTotalPendingWebSocketSendBytes;
while (true)
{
var current = Interlocked.Read(ref pendingSendBytes);
if (byteCount > limit - current)
return false;
if (Interlocked.CompareExchange(
ref pendingSendBytes,
current + byteCount,
current) == current)
return true;
}
}
void IPendingSendBudget.Release(int byteCount)
=> Interlocked.Add(ref pendingSendBytes, -byteCount);
public bool IsOriginAllowed(string? origin, string requestScheme, string requestAuthority)
{
if (string.IsNullOrWhiteSpace(origin))
return false;
if (Options.AllowAnyWebSocketOrigin)
return true;
if (!EsiurOrigin.TryNormalize(origin, out var normalized))
return false;
if (AllowedOrigins.Count == 0)
{
return EsiurOrigin.TryNormalize(
$"{requestScheme}://{requestAuthority}",
out var requestOrigin)
&& string.Equals(normalized, requestOrigin, StringComparison.OrdinalIgnoreCase);
}
return AllowedOrigins.Contains(normalized);
}
}
@@ -0,0 +1,67 @@
using Esiur.Protocol;
using Esiur.Resource;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace Esiur.AspNetCore;
/// <summary>Registers Esiur with the ASP.NET Core dependency injection container.</summary>
public static class EsiurServiceCollectionExtensions
{
/// <summary>Adds one host-managed Esiur runtime.</summary>
public static EsiurBuilder AddEsiur(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
if (services.Any(descriptor =>
descriptor.ServiceType == typeof(EsiurRegistrationMarker)))
{
throw new InvalidOperationException(
"Esiur has already been added to this service collection.");
}
services.AddSingleton<EsiurRegistrationMarker>();
services.AddOptions<EsiurOptions>().ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IPostConfigureOptions<EsiurOptions>,
EsiurOptionsPostConfigure>());
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<EsiurOptions>,
EsiurOptionsValidator>());
services.AddSingleton<EsiurRuntime>();
services.AddSingleton<Warehouse>(provider =>
provider.GetRequiredService<EsiurRuntime>().Warehouse);
services.AddSingleton<EpServer>(provider =>
provider.GetRequiredService<EsiurRuntime>().Server);
services.AddSingleton<EsiurWebSocketEndpoint>();
services.AddSingleton<IHostedService, EsiurHostedService>();
return new EsiurBuilder(services);
}
/// <summary>Adds and configures one host-managed Esiur runtime.</summary>
public static EsiurBuilder AddEsiur(
this IServiceCollection services,
Action<EsiurBuilder> configure)
{
ArgumentNullException.ThrowIfNull(configure);
var builder = services.AddEsiur();
configure(builder);
return builder;
}
/// <summary>Adds Esiur around an existing Warehouse and EP server.</summary>
public static EsiurBuilder AddEsiur(
this IServiceCollection services,
Warehouse warehouse,
EpServer server,
bool manageWarehouseLifecycle = true)
=> services.AddEsiur()
.UseWarehouse(warehouse, manageWarehouseLifecycle)
.UseServer(server);
private sealed class EsiurRegistrationMarker;
}
@@ -0,0 +1,230 @@
using System.Net;
using System.Net.WebSockets;
using Esiur.Net.Sockets;
using Esiur.Protocol;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
namespace Esiur.AspNetCore;
internal sealed class EsiurWebSocketEndpoint
{
private readonly EsiurRuntime runtime;
private readonly ILogger<EsiurWebSocketEndpoint> logger;
public EsiurWebSocketEndpoint(
EsiurRuntime runtime,
ILogger<EsiurWebSocketEndpoint> logger)
{
this.runtime = runtime;
this.logger = logger;
}
public async Task HandleAsync(HttpContext context)
{
if (!runtime.IsReady)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
return;
}
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status426UpgradeRequired;
context.Response.Headers["Upgrade"] = "websocket";
return;
}
if (!context.WebSockets.WebSocketRequestedProtocols.Contains(
FrameworkWebSocket.SubProtocol,
StringComparer.Ordinal))
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync(
$"The '{FrameworkWebSocket.SubProtocol}' WebSocket subprotocol is required.",
context.RequestAborted);
return;
}
var origins = context.Request.Headers.Origin;
if (!IsOriginAllowed(origins, context))
{
logger.LogDebug(
"Rejected Esiur WebSocket from disallowed origin {Origin}.",
origins.ToString());
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
if (!TryCreateEndPoint(
context.Connection.LocalIpAddress,
context.Connection.LocalPort,
out var localEndPoint)
|| !TryCreateEndPoint(
context.Connection.RemoteIpAddress,
context.Connection.RemotePort,
out var remoteEndPoint))
{
logger.LogError(
"Esiur cannot accept a WebSocket without concrete local and remote transport endpoints.");
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
return;
}
WebSocket? webSocket = null;
FrameworkWebSocket? socket = null;
EpConnection? connection = null;
var admitted = false;
try
{
webSocket = await context.WebSockets.AcceptWebSocketAsync(
FrameworkWebSocket.SubProtocol);
socket = new FrameworkWebSocket(
webSocket,
localEndPoint,
remoteEndPoint,
context.RequestAborted)
{
MaximumPendingSendBytes = runtime.Options.MaximumPendingWebSocketSendBytes,
PendingSendBudget = runtime,
};
connection = new EpConnection();
connection.Assign(socket);
// Recheck readiness while admission is serialized with host shutdown. The
// WebSocket upgrade can take long enough for ApplicationStopping to begin
// after the initial readiness check.
admitted = runtime.TryAdd(connection);
if (!admitted)
return;
if (!socket.Begin())
throw new InvalidOperationException("The Esiur WebSocket could not be started.");
logger.LogDebug(
"Accepted Esiur WebSocket from {RemoteEndPoint}.",
socket.RemoteEndPoint);
await socket.Completion.WaitAsync(context.RequestAborted);
await socket.CloseNotification.WaitAsync(context.RequestAborted);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
logger.LogDebug("The Esiur WebSocket request was aborted by its peer or host.");
}
catch (WebSocketException exception)
{
logger.LogDebug(exception, "The Esiur WebSocket transport closed with an error.");
}
catch (InvalidDataException exception)
{
logger.LogDebug(exception, "The Esiur WebSocket sent invalid EP data.");
}
catch (Exception exception)
{
logger.LogWarning(exception, "The Esiur WebSocket endpoint failed.");
}
finally
{
if (admitted && connection is not null)
{
try
{
runtime.Server.Remove(connection);
}
catch (Exception exception)
{
logger.LogDebug(exception, "Failed to remove an Esiur connection during cleanup.");
}
}
if (connection is not null)
{
if (socket is not null)
DestroyAfterCloseNotification(connection, socket);
else
DestroyConnection(connection);
}
else
{
try
{
socket?.Destroy();
webSocket?.Dispose();
}
catch (Exception exception)
{
logger.LogDebug(exception, "Failed to dispose an Esiur WebSocket cleanly.");
}
}
}
}
private void DestroyAfterCloseNotification(
EpConnection connection,
FrameworkWebSocket socket)
{
// RequestAborted commonly wins the endpoint wait when a peer disappears.
// Abort the physical transport immediately, but keep the connection assigned
// until NetworkClose has run; otherwise its stale-socket guard would skip
// Disconnected and the authentication provider's Logout callback.
socket.Destroy();
if (socket.CloseNotification.IsCompleted)
{
DestroyConnection(connection);
return;
}
_ = DestroyAfterCloseNotificationAsync(connection, socket);
}
private async Task DestroyAfterCloseNotificationAsync(
EpConnection connection,
FrameworkWebSocket socket)
{
await socket.CloseNotification;
DestroyConnection(connection);
}
private void DestroyConnection(EpConnection connection)
{
try
{
connection.Destroy();
}
catch (Exception exception)
{
logger.LogDebug(exception, "Failed to dispose an Esiur connection cleanly.");
}
}
private bool IsOriginAllowed(StringValues origins, HttpContext context)
=> origins.Count == 0
|| (origins.Count == 1
&& runtime.IsOriginAllowed(
origins[0],
context.Request.Scheme,
context.Request.Host.Value ?? string.Empty));
private static bool TryCreateEndPoint(
IPAddress? address,
int port,
out IPEndPoint endPoint)
{
endPoint = null!;
if (address is null
|| port is <= IPEndPoint.MinPort or > IPEndPoint.MaxPort
|| address.Equals(IPAddress.Any)
|| address.Equals(IPAddress.IPv6Any)
|| address.Equals(IPAddress.None)
|| address.Equals(IPAddress.IPv6None))
return false;
endPoint = new IPEndPoint(address, port);
return true;
}
}
+275 -34
View File
@@ -1,51 +1,292 @@
# Esiur ASP.Net Core Middleware
# Esiur for ASP.NET Core
`Esiur.AspNetCore` exposes an Esiur EP server through ASP.NET Core endpoint
routing and WebSockets. ASP.NET Core owns the application lifetime, while Esiur
continues to own its session authentication, encryption, and resource
authorization.
The integration does not open Esiur's separate TCP listener. Clients connect to
the route mapped with `MapEsiur`.
## Installation
```shell
dotnet add package Esiur.AspNetCore
```
## Development quick start
The following example deliberately enables anonymous Esiur sessions to keep a
local development service easy to try. Do not expose resources this way unless
their authorization policy is safe for anonymous callers.
```csharp
using Esiur.AspNetCore;
This project brings Esiur distributed resource framework to ASP.Net using WebSockets in the ASP.Net pipeline.
# Installation
- Nuget
```Install-Package Esiur.AspNetCore```
- Command-line
``` dotnet add package Esiur.AspNetCore ```
# Example
```C#
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://localhost:8080");
builder.Services.AddEsiur(esiur =>
{
esiur
.AddMemoryStore("sys")
.AddResource<HelloResource>("sys/service")
.AllowAnonymous() // Development only.
.IncludeExceptionMessages(); // Development diagnostics only.
});
var app = builder.Build();
app.UseWebSockets();
var webSockets = new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30),
};
webSockets.AllowedOrigins.Add("http://localhost:8080");
app.UseWebSockets(webSockets);
await Warehouse.Put("sys", new MemoryStore());
await Warehouse.Put("sys/service", new MyResource());
var server = await Warehouse.Put("sys/server", new DistributedServer());
await Warehouse.Open();
app.UseEsiur(new EsiurOptions() { Server = server });
app.MapEsiur("/esiur");
await app.RunAsync();
```
## MyResource.cs
```csharp
using Esiur.Resource;
```c#
[Resource]
public partial class MyResource
[Resource]
public partial class HelloResource
{
private int count;
[Export]
public string Hello(string name) => $"Hello, {name}!";
[Export]
public int Increment() => Interlocked.Increment(ref count);
[Export]
public int CurrentCount() => Volatile.Read(ref count);
}
```
`AddEsiur` registers a host-managed Warehouse and EP server. The hosted runtime
adds the resources, opens the Warehouse during application startup, and closes
it during graceful shutdown. Shutdown stops admission and drains or aborts active
WebSockets before resource termination begins. Configuration is validated when
the host starts.
## Browser origin policy
Call `UseWebSockets` before the mapped endpoint. The Esiur endpoint accepts
native clients that omit the HTTP `Origin` header. For browser clients, it
allows the request's own origin when no Esiur origin list is configured.
Once origins are configured, they become the complete browser-origin
allowlist:
```csharp
builder.Services.AddEsiur(esiur =>
{
esiur
.AddMemoryStore("sys")
.AddResource<HelloResource>("sys/service")
.AllowAnonymous() // Development only; use authentication in production.
.AllowWebSocketOrigins("https://app.example.com");
});
```
If `WebSocketOptions.AllowedOrigins` is also restricted, add the same origins
there. Set ASP.NET Core `AllowedHosts` to the service's real host names in
production; do not leave it as `*`, because implicit same-origin checks rely on
the validated request host. `AllowAnyWebSocketOrigin` is available for
exceptional cases, but it should not be the production default.
## Production authentication and encryption
Register an application authentication provider and authenticated record
encryption together. `UseAuthentication` and `UseEncryption` register their
`DefaultName` protocol names with the Warehouse and allow them on the EP server.
The facade deliberately does not offer protocol aliases: the provider and its
handshake handler must negotiate the same stable name across every Esiur
language implementation.
```csharp
using Esiur.AspNetCore;
using Esiur.Security.Authority.Providers;
using Esiur.Security.Cryptography;
// Load Esiur's precomputed salted password credentials from the application's
// account store. Do not keep raw passwords in this dictionary.
var passwordCredentials =
new Dictionary<(string Domain, string Identity), PasswordHash>();
builder.Services.AddEsiur(esiur =>
{
esiur
.AddMemoryStore("sys")
.AddResource<HelloResource>("sys/service")
.UsePasswordAuthentication((identity, domain) =>
passwordCredentials.TryGetValue((domain, identity), out var credential)
? credential
: null)
.UseEncryption(new AesEncryptionProvider())
.AllowWebSocketOrigins("https://app.example.com")
.RequireEncryption();
});
```
`UsePasswordAuthentication` is the simple server shortcut: the callback returns
a `PasswordHash` for an account or `null` when it is unknown, so applications do
not need to subclass `PasswordAuthenticationProvider`. The lookup is synchronous
and should use a memory cache or another non-blocking credential store. Advanced
protocols and asynchronous backends can still use `UseAuthentication(provider)`,
`UseAuthentication<TProvider>()`, or the provider factory overload. Each form
uses the provider's `DefaultName`.
The shortcut continues the same challenge-response shape for an unknown
identity using provider-local random dummy material. The application should
still make known and unknown lookups take comparable time and apply handshake
rate limits; the shortcut cannot hide timing differences in the application's
credential store. Identities and domains are limited to 512 UTF-8 bytes before
the application callback runs.
Create each stored value once during account enrollment with
`PasswordAuthenticationProvider.CreateCredential(passwordBytes)`, persist its
hash and salt, then clear and discard `passwordBytes`. Do not regenerate the
credential inside the authentication callback. The stored hash is a
password-equivalent verifier: anyone who obtains it can authenticate through
this protocol without recovering the original password (a pass-the-hash
attack). Keep it secret like a plaintext password: restrict access, encrypt it
at rest where appropriate, and never put it in logs or client-visible data.
For new production deployments, prefer `PpapAuthenticationProvider`. PPAP uses
Argon2id as part of its password-derived ML-KEM identity flow. An Argon2id hash
cannot simply replace `PasswordHash.Hash` here: `password-sha3-v1` fixes the
wire verifier to SHA3-256 of the password and protocol salt, so changing only
server-side storage would make clients compute a different value and fail
authentication. Memory-hard derivation therefore needs a protocol designed for
it on both peers, such as PPAP.
Omitting `AllowAnonymous` makes Esiur authentication mandatory.
`RequireEncryption` rejects sessions that do not negotiate both authentication
and an allowed encryption provider.
Remote errors contain stable Esiur codes only by default. Development services
can call `IncludeExceptionMessages()`. Source and stack details require an
explicit advanced `ConfigureServer` setting and should not be exposed publicly.
## Connection and memory limits
Esiur applies global and per-IP concurrent-connection and attempt limits. Tune
them for the deployment, and reduce the per-connection WebSocket send queue if
the application does not send large values:
```csharp
builder.Services.AddEsiur(esiur => esiur
.AddMemoryStore("sys")
.AddResource<HelloResource>("sys/service")
.UsePasswordAuthentication((identity, domain) =>
passwordCredentials.TryGetValue((domain, identity), out var credential)
? credential
: null)
.LimitPendingWebSocketSendBytes(2 * 1024 * 1024)
.LimitTotalPendingWebSocketSendBytes(256L * 1024 * 1024)
.ConfigureWarehouse(configuration =>
{
[Export] int number;
[Export] public string Hello() => "Hi";
}
configuration.Connections.MaximumConnections = 500;
configuration.Connections.MaximumConnectionsPerIpAddress = 20;
configuration.Connections.MaximumConnectionAttempts = 2_000;
}));
```
ASP.NET Core endpoint rate limiting can add a separate handshake policy. A
handshake limiter does not limit traffic after the WebSocket is established.
The total pending-send limit is shared by every Esiur WebSocket in the host, so
the per-connection allowance cannot multiply without bound under many slow
clients. Exceeding either limit closes the affected connection rather than
dropping a protocol frame and continuing with a corrupted stream.
## Calling from JavaScript
The mapped endpoint participates in standard ASP.NET Core endpoint conventions:
Esiur provides a command line interpreter for debugging using Node.JS which can be installed using
```npm install -g esiur```
To access the shell
```esiur shell```
Now you can simply test the running service typing
```javascript
let x = await wh.get("EP://localhost:8080/sys/service", {secure: false});
await x.Hello();
```csharp
app.MapEsiur("/esiur")
.RequireAuthorization("EsiurUpgrade")
.RequireRateLimiting("EsiurHandshakes");
```
ASP.NET Core authorization only gates the HTTP WebSocket upgrade. It does not
replace Esiur session authentication or Esiur resource authorization.
## Reverse proxies
When TLS terminates at a trusted reverse proxy, apply forwarded headers before
`UseWebSockets` and `MapEsiur`. The original scheme and host are needed for
same-origin validation and correct connection metadata.
```csharp
using Microsoft.AspNetCore.HttpOverrides;
using System.Net;
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor |
ForwardedHeaders.XForwardedProto |
ForwardedHeaders.XForwardedHost;
// Trust only the proxy addresses used by this deployment.
options.KnownProxies.Add(IPAddress.Parse("10.0.0.10"));
});
// After builder.Build():
app.UseForwardedHeaders();
app.UseWebSockets();
app.MapEsiur("/esiur");
```
The proxy must support WebSocket upgrades and supply the corresponding
`X-Forwarded-*` headers. Do not trust arbitrary proxy addresses.
## Connecting from .NET through WebSockets
Set `EpConnectionContext.WebSocketUri` to the public `ws` or `wss` endpoint,
including the route mapped by `MapEsiur`:
```csharp
using Esiur.Protocol;
using Esiur.Resource;
var clientWarehouse = new Warehouse();
var service = await clientWarehouse.Get<IResource>(
"ep://api.example.com/sys/service",
new EpConnectionContext
{
WebSocketUri = new Uri("wss://api.example.com/esiur"),
// Add the authentication mode, identity, and protocol required by the server.
});
```
Every WebSocket client, including clients in other languages, must request the
exact, case-sensitive `EP` WebSocket subprotocol (`Sec-WebSocket-Protocol: EP`).
The Esiur client transports do this automatically.
The `ep://` URL identifies the logical EP connection and resource path;
`WebSocketUri` selects its WebSocket transport and carries the ASP.NET route.
When using address-bound encryption, use an IP-literal `WebSocketUri`: the .NET
`ClientWebSocket` API does not expose the endpoint selected for a DNS host, so
Esiur fails address binding closed instead of recording a separate DNS guess.
## Existing Warehouse and server
Advanced applications can supply their own instances while still using the
ASP.NET Core endpoint and host lifecycle:
```csharp
builder.Services.AddEsiur()
.UseWarehouse(warehouse)
.UseServer(server, "sys/server");
```
Use `UseWarehouse(warehouse, manageLifecycle: false)` only when another
component owns the Warehouse lifecycle and the supplied server and resources
are already attached and initialized.