diff --git a/Examples/AspNet/Controllers/MainController.cs b/Examples/AspNet/Controllers/MainController.cs deleted file mode 100644 index ea1045f..0000000 --- a/Examples/AspNet/Controllers/MainController.cs +++ /dev/null @@ -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 _logger; - - public MainController(ILogger logger) - { - _logger = logger; - } - - [HttpGet(Name = "Get")] - public async AsyncReply Get() - { - return await Warehouse.Default.Get("/sys/demo"); - } - } -} diff --git a/Examples/AspNet/Esiur.Examples.AspNet.csproj b/Examples/AspNet/Esiur.Examples.AspNet.csproj index c406270..eeab805 100644 --- a/Examples/AspNet/Esiur.Examples.AspNet.csproj +++ b/Examples/AspNet/Esiur.Examples.AspNet.csproj @@ -7,13 +7,8 @@ - + + - - - - - - diff --git a/Examples/AspNet/Esiur.Examples.AspNet.http b/Examples/AspNet/Esiur.Examples.AspNet.http deleted file mode 100644 index a934301..0000000 --- a/Examples/AspNet/Esiur.Examples.AspNet.http +++ /dev/null @@ -1,6 +0,0 @@ -@Esiur.ASPNet_HostAddress = http://localhost:5141 - -GET {{Esiur.ASPNet_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/Examples/AspNet/MyResource.cs b/Examples/AspNet/MyResource.cs index fbdb5fc..0947330 100644 --- a/Examples/AspNet/MyResource.cs +++ b/Examples/AspNet/MyResource.cs @@ -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); } diff --git a/Examples/AspNet/Program.cs b/Examples/AspNet/Program.cs index 12eb398..7ddf96d 100644 --- a/Examples/AspNet/Program.cs +++ b/Examples/AspNet/Program.cs @@ -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("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(); diff --git a/Examples/AspNet/Properties/launchSettings.json b/Examples/AspNet/Properties/launchSettings.json index 678b351..2429a43 100644 --- a/Examples/AspNet/Properties/launchSettings.json +++ b/Examples/AspNet/Properties/launchSettings.json @@ -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" } diff --git a/Examples/StandaloneWebServer/Web/js/app.js b/Examples/StandaloneWebServer/Web/js/app.js index 5c67947..a1d786e 100644 --- a/Examples/StandaloneWebServer/Web/js/app.js +++ b/Examples/StandaloneWebServer/Web/js/app.js @@ -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]; - - \ No newline at end of file diff --git a/Integrations/Esiur.AspNetCore/CallbackPasswordAuthenticationProvider.cs b/Integrations/Esiur.AspNetCore/CallbackPasswordAuthenticationProvider.cs new file mode 100644 index 0000000..b625b29 --- /dev/null +++ b/Integrations/Esiur.AspNetCore/CallbackPasswordAuthenticationProvider.cs @@ -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 findCredential; + private readonly byte[] dummyVerifier = + RandomNumberGenerator.GetBytes(CredentialLength); + private readonly byte[] dummySalt = + RandomNumberGenerator.GetBytes(SaltLength); + + public CallbackPasswordAuthenticationProvider( + Func 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 Login(Session session) => new(true); + + public override AsyncReply Logout(Session session) => new(true); +} diff --git a/Integrations/Esiur.AspNetCore/Esiur.AspNetCore.csproj b/Integrations/Esiur.AspNetCore/Esiur.AspNetCore.csproj index ed5cf93..0933a89 100644 --- a/Integrations/Esiur.AspNetCore/Esiur.AspNetCore.csproj +++ b/Integrations/Esiur.AspNetCore/Esiur.AspNetCore.csproj @@ -1,34 +1,35 @@  - Esiur distributed resource framework support for ASP.Net + ASP.NET Core hosting and endpoint routing for the Esiur distributed resource framework Ahmed Kh. Zamil - http://www.esiur.com + https://www.esiur.com true - 1.0.0 + 3.0.0 Ahmed Kh. Zamil Esiur Foundation - http://www.esiur.com https://github.com/esiur/esiur-dotnet/ README.md MIT - net10.0 + net8.0;net10.0 enable enable - Esiur ASP.Net Middleware + $(MSBuildProjectDirectory)=/_/Esiur.AspNetCore + true + Esiur for ASP.NET Core - - - + - + diff --git a/Integrations/Esiur.AspNetCore/EsiurBuilder.cs b/Integrations/Esiur.AspNetCore/EsiurBuilder.cs new file mode 100644 index 0000000..42eddf7 --- /dev/null +++ b/Integrations/Esiur.AspNetCore/EsiurBuilder.cs @@ -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; + +/// +/// Fluent registration surface for an Esiur runtime hosted by ASP.NET Core. +/// +public sealed class EsiurBuilder +{ + internal EsiurBuilder(IServiceCollection services) => Services = services; + + /// The application service collection. + public IServiceCollection Services { get; } + + /// Uses an existing Warehouse. The ASP.NET host owns its lifecycle by default. + public EsiurBuilder UseWarehouse(Warehouse warehouse, bool manageLifecycle = true) + { + ArgumentNullException.ThrowIfNull(warehouse); + Services.Configure(options => + { + options.Warehouse = warehouse; + options.ManageWarehouseLifecycle = manageLifecycle; + }); + return this; + } + + /// + /// Uses an existing EP server. is used only when the + /// server is not already attached to the Warehouse. + /// + public EsiurBuilder UseServer(EpServer server, string serverPath = "sys/server") + { + ArgumentNullException.ThrowIfNull(server); + var path = EsiurConfigurationPath.Normalize(serverPath); + Services.Configure(options => + { + options.Server = server; + options.ServerPath = path; + }); + return this; + } + + /// Configures the final EP server, including externally supplied servers. + public EsiurBuilder ConfigureServer(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + Services.Configure(options => options.ServerConfigurations.Add(configure)); + return this; + } + + /// + /// Explicitly allows unauthenticated EP sessions. Use this only for resources whose + /// authorization policy is safe for anonymous callers. + /// + public EsiurBuilder AllowAnonymous() + { + Services.Configure(options => + options.ServerConfigurations.Add(server => + server.AllowUnauthorizedAccess = true)); + return this; + } + + /// Rejects EP sessions that do not negotiate authenticated encryption. + public EsiurBuilder RequireEncryption() + { + Services.Configure(options => + options.ServerConfigurations.Add(server => server.RequireEncryption = true)); + return this; + } + + /// + /// Includes remote exception messages in addition to stable error codes. Prefer the + /// code-only default in production because messages can contain application details. + /// + public EsiurBuilder IncludeExceptionMessages() + { + Services.Configure(options => + options.ServerConfigurations.Add(server => + server.ExceptionLevel |= Esiur.Core.ExceptionLevel.Code + | Esiur.Core.ExceptionLevel.Message)); + return this; + } + + /// Limits copied outbound WebSocket data retained per connection. + public EsiurBuilder LimitPendingWebSocketSendBytes(long maximumBytes) + { + if (maximumBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(maximumBytes)); + + Services.Configure(options => + options.MaximumPendingWebSocketSendBytes = maximumBytes); + return this; + } + + /// Limits copied outbound WebSocket data retained by the whole Esiur host. + public EsiurBuilder LimitTotalPendingWebSocketSendBytes(long maximumBytes) + { + if (maximumBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(maximumBytes)); + + Services.Configure(options => + options.MaximumTotalPendingWebSocketSendBytes = maximumBytes); + return this; + } + + /// Configures the final Warehouse runtime limits. + public EsiurBuilder ConfigureWarehouse(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + Services.Configure(options => options.WarehouseConfigurations.Add(configure)); + return this; + } + + /// Adds an in-memory root store. + public EsiurBuilder AddMemoryStore(string path = "sys") + => AddResourceRegistration( + path, + _ => new MemoryStore(), + reuseExistingStore: true); + + /// Adds a resource constructed through ASP.NET Core dependency injection. + public EsiurBuilder AddResource(string path) + where TResource : class, IResource + => AddResourceRegistration( + path, + services => ActivatorUtilities.CreateInstance(services), + reuseExistingStore: false); + + /// Adds an existing resource instance. + public EsiurBuilder AddResource(string path, TResource resource) + where TResource : class, IResource + { + ArgumentNullException.ThrowIfNull(resource); + return AddResourceRegistration(path, _ => resource, reuseExistingStore: false); + } + + /// Adds a resource created by an application service factory. + public EsiurBuilder AddResource( + string path, + Func 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); + } + + /// + /// Registers and allows an authentication provider under its default protocol name. + /// + public EsiurBuilder UseAuthentication(IAuthenticationProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + return UseAuthentication(_ => provider); + } + + /// + /// Registers and allows a dependency-injected authentication provider. + /// + public EsiurBuilder UseAuthentication() + where TProvider : class, IAuthenticationProvider + { + Services.TryAddSingleton(); + return UseAuthentication( + services => services.GetRequiredService()); + } + + /// + /// Registers and allows an authentication provider factory under the provider's + /// default protocol name. + /// + public EsiurBuilder UseAuthentication( + Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + Services.Configure(options => + options.AuthenticationProviders.Add(factory)); + return this; + } + + /// + /// Adds server-side password authentication without requiring a custom provider + /// class. The synchronous lookup should read a cached, precomputed Esiur password + /// credential and return for an unknown account. + /// + public EsiurBuilder UsePasswordAuthentication( + Func findCredential) + { + ArgumentNullException.ThrowIfNull(findCredential); + return UsePasswordAuthentication( + (_, identity, domain) => findCredential(identity, domain)); + } + + /// + /// 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. + /// + public EsiurBuilder UsePasswordAuthentication( + Func findCredential) + { + ArgumentNullException.ThrowIfNull(findCredential); + return UseAuthentication(services => + new CallbackPasswordAuthenticationProvider( + (identity, domain) => findCredential(services, identity, domain))); + } + + /// Registers and allows an encryption provider under its default name. + public EsiurBuilder UseEncryption(IEncryptionProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + return UseEncryption(_ => provider); + } + + /// Registers and allows a dependency-injected encryption provider. + public EsiurBuilder UseEncryption() + where TProvider : class, IEncryptionProvider + { + Services.TryAddSingleton(); + return UseEncryption( + services => services.GetRequiredService()); + } + + /// + /// Registers and allows an encryption provider factory under the provider's default + /// protocol name. + /// + public EsiurBuilder UseEncryption( + Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + Services.Configure(options => + options.EncryptionProviders.Add(factory)); + return this; + } + + /// Allows browser WebSocket requests from the specified origins. + 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(options => + { + foreach (var origin in normalized) + options.AllowedWebSocketOrigins.Add(origin); + }); + return this; + } + + /// Allows browser WebSocket requests from every origin. + public EsiurBuilder AllowAnyWebSocketOrigin() + { + Services.Configure(options => options.AllowAnyWebSocketOrigin = true); + return this; + } + + private EsiurBuilder AddResourceRegistration( + string path, + Func factory, + bool reuseExistingStore) + { + var normalizedPath = EsiurConfigurationPath.Normalize(path); + Services.Configure(options => + options.Resources.Add( + new EsiurResourceRegistration(normalizedPath, factory, reuseExistingStore))); + return this; + } +} diff --git a/Integrations/Esiur.AspNetCore/EsiurEndpointRouteBuilderExtensions.cs b/Integrations/Esiur.AspNetCore/EsiurEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..cecae43 --- /dev/null +++ b/Integrations/Esiur.AspNetCore/EsiurEndpointRouteBuilderExtensions.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Esiur.AspNetCore; + +/// Maps Esiur WebSocket endpoints into ASP.NET Core endpoint routing. +public static class EsiurEndpointRouteBuilderExtensions +{ + /// + /// Maps an EP WebSocket endpoint. The returned builder supports ASP.NET Core endpoint + /// conventions such as authorization and rate limiting. + /// + 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() + .HandleAsync(context)) + .WithDisplayName("Esiur EP WebSocket"); + } +} diff --git a/Integrations/Esiur.AspNetCore/EsiurHostedService.cs b/Integrations/Esiur.AspNetCore/EsiurHostedService.cs new file mode 100644 index 0000000..b220f9b --- /dev/null +++ b/Integrations/Esiur.AspNetCore/EsiurHostedService.cs @@ -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 logger; + private readonly TimeSpan shutdownTimeout; + private readonly List 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(); + private string[] originalAllowedEncryptionProviders = Array.Empty(); + + public EsiurHostedService( + EsiurRuntime runtime, + IServiceProvider services, + IHostApplicationLifetime applicationLifetime, + IOptions hostOptions, + ILogger 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(); + originalAllowedEncryptionProviders = + server.AllowedEncryptionProviders ?? Array.Empty(); + 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( + 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( + 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 CloseWarehouseAsync(Warehouse warehouse) + { + var completion = new TaskCompletionSource( + 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 CloseWarehouseAsync( + Warehouse warehouse, + CancellationToken cancellationToken) + => CloseWarehouseAsync(warehouse).WaitAsync(cancellationToken); +} diff --git a/Integrations/Esiur.AspNetCore/EsiurMiddleware.cs b/Integrations/Esiur.AspNetCore/EsiurMiddleware.cs deleted file mode 100644 index 0b97e7c..0000000 --- a/Integrations/Esiur.AspNetCore/EsiurMiddleware.cs +++ /dev/null @@ -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(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 options, ILoggerFactory loggerFactory) - { - this.server = options.Value.Server; - this.loggerFactory = loggerFactory; - this.next = next; - } - } -} diff --git a/Integrations/Esiur.AspNetCore/EsiurMiddlewareExtensions.cs b/Integrations/Esiur.AspNetCore/EsiurMiddlewareExtensions.cs deleted file mode 100644 index 2980149..0000000 --- a/Integrations/Esiur.AspNetCore/EsiurMiddlewareExtensions.cs +++ /dev/null @@ -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(Options.Create(options)); - } - - } -} diff --git a/Integrations/Esiur.AspNetCore/EsiurOptions.cs b/Integrations/Esiur.AspNetCore/EsiurOptions.cs index 3be78be..c2d98ce 100644 --- a/Integrations/Esiur.AspNetCore/EsiurOptions.cs +++ b/Integrations/Esiur.AspNetCore/EsiurOptions.cs @@ -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; + +/// +/// Configures the Esiur runtime hosted by ASP.NET Core. +/// +public sealed class EsiurOptions { - public class EsiurOptions + /// The Warehouse owned by the ASP.NET Core host. + public Warehouse Warehouse { get; set; } = new(); + + /// The EP server that accepts mapped WebSocket connections. + public EpServer Server { get; set; } = new() { - public required EpServer Server { get; set; } + ExceptionLevel = Esiur.Core.ExceptionLevel.Code, + }; + + /// + /// Warehouse path used when has not already been added to the + /// Warehouse. + /// + public string ServerPath { get; set; } = "sys/server"; + + /// + /// Opens the Warehouse when the host starts and closes it when the host stops. + /// Disable only when another component explicitly owns the Warehouse lifecycle. + /// + public bool ManageWarehouseLifecycle { get; set; } = true; + + /// + /// Allows browser WebSocket requests from every Origin. Non-browser clients that do + /// not send an Origin header are always allowed. + /// + public bool AllowAnyWebSocketOrigin { get; set; } + + /// + /// 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. + /// + public ISet AllowedWebSocketOrigins { get; } = + new HashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Maximum copied outbound WebSocket data retained per connection while the peer is + /// under backpressure. + /// + public long MaximumPendingWebSocketSendBytes { get; set; } = 16 * 1024 * 1024; + + /// + /// Maximum copied, unsent WebSocket data retained across every connection hosted by + /// this Esiur runtime. + /// + public long MaximumTotalPendingWebSocketSendBytes { get; set; } = 256L * 1024 * 1024; + + internal IList Resources { get; } = + new List(); + + internal IList> + AuthenticationProviders { get; } = + new List>(); + + internal IList> + EncryptionProviders { get; } = + new List>(); + + internal IList> ServerConfigurations { get; } = + new List>(); + + internal IList> WarehouseConfigurations { get; } = + new List>(); +} + +internal sealed record EsiurResourceRegistration( + string Path, + Func Factory, + bool ReuseExistingStore); + +internal sealed class EsiurOptionsPostConfigure : IPostConfigureOptions +{ + 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 +{ + public ValidateOptionsResult Validate(string? name, EsiurOptions options) + { + var failures = new List(); + + 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(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 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; } } diff --git a/Integrations/Esiur.AspNetCore/EsiurRuntime.cs b/Integrations/Esiur.AspNetCore/EsiurRuntime.cs new file mode 100644 index 0000000..c38e268 --- /dev/null +++ b/Integrations/Esiur.AspNetCore/EsiurRuntime.cs @@ -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 stoppingSockets = new(); + private bool ready; + private long pendingSendBytes; + + public EsiurRuntime(IOptions 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 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); + } +} diff --git a/Integrations/Esiur.AspNetCore/EsiurServiceCollectionExtensions.cs b/Integrations/Esiur.AspNetCore/EsiurServiceCollectionExtensions.cs new file mode 100644 index 0000000..d0d2145 --- /dev/null +++ b/Integrations/Esiur.AspNetCore/EsiurServiceCollectionExtensions.cs @@ -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; + +/// Registers Esiur with the ASP.NET Core dependency injection container. +public static class EsiurServiceCollectionExtensions +{ + /// Adds one host-managed Esiur runtime. + 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(); + services.AddOptions().ValidateOnStart(); + services.TryAddEnumerable( + ServiceDescriptor.Singleton, + EsiurOptionsPostConfigure>()); + services.TryAddEnumerable( + ServiceDescriptor.Singleton, + EsiurOptionsValidator>()); + + services.AddSingleton(); + services.AddSingleton(provider => + provider.GetRequiredService().Warehouse); + services.AddSingleton(provider => + provider.GetRequiredService().Server); + services.AddSingleton(); + services.AddSingleton(); + + return new EsiurBuilder(services); + } + + /// Adds and configures one host-managed Esiur runtime. + public static EsiurBuilder AddEsiur( + this IServiceCollection services, + Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + var builder = services.AddEsiur(); + configure(builder); + return builder; + } + + /// Adds Esiur around an existing Warehouse and EP server. + 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; +} diff --git a/Integrations/Esiur.AspNetCore/EsiurWebSocketEndpoint.cs b/Integrations/Esiur.AspNetCore/EsiurWebSocketEndpoint.cs new file mode 100644 index 0000000..46b5f22 --- /dev/null +++ b/Integrations/Esiur.AspNetCore/EsiurWebSocketEndpoint.cs @@ -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 logger; + + public EsiurWebSocketEndpoint( + EsiurRuntime runtime, + ILogger 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; + } +} diff --git a/Integrations/Esiur.AspNetCore/README.md b/Integrations/Esiur.AspNetCore/README.md index f888149..a9fbf3a 100644 --- a/Integrations/Esiur.AspNetCore/README.md +++ b/Integrations/Esiur.AspNetCore/README.md @@ -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("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("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("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()`, 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("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(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( + "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. diff --git a/Libraries/Esiur/Core/AsyncReply.cs b/Libraries/Esiur/Core/AsyncReply.cs index 41d51d5..bc940c9 100644 --- a/Libraries/Esiur/Core/AsyncReply.cs +++ b/Libraries/Esiur/Core/AsyncReply.cs @@ -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); + + /// + /// 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. + /// + internal void TriggerDetached(object result) => + TrySetResult(result, dispatchCallbacksAsynchronously: true); + + private bool TrySetResult(object result, bool dispatchCallbacksAsynchronously) { Action singleCallback = null; Action[] 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); } + /// + /// 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. + /// + 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 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( + Action singleCallback, + Action[] 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); + } + }); + } } diff --git a/Libraries/Esiur/Esiur.csproj b/Libraries/Esiur/Esiur.csproj index aa181d6..3fda592 100644 --- a/Libraries/Esiur/Esiur.csproj +++ b/Libraries/Esiur/Esiur.csproj @@ -16,6 +16,9 @@ Esiur Esiur latest + + $(MSBuildProjectDirectory)=/_/Esiur @@ -39,6 +42,8 @@ + + @@ -50,7 +55,9 @@ - + + diff --git a/Libraries/Esiur/Net/NetworkServer.cs b/Libraries/Esiur/Net/NetworkServer.cs index cb4eaa2..f81f60c 100644 --- a/Libraries/Esiur/Net/NetworkServer.cs +++ b/Libraries/Esiur/Net/NetworkServer.cs @@ -53,6 +53,13 @@ public abstract class NetworkServer : 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>(this); + } + private void MinuteThread(object state) { @@ -93,8 +100,6 @@ public abstract class NetworkServer : IDestructible where TConnecti if (listener != null) return; - Connections = new AutoList>(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 : 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."); + } } } diff --git a/Libraries/Esiur/Net/Sockets/FrameworkWebSocket.cs b/Libraries/Esiur/Net/Sockets/FrameworkWebSocket.cs index 89d4eb4..a984dba 100644 --- a/Libraries/Esiur/Net/Sockets/FrameworkWebSocket.cs +++ b/Libraries/Esiur/Net/Sockets/FrameworkWebSocket.cs @@ -1,311 +1,937 @@ -using Esiur.Core; +using Esiur.Core; +using Esiur.Misc; +using Esiur.Resource; using System; using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Net; -using System.Text; using System.Net.WebSockets; -using Esiur.Net.Packets; -using Esiur.Resource; -using Esiur.Misc; -using System.Drawing; using System.Threading; using System.Threading.Tasks; -using System.Net.Sockets; -using Microsoft.CodeAnalysis; -namespace Esiur.Net.Sockets +namespace Esiur.Net.Sockets; + +internal interface IPendingSendBudget { - public class FrameworkWebSocket : ISocket + bool TryReserve(int byteCount); + void Release(int byteCount); +} + +/// +/// Adapts a platform to Esiur's socket abstraction. +/// +public sealed class FrameworkWebSocket : ISocket +{ + private sealed class PendingSend { - bool began; + public byte[] Buffer; + public AsyncReply Reply; + public IPendingSendBudget Budget; + } - WebSocket sock; + public const string SubProtocol = "EP"; - NetworkBuffer receiveNetworkBuffer = new NetworkBuffer(); - NetworkBuffer sendNetworkBuffer = new NetworkBuffer(); + private const int ReceiveBufferSize = 16 * 1024; + private static readonly TimeSpan CloseTimeout = TimeSpan.FromSeconds(2); - byte[] websocketReceiveBuffer = new byte[10240]; - ArraySegment websocketReceiveBufferSegment; + private readonly object lifecycleLock = new object(); + private readonly object sendLock = new object(); + private readonly SemaphoreSlim sendOperation = new SemaphoreSlim(1, 1); + private readonly Queue sendQueue = new Queue(); + private readonly byte[] receiveBuffer = new byte[ReceiveBufferSize]; + private readonly NetworkBuffer receiveNetworkBuffer = new NetworkBuffer(); + private readonly CancellationTokenSource lifetimeCancellation; + private readonly TaskCompletionSource completionSource = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource closeNotificationSource = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Uri configuredUri; - object sendLock = new object(); - readonly SemaphoreSlim sendSemaphore = new SemaphoreSlim(1, 1); - int sendFailureNotified; - bool held; + private WebSocket socket; + private SocketState state; + private bool began; + private bool closing; + private bool completed; + private bool destroyed; + private bool held; + private bool sendPumpRunning; + private PendingSend inFlightSend; + private int closeNotified; + private long maximumPendingSendBytes = 16 * 1024 * 1024; + private long pendingSendBytes; + private long totalSent; + private long totalReceived; - public event DestroyedEvent OnDestroy; + public event DestroyedEvent OnDestroy; - long totalSent, totalReceived; + /// + /// Creates an outbound WebSocket for an absolute ws or wss URI. + /// The URI can include a path and query string. + /// + public FrameworkWebSocket(Uri uri) + { + configuredUri = ValidateUri(uri); + lifetimeCancellation = new CancellationTokenSource(); + state = SocketState.Initial; + LocalEndPoint = new IPEndPoint(IPAddress.Any, 0); + RemoteEndPoint = new IPEndPoint(IPAddress.Any, 0); + } + /// + /// Wraps an accepted WebSocket. ASP.NET integrations must pass the concrete + /// transport endpoints because EP admission controls and address-bound encryption + /// depend on the observed peer address. + /// + public FrameworkWebSocket( + WebSocket webSocket, + IPEndPoint localEndPoint, + IPEndPoint remoteEndPoint, + CancellationToken cancellationToken = default) + { + socket = webSocket ?? throw new ArgumentNullException(nameof(webSocket)); + if (!string.Equals(webSocket.SubProtocol, SubProtocol, StringComparison.Ordinal)) + throw new ArgumentException( + $"The WebSocket must have negotiated the '{SubProtocol}' subprotocol.", + nameof(webSocket)); - public IPEndPoint LocalEndPoint { get; } = new IPEndPoint(IPAddress.Any, 0); + LocalEndPoint = localEndPoint ?? throw new ArgumentNullException(nameof(localEndPoint)); + RemoteEndPoint = remoteEndPoint ?? throw new ArgumentNullException(nameof(remoteEndPoint)); + lifetimeCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + state = MapState(webSocket.State); + } - public IPEndPoint RemoteEndPoint { get; } = new IPEndPoint(IPAddress.Any, 0); + public INetworkReceiver Receiver { get; set; } + public IPEndPoint LocalEndPoint { get; private set; } - public SocketState State => sock == null ? SocketState.Closed : sock.State switch + public IPEndPoint RemoteEndPoint { get; private set; } + + public SocketState State + { + get { - WebSocketState.Aborted => SocketState.Closed, - WebSocketState.Closed => SocketState.Closed, - WebSocketState.Connecting => SocketState.Connecting, - WebSocketState.Open => SocketState.Established, - WebSocketState.CloseReceived => SocketState.Closed, - WebSocketState.CloseSent => SocketState.Closed, - WebSocketState.None => SocketState.Initial, - _ => SocketState.Initial - }; - - public INetworkReceiver Receiver { get; set; } - - public FrameworkWebSocket() - { - websocketReceiveBufferSegment = new ArraySegment(websocketReceiveBuffer); + lock (lifecycleLock) + return state; } + } + /// + /// Completes when the WebSocket has terminated. Unexpected transport failures + /// fault the task; normal peer closure, local closure, and owner cancellation do not. + /// + public Task Completion => completionSource.Task; - public FrameworkWebSocket(WebSocket webSocket) + /// + /// Completes after the protocol receiver has observed transport closure. This is + /// separate from so application callbacks cannot hold the + /// physical transport open during host shutdown. + /// + internal Task CloseNotification => closeNotificationSource.Task; + + public long PendingSendBytes => Interlocked.Read(ref pendingSendBytes); + + public long TotalSent => Interlocked.Read(ref totalSent); + + public long TotalReceived => Interlocked.Read(ref totalReceived); + + internal IPendingSendBudget PendingSendBudget { get; set; } + + /// + /// Maximum number of copied, unsent bytes retained for this connection. + /// + public long MaximumPendingSendBytes + { + get => Interlocked.Read(ref maximumPendingSendBytes); + set { - websocketReceiveBufferSegment = new ArraySegment(websocketReceiveBuffer); - sock = webSocket; + if (value <= 0) + throw new ArgumentOutOfRangeException(nameof(value)); - } - - - public void Send(byte[] message) - { - byte[] queued = null; - lock (sendLock) - { - if (held) - { - sendNetworkBuffer.Write(message); - } - else - { - queued = (byte[])message.Clone(); - } - } - - if (queued != null) - ObserveSend(QueueSend(queued)); + Interlocked.Exchange(ref maximumPendingSendBytes, value); } + } + public AsyncReply Connect(string hostname, ushort port) + { + if (configuredUri != null) + return Connect(configuredUri); - public void Send(byte[] message, int offset, int size) + if (string.IsNullOrWhiteSpace(hostname)) + throw new ArgumentException("A WebSocket host is required.", nameof(hostname)); + + return Connect(new UriBuilder("ws", hostname, port).Uri); + } + + /// + /// Connects to a full WebSocket URI and requests the EP subprotocol. + /// + public async AsyncReply Connect( + Uri uri, + CancellationToken cancellationToken = default) + { + uri = ValidateUri(uri); + + ClientWebSocket client; + lock (lifecycleLock) { - byte[] queued = null; - lock (sendLock) - { - if (held) - { - sendNetworkBuffer.Write(message, (uint)offset, (uint)size); - } - else - { - queued = new byte[size]; - Buffer.BlockCopy(message, offset, queued, 0, size); - } - } - - if (queued != null) - ObserveSend(QueueSend(queued)); - } - - - public void Close() - { - sock?.CloseAsync(WebSocketCloseStatus.NormalClosure, "", new System.Threading.CancellationToken()); - } - - public bool Secure { get; set; } - - public async AsyncReply Connect(string hostname, ushort port) - { - var url = new Uri($"{(Secure ? "wss" : "ws")}://{hostname}:{port}"); - - var ws = new ClientWebSocket(); - sock = ws; - - await ws.ConnectAsync(url, new CancellationToken()); - - - _ = sock.ReceiveAsync(websocketReceiveBufferSegment, CancellationToken.None) - .ContinueWith(NetworkReceive); - - return true; - } - - - public bool Begin() - { - - // Socket destroyed - if (sock == null) + if (completed || closing) return false; + if (state != SocketState.Initial) + throw new InvalidOperationException("The WebSocket has already been connected."); - if (began) + client = new ClientWebSocket(); + client.Options.AddSubProtocol(SubProtocol); + socket = client; + state = SocketState.Connecting; + } + + using (var connectCancellation = CancellationTokenSource.CreateLinkedTokenSource( + lifetimeCancellation.Token, + cancellationToken)) + { + try + { + // ClientWebSocket does not expose its selected transport endpoint. Only + // an IP-literal URI can therefore provide an authoritative address for + // address-bound EP encryption; do not guess by performing a second DNS + // lookup that may select a different address than ConnectAsync. + var remoteAddress = IPAddress.TryParse(uri.Host, out var literalAddress) + ? literalAddress + : IPAddress.Any; + await client.ConnectAsync(uri, connectCancellation.Token); + + if (!string.Equals( + client.SubProtocol, + SubProtocol, + StringComparison.Ordinal)) + { + throw new WebSocketException( + $"The server did not negotiate the required '{SubProtocol}' subprotocol."); + } + + lock (lifecycleLock) + { + if (completed || closing) + return false; + + RemoteEndPoint = new IPEndPoint(remoteAddress, GetPort(uri)); + state = SocketState.Established; + } + + return true; + } + catch (Exception exception) + { + Finish(exception, notifyReceiver: false, abort: true); + throw; + } + } + } + + public bool Begin() + { + lock (lifecycleLock) + { + if (completed || closing || began || socket == null || state != SocketState.Established) return false; began = true; - - sock.ReceiveAsync(websocketReceiveBufferSegment, CancellationToken.None) - .ContinueWith(NetworkReceive); - - return true; - } - public bool Trigger(ResourceOperation trigger) + _ = ReceiveLoopAsync(); + return true; + } + + public AsyncReply BeginAsync() => new AsyncReply(Begin()); + + public void Send(byte[] message) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + + Send(message, 0, message.Length); + } + + public void Send(byte[] message, int offset, int length) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + + ValidateRange(message, offset, length); + if (length == 0 || !CanSend()) + return; + + EnqueueSend(message, offset, length, null, throwOnCapacityFailure: true); + } + + public AsyncReply SendAsync(byte[] message, int offset, int length) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + + ValidateRange(message, offset, length); + + var reply = new AsyncReply(); + if (length == 0) { - return true; + reply.Trigger(true); + return reply; } - public void Destroy() + if (!CanSend()) { - var ws = sock; + reply.Trigger(false); + return reply; + } - Close(); // best-effort graceful close handshake (fire-and-forget) + EnqueueSend(message, offset, length, reply, throwOnCapacityFailure: false); + return reply; + } - receiveNetworkBuffer = null; - Receiver = null; - sock = null; + public void Hold() + { + lock (sendLock) + held = true; + } - // Dispose the WebSocket so its buffers and handle are released; Close() only - // starts the async close handshake and never disposes. - try { ws?.Dispose(); } catch { } + public void Unhold() + { + var startPump = false; + lock (sendLock) + { + held = false; + if (!sendPumpRunning && sendQueue.Count > 0 && CanSend()) + { + sendPumpRunning = true; + startPump = true; + } + } - OnDestroy?.Invoke(this); + if (startPump) + _ = SendLoopAsync(); + } + + public void Close() => _ = CloseAsync(); + + /// + /// Starts a graceful close and returns the same task exposed by + /// . + /// + public Task CloseAsync() + { + if (TryBeginClose()) + _ = CloseCoreAsync(); + + return Completion; + } + + public void Destroy() + { + DestroyedEvent destroyedHandler = null; + lock (lifecycleLock) + { + if (destroyed) + return; + + destroyed = true; + closing = true; + destroyedHandler = OnDestroy; OnDestroy = null; } - public AsyncReply AcceptAsync() - { - throw new NotImplementedException(); - } + Finish(null, notifyReceiver: true, abort: true); + QueueDestroyedNotification(destroyedHandler); + } - public void Hold() - { - held = true; - } + public bool Trigger(ResourceOperation trigger) => true; - public void Unhold() + public AsyncReply AcceptAsync() => + throw new NotSupportedException("A WebSocket is not a listening socket."); + + public ISocket Accept() => + throw new NotSupportedException("A WebSocket is not a listening socket."); + + private void EnqueueSend( + byte[] message, + int offset, + int length, + AsyncReply reply, + bool throwOnCapacityFailure) + { + var startPump = false; + var rejected = false; + Exception capacityFailure = null; + + lock (sendLock) { - byte[] message; - lock (sendLock) + if (!CanSend()) { - held = false; - message = sendNetworkBuffer.Read(); + rejected = true; } - - if (message != null) - ObserveSend(QueueSend(message)); - } - - public async AsyncReply SendAsync(byte[] message, int offset, int length) - { - byte[] queued = null; - lock (sendLock) + else { - if (held) + try { - sendNetworkBuffer.Write(message, (uint)offset, (uint)length); + EnsureSendCapacity(length); } - else + catch (Exception exception) { - queued = new byte[length]; - Buffer.BlockCopy(message, offset, queued, 0, length); + capacityFailure = exception; + } + + if (capacityFailure == null) + { + var budget = PendingSendBudget; + if (budget != null && !budget.TryReserve(length)) + { + capacityFailure = new InvalidOperationException( + "The host-wide WebSocket send queue limit was exceeded."); + } + else + { + try + { + var copy = new byte[length]; + Buffer.BlockCopy(message, offset, copy, 0, length); + sendQueue.Enqueue(new PendingSend + { + Buffer = copy, + Reply = reply, + Budget = budget, + }); + Interlocked.Add(ref pendingSendBytes, length); + } + catch (Exception exception) + { + budget?.Release(length); + capacityFailure = exception; + } + } + + if (capacityFailure == null && !held && !sendPumpRunning) + { + sendPumpRunning = true; + startPump = true; + } } } - - if (queued != null) - await QueueSend(queued); - - return true; } - async Task QueueSend(byte[] message) + if (rejected) { - await sendSemaphore.WaitAsync(); + CompleteSendReply(reply, false, null); + return; + } + + if (capacityFailure != null) + { + if (!throwOnCapacityFailure) + CompleteSendReply(reply, false, capacityFailure); + + // Capacity exhaustion is a terminal backpressure failure. Finish must run + // after releasing sendLock because it drains the queue under that lock and + // can synchronously notify a receiver that re-enters this socket. + Finish(capacityFailure, notifyReceiver: true, abort: true); + + if (throwOnCapacityFailure) + throw capacityFailure; + + return; + } + + if (startPump) + _ = SendLoopAsync(); + } + + private async Task SendLoopAsync() + { + while (true) + { + PendingSend pending; + WebSocket currentSocket; + + lock (sendLock) + { + if (held || !CanSend() || sendQueue.Count == 0) + { + sendPumpRunning = false; + return; + } + + pending = sendQueue.Dequeue(); + inFlightSend = pending; + currentSocket = socket; + } + + Exception sendError = null; + var ownsSendOperation = false; try { - var socket = sock ?? throw new InvalidOperationException("WebSocket is closed."); - await socket.SendAsync( - new ArraySegment(message), + await sendOperation.WaitAsync(lifetimeCancellation.Token); + ownsSendOperation = true; + await currentSocket.SendAsync( + new ArraySegment(pending.Buffer), WebSocketMessageType.Binary, - true, - CancellationToken.None); - Interlocked.Add(ref totalSent, message.Length); + endOfMessage: true, + lifetimeCancellation.Token); + } - catch + catch (Exception exception) { - NotifySendFailure(); - throw; + sendError = exception; } finally { - sendSemaphore.Release(); + if (ownsSendOperation) + sendOperation.Release(); } - } - void ObserveSend(Task task) - { - _ = task.ContinueWith( - completed => + if (sendError == null) + { + Interlocked.Add(ref totalSent, pending.Buffer.Length); + if (!TryCompleteInFlightSend(pending, true, null)) { - // Observe the exception; QueueSend already closed/notified the receiver. - _ = completed.Exception; - }, - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted, - TaskScheduler.Default); - } + lock (sendLock) + sendPumpRunning = false; + return; + } - void NotifySendFailure() - { - if (Interlocked.Exchange(ref sendFailureNotified, 1) == 0) - { - try { sock?.Abort(); } catch { } - Receiver?.NetworkClose(this); + continue; } - } - public ISocket Accept() - { - throw new NotImplementedException(); - } - - public AsyncReply BeginAsync() - { - return new AsyncReply(Begin()); - } - - - private void NetworkReceive(Task task) - { - - if (sock.State == WebSocketState.Closed || sock.State == WebSocketState.Aborted || sock.State == WebSocketState.CloseReceived) + if (IsClosingOrCompleted()) { - Receiver?.NetworkClose(this); + TryCompleteInFlightSend(pending, false, null); + lock (sendLock) + sendPumpRunning = false; return; } - - var receivedLength = task.Result.Count; + if (lifetimeCancellation.IsCancellationRequested) + { + if (!TryCompleteInFlightSend(pending, false, null)) + { + lock (sendLock) + sendPumpRunning = false; + return; + } - totalReceived += receivedLength; + lock (sendLock) + sendPumpRunning = false; + Finish(null, notifyReceiver: true, abort: true); + return; + } - receiveNetworkBuffer.Write(websocketReceiveBuffer, 0, (uint)receivedLength); + if (!TryCompleteInFlightSend(pending, false, sendError)) + { + lock (sendLock) + sendPumpRunning = false; + return; + } - Receiver?.NetworkReceive(this, receiveNetworkBuffer); - - sock.ReceiveAsync(websocketReceiveBufferSegment, CancellationToken.None) - .ContinueWith(NetworkReceive); - - } - - - public void NetworkConnect(ISocket sender) - { - Receiver?.NetworkConnect(this); + lock (sendLock) + sendPumpRunning = false; + Finish(sendError, notifyReceiver: true, abort: true); + return; } } + + private async Task ReceiveLoopAsync() + { + try + { + while (!lifetimeCancellation.IsCancellationRequested) + { + var currentSocket = socket; + if (currentSocket == null) + return; + + var result = await currentSocket.ReceiveAsync( + new ArraySegment(receiveBuffer), + lifetimeCancellation.Token); + + if (result.MessageType == WebSocketMessageType.Close) + { + await AcknowledgeRemoteCloseAsync(currentSocket, result); + return; + } + + if (result.MessageType != WebSocketMessageType.Binary) + throw new InvalidDataException("EP WebSockets only accept binary messages."); + + if (result.Count == 0) + continue; + + Interlocked.Add(ref totalReceived, result.Count); + receiveNetworkBuffer.Write(receiveBuffer, 0, (uint)result.Count); + Receiver?.NetworkReceive(this, receiveNetworkBuffer); + } + + if (!IsClosingOrCompleted()) + Finish(null, notifyReceiver: true, abort: true); + } + catch (OperationCanceledException) when (lifetimeCancellation.IsCancellationRequested) + { + if (!IsClosingOrCompleted()) + Finish(null, notifyReceiver: true, abort: true); + } + catch (ObjectDisposedException) when (IsClosingOrCompleted()) + { + } + catch (Exception exception) + { + if (!IsClosingOrCompleted()) + Finish(exception, notifyReceiver: true, abort: true); + } + } + + private async Task AcknowledgeRemoteCloseAsync( + WebSocket currentSocket, + WebSocketReceiveResult result) + { + // TryBeginClose can return false because a local close is already in progress. + // That does not prove CloseCoreAsync has sent its close frame yet: the send + // semaphore may still be held by an application write. Always inspect the + // platform state under the same semaphore and acknowledge CloseReceived when + // needed. If our frame was already sent, the state is CloseSent and this is a + // no-op before completing the transport. + TryBeginClose(); + + try + { + using (var closeCancellation = new CancellationTokenSource(CloseTimeout)) + { + var ownsSendOperation = false; + try + { + await sendOperation.WaitAsync(closeCancellation.Token); + ownsSendOperation = true; + + if (currentSocket.State == WebSocketState.CloseReceived) + { + await currentSocket.CloseOutputAsync( + result.CloseStatus ?? WebSocketCloseStatus.NormalClosure, + result.CloseStatusDescription ?? string.Empty, + closeCancellation.Token); + } + } + finally + { + if (ownsSendOperation) + sendOperation.Release(); + } + } + } + catch (Exception exception) + { + Global.Log("FrameworkWebSocket:Close", LogType.Debug, exception.Message); + } + finally + { + Finish(null, notifyReceiver: true, abort: false); + } + } + + private async Task CloseCoreAsync() + { + var currentSocket = socket; + try + { + if (currentSocket == null + || (currentSocket.State != WebSocketState.Open + && currentSocket.State != WebSocketState.CloseReceived)) + { + Finish(null, notifyReceiver: true, abort: false); + return; + } + + if (currentSocket.State == WebSocketState.Open + || currentSocket.State == WebSocketState.CloseReceived) + { + using (var closeCancellation = new CancellationTokenSource(CloseTimeout)) + { + var ownsSendOperation = false; + try + { + await sendOperation.WaitAsync(closeCancellation.Token); + ownsSendOperation = true; + if (currentSocket.State == WebSocketState.Open + || currentSocket.State == WebSocketState.CloseReceived) + { + await currentSocket.CloseOutputAsync( + WebSocketCloseStatus.NormalClosure, + string.Empty, + closeCancellation.Token); + } + } + finally + { + if (ownsSendOperation) + sendOperation.Release(); + } + } + } + + // CloseOutputAsync sends our half of the close handshake. Keep the receive + // loop alive until the peer acknowledges it; otherwise disposing the ASP.NET + // socket here makes clients observe an incomplete close handshake. + var completed = await Task.WhenAny( + Completion, + Task.Delay(CloseTimeout)); + if (ReferenceEquals(completed, Completion)) + { + try { await Completion; } catch { } + } + else + { + Finish(null, notifyReceiver: true, abort: true); + } + } + catch (Exception exception) + { + Global.Log("FrameworkWebSocket:Close", LogType.Debug, exception.Message); + Finish(null, notifyReceiver: true, abort: true); + } + } + + private bool TryBeginClose() + { + lock (lifecycleLock) + { + if (completed || closing) + return false; + + closing = true; + state = SocketState.Closed; + return true; + } + } + + private void Finish(Exception error, bool notifyReceiver, bool abort) + { + WebSocket currentSocket; + INetworkReceiver receiver = null; + List pendingSends; + + lock (lifecycleLock) + { + if (completed) + return; + + completed = true; + closing = true; + state = SocketState.Closed; + currentSocket = socket; + socket = null; + + if (notifyReceiver && Interlocked.Exchange(ref closeNotified, 1) == 0) + receiver = Receiver; + } + + try { lifetimeCancellation.Cancel(); } catch { } + if (abort) + { + try { currentSocket?.Abort(); } catch { } + } + try { currentSocket?.Dispose(); } catch { } + + lock (sendLock) + { + pendingSends = sendQueue.ToList(); + sendQueue.Clear(); + if (inFlightSend != null) + { + pendingSends.Add(inFlightSend); + inFlightSend = null; + } + sendPumpRunning = false; + foreach (var pending in pendingSends) + { + ReleasePendingSend(pending); + // Mark every reply terminal before the socket can publish transport + // completion. Callbacks are never invoked synchronously under sendLock. + CompleteSendReply(pending.Reply, false, error, detachCallbacks: true); + } + } + + if (error != null) + Global.Log("FrameworkWebSocket", LogType.Debug, error.Message); + + QueueCloseNotification(receiver); + CompleteTransport(error); + } + + private bool CanSend() + { + lock (lifecycleLock) + return !completed && !closing && state == SocketState.Established && socket != null; + } + + private bool IsClosingOrCompleted() + { + lock (lifecycleLock) + return closing || completed; + } + + private void EnsureSendCapacity(int length) + { + var limit = Interlocked.Read(ref maximumPendingSendBytes); + var pending = Interlocked.Read(ref pendingSendBytes); + if (length > limit - pending) + { + throw new InvalidOperationException( + $"The WebSocket send queue exceeded its {limit}-byte limit."); + } + } + + private void ReleasePendingSend(PendingSend pending) + { + Interlocked.Add(ref pendingSendBytes, -pending.Buffer.Length); + pending.Budget?.Release(pending.Buffer.Length); + } + + private bool TryCompleteInFlightSend( + PendingSend pending, + bool result, + Exception error) + { + lock (sendLock) + { + if (!ReferenceEquals(inFlightSend, pending)) + return false; + + ReleasePendingSend(pending); + // The terminal state is set while this socket still owns the send. Finish + // cannot publish Completion in the ownership-transfer gap. + CompleteSendReply( + pending.Reply, + result, + error, + detachCallbacks: true); + inFlightSend = null; + return true; + } + } + + private void QueueCloseNotification(INetworkReceiver receiver) + { + if (receiver == null) + { + closeNotificationSource.TrySetResult(null); + return; + } + + ThreadPool.QueueUserWorkItem(_ => + { + try + { + receiver.NetworkClose(this); + } + catch (Exception exception) + { + Global.Log(exception); + } + finally + { + closeNotificationSource.TrySetResult(null); + } + }); + } + + private void QueueDestroyedNotification(DestroyedEvent handler) + { + if (handler == null) + return; + + // Preserve the historical NetworkClose-before-OnDestroy ordering without + // allowing either consumer callback to block the caller of Destroy. + _ = closeNotificationSource.Task.ContinueWith( + completedNotification => + { + try { handler(this); } + catch (Exception exception) { Global.Log(exception); } + }, + CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default); + } + + private void CompleteTransport(Exception error) + { + if (error == null) + completionSource.TrySetResult(null); + else + completionSource.TrySetException(error); + } + + private static Uri ValidateUri(Uri uri) + { + if (uri == null) + throw new ArgumentNullException(nameof(uri)); + if (!uri.IsAbsoluteUri || + (!string.Equals(uri.Scheme, "ws", StringComparison.OrdinalIgnoreCase) && + !string.Equals(uri.Scheme, "wss", StringComparison.OrdinalIgnoreCase))) + { + throw new ArgumentException( + "The WebSocket URI must be absolute and use the ws or wss scheme.", + nameof(uri)); + } + + return uri; + } + + private static int GetPort(Uri uri) => + uri.IsDefaultPort + ? string.Equals(uri.Scheme, "wss", StringComparison.OrdinalIgnoreCase) ? 443 : 80 + : uri.Port; + + private static SocketState MapState(WebSocketState webSocketState) + { + switch (webSocketState) + { + case WebSocketState.Open: + return SocketState.Established; + case WebSocketState.Connecting: + return SocketState.Connecting; + case WebSocketState.None: + return SocketState.Initial; + default: + return SocketState.Closed; + } + } + + private static void ValidateRange(byte[] message, int offset, int length) + { + if (offset < 0 || length < 0 || offset > message.Length - length) + throw new ArgumentOutOfRangeException(); + } + + private static void CompleteSendReply( + AsyncReply reply, + bool result, + Exception error, + bool detachCallbacks = false) + { + if (reply == null) + return; + + try + { + if (error == null && detachCallbacks) + reply.TriggerDetached(result); + else if (error == null) + reply.Trigger(result); + else if (detachCallbacks) + reply.TriggerErrorDetached(error); + else + reply.TriggerError(error); + } + catch (Exception exception) + { + Global.Log(exception); + } + } + + public void NetworkConnect(ISocket sender) => Receiver?.NetworkConnect(this); } diff --git a/Libraries/Esiur/Protocol/EpConnection.cs b/Libraries/Esiur/Protocol/EpConnection.cs index 421abec..cf8ef05 100644 --- a/Libraries/Esiur/Protocol/EpConnection.cs +++ b/Libraries/Esiur/Protocol/EpConnection.cs @@ -115,6 +115,12 @@ public partial class EpConnection : NetworkConnection, IStore Session _session; AsyncReply _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 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; } + /// + /// Full WebSocket endpoint used by outbound connections. When null, EP uses + /// its native TCP transport (except on browser runtimes). + /// + 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 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 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 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(); - if (Interlocked.CompareExchange(ref _openReply, openReply, null) != null) - throw new AsyncException(ErrorType.Exception, 0, "Connection in progress"); + AsyncReply 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(); + 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 expectedOpenReply, + long attemptGeneration) + { + if (!IsCurrentConnectAttempt(expectedOpenReply, attemptGeneration)) + { + DestroyStaleSocket(socket); + return; + } + + AsyncReply 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 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 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 expectedOpenReply, + long attemptGeneration) + { + lock (_connectLifecycleLock) + return IsCurrentConnectAttemptLocked(expectedOpenReply, attemptGeneration); + } + + bool IsCurrentConnectAttemptLocked( + AsyncReply expectedOpenReply, + long attemptGeneration) + => !_isDestroyed + && _connectAttemptGeneration == attemptGeneration + && ReferenceEquals(Volatile.Read(ref _openReply), expectedOpenReply); + + void FailConnectAttempt( + AsyncReply 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 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 { diff --git a/Libraries/Esiur/Protocol/EpConnectionContext.cs b/Libraries/Esiur/Protocol/EpConnectionContext.cs index 24b76f5..450b2ea 100644 --- a/Libraries/Esiur/Protocol/EpConnectionContext.cs +++ b/Libraries/Esiur/Protocol/EpConnectionContext.cs @@ -15,23 +15,6 @@ namespace Esiur.Protocol; public class EpConnectionContext : IResourceContext { - //public EpConnectionContext() - // : base(0, new Map(), 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; } + /// + /// Uses WebSocket transport when set. The absolute ws or wss + /// URI can include the endpoint path and query string. + /// + public Uri WebSocketUri { get; set; } // public string Password { get; set; } diff --git a/Libraries/Esiur/Protocol/EpServer.cs b/Libraries/Esiur/Protocol/EpServer.cs index ccd3165..8bacb6e 100644 --- a/Libraries/Esiur/Protocol/EpServer.cs +++ b/Libraries/Esiur/Protocol/EpServer.cs @@ -52,9 +52,11 @@ public class EpServer : NetworkServer, IResource readonly object _peerConnectionsLock = new object(); readonly Dictionary _peerConnectionCounts = new Dictionary(); - readonly Dictionary _admittedConnections = new Dictionary(); + readonly Dictionary _admittedConnections = + new Dictionary(); readonly Dictionary _peerAttemptWindows = new Dictionary(); + readonly PeerAttemptWindow _globalAttemptWindow = new PeerAttemptWindow(); uint _attemptSweepSequence; @@ -136,6 +138,12 @@ public class EpServer : NetworkServer, IResource set; } = 10518; + /// + /// Controls whether warehouse initialization opens Esiur's native TCP listener. + /// Disable this when an external host, such as ASP.NET Core, supplies connections. + /// + public bool EnableTcpListener { get; set; } = true; + //[Attribute] public ExceptionLevel ExceptionLevel { get; set; } @@ -157,6 +165,9 @@ public class EpServer : NetworkServer, IResource { if (operation == ResourceOperation.Initialize) { + if (!EnableTcpListener) + return new AsyncReply(true); + TcpSocket listener; if (IP != null) @@ -194,15 +205,31 @@ public class EpServer : NetworkServer, IResource } public override void Add(EpConnection connection) + => TryAdd(connection); + + /// + /// 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. + /// + /// when the connection was admitted. + 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, IResource connection.AuthenticationTimeout = AuthenticationTimeout; connection.RestartAuthenticationDeadline(); base.Add(connection); + return true; } catch { @@ -243,14 +271,50 @@ public class EpServer : NetworkServer, 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, IResource _admittedConnections.Remove(connection); + if (address == null) + return; + if (!_peerConnectionCounts.TryGetValue(address, out var count)) return; diff --git a/Libraries/Esiur/README.md b/Libraries/Esiur/README.md index 67d58ae..92b3536 100644 --- a/Libraries/Esiur/README.md +++ b/Libraries/Esiur/README.md @@ -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("EP://localhost/sys/hello"); +var warehouse = new Warehouse(); +dynamic res = await warehouse.Get("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("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("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("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("ep://localhost/sys/hello"); +var reply = await res.SayHi("Static typing is better"); +Console.WriteLine(reply); ``` diff --git a/Libraries/Esiur/Resource/Warehouse.cs b/Libraries/Esiur/Resource/Warehouse.cs index 1f152d0..af4ad9b 100644 --- a/Libraries/Esiur/Resource/Warehouse.cs +++ b/Libraries/Esiur/Resource/Warehouse.cs @@ -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>> _proxyTypeDefs = new(); - Map _authenticationProviders = new Map(); + readonly ConcurrentDictionary _authenticationProviders + = new ConcurrentDictionary(StringComparer.Ordinal); readonly ConcurrentDictionary _encryptionProviders = new ConcurrentDictionary(StringComparer.Ordinal); readonly ConcurrentDictionary _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 /// public WarehouseConfiguration Configuration { get; } + /// Indicates whether this Warehouse is currently open. + 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."); } + /// Removes an authentication provider only when the same instance is registered. + public bool UnregisterAuthenticationProvider(string name, IAuthenticationProvider provider) + => !string.IsNullOrWhiteSpace(name) + && provider != null + && ((ICollection>)_authenticationProviders) + .Remove(new KeyValuePair(name, provider)); + /// /// Registers an encryption provider using its default protocol name. /// @@ -146,7 +178,8 @@ public class Warehouse /// 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."); } + /// Removes an encryption provider only when the same instance is registered. + public bool UnregisterEncryptionProvider(string name, IEncryptionProvider provider) + => !string.IsNullOrWhiteSpace(name) + && provider != null + && ((ICollection>)_encryptionProviders) + .Remove(new KeyValuePair(name, provider)); + /// /// 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 /// True, if no problem occurred. public async AsyncReply 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(); + } } /// @@ -742,56 +801,109 @@ public class Warehouse /// This function issues terminate trigger to all resources and stores. /// /// True, if no problem occurred. - public AsyncReply Close() + public async AsyncReply Close() { + await _warehouseLifecycleGate.WaitAsync(); + try + { + lock (_warehouseLifecycleLock) + { + if (!_warehouseRequiresTermination) + return false; - var bag = new AsyncBag(); + // 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(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 + { + 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(); - 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); } diff --git a/Libraries/Esiur/Resource/WarehouseConfiguration.cs b/Libraries/Esiur/Resource/WarehouseConfiguration.cs index 21a6ff2..21332a8 100644 --- a/Libraries/Esiur/Resource/WarehouseConfiguration.cs +++ b/Libraries/Esiur/Resource/WarehouseConfiguration.cs @@ -65,8 +65,18 @@ public sealed class ResourceAttachmentConfiguration /// public sealed class ConnectionConfiguration { + /// Maximum concurrent connections admitted by one EP server. + public int MaximumConnections { get; set; } = 1_024; + public int MaximumConnectionsPerIpAddress { get; set; } = 64; + /// + /// Maximum connection attempts admitted by one EP server during + /// . This also bounds clients that rotate IP + /// addresses. A value of zero disables the limit. + /// + public int MaximumConnectionAttempts { get; set; } = 4_096; + /// /// Maximum connection attempts admitted from one IP during /// . This limits repeated pre-authentication diff --git a/Libraries/Esiur/Security/Authority/Providers/PasswordAuthenticationProvider.cs b/Libraries/Esiur/Security/Authority/Providers/PasswordAuthenticationProvider.cs index 4ef9156..3cd26e0 100644 --- a/Libraries/Esiur/Security/Authority/Providers/PasswordAuthenticationProvider.cs +++ b/Libraries/Esiur/Security/Authority/Providers/PasswordAuthenticationProvider.cs @@ -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 /// public const string ProtocolName = "password-sha3-v1"; - /// - /// Previous protocol name, retained only to make explicit migration aliases possible. - /// New connections should use . - /// - [Obsolete("Use ProtocolName (`password-sha3-v1`) for new connections.")] - public const string LegacyProtocolName = "hash"; - public string DefaultName => ProtocolName; + /// + /// Creates the salted credential stored by a server for the + /// password-sha3-v1 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. + /// + 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, diff --git a/README.md b/README.md index 208df64..44dca2f 100644 --- a/README.md +++ b/README.md @@ -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("iip://localhost/sys/hello"); +var warehouse = new Warehouse(); +dynamic res = await warehouse.Get("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("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("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("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("ep://localhost/sys/hello"); +var reply = await res.SayHi("Static typing is better"); +Console.WriteLine(reply); ``` diff --git a/Tests/Unit/AspNetCoreIntegrationTests.cs b/Tests/Unit/AspNetCoreIntegrationTests.cs new file mode 100644 index 0000000..6454eab --- /dev/null +++ b/Tests/Unit/AspNetCoreIntegrationTests.cs @@ -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( + () => 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( + () => 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() + .UseAuthentication(_ => authenticationProvider) + .UseEncryption(encryptionProvider) + .UseEncryption() + .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(); + var server = application.Services.GetRequiredService(); + 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( + () => 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( + () => 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>() + .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(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( + () => 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(); + var provider = Assert.IsAssignableFrom( + 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(() => + provider.GetHostedAccountCredential("bad-hash", "example")); + Assert.Throws(() => + provider.GetHostedAccountCredential("bad-salt", "example")); + Assert.Throws(() => + 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( + "sys/failure", + _ => throw new InvalidOperationException("factory failed")) + .UseAuthentication(authenticationProvider)); + var warehouse = application.Services.GetRequiredService(); + var server = application.Services.GetRequiredService(); + + var exception = await Assert.ThrowsAsync( + () => 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( + () => 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( + () => stop); + + Assert.False(host.Server.IsRunning); + Assert.Empty(host.Server.Connections); + Assert.Null(stalledResource.Instance); + Assert.False(host.Application.Services.GetRequiredService().IsOpen); + } + + private static WebApplication BuildApplication( + Action? configureEsiur = null, + Action? configureServer = null, + Action? 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 StartApplicationAsync( + Action? configureServer = null, + Action? configureWarehouse = null, + Action? 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() + .Features + .Get() + ?.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 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(); + } + + 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 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 Handle( + ResourceOperation operation, + IResourceContext? context = null) + { + if (operation == ResourceOperation.Terminate) + { + Interlocked.Increment(ref terminationCount); + if (stallTermination) + return termination; + } + + return new AsyncReply(true); + } + + public void Destroy() => OnDestroy?.Invoke(this); + } + + private sealed class BlockingStartupResource : IResource + { + private readonly AsyncReply 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 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(true); + } + + public void Destroy() => OnDestroy?.Invoke(this); + } +} diff --git a/Tests/Unit/EpConnectionReconnectTests.cs b/Tests/Unit/EpConnectionReconnectTests.cs new file mode 100644 index 0000000..9e2a4d4 --- /dev/null +++ b/Tests/Unit/EpConnectionReconnectTests.cs @@ -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( + 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? pendingConnect; + private SocketState state = SocketState.Initial; + + public ConnectTestSocket(bool? connects) => this.connects = connects; + + public event DestroyedEvent? OnDestroy; + public SocketState State => state; + public INetworkReceiver 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 Connect(string hostname, ushort port) + { + ConnectCount++; + connectInvoked.TrySetResult(); + if (connects is null) + { + pendingConnect = new AsyncReply(); + return pendingConnect; + } + + if (connects.Value) + { + state = SocketState.Established; + return new AsyncReply(true); + } + + state = SocketState.Closed; + var reply = new AsyncReply(); + 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 BeginAsync() => new(Begin()); + public AsyncReply 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 AcceptAsync() => + throw new NotSupportedException(); + public ISocket Accept() => throw new NotSupportedException(); + } +} diff --git a/Tests/Unit/Esiur.Tests.Unit.csproj b/Tests/Unit/Esiur.Tests.Unit.csproj index 4246f3a..96da8ca 100644 --- a/Tests/Unit/Esiur.Tests.Unit.csproj +++ b/Tests/Unit/Esiur.Tests.Unit.csproj @@ -14,6 +14,10 @@ + + + + @@ -22,6 +26,7 @@ mirroring Tests/Features/Functional so [Resource]/[Export] test types get generated code. --> + - \ No newline at end of file + diff --git a/Tests/Unit/FrameworkWebSocketTests.cs b/Tests/Unit/FrameworkWebSocketTests.cs new file mode 100644 index 0000000..e635368 --- /dev/null +++ b/Tests/Unit/FrameworkWebSocketTests.cs @@ -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(() => + 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(() => 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(() => + 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(() => 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(() => 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(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(() => + 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 + { + public List Messages { get; } = new List(); + 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 + { + 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 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 SentMessages { get; } = new List(); + 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(), 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 ReceiveAsync( + ArraySegment 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 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); + } +} diff --git a/Tests/Unit/Integration/IntegrationHarness.cs b/Tests/Unit/Integration/IntegrationHarness.cs index f3c0e18..185f7a3 100644 --- a/Tests/Unit/Integration/IntegrationHarness.cs +++ b/Tests/Unit/Integration/IntegrationHarness.cs @@ -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; diff --git a/Tests/Unit/Integration/SessionHeadersIntegrationTests.cs b/Tests/Unit/Integration/SessionHeadersIntegrationTests.cs index e0d8cf2..fbf8225 100644 --- a/Tests/Unit/Integration/SessionHeadersIntegrationTests.cs +++ b/Tests/Unit/Integration/SessionHeadersIntegrationTests.cs @@ -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(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(exception); + Assert.True(cluster.Connection.IsEncrypted); + Assert.True(Assert.Single(cluster.Server.Connections).IsEncrypted); + Assert.NotEqual(IPAddress.Any, cluster.Connection.RemoteEndPoint.Address); } } diff --git a/Tests/Unit/PasswordAuthenticationProviderTests.cs b/Tests/Unit/PasswordAuthenticationProviderTests.cs new file mode 100644 index 0000000..07e8670 --- /dev/null +++ b/Tests/Unit/PasswordAuthenticationProviderTests.cs @@ -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(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(() => + PasswordAuthenticationProvider.CreateCredential(null!)); + Assert.Throws(() => + PasswordAuthenticationProvider.CreateCredential(Array.Empty())); + } +} diff --git a/Tests/Unit/PeerConnectionLimitTests.cs b/Tests/Unit/PeerConnectionLimitTests.cs index 80179e6..bb7c034 100644 --- a/Tests/Unit/PeerConnectionLimitTests.cs +++ b/Tests/Unit/PeerConnectionLimitTests.cs @@ -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( + () => 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() { diff --git a/Tests/Unit/WarehouseLifecycleTests.cs b/Tests/Unit/WarehouseLifecycleTests.cs new file mode 100644 index 0000000..d1f433e --- /dev/null +++ b/Tests/Unit/WarehouseLifecycleTests.cs @@ -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(); + var systemTerminatedReply = new AsyncReply(); + 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( + () => 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(); + var blockingReply = new AsyncReply(); + 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(() => 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(false))); + + Assert.True(await warehouse.Open()); + + Assert.False(await warehouse.Close()); + Assert.True(resource.SystemTerminatedStarted.IsCompletedSuccessfully); + Assert.False(warehouse.IsOpen); + } + + private static async Task Observe(AsyncReply reply) => await reply; + + private sealed class ControlledLifecycleResource : IResource + { + private readonly AsyncReply? terminateReply; + private readonly AsyncReply? systemTerminatedReply; + private readonly Exception? terminateException; + private readonly TaskCompletionSource terminateStarted = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource systemTerminatedStarted = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public ControlledLifecycleResource( + AsyncReply? terminateReply = null, + AsyncReply? 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 Handle( + ResourceOperation operation, + IResourceContext? context = null) + { + if (operation == ResourceOperation.Terminate) + { + terminateStarted.TrySetResult(); + if (terminateException != null) + throw terminateException; + + return terminateReply ?? new AsyncReply(true); + } + + if (operation == ResourceOperation.SystemTerminated) + { + systemTerminatedStarted.TrySetResult(); + return systemTerminatedReply ?? new AsyncReply(true); + } + + return new AsyncReply(true); + } + + public void Destroy() => OnDestroy?.Invoke(this); + } + + private sealed class BlockingResource : IResource + { + private readonly AsyncReply 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 Handle( + ResourceOperation operation, + IResourceContext? context = null) + { + if (operation == ResourceOperation.Initialize) + { + initializeStarted.TrySetResult(); + return initialize; + } + + if (operation == ResourceOperation.Terminate) + terminateStarted.TrySetResult(); + + return new AsyncReply(true); + } + + public void Destroy() => OnDestroy?.Invoke(this); + } +} diff --git a/Tools/Esiur.CLI/Properties/launchSettings.json b/Tools/Esiur.CLI/Properties/launchSettings.json index a101bf0..5d09dc0 100644 --- a/Tools/Esiur.CLI/Properties/launchSettings.json +++ b/Tools/Esiur.CLI/Properties/launchSettings.json @@ -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" } } -} \ No newline at end of file +} diff --git a/Tools/Esiur.CLI/README.md b/Tools/Esiur.CLI/README.md index db6dae3..bbb30fb 100644 --- a/Tools/Esiur.CLI/README.md +++ b/Tools/Esiur.CLI/README.md @@ -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 ```