diff --git a/MoonlightServers.ApiServer/Http/Controllers/Users/ServersController.cs b/MoonlightServers.ApiServer/Http/Controllers/Users/ServersController.cs index 7b2abb4..78769bc 100644 --- a/MoonlightServers.ApiServer/Http/Controllers/Users/ServersController.cs +++ b/MoonlightServers.ApiServer/Http/Controllers/Users/ServersController.cs @@ -118,6 +118,36 @@ public class ServersController : Controller throw new HttpApiException("Unable to access the node the server is running on", 502); } } + + [HttpGet("{serverId:int}/console")] + [RequirePermission("meta.authenticated")] + public async Task GetConsole([FromRoute] int serverId) + { + var server = await GetServerWithPermCheck(serverId); + + // TODO: Handle transparent proxy + + var accessToken = NodeService.CreateAccessToken(server.Node, parameters => + { + parameters.Add("type", "console"); + parameters.Add("serverId", server.Id); + }, TimeSpan.FromMinutes(10)); + + var url = ""; + + if (server.Node.UseSsl) + url += "https://"; + else + url += "http://"; + + url += $"{server.Node.Fqdn}:{server.Node.HttpPort}/api/servers/console"; + + return new ServerConsoleResponse() + { + Target = url, + AccessToken = accessToken + }; + } private async Task GetServerWithPermCheck(int serverId, Func, IQueryable>? queryModifier = null) diff --git a/MoonlightServers.ApiServer/Services/NodeService.cs b/MoonlightServers.ApiServer/Services/NodeService.cs index 0f7d2cc..efa8fc9 100644 --- a/MoonlightServers.ApiServer/Services/NodeService.cs +++ b/MoonlightServers.ApiServer/Services/NodeService.cs @@ -1,4 +1,5 @@ using MoonCore.Attributes; +using MoonCore.Extended.Helpers; using MoonCore.Helpers; using MoonlightServers.ApiServer.Database.Entities; using MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Statistics; @@ -11,7 +12,7 @@ public class NodeService { public async Task CreateApiClient(Node node) { - string url = ""; + var url = ""; if (node.UseSsl) url += "https://"; @@ -29,6 +30,9 @@ public class NodeService return new HttpApiClient(httpClient); } + + public string CreateAccessToken(Node node, Action> parameters, TimeSpan duration) + => JwtHelper.Encode(node.Token, parameters, duration); public async Task GetSystemStatus(Node node) { diff --git a/MoonlightServers.Daemon/Extensions/ServerExtensions/ServerMetaExtensions.cs b/MoonlightServers.Daemon/Extensions/ServerExtensions/ServerMetaExtensions.cs index 1b2e764..f5ab185 100644 --- a/MoonlightServers.Daemon/Extensions/ServerExtensions/ServerMetaExtensions.cs +++ b/MoonlightServers.Daemon/Extensions/ServerExtensions/ServerMetaExtensions.cs @@ -8,5 +8,6 @@ public static class ServerMetaExtensions public static async Task NotifyTask(this Server server, ServerTask task) { server.Logger.LogInformation("Task: {task}", task); + await server.InvokeTaskAdded(task.ToString()); } } \ No newline at end of file diff --git a/MoonlightServers.Daemon/Helpers/AccessTokenHelper.cs b/MoonlightServers.Daemon/Helpers/AccessTokenHelper.cs new file mode 100644 index 0000000..32380d9 --- /dev/null +++ b/MoonlightServers.Daemon/Helpers/AccessTokenHelper.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using MoonCore.Attributes; +using MoonCore.Extended.Helpers; +using MoonlightServers.Daemon.Configuration; + +namespace MoonlightServers.Daemon.Helpers; + +[Singleton] +public class AccessTokenHelper +{ + private readonly AppConfiguration Configuration; + + public AccessTokenHelper(AppConfiguration configuration) + { + Configuration = configuration; + } + + public bool Process(string accessToken, out Dictionary data) + { + return JwtHelper.TryVerifyAndDecodePayload(Configuration.Security.Token, accessToken, out data); + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Helpers/ServerConsoleConnection.cs b/MoonlightServers.Daemon/Helpers/ServerConsoleConnection.cs new file mode 100644 index 0000000..f1a4535 --- /dev/null +++ b/MoonlightServers.Daemon/Helpers/ServerConsoleConnection.cs @@ -0,0 +1,7 @@ +namespace MoonlightServers.Daemon.Helpers; + +public class ServerConsoleConnection +{ + public DateTime AuthenticatedUntil { get; set; } + public int ServerId { get; set; } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Helpers/ServerConsoleMonitor.cs b/MoonlightServers.Daemon/Helpers/ServerConsoleMonitor.cs new file mode 100644 index 0000000..150d9c6 --- /dev/null +++ b/MoonlightServers.Daemon/Helpers/ServerConsoleMonitor.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.SignalR; +using MoonlightServers.Daemon.Models; +using MoonlightServers.DaemonShared.Enums; + +namespace MoonlightServers.Daemon.Helpers; + +public class ServerConsoleMonitor +{ + private readonly Server Server; + private readonly IHubClients Clients; + + public ServerConsoleMonitor(Server server, IHubClients clients) + { + Server = server; + Clients = clients; + } + + public void Initialize() + { + Server.StateMachine.OnTransitioned += OnPowerStateChanged; + Server.OnTaskAdded += OnTaskNotify; + } + + public void Destroy() + { + Server.StateMachine.OnTransitioned -= OnPowerStateChanged; + } + + private async Task OnTaskNotify(string task) + { + await Clients.Group($"server-{Server.Configuration.Id}").SendAsync( + "TaskNotify", + task + ); + } + + private async Task OnPowerStateChanged(ServerState serverState) + { + await Clients.Group($"server-{Server.Configuration.Id}").SendAsync( + "PowerStateChanged", + serverState.ToString() + ); + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Http/Hubs/ServerConsoleHub.cs b/MoonlightServers.Daemon/Http/Hubs/ServerConsoleHub.cs new file mode 100644 index 0000000..7899dd0 --- /dev/null +++ b/MoonlightServers.Daemon/Http/Hubs/ServerConsoleHub.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.SignalR; +using MoonlightServers.Daemon.Helpers; +using MoonlightServers.Daemon.Models; +using MoonlightServers.Daemon.Services; +using MoonlightServers.DaemonShared.Enums; + +namespace MoonlightServers.Daemon.Http.Hubs; + +public class ServerConsoleHub : Hub +{ + private readonly ILogger Logger; + private readonly ServerConsoleService ConsoleService; + + public ServerConsoleHub(ILogger logger, ServerConsoleService consoleService) + { + Logger = logger; + ConsoleService = consoleService; + } + + [HubMethodName("Authenticate")] + public async Task Authenticate(string accessToken) + { + try + { + await ConsoleService.Authenticate(Context, accessToken); + } + catch (Exception e) + { + Logger.LogError("An unhandled error occured in the Authenticate method: {e}", e); + } + } + + public override async Task OnDisconnectedAsync(Exception? exception) + => await ConsoleService.OnClientDisconnected(Context); +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Models/Server.cs b/MoonlightServers.Daemon/Models/Server.cs index e80346f..ce9814e 100644 --- a/MoonlightServers.Daemon/Models/Server.cs +++ b/MoonlightServers.Daemon/Models/Server.cs @@ -15,6 +15,7 @@ public class Server public StateMachine StateMachine { get; set; } public ServerConfiguration Configuration { get; set; } public string? ContainerId { get; set; } + public event Func OnTaskAdded; // This can be used to stop streaming when the server gets destroyed or something public CancellationTokenSource Cancellation { get; set; } @@ -59,4 +60,16 @@ public class Server } #endregion + + #region Event invokers + + public async Task InvokeTaskAdded(string task) + { + if(OnTaskAdded == null) + return; + + await OnTaskAdded.Invoke(task).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + + #endregion } \ No newline at end of file diff --git a/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj b/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj index dd90d61..f782a5c 100644 --- a/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj +++ b/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj @@ -8,6 +8,7 @@ + diff --git a/MoonlightServers.Daemon/Services/ServerConsoleService.cs b/MoonlightServers.Daemon/Services/ServerConsoleService.cs new file mode 100644 index 0000000..9f16037 --- /dev/null +++ b/MoonlightServers.Daemon/Services/ServerConsoleService.cs @@ -0,0 +1,159 @@ +using Microsoft.AspNetCore.SignalR; +using MoonCore.Attributes; +using MoonlightServers.Daemon.Helpers; +using MoonlightServers.Daemon.Http.Hubs; + +namespace MoonlightServers.Daemon.Services; + +[Singleton] +public class ServerConsoleService +{ + private readonly Dictionary Monitors = new(); + private readonly Dictionary Connections = new(); + + private readonly ILogger Logger; + private readonly AccessTokenHelper AccessTokenHelper; + private readonly ServerService ServerService; + + private readonly IHubContext HubContext; + + public ServerConsoleService( + ILogger logger, + AccessTokenHelper accessTokenHelper, + ServerService serverService, IHubContext hubContext) + { + Logger = logger; + AccessTokenHelper = accessTokenHelper; + ServerService = serverService; + HubContext = hubContext; + } + + public Task OnClientDisconnected(HubCallerContext context) + { + ServerConsoleConnection? removedConnection; + + lock (Connections) + { + removedConnection = Connections.GetValueOrDefault(context.ConnectionId); + + if(removedConnection != null) + Connections.Remove(context.ConnectionId); + } + + // Client never authenticated themselves, nothing to do + if(removedConnection == null) + return Task.CompletedTask; + + Logger.LogDebug("Authenticated client {id} disconnected", context.ConnectionId); + + // Count remaining clients requesting the same resource + int count; + + lock (Connections) + { + count = Connections + .Values + .Count(x => x.ServerId == removedConnection.ServerId); + } + + if(count > 0) + return Task.CompletedTask; + + ServerConsoleMonitor? monitor; + + lock (Monitors) + monitor = Monitors.GetValueOrDefault(removedConnection.ServerId); + + if(monitor == null) + return Task.CompletedTask; + + Logger.LogDebug("Destroying console monitor for server {id}", removedConnection.ServerId); + monitor.Destroy(); + + lock (Monitors) + Monitors.Remove(removedConnection.ServerId); + + return Task.CompletedTask; + } + + public async Task Authenticate(HubCallerContext context, string accessToken) + { + // Validate access token + if (!AccessTokenHelper.Process(accessToken, out var accessData)) + { + Logger.LogDebug("Received invalid or expired access token. Closing connection"); + context.Abort(); + return; + } + + // Validate access token data + if (!accessData.ContainsKey("type") || !accessData.ContainsKey("serverId")) + { + Logger.LogDebug("Received invalid access token: Required parameters are missing. Closing connection"); + context.Abort(); + return; + } + + // Validate access token type + var type = accessData["type"].GetString()!; + + if (type != "console") + { + Logger.LogDebug("Received invalid access token: Invalid type '{type}'. Closing connection", type); + context.Abort(); + return; + } + + var serverId = accessData["serverId"].GetInt32(); + var server = ServerService.GetServer(serverId); + + if (server == null) + { + Logger.LogDebug("Received invalid access token: No server found with the requested id. Closing connection"); + context.Abort(); + return; + } + + ServerConsoleConnection? connection; + + lock (Connections) + { + connection = Connections + .GetValueOrDefault(context.ConnectionId); + } + + if (connection == null) // If no existing connection has been found, we create a new one + { + connection = new() + { + ServerId = server.Configuration.Id, + AuthenticatedUntil = DateTime.UtcNow.AddMinutes(10) + }; + + lock (Connections) + Connections.Add(context.ConnectionId, connection); + + Logger.LogDebug("Connection {id} authenticated successfully", context.ConnectionId); + } + else + Logger.LogDebug("Connection {id} re-authenticated successfully", context.ConnectionId); + + ServerConsoleMonitor? monitor; + + lock (Monitors) + monitor = Monitors.GetValueOrDefault(server.Configuration.Id); + + if (monitor == null) + { + Logger.LogDebug("Initializing console monitor for server {id}", server.Configuration.Id); + + monitor = new ServerConsoleMonitor(server, HubContext.Clients); + monitor.Initialize(); + + lock (Monitors) + Monitors.Add(server.Configuration.Id, monitor); + } + + await HubContext.Groups.AddToGroupAsync(context.ConnectionId, $"server-{server.Configuration.Id}"); + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Startup.cs b/MoonlightServers.Daemon/Startup.cs index f42ed86..1da9b70 100644 --- a/MoonlightServers.Daemon/Startup.cs +++ b/MoonlightServers.Daemon/Startup.cs @@ -6,6 +6,7 @@ using MoonCore.Extensions; using MoonCore.Helpers; using MoonCore.Services; using MoonlightServers.Daemon.Configuration; +using MoonlightServers.Daemon.Http.Hubs; using MoonlightServers.Daemon.Services; namespace MoonlightServers.Daemon; @@ -43,13 +44,17 @@ public class Startup await RegisterBase(); await RegisterDocker(); await RegisterServers(); + await RegisterSignalR(); + await RegisterCors(); await BuildWebApplication(); await UseBase(); + await UseCors(); await UseBaseMiddleware(); await MapBase(); + await MapHubs(); await WebApplication.RunAsync(); } @@ -71,7 +76,6 @@ public class Startup WebApplicationBuilder.Services.AddControllers(); WebApplicationBuilder.Services.AddApiExceptionHandler(); - WebApplicationBuilder.Services.AddSignalR(); return Task.CompletedTask; } @@ -248,7 +252,7 @@ public class Startup WebApplicationBuilder.Services.AddHostedService( sp => sp.GetRequiredService() ); - + return Task.CompletedTask; } @@ -258,4 +262,43 @@ public class Startup } #endregion + + #region Maps + + private Task RegisterSignalR() + { + WebApplicationBuilder.Services.AddSignalR(); + return Task.CompletedTask; + } + + private Task MapHubs() + { + WebApplication.MapHub("api/servers/console"); + + return Task.CompletedTask; + } + + #endregion + + #region Cors + + private Task RegisterCors() + { + //TODO: IMPORTANT: CHANGE !!! + WebApplicationBuilder.Services.AddCors(x => + x.AddDefaultPolicy(builder => + builder.AllowAnyHeader().AllowAnyOrigin().AllowAnyMethod().Build() + ) + ); + + return Task.CompletedTask; + } + + private Task UseCors() + { + WebApplication.UseCors(); + return Task.CompletedTask; + } + + #endregion } \ No newline at end of file diff --git a/MoonlightServers.Frontend/MoonlightServers.Frontend.csproj b/MoonlightServers.Frontend/MoonlightServers.Frontend.csproj index 82a338f..deae6ac 100644 --- a/MoonlightServers.Frontend/MoonlightServers.Frontend.csproj +++ b/MoonlightServers.Frontend/MoonlightServers.Frontend.csproj @@ -9,6 +9,7 @@ + diff --git a/MoonlightServers.Frontend/UI/Views/User/Manage.razor b/MoonlightServers.Frontend/UI/Views/User/Manage.razor index 3bf4796..ef1b727 100644 --- a/MoonlightServers.Frontend/UI/Views/User/Manage.razor +++ b/MoonlightServers.Frontend/UI/Views/User/Manage.razor @@ -1,12 +1,16 @@ @page "/servers/{ServerId:int}" +@using Microsoft.AspNetCore.SignalR.Client @using MoonlightServers.Shared.Http.Responses.Users.Servers @using MoonCore.Blazor.Tailwind.Components @using MoonCore.Exceptions @using MoonCore.Helpers +@using MoonlightServers.Shared.Enums @inject HttpApiClient ApiClient +@implements IAsyncDisposable + @if (NotFound) { @@ -22,7 +26,19 @@ {
-
+ @{ + var bgColor = PowerState switch + { + ServerPowerState.Installing => "bg-primary-500", + ServerPowerState.Offline => "bg-danger-500", + ServerPowerState.Starting => "bg-warning-500", + ServerPowerState.Stopping => "bg-warning-500", + ServerPowerState.Online => "bg-success-500", + _ => "bg-gray-500" + }; + } + +
@@ -99,16 +115,57 @@ private ServerDetailResponse Server; private bool NotFound = false; + private ServerPowerState PowerState; private string CurrentTask = ""; + private HubConnection ConsoleConnection; private async Task Load(LazyLoader _) { try { + // Load meta data Server = await ApiClient.GetJson( $"api/servers/{ServerId}" ); + + // Load initial status for first render + var status = await ApiClient.GetJson( + $"api/servers/{ServerId}/status" + ); + + PowerState = status.PowerState; + + // Load console meta + var consoleDetails = await ApiClient.GetJson( + $"api/servers/{ServerId}/console" + ); + + // Build signal r + ConsoleConnection = new HubConnectionBuilder() + .WithUrl(consoleDetails.Target) + .Build(); + + // Define handlers + ConsoleConnection.On("PowerStateChanged", async powerStateStr => + { + if(!Enum.TryParse(powerStateStr, out ServerPowerState receivedState)) + return; + + PowerState = receivedState; + await InvokeAsync(StateHasChanged); + }); + + ConsoleConnection.On("TaskNotify", async task => + { + await AddTask(Formatter.ConvertCamelCaseToSpaces(task)); + }); + + // Connect + await ConsoleConnection.StartAsync(); + + // Authenticate + await ConsoleConnection.SendAsync("Authenticate", consoleDetails.AccessToken); } catch (HttpApiException e) { @@ -153,4 +210,12 @@ await InvokeAsync(StateHasChanged); }); } + + public async ValueTask DisposeAsync() + { + if (ConsoleConnection.State == HubConnectionState.Connected) + await ConsoleConnection.StopAsync(); + + await ConsoleConnection.DisposeAsync(); + } } diff --git a/MoonlightServers.Shared/Http/Responses/Users/Servers/ServerConsoleResponse.cs b/MoonlightServers.Shared/Http/Responses/Users/Servers/ServerConsoleResponse.cs new file mode 100644 index 0000000..4445b09 --- /dev/null +++ b/MoonlightServers.Shared/Http/Responses/Users/Servers/ServerConsoleResponse.cs @@ -0,0 +1,7 @@ +namespace MoonlightServers.Shared.Http.Responses.Users.Servers; + +public class ServerConsoleResponse +{ + public string Target { get; set; } + public string AccessToken { get; set; } +} \ No newline at end of file