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
@@ -1,25 +0,0 @@
using Esiur.Core;
using Esiur.Resource;
using Microsoft.AspNetCore.Mvc;
namespace Esiur.AspNetCore.Example
{
[ApiController]
[Route("[controller]")]
public class MainController : ControllerBase
{
private readonly ILogger<MainController> _logger;
public MainController(ILogger<MainController> logger)
{
_logger = logger;
}
[HttpGet(Name = "Get")]
public async AsyncReply<MyResource> Get()
{
return await Warehouse.Default.Get<MyResource>("/sys/demo");
}
}
}
+2 -7
View File
@@ -7,13 +7,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.8.1" />
<ProjectReference Include="..\..\Integrations\Esiur.AspNetCore\Esiur.AspNetCore.csproj" />
<ProjectReference Include="..\..\Libraries\Esiur\Esiur.csproj" OutputItemType="Analyzer" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Integrations\Esiur.AspNetCore\Esiur.AspNetCore.csproj" />
<ProjectReference Include="..\..\Libraries\Esiur\Esiur.csproj" OutputItemType="Analyzer" />
</ItemGroup>
</Project>
@@ -1,6 +0,0 @@
@Esiur.ASPNet_HostAddress = http://localhost:5141
GET {{Esiur.ASPNet_HostAddress}}/weatherforecast/
Accept: application/json
###
+12 -37
View File
@@ -1,43 +1,18 @@
/*
MIT License
Copyright (c) 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.Resource;
namespace Esiur.AspNetCore.Example
namespace Esiur.AspNetCore.Example;
[Resource]
public partial class MyResource
{
[Resource]
public partial class MyResource
{
[Annotation("A1","B2")][Export] int number;
private int count;
[Export]
public string[] GetInfo() => new string[] { Environment.MachineName, Environment.UserName, Environment.CurrentDirectory,
Environment.CommandLine, Environment.OSVersion.ToString()};
[Export]
public string Hello(string name) => $"Hello, {name}!";
}
[Export]
public int Increment() => Interlocked.Increment(ref count);
[Export]
public int CurrentCount() => Volatile.Read(ref count);
}
+21 -55
View File
@@ -1,69 +1,35 @@
/*
MIT License
Copyright (c) 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.AspNetCore;
using Esiur.AspNetCore.Example;
using Esiur.Core;
using Esiur.Net.Sockets;
using Esiur.Protocol;
using Esiur.Resource;
using Esiur.Stores;
using Microsoft.AspNetCore.Hosting.Server;
using System.Net.WebSockets;
using System.Reflection;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
//builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
//builder.Services.AddEndpointsApiExplorer();
//builder.Services.AddSwaggerGen();
builder.WebHost.UseUrls("http://localhost:8080");
builder.Services.AddEsiur(esiur =>
{
esiur
.AddMemoryStore("sys")
.AddResource<MyResource>("sys/service")
// This sample is a development quick start. Production applications
// should configure an authentication provider instead.
.AllowAnonymous()
.IncludeExceptionMessages();
});
var app = builder.Build();
var webSocketOptions = new WebSocketOptions()
var webSocketOptions = new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(120),
KeepAliveInterval = TimeSpan.FromSeconds(30),
};
webSocketOptions.AllowedOrigins.Add("http://localhost:8080");
app.UseWebSockets(webSocketOptions);
await Warehouse.Default.Put("sys", new MemoryStore());
await Warehouse.Default.Put("sys/service", new MyResource());
var server = await Warehouse.Default.Put("sys/server", new EpServer());
await Warehouse.Default.Open();
app.UseEsiur(new EsiurOptions() { Server = server });
app.MapEsiur("/esiur");
app.MapGet("/", () => new
{
message = "Esiur is available over WebSockets at /esiur.",
resource = "sys/service",
authentication = "Anonymous access is enabled for this development sample.",
});
await app.RunAsync();
+4 -31
View File
@@ -1,38 +1,11 @@
{
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:24282",
"sslPort": 44358
}
},
"profiles": {
"http": {
"Esiur.AspNetCore.Example": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5141",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7282;http://localhost:5141",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"launchBrowser": false,
"applicationUrl": "http://localhost:8080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
+1 -3
View File
@@ -1,7 +1,7 @@
async function init() {
try {
connection = await wh.get(`EP://${window.location.hostname}`, {
connection = await wh.get(`ep://${window.location.hostname}`, {
autoReconnect: true
});
@@ -125,5 +125,3 @@ async function init() {
const FORMAT_CONNECTION_STATUS = (x) => ["Offline", "Connecting...", "Online"][x];
@@ -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.
+85 -5
View File
@@ -22,6 +22,7 @@ SOFTWARE.
*/
using Esiur.Misc;
using Esiur.Resource;
using System;
using System.Collections.Generic;
@@ -217,7 +218,17 @@ public class AsyncReply
return this;
}
public void Trigger(object result)
public void Trigger(object result) => TrySetResult(result, dispatchCallbacksAsynchronously: false);
/// <summary>
/// Completes the reply immediately while dispatching callbacks independently.
/// This is reserved for teardown paths where consumer code must not hold a
/// transport or other infrastructure resource open.
/// </summary>
internal void TriggerDetached(object result) =>
TrySetResult(result, dispatchCallbacksAsynchronously: true);
private bool TrySetResult(object result, bool dispatchCallbacksAsynchronously)
{
Action<object> singleCallback = null;
Action<object>[] registeredCallbacks = null;
@@ -227,7 +238,7 @@ public class AsyncReply
lock (asyncLock)
{
if (exception != null || resultReady)
return;
return false;
ReadyTime = DateTime.Now;
this.result = result;
@@ -250,11 +261,24 @@ public class AsyncReply
waiter?.Set();
if (callbackCount == 1)
if (dispatchCallbacksAsynchronously)
{
if (callbackCount == 1)
QueueDetachedCallbacks(singleCallback, null, result);
else if (registeredCallbacks != null)
QueueDetachedCallbacks(null, registeredCallbacks, result);
}
else if (callbackCount == 1)
{
singleCallback(result);
}
else if (registeredCallbacks != null)
{
foreach (var callback in registeredCallbacks)
callback(result);
}
return true;
}
public virtual void TriggerError(Exception exception)
@@ -262,6 +286,19 @@ public class AsyncReply
TrySetException(exception);
}
/// <summary>
/// Faults the reply immediately while dispatching callbacks independently.
/// This is reserved for teardown paths where consumer code must not hold a
/// transport or other infrastructure resource open.
/// </summary>
internal void TriggerErrorDetached(Exception exception)
{
TrySetException(
exception,
preserveSourceForAwait: false,
dispatchCallbacksAsynchronously: true);
}
internal void TriggerErrorFromBuilder(Exception exception, bool preserveSourceForAwait)
{
TrySetException(exception, preserveSourceForAwait);
@@ -347,7 +384,10 @@ public class AsyncReply
}
}
private bool TrySetException(Exception sourceException, bool preserveSourceForAwait = false)
private bool TrySetException(
Exception sourceException,
bool preserveSourceForAwait = false,
bool dispatchCallbacksAsynchronously = false)
{
AsyncException asyncException;
Action<AsyncException> singleCallback = null;
@@ -378,12 +418,52 @@ public class AsyncReply
waiter?.Set();
if (callbackCount == 1)
if (dispatchCallbacksAsynchronously)
{
if (callbackCount == 1)
QueueDetachedCallbacks(singleCallback, null, asyncException);
else if (registeredCallbacks != null)
QueueDetachedCallbacks(null, registeredCallbacks, asyncException);
}
else if (callbackCount == 1)
{
singleCallback(asyncException);
}
else if (registeredCallbacks != null)
{
foreach (var callback in registeredCallbacks)
callback(asyncException);
}
return true;
}
private static void QueueDetachedCallbacks<T>(
Action<T> singleCallback,
Action<T>[] registeredCallbacks,
T value)
{
ThreadPool.QueueUserWorkItem(_ =>
{
try
{
if (singleCallback != null)
{
singleCallback(value);
}
else
{
foreach (var callback in registeredCallbacks)
{
try { callback(value); }
catch (Exception exception) { Global.Log(exception); }
}
}
}
catch (Exception exception)
{
Global.Log(exception);
}
});
}
}
+8 -1
View File
@@ -16,6 +16,9 @@
<PackageId>Esiur</PackageId>
<Product>Esiur</Product>
<LangVersion>latest</LangVersion>
<!-- Keep CallerFilePath/PDB entries deterministic and avoid embedding the
build machine's absolute source directory in the package assembly. -->
<PathMap>$(MSBuildProjectDirectory)=/_/Esiur</PathMap>
<!-- Enable the nullable *annotation* context project-wide so '?' reference-type
annotations are legal everywhere, without turning on nullable flow warnings
(files that opt in via '#nullable enable' still get full checking). -->
@@ -39,6 +42,8 @@
<ItemGroup>
<!-- Allow the unit test project to exercise internal helpers (e.g. wait-for cycle detection). -->
<InternalsVisibleTo Include="Esiur.Tests.Unit" />
<!-- The ASP.NET adapter supplies a host-wide transport memory budget. -->
<InternalsVisibleTo Include="Esiur.AspNetCore" />
</ItemGroup>
<ItemGroup>
@@ -50,7 +55,9 @@
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" PrivateAssets="all" />
<!-- Compile the generator against the .NET 8 Roslyn baseline so the combined
runtime/analyzer assembly does not require System.Collections.Immutable 9. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="4.14.0" PrivateAssets="all" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageReference Include="System.Reflection.Emit" Version="4.7.0" />
+13 -3
View File
@@ -53,6 +53,13 @@ public abstract class NetworkServer<TConnection> : IDestructible where TConnecti
public event DestroyedEvent OnDestroy;
protected NetworkServer()
{
// Connection tracking is also used by externally hosted transports (for example,
// ASP.NET Core WebSockets) that don't create an ISocket listener through Start().
Connections = new AutoList<TConnection, NetworkServer<TConnection>>(this);
}
private void MinuteThread(object state)
{
@@ -93,8 +100,6 @@ public abstract class NetworkServer<TConnection> : IDestructible where TConnecti
if (listener != null)
return;
Connections = new AutoList<TConnection, NetworkServer<TConnection>>(this);
if (Timeout > 0 && Clock > 0)
{
timer = new Timer(MinuteThread, null, TimeSpan.FromMinutes(0), TimeSpan.FromSeconds(Clock));
@@ -273,7 +278,12 @@ public abstract class NetworkServer<TConnection> : IDestructible where TConnecti
finally
{
try { currentTimer?.Dispose(); } catch { }
Global.Log("NetworkServer", LogType.Warning, $"Server on port {port} is down.");
if (currentListener != null
|| currentTimer != null
|| (connections?.Length ?? 0) > 0)
{
Global.Log("NetworkServer", LogType.Debug, $"Server on port {port} is down.");
}
}
}
File diff suppressed because it is too large Load Diff
+425 -85
View File
@@ -115,6 +115,12 @@ public partial class EpConnection : NetworkConnection, IStore
Session _session;
AsyncReply<bool> _openReply;
readonly object _connectLifecycleLock = new object();
long _connectAttemptGeneration;
bool _isDestroyed;
bool _connectAttemptIsRetrying;
CancellationTokenSource _connectRetryCancellation;
volatile bool _autoReconnect;
bool _authenticated;
bool _authenticationHandshakeSucceeded;
@@ -191,7 +197,44 @@ public partial class EpConnection : NetworkConnection, IStore
//[Attribute]
public bool AutoReconnect { get; set; } = false;
public bool AutoReconnect
{
get => _autoReconnect;
set
{
AsyncReply<bool> canceledReply = null;
CancellationTokenSource retryCancellation = null;
lock (_connectLifecycleLock)
{
_autoReconnect = value;
if (value || !_connectAttemptIsRetrying)
return;
_connectAttemptIsRetrying = false;
retryCancellation = _connectRetryCancellation;
_connectRetryCancellation = null;
var pending = Volatile.Read(ref _openReply);
if (pending != null
&& ReferenceEquals(
Interlocked.CompareExchange(ref _openReply, null, pending),
pending))
{
Status = EpConnectionStatus.Closed;
canceledReply = pending;
}
}
CancelAndDispose(retryCancellation);
TriggerOpenError(
canceledReply,
new AsyncException(
ErrorType.Management,
0,
"Automatic reconnect was canceled."));
}
}
//[Attribute]
public uint ReconnectInterval { get; set; } = 5;
@@ -199,11 +242,15 @@ public partial class EpConnection : NetworkConnection, IStore
//[Attribute]
//public string Username { get; set; }
//[Attribute]
public bool UseWebSocket { get; set; }
/// <summary>
/// Full WebSocket endpoint used by outbound connections. When null, EP uses
/// its native TCP transport (except on browser runtimes).
/// </summary>
public Uri WebSocketUri { get; set; }
//[Attribute]
public bool SecureWebSocket { get; set; }
// Test seam for verifying the same fresh-socket policy used by native TCP and
// FrameworkWebSocket reconnects without expanding the public API.
internal Func<ISocket> ClientSocketFactory { get; set; }
//[Attribute]
//public string Password { get; set; }
@@ -857,17 +904,16 @@ public partial class EpConnection : NetworkConnection, IStore
void FailPendingOpen(Exception exception)
{
var pending = Interlocked.Exchange(ref _openReply, null);
TriggerOpenError(pending, exception);
}
static void TriggerOpenError(AsyncReply<bool> pending, Exception exception)
{
if (pending == null)
return;
try
{
pending.TriggerError(exception);
}
catch (Exception ex)
{
Global.Log(ex);
}
try { pending.TriggerError(exception); }
catch (Exception ex) { Global.Log(ex); }
}
void BeginPlaintextHandshake()
@@ -1005,14 +1051,9 @@ public partial class EpConnection : NetworkConnection, IStore
{
if (cancellation == null)
return;
try
{
cancellation.Cancel();
}
finally
{
cancellation.Dispose();
}
try { cancellation.Cancel(); } catch (ObjectDisposedException) { }
try { cancellation.Dispose(); } catch (ObjectDisposedException) { }
}
bool TryPublishAuthenticationReady(
@@ -1233,6 +1274,23 @@ public partial class EpConnection : NetworkConnection, IStore
public override void Destroy()
{
CancellationTokenSource retryCancellation;
lock (_connectLifecycleLock)
{
_isDestroyed = true;
_connectAttemptGeneration++;
_autoReconnect = false;
_connectAttemptIsRetrying = false;
retryCancellation = _connectRetryCancellation;
_connectRetryCancellation = null;
Status = EpConnectionStatus.Closed;
}
CancelAndDispose(retryCancellation);
FailPendingOpen(new AsyncException(
ErrorType.Management,
0,
"The connection was destroyed before it finished opening."));
TerminateInvocations();
UnsubscribeAll();
EndAuthenticationAttempt();
@@ -1479,8 +1537,24 @@ public partial class EpConnection : NetworkConnection, IStore
Socket?.Unhold();
var res = new HttpResponsePacket();
HttpConnection.Upgrade(
req,
res,
new[] { FrameworkWebSocket.SubProtocol },
out var selectedSubProtocol);
HttpConnection.Upgrade(req, res);
if (selectedSubProtocol != FrameworkWebSocket.SubProtocol)
{
res = new HttpResponsePacket
{
Number = HttpResponseCode.BadRequest,
Text = "The EP WebSocket subprotocol is required."
};
res.Compose(HttpComposeOption.AllCalculateLength);
Send(res.Data);
Close();
return ends;
}
res.Compose(HttpComposeOption.AllCalculateLength);
Send(res.Data);
@@ -3101,8 +3175,7 @@ public partial class EpConnection : NetworkConnection, IStore
ReconnectInterval = epContext.ReconnectInterval;
AuthenticationTimeout = epContext.AuthenticationTimeout;
ExceptionLevel = epContext.ExceptionLevel;
UseWebSocket = epContext.UseWebSocket;
SecureWebSocket = epContext.SecureWebSocket;
WebSocketUri = epContext.WebSocketUri;
AutoReconnect = epContext.AutoReconnect;
_hostname = address;
_port = port;
@@ -3124,77 +3197,335 @@ public partial class EpConnection : NetworkConnection, IStore
public AsyncReply<bool> Connect(ISocket socket = null, string hostname = null, ushort port = 0, string domain = null)
{
if (IsConnected || Status == EpConnectionStatus.Connected)
throw new AsyncException(ErrorType.Exception, 0, "Connection is already established");
var openReply = new AsyncReply<bool>();
if (Interlocked.CompareExchange(ref _openReply, openReply, null) != null)
throw new AsyncException(ErrorType.Exception, 0, "Connection in progress");
AsyncReply<bool> openReply;
long attemptGeneration;
bool canCreateReplacement;
Status = EpConnectionStatus.Connecting;
// set auth direction to initiator
_authDirection = AuthenticationDirection.Initiator;
if (hostname != null)
lock (_connectLifecycleLock)
{
DisposeSessionEncryption();
_session = new Session();
_authDirection = AuthenticationDirection.Initiator;
_invalidCredentials = false;
if (_isDestroyed)
throw new AsyncException(ErrorType.Exception, 0, "Connection was destroyed");
if (IsConnected || Status == EpConnectionStatus.Connected)
throw new AsyncException(ErrorType.Exception, 0, "Connection is already established");
_session.LocalHeaders.Domain = domain;
_hostname = hostname;
openReply = new AsyncReply<bool>();
if (Interlocked.CompareExchange(ref _openReply, openReply, null) != null)
throw new AsyncException(ErrorType.Exception, 0, "Connection in progress");
attemptGeneration = ++_connectAttemptGeneration;
_connectAttemptIsRetrying = false;
try
{
Status = EpConnectionStatus.Connecting;
// set auth direction to initiator
_authDirection = AuthenticationDirection.Initiator;
if (hostname != null)
{
DisposeSessionEncryption();
_session = new Session();
_authDirection = AuthenticationDirection.Initiator;
_invalidCredentials = false;
_session.LocalHeaders.Domain = domain;
_hostname = hostname;
}
if (port > 0)
this._port = port;
if (_session == null)
throw new AsyncException(ErrorType.Exception, 0, "Session not initialized");
if (_authenticationProvider != null && _authenticationContext != null)
{
DisposeAuthenticationHandler();
_session.AuthenticationHandler =
_authenticationProvider.CreateAuthenticationHandler(_authenticationContext);
}
BeginPlaintextHandshake();
canCreateReplacement = socket == null;
socket ??= CreateClientSocket();
}
catch
{
Interlocked.CompareExchange(ref _openReply, null, openReply);
_connectAttemptIsRetrying = false;
Status = EpConnectionStatus.Closed;
throw;
}
}
if (port > 0)
this._port = port;
if (_session == null)
throw new AsyncException(ErrorType.Exception, 0, "Session not initialized");
if (_authenticationProvider != null && _authenticationContext != null)
{
DisposeAuthenticationHandler();
_session.AuthenticationHandler =
_authenticationProvider.CreateAuthenticationHandler(_authenticationContext);
}
BeginPlaintextHandshake();
if (socket == null)
{
var os = RuntimeInformation.FrameworkDescription;
if (UseWebSocket || RuntimeInformation.OSDescription == "Browser")
socket = new FrameworkWebSocket();
else
socket = new TcpSocket();
}
connectSocket(socket);
connectSocket(socket, canCreateReplacement, openReply, attemptGeneration);
return openReply;
}
void connectSocket(ISocket socket)
ISocket CreateClientSocket()
{
socket.Connect(this._hostname, this._port).Then(x =>
if (ClientSocketFactory != null)
return ClientSocketFactory();
if (WebSocketUri != null)
return new FrameworkWebSocket(WebSocketUri);
if (RuntimeInformation.OSDescription == "Browser")
{
Assign(socket);
}).Error((x) =>
{
if (AutoReconnect)
{
Global.Log("EpConnection", LogType.Debug, "Reconnecting socket...");
Task.Delay(TimeSpan.FromSeconds(ReconnectInterval))
.ContinueWith((x) => connectSocket(socket));
}
else
{
FailPendingOpen(x);
}
});
return new FrameworkWebSocket(
new UriBuilder("ws", _hostname, _port).Uri);
}
return new TcpSocket();
}
void connectSocket(
ISocket socket,
bool canCreateReplacement,
AsyncReply<bool> expectedOpenReply,
long attemptGeneration)
{
if (!IsCurrentConnectAttempt(expectedOpenReply, attemptGeneration))
{
DestroyStaleSocket(socket);
return;
}
AsyncReply<bool> connectReply;
try
{
connectReply = socket.Connect(this._hostname, this._port);
}
catch (Exception exception)
{
HandleSocketConnectFailure(
socket,
canCreateReplacement,
expectedOpenReply,
attemptGeneration,
exception);
return;
}
connectReply.Then(connected =>
{
if (!connected)
{
HandleSocketConnectFailure(
socket,
canCreateReplacement,
expectedOpenReply,
attemptGeneration,
new AsyncException(
ErrorType.Management,
0,
"The socket did not establish a connection."));
return;
}
try
{
var staleAttempt = false;
lock (_connectLifecycleLock)
{
staleAttempt = !IsCurrentConnectAttemptLocked(
expectedOpenReply,
attemptGeneration);
if (!staleAttempt)
{
_connectAttemptIsRetrying = false;
Assign(socket);
// Some transports begin their receive loop as part of Connect
// and report false here to mean "already started". Preserve that
// valid contract; assignment/handshake failures still throw.
socket.Begin();
}
}
if (staleAttempt)
DestroyStaleSocket(socket);
}
catch (Exception exception)
{
// A connected transport that cannot be assigned or initialize the EP
// handshake has a deterministic configuration/protocol failure. A new
// socket cannot correct it.
HandleSocketConnectFailure(
socket,
canCreateReplacement: false,
expectedOpenReply,
attemptGeneration,
exception);
}
}).Error(exception =>
HandleSocketConnectFailure(
socket,
canCreateReplacement,
expectedOpenReply,
attemptGeneration,
exception));
}
void HandleSocketConnectFailure(
ISocket socket,
bool canCreateReplacement,
AsyncReply<bool> expectedOpenReply,
long attemptGeneration,
Exception exception)
{
if (!IsCurrentConnectAttempt(expectedOpenReply, attemptGeneration))
{
DestroyStaleSocket(socket);
return;
}
if (ReferenceEquals(Socket, socket))
Unassign();
DestroyStaleSocket(socket);
CancellationTokenSource retryCancellation = null;
CancellationTokenSource previousRetryCancellation = null;
if (canCreateReplacement)
{
lock (_connectLifecycleLock)
{
if (_autoReconnect
&& IsCurrentConnectAttemptLocked(expectedOpenReply, attemptGeneration))
{
_connectAttemptIsRetrying = true;
retryCancellation = new CancellationTokenSource();
previousRetryCancellation = _connectRetryCancellation;
_connectRetryCancellation = retryCancellation;
}
}
}
CancelAndDispose(previousRetryCancellation);
if (retryCancellation != null)
{
Global.Log(
"EpConnection",
LogType.Debug,
$"Reconnecting with a fresh socket after: {exception.Message}");
_ = RetryConnectSocketAsync(
retryCancellation,
expectedOpenReply,
attemptGeneration);
return;
}
FailConnectAttempt(expectedOpenReply, attemptGeneration, exception);
}
async Task RetryConnectSocketAsync(
CancellationTokenSource retryCancellation,
AsyncReply<bool> expectedOpenReply,
long attemptGeneration)
{
try
{
await Task.Delay(
TimeSpan.FromSeconds(ReconnectInterval),
retryCancellation.Token);
}
catch (OperationCanceledException) when (retryCancellation.IsCancellationRequested)
{
return;
}
finally
{
lock (_connectLifecycleLock)
{
if (ReferenceEquals(_connectRetryCancellation, retryCancellation))
_connectRetryCancellation = null;
}
retryCancellation.Dispose();
}
lock (_connectLifecycleLock)
{
if (!_autoReconnect
|| !IsCurrentConnectAttemptLocked(expectedOpenReply, attemptGeneration))
return;
}
try
{
connectSocket(
CreateClientSocket(),
canCreateReplacement: true,
expectedOpenReply,
attemptGeneration);
}
catch (Exception retryException)
{
FailConnectAttempt(
expectedOpenReply,
attemptGeneration,
retryException);
}
}
bool IsCurrentConnectAttempt(
AsyncReply<bool> expectedOpenReply,
long attemptGeneration)
{
lock (_connectLifecycleLock)
return IsCurrentConnectAttemptLocked(expectedOpenReply, attemptGeneration);
}
bool IsCurrentConnectAttemptLocked(
AsyncReply<bool> expectedOpenReply,
long attemptGeneration)
=> !_isDestroyed
&& _connectAttemptGeneration == attemptGeneration
&& ReferenceEquals(Volatile.Read(ref _openReply), expectedOpenReply);
void FailConnectAttempt(
AsyncReply<bool> expectedOpenReply,
long attemptGeneration,
Exception exception)
{
var ownsAttempt = false;
CancellationTokenSource retryCancellation = null;
lock (_connectLifecycleLock)
{
if (IsCurrentConnectAttemptLocked(expectedOpenReply, attemptGeneration))
{
ownsAttempt = ReferenceEquals(
Interlocked.CompareExchange(
ref _openReply,
null,
expectedOpenReply),
expectedOpenReply);
if (ownsAttempt)
{
Status = EpConnectionStatus.Closed;
_connectAttemptIsRetrying = false;
retryCancellation = _connectRetryCancellation;
_connectRetryCancellation = null;
}
}
}
if (!ownsAttempt)
return;
CancelAndDispose(retryCancellation);
TriggerOpenError(expectedOpenReply, exception);
}
void DestroyStaleSocket(ISocket socket)
{
if (ReferenceEquals(Socket, socket))
return;
try { socket.Destroy(); } catch { }
}
public async AsyncReply<bool> Reconnect()
@@ -3414,8 +3745,17 @@ public partial class EpConnection : NetworkConnection, IStore
if (AutoReconnect && !_invalidCredentials)
{
Task.Delay(TimeSpan.FromSeconds(ReconnectInterval))
.ContinueWith((x) => Reconnect());
_ = Task.Delay(TimeSpan.FromSeconds(ReconnectInterval))
.ContinueWith(completedDelay =>
{
lock (_connectLifecycleLock)
{
if (!_autoReconnect || _isDestroyed)
return;
_ = Reconnect();
}
});
}
else
{
@@ -15,23 +15,6 @@ namespace Esiur.Protocol;
public class EpConnectionContext : IResourceContext
{
//public EpConnectionContext()
// : base(0, new Map<string, object>(), null, null)
//{
//}
//public override void Build()
//{
// Attributes["AutoConnect"] = AutoReconnect;
// Attributes["ReconnectInterval"] = ReconnectInterval;
// Attributes["UseWebSocket"] = UseWebSocket;
// Attributes["SecureWebSocket"] = SecureWebSocket;
// Attributes["Domain"] = SecureWebSocket;
// Attributes["AuthenticationProtocol"] = SecureWebSocket;
// Attributes["Identity"] = SecureWebSocket;
//}
public ExceptionLevel ExceptionLevel { get; set; }
= ExceptionLevel.Code | ExceptionLevel.Message | ExceptionLevel.Source | ExceptionLevel.Trace;
@@ -74,9 +57,11 @@ public class EpConnectionContext : IResourceContext
//public string Username { get; set; }
public bool UseWebSocket { get; set; }
public bool SecureWebSocket { get; set; }
/// <summary>
/// Uses WebSocket transport when set. The absolute <c>ws</c> or <c>wss</c>
/// URI can include the endpoint path and query string.
/// </summary>
public Uri WebSocketUri { get; set; }
// public string Password { get; set; }
+72 -5
View File
@@ -52,9 +52,11 @@ public class EpServer : NetworkServer<EpConnection>, IResource
readonly object _peerConnectionsLock = new object();
readonly Dictionary<IPAddress, int> _peerConnectionCounts = new Dictionary<IPAddress, int>();
readonly Dictionary<EpConnection, IPAddress> _admittedConnections = new Dictionary<EpConnection, IPAddress>();
readonly Dictionary<EpConnection, IPAddress?> _admittedConnections =
new Dictionary<EpConnection, IPAddress?>();
readonly Dictionary<IPAddress, PeerAttemptWindow> _peerAttemptWindows =
new Dictionary<IPAddress, PeerAttemptWindow>();
readonly PeerAttemptWindow _globalAttemptWindow = new PeerAttemptWindow();
uint _attemptSweepSequence;
@@ -136,6 +138,12 @@ public class EpServer : NetworkServer<EpConnection>, IResource
set;
} = 10518;
/// <summary>
/// Controls whether warehouse initialization opens Esiur's native TCP listener.
/// Disable this when an external host, such as ASP.NET Core, supplies connections.
/// </summary>
public bool EnableTcpListener { get; set; } = true;
//[Attribute]
public ExceptionLevel ExceptionLevel { get; set; }
@@ -157,6 +165,9 @@ public class EpServer : NetworkServer<EpConnection>, IResource
{
if (operation == ResourceOperation.Initialize)
{
if (!EnableTcpListener)
return new AsyncReply<bool>(true);
TcpSocket listener;
if (IP != null)
@@ -194,15 +205,31 @@ public class EpServer : NetworkServer<EpConnection>, IResource
}
public override void Add(EpConnection connection)
=> TryAdd(connection);
/// <summary>
/// Applies admission controls, configures, and tracks an accepted EP connection.
/// The connection must already have its socket assigned so peer-based policies can
/// inspect the actual remote endpoint.
/// </summary>
/// <returns><see langword="true"/> when the connection was admitted.</returns>
public bool TryAdd(EpConnection connection)
{
if (connection == null)
throw new ArgumentNullException(nameof(connection));
if (connection.Socket == null)
throw new InvalidOperationException(
"Assign a socket before adding an EP connection to the server.");
if (!TryAdmitConnection(connection, out var rejectionReason))
{
Global.Log(
"EpServer:ConnectionLimit",
LogType.Warning,
LogType.Debug,
$"Rejected connection from {connection.RemoteEndPoint?.Address}: {rejectionReason}");
connection.Close();
return;
return false;
}
try
@@ -218,6 +245,7 @@ public class EpServer : NetworkServer<EpConnection>, IResource
connection.AuthenticationTimeout = AuthenticationTimeout;
connection.RestartAuthenticationDeadline();
base.Add(connection);
return true;
}
catch
{
@@ -243,14 +271,50 @@ public class EpServer : NetworkServer<EpConnection>, IResource
{
rejectionReason = null;
var address = NormalizeAddress(connection.RemoteEndPoint?.Address);
if (address == null)
return true;
lock (_peerConnectionsLock)
{
var configuration = Instance.Warehouse.Configuration.Connections;
var now = DateTime.UtcNow;
if (_admittedConnections.ContainsKey(connection))
{
rejectionReason = "connection is already admitted";
return false;
}
if (configuration.MaximumConnectionAttempts > 0
&& configuration.ConnectionAttemptWindow > TimeSpan.Zero)
{
if (now - _globalAttemptWindow.StartedUtc
>= configuration.ConnectionAttemptWindow)
{
_globalAttemptWindow.StartedUtc = now;
_globalAttemptWindow.Count = 0;
}
if (_globalAttemptWindow.Count >= configuration.MaximumConnectionAttempts)
{
rejectionReason = "global connection-attempt rate reached";
return false;
}
_globalAttemptWindow.Count++;
}
var globalLimit = configuration.MaximumConnections;
if (globalLimit > 0 && _admittedConnections.Count >= globalLimit)
{
rejectionReason = "global concurrent-connection limit reached";
return false;
}
if (address == null)
{
_admittedConnections[connection] = null;
return true;
}
if (++_attemptSweepSequence % 256 == 0)
{
foreach (var expired in _peerAttemptWindows
@@ -310,6 +374,9 @@ public class EpServer : NetworkServer<EpConnection>, IResource
_admittedConnections.Remove(connection);
if (address == null)
return;
if (!_peerConnectionCounts.TryGetValue(address, out var count))
return;
+42 -28
View File
@@ -41,7 +41,7 @@ Esiur has implementations in C#, JavaScript, and Dart, making it highly versatil
* Game Development: Enable multiplayer synchronization of game states and events, ensuring real-time feedback for interactive experiences.
* Financial Services: Support real-time, distributed data sharing for trading platforms or monitoring systems where latency is critical.
Esiurs robust, feature-rich approach allows developers to focus on building applications without worrying about the complexities of distributed systems, while its self-describing API and broad data type support enable flexible and scalable application design.
Esiurs robust, feature-rich approach allows developers to focus on building applications without worrying about the complexities of distributed systems, while its self-describing API and broad data type support enable flexible and scalable application design.
## Installation
- Nuget
@@ -68,42 +68,53 @@ Esiur for C# uses source generator feature of .Net framework to implement the ne
>```
### Setting up the server
Esiur resources are arrange by stores (IStore) and accessed in a similar *nix file system paths, each store is responsible for storing and retriving of it's resources from memory, files or database.
Esiur resources are arranged into stores (`IStore`) and accessed using paths similar to a Unix file system. Each store is responsible for keeping its resources in memory, files, or a database.
In this example we're going to use the built-in MemoryStore, which keeps its resources in RAM.
This example creates a Warehouse and uses the built-in `MemoryStore`, which keeps its resources in RAM.
```C#
// Warehouse is the singleton instance that holds all stores and active resources.
await Warehouse.Put("sys", new MemoryStore());
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
```
Now we can add our resource to the memory store using ***Warehouse.Put***
Now we can add our resource to the memory store using `warehouse.Put`.
```C#
await Warehouse.Put("sys/hello", new HelloResource());
await warehouse.Put("sys/hello", new HelloResource());
```
To distribute our resource using Esiur EP Protocol we need to add a DistributedServer
Add an `EpServer` to expose the resource through the Esiur EP protocol. Anonymous access is enabled here only to make the development example easy to run; production applications should configure an authentication provider.
```C#
await Warehouse.Put("sys/server", new DistributedServer());
await warehouse.Put("sys/server", new EpServer
{
AllowUnauthorizedAccess = true, // Development only.
});
```
Finally we call ***Warehouse.Open*** to initialize the system.
Finally, open the Warehouse to initialize the system.
```C#
await Warehouse.Open();
await warehouse.Open();
```
To sum up
>***Program.cs***
>```C#
> await Warehouse.Put("sys", new MemoryStore());
> await Warehouse.Put("sys/hello", new HelloResource());
> await Warehouse.Put("sys/server", new DistributedServer());
> await Warehouse.Open();
>using Esiur.Protocol;
>using Esiur.Resource;
>using Esiur.Stores;
>
>var warehouse = new Warehouse();
>await warehouse.Put("sys", new MemoryStore());
>await warehouse.Put("sys/hello", new HelloResource());
>await warehouse.Put("sys/server", new EpServer
>{
> AllowUnauthorizedAccess = true, // Development only.
>});
>await warehouse.Open();
>```
@@ -111,7 +122,8 @@ To sum up
To access our resource remotely, we need to use it's full path including the protocol, host and instance link.
```C#
dynamic res = await Warehouse.Get<IResource>("EP://localhost/sys/hello");
var warehouse = new Warehouse();
dynamic res = await warehouse.Get<IResource>("ep://localhost/sys/hello");
```
Now we can invoke the exported functions and read/write properties;
@@ -126,14 +138,15 @@ Summing up
>***Program.cs***
>```C#
> using Esiur.Resource;
>
> dynamic res = await Warehouse.Get<IResource>("EP://localhost/sys/hello");
>
> var reply = await res.SayHi("Hi, I'm calling you from dotnet");
>
> Console.WriteLine(reply);
> Console.WriteLine($"Number of people said hi {res.Counts}");
>using Esiur.Resource;
>
>var warehouse = new Warehouse();
>dynamic res = await warehouse.Get<IResource>("ep://localhost/sys/hello");
>
>var reply = await res.SayHi("Hi, I'm calling you from dotnet");
>
>Console.WriteLine(reply);
>Console.WriteLine($"Number of people said hi {res.Counts}");
>
>```
@@ -143,7 +156,7 @@ In the above client example, we relied on Esiur support for dynamic objects, but
Esiur has a self describing feature which comes with every language it supports, allowing the developer to fetch and generate classes that match the ones on the other side (i.e. server).
After installing the Esiur nuget package a new command is added to Visual Studio Package Console Manager that is called ***Get-Types***, which generates client side classes for robust static typing.
After installing the Esiur NuGet package, a new command named ***Get-Types*** is added to the Visual Studio Package Manager Console. It generates client-side classes for robust static typing.
```ps
Get-Types ep://localhost/sys/hello
@@ -153,8 +166,9 @@ This will generate and add wrappers for all types needed by our resource.
Allowing us to use
```C#
var res = await Warehouse.Get<MyResource>("EP://localhost/sys/hello");
var reply = await res.SayHi("Static typing is better");
Console.WriteLine(reply);
var warehouse = new Warehouse();
var res = await warehouse.Get<MyResource>("ep://localhost/sys/hello");
var reply = await res.SayHi("Static typing is better");
Console.WriteLine(reply);
```
+198 -86
View File
@@ -43,6 +43,7 @@ using System.Data;
using System.Linq;
using System.Reflection;
using System.Reflection.Metadata;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
@@ -89,7 +90,8 @@ public class Warehouse
KeyList<string, KeyList<TypeDefKind, KeyList<string, Type>>> _proxyTypeDefs = new();
Map<string, IAuthenticationProvider> _authenticationProviders = new Map<string, IAuthenticationProvider>();
readonly ConcurrentDictionary<string, IAuthenticationProvider> _authenticationProviders
= new ConcurrentDictionary<string, IAuthenticationProvider>(StringComparer.Ordinal);
readonly ConcurrentDictionary<string, IEncryptionProvider> _encryptionProviders
= new ConcurrentDictionary<string, IEncryptionProvider>(StringComparer.Ordinal);
readonly ConcurrentDictionary<Type, IResourceManager> _resourceManagers
@@ -102,7 +104,10 @@ public class Warehouse
object _typeDefsLock = new object();
bool _warehouseIsOpen = false;
readonly object _warehouseLifecycleLock = new object();
readonly SemaphoreSlim _warehouseLifecycleGate = new SemaphoreSlim(1, 1);
volatile bool _warehouseIsOpen = false;
bool _warehouseRequiresTermination = false;
public delegate void StoreEvent(IStore store);
public event StoreEvent StoreConnected;
@@ -117,19 +122,46 @@ public class Warehouse
/// </summary>
public WarehouseConfiguration Configuration { get; }
/// <summary>Indicates whether this Warehouse is currently open.</summary>
public bool IsOpen
{
get
{
lock (_warehouseLifecycleLock)
return _warehouseIsOpen;
}
}
private Regex urlRegex = new Regex(@"^(?:([\S]*)://([^/]*)/?)");
public void RegisterAuthenticationProvider(IAuthenticationProvider provider)
{
if (provider == null)
throw new ArgumentNullException(nameof(provider));
RegisterAuthenticationProvider(provider.DefaultName, provider);
}
public void RegisterAuthenticationProvider(string name, IAuthenticationProvider provider)
{
_authenticationProviders.Add(name, provider);
if (string.IsNullOrWhiteSpace(name)
|| !string.Equals(name, name.Trim(), StringComparison.Ordinal))
throw new ArgumentException("An authentication provider name is required.", nameof(name));
if (provider == null)
throw new ArgumentNullException(nameof(provider));
if (!_authenticationProviders.TryAdd(name, provider))
throw new InvalidOperationException(
$"An authentication provider named `{name}` is already registered.");
}
/// <summary>Removes an authentication provider only when the same instance is registered.</summary>
public bool UnregisterAuthenticationProvider(string name, IAuthenticationProvider provider)
=> !string.IsNullOrWhiteSpace(name)
&& provider != null
&& ((ICollection<KeyValuePair<string, IAuthenticationProvider>>)_authenticationProviders)
.Remove(new KeyValuePair<string, IAuthenticationProvider>(name, provider));
/// <summary>
/// Registers an encryption provider using its default protocol name.
/// </summary>
@@ -146,7 +178,8 @@ public class Warehouse
/// </summary>
public void RegisterEncryptionProvider(string name, IEncryptionProvider provider)
{
if (string.IsNullOrWhiteSpace(name))
if (string.IsNullOrWhiteSpace(name)
|| !string.Equals(name, name.Trim(), StringComparison.Ordinal))
throw new ArgumentException("An encryption provider name is required.", nameof(name));
if (provider == null)
throw new ArgumentNullException(nameof(provider));
@@ -154,6 +187,13 @@ public class Warehouse
throw new InvalidOperationException($"An encryption provider named `{name}` is already registered.");
}
/// <summary>Removes an encryption provider only when the same instance is registered.</summary>
public bool UnregisterEncryptionProvider(string name, IEncryptionProvider provider)
=> !string.IsNullOrWhiteSpace(name)
&& provider != null
&& ((ICollection<KeyValuePair<string, IEncryptionProvider>>)_encryptionProviders)
.Remove(new KeyValuePair<string, IEncryptionProvider>(name, provider));
/// <summary>
/// Registers a manager instance by its concrete type. Type attributes and
@@ -560,15 +600,16 @@ public class Warehouse
public IAuthenticationProvider GetAuthenticationProvider(string name)
{
if (_authenticationProviders.ContainsKey(name))
return _authenticationProviders[name];
if (_authenticationProviders.TryGetValue(name, out var provider))
return provider;
throw new Exception("Authentication provider not found.");
}
public IAuthenticationProvider? TryGetAuthenticationProvider(string name)
{
if (_authenticationProviders.ContainsKey(name))
return _authenticationProviders[name];
if (!string.IsNullOrWhiteSpace(name)
&& _authenticationProviders.TryGetValue(name, out var provider))
return provider;
return null;
}
@@ -689,52 +730,70 @@ public class Warehouse
/// <returns>True, if no problem occurred.</returns>
public async AsyncReply<bool> Open()
{
if (_warehouseIsOpen)
return false;
// Load generated models
//LoadGenerated();
_warehouseIsOpen = true;
var resSnap = _resources.Select(x =>
await _warehouseLifecycleGate.WaitAsync();
try
{
IResource r;
if (x.Value.TryGetTarget(out r))
return r;
else
return null;
}).Where(r => r != null).ToArray();
foreach (var r in resSnap)
{
//IResource r;
//if (rk.Value.TryGetTarget(out r))
//{
var rt = await r.Handle(ResourceOperation.Initialize);
//if (!rt)
// return false;
if (!rt)
lock (_warehouseLifecycleLock)
{
Global.Log("Warehouse", LogType.Warning, $"Resource failed at Initialize {r.Instance.Name} [{r.Instance.Definition.Name}]");
if (_warehouseIsOpen || _warehouseRequiresTermination)
return false;
_warehouseIsOpen = true;
_warehouseRequiresTermination = true;
}
//}
}
foreach (var r in resSnap)
{
var rt = await r.Handle(ResourceOperation.SystemReady);
if (!rt)
try
{
Global.Log("Warehouse", LogType.Warning, $"Resource failed at SystemInitialized {r.Instance.Name} [{r.Instance.Definition.Name}]");
// Load generated models
//LoadGenerated();
var resSnap = _resources.Select(x =>
{
IResource r;
if (x.Value.TryGetTarget(out r))
return r;
else
return null;
}).Where(r => r != null).ToArray();
foreach (var r in resSnap)
{
//IResource r;
//if (rk.Value.TryGetTarget(out r))
//{
var rt = await r.Handle(ResourceOperation.Initialize);
//if (!rt)
// return false;
if (!rt)
{
Global.Log("Warehouse", LogType.Warning, $"Resource failed at Initialize {r.Instance.Name} [{r.Instance.Definition.Name}]");
}
//}
}
foreach (var r in resSnap)
{
var rt = await r.Handle(ResourceOperation.SystemReady);
if (!rt)
{
Global.Log("Warehouse", LogType.Warning, $"Resource failed at SystemInitialized {r.Instance.Name} [{r.Instance.Definition.Name}]");
}
}
return true;
}
catch
{
lock (_warehouseLifecycleLock)
_warehouseIsOpen = false;
throw;
}
}
return true;
finally
{
_warehouseLifecycleGate.Release();
}
}
/// <summary>
@@ -742,56 +801,109 @@ public class Warehouse
/// This function issues terminate trigger to all resources and stores.
/// </summary>
/// <returns>True, if no problem occurred.</returns>
public AsyncReply<bool> Close()
public async AsyncReply<bool> Close()
{
await _warehouseLifecycleGate.WaitAsync();
try
{
lock (_warehouseLifecycleLock)
{
if (!_warehouseRequiresTermination)
return false;
var bag = new AsyncBag<bool>();
// Resources added after this point must not initialize while the
// existing graph is terminating.
_warehouseIsOpen = false;
}
// Use the same stable resource graph for both shutdown phases. Stores are
// also retained by _stores, so include that registry in case its weak
// _resources entry was concurrently removed or became unavailable.
var resources = SnapshotResources();
// Every Terminate callback must settle before SystemTerminated starts.
// Each phase captures failures per resource so one synchronous throw or
// failed reply cannot prevent the remaining callbacks from running.
var terminate = await SettleOperation(resources, ResourceOperation.Terminate);
var systemTerminated = await SettleOperation(resources, ResourceOperation.SystemTerminated);
var errors = terminate.Errors.Concat(systemTerminated.Errors).ToArray();
if (errors.Length == 1)
throw errors[0];
if (errors.Length > 1)
throw new AggregateException(
"One or more resources failed while closing the warehouse.",
errors);
return terminate.Success && systemTerminated.Success;
}
finally
{
lock (_warehouseLifecycleLock)
{
_warehouseIsOpen = false;
_warehouseRequiresTermination = false;
}
_warehouseLifecycleGate.Release();
}
}
private IResource[] SnapshotResources()
{
var resources = new HashSet<IResource>(ResourceReferenceComparer.Instance);
foreach (var resource in _resources.Values)
{
IResource r;
if (resource.TryGetTarget(out r))
{
if (!(r is IStore))
bag.Add(r.Handle(ResourceOperation.Terminate));
if (resource.TryGetTarget(out var target))
resources.Add(target);
}
foreach (var store in _stores.Keys)
resources.Add(store);
return resources.ToArray();
}
private static async Task<(bool Success, Exception[] Errors)> SettleOperation(
IResource[] resources,
ResourceOperation operation)
{
var pending = resources
.Select(resource => SettleResourceOperation(resource, operation))
.ToArray();
var outcomes = await Task.WhenAll(pending);
return (
outcomes.All(outcome => outcome.Success),
outcomes
.Where(outcome => outcome.Error != null)
.Select(outcome => outcome.Error!)
.ToArray());
}
private static async Task<(bool Success, Exception? Error)> SettleResourceOperation(
IResource resource,
ResourceOperation operation)
{
try
{
var reply = resource.Handle(operation)
?? throw new InvalidOperationException(
$"Resource `{resource.GetType().FullName}` returned no reply for `{operation}`.");
return (await reply, null);
}
foreach (var store in _stores)
bag.Add(store.Key.Handle(ResourceOperation.Terminate));
foreach (var resource in _resources.Values)
catch (Exception exception)
{
IResource r;
if (resource.TryGetTarget(out r))
{
if (!(r is IStore))
bag.Add(r.Handle(ResourceOperation.SystemTerminated));
}
return (false, exception);
}
}
private sealed class ResourceReferenceComparer : IEqualityComparer<IResource>
{
internal static readonly ResourceReferenceComparer Instance = new ResourceReferenceComparer();
foreach (var store in _stores)
bag.Add(store.Key.Handle(ResourceOperation.SystemTerminated));
public bool Equals(IResource x, IResource y) => ReferenceEquals(x, y);
bag.Seal();
var rt = new AsyncReply<bool>();
bag.Then((x) =>
{
foreach (var b in x)
if (!b)
{
rt.Trigger(false);
return;
}
rt.Trigger(true);
});
return rt;
public int GetHashCode(IResource resource) => RuntimeHelpers.GetHashCode(resource);
}
@@ -65,8 +65,18 @@ public sealed class ResourceAttachmentConfiguration
/// </summary>
public sealed class ConnectionConfiguration
{
/// <summary>Maximum concurrent connections admitted by one EP server.</summary>
public int MaximumConnections { get; set; } = 1_024;
public int MaximumConnectionsPerIpAddress { get; set; } = 64;
/// <summary>
/// Maximum connection attempts admitted by one EP server during
/// <see cref="ConnectionAttemptWindow"/>. This also bounds clients that rotate IP
/// addresses. A value of zero disables the limit.
/// </summary>
public int MaximumConnectionAttempts { get; set; } = 4_096;
/// <summary>
/// Maximum connection attempts admitted from one IP during
/// <see cref="ConnectionAttemptWindow"/>. This limits repeated pre-authentication
@@ -1,6 +1,7 @@
using Esiur.Core;
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
namespace Esiur.Security.Authority.Providers
@@ -12,15 +13,41 @@ namespace Esiur.Security.Authority.Providers
/// </summary>
public const string ProtocolName = "password-sha3-v1";
/// <summary>
/// Previous protocol name, retained only to make explicit migration aliases possible.
/// New connections should use <see cref="ProtocolName"/>.
/// </summary>
[Obsolete("Use ProtocolName (`password-sha3-v1`) for new connections.")]
public const string LegacyProtocolName = "hash";
public string DefaultName => ProtocolName;
/// <summary>
/// Creates the salted credential stored by a server for the
/// <c>password-sha3-v1</c> protocol. Call this once during account enrollment,
/// persist the returned hash and salt, and discard the plaintext password.
/// The hash is a password-equivalent protocol verifier and must be protected
/// from disclosure, logs, and client-visible data.
/// </summary>
public static PasswordHash CreateCredential(byte[] password)
{
if (password == null)
throw new ArgumentNullException(nameof(password));
if (password.Length == 0)
throw new ArgumentException("A password cannot be empty.", nameof(password));
var salt = new byte[32];
using (var random = RandomNumberGenerator.Create())
random.GetBytes(salt);
var material = new byte[password.Length + salt.Length];
try
{
Buffer.BlockCopy(password, 0, material, 0, password.Length);
Buffer.BlockCopy(salt, 0, material, password.Length, salt.Length);
return new PasswordHash(
PasswordAuthenticationHandler.ComputeSha3(material),
salt);
}
finally
{
Array.Clear(material, 0, material.Length);
}
}
public IAuthenticationHandler CreateAuthenticationHandler(AuthenticationContext context)
{
var authHandler = new PasswordAuthenticationHandler(context.Mode,
+42 -28
View File
@@ -68,42 +68,53 @@ Esiur for C# uses source generator feature of .Net framework to implement the ne
>```
### Setting up the server
Esiur resources are arrange by stores (IStore) and accessed in a similar *nix file system paths, each store is responsible for storing and retriving of it's resources from memory, files or database.
Esiur resources are arranged into stores (`IStore`) and accessed using paths similar to a Unix file system. Each store is responsible for keeping its resources in memory, files, or a database.
In this example we're going to use the built-in MemoryStore, which keeps its resources in RAM.
This example creates a Warehouse and uses the built-in `MemoryStore`, which keeps its resources in RAM.
```C#
// Warehouse is the singleton instance that holds all stores and active resources.
await Warehouse.Put("sys", new MemoryStore());
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
```
Now we can add our resource to the memory store using ***Warehouse.Put***
Now we can add our resource to the memory store using `warehouse.Put`.
```C#
await Warehouse.Put("sys/hello", new HelloResource());
await warehouse.Put("sys/hello", new HelloResource());
```
To distribute our resource using Esiur IIP Protocol we need to add a DistributedServer
Add an `EpServer` to expose the resource through the Esiur EP protocol. Anonymous access is enabled here only to make the development example easy to run; production applications should configure an authentication provider.
```C#
await Warehouse.Put("sys/server", new DistributedServer());
await warehouse.Put("sys/server", new EpServer
{
AllowUnauthorizedAccess = true, // Development only.
});
```
Finally we call ***Warehouse.Open*** to initialize the system.
Finally, open the Warehouse to initialize the system.
```C#
await Warehouse.Open();
await warehouse.Open();
```
To sum up
>***Program.cs***
>```C#
> await Warehouse.Put("sys", new MemoryStore());
> await Warehouse.Put("sys/hello", new HelloResource());
> await Warehouse.Put("sys/server", new DistributedServer());
> await Warehouse.Open();
>using Esiur.Protocol;
>using Esiur.Resource;
>using Esiur.Stores;
>
>var warehouse = new Warehouse();
>await warehouse.Put("sys", new MemoryStore());
>await warehouse.Put("sys/hello", new HelloResource());
>await warehouse.Put("sys/server", new EpServer
>{
> AllowUnauthorizedAccess = true, // Development only.
>});
>await warehouse.Open();
>```
@@ -111,7 +122,8 @@ To sum up
To access our resource remotely, we need to use it's full path including the protocol, host and instance link.
```C#
dynamic res = await Warehouse.Get<IResource>("iip://localhost/sys/hello");
var warehouse = new Warehouse();
dynamic res = await warehouse.Get<IResource>("ep://localhost/sys/hello");
```
Now we can invoke the exported functions and read/write properties;
@@ -126,14 +138,15 @@ Summing up
>***Program.cs***
>```C#
> using Esiur.Resource;
>
> dynamic res = await Warehouse.Get<IResource>("iip://localhost/sys/hello");
>
> var reply = await res.SayHi("Hi, I'm calling you from dotnet");
>
> Console.WriteLine(reply);
> Console.WriteLine($"Number of people said hi {res.Counts}");
>using Esiur.Resource;
>
>var warehouse = new Warehouse();
>dynamic res = await warehouse.Get<IResource>("ep://localhost/sys/hello");
>
>var reply = await res.SayHi("Hi, I'm calling you from dotnet");
>
>Console.WriteLine(reply);
>Console.WriteLine($"Number of people said hi {res.Counts}");
>
>```
@@ -143,18 +156,19 @@ In the above client example, we relied on Esiur support for dynamic objects, but
Esiur has a self describing feature which comes with every language it supports, allowing the developer to fetch and generate classes that match the ones on the other side (i.e. server).
After installing the Esiur nuget package a new command is added to Visual Studio Package Console Manager that is called ***Get-Template***, which generates client side classes for robust static typing.
After installing the Esiur NuGet package, a new command named ***Get-Types*** is added to the Visual Studio Package Manager Console. It generates client-side classes for robust static typing.
```ps
Get-Template iip://localhost/sys/hello
Get-Types ep://localhost/sys/hello
```
This will generate and add wrappers for all types needed by our resource.
Allowing us to use
```C#
var res = await Warehouse.Get<MyResource>("iip://localhost/sys/hello");
var reply = await res.SayHi("Static typing is better");
Console.WriteLine(reply);
var warehouse = new Warehouse();
var res = await warehouse.Get<MyResource>("ep://localhost/sys/hello");
var reply = await res.SayHi("Static typing is better");
Console.WriteLine(reply);
```
+849
View File
@@ -0,0 +1,849 @@
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using Esiur.AspNetCore;
using Esiur.Core;
using Esiur.Net.Sockets;
using Esiur.Protocol;
using Esiur.Resource;
using Esiur.Security.Authority.Providers;
using Esiur.Security.Cryptography;
using Esiur.Stores;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace Esiur.Tests.Unit;
public sealed class AspNetCoreIntegrationTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10);
[Fact]
public async Task AddEsiur_ValidatesConfigurationWhenHostStarts()
{
await using var application = BuildApplication(builder =>
builder.AddMemoryStore("sys"));
var exception = await Assert.ThrowsAsync<OptionsValidationException>(
() => application.StartAsync().WaitAsync(TestTimeout));
Assert.Contains(
"Authentication is required",
exception.Message,
StringComparison.Ordinal);
}
[Fact]
public async Task AddEsiur_RejectsAnonymousOnlyRequiredEncryption()
{
await using var application = BuildApplication(builder => builder
.AddMemoryStore("sys")
.AllowAnonymous()
.UseEncryption(new AesEncryptionProvider())
.RequireEncryption());
var exception = await Assert.ThrowsAsync<OptionsValidationException>(
() => application.StartAsync().WaitAsync(TestTimeout));
Assert.Contains(
"Encrypted EP sessions require authentication",
exception.Message,
StringComparison.Ordinal);
}
[Fact]
public void ProviderFacade_ExposesInstancesTypesAndFactoriesWithoutProtocolAliases()
{
var authenticationMethods = typeof(EsiurBuilder).GetMethods()
.Where(method => method.Name == nameof(EsiurBuilder.UseAuthentication))
.ToArray();
var encryptionMethods = typeof(EsiurBuilder).GetMethods()
.Where(method => method.Name == nameof(EsiurBuilder.UseEncryption))
.ToArray();
Assert.Equal(3, authenticationMethods.Length);
Assert.Equal(3, encryptionMethods.Length);
Assert.All(
authenticationMethods.Concat(encryptionMethods),
method => Assert.DoesNotContain(
method.GetParameters(),
parameter => parameter.ParameterType == typeof(string)));
var services = new ServiceCollection();
var esiur = services.AddEsiur();
var authenticationProvider = new PasswordAuthenticationProvider();
var encryptionProvider = new AesEncryptionProvider();
esiur
.UseAuthentication(authenticationProvider)
.UseAuthentication<PasswordAuthenticationProvider>()
.UseAuthentication(_ => authenticationProvider)
.UseEncryption(encryptionProvider)
.UseEncryption<AesEncryptionProvider>()
.UseEncryption(_ => encryptionProvider);
}
[Fact]
public async Task ProviderFacade_RegistersAndAllowsOnlyProviderDefaultNames()
{
var authenticationProvider = new PasswordAuthenticationProvider();
var encryptionProvider = new AesEncryptionProvider();
await using var application = BuildApplication(builder => builder
.AddMemoryStore("sys")
.UseAuthentication(authenticationProvider)
.UseEncryption(encryptionProvider)
.RequireEncryption());
using var cancellation = new CancellationTokenSource(TestTimeout);
await application.StartAsync(cancellation.Token);
var warehouse = application.Services.GetRequiredService<Warehouse>();
var server = application.Services.GetRequiredService<EpServer>();
Assert.Same(
authenticationProvider,
warehouse.TryGetAuthenticationProvider(authenticationProvider.DefaultName));
Assert.Same(
encryptionProvider,
warehouse.TryGetEncryptionProvider(encryptionProvider.DefaultName));
Assert.Equal(
new[] { authenticationProvider.DefaultName },
server.AllowedAuthenticationProviders);
Assert.Equal(
new[] { encryptionProvider.DefaultName },
server.AllowedEncryptionProviders);
}
[Theory]
[InlineData(false, "Warehouse.Configuration.Connections is required")]
[InlineData(true, "MaximumConnections cannot be negative")]
public async Task AddEsiur_RejectsNullOrNegativeConnectionConfiguration(
bool useNegativeLimit,
string expectedFailure)
{
await using var application = BuildApplication(
builder => builder.AddMemoryStore("sys").AllowAnonymous(),
configureWarehouse: configuration =>
{
if (useNegativeLimit)
configuration.Connections.MaximumConnections = -1;
else
configuration.Connections = null!;
});
var exception = await Assert.ThrowsAsync<OptionsValidationException>(
() => application.StartAsync().WaitAsync(TestTimeout));
Assert.Contains(expectedFailure, exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task AddEsiur_RejectsPerConnectionSendLimitAboveHostBudget()
{
await using var application = BuildApplication(builder => builder
.AddMemoryStore("sys")
.AllowAnonymous()
.LimitPendingWebSocketSendBytes(2)
.LimitTotalPendingWebSocketSendBytes(1));
var exception = await Assert.ThrowsAsync<OptionsValidationException>(
() => application.StartAsync().WaitAsync(TestTimeout));
Assert.Contains(
"cannot exceed the host-wide send limit",
exception.Message,
StringComparison.Ordinal);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task AddEsiur_UsesCodeOnlyExceptionsUnlessMessagesAreExplicitlyIncluded(
bool includeMessages)
{
await using var application = BuildApplication(builder =>
{
builder.AddMemoryStore("sys").AllowAnonymous();
if (includeMessages)
builder.IncludeExceptionMessages();
});
var options = application.Services
.GetRequiredService<IOptions<EsiurOptions>>()
.Value;
var expected = ExceptionLevel.Code;
if (includeMessages)
expected |= ExceptionLevel.Message;
Assert.Equal(expected, options.Server.ExceptionLevel);
Assert.Equal(includeMessages, options.Server.ExceptionLevel.HasFlag(ExceptionLevel.Message));
Assert.False(options.Server.ExceptionLevel.HasFlag(ExceptionLevel.Source));
Assert.False(options.Server.ExceptionLevel.HasFlag(ExceptionLevel.Trace));
}
[Fact]
public async Task MappedEndpoint_DoesNotAffectOrdinaryHttp_AndRequiresWebSocketUpgrade()
{
await using var host = await StartApplicationAsync();
using var client = new HttpClient { BaseAddress = host.HttpAddress };
using var healthResponse = await client.GetAsync("/health");
Assert.Equal(HttpStatusCode.OK, healthResponse.StatusCode);
Assert.Equal("healthy", await healthResponse.Content.ReadAsStringAsync());
using var endpointResponse = await client.GetAsync("/esiur");
Assert.Equal(HttpStatusCode.UpgradeRequired, endpointResponse.StatusCode);
Assert.Equal("websocket", endpointResponse.Headers.GetValues("Upgrade").Single());
}
[Theory]
[InlineData(null)]
[InlineData("not-ep")]
public async Task MappedEndpoint_RejectsWebSocketHandshakeWithoutEpSubProtocol(
string? protocol)
{
await using var host = await StartApplicationAsync();
using var client = new HttpClient { BaseAddress = host.HttpAddress };
using var request = CreateWebSocketUpgradeRequest("/esiur", protocol);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains(
$"'{FrameworkWebSocket.SubProtocol}' WebSocket subprotocol is required",
await response.Content.ReadAsStringAsync(),
StringComparison.Ordinal);
}
[Fact]
public async Task MappedEndpoint_RejectsCrossOriginBrowserHandshakeByDefault()
{
await using var host = await StartApplicationAsync();
using var client = new HttpClient { BaseAddress = host.HttpAddress };
using var request = CreateWebSocketUpgradeRequest(
"/esiur",
FrameworkWebSocket.SubProtocol);
request.Headers.TryAddWithoutValidation("Origin", "https://untrusted.example");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task MappedEndpoint_ExplicitOriginAllowlist_ReplacesImplicitSameOriginPolicy()
{
const string trustedOrigin = "https://trusted.example";
await using var host = await StartApplicationAsync(
configureEsiur: builder => builder.AllowWebSocketOrigins(trustedOrigin));
using var cancellation = new CancellationTokenSource(TestTimeout);
using var client = new HttpClient { BaseAddress = host.HttpAddress };
using var sameOriginRequest = CreateWebSocketUpgradeRequest(
"/esiur",
FrameworkWebSocket.SubProtocol);
sameOriginRequest.Headers.TryAddWithoutValidation(
"Origin",
host.HttpAddress.GetLeftPart(UriPartial.Authority));
using var sameOriginResponse = await client.SendAsync(
sameOriginRequest,
cancellation.Token);
Assert.Equal(HttpStatusCode.Forbidden, sameOriginResponse.StatusCode);
using var trustedSocket = new ClientWebSocket();
trustedSocket.Options.AddSubProtocol(FrameworkWebSocket.SubProtocol);
trustedSocket.Options.SetRequestHeader("Origin", trustedOrigin);
await trustedSocket.ConnectAsync(host.WebSocketAddress, cancellation.Token);
Assert.Equal(WebSocketState.Open, trustedSocket.State);
Assert.Equal(FrameworkWebSocket.SubProtocol, trustedSocket.SubProtocol);
await WaitUntilAsync(
() => host.Server.Connections.Count == 1,
cancellation.Token);
trustedSocket.Abort();
await WaitUntilAsync(
() => host.Server.Connections.Count == 0,
cancellation.Token);
}
[Fact]
public async Task MappedEndpoint_AppliesConfiguredPendingSendLimitToAcceptedSocket()
{
const long pendingSendLimit = 4 * 1024;
await using var host = await StartApplicationAsync(
configureEsiur: builder =>
builder.LimitPendingWebSocketSendBytes(pendingSendLimit));
using var cancellation = new CancellationTokenSource(TestTimeout);
var clientSocket = new FrameworkWebSocket(host.WebSocketAddress);
try
{
Assert.True(await clientSocket.Connect(host.WebSocketAddress, cancellation.Token));
Assert.True(clientSocket.Begin());
await WaitUntilAsync(
() => host.Server.Connections.Count == 1,
cancellation.Token);
var connection = Assert.Single(host.Server.Connections);
var acceptedSocket = Assert.IsType<FrameworkWebSocket>(connection.Socket);
Assert.Equal(pendingSendLimit, acceptedSocket.MaximumPendingSendBytes);
Assert.NotNull(acceptedSocket.PendingSendBudget);
}
finally
{
clientSocket.Destroy();
}
await WaitUntilAsync(
() => host.Server.Connections.Count == 0,
cancellation.Token);
}
[Fact]
public async Task FrameworkWebSocket_ConnectsThroughKestrel_WithRealPeerAdmission()
{
await using var host = await StartApplicationAsync(
configureWarehouse: configuration =>
configuration.Connections.MaximumConnectionsPerIpAddress = 1);
using var cancellation = new CancellationTokenSource(TestTimeout);
var socket = new FrameworkWebSocket(host.WebSocketAddress);
try
{
Assert.True(await socket.Connect(host.WebSocketAddress, cancellation.Token));
Assert.Equal(SocketState.Established, socket.State);
Assert.True(socket.Begin());
await WaitUntilAsync(
() => host.Server.Connections.Count == 1,
cancellation.Token);
var connection = Assert.Single(host.Server.Connections);
Assert.Equal(IPAddress.Loopback, connection.RemoteEndPoint.Address);
Assert.Equal(1, host.Server.GetConnectionCount(IPAddress.Loopback));
var rejectedSocket = new FrameworkWebSocket(host.WebSocketAddress);
try
{
// The HTTP upgrade can complete before the EP admission decision closes the
// second transport. What matters is that it is never added to the server.
if (await rejectedSocket.Connect(host.WebSocketAddress, cancellation.Token))
rejectedSocket.Begin();
await WaitUntilAsync(
() => rejectedSocket.State == SocketState.Closed,
cancellation.Token);
Assert.Single(host.Server.Connections);
Assert.Equal(1, host.Server.GetConnectionCount(IPAddress.Loopback));
}
finally
{
rejectedSocket.Destroy();
}
}
finally
{
socket.Destroy();
}
using var cleanupCancellation = new CancellationTokenSource(TestTimeout);
await WaitUntilAsync(
() => host.Server.Connections.Count == 0
&& host.Server.GetConnectionCount(IPAddress.Loopback) == 0,
cleanupCancellation.Token);
}
[Fact]
public async Task HostShutdown_CancelsWebSocketAndCleansUpAdmission()
{
await using var host = await StartApplicationAsync();
using var cancellation = new CancellationTokenSource(TestTimeout);
var socket = new FrameworkWebSocket(host.WebSocketAddress);
try
{
Assert.True(await socket.Connect(host.WebSocketAddress, cancellation.Token));
Assert.True(socket.Begin());
await WaitUntilAsync(
() => host.Server.Connections.Count == 1,
cancellation.Token);
await host.Application.StopAsync(cancellation.Token);
Assert.True(socket.Completion.IsCompleted);
Assert.Equal(SocketState.Closed, socket.State);
Assert.Empty(host.Server.Connections);
Assert.Equal(0, host.Server.GetConnectionCount(IPAddress.Loopback));
}
finally
{
socket.Destroy();
}
}
[Fact]
public async Task AspNetHosting_DoesNotOpenTheNativeEpTcpListener()
{
var nativePort = GetUnusedTcpPort();
await using var host = await StartApplicationAsync(
configureServer: server => server.Port = checked((ushort)nativePort));
Assert.False(host.Server.EnableTcpListener);
Assert.False(host.Server.IsRunning);
var probe = new TcpListener(IPAddress.Loopback, nativePort);
try
{
probe.Start();
}
finally
{
probe.Stop();
}
}
[Fact]
public async Task ExternalWarehouse_MustBeOpenBeforeEsiurStarts()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
var server = await warehouse.Put("sys/server", new EpServer
{
EnableTcpListener = false,
AuthenticationTimeout = TestTimeout,
});
var builder = WebApplication.CreateBuilder();
builder.WebHost.ConfigureKestrel(options =>
options.Listen(IPAddress.Loopback, 0));
builder.Services
.AddEsiur(warehouse, server, manageWarehouseLifecycle: false)
.UsePasswordAuthentication((_, _) => null);
await using var application = builder.Build();
application.UseWebSockets();
application.MapEsiur();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => application.StartAsync().WaitAsync(TestTimeout));
Assert.Contains(
"externally managed Warehouse must be open",
exception.Message,
StringComparison.Ordinal);
Assert.Null(warehouse.TryGetAuthenticationProvider(
PasswordAuthenticationProvider.ProtocolName));
Assert.Empty(server.AllowedAuthenticationProviders);
}
[Fact]
public async Task ExternalWarehouse_DoesNotRetainFacadeAuthenticationOnShutdown()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
var server = await warehouse.Put("sys/server", new EpServer
{
EnableTcpListener = false,
AuthenticationTimeout = TestTimeout,
});
await warehouse.Open();
var builder = WebApplication.CreateBuilder();
builder.WebHost.ConfigureKestrel(options =>
options.Listen(IPAddress.Loopback, 0));
builder.Services
.AddEsiur(warehouse, server, manageWarehouseLifecycle: false)
.UsePasswordAuthentication((_, _) => null);
await using var application = builder.Build();
application.UseWebSockets();
application.MapEsiur();
try
{
using var cancellation = new CancellationTokenSource(TestTimeout);
await application.StartAsync(cancellation.Token);
Assert.NotNull(warehouse.TryGetAuthenticationProvider(
PasswordAuthenticationProvider.ProtocolName));
Assert.Equal(
new[] { PasswordAuthenticationProvider.ProtocolName },
server.AllowedAuthenticationProviders);
await application.StopAsync(cancellation.Token);
Assert.Null(warehouse.TryGetAuthenticationProvider(
PasswordAuthenticationProvider.ProtocolName));
Assert.Empty(server.AllowedAuthenticationProviders);
Assert.True(warehouse.IsOpen);
}
finally
{
if (warehouse.IsOpen)
await warehouse.Close();
}
}
[Fact]
public async Task PasswordShortcut_UsesValidatedNonAliasedCredentialsAndUnlinkableDummies()
{
var knownHash = Enumerable.Range(1, 32).Select(value => (byte)value).ToArray();
var knownSalt = Enumerable.Range(33, 32).Select(value => (byte)value).ToArray();
await using var application = BuildApplication(builder => builder
.AddMemoryStore("sys")
.UsePasswordAuthentication((identity, _) => identity switch
{
"known" => new PasswordHash(knownHash, knownSalt),
"bad-hash" => new PasswordHash(new byte[31], new byte[32]),
"bad-salt" => new PasswordHash(new byte[32], new byte[31]),
"partial" => new PasswordHash(new byte[32], null!),
_ => null,
}));
using var cancellation = new CancellationTokenSource(TestTimeout);
await application.StartAsync(cancellation.Token);
var warehouse = application.Services.GetRequiredService<Warehouse>();
var provider = Assert.IsAssignableFrom<PasswordAuthenticationProvider>(
warehouse.TryGetAuthenticationProvider(
PasswordAuthenticationProvider.ProtocolName));
var firstUnknown = provider.GetHostedAccountCredential("missing", "example");
var secondUnknown = provider.GetHostedAccountCredential("missing", "example");
var otherUnknown = provider.GetHostedAccountCredential("other", "example");
var originalDummyHash = (byte[])firstUnknown.Hash.Clone();
var originalDummySalt = (byte[])firstUnknown.Salt.Clone();
Assert.Equal(32, firstUnknown.Hash.Length);
Assert.Equal(32, firstUnknown.Salt.Length);
Assert.Equal(originalDummyHash, secondUnknown.Hash);
Assert.Equal(originalDummySalt, secondUnknown.Salt);
Assert.NotSame(firstUnknown.Hash, secondUnknown.Hash);
Assert.NotSame(firstUnknown.Salt, secondUnknown.Salt);
Assert.NotEqual(originalDummyHash, otherUnknown.Hash);
Assert.NotEqual(originalDummySalt, otherUnknown.Salt);
Assert.NotEqual(knownHash, originalDummyHash);
Assert.NotEqual(knownSalt, originalDummySalt);
firstUnknown.Hash[0] ^= 0xff;
firstUnknown.Salt[0] ^= 0xff;
var thirdUnknown = provider.GetHostedAccountCredential("missing", "example");
Assert.Equal(originalDummyHash, thirdUnknown.Hash);
Assert.Equal(originalDummySalt, thirdUnknown.Salt);
var firstKnown = provider.GetHostedAccountCredential("known", "example");
Assert.Equal(knownHash, firstKnown.Hash);
Assert.Equal(knownSalt, firstKnown.Salt);
Assert.NotSame(knownHash, firstKnown.Hash);
Assert.NotSame(knownSalt, firstKnown.Salt);
firstKnown.Hash[0] ^= 0xff;
firstKnown.Salt[0] ^= 0xff;
var secondKnown = provider.GetHostedAccountCredential("known", "example");
Assert.Equal(knownHash, secondKnown.Hash);
Assert.Equal(knownSalt, secondKnown.Salt);
Assert.Throws<InvalidOperationException>(() =>
provider.GetHostedAccountCredential("bad-hash", "example"));
Assert.Throws<InvalidOperationException>(() =>
provider.GetHostedAccountCredential("bad-salt", "example"));
Assert.Throws<InvalidOperationException>(() =>
provider.GetHostedAccountCredential("partial", "example"));
Assert.Null(provider.GetHostedAccountCredential(
new string('i', 513),
"example").Hash);
Assert.Null(provider.GetHostedAccountCredential(
"missing",
new string('d', 513)).Hash);
}
[Fact]
public async Task StartupFailure_RollsBackResourcesOwnedByTheFacade()
{
var attachedBeforeFailure = new LifecycleTestResource();
var authenticationProvider = new PasswordAuthenticationProvider();
await using var application = BuildApplication(builder => builder
.AddMemoryStore("sys")
.AddResource("sys/first", attachedBeforeFailure)
.AddResource<LifecycleTestResource>(
"sys/failure",
_ => throw new InvalidOperationException("factory failed"))
.UseAuthentication(authenticationProvider));
var warehouse = application.Services.GetRequiredService<Warehouse>();
var server = application.Services.GetRequiredService<EpServer>();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => application.StartAsync().WaitAsync(TestTimeout));
Assert.Contains("factory failed", exception.Message, StringComparison.Ordinal);
Assert.Null(attachedBeforeFailure.Instance);
Assert.Null(warehouse.GetStore("sys"));
Assert.Null(server.Instance);
Assert.False(warehouse.IsOpen);
Assert.Equal(0, attachedBeforeFailure.TerminationCount);
Assert.True(server.EnableTcpListener);
Assert.Empty(server.AllowedAuthenticationProviders);
Assert.Null(warehouse.TryGetAuthenticationProvider(
PasswordAuthenticationProvider.ProtocolName));
}
[Fact]
public async Task StartupCancellationDuringWarehouseOpen_CompletesRollback()
{
var resource = new BlockingStartupResource();
await using var application = BuildApplication(builder => builder
.AddMemoryStore("sys")
.AddResource("sys/blocking", resource)
.AllowAnonymous());
using var cancellation = new CancellationTokenSource();
var start = application.StartAsync(cancellation.Token);
await resource.InitializeStarted.WaitAsync(TestTimeout);
cancellation.Cancel();
resource.ReleaseInitialize();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => start.WaitAsync(TestTimeout));
Assert.Equal(1, resource.TerminationCount);
Assert.Null(resource.Instance);
}
[Fact]
public async Task HostShutdown_ObservesCancellationWhenResourceTerminationStalls()
{
var stalledResource = new LifecycleTestResource(stallTermination: true);
await using var host = await StartApplicationAsync(
configureEsiur: builder =>
builder.AddResource("sys/stalled", stalledResource));
using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
var stop = host.Application.StopAsync(cancellation.Token);
await Task.Delay(TimeSpan.FromMilliseconds(200));
stalledResource.ReleaseTermination();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => stop);
Assert.False(host.Server.IsRunning);
Assert.Empty(host.Server.Connections);
Assert.Null(stalledResource.Instance);
Assert.False(host.Application.Services.GetRequiredService<Warehouse>().IsOpen);
}
private static WebApplication BuildApplication(
Action<EsiurBuilder>? configureEsiur = null,
Action<EpServer>? configureServer = null,
Action<WarehouseConfiguration>? configureWarehouse = null)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
EnvironmentName = Environments.Development,
});
builder.WebHost.ConfigureKestrel(options =>
options.Listen(IPAddress.Loopback, 0));
var warehouse = new Warehouse();
var server = new EpServer
{
AuthenticationTimeout = TestTimeout,
};
var esiur = builder.Services.AddEsiur(warehouse, server);
configureEsiur?.Invoke(esiur);
if (configureServer is not null)
esiur.ConfigureServer(configureServer);
if (configureWarehouse is not null)
esiur.ConfigureWarehouse(configureWarehouse);
var application = builder.Build();
application.UseWebSockets();
application.MapGet("/health", () => Results.Text("healthy"));
application.MapEsiur("/esiur");
return application;
}
private static async Task<TestApplication> StartApplicationAsync(
Action<EpServer>? configureServer = null,
Action<WarehouseConfiguration>? configureWarehouse = null,
Action<EsiurBuilder>? configureEsiur = null)
{
var application = BuildApplication(
esiur =>
{
esiur.AddMemoryStore("sys").AllowAnonymous();
configureEsiur?.Invoke(esiur);
},
configureServer,
configureWarehouse);
using var cancellation = new CancellationTokenSource(TestTimeout);
await application.StartAsync(cancellation.Token);
var addresses = application.Services
.GetRequiredService<IServer>()
.Features
.Get<IServerAddressesFeature>()
?.Addresses;
var address = Assert.Single(addresses!);
var httpAddress = new Uri(address);
var webSocketAddress = new UriBuilder(httpAddress)
{
Scheme = "ws",
Path = "/esiur",
}.Uri;
return new TestApplication(application, httpAddress, webSocketAddress);
}
private static HttpRequestMessage CreateWebSocketUpgradeRequest(
string path,
string? protocol)
{
var request = new HttpRequestMessage(HttpMethod.Get, path)
{
Version = HttpVersion.Version11,
VersionPolicy = HttpVersionPolicy.RequestVersionExact,
};
request.Headers.TryAddWithoutValidation("Connection", "Upgrade");
request.Headers.TryAddWithoutValidation("Upgrade", "websocket");
request.Headers.TryAddWithoutValidation("Sec-WebSocket-Version", "13");
request.Headers.TryAddWithoutValidation(
"Sec-WebSocket-Key",
Convert.ToBase64String(Guid.NewGuid().ToByteArray()));
if (protocol is not null)
request.Headers.TryAddWithoutValidation("Sec-WebSocket-Protocol", protocol);
return request;
}
private static async Task WaitUntilAsync(
Func<bool> condition,
CancellationToken cancellationToken)
{
while (!condition())
await Task.Delay(10, cancellationToken);
}
private static int GetUnusedTcpPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
finally
{
listener.Stop();
}
}
private sealed class TestApplication : IAsyncDisposable
{
public TestApplication(
WebApplication application,
Uri httpAddress,
Uri webSocketAddress)
{
Application = application;
HttpAddress = httpAddress;
WebSocketAddress = webSocketAddress;
Server = application.Services.GetRequiredService<EpServer>();
}
public WebApplication Application { get; }
public Uri HttpAddress { get; }
public Uri WebSocketAddress { get; }
public EpServer Server { get; }
public async ValueTask DisposeAsync()
{
try
{
using var cancellation = new CancellationTokenSource(TestTimeout);
await Application.StopAsync(cancellation.Token);
}
finally
{
await Application.DisposeAsync();
}
}
}
private sealed class LifecycleTestResource : IResource
{
private readonly bool stallTermination;
private readonly AsyncReply<bool> termination = new();
private int terminationCount;
public LifecycleTestResource(bool stallTermination = false)
{
this.stallTermination = stallTermination;
}
public event DestroyedEvent? OnDestroy;
public Instance? Instance { get; set; }
public int TerminationCount => Volatile.Read(ref terminationCount);
public void ReleaseTermination() => termination.Trigger(true);
public AsyncReply<bool> Handle(
ResourceOperation operation,
IResourceContext? context = null)
{
if (operation == ResourceOperation.Terminate)
{
Interlocked.Increment(ref terminationCount);
if (stallTermination)
return termination;
}
return new AsyncReply<bool>(true);
}
public void Destroy() => OnDestroy?.Invoke(this);
}
private sealed class BlockingStartupResource : IResource
{
private readonly AsyncReply<bool> initialize = new();
private readonly TaskCompletionSource initializeStarted = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private int terminationCount;
public event DestroyedEvent? OnDestroy;
public Instance? Instance { get; set; }
public Task InitializeStarted => initializeStarted.Task;
public int TerminationCount => Volatile.Read(ref terminationCount);
public void ReleaseInitialize() => initialize.Trigger(true);
public AsyncReply<bool> Handle(
ResourceOperation operation,
IResourceContext? context = null)
{
if (operation == ResourceOperation.Initialize)
{
initializeStarted.TrySetResult();
return initialize;
}
if (operation == ResourceOperation.Terminate)
Interlocked.Increment(ref terminationCount);
return new AsyncReply<bool>(true);
}
public void Destroy() => OnDestroy?.Invoke(this);
}
}
+233
View File
@@ -0,0 +1,233 @@
using System.Net;
using Esiur.Core;
using Esiur.Net;
using Esiur.Net.Sockets;
using Esiur.Protocol;
using Esiur.Resource;
namespace Esiur.Tests.Unit;
public sealed class EpConnectionReconnectTests
{
[Fact]
public async Task InitialAutoReconnect_CreatesAFreshSocketAfterConnectFailure()
{
var failedSocket = new ConnectTestSocket(connects: false);
var connectedSocket = new ConnectTestSocket(connects: true);
var sockets = new Queue<ConnectTestSocket>(
new[] { failedSocket, connectedSocket });
var connection = new EpConnection
{
AutoReconnect = true,
ReconnectInterval = 0,
ClientSocketFactory = () => sockets.Dequeue(),
};
var warehouse = new Warehouse();
await warehouse.Put("client", connection);
var open = connection.Connect(
hostname: "localhost",
port: 10518,
domain: "test");
var completed = await Task.WhenAny(
connectedSocket.Began,
Task.Delay(TimeSpan.FromSeconds(2)));
Assert.True(
ReferenceEquals(completed, connectedSocket.Began),
$"Fresh socket was not started (remaining={sockets.Count}, " +
$"failed-connects={failedSocket.ConnectCount}, " +
$"replacement-connects={connectedSocket.ConnectCount}, " +
$"open-error={open.Exception?.Message}).");
Assert.Equal(1, failedSocket.ConnectCount);
Assert.Equal(1, connectedSocket.ConnectCount);
Assert.Equal(1, connectedSocket.BeginCount);
Assert.Same(connectedSocket, connection.Socket);
Assert.Empty(sockets);
connection.Destroy();
}
[Fact]
public async Task DestroyDuringPendingConnect_DoesNotAttachLateSocket()
{
var delayedSocket = new ConnectTestSocket(connects: null);
var connection = new EpConnection
{
ClientSocketFactory = () => delayedSocket,
};
var warehouse = new Warehouse();
await warehouse.Put("client", connection);
var open = connection.Connect(
hostname: "localhost",
port: 10518,
domain: "test");
await delayedSocket.ConnectInvoked.WaitAsync(TimeSpan.FromSeconds(2));
connection.Destroy();
delayedSocket.CompleteConnect(true);
Assert.Null(connection.Socket);
Assert.Equal(0, delayedSocket.BeginCount);
Assert.Equal(SocketState.Closed, delayedSocket.State);
Assert.NotNull(open.Exception);
}
[Fact]
public async Task DisablingAutoReconnect_CompletesDelayedOpenAndAllowsAnotherConnect()
{
var failedSocket = new ConnectTestSocket(connects: false);
var factoryCalls = 0;
var connection = new EpConnection
{
AutoReconnect = true,
ReconnectInterval = 30,
ClientSocketFactory = () =>
{
factoryCalls++;
return failedSocket;
},
};
var warehouse = new Warehouse();
await warehouse.Put("client", connection);
var open = connection.Connect(
hostname: "localhost",
port: 10518,
domain: "test");
connection.AutoReconnect = false;
Assert.NotNull(open.Exception);
Assert.Equal(EpConnectionStatus.Closed, connection.Status);
Assert.Equal(1, factoryCalls);
var replacement = new ConnectTestSocket(connects: true);
var secondOpen = connection.Connect(replacement);
Assert.Null(secondOpen.Exception);
Assert.Same(replacement, connection.Socket);
connection.Destroy();
}
[Fact]
public async Task DisablingAutoReconnectDuringDisconnectDelay_PreventsReconnect()
{
var initialSocket = new ConnectTestSocket(connects: true);
var factoryCalls = 0;
var connection = new EpConnection
{
AutoReconnect = true,
ReconnectInterval = 1,
ClientSocketFactory = () =>
{
factoryCalls++;
return new ConnectTestSocket(connects: true);
},
};
var warehouse = new Warehouse();
await warehouse.Put("client", connection);
_ = connection.Connect(
initialSocket,
hostname: "localhost",
port: 10518,
domain: "test");
initialSocket.Disconnect();
connection.AutoReconnect = false;
await Task.Delay(TimeSpan.FromMilliseconds(1_200));
Assert.Equal(0, factoryCalls);
connection.Destroy();
}
private sealed class ConnectTestSocket : ISocket
{
private readonly bool? connects;
private readonly TaskCompletionSource began = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource connectInvoked = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private AsyncReply<bool>? pendingConnect;
private SocketState state = SocketState.Initial;
public ConnectTestSocket(bool? connects) => this.connects = connects;
public event DestroyedEvent? OnDestroy;
public SocketState State => state;
public INetworkReceiver<ISocket> Receiver { get; set; } = null!;
public IPEndPoint RemoteEndPoint { get; } =
new(IPAddress.Loopback, 10518);
public IPEndPoint LocalEndPoint { get; } =
new(IPAddress.Loopback, 50000);
public int ConnectCount { get; private set; }
public int BeginCount { get; private set; }
public Task Began => began.Task;
public Task ConnectInvoked => connectInvoked.Task;
public AsyncReply<bool> Connect(string hostname, ushort port)
{
ConnectCount++;
connectInvoked.TrySetResult();
if (connects is null)
{
pendingConnect = new AsyncReply<bool>();
return pendingConnect;
}
if (connects.Value)
{
state = SocketState.Established;
return new AsyncReply<bool>(true);
}
state = SocketState.Closed;
var reply = new AsyncReply<bool>();
reply.TriggerError(new InvalidOperationException("connect failed"));
return reply;
}
public void CompleteConnect(bool connected)
{
var reply = pendingConnect
?? throw new InvalidOperationException("No connection is pending.");
pendingConnect = null;
state = connected ? SocketState.Established : SocketState.Closed;
reply.Trigger(connected);
}
public void Disconnect()
{
state = SocketState.Closed;
Receiver.NetworkClose(this);
}
public bool Begin()
{
BeginCount++;
began.TrySetResult();
return state == SocketState.Established;
}
public AsyncReply<bool> BeginAsync() => new(Begin());
public AsyncReply<bool> SendAsync(byte[] message, int offset, int length) =>
new(state == SocketState.Established);
public void Send(byte[] message) { }
public void Send(byte[] message, int offset, int length) { }
public void Hold() { }
public void Unhold() { }
public void Close() => state = SocketState.Closed;
public void Destroy()
{
state = SocketState.Closed;
OnDestroy?.Invoke(this);
}
public AsyncReply<ISocket> AcceptAsync() =>
throw new NotSupportedException();
public ISocket Accept() => throw new NotSupportedException();
}
}
+6 -1
View File
@@ -14,6 +14,10 @@
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
@@ -22,6 +26,7 @@
mirroring Tests/Features/Functional so [Resource]/[Export] test types get generated code. -->
<ItemGroup>
<ProjectReference Include="..\..\Libraries\Esiur\Esiur.csproj" OutputItemType="Analyzer" />
<ProjectReference Include="..\..\Integrations\Esiur.AspNetCore\Esiur.AspNetCore.csproj" />
</ItemGroup>
</Project>
</Project>
+604
View File
@@ -0,0 +1,604 @@
using Esiur.Net;
using Esiur.Net.Sockets;
using System.Collections.Concurrent;
using System.Net;
using System.Net.WebSockets;
namespace Esiur.Tests.Unit;
public class FrameworkWebSocketTests
{
[Fact]
public async Task AcceptedSocket_ReceivesBinaryDataAndCompletesOnceOnPeerClose()
{
var platformSocket = new TestWebSocket();
platformSocket.QueueBinary(new byte[] { 1, 2, 3 });
platformSocket.QueueClose();
var local = new IPEndPoint(IPAddress.Loopback, 443);
var remote = new IPEndPoint(IPAddress.Parse("192.0.2.10"), 55123);
var socket = new FrameworkWebSocket(platformSocket, local, remote);
var receiver = new RecordingReceiver();
socket.Receiver = receiver;
Assert.True(socket.Begin());
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(local, socket.LocalEndPoint);
Assert.Equal(remote, socket.RemoteEndPoint);
Assert.Equal(new byte[] { 1, 2, 3 }, Assert.Single(receiver.Messages));
Assert.Equal(1, receiver.CloseCount);
Assert.Equal(1, platformSocket.CloseOutputCount);
socket.Close();
socket.Destroy();
Assert.Equal(1, receiver.CloseCount);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("ep")]
[InlineData("not-ep")]
public void AcceptedSocket_RequiresExactEpSubprotocol(string? subProtocol)
{
var platformSocket = new TestWebSocket(subProtocol);
var exception = Assert.Throws<ArgumentException>(() =>
new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Parse("192.0.2.10"), 55123)));
Assert.Contains("'EP' subprotocol", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task OwnerCancellation_EndsReceiveLoopWithoutFaultOrDuplicateClose()
{
var platformSocket = new TestWebSocket();
using var cancellation = new CancellationTokenSource();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000),
cancellation.Token);
var receiver = new RecordingReceiver();
socket.Receiver = receiver;
Assert.True(socket.Begin());
cancellation.Cancel();
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
socket.Close();
socket.Destroy();
socket.Destroy();
Assert.Equal(1, receiver.CloseCount);
Assert.Equal(SocketState.Closed, socket.State);
}
[Fact]
public async Task LocalClose_WaitsForPeerAcknowledgementBeforeCompleting()
{
var platformSocket = new TestWebSocket();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000));
var receiver = new RecordingReceiver();
socket.Receiver = receiver;
Assert.True(socket.Begin());
var completion = socket.CloseAsync();
await Task.Delay(25);
Assert.False(completion.IsCompleted);
Assert.Equal(1, platformSocket.CloseOutputCount);
platformSocket.QueueClose();
await completion.WaitAsync(TimeSpan.FromSeconds(2));
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(1, receiver.CloseCount);
Assert.Equal(SocketState.Closed, socket.State);
}
[Fact]
public async Task SimultaneousPeerClose_AcknowledgesAfterAnInFlightSend()
{
var platformSocket = new TestWebSocket();
platformSocket.BlockSends();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000));
socket.Receiver = new RecordingReceiver();
Assert.True(socket.Begin());
var send = socket.SendAsync(new byte[] { 1 }, 0, 1);
await platformSocket.SendStarted.WaitAsync(TimeSpan.FromSeconds(2));
var completion = socket.CloseAsync();
platformSocket.QueueClose();
platformSocket.ReleaseSends();
await completion.WaitAsync(TimeSpan.FromSeconds(2));
Assert.True(await send);
Assert.Equal(1, platformSocket.CloseOutputCount);
}
[Fact]
public async Task SendQueue_OverflowAbortsOnceAndSettlesEveryReply()
{
var platformSocket = new TestWebSocket();
platformSocket.BlockSends();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000))
{
MaximumPendingSendBytes = 4,
};
var receiver = new RecordingReceiver();
socket.Receiver = receiver;
var first = socket.SendAsync(new byte[] { 1, 2 }, 0, 2);
await platformSocket.SendStarted.WaitAsync(TimeSpan.FromSeconds(2));
var second = socket.SendAsync(new byte[] { 3, 4 }, 0, 2);
var overflow = socket.SendAsync(new byte[] { 5 }, 0, 1);
Assert.True(overflow.Failed);
Assert.Contains("send queue exceeded", overflow.Exception?.Message);
Assert.True(second.Failed);
Assert.Contains("send queue exceeded", second.Exception?.Message);
Assert.False(await first);
await Assert.ThrowsAsync<InvalidOperationException>(() => socket.Completion);
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(SocketState.Closed, socket.State);
Assert.Equal(0, socket.PendingSendBytes);
Assert.Equal(1, platformSocket.AbortCount);
Assert.Equal(1, receiver.CloseCount);
Assert.Empty(platformSocket.SentMessages);
var later = socket.SendAsync(new byte[] { 6 }, 0, 1);
Assert.True(later.Ready);
Assert.False(await later);
socket.Destroy();
Assert.Equal(1, platformSocket.AbortCount);
Assert.Equal(1, receiver.CloseCount);
}
[Fact]
public async Task SynchronousSend_OverflowAbortsBeforeThrowing()
{
var platformSocket = new TestWebSocket();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000))
{
MaximumPendingSendBytes = 1,
};
var receiver = new RecordingReceiver();
socket.Receiver = receiver;
socket.Hold();
socket.Send(new byte[] { 1 }, 0, 1);
var error = Assert.Throws<InvalidOperationException>(() =>
socket.Send(new byte[] { 2 }, 0, 1));
Assert.Contains("send queue exceeded", error.Message);
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(SocketState.Closed, socket.State);
Assert.Equal(0, socket.PendingSendBytes);
Assert.Equal(1, platformSocket.AbortCount);
Assert.Equal(1, receiver.CloseCount);
await Assert.ThrowsAsync<InvalidOperationException>(() => socket.Completion);
}
[Fact]
public async Task SharedSendBudget_BoundsAggregateQueuedBytesAcrossSockets()
{
var budget = new TestPendingSendBudget(3);
var firstPlatform = new TestWebSocket();
firstPlatform.BlockSends();
var firstSocket = CreateAcceptedSocket(firstPlatform, budget);
var secondSocket = CreateAcceptedSocket(new TestWebSocket(), budget);
var firstSend = firstSocket.SendAsync(new byte[] { 1, 2 }, 0, 2);
await firstPlatform.SendStarted.WaitAsync(TimeSpan.FromSeconds(2));
var overflow = secondSocket.SendAsync(new byte[] { 3, 4 }, 0, 2);
Assert.True(overflow.Failed);
Assert.Contains("host-wide", overflow.Exception?.Message);
Assert.Equal(SocketState.Closed, secondSocket.State);
Assert.Equal(2, budget.ReservedBytes);
firstSocket.Destroy();
Assert.False(await firstSend);
await firstSocket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(0, budget.ReservedBytes);
await Assert.ThrowsAsync<InvalidOperationException>(() => secondSocket.Completion);
}
[Fact]
public async Task Completion_DoesNotWaitForAnInProgressSendReplyCallback()
{
var platformSocket = new TestWebSocket();
platformSocket.BlockSends();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000));
using var callbackStarted = new ManualResetEventSlim();
using var releaseCallback = new ManualResetEventSlim();
try
{
var send = socket.SendAsync(new byte[] { 1 }, 0, 1);
_ = send.Then(_ =>
{
callbackStarted.Set();
releaseCallback.Wait();
});
await platformSocket.SendStarted.WaitAsync(TimeSpan.FromSeconds(2));
platformSocket.ReleaseSends();
Assert.True(callbackStarted.Wait(TimeSpan.FromSeconds(2)));
socket.Destroy();
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
Assert.False(releaseCallback.IsSet);
Assert.True(send.Ready);
Assert.True(await send);
}
finally
{
releaseCallback.Set();
socket.Destroy();
}
}
[Fact]
public async Task Destroy_DoesNotWaitForABlockedQueuedSendReplyCallback()
{
var platformSocket = new TestWebSocket();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000));
using var callbackStarted = new ManualResetEventSlim();
using var releaseCallback = new ManualResetEventSlim();
try
{
socket.Hold();
var send = socket.SendAsync(new byte[] { 1 }, 0, 1);
_ = send.Then(_ =>
{
callbackStarted.Set();
releaseCallback.Wait();
});
socket.Destroy();
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
Assert.True(send.Ready);
Assert.False(await send);
Assert.True(callbackStarted.Wait(TimeSpan.FromSeconds(2)));
Assert.False(releaseCallback.IsSet);
}
finally
{
releaseCallback.Set();
socket.Destroy();
}
}
[Fact]
public async Task TextMessage_FaultsCompletionAndNotifiesCloseOnce()
{
var platformSocket = new TestWebSocket();
platformSocket.QueueText("not EP");
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000));
var receiver = new RecordingReceiver();
socket.Receiver = receiver;
Assert.True(socket.Begin());
await Assert.ThrowsAsync<InvalidDataException>(async () =>
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2)));
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
socket.Close();
socket.Destroy();
Assert.Equal(1, receiver.CloseCount);
}
[Fact]
public async Task Completion_DoesNotWaitForABlockedReceiverCloseCallback()
{
var platformSocket = new TestWebSocket();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000));
using var callbackStarted = new ManualResetEventSlim();
using var releaseCallback = new ManualResetEventSlim();
socket.Receiver = new BlockingCloseReceiver(callbackStarted, releaseCallback);
try
{
socket.Destroy();
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
Assert.True(callbackStarted.Wait(TimeSpan.FromSeconds(2)));
Assert.False(socket.CloseNotification.IsCompleted);
}
finally
{
releaseCallback.Set();
}
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
}
[Fact]
public async Task ThrowingSendCallback_DoesNotCorruptQueueOrCloseTransport()
{
var platformSocket = new TestWebSocket();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000));
socket.Hold();
var reply = socket.SendAsync(new byte[] { 42 }, 0, 1);
_ = reply.Then(_ => throw new InvalidOperationException("consumer callback failed"));
socket.Unhold();
Assert.True(await reply);
Assert.Equal(SocketState.Established, socket.State);
Assert.Equal(0, socket.PendingSendBytes);
Assert.Equal(new byte[] { 42 }, Assert.Single(platformSocket.SentMessages));
socket.Destroy();
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
}
[Fact]
public void OutboundUri_RequiresWebSocketScheme()
{
Assert.Throws<ArgumentException>(() =>
new FrameworkWebSocket(new Uri("https://example.test/esiur")));
_ = new FrameworkWebSocket(new Uri("wss://example.test/esiur?tenant=one"));
Assert.Equal("EP", FrameworkWebSocket.SubProtocol);
}
private sealed class RecordingReceiver : INetworkReceiver<ISocket>
{
public List<byte[]> Messages { get; } = new List<byte[]>();
public int CloseCount;
public void NetworkClose(ISocket sender) => Interlocked.Increment(ref CloseCount);
public void NetworkConnect(ISocket sender)
{
}
public void NetworkReceive(ISocket sender, NetworkBuffer buffer)
{
var message = buffer.Read();
if (message != null)
Messages.Add(message);
}
}
private sealed class BlockingCloseReceiver : INetworkReceiver<ISocket>
{
private readonly ManualResetEventSlim callbackStarted;
private readonly ManualResetEventSlim releaseCallback;
public BlockingCloseReceiver(
ManualResetEventSlim callbackStarted,
ManualResetEventSlim releaseCallback)
{
this.callbackStarted = callbackStarted;
this.releaseCallback = releaseCallback;
}
public void NetworkClose(ISocket sender)
{
callbackStarted.Set();
releaseCallback.Wait();
}
public void NetworkConnect(ISocket sender)
{
}
public void NetworkReceive(ISocket sender, NetworkBuffer buffer)
{
}
}
private static FrameworkWebSocket CreateAcceptedSocket(
TestWebSocket platformSocket,
IPendingSendBudget budget)
=> new(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000))
{
PendingSendBudget = budget,
};
private sealed class TestPendingSendBudget : IPendingSendBudget
{
private readonly long limit;
private long reservedBytes;
public TestPendingSendBudget(long limit) => this.limit = limit;
public long ReservedBytes => Interlocked.Read(ref reservedBytes);
public bool TryReserve(int byteCount)
{
while (true)
{
var current = ReservedBytes;
if (byteCount > limit - current)
return false;
if (Interlocked.CompareExchange(
ref reservedBytes,
current + byteCount,
current) == current)
return true;
}
}
public void Release(int byteCount) =>
Interlocked.Add(ref reservedBytes, -byteCount);
}
private sealed class TestWebSocket : WebSocket
{
private readonly ConcurrentQueue<ReceiveFrame> receiveFrames = new();
private readonly SemaphoreSlim receiveSignal = new(0);
private WebSocketState state = WebSocketState.Open;
private WebSocketCloseStatus? closeStatus;
private string? closeStatusDescription;
private readonly string? subProtocol;
private TaskCompletionSource? sendStarted;
private TaskCompletionSource? sendRelease;
public TestWebSocket(string? subProtocol = FrameworkWebSocket.SubProtocol)
=> this.subProtocol = subProtocol;
public List<byte[]> SentMessages { get; } = new List<byte[]>();
public int AbortCount;
public int CloseOutputCount;
public Task SendStarted => sendStarted?.Task ?? Task.CompletedTask;
public void BlockSends()
{
sendStarted = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
sendRelease = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
}
public void ReleaseSends() => sendRelease?.TrySetResult();
public override WebSocketCloseStatus? CloseStatus => closeStatus;
public override string? CloseStatusDescription => closeStatusDescription;
public override WebSocketState State => state;
public override string? SubProtocol => subProtocol;
public void QueueBinary(byte[] data) =>
Queue(new ReceiveFrame(data, WebSocketMessageType.Binary, true));
public void QueueText(string data) =>
Queue(new ReceiveFrame(
System.Text.Encoding.UTF8.GetBytes(data),
WebSocketMessageType.Text,
true));
public void QueueClose() =>
Queue(new ReceiveFrame(Array.Empty<byte>(), WebSocketMessageType.Close, true));
public override void Abort()
{
Interlocked.Increment(ref AbortCount);
state = WebSocketState.Aborted;
}
public override Task CloseAsync(
WebSocketCloseStatus closeStatus,
string? statusDescription,
CancellationToken cancellationToken)
{
this.closeStatus = closeStatus;
closeStatusDescription = statusDescription;
state = WebSocketState.Closed;
return Task.CompletedTask;
}
public override Task CloseOutputAsync(
WebSocketCloseStatus closeStatus,
string? statusDescription,
CancellationToken cancellationToken)
{
Interlocked.Increment(ref CloseOutputCount);
this.closeStatus = closeStatus;
closeStatusDescription = statusDescription;
state = WebSocketState.Closed;
return Task.CompletedTask;
}
public override void Dispose() => state = WebSocketState.Closed;
public override async Task<WebSocketReceiveResult> ReceiveAsync(
ArraySegment<byte> buffer,
CancellationToken cancellationToken)
{
await receiveSignal.WaitAsync(cancellationToken);
if (!receiveFrames.TryDequeue(out var frame))
throw new InvalidOperationException("The receive signal had no frame.");
if (frame.MessageType == WebSocketMessageType.Close)
{
state = WebSocketState.CloseReceived;
closeStatus = WebSocketCloseStatus.NormalClosure;
}
Buffer.BlockCopy(frame.Data, 0, buffer.Array!, buffer.Offset, frame.Data.Length);
return new WebSocketReceiveResult(
frame.Data.Length,
frame.MessageType,
frame.EndOfMessage,
frame.MessageType == WebSocketMessageType.Close
? WebSocketCloseStatus.NormalClosure
: null,
null);
}
public override async Task SendAsync(
ArraySegment<byte> buffer,
WebSocketMessageType messageType,
bool endOfMessage,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
sendStarted?.TrySetResult();
if (sendRelease is not null)
await sendRelease.Task.WaitAsync(cancellationToken);
var copy = new byte[buffer.Count];
Buffer.BlockCopy(buffer.Array!, buffer.Offset, copy, 0, buffer.Count);
SentMessages.Add(copy);
}
private void Queue(ReceiveFrame frame)
{
receiveFrames.Enqueue(frame);
receiveSignal.Release();
}
private readonly record struct ReceiveFrame(
byte[] Data,
WebSocketMessageType MessageType,
bool EndOfMessage);
}
}
+3 -1
View File
@@ -265,7 +265,9 @@ internal sealed class IntegrationCluster : IAsyncDisposable
? encryptionMode
: EncryptionMode.None,
EncryptionProviders = new[] { AesEncryptionProvider.Name },
UseWebSocket = useWebSocket,
WebSocketUri = useWebSocket
? new Uri($"ws://127.0.0.1:{port}")
: null,
});
return cluster;
@@ -5,6 +5,7 @@ using Esiur.Net.Sockets;
using Esiur.Protocol;
using Esiur.Security.Authority.Providers;
using Esiur.Security.Cryptography;
using System.Net;
[Collection("Integration")]
public class SessionHeadersIntegrationTests
@@ -248,16 +249,17 @@ public class SessionHeadersIntegrationTests
}
[Fact]
public async Task AddressBoundEncryption_RejectsWebSocketWithoutConcreteEndpoints()
public async Task AddressBoundEncryption_WorksAcrossWebSocketWithConcreteEndpoints()
{
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
await IntegrationCluster.StartAsync(
_ => Task.CompletedTask,
encrypted: true,
encryptionMode: EncryptionMode.EncryptWithSessionKeyAndAddress,
useWebSocket: true)
.WaitAsync(TimeSpan.FromSeconds(5)));
await using var cluster = await IntegrationCluster.StartAsync(
_ => Task.CompletedTask,
encrypted: true,
encryptionMode: EncryptionMode.EncryptWithSessionKeyAndAddress,
useWebSocket: true)
.WaitAsync(TimeSpan.FromSeconds(10));
Assert.IsNotType<TimeoutException>(exception);
Assert.True(cluster.Connection.IsEncrypted);
Assert.True(Assert.Single(cluster.Server.Connections).IsEncrypted);
Assert.NotEqual(IPAddress.Any, cluster.Connection.RemoteEndPoint.Address);
}
}
@@ -0,0 +1,47 @@
using Esiur.Security.Authority.Providers;
namespace Esiur.Tests.Unit;
public sealed class PasswordAuthenticationProviderTests
{
[Fact]
public void ProtocolName_HasNoLegacyPublicAlias()
{
var publicProtocolConstants = typeof(PasswordAuthenticationProvider)
.GetFields(System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.Static)
.Where(field => field.IsLiteral && field.FieldType == typeof(string))
.Select(field => Assert.IsType<string>(field.GetRawConstantValue()))
.ToArray();
Assert.Equal(new[] { PasswordAuthenticationProvider.ProtocolName },
publicProtocolConstants);
}
[Fact]
public void CreateCredential_GeneratesFreshSaltAndExpectedProtocolHash()
{
var password = new byte[] { 1, 2, 3, 4, 5 };
var first = PasswordAuthenticationProvider.CreateCredential(password);
var second = PasswordAuthenticationProvider.CreateCredential(password);
Assert.Equal(32, first.Salt.Length);
Assert.Equal(32, first.Hash.Length);
Assert.Equal(
PasswordAuthenticationHandler.ComputeSha3(
password.Concat(first.Salt).ToArray()),
first.Hash);
Assert.False(first.Salt.SequenceEqual(second.Salt));
Assert.Equal(new byte[] { 1, 2, 3, 4, 5 }, password);
}
[Fact]
public void CreateCredential_RejectsMissingOrEmptyPassword()
{
Assert.Throws<ArgumentNullException>(() =>
PasswordAuthenticationProvider.CreateCredential(null!));
Assert.Throws<ArgumentException>(() =>
PasswordAuthenticationProvider.CreateCredential(Array.Empty<byte>()));
}
}
+119 -3
View File
@@ -10,6 +10,45 @@ namespace Esiur.Tests.Unit;
public class PeerConnectionLimitTests
{
[Fact]
public async Task Server_CanTrackExternallyHostedConnectionsWithoutOpeningTcpListener()
{
var warehouse = new Warehouse();
var server = new EpServer
{
EnableTcpListener = false,
};
server.Instance = new Instance(warehouse, 1, "server", server, null);
Assert.True(await server.Handle(ResourceOperation.Initialize));
Assert.False(server.IsRunning);
Assert.Empty(server.Connections);
var socket = new TestSocket(IPAddress.Parse("192.0.2.5"), 10000);
var connection = new EpConnection();
connection.Assign(socket);
Assert.True(server.TryAdd(connection));
Assert.Single(server.Connections);
socket.Close();
Assert.Empty(server.Connections);
}
[Fact]
public void Server_RequiresSocketAssignmentBeforeAdmission()
{
var warehouse = new Warehouse();
var server = new EpServer();
server.Instance = new Instance(warehouse, 1, "server", server, null);
var error = Assert.Throws<InvalidOperationException>(
() => server.TryAdd(new EpConnection()));
Assert.Contains("Assign a socket", error.Message, StringComparison.Ordinal);
Assert.Empty(server.Connections);
}
[Fact]
public void Server_RejectsConnectionsAbovePerIpLimit_AndReleasesClosedSlot()
{
@@ -24,12 +63,12 @@ public class PeerConnectionLimitTests
var firstSocket = new TestSocket(address, 10001);
var first = new EpConnection();
first.Assign(firstSocket);
server.Add(first);
Assert.True(server.TryAdd(first));
var rejectedSocket = new TestSocket(address, 10002);
var rejected = new EpConnection();
rejected.Assign(rejectedSocket);
server.Add(rejected);
Assert.False(server.TryAdd(rejected));
Assert.Single(server.Connections);
Assert.Equal(SocketState.Closed, rejectedSocket.State);
@@ -41,7 +80,7 @@ public class PeerConnectionLimitTests
var replacementSocket = new TestSocket(address, 10003);
var replacement = new EpConnection();
replacement.Assign(replacementSocket);
server.Add(replacement);
Assert.True(server.TryAdd(replacement));
Assert.Equal(SocketState.Established, replacementSocket.State);
Assert.Equal(1, server.GetConnectionCount(address));
@@ -49,6 +88,49 @@ public class PeerConnectionLimitTests
replacementSocket.Close();
}
[Fact]
public void Server_RejectsConnectionsAboveGlobalLimitAcrossDifferentAddresses_AndReleasesSlot()
{
var warehouse = new Warehouse();
warehouse.Configuration.Connections.MaximumConnections = 2;
warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 0;
warehouse.Configuration.Connections.MaximumConnectionAttempts = 0;
warehouse.Configuration.Connections.MaximumConnectionAttemptsPerIpAddress = 0;
var server = new EpServer();
server.Instance = new Instance(warehouse, 1, "server", server, null);
var firstSocket = new TestSocket(IPAddress.Parse("192.0.2.10"), 10101);
var first = new EpConnection();
first.Assign(firstSocket);
Assert.True(server.TryAdd(first));
var secondSocket = new TestSocket(IPAddress.Parse("192.0.2.11"), 10102);
var second = new EpConnection();
second.Assign(secondSocket);
Assert.True(server.TryAdd(second));
var rejectedSocket = new TestSocket(IPAddress.Parse("192.0.2.12"), 10103);
var rejected = new EpConnection();
rejected.Assign(rejectedSocket);
Assert.False(server.TryAdd(rejected));
Assert.Equal(2, server.Connections.Count);
Assert.Equal(SocketState.Closed, rejectedSocket.State);
firstSocket.Close();
var replacementSocket = new TestSocket(IPAddress.Parse("192.0.2.13"), 10104);
var replacement = new EpConnection();
replacement.Assign(replacementSocket);
Assert.True(server.TryAdd(replacement));
Assert.Equal(2, server.Connections.Count);
secondSocket.Close();
replacementSocket.Close();
Assert.Empty(server.Connections);
}
[Fact]
public void Server_RejectsRepeatedConnectionsAbovePerIpAttemptRate()
{
@@ -88,6 +170,40 @@ public class PeerConnectionLimitTests
otherSocket.Close();
}
[Fact]
public void Server_RejectsRepeatedConnectionsAboveGlobalAttemptRateAcrossDifferentAddresses()
{
var warehouse = new Warehouse();
warehouse.Configuration.Connections.MaximumConnections = 0;
warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 0;
warehouse.Configuration.Connections.MaximumConnectionAttempts = 2;
warehouse.Configuration.Connections.MaximumConnectionAttemptsPerIpAddress = 0;
warehouse.Configuration.Connections.ConnectionAttemptWindow = TimeSpan.FromMinutes(1);
var server = new EpServer();
server.Instance = new Instance(warehouse, 1, "server", server, null);
for (var index = 0; index < 2; index++)
{
var admittedSocket = new TestSocket(
IPAddress.Parse($"192.0.2.{20 + index}"),
11100 + index);
var admitted = new EpConnection();
admitted.Assign(admittedSocket);
Assert.True(server.TryAdd(admitted));
admittedSocket.Close();
}
var rejectedSocket = new TestSocket(IPAddress.Parse("192.0.2.22"), 11102);
var rejected = new EpConnection();
rejected.Assign(rejectedSocket);
Assert.False(server.TryAdd(rejected));
Assert.Equal(SocketState.Closed, rejectedSocket.State);
Assert.Empty(server.Connections);
}
[Fact]
public async Task Server_ClosesStalledAuthenticationAtDeadlineAndReleasesSlot()
{
+262
View File
@@ -0,0 +1,262 @@
using Esiur.Core;
using Esiur.Resource;
using Esiur.Stores;
namespace Esiur.Tests.Unit;
public sealed class WarehouseLifecycleTests
{
[Fact]
public async Task Close_AllowsWarehouseToBeOpenedAgain()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
Assert.False(warehouse.IsOpen);
Assert.True(await warehouse.Open());
Assert.True(warehouse.IsOpen);
Assert.False(await warehouse.Open());
Assert.True(await warehouse.Close());
Assert.False(warehouse.IsOpen);
Assert.True(await warehouse.Open());
Assert.True(warehouse.IsOpen);
Assert.True(await warehouse.Close());
Assert.False(warehouse.IsOpen);
}
[Fact]
public async Task Close_WaitsForAnInProgressOpenBeforeTerminatingResources()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
var resource = await warehouse.Put("sys/blocking", new BlockingResource());
var open = warehouse.Open();
await resource.InitializeStarted.WaitAsync(TimeSpan.FromSeconds(2));
var close = warehouse.Close();
await Task.Delay(25);
Assert.False(resource.TerminateStarted.IsCompleted);
resource.ReleaseInitialize();
Assert.True(await open);
Assert.True(await close);
Assert.True(resource.TerminateStarted.IsCompletedSuccessfully);
Assert.False(warehouse.IsOpen);
}
[Fact]
public async Task Close_WaitsForEachShutdownPhaseBeforeAdvancingOrReopening()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
var terminateReply = new AsyncReply<bool>();
var systemTerminatedReply = new AsyncReply<bool>();
var terminateBlocker = await warehouse.Put(
"sys/terminate-blocker",
new ControlledLifecycleResource(terminateReply: terminateReply));
var systemTerminatedBlocker = await warehouse.Put(
"sys/system-terminated-blocker",
new ControlledLifecycleResource(systemTerminatedReply: systemTerminatedReply));
Assert.True(await warehouse.Open());
var close = Observe(warehouse.Close());
await Task.WhenAll(
terminateBlocker.TerminateStarted,
systemTerminatedBlocker.TerminateStarted)
.WaitAsync(TimeSpan.FromSeconds(2));
Assert.False(terminateBlocker.SystemTerminatedStarted.IsCompleted);
Assert.False(systemTerminatedBlocker.SystemTerminatedStarted.IsCompleted);
terminateReply.Trigger(true);
await Task.WhenAll(
terminateBlocker.SystemTerminatedStarted,
systemTerminatedBlocker.SystemTerminatedStarted)
.WaitAsync(TimeSpan.FromSeconds(2));
Assert.False(close.IsCompleted);
var reopen = Observe(warehouse.Open());
await Task.Delay(25);
Assert.False(reopen.IsCompleted);
systemTerminatedReply.Trigger(true);
Assert.True(await close);
Assert.True(await reopen);
Assert.True(await warehouse.Close());
}
[Fact]
public async Task Close_SynchronousThrowDoesNotSkipOtherResourcesOrSecondPhase()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
var expected = new InvalidOperationException("terminate failed synchronously");
var throwing = await warehouse.Put(
"sys/throwing",
new ControlledLifecycleResource(terminateException: expected));
var other = await warehouse.Put(
"sys/other",
new ControlledLifecycleResource());
Assert.True(await warehouse.Open());
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => Observe(warehouse.Close()));
Assert.Same(expected, exception);
Assert.True(other.TerminateStarted.IsCompletedSuccessfully);
Assert.True(throwing.SystemTerminatedStarted.IsCompletedSuccessfully);
Assert.True(other.SystemTerminatedStarted.IsCompletedSuccessfully);
Assert.False(warehouse.IsOpen);
Assert.False(await warehouse.Close());
}
[Fact]
public async Task Close_AsyncErrorWaitsForEveryOtherReplyToSettle()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
var failingReply = new AsyncReply<bool>();
var blockingReply = new AsyncReply<bool>();
var failing = await warehouse.Put(
"sys/failing",
new ControlledLifecycleResource(systemTerminatedReply: failingReply));
var blocking = await warehouse.Put(
"sys/blocking",
new ControlledLifecycleResource(systemTerminatedReply: blockingReply));
Assert.True(await warehouse.Open());
var close = Observe(warehouse.Close());
await Task.WhenAll(failing.SystemTerminatedStarted, blocking.SystemTerminatedStarted)
.WaitAsync(TimeSpan.FromSeconds(2));
var expected = new InvalidOperationException("system termination failed asynchronously");
failingReply.TriggerError(expected);
Assert.False(close.IsCompleted);
blockingReply.Trigger(true);
var exception = await Assert.ThrowsAsync<AsyncException>(() => close);
Assert.Same(expected, exception.InnerException);
Assert.True(failing.TerminateStarted.IsCompletedSuccessfully);
Assert.True(blocking.TerminateStarted.IsCompletedSuccessfully);
Assert.False(warehouse.IsOpen);
Assert.False(await warehouse.Close());
}
[Fact]
public async Task Close_ReturnsFalseAfterBothPhasesWhenAResourceReturnsFalse()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
var resource = await warehouse.Put(
"sys/false-result",
new ControlledLifecycleResource(
terminateReply: new AsyncReply<bool>(false)));
Assert.True(await warehouse.Open());
Assert.False(await warehouse.Close());
Assert.True(resource.SystemTerminatedStarted.IsCompletedSuccessfully);
Assert.False(warehouse.IsOpen);
}
private static async Task<bool> Observe(AsyncReply<bool> reply) => await reply;
private sealed class ControlledLifecycleResource : IResource
{
private readonly AsyncReply<bool>? terminateReply;
private readonly AsyncReply<bool>? systemTerminatedReply;
private readonly Exception? terminateException;
private readonly TaskCompletionSource terminateStarted = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource systemTerminatedStarted = new(
TaskCreationOptions.RunContinuationsAsynchronously);
public ControlledLifecycleResource(
AsyncReply<bool>? terminateReply = null,
AsyncReply<bool>? systemTerminatedReply = null,
Exception? terminateException = null)
{
this.terminateReply = terminateReply;
this.systemTerminatedReply = systemTerminatedReply;
this.terminateException = terminateException;
}
public event DestroyedEvent? OnDestroy;
public Instance? Instance { get; set; }
public Task TerminateStarted => terminateStarted.Task;
public Task SystemTerminatedStarted => systemTerminatedStarted.Task;
public AsyncReply<bool> Handle(
ResourceOperation operation,
IResourceContext? context = null)
{
if (operation == ResourceOperation.Terminate)
{
terminateStarted.TrySetResult();
if (terminateException != null)
throw terminateException;
return terminateReply ?? new AsyncReply<bool>(true);
}
if (operation == ResourceOperation.SystemTerminated)
{
systemTerminatedStarted.TrySetResult();
return systemTerminatedReply ?? new AsyncReply<bool>(true);
}
return new AsyncReply<bool>(true);
}
public void Destroy() => OnDestroy?.Invoke(this);
}
private sealed class BlockingResource : IResource
{
private readonly AsyncReply<bool> initialize = new();
private readonly TaskCompletionSource initializeStarted = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource terminateStarted = new(
TaskCreationOptions.RunContinuationsAsynchronously);
public event DestroyedEvent? OnDestroy;
public Instance? Instance { get; set; }
public Task InitializeStarted => initializeStarted.Task;
public Task TerminateStarted => terminateStarted.Task;
public void ReleaseInitialize() => initialize.Trigger(true);
public AsyncReply<bool> Handle(
ResourceOperation operation,
IResourceContext? context = null)
{
if (operation == ResourceOperation.Initialize)
{
initializeStarted.TrySetResult();
return initialize;
}
if (operation == ResourceOperation.Terminate)
terminateStarted.TrySetResult();
return new AsyncReply<bool>(true);
}
public void Destroy() => OnDestroy?.Invoke(this);
}
}
@@ -2,7 +2,7 @@
"profiles": {
"Esiur.CLI": {
"commandName": "Project",
"commandLineArgs": "get-template EP://phase.delta.iq/sys/phase --dir c:\\temp\\an"
"commandLineArgs": "get-template ep://phase.delta.iq/sys/phase --dir c:\\temp\\an"
}
}
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ Global options:
## Example
```
dotnet run esiur get-template EP://localhost/sys/service
dotnet run esiur get-template ep://localhost/sys/service
```