From efca9cf5d871ddcf040ab7fe8298fbbcadc11e2f Mon Sep 17 00:00:00 2001 From: ChiaraBm Date: Tue, 16 Sep 2025 08:02:53 +0000 Subject: [PATCH] Implemented SignalR scaling using redis. Improved diagnose report generator. Added SignalR debug card in Diagnose page --- .../Configuration/AppConfiguration.cs | 12 ++++ .../Extensions/ZipArchiveExtensions.cs | 8 +-- .../Admin/Sys/DiagnoseController.cs | 17 ++--- Moonlight.ApiServer/Http/Hubs/DiagnoseHub.cs | 14 +++++ .../Diagnose/CoreConfigDiagnoseProvider.cs | 47 +++++++------- .../Diagnose/LogsDiagnoseProvider.cs | 15 +++-- .../Interfaces/IDiagnoseProvider.cs | 2 +- .../Moonlight.ApiServer.csproj | 1 + .../Services/DiagnoseService.cs | 6 +- Moonlight.ApiServer/Startup/Startup.Auth.cs | 29 ++++++--- .../Startup/Startup.SignalR.cs | 25 ++++++++ Moonlight.ApiServer/Startup/Startup.cs | 2 + Moonlight.Client/Moonlight.Client.csproj | 1 + .../UI/Components/SignalRDebug.razor | 62 +++++++++++++++++++ .../UI/Views/Admin/Sys/Diagnose.razor | 22 ++++--- 15 files changed, 193 insertions(+), 70 deletions(-) create mode 100644 Moonlight.ApiServer/Http/Hubs/DiagnoseHub.cs create mode 100644 Moonlight.ApiServer/Startup/Startup.SignalR.cs create mode 100644 Moonlight.Client/UI/Components/SignalRDebug.razor diff --git a/Moonlight.ApiServer/Configuration/AppConfiguration.cs b/Moonlight.ApiServer/Configuration/AppConfiguration.cs index e04e4068..34bebf5f 100644 --- a/Moonlight.ApiServer/Configuration/AppConfiguration.cs +++ b/Moonlight.ApiServer/Configuration/AppConfiguration.cs @@ -30,6 +30,9 @@ public record AppConfiguration [YamlMember(Description = "\nSettings for open telemetry")] public OpenTelemetryData OpenTelemetry { get; set; } = new(); + [YamlMember(Description = "\nConfiguration for the realtime communication solution SignalR")] + public SignalRData SignalR { get; set; } = new(); + public static AppConfiguration CreateEmpty() { return new AppConfiguration() @@ -47,6 +50,15 @@ public record AppConfiguration }; } + public record SignalRData + { + [YamlMember(Description = + "\nWhether to use redis (or any other redis compatible solution) to scale out SignalR hubs. This is required when using multiple api server replicas")] + public bool UseRedis { get; set; } = false; + + public string RedisConnectionString { get; set; } = ""; + } + public record FilesData { [YamlMember(Description = "The maximum file size limit a combine operation is allowed to process")] diff --git a/Moonlight.ApiServer/Extensions/ZipArchiveExtensions.cs b/Moonlight.ApiServer/Extensions/ZipArchiveExtensions.cs index 95c928d7..11105ff9 100644 --- a/Moonlight.ApiServer/Extensions/ZipArchiveExtensions.cs +++ b/Moonlight.ApiServer/Extensions/ZipArchiveExtensions.cs @@ -5,7 +5,7 @@ namespace Moonlight.ApiServer.Extensions; public static class ZipArchiveExtensions { - public static async Task AddBinary(this ZipArchive archive, string name, byte[] bytes) + public static async Task AddBinaryAsync(this ZipArchive archive, string name, byte[] bytes) { var entry = archive.CreateEntry(name); await using var dataStream = entry.Open(); @@ -14,13 +14,13 @@ public static class ZipArchiveExtensions await dataStream.FlushAsync(); } - public static async Task AddText(this ZipArchive archive, string name, string content) + public static async Task AddTextAsync(this ZipArchive archive, string name, string content) { var data = Encoding.UTF8.GetBytes(content); - await archive.AddBinary(name, data); + await archive.AddBinaryAsync(name, data); } - public static async Task AddFile(this ZipArchive archive, string name, string path) + public static async Task AddFileAsync(this ZipArchive archive, string name, string path) { var fs = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/DiagnoseController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/DiagnoseController.cs index 8b2bbc51..8f79f4b4 100644 --- a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/DiagnoseController.cs +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/DiagnoseController.cs @@ -21,21 +21,16 @@ public class DiagnoseController : Controller } [HttpPost] - public async Task Diagnose([FromBody] GenerateDiagnoseRequest request) + public async Task Diagnose([FromBody] GenerateDiagnoseRequest request) { - var stream = await DiagnoseService.GenerateDiagnose(request.Providers); - - await Results.Stream( - stream, - contentType: "application/zip", - fileDownloadName: "diagnose.zip" - ) - .ExecuteAsync(HttpContext); + var stream = await DiagnoseService.GenerateDiagnoseAsync(request.Providers); + + return File(stream, "application/zip", "diagnose.zip"); } [HttpGet("providers")] - public async Task GetProviders() + public async Task> GetProviders() { - return await DiagnoseService.GetProviders(); + return await DiagnoseService.GetProvidersAsync(); } } \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Hubs/DiagnoseHub.cs b/Moonlight.ApiServer/Http/Hubs/DiagnoseHub.cs new file mode 100644 index 00000000..a9712499 --- /dev/null +++ b/Moonlight.ApiServer/Http/Hubs/DiagnoseHub.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace Moonlight.ApiServer.Http.Hubs; + +[Authorize(Policy = "permissions:admin.system.diagnose")] +public class DiagnoseHub : Hub +{ + [HubMethodName("Ping")] + public async Task Ping() + { + await Clients.All.SendAsync("Pong"); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Implementations/Diagnose/CoreConfigDiagnoseProvider.cs b/Moonlight.ApiServer/Implementations/Diagnose/CoreConfigDiagnoseProvider.cs index a70c044a..8a3986c6 100644 --- a/Moonlight.ApiServer/Implementations/Diagnose/CoreConfigDiagnoseProvider.cs +++ b/Moonlight.ApiServer/Implementations/Diagnose/CoreConfigDiagnoseProvider.cs @@ -1,5 +1,6 @@ +using System.Diagnostics; using System.IO.Compression; -using System.Text.Json; +using MoonCore.Yaml; using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Extensions; using Moonlight.ApiServer.Interfaces; @@ -8,11 +9,11 @@ namespace Moonlight.ApiServer.Implementations.Diagnose; public class CoreConfigDiagnoseProvider : IDiagnoseProvider { - private readonly AppConfiguration Config; + private readonly AppConfiguration Configuration; - public CoreConfigDiagnoseProvider(AppConfiguration config) + public CoreConfigDiagnoseProvider(AppConfiguration configuration) { - Config = config; + Configuration = configuration; } private string CheckForNullOrEmpty(string? content) @@ -22,29 +23,25 @@ public class CoreConfigDiagnoseProvider : IDiagnoseProvider : "ISNOTEMPTY"; } - public async Task ModifyZipArchive(ZipArchive archive) + public async Task ModifyZipArchiveAsync(ZipArchive archive) { - var json = JsonSerializer.Serialize(Config); - var config = JsonSerializer.Deserialize(json); - - if (config == null) + try { - await archive.AddText("core/config.txt", "Could not fetch config."); - return; + var configString = YamlSerializer.Serialize(Configuration); + var configuration = YamlSerializer.Deserialize(configString); + + configuration.Database.Password = CheckForNullOrEmpty(configuration.Database.Password); + configuration.Authentication.Secret = CheckForNullOrEmpty(configuration.Authentication.Secret); + configuration.SignalR.RedisConnectionString = CheckForNullOrEmpty(configuration.SignalR.RedisConnectionString); + + await archive.AddTextAsync( + "core/config.txt", + YamlSerializer.Serialize(configuration) + ); + } + catch (Exception e) + { + await archive.AddTextAsync("core/config.txt", $"Unable to load config: {e.ToStringDemystified()}"); } - - config.Database.Password = CheckForNullOrEmpty(config.Database.Password); - config.Authentication.Secret = CheckForNullOrEmpty(config.Authentication.Secret); - - await archive.AddText( - "core/config.txt", - JsonSerializer.Serialize( - config, - new JsonSerializerOptions() - { - WriteIndented = true - } - ) - ); } } \ No newline at end of file diff --git a/Moonlight.ApiServer/Implementations/Diagnose/LogsDiagnoseProvider.cs b/Moonlight.ApiServer/Implementations/Diagnose/LogsDiagnoseProvider.cs index 0ef1e82b..b522f902 100644 --- a/Moonlight.ApiServer/Implementations/Diagnose/LogsDiagnoseProvider.cs +++ b/Moonlight.ApiServer/Implementations/Diagnose/LogsDiagnoseProvider.cs @@ -6,17 +6,16 @@ namespace Moonlight.ApiServer.Implementations.Diagnose; public class LogsDiagnoseProvider : IDiagnoseProvider { - public async Task ModifyZipArchive(ZipArchive archive) + public async Task ModifyZipArchiveAsync(ZipArchive archive) { - var path = Path.Combine("storage", "logs", "latest.log"); + var path = Path.Combine("storage", "logs", "moonlight.log"); - if (!File.Exists(path)) + if (File.Exists(path)) { - await archive.AddText("logs.txt", "Logs file latest.log has not been found"); - return; + var logsContent = await File.ReadAllTextAsync(path); + await archive.AddTextAsync("logs.txt", logsContent); } - - var logsContent = await File.ReadAllTextAsync(path); - await archive.AddText("logs.txt", logsContent); + else + await archive.AddTextAsync("logs.txt", "Logs file moonlight.log has not been found"); } } \ No newline at end of file diff --git a/Moonlight.ApiServer/Interfaces/IDiagnoseProvider.cs b/Moonlight.ApiServer/Interfaces/IDiagnoseProvider.cs index 5a4a9cf2..f7b19273 100644 --- a/Moonlight.ApiServer/Interfaces/IDiagnoseProvider.cs +++ b/Moonlight.ApiServer/Interfaces/IDiagnoseProvider.cs @@ -4,5 +4,5 @@ namespace Moonlight.ApiServer.Interfaces; public interface IDiagnoseProvider { - public Task ModifyZipArchive(ZipArchive archive); + public Task ModifyZipArchiveAsync(ZipArchive archive); } \ No newline at end of file diff --git a/Moonlight.ApiServer/Moonlight.ApiServer.csproj b/Moonlight.ApiServer/Moonlight.ApiServer.csproj index 2e3d34df..6bc2b9e7 100644 --- a/Moonlight.ApiServer/Moonlight.ApiServer.csproj +++ b/Moonlight.ApiServer/Moonlight.ApiServer.csproj @@ -25,6 +25,7 @@ + diff --git a/Moonlight.ApiServer/Services/DiagnoseService.cs b/Moonlight.ApiServer/Services/DiagnoseService.cs index 51c9e04a..ad47ae5a 100644 --- a/Moonlight.ApiServer/Services/DiagnoseService.cs +++ b/Moonlight.ApiServer/Services/DiagnoseService.cs @@ -22,7 +22,7 @@ public class DiagnoseService Logger = logger; } - public Task GetProviders() + public Task GetProvidersAsync() { var availableProviders = new List(); @@ -48,7 +48,7 @@ public class DiagnoseService ); } - public async Task GenerateDiagnose(string[] requestedProviders) + public async Task GenerateDiagnoseAsync(string[] requestedProviders) { IDiagnoseProvider[] providers; @@ -78,7 +78,7 @@ public class DiagnoseService foreach (var provider in providers) { - await provider.ModifyZipArchive(zipArchive); + await provider.ModifyZipArchiveAsync(zipArchive); } zipArchive.Dispose(); diff --git a/Moonlight.ApiServer/Startup/Startup.Auth.cs b/Moonlight.ApiServer/Startup/Startup.Auth.cs index 0a0995e1..08d4f561 100644 --- a/Moonlight.ApiServer/Startup/Startup.Auth.cs +++ b/Moonlight.ApiServer/Startup/Startup.Auth.cs @@ -23,15 +23,18 @@ public partial class Startup // we want to use the ApiKey scheme for authenticating the request options.ForwardDefaultSelector = context => { - if (!context.Request.Headers.TryGetValue("Authorization", out var authHeader)) - return "Session"; + var headers = context.Request.Headers; + + // For regular api calls + if (headers.ContainsKey("Authorization")) + return "ApiKey"; + + // For websocket requests which cannot use the Authorization header + if (headers.Upgrade == "websocket" && headers.Connection == "Upgrade" && context.Request.Query.ContainsKey("access_token")) + return "ApiKey"; - var auth = authHeader.FirstOrDefault(); - - if (string.IsNullOrEmpty(auth) || !auth.StartsWith("Bearer ")) - return "Session"; - - return "ApiKey"; + // Regular user traffic/auth + return "Session"; }; }) .AddJwtBearer("ApiKey", null, options => @@ -63,6 +66,16 @@ public partial class Startup if (!result) context.Fail("API key has been deleted"); + }, + + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + + if (!string.IsNullOrEmpty(accessToken)) + context.Token = accessToken; + + return Task.CompletedTask; } }; }) diff --git a/Moonlight.ApiServer/Startup/Startup.SignalR.cs b/Moonlight.ApiServer/Startup/Startup.SignalR.cs new file mode 100644 index 00000000..ab4bf2a3 --- /dev/null +++ b/Moonlight.ApiServer/Startup/Startup.SignalR.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Moonlight.ApiServer.Http.Hubs; + +namespace Moonlight.ApiServer.Startup; + +public partial class Startup +{ + public Task RegisterSignalR() + { + var signalRBuilder = WebApplicationBuilder.Services.AddSignalR(); + + if (Configuration.SignalR.UseRedis) + signalRBuilder.AddStackExchangeRedis(Configuration.SignalR.RedisConnectionString); + + return Task.CompletedTask; + } + + public Task MapSignalR() + { + WebApplication.MapHub("/api/admin/system/diagnose/ws"); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Startup/Startup.cs b/Moonlight.ApiServer/Startup/Startup.cs index 731ca06b..64268b0a 100644 --- a/Moonlight.ApiServer/Startup/Startup.cs +++ b/Moonlight.ApiServer/Startup/Startup.cs @@ -46,6 +46,7 @@ public partial class Startup await RegisterAuth(); await RegisterCors(); await RegisterHangfire(); + await RegisterSignalR(); await HookPluginBuild(); } @@ -62,6 +63,7 @@ public partial class Startup await HookPluginConfigure(); await MapBase(); + await MapSignalR(); await HookPluginEndpoints(); } } \ No newline at end of file diff --git a/Moonlight.Client/Moonlight.Client.csproj b/Moonlight.Client/Moonlight.Client.csproj index 705908e9..2dc84988 100644 --- a/Moonlight.Client/Moonlight.Client.csproj +++ b/Moonlight.Client/Moonlight.Client.csproj @@ -22,6 +22,7 @@ + diff --git a/Moonlight.Client/UI/Components/SignalRDebug.razor b/Moonlight.Client/UI/Components/SignalRDebug.razor new file mode 100644 index 00000000..270b2bb1 --- /dev/null +++ b/Moonlight.Client/UI/Components/SignalRDebug.razor @@ -0,0 +1,62 @@ +@using Microsoft.AspNetCore.SignalR.Client +@using Microsoft.Extensions.DependencyInjection + +@inject NavigationManager Navigation +@inject ToastService ToastService + +@implements IAsyncDisposable + +
+
+

SignalR

+
+
+

+ SignalR is used by Moonlight to provide realtime communication to itself and other modules. + You can test the SignalR communication by pressing the button below. For scaled instances you need to configure a redis compatible server to be used by all your replicas in order + for all SignalR Hubs to be synced. +

+
+ + + Send broadcast + + +
+
+
+ +@code +{ + private HubConnection? Connection; + + private async Task Load(LazyLoader lazyLoader) + { + await lazyLoader.UpdateText("Connecting to SignalR endpoint"); + + Connection = new HubConnectionBuilder() + .WithUrl(Navigation.ToAbsoluteUri("/api/admin/system/diagnose/ws")) + .AddJsonProtocol() + .Build(); + + Connection.On( + "Pong", + async () => await ToastService.Success("Received broadcast") + ); + + await Connection.StartAsync(); + } + + private async Task OnClick(WButton _) + { + if (Connection == null) + return; + + await Connection.SendAsync("Ping"); + } + + public async ValueTask DisposeAsync() + { + if (Connection != null) await Connection.DisposeAsync(); + } +} diff --git a/Moonlight.Client/UI/Views/Admin/Sys/Diagnose.razor b/Moonlight.Client/UI/Views/Admin/Sys/Diagnose.razor index 92a872d5..911f7037 100644 --- a/Moonlight.Client/UI/Views/Admin/Sys/Diagnose.razor +++ b/Moonlight.Client/UI/Views/Admin/Sys/Diagnose.razor @@ -3,6 +3,7 @@ @using Microsoft.AspNetCore.Authorization @using MoonCore.Blazor.FlyonUi.Helpers @using MoonCore.Helpers +@using Moonlight.Client.UI.Components @using Moonlight.Shared.Http.Requests.Admin.Sys @using Moonlight.Shared.Http.Responses.Admin.Sys @@ -15,22 +16,19 @@ -
-
+
+
- Diagnose + Report

- If you're experiencing issues or need help via our Discord, you're in the right place here! - By pressing the button below, Moonlight will run all available diagnostic checks and package the results - into a - downloadable zip file. - The report includes useful information about your system, plugins, and environment, making it easier to - identify problems or share with support. + With the button below you can create a diagnose report containing all important information to troubleshoot your moonlight instance and its modules. + The diagnose file is a zip containing different logs and censored config files which can be shared with our support on discord. + If you only want to export specific parts of the diagnose report, click on "Advanced" and select the desired providers

- Generate diagnose + Generate report
+ +
+ +
@code