diff --git a/Moonlight/Core/Database/DataContext.cs b/Moonlight/Core/Database/DataContext.cs index 6667857c..a45034ca 100644 --- a/Moonlight/Core/Database/DataContext.cs +++ b/Moonlight/Core/Database/DataContext.cs @@ -2,6 +2,7 @@ using Moonlight.Core.Database.Entities; using Moonlight.Core.Services; using Moonlight.Features.Community.Entities; +using Moonlight.Features.Servers.Entities; using Moonlight.Features.ServiceManagement.Entities; using Moonlight.Features.StoreSystem.Entities; using Moonlight.Features.Theming.Entities; @@ -39,6 +40,15 @@ public class DataContext : DbContext // Themes public DbSet Themes { get; set; } + + // Servers + public DbSet Servers { get; set; } + public DbSet ServerAllocations { get; set; } + public DbSet ServerImages { get; set; } + public DbSet ServerNodes { get; set; } + public DbSet ServerVariables { get; set; } + public DbSet ServerDockerImages { get; set; } + public DbSet ServerImageVariables { get; set; } public DataContext(ConfigService configService) { diff --git a/Moonlight/Core/Helpers/WsPacketConnection.cs b/Moonlight/Core/Helpers/WsPacketConnection.cs new file mode 100644 index 00000000..477d7b6c --- /dev/null +++ b/Moonlight/Core/Helpers/WsPacketConnection.cs @@ -0,0 +1,99 @@ +using System.Net.WebSockets; +using System.Text; +using Newtonsoft.Json; + +namespace Moonlight.Core.Helpers; + +public class WsPacketConnection +{ + private readonly Dictionary Packets = new(); + private readonly WebSocket WebSocket; + + public WsPacketConnection(WebSocket webSocket) + { + WebSocket = webSocket; + } + + public Task RegisterPacket(string id) + { + lock (Packets) + Packets.Add(id, typeof(T)); + + return Task.CompletedTask; + } + + public async Task Send(object packet) + { + string? packetId = null; + + // Search packet registration + lock (Packets) + { + if (Packets.Any(x => x.Value == packet.GetType())) + packetId = Packets.First(x => x.Value == packet.GetType()).Key; + + if (packetId == null) + throw new ArgumentException($"A packet with the type {packet.GetType().FullName} is not registered"); + } + + // Build raw packet + var rawPacket = new RawPacket() + { + Id = packetId, + Data = packet + }; + + // Serialize, encode and build buffer + var json = JsonConvert.SerializeObject(rawPacket); + var buffer = Encoding.UTF8.GetBytes(json); + + await WebSocket.SendAsync(buffer, WebSocketMessageType.Text, WebSocketMessageFlags.None, + CancellationToken.None); + } + + public async Task Receive() + { + // Build buffer and read + var buffer = new byte[1024]; + await WebSocket.ReceiveAsync(buffer, CancellationToken.None); + + // Decode and deserialize + var json = Encoding.UTF8.GetString(buffer); + var rawPacket = JsonConvert.DeserializeObject(json)!; + + object? packetType = null; + + // Search packet registration + lock (Packets) + { + if (Packets.ContainsKey(rawPacket.Id)) + packetType = Packets[rawPacket.Id]; + + if (packetType == null) + throw new ArgumentException($"A packet with the type {rawPacket.Id} is not registered"); + } + + var typedPacketType = typeof(RawPacket<>).MakeGenericType((packetType as Type)!); + var typedPacket = JsonConvert.DeserializeObject(json, typedPacketType); + + return typedPacketType.GetProperty("Data")!.GetValue(typedPacket); + } + + public async Task Close() + { + if(WebSocket.State == WebSocketState.Open) + await WebSocket.CloseAsync(WebSocketCloseStatus.Empty, null, CancellationToken.None); + } + + public class RawPacket + { + public string Id { get; set; } + public object Data { get; set; } + } + + public class RawPacket + { + public string Id { get; set; } + public T Data { get; set; } + } +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Entities/Server.cs b/Moonlight/Features/Servers/Entities/Server.cs new file mode 100644 index 00000000..abc0e479 --- /dev/null +++ b/Moonlight/Features/Servers/Entities/Server.cs @@ -0,0 +1,24 @@ +using Moonlight.Features.ServiceManagement.Entities; + +namespace Moonlight.Features.Servers.Entities; + +public class Server +{ + public int Id { get; set; } + public Service Service { get; set; } + + public string Name { get; set; } + + public int Cpu { get; set; } + public int Memory { get; set; } + public int Disk { get; set; } + + public ServerImage Image { get; set; } + public int DockerImageIndex { get; set; } + public string? OverrideStartupCommand { get; set; } + public List Variables { get; set; } + + public ServerNode Node { get; set; } + public ServerAllocation MainAllocation { get; set; } + public List Allocations { get; set; } = new(); +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Entities/ServerAllocation.cs b/Moonlight/Features/Servers/Entities/ServerAllocation.cs new file mode 100644 index 00000000..b1c75c9c --- /dev/null +++ b/Moonlight/Features/Servers/Entities/ServerAllocation.cs @@ -0,0 +1,8 @@ +namespace Moonlight.Features.Servers.Entities; + +public class ServerAllocation +{ + public int Id { get; set; } + public string IpAddress { get; set; } = "0.0.0.0"; + public int Port { get; set; } +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Entities/ServerDockerImage.cs b/Moonlight/Features/Servers/Entities/ServerDockerImage.cs new file mode 100644 index 00000000..cfc302ed --- /dev/null +++ b/Moonlight/Features/Servers/Entities/ServerDockerImage.cs @@ -0,0 +1,9 @@ +namespace Moonlight.Features.Servers.Entities; + +public class ServerDockerImage +{ + public int Id { get; set; } + public string Name { get; set; } + public string DisplayName { get; set; } + public bool AutoPull { get; set; } = true; +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Entities/ServerImage.cs b/Moonlight/Features/Servers/Entities/ServerImage.cs new file mode 100644 index 00000000..fe55677f --- /dev/null +++ b/Moonlight/Features/Servers/Entities/ServerImage.cs @@ -0,0 +1,24 @@ +namespace Moonlight.Features.Servers.Entities; + +public class ServerImage +{ + public int Id { get; set; } + public string Name { get; set; } + + public int AllocationsNeeded { get; set; } + public string StartupCommand { get; set; } + public string StopCommand { get; set; } + public string OnlineDetection { get; set; } + public string ParseConfigurations { get; set; } = "[]"; + + public string InstallDockerImage { get; set; } + public string InstallShell { get; set; } + public string InstallScript { get; set; } + + public string Author { get; set; } + public string? DonateUrl { get; set; } + public string? UpdateUrl { get; set; } + + public List Variables = new(); + public List DockerImages { get; set; } +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Entities/ServerImageVariable.cs b/Moonlight/Features/Servers/Entities/ServerImageVariable.cs new file mode 100644 index 00000000..bda1a4ac --- /dev/null +++ b/Moonlight/Features/Servers/Entities/ServerImageVariable.cs @@ -0,0 +1,13 @@ +namespace Moonlight.Features.Servers.Entities; + +public class ServerImageVariable +{ + public int Id { get; set; } + public string Key { get; set; } + public string DefaultValue { get; set; } + + public string DisplayName { get; set; } + public string Description { get; set; } + public bool AllowUserToEdit { get; set; } + public bool AllowUserToView { get; set; } +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Entities/ServerNode.cs b/Moonlight/Features/Servers/Entities/ServerNode.cs new file mode 100644 index 00000000..aacc00be --- /dev/null +++ b/Moonlight/Features/Servers/Entities/ServerNode.cs @@ -0,0 +1,13 @@ +namespace Moonlight.Features.Servers.Entities; + +public class ServerNode +{ + public int Id { get; set; } + public string Name { get; set; } + + public string Token { get; set; } + public int HttpPort { get; set; } + public int FtpPort { get; set; } + + public List Allocations { get; set; } = new(); +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Entities/ServerVariable.cs b/Moonlight/Features/Servers/Entities/ServerVariable.cs new file mode 100644 index 00000000..92f892e6 --- /dev/null +++ b/Moonlight/Features/Servers/Entities/ServerVariable.cs @@ -0,0 +1,8 @@ +namespace Moonlight.Features.Servers.Entities; + +public class ServerVariable +{ + public int Id { get; set; } + public string Key { get; set; } + public string Value { get; set; } +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Extensions/Attributes/EnableNodeMiddlewareAttribute.cs b/Moonlight/Features/Servers/Extensions/Attributes/EnableNodeMiddlewareAttribute.cs new file mode 100644 index 00000000..4fa7cedd --- /dev/null +++ b/Moonlight/Features/Servers/Extensions/Attributes/EnableNodeMiddlewareAttribute.cs @@ -0,0 +1,6 @@ +namespace Moonlight.Features.Servers.Extensions.Attributes; + +public class EnableNodeMiddlewareAttribute : Attribute +{ + +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Extensions/ServerExtensions.cs b/Moonlight/Features/Servers/Extensions/ServerExtensions.cs new file mode 100644 index 00000000..89f3f2ff --- /dev/null +++ b/Moonlight/Features/Servers/Extensions/ServerExtensions.cs @@ -0,0 +1,63 @@ +using Moonlight.Features.Servers.Entities; +using Moonlight.Features.Servers.Models.Abstractions; + +namespace Moonlight.Features.Servers.Extensions; + +public static class ServerExtensions +{ + public static ServerConfiguration ToServerConfiguration(this Server server) + { + var serverConfiguration = new ServerConfiguration(); + + // Set general information + serverConfiguration.Id = server.Id; + + // Set variables + serverConfiguration.Variables = server.Variables + .ToDictionary(x => x.Key, x => x.Value); + + // Set server image + serverConfiguration.Image = new() + { + OnlineDetection = server.Image.OnlineDetection, + ParseConfigurations = server.Image.ParseConfigurations, + StartupCommand = server.Image.StartupCommand, + StopCommand = server.Image.StopCommand + }; + + // Find docker image by index + ServerDockerImage dockerImage; + + if (server.DockerImageIndex >= server.Image.DockerImages.Count || server.DockerImageIndex == -1) + dockerImage = server.Image.DockerImages.Last(); + else + dockerImage = server.Image.DockerImages[server.DockerImageIndex]; + + serverConfiguration.Image.DockerImage = dockerImage.Name; + serverConfiguration.Image.PullDockerImage = dockerImage.AutoPull; + + // Set server limits + serverConfiguration.Limits = new() + { + Cpu = server.Cpu, + Memory = server.Memory, + Disk = server.Disk + }; + + // Set allocations + serverConfiguration.Allocations = server.Allocations.Select(x => new ServerConfiguration.AllocationData() + { + IpAddress = x.IpAddress, + Port = x.Port + }).ToList(); + + // Set main allocation + serverConfiguration.MainAllocation = new() + { + IpAddress = server.MainAllocation.IpAddress, + Port = server.MainAllocation.Port + }; + + return serverConfiguration; + } +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Http/Controllers/NodeController.cs b/Moonlight/Features/Servers/Http/Controllers/NodeController.cs new file mode 100644 index 00000000..5d4abfe4 --- /dev/null +++ b/Moonlight/Features/Servers/Http/Controllers/NodeController.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Mvc; +using Moonlight.Features.Servers.Entities; +using Moonlight.Features.Servers.Extensions.Attributes; +using Moonlight.Features.Servers.Services; + +namespace Moonlight.Features.Servers.Http.Controllers; + +[ApiController] +[Route("api/servers/node")] +[EnableNodeMiddleware] +public class NodeController : Controller +{ + private readonly NodeService NodeService; + + public NodeController(NodeService nodeService) + { + NodeService = nodeService; + } + + [HttpPost("notify/start")] + public async Task NotifyBootStart() + { + // Load node from request context + var node = (HttpContext.Items["Node"] as ServerNode)!; + + await NodeService.UpdateMeta(node, meta => + { + meta.IsBooting = true; + }); + + return Ok(); + } + + [HttpPost("notify/finish")] + public async Task NotifyBootFinish() + { + // Load node from request context + var node = (HttpContext.Items["Node"] as ServerNode)!; + + await NodeService.UpdateMeta(node, meta => + { + meta.IsBooting = true; + }); + + return Ok(); + } +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Http/Controllers/ServersControllers.cs b/Moonlight/Features/Servers/Http/Controllers/ServersControllers.cs new file mode 100644 index 00000000..4b3eaf35 --- /dev/null +++ b/Moonlight/Features/Servers/Http/Controllers/ServersControllers.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moonlight.Core.Helpers; +using Moonlight.Core.Repositories; +using Moonlight.Features.Servers.Entities; +using Moonlight.Features.Servers.Extensions; +using Moonlight.Features.Servers.Extensions.Attributes; +using Moonlight.Features.Servers.Models.Abstractions; + +namespace Moonlight.Features.Servers.Http.Controllers; + +[ApiController] +[Route("api/servers")] +[EnableNodeMiddleware] +public class ServersControllers : Controller +{ + private readonly Repository ServerRepository; + + public ServersControllers(Repository serverRepository) + { + ServerRepository = serverRepository; + } + + [HttpGet("ws")] + public async Task GetAllServersWs() + { + // Validate if it is even a websocket connection + if (HttpContext.WebSockets.IsWebSocketRequest) + return BadRequest("This endpoint is only available for websockets"); + + // Accept websocket connection + var websocket = await HttpContext.WebSockets.AcceptWebSocketAsync(); + + // Build connection wrapper + var wsPacketConnection = new WsPacketConnection(websocket); + await wsPacketConnection.RegisterPacket("serverConfiguration"); + + // Read server data for the node + var node = (HttpContext.Items["Node"] as ServerNode)!; + + // Load server data with including the relational data + var servers = ServerRepository + .Get() + .Include(x => x.Allocations) + .Include(x => x.MainAllocation) + .Include(x => x.Image) + .ThenInclude(x => x.Variables) + .Include(x => x.Image) + .ThenInclude(x => x.DockerImages) + .Where(x => x.Node.Id == node.Id) + .ToArray(); + + // Convert the data to server configurations + var serverConfigurations = servers + .Select(x => x.ToServerConfiguration()) + .ToArray(); + + // Send the server configurations + foreach (var serverConfiguration in serverConfigurations) + await wsPacketConnection.Send(serverConfiguration); + + // Close the connection + await wsPacketConnection.Close(); + + return Ok(); + } +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Http/Middleware/NodeMiddleware.cs b/Moonlight/Features/Servers/Http/Middleware/NodeMiddleware.cs new file mode 100644 index 00000000..73f679c7 --- /dev/null +++ b/Moonlight/Features/Servers/Http/Middleware/NodeMiddleware.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Http.Features; +using Moonlight.Core.Repositories; +using Moonlight.Features.Servers.Entities; +using Moonlight.Features.Servers.Extensions.Attributes; + +namespace Moonlight.Features.Servers.Http.Middleware; + +public class NodeMiddleware +{ + private RequestDelegate Next; + private readonly Repository NodeRepository; + + public NodeMiddleware(RequestDelegate next, Repository nodeRepository) + { + Next = next; + NodeRepository = nodeRepository; + } + + public async Task Invoke(HttpContext context) + { + // Check if the path is targeting the /api/servers endpoints + if (!context.Request.Path.HasValue || !context.Request.Path.Value.StartsWith("/api/servers")) + { + await Next(context); + return; + } + + // Load endpoint + var endpoint = context.Features.Get(); + + // Null checks to ensure we have data to check + if (endpoint == null || endpoint.Endpoint == null) + { + await Next(context); + return; + } + + // Reference to the controller meta + var controllerMeta = endpoint.Endpoint.Metadata; + + // If the node middleware attribute is missing, we want to continue + if(controllerMeta.All(x => x is EnableNodeMiddlewareAttribute)) + { + await Next(context); + return; + } + + // Now we actually want to validate the request + // so every return after this text will prevent + // the call of the controller action + + // Check if header exists + if (!context.Request.Headers.ContainsKey("Authorization")) + { + // TODO: Add a proper extensions pack to support proper error messages + context.Response.StatusCode = 403; + return; + } + + var token = context.Request.Headers["Authorization"]; + + // Check if header is null + if (string.IsNullOrEmpty(token)) + { + context.Response.StatusCode = 403; + return; + } + + // Check if any node has the token specified by the request + var node = NodeRepository + .Get() + .FirstOrDefault(x => x.Token == token); + + if (node == null) + { + context.Response.StatusCode = 403; + return; + } + + // Request is valid, because we found a node by this token + // so now we want to save it for the controller to use and + // continue in the request pipeline + + context.Items["Node"] = node; + + await Next(context); + } +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Models/Abstractions/NodeMeta.cs b/Moonlight/Features/Servers/Models/Abstractions/NodeMeta.cs new file mode 100644 index 00000000..ae5f8f09 --- /dev/null +++ b/Moonlight/Features/Servers/Models/Abstractions/NodeMeta.cs @@ -0,0 +1,6 @@ +namespace Moonlight.Features.Servers.Models.Abstractions; + +public class NodeMeta +{ + public bool IsBooting { get; set; } +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Models/Abstractions/ServerConfiguration.cs b/Moonlight/Features/Servers/Models/Abstractions/ServerConfiguration.cs new file mode 100644 index 00000000..e3bf0586 --- /dev/null +++ b/Moonlight/Features/Servers/Models/Abstractions/ServerConfiguration.cs @@ -0,0 +1,35 @@ +namespace Moonlight.Features.Servers.Models.Abstractions; + +public class ServerConfiguration +{ + public int Id { get; set; } + + public LimitsData Limits { get; set; } + public ImageData Image { get; set; } + public AllocationData MainAllocation { get; set; } + public List Allocations { get; set; } + public Dictionary Variables { get; set; } = new(); + + public class LimitsData + { + public int Cpu { get; set; } + public int Memory { get; set; } + public int Disk { get; set; } + } + + public class ImageData + { + public string DockerImage { get; set; } + public bool PullDockerImage { get; set; } + public string StartupCommand { get; set; } + public string StopCommand { get; set; } + public string OnlineDetection { get; set; } + public string ParseConfigurations { get; set; } + } + + public class AllocationData + { + public string IpAddress { get; set; } + public int Port { get; set; } + } +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Services/NodeService.cs b/Moonlight/Features/Servers/Services/NodeService.cs new file mode 100644 index 00000000..03da86c0 --- /dev/null +++ b/Moonlight/Features/Servers/Services/NodeService.cs @@ -0,0 +1,30 @@ +using Moonlight.Features.Servers.Entities; +using Moonlight.Features.Servers.Models.Abstractions; + +namespace Moonlight.Features.Servers.Services; + +public class NodeService +{ + private readonly Dictionary MetaCache = new(); + + public Task UpdateMeta(ServerNode node, Action metaAction) + { + lock (MetaCache) + { + NodeMeta? meta = null; + + if (MetaCache.ContainsKey(node.Id)) + meta = MetaCache[node.Id]; + + if (meta == null) + { + meta = new(); + MetaCache.Add(node.Id, meta); + } + + metaAction.Invoke(meta); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Moonlight/Moonlight.csproj b/Moonlight/Moonlight.csproj index ba1edaa8..c39c194c 100644 --- a/Moonlight/Moonlight.csproj +++ b/Moonlight/Moonlight.csproj @@ -33,6 +33,14 @@ + + + + + + + + diff --git a/Moonlight/Program.cs b/Moonlight/Program.cs index d3d6a7f1..91cbd664 100644 --- a/Moonlight/Program.cs +++ b/Moonlight/Program.cs @@ -13,6 +13,8 @@ using Moonlight.Core.Services.Users; using Moonlight.Core.Services.Utils; using Moonlight.Features.Advertisement.Services; using Moonlight.Features.Community.Services; +using Moonlight.Features.Servers.Http.Middleware; +using Moonlight.Features.Servers.Services; using Moonlight.Features.ServiceManagement.Entities.Enums; using Moonlight.Features.ServiceManagement.Services; using Moonlight.Features.StoreSystem.Services; @@ -51,6 +53,10 @@ builder.Services.AddSingleton(pluginService); await pluginService.Load(builder); await pluginService.RunPreInit(); +// TODO: Add automatic assembly scanning +// dependency injection registration +// using attributes + builder.Services.AddDbContext(); // Repositories @@ -99,6 +105,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// Services / Servers +builder.Services.AddSingleton(); + // Services builder.Services.AddScoped(); builder.Services.AddSingleton(configService); @@ -127,6 +136,8 @@ var app = builder.Build(); app.UseStaticFiles(); app.UseRouting(); +app.UseMiddleware(); + app.MapBlazorHub(); app.MapFallbackToPage("/_Host"); app.MapControllers();