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:
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user