Implemented SignalR scaling using redis. Improved diagnose report generator. Added SignalR debug card in Diagnose page

This commit is contained in:
2025-09-16 08:02:53 +00:00
parent 8573fffaa2
commit efca9cf5d8
15 changed files with 193 additions and 70 deletions

View File

@@ -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")]

View File

@@ -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);

View File

@@ -21,21 +21,16 @@ public class DiagnoseController : Controller
}
[HttpPost]
public async Task Diagnose([FromBody] GenerateDiagnoseRequest request)
public async Task<ActionResult> 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<DiagnoseProvideResponse[]> GetProviders()
public async Task<ActionResult<DiagnoseProvideResponse[]>> GetProviders()
{
return await DiagnoseService.GetProviders();
return await DiagnoseService.GetProvidersAsync();
}
}

View File

@@ -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");
}
}

View File

@@ -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<AppConfiguration>(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<AppConfiguration>(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
}
)
);
}
}

View File

@@ -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");
}
}

View File

@@ -4,5 +4,5 @@ namespace Moonlight.ApiServer.Interfaces;
public interface IDiagnoseProvider
{
public Task ModifyZipArchive(ZipArchive archive);
public Task ModifyZipArchiveAsync(ZipArchive archive);
}

View File

@@ -25,6 +25,7 @@
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.20"/>
<PackageReference Include="Hangfire.Core" Version="1.8.20"/>
<PackageReference Include="Hangfire.EntityFrameworkCore" Version="0.7.0"/>
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.9" />
<PackageReference Include="MoonCore" Version="1.9.7" />
<PackageReference Include="MoonCore.Extended" Version="1.3.7" />
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.2"/>

View File

@@ -22,7 +22,7 @@ public class DiagnoseService
Logger = logger;
}
public Task<DiagnoseProvideResponse[]> GetProviders()
public Task<DiagnoseProvideResponse[]> GetProvidersAsync()
{
var availableProviders = new List<DiagnoseProvideResponse>();
@@ -48,7 +48,7 @@ public class DiagnoseService
);
}
public async Task<MemoryStream> GenerateDiagnose(string[] requestedProviders)
public async Task<MemoryStream> 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();

View File

@@ -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;
}
};
})

View File

@@ -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<DiagnoseHub>("/api/admin/system/diagnose/ws");
return Task.CompletedTask;
}
}

View File

@@ -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();
}
}