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() { } } }