diff --git a/LANCommander.Server.Data.MySQL/Migrations/20260308000001_AddRemoteServerEngine.cs b/LANCommander.Server.Data.MySQL/Migrations/20260308000001_AddRemoteServerEngine.cs new file mode 100644 index 00000000..fad147cb --- /dev/null +++ b/LANCommander.Server.Data.MySQL/Migrations/20260308000001_AddRemoteServerEngine.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LANCommander.Server.Data.MySQL.Migrations +{ + /// + public partial class AddRemoteServerEngine : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RemoteHostId", + table: "Servers", + type: "char(36)", + nullable: true, + collation: "ascii_general_ci"); + + migrationBuilder.AddColumn( + name: "RemoteServerId", + table: "Servers", + type: "char(36)", + nullable: true, + collation: "ascii_general_ci"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RemoteHostId", + table: "Servers"); + + migrationBuilder.DropColumn( + name: "RemoteServerId", + table: "Servers"); + } + } +} diff --git a/LANCommander.Server.Data.MySQL/Migrations/DatabaseContextModelSnapshot.cs b/LANCommander.Server.Data.MySQL/Migrations/DatabaseContextModelSnapshot.cs index 08efa71b..1875e91b 100644 --- a/LANCommander.Server.Data.MySQL/Migrations/DatabaseContextModelSnapshot.cs +++ b/LANCommander.Server.Data.MySQL/Migrations/DatabaseContextModelSnapshot.cs @@ -1204,6 +1204,12 @@ namespace LANCommander.Server.Data.MySQL.Migrations b.Property("ProcessTerminationMethod") .HasColumnType("int"); + b.Property("RemoteHostId") + .HasColumnType("char(36)"); + + b.Property("RemoteServerId") + .HasColumnType("char(36)"); + b.Property("UpdatedById") .HasColumnType("char(36)"); diff --git a/LANCommander.Server.Data.PostgreSQL/Migrations/20260308000001_AddRemoteServerEngine.cs b/LANCommander.Server.Data.PostgreSQL/Migrations/20260308000001_AddRemoteServerEngine.cs new file mode 100644 index 00000000..191d5da9 --- /dev/null +++ b/LANCommander.Server.Data.PostgreSQL/Migrations/20260308000001_AddRemoteServerEngine.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LANCommander.Server.Data.PostgreSQL.Migrations +{ + /// + public partial class AddRemoteServerEngine : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RemoteHostId", + table: "Servers", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "RemoteServerId", + table: "Servers", + type: "uuid", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RemoteHostId", + table: "Servers"); + + migrationBuilder.DropColumn( + name: "RemoteServerId", + table: "Servers"); + } + } +} diff --git a/LANCommander.Server.Data.PostgreSQL/Migrations/DatabaseContextModelSnapshot.cs b/LANCommander.Server.Data.PostgreSQL/Migrations/DatabaseContextModelSnapshot.cs index f4fd3ef7..e9b62da2 100644 --- a/LANCommander.Server.Data.PostgreSQL/Migrations/DatabaseContextModelSnapshot.cs +++ b/LANCommander.Server.Data.PostgreSQL/Migrations/DatabaseContextModelSnapshot.cs @@ -1204,6 +1204,12 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations b.Property("ProcessTerminationMethod") .HasColumnType("integer"); + b.Property("RemoteHostId") + .HasColumnType("uuid"); + + b.Property("RemoteServerId") + .HasColumnType("uuid"); + b.Property("UpdatedById") .HasColumnType("uuid"); diff --git a/LANCommander.Server.Data.SQLite/Migrations/20260308000001_AddRemoteServerEngine.cs b/LANCommander.Server.Data.SQLite/Migrations/20260308000001_AddRemoteServerEngine.cs new file mode 100644 index 00000000..bed2ce8b --- /dev/null +++ b/LANCommander.Server.Data.SQLite/Migrations/20260308000001_AddRemoteServerEngine.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LANCommander.Migrations +{ + /// + public partial class AddRemoteServerEngine : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RemoteHostId", + table: "Servers", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "RemoteServerId", + table: "Servers", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RemoteHostId", + table: "Servers"); + + migrationBuilder.DropColumn( + name: "RemoteServerId", + table: "Servers"); + } + } +} diff --git a/LANCommander.Server.Data.SQLite/Migrations/DatabaseContextModelSnapshot.cs b/LANCommander.Server.Data.SQLite/Migrations/DatabaseContextModelSnapshot.cs index 3733387f..a01d905b 100644 --- a/LANCommander.Server.Data.SQLite/Migrations/DatabaseContextModelSnapshot.cs +++ b/LANCommander.Server.Data.SQLite/Migrations/DatabaseContextModelSnapshot.cs @@ -1370,6 +1370,12 @@ namespace LANCommander.Migrations b.Property("ProcessTerminationMethod") .HasColumnType("INTEGER"); + b.Property("RemoteHostId") + .HasColumnType("TEXT"); + + b.Property("RemoteServerId") + .HasColumnType("TEXT"); + b.Property("UpdatedById") .HasColumnType("TEXT"); diff --git a/LANCommander.Server.Data/Models/Server.cs b/LANCommander.Server.Data/Models/Server.cs index b0666212..bf4642bb 100644 --- a/LANCommander.Server.Data/Models/Server.cs +++ b/LANCommander.Server.Data/Models/Server.cs @@ -19,6 +19,8 @@ namespace LANCommander.Server.Data.Models public string OnStopScriptPath { get; set; } = ""; public Guid? DockerHostId { get; set; } + public Guid? RemoteHostId { get; set; } + public Guid? RemoteServerId { get; set; } [MaxLength(64)] public string ContainerId { get; set; } = ""; public string Host { get; set; } = ""; diff --git a/LANCommander.Server.Services/Extensions/IServiceCollectionExtensions.cs b/LANCommander.Server.Services/Extensions/IServiceCollectionExtensions.cs index 51b53fbd..585a03ed 100644 --- a/LANCommander.Server.Services/Extensions/IServiceCollectionExtensions.cs +++ b/LANCommander.Server.Services/Extensions/IServiceCollectionExtensions.cs @@ -72,6 +72,9 @@ public static class IServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(provider => provider.GetService()); + + services.AddSingleton(); + services.AddSingleton(provider => provider.GetService()); services.AddSingleton(); services.AddSingleton(sp => diff --git a/LANCommander.Server.Services/ServerEngines/RemoteServerEngine.cs b/LANCommander.Server.Services/ServerEngines/RemoteServerEngine.cs new file mode 100644 index 00000000..9ad0f818 --- /dev/null +++ b/LANCommander.Server.Services/ServerEngines/RemoteServerEngine.cs @@ -0,0 +1,273 @@ +using System.Net.Http.Json; +using System.Text.Json; +using LANCommander.SDK.Models; +using LANCommander.Server.Services.Abstractions; +using LANCommander.Server.Services.Enums; +using LANCommander.Server.Services.Models; +using LANCommander.Server.Settings.Enums; +using LANCommander.Server.Settings.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace LANCommander.Server.Services.ServerEngines; + +public class RemoteServerEngine( + ILogger logger, + SettingsProvider settingsProvider, + IServiceProvider serviceProvider) : IServerEngine +{ + private record RemoteServerInfo(Guid HostId, Guid RemoteServerId); + + private readonly Dictionary _tracked = new(); + private readonly Dictionary _clients = new(); + private readonly Dictionary _status = new(); + private Timer _pollTimer; + + public event EventHandler? OnServerStatusUpdate; + public event EventHandler? OnServerLog; + + public async Task InitializeAsync() + { + foreach (var config in settingsProvider.CurrentValue.Server.GameServers.ServerEngines + .Where(e => e.Type == ServerEngine.Remote)) + { + if (!string.IsNullOrWhiteSpace(config.Address) && + Uri.TryCreate(config.Address, UriKind.Absolute, out _)) + { + _clients[config.Id] = CreateHttpClient(config); + } + } + + using var scope = serviceProvider.CreateScope(); + var serverService = scope.ServiceProvider.GetRequiredService(); + + var servers = await serverService.GetAsync(s => s.Engine == ServerEngine.Remote); + + foreach (var server in servers) + { + if (server.RemoteHostId.HasValue && server.RemoteServerId.HasValue && + _clients.ContainsKey(server.RemoteHostId.Value)) + { + _tracked[server.Id] = new RemoteServerInfo(server.RemoteHostId.Value, server.RemoteServerId.Value); + } + } + + _pollTimer = new Timer(PollStatus, null, TimeSpan.Zero, TimeSpan.FromSeconds(10)); + } + + public bool IsManaging(Guid serverId) => _tracked.ContainsKey(serverId); + + public async Task StartAsync(Guid serverId) + { + if (!_tracked.ContainsKey(serverId)) + throw new Exception("Server is not being tracked by this engine."); + + var info = _tracked[serverId]; + await EnsureTokenValidAsync(info.HostId); + + var client = _clients[info.HostId]; + await client.PostAsync($"api/Server/{info.RemoteServerId}/Start", null); + } + + public async Task StopAsync(Guid serverId) + { + if (!_tracked.ContainsKey(serverId)) + throw new Exception("Server is not being tracked by this engine."); + + var info = _tracked[serverId]; + await EnsureTokenValidAsync(info.HostId); + + var client = _clients[info.HostId]; + await client.PostAsync($"api/Server/{info.RemoteServerId}/Stop", null); + } + + public async Task GetStatusAsync(Guid serverId) + { + if (!_tracked.ContainsKey(serverId)) + return ServerProcessStatus.Stopped; + + var info = _tracked[serverId]; + + try + { + await EnsureTokenValidAsync(info.HostId); + + var client = _clients[info.HostId]; + var response = await client.GetAsync($"api/Server/{info.RemoteServerId}/Status"); + + if (!response.IsSuccessStatusCode) + return ServerProcessStatus.Stopped; + + var status = await response.Content.ReadFromJsonAsync(); + return status; + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Could not retrieve status for remote server {ServerId}", serverId); + return ServerProcessStatus.Stopped; + } + } + + public async Task> GetRemoteServersAsync(Guid hostId) + { + if (!_clients.TryGetValue(hostId, out var client)) + return []; + + try + { + await EnsureTokenValidAsync(hostId); + + var servers = await client.GetFromJsonAsync>("api/Server/"); + + return servers ?? []; + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Could not retrieve servers from remote host {HostId}", hostId); + return []; + } + } + + public async Task AuthenticateAsync(Guid hostId, string username, string password) + { + var config = settingsProvider.CurrentValue.Server.GameServers.ServerEngines + .FirstOrDefault(e => e.Id == hostId); + + if (config == null) + return "Remote host configuration not found."; + + if (!Uri.TryCreate(config.Address, UriKind.Absolute, out _)) + return "Invalid address configured for remote host."; + + try + { + var tempClient = CreateHttpClient(config, includeAuth: false); + var loginModel = new { Username = username, Password = password }; + + var response = await tempClient.PostAsJsonAsync("api/Auth/Login", loginModel); + + if (!response.IsSuccessStatusCode) + return $"Authentication failed: {response.StatusCode}"; + + var token = await response.Content.ReadFromJsonAsync(); + + if (token == null) + return "Authentication failed: empty response."; + + settingsProvider.Update(s => + { + var c = s.Server.GameServers.ServerEngines.FirstOrDefault(e => e.Id == hostId); + + if (c == null) + return; + + c.AccessToken = token.AccessToken; + c.RefreshToken = token.RefreshToken; + c.TokenExpiration = token.Expiration; + }); + + _clients[hostId] = CreateHttpClient(settingsProvider.CurrentValue.Server.GameServers.ServerEngines + .FirstOrDefault(e => e.Id == hostId) ?? config); + + return string.Empty; + } + catch (Exception ex) + { + logger?.LogError(ex, "Error authenticating with remote host {HostId}", hostId); + return ex.Message; + } + } + + private async Task EnsureTokenValidAsync(Guid hostId) + { + var config = settingsProvider.CurrentValue.Server.GameServers.ServerEngines + .FirstOrDefault(e => e.Id == hostId); + + if (config == null) + return; + + if (config.TokenExpiration > DateTime.UtcNow.AddMinutes(5)) + return; + + if (string.IsNullOrWhiteSpace(config.RefreshToken)) + return; + + try + { + var tempClient = CreateHttpClient(config, includeAuth: false); + var refreshPayload = new AuthToken + { + AccessToken = config.AccessToken, + RefreshToken = config.RefreshToken, + Expiration = config.TokenExpiration + }; + + var response = await tempClient.PostAsJsonAsync("api/Auth/Refresh", refreshPayload); + + if (!response.IsSuccessStatusCode) + return; + + var token = await response.Content.ReadFromJsonAsync(); + + if (token == null) + return; + + settingsProvider.Update(s => + { + var c = s.Server.GameServers.ServerEngines.FirstOrDefault(e => e.Id == hostId); + if (c != null) + { + c.AccessToken = token.AccessToken; + c.RefreshToken = token.RefreshToken; + c.TokenExpiration = token.Expiration; + } + }); + + _clients[hostId] = CreateHttpClient(settingsProvider.CurrentValue.Server.GameServers.ServerEngines + .FirstOrDefault(e => e.Id == hostId) ?? config); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Could not refresh token for remote host {HostId}", hostId); + } + } + + private HttpClient CreateHttpClient(ServerEngineConfiguration config, bool includeAuth = true) + { + var client = new HttpClient(); + client.BaseAddress = new Uri(config.Address.TrimEnd('/') + "/"); + + if (includeAuth && !string.IsNullOrWhiteSpace(config.AccessToken)) + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", config.AccessToken); + + return client; + } + + private async void PollStatus(object? state) + { + foreach (var (serverId, info) in _tracked.ToList()) + { + try + { + var newStatus = await GetStatusAsync(serverId); + + if (!_status.TryGetValue(serverId, out var oldStatus) || oldStatus != newStatus) + { + _status[serverId] = newStatus; + + using var scope = serviceProvider.CreateScope(); + var serverService = scope.ServiceProvider.GetRequiredService(); + var server = await serverService.GetAsync(serverId); + + if (server != null) + OnServerStatusUpdate?.Invoke(this, new ServerStatusUpdateEventArgs(server, newStatus)); + } + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Error polling status for remote server {ServerId}", serverId); + } + } + } +} diff --git a/LANCommander.Server.Settings/Enums/ServerEngine.cs b/LANCommander.Server.Settings/Enums/ServerEngine.cs index 3e64ea0b..511a514a 100644 --- a/LANCommander.Server.Settings/Enums/ServerEngine.cs +++ b/LANCommander.Server.Settings/Enums/ServerEngine.cs @@ -4,4 +4,5 @@ public enum ServerEngine { Local, Docker, + Remote = 2, } \ No newline at end of file diff --git a/LANCommander.Server.Settings/Models/ServerEngineConfiguration.cs b/LANCommander.Server.Settings/Models/ServerEngineConfiguration.cs index 2b475837..627bbec2 100644 --- a/LANCommander.Server.Settings/Models/ServerEngineConfiguration.cs +++ b/LANCommander.Server.Settings/Models/ServerEngineConfiguration.cs @@ -8,6 +8,9 @@ public class ServerEngineConfiguration public string Name { get; set; } = "Local"; public ServerEngine Type { get; set; } = ServerEngine.Local; public string Address { get; set; } = String.Empty; + public string AccessToken { get; set; } = String.Empty; + public string RefreshToken { get; set; } = String.Empty; + public DateTime TokenExpiration { get; set; } public ServerEngineConfiguration() { diff --git a/LANCommander.Server/Controllers/ServerController.cs b/LANCommander.Server/Controllers/ServerController.cs index 73f9366b..cc1b7bad 100644 --- a/LANCommander.Server/Controllers/ServerController.cs +++ b/LANCommander.Server/Controllers/ServerController.cs @@ -2,6 +2,7 @@ using LANCommander.SDK.Helpers; using LANCommander.Server.Services; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using System.IO.Compression; namespace LANCommander.Server.Controllers @@ -10,15 +11,18 @@ namespace LANCommander.Server.Controllers { private readonly ServerService ServerService; private readonly IMapper Mapper; + private readonly IOptions _settings; public ServerController( ILogger logger, SettingsProvider settingsProvider, IMapper mapper, - ServerService serverService) : base(logger, settingsProvider) + ServerService serverService, + IOptions settings) : base(logger, settingsProvider) { ServerService = serverService; Mapper = mapper; + _settings = settings; } [HttpGet("/Server/{id:guid}/{*path}")] @@ -31,6 +35,17 @@ namespace LANCommander.Server.Controllers if (server == null) return NotFound(); + if (server.Engine == Settings.Enums.ServerEngine.Remote) + { + var config = _settings.Value.Server.GameServers.ServerEngines + .FirstOrDefault(e => e.Id == server.RemoteHostId); + + if (config == null) + return NotFound(); + + return Redirect($"{config.Address.TrimEnd('/')}/Server/{server.RemoteServerId}/{path}"); + } + if (server.HttpPaths == null || server.HttpPaths.Count == 0) return NotFound(); diff --git a/LANCommander.Server/UI/Pages/Servers/Edit/General.razor b/LANCommander.Server/UI/Pages/Servers/Edit/General.razor index 6f1e87e5..1e6b6ec2 100644 --- a/LANCommander.Server/UI/Pages/Servers/Edit/General.razor +++ b/LANCommander.Server/UI/Pages/Servers/Edit/General.razor @@ -13,6 +13,7 @@ @inject ServerService ServerService @inject GameService GameService @inject DockerServerEngine DockerServerEngine +@inject RemoteServerEngine RemoteServerEngine @inject IOptions Settings @inject NavigationManager NavigationManager @inject IMessageService MessageService @@ -83,7 +84,7 @@ @bind-Value="context.DockerHostId" OnSelectedItemChanged="ChangeDockerHost"> - + } - - - - - - - - - - + + @if (context.Engine == ServerEngine.Remote) + { + + + + } + + @if (context.Engine != ServerEngine.Remote) + { + + + + + + + + + + } - - - Use Shell Execute - - - - - - - - - - + + } @@ -134,6 +164,7 @@ IEnumerable Games = new List(); IEnumerable Containers = new List(); + IEnumerable RemoteServers = new List(); Guid GameId; @@ -172,6 +203,11 @@ { Containers = await DockerServerEngine.GetContainersAsync(hostConfiguration.Id); } + + async Task ChangeRemoteHost(ServerEngineConfiguration hostConfiguration) + { + RemoteServers = await RemoteServerEngine.GetRemoteServersAsync(hostConfiguration.Id); + } async Task Save(Server server) { diff --git a/LANCommander.Server/UI/Pages/Settings/Components/ServerEngineEditor.razor b/LANCommander.Server/UI/Pages/Settings/Components/ServerEngineEditor.razor index 0e0df0c5..3491f832 100644 --- a/LANCommander.Server/UI/Pages/Settings/Components/ServerEngineEditor.razor +++ b/LANCommander.Server/UI/Pages/Settings/Components/ServerEngineEditor.razor @@ -1,12 +1,14 @@ +@using LANCommander.Server.Services.ServerEngines @using LANCommander.Server.Settings.Enums @using LANCommander.Server.Settings.Models +@inject RemoteServerEngine RemoteServerEngine @if (!ServerEngines.Any()) { - + } - + @foreach (var serverEngine in ServerEngines) { @@ -23,9 +25,9 @@ - + } - + @if (serverEngine.Type == ServerEngine.Docker) { @@ -37,7 +39,7 @@ - + @@ -45,11 +47,61 @@ } + + @if (serverEngine.Type == ServerEngine.Remote) + { + + + + + + + + } } - - + + + + @@ -59,24 +111,76 @@ List ServerEngines = new(); + Dictionary _remoteUsernames = new(); + Dictionary _remotePasswords = new(); + Dictionary _remoteErrors = new(); + Dictionary _remoteConnecting = new(); + protected override void OnParametersSet() { ServerEngines = Values.OrderBy(e => e.Type).ThenBy(e => e.Name).ToList(); + + foreach (var engine in ServerEngines.Where(e => e.Type == ServerEngine.Remote)) + { + _remoteUsernames.TryAdd(engine.Id, string.Empty); + _remotePasswords.TryAdd(engine.Id, string.Empty); + _remoteErrors.TryAdd(engine.Id, string.Empty); + _remoteConnecting.TryAdd(engine.Id, false); + } } - - async Task Add() + + async Task Add(ServerEngine type) { - ServerEngines.Add(new ServerEngineConfiguration()); + var newEngine = new ServerEngineConfiguration + { + Type = type, + Name = type.ToString() + }; + + ServerEngines.Add(newEngine); + + if (type == ServerEngine.Remote) + { + _remoteUsernames[newEngine.Id] = string.Empty; + _remotePasswords[newEngine.Id] = string.Empty; + _remoteErrors[newEngine.Id] = string.Empty; + _remoteConnecting[newEngine.Id] = false; + } if (ValuesChanged.HasDelegate) await ValuesChanged.InvokeAsync(ServerEngines); } - + async Task Remove(ServerEngineConfiguration serverEngine) { ServerEngines.Remove(serverEngine); + _remoteUsernames.Remove(serverEngine.Id); + _remotePasswords.Remove(serverEngine.Id); + _remoteErrors.Remove(serverEngine.Id); + _remoteConnecting.Remove(serverEngine.Id); + if (ValuesChanged.HasDelegate) await ValuesChanged.InvokeAsync(ServerEngines); } -} \ No newline at end of file + + async Task Connect(ServerEngineConfiguration serverEngine) + { + _remoteConnecting[serverEngine.Id] = true; + _remoteErrors[serverEngine.Id] = string.Empty; + + await InvokeAsync(StateHasChanged); + + var username = _remoteUsernames.GetValueOrDefault(serverEngine.Id, string.Empty); + var password = _remotePasswords.GetValueOrDefault(serverEngine.Id, string.Empty); + + var error = await RemoteServerEngine.AuthenticateAsync(serverEngine.Id, username, password); + + if (!string.IsNullOrWhiteSpace(error)) + _remoteErrors[serverEngine.Id] = error; + + _remoteConnecting[serverEngine.Id] = false; + + await InvokeAsync(StateHasChanged); + } +}