mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
ASP.Net
This commit is contained in:
@@ -1,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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
###
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,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));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,393 @@
|
||||
/*
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017-2024 Esiur Foundation, Ahmed Kh. Zamil.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
*/
|
||||
|
||||
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Resource;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Cryptography;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Esiur.AspNetCore
|
||||
namespace Esiur.AspNetCore;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the Esiur runtime hosted by ASP.NET Core.
|
||||
/// </summary>
|
||||
public sealed class EsiurOptions
|
||||
{
|
||||
public class EsiurOptions
|
||||
/// <summary>The Warehouse owned by the ASP.NET Core host.</summary>
|
||||
public Warehouse Warehouse { get; set; } = new();
|
||||
|
||||
/// <summary>The EP server that accepts mapped WebSocket connections.</summary>
|
||||
public EpServer Server { get; set; } = new()
|
||||
{
|
||||
public required EpServer Server { get; set; }
|
||||
ExceptionLevel = Esiur.Core.ExceptionLevel.Code,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Warehouse path used when <see cref="Server"/> has not already been added to the
|
||||
/// Warehouse.
|
||||
/// </summary>
|
||||
public string ServerPath { get; set; } = "sys/server";
|
||||
|
||||
/// <summary>
|
||||
/// Opens the Warehouse when the host starts and closes it when the host stops.
|
||||
/// Disable only when another component explicitly owns the Warehouse lifecycle.
|
||||
/// </summary>
|
||||
public bool ManageWarehouseLifecycle { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Allows browser WebSocket requests from every Origin. Non-browser clients that do
|
||||
/// not send an Origin header are always allowed.
|
||||
/// </summary>
|
||||
public bool AllowAnyWebSocketOrigin { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Browser origins allowed to open an Esiur WebSocket. When this set is empty, only
|
||||
/// the request's own origin is allowed. Once configured, it becomes the complete
|
||||
/// browser-origin allowlist.
|
||||
/// </summary>
|
||||
public ISet<string> AllowedWebSocketOrigins { get; } =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum copied outbound WebSocket data retained per connection while the peer is
|
||||
/// under backpressure.
|
||||
/// </summary>
|
||||
public long MaximumPendingWebSocketSendBytes { get; set; } = 16 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum copied, unsent WebSocket data retained across every connection hosted by
|
||||
/// this Esiur runtime.
|
||||
/// </summary>
|
||||
public long MaximumTotalPendingWebSocketSendBytes { get; set; } = 256L * 1024 * 1024;
|
||||
|
||||
internal IList<EsiurResourceRegistration> Resources { get; } =
|
||||
new List<EsiurResourceRegistration>();
|
||||
|
||||
internal IList<Func<IServiceProvider, IAuthenticationProvider>>
|
||||
AuthenticationProviders { get; } =
|
||||
new List<Func<IServiceProvider, IAuthenticationProvider>>();
|
||||
|
||||
internal IList<Func<IServiceProvider, IEncryptionProvider>>
|
||||
EncryptionProviders { get; } =
|
||||
new List<Func<IServiceProvider, IEncryptionProvider>>();
|
||||
|
||||
internal IList<Action<EpServer>> ServerConfigurations { get; } =
|
||||
new List<Action<EpServer>>();
|
||||
|
||||
internal IList<Action<WarehouseConfiguration>> WarehouseConfigurations { get; } =
|
||||
new List<Action<WarehouseConfiguration>>();
|
||||
}
|
||||
|
||||
internal sealed record EsiurResourceRegistration(
|
||||
string Path,
|
||||
Func<IServiceProvider, IResource> Factory,
|
||||
bool ReuseExistingStore);
|
||||
|
||||
internal sealed class EsiurOptionsPostConfigure : IPostConfigureOptions<EsiurOptions>
|
||||
{
|
||||
public void PostConfigure(string? name, EsiurOptions options)
|
||||
{
|
||||
if (EsiurConfigurationPath.TryNormalize(options.ServerPath, out var serverPath))
|
||||
options.ServerPath = serverPath;
|
||||
|
||||
if (options.Warehouse is not null)
|
||||
foreach (var configure in options.WarehouseConfigurations)
|
||||
configure(options.Warehouse.Configuration);
|
||||
|
||||
if (options.Server is not null)
|
||||
{
|
||||
// ASP.NET hosts are remotely reachable by default. Supplied core servers can
|
||||
// opt back into messages/source/trace through ConfigureServer, but must not
|
||||
// accidentally carry the core diagnostic disclosure default into production.
|
||||
options.Server.ExceptionLevel &= Esiur.Core.ExceptionLevel.Code;
|
||||
|
||||
foreach (var configure in options.ServerConfigurations)
|
||||
configure(options.Server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class EsiurOptionsValidator : IValidateOptions<EsiurOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, EsiurOptions options)
|
||||
{
|
||||
var failures = new List<string>();
|
||||
|
||||
if (options.Warehouse is null)
|
||||
failures.Add("EsiurOptions.Warehouse is required.");
|
||||
|
||||
if (options.Server is null)
|
||||
failures.Add("EsiurOptions.Server is required.");
|
||||
|
||||
if (!EsiurConfigurationPath.TryNormalize(options.ServerPath, out var serverPath))
|
||||
{
|
||||
failures.Add(
|
||||
"EsiurOptions.ServerPath must be a relative Warehouse path such as 'sys/server'.");
|
||||
}
|
||||
else if (!serverPath.Contains('/'))
|
||||
{
|
||||
failures.Add("EsiurOptions.ServerPath must place the server below a root store.");
|
||||
}
|
||||
|
||||
var paths = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var resource in options.Resources)
|
||||
{
|
||||
if (!EsiurConfigurationPath.TryNormalize(resource.Path, out var resourcePath))
|
||||
{
|
||||
failures.Add($"The resource path '{resource.Path}' is invalid.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!paths.Add(resourcePath))
|
||||
failures.Add($"The resource path '{resourcePath}' is registered more than once.");
|
||||
}
|
||||
|
||||
if (serverPath is not null && paths.Contains(serverPath))
|
||||
failures.Add($"The server path '{serverPath}' is also used by another resource.");
|
||||
|
||||
if (serverPath is not null && options.Server?.Instance is null)
|
||||
{
|
||||
var rootPath = serverPath.Split('/')[0];
|
||||
var existingStore = options.Warehouse?.GetStore(rootPath);
|
||||
if (existingStore is null && !paths.Contains(rootPath))
|
||||
{
|
||||
failures.Add(
|
||||
$"ServerPath '{serverPath}' requires a root store. " +
|
||||
$"Call AddMemoryStore(\"{rootPath}\") or supply a Warehouse containing it.");
|
||||
}
|
||||
}
|
||||
|
||||
if (options.Server?.Instance is { } serverInstance
|
||||
&& options.Warehouse is not null
|
||||
&& !ReferenceEquals(serverInstance.Warehouse, options.Warehouse))
|
||||
{
|
||||
failures.Add("The supplied EpServer belongs to a different Warehouse.");
|
||||
}
|
||||
|
||||
if (options.Server?.IsRunning == true)
|
||||
{
|
||||
failures.Add(
|
||||
"The supplied EpServer is already listening. Stop it before handing it to ASP.NET Core.");
|
||||
}
|
||||
|
||||
if (!options.ManageWarehouseLifecycle
|
||||
&& (options.Server?.Instance is null || options.Resources.Count > 0))
|
||||
{
|
||||
failures.Add(
|
||||
"When ManageWarehouseLifecycle is false, attach and initialize the server and " +
|
||||
"resources before registering Esiur with ASP.NET Core.");
|
||||
}
|
||||
|
||||
if (options.Server?.AllowedAuthenticationProviders is null)
|
||||
failures.Add("EpServer.AllowedAuthenticationProviders cannot be null.");
|
||||
|
||||
if (options.Server?.AllowedEncryptionProviders is null)
|
||||
failures.Add("EpServer.AllowedEncryptionProviders cannot be null.");
|
||||
|
||||
if (options.Server is { } authenticationServer
|
||||
&& options.AuthenticationProviders.Count == 0
|
||||
&& (authenticationServer.AllowedAuthenticationProviders?.Length ?? 0) == 0)
|
||||
{
|
||||
if (!authenticationServer.AllowUnauthorizedAccess)
|
||||
{
|
||||
failures.Add(
|
||||
"Authentication is required, but no authentication provider is configured. " +
|
||||
"Call UseAuthentication(...) or explicitly enable anonymous access.");
|
||||
}
|
||||
else if (authenticationServer.RequireEncryption)
|
||||
{
|
||||
failures.Add(
|
||||
"Encrypted EP sessions require authentication, but no authentication " +
|
||||
"provider is configured. Call UseAuthentication(...).");
|
||||
}
|
||||
}
|
||||
|
||||
if (options.Server is { RequireEncryption: true } encryptionServer
|
||||
&& options.EncryptionProviders.Count == 0
|
||||
&& (encryptionServer.AllowedEncryptionProviders?.Length ?? 0) == 0)
|
||||
{
|
||||
failures.Add(
|
||||
"Encryption is required, but no encryption provider is configured. " +
|
||||
"Call UseEncryption(...).");
|
||||
}
|
||||
|
||||
if (options.Server is { AuthenticationTimeout: var timeout }
|
||||
&& timeout <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("EpServer.AuthenticationTimeout must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.MaximumPendingWebSocketSendBytes <= 0)
|
||||
{
|
||||
failures.Add(
|
||||
"EsiurOptions.MaximumPendingWebSocketSendBytes must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.MaximumTotalPendingWebSocketSendBytes <= 0)
|
||||
{
|
||||
failures.Add(
|
||||
"EsiurOptions.MaximumTotalPendingWebSocketSendBytes must be greater than zero.");
|
||||
}
|
||||
else if (options.MaximumPendingWebSocketSendBytes
|
||||
> options.MaximumTotalPendingWebSocketSendBytes)
|
||||
{
|
||||
failures.Add(
|
||||
"MaximumPendingWebSocketSendBytes cannot exceed the host-wide send limit.");
|
||||
}
|
||||
|
||||
ValidateWarehouseConfiguration(options.Warehouse?.Configuration, failures);
|
||||
|
||||
if (options.AllowAnyWebSocketOrigin && options.AllowedWebSocketOrigins.Count > 0)
|
||||
{
|
||||
failures.Add(
|
||||
"Configure either AllowAnyWebSocketOrigin or AllowedWebSocketOrigins, not both.");
|
||||
}
|
||||
|
||||
foreach (var origin in options.AllowedWebSocketOrigins)
|
||||
{
|
||||
if (!EsiurOrigin.TryNormalize(origin, out _))
|
||||
failures.Add($"The WebSocket origin '{origin}' is not a valid HTTP(S) origin.");
|
||||
}
|
||||
|
||||
return failures.Count == 0
|
||||
? ValidateOptionsResult.Success
|
||||
: ValidateOptionsResult.Fail(failures);
|
||||
}
|
||||
|
||||
private static void ValidateWarehouseConfiguration(
|
||||
WarehouseConfiguration? configuration,
|
||||
List<string> failures)
|
||||
{
|
||||
if (configuration is null)
|
||||
{
|
||||
failures.Add("Warehouse.Configuration is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (configuration.Connections is null)
|
||||
{
|
||||
failures.Add("Warehouse.Configuration.Connections is required.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var connections = configuration.Connections;
|
||||
if (connections.MaximumConnections < 0)
|
||||
failures.Add("MaximumConnections cannot be negative.");
|
||||
if (connections.MaximumConnectionsPerIpAddress < 0)
|
||||
failures.Add("MaximumConnectionsPerIpAddress cannot be negative.");
|
||||
if (connections.MaximumConnectionAttempts < 0)
|
||||
failures.Add("MaximumConnectionAttempts cannot be negative.");
|
||||
if (connections.MaximumConnectionAttemptsPerIpAddress < 0)
|
||||
failures.Add("MaximumConnectionAttemptsPerIpAddress cannot be negative.");
|
||||
if ((connections.MaximumConnectionAttempts > 0
|
||||
|| connections.MaximumConnectionAttemptsPerIpAddress > 0)
|
||||
&& connections.ConnectionAttemptWindow <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add(
|
||||
"ConnectionAttemptWindow must be greater than zero when attempt limiting is enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
if (configuration.Parser is null)
|
||||
failures.Add("Warehouse.Configuration.Parser is required.");
|
||||
else
|
||||
{
|
||||
if (configuration.Parser.MaximumCollectionItems < 0)
|
||||
failures.Add("MaximumCollectionItems cannot be negative.");
|
||||
if (configuration.Parser.MaximumTypeMetadataDepth < 0)
|
||||
failures.Add("MaximumTypeMetadataDepth cannot be negative.");
|
||||
}
|
||||
|
||||
if (configuration.ResourceAttachments is null)
|
||||
failures.Add("Warehouse.Configuration.ResourceAttachments is required.");
|
||||
else
|
||||
{
|
||||
if (configuration.ResourceAttachments.MaximumAttachedResourcesPerConnection < 0)
|
||||
failures.Add("MaximumAttachedResourcesPerConnection cannot be negative.");
|
||||
if (configuration.ResourceAttachments.MaximumPendingAttachmentsPerConnection < 0)
|
||||
failures.Add("MaximumPendingAttachmentsPerConnection cannot be negative.");
|
||||
}
|
||||
|
||||
if (configuration.Encryption is null)
|
||||
failures.Add("Warehouse.Configuration.Encryption is required.");
|
||||
|
||||
if (configuration.RateControl is null)
|
||||
failures.Add("Warehouse.Configuration.RateControl is required.");
|
||||
else
|
||||
{
|
||||
if (configuration.RateControl.DenialsBeforeConnectionBlock < 0)
|
||||
failures.Add("DenialsBeforeConnectionBlock cannot be negative.");
|
||||
if (configuration.RateControl.DenialsBeforeConnectionBlock > 0
|
||||
&& configuration.RateControl.DenialWindow <= TimeSpan.Zero)
|
||||
failures.Add("DenialWindow must be greater than zero when blocking is enabled.");
|
||||
if (configuration.RateControl.ConnectionBlockDelay < TimeSpan.Zero)
|
||||
failures.Add("ConnectionBlockDelay cannot be negative.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class EsiurConfigurationPath
|
||||
{
|
||||
public static string Normalize(string path)
|
||||
{
|
||||
if (!TryNormalize(path, out var normalized))
|
||||
throw new ArgumentException(
|
||||
"A relative Warehouse path without empty, '.' or '..' segments is required.",
|
||||
nameof(path));
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static bool TryNormalize(
|
||||
string? path,
|
||||
[NotNullWhen(true)] out string? normalized)
|
||||
{
|
||||
normalized = null;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return false;
|
||||
|
||||
var candidate = path.Trim().Trim('/');
|
||||
if (candidate.Length == 0
|
||||
|| candidate.Contains('\\')
|
||||
|| candidate.Contains('?')
|
||||
|| candidate.Contains('#'))
|
||||
return false;
|
||||
|
||||
var segments = candidate.Split('/');
|
||||
if (segments.Any(segment =>
|
||||
string.IsNullOrWhiteSpace(segment) || segment is "." or ".."))
|
||||
return false;
|
||||
|
||||
normalized = string.Join('/', segments);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class EsiurOrigin
|
||||
{
|
||||
public static string Normalize(string origin)
|
||||
{
|
||||
if (!TryNormalize(origin, out var normalized))
|
||||
throw new ArgumentException("A valid HTTP(S) origin is required.", nameof(origin));
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static bool TryNormalize(
|
||||
string? origin,
|
||||
[NotNullWhen(true)] out string? normalized)
|
||||
{
|
||||
normalized = null;
|
||||
if (string.IsNullOrWhiteSpace(origin)
|
||||
|| !Uri.TryCreate(origin, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|
||||
|| string.IsNullOrEmpty(uri.Host)
|
||||
|| uri.UserInfo.Length != 0
|
||||
|| (uri.AbsolutePath != "/" && uri.AbsolutePath.Length != 0)
|
||||
|| uri.Query.Length != 0
|
||||
|| uri.Fragment.Length != 0)
|
||||
return false;
|
||||
|
||||
normalized = uri.GetComponents(UriComponents.SchemeAndServer, UriFormat.UriEscaped);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Net.Sockets;
|
||||
using Esiur.Resource;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Esiur.AspNetCore;
|
||||
|
||||
internal sealed class EsiurRuntime : IPendingSendBudget
|
||||
{
|
||||
private readonly object lifecycleLock = new();
|
||||
private readonly HashSet<FrameworkWebSocket> stoppingSockets = new();
|
||||
private bool ready;
|
||||
private long pendingSendBytes;
|
||||
|
||||
public EsiurRuntime(IOptions<EsiurOptions> options)
|
||||
{
|
||||
Options = options.Value;
|
||||
Warehouse = Options.Warehouse;
|
||||
Server = Options.Server;
|
||||
AllowedOrigins = Options.AllowedWebSocketOrigins
|
||||
.Select(EsiurOrigin.Normalize)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public EsiurOptions Options { get; }
|
||||
public Warehouse Warehouse { get; }
|
||||
public EpServer Server { get; }
|
||||
public IReadOnlySet<string> AllowedOrigins { get; }
|
||||
public bool IsReady
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (lifecycleLock)
|
||||
return ready;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryMarkReady(CancellationToken applicationStopping)
|
||||
{
|
||||
lock (lifecycleLock)
|
||||
{
|
||||
if (applicationStopping.IsCancellationRequested)
|
||||
return false;
|
||||
|
||||
ready = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryAdd(EpConnection connection)
|
||||
{
|
||||
lock (lifecycleLock)
|
||||
return ready && Server.TryAdd(connection);
|
||||
}
|
||||
|
||||
public void StopTransport()
|
||||
{
|
||||
// Admission and the Stop snapshot share this lock. A request that passed the
|
||||
// initial HTTP readiness check therefore cannot add a connection after shutdown
|
||||
// has already taken the server's connection snapshot.
|
||||
lock (lifecycleLock)
|
||||
{
|
||||
ready = false;
|
||||
CaptureActiveWebSockets();
|
||||
Server.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DrainTransportAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
FrameworkWebSocket[] sockets;
|
||||
lock (lifecycleLock)
|
||||
{
|
||||
CaptureActiveWebSockets();
|
||||
sockets = stoppingSockets.ToArray();
|
||||
}
|
||||
|
||||
foreach (var socket in sockets)
|
||||
_ = socket.CloseAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Normal shutdown waits for both physical closure and protocol cleanup.
|
||||
// A blocked application callback is bounded by the host stop token.
|
||||
await Task.WhenAll(sockets.Select(ObserveShutdownAsync))
|
||||
.WaitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// A host deadline is terminal. Abort any peer that did not finish the
|
||||
// close handshake so no transport can outlive provider/resource cleanup.
|
||||
foreach (var socket in sockets)
|
||||
if (!socket.Completion.IsCompleted)
|
||||
socket.Destroy();
|
||||
|
||||
await Task.WhenAll(sockets.Select(ObserveCompletionAsync));
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (lifecycleLock)
|
||||
foreach (var socket in sockets)
|
||||
stoppingSockets.Remove(socket);
|
||||
}
|
||||
}
|
||||
|
||||
private void CaptureActiveWebSockets()
|
||||
{
|
||||
foreach (var connection in Server.Connections.ToArray())
|
||||
if (connection.Socket is FrameworkWebSocket socket)
|
||||
stoppingSockets.Add(socket);
|
||||
}
|
||||
|
||||
private static async Task ObserveCompletionAsync(FrameworkWebSocket socket)
|
||||
{
|
||||
try { await socket.Completion; }
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static async Task ObserveShutdownAsync(FrameworkWebSocket socket)
|
||||
{
|
||||
await ObserveCompletionAsync(socket);
|
||||
await socket.CloseNotification;
|
||||
}
|
||||
|
||||
bool IPendingSendBudget.TryReserve(int byteCount)
|
||||
{
|
||||
var limit = Options.MaximumTotalPendingWebSocketSendBytes;
|
||||
while (true)
|
||||
{
|
||||
var current = Interlocked.Read(ref pendingSendBytes);
|
||||
if (byteCount > limit - current)
|
||||
return false;
|
||||
|
||||
if (Interlocked.CompareExchange(
|
||||
ref pendingSendBytes,
|
||||
current + byteCount,
|
||||
current) == current)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void IPendingSendBudget.Release(int byteCount)
|
||||
=> Interlocked.Add(ref pendingSendBytes, -byteCount);
|
||||
|
||||
public bool IsOriginAllowed(string? origin, string requestScheme, string requestAuthority)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(origin))
|
||||
return false;
|
||||
|
||||
if (Options.AllowAnyWebSocketOrigin)
|
||||
return true;
|
||||
|
||||
if (!EsiurOrigin.TryNormalize(origin, out var normalized))
|
||||
return false;
|
||||
|
||||
if (AllowedOrigins.Count == 0)
|
||||
{
|
||||
return EsiurOrigin.TryNormalize(
|
||||
$"{requestScheme}://{requestAuthority}",
|
||||
out var requestOrigin)
|
||||
&& string.Equals(normalized, requestOrigin, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return AllowedOrigins.Contains(normalized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Resource;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Esiur.AspNetCore;
|
||||
|
||||
/// <summary>Registers Esiur with the ASP.NET Core dependency injection container.</summary>
|
||||
public static class EsiurServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Adds one host-managed Esiur runtime.</summary>
|
||||
public static EsiurBuilder AddEsiur(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
if (services.Any(descriptor =>
|
||||
descriptor.ServiceType == typeof(EsiurRegistrationMarker)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Esiur has already been added to this service collection.");
|
||||
}
|
||||
|
||||
services.AddSingleton<EsiurRegistrationMarker>();
|
||||
services.AddOptions<EsiurOptions>().ValidateOnStart();
|
||||
services.TryAddEnumerable(
|
||||
ServiceDescriptor.Singleton<IPostConfigureOptions<EsiurOptions>,
|
||||
EsiurOptionsPostConfigure>());
|
||||
services.TryAddEnumerable(
|
||||
ServiceDescriptor.Singleton<IValidateOptions<EsiurOptions>,
|
||||
EsiurOptionsValidator>());
|
||||
|
||||
services.AddSingleton<EsiurRuntime>();
|
||||
services.AddSingleton<Warehouse>(provider =>
|
||||
provider.GetRequiredService<EsiurRuntime>().Warehouse);
|
||||
services.AddSingleton<EpServer>(provider =>
|
||||
provider.GetRequiredService<EsiurRuntime>().Server);
|
||||
services.AddSingleton<EsiurWebSocketEndpoint>();
|
||||
services.AddSingleton<IHostedService, EsiurHostedService>();
|
||||
|
||||
return new EsiurBuilder(services);
|
||||
}
|
||||
|
||||
/// <summary>Adds and configures one host-managed Esiur runtime.</summary>
|
||||
public static EsiurBuilder AddEsiur(
|
||||
this IServiceCollection services,
|
||||
Action<EsiurBuilder> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
var builder = services.AddEsiur();
|
||||
configure(builder);
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>Adds Esiur around an existing Warehouse and EP server.</summary>
|
||||
public static EsiurBuilder AddEsiur(
|
||||
this IServiceCollection services,
|
||||
Warehouse warehouse,
|
||||
EpServer server,
|
||||
bool manageWarehouseLifecycle = true)
|
||||
=> services.AddEsiur()
|
||||
.UseWarehouse(warehouse, manageWarehouseLifecycle)
|
||||
.UseServer(server);
|
||||
|
||||
private sealed class EsiurRegistrationMarker;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
using Esiur.Net.Sockets;
|
||||
using Esiur.Protocol;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace Esiur.AspNetCore;
|
||||
|
||||
internal sealed class EsiurWebSocketEndpoint
|
||||
{
|
||||
private readonly EsiurRuntime runtime;
|
||||
private readonly ILogger<EsiurWebSocketEndpoint> logger;
|
||||
|
||||
public EsiurWebSocketEndpoint(
|
||||
EsiurRuntime runtime,
|
||||
ILogger<EsiurWebSocketEndpoint> logger)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task HandleAsync(HttpContext context)
|
||||
{
|
||||
if (!runtime.IsReady)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status426UpgradeRequired;
|
||||
context.Response.Headers["Upgrade"] = "websocket";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.WebSockets.WebSocketRequestedProtocols.Contains(
|
||||
FrameworkWebSocket.SubProtocol,
|
||||
StringComparer.Ordinal))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsync(
|
||||
$"The '{FrameworkWebSocket.SubProtocol}' WebSocket subprotocol is required.",
|
||||
context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
var origins = context.Request.Headers.Origin;
|
||||
if (!IsOriginAllowed(origins, context))
|
||||
{
|
||||
logger.LogDebug(
|
||||
"Rejected Esiur WebSocket from disallowed origin {Origin}.",
|
||||
origins.ToString());
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryCreateEndPoint(
|
||||
context.Connection.LocalIpAddress,
|
||||
context.Connection.LocalPort,
|
||||
out var localEndPoint)
|
||||
|| !TryCreateEndPoint(
|
||||
context.Connection.RemoteIpAddress,
|
||||
context.Connection.RemotePort,
|
||||
out var remoteEndPoint))
|
||||
{
|
||||
logger.LogError(
|
||||
"Esiur cannot accept a WebSocket without concrete local and remote transport endpoints.");
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
return;
|
||||
}
|
||||
|
||||
WebSocket? webSocket = null;
|
||||
FrameworkWebSocket? socket = null;
|
||||
EpConnection? connection = null;
|
||||
var admitted = false;
|
||||
|
||||
try
|
||||
{
|
||||
webSocket = await context.WebSockets.AcceptWebSocketAsync(
|
||||
FrameworkWebSocket.SubProtocol);
|
||||
|
||||
socket = new FrameworkWebSocket(
|
||||
webSocket,
|
||||
localEndPoint,
|
||||
remoteEndPoint,
|
||||
context.RequestAborted)
|
||||
{
|
||||
MaximumPendingSendBytes = runtime.Options.MaximumPendingWebSocketSendBytes,
|
||||
PendingSendBudget = runtime,
|
||||
};
|
||||
|
||||
connection = new EpConnection();
|
||||
connection.Assign(socket);
|
||||
|
||||
// Recheck readiness while admission is serialized with host shutdown. The
|
||||
// WebSocket upgrade can take long enough for ApplicationStopping to begin
|
||||
// after the initial readiness check.
|
||||
admitted = runtime.TryAdd(connection);
|
||||
if (!admitted)
|
||||
return;
|
||||
|
||||
if (!socket.Begin())
|
||||
throw new InvalidOperationException("The Esiur WebSocket could not be started.");
|
||||
|
||||
logger.LogDebug(
|
||||
"Accepted Esiur WebSocket from {RemoteEndPoint}.",
|
||||
socket.RemoteEndPoint);
|
||||
|
||||
await socket.Completion.WaitAsync(context.RequestAborted);
|
||||
await socket.CloseNotification.WaitAsync(context.RequestAborted);
|
||||
}
|
||||
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
logger.LogDebug("The Esiur WebSocket request was aborted by its peer or host.");
|
||||
}
|
||||
catch (WebSocketException exception)
|
||||
{
|
||||
logger.LogDebug(exception, "The Esiur WebSocket transport closed with an error.");
|
||||
}
|
||||
catch (InvalidDataException exception)
|
||||
{
|
||||
logger.LogDebug(exception, "The Esiur WebSocket sent invalid EP data.");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "The Esiur WebSocket endpoint failed.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (admitted && connection is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
runtime.Server.Remove(connection);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogDebug(exception, "Failed to remove an Esiur connection during cleanup.");
|
||||
}
|
||||
}
|
||||
|
||||
if (connection is not null)
|
||||
{
|
||||
if (socket is not null)
|
||||
DestroyAfterCloseNotification(connection, socket);
|
||||
else
|
||||
DestroyConnection(connection);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
socket?.Destroy();
|
||||
webSocket?.Dispose();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogDebug(exception, "Failed to dispose an Esiur WebSocket cleanly.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DestroyAfterCloseNotification(
|
||||
EpConnection connection,
|
||||
FrameworkWebSocket socket)
|
||||
{
|
||||
// RequestAborted commonly wins the endpoint wait when a peer disappears.
|
||||
// Abort the physical transport immediately, but keep the connection assigned
|
||||
// until NetworkClose has run; otherwise its stale-socket guard would skip
|
||||
// Disconnected and the authentication provider's Logout callback.
|
||||
socket.Destroy();
|
||||
if (socket.CloseNotification.IsCompleted)
|
||||
{
|
||||
DestroyConnection(connection);
|
||||
return;
|
||||
}
|
||||
|
||||
_ = DestroyAfterCloseNotificationAsync(connection, socket);
|
||||
}
|
||||
|
||||
private async Task DestroyAfterCloseNotificationAsync(
|
||||
EpConnection connection,
|
||||
FrameworkWebSocket socket)
|
||||
{
|
||||
await socket.CloseNotification;
|
||||
DestroyConnection(connection);
|
||||
}
|
||||
|
||||
private void DestroyConnection(EpConnection connection)
|
||||
{
|
||||
try
|
||||
{
|
||||
connection.Destroy();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogDebug(exception, "Failed to dispose an Esiur connection cleanly.");
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsOriginAllowed(StringValues origins, HttpContext context)
|
||||
=> origins.Count == 0
|
||||
|| (origins.Count == 1
|
||||
&& runtime.IsOriginAllowed(
|
||||
origins[0],
|
||||
context.Request.Scheme,
|
||||
context.Request.Host.Value ?? string.Empty));
|
||||
|
||||
private static bool TryCreateEndPoint(
|
||||
IPAddress? address,
|
||||
int port,
|
||||
out IPEndPoint endPoint)
|
||||
{
|
||||
endPoint = null!;
|
||||
if (address is null
|
||||
|| port is <= IPEndPoint.MinPort or > IPEndPoint.MaxPort
|
||||
|| address.Equals(IPAddress.Any)
|
||||
|| address.Equals(IPAddress.IPv6Any)
|
||||
|| address.Equals(IPAddress.None)
|
||||
|| address.Equals(IPAddress.IPv6None))
|
||||
return false;
|
||||
|
||||
endPoint = new IPEndPoint(address, port);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,292 @@
|
||||
# Esiur ASP.Net Core Middleware
|
||||
# Esiur for ASP.NET Core
|
||||
|
||||
`Esiur.AspNetCore` exposes an Esiur EP server through ASP.NET Core endpoint
|
||||
routing and WebSockets. ASP.NET Core owns the application lifetime, while Esiur
|
||||
continues to own its session authentication, encryption, and resource
|
||||
authorization.
|
||||
|
||||
The integration does not open Esiur's separate TCP listener. Clients connect to
|
||||
the route mapped with `MapEsiur`.
|
||||
|
||||
## Installation
|
||||
|
||||
```shell
|
||||
dotnet add package Esiur.AspNetCore
|
||||
```
|
||||
|
||||
## Development quick start
|
||||
|
||||
The following example deliberately enables anonymous Esiur sessions to keep a
|
||||
local development service easy to try. Do not expose resources this way unless
|
||||
their authorization policy is safe for anonymous callers.
|
||||
|
||||
```csharp
|
||||
using Esiur.AspNetCore;
|
||||
|
||||
This project brings Esiur distributed resource framework to ASP.Net using WebSockets in the ASP.Net pipeline.
|
||||
# Installation
|
||||
- Nuget
|
||||
```Install-Package Esiur.AspNetCore```
|
||||
- Command-line
|
||||
``` dotnet add package Esiur.AspNetCore ```
|
||||
# Example
|
||||
```C#
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.WebHost.UseUrls("http://localhost:8080");
|
||||
|
||||
builder.Services.AddEsiur(esiur =>
|
||||
{
|
||||
esiur
|
||||
.AddMemoryStore("sys")
|
||||
.AddResource<HelloResource>("sys/service")
|
||||
.AllowAnonymous() // Development only.
|
||||
.IncludeExceptionMessages(); // Development diagnostics only.
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseWebSockets();
|
||||
var webSockets = new WebSocketOptions
|
||||
{
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
webSockets.AllowedOrigins.Add("http://localhost:8080");
|
||||
app.UseWebSockets(webSockets);
|
||||
|
||||
await Warehouse.Put("sys", new MemoryStore());
|
||||
await Warehouse.Put("sys/service", new MyResource());
|
||||
var server = await Warehouse.Put("sys/server", new DistributedServer());
|
||||
await Warehouse.Open();
|
||||
|
||||
app.UseEsiur(new EsiurOptions() { Server = server });
|
||||
app.MapEsiur("/esiur");
|
||||
|
||||
await app.RunAsync();
|
||||
```
|
||||
|
||||
## MyResource.cs
|
||||
```csharp
|
||||
using Esiur.Resource;
|
||||
|
||||
```c#
|
||||
[Resource]
|
||||
public partial class MyResource
|
||||
[Resource]
|
||||
public partial class HelloResource
|
||||
{
|
||||
private int count;
|
||||
|
||||
[Export]
|
||||
public string Hello(string name) => $"Hello, {name}!";
|
||||
|
||||
[Export]
|
||||
public int Increment() => Interlocked.Increment(ref count);
|
||||
|
||||
[Export]
|
||||
public int CurrentCount() => Volatile.Read(ref count);
|
||||
}
|
||||
```
|
||||
|
||||
`AddEsiur` registers a host-managed Warehouse and EP server. The hosted runtime
|
||||
adds the resources, opens the Warehouse during application startup, and closes
|
||||
it during graceful shutdown. Shutdown stops admission and drains or aborts active
|
||||
WebSockets before resource termination begins. Configuration is validated when
|
||||
the host starts.
|
||||
|
||||
## Browser origin policy
|
||||
|
||||
Call `UseWebSockets` before the mapped endpoint. The Esiur endpoint accepts
|
||||
native clients that omit the HTTP `Origin` header. For browser clients, it
|
||||
allows the request's own origin when no Esiur origin list is configured.
|
||||
|
||||
Once origins are configured, they become the complete browser-origin
|
||||
allowlist:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddEsiur(esiur =>
|
||||
{
|
||||
esiur
|
||||
.AddMemoryStore("sys")
|
||||
.AddResource<HelloResource>("sys/service")
|
||||
.AllowAnonymous() // Development only; use authentication in production.
|
||||
.AllowWebSocketOrigins("https://app.example.com");
|
||||
});
|
||||
```
|
||||
|
||||
If `WebSocketOptions.AllowedOrigins` is also restricted, add the same origins
|
||||
there. Set ASP.NET Core `AllowedHosts` to the service's real host names in
|
||||
production; do not leave it as `*`, because implicit same-origin checks rely on
|
||||
the validated request host. `AllowAnyWebSocketOrigin` is available for
|
||||
exceptional cases, but it should not be the production default.
|
||||
|
||||
## Production authentication and encryption
|
||||
|
||||
Register an application authentication provider and authenticated record
|
||||
encryption together. `UseAuthentication` and `UseEncryption` register their
|
||||
`DefaultName` protocol names with the Warehouse and allow them on the EP server.
|
||||
The facade deliberately does not offer protocol aliases: the provider and its
|
||||
handshake handler must negotiate the same stable name across every Esiur
|
||||
language implementation.
|
||||
|
||||
```csharp
|
||||
using Esiur.AspNetCore;
|
||||
using Esiur.Security.Authority.Providers;
|
||||
using Esiur.Security.Cryptography;
|
||||
|
||||
// Load Esiur's precomputed salted password credentials from the application's
|
||||
// account store. Do not keep raw passwords in this dictionary.
|
||||
var passwordCredentials =
|
||||
new Dictionary<(string Domain, string Identity), PasswordHash>();
|
||||
|
||||
builder.Services.AddEsiur(esiur =>
|
||||
{
|
||||
esiur
|
||||
.AddMemoryStore("sys")
|
||||
.AddResource<HelloResource>("sys/service")
|
||||
.UsePasswordAuthentication((identity, domain) =>
|
||||
passwordCredentials.TryGetValue((domain, identity), out var credential)
|
||||
? credential
|
||||
: null)
|
||||
.UseEncryption(new AesEncryptionProvider())
|
||||
.AllowWebSocketOrigins("https://app.example.com")
|
||||
.RequireEncryption();
|
||||
});
|
||||
```
|
||||
|
||||
`UsePasswordAuthentication` is the simple server shortcut: the callback returns
|
||||
a `PasswordHash` for an account or `null` when it is unknown, so applications do
|
||||
not need to subclass `PasswordAuthenticationProvider`. The lookup is synchronous
|
||||
and should use a memory cache or another non-blocking credential store. Advanced
|
||||
protocols and asynchronous backends can still use `UseAuthentication(provider)`,
|
||||
`UseAuthentication<TProvider>()`, or the provider factory overload. Each form
|
||||
uses the provider's `DefaultName`.
|
||||
|
||||
The shortcut continues the same challenge-response shape for an unknown
|
||||
identity using provider-local random dummy material. The application should
|
||||
still make known and unknown lookups take comparable time and apply handshake
|
||||
rate limits; the shortcut cannot hide timing differences in the application's
|
||||
credential store. Identities and domains are limited to 512 UTF-8 bytes before
|
||||
the application callback runs.
|
||||
|
||||
Create each stored value once during account enrollment with
|
||||
`PasswordAuthenticationProvider.CreateCredential(passwordBytes)`, persist its
|
||||
hash and salt, then clear and discard `passwordBytes`. Do not regenerate the
|
||||
credential inside the authentication callback. The stored hash is a
|
||||
password-equivalent verifier: anyone who obtains it can authenticate through
|
||||
this protocol without recovering the original password (a pass-the-hash
|
||||
attack). Keep it secret like a plaintext password: restrict access, encrypt it
|
||||
at rest where appropriate, and never put it in logs or client-visible data.
|
||||
|
||||
For new production deployments, prefer `PpapAuthenticationProvider`. PPAP uses
|
||||
Argon2id as part of its password-derived ML-KEM identity flow. An Argon2id hash
|
||||
cannot simply replace `PasswordHash.Hash` here: `password-sha3-v1` fixes the
|
||||
wire verifier to SHA3-256 of the password and protocol salt, so changing only
|
||||
server-side storage would make clients compute a different value and fail
|
||||
authentication. Memory-hard derivation therefore needs a protocol designed for
|
||||
it on both peers, such as PPAP.
|
||||
|
||||
Omitting `AllowAnonymous` makes Esiur authentication mandatory.
|
||||
`RequireEncryption` rejects sessions that do not negotiate both authentication
|
||||
and an allowed encryption provider.
|
||||
|
||||
Remote errors contain stable Esiur codes only by default. Development services
|
||||
can call `IncludeExceptionMessages()`. Source and stack details require an
|
||||
explicit advanced `ConfigureServer` setting and should not be exposed publicly.
|
||||
|
||||
## Connection and memory limits
|
||||
|
||||
Esiur applies global and per-IP concurrent-connection and attempt limits. Tune
|
||||
them for the deployment, and reduce the per-connection WebSocket send queue if
|
||||
the application does not send large values:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddEsiur(esiur => esiur
|
||||
.AddMemoryStore("sys")
|
||||
.AddResource<HelloResource>("sys/service")
|
||||
.UsePasswordAuthentication((identity, domain) =>
|
||||
passwordCredentials.TryGetValue((domain, identity), out var credential)
|
||||
? credential
|
||||
: null)
|
||||
.LimitPendingWebSocketSendBytes(2 * 1024 * 1024)
|
||||
.LimitTotalPendingWebSocketSendBytes(256L * 1024 * 1024)
|
||||
.ConfigureWarehouse(configuration =>
|
||||
{
|
||||
[Export] int number;
|
||||
[Export] public string Hello() => "Hi";
|
||||
}
|
||||
configuration.Connections.MaximumConnections = 500;
|
||||
configuration.Connections.MaximumConnectionsPerIpAddress = 20;
|
||||
configuration.Connections.MaximumConnectionAttempts = 2_000;
|
||||
}));
|
||||
```
|
||||
|
||||
ASP.NET Core endpoint rate limiting can add a separate handshake policy. A
|
||||
handshake limiter does not limit traffic after the WebSocket is established.
|
||||
The total pending-send limit is shared by every Esiur WebSocket in the host, so
|
||||
the per-connection allowance cannot multiply without bound under many slow
|
||||
clients. Exceeding either limit closes the affected connection rather than
|
||||
dropping a protocol frame and continuing with a corrupted stream.
|
||||
|
||||
## Calling from JavaScript
|
||||
The mapped endpoint participates in standard ASP.NET Core endpoint conventions:
|
||||
|
||||
Esiur provides a command line interpreter for debugging using Node.JS which can be installed using
|
||||
```npm install -g esiur```
|
||||
|
||||
To access the shell
|
||||
```esiur shell```
|
||||
|
||||
Now you can simply test the running service typing
|
||||
```javascript
|
||||
let x = await wh.get("EP://localhost:8080/sys/service", {secure: false});
|
||||
await x.Hello();
|
||||
```csharp
|
||||
app.MapEsiur("/esiur")
|
||||
.RequireAuthorization("EsiurUpgrade")
|
||||
.RequireRateLimiting("EsiurHandshakes");
|
||||
```
|
||||
|
||||
ASP.NET Core authorization only gates the HTTP WebSocket upgrade. It does not
|
||||
replace Esiur session authentication or Esiur resource authorization.
|
||||
|
||||
## Reverse proxies
|
||||
|
||||
When TLS terminates at a trusted reverse proxy, apply forwarded headers before
|
||||
`UseWebSockets` and `MapEsiur`. The original scheme and host are needed for
|
||||
same-origin validation and correct connection metadata.
|
||||
|
||||
```csharp
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using System.Net;
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders =
|
||||
ForwardedHeaders.XForwardedFor |
|
||||
ForwardedHeaders.XForwardedProto |
|
||||
ForwardedHeaders.XForwardedHost;
|
||||
|
||||
// Trust only the proxy addresses used by this deployment.
|
||||
options.KnownProxies.Add(IPAddress.Parse("10.0.0.10"));
|
||||
});
|
||||
|
||||
// After builder.Build():
|
||||
app.UseForwardedHeaders();
|
||||
app.UseWebSockets();
|
||||
app.MapEsiur("/esiur");
|
||||
```
|
||||
|
||||
The proxy must support WebSocket upgrades and supply the corresponding
|
||||
`X-Forwarded-*` headers. Do not trust arbitrary proxy addresses.
|
||||
|
||||
## Connecting from .NET through WebSockets
|
||||
|
||||
Set `EpConnectionContext.WebSocketUri` to the public `ws` or `wss` endpoint,
|
||||
including the route mapped by `MapEsiur`:
|
||||
|
||||
```csharp
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Resource;
|
||||
|
||||
var clientWarehouse = new Warehouse();
|
||||
|
||||
var service = await clientWarehouse.Get<IResource>(
|
||||
"ep://api.example.com/sys/service",
|
||||
new EpConnectionContext
|
||||
{
|
||||
WebSocketUri = new Uri("wss://api.example.com/esiur"),
|
||||
// Add the authentication mode, identity, and protocol required by the server.
|
||||
});
|
||||
```
|
||||
|
||||
Every WebSocket client, including clients in other languages, must request the
|
||||
exact, case-sensitive `EP` WebSocket subprotocol (`Sec-WebSocket-Protocol: EP`).
|
||||
The Esiur client transports do this automatically.
|
||||
|
||||
The `ep://` URL identifies the logical EP connection and resource path;
|
||||
`WebSocketUri` selects its WebSocket transport and carries the ASP.NET route.
|
||||
When using address-bound encryption, use an IP-literal `WebSocketUri`: the .NET
|
||||
`ClientWebSocket` API does not expose the endpoint selected for a DNS host, so
|
||||
Esiur fails address binding closed instead of recording a separate DNS guess.
|
||||
|
||||
## Existing Warehouse and server
|
||||
|
||||
Advanced applications can supply their own instances while still using the
|
||||
ASP.NET Core endpoint and host lifecycle:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddEsiur()
|
||||
.UseWarehouse(warehouse)
|
||||
.UseServer(server, "sys/server");
|
||||
```
|
||||
|
||||
Use `UseWarehouse(warehouse, manageLifecycle: false)` only when another
|
||||
component owns the Warehouse lifecycle and the supplied server and resources
|
||||
are already attached and initialized.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Esiur’s 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.
|
||||
Esiur’s 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);
|
||||
```
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
```
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>()));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user