diff --git a/Application/IO/GameLogEventDetection.cs b/Application/IO/GameLogEventDetection.cs index 955a95c5..eca6fb8c 100644 --- a/Application/IO/GameLogEventDetection.cs +++ b/Application/IO/GameLogEventDetection.cs @@ -21,7 +21,7 @@ namespace IW4MAdmin.Application.IO { _reader = gameLogReaderFactory.CreateGameLogReader(gameLogUris, server.EventParser); _server = server; - _ignoreBots = server?.Manager.GetApplicationSettings().Configuration().IgnoreBots ?? false; + _ignoreBots = server.Manager.GetApplicationSettings().Configuration()?.IgnoreBots ?? false; _logger = logger; } @@ -69,7 +69,7 @@ namespace IW4MAdmin.Application.IO return; } - var events = await _reader.ReadEventsFromLog(fileDiff, previousFileSize); + var events = await _reader.ReadEventsFromLog(fileDiff, previousFileSize, _server); foreach (var gameEvent in events) { diff --git a/Application/IO/GameLogReader.cs b/Application/IO/GameLogReader.cs index d1339cd9..03a268ba 100644 --- a/Application/IO/GameLogReader.cs +++ b/Application/IO/GameLogReader.cs @@ -28,7 +28,7 @@ namespace IW4MAdmin.Application.IO _logger = logger; } - public async Task> ReadEventsFromLog(long fileSizeDiff, long startPosition) + public async Task> ReadEventsFromLog(long fileSizeDiff, long startPosition, Server server = null) { // allocate the bytes for the new log lines List logLines = new List(); diff --git a/Application/IO/GameLogReaderHttp.cs b/Application/IO/GameLogReaderHttp.cs index 5662593e..a585fa89 100644 --- a/Application/IO/GameLogReaderHttp.cs +++ b/Application/IO/GameLogReaderHttp.cs @@ -34,7 +34,7 @@ namespace IW4MAdmin.Application.IO public int UpdateInterval => 500; - public async Task> ReadEventsFromLog(long fileSizeDiff, long startPosition) + public async Task> ReadEventsFromLog(long fileSizeDiff, long startPosition, Server server = null) { var events = new List(); var response = await _logServerApi.Log(_safeLogPath, lastKey); diff --git a/Application/IO/NetworkGameLogReader.cs b/Application/IO/NetworkGameLogReader.cs index a1abef43..e474a386 100644 --- a/Application/IO/NetworkGameLogReader.cs +++ b/Application/IO/NetworkGameLogReader.cs @@ -5,92 +5,153 @@ using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Sockets; +using System.Threading; using System.Threading.Tasks; +using Integrations.Cod; using Microsoft.Extensions.Logging; +using Serilog.Context; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace IW4MAdmin.Application.IO { /// - /// provides capability of reading log files over HTTP + /// provides capability of reading log files over udp /// class NetworkGameLogReader : IGameLogReader { private readonly IEventParser _eventParser; - private readonly UdpClient _udpClient; private readonly ILogger _logger; + private readonly Uri _uri; + private static readonly NetworkLogState State = new(); + private bool _stateRegistered; + private CancellationToken _token; - public NetworkGameLogReader(Uri[] uris, IEventParser parser, ILogger logger) + public NetworkGameLogReader(IReadOnlyList uris, IEventParser parser, ILogger logger) { _eventParser = parser; - try - { - var endPoint = new IPEndPoint(Dns.GetHostAddresses(uris[0].Host).First(), uris[0].Port); - _udpClient = new UdpClient(endPoint); - } - catch (Exception ex) - { - logger.LogError(ex, "Could setup {LogReader}", nameof(NetworkGameLogReader)); - } - + _uri = uris[0]; _logger = logger; } public long Length => -1; - public int UpdateInterval => 500; + public int UpdateInterval => 150; - public async Task> ReadEventsFromLog(long fileSizeDiff, long startPosition) + public Task> ReadEventsFromLog(long fileSizeDiff, long startPosition, + Server server = null) { - if (_udpClient == null) + // todo: other games might support this + var serverEndpoint = (server?.RemoteConnection as CodRConConnection)?.Endpoint; + + if (serverEndpoint is null) { - return Enumerable.Empty(); + return Task.FromResult(Enumerable.Empty()); } - byte[] buffer; - try - { - buffer = (await _udpClient.ReceiveAsync()).Buffer; - } - catch (Exception ex) - { - _logger.LogError(ex, "Could receive lines for {LogReader}", nameof(NetworkGameLogReader)); - return Enumerable.Empty(); - } - - if (!buffer.Any()) - { - return Enumerable.Empty(); - } - - var logData = Utilities.EncodingType.GetString(buffer); - - if (string.IsNullOrWhiteSpace(logData)) - { - return Enumerable.Empty(); - } - - var lines = logData - .Split('\n') - .Where(line => line.Length > 0); - - var events = new List(); - foreach (var eventLine in lines) + if (!_stateRegistered && !State.EndPointExists(serverEndpoint)) { try { - // this trim end should hopefully fix the nasty runaway regex - var gameEvent = _eventParser.GenerateGameEvent(eventLine.TrimEnd('\r')); - events.Add(gameEvent); - } + var client = State.RegisterEndpoint(serverEndpoint, BuildLocalEndpoint()).Client; + _stateRegistered = true; + _token = server.Manager.CancellationToken; + + if (client == null) + { + using (LogContext.PushProperty("Server", server.ToString())) + { + _logger.LogInformation("Not registering {Name} socket because it is already bound", + nameof(NetworkGameLogReader)); + } + return Task.FromResult(Enumerable.Empty()); + } + + new Thread(() => ReadNetworkData(client)).Start(); + } catch (Exception ex) { - _logger.LogError(ex, "Could not properly parse event line from http {eventLine}", eventLine); + _logger.LogError(ex, "Could not register {Name} endpoint {Endpoint}", + nameof(NetworkGameLogReader), _uri); + throw; } } - return events; + var events = new List(); + + foreach (var logData in State.GetServerLogData(serverEndpoint) + .Select(log => Utilities.EncodingType.GetString(log))) + { + if (string.IsNullOrWhiteSpace(logData)) + { + return Task.FromResult(Enumerable.Empty()); + } + + var lines = logData + .Split('\n') + .Where(line => line.Length > 0); + + foreach (var eventLine in lines) + { + try + { + // this trim end should hopefully fix the nasty runaway regex + var gameEvent = _eventParser.GenerateGameEvent(eventLine.TrimEnd('\r')); + events.Add(gameEvent); + } + + catch (Exception ex) + { + _logger.LogError(ex, "Could not properly parse event line from http {EventLine}", + eventLine); + } + } + } + + return Task.FromResult((IEnumerable)events); + } + + private void ReadNetworkData(UdpClient client) + { + while (!_token.IsCancellationRequested) + { + // get more data + IPEndPoint remoteEndpoint = null; + byte[] bufferedData = null; + + if (client == null) + { + // we already have a socket listening on this port for data, so we don't need to run another thread + break; + } + + try + { + bufferedData = client.Receive(ref remoteEndpoint); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not receive lines for {LogReader}", nameof(NetworkGameLogReader)); + } + + if (bufferedData != null) + { + State.QueueServerLogData(remoteEndpoint, bufferedData); + } + } + } + + private IPEndPoint BuildLocalEndpoint() + { + try + { + return new IPEndPoint(Dns.GetHostAddresses(_uri.Host).First(), _uri.Port); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not setup {LogReader} endpoint", nameof(NetworkGameLogReader)); + throw; + } } } } diff --git a/Application/IO/NetworkLogState.cs b/Application/IO/NetworkLogState.cs new file mode 100644 index 00000000..7db9a178 --- /dev/null +++ b/Application/IO/NetworkLogState.cs @@ -0,0 +1,138 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; + +namespace IW4MAdmin.Application.IO; + +public class NetworkLogState : Dictionary +{ + public UdpClientState RegisterEndpoint(IPEndPoint serverEndpoint, IPEndPoint localEndpoint) + { + try + { + lock (this) + { + if (!ContainsKey(serverEndpoint)) + { + Add(serverEndpoint, new UdpClientState { Client = new UdpClient(localEndpoint) }); + } + } + } + catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) + { + lock (this) + { + // we don't add the udp client because it already exists (listening to multiple servers from one socket) + Add(serverEndpoint, new UdpClientState()); + } + } + + return this[serverEndpoint]; + } + + + public List GetServerLogData(IPEndPoint serverEndpoint) + { + try + { + var state = this[serverEndpoint]; + + if (state == null) + { + return new List(); + } + + // it's possible that we could be trying to read and write to the queue simultaneously so we need to wait + this[serverEndpoint].OnAction.Wait(); + + var data = new List(); + + while (this[serverEndpoint].AvailableLogData.Count > 0) + { + data.Add(this[serverEndpoint].AvailableLogData.Dequeue()); + } + + return data; + } + finally + { + if (this[serverEndpoint].OnAction.CurrentCount == 0) + { + this[serverEndpoint].OnAction.Release(1); + } + } + } + + public void QueueServerLogData(IPEndPoint serverEndpoint, byte[] data) + { + var endpoint = Keys.FirstOrDefault(key => + Equals(key.Address, serverEndpoint.Address) && key.Port == serverEndpoint.Port); + + try + { + if (endpoint == null) + { + return; + } + + // currently our expected start and end characters + var startsWithPrefix = StartsWith(data, "ÿÿÿÿprint\n"); + var endsWithDelimiter = data[^1] == '\n'; + + // we have the data we expected + if (!startsWithPrefix || !endsWithDelimiter) + { + return; + } + + // it's possible that we could be trying to read and write to the queue simultaneously so we need to wait + this[endpoint].OnAction.Wait(); + this[endpoint].AvailableLogData.Enqueue(data); + } + finally + { + if (endpoint != null && this[endpoint].OnAction.CurrentCount == 0) + { + this[endpoint].OnAction.Release(1); + } + } + } + + public bool EndPointExists(IPEndPoint serverEndpoint) + { + lock (this) + { + return ContainsKey(serverEndpoint); + } + } + + private static bool StartsWith(byte[] sourceArray, string match) + { + if (sourceArray is null) + { + return false; + } + + if (match.Length > sourceArray.Length) + { + return false; + } + + return !match.Where((t, i) => sourceArray[i] != (byte)t).Any(); + } +} + +public class UdpClientState +{ + public UdpClient Client { get; set; } + public Queue AvailableLogData { get; } = new(); + public SemaphoreSlim OnAction { get; } = new(1, 1); + + ~UdpClientState() + { + OnAction.Dispose(); + Client?.Dispose(); + } +} diff --git a/IW4MAdmin.sln b/IW4MAdmin.sln index 00c9714d..ebf38ad8 100644 --- a/IW4MAdmin.sln +++ b/IW4MAdmin.sln @@ -13,6 +13,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution version.txt = version.txt DeploymentFiles\UpdateIW4MAdmin.ps1 = DeploymentFiles\UpdateIW4MAdmin.ps1 DeploymentFiles\UpdateIW4MAdmin.sh = DeploymentFiles\UpdateIW4MAdmin.sh + GameFiles\IW4x\userraw\scripts\_integration.gsc = GameFiles\IW4x\userraw\scripts\_integration.gsc EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharedLibraryCore", "SharedLibraryCore\SharedLibraryCore.csproj", "{AA0541A2-8D51-4AD9-B0AC-3D1F5B162481}" diff --git a/SharedLibraryCore/Interfaces/IGameLogReader.cs b/SharedLibraryCore/Interfaces/IGameLogReader.cs index f6783a09..8ef61f53 100644 --- a/SharedLibraryCore/Interfaces/IGameLogReader.cs +++ b/SharedLibraryCore/Interfaces/IGameLogReader.cs @@ -23,7 +23,8 @@ namespace SharedLibraryCore.Interfaces /// /// /// + /// /// - Task> ReadEventsFromLog(long fileSizeDiff, long startPosition); + Task> ReadEventsFromLog(long fileSizeDiff, long startPosition, Server server); } -} \ No newline at end of file +}