mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
481 lines
19 KiB
C#
481 lines
19 KiB
C#
using Esiur.Protocol;
|
|
using Esiur.Core;
|
|
using Esiur.Resource;
|
|
using Esiur.Security.Authority;
|
|
using Esiur.Security.Cryptography;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace Esiur.AspNetCore;
|
|
|
|
internal sealed class EsiurHostedService : IHostedService
|
|
{
|
|
private readonly EsiurRuntime runtime;
|
|
private readonly IServiceProvider services;
|
|
private readonly IHostApplicationLifetime applicationLifetime;
|
|
private readonly ILogger<EsiurHostedService> logger;
|
|
private readonly TimeSpan shutdownTimeout;
|
|
private readonly List<IResource> attachedResources = new();
|
|
private readonly List<(string Name, IAuthenticationProvider Provider)>
|
|
registeredAuthenticationProviders = new();
|
|
private readonly List<(string Name, IEncryptionProvider Provider)>
|
|
registeredEncryptionProviders = new();
|
|
private CancellationTokenRegistration stoppingRegistration;
|
|
private bool lifecycleStarted;
|
|
private bool serverStateCaptured;
|
|
private bool originalEnableTcpListener;
|
|
private string[] originalAllowedAuthenticationProviders = Array.Empty<string>();
|
|
private string[] originalAllowedEncryptionProviders = Array.Empty<string>();
|
|
|
|
public EsiurHostedService(
|
|
EsiurRuntime runtime,
|
|
IServiceProvider services,
|
|
IHostApplicationLifetime applicationLifetime,
|
|
IOptions<HostOptions> hostOptions,
|
|
ILogger<EsiurHostedService> logger)
|
|
{
|
|
this.runtime = runtime;
|
|
this.services = services;
|
|
this.applicationLifetime = applicationLifetime;
|
|
shutdownTimeout = hostOptions.Value.ShutdownTimeout;
|
|
this.logger = logger;
|
|
}
|
|
|
|
public async Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
var options = runtime.Options;
|
|
var warehouse = runtime.Warehouse;
|
|
var server = runtime.Server;
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (!options.ManageWarehouseLifecycle && !warehouse.IsOpen)
|
|
throw new InvalidOperationException(
|
|
"The externally managed Warehouse must be open before Esiur starts.");
|
|
|
|
if (options.ManageWarehouseLifecycle && warehouse.IsOpen)
|
|
throw new InvalidOperationException(
|
|
"The Warehouse is already open. Use manageLifecycle: false when another component owns it.");
|
|
|
|
try
|
|
{
|
|
CaptureServerState(server);
|
|
|
|
// An ASP.NET endpoint owns the transport. The EP resource still owns admission,
|
|
// authentication and all protocol state, but it must not expose a second TCP port.
|
|
server.EnableTcpListener = false;
|
|
|
|
RegisterAuthenticationProviders(
|
|
options,
|
|
warehouse,
|
|
server,
|
|
registeredAuthenticationProviders);
|
|
RegisterEncryptionProviders(
|
|
options,
|
|
warehouse,
|
|
server,
|
|
registeredEncryptionProviders);
|
|
ValidateAllowedProviders(warehouse, server);
|
|
|
|
foreach (var registration in options.Resources
|
|
.OrderBy(resource => resource.Path.Count(character => character == '/')))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (registration.ReuseExistingStore
|
|
&& !registration.Path.Contains('/')
|
|
&& warehouse.GetStore(registration.Path) is not null)
|
|
continue;
|
|
|
|
var resource = registration.Factory(services)
|
|
?? throw new InvalidOperationException(
|
|
$"The resource factory for '{registration.Path}' returned null.");
|
|
|
|
if (resource.Instance is not null)
|
|
{
|
|
if (ReferenceEquals(resource.Instance.Warehouse, warehouse)
|
|
&& string.Equals(
|
|
resource.Instance.Link,
|
|
registration.Path,
|
|
StringComparison.Ordinal))
|
|
continue;
|
|
|
|
throw new InvalidOperationException(
|
|
$"The resource for '{registration.Path}' is already attached to a Warehouse.");
|
|
}
|
|
|
|
await warehouse.Put(registration.Path, resource);
|
|
attachedResources.Add(resource);
|
|
}
|
|
|
|
if (server.Instance is null)
|
|
{
|
|
await warehouse.Put(options.ServerPath, server);
|
|
attachedResources.Add(server);
|
|
}
|
|
else if (!ReferenceEquals(server.Instance.Warehouse, warehouse))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The configured EpServer belongs to a different Warehouse.");
|
|
}
|
|
|
|
if (options.ManageWarehouseLifecycle)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
// Mark ownership immediately before Open so startup rollback closes a
|
|
// partially initialized Warehouse, but provider/resource validation
|
|
// failures before Open do not terminate an unopened user graph.
|
|
lifecycleStarted = true;
|
|
if (!await warehouse.Open())
|
|
throw new InvalidOperationException(
|
|
"The Warehouse is already open. Use manageLifecycle: false when another component owns it.");
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
}
|
|
|
|
stoppingRegistration = applicationLifetime.ApplicationStopping.Register(
|
|
static state =>
|
|
{
|
|
var hostedService = (EsiurHostedService)state!;
|
|
hostedService.runtime.StopTransport();
|
|
},
|
|
this);
|
|
|
|
if (!runtime.TryMarkReady(applicationLifetime.ApplicationStopping))
|
|
throw new OperationCanceledException(
|
|
"The ASP.NET Core host stopped while Esiur was starting.",
|
|
applicationLifetime.ApplicationStopping);
|
|
|
|
logger.LogInformation(
|
|
"Esiur is ready on mapped ASP.NET Core WebSocket endpoints using Warehouse server {ServerPath}.",
|
|
server.Instance?.Link ?? options.ServerPath);
|
|
}
|
|
catch
|
|
{
|
|
stoppingRegistration.Dispose();
|
|
runtime.StopTransport();
|
|
var cleanupFinished = !options.ManageWarehouseLifecycle || !lifecycleStarted;
|
|
|
|
if (options.ManageWarehouseLifecycle && lifecycleStarted)
|
|
{
|
|
try
|
|
{
|
|
// Startup cancellation must not cancel rollback immediately. Give
|
|
// partially initialized resources an independent, bounded window
|
|
// to terminate before detaching them.
|
|
using var cleanupCancellation = new CancellationTokenSource();
|
|
cleanupCancellation.CancelAfter(shutdownTimeout);
|
|
await CloseWarehouseAsync(warehouse, cleanupCancellation.Token);
|
|
cleanupFinished = true;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
logger.LogError(
|
|
"Esiur startup rollback exceeded the host shutdown timeout; " +
|
|
"resources remain attached to avoid racing their termination callbacks.");
|
|
}
|
|
catch (Exception cleanupException)
|
|
{
|
|
// Warehouse.Close completed with an error and released its lifecycle
|
|
// gate, so registrations can still be detached safely.
|
|
cleanupFinished = true;
|
|
logger.LogError(
|
|
cleanupException,
|
|
"Esiur failed to clean up after a startup failure.");
|
|
}
|
|
}
|
|
|
|
if (cleanupFinished)
|
|
{
|
|
lifecycleStarted = false;
|
|
CleanupHostRegistrations(warehouse, "a startup failure");
|
|
}
|
|
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task StopAsync(CancellationToken cancellationToken)
|
|
{
|
|
stoppingRegistration.Dispose();
|
|
|
|
// ApplicationStopping normally initiates this before Kestrel waits for mapped
|
|
// WebSocket requests. Calling it again keeps direct hosted-service shutdown safe.
|
|
runtime.StopTransport();
|
|
var cleanupFinished = !runtime.Options.ManageWarehouseLifecycle || !lifecycleStarted;
|
|
OperationCanceledException? shutdownCancellation = null;
|
|
|
|
try
|
|
{
|
|
await runtime.DrainTransportAsync(cancellationToken);
|
|
}
|
|
catch (OperationCanceledException exception)
|
|
when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
shutdownCancellation = exception;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
// Transport errors are already observed per socket. Continue Warehouse
|
|
// termination even if an unexpected adapter failure escaped the drain.
|
|
logger.LogError(exception, "Esiur transport shutdown failed.");
|
|
}
|
|
|
|
if (runtime.Options.ManageWarehouseLifecycle && lifecycleStarted)
|
|
{
|
|
var closeTask = CloseWarehouseAsync(runtime.Warehouse);
|
|
if (shutdownCancellation is null)
|
|
{
|
|
try
|
|
{
|
|
await closeTask.WaitAsync(cancellationToken);
|
|
cleanupFinished = true;
|
|
}
|
|
catch (OperationCanceledException exception)
|
|
when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
shutdownCancellation = exception;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
// Warehouse.Close settles every resource before propagating errors.
|
|
cleanupFinished = true;
|
|
logger.LogError(exception, "Esiur failed to shut down cleanly.");
|
|
}
|
|
}
|
|
|
|
if (shutdownCancellation is not null && !cleanupFinished)
|
|
{
|
|
// The host token no longer provides cleanup time. Continue the already
|
|
// started close under an independent bounded deadline so cancellation
|
|
// cannot strand a live Warehouse/provider graph.
|
|
using var cleanupCancellation = new CancellationTokenSource();
|
|
cleanupCancellation.CancelAfter(shutdownTimeout);
|
|
try
|
|
{
|
|
await closeTask.WaitAsync(cleanupCancellation.Token);
|
|
cleanupFinished = true;
|
|
}
|
|
catch (OperationCanceledException)
|
|
when (cleanupCancellation.IsCancellationRequested)
|
|
{
|
|
logger.LogError(
|
|
"Esiur Warehouse cleanup exceeded the independent shutdown timeout; " +
|
|
"registrations remain attached until termination settles.");
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
cleanupFinished = true;
|
|
logger.LogError(exception, "Esiur failed to shut down cleanly.");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (cleanupFinished)
|
|
{
|
|
lifecycleStarted = false;
|
|
CleanupHostRegistrations(runtime.Warehouse, "host shutdown");
|
|
}
|
|
|
|
if (shutdownCancellation is not null)
|
|
{
|
|
logger.LogWarning(
|
|
"Esiur transport or Warehouse shutdown exceeded the host shutdown deadline.");
|
|
throw shutdownCancellation;
|
|
}
|
|
}
|
|
|
|
private void CaptureServerState(EpServer server)
|
|
{
|
|
if (serverStateCaptured)
|
|
throw new InvalidOperationException("The Esiur hosted runtime is already started.");
|
|
|
|
originalEnableTcpListener = server.EnableTcpListener;
|
|
originalAllowedAuthenticationProviders =
|
|
server.AllowedAuthenticationProviders ?? Array.Empty<string>();
|
|
originalAllowedEncryptionProviders =
|
|
server.AllowedEncryptionProviders ?? Array.Empty<string>();
|
|
serverStateCaptured = true;
|
|
}
|
|
|
|
private void CleanupHostRegistrations(Warehouse warehouse, string operation)
|
|
{
|
|
for (var index = attachedResources.Count - 1; index >= 0; index--)
|
|
{
|
|
try
|
|
{
|
|
if (attachedResources[index].Instance is not null)
|
|
warehouse.Remove(attachedResources[index]);
|
|
}
|
|
catch (Exception cleanupException)
|
|
{
|
|
logger.LogError(
|
|
cleanupException,
|
|
"Esiur failed to detach a resource after {Operation}.",
|
|
operation);
|
|
}
|
|
}
|
|
attachedResources.Clear();
|
|
|
|
foreach (var registration in registeredAuthenticationProviders)
|
|
warehouse.UnregisterAuthenticationProvider(
|
|
registration.Name,
|
|
registration.Provider);
|
|
registeredAuthenticationProviders.Clear();
|
|
|
|
foreach (var registration in registeredEncryptionProviders)
|
|
warehouse.UnregisterEncryptionProvider(
|
|
registration.Name,
|
|
registration.Provider);
|
|
registeredEncryptionProviders.Clear();
|
|
|
|
RestoreServerState();
|
|
}
|
|
|
|
private void RestoreServerState()
|
|
{
|
|
if (!serverStateCaptured)
|
|
return;
|
|
|
|
runtime.Server.EnableTcpListener = originalEnableTcpListener;
|
|
runtime.Server.AllowedAuthenticationProviders =
|
|
originalAllowedAuthenticationProviders;
|
|
runtime.Server.AllowedEncryptionProviders = originalAllowedEncryptionProviders;
|
|
serverStateCaptured = false;
|
|
}
|
|
|
|
private void RegisterAuthenticationProviders(
|
|
EsiurOptions options,
|
|
Warehouse warehouse,
|
|
EpServer server,
|
|
List<(string Name, IAuthenticationProvider Provider)> registeredProviders)
|
|
{
|
|
var allowed = new HashSet<string>(
|
|
server.AllowedAuthenticationProviders,
|
|
StringComparer.Ordinal);
|
|
|
|
foreach (var factory in options.AuthenticationProviders)
|
|
{
|
|
var provider = factory(services)
|
|
?? throw new InvalidOperationException(
|
|
"An Esiur authentication provider factory returned null.");
|
|
var name = ResolveProviderName(provider.DefaultName, "authentication");
|
|
var existing = warehouse.TryGetAuthenticationProvider(name);
|
|
|
|
if (existing is null)
|
|
{
|
|
warehouse.RegisterAuthenticationProvider(name, provider);
|
|
registeredProviders.Add((name, provider));
|
|
}
|
|
else if (!ReferenceEquals(existing, provider))
|
|
throw new InvalidOperationException(
|
|
$"A different authentication provider named '{name}' is already registered.");
|
|
|
|
allowed.Add(name);
|
|
}
|
|
|
|
server.AllowedAuthenticationProviders = allowed.ToArray();
|
|
}
|
|
|
|
private void RegisterEncryptionProviders(
|
|
EsiurOptions options,
|
|
Warehouse warehouse,
|
|
EpServer server,
|
|
List<(string Name, IEncryptionProvider Provider)> registeredProviders)
|
|
{
|
|
var allowed = new HashSet<string>(
|
|
server.AllowedEncryptionProviders,
|
|
StringComparer.Ordinal);
|
|
|
|
foreach (var factory in options.EncryptionProviders)
|
|
{
|
|
var provider = factory(services)
|
|
?? throw new InvalidOperationException(
|
|
"An Esiur encryption provider factory returned null.");
|
|
var name = ResolveProviderName(provider.DefaultName, "encryption");
|
|
var existing = warehouse.TryGetEncryptionProvider(name);
|
|
|
|
if (existing is null)
|
|
{
|
|
warehouse.RegisterEncryptionProvider(name, provider);
|
|
registeredProviders.Add((name, provider));
|
|
}
|
|
else if (!ReferenceEquals(existing, provider))
|
|
throw new InvalidOperationException(
|
|
$"A different encryption provider named '{name}' is already registered.");
|
|
|
|
allowed.Add(name);
|
|
}
|
|
|
|
server.AllowedEncryptionProviders = allowed.ToArray();
|
|
}
|
|
|
|
private static void ValidateAllowedProviders(Warehouse warehouse, EpServer server)
|
|
{
|
|
foreach (var name in server.AllowedAuthenticationProviders)
|
|
{
|
|
if (warehouse.TryGetAuthenticationProvider(name) is null)
|
|
throw new InvalidOperationException(
|
|
$"Allowed authentication provider '{name}' is not registered with the Warehouse.");
|
|
}
|
|
|
|
foreach (var name in server.AllowedEncryptionProviders)
|
|
{
|
|
if (warehouse.TryGetEncryptionProvider(name) is null)
|
|
throw new InvalidOperationException(
|
|
$"Allowed encryption provider '{name}' is not registered with the Warehouse.");
|
|
}
|
|
|
|
if (!server.AllowUnauthorizedAccess
|
|
&& server.AllowedAuthenticationProviders.Length == 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Authentication is required, but no authentication provider is allowed.");
|
|
}
|
|
|
|
if (server.RequireEncryption
|
|
&& server.AllowedAuthenticationProviders.Length == 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Encrypted EP sessions require an allowed authentication provider.");
|
|
}
|
|
|
|
if (server.RequireEncryption && server.AllowedEncryptionProviders.Length == 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Encryption is required, but no encryption provider is allowed.");
|
|
}
|
|
}
|
|
|
|
private static string ResolveProviderName(
|
|
string defaultName,
|
|
string providerKind)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(defaultName)
|
|
|| !string.Equals(defaultName, defaultName.Trim(), StringComparison.Ordinal))
|
|
throw new InvalidOperationException(
|
|
$"The {providerKind} provider does not define a valid protocol name.");
|
|
|
|
return defaultName;
|
|
}
|
|
|
|
private static Task<bool> CloseWarehouseAsync(Warehouse warehouse)
|
|
{
|
|
var completion = new TaskCompletionSource<bool>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var reply = warehouse.Close();
|
|
reply.Then(
|
|
result => completion.TrySetResult(result),
|
|
nameof(CloseWarehouseAsync),
|
|
string.Empty,
|
|
0);
|
|
reply.Error(exception => completion.TrySetException(exception));
|
|
return completion.Task;
|
|
}
|
|
|
|
private static Task<bool> CloseWarehouseAsync(
|
|
Warehouse warehouse,
|
|
CancellationToken cancellationToken)
|
|
=> CloseWarehouseAsync(warehouse).WaitAsync(cancellationToken);
|
|
}
|