using System.Reflection; using FinalFactory.Rendezvous.Contracts; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; namespace FinalFactory.Rendezvous.Server.Diagnostics; internal static class DiagnosticDashboardEndpoints { private const string ContentSecurityPolicy = "default-src 'none'; base-uri 'none'; connect-src 'self'; " + "font-src 'self'; form-action 'none'; frame-ancestors 'none'; " + "img-src 'self'; manifest-src 'none'; object-src 'none'; " + "script-src 'self'; style-src 'self'"; private static readonly byte[] Index = ReadAsset("index.html"); private static readonly byte[] Script = ReadAsset("app.mjs"); private static readonly byte[] Styles = ReadAsset("styles.css"); public static IEndpointRouteBuilder MapDiagnosticDashboardEndpoints( this IEndpointRouteBuilder endpoints) { RouteGroupBuilder dashboard = endpoints.MapGroup("/diagnostics") .ExcludeFromDescription(); dashboard.MapGet("", ServeIndex); dashboard.MapGet("/app.mjs", ServeScript); dashboard.MapGet("/styles.css", ServeStyles); dashboard.MapGet("/config.json", ServeConfiguration); return endpoints; } private static IResult ServeIndex( HttpContext context, [FromServices] IOptions configured) => ServeAsset(context, configured.Value, Index, "text/html; charset=utf-8"); private static IResult ServeScript( HttpContext context, [FromServices] IOptions configured) => ServeAsset(context, configured.Value, Script, "text/javascript; charset=utf-8"); private static IResult ServeStyles( HttpContext context, [FromServices] IOptions configured) => ServeAsset(context, configured.Value, Styles, "text/css; charset=utf-8"); private static IResult ServeConfiguration( HttpContext context, [FromServices] IOptions configured) { DiagnosticDashboardOptions options = configured.Value; if (!options.Enabled) { return Results.NotFound(); } ApplySecurityHeaders(context.Response); return Results.Json(new { contractVersion = ContractLimits.ContractVersion, pollIntervalSeconds = options.PollIntervalSeconds, maximumRenderedSessions = options.MaximumRenderedSessions, scopes = options.Scopes.Select(static scope => new { gameId = scope.GameId, environmentId = scope.EnvironmentId, protocolVersions = scope.ProtocolVersions, regions = scope.Regions, visibility = "public", }), }); } private static IResult ServeAsset( HttpContext context, DiagnosticDashboardOptions options, byte[] content, string contentType) { if (!options.Enabled) { return Results.NotFound(); } ApplySecurityHeaders(context.Response); return Results.Bytes(content, contentType); } private static void ApplySecurityHeaders(HttpResponse response) { response.Headers.CacheControl = "no-store"; response.Headers["Content-Security-Policy"] = ContentSecurityPolicy; response.Headers["X-Content-Type-Options"] = "nosniff"; response.Headers["X-Frame-Options"] = "DENY"; response.Headers["Cross-Origin-Opener-Policy"] = "same-origin"; response.Headers["Cross-Origin-Resource-Policy"] = "same-origin"; response.Headers["Permissions-Policy"] = "camera=(), geolocation=(), microphone=(), payment=(), usb=()"; response.Headers["Referrer-Policy"] = "no-referrer"; } private static byte[] ReadAsset(string fileName) { Assembly assembly = typeof(DiagnosticDashboardEndpoints).Assembly; string resourceName = $"FinalFactory.Rendezvous.Server.Diagnostics.Assets.{fileName}"; using Stream source = assembly.GetManifestResourceStream(resourceName) ?? throw new InvalidOperationException($"Missing embedded dashboard asset {fileName}."); using MemoryStream destination = new(); source.CopyTo(destination); return destination.ToArray(); } }