From f41ce39180074cad3dfea7036c35f87f84200705 Mon Sep 17 00:00:00 2001 From: RaidMax Date: Wed, 5 Apr 2023 09:54:57 -0500 Subject: [PATCH] implement new eventing system --- Application/Application.csproj | 1 - Application/ApplicationManager.cs | 373 ++++++++++++------ Application/CoreEventHandler.cs | 145 +++++++ Application/IW4MServer.cs | 257 ++++++++---- Application/Main.cs | 107 +++-- Application/Misc/RemoteCommandService.cs | 12 +- Application/Misc/ServerDataCollector.cs | 29 +- Application/Misc/ServerDataViewer.cs | 2 +- Application/Plugin/Script/ScriptPluginV2.cs | 2 + .../QueryHelpers/ClientResourceQueryHelper.cs | 6 +- .../LiveRadar/Controllers/RadarController.cs | 2 +- Plugins/Mute/Plugin.cs | 4 +- Plugins/ScriptPlugins/GameInterface.js | 6 +- SharedLibraryCore/Events/CoreEvent.cs | 12 + SharedLibraryCore/Events/EventAPI.cs | 88 ----- SharedLibraryCore/Events/EventExtensions.cs | 44 +++ SharedLibraryCore/Events/Game/GameEventV2.cs | 2 +- SharedLibraryCore/Events/GameEvent.cs | 38 +- .../ClientPersistentIdReceiveEvent.cs | 14 + .../Events/IGameEventSubscriptions.cs | 120 ++++++ .../Events/IGameServerEventSubscriptions.cs | 102 +++++ .../Events/IManagementEventSubscriptions.cs | 130 ++++++ .../Interfaces/ICoreEventHandler.cs | 19 + SharedLibraryCore/Interfaces/IGameServer.cs | 64 +++ SharedLibraryCore/Interfaces/IManager.cs | 8 +- .../Interfaces/IModularAssembly.cs | 11 + SharedLibraryCore/Interfaces/IRConParser.cs | 6 +- SharedLibraryCore/PartialEntities/EFClient.cs | 14 +- SharedLibraryCore/Server.cs | 48 +-- SharedLibraryCore/SharedLibraryCore.csproj | 8 +- SharedLibraryCore/Utilities.cs | 123 ++++-- .../Controllers/API/ClientController.cs | 21 + WebfrontCore/Controllers/API/Server.cs | 2 +- WebfrontCore/Controllers/AboutController.cs | 2 +- WebfrontCore/Controllers/AccountController.cs | 21 + .../Controllers/Client/ClientController.cs | 1 - .../Client/ClientStatisticsController.cs | 5 +- WebfrontCore/Program.cs | 36 +- WebfrontCore/Startup.cs | 51 +-- 39 files changed, 1410 insertions(+), 526 deletions(-) create mode 100644 Application/CoreEventHandler.cs create mode 100644 SharedLibraryCore/Events/CoreEvent.cs delete mode 100644 SharedLibraryCore/Events/EventAPI.cs create mode 100644 SharedLibraryCore/Events/EventExtensions.cs create mode 100644 SharedLibraryCore/Events/Management/ClientPersistentIdReceiveEvent.cs create mode 100644 SharedLibraryCore/Interfaces/Events/IGameEventSubscriptions.cs create mode 100644 SharedLibraryCore/Interfaces/Events/IGameServerEventSubscriptions.cs create mode 100644 SharedLibraryCore/Interfaces/Events/IManagementEventSubscriptions.cs create mode 100644 SharedLibraryCore/Interfaces/ICoreEventHandler.cs create mode 100644 SharedLibraryCore/Interfaces/IModularAssembly.cs diff --git a/Application/Application.csproj b/Application/Application.csproj index 30ff32291..78ebd5fb8 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -21,7 +21,6 @@ IW4MAdmin.Application false - enable diff --git a/Application/ApplicationManager.cs b/Application/ApplicationManager.cs index 165ad1f12..6c5e33c0b 100644 --- a/Application/ApplicationManager.cs +++ b/Application/ApplicationManager.cs @@ -16,6 +16,7 @@ using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; using System.Linq; using System.Reflection; using System.Text; @@ -23,12 +24,18 @@ using System.Threading; using System.Threading.Tasks; using Data.Abstractions; using Data.Context; +using Data.Models; +using IW4MAdmin.Application.Configuration; using IW4MAdmin.Application.Migration; using IW4MAdmin.Application.Plugin.Script; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Serilog.Context; +using SharedLibraryCore.Events; +using SharedLibraryCore.Events.Management; +using SharedLibraryCore.Events.Server; using SharedLibraryCore.Formatting; +using SharedLibraryCore.Interfaces.Events; using static SharedLibraryCore.GameEvent; using ILogger = Microsoft.Extensions.Logging.ILogger; using ObsoleteLogger = SharedLibraryCore.Interfaces.ILogger; @@ -50,7 +57,7 @@ namespace IW4MAdmin.Application public IList> CommandInterceptors { get; set; } = new List>(); public ITokenAuthentication TokenAuthenticator { get; } - public CancellationToken CancellationToken => _tokenSource.Token; + public CancellationToken CancellationToken => _isRunningTokenSource.Token; public string ExternalIPAddress { get; private set; } public bool IsRestartRequested { get; private set; } public IMiddlewareActionHandler MiddlewareActionHandler { get; } @@ -64,29 +71,30 @@ namespace IW4MAdmin.Application public IConfigurationHandler ConfigHandler; readonly IPageList PageList; private readonly TimeSpan _throttleTimeout = new TimeSpan(0, 1, 0); - private CancellationTokenSource _tokenSource; + private CancellationTokenSource _isRunningTokenSource; + private CancellationTokenSource _eventHandlerTokenSource; private readonly Dictionary> _operationLookup = new Dictionary>(); private readonly ITranslationLookup _translationLookup; private readonly IConfigurationHandler _commandConfiguration; private readonly IGameServerInstanceFactory _serverInstanceFactory; private readonly IParserRegexFactory _parserRegexFactory; private readonly IEnumerable _customParserEvents; - private readonly IEventHandler _eventHandler; + private readonly ICoreEventHandler _coreEventHandler; private readonly IScriptCommandFactory _scriptCommandFactory; private readonly IMetaRegistration _metaRegistration; private readonly IScriptPluginServiceResolver _scriptPluginServiceResolver; private readonly IServiceProvider _serviceProvider; private readonly ChangeHistoryService _changeHistoryService; private readonly ApplicationConfiguration _appConfig; - public ConcurrentDictionary ProcessingEvents { get; } = new ConcurrentDictionary(); + public ConcurrentDictionary ProcessingEvents { get; } = new(); public ApplicationManager(ILogger logger, IMiddlewareActionHandler actionHandler, IEnumerable commands, ITranslationLookup translationLookup, IConfigurationHandler commandConfiguration, IConfigurationHandler appConfigHandler, IGameServerInstanceFactory serverInstanceFactory, IEnumerable plugins, IParserRegexFactory parserRegexFactory, IEnumerable customParserEvents, - IEventHandler eventHandler, IScriptCommandFactory scriptCommandFactory, IDatabaseContextFactory contextFactory, + ICoreEventHandler coreEventHandler, IScriptCommandFactory scriptCommandFactory, IDatabaseContextFactory contextFactory, IMetaRegistration metaRegistration, IScriptPluginServiceResolver scriptPluginServiceResolver, ClientService clientService, IServiceProvider serviceProvider, - ChangeHistoryService changeHistoryService, ApplicationConfiguration appConfig, PenaltyService penaltyService, IAlertManager alertManager, IInteractionRegistration interactionRegistration) + ChangeHistoryService changeHistoryService, ApplicationConfiguration appConfig, PenaltyService penaltyService, IAlertManager alertManager, IInteractionRegistration interactionRegistration, IEnumerable v2PLugins) { MiddlewareActionHandler = actionHandler; _servers = new ConcurrentBag(); @@ -101,14 +109,14 @@ namespace IW4MAdmin.Application AdditionalRConParsers = new List { new BaseRConParser(serviceProvider.GetRequiredService>(), parserRegexFactory) }; TokenAuthenticator = new TokenAuthentication(); _logger = logger; - _tokenSource = new CancellationTokenSource(); + _isRunningTokenSource = new CancellationTokenSource(); _commands = commands.ToList(); _translationLookup = translationLookup; _commandConfiguration = commandConfiguration; _serverInstanceFactory = serverInstanceFactory; _parserRegexFactory = parserRegexFactory; _customParserEvents = customParserEvents; - _eventHandler = eventHandler; + _coreEventHandler = coreEventHandler; _scriptCommandFactory = scriptCommandFactory; _metaRegistration = metaRegistration; _scriptPluginServiceResolver = scriptPluginServiceResolver; @@ -117,6 +125,8 @@ namespace IW4MAdmin.Application _appConfig = appConfig; Plugins = plugins; InteractionRegistration = interactionRegistration; + + IManagementEventSubscriptions.ClientPersistentIdReceived += OnClientPersistentIdReceived; } public IEnumerable Plugins { get; } @@ -124,7 +134,7 @@ namespace IW4MAdmin.Application public async Task ExecuteEvent(GameEvent newEvent) { - ProcessingEvents.TryAdd(newEvent.Id, newEvent); + ProcessingEvents.TryAdd(newEvent.IncrementalId, newEvent); // the event has failed already if (newEvent.Failed) @@ -142,12 +152,12 @@ namespace IW4MAdmin.Application catch (TaskCanceledException) { - _logger.LogDebug("Received quit signal for event id {eventId}, so we are aborting early", newEvent.Id); + _logger.LogDebug("Received quit signal for event id {EventId}, so we are aborting early", newEvent.IncrementalId); } catch (OperationCanceledException) { - _logger.LogDebug("Received quit signal for event id {eventId}, so we are aborting early", newEvent.Id); + _logger.LogDebug("Received quit signal for event id {EventId}, so we are aborting early", newEvent.IncrementalId); } // this happens if a plugin requires login @@ -186,11 +196,11 @@ namespace IW4MAdmin.Application } skip: - if (newEvent.Type == EventType.Command && newEvent.ImpersonationOrigin == null) + if (newEvent.Type == EventType.Command && newEvent.ImpersonationOrigin == null && newEvent.CorrelationId is not null) { var correlatedEvents = ProcessingEvents.Values.Where(ev => - ev.CorrelationId == newEvent.CorrelationId && ev.Id != newEvent.Id) + ev.CorrelationId == newEvent.CorrelationId && ev.IncrementalId != newEvent.IncrementalId) .ToList(); await Task.WhenAll(correlatedEvents.Select(ev => @@ -199,14 +209,16 @@ namespace IW4MAdmin.Application foreach (var correlatedEvent in correlatedEvents) { - ProcessingEvents.Remove(correlatedEvent.Id, out _); + ProcessingEvents.Remove(correlatedEvent.IncrementalId, out _); } } // we don't want to remove events that are correlated to command - if (ProcessingEvents.Values.ToList()?.Count(gameEvent => gameEvent.CorrelationId == newEvent.CorrelationId) == 1) + if (ProcessingEvents.Values.Count(gameEvent => + newEvent.CorrelationId is not null && newEvent.CorrelationId == gameEvent.CorrelationId) == 1 || + newEvent.CorrelationId is null) { - ProcessingEvents.Remove(newEvent.Id, out _); + ProcessingEvents.Remove(newEvent.IncrementalId, out _); } // tell anyone waiting for the output that we're done @@ -226,75 +238,58 @@ namespace IW4MAdmin.Application public IReadOnlyList Commands => _commands.ToImmutableList(); - public async Task UpdateServerStates() + private Task UpdateServerStates() { - // store the server hash code and task for it - var runningUpdateTasks = new Dictionary(); - var timeout = TimeSpan.FromSeconds(60); - - while (!_tokenSource.IsCancellationRequested) // main shutdown requested + var index = 0; + return Task.WhenAll(_servers.Select(server => { - // select the server ids that have completed the update task - var serverTasksToRemove = runningUpdateTasks - .Where(ut => ut.Value.task.IsCompleted) - .Select(ut => ut.Key) - .ToList(); - - // remove the update tasks as they have completed - foreach (var serverId in serverTasksToRemove.Where(serverId => runningUpdateTasks.ContainsKey(serverId))) - { - if (!runningUpdateTasks[serverId].tokenSource.Token.IsCancellationRequested) - { - runningUpdateTasks[serverId].tokenSource.Cancel(); - } - - runningUpdateTasks.Remove(serverId); - } - - // select the servers where the tasks have completed - var newTaskServers = Servers.Select(s => s.EndPoint).Except(runningUpdateTasks.Select(r => r.Key)).ToList(); - - foreach (var server in Servers.Where(s => newTaskServers.Contains(s.EndPoint))) - { - var firstTokenSource = new CancellationTokenSource(); - firstTokenSource.CancelAfter(timeout); - var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(firstTokenSource.Token, _tokenSource.Token); - runningUpdateTasks.Add(server.EndPoint, (ProcessUpdateHandler(server, linkedTokenSource.Token), linkedTokenSource, DateTime.Now)); - } - - try - { - await Task.Delay(ConfigHandler.Configuration().RConPollRate, _tokenSource.Token); - } - // if a cancellation is received, we want to return immediately after shutting down - catch - { - foreach (var server in Servers.Where(s => newTaskServers.Contains(s.EndPoint))) - { - await server.ProcessUpdatesAsync(_tokenSource.Token); - } - break; - } - } + var thisIndex = index; + Interlocked.Increment(ref index); + return ProcessUpdateHandler(server, thisIndex); + })); } - private async Task ProcessUpdateHandler(Server server, CancellationToken token) + private async Task ProcessUpdateHandler(Server server, int index) { - try + const int delayScalar = 50; // Task.Delay is inconsistent enough there's no reason to try to prevent collisions + var timeout = TimeSpan.FromMinutes(2); + + while (!_isRunningTokenSource.IsCancellationRequested) { - await server.ProcessUpdatesAsync(token); - } - catch (Exception ex) - { - using (LogContext.PushProperty("Server", server.ToString())) + try { - _logger.LogError(ex, "Failed to update status"); + var delayFactor = Math.Min(_appConfig.RConPollRate, delayScalar * index); + await Task.Delay(delayFactor, _isRunningTokenSource.Token); + + using var timeoutTokenSource = new CancellationTokenSource(); + timeoutTokenSource.CancelAfter(timeout); + using var linkedTokenSource = + CancellationTokenSource.CreateLinkedTokenSource(timeoutTokenSource.Token, + _isRunningTokenSource.Token); + await server.ProcessUpdatesAsync(linkedTokenSource.Token); + + await Task.Delay(Math.Max(1000, _appConfig.RConPollRate - delayFactor), + _isRunningTokenSource.Token); + } + catch (OperationCanceledException) + { + // ignored + } + catch (Exception ex) + { + using (LogContext.PushProperty("Server", server.Id)) + { + _logger.LogError(ex, "Failed to update status"); + } + } + finally + { + server.IsInitialized = true; } } - finally - { - server.IsInitialized = true; - } + + // run the final updates to clean up server + await server.ProcessUpdatesAsync(_isRunningTokenSource.Token); } public async Task Init() @@ -305,18 +300,24 @@ namespace IW4MAdmin.Application #region DATABASE _logger.LogInformation("Beginning database migration sync"); Console.WriteLine(_translationLookup["MANAGER_MIGRATION_START"]); - await ContextSeed.Seed(_serviceProvider.GetRequiredService(), _tokenSource.Token); - await DatabaseHousekeeping.RemoveOldRatings(_serviceProvider.GetRequiredService(), _tokenSource.Token); + await ContextSeed.Seed(_serviceProvider.GetRequiredService(), _isRunningTokenSource.Token); + await DatabaseHousekeeping.RemoveOldRatings(_serviceProvider.GetRequiredService(), _isRunningTokenSource.Token); _logger.LogInformation("Finished database migration sync"); Console.WriteLine(_translationLookup["MANAGER_MIGRATION_END"]); #endregion + + #region EVENTS + IGameServerEventSubscriptions.ServerValueRequested += OnServerValueRequested; + IGameServerEventSubscriptions.ServerValueSetRequested += OnServerValueSetRequested; + await IManagementEventSubscriptions.InvokeLoadAsync(this, CancellationToken); + # endregion #region PLUGINS foreach (var plugin in Plugins) { try { - if (plugin is ScriptPlugin scriptPlugin) + if (plugin is ScriptPlugin scriptPlugin && !plugin.IsParser) { await scriptPlugin.Initialize(this, _scriptCommandFactory, _scriptPluginServiceResolver, _serviceProvider.GetService>()); @@ -391,13 +392,11 @@ namespace IW4MAdmin.Application if (string.IsNullOrEmpty(_appConfig.Id)) { _appConfig.Id = Guid.NewGuid().ToString(); - await ConfigHandler.Save(); } if (string.IsNullOrEmpty(_appConfig.WebfrontBindUrl)) { _appConfig.WebfrontBindUrl = "http://0.0.0.0:1624"; - await ConfigHandler.Save(); } #pragma warning disable 618 @@ -442,8 +441,8 @@ namespace IW4MAdmin.Application serverConfig.ModifyParsers(); } - await ConfigHandler.Save(); } + await ConfigHandler.Save(); } if (_appConfig.Servers.Length == 0) @@ -468,7 +467,7 @@ namespace IW4MAdmin.Application #endregion #region COMMANDS - if (await ClientSvc.HasOwnerAsync(_tokenSource.Token)) + if (await ClientSvc.HasOwnerAsync(_isRunningTokenSource.Token)) { _commands.RemoveAll(_cmd => _cmd.GetType() == typeof(OwnerCommand)); } @@ -526,7 +525,7 @@ namespace IW4MAdmin.Application } } #endregion - + Console.WriteLine(_translationLookup["MANAGER_COMMUNICATION_INFO"]); await InitializeServers(); IsInitialized = true; @@ -543,26 +542,23 @@ namespace IW4MAdmin.Application try { // todo: this might not always be an IW4MServer - var ServerInstance = _serverInstanceFactory.CreateServer(Conf, this) as IW4MServer; - using (LogContext.PushProperty("Server", ServerInstance.ToString())) + var serverInstance = _serverInstanceFactory.CreateServer(Conf, this) as IW4MServer; + using (LogContext.PushProperty("Server", serverInstance!.ToString())) { _logger.LogInformation("Beginning server communication initialization"); - await ServerInstance.Initialize(); + await serverInstance.Initialize(); - _servers.Add(ServerInstance); - Console.WriteLine(Utilities.CurrentLocalization.LocalizationIndex["MANAGER_MONITORING_TEXT"].FormatExt(ServerInstance.Hostname.StripColors())); - _logger.LogInformation("Finishing initialization and now monitoring [{Server}]", ServerInstance.Hostname); + _servers.Add(serverInstance); + Console.WriteLine(Utilities.CurrentLocalization.LocalizationIndex["MANAGER_MONITORING_TEXT"].FormatExt(serverInstance.Hostname.StripColors())); + _logger.LogInformation("Finishing initialization and now monitoring [{Server}]", serverInstance.Hostname); } - // add the start event for this server - var e = new GameEvent() + QueueEvent(new MonitorStartEvent { - Type = EventType.Start, - Data = $"{ServerInstance.GameName} started", - Owner = ServerInstance - }; - - AddEvent(e); + Server = serverInstance, + Source = this + }); + successServers++; } @@ -593,11 +589,27 @@ namespace IW4MAdmin.Application } } - public async Task Start() => await UpdateServerStates(); + public async Task Start() + { + _eventHandlerTokenSource = new CancellationTokenSource(); + + var eventHandlerThread = new Thread(() => + { + _coreEventHandler.StartProcessing(_eventHandlerTokenSource.Token); + }) + { + Name = nameof(CoreEventHandler) + }; + + eventHandlerThread.Start(); + await UpdateServerStates(); + _eventHandlerTokenSource.Cancel(); + eventHandlerThread.Join(); + } public async Task Stop() { - foreach (var plugin in Plugins) + foreach (var plugin in Plugins.Where(plugin => !plugin.IsParser)) { try { @@ -607,19 +619,32 @@ namespace IW4MAdmin.Application { _logger.LogError(ex, "Could not cleanly unload plugin {PluginName}", plugin.Name); } - } - - _tokenSource.Cancel(); - + } + + _isRunningTokenSource.Cancel(); + IsRunning = false; } - public void Restart() + public async Task Restart() { IsRestartRequested = true; - Stop().GetAwaiter().GetResult(); - _tokenSource.Dispose(); - _tokenSource = new CancellationTokenSource(); + await Stop(); + + using var subscriptionTimeoutToken = new CancellationTokenSource(); + subscriptionTimeoutToken.CancelAfter(Utilities.DefaultCommandTimeout); + + await IManagementEventSubscriptions.InvokeUnloadAsync(this, subscriptionTimeoutToken.Token); + + IGameEventSubscriptions.ClearEventInvocations(); + IGameServerEventSubscriptions.ClearEventInvocations(); + IManagementEventSubscriptions.ClearEventInvocations(); + + _isRunningTokenSource.Dispose(); + _isRunningTokenSource = new CancellationTokenSource(); + + _eventHandlerTokenSource.Dispose(); + _eventHandlerTokenSource = new CancellationTokenSource(); } [Obsolete] @@ -661,9 +686,14 @@ namespace IW4MAdmin.Application public void AddEvent(GameEvent gameEvent) { - _eventHandler.HandleEvent(this, gameEvent); + _coreEventHandler.QueueEvent(this, gameEvent); } + public void QueueEvent(CoreEvent coreEvent) + { + _coreEventHandler.QueueEvent(this, coreEvent); + } + public IPageList GetPageList() { return PageList; @@ -698,15 +728,132 @@ namespace IW4MAdmin.Application public void AddAdditionalCommand(IManagerCommand command) { - if (_commands.Any(_command => _command.Name == command.Name || _command.Alias == command.Alias)) + lock (_commands) { - throw new InvalidOperationException($"Duplicate command name or alias ({command.Name}, {command.Alias})"); - } + if (_commands.Any(cmd => cmd.Name == command.Name || cmd.Alias == command.Alias)) + { + throw new InvalidOperationException( + $"Duplicate command name or alias ({command.Name}, {command.Alias})"); + } - _commands.Add(command); + _commands.Add(command); + } } public void RemoveCommandByName(string commandName) => _commands.RemoveAll(_command => _command.Name == commandName); public IAlertManager AlertManager => _alertManager; + + private async Task OnServerValueRequested(ServerValueRequestEvent requestEvent, CancellationToken token) + { + if (requestEvent.Server is not IW4MServer server) + { + return; + } + + Dvar serverValue = null; + try + { + if (requestEvent.DelayMs.HasValue) + { + await Task.Delay(requestEvent.DelayMs.Value, token); + } + + var waitToken = token; + using var timeoutTokenSource = new CancellationTokenSource(); + using var linkedTokenSource = + CancellationTokenSource.CreateLinkedTokenSource(timeoutTokenSource.Token, token); + + if (requestEvent.TimeoutMs is not null) + { + timeoutTokenSource.CancelAfter(requestEvent.TimeoutMs.Value); + waitToken = linkedTokenSource.Token; + } + + serverValue = + await server.GetDvarAsync(requestEvent.ValueName, requestEvent.FallbackValue, waitToken); + } + catch + { + // ignored + } + finally + { + QueueEvent(new ServerValueReceiveEvent + { + Server = server, + Source = server, + Response = serverValue ?? new Dvar { Name = requestEvent.ValueName }, + Success = serverValue is not null + }); + } + } + + private async Task OnServerValueSetRequested(ServerValueSetRequestEvent requestEvent, CancellationToken token) + { + if (requestEvent.Server is not IW4MServer server) + { + return; + } + + var completed = false; + try + { + if (requestEvent.DelayMs.HasValue) + { + await Task.Delay(requestEvent.DelayMs.Value, token); + } + + if (requestEvent.TimeoutMs is not null) + { + using var timeoutTokenSource = new CancellationTokenSource(requestEvent.TimeoutMs.Value); + using var linkedTokenSource = + CancellationTokenSource.CreateLinkedTokenSource(timeoutTokenSource.Token, token); + token = linkedTokenSource.Token; + } + + await server.SetDvarAsync(requestEvent.ValueName, requestEvent.Value, token); + completed = true; + } + catch + { + // ignored + } + finally + { + QueueEvent(new ServerValueSetCompleteEvent + { + Server = server, + Source = server, + Success = completed, + Value = requestEvent.Value, + ValueName = requestEvent.ValueName + }); + } + } + + private async Task OnClientPersistentIdReceived(ClientPersistentIdReceiveEvent receiveEvent, CancellationToken token) + { + var parts = receiveEvent.PersistentId.Split(","); + + if (parts.Length == 2 && int.TryParse(parts[0], out var high) && + int.TryParse(parts[1], out var low)) + { + var guid = long.Parse(high.ToString("X") + low.ToString("X"), NumberStyles.HexNumber); + + var penalties = await PenaltySvc + .GetActivePenaltiesByIdentifier(null, guid, receiveEvent.Client.GameName); + var banPenalty = + penalties.FirstOrDefault(penalty => penalty.Type == EFPenalty.PenaltyType.Ban); + + if (banPenalty is not null && receiveEvent.Client.Level != Data.Models.Client.EFClient.Permission.Banned) + { + _logger.LogInformation( + "Banning {Client} as they have have provided a persistent clientId of {PersistentClientId}, which is banned", + receiveEvent.Client, guid); + receiveEvent.Client.Ban(_translationLookup["SERVER_BAN_EVADE"].FormatExt(guid), + receiveEvent.Client.CurrentServer.AsConsoleClient(), true); + } + } + } } } diff --git a/Application/CoreEventHandler.cs b/Application/CoreEventHandler.cs new file mode 100644 index 000000000..37f135f88 --- /dev/null +++ b/Application/CoreEventHandler.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Concurrent; +using SharedLibraryCore; +using SharedLibraryCore.Events; +using SharedLibraryCore.Interfaces; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using SharedLibraryCore.Events.Management; +using SharedLibraryCore.Events.Server; +using SharedLibraryCore.Interfaces.Events; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace IW4MAdmin.Application +{ + public class CoreEventHandler : ICoreEventHandler + { + private const int MaxCurrentEvents = 25; + private readonly ILogger _logger; + private readonly SemaphoreSlim _onProcessingEvents = new(MaxCurrentEvents, MaxCurrentEvents); + private readonly ManualResetEventSlim _onEventReady = new(false); + private readonly ConcurrentQueue<(IManager, CoreEvent)> _runningEventTasks = new(); + private CancellationToken _cancellationToken; + private int _activeTasks; + + private static readonly GameEvent.EventType[] OverrideEvents = + { + GameEvent.EventType.Connect, + GameEvent.EventType.Disconnect, + GameEvent.EventType.Quit, + GameEvent.EventType.Stop + }; + + public CoreEventHandler(ILogger logger) + { + _logger = logger; + } + + public void QueueEvent(IManager manager, CoreEvent coreEvent) + { + _runningEventTasks.Enqueue((manager, coreEvent)); + _onEventReady.Set(); + } + + public void StartProcessing(CancellationToken token) + { + _cancellationToken = token; + + while (!_cancellationToken.IsCancellationRequested) + { + _onEventReady.Reset(); + + try + { + _onProcessingEvents.Wait(_cancellationToken); + + if (!_runningEventTasks.TryDequeue(out var coreEvent)) + { + if (_onProcessingEvents.CurrentCount < MaxCurrentEvents) + { + _onProcessingEvents.Release(1); + } + + _onEventReady.Wait(_cancellationToken); + continue; + } + + _logger.LogDebug("Start processing event {Name} {SemaphoreCount} - {QueuedTasks}", + coreEvent.Item2.GetType().Name, _onProcessingEvents.CurrentCount, _runningEventTasks.Count); + + _ = Task.Factory.StartNew(() => + { + Interlocked.Increment(ref _activeTasks); + _logger.LogDebug("[Start] Active Tasks = {TaskCount}", _activeTasks); + return HandleEventTaskExecute(coreEvent); + }); + } + catch (OperationCanceledException) + { + // ignored + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not enqueue event for processing"); + } + } + } + + private async Task HandleEventTaskExecute((IManager, CoreEvent) coreEvent) + { + try + { + await GetEventTask(coreEvent.Item1, coreEvent.Item2); + } + catch (OperationCanceledException) + { + _logger.LogWarning("Event timed out {Type}", coreEvent.Item2.GetType().Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not complete invoke for {EventType}", + coreEvent.Item2.GetType().Name); + } + finally + { + if (_onProcessingEvents.CurrentCount < MaxCurrentEvents) + { + _logger.LogDebug("Freeing up event semaphore for next event {SemaphoreCount}", + _onProcessingEvents.CurrentCount); + _onProcessingEvents.Release(1); + } + + Interlocked.Decrement(ref _activeTasks); + _logger.LogDebug("[Complete] {Type}, Active Tasks = {TaskCount} - {Queue}", coreEvent.Item2.GetType(), + _activeTasks, _runningEventTasks.Count); + } + } + + private Task GetEventTask(IManager manager, CoreEvent coreEvent) + { + return coreEvent switch + { + GameEvent gameEvent => BuildLegacyEventTask(manager, coreEvent, gameEvent), + GameServerEvent gameServerEvent => IGameServerEventSubscriptions.InvokeEventAsync(gameServerEvent, + manager.CancellationToken), + ManagementEvent managementEvent => IManagementEventSubscriptions.InvokeEventAsync(managementEvent, + manager.CancellationToken), + _ => Task.CompletedTask + }; + } + + private async Task BuildLegacyEventTask(IManager manager, CoreEvent coreEvent, GameEvent gameEvent) + { + if (manager.IsRunning || OverrideEvents.Contains(gameEvent.Type)) + { + await manager.ExecuteEvent(gameEvent); + await IGameEventSubscriptions.InvokeEventAsync(coreEvent, manager.CancellationToken); + return; + } + + _logger.LogDebug("Skipping event as we're shutting down {EventId}", gameEvent.IncrementalId); + } + } +} diff --git a/Application/IW4MServer.cs b/Application/IW4MServer.cs index 9b3fb5c16..dc0a5c7ce 100644 --- a/Application/IW4MServer.cs +++ b/Application/IW4MServer.cs @@ -9,7 +9,7 @@ using SharedLibraryCore.Helpers; using SharedLibraryCore.Interfaces; using System; using System.Collections.Generic; -using System.Globalization; +using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Net; @@ -31,6 +31,9 @@ using IW4MAdmin.Application.Plugin.Script; using IW4MAdmin.Plugins.Stats.Helpers; using Microsoft.EntityFrameworkCore; using SharedLibraryCore.Alerts; +using SharedLibraryCore.Events.Management; +using SharedLibraryCore.Events.Server; +using SharedLibraryCore.Interfaces.Events; using static Data.Models.Client.EFClient; namespace IW4MAdmin @@ -44,11 +47,12 @@ namespace IW4MAdmin private const int REPORT_FLAG_COUNT = 4; private long lastGameTime = 0; - public int Id { get; private set; } private readonly IServiceProvider _serviceProvider; private readonly IClientNoticeMessageFormatter _messageFormatter; private readonly ILookupCache _serverCache; private readonly CommandConfiguration _commandConfiguration; + private EFServer _cachedDatabaseServer; + private readonly StatManager _statManager; public IW4MServer( ServerConfiguration serverConfiguration, @@ -72,6 +76,18 @@ namespace IW4MAdmin _messageFormatter = messageFormatter; _serverCache = serverCache; _commandConfiguration = commandConfiguration; + _statManager = serviceProvider.GetRequiredService(); + + IGameServerEventSubscriptions.MonitoringStarted += async (gameEvent, token) => + { + if (gameEvent.Server.Id != Id) + { + return; + } + + await EnsureServerAdded(); + await _statManager.EnsureServerAdded(gameEvent.Server, token); + }; } public override async Task OnClientConnected(EFClient clientFromLog) @@ -108,7 +124,7 @@ namespace IW4MAdmin Clients[client.ClientNumber] = client; ServerLogger.LogDebug("End PreConnect for {client}", client.ToString()); - var e = new GameEvent() + var e = new GameEvent { Origin = client, Owner = this, @@ -116,6 +132,11 @@ namespace IW4MAdmin }; Manager.AddEvent(e); + Manager.QueueEvent(new ClientStateInitializeEvent + { + Client = client, + Source = this, + }); return client; } @@ -210,10 +231,17 @@ namespace IW4MAdmin ServerLogger.LogInformation("Executing command {Command} for {Client}", cmd.Name, E.Origin.ToString()); await cmd.ExecuteAsync(E); + Manager.QueueEvent(new ClientExecuteCommandEvent + { + Command = cmd, + Client = E.Origin, + Source = this, + CommandText = E.Data + }); } - var pluginTasks = Manager.Plugins - .Select(async plugin => await CreatePluginTask(plugin, E)); + var pluginTasks = Manager.Plugins.Where(plugin => !plugin.IsParser) + .Select(plugin => CreatePluginTask(plugin, E)); await Task.WhenAll(pluginTasks); } @@ -250,7 +278,7 @@ namespace IW4MAdmin try { - await plugin.OnEventAsync(gameEvent, this).WithWaitCancellation(tokenSource.Token); + await plugin.OnEventAsync(gameEvent, this); } catch (OperationCanceledException) { @@ -277,29 +305,7 @@ namespace IW4MAdmin { ServerLogger.LogDebug("processing event of type {type}", E.Type); - if (E.Type == GameEvent.EventType.Start) - { - var existingServer = (await _serverCache - .FirstAsync(server => server.Id == EndPoint)); - - var serverId = await GetIdForServer(E.Owner); - - if (existingServer == null) - { - var server = new EFServer() - { - Port = Port, - EndPoint = ToString(), - ServerId = serverId, - GameName = (Reference.Game?)GameName, - HostName = Hostname - }; - - await _serverCache.AddAsync(server); - } - } - - else if (E.Type == GameEvent.EventType.ConnectionLost) + if (E.Type == GameEvent.EventType.ConnectionLost) { var exception = E.Extra as Exception; ServerLogger.LogError(exception, @@ -350,9 +356,18 @@ namespace IW4MAdmin else if (E.Type == GameEvent.EventType.ChangePermission) { var newPermission = (Permission) E.Extra; + var oldPermission = E.Target.Level; ServerLogger.LogInformation("{origin} is setting {target} to permission level {newPermission}", E.Origin.ToString(), E.Target.ToString(), newPermission); await Manager.GetClientService().UpdateLevel(newPermission, E.Target, E.Origin); + + Manager.QueueEvent(new ClientPermissionChangeEvent + { + Client = E.Origin, + Source = this, + OldPermission = oldPermission, + NewPermission = newPermission + }); } else if (E.Type == GameEvent.EventType.Connect) @@ -500,6 +515,12 @@ namespace IW4MAdmin await Manager.GetPenaltyService().Create(newPenalty); E.Target.SetLevel(Permission.Flagged, E.Origin); + + Manager.QueueEvent(new ClientPenaltyEvent + { + Client = E.Target, + Penalty = newPenalty + }); } else if (E.Type == GameEvent.EventType.Unflag) @@ -519,6 +540,12 @@ namespace IW4MAdmin await Manager.GetPenaltyService().RemoveActivePenalties(E.Target.AliasLinkId, E.Target.NetworkId, E.Target.GameName, E.Target.CurrentAlias?.IPAddress); await Manager.GetPenaltyService().Create(unflagPenalty); + + Manager.QueueEvent(new ClientPenaltyRevokeEvent + { + Client = E.Target, + Penalty = unflagPenalty + }); } else if (E.Type == GameEvent.EventType.Report) @@ -554,6 +581,13 @@ namespace IW4MAdmin Utilities.CurrentLocalization.LocalizationIndex["SERVER_AUTO_FLAG_REPORT"] .FormatExt(reportNum), Utilities.IW4MAdminClient(E.Owner)); } + + Manager.QueueEvent(new ClientPenaltyEvent + { + Client = E.Target, + Penalty = newReport, + Source = this + }); } else if (E.Type == GameEvent.EventType.TempBan) @@ -728,6 +762,11 @@ namespace IW4MAdmin { MaxClients = int.Parse(dict["com_maxclients"]); } + + else if (dict.ContainsKey("com_maxplayers")) + { + MaxClients = int.Parse(dict["com_maxplayers"]); + } if (dict.ContainsKey("mapname")) { @@ -772,34 +811,6 @@ namespace IW4MAdmin { E.Origin.UpdateTeam(E.Extra as string); } - - else if (E.Type == GameEvent.EventType.MetaUpdated) - { - if (E.Extra is "PersistentClientGuid") - { - var parts = E.Data.Split(","); - - if (parts.Length == 2 && int.TryParse(parts[0], out var high) && - int.TryParse(parts[1], out var low)) - { - var guid = long.Parse(high.ToString("X") + low.ToString("X"), NumberStyles.HexNumber); - - var penalties = await Manager.GetPenaltyService() - .GetActivePenaltiesByIdentifier(null, guid, (Reference.Game)GameName); - var banPenalty = - penalties.FirstOrDefault(penalty => penalty.Type == EFPenalty.PenaltyType.Ban); - - if (banPenalty is not null && E.Origin.Level != Permission.Banned) - { - ServerLogger.LogInformation( - "Banning {Client} as they have have provided a persistent clientId of {PersistentClientId}, which is banned", - E.Origin.ToString(), guid); - E.Origin.Ban(loc["SERVER_BAN_EVADE"].FormatExt(guid), - Utilities.IW4MAdminClient(this), true); - } - } - } - } lock (ChatHistory) { @@ -820,6 +831,53 @@ namespace IW4MAdmin } } + public async Task EnsureServerAdded() + { + var gameServer = await _serverCache + .FirstAsync(server => server.EndPoint == base.Id); + + if (gameServer == null) + { + gameServer = new EFServer + { + Port = ListenPort, + EndPoint = base.Id, + ServerId = BuildLegacyDatabaseId(), + GameName = (Reference.Game?)GameName, + HostName = ServerName + }; + + await _serverCache.AddAsync(gameServer); + } + + await using var context = _serviceProvider.GetRequiredService() + .CreateContext(enableTracking: false); + + context.Servers.Attach(gameServer); + + // we want to set the gamename up if it's never been set, or it changed + if (!gameServer.GameName.HasValue || gameServer.GameName.Value != GameCode) + { + gameServer.GameName = GameCode; + context.Entry(gameServer).Property(property => property.GameName).IsModified = true; + } + + if (gameServer.HostName == null || gameServer.HostName != ServerName) + { + gameServer.HostName = ServerName; + context.Entry(gameServer).Property(property => property.HostName).IsModified = true; + } + + if (gameServer.IsPasswordProtected != !string.IsNullOrEmpty(GamePassword)) + { + gameServer.IsPasswordProtected = !string.IsNullOrEmpty(GamePassword); + context.Entry(gameServer).Property(property => property.IsPasswordProtected).IsModified = true; + } + + await context.SaveChangesAsync(); + _cachedDatabaseServer = gameServer; + } + private async Task OnClientUpdate(EFClient origin) { var client = GetClientsAsList().FirstOrDefault(c => c.NetworkId == origin.NetworkId); @@ -909,22 +967,15 @@ namespace IW4MAdmin public override async Task GetIdForServer(Server server = null) { server ??= this; - - if ($"{server.IP}:{server.Port.ToString()}" == "66.150.121.184:28965") - { - return 886229536; - } - // todo: this is not stable and will need to be migrated again... - long id = HashCode.Combine(server.IP, server.Port); - id = id < 0 ? Math.Abs(id) : id; + return (await _serverCache.FirstAsync(cachedServer => + cachedServer.EndPoint == server.Id || cachedServer.ServerId == server.EndPoint)).ServerId; + } - var serverId = (await _serverCache - .FirstAsync(_server => _server.ServerId == server.EndPoint || - _server.EndPoint == server.ToString() || - _server.ServerId == id))?.ServerId; - - return !serverId.HasValue ? id : serverId.Value; + private long BuildLegacyDatabaseId() + { + long id = HashCode.Combine(ListenAddress, ListenPort); + return id < 0 ? Math.Abs(id) : id; } private void UpdateMap(string mapname) @@ -983,7 +1034,7 @@ namespace IW4MAdmin { await client.OnDisconnect(); - var e = new GameEvent() + var e = new GameEvent { Type = GameEvent.EventType.Disconnect, Owner = this, @@ -994,6 +1045,14 @@ namespace IW4MAdmin await e.WaitAsync(Utilities.DefaultCommandTimeout, new CancellationTokenRegistration().Token); } + + using var tokenSource = new CancellationTokenSource(); + tokenSource.CancelAfter(Utilities.DefaultCommandTimeout); + + Manager.QueueEvent(new MonitorStopEvent + { + Server = this + }); } private DateTime _lastMessageSent = DateTime.Now; @@ -1075,6 +1134,16 @@ namespace IW4MAdmin Manager.AddEvent(gameEvent); } + if (polledClients[2].Any()) + { + Manager.QueueEvent(new ClientDataUpdateEvent + { + Clients = new ReadOnlyCollection(polledClients[2]), + Server = this, + Source = this, + }); + } + if (Throttled) { var gameEvent = new GameEvent @@ -1086,6 +1155,12 @@ namespace IW4MAdmin }; Manager.AddEvent(gameEvent); + + Manager.QueueEvent(new ConnectionRestoreEvent + { + Server = this, + Source = this + }); } LastPoll = DateTime.Now; @@ -1109,6 +1184,12 @@ namespace IW4MAdmin }; Manager.AddEvent(gameEvent); + Manager.QueueEvent(new ConnectionInterruptEvent + { + Server = this, + Source = this + }); + return true; } finally @@ -1469,6 +1550,12 @@ namespace IW4MAdmin .FormatExt(activeClient.Warnings, activeClient.Name, reason); activeClient.CurrentServer.Broadcast(message); } + + Manager.QueueEvent(new ClientPenaltyEvent + { + Client = targetClient, + Penalty = newPenalty + }); } public override async Task Kick(string reason, EFClient targetClient, EFClient originClient, EFPenalty previousPenalty) @@ -1507,6 +1594,12 @@ namespace IW4MAdmin ServerLogger.LogDebug("Executing tempban kick command for {ActiveClient}", activeClient.ToString()); await activeClient.CurrentServer.ExecuteCommandAsync(formattedKick); } + + Manager.QueueEvent(new ClientPenaltyEvent + { + Client = targetClient, + Penalty = newPenalty + }); } public override async Task TempBan(string reason, TimeSpan length, EFClient targetClient, EFClient originClient) @@ -1540,6 +1633,12 @@ namespace IW4MAdmin ServerLogger.LogDebug("Executing tempban kick command for {ActiveClient}", activeClient.ToString()); await activeClient.CurrentServer.ExecuteCommandAsync(formattedKick); } + + Manager.QueueEvent(new ClientPenaltyEvent + { + Client = targetClient, + Penalty = newPenalty + }); } public override async Task Ban(string reason, EFClient targetClient, EFClient originClient, bool isEvade = false) @@ -1575,6 +1674,12 @@ namespace IW4MAdmin _messageFormatter.BuildFormattedMessage(RconParser.Configuration, newPenalty)); await activeClient.CurrentServer.ExecuteCommandAsync(formattedString); } + + Manager.QueueEvent(new ClientPenaltyEvent + { + Client = targetClient, + Penalty = newPenalty + }); } public override async Task Unban(string reason, EFClient targetClient, EFClient originClient) @@ -1596,6 +1701,12 @@ namespace IW4MAdmin await Manager.GetPenaltyService().RemoveActivePenalties(targetClient.AliasLink.AliasLinkId, targetClient.NetworkId, targetClient.GameName, targetClient.CurrentAlias?.IPAddress); await Manager.GetPenaltyService().Create(unbanPenalty); + + Manager.QueueEvent(new ClientPenaltyRevokeEvent + { + Client = targetClient, + Penalty = unbanPenalty + }); } public override void InitializeTokens() @@ -1605,5 +1716,7 @@ namespace IW4MAdmin Manager.GetMessageTokens().Add(new MessageToken("NEXTMAP", (Server s) => SharedLibraryCore.Commands.NextMapCommand.GetNextMap(s, _translationLookup))); Manager.GetMessageTokens().Add(new MessageToken("ADMINS", (Server s) => Task.FromResult(ListAdminsCommand.OnlineAdmins(s, _translationLookup)))); } + + public override long LegacyDatabaseId => _cachedDatabaseServer.ServerId; } } diff --git a/Application/Main.cs b/Application/Main.cs index 6cf779ea5..e9a855171 100644 --- a/Application/Main.cs +++ b/Application/Main.cs @@ -51,7 +51,7 @@ namespace IW4MAdmin.Application public static BuildNumber Version { get; } = BuildNumber.Parse(Utilities.GetVersionAsString()); private static ApplicationManager _serverManager; private static Task _applicationTask; - private static ServiceProvider _serviceProvider; + private static IServiceProvider _serviceProvider; /// /// entrypoint of the application @@ -112,23 +112,24 @@ namespace IW4MAdmin.Application ConfigurationMigration.MoveConfigFolder10518(null); ConfigurationMigration.CheckDirectories(); ConfigurationMigration.RemoveObsoletePlugins20210322(); + logger.LogDebug("Configuring services..."); - var services = await ConfigureServices(args); - _serviceProvider = services.BuildServiceProvider(); - var versionChecker = _serviceProvider.GetRequiredService(); - _serverManager = (ApplicationManager) _serviceProvider.GetRequiredService(); - translationLookup = _serviceProvider.GetRequiredService(); - _applicationTask = RunApplicationTasksAsync(logger, services); - var tasks = new[] - { - versionChecker.CheckVersion(), - _applicationTask - }; + var configHandler = new BaseConfigurationHandler("IW4MAdminSettings"); + await configHandler.BuildAsync(); + _serviceProvider = WebfrontCore.Program.InitializeServices(ConfigureServices, + (configHandler.Configuration() ?? new ApplicationConfiguration()).WebfrontBindUrl); + + _serverManager = (ApplicationManager)_serviceProvider.GetRequiredService(); + translationLookup = _serviceProvider.GetRequiredService(); await _serverManager.Init(); - await Task.WhenAll(tasks); + _applicationTask = Task.WhenAll(RunApplicationTasksAsync(logger, _serviceProvider), + _serverManager.Start()); + + await _applicationTask; + logger.LogInformation("Shutdown completed successfully"); } catch (Exception e) @@ -178,21 +179,20 @@ namespace IW4MAdmin.Application { goto restart; } - - await _serviceProvider.DisposeAsync(); } /// /// runs the core application tasks /// /// - private static async Task RunApplicationTasksAsync(ILogger logger, IServiceCollection services) + private static Task RunApplicationTasksAsync(ILogger logger, IServiceProvider serviceProvider) { var webfrontTask = _serverManager.GetApplicationSettings().Configuration().EnableWebFront - ? WebfrontCore.Program.Init(_serverManager, _serviceProvider, services, _serverManager.CancellationToken) + ? WebfrontCore.Program.GetWebHostTask(_serverManager.CancellationToken) : Task.CompletedTask; - var collectionService = _serviceProvider.GetRequiredService(); + var collectionService = serviceProvider.GetRequiredService(); + var versionChecker = serviceProvider.GetRequiredService(); // we want to run this one on a manual thread instead of letting the thread pool handle it, // because we can't exit early from waiting on console input, and it prevents us from restarting @@ -203,18 +203,15 @@ namespace IW4MAdmin.Application var tasks = new[] { + versionChecker.CheckVersion(), webfrontTask, - _serverManager.Start(), - _serviceProvider.GetRequiredService() + serviceProvider.GetRequiredService() .RunUploadStatus(_serverManager.CancellationToken), collectionService.BeginCollectionAsync(cancellationToken: _serverManager.CancellationToken) }; logger.LogDebug("Starting webfront and input tasks"); - await Task.WhenAll(tasks); - - logger.LogInformation("Shutdown completed successfully"); - Console.WriteLine(Utilities.CurrentLocalization.LocalizationIndex["MANAGER_SHUTDOWN_SUCCESS"]); + return Task.WhenAll(tasks); } /// @@ -302,8 +299,21 @@ namespace IW4MAdmin.Application var (plugins, commands, configurations) = pluginImporter.DiscoverAssemblyPluginImplementations(); foreach (var pluginType in plugins) { - defaultLogger.LogDebug("Registered plugin type {Name}", pluginType.FullName); - serviceCollection.AddSingleton(typeof(IPlugin), pluginType); + var isV2 = pluginType.GetInterface(nameof(IPluginV2), false) != null; + + defaultLogger.LogDebug("Registering plugin type {Name}", pluginType.FullName); + + serviceCollection.AddSingleton(!isV2 ? typeof(IPlugin) : typeof(IPluginV2), pluginType); + + try + { + var registrationMethod = pluginType.GetMethod(nameof(IPluginV2.RegisterDependencies)); + registrationMethod?.Invoke(null, new object[] { serviceCollection }); + } + catch (Exception ex) + { + defaultLogger.LogError(ex, "Could not register plugin of type {Type}", pluginType.Name); + } } // register the plugin commands @@ -351,13 +361,11 @@ namespace IW4MAdmin.Application /// /// Configures the dependency injection services /// - private static async Task ConfigureServices(string[] args) + private static void ConfigureServices(IServiceCollection serviceCollection) { // todo: this is a quick fix AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); - // setup the static resources (config/master api/translations) - var serviceCollection = new ServiceCollection(); serviceCollection.AddConfiguration("IW4MAdminSettings") .AddConfiguration() .AddConfiguration() @@ -365,14 +373,10 @@ namespace IW4MAdmin.Application // for legacy purposes. update at some point var appConfigHandler = new BaseConfigurationHandler("IW4MAdminSettings"); - await appConfigHandler.BuildAsync(); - var defaultConfigHandler = new BaseConfigurationHandler("DefaultSettings"); - await defaultConfigHandler.BuildAsync(); + appConfigHandler.BuildAsync().GetAwaiter().GetResult(); var commandConfigHandler = new BaseConfigurationHandler("CommandConfiguration"); - await commandConfigHandler.BuildAsync(); - var statsCommandHandler = new BaseConfigurationHandler("StatsPluginSettings"); - await statsCommandHandler.BuildAsync(); - var defaultConfig = defaultConfigHandler.Configuration(); + commandConfigHandler.BuildAsync().GetAwaiter().GetResult(); + var appConfig = appConfigHandler.Configuration(); var masterUri = Utilities.IsDevelopment ? new Uri("http://127.0.0.1:8080") @@ -385,13 +389,6 @@ namespace IW4MAdmin.Application var masterRestClient = RestClient.For(httpClient); var translationLookup = Configure.Initialize(Utilities.DefaultLogger, masterRestClient, appConfig); - if (appConfig == null) - { - appConfig = (ApplicationConfiguration) new ApplicationConfiguration().Generate(); - appConfigHandler.Set(appConfig); - await appConfigHandler.Save(); - } - // register override level names foreach (var (key, value) in appConfig.OverridePermissionLevelNames) { @@ -402,17 +399,10 @@ namespace IW4MAdmin.Application } // build the dependency list - HandlePluginRegistration(appConfig, serviceCollection, masterRestClient); - serviceCollection .AddBaseLogger(appConfig) - .AddSingleton(defaultConfig) - .AddSingleton(serviceCollection) - .AddSingleton, BaseConfigurationHandler>() .AddSingleton((IConfigurationHandler) appConfigHandler) .AddSingleton>(commandConfigHandler) - .AddSingleton(appConfig) - .AddSingleton(statsCommandHandler.Configuration() ?? new StatsConfiguration()) .AddSingleton(serviceProvider => serviceProvider.GetRequiredService>() .Configuration() ?? new CommandConfiguration()) @@ -464,7 +454,9 @@ namespace IW4MAdmin.Application .AddSingleton() .AddSingleton(new GeoLocationService(Path.Join(".", "Resources", "GeoLite2-Country.mmdb"))) .AddSingleton() +#pragma warning disable CS0618 .AddTransient() +#pragma warning restore CS0618 .AddSingleton() .AddSingleton() .AddSingleton(new ConfigurationWatcher()) @@ -472,19 +464,10 @@ namespace IW4MAdmin.Application .AddSingleton() .AddSingleton(translationLookup) .AddDatabaseContextOptions(appConfig); - - if (args.Contains("serialevents")) - { - serviceCollection.AddSingleton(); - } - else - { - serviceCollection.AddSingleton(); - } - + + serviceCollection.AddSingleton(); serviceCollection.AddSource(); - - return serviceCollection; + HandlePluginRegistration(appConfig, serviceCollection, masterRestClient); } private static ILogger BuildDefaultLogger(ApplicationConfiguration appConfig) diff --git a/Application/Misc/RemoteCommandService.cs b/Application/Misc/RemoteCommandService.cs index ccd573d3f..343e66c8b 100644 --- a/Application/Misc/RemoteCommandService.cs +++ b/Application/Misc/RemoteCommandService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -27,7 +28,7 @@ public class RemoteCommandService : IRemoteCommandService public async Task> Execute(int originId, int? targetId, string command, IEnumerable arguments, Server server) { - var (success, result) = await ExecuteWithResult(originId, targetId, command, arguments, server); + var (_, result) = await ExecuteWithResult(originId, targetId, command, arguments, server); return result; } @@ -56,7 +57,8 @@ public class RemoteCommandService : IRemoteCommandService : $"{_appConfig.CommandPrefix}{command}", Origin = client, Owner = server, - IsRemote = true + IsRemote = true, + CorrelationId = Guid.NewGuid() }; server.Manager.AddEvent(remoteEvent); @@ -72,7 +74,7 @@ public class RemoteCommandService : IRemoteCommandService { response = new[] { - new CommandResponseInfo() + new CommandResponseInfo { ClientId = client.ClientId, Response = Utilities.CurrentLocalization.LocalizationIndex["SERVER_ERROR_COMMAND_TIMEOUT"] @@ -90,7 +92,7 @@ public class RemoteCommandService : IRemoteCommandService } } - catch (System.OperationCanceledException) + catch (OperationCanceledException) { response = new[] { diff --git a/Application/Misc/ServerDataCollector.cs b/Application/Misc/ServerDataCollector.cs index ebe118982..ef6e9b78a 100644 --- a/Application/Misc/ServerDataCollector.cs +++ b/Application/Misc/ServerDataCollector.cs @@ -12,8 +12,10 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SharedLibraryCore; using SharedLibraryCore.Configuration; +using SharedLibraryCore.Events.Management; using ILogger = Microsoft.Extensions.Logging.ILogger; using SharedLibraryCore.Interfaces; +using SharedLibraryCore.Interfaces.Events; namespace IW4MAdmin.Application.Misc { @@ -24,28 +26,20 @@ namespace IW4MAdmin.Application.Misc private readonly IManager _manager; private readonly IDatabaseContextFactory _contextFactory; private readonly ApplicationConfiguration _appConfig; - private readonly IEventPublisher _eventPublisher; private bool _inProgress; private TimeSpan _period; public ServerDataCollector(ILogger logger, ApplicationConfiguration appConfig, - IManager manager, IDatabaseContextFactory contextFactory, IEventPublisher eventPublisher) + IManager manager, IDatabaseContextFactory contextFactory) { _logger = logger; _appConfig = appConfig; _manager = manager; _contextFactory = contextFactory; - _eventPublisher = eventPublisher; - _eventPublisher.OnClientConnect += SaveConnectionInfo; - _eventPublisher.OnClientDisconnect += SaveConnectionInfo; - } - - ~ServerDataCollector() - { - _eventPublisher.OnClientConnect -= SaveConnectionInfo; - _eventPublisher.OnClientDisconnect -= SaveConnectionInfo; + IManagementEventSubscriptions.ClientStateAuthorized += SaveConnectionInfo; + IManagementEventSubscriptions.ClientStateDisposed += SaveConnectionInfo; } public async Task BeginCollectionAsync(TimeSpan? period = null, CancellationToken cancellationToken = default) @@ -131,18 +125,19 @@ namespace IW4MAdmin.Application.Misc await context.SaveChangesAsync(token); } - private void SaveConnectionInfo(object sender, GameEvent gameEvent) + private async Task SaveConnectionInfo(ClientStateEvent stateEvent, CancellationToken token) { - using var context = _contextFactory.CreateContext(enableTracking: false); + await using var context = _contextFactory.CreateContext(enableTracking: false); context.ConnectionHistory.Add(new EFClientConnectionHistory { - ClientId = gameEvent.Origin.ClientId, - ServerId = gameEvent.Owner.GetIdForServer().Result, - ConnectionType = gameEvent.Type == GameEvent.EventType.Connect + ClientId = stateEvent.Client.ClientId, + ServerId = await stateEvent.Client.CurrentServer.GetIdForServer(), + ConnectionType = stateEvent is ClientStateAuthorizeEvent ? Reference.ConnectionType.Connect : Reference.ConnectionType.Disconnect }); - context.SaveChanges(); + + await context.SaveChangesAsync(); } } } diff --git a/Application/Misc/ServerDataViewer.cs b/Application/Misc/ServerDataViewer.cs index 3a0bdabf8..e174f9ccf 100644 --- a/Application/Misc/ServerDataViewer.cs +++ b/Application/Misc/ServerDataViewer.cs @@ -176,7 +176,7 @@ namespace IW4MAdmin.Application.Misc .Where(rating => rating.Client.Level != EFClient.Permission.Banned) .Where(rating => rating.Ranking != null) .CountAsync(cancellationToken); - }, nameof(_rankedClientsCache), serverId is null ? null: new[] { (object)serverId }, _cacheTimeSpan); + }, nameof(_rankedClientsCache), serverId is null ? null: new[] { (object)serverId }, _cacheTimeSpan); try { diff --git a/Application/Plugin/Script/ScriptPluginV2.cs b/Application/Plugin/Script/ScriptPluginV2.cs index be797f68a..63968ea3d 100644 --- a/Application/Plugin/Script/ScriptPluginV2.cs +++ b/Application/Plugin/Script/ScriptPluginV2.cs @@ -151,10 +151,12 @@ public class ScriptPluginV2 : IPluginV2 } ScriptEngine.Execute(pluginScript); +#pragma warning disable CS8974 var initResult = ScriptEngine.Call("init", JsValue.FromObject(ScriptEngine, EventCallbackWrapper), JsValue.FromObject(ScriptEngine, _pluginServiceResolver), JsValue.FromObject(ScriptEngine, _scriptPluginConfigurationWrapper), JsValue.FromObject(ScriptEngine, new ScriptPluginHelper(manager, this))); +#pragma warning restore CS8974 if (initResult.IsNull() || initResult.IsUndefined()) { diff --git a/Application/QueryHelpers/ClientResourceQueryHelper.cs b/Application/QueryHelpers/ClientResourceQueryHelper.cs index 44df86d9c..acee9f01e 100644 --- a/Application/QueryHelpers/ClientResourceQueryHelper.cs +++ b/Application/QueryHelpers/ClientResourceQueryHelper.cs @@ -142,12 +142,12 @@ public class ClientResourceQueryHelper : IResourceQueryHelper, DateTime> SearchByAliasLocal(string? clientName, - string? ipAddress) + private static Func, DateTime> SearchByAliasLocal(string clientName, + string ipAddress) { return group => { - ClientResourceResponse? match = null; + ClientResourceResponse match = null; var lowercaseClientName = clientName?.ToLower(); if (!string.IsNullOrWhiteSpace(lowercaseClientName)) diff --git a/Plugins/LiveRadar/Controllers/RadarController.cs b/Plugins/LiveRadar/Controllers/RadarController.cs index b82805b7c..505322816 100644 --- a/Plugins/LiveRadar/Controllers/RadarController.cs +++ b/Plugins/LiveRadar/Controllers/RadarController.cs @@ -43,7 +43,7 @@ namespace IW4MAdmin.Plugins.LiveRadar.Web.Controllers [HttpGet] [Route("Radar/{serverId}/Map")] - public async Task Map(string serverId = null) + public IActionResult Map(string serverId = null) { var server = serverId == null ? _manager.GetServers().FirstOrDefault() diff --git a/Plugins/Mute/Plugin.cs b/Plugins/Mute/Plugin.cs index f2b0e2c54..242857da6 100644 --- a/Plugins/Mute/Plugin.cs +++ b/Plugins/Mute/Plugin.cs @@ -59,7 +59,9 @@ public class Plugin : IPluginV2 return true; } - var muteMeta = _muteManager.GetCurrentMuteState(gameEvent.Origin).GetAwaiter().GetResult(); + var muteMeta = Task.Run(() => _muteManager.GetCurrentMuteState(gameEvent.Origin), cancellationToken) + .GetAwaiter().GetResult(); + if (muteMeta.MuteState is not MuteState.Muted) { return true; diff --git a/Plugins/ScriptPlugins/GameInterface.js b/Plugins/ScriptPlugins/GameInterface.js index 44c7de553..79f49409b 100644 --- a/Plugins/ScriptPlugins/GameInterface.js +++ b/Plugins/ScriptPlugins/GameInterface.js @@ -196,7 +196,7 @@ const plugin = { let data = []; - const metaService = this.serviceResolver.ResolveService('IMetaServiceV2'); + const metaService = this.serviceResolver.resolveService('IMetaServiceV2'); if (event.subType === 'Meta') { const meta = (await metaService.getPersistentMeta(event.data, client.clientId, token)).result; @@ -237,8 +237,8 @@ const plugin = { this.logger.logDebug('ClientId={clientId}', clientId); - if (clientId == null) { - this.logger.logWarning('Could not find client slot {clientNumber} when processing {eventType}', event.clientNumber, event.eventType); + if (clientId == null || isNaN(clientId)) { + this.logger.logWarning('Could not find client slot {clientNumber} when processing {eventType}: {EventData}', event.clientNumber, event.eventType, event.data); this.sendEventMessage(server, false, 'SetClientDataCompleted', 'Meta', { ClientNumber: event.clientNumber }, undefined, { diff --git a/SharedLibraryCore/Events/CoreEvent.cs b/SharedLibraryCore/Events/CoreEvent.cs new file mode 100644 index 000000000..17d44b49e --- /dev/null +++ b/SharedLibraryCore/Events/CoreEvent.cs @@ -0,0 +1,12 @@ +using System; + +namespace SharedLibraryCore.Events; + +public abstract class CoreEvent +{ + public Guid Id { get; } = Guid.NewGuid(); + public Guid? CorrelationId { get; init; } + public object Source { get; init; } + public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow; + public DateTimeOffset? ProcessedAt { get; set; } +} diff --git a/SharedLibraryCore/Events/EventAPI.cs b/SharedLibraryCore/Events/EventAPI.cs deleted file mode 100644 index 136225a49..000000000 --- a/SharedLibraryCore/Events/EventAPI.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Collections.Concurrent; -using System.Collections.Generic; -using SharedLibraryCore.Dtos; - -namespace SharedLibraryCore.Events -{ - public class EventApi - { - private const int MaxEvents = 25; - private static readonly ConcurrentQueue RecentEvents = new ConcurrentQueue(); - - public static IEnumerable GetEvents(bool shouldConsume) - { - var eventList = RecentEvents.ToArray(); - - // clear queue if events should be consumed - if (shouldConsume) - { - RecentEvents.Clear(); - } - - return eventList; - } - - public static void OnGameEvent(GameEvent gameEvent) - { - var E = gameEvent; - // don't want to clog up the api with unknown events - if (E.Type == GameEvent.EventType.Unknown) - { - return; - } - - var apiEvent = new EventInfo - { - ExtraInfo = E.Extra?.ToString() ?? E.Data, - GameInfo = new EntityInfo - { - Name = E.Owner.GameName.ToString(), - Id = (int)E.Owner.GameName - }, - OwnerEntity = new EntityInfo - { - Name = E.Owner.Hostname, - Id = E.Owner.EndPoint - }, - OriginEntity = E.Origin == null - ? null - : new EntityInfo - { - Id = E.Origin.ClientId, - Name = E.Origin.Name - }, - TargetEntity = E.Target == null - ? null - : new EntityInfo - { - Id = E.Target.ClientId, - Name = E.Target.Name - }, - EventType = new EntityInfo - { - Id = (int)E.Type, - Name = E.Type.ToString() - }, - EventTime = E.Time - }; - - // add the new event to the list - AddNewEvent(apiEvent); - } - - /// - /// Adds event to the list and removes first added if reached max capacity - /// - /// EventInfo to add - private static void AddNewEvent(EventInfo info) - { - // remove the first added event - if (RecentEvents.Count >= MaxEvents) - { - RecentEvents.TryDequeue(out _); - } - - RecentEvents.Enqueue(info); - } - } -} \ No newline at end of file diff --git a/SharedLibraryCore/Events/EventExtensions.cs b/SharedLibraryCore/Events/EventExtensions.cs new file mode 100644 index 000000000..2d7813b2a --- /dev/null +++ b/SharedLibraryCore/Events/EventExtensions.cs @@ -0,0 +1,44 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace SharedLibraryCore.Events; + +public static class EventExtensions +{ + public static Task InvokeAsync(this Func function, + TEventType eventArgType, CancellationToken token) + { + if (function is null) + { + return Task.CompletedTask; + } + + return Task.WhenAll(function.GetInvocationList().Cast>() + .Select(x => RunHandler(x, eventArgType, token))); + } + + private static async Task RunHandler(Func handler, + TEventType eventArgType, CancellationToken token) + { + if (token == CancellationToken.None) + { + // special case to allow tasks like request after delay to run longer + await handler(eventArgType, token); + } + + using var timeoutToken = new CancellationTokenSource(Utilities.DefaultCommandTimeout); + using var tokenSource = + CancellationTokenSource.CreateLinkedTokenSource(token, timeoutToken.Token); + + try + { + await handler(eventArgType, tokenSource.Token); + } + catch (Exception) + { + // ignored + } + } +} diff --git a/SharedLibraryCore/Events/Game/GameEventV2.cs b/SharedLibraryCore/Events/Game/GameEventV2.cs index eb48e9aec..c27e31318 100644 --- a/SharedLibraryCore/Events/Game/GameEventV2.cs +++ b/SharedLibraryCore/Events/Game/GameEventV2.cs @@ -4,5 +4,5 @@ namespace SharedLibraryCore.Events.Game; public abstract class GameEventV2 : GameEvent { - public IGameServer Server { get; init; } + public IGameServer Server => Owner; } diff --git a/SharedLibraryCore/Events/GameEvent.cs b/SharedLibraryCore/Events/GameEvent.cs index dacf84565..c1a1f606e 100644 --- a/SharedLibraryCore/Events/GameEvent.cs +++ b/SharedLibraryCore/Events/GameEvent.cs @@ -5,10 +5,11 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Serilog.Context; using SharedLibraryCore.Database.Models; +using SharedLibraryCore.Events; namespace SharedLibraryCore { - public class GameEvent + public class GameEvent : CoreEvent { public enum EventFailReason { @@ -133,6 +134,8 @@ namespace SharedLibraryCore /// connection was restored to a server (the server began responding again) /// ConnectionRestored, + + SayTeam = 99, // events "generated" by clients /// @@ -246,7 +249,7 @@ namespace SharedLibraryCore /// team info printed out by game script /// JoinTeam = 304, - + /// /// used for community generated plugin events /// @@ -267,7 +270,7 @@ namespace SharedLibraryCore public GameEvent() { Time = DateTime.UtcNow; - Id = GetNextEventId(); + IncrementalId = GetNextEventId(); } ~GameEvent() @@ -275,8 +278,6 @@ namespace SharedLibraryCore _eventFinishedWaiter.Dispose(); } - public EventSource Source { get; set; } - /// /// suptype of the event for more detailed classification /// @@ -293,11 +294,10 @@ namespace SharedLibraryCore public bool IsRemote { get; set; } public object Extra { get; set; } public DateTime Time { get; set; } - public long Id { get; } + public long IncrementalId { get; } 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(); + public List Output { get; set; } = new(); /// /// Indicates if the event should block until it is complete @@ -328,23 +328,31 @@ namespace SharedLibraryCore /// waitable task public async Task WaitAsync(TimeSpan timeSpan, CancellationToken token) { - var processed = false; - Utilities.DefaultLogger.LogDebug("Begin wait for event {Id}", Id); - try + if (FailReason == EventFailReason.Timeout) { - processed = await _eventFinishedWaiter.WaitAsync(timeSpan, token); + return this; } - catch (TaskCanceledException) + var processed = false; + Utilities.DefaultLogger.LogDebug("Begin wait for {Name}, {Type}, {Id}", Type, GetType().Name, + IncrementalId); + + try { + await _eventFinishedWaiter.WaitAsync(timeSpan, token); processed = true; } + catch (OperationCanceledException) + { + processed = false; + } + finally { - if (_eventFinishedWaiter.CurrentCount == 0) + if (processed) { - _eventFinishedWaiter.Release(); + Complete(); } } diff --git a/SharedLibraryCore/Events/Management/ClientPersistentIdReceiveEvent.cs b/SharedLibraryCore/Events/Management/ClientPersistentIdReceiveEvent.cs new file mode 100644 index 000000000..e8c896ad6 --- /dev/null +++ b/SharedLibraryCore/Events/Management/ClientPersistentIdReceiveEvent.cs @@ -0,0 +1,14 @@ +using SharedLibraryCore.Database.Models; + +namespace SharedLibraryCore.Events.Management; + +public class ClientPersistentIdReceiveEvent : ClientStateEvent +{ + public ClientPersistentIdReceiveEvent(EFClient client, string persistentId) + { + Client = client; + PersistentId = persistentId; + } + + public string PersistentId { get; init; } +} diff --git a/SharedLibraryCore/Interfaces/Events/IGameEventSubscriptions.cs b/SharedLibraryCore/Interfaces/Events/IGameEventSubscriptions.cs new file mode 100644 index 000000000..b4807712e --- /dev/null +++ b/SharedLibraryCore/Interfaces/Events/IGameEventSubscriptions.cs @@ -0,0 +1,120 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using SharedLibraryCore.Events; +using SharedLibraryCore.Events.Game; + +namespace SharedLibraryCore.Interfaces.Events; + +public interface IGameEventSubscriptions +{ + /// + /// Raised when game log prints that match has started + /// InitGame + /// + /// + static event Func MatchStarted; + + /// + /// Raised when game log prints that match has ended + /// ShutdownGame: + /// + /// + static event Func MatchEnded; + + /// + /// Raised when game log printed that client has entered the match + /// J;clientNetworkId;clientSlotNumber;clientName + /// J;110000100000000;0;bot + /// + /// + public static event Func ClientEnteredMatch; + + /// + /// Raised when game log prints that client has exited the match + /// Q;clientNetworkId;clientSlotNumber;clientName + /// Q;110000100000000;0;bot + /// + /// + static event Func ClientExitedMatch; + + /// + /// Raised when game log prints that client has joined a team + /// JT;clientNetworkId;clientSlotNumber;clientTeam;clientName + /// JT;110000100000000;0;axis;bot + /// + /// + static event Func ClientJoinedTeam; + + /// + /// Raised when game log prints that client has been damaged + /// D;victimNetworkId;victimSlotNumber;victimTeam;victimName;attackerNetworkId;attackerSlotNumber;attackerTeam;attackerName;weapon;damage;meansOfDeath;hitLocation + /// D;110000100000000;17;axis;bot_0;110000100000001;4;allies;bot_1;scar_mp;38;MOD_HEAD_SHOT;head + /// + /// + static event Func ClientDamaged; + + /// + /// Raised when game log prints that client has been killed + /// K;victimNetworkId;victimSlotNumber;victimTeam;victimName;attackerNetworkId;attackerSlotNumber;attackerTeam;attackerName;weapon;damage;meansOfDeath;hitLocation + /// K;110000100000000;17;axis;bot_0;110000100000001;4;allies;bot_1;scar_mp;100;MOD_HEAD_SHOT;head + /// + /// + static event Func ClientKilled; + + /// + /// Raised when game log prints that client entered a chat message + /// say;clientNetworkId;clientSlotNumber;clientName;message + /// say;110000100000000;0;bot;hello world! + /// + /// + static event Func ClientMessaged; + + /// + /// Raised when game log prints that client entered a command (chat message prefixed with command character(s)) + /// say;clientNetworkId;clientSlotNumber;clientName;command + /// say;110000100000000;0;bot;!command + /// + /// + static event Func ClientEnteredCommand; + + /// + /// Raised when game log prints user generated script event + /// GSE;data + /// GSE;loadBank=1 + /// + /// + static event Func ScriptEventTriggered; + + static Task InvokeEventAsync(CoreEvent coreEvent, CancellationToken token) + { + return coreEvent switch + { + MatchStartEvent matchStartEvent => MatchStarted?.InvokeAsync(matchStartEvent, token) ?? Task.CompletedTask, + MatchEndEvent matchEndEvent => MatchEnded?.InvokeAsync(matchEndEvent, token) ?? Task.CompletedTask, + ClientEnterMatchEvent clientEnterMatchEvent => ClientEnteredMatch?.InvokeAsync(clientEnterMatchEvent, token) ?? Task.CompletedTask, + ClientExitMatchEvent clientExitMatchEvent => ClientExitedMatch?.InvokeAsync(clientExitMatchEvent, token) ?? Task.CompletedTask, + ClientJoinTeamEvent clientJoinTeamEvent => ClientJoinedTeam?.InvokeAsync(clientJoinTeamEvent, token) ?? Task.CompletedTask, + ClientKillEvent clientKillEvent => ClientKilled?.InvokeAsync(clientKillEvent, token) ?? Task.CompletedTask, + ClientDamageEvent clientDamageEvent => ClientDamaged?.InvokeAsync(clientDamageEvent, token) ?? Task.CompletedTask, + ClientCommandEvent clientCommandEvent => ClientEnteredCommand?.InvokeAsync(clientCommandEvent, token) ?? Task.CompletedTask, + ClientMessageEvent clientMessageEvent => ClientMessaged?.InvokeAsync(clientMessageEvent, token) ?? Task.CompletedTask, + GameScriptEvent gameScriptEvent => ScriptEventTriggered?.InvokeAsync(gameScriptEvent, token) ?? Task.CompletedTask, + _ => Task.CompletedTask + }; + } + + static void ClearEventInvocations() + { + MatchStarted = null; + MatchEnded = null; + ClientEnteredMatch = null; + ClientExitedMatch = null; + ClientJoinedTeam = null; + ClientDamaged = null; + ClientKilled = null; + ClientMessaged = null; + ClientEnteredCommand = null; + ScriptEventTriggered = null; + } +} diff --git a/SharedLibraryCore/Interfaces/Events/IGameServerEventSubscriptions.cs b/SharedLibraryCore/Interfaces/Events/IGameServerEventSubscriptions.cs new file mode 100644 index 000000000..b750ecbfc --- /dev/null +++ b/SharedLibraryCore/Interfaces/Events/IGameServerEventSubscriptions.cs @@ -0,0 +1,102 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using SharedLibraryCore.Events; +using SharedLibraryCore.Events.Server; + +namespace SharedLibraryCore.Interfaces.Events; + +public interface IGameServerEventSubscriptions +{ + /// + /// Raised when IW4MAdmin starts monitoring a game server + /// + /// + static event Func MonitoringStarted; + + /// + /// Raised when IW4MAdmin stops monitoring a game server + /// + /// + static event Func MonitoringStopped; + + /// + /// Raised when communication was interrupted with a game server + /// + /// + static event Func ConnectionInterrupted; + + /// + /// Raised when communication was resumed with a game server + /// + /// + static event Func ConnectionRestored; + + /// + /// Raised when updated client data was received from a game server + /// + /// + static event Func ClientDataUpdated; + + /// + /// Raised when a command was executed on a game server + /// + /// + static event Func ServerCommandExecuted; + + /// + /// Raised when a server value is requested for a game server + /// + /// + static event Func ServerValueRequested; + + /// + /// Raised when a server value was received from a game server (success or fail) + /// + /// + static event Func ServerValueReceived; + + /// + /// Raised when a request to set a server value on a game server is received + /// + /// + static event Func ServerValueSetRequested; + + /// + /// Raised when a setting server value on a game server is completed (success or fail) + /// + /// + static event Func ServerValueSetCompleted; + + static Task InvokeEventAsync(CoreEvent coreEvent, CancellationToken token) + { + return coreEvent switch + { + MonitorStartEvent monitoringStartEvent => MonitoringStarted?.InvokeAsync(monitoringStartEvent, token) ?? Task.CompletedTask, + MonitorStopEvent monitorStopEvent => MonitoringStopped?.InvokeAsync(monitorStopEvent, token) ?? Task.CompletedTask, + ConnectionInterruptEvent connectionInterruptEvent => ConnectionInterrupted?.InvokeAsync(connectionInterruptEvent, token) ?? Task.CompletedTask, + ConnectionRestoreEvent connectionRestoreEvent => ConnectionRestored?.InvokeAsync(connectionRestoreEvent, token) ?? Task.CompletedTask, + ClientDataUpdateEvent clientDataUpdateEvent => ClientDataUpdated?.InvokeAsync(clientDataUpdateEvent, token) ?? Task.CompletedTask, + ServerCommandExecuteEvent dataReceiveEvent => ServerCommandExecuted?.InvokeAsync(dataReceiveEvent, token) ?? Task.CompletedTask, + ServerValueRequestEvent serverValueRequestEvent => ServerValueRequested?.InvokeAsync(serverValueRequestEvent, token) ?? Task.CompletedTask, + ServerValueReceiveEvent serverValueReceiveEvent => ServerValueReceived?.InvokeAsync(serverValueReceiveEvent, token) ?? Task.CompletedTask, + ServerValueSetRequestEvent serverValueSetRequestEvent => ServerValueSetRequested?.InvokeAsync(serverValueSetRequestEvent, token) ?? Task.CompletedTask, + ServerValueSetCompleteEvent serverValueSetCompleteEvent => ServerValueSetCompleted?.InvokeAsync(serverValueSetCompleteEvent, token) ?? Task.CompletedTask, + _ => Task.CompletedTask + }; + } + + static void ClearEventInvocations() + { + MonitoringStarted = null; + MonitoringStopped = null; + ConnectionInterrupted = null; + ConnectionRestored = null; + ClientDataUpdated = null; + ServerCommandExecuted = null; + ServerValueReceived = null; + ServerValueRequested = null; + ServerValueSetRequested = null; + ServerValueSetCompleted = null; + } +} diff --git a/SharedLibraryCore/Interfaces/Events/IManagementEventSubscriptions.cs b/SharedLibraryCore/Interfaces/Events/IManagementEventSubscriptions.cs new file mode 100644 index 000000000..45779b2a1 --- /dev/null +++ b/SharedLibraryCore/Interfaces/Events/IManagementEventSubscriptions.cs @@ -0,0 +1,130 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using SharedLibraryCore.Events; +using SharedLibraryCore.Events.Management; + +namespace SharedLibraryCore.Interfaces.Events; + +public interface IManagementEventSubscriptions +{ + /// + /// Raised when is loading + /// + static event Func Load; + + /// + /// Raised when is restarting + /// + static event Func Unload; + + /// + /// Raised when client enters a tracked state + /// + /// At this point, the client is not guaranteed to be allowed to play on the server. + /// See for final state. + /// + /// + /// + static event Func ClientStateInitialized; + + /// + /// Raised when client enters an authorized state (valid data and no bans) + /// + /// + static event Func ClientStateAuthorized; + + /// + /// Raised when client is no longer tracked (unknown state) + /// At this point any references to the client should be dropped + /// + /// + static event Func ClientStateDisposed; + + /// + /// Raised when a client receives a penalty + /// + /// + static event Func ClientPenaltyAdministered; + + /// + /// Raised when a client penalty is revoked (eg unflag/unban) + /// + /// + static event Func ClientPenaltyRevoked; + + /// + /// Raised when a client command is executed (after completion of the command) + /// + /// + static event Func ClientCommandExecuted; + + /// + /// Raised when a client's permission level changes + /// + /// + static event Func ClientPermissionChanged; + + /// + /// Raised when a client logs in to the webfront or ingame + /// + /// + static event Func ClientLoggedIn; + + /// + /// Raised when a client logs out of the webfront + /// + /// + static event Func ClientLoggedOut; + + /// + /// Raised when a client's persistent id (stats file marker) is received + /// + /// + static event Func ClientPersistentIdReceived; + + static Task InvokeEventAsync(CoreEvent coreEvent, CancellationToken token) + { + return coreEvent switch + { + ClientStateInitializeEvent clientStateInitializeEvent => ClientStateInitialized?.InvokeAsync( + clientStateInitializeEvent, token) ?? Task.CompletedTask, + ClientStateDisposeEvent clientStateDisposedEvent => ClientStateDisposed?.InvokeAsync( + clientStateDisposedEvent, token) ?? Task.CompletedTask, + ClientStateAuthorizeEvent clientStateAuthorizeEvent => ClientStateAuthorized?.InvokeAsync( + clientStateAuthorizeEvent, token) ?? Task.CompletedTask, + ClientPenaltyRevokeEvent clientPenaltyRevokeEvent => ClientPenaltyRevoked?.InvokeAsync( + clientPenaltyRevokeEvent, token) ?? Task.CompletedTask, + ClientPenaltyEvent clientPenaltyEvent => + ClientPenaltyAdministered?.InvokeAsync(clientPenaltyEvent, token) ?? Task.CompletedTask, + ClientPermissionChangeEvent clientPermissionChangeEvent => ClientPermissionChanged?.InvokeAsync( + clientPermissionChangeEvent, token) ?? Task.CompletedTask, + ClientExecuteCommandEvent clientExecuteCommandEvent => ClientCommandExecuted?.InvokeAsync( + clientExecuteCommandEvent, token) ?? Task.CompletedTask, + LogoutEvent logoutEvent => ClientLoggedOut?.InvokeAsync(logoutEvent, token) ?? Task.CompletedTask, + LoginEvent loginEvent => ClientLoggedIn?.InvokeAsync(loginEvent, token) ?? Task.CompletedTask, + ClientPersistentIdReceiveEvent clientPersistentIdReceiveEvent => ClientPersistentIdReceived?.InvokeAsync( + clientPersistentIdReceiveEvent, token) ?? Task.CompletedTask, + _ => Task.CompletedTask + }; + } + + static Task InvokeLoadAsync(IManager manager, CancellationToken token) => Load?.InvokeAsync(manager, token) ?? Task.CompletedTask; + static Task InvokeUnloadAsync(IManager manager, CancellationToken token) => Unload?.InvokeAsync(manager, token) ?? Task.CompletedTask; + + static void ClearEventInvocations() + { + Load = null; + Unload = null; + ClientStateInitialized = null; + ClientStateAuthorized = null; + ClientStateDisposed = null; + ClientPenaltyAdministered = null; + ClientPenaltyRevoked = null; + ClientCommandExecuted = null; + ClientPermissionChanged = null; + ClientLoggedIn = null; + ClientLoggedOut = null; + ClientPersistentIdReceived = null; + } +} diff --git a/SharedLibraryCore/Interfaces/ICoreEventHandler.cs b/SharedLibraryCore/Interfaces/ICoreEventHandler.cs new file mode 100644 index 000000000..7e264d8be --- /dev/null +++ b/SharedLibraryCore/Interfaces/ICoreEventHandler.cs @@ -0,0 +1,19 @@ +using System.Threading; +using SharedLibraryCore.Events; + +namespace SharedLibraryCore.Interfaces; + +/// +/// Handles games events (from log, manual events, etc) +/// +public interface ICoreEventHandler +{ + /// + /// Add a core event event to the queue to be processed + /// + /// + /// + void QueueEvent(IManager manager, CoreEvent coreEvent); + + void StartProcessing(CancellationToken token); +} diff --git a/SharedLibraryCore/Interfaces/IGameServer.cs b/SharedLibraryCore/Interfaces/IGameServer.cs index 707ee865c..907b68659 100644 --- a/SharedLibraryCore/Interfaces/IGameServer.cs +++ b/SharedLibraryCore/Interfaces/IGameServer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Data.Models; using SharedLibraryCore.Database.Models; @@ -16,7 +17,70 @@ namespace SharedLibraryCore.Interfaces /// previous penalty the kick is occuring for (if applicable) /// Task Kick(string reason, EFClient target, EFClient origin, EFPenalty previousPenalty = null); + + /// + /// Time the most recent match ended + /// DateTime? MatchEndTime { get; } + + /// + /// Time the current match started + /// DateTime? MatchStartTime { get; } + + /// + /// List of connected clients + /// + IReadOnlyList ConnectedClients { get; } + + /// + /// Game code corresponding to the development studio project + /// + Reference.Game GameCode { get; } + + /// + /// Indicates if the anticheat/custom callbacks/live radar integration is enabled + /// + bool IsLegacyGameIntegrationEnabled { get; } + + /// + /// Unique identifier for the server (typically ip:port) + /// + string Id { get; } + + /// + /// Network address the server is listening on + /// + string ListenAddress { get; } + + /// + /// Network port the server is listening on + /// + int ListenPort { get; } + + /// + /// Name of the server (hostname) + /// + string ServerName { get; } + + /// + /// Current gametype + /// + string Gametype { get; } + + /// + /// Game password (required to join) + /// + string GamePassword { get; } + + /// + /// Current map the game server is running + /// + Map Map { get; } + + /// + /// Database id for EFServer table and references + /// + long LegacyDatabaseId { get; } } } diff --git a/SharedLibraryCore/Interfaces/IManager.cs b/SharedLibraryCore/Interfaces/IManager.cs index 049266ae7..3f0b4c6ec 100644 --- a/SharedLibraryCore/Interfaces/IManager.cs +++ b/SharedLibraryCore/Interfaces/IManager.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using SharedLibraryCore.Configuration; using SharedLibraryCore.Database.Models; +using SharedLibraryCore.Events; using SharedLibraryCore.Helpers; using SharedLibraryCore.Services; @@ -34,7 +35,7 @@ namespace SharedLibraryCore.Interfaces Task Init(); Task Start(); Task Stop(); - void Restart(); + Task Restart(); [Obsolete] ILogger GetLogger(long serverId); @@ -87,6 +88,11 @@ namespace SharedLibraryCore.Interfaces /// event to be processed void AddEvent(GameEvent gameEvent); + /// + /// queues an event for processing + /// + void QueueEvent(CoreEvent coreEvent); + /// /// adds an additional (script) command to the command list /// diff --git a/SharedLibraryCore/Interfaces/IModularAssembly.cs b/SharedLibraryCore/Interfaces/IModularAssembly.cs new file mode 100644 index 000000000..a6c96c727 --- /dev/null +++ b/SharedLibraryCore/Interfaces/IModularAssembly.cs @@ -0,0 +1,11 @@ +namespace SharedLibraryCore.Interfaces; + +public interface IModularAssembly +{ + string Name { get; } + string Author { get; } + string Version { get; } + string Scope => string.Empty; + string Role => string.Empty; + string[] Claims => System.Array.Empty(); +} diff --git a/SharedLibraryCore/Interfaces/IRConParser.cs b/SharedLibraryCore/Interfaces/IRConParser.cs index 3737fdbac..deee71d63 100644 --- a/SharedLibraryCore/Interfaces/IRConParser.cs +++ b/SharedLibraryCore/Interfaces/IRConParser.cs @@ -55,8 +55,6 @@ namespace SharedLibraryCore.Interfaces /// /// Task> GetDvarAsync(IRConConnection connection, string dvarName, T fallbackValue = default, CancellationToken token = default); - - void BeginGetDvar(IRConConnection connection, string dvarName, AsyncCallback callback, CancellationToken token = default); /// /// set value of DVAR by name @@ -67,9 +65,7 @@ namespace SharedLibraryCore.Interfaces /// /// Task SetDvarAsync(IRConConnection connection, string dvarName, object dvarValue, CancellationToken token = default); - - void BeginSetDvar(IRConConnection connection, string dvarName, object dvarValue, AsyncCallback callback, CancellationToken token = default); - + /// /// executes a console command on the server /// diff --git a/SharedLibraryCore/PartialEntities/EFClient.cs b/SharedLibraryCore/PartialEntities/EFClient.cs index a4a881e72..bfa35930d 100644 --- a/SharedLibraryCore/PartialEntities/EFClient.cs +++ b/SharedLibraryCore/PartialEntities/EFClient.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Data.Models; using Microsoft.Extensions.Logging; using Serilog.Context; +using SharedLibraryCore.Events.Management; using SharedLibraryCore.Localization; namespace SharedLibraryCore.Database.Models @@ -603,7 +604,13 @@ namespace SharedLibraryCore.Database.Models LastConnection = DateTime.UtcNow; Utilities.DefaultLogger.LogInformation("Client {client} is leaving the game", ToString()); - + + CurrentServer?.Manager.QueueEvent(new ClientStateDisposeEvent + { + Source = CurrentServer, + Client = this + }); + try { await CurrentServer.Manager.GetClientService().Update(this); @@ -658,6 +665,11 @@ namespace SharedLibraryCore.Database.Models }; CurrentServer.Manager.AddEvent(e); + CurrentServer.Manager.QueueEvent(new ClientStateAuthorizeEvent + { + Source = CurrentServer, + Client = this + }); } } diff --git a/SharedLibraryCore/Server.cs b/SharedLibraryCore/Server.cs index fd5f704e2..c3e5009ae 100644 --- a/SharedLibraryCore/Server.cs +++ b/SharedLibraryCore/Server.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Net; using System.Threading; @@ -63,7 +64,7 @@ namespace SharedLibraryCore { Password = config.Password; IP = config.IPAddress; - Port = config.Port; + ListenPort = config.Port; Manager = mgr; #pragma warning disable CS0612 Logger = deprecatedLogger ?? throw new ArgumentNullException(nameof(deprecatedLogger)); @@ -89,8 +90,6 @@ namespace SharedLibraryCore ? Convert.ToInt64($"{ListenAddress!.Replace(".", "")}{ListenPort}") : $"{ListenAddress!.Replace(".", "")}{ListenPort}".GetStableHashCode(); - public long LegacyEndpoint => EndPoint; - public abstract long LegacyDatabaseId { get; } public string Id => $"{ListenAddress}:{ListenPort}"; @@ -105,6 +104,7 @@ namespace SharedLibraryCore public List ChatHistory { get; protected set; } public ClientHistoryInfo ClientHistory { get; } public Game GameName { get; set; } + public Reference.Game GameCode => (Reference.Game)GameName; public DateTime? MatchEndTime { get; protected set; } public DateTime? MatchStartTime { get; protected set; } @@ -114,14 +114,17 @@ namespace SharedLibraryCore protected set => hostname = value; } + public string ServerName => Hostname; + public string Website { get; protected set; } public string Gametype { get; set; } - public string GametypeName => DefaultSettings.Gametypes.FirstOrDefault(gt => gt.Game == GameName)?.Gametypes + public string GametypeName => DefaultSettings.Gametypes?.FirstOrDefault(gt => gt.Game == GameName)?.Gametypes ?.FirstOrDefault(gt => gt.Name == Gametype)?.Alias ?? Gametype; public string GamePassword { get; protected set; } public Map CurrentMap { get; set; } + public Map Map => CurrentMap; public int ClientNum { @@ -130,9 +133,13 @@ namespace SharedLibraryCore public int MaxClients { get; protected set; } public List Clients { get; protected set; } + + public IReadOnlyList ConnectedClients => + new ReadOnlyCollection(GetClientsAsList()); public string Password { get; } public bool Throttled { get; protected set; } public bool CustomCallback { get; protected set; } + public bool IsLegacyGameIntegrationEnabled => CustomCallback; public string WorkingDirectory { get; protected set; } public IRConConnection RemoteConnection { get; protected set; } public IRConParser RconParser { get; set; } @@ -143,7 +150,7 @@ namespace SharedLibraryCore // Internal /// - /// this is actually the hostname now + /// this is actually the listen address now /// public string IP { get; protected set; } @@ -153,7 +160,7 @@ namespace SharedLibraryCore public string Version { get; protected set; } public bool IsInitialized { get; set; } - public int Port { get; } + public int ListenPort { get; } public abstract Task Kick(string reason, EFClient target, EFClient origin, EFPenalty originalPenalty); /// @@ -416,35 +423,6 @@ namespace SharedLibraryCore public abstract Task GetIdForServer(Server server = null); - public string GetServerDvar(string dvarName, int timeoutMs = 1000) - { - using var tokenSource = new CancellationTokenSource(); - tokenSource.CancelAfter(TimeSpan.FromSeconds(timeoutMs)); - try - { - return this.GetDvarAsync(dvarName, token: tokenSource.Token).GetAwaiter().GetResult().Value; - } - catch - { - return null; - } - } - - public bool SetServerDvar(string dvarName, string dvarValue, int timeoutMs = 1000) - { - using var tokenSource = new CancellationTokenSource(); - tokenSource.CancelAfter(TimeSpan.FromSeconds(timeoutMs)); - try - { - this.SetDvarAsync(dvarName, dvarValue, tokenSource.Token).GetAwaiter().GetResult(); - return true; - } - catch - { - return false; - } - } - public EFClient GetClientByNumber(int clientNumber) => GetClientsAsList().FirstOrDefault(client => client.ClientNumber == clientNumber); } diff --git a/SharedLibraryCore/SharedLibraryCore.csproj b/SharedLibraryCore/SharedLibraryCore.csproj index b173fb3df..7c6d139e9 100644 --- a/SharedLibraryCore/SharedLibraryCore.csproj +++ b/SharedLibraryCore/SharedLibraryCore.csproj @@ -4,22 +4,22 @@ Library net6.0 RaidMax.IW4MAdmin.SharedLibraryCore - 2022.10.13.1 + 2023.4.5.1 RaidMax Forever None Debug;Release;Prerelease false - default + Preview IW4MAdmin https://github.com/RaidMax/IW4M-Admin/ https://www.raidmax.org/IW4MAdmin/ - 2022 + 2023 true true true MIT Shared Library for IW4MAdmin - 2022.10.13.1 + 2023.4.5.1 true $(NoWarn);1591 diff --git a/SharedLibraryCore/Utilities.cs b/SharedLibraryCore/Utilities.cs index fc5ae1b1e..f0ceafa62 100644 --- a/SharedLibraryCore/Utilities.cs +++ b/SharedLibraryCore/Utilities.cs @@ -14,10 +14,13 @@ using System.Threading.Tasks; using Data.Models; using Humanizer; using Humanizer.Localisation; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using SharedLibraryCore.Configuration; using SharedLibraryCore.Database.Models; using SharedLibraryCore.Dtos.Meta; +using SharedLibraryCore.Events.Server; +using SharedLibraryCore.Exceptions; using SharedLibraryCore.Helpers; using SharedLibraryCore.Interfaces; using SharedLibraryCore.Localization; @@ -26,7 +29,6 @@ using static SharedLibraryCore.Server; using static Data.Models.Client.EFClient; using static Data.Models.EFPenalty; using ILogger = Microsoft.Extensions.Logging.ILogger; -using RegionInfo = System.Globalization.RegionInfo; namespace SharedLibraryCore { @@ -43,7 +45,7 @@ namespace SharedLibraryCore public static Encoding EncodingType; public static Layout CurrentLocalization = new Layout(new Dictionary()); - public static TimeSpan DefaultCommandTimeout { get; set; } = new(0, 0, Utilities.IsDevelopment ? 360 : 25); + public static TimeSpan DefaultCommandTimeout { get; set; } = new(0, 0, /*Utilities.IsDevelopment ? 360 : */25); public static char[] DirectorySeparatorChars = { '\\', '/' }; public static char CommandPrefix { get; set; } = '!'; @@ -66,6 +68,22 @@ namespace SharedLibraryCore }; } + public static EFClient AsConsoleClient(this IGameServer server) + { + return new EFClient + { + ClientId = 1, + State = EFClient.ClientState.Connected, + Level = Permission.Console, + CurrentServer = server as Server, + CurrentAlias = new EFAlias + { + Name = "IW4MAdmin" + }, + AdministeredPenalties = new List() + }; + } + /// /// fallback id for world events /// @@ -95,14 +113,19 @@ namespace SharedLibraryCore /// /// caps client name to the specified character length - 3 - /// and adds ellipses to the end of the reamining client name + /// and adds ellipses to the end of the remaining client name /// - /// client name + /// client name /// max number of characters for the name /// - public static string CapClientName(this string str, int maxLength) + public static string CapClientName(this string name, int maxLength) { - return str.Length > maxLength ? $"{str.Substring(0, maxLength - 3)}..." : str; + if (string.IsNullOrWhiteSpace(name)) + { + return "-"; + } + + return name.Length > maxLength ? $"{name[..(maxLength - 3)]}..." : name; } public static Permission MatchPermission(string str) @@ -712,15 +735,21 @@ namespace SharedLibraryCore public static Dictionary DictionaryFromKeyValue(this string eventLine) { - var values = eventLine.Substring(1).Split('\\'); + var values = eventLine[1..].Split('\\'); - Dictionary dict = null; + Dictionary dict = new(); - if (values.Length > 1) + if (values.Length <= 1) { - dict = new Dictionary(); - for (var i = values.Length % 2 == 0 ? 0 : 1; i < values.Length; i += 2) + return dict; + } + + for (var i = values.Length % 2 == 0 ? 0 : 1; i < values.Length; i += 2) + { + if (!dict.ContainsKey(values[i])) + { dict.Add(values[i], values[i + 1]); + } } return dict; @@ -771,11 +800,6 @@ namespace SharedLibraryCore { return await server.RconParser.GetDvarAsync(server.RemoteConnection, dvarName, fallbackValue, token); } - - public static void BeginGetDvar(this Server server, string dvarName, AsyncCallback callback, CancellationToken token = default) - { - server.RconParser.BeginGetDvar(server.RemoteConnection, dvarName, callback, token); - } public static async Task> GetDvarAsync(this Server server, string dvarName, T fallbackValue = default) @@ -807,30 +831,36 @@ namespace SharedLibraryCore return await server.GetDvarAsync(mappedKey, defaultValue, token: token); } - public static async Task SetDvarAsync(this Server server, string dvarName, object dvarValue, CancellationToken token = default) + public static async Task SetDvarAsync(this Server server, string dvarName, object dvarValue, + CancellationToken token) { await server.RconParser.SetDvarAsync(server.RemoteConnection, dvarName, dvarValue, token); } - public static void BeginSetDvar(this Server server, string dvarName, object dvarValue, - AsyncCallback callback, CancellationToken token = default) - { - server.RconParser.BeginSetDvar(server.RemoteConnection, dvarName, dvarValue, callback, token); - } - public static async Task SetDvarAsync(this Server server, string dvarName, object dvarValue) { await SetDvarAsync(server, dvarName, dvarValue, default); } - public static async Task ExecuteCommandAsync(this Server server, string commandName, CancellationToken token = default) + public static async Task ExecuteCommandAsync(this Server server, string commandName, + CancellationToken token) { - return await server.RconParser.ExecuteCommandAsync(server.RemoteConnection, commandName, token); + var response = await server.RconParser.ExecuteCommandAsync(server.RemoteConnection, commandName, token); + + server.Manager.QueueEvent(new ServerCommandExecuteEvent + { + Server = server, + Source = server, + Command = commandName, + Output = response + }); + + return response; } - + public static async Task ExecuteCommandAsync(this Server server, string commandName) { - return await ExecuteCommandAsync(server, commandName, default); + return await ExecuteCommandAsync(server, commandName, default); } public static async Task GetStatusAsync(this Server server, CancellationToken token) @@ -1262,5 +1292,44 @@ namespace SharedLibraryCore public static string MakeAbbreviation(string gameName) => string.Join("", gameName.Split(' ').Select(word => char.ToUpper(word.First())).ToArray()); + + public static IServiceCollection AddConfiguration( + this IServiceCollection serviceCollection, string fileName = null, TConfigurationType defaultConfig = null) + where TConfigurationType : class + { + serviceCollection.AddSingleton(serviceProvider => + { + var configurationHandler = + serviceProvider.GetRequiredService>(); + + var configuration = + Task.Run(() => configurationHandler.Get(fileName ?? typeof(TConfigurationType).Name, defaultConfig)) + .GetAwaiter().GetResult(); + + if (typeof(TConfigurationType).GetInterface(nameof(IBaseConfiguration)) is not null && + defaultConfig is null && configuration is null) + { + defaultConfig = + (TConfigurationType)((IBaseConfiguration)Activator.CreateInstance()) + .Generate(); + } + + if (defaultConfig is not null && configuration is null) + { + Task.Run(() => configurationHandler.Set(defaultConfig)).GetAwaiter().GetResult(); + configuration = defaultConfig; + } + + if (configuration is null) + { + throw new ConfigurationException( + $"Could not register configuration {typeof(TConfigurationType).Name}. Configuration file does not exist and no default configuration was provided."); + } + + return configuration; + }); + + return serviceCollection; + } } } diff --git a/WebfrontCore/Controllers/API/ClientController.cs b/WebfrontCore/Controllers/API/ClientController.cs index 503725029..764e26621 100644 --- a/WebfrontCore/Controllers/API/ClientController.cs +++ b/WebfrontCore/Controllers/API/ClientController.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Logging; using SharedLibraryCore; +using SharedLibraryCore.Events.Management; using SharedLibraryCore.Helpers; using SharedLibraryCore.Services; using WebfrontCore.Controllers.API.Dtos; @@ -136,6 +137,16 @@ namespace WebfrontCore.Controllers.API ? HttpContext.Request.Headers["X-Forwarded-For"].ToString() : HttpContext.Connection.RemoteIpAddress.ToString() }); + + Manager.QueueEvent(new LoginEvent + { + Source = this, + LoginSource = LoginEvent.LoginSourceType.Webfront, + EntityId = Client.ClientId.ToString(), + Identifier = HttpContext.Request.Headers.ContainsKey("X-Forwarded-For") + ? HttpContext.Request.Headers["X-Forwarded-For"].ToString() + : HttpContext.Connection.RemoteIpAddress?.ToString() + }); return Ok(); } @@ -165,6 +176,16 @@ namespace WebfrontCore.Controllers.API ? HttpContext.Request.Headers["X-Forwarded-For"].ToString() : HttpContext.Connection.RemoteIpAddress.ToString() }); + + Manager.QueueEvent(new LogoutEvent + { + Source = this, + LoginSource = LoginEvent.LoginSourceType.Webfront, + EntityId = Client.ClientId.ToString(), + Identifier = HttpContext.Request.Headers.ContainsKey("X-Forwarded-For") + ? HttpContext.Request.Headers["X-Forwarded-For"].ToString() + : HttpContext.Connection.RemoteIpAddress?.ToString() + }); } await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); diff --git a/WebfrontCore/Controllers/API/Server.cs b/WebfrontCore/Controllers/API/Server.cs index ab1324bcc..66ca820aa 100644 --- a/WebfrontCore/Controllers/API/Server.cs +++ b/WebfrontCore/Controllers/API/Server.cs @@ -91,7 +91,7 @@ namespace WebfrontCore.Controllers.API var start = DateTime.Now; Client.CurrentServer = foundServer; - var commandEvent = new GameEvent() + var commandEvent = new GameEvent { Type = GameEvent.EventType.Command, Owner = foundServer, diff --git a/WebfrontCore/Controllers/AboutController.cs b/WebfrontCore/Controllers/AboutController.cs index 2928a2aa5..8119491c5 100644 --- a/WebfrontCore/Controllers/AboutController.cs +++ b/WebfrontCore/Controllers/AboutController.cs @@ -47,4 +47,4 @@ namespace WebfrontCore.Controllers return View(info); } } -} \ No newline at end of file +} diff --git a/WebfrontCore/Controllers/AccountController.cs b/WebfrontCore/Controllers/AccountController.cs index 01b21a96b..a436b16d7 100644 --- a/WebfrontCore/Controllers/AccountController.cs +++ b/WebfrontCore/Controllers/AccountController.cs @@ -7,6 +7,7 @@ using System; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; +using SharedLibraryCore.Events.Management; using SharedLibraryCore.Helpers; namespace WebfrontCore.Controllers @@ -72,6 +73,16 @@ namespace WebfrontCore.Controllers ? HttpContext.Request.Headers["X-Forwarded-For"].ToString() : HttpContext.Connection.RemoteIpAddress?.ToString() }); + + Manager.QueueEvent(new LoginEvent + { + Source = this, + LoginSource = LoginEvent.LoginSourceType.Webfront, + EntityId = privilegedClient.ClientId.ToString(), + Identifier = HttpContext.Request.Headers.ContainsKey("X-Forwarded-For") + ? HttpContext.Request.Headers["X-Forwarded-For"].ToString() + : HttpContext.Connection.RemoteIpAddress?.ToString() + }); return Ok(Localization["WEBFRONT_ACTION_LOGIN_SUCCESS"].FormatExt(privilegedClient.CleanedName)); } @@ -99,6 +110,16 @@ namespace WebfrontCore.Controllers ? HttpContext.Request.Headers["X-Forwarded-For"].ToString() : HttpContext.Connection.RemoteIpAddress?.ToString() }); + + Manager.QueueEvent(new LogoutEvent + { + Source = this, + LoginSource = LoginEvent.LoginSourceType.Webfront, + EntityId = Client.ClientId.ToString(), + Identifier = HttpContext.Request.Headers.ContainsKey("X-Forwarded-For") + ? HttpContext.Request.Headers["X-Forwarded-For"].ToString() + : HttpContext.Connection.RemoteIpAddress?.ToString() + }); } await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); diff --git a/WebfrontCore/Controllers/Client/ClientController.cs b/WebfrontCore/Controllers/Client/ClientController.cs index 27e2d33e3..033774d0e 100644 --- a/WebfrontCore/Controllers/Client/ClientController.cs +++ b/WebfrontCore/Controllers/Client/ClientController.cs @@ -7,7 +7,6 @@ using SharedLibraryCore.Interfaces; using SharedLibraryCore.QueryHelper; using System; using System.Collections.Generic; -using System.ComponentModel; using System.Linq; using System.Threading; using System.Threading.Tasks; diff --git a/WebfrontCore/Controllers/Client/ClientStatisticsController.cs b/WebfrontCore/Controllers/Client/ClientStatisticsController.cs index a49d8c3ca..55de0e4eb 100644 --- a/WebfrontCore/Controllers/Client/ClientStatisticsController.cs +++ b/WebfrontCore/Controllers/Client/ClientStatisticsController.cs @@ -1,7 +1,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using IW4MAdmin.Plugins.Stats.Helpers; using Microsoft.AspNetCore.Mvc; using SharedLibraryCore; using SharedLibraryCore.Configuration; @@ -41,12 +40,12 @@ namespace WebfrontCore.Controllers return NotFound(); } - var server = Manager.GetServers().FirstOrDefault(server => server.ToString() == serverId); + var server = Manager.GetServers().FirstOrDefault(server => server.Id == serverId) as IGameServer; long? matchedServerId = null; if (server != null) { - matchedServerId = StatManager.GetIdForServer(server); + matchedServerId = server.LegacyDatabaseId; } hitInfo.TotalRankedClients = await _serverDataViewer.RankedClientsCountAsync(matchedServerId, token); diff --git a/WebfrontCore/Program.cs b/WebfrontCore/Program.cs index 728b4db47..12e6784bf 100644 --- a/WebfrontCore/Program.cs +++ b/WebfrontCore/Program.cs @@ -3,9 +3,8 @@ using System.IO; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Serilog; +using SharedLibraryCore.Configuration; using SharedLibraryCore.Interfaces; using WebfrontCore.Middleware; @@ -14,33 +13,36 @@ namespace WebfrontCore public class Program { public static IManager Manager; - public static IServiceCollection Services; - public static IServiceProvider ApplicationServiceProvider; + private static IWebHost _webHost; - public static Task Init(IManager mgr, IServiceProvider existingServiceProvider, IServiceCollection services, CancellationToken cancellationToken) + public static IServiceProvider InitializeServices(Action registerDependenciesAction, string bindUrl) { - Services = services; - Manager = mgr; - ApplicationServiceProvider = existingServiceProvider; - var config = Manager.GetApplicationSettings().Configuration(); - Manager.MiddlewareActionHandler.Register(null, new CustomCssAccentMiddlewareAction("#007ACC", "#fd7e14", config.WebfrontPrimaryColor, config.WebfrontSecondaryColor), "custom_css_accent"); - return BuildWebHost().RunAsync(cancellationToken); + _webHost = BuildWebHost(registerDependenciesAction, bindUrl); + Manager = _webHost.Services.GetRequiredService(); + return _webHost.Services; } - private static IWebHost BuildWebHost() + public static Task GetWebHostTask(CancellationToken cancellationToken) + { + var config = _webHost.Services.GetRequiredService(); + Manager.MiddlewareActionHandler.Register(null, + new CustomCssAccentMiddlewareAction("#007ACC", "#fd7e14", config.WebfrontPrimaryColor, + config.WebfrontSecondaryColor), "custom_css_accent"); + + return _webHost?.RunAsync(cancellationToken); + } + + private static IWebHost BuildWebHost(Action registerDependenciesAction, string bindUrl) { - var config = new ConfigurationBuilder() - .AddEnvironmentVariables() - .Build(); - return new WebHostBuilder() #if DEBUG .UseContentRoot(Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), @"..\..\..\..\", "WebfrontCore"))) #else .UseContentRoot(SharedLibraryCore.Utilities.OperatingDirectory) #endif - .UseUrls(Manager.GetApplicationSettings().Configuration().WebfrontBindUrl) + .UseUrls(bindUrl) .UseKestrel() + .ConfigureServices(registerDependenciesAction) .UseStartup() .Build(); } diff --git a/WebfrontCore/Startup.cs b/WebfrontCore/Startup.cs index 9c8e44077..2eb8ddd8f 100644 --- a/WebfrontCore/Startup.cs +++ b/WebfrontCore/Startup.cs @@ -24,6 +24,7 @@ using System.Reflection; using System.Threading.Tasks; using Data.Abstractions; using Data.Helpers; +using IW4MAdmin.Plugins.Stats.Helpers; using Stats.Client.Abstractions; using Stats.Config; using WebfrontCore.Controllers.API.Validation; @@ -64,23 +65,8 @@ namespace WebfrontCore } // Add framework services. - var mvcBuilder = services.AddMvc(_options => _options.SuppressAsyncSuffixInActionNames = false) - .AddFluentValidation() - .ConfigureApplicationPartManager(_partManager => - { - foreach (var assembly in pluginAssemblies()) - { - if (assembly.FullName.Contains("Views")) - { - _partManager.ApplicationParts.Add(new CompiledRazorAssemblyPart(assembly)); - } - - else if (assembly.FullName.Contains("Web")) - { - _partManager.ApplicationParts.Add(new AssemblyPart(assembly)); - } - } - }); + var mvcBuilder = services.AddMvc(options => options.SuppressAsyncSuffixInActionNames = false); + services.AddFluentValidationAutoValidation().AddFluentValidationClientsideAdapters(); #if DEBUG { @@ -109,42 +95,13 @@ namespace WebfrontCore options.Events.OnSignedIn += ClaimsPermissionRemoval.OnSignedIn; }); - services.AddSingleton(Program.Manager); services.AddSingleton, ChatResourceQueryHelper>(); services.AddTransient, FindClientRequestValidator>(); services.AddSingleton, ClientService>(); services.AddSingleton, StatsResourceQueryHelper>(); services.AddSingleton, AdvancedClientStatsResourceQueryHelper>(); - services.AddScoped(sp => - Program.ApplicationServiceProvider - .GetRequiredService>()); services.AddSingleton(typeof(IDataValueCache<,>), typeof(DataValueCache<,>)); - // todo: this needs to be handled more gracefully - services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService()); - services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService()); - services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService()); - services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService()); - services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService()); - services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService()); - services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService>()); -#pragma warning disable CS0618 - services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService()); -#pragma warning restore CS0618 - services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService()); - services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService()); - services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService()); - services.AddSingleton, BanInfoResourceQueryHelper>(); - services.AddSingleton( - Program.ApplicationServiceProvider.GetRequiredService()); - services.AddSingleton(Program.ApplicationServiceProvider - .GetRequiredService>()); - services.AddSingleton(Program.ApplicationServiceProvider - .GetRequiredService()); - services.AddSingleton(Program.ApplicationServiceProvider - .GetRequiredService()); - services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService()); - services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService()); - services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService()); + services.AddSingleton, BanInfoResourceQueryHelper>(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.