From 23a33ba4898416e7e3791ea274227f55864dfc9b Mon Sep 17 00:00:00 2001 From: RaidMax Date: Sun, 17 Jan 2021 21:58:18 -0600 Subject: [PATCH] implement more robust command api and login improve web console command response reliability and consistency --- Application/ApplicationManager.cs | 26 +++++ Application/IW4MServer.cs | 3 +- SharedLibraryCore/Commands/RunAsCommand.cs | 29 +++-- SharedLibraryCore/Events/GameEvent.cs | 5 +- SharedLibraryCore/Interfaces/IManager.cs | 2 + SharedLibraryCore/PartialEntities/EFClient.cs | 8 +- SharedLibraryCore/Server.cs | 19 ---- .../Controllers/API/ClientController.cs | 100 ++++++++++++++++-- .../Controllers/API/Models/CommandRequest.cs | 7 ++ WebfrontCore/Controllers/API/Server.cs | 93 ++++++++++++++++ WebfrontCore/Controllers/ConsoleController.cs | 23 ++-- WebfrontCore/Startup.cs | 1 + 12 files changed, 260 insertions(+), 56 deletions(-) create mode 100644 WebfrontCore/Controllers/API/Models/CommandRequest.cs create mode 100644 WebfrontCore/Controllers/API/Server.cs diff --git a/Application/ApplicationManager.cs b/Application/ApplicationManager.cs index 19d65af28..e16ec0469 100644 --- a/Application/ApplicationManager.cs +++ b/Application/ApplicationManager.cs @@ -72,6 +72,7 @@ namespace IW4MAdmin.Application private readonly IServiceProvider _serviceProvider; private readonly ChangeHistoryService _changeHistoryService; private readonly ApplicationConfiguration _appConfig; + public ConcurrentDictionary ProcessingEvents { get; } = new ConcurrentDictionary(); public ApplicationManager(ILogger logger, IMiddlewareActionHandler actionHandler, IEnumerable commands, ITranslationLookup translationLookup, IConfigurationHandler commandConfiguration, @@ -115,6 +116,8 @@ namespace IW4MAdmin.Application public async Task ExecuteEvent(GameEvent newEvent) { + ProcessingEvents.TryAdd(newEvent.Id, newEvent); + // the event has failed already if (newEvent.Failed) { @@ -175,6 +178,29 @@ namespace IW4MAdmin.Application } skip: + if (newEvent.Type == EventType.Command && newEvent.ImpersonationOrigin == null) + { + var correlatedEvents = + ProcessingEvents.Values.Where(ev => + ev.CorrelationId == newEvent.CorrelationId && ev.Id != newEvent.Id) + .ToList(); + + await Task.WhenAll(correlatedEvents.Select(ev => + ev.WaitAsync(Utilities.DefaultCommandTimeout, CancellationToken))); + newEvent.Output.AddRange(correlatedEvents.SelectMany(ev => ev.Output)); + + foreach (var correlatedEvent in correlatedEvents) + { + ProcessingEvents.Remove(correlatedEvent.Id, out _); + } + } + + // we don't want to remove events that are correlated to command + if (ProcessingEvents.Values.Count(gameEvent => gameEvent.CorrelationId == newEvent.CorrelationId) == 1) + { + ProcessingEvents.Remove(newEvent.Id, out _); + } + // tell anyone waiting for the output that we're done newEvent.Complete(); OnGameEventExecuted?.Invoke(this, newEvent); diff --git a/Application/IW4MServer.cs b/Application/IW4MServer.cs index 17c9bd80f..5398b481c 100644 --- a/Application/IW4MServer.cs +++ b/Application/IW4MServer.cs @@ -154,7 +154,8 @@ namespace IW4MAdmin catch (CommandException e) { - ServerLogger.LogWarning(e, "Error validating command from event {@event}", E); + ServerLogger.LogWarning(e, "Error validating command from event {@event}", + new { E.Type, E.Data, E.Message, E.Subtype, E.IsRemote, E.CorrelationId }); E.FailReason = GameEvent.EventFailReason.Invalid; } diff --git a/SharedLibraryCore/Commands/RunAsCommand.cs b/SharedLibraryCore/Commands/RunAsCommand.cs index c20a8d9d4..01e9c2dd8 100644 --- a/SharedLibraryCore/Commands/RunAsCommand.cs +++ b/SharedLibraryCore/Commands/RunAsCommand.cs @@ -25,40 +25,39 @@ namespace SharedLibraryCore.Commands }; } - public override async Task ExecuteAsync(GameEvent E) + public override async Task ExecuteAsync(GameEvent gameEvent) { - if (E.IsTargetingSelf()) + if (gameEvent.IsTargetingSelf()) { - E.Origin.Tell(_translationLookup["COMMANDS_RUN_AS_SELF"]); + gameEvent.Origin.Tell(_translationLookup["COMMANDS_RUN_AS_SELF"]); return; } - if (!E.CanPerformActionOnTarget()) + if (!gameEvent.CanPerformActionOnTarget()) { - E.Origin.Tell(_translationLookup["COMMANDS_RUN_AS_FAIL_PERM"]); + gameEvent.Origin.Tell(_translationLookup["COMMANDS_RUN_AS_FAIL_PERM"]); return; } - string cmd = $"{Utilities.CommandPrefix}{E.Data}"; + var cmd = $"{Utilities.CommandPrefix}{gameEvent.Data}"; var impersonatedCommandEvent = new GameEvent() { Type = GameEvent.EventType.Command, - Origin = E.Target, - ImpersonationOrigin = E.Origin, + Origin = gameEvent.Target, + ImpersonationOrigin = gameEvent.Origin, Message = cmd, Data = cmd, - Owner = E.Owner + Owner = gameEvent.Owner, + CorrelationId = gameEvent.CorrelationId }; - E.Owner.Manager.AddEvent(impersonatedCommandEvent); + gameEvent.Owner.Manager.AddEvent(impersonatedCommandEvent); - var result = await impersonatedCommandEvent.WaitAsync(Utilities.DefaultCommandTimeout, E.Owner.Manager.CancellationToken); - var response = E.Owner.CommandResult.Where(c => c.ClientId == E.Target.ClientId).ToList(); + var result = await impersonatedCommandEvent.WaitAsync(Utilities.DefaultCommandTimeout, gameEvent.Owner.Manager.CancellationToken); // remove the added command response - for (int i = 0; i < response.Count; i++) + foreach (var output in result.Output) { - E.Origin.Tell(_translationLookup["COMMANDS_RUN_AS_SUCCESS"].FormatExt(response[i].Response)); - E.Owner.CommandResult.Remove(response[i]); + gameEvent.Origin.Tell(_translationLookup["COMMANDS_RUN_AS_SUCCESS"].FormatExt(output)); } } } diff --git a/SharedLibraryCore/Events/GameEvent.cs b/SharedLibraryCore/Events/GameEvent.cs index 88db30350..d814ea9b8 100644 --- a/SharedLibraryCore/Events/GameEvent.cs +++ b/SharedLibraryCore/Events/GameEvent.cs @@ -1,6 +1,7 @@ using SharedLibraryCore.Database.Models; using SharedLibraryCore.Events; using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -247,6 +248,8 @@ namespace SharedLibraryCore public long Id { get; private set; } public EventFailReason FailReason { get; set; } public bool Failed => FailReason != EventFailReason.None; + public Guid CorrelationId { get; set; } = Guid.NewGuid(); + public List Output { get; set; } = new List(); /// /// Indicates if the event should block until it is complete @@ -280,7 +283,7 @@ namespace SharedLibraryCore { using(LogContext.PushProperty("Server", Owner?.ToString())) { - Utilities.DefaultLogger.LogError("Waiting for event to complete timed out {@eventData}", new { Event = this, Message, Origin = Origin.ToString(), Target = Target.ToString()}); + Utilities.DefaultLogger.LogError("Waiting for event to complete timed out {@eventData}", new { Event = this, Message, Origin = Origin?.ToString(), Target = Target?.ToString()}); } } diff --git a/SharedLibraryCore/Interfaces/IManager.cs b/SharedLibraryCore/Interfaces/IManager.cs index 6ba8f6c23..40b8f3e03 100644 --- a/SharedLibraryCore/Interfaces/IManager.cs +++ b/SharedLibraryCore/Interfaces/IManager.cs @@ -6,6 +6,7 @@ using SharedLibraryCore.Database.Models; using System.Threading; using System.Collections; using System; +using System.Collections.Concurrent; using Microsoft.Extensions.Logging; namespace SharedLibraryCore.Interfaces @@ -86,5 +87,6 @@ namespace SharedLibraryCore.Interfaces /// event executed when event has finished executing /// event EventHandler OnGameEventExecuted; + ConcurrentDictionary ProcessingEvents { get; } } } diff --git a/SharedLibraryCore/PartialEntities/EFClient.cs b/SharedLibraryCore/PartialEntities/EFClient.cs index 957462dd3..25b73f59d 100644 --- a/SharedLibraryCore/PartialEntities/EFClient.cs +++ b/SharedLibraryCore/PartialEntities/EFClient.cs @@ -133,7 +133,7 @@ namespace SharedLibraryCore.Database.Models /// send a message directly to the connected client /// /// message content to send to client - public GameEvent Tell(String message) + public GameEvent Tell(string message) { var e = new GameEvent() { @@ -141,8 +141,12 @@ namespace SharedLibraryCore.Database.Models Target = this, Owner = CurrentServer, Type = GameEvent.EventType.Tell, - Data = message + Data = message, + CorrelationId = CurrentServer.Manager.ProcessingEvents.Values + .FirstOrDefault(ev => ev.Type == GameEvent.EventType.Command && (ev.Origin?.ClientId == ClientId || ev.ImpersonationOrigin?.ClientId == ClientId))?.CorrelationId ?? Guid.NewGuid() }; + + e.Output.Add(message.StripColors()); CurrentServer?.Manager.AddEvent(e); return e; diff --git a/SharedLibraryCore/Server.cs b/SharedLibraryCore/Server.cs index 84f16916e..5449d8285 100644 --- a/SharedLibraryCore/Server.cs +++ b/SharedLibraryCore/Server.cs @@ -172,22 +172,6 @@ namespace SharedLibraryCore Console.WriteLine(message.StripColors()); Console.ForegroundColor = ConsoleColor.Gray; } - - // prevent this from queueing up too many command responses - if (CommandResult.Count > 15) - { - CommandResult.RemoveAt(0); - } - - // it was a remote command so we need to add it to the command result queue - if (target.ClientNumber < 0) - { - CommandResult.Add(new CommandResponseInfo() - { - Response = message.StripColors(), - ClientId = target.ClientId - }); - } } /// @@ -347,8 +331,5 @@ namespace SharedLibraryCore // only here for performance private readonly bool CustomSayEnabled; private readonly string CustomSayName; - - //Remote - public IList CommandResult = new List(); } } diff --git a/WebfrontCore/Controllers/API/ClientController.cs b/WebfrontCore/Controllers/API/ClientController.cs index 9f149635f..90bb610b0 100644 --- a/WebfrontCore/Controllers/API/ClientController.cs +++ b/WebfrontCore/Controllers/API/ClientController.cs @@ -3,9 +3,15 @@ using Microsoft.AspNetCore.Mvc; using SharedLibraryCore.Dtos; using SharedLibraryCore.Interfaces; using System; +using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Security.Claims; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Logging; +using SharedLibraryCore; +using SharedLibraryCore.Services; using WebfrontCore.Controllers.API.Dtos; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -15,29 +21,34 @@ namespace WebfrontCore.Controllers.API /// api controller for client operations /// [ApiController] - [Route("api/client")] - public class ClientController : ControllerBase + [Route("api/[controller]")] + public class ClientController : BaseController { private readonly IResourceQueryHelper _clientQueryHelper; private readonly ILogger _logger; + private readonly ClientService _clientService; - public ClientController(ILogger logger, IResourceQueryHelper clientQueryHelper) + public ClientController(ILogger logger, + IResourceQueryHelper clientQueryHelper, + ClientService clientService, IManager manager) : base(manager) { _logger = logger; _clientQueryHelper = clientQueryHelper; + _clientService = clientService; } [HttpGet("find")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task FindAsync([FromQuery]FindClientRequest request) + public async Task FindAsync([FromQuery] FindClientRequest request) { if (!ModelState.IsValid) { return BadRequest(new ErrorResponse() { - Messages = ModelState.Values.SelectMany(_value => _value.Errors.Select(_error => _error.ErrorMessage)).ToArray() + Messages = ModelState.Values + .SelectMany(_value => _value.Errors.Select(_error => _error.ErrorMessage)).ToArray() }); } @@ -58,9 +69,84 @@ namespace WebfrontCore.Controllers.API return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse() { - Messages = new[] { e.Message } + Messages = new[] {e.Message} }); } } + + + [HttpPost("{clientId:int}/login")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task LoginAsync([FromRoute] int clientId, + [FromBody, Required] PasswordRequest request) + { + if (clientId == 0) + { + return Unauthorized(); + } + + HttpContext.Request.Cookies.TryGetValue(".AspNetCore.Cookies", out var cookie); + + if (Authorized) + { + return Ok(); + } + + try + { + var privilegedClient = await _clientService.GetClientForLogin(clientId); + var loginSuccess = false; + + if (!Authorized) + { + loginSuccess = + Manager.TokenAuthenticator.AuthorizeToken(privilegedClient.NetworkId, request.Password) || + (await Task.FromResult(SharedLibraryCore.Helpers.Hashing.Hash(request.Password, + privilegedClient.PasswordSalt)))[0] == privilegedClient.Password; + } + + if (loginSuccess) + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, privilegedClient.Name), + new Claim(ClaimTypes.Role, privilegedClient.Level.ToString()), + new Claim(ClaimTypes.Sid, privilegedClient.ClientId.ToString()), + new Claim(ClaimTypes.PrimarySid, privilegedClient.NetworkId.ToString("X")) + }; + + var claimsIdentity = new ClaimsIdentity(claims, "login"); + var claimsPrinciple = new ClaimsPrincipal(claimsIdentity); + await SignInAsync(claimsPrinciple); + + return Ok(); + } + } + + catch (Exception) + { + return Unauthorized(); + } + + return Unauthorized(); + } + + [HttpPost("{clientId:int}/logout")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task LogoutAsync() + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + + return Ok(); + } + + public class PasswordRequest + { + public string Password { get; set; } + } } -} +} \ No newline at end of file diff --git a/WebfrontCore/Controllers/API/Models/CommandRequest.cs b/WebfrontCore/Controllers/API/Models/CommandRequest.cs new file mode 100644 index 000000000..64859a6ad --- /dev/null +++ b/WebfrontCore/Controllers/API/Models/CommandRequest.cs @@ -0,0 +1,7 @@ +namespace WebfrontCore.Controllers.API.Models +{ + public class CommandRequest + { + public string Command { get; set; } + } +} \ No newline at end of file diff --git a/WebfrontCore/Controllers/API/Server.cs b/WebfrontCore/Controllers/API/Server.cs new file mode 100644 index 000000000..409032887 --- /dev/null +++ b/WebfrontCore/Controllers/API/Server.cs @@ -0,0 +1,93 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using SharedLibraryCore; +using SharedLibraryCore.Interfaces; +using WebfrontCore.Controllers.API.Models; + +namespace WebfrontCore.Controllers.API +{ + [ApiController] + [Route("api/[controller]")] + public class Server : BaseController + { + + public Server(IManager manager) : base(manager) + { + } + + [HttpGet] + public IActionResult Index() + { + return new JsonResult(Manager.GetServers().Select(server => new + { + Id = server.EndPoint, + server.Hostname, + server.IP, + server.Port + })); + } + + [HttpGet("{id}")] + public IActionResult GetServerById(string id) + { + var foundServer = Manager.GetServers().FirstOrDefault(server => server.EndPoint == long.Parse(id)); + + if (foundServer == null) + { + return new NotFoundResult(); + } + + return new JsonResult(new + { + Id = foundServer.EndPoint, + foundServer.Hostname, + foundServer.IP, + foundServer.Port + }); + } + + [HttpPost("{id}/execute")] + public async Task ExecuteCommandForServer(string id, [FromBody] CommandRequest commandRequest) + { + if (!Authorized) + { + return Unauthorized(); + } + + var foundServer = Manager.GetServers().FirstOrDefault(server => server.EndPoint == long.Parse(id)); + + if (foundServer == null) + { + return new BadRequestObjectResult($"No server with id '{id}' was found"); + } + + if (string.IsNullOrEmpty(commandRequest.Command)) + { + return new BadRequestObjectResult("Command cannot be empty"); + } + + var start = DateTime.Now; + Client.CurrentServer = foundServer; + + var commandEvent = new GameEvent() + { + Type = GameEvent.EventType.Command, + Owner = foundServer, + Origin = Client, + Data = commandRequest.Command, + Extra = commandRequest.Command + }; + + Manager.AddEvent(commandEvent); + var completedEvent = await commandEvent.WaitAsync(Utilities.DefaultCommandTimeout, foundServer.Manager.CancellationToken); + + return new JsonResult(new + { + ExecutionTimeMs = Math.Round((DateTime.Now - start).TotalMilliseconds, 0), + completedEvent.Output + }); + } + } +} \ No newline at end of file diff --git a/WebfrontCore/Controllers/ConsoleController.cs b/WebfrontCore/Controllers/ConsoleController.cs index 203652084..5d2bddad4 100644 --- a/WebfrontCore/Controllers/ConsoleController.cs +++ b/WebfrontCore/Controllers/ConsoleController.cs @@ -52,8 +52,10 @@ namespace WebfrontCore.Controllers var remoteEvent = new GameEvent() { Type = GameEvent.EventType.Command, - Data = command.StartsWith(_appconfig.CommandPrefix) || command.StartsWith(_appconfig.BroadcastCommandPrefix) ? - command : $"{_appconfig.CommandPrefix}{command}", + Data = command.StartsWith(_appconfig.CommandPrefix) || + command.StartsWith(_appconfig.BroadcastCommandPrefix) + ? command + : $"{_appconfig.CommandPrefix}{command}", Origin = client, Owner = server, IsRemote = true @@ -65,7 +67,8 @@ namespace WebfrontCore.Controllers try { // wait for the event to process - var completedEvent = await remoteEvent.WaitAsync(Utilities.DefaultCommandTimeout, server.Manager.CancellationToken); + var completedEvent = + await remoteEvent.WaitAsync(Utilities.DefaultCommandTimeout, server.Manager.CancellationToken); if (completedEvent.FailReason == GameEvent.EventFailReason.Timeout) { @@ -81,13 +84,11 @@ namespace WebfrontCore.Controllers else { - response = response = server.CommandResult.Where(c => c.ClientId == client.ClientId).ToArray(); - } - - // remove the added command response - for (int i = 0; i < response?.Length; i++) - { - server.CommandResult.Remove(response[i]); + response = completedEvent.Output.Select(output => new CommandResponseInfo() + { + Response = output, + ClientId = client.ClientId + }).ToArray(); } } @@ -106,4 +107,4 @@ namespace WebfrontCore.Controllers return View("_Response", response); } } -} +} \ No newline at end of file diff --git a/WebfrontCore/Startup.cs b/WebfrontCore/Startup.cs index f4986473d..1ec1205de 100644 --- a/WebfrontCore/Startup.cs +++ b/WebfrontCore/Startup.cs @@ -115,6 +115,7 @@ namespace WebfrontCore services.AddSingleton(Program.ApplicationServiceProvider.GetService>()); services.AddSingleton(Program.ApplicationServiceProvider.GetService()); services.AddSingleton(Program.ApplicationServiceProvider.GetService()); + services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService()); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.