Implemented power state and task streaming over signalr

This commit is contained in:
2024-12-30 01:16:23 +01:00
parent 394d8b05ed
commit 0bd9074494
14 changed files with 436 additions and 4 deletions

View File

@@ -118,6 +118,36 @@ public class ServersController : Controller
throw new HttpApiException("Unable to access the node the server is running on", 502); throw new HttpApiException("Unable to access the node the server is running on", 502);
} }
} }
[HttpGet("{serverId:int}/console")]
[RequirePermission("meta.authenticated")]
public async Task<ServerConsoleResponse> 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<Server> GetServerWithPermCheck(int serverId, private async Task<Server> GetServerWithPermCheck(int serverId,
Func<IQueryable<Server>, IQueryable<Server>>? queryModifier = null) Func<IQueryable<Server>, IQueryable<Server>>? queryModifier = null)

View File

@@ -1,4 +1,5 @@
using MoonCore.Attributes; using MoonCore.Attributes;
using MoonCore.Extended.Helpers;
using MoonCore.Helpers; using MoonCore.Helpers;
using MoonlightServers.ApiServer.Database.Entities; using MoonlightServers.ApiServer.Database.Entities;
using MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Statistics; using MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Statistics;
@@ -11,7 +12,7 @@ public class NodeService
{ {
public async Task<HttpApiClient> CreateApiClient(Node node) public async Task<HttpApiClient> CreateApiClient(Node node)
{ {
string url = ""; var url = "";
if (node.UseSsl) if (node.UseSsl)
url += "https://"; url += "https://";
@@ -29,6 +30,9 @@ public class NodeService
return new HttpApiClient(httpClient); return new HttpApiClient(httpClient);
} }
public string CreateAccessToken(Node node, Action<Dictionary<string, object>> parameters, TimeSpan duration)
=> JwtHelper.Encode(node.Token, parameters, duration);
public async Task<SystemStatusResponse> GetSystemStatus(Node node) public async Task<SystemStatusResponse> GetSystemStatus(Node node)
{ {

View File

@@ -8,5 +8,6 @@ public static class ServerMetaExtensions
public static async Task NotifyTask(this Server server, ServerTask task) public static async Task NotifyTask(this Server server, ServerTask task)
{ {
server.Logger.LogInformation("Task: {task}", task); server.Logger.LogInformation("Task: {task}", task);
await server.InvokeTaskAdded(task.ToString());
} }
} }

View File

@@ -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<string, JsonElement> data)
{
return JwtHelper.TryVerifyAndDecodePayload(Configuration.Security.Token, accessToken, out data);
}
}

View File

@@ -0,0 +1,7 @@
namespace MoonlightServers.Daemon.Helpers;
public class ServerConsoleConnection
{
public DateTime AuthenticatedUntil { get; set; }
public int ServerId { get; set; }
}

View File

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

View File

@@ -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<ServerConsoleHub> Logger;
private readonly ServerConsoleService ConsoleService;
public ServerConsoleHub(ILogger<ServerConsoleHub> 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);
}

View File

@@ -15,6 +15,7 @@ public class Server
public StateMachine<ServerState> StateMachine { get; set; } public StateMachine<ServerState> StateMachine { get; set; }
public ServerConfiguration Configuration { get; set; } public ServerConfiguration Configuration { get; set; }
public string? ContainerId { get; set; } public string? ContainerId { get; set; }
public event Func<string, Task> OnTaskAdded;
// This can be used to stop streaming when the server gets destroyed or something // This can be used to stop streaming when the server gets destroyed or something
public CancellationTokenSource Cancellation { get; set; } public CancellationTokenSource Cancellation { get; set; }
@@ -59,4 +60,16 @@ public class Server
} }
#endregion #endregion
#region Event invokers
public async Task InvokeTaskAdded(string task)
{
if(OnTaskAdded == null)
return;
await OnTaskAdded.Invoke(task).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
#endregion
} }

View File

@@ -8,6 +8,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Docker.DotNet" Version="3.125.15" /> <PackageReference Include="Docker.DotNet" Version="3.125.15" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="MoonCore" Version="1.8.1" /> <PackageReference Include="MoonCore" Version="1.8.1" />
<PackageReference Include="MoonCore.Extended" Version="1.2.4" /> <PackageReference Include="MoonCore.Extended" Version="1.2.4" />
<PackageReference Include="MoonCore.Unix" Version="1.0.0" /> <PackageReference Include="MoonCore.Unix" Version="1.0.0" />

View File

@@ -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<int, ServerConsoleMonitor> Monitors = new();
private readonly Dictionary<string, ServerConsoleConnection> Connections = new();
private readonly ILogger<ServerConsoleService> Logger;
private readonly AccessTokenHelper AccessTokenHelper;
private readonly ServerService ServerService;
private readonly IHubContext<ServerConsoleHub> HubContext;
public ServerConsoleService(
ILogger<ServerConsoleService> logger,
AccessTokenHelper accessTokenHelper,
ServerService serverService, IHubContext<ServerConsoleHub> 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}");
}
}

View File

@@ -6,6 +6,7 @@ using MoonCore.Extensions;
using MoonCore.Helpers; using MoonCore.Helpers;
using MoonCore.Services; using MoonCore.Services;
using MoonlightServers.Daemon.Configuration; using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Http.Hubs;
using MoonlightServers.Daemon.Services; using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon; namespace MoonlightServers.Daemon;
@@ -43,13 +44,17 @@ public class Startup
await RegisterBase(); await RegisterBase();
await RegisterDocker(); await RegisterDocker();
await RegisterServers(); await RegisterServers();
await RegisterSignalR();
await RegisterCors();
await BuildWebApplication(); await BuildWebApplication();
await UseBase(); await UseBase();
await UseCors();
await UseBaseMiddleware(); await UseBaseMiddleware();
await MapBase(); await MapBase();
await MapHubs();
await WebApplication.RunAsync(); await WebApplication.RunAsync();
} }
@@ -71,7 +76,6 @@ public class Startup
WebApplicationBuilder.Services.AddControllers(); WebApplicationBuilder.Services.AddControllers();
WebApplicationBuilder.Services.AddApiExceptionHandler(); WebApplicationBuilder.Services.AddApiExceptionHandler();
WebApplicationBuilder.Services.AddSignalR();
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -248,7 +252,7 @@ public class Startup
WebApplicationBuilder.Services.AddHostedService<ApplicationStateService>( WebApplicationBuilder.Services.AddHostedService<ApplicationStateService>(
sp => sp.GetRequiredService<ApplicationStateService>() sp => sp.GetRequiredService<ApplicationStateService>()
); );
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -258,4 +262,43 @@ public class Startup
} }
#endregion #endregion
#region Maps
private Task RegisterSignalR()
{
WebApplicationBuilder.Services.AddSignalR();
return Task.CompletedTask;
}
private Task MapHubs()
{
WebApplication.MapHub<ServerConsoleHub>("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
} }

View File

@@ -9,6 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.11"/> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.11"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.11" PrivateAssets="all"/> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.11" PrivateAssets="all"/>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageReference Include="Moonlight.Client" Version="2.1.0"/> <PackageReference Include="Moonlight.Client" Version="2.1.0"/>
</ItemGroup> </ItemGroup>

View File

@@ -1,12 +1,16 @@
@page "/servers/{ServerId:int}" @page "/servers/{ServerId:int}"
@using Microsoft.AspNetCore.SignalR.Client
@using MoonlightServers.Shared.Http.Responses.Users.Servers @using MoonlightServers.Shared.Http.Responses.Users.Servers
@using MoonCore.Blazor.Tailwind.Components @using MoonCore.Blazor.Tailwind.Components
@using MoonCore.Exceptions @using MoonCore.Exceptions
@using MoonCore.Helpers @using MoonCore.Helpers
@using MoonlightServers.Shared.Enums
@inject HttpApiClient ApiClient @inject HttpApiClient ApiClient
@implements IAsyncDisposable
<LazyLoader Load="Load"> <LazyLoader Load="Load">
@if (NotFound) @if (NotFound)
{ {
@@ -22,7 +26,19 @@
{ {
<div class="card card-body justify-between py-2.5 px-5 flex-row"> <div class="card card-body justify-between py-2.5 px-5 flex-row">
<div class="flex flex-row items-center"> <div class="flex flex-row items-center">
<div class="p-2.5 rounded-full bg-success-400 me-3"></div> @{
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"
};
}
<div class="p-2.5 rounded-full @bgColor me-3"></div>
<div class="flex flex-col"> <div class="flex flex-col">
<div class="hidden sm:flex text-lg font-semibold">@Server.Name</div> <div class="hidden sm:flex text-lg font-semibold">@Server.Name</div>
@@ -99,16 +115,57 @@
private ServerDetailResponse Server; private ServerDetailResponse Server;
private bool NotFound = false; private bool NotFound = false;
private ServerPowerState PowerState;
private string CurrentTask = ""; private string CurrentTask = "";
private HubConnection ConsoleConnection;
private async Task Load(LazyLoader _) private async Task Load(LazyLoader _)
{ {
try try
{ {
// Load meta data
Server = await ApiClient.GetJson<ServerDetailResponse>( Server = await ApiClient.GetJson<ServerDetailResponse>(
$"api/servers/{ServerId}" $"api/servers/{ServerId}"
); );
// Load initial status for first render
var status = await ApiClient.GetJson<ServerStatusResponse>(
$"api/servers/{ServerId}/status"
);
PowerState = status.PowerState;
// Load console meta
var consoleDetails = await ApiClient.GetJson<ServerConsoleResponse>(
$"api/servers/{ServerId}/console"
);
// Build signal r
ConsoleConnection = new HubConnectionBuilder()
.WithUrl(consoleDetails.Target)
.Build();
// Define handlers
ConsoleConnection.On<string>("PowerStateChanged", async powerStateStr =>
{
if(!Enum.TryParse(powerStateStr, out ServerPowerState receivedState))
return;
PowerState = receivedState;
await InvokeAsync(StateHasChanged);
});
ConsoleConnection.On<string>("TaskNotify", async task =>
{
await AddTask(Formatter.ConvertCamelCaseToSpaces(task));
});
// Connect
await ConsoleConnection.StartAsync();
// Authenticate
await ConsoleConnection.SendAsync("Authenticate", consoleDetails.AccessToken);
} }
catch (HttpApiException e) catch (HttpApiException e)
{ {
@@ -153,4 +210,12 @@
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
}); });
} }
public async ValueTask DisposeAsync()
{
if (ConsoleConnection.State == HubConnectionState.Connected)
await ConsoleConnection.StopAsync();
await ConsoleConnection.DisposeAsync();
}
} }

View File

@@ -0,0 +1,7 @@
namespace MoonlightServers.Shared.Http.Responses.Users.Servers;
public class ServerConsoleResponse
{
public string Target { get; set; }
public string AccessToken { get; set; }
}