using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Mbproxy.Options;
namespace Mbproxy.Admin;
///
/// Owns the Kestrel-backed admin HTTP endpoint. Driven by
/// (which calls after listeners are up and
/// at the END of graceful shutdown — supervisors stop and drain first, admin stops last).
///
/// Lifecycle:
///
/// - builds a bound to
/// Mbproxy.AdminPort and starts it non-blocking.
/// - If the bind fails (port in use, etc.), logs mbproxy.admin.bind.failed
/// at Error and continues — the proxy listeners are unaffected.
/// - If AdminPort changes via hot-reload, the current app is stopped and a
/// new one is started on the new port. Other config changes are ignored here.
/// - shuts down the current Kestrel app with a 2 s deadline.
///
///
/// Routes: exactly two — GET / (HTML) and GET /status.json (JSON).
///
/// Phase 12 (W1.5) — was previously also registered as ,
/// but the host's automatic stop ordering (reverse of registration) ran admin.StopAsync
/// BEFORE ProxyWorker.StopAsync, which broke the design's "drain THEN stop admin" guarantee
/// and caused a double-stop with the now-deleted ShutdownCoordinator. Now a plain
/// singleton with explicit lifecycle calls from ProxyWorker.
///
internal sealed partial class AdminEndpointHost : IAsyncDisposable
{
private readonly IOptionsMonitor _optionsMonitor;
private readonly StatusSnapshotBuilder _builder;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
// The currently-running Kestrel app; null when stopped or when bind failed.
private WebApplication? _app;
// Protects concurrent Start/Stop calls (hot-reload + StopAsync racing).
private readonly SemaphoreSlim _lock = new(1, 1);
// Current configured port — used to detect changes on hot-reload.
private int _currentPort;
// Subscription token for IOptionsMonitor.OnChange.
private IDisposable? _optionsChangeRegistration;
public AdminEndpointHost(
IOptionsMonitor optionsMonitor,
StatusSnapshotBuilder builder,
ILoggerFactory loggerFactory)
{
_optionsMonitor = optionsMonitor;
_builder = builder;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger();
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_currentPort = _optionsMonitor.CurrentValue.AdminPort;
await StartAppAsync(_currentPort, cancellationToken).ConfigureAwait(false);
// Subscribe to config changes: if AdminPort changes, re-bind.
_optionsChangeRegistration = _optionsMonitor.OnChange(opts =>
{
int newPort = opts.AdminPort;
if (newPort == _currentPort) return; // Only care about AdminPort changes.
// Fire-and-forget: re-bind is async; we can't await in OnChange.
_ = Task.Run(async () =>
{
await _lock.WaitAsync().ConfigureAwait(false);
try
{
if (newPort == _currentPort) return; // double-check under lock
// Stop the old app.
await StopCurrentAppAsync().ConfigureAwait(false);
_currentPort = newPort;
// Start on the new port.
await StartAppAsync(newPort, CancellationToken.None).ConfigureAwait(false);
}
finally
{
_lock.Release();
}
});
});
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_optionsChangeRegistration?.Dispose();
_optionsChangeRegistration = null;
await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await StopCurrentAppAsync().ConfigureAwait(false);
}
finally
{
_lock.Release();
}
}
// ── Internal helpers ─────────────────────────────────────────────────────
///
/// Builds and starts a Kestrel on .
/// On bind failure, logs the error and sets _app = null — does NOT throw.
/// Caller must hold _lock or be in a single-threaded context (StartAsync).
///
private async Task StartAppAsync(int port, CancellationToken ct)
{
try
{
// Use CreateSlimBuilder with explicit args (empty) to avoid inheriting
// process-level environment variables like ASPNETCORE_URLS.
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
{
Args = [],
});
// Suppress Kestrel/ASP.NET Core built-in logging; forward to the outer host's
// logger factory so that admin-endpoint errors appear in the proxy's log stream.
builder.Logging.ClearProviders();
builder.Logging.AddProvider(new ForwardingLoggerProvider(_loggerFactory));
// Explicit Kestrel listen — overrides any ASPNETCORE_URLS that leaked in.
builder.WebHost.UseKestrel(k =>
{
k.Listen(System.Net.IPAddress.Any, port);
});
var app = builder.Build();
// ── Routes ───────────────────────────────────────────────────────
app.MapGet("/", (HttpContext ctx) =>
{
var snapshot = _builder.Build();
string html = StatusHtmlRenderer.Render(snapshot);
return Results.Content(html, "text/html; charset=utf-8");
});
app.MapGet("/status.json", (HttpContext ctx) =>
{
var snapshot = _builder.Build();
string json = JsonSerializer.Serialize(snapshot, StatusJsonContext.Default.StatusResponse);
return Results.Content(json, "application/json");
});
await app.StartAsync(ct).ConfigureAwait(false);
_app = app;
LogAdminStarted(_logger, port);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Bind failed — log and continue. Proxy listeners are unaffected.
LogAdminBindFailed(_logger, port, ex.Message);
_app = null;
}
}
///
/// Stops the current with a 2 s deadline, then disposes it.
///
private async Task StopCurrentAppAsync()
{
if (_app is null) return;
var app = _app;
_app = null;
try
{
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
await app.StopAsync(stopCts.Token).ConfigureAwait(false);
}
catch
{
// Best-effort.
}
await app.DisposeAsync().ConfigureAwait(false);
}
// ── IAsyncDisposable ─────────────────────────────────────────────────────
public async ValueTask DisposeAsync()
{
_optionsChangeRegistration?.Dispose();
_lock.Dispose();
if (_app is { } app)
{
_app = null;
await app.DisposeAsync().ConfigureAwait(false);
}
}
// ── Logging ──────────────────────────────────────────────────────────────
[LoggerMessage(EventId = 70, EventName = "mbproxy.admin.started",
Level = LogLevel.Information,
Message = "Admin endpoint started on port {Port}")]
private static partial void LogAdminStarted(ILogger logger, int port);
[LoggerMessage(EventId = 71, EventName = "mbproxy.admin.bind.failed",
Level = LogLevel.Error,
Message = "Admin endpoint bind failed — admin page will be unavailable: Port={Port} Reason={Reason}")]
private static partial void LogAdminBindFailed(ILogger logger, int port, string reason);
// ── Inner logger provider (forwards Kestrel/ASP.NET logs to the proxy's factory) ────
private sealed class ForwardingLoggerProvider : ILoggerProvider
{
private readonly ILoggerFactory _factory;
public ForwardingLoggerProvider(ILoggerFactory factory) => _factory = factory;
public ILogger CreateLogger(string categoryName) => _factory.CreateLogger(categoryName);
public void Dispose() { }
}
}