From da70875c8165b4fb266dfa3eb6ff0fdc5d93bbb2 Mon Sep 17 00:00:00 2001 From: EonaCat Date: Fri, 28 Nov 2025 19:30:32 +0100 Subject: [PATCH] Updated --- .../EonaCat.Connections.Client.csproj | 6 - EonaCat.Connections.Client/Program.cs | 300 +++- .../EonaCat.Connections.Server.csproj | 6 - EonaCat.Connections.Server/Program.cs | 112 +- EonaCat.Connections.sln | 4 +- EonaCat.Connections/BufferSize.cs | 16 + EonaCat.Connections/DisconnectReason.cs | 17 +- .../EonaCat.Connections.csproj | 11 +- EonaCat.Connections/EonaCat.ico | Bin 0 -> 254014 bytes EonaCat.Connections/EonaCat.png | Bin 0 -> 89562 bytes .../EventArguments/ConnectionEventArgs.cs | 18 +- .../EventArguments/DataReceivedEventArgs.cs | 5 +- .../EventArguments/ErrorEventArgs.cs | 4 +- .../EventArguments/PingEventArgs.cs | 12 + EonaCat.Connections/Helpers/AesKeyExchange.cs | 55 +- EonaCat.Connections/Helpers/StringHelper.cs | 20 + EonaCat.Connections/Helpers/StringHelpers.cs | 31 - EonaCat.Connections/Helpers/TcpSeparators.cs | 36 + EonaCat.Connections/IClientPlugin.cs | 17 - EonaCat.Connections/IServerPlugin.cs | 55 - EonaCat.Connections/Models/Configuration.cs | 100 +- EonaCat.Connections/Models/Connection.cs | 165 +- EonaCat.Connections/Models/FramingMode.cs | 9 + .../Models/ProcessedMessage.cs | 13 + .../Models/ProcessedTextMessage.cs | 9 + EonaCat.Connections/Models/ProtocolType.cs | 8 + EonaCat.Connections/Models/Stats.cs | 3 - EonaCat.Connections/NetworkClient.cs | 1506 +++++++++++++---- EonaCat.Connections/NetworkServer.cs | 1239 ++++++++++---- .../Plugins/Client/ClientHttpMetricsPlugin.cs | 112 -- .../Plugins/Server/HttpMetricsPlugin.cs | 106 -- .../Plugins/Server/IdleTimeoutPlugin.cs | 53 - .../Plugins/Server/MetricsPlugin.cs | 65 - .../Plugins/Server/RateLimiterPlugin.cs | 57 - .../Processors/JsonDataProcessor.cs | 470 +++-- .../Processors/JsonDataProcessorHelper.cs | 266 +++ EonaCat.Connections/ProtocolType.cs | 11 - 37 files changed, 3298 insertions(+), 1619 deletions(-) create mode 100644 EonaCat.Connections/BufferSize.cs create mode 100644 EonaCat.Connections/EonaCat.ico create mode 100644 EonaCat.Connections/EonaCat.png create mode 100644 EonaCat.Connections/EventArguments/PingEventArgs.cs create mode 100644 EonaCat.Connections/Helpers/StringHelper.cs delete mode 100644 EonaCat.Connections/Helpers/StringHelpers.cs create mode 100644 EonaCat.Connections/Helpers/TcpSeparators.cs delete mode 100644 EonaCat.Connections/IClientPlugin.cs delete mode 100644 EonaCat.Connections/IServerPlugin.cs create mode 100644 EonaCat.Connections/Models/FramingMode.cs create mode 100644 EonaCat.Connections/Models/ProcessedMessage.cs create mode 100644 EonaCat.Connections/Models/ProcessedTextMessage.cs create mode 100644 EonaCat.Connections/Models/ProtocolType.cs delete mode 100644 EonaCat.Connections/Plugins/Client/ClientHttpMetricsPlugin.cs delete mode 100644 EonaCat.Connections/Plugins/Server/HttpMetricsPlugin.cs delete mode 100644 EonaCat.Connections/Plugins/Server/IdleTimeoutPlugin.cs delete mode 100644 EonaCat.Connections/Plugins/Server/MetricsPlugin.cs delete mode 100644 EonaCat.Connections/Plugins/Server/RateLimiterPlugin.cs create mode 100644 EonaCat.Connections/Processors/JsonDataProcessorHelper.cs delete mode 100644 EonaCat.Connections/ProtocolType.cs diff --git a/EonaCat.Connections.Client/EonaCat.Connections.Client.csproj b/EonaCat.Connections.Client/EonaCat.Connections.Client.csproj index c448043..f01369b 100644 --- a/EonaCat.Connections.Client/EonaCat.Connections.Client.csproj +++ b/EonaCat.Connections.Client/EonaCat.Connections.Client.csproj @@ -11,10 +11,4 @@ - - - PreserveNewest - - - diff --git a/EonaCat.Connections.Client/Program.cs b/EonaCat.Connections.Client/Program.cs index 16e395b..639c227 100644 --- a/EonaCat.Connections.Client/Program.cs +++ b/EonaCat.Connections.Client/Program.cs @@ -1,82 +1,310 @@ using EonaCat.Connections.Models; +using EonaCat.Connections.Processors; +using System.Reflection; +using System.Text; namespace EonaCat.Connections.Client.Example { - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - public class Program { - private static NetworkClient _client; + private const bool UseProcessor = true; + private const bool IsHeartBeatEnabled = false; + private static List _clients = new List(); + private static long clientCount = 1; + private static long clientsConnected = 0; + private static string _jsonContent; + + //public static string SERVER_IP = "10.40.11.22"; + public static string SERVER_IP = "127.0.0.1"; + + private static Dictionary> _clientsProcessors = new Dictionary>(); + private static bool testDataProcessor; + + public static bool WaitForMessage { get; private set; } + public static bool PressEnterForNextMessage { get; private set; } + public static bool ToConsoleOnly { get; private set; } = true; + public static bool UseJson { get; private set; } = true; + public static bool TESTBYTES { get; private set; } + public static bool UseJsonProcessorTest { get; private set; } = false; public static async Task Main(string[] args) { - await CreateClientAsync().ConfigureAwait(false); + for (long i = 0; i < clientCount; i++) + { + var clientName = $"User {i}"; + var client = await CreateClientAsync().ConfigureAwait(false); + _clients.Add(client); + + if (testDataProcessor) + { + _clientsProcessors[client] = new JsonDataProcessor(); + } + else + { + _ = StartClientAsync(clientName, client); + } + } while (true) { - if (!_client.IsConnected) - { - await Task.Delay(1000).ConfigureAwait(false); - continue; - } + string message = string.Empty; - Console.Write("Enter message to send (or 'exit' to quit): "); - var message = Console.ReadLine(); + if (WaitForMessage) + { + Console.Write("Enter message to send (or 'exit' to quit): "); + message = Console.ReadLine(); + } if (!string.IsNullOrEmpty(message) && message.Equals("exit", StringComparison.OrdinalIgnoreCase)) { - await _client.DisconnectAsync().ConfigureAwait(false); + foreach (var client in _clients) + { + await client.DisconnectClientAsync().ConfigureAwait(false); + } break; } + var jsonUrl = "https://samples.json-format.com/employees/json/employees_500KB.json"; + + try + { + if (string.IsNullOrEmpty(_jsonContent) && UseJson) + { + using var httpClient = new HttpClient(); + _jsonContent = await httpClient.GetStringAsync(jsonUrl); + + var jsonSize = Encoding.UTF8.GetByteCount(_jsonContent); + WriteToLog($"Using large JSON file (size: {jsonSize / 1024 / 1024} MB)"); + } + + if (UseJson) + { + message = _jsonContent; + } + + if (UseJsonProcessorTest) + { + foreach (var client in _clients) + { + var processor = _clientsProcessors[client]; + processor.OnProcessTextMessage += (sender, e) => + { + WriteToLog($"Processed message from {e.ClientName}: {e.Text}"); + }; + processor.OnProcessMessage += (sender, e) => + { + WriteToLog($"Processed JSON message from {e.ClientName} ({e.ClientEndpoint}): {e.RawData}"); + }; + processor.MaxAllowedBufferSize = 10 * 1024 * 1024; // 10 MB + processor.MaxMessagesPerBatch = 5; + var json = _jsonContent; + + while (true) + { + processor.Process(json, "TestClient"); + await Task.Delay(100).ConfigureAwait(false); + } + } + } + } + catch (Exception exception) + { + WriteToLog($"Failed to download large JSON file: {exception.Message}"); + } + if (!string.IsNullOrEmpty(message)) { - await _client.SendAsync(message).ConfigureAwait(false); + foreach (var client in _clients) + { + if (TESTBYTES) + { + var bytes = new byte[] { 0x00, 0x04, 0x31, 0x32, 0x30, 0x30 }; + await client.SendAsync(bytes).ConfigureAwait(false); + } + else + { + await client.SendAsync(message).ConfigureAwait(false); + } + } } + + await Task.Delay(1000).ConfigureAwait(false); } } - private static async Task CreateClientAsync() + private static async Task StartClientAsync(string clientName, NetworkClient client) + { + await client.ConnectAsync().ConfigureAwait(false); + + // Send nickname + await client.SendNicknameAsync(clientName); + + // Send a message + await client.SendAsync($"Hello server, my name is {clientName}!"); + } + + private static async Task CreateClientAsync() { var config = new Configuration { Protocol = ProtocolType.TCP, - Host = "127.0.0.1", + Host = SERVER_IP, Port = 1111, - UseSsl = true, - UseAesEncryption = true, + UseSsl = false, + UseAesEncryption = false, + EnableHeartbeat = IsHeartBeatEnabled, AesPassword = "EonaCat.Connections.Password", Certificate = new System.Security.Cryptography.X509Certificates.X509Certificate2("client.pfx", "p@ss"), }; - _client = new NetworkClient(config); - - _client.OnGeneralError += (sender, e) => - Console.WriteLine($"Error: {e.Message}"); + var client = new NetworkClient(config); // Subscribe to events - _client.OnConnected += async (sender, e) => + client.OnConnected += (sender, e) => { + WriteToLog($"Connected to server at {e.RemoteEndPoint}"); Console.WriteLine($"Connected to server at {e.RemoteEndPoint}"); - - // Set nickname - await _client.SendNicknameAsync("TestUser"); - - // Send a message - await _client.SendAsync("Hello server!"); + Console.Title = $"Total clients {++clientsConnected}"; }; - _client.OnDataReceived += (sender, e) => - Console.WriteLine($"Server says: {(e.IsBinary ? $"{e.Data.Length} bytes" : e.StringData)}"); - - _client.OnDisconnected += (sender, e) => + if (UseProcessor) { - Console.WriteLine("Disconnected from server"); + _clientsProcessors[client] = new JsonDataProcessor(); + _clientsProcessors[client].OnError += (sender, e) => + { + Console.WriteLine($"Processor error: {e.Message}"); + }; + + _clientsProcessors[client].OnMessageError += (sender, e) => + { + Console.WriteLine($"Processor message error: {e.Message}"); + }; + + _clientsProcessors[client].OnProcessTextMessage += (sender, e) => + { + Console.WriteLine($"Processed text message from {e.ClientName}: {e.Text}"); + }; + + _clientsProcessors[client].OnProcessMessage += (sender, e) => + { + ProcessMessage(e.RawData, e.ClientName, e.ClientEndpoint ?? "Unknown endpoint"); + }; + } + + client.OnDataReceived += (sender, e) => + { + if (UseProcessor) + { + _clientsProcessors[client].Process(e, currentClientName: e.Nickname); + return; + } + else + { + WriteToLog($"Server says: {(e.IsBinary ? $"{e.Data.Length} bytes" : "We got a json message")}"); + Console.WriteLine($"{e.StringData}"); + + if (PressEnterForNextMessage) + { + Console.ReadKey(); + } + } }; - Console.WriteLine("Connecting to server..."); - await _client.ConnectAsync(); + client.OnDisconnected += (sender, e) => + { + var message = string.Empty; + if (e.Reason == DisconnectReason.LocalClosed) + { + if (e.Exception != null) + { + message = $"Disconnected from server (local close). Exception: {e.Exception.Message}"; + } + else + { + message = "Disconnected from server (local close)."; + } + } + else + { + if (e.Exception != null) + { + message = $"Disconnected from server (remote close). Reason: {e.Reason}. Exception: {e.Exception.Message}"; + } + else + { + message = $"Disconnected from server (remote close). Reason: {e.Reason}"; + } + } + + WriteToLog(message); + Console.WriteLine(message); + Console.Title = $"Total clients {--clientsConnected}"; + }; + + client.OnEncryptionError += Client_OnEncryptionError; + client.OnGeneralError += Client_OnGeneralError; + client.OnSslError += Client_OnSslError; + return client; + } + + private static void ProcessMessage(object message, string clientName, string remoteEndpoint) + { + WriteToLog($"Processed message from {clientName} ({remoteEndpoint}): {message}"); + if (PressEnterForNextMessage) + { + Console.ReadKey(); + } + } + + private static void Client_OnSslError(object? sender, EventArguments.ErrorEventArgs e) + { + WriteToLog($"SSL error: {e.Message} => {e.Exception?.Message} => {e.Nickname}"); + } + + private static void Client_OnGeneralError(object? sender, EventArguments.ErrorEventArgs e) + { + WriteToLog($"General error: {e.Message} => {e.Exception?.Message} => {e.Nickname}"); + } + + private static void Client_OnEncryptionError(object? sender, EventArguments.ErrorEventArgs e) + { + WriteToLog($"Encryption error: {e.Message} => {e.Exception?.Message} => {e.Nickname}"); + } + + public static void WriteToLog(string message) + { + try + { + if (ToConsoleOnly) + { + var dateTimeNow = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); + Console.WriteLine($"{dateTimeNow}: {message}"); + return; + } + + var logFilePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? ".", "client_log.txt"); + var logMessage = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} - {message}{Environment.NewLine}"; + + if (!File.Exists(logFilePath)) + { + File.WriteAllText(logFilePath, logMessage); + return; + } + + if (new FileInfo(logFilePath).Length > 5 * 1024 * 1024) // 5 MB + { + var archiveFilePath = Path.Combine(Path.GetDirectoryName(logFilePath) ?? ".", $"client_log_{DateTime.Now:yyyyMMdd_HHmmss}.txt"); + File.Move(logFilePath, archiveFilePath); + File.WriteAllText(logFilePath, logMessage); + return; + } + + File.AppendAllText(logFilePath, logMessage); + } + catch + { + // Ignore logging errors + } } } } \ No newline at end of file diff --git a/EonaCat.Connections.Server/EonaCat.Connections.Server.csproj b/EonaCat.Connections.Server/EonaCat.Connections.Server.csproj index 9dfcd2b..f01369b 100644 --- a/EonaCat.Connections.Server/EonaCat.Connections.Server.csproj +++ b/EonaCat.Connections.Server/EonaCat.Connections.Server.csproj @@ -11,10 +11,4 @@ - - - PreserveNewest - - - diff --git a/EonaCat.Connections.Server/Program.cs b/EonaCat.Connections.Server/Program.cs index f2a87b2..4d80b42 100644 --- a/EonaCat.Connections.Server/Program.cs +++ b/EonaCat.Connections.Server/Program.cs @@ -1,22 +1,30 @@ using EonaCat.Connections.Models; +using System.Reflection; namespace EonaCat.Connections.Server.Example { - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - public class Program { + private const bool IsHeartBeatEnabled = false; private static NetworkServer _server; - public static void Main(string[] args) + public static bool WaitForMessage { get; private set; } = true; + public static bool ToConsoleOnly { get; private set; } = true; + + public static async Task Main(string[] args) { CreateServerAsync().ConfigureAwait(false); while (true) { - Console.Write("Enter message to send (or 'exit' to quit): "); - var message = Console.ReadLine(); + var message = string.Empty; + + if (WaitForMessage) + { + Console.Write("Enter message to send (or 'exit' to quit): "); + message = Console.ReadLine(); + } + if (!string.IsNullOrEmpty(message) && message.Equals("exit", StringComparison.OrdinalIgnoreCase)) { _server.Stop(); @@ -27,8 +35,10 @@ namespace EonaCat.Connections.Server.Example if (!string.IsNullOrEmpty(message)) { - _server.BroadcastAsync(message).ConfigureAwait(false); + await _server.BroadcastAsync(message).ConfigureAwait(false); } + + await Task.Delay(5000).ConfigureAwait(false); } } @@ -37,33 +47,30 @@ namespace EonaCat.Connections.Server.Example var config = new Configuration { Protocol = ProtocolType.TCP, + Host = "0.0.0.0", Port = 1111, - UseSsl = true, - UseAesEncryption = true, + UseSsl = false, + UseAesEncryption = false, MaxConnections = 100000, AesPassword = "EonaCat.Connections.Password", - Certificate = new System.Security.Cryptography.X509Certificates.X509Certificate2("server.pfx", "p@ss") + Certificate = new System.Security.Cryptography.X509Certificates.X509Certificate2("server.pfx", "p@ss"), + EnableHeartbeat = IsHeartBeatEnabled }; _server = new NetworkServer(config); // Subscribe to events _server.OnConnected += (sender, e) => - Console.WriteLine($"Client {e.ClientId} connected from {e.RemoteEndPoint}"); + { + WriteToLog($"Client {e.ClientId} connected from {e.RemoteEndPoint}"); + Console.WriteLine($"New connection from {e.RemoteEndPoint} with Client ID: {e.ClientId}"); + }; - _server.OnConnectedWithNickname += (sender, e) => - Console.WriteLine($"Client {e.ClientId} connected with nickname: {e.Nickname}"); + _server.OnConnectedWithNickname += (sender, e) => WriteToLog($"Client {e.ClientId} connected with nickname: {e.Nickname}"); _server.OnDataReceived += async (sender, e) => { - if (e.HasNickname) - { - Console.WriteLine($"Received from {e.Nickname}: {(e.IsBinary ? $"{e.Data.Length} bytes" : e.StringData)}"); - } - else - { - Console.WriteLine($"Received from {e.ClientId}: {(e.IsBinary ? $"{e.Data.Length} bytes" : e.StringData)}"); - } + WriteToLog($"Received from {e.ClientId} ({e.RemoteEndPoint.ToString()}): {(e.IsBinary ? $"{e.Data.Length} bytes" : "a message")}"); // Echo back the message if (e.IsBinary) @@ -72,23 +79,76 @@ namespace EonaCat.Connections.Server.Example } else { - await _server.SendToClientAsync(e.ClientId, $"Echo: {e.StringData}"); + await _server.SendToClientAsync(e.ClientId, e.StringData); } }; _server.OnDisconnected += (sender, e) => { - if (e.HasNickname) + var message = string.Empty; + if (e.Reason == DisconnectReason.LocalClosed) { - Console.WriteLine($"Client {e.Nickname} disconnected"); + if (e.Exception != null) + { + message = $"{e.Nickname} disconnected from server (local close). Exception: {e.Exception.Message}"; + } + else + { + message = $"{e.Nickname} disconnected from server (local close)."; + } } else { - Console.WriteLine($"Client {e.ClientId} disconnected"); + if (e.Exception != null) + { + message = $"{e.Nickname} disconnected from server (remote close). Reason: {e.Reason}. Exception: {e.Exception.Message}"; + } + else + { + message = $"{e.Nickname} disconnected from server (remote close). Reason: {e.Reason}"; + } } + WriteToLog(message); + Console.WriteLine(message); }; - await _server.StartAsync(); + _ = _server.StartAsync(); + } + + public static void WriteToLog(string message) + { + try + { + if (ToConsoleOnly) + { + var dateTimeNow = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); + Console.WriteLine($"{dateTimeNow}: {message}"); + return; + } + + var logFilePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? ".", "server_log.txt"); + var logMessage = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} - {message}{Environment.NewLine}"; + + if (!File.Exists(logFilePath)) + { + File.WriteAllText(logFilePath, logMessage); + return; + } + + if (new FileInfo(logFilePath).Length > 5 * 1024 * 1024) // 5 MB + { + var archiveFilePath = Path.Combine(Path.GetDirectoryName(logFilePath) ?? ".", $"server_log{DateTime.Now:yyyyMMdd_HHmmss}.txt"); + File.Move(logFilePath, archiveFilePath); + File.WriteAllText(logFilePath, logMessage); + return; + } + + File.AppendAllText(logFilePath, logMessage); + } + catch + { + // Ignore logging errors + } } } } \ No newline at end of file diff --git a/EonaCat.Connections.sln b/EonaCat.Connections.sln index dc18236..9494a31 100644 --- a/EonaCat.Connections.sln +++ b/EonaCat.Connections.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36408.4 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11205.157 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.Connections", "EonaCat.Connections\EonaCat.Connections.csproj", "{D3925D5A-4791-C3BB-93E0-25AC0C0ED425}" EndProject diff --git a/EonaCat.Connections/BufferSize.cs b/EonaCat.Connections/BufferSize.cs new file mode 100644 index 0000000..0d57d8e --- /dev/null +++ b/EonaCat.Connections/BufferSize.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EonaCat.Connections +{ + internal enum BufferSizeMaximum + { + Minimal = 8192, + Medium = 65536, + Large = 262144, + ExtraLarge = 1048576, + } +} diff --git a/EonaCat.Connections/DisconnectReason.cs b/EonaCat.Connections/DisconnectReason.cs index 771e7f1..e67f4e1 100644 --- a/EonaCat.Connections/DisconnectReason.cs +++ b/EonaCat.Connections/DisconnectReason.cs @@ -1,13 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace EonaCat.Connections +namespace EonaCat.Connections { - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. public enum DisconnectReason { Unknown, @@ -15,9 +7,12 @@ namespace EonaCat.Connections LocalClosed, Timeout, Error, + SSLError, ServerShutdown, Reconnect, ClientRequested, - Forced + Forced, + NoPongReceived, + ProtocolError, } -} +} \ No newline at end of file diff --git a/EonaCat.Connections/EonaCat.Connections.csproj b/EonaCat.Connections/EonaCat.Connections.csproj index d004030..53256cb 100644 --- a/EonaCat.Connections/EonaCat.Connections.csproj +++ b/EonaCat.Connections/EonaCat.Connections.csproj @@ -11,7 +11,7 @@ EonaCat (Jeroen Saey) readme.md EonaCat.Connections - 1.0.8 + 1.0.9 EonaCat (Jeroen Saey) LICENSE EonaCat.png @@ -19,6 +19,10 @@ https://git.saey.me/EonaCat/EonaCat.Connections + + + + True @@ -35,7 +39,10 @@ - + + + + diff --git a/EonaCat.Connections/EonaCat.ico b/EonaCat.Connections/EonaCat.ico new file mode 100644 index 0000000000000000000000000000000000000000..406f2659ac50172f9307c0af9038f473b50b6598 GIT binary patch literal 254014 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 z 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 IMBDrS02~H<8~^|S literal 0 HcmV?d00001 diff --git a/EonaCat.Connections/EventArguments/ConnectionEventArgs.cs b/EonaCat.Connections/EventArguments/ConnectionEventArgs.cs index 2b55fe7..f6728f8 100644 --- a/EonaCat.Connections/EventArguments/ConnectionEventArgs.cs +++ b/EonaCat.Connections/EventArguments/ConnectionEventArgs.cs @@ -3,9 +3,6 @@ using System.Net.Sockets; namespace EonaCat.Connections.EventArguments { - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - public class ConnectionEventArgs : EventArgs { public string ClientId { get; set; } @@ -34,17 +31,16 @@ namespace EonaCat.Connections.EventArguments public bool HasRemoteEndPointIPv6 => RemoteEndPoint?.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6; public bool IsRemoteEndPointLoopback => RemoteEndPoint != null && IPAddress.IsLoopback(RemoteEndPoint.Address); - - public static DisconnectReason Determine(DisconnectReason reason, Exception ex) + public static DisconnectReason Determine(DisconnectReason reason, Exception exception) { - if (ex == null) + if (exception == null) { return reason; } - if (ex is SocketException socketEx) + if (exception is SocketException socketException) { - switch (socketEx.SocketErrorCode) + switch (socketException.SocketErrorCode) { case SocketError.ConnectionReset: case SocketError.Shutdown: @@ -64,13 +60,13 @@ namespace EonaCat.Connections.EventArguments } } - if (ex is ObjectDisposedException || ex is InvalidOperationException) + if (exception is ObjectDisposedException || exception is InvalidOperationException) { return DisconnectReason.LocalClosed; } - if (ex.Message.Contains("An existing connection was forcibly closed by the remote host") - || ex.Message.Contains("The remote party has closed the transport stream")) + if (exception.Message.Contains("An existing connection was forcibly closed by the remote host") + || exception.Message.Contains("The remote party has closed the transport stream")) { return DisconnectReason.RemoteClosed; } diff --git a/EonaCat.Connections/EventArguments/DataReceivedEventArgs.cs b/EonaCat.Connections/EventArguments/DataReceivedEventArgs.cs index 57763ea..473d465 100644 --- a/EonaCat.Connections/EventArguments/DataReceivedEventArgs.cs +++ b/EonaCat.Connections/EventArguments/DataReceivedEventArgs.cs @@ -2,9 +2,6 @@ namespace EonaCat.Connections { - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - public class DataReceivedEventArgs : EventArgs { public string ClientId { get; internal set; } @@ -14,6 +11,6 @@ namespace EonaCat.Connections public DateTime Timestamp { get; internal set; } = DateTime.UtcNow; public IPEndPoint RemoteEndPoint { get; internal set; } public string Nickname { get; internal set; } - public bool HasNickname => !string.IsNullOrEmpty(Nickname); + public bool HasNickname => !string.IsNullOrWhiteSpace(Nickname); } } \ No newline at end of file diff --git a/EonaCat.Connections/EventArguments/ErrorEventArgs.cs b/EonaCat.Connections/EventArguments/ErrorEventArgs.cs index 9afc4b6..6207a4f 100644 --- a/EonaCat.Connections/EventArguments/ErrorEventArgs.cs +++ b/EonaCat.Connections/EventArguments/ErrorEventArgs.cs @@ -1,12 +1,10 @@ namespace EonaCat.Connections.EventArguments { - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - public class ErrorEventArgs : EventArgs { public string ClientId { get; set; } public string Nickname { get; set; } + public bool HasNickname => !string.IsNullOrWhiteSpace(Nickname); public Exception Exception { get; set; } public string Message { get; set; } public DateTime Timestamp { get; set; } = DateTime.UtcNow; diff --git a/EonaCat.Connections/EventArguments/PingEventArgs.cs b/EonaCat.Connections/EventArguments/PingEventArgs.cs new file mode 100644 index 0000000..cbf2fbe --- /dev/null +++ b/EonaCat.Connections/EventArguments/PingEventArgs.cs @@ -0,0 +1,12 @@ +using System.Net; + +namespace EonaCat.Connections.EventArguments +{ + public class PingEventArgs : EventArgs + { + public string Id { get; set; } + public DateTime ReceivedTime { get; set; } + public string Nickname { get; set; } + public IPEndPoint RemoteEndPoint { get; set; } + } +} \ No newline at end of file diff --git a/EonaCat.Connections/Helpers/AesKeyExchange.cs b/EonaCat.Connections/Helpers/AesKeyExchange.cs index 3e09bd4..ba876bb 100644 --- a/EonaCat.Connections/Helpers/AesKeyExchange.cs +++ b/EonaCat.Connections/Helpers/AesKeyExchange.cs @@ -3,9 +3,6 @@ using System.Text; namespace EonaCat.Connections.Helpers { - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - public static class AesKeyExchange { // 256-bit salt @@ -25,27 +22,26 @@ namespace EonaCat.Connections.Helpers private static readonly byte[] KeyConfirmationLabel = Encoding.UTF8.GetBytes("KEYCONFIRMATION"); - public static async Task EncryptDataAsync(byte[] data, Aes aes) + public static async Task EncryptDataAsync(byte[] buffer, int bytesToSend, Aes aes) { using (var encryptor = aes.CreateEncryptor()) - using (var ms = new MemoryStream()) - using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) { - await cs.WriteAsync(data, 0, data.Length); - cs.FlushFinalBlock(); - return ms.ToArray(); + byte[] encrypted = await Task.Run(() => encryptor.TransformFinalBlock(buffer, 0, bytesToSend)).ConfigureAwait(false); + + Buffer.BlockCopy(encrypted, 0, buffer, 0, encrypted.Length); + return encrypted.Length; } } - public static async Task DecryptDataAsync(byte[] data, Aes aes) + public static async Task DecryptDataAsync(byte[] buffer, int bytesToSend, Aes aes) { using (var decryptor = aes.CreateDecryptor()) - using (var ms = new MemoryStream(data)) - using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read)) - using (var result = new MemoryStream()) { - await cs.CopyToAsync(result); - return result.ToArray(); + byte[] decrypted = await Task.Run(() => decryptor.TransformFinalBlock(buffer, 0, bytesToSend)).ConfigureAwait(false); + + Buffer.BlockCopy(decrypted, 0, buffer, 0, decrypted.Length); + + return decrypted.Length; } } @@ -135,12 +131,12 @@ namespace EonaCat.Connections.Helpers Buffer.BlockCopy(keyMaterial, _aesKeySize, hmacKey, 0, _hmacKeySize); byte[] expected; - using (var h = new HMACSHA256(hmacKey)) + using (var hmac = new HMACSHA256(hmacKey)) { - h.TransformBlock(KeyConfirmationLabel, 0, KeyConfirmationLabel.Length, null, 0); - h.TransformBlock(salt, 0, salt.Length, null, 0); - h.TransformFinalBlock(iv, 0, iv.Length); - expected = h.Hash; + hmac.TransformBlock(KeyConfirmationLabel, 0, KeyConfirmationLabel.Length, null, 0); + hmac.TransformBlock(salt, 0, salt.Length, null, 0); + hmac.TransformFinalBlock(iv, 0, iv.Length); + expected = hmac.Hash; } if (!FixedTimeEquals(expected, keyConfirm)) @@ -158,7 +154,6 @@ namespace EonaCat.Connections.Helpers return aes; } - private static async Task WriteWithLengthAsync(Stream stream, byte[] data) { var byteLength = BitConverter.GetBytes(data.Length); @@ -214,31 +209,31 @@ namespace EonaCat.Connections.Helpers } } - private static byte[] RandomBytes(int n) + private static byte[] RandomBytes(int total) { - var b = new byte[n]; + var bytes = new byte[total]; using (var random = RandomNumberGenerator.Create()) { - random.GetBytes(b); + random.GetBytes(bytes); } - return b; + return bytes; } - private static bool FixedTimeEquals(byte[] a, byte[] b) + private static bool FixedTimeEquals(byte[] firstByteArray, byte[] secondByteArray) { - if (a == null || b == null || a.Length != b.Length) + if (firstByteArray == null || secondByteArray == null || firstByteArray.Length != secondByteArray.Length) { return false; } int difference = 0; - for (int i = 0; i < a.Length; i++) + for (int i = 0; i < firstByteArray.Length; i++) { - difference |= a[i] ^ b[i]; + difference |= firstByteArray[i] ^ secondByteArray[i]; } return difference == 0; } } -} +} \ No newline at end of file diff --git a/EonaCat.Connections/Helpers/StringHelper.cs b/EonaCat.Connections/Helpers/StringHelper.cs new file mode 100644 index 0000000..6a5e9b8 --- /dev/null +++ b/EonaCat.Connections/Helpers/StringHelper.cs @@ -0,0 +1,20 @@ +namespace EonaCat.Connections.Helpers +{ + internal static class StringHelper + { + public static string GetTextBetweenTags(this string value, + string startTag, + string endTag) + { + if (value.Contains(startTag) && value.Contains(endTag)) + { + int index = value.IndexOf(startTag) + startTag.Length; + return value.Substring(index, value.IndexOf(endTag) - index); + } + else + { + return null; + } + } + } +} diff --git a/EonaCat.Connections/Helpers/StringHelpers.cs b/EonaCat.Connections/Helpers/StringHelpers.cs deleted file mode 100644 index f2c1a7c..0000000 --- a/EonaCat.Connections/Helpers/StringHelpers.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace EonaCat.Connections.Helpers -{ - internal class StringHelper - { - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - - public static string GetTextBetweenTags(string message, string startTag, string endTag) - { - int startIndex = message.IndexOf(startTag); - if (startIndex == -1) - { - return string.Empty; - } - - int endIndex = message.IndexOf(endTag, startIndex + startTag.Length); - if (endIndex == -1) - { - return string.Empty; - } - - int length = endIndex - startIndex - startTag.Length; - if (length < 0) - { - return string.Empty; - } - - return message.Substring(startIndex + startTag.Length, length); - } - } -} diff --git a/EonaCat.Connections/Helpers/TcpSeparators.cs b/EonaCat.Connections/Helpers/TcpSeparators.cs new file mode 100644 index 0000000..0391256 --- /dev/null +++ b/EonaCat.Connections/Helpers/TcpSeparators.cs @@ -0,0 +1,36 @@ +using System.Text; + +namespace EonaCat.Connections.Helpers +{ + public static class TcpSeparators + { + public static byte[] NewLine => Encoding.UTF8.GetBytes("\n"); + public static byte[] CarriageReturnNewLine => Encoding.UTF8.GetBytes("\r\n"); + public static byte[] CarriageReturn => Encoding.UTF8.GetBytes("\r"); + public static byte[] Tab => Encoding.UTF8.GetBytes("\t"); + public static byte[] Space => Encoding.UTF8.GetBytes(" "); + public static byte[] Colon => Encoding.UTF8.GetBytes(":"); + public static byte[] Comma => Encoding.UTF8.GetBytes(","); + public static byte[] SemiColon => Encoding.UTF8.GetBytes(";"); + public static byte[] Equal => Encoding.UTF8.GetBytes("="); + public static byte[] Ampersand => Encoding.UTF8.GetBytes("&"); + public static byte[] QuestionMark => Encoding.UTF8.GetBytes("?"); + public static byte[] Slash => Encoding.UTF8.GetBytes("/"); + public static byte[] BackSlash => Encoding.UTF8.GetBytes("\\"); + public static byte[] Dot => Encoding.UTF8.GetBytes("."); + public static byte[] Dash => Encoding.UTF8.GetBytes("-"); + public static byte[] Underscore => Encoding.UTF8.GetBytes("_"); + public static byte[] Pipe => Encoding.UTF8.GetBytes("|"); + public static byte[] ExclamationMark => Encoding.UTF8.GetBytes("!"); + public static byte[] At => Encoding.UTF8.GetBytes("@"); + public static byte[] Hash => Encoding.UTF8.GetBytes("#"); + public static byte[] Dollar => Encoding.UTF8.GetBytes("$"); + public static byte[] Percent => Encoding.UTF8.GetBytes("%"); + public static byte[] Caret => Encoding.UTF8.GetBytes("^"); + public static byte[] Asterisk => Encoding.UTF8.GetBytes("*"); + public static byte[] OpenParenthesis => Encoding.UTF8.GetBytes("("); + public static byte[] CloseParenthesis => Encoding.UTF8.GetBytes(")"); + public static byte[] OpenBracket => Encoding.UTF8.GetBytes("["); + public static byte[] CloseBracket => Encoding.UTF8.GetBytes("]"); + } +} \ No newline at end of file diff --git a/EonaCat.Connections/IClientPlugin.cs b/EonaCat.Connections/IClientPlugin.cs deleted file mode 100644 index e07ff23..0000000 --- a/EonaCat.Connections/IClientPlugin.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace EonaCat.Connections -{ - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - - public interface IClientPlugin - { - string Name { get; } - - void OnClientStarted(NetworkClient client); - void OnClientConnected(NetworkClient client); - void OnClientDisconnected(NetworkClient client, DisconnectReason reason, Exception exception); - void OnDataReceived(NetworkClient client, byte[] data, string stringData, bool isBinary); - void OnError(NetworkClient client, Exception exception, string message); - void OnClientStopped(NetworkClient client); - } -} diff --git a/EonaCat.Connections/IServerPlugin.cs b/EonaCat.Connections/IServerPlugin.cs deleted file mode 100644 index e3aa103..0000000 --- a/EonaCat.Connections/IServerPlugin.cs +++ /dev/null @@ -1,55 +0,0 @@ -using EonaCat.Connections.Models; - -namespace EonaCat.Connections -{ - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - - ///

- /// Defines the contract for plugins that extend the behavior of the NetworkServer. - /// Implement this interface to hook into server events such as - /// client connections, disconnections, message handling, and lifecycle events. - /// - public interface IServerPlugin - { - /// - /// Gets the unique name of this plugin (used for logging/error reporting). - /// - string Name { get; } - - /// - /// Called when the server has started successfully. - /// - /// The server instance that started. - void OnServerStarted(NetworkServer server); - - /// - /// Called when the server has stopped. - /// - /// The server instance that stopped. - void OnServerStopped(NetworkServer server); - - /// - /// Called when a client successfully connects. - /// - /// The connected client. - void OnClientConnected(Connection client); - - /// - /// Called when a client disconnects. - /// - /// The client that disconnected. - /// The reason for disconnection. - /// Optional exception if the disconnect was caused by an error. - void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception); - - /// - /// Called when data is received from a client. - /// - /// The client that sent the data. - /// The raw bytes received. - /// The decoded string (if text-based, otherwise null). - /// True if the message is binary data, false if text. - void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary); - } -} diff --git a/EonaCat.Connections/Models/Configuration.cs b/EonaCat.Connections/Models/Configuration.cs index 9b239fe..84ecf5a 100644 --- a/EonaCat.Connections/Models/Configuration.cs +++ b/EonaCat.Connections/Models/Configuration.cs @@ -4,14 +4,23 @@ using System.Security.Cryptography.X509Certificates; namespace EonaCat.Connections.Models { - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - public class Configuration { + public event EventHandler OnLog; + public List TrustedThumbprints = new List { "31446234774e63fed4bc252cf466ac1bed050439", "4e34475f8618f4cbd65a9852b8bf45e740a52fff", "b0d0de15cfe045547aeb403d0e9df8095a88a32a" }; public bool EnableAutoReconnect { get; set; } = true; - public int ReconnectDelayMs { get; set; } = 5000; + public int ReconnectDelayInSeconds { get; set; } = 5; public int MaxReconnectAttempts { get; set; } = 0; // 0 means unlimited attempts + public int SSLMaxRetries { get; set; } = 0; // 0 means unlimited attempts + public int SSLTimeoutInSeconds { get; set; } = 10; + public int SSLRetryDelayInSeconds { get; set; } = 2; + + public FramingMode MessageFraming { get; set; } = FramingMode.None; + public byte[] Delimiter { get; internal set; } = Helpers.TcpSeparators.Percent; + public bool HasDelimiter => MessageFraming == FramingMode.Delimiter && Delimiter != null && Delimiter.Length > 0; + + public const string PING_VALUE = "¯"; + public const string PONG_VALUE = "‰"; public ProtocolType Protocol { get; set; } = ProtocolType.TCP; public int Port { get; set; } = 8080; @@ -19,63 +28,120 @@ namespace EonaCat.Connections.Models public bool UseSsl { get; set; } = false; public X509Certificate2 Certificate { get; set; } public bool UseAesEncryption { get; set; } = false; - public int BufferSize { get; set; } = 8192; + public int BufferSize { get; set; } = (int)BufferSizeMaximum.Medium; public int MaxConnections { get; set; } = 100000; public TimeSpan ConnectionTimeout { get; set; } = TimeSpan.FromSeconds(30); public bool EnableKeepAlive { get; set; } = true; public bool EnableNagle { get; set; } = false; // For testing purposes, allow self-signed certificates - public bool IsSelfSignedEnabled { get; set; } = true; + public bool IsSelfSignedEnabled { get; set; } + public string AesPassword { get; set; } + public bool CheckAgainstInternalTrustedCertificates { get; private set; } = true; public bool CheckCertificateRevocation { get; set; } public bool MutuallyAuthenticate { get; set; } = true; + public double ClientTimeoutInMinutes { get; set; } = 10; + public bool EnableHeartbeat { get; set; } + public bool UseBigEndian { get; set; } + internal int HeartbeatIntervalSeconds { get; set; } = 5; + public bool EnablePingPongLogs { get; set; } + public int MAX_MESSAGE_SIZE { get; set; } = 100 * 1024 * 1024; // 100 MB + public bool DisconectOnMissedPong { get; set; } internal RemoteCertificateValidationCallback GetRemoteCertificateValidationCallback() { return CertificateValidation; } + public void DisableInternalCertificateCheck() + { + CheckAgainstInternalTrustedCertificates = false; + OnLog?.Invoke(this, "Internal certificate check disabled."); + } + private bool CertificateValidation(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) { - var sw = Stopwatch.StartNew(); + var stopwatch = Stopwatch.StartNew(); try { if (IsSelfSignedEnabled) { + OnLog?.Invoke(this, $"WARNING: Accepting all invalid certificates: {certificate?.Subject}"); return true; } + if (chain != null) + { + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.VerificationFlags = + X509VerificationFlags.IgnoreCertificateAuthorityRevocationUnknown | + X509VerificationFlags.IgnoreEndRevocationUnknown | + X509VerificationFlags.AllowUnknownCertificateAuthority; + + chain.Build((X509Certificate2)certificate); + + foreach (var status in chain.ChainStatus) + { + OnLog?.Invoke(this, $"ChainStatus: {status.Status} - {status.StatusInformation}"); + } + } + if (sslPolicyErrors == SslPolicyErrors.None) { + OnLog?.Invoke(this, $"Certificate validation succeeded in {stopwatch.ElapsedMilliseconds} ms"); return true; } + if (CheckAgainstInternalTrustedCertificates && certificate is X509Certificate2 cert2) + { + string thumbprint = cert2.Thumbprint?.Replace(" ", "").ToLowerInvariant(); + if (thumbprint != null && TrustedThumbprints.Contains(thumbprint)) + { + OnLog?.Invoke(this, $"Trusted thumbprint matched: {thumbprint}"); + return true; + } + OnLog?.Invoke(this, $"Certificate thumbprint {thumbprint} not trusted (Validation took {stopwatch.ElapsedMilliseconds} ms)"); + return false; + } + if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors) && chain != null) { + bool fatal = false; foreach (var status in chain.ChainStatus) { - if (status.Status == X509ChainStatusFlags.RevocationStatusUnknown || - status.Status == X509ChainStatusFlags.OfflineRevocation) - { - continue; - } - if (status.Status == X509ChainStatusFlags.Revoked) { - return false; + OnLog?.Invoke(this, $"Certificate revoked: {status.StatusInformation}"); + fatal = true; + break; } - return false; + if (status.Status == X509ChainStatusFlags.NotSignatureValid) + { + OnLog?.Invoke(this, $"Invalid signature: {status.StatusInformation}"); + fatal = true; + break; + } } - return true; + + if (!fatal) + { + OnLog?.Invoke(this, $"Certificate accepted (ignoring minor chain warnings)"); + return true; + } + + OnLog?.Invoke(this, $"Certificate validation failed (Validation took {stopwatch.ElapsedMilliseconds} ms)"); + return false; } + + OnLog?.Invoke(this, $"Certificate rejected: {sslPolicyErrors} (Validation took {stopwatch.ElapsedMilliseconds} ms)"); return false; } finally { - sw.Stop(); + stopwatch.Stop(); } } } diff --git a/EonaCat.Connections/Models/Connection.cs b/EonaCat.Connections/Models/Connection.cs index 2efa4a5..a29bdc3 100644 --- a/EonaCat.Connections/Models/Connection.cs +++ b/EonaCat.Connections/Models/Connection.cs @@ -4,9 +4,6 @@ using System.Security.Cryptography; namespace EonaCat.Connections.Models { - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - public class Connection { public string Id { get; set; } @@ -16,6 +13,7 @@ namespace EonaCat.Connections.Models public Stream Stream { get; set; } private string _nickName; + public string Nickname { get @@ -42,8 +40,160 @@ namespace EonaCat.Connections.Models public bool HasNickname => !string.IsNullOrWhiteSpace(_nickName) && _nickName != Id; - public DateTime ConnectedAt { get; set; } - public DateTime LastActive { get; set; } + public bool IsConnected + { + get + { + try + { + if (TcpClient != null && TcpClient.Client != null) + { + return !(TcpClient.Client.Poll(1, SelectMode.SelectRead) && TcpClient.Client.Available == 0); + } + else if (UdpClient != null) + { + return true; + } + else + { + return false; + } + } + catch + { + return false; + } + } + } + + public DateTime ConnectedAt { get; internal set; } + public DateTime LastActive { get; internal set; } + public DateTime DisconnectionTime { get; internal set; } + public DateTime LastDataSent { get; internal set; } + public DateTime LastDataReceived { get; internal set; } + + public int IdleTimeInSeconds() + { + var idleTime = IdleTime(); + return (int)idleTime.TotalSeconds; + } + + public int IdleTimeInMinutes() + { + var idleTime = IdleTime(); + return (int)idleTime.TotalMinutes; + } + + public int IdleTimeInHours() + { + var idleTime = IdleTime(); + return (int)idleTime.TotalHours; + } + + public int IdleTimeInDays() + { + var idleTime = IdleTime(); + return (int)idleTime.TotalDays; + } + + public TimeSpan IdleTime() + { + return DateTime.UtcNow - LastActive; + } + + public string IdleTimeFormatted(bool includeDays = true, bool includeHours = true, bool includeMinutes = true, bool includeSeconds = true, bool includeMilliseconds = true) + { + var idleTime = IdleTime(); + var parts = new List(); + + if (includeDays) + { + parts.Add($"{(int)idleTime.TotalDays:D2}d"); + } + + if (includeHours) + { + parts.Add($"{idleTime.Hours:D2}h"); + } + + if (includeMinutes) + { + parts.Add($"{idleTime.Minutes:D2}m"); + } + + if (includeSeconds) + { + parts.Add($"{idleTime.Seconds:D2}s"); + } + + if (includeMilliseconds) + { + parts.Add($"{idleTime.Milliseconds:D3}ms"); + } + return string.Join(" ", parts); + } + + public int ConnectedTimeInSeconds() + { + var connectedTime = DateTime.UtcNow - ConnectedAt; + return (int)connectedTime.TotalSeconds; + } + + public int ConnectedTimeInMinutes() + { + var connectedTime = DateTime.UtcNow - ConnectedAt; + return (int)connectedTime.TotalMinutes; + } + + public int ConnectedTimeInHours() + { + var connectedTime = DateTime.UtcNow - ConnectedAt; + return (int)connectedTime.TotalHours; + } + + public int ConnectedTimeInDays() + { + var connectedTime = DateTime.UtcNow - ConnectedAt; + return (int)connectedTime.TotalDays; + } + + public TimeSpan ConnectedTime() + { + return DateTime.UtcNow - ConnectedAt; + } + + public string ConnectedTimeFormatted(bool includeDays = true, bool includeHours = true, bool includeMinutes = true, bool includeSeconds = true, bool includeMilliseconds = true) + { + var connectedTime = ConnectedTime(); + var parts = new List(); + + if (includeDays) + { + parts.Add($"{(int)connectedTime.TotalDays:D2}d"); + } + + if (includeHours) + { + parts.Add($"{connectedTime.Hours:D2}h"); + } + + if (includeMinutes) + { + parts.Add($"{connectedTime.Minutes:D2}m"); + } + + if (includeSeconds) + { + parts.Add($"{connectedTime.Seconds:D2}s"); + } + + if (includeMilliseconds) + { + parts.Add($"{connectedTime.Milliseconds:D3}ms"); + } + return string.Join(" ", parts); + } + public bool IsSecure { get; set; } public bool IsEncrypted { get; set; } public Aes AesEncryption { get; set; } @@ -54,12 +204,17 @@ namespace EonaCat.Connections.Models public long BytesSent => Interlocked.Read(ref _bytesSent); public void AddBytesReceived(long count) => Interlocked.Add(ref _bytesReceived, count); + public void AddBytesSent(long count) => Interlocked.Add(ref _bytesSent, count); public SemaphoreSlim SendLock { get; } = new SemaphoreSlim(1, 1); public SemaphoreSlim ReadLock { get; } = new SemaphoreSlim(1, 1); + internal Task ReceiveTask { get; set; } private int _disconnected; + public bool MarkDisconnected() => Interlocked.Exchange(ref _disconnected, 1) == 0; + + public Dictionary Metadata { get; } = new Dictionary(); } } \ No newline at end of file diff --git a/EonaCat.Connections/Models/FramingMode.cs b/EonaCat.Connections/Models/FramingMode.cs new file mode 100644 index 0000000..e5eb285 --- /dev/null +++ b/EonaCat.Connections/Models/FramingMode.cs @@ -0,0 +1,9 @@ +namespace EonaCat.Connections.Models +{ + public enum FramingMode + { + None, + Delimiter, + LengthPrefixed + } +} \ No newline at end of file diff --git a/EonaCat.Connections/Models/ProcessedMessage.cs b/EonaCat.Connections/Models/ProcessedMessage.cs new file mode 100644 index 0000000..2e9a070 --- /dev/null +++ b/EonaCat.Connections/Models/ProcessedMessage.cs @@ -0,0 +1,13 @@ +namespace EonaCat.Connections.Models +{ + public class ProcessedMessage + { + public TData Data { get; set; } + public string RawData { get; set; } + public string ClientName { get; set; } + public string? ClientEndpoint { get; set; } + public bool HasClientEndpoint => !string.IsNullOrEmpty(ClientEndpoint); + public bool HasRawData => !string.IsNullOrEmpty(RawData); + public bool HasObject => Data != null; + } +} \ No newline at end of file diff --git a/EonaCat.Connections/Models/ProcessedTextMessage.cs b/EonaCat.Connections/Models/ProcessedTextMessage.cs new file mode 100644 index 0000000..10fae02 --- /dev/null +++ b/EonaCat.Connections/Models/ProcessedTextMessage.cs @@ -0,0 +1,9 @@ +namespace EonaCat.Connections.Models +{ + public class ProcessedTextMessage + { + public string Text { get; set; } + public string ClientName { get; set; } + public string? ClientEndpoint { get; set; } + } +} \ No newline at end of file diff --git a/EonaCat.Connections/Models/ProtocolType.cs b/EonaCat.Connections/Models/ProtocolType.cs new file mode 100644 index 0000000..f37947e --- /dev/null +++ b/EonaCat.Connections/Models/ProtocolType.cs @@ -0,0 +1,8 @@ +namespace EonaCat.Connections.Models +{ + public enum ProtocolType + { + TCP, + UDP + } +} \ No newline at end of file diff --git a/EonaCat.Connections/Models/Stats.cs b/EonaCat.Connections/Models/Stats.cs index aa1150d..064b879 100644 --- a/EonaCat.Connections/Models/Stats.cs +++ b/EonaCat.Connections/Models/Stats.cs @@ -1,8 +1,5 @@ namespace EonaCat.Connections { - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - public class Stats { public int ActiveConnections { get; set; } diff --git a/EonaCat.Connections/NetworkClient.cs b/EonaCat.Connections/NetworkClient.cs index 71f4c63..d608165 100644 --- a/EonaCat.Connections/NetworkClient.cs +++ b/EonaCat.Connections/NetworkClient.cs @@ -1,20 +1,20 @@ using EonaCat.Connections.EventArguments; using EonaCat.Connections.Helpers; using EonaCat.Connections.Models; +using System.Buffers; using System.Net; using System.Net.Security; using System.Net.Sockets; +using System.Security.Authentication; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using ErrorEventArgs = EonaCat.Connections.EventArguments.ErrorEventArgs; +using ProtocolType = EonaCat.Connections.Models.ProtocolType; namespace EonaCat.Connections { - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - - public class NetworkClient : IDisposable + public class NetworkClient : IAsyncDisposable, IDisposable { private readonly Configuration _config; private TcpClient _tcpClient; @@ -24,6 +24,12 @@ namespace EonaCat.Connections private CancellationTokenSource _cancellation; private bool _isConnected; + private Task _pingTask; + private Task _pongTask; + + private CancellationTokenSource _pingCancellation; + private CancellationTokenSource _pongCancellation; + public bool IsConnected => _isConnected; public bool IsSecure => _config != null && (_config.UseSsl || _config.UseAesEncryption); public bool IsEncrypted => _config != null && _config.UseAesEncryption; @@ -31,302 +37,941 @@ namespace EonaCat.Connections private readonly SemaphoreSlim _sendLock = new(1, 1); private readonly SemaphoreSlim _connectLock = new(1, 1); - private readonly SemaphoreSlim _readLock = new(1, 1); + + public event EventHandler OnLog; public DateTime ConnectionTime { get; private set; } - public DateTime StartTime { get; set; } public TimeSpan Uptime => DateTime.UtcNow - ConnectionTime; + public DateTime LastActive { get; internal set; } + + public int IdleTimeInSeconds() + { + var idleTime = IdleTime(); + return (int)idleTime.TotalSeconds; + } + + public int IdleTimeInMinutes() + { + var idleTime = IdleTime(); + return (int)idleTime.TotalMinutes; + } + + public int IdleTimeInHours() + { + var idleTime = IdleTime(); + return (int)idleTime.TotalHours; + } + + public int IdleTimeInDays() + { + var idleTime = IdleTime(); + return (int)idleTime.TotalDays; + } + + public TimeSpan IdleTime() + { + return DateTime.UtcNow - LastActive; + } + + public string IdleTimeFormatted(bool includeDays = true, bool includeHours = true, bool includeMinutes = true, bool includeSeconds = true, bool includeMilliseconds = true) + { + var idleTime = IdleTime(); + var parts = new List(); + + if (includeDays) + { + parts.Add($"{(int)idleTime.TotalDays:D2}d"); + } + + if (includeHours) + { + parts.Add($"{idleTime.Hours:D2}h"); + } + + if (includeMinutes) + { + parts.Add($"{idleTime.Minutes:D2}m"); + } + + if (includeSeconds) + { + parts.Add($"{idleTime.Seconds:D2}s"); + } + + if (includeMilliseconds) + { + parts.Add($"{idleTime.Milliseconds:D3}ms"); + } + return string.Join(" ", parts); + } + + public int ConnectedTimeInSeconds() + { + var connectedTime = DateTime.UtcNow - ConnectionTime; + return (int)connectedTime.TotalSeconds; + } + + public int ConnectedTimeInMinutes() + { + var connectedTime = DateTime.UtcNow - ConnectionTime; + return (int)connectedTime.TotalMinutes; + } + + public int ConnectedTimeInHours() + { + var connectedTime = DateTime.UtcNow - ConnectionTime; + return (int)connectedTime.TotalHours; + } + + public int ConnectedTimeInDays() + { + var connectedTime = DateTime.UtcNow - ConnectionTime; + return (int)connectedTime.TotalDays; + } + + public TimeSpan ConnectedTime() + { + return DateTime.UtcNow - ConnectionTime; + } + + public string ConnectedTimeFormatted(bool includeDays = true, bool includeHours = true, bool includeMinutes = true, bool includeSeconds = true, bool includeMilliseconds = true) + { + var connectedTime = ConnectedTime(); + var parts = new List(); + + if (includeDays) + { + parts.Add($"{(int)connectedTime.TotalDays:D2}d"); + } + + if (includeHours) + { + parts.Add($"{connectedTime.Hours:D2}h"); + } + + if (includeMinutes) + { + parts.Add($"{connectedTime.Minutes:D2}m"); + } + + if (includeSeconds) + { + parts.Add($"{connectedTime.Seconds:D2}s"); + } + + if (includeMilliseconds) + { + parts.Add($"{connectedTime.Milliseconds:D3}ms"); + } + return string.Join(" ", parts); + } private bool _disposed; + private bool _stopAutoReconnecting; + public event EventHandler OnConnected; + + public event EventHandler OnNicknameSend; + public event EventHandler OnDataReceived; + public event EventHandler OnDisconnected; + public event EventHandler OnSslError; + public event EventHandler OnEncryptionError; + public event EventHandler OnGeneralError; - private readonly List _plugins = new(); + public event EventHandler OnPingResponse; + public event EventHandler OnPongMissed; public NetworkClient(Configuration config) { _config = config ?? throw new ArgumentNullException(nameof(config)); } - public async Task ConnectAsync() + public async Task ConnectAsync() { - await _connectLock.WaitAsync(); + await _connectLock.WaitAsync().ConfigureAwait(false); + try { + await CleanupAsync().ConfigureAwait(false); _cancellation = new CancellationTokenSource(); - + if (_config.Protocol == ProtocolType.TCP) { - await ConnectTcpAsync(); + var result = await ConnectTcpAsync().ConfigureAwait(false); + + if (!result) + { + throw new Exception("Failed to connect via TCP"); + } } else { - await ConnectUdpAsync(); + var result = await ConnectUdpAsync().ConfigureAwait(false); + if (!result) + { + throw new Exception("Failed to connect via UDP"); + } } + + // If we already had a nickname, resend it + if (!string.IsNullOrEmpty(Nickname)) + { + OnLog?.Invoke(this, "Resending nickname after reconnect"); + await SendNicknameAsync(Nickname).ConfigureAwait(false); + } + + return true; } - catch (Exception ex) + catch (Exception exception) { - OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Connection error" }); - NotifyError(ex, "General error"); + _isConnected = false; + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + Exception = exception, Message = "Connection error" + }); + if (_config.EnableAutoReconnect) { _ = Task.Run(() => AutoReconnectAsync()); } + return false; } finally { - _connectLock.Release(); + try + { + _connectLock.Release(); + } + catch + { + // Do nothing + } } } - private async Task ConnectTcpAsync() + private async Task CleanupAsync() { - _tcpClient = new TcpClient(); - await _tcpClient.ConnectAsync(_config.Host, _config.Port); - - Stream stream = _tcpClient.GetStream(); - - // Setup SSL if required - if (_config.UseSsl) + try { - try + _cancellation?.Cancel(); + } + catch + { + // Do nothing + } + + try + { + _pingCancellation?.Cancel(); + } + catch + { + // Do nothing + } + + try + { + _pongCancellation?.Cancel(); + } + catch + { + // Do nothing + } + + if (_pingTask != null) + { + try { - var sslStream = new SslStream(stream, false, userCertificateValidationCallback: _config.GetRemoteCertificateValidationCallback()); - if (_config.Certificate != null) - { - await sslStream.AuthenticateAsClientAsync( - _config.Host, - new X509CertificateCollection { _config.Certificate }, - _config.CheckCertificateRevocation - ); - } - else - { - await sslStream.AuthenticateAsClientAsync(_config.Host); - } - stream = sslStream; + await Task.WhenAny(_pingTask, Task.Delay(500)).ConfigureAwait(false); } - catch (Exception ex) + catch { - OnSslError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "SSL authentication failed" }); - return; + // Do nothing + } + } + + if (_pongTask != null) + { + try + { + await Task.WhenAny(_pongTask, Task.Delay(500)).ConfigureAwait(false); + } + catch + { + // Do nothing + } + } + + try + { + _cancellation?.Dispose(); + } + catch + { + // Do nothing + } + + try + { + _pingCancellation?.Dispose(); + } + catch + { + // Do nothing + } + + try + { + _pongCancellation?.Dispose(); + } + catch + { + // Do nothing + } + + _cancellation = null; + _pingCancellation = null; + _pongCancellation = null; + _pingTask = null; + _pongTask = null; + + try + { + _tcpClient?.Close(); + } + catch + { + // Do nothing + } + + try + { + _tcpClient?.Dispose(); + } + catch + { + // Do nothing + } + _tcpClient = null; + + try + { + _udpClient?.Close(); + } + catch + { + // Do nothing + } + + try + { + _udpClient?.Dispose(); + } + catch + { + // Do nothing + } + _udpClient = null; + + try + { + _stream?.Dispose(); + } + catch + { + // Do nothing + } + _stream = null; + + try + { + _aesEncryption?.Dispose(); + } + catch + { + // Do nothing + } + _aesEncryption = null; + + _isConnected = false; + _stopAutoReconnecting = false; + } + + private async Task ConnectTcpAsync() + { + int attempt = 0; + while (_config.SSLMaxRetries == 0 || attempt < _config.SSLMaxRetries) + { + attempt++; + try + { + _tcpClient?.Dispose(); + _tcpClient = new TcpClient(); + + _tcpClient.NoDelay = !_config.EnableNagle; + _tcpClient.ReceiveBufferSize = _config.BufferSize; + _tcpClient.SendBufferSize = _config.BufferSize; + _tcpClient.LingerState = new LingerOption(enable: true, seconds: 0); + + await _tcpClient.ConnectAsync(_config.Host, _config.Port).ConfigureAwait(false); + NetworkStream networkStream = _tcpClient.GetStream(); + + if (!_config.UseSsl) + { + _stream = networkStream; + break; + } + + var sslStream = new SslStream(networkStream, leaveInnerStreamOpen: true, _config.GetRemoteCertificateValidationCallback()); + try + { + if (_config.Certificate != null) + { + await sslStream.AuthenticateAsClientAsync(_config.Host, new X509CertificateCollection { _config.Certificate }, SslProtocols.Tls12 | SslProtocols.Tls13, _config.CheckCertificateRevocation).ConfigureAwait(false); + } + else + { + await sslStream.AuthenticateAsClientAsync(_config.Host, null, SslProtocols.Tls12 | SslProtocols.Tls13, _config.CheckCertificateRevocation).ConfigureAwait(false); + } + + _stream = sslStream; + break; + } + catch (Exception exception) + { + sslStream.Dispose(); + + if (_config.SSLMaxRetries != 0 && attempt >= _config.SSLMaxRetries) + { + throw; + } + OnLog?.Invoke(this, $"[Attempt {attempt}] SSL handshake failed for {_config.Host}:{_config.Port}: {exception.Message}"); + await Task.Delay(_config.SSLRetryDelayInSeconds * 1000).ConfigureAwait(false); + } + } + catch (Exception exception) + { + OnLog?.Invoke(this, $"[Attempt {attempt}] TCP connection failed for {_config.Host}:{_config.Port}: {exception.Message}"); + + if (_config.SSLMaxRetries != 0 && attempt >= _config.SSLMaxRetries) + { + throw; + } + await Task.Delay(_config.SSLRetryDelayInSeconds * 1000).ConfigureAwait(false); } } - // Setup AES encryption if required if (_config.UseAesEncryption) { - try - { - _aesEncryption = await AesKeyExchange.ReceiveAesKeyAsync(stream, _config.AesPassword); - } - catch (Exception ex) - { - OnEncryptionError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "AES setup failed" }); - return; - } + _aesEncryption = await AesKeyExchange.ReceiveAesKeyAsync(_stream, _config.AesPassword).ConfigureAwait(false); } - _stream = stream; _isConnected = true; ConnectionTime = DateTime.UtcNow; - OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) }); - NotifyConnected(); + OnConnected?.Invoke(this, new ConnectionEventArgs + { + ClientId = "self", + RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) + }); - // Start receiving data _ = Task.Run(() => ReceiveDataAsync(), _cancellation.Token); - } - public void RegisterPlugin(IClientPlugin plugin) - { - if (_plugins.Any(p => p.Name == plugin.Name)) - return; - - _plugins.Add(plugin); - plugin.OnClientStarted(this); - } - - public void UnregisterPlugin(IClientPlugin plugin) - { - if (_plugins.Remove(plugin)) + if (_config.EnableHeartbeat) { - plugin.OnClientStopped(this); - } - } - - private void NotifyConnected() - { - foreach (var plugin in _plugins) - { - plugin.OnClientConnected(this); - } - } - - private void NotifyDisconnected(DisconnectReason reason, Exception exception) - { - foreach (var plugin in _plugins) - { - plugin.OnClientDisconnected(this, reason, exception); - } - } - - private void NotifyData(byte[] data, string stringData, bool isBinary) - { - foreach (var plugin in _plugins) - { - plugin.OnDataReceived(this, data, stringData, isBinary); - } - } - - private void NotifyError(Exception ex, string message) - { - foreach (var plugin in _plugins) - { - plugin.OnError(this, ex, message); + _ = Task.Run(StartPingLoop, _cancellation.Token); + _ = Task.Run(StartPongLoop, _cancellation.Token); } + return true; } public string IpAddress => _config != null ? _config.Host : string.Empty; public int Port => _config != null ? _config.Port : 0; - public bool IsAutoReconnectRunning { get; private set; } + public string Nickname { get; private set; } + public bool DEBUG_DATA_SEND { get; set; } + public bool DEBUG_DATA_RECEIVED { get; set; } + public DateTime LastPongReceived { get; private set; } + public DateTime LastPingSent { get; private set; } + public bool DisconnectOnMissedPong { get; set; } + public DateTime LastDataSent { get; private set; } + public DateTime LastDataReceived { get; private set; } + public DateTime DisconnectionTime { get; private set; } - private async Task ConnectUdpAsync() + + + private async Task ConnectUdpAsync() { - _udpClient = new UdpClient(); - _udpClient.Connect(_config.Host, _config.Port); - _isConnected = true; - ConnectionTime = DateTime.UtcNow; - OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) }); + try + { + _udpClient = new UdpClient(); + _udpClient.Client.ReceiveBufferSize = _config.BufferSize; + _udpClient.Client.SendBufferSize = _config.BufferSize; + _udpClient.Connect(_config.Host, _config.Port); + _isConnected = true; + ConnectionTime = DateTime.UtcNow; + OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) }); + _ = Task.Run(() => ReceiveUdpDataAsync(), _cancellation.Token); - // Start receiving data - _ = Task.Run(() => ReceiveUdpDataAsync(), _cancellation.Token); - await Task.CompletedTask; + if (_config.EnableHeartbeat) + { + _ = Task.Run(StartPingLoop, _cancellation.Token); + _ = Task.Run(StartPongLoop, _cancellation.Token); + } + return true; + } + catch (Exception exception) + { + OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = exception, Message = "UDP connection error" }); + return false; + } } - private async Task ReceiveDataAsync() + private void StartPingLoop() { - while (!_cancellation.Token.IsCancellationRequested && _isConnected) + try { - try - { - byte[] data; + _pingCancellation?.Cancel(); + } + catch + { + // Do nothing + } - if (_config.UseAesEncryption && _aesEncryption != null) + _pingCancellation?.Dispose(); + _pingCancellation = new CancellationTokenSource(); + var token = _pingCancellation.Token; + + _pingTask = Task.Run(async () => + { + var interval = TimeSpan.FromSeconds(_config.HeartbeatIntervalSeconds); + var next = DateTime.UtcNow + interval; + + while (!token.IsCancellationRequested && _isConnected) + { + try { - var lengthBuffer = new byte[4]; - int read = await ReadExactAsync(_stream, lengthBuffer, 4, _cancellation.Token).ConfigureAwait(false); - if (read == 0) + if (_stream == null || !_stream.CanWrite) { break; } - if (BitConverter.IsLittleEndian) + var pingData = Encoding.UTF8.GetBytes(Configuration.PING_VALUE); + await WriteToStreamAsync(pingData).ConfigureAwait(false); + + if (_config.EnablePingPongLogs) { - Array.Reverse(lengthBuffer); + OnLog?.Invoke(this, $"[PING] Sent at {DateTime.UtcNow:O}"); } - int length = BitConverter.ToInt32(lengthBuffer, 0); - if (length <= 0) + var delay = next - DateTime.UtcNow; + if (delay > TimeSpan.Zero) { - throw new InvalidDataException("Invalid packet length"); + await Task.Delay(delay, token).ConfigureAwait(false); } - var encrypted = new byte[length]; - await ReadExactAsync(_stream, encrypted, length, _cancellation.Token).ConfigureAwait(false); - data = await AesKeyExchange.DecryptDataAsync(encrypted, _aesEncryption).ConfigureAwait(false); + next += interval; } - else + catch (OperationCanceledException) { - data = new byte[_config.BufferSize]; - int bytesRead; - await _readLock.WaitAsync(_cancellation.Token); - try - { - bytesRead = await _stream.ReadAsync(data, 0, data.Length, _cancellation.Token); - } - finally - { - _readLock.Release(); - } - - if (bytesRead == 0) - { - await DisconnectAsync(DisconnectReason.RemoteClosed); - return; - } - - if (bytesRead < data.Length) - { - var tmp = new byte[bytesRead]; - Array.Copy(data, tmp, bytesRead); - data = tmp; - } + break; + } + catch (Exception exception) + { + OnLog?.Invoke(this, $"[PING] Error sending ping: {exception.Message}"); + break; } - - await ProcessReceivedDataAsync(data); } - catch (IOException ioEx) - { - await DisconnectAsync(DisconnectReason.RemoteClosed, ioEx); - } - catch (SocketException sockEx) - { - await DisconnectAsync(DisconnectReason.Error, sockEx); - } - catch (OperationCanceledException) - { - await DisconnectAsync(DisconnectReason.Timeout); - } - catch (Exception ex) - { - await DisconnectAsync(DisconnectReason.Error, ex); - } - } - - await DisconnectAsync(); + }, token); } - private async Task ReadExactAsync(Stream stream, byte[] buffer, int length, CancellationToken ct) + private void StartPongLoop() { - int offset = 0; - await _readLock.WaitAsync(ct); + _pongCancellation?.Cancel(); + _pongCancellation?.Dispose(); + + _pongCancellation = new CancellationTokenSource(); + var token = _pongCancellation.Token; + + _pongTask = Task.Run(async () => + { + var interval = TimeSpan.FromSeconds(1); + var next = DateTime.UtcNow + interval; + + while (!token.IsCancellationRequested && _isConnected) + { + try + { + var elapsed = (DateTime.UtcNow - LastPongReceived).TotalSeconds; + + if (LastPongReceived != DateTime.MinValue && elapsed > _config.HeartbeatIntervalSeconds * 2) + { + if (_config.EnablePingPongLogs) + { + OnLog?.Invoke(this, "Server heartbeat timeout. Disconnecting."); + } + + OnPongMissed?.Invoke(this, new PingEventArgs + { + Id = "server", + Nickname = Nickname, + ReceivedTime = DateTime.UtcNow, + RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) + }); + + if (DisconnectOnMissedPong) + { + await DisconnectClientAsync(DisconnectReason.NoPongReceived); + break; + } + } + + var delay = next - DateTime.UtcNow; + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay, token).ConfigureAwait(false); + } + + next += interval; + } + catch (TaskCanceledException) + { + break; + } + catch (Exception exception) + { + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + Exception = exception, + Message = "Client pong watchdog failed" + }); + } + } + }, token); + } + + + private async Task ReceiveDataAsync() + { + var pooled = ArrayPool.Shared.Rent(_config.BufferSize); + var assemblyBuffer = new List(_config.BufferSize * 2); try { - while (offset < length) + while (_cancellation != null && !_cancellation.Token.IsCancellationRequested && _isConnected) { - int read = await stream.ReadAsync(buffer, offset, length - offset, ct); - if (read == 0) + int bytesRead; + + try { - return 0; + bytesRead = await _stream.ReadAsync(pooled, 0, pooled.Length, _cancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception exception) + { + await DisconnectClientAsync(DisconnectReason.Error, exception).ConfigureAwait(false); + break; + } + + if (bytesRead == 0) + { + await DisconnectClientAsync(DisconnectReason.RemoteClosed).ConfigureAwait(false); + break; + } + + if (assemblyBuffer.Count > _config.MAX_MESSAGE_SIZE) + { + OnLog?.Invoke(this, "Buffer overflow detected; dropping connection"); + await DisconnectClientAsync(DisconnectReason.Error).ConfigureAwait(false); + break; + } + + assemblyBuffer.AddRange(pooled.AsSpan(0, bytesRead).ToArray()); + LastDataReceived = DateTime.UtcNow; + + while (true) + { + var message = BuildMessage(assemblyBuffer); + if (message == null) + { + // Need more data + break; + } + + if (_config.UseAesEncryption && _aesEncryption != null) + { + try + { + await AesKeyExchange.DecryptDataAsync(message, message.Length, _aesEncryption).ConfigureAwait(false); + } + catch (Exception exception) + { + OnEncryptionError?.Invoke(this, new ErrorEventArgs { Exception = exception, Message = "AES decryption failed" }); + continue; + } + } + await ProcessReceivedDataAsync(message).ConfigureAwait(false); } - offset += read; } - return offset; } finally { - _readLock.Release(); + ArrayPool.Shared.Return(pooled, clearArray: true); } } + private byte[] BuildMessage(List buffer) + { + if (buffer == null || buffer.Count == 0) + { + return null; + } + + byte[] message = null; + bool useBigEndian = _config.UseBigEndian; + + switch (_config.MessageFraming) + { + case FramingMode.LengthPrefixed: + if (buffer.Count < 4) + { + break; + } + + var lengthBytes = buffer.Take(4).ToArray(); + if (useBigEndian && BitConverter.IsLittleEndian) + { + Array.Reverse(lengthBytes); + } + + int length = BitConverter.ToInt32(lengthBytes, 0); + if (length < 0 || length > _config.MAX_MESSAGE_SIZE) + { + OnLog?.Invoke(this, $"Invalid message length: {length}"); + buffer.Clear(); + return null; + } + if (buffer.Count < 4 + length) + { + break; + } + + message = buffer.Skip(4).Take(length).ToArray(); + buffer.RemoveRange(0, 4 + length); + break; + + case FramingMode.Delimiter: + int index = IndexOfDelimiter(buffer, _config.Delimiter); + if (index < 0) + { + break; + } + + message = buffer.Take(index).ToArray(); + buffer.RemoveRange(0, index + _config.Delimiter.Length); + break; + + case FramingMode.None: + message = buffer.ToArray(); + buffer.Clear(); + break; + } + return message; + } + + private int IndexOfDelimiter(List buffer, byte[] delimiter) + { + if (delimiter == null || delimiter.Length == 0 || buffer.Count < delimiter.Length) + { + return -1; + } + + for (int i = 0; i <= buffer.Count - delimiter.Length; i++) + { + bool match = true; + for (int j = 0; j < delimiter.Length; j++) + { + if (buffer[i + j] != delimiter[j]) { match = false; break; } + } + if (match) + { + return i; + } + } + return -1; + } + + private async Task ProcessReceivedDataAsync(byte[] data) + { + if (data == null || data.Length == 0) + { + return; + } + + string stringData = null; + bool isBinary = true; + + int realLength = Array.FindLastIndex(data, b => b != 0) + 1; + if (realLength > 0 && realLength < data.Length) + { + var trimmed = new byte[realLength]; + Buffer.BlockCopy(data, 0, trimmed, 0, realLength); + data = trimmed; + } + + try + { + stringData = Encoding.UTF8.GetString(data); + if (Encoding.UTF8.GetByteCount(stringData) == data.Length) + { + isBinary = false; + } + } + catch + { + // Not a valid UTF-8 string + } + + if (!string.IsNullOrEmpty(stringData)) + { + LastPongReceived = DateTime.UtcNow; + ProcessPingPong(ref stringData); + if (string.IsNullOrEmpty(stringData)) + { + return; + } + } + + if (DEBUG_DATA_RECEIVED) + { + if (isBinary) + { + OnLog?.Invoke(this, $"[DEBUG DATA] Received binary data: {BitConverter.ToString(data)}"); + } + else + { + OnLog?.Invoke(this, $"[DEBUG DATA] Received string data: {stringData}"); + } + } + + if (!string.IsNullOrEmpty(stringData)) + { + bool handled = await HandleCommandAsync(stringData).ConfigureAwait(false); + if (handled) + { + return; + } + } + + if (!string.IsNullOrEmpty(stringData)) + { + OnDataReceived?.Invoke(this, new DataReceivedEventArgs + { + ClientId = "server", + Data = data, + StringData = stringData, + IsBinary = isBinary, + Timestamp = DateTime.UtcNow, + RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port), + Nickname = Nickname, + }); + } + } + + private void ProcessPingPong(ref string message) + { + if (!_config.EnableHeartbeat || string.IsNullOrEmpty(message)) + { + return; + } + + if (message.Contains(Configuration.PING_VALUE)) + { + LastPingSent = DateTime.UtcNow; + if (_config.EnablePingPongLogs) + { + OnLog?.Invoke(this, "Received PING from server. Sending PONG response."); + } + SendAsync(Configuration.PONG_VALUE).ConfigureAwait(false); + message = message.Replace(Configuration.PING_VALUE, string.Empty); + } + + if (message.Contains(Configuration.PONG_VALUE)) + { + LastPongReceived = DateTime.UtcNow; + if (_config.EnablePingPongLogs) + { + OnLog?.Invoke(this, "Received PONG from server for PING"); + } + OnPingResponse?.Invoke(this, new PingEventArgs + { + Id = "server", + Nickname = Nickname, + ReceivedTime = DateTime.UtcNow, + RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) + }); + message = message.Replace(Configuration.PONG_VALUE, string.Empty); + } + } + + private async Task HandleCommandAsync(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + if (text.Equals("DISCONNECT", StringComparison.OrdinalIgnoreCase)) + { + await DisconnectClientAsync(DisconnectReason.RemoteClosed).ConfigureAwait(false); + return true; + } + return false; + } + private async Task ReceiveUdpDataAsync() { - while (!_cancellation.Token.IsCancellationRequested && _isConnected) + while (_cancellation != null && !_cancellation.Token.IsCancellationRequested && _isConnected) { try { - var result = await _udpClient.ReceiveAsync(); - await ProcessReceivedDataAsync(result.Buffer); + var result = await _udpClient.ReceiveAsync().ConfigureAwait(false); + var buffer = result.Buffer; + + if (_config.EnableHeartbeat) + { + try + { + buffer = HandleHeartbeat(buffer, out bool foundHeartbeat); + if (foundHeartbeat) + { + LastPongReceived = DateTime.UtcNow; + if (_config.EnablePingPongLogs) + { + OnLog?.Invoke(this, "Received PONG from server for PING (UDP)"); + } + } + if (buffer == null || buffer.Length == 0) + { + continue; + } + } + catch (Exception exception) + { + OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = exception, Message = "Heartbeat handling failed (UDP)" }); + } + } + await ProcessReceivedDataAsync(buffer).ConfigureAwait(false); } - catch (Exception ex) + catch (Exception exception) { - OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error receiving data" }); - NotifyError(ex, "General error"); + OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = exception, Message = "Error receiving data" }); _isConnected = false; ConnectionTime = DateTime.MinValue; _ = Task.Run(() => AutoReconnectAsync()); @@ -335,170 +980,217 @@ namespace EonaCat.Connections } } - private async Task ProcessReceivedDataAsync(byte[] data) + public async Task SendNicknameAsync(string nickname) { - try + if (string.IsNullOrWhiteSpace(nickname)) { - bool isBinary = true; - string stringData = null; + return false; + } - try + var result = await SendAsync($"[NICKNAME]{nickname}[/NICKNAME]").ConfigureAwait(false); + if (result) + { + Nickname = nickname.Trim(); + OnNicknameSend?.Invoke(this, new ConnectionEventArgs { - stringData = Encoding.UTF8.GetString(data); - if (Encoding.UTF8.GetBytes(stringData).Length == data.Length) - { - isBinary = false; - } - } - catch - { - // Keep as binary - } - - if (!isBinary && stringData != null && stringData.Equals("DISCONNECT", StringComparison.OrdinalIgnoreCase)) - { - await DisconnectAsync(DisconnectReason.RemoteClosed); - return; - } - - OnDataReceived?.Invoke(this, new DataReceivedEventArgs - { - ClientId = "server", - Data = data, - StringData = stringData, - IsBinary = isBinary + ClientId = nickname, + RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port), + Nickname = nickname }); - NotifyData(data, stringData, isBinary); - } - catch (Exception ex) - { - if (_config.UseAesEncryption) - { - OnEncryptionError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error processing data" }); - } - else - { - OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error processing data" }); - NotifyError(ex, "General error"); - } } + return result; } - public async Task SendAsync(byte[] data) + public async Task SendAsync(string message) => await SendAsync(Encoding.UTF8.GetBytes(message)).ConfigureAwait(false); + + public async Task SendAsync(byte[] data) { if (!_isConnected) { - return; + return false; } - await _sendLock.WaitAsync(); + await _sendLock.WaitAsync().ConfigureAwait(false); + try { + byte[] payload = data; if (_config.UseAesEncryption && _aesEncryption != null) { - data = await AesKeyExchange.EncryptDataAsync(data, _aesEncryption); - - var lengthPrefix = BitConverter.GetBytes(data.Length); - if (BitConverter.IsLittleEndian) - { - Array.Reverse(lengthPrefix); - } - - var framed = new byte[lengthPrefix.Length + data.Length]; - Buffer.BlockCopy(lengthPrefix, 0, framed, 0, lengthPrefix.Length); - Buffer.BlockCopy(data, 0, framed, lengthPrefix.Length, data.Length); - data = framed; + await AesKeyExchange.EncryptDataAsync(payload, payload.Length, _aesEncryption).ConfigureAwait(false); } - if (_config.Protocol == ProtocolType.TCP) + byte[] framedData = null; + switch (_config.MessageFraming) { - await _stream.WriteAsync(data, 0, data.Length); - await _stream.FlushAsync(); + case FramingMode.LengthPrefixed: + var lengthPrefix = BitConverter.GetBytes(payload.Length); + if (_config.UseBigEndian && BitConverter.IsLittleEndian) + { + Array.Reverse(lengthPrefix); + } + + framedData = new byte[lengthPrefix.Length + payload.Length]; + Buffer.BlockCopy(lengthPrefix, 0, framedData, 0, lengthPrefix.Length); + Buffer.BlockCopy(payload, 0, framedData, lengthPrefix.Length, payload.Length); + break; + + case FramingMode.Delimiter: + if (_config.Delimiter == null || _config.Delimiter.Length == 0) + { + throw new InvalidOperationException("Delimiter cannot be null or empty."); + } + + framedData = new byte[payload.Length + _config.Delimiter.Length]; + Buffer.BlockCopy(payload, 0, framedData, 0, payload.Length); + Buffer.BlockCopy(_config.Delimiter, 0, framedData, payload.Length, _config.Delimiter.Length); + break; + + case FramingMode.None: + framedData = payload; + break; } - else + + if (DEBUG_DATA_SEND) { - await _udpClient.SendAsync(data, data.Length); + OnLog?.Invoke(this, $"[DEBUG] Sending raw: {BitConverter.ToString(data)}"); + OnLog?.Invoke(this, $"[DEBUG] Sending framed: {BitConverter.ToString(framedData)}"); } + return await WriteToStreamAsync(framedData).ConfigureAwait(false); } - catch (Exception ex) + catch (Exception exception) { - if (_config.UseAesEncryption) - { - OnEncryptionError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error encrypting/sending data" }); - } - else - { - OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error sending data" }); - NotifyError(ex, "General error"); - } + OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = exception, Message = "Error sending data" }); + return false; } finally { - _sendLock.Release(); + try + { + _sendLock.Release(); + } + catch + { + // Do nothing + } } } - public async Task SendAsync(string message) - { - await SendAsync(Encoding.UTF8.GetBytes(message)); - } + private readonly SemaphoreSlim _writeLock = new(1, 1); - public async Task SendNicknameAsync(string nickname) + private async Task WriteToStreamAsync(byte[] dataToSend) { - await SendAsync($"NICKNAME:{nickname}"); + if (_stream == null || !_stream.CanWrite) + { + return false; + } + + await _writeLock.WaitAsync().ConfigureAwait(false); + + try + { + if (_config.Protocol == ProtocolType.TCP) + { + await _stream.WriteAsync(dataToSend, 0, dataToSend.Length).ConfigureAwait(false); + } + else + { + await _udpClient.SendAsync(dataToSend, dataToSend.Length).ConfigureAwait(false); + } + LastActive = DateTime.UtcNow; + LastDataSent = DateTime.UtcNow; + } + finally + { + try + { + _writeLock.Release(); + } + catch + { + // Do nothing + } + } + return true; } private async Task AutoReconnectAsync() { - if (!_config.EnableAutoReconnect) + if (!_config.EnableAutoReconnect || IsAutoReconnectRunning) { return; } - if (IsAutoReconnectRunning) + IsAutoReconnectRunning = true; + + try { - return; - } - - int attempt = 0; - - while (_config.EnableAutoReconnect && !_isConnected && (_config.MaxReconnectAttempts == 0 || attempt < _config.MaxReconnectAttempts)) - { - attempt++; - - try + bool wasConnected = false; + int attempt = 0; + while (_config.EnableAutoReconnect && (_config.MaxReconnectAttempts == 0 || attempt < _config.MaxReconnectAttempts)) { - OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Attempting to reconnect (Attempt {attempt})" }); - IsAutoReconnectRunning = true; - await ConnectAsync(); - - if (_isConnected) + if (_stopAutoReconnecting) { - OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Reconnected successfully after {attempt} attempt(s)" }); - IsAutoReconnectRunning = false; + _stopAutoReconnecting = false; break; } - } - catch - { - // Do nothing - } + + if (IsConnected) + { + wasConnected = true; + attempt = 0; + await Task.Delay(_config.ReconnectDelayInSeconds * 1000).ConfigureAwait(false); + continue; + } + + if (wasConnected) + { + OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = "Connection lost. Starting auto-reconnect attempts." }); + wasConnected = false; + } + attempt++; + + try + { + OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Attempting to reconnect (Attempt {attempt})" }); + await ConnectAsync().ConfigureAwait(false); + + if (IsConnected) + { + OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Reconnected successfully after {attempt} attempt(s)" }); + attempt = 0; + await Task.Delay(_config.ReconnectDelayInSeconds * 1000).ConfigureAwait(false); + continue; + } + } + catch (Exception exception) + { + var stringBuilder = new StringBuilder(); + stringBuilder.AppendLine($"Reconnect attempt failed: {exception.Message}"); + var inner = exception.InnerException; + + while (inner != null) + { + stringBuilder.AppendLine($"Inner exception: {inner.Message}"); inner = inner.InnerException; + } - await Task.Delay(_config.ReconnectDelayMs); + OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = exception, Message = stringBuilder.ToString() }); + } + await Task.Delay(_config.ReconnectDelayInSeconds * 1000).ConfigureAwait(false); + } } - - if (!_isConnected) + finally { - OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = "Failed to reconnect" }); + IsAutoReconnectRunning = false; } } - public async Task DisconnectAsync( - DisconnectReason reason = DisconnectReason.LocalClosed, - Exception exception = null, - bool forceDisconnection = false) + public async Task DisconnectAsync() => await DisconnectClientAsync(DisconnectReason.ClientRequested, forceDisconnection: true).ConfigureAwait(false); + + public async Task DisconnectClientAsync(DisconnectReason reason = DisconnectReason.LocalClosed, Exception exception = null, bool forceDisconnection = false) { - await _connectLock.WaitAsync(); + await _connectLock.WaitAsync().ConfigureAwait(false); + try { if (!_isConnected) @@ -508,12 +1200,43 @@ namespace EonaCat.Connections _isConnected = false; ConnectionTime = DateTime.MinValue; + _stopAutoReconnecting = forceDisconnection; - _cancellation?.Cancel(); - _tcpClient?.Close(); - _udpClient?.Close(); - _stream?.Dispose(); - _aesEncryption?.Dispose(); + + OnLog?.Invoke(this, $"Disconnecting client... {reason} {exception}"); + + try + { + _pingCancellation?.Cancel(); + } + catch + { + // Do nothing + } + + try + { + _pongCancellation?.Cancel(); + } + catch + { + // Do nothing + } + + try + { + _cancellation?.Cancel(); + } + catch + { + // Do nothing + } + + await SafeTaskCompletion(_pingTask).ConfigureAwait(false); + await SafeTaskCompletion(_pongTask).ConfigureAwait(false); + await CleanupAsync().ConfigureAwait(false); + + DisconnectionTime = DateTime.UtcNow; OnDisconnected?.Invoke(this, new ConnectionEventArgs { @@ -522,16 +1245,14 @@ namespace EonaCat.Connections Reason = ConnectionEventArgs.Determine(reason, exception), Exception = exception }); - NotifyDisconnected(reason, exception); - if (!forceDisconnection && reason != DisconnectReason.Forced) + if (!forceDisconnection && reason != DisconnectReason.Forced && _config.EnableAutoReconnect) { _ = Task.Run(() => AutoReconnectAsync()); } else { - Console.WriteLine("Auto-reconnect disabled due to forced disconnection."); - _config.EnableAutoReconnect = false; + OnLog?.Invoke(this, "Auto-reconnect disabled due to forced disconnection."); } } finally @@ -540,6 +1261,22 @@ namespace EonaCat.Connections } } + private async Task SafeTaskCompletion(Task task) + { + if (task == null) + { + return; + } + + try + { + await task.ConfigureAwait(false); + } + catch + { + // Do nothing + } + } public async ValueTask DisposeAsync() { @@ -549,29 +1286,144 @@ namespace EonaCat.Connections } _disposed = true; - - await DisconnectAsync(forceDisconnection: true); - - foreach (var plugin in _plugins.ToList()) + + try { - plugin.OnClientStopped(this); - } + await DisconnectClientAsync(forceDisconnection: true).ConfigureAwait(false); + await SafeTaskCompletion(_pingTask).ConfigureAwait(false); + await SafeTaskCompletion(_pongTask).ConfigureAwait(false); + _pingTask = null; _pongTask = null; + + try + { + _cancellation?.Cancel(); + } + catch + { + // Do nothing + } - _cancellation?.Dispose(); - _sendLock.Dispose(); - _connectLock.Dispose(); - _readLock.Dispose(); + try + { + _pingCancellation?.Cancel(); + } + catch + { + // Do nothing + } + + try + { + _pongCancellation?.Cancel(); + } + catch + { + // Do nothing + } + + try + { + _cancellation?.Dispose(); + } + catch + { + _cancellation = null; + } + + try + { + _pingCancellation?.Dispose(); + } + catch + { + _pingCancellation = null; + } + + try + { + _pongCancellation?.Dispose(); + } + catch + { + _pongCancellation = null; + } + + _sendLock.Dispose(); + _connectLock.Dispose(); + OnConnected = null; + OnDisconnected = null; + OnDataReceived = null; + OnGeneralError = null; + OnPingResponse = null; + } + finally + { + GC.SuppressFinalize(this); + } } - public void Dispose() + private byte[] HandleHeartbeat(byte[] data, out bool hasHeartbeat) { - if (_disposed) + hasHeartbeat = false; + if (!_config.EnableHeartbeat || data == null || data.Length == 0) { - return; + return data; } - _disposed = true; - DisposeAsync().AsTask().GetAwaiter().GetResult(); + string text = null; + + try + { + text = Encoding.UTF8.GetString(data); + } + catch + { + // Not a valid UTF-8 string + } + + if (string.IsNullOrEmpty(text)) + { + return data; + } + + if (text.Equals("DISCONNECT", StringComparison.OrdinalIgnoreCase)) + { + DisconnectClientAsync(DisconnectReason.RemoteClosed).ConfigureAwait(false); + hasHeartbeat = true; + } + if (text.Contains(Configuration.PING_VALUE)) + { + hasHeartbeat = true; + SendAsync(Configuration.PONG_VALUE).ConfigureAwait(false); + if (_config.EnablePingPongLogs) + { + OnLog?.Invoke(this, "PING received. Sent PONG response."); + } + } + if (text.Contains(Configuration.PONG_VALUE)) + { + hasHeartbeat = true; + var now = DateTime.UtcNow; + OnPingResponse?.Invoke(this, new PingEventArgs + { + Id = "server", + Nickname = Nickname, + ReceivedTime = now, + RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) + }); + if (_config.EnablePingPongLogs) + { + OnLog?.Invoke(this, "PONG received."); + } + } + if (hasHeartbeat && !string.IsNullOrEmpty(text)) + { + text = text.Replace(Configuration.PING_VALUE, string.Empty).Replace(Configuration.PONG_VALUE, string.Empty).Replace("DISCONNECT", string.Empty); + data = Encoding.UTF8.GetBytes(text); + } + return data; } + + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); } -} +} \ No newline at end of file diff --git a/EonaCat.Connections/NetworkServer.cs b/EonaCat.Connections/NetworkServer.cs index bd6abdd..faf2e1d 100644 --- a/EonaCat.Connections/NetworkServer.cs +++ b/EonaCat.Connections/NetworkServer.cs @@ -1,6 +1,7 @@ using EonaCat.Connections.EventArguments; using EonaCat.Connections.Helpers; using EonaCat.Connections.Models; +using System.Buffers; using System.Collections.Concurrent; using System.Net; using System.Net.Security; @@ -9,14 +10,15 @@ using System.Security.Authentication; using System.Security.Cryptography; using System.Text; using ErrorEventArgs = EonaCat.Connections.EventArguments.ErrorEventArgs; +using ProtocolType = EonaCat.Connections.Models.ProtocolType; namespace EonaCat.Connections { - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - - public class NetworkServer + public partial class NetworkServer { + private const int MAX_BUFFER_MEMORY_IN_KILOBYTES = 1024; + private const int MAX_BUFFER_HEADER_LENGTH = 16; + private const int MAX_MESSAGE_FRAMING_PREFIX_LENGTH = 4; private readonly Configuration _config; private readonly Stats _stats; private readonly ConcurrentDictionary _clients; @@ -28,13 +30,30 @@ namespace EonaCat.Connections private readonly object _udpLock = new object(); public event EventHandler OnConnected; + public event EventHandler OnConnectedWithNickname; + public event EventHandler OnDataReceived; + public event EventHandler OnDisconnected; + public event EventHandler OnSslError; + public event EventHandler OnEncryptionError; + public event EventHandler OnGeneralError; + public event EventHandler OnClientPingResponse; + public event EventHandler OnPongMissed; + + private readonly ConcurrentDictionary _lastPingTimes = new(); + private CancellationTokenSource _pingCancellation; + private CancellationTokenSource _pongCancellation; + private Task _pingTask; + private Task _pongTask; + + public event EventHandler OnLog; + public bool IsStarted => _serverCancellation != null && !_serverCancellation.IsCancellationRequested; public bool IsSecure => _config != null && (_config.UseSsl || _config.UseAesEncryption); public bool IsEncrypted => _config != null && _config.UseAesEncryption; @@ -48,28 +67,14 @@ namespace EonaCat.Connections public TimeSpan Uptime => _stats.Uptime; public DateTime StartTime => _stats.StartTime; public int MaxConnections => _config != null ? _config.MaxConnections : 0; + public DateTime LastDataSent { get; private set; } + public DateTime LastDataReceived { get; private set; } public ProtocolType Protocol => _config != null ? _config.Protocol : ProtocolType.TCP; private int _tcpRunning = 0; private int _udpRunning = 0; - - private readonly List _plugins = new List(); - public void RegisterPlugin(IServerPlugin plugin) => _plugins.Add(plugin); - public void UnregisterPlugin(IServerPlugin plugin) => _plugins.Remove(plugin); - private void InvokePlugins(Action action) - { - foreach (var plugin in _plugins) - { - try { action(plugin); } - catch (Exception ex) - { - OnGeneralError?.Invoke(this, new ErrorEventArgs - { - Exception = ex, - Message = $"Plugin {plugin.Name} failed" - }); - } - } - } + private ArrayPool _arrayPool = ArrayPool.Shared; + private Task _tcpTask; + private Task _udpTask; public NetworkServer(Configuration config) { @@ -90,20 +95,159 @@ namespace EonaCat.Connections public string IpAddress => _config != null ? _config.Host : string.Empty; public int Port => _config != null ? _config.Port : 0; - public async Task StartAsync() + public bool DEBUG_DATA_SEND { get; set; } + public bool DEBUG_DATA_RECEIVED { get; set; } + + public Task StartAsync() { _serverCancellation = new CancellationTokenSource(); if (_config.Protocol == ProtocolType.TCP) { - await StartTcpServerAsync(); + _tcpTask = Task.Run(() => StartTcpServerAsync(), _serverCancellation.Token); } else { - await StartUdpServerAsync(); + _udpTask = Task.Run(() => StartUdpServerAsync(), _serverCancellation.Token); } - InvokePlugins(p => p.OnServerStarted(this)); + if (_config.EnableHeartbeat) + { + _pingTask = Task.Run(StartPingLoop, _serverCancellation.Token); + _pongTask = Task.Run(StartPongLoop, _serverCancellation.Token); + } + + return Task.CompletedTask; + } + + private void StartPongLoop() + { + _pongCancellation?.Cancel(); + _pongCancellation?.Dispose(); + + _pongCancellation = new CancellationTokenSource(); + var token = _pongCancellation.Token; + + _pongTask = Task.Run(async () => + { + while (!token.IsCancellationRequested) + { + try + { + var now = DateTime.UtcNow; + var timeout = _config.HeartbeatIntervalSeconds * 2; + + var pingTimes = _lastPingTimes.ToList(); + var toRemove = new List(); + + foreach (var pings in pingTimes) + { + var clientId = pings.Key; + var lastPong = pings.Value; + + if (!_clients.TryGetValue(clientId, out var client)) + { + continue; + } + + if (!client.IsConnected) + { + continue; + } + + var elapsed = (now - lastPong).TotalSeconds; + + if (elapsed > timeout) + { + if (_config.EnablePingPongLogs) + { + OnLog?.Invoke(this, $"Client '{client.Nickname}' no PONG for {elapsed:F1}s → disconnect."); + } + + OnPongMissed?.Invoke(this, new PingEventArgs + { + Id = client.Id, + Nickname = client.Nickname, + ReceivedTime = now, + RemoteEndPoint = new IPEndPoint(IPAddress.Parse(client.RemoteEndPoint.Address.ToString()), client.RemoteEndPoint.Port) + }); + + if (_config.DisconectOnMissedPong) + { + _ = DisconnectClientAsync(clientId, DisconnectReason.NoPongReceived); + } + } + + if (elapsed > _config.HeartbeatIntervalSeconds * 4) + { + toRemove.Add(clientId); + } + } + + foreach (var remove in toRemove) + { + _lastPingTimes.TryRemove(remove, out _); + } + + await Task.Delay(TimeSpan.FromSeconds(1), token); + } + catch (TaskCanceledException) + { + break; + } + catch (Exception exception) + { + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + Exception = exception, + Message = "Pong loop error" + }); + } + } + }, token); + } + + private void StartPingLoop() + { + _pingCancellation?.Cancel(); + _pingCancellation?.Dispose(); + + _pingCancellation = new CancellationTokenSource(); + var token = _pingCancellation.Token; + + _pingTask = Task.Run(async () => + { + while (!token.IsCancellationRequested) + { + try + { + var clients = _clients.Values.ToList(); + foreach (var client in clients) + { + if (client?.IsConnected != true) + { + continue; + } + + await SendToClientAsync(client.Id, Configuration.PING_VALUE).ConfigureAwait(false); + } + + await Task.Delay(TimeSpan.FromSeconds(_config.HeartbeatIntervalSeconds), token).ConfigureAwait(false); + } + catch (TaskCanceledException) + { + break; + } + catch (Exception exception) + { + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + Exception = exception, + Message = "Ping loop error" + }); + } + } + }, token); } public async Task StartTcpServerAsync() @@ -116,44 +260,32 @@ namespace EonaCat.Connections try { - lock (_tcpLock) - { - _tcpListener = new TcpListener(IPAddress.Parse(_config.Host), _config.Port); - _tcpListener.Start(); - } + _tcpListener = new TcpListener(IPAddress.Parse(_config.Host), _config.Port); + _tcpListener.Start(); - Console.WriteLine($"TCP Server started on {_config.Host}:{_config.Port}"); + Console.WriteLine($"EonaCat TCP Server started on {_config.Host}:{_config.Port}"); while (!_serverCancellation.Token.IsCancellationRequested) { - TcpClient? tcpClient = null; - + TcpClient tcpClient = null; try { - lock (_tcpLock) - { - if (_tcpListener == null) - { - break; - } - } - - tcpClient = await _tcpListener!.AcceptTcpClientAsync().ConfigureAwait(false); + tcpClient = await _tcpListener.AcceptTcpClientAsync().ConfigureAwait(false); _ = Task.Run(() => HandleTcpClientAsync(tcpClient), _serverCancellation.Token); } - catch (ObjectDisposedException) + catch (ObjectDisposedException) { break; } - catch (InvalidOperationException ex) when (ex.Message.Contains("Not listening")) + catch (InvalidOperationException exception) when (exception.Message.Contains("Not listening")) { break; } - catch (Exception ex) + catch (Exception exception) { OnGeneralError?.Invoke(this, new ErrorEventArgs { - Exception = ex, + Exception = exception, Message = "Error accepting TCP client" }); } @@ -171,8 +303,9 @@ namespace EonaCat.Connections { _tcpListener?.Stop(); _tcpListener = null; + _udpTask = null; + _tcpTask = null; } - Interlocked.Exchange(ref _tcpRunning, 0); } @@ -185,10 +318,12 @@ namespace EonaCat.Connections { if (Interlocked.CompareExchange(ref _udpRunning, 1, 0) == 1) { - Console.WriteLine("UDP Server is already running."); + Console.WriteLine("EonaCat UDP Server is already running."); return; } + _ = Task.Run(() => CleanupUdpClientsAsync(), _serverCancellation.Token).ConfigureAwait(false); + try { lock (_udpLock) @@ -196,7 +331,7 @@ namespace EonaCat.Connections _udpListener = new UdpClient(_config.Port); } - Console.WriteLine($"UDP Server started on {_config.Host}:{_config.Port}"); + Console.WriteLine($"EonaCat UDP Server started on {_config.Host}:{_config.Port}"); while (!_serverCancellation.Token.IsCancellationRequested) { @@ -213,22 +348,21 @@ namespace EonaCat.Connections } result = await _udpListener!.ReceiveAsync().ConfigureAwait(false); - _ = Task.Run(() => HandleUdpDataAsync(result), _serverCancellation.Token); } - catch (ObjectDisposedException) + catch (ObjectDisposedException) { break; } - catch (SocketException ex) when (ex.SocketErrorCode == SocketError.Interrupted) + catch (SocketException exception) when (exception.SocketErrorCode == SocketError.Interrupted) { break; } - catch (Exception ex) + catch (Exception exception) { OnGeneralError?.Invoke(this, new ErrorEventArgs { - Exception = ex, + Exception = exception, Message = "Error receiving UDP data" }); } @@ -248,18 +382,18 @@ namespace EonaCat.Connections _udpListener?.Dispose(); _udpListener = null; } - Interlocked.Exchange(ref _udpRunning, 0); } private async Task HandleTcpClientAsync(TcpClient tcpClient) { var clientId = Guid.NewGuid().ToString(); + var remoteEndPoint = (IPEndPoint)tcpClient.Client.RemoteEndPoint; var client = new Connection { Id = clientId, TcpClient = tcpClient, - RemoteEndPoint = (IPEndPoint)tcpClient.Client.RemoteEndPoint, + RemoteEndPoint = remoteEndPoint, ConnectedAt = DateTime.UtcNow, LastActive = DateTime.UtcNow, CancellationToken = new CancellationTokenSource() @@ -268,29 +402,55 @@ namespace EonaCat.Connections try { tcpClient.NoDelay = !_config.EnableNagle; - if (_config.EnableKeepAlive) - { - tcpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); - } - Stream stream = tcpClient.GetStream(); if (_config.UseSsl) { - try + bool sslAuthenticated = false; + + for (int attempt = 1; attempt <= _config.SSLMaxRetries || _config.SSLMaxRetries == 0; attempt++) { - var sslStream = new SslStream(stream, false, userCertificateValidationCallback: _config.GetRemoteCertificateValidationCallback()); - await sslStream.AuthenticateAsServerAsync(_config.Certificate, _config.MutuallyAuthenticate, SslProtocols.Tls12 | SslProtocols.Tls13, _config.CheckCertificateRevocation); - stream = sslStream; - client.IsSecure = true; + SslStream sslStream = null; + + try + { + sslStream = new SslStream(stream, leaveInnerStreamOpen: true, _config.GetRemoteCertificateValidationCallback()); + await sslStream.AuthenticateAsServerAsync(_config.Certificate, _config.MutuallyAuthenticate, SslProtocols.Tls12 | SslProtocols.Tls13, _config.CheckCertificateRevocation ).ConfigureAwait(false); + + stream = sslStream; + client.IsSecure = true; + sslAuthenticated = true; + break; + } + catch (IOException ioException) when (ioException.Message.Contains("0 bytes from the transport stream") || ioException.Message.Contains("Unexpected EOF")) + { + OnLog?.Invoke(this, $"SSL handshake EOF from {remoteEndPoint}. Attempt {attempt}"); + } + catch (AuthenticationException authException) + { + OnLog?.Invoke(this, $"SSL Authentication failed for {remoteEndPoint}: {authException.Message}"); + } + finally + { + if (!sslAuthenticated) + { + sslStream?.Dispose(); + } + } + await Task.Delay(_config.SSLRetryDelayInSeconds * 1000); } - catch (Exception ex) + if (!sslAuthenticated) { - OnSslError?.Invoke(this, new ErrorEventArgs { ClientId = clientId, Nickname = client.Nickname, Exception = ex, Message = "SSL authentication failed" }); + await DisconnectClientAsync(clientId, DisconnectReason.SSLError).ConfigureAwait(false); return; } } + if (_config.EnableKeepAlive) + { + tcpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); + } + if (_config.UseAesEncryption) { try @@ -299,16 +459,15 @@ namespace EonaCat.Connections client.AesEncryption.GenerateKey(); client.AesEncryption.GenerateIV(); client.IsEncrypted = true; - await AesKeyExchange.SendAesKeyAsync(stream, client.AesEncryption, _config.AesPassword); } - catch (Exception ex) + catch (Exception exception) { OnEncryptionError?.Invoke(this, new ErrorEventArgs { ClientId = clientId, Nickname = client.Nickname, - Exception = ex, + Exception = exception, Message = "AES setup failed" }); return; @@ -316,32 +475,60 @@ namespace EonaCat.Connections } client.Stream = stream; - _clients[clientId] = client; + client.LastActive = DateTime.UtcNow; + client.LastDataReceived = DateTime.UtcNow; + client.LastDataSent = DateTime.UtcNow; - lock (_statsLock) + _clients[clientId] = client; + lock (_statsLock) { - _stats.TotalConnections++; + _stats.TotalConnections++; } - OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = clientId, RemoteEndPoint = client.RemoteEndPoint, Nickname = client.Nickname }); - InvokePlugins(p => p.OnClientConnected(client)); + OnConnected?.Invoke(this, new ConnectionEventArgs + { + ClientId = clientId, + RemoteEndPoint = remoteEndPoint, + Nickname = client.Nickname + }); - await HandleClientCommunicationAsync(client); + await HandleClientCommunicationAsync(client).ConfigureAwait(false); } - catch (Exception ex) + catch (Exception exception) { - await DisconnectClientAsync(clientId, DisconnectReason.Error, ex); + await DisconnectClientAsync(clientId, DisconnectReason.Error, exception).ConfigureAwait(false); } finally { - await DisconnectClientAsync(clientId, DisconnectReason.Unknown); + if (_clients.ContainsKey(clientId)) + { + await DisconnectClientAsync(clientId, DisconnectReason.Unknown).ConfigureAwait(false); + } } } + private async Task CleanupUdpClientsAsync() + { + _ = Task.Run(async () => + { + while (!_serverCancellation.IsCancellationRequested) + { + foreach (var kvp in _clients) + { + if ((DateTime.UtcNow - kvp.Value.LastActive).TotalMinutes > _config.ClientTimeoutInMinutes) + { + await DisconnectClientAsync(kvp.Key, DisconnectReason.Timeout); + } + } + await Task.Delay(TimeSpan.FromMinutes(1), _serverCancellation.Token); + } + }); + } + private async Task HandleUdpDataAsync(UdpReceiveResult result) { var clientKey = result.RemoteEndPoint.ToString(); - + if (!_clients.TryGetValue(clientKey, out var client)) { client = new Connection @@ -351,329 +538,526 @@ namespace EonaCat.Connections ConnectedAt = DateTime.UtcNow }; _clients[clientKey] = client; - - lock (_statsLock) + + lock (_statsLock) { - _stats.TotalConnections++; + _stats.TotalConnections++; } - OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = clientKey, RemoteEndPoint = result.RemoteEndPoint }); - InvokePlugins(p => p.OnClientConnected(client)); + } + await ProcessReceivedDataAsync(client, result.Buffer); + } + + public void SetSeparator(string separator) => _config.Delimiter = Encoding.UTF8.GetBytes(separator); + + public void SetSeparator(byte[] separator) => _config.Delimiter = separator; + + private static int IndexOfDelimiter(List buffer, byte[] delimiter) + { + if (buffer == null || delimiter == null || buffer.Count < delimiter.Length) + { + return -1; } - await ProcessReceivedDataAsync(client, result.Buffer); + for (int i = 0; i <= buffer.Count - delimiter.Length; i++) + { + bool match = true; + for (int j = 0; j < delimiter.Length; j++) + { + if (buffer[i + j] != delimiter[j]) + { + match = false; + break; + } + } + + if (match) + { + return i; + } + } + return -1; } private async Task HandleClientCommunicationAsync(Connection client) { - var lengthBuffer = new byte[4]; - - while (!client.CancellationToken.Token.IsCancellationRequested && client.TcpClient.Connected) + if (client.Stream == null) { - try - { - byte[] data; + throw new InvalidOperationException("Client stream is null or disposed."); + } - if (client.IsEncrypted && client.AesEncryption != null) + byte[] readBuffer = ArrayPool.Shared.Rent(_config.BufferSize); + var aggregate = new List(_config.BufferSize * 2); + var stream = client.Stream; + + try + { + while (!client.CancellationToken.Token.IsCancellationRequested && client.TcpClient.Connected) + { + int bytesRead; + + try { - int read = await ReadExactAsync(client.Stream, lengthBuffer, 4, client, client.CancellationToken.Token); - if (read == 0) + bytesRead = await stream.ReadAsync(readBuffer, 0, readBuffer.Length, client.CancellationToken.Token).ConfigureAwait(false); + } + catch (IOException ioException) + { + await DisconnectClientAsync(client.Id, DisconnectReason.Error, ioException).ConfigureAwait(false); + return; + } + + if (bytesRead == 0) + { + await DisconnectClientAsync(client.Id, DisconnectReason.RemoteClosed).ConfigureAwait(false); + return; + } + + aggregate.AddRange(readBuffer.AsSpan(0, bytesRead).ToArray()); + + if (aggregate.Count > _config.MAX_MESSAGE_SIZE) + { + aggregate.Clear(); + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + ClientId = client.Id, + Nickname = client.Nickname, + Message = "Data buffer overflow - possible malicious activity (More than MAX_MESSAGE_SIZE)" + }); + await DisconnectClientAsync(client.Id, DisconnectReason.ProtocolError).ConfigureAwait(false); + return; + } + + while (true) + { + byte[] message = null; + + switch (_config.MessageFraming) + { + case FramingMode.LengthPrefixed: + if (aggregate.Count < 4) + { + break; + } + + var lengthBytes = aggregate.GetRange(0, 4).ToArray(); + if (_config.UseBigEndian && BitConverter.IsLittleEndian) + { + Array.Reverse(lengthBytes); + } + + int msgLength = BitConverter.ToInt32(lengthBytes, 0); + if (msgLength <= 0 || msgLength > _config.MAX_MESSAGE_SIZE) + { + await DisconnectClientAsync(client.Id, DisconnectReason.ProtocolError).ConfigureAwait(false); return; + } + if (aggregate.Count < 4 + msgLength) + { + break; + } + + message = aggregate.Skip(4).Take(msgLength).ToArray(); + aggregate.RemoveRange(0, 4 + msgLength); + break; + + case FramingMode.Delimiter: + int delimiterIndex = IndexOfDelimiter(aggregate, _config.Delimiter); + if (delimiterIndex < 0) + { + break; + } + + message = aggregate.Take(delimiterIndex).ToArray(); + aggregate.RemoveRange(0, delimiterIndex + _config.Delimiter.Length); + break; + + case FramingMode.None: + if (aggregate.Count == 0) + { + break; + } + + message = aggregate.ToArray(); + aggregate.Clear(); + break; + } + + if (message == null) { break; } - if (BitConverter.IsLittleEndian) + if (client.IsEncrypted && client.AesEncryption != null) { - Array.Reverse(lengthBuffer); - } - - int length = BitConverter.ToInt32(lengthBuffer, 0); - - var encrypted = new byte[length]; - await ReadExactAsync(client.Stream, encrypted, length, client, client.CancellationToken.Token); - - data = await AesKeyExchange.DecryptDataAsync(encrypted, client.AesEncryption); - } - else - { - data = new byte[_config.BufferSize]; - - await client.ReadLock.WaitAsync(client.CancellationToken.Token); // NEW - try - { - int bytesRead = await client.Stream.ReadAsync(data, 0, data.Length, client.CancellationToken.Token); - if (bytesRead == 0) + try { - await DisconnectClientAsync(client.Id, DisconnectReason.RemoteClosed); - return; + await AesKeyExchange.DecryptDataAsync(message, message.Length, client.AesEncryption).ConfigureAwait(false); } - - if (bytesRead < data.Length) + catch (Exception exception) { - var tmp = new byte[bytesRead]; - Array.Copy(data, tmp, bytesRead); - data = tmp; + await DisconnectClientAsync(client.Id, DisconnectReason.Error, exception).ConfigureAwait(false); + return; } } - catch (IOException ioEx) - { - await DisconnectClientAsync(client.Id, DisconnectReason.RemoteClosed, ioEx); - return; - } - catch (SocketException sockEx) - { - await DisconnectClientAsync(client.Id, DisconnectReason.Error, sockEx); - return; - } - catch (OperationCanceledException) - { - await DisconnectClientAsync(client.Id, DisconnectReason.Timeout); - return; - } - catch (Exception ex) - { - await DisconnectClientAsync(client.Id, DisconnectReason.Error, ex); - return; - } - finally - { - client.ReadLock.Release(); - } + await ProcessReceivedDataAsync(client, message).ConfigureAwait(false); } - - await ProcessReceivedDataAsync(client, data); - } - catch (IOException ioEx) - { - await DisconnectClientAsync(client.Id, DisconnectReason.RemoteClosed, ioEx); - } - catch (SocketException sockEx) - { - await DisconnectClientAsync(client.Id, DisconnectReason.Error, sockEx); - } - catch (Exception ex) - { - await DisconnectClientAsync(client.Id, DisconnectReason.Error, ex); - } - } - } - - private async Task ReadExactAsync(Stream stream, byte[] buffer, int length, Connection client, CancellationToken ct) - { - await client.ReadLock.WaitAsync(ct); // NEW - try - { - int offset = 0; - while (offset < length) - { - int read = await stream.ReadAsync(buffer, offset, length - offset, ct); - if (read == 0) + if (aggregate.Count > 0 && DEBUG_DATA_RECEIVED) { - return 0; + OnLog?.Invoke(this, $"[DEBUG] {aggregate.Count} bytes remain unparsed after read."); } - - offset += read; } - return offset; } - finally + catch (OperationCanceledException) { - client.ReadLock.Release(); + // Do nothing, cancellation requested + } + catch (Exception exception) + { + await DisconnectClientAsync(client.Id, DisconnectReason.Error, exception).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(readBuffer, clearArray: true); } } - private async Task ProcessReceivedDataAsync(Connection client, byte[] data) { + if (client == null || data == null || data.Length == 0) + { + return; + } + try { + var now = DateTime.UtcNow; + + LastDataReceived = now; + client.LastDataReceived = now; + client.LastActive = now; client.AddBytesReceived(data.Length); + _lastPingTimes[client.Id] = now; + lock (_statsLock) { _stats.BytesReceived += data.Length; _stats.MessagesReceived++; } - bool isBinary = true; string stringData = null; + bool isBinary = true; try { stringData = Encoding.UTF8.GetString(data); - if (Encoding.UTF8.GetBytes(stringData).Length == data.Length) + if (Encoding.UTF8.GetByteCount(stringData) == data.Length) { isBinary = false; } } - catch { } - - if (!isBinary && stringData != null) + catch { - if (stringData.StartsWith("NICKNAME:")) + stringData = null; + isBinary = true; + } + + if (stringData != null) + { + ProcessPingPong(client, ref stringData); + if (string.IsNullOrEmpty(stringData)) { - var nickname = stringData.Substring(9); - client.Nickname = nickname; - OnConnectedWithNickname?.Invoke(this, new ConnectionEventArgs - { - ClientId = client.Id, - RemoteEndPoint = client.RemoteEndPoint, - Nickname = nickname - }); - _clients[client.Id] = client; return; } - else if (stringData.StartsWith("[NICKNAME]", StringComparison.OrdinalIgnoreCase)) - { - var nickname = StringHelper.GetTextBetweenTags(stringData, "[NICKNAME]", "[/NICKNAME]"); - if (string.IsNullOrWhiteSpace(nickname)) - { - nickname = client.Id; - } - else - { - client.Nickname = nickname; - } + } - OnConnectedWithNickname?.Invoke(this, new ConnectionEventArgs + if (DEBUG_DATA_RECEIVED) + { + var debug = isBinary + ? $"[DEBUG DATA] Received from {client.Id} ({client.Nickname ?? "NoNickname"}): [binary {data.Length} bytes]" + : $"[DEBUG DATA] Received from {client.Id} ({client.Nickname ?? "NoNickname"}): {stringData}"; + + OnLog?.Invoke(this, debug); + } + + if (!string.IsNullOrEmpty(stringData)) + { + if (stringData.StartsWith("[NICKNAME]", StringComparison.OrdinalIgnoreCase)) + { + string nickname = StringHelper.GetTextBetweenTags(stringData, "[NICKNAME]", "[/NICKNAME]"); + HandleNickname(client, nickname); + + stringData = stringData.Replace($"[NICKNAME]{nickname}[/NICKNAME]", string.Empty).Trim(); + if (string.IsNullOrEmpty(stringData)) { - ClientId = client.Id, - RemoteEndPoint = client.RemoteEndPoint, - Nickname = nickname - }); - _clients[client.Id] = client; - return; + return; + } } else if (stringData.Equals("DISCONNECT", StringComparison.OrdinalIgnoreCase)) { - await DisconnectClientAsync(client.Id, DisconnectReason.ClientRequested); + await DisconnectClientAsync(client.Id, DisconnectReason.ClientRequested) + .ConfigureAwait(false); return; } } - client.LastActive = DateTime.UtcNow; - OnDataReceived?.Invoke(this, new DataReceivedEventArgs + if (stringData != null) { - ClientId = client.Id, - Nickname = client.Nickname, - RemoteEndPoint = client.RemoteEndPoint, - Data = data, - StringData = stringData, - IsBinary = isBinary - }); - InvokePlugins(p => p.OnDataReceived(client, data, stringData, isBinary)); - } - catch (Exception ex) - { - if (client.IsEncrypted) - { - OnEncryptionError?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Nickname = client.Nickname, Exception = ex, Message = "Error processing data" }); - } - else - { - OnGeneralError?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Nickname = client.Nickname, Exception = ex, Message = "Error processing data" }); - } - } - } + var pooledCopy = _arrayPool.Rent(data.Length); + Buffer.BlockCopy(data, 0, pooledCopy, 0, data.Length); - public async Task SendToClientAsync(string clientId, byte[] data) - { - var client = GetClient(clientId); - if (client != null && client.Count > 0) - { - foreach (var current in client) - { - await SendDataAsync(current, data); - } - } - } - - public async Task SendToClientAsync(string clientId, string message) - { - await SendToClientAsync(clientId, Encoding.UTF8.GetBytes(message)); - } - - public async Task SendFromClientToClientAsync(string fromClientId, string toClientId, byte[] data) - { - var fromClient = GetClient(fromClientId); - var toClient = GetClient(toClientId); - if (fromClient != null && toClient != null && fromClient.Count > 0 && toClient.Count > 0) - { - foreach (var current in toClient) - { - await SendDataAsync(current, data); - } - } - } - - public async Task SendFromClientToClientAsync(string fromClientId, string toClientId, string message) - { - await SendFromClientToClientAsync(fromClientId, toClientId, Encoding.UTF8.GetBytes(message)); - } - - public async Task BroadcastAsync(byte[] data) - { - var tasks = new List(); - foreach (var client in _clients.Values) - { - tasks.Add(SendDataAsync(client, data)); - } - await Task.WhenAll(tasks); - } - - public async Task BroadcastAsync(string message) - { - await BroadcastAsync(Encoding.UTF8.GetBytes(message)); - } - - private async Task SendDataAsync(Connection client, byte[] data) - { - await client.SendLock.WaitAsync(); - try - { - if (client.IsEncrypted && client.AesEncryption != null) - { - data = await AesKeyExchange.EncryptDataAsync(data, client.AesEncryption); - - var lengthPrefix = BitConverter.GetBytes(data.Length); - if (BitConverter.IsLittleEndian) + try { - Array.Reverse(lengthPrefix); + OnDataReceived?.Invoke(this, new DataReceivedEventArgs + { + ClientId = client.Id, + Nickname = client.Nickname, + RemoteEndPoint = client.RemoteEndPoint, + Data = pooledCopy, + StringData = stringData, + IsBinary = isBinary, + Timestamp = now + }); + } + finally + { + _arrayPool.Return(pooledCopy, clearArray: true); } - - var framed = new byte[lengthPrefix.Length + data.Length]; - Buffer.BlockCopy(lengthPrefix, 0, framed, 0, lengthPrefix.Length); - Buffer.BlockCopy(data, 0, framed, lengthPrefix.Length, data.Length); - data = framed; - } - - if (_config.Protocol == ProtocolType.TCP) - { - await client.Stream.WriteAsync(data, 0, data.Length); - await client.Stream.FlushAsync(); - } - else - { - await _udpListener.SendAsync(data, data.Length, client.RemoteEndPoint); - } - - client.AddBytesSent(data.Length); - lock (_statsLock) - { - _stats.BytesSent += data.Length; - _stats.MessagesSent++; } } catch (Exception ex) { var handler = client.IsEncrypted ? OnEncryptionError : OnGeneralError; - handler?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Nickname = client.Nickname, Exception = ex, Message = "Error sending data" }); + handler?.Invoke(this, new ErrorEventArgs + { + ClientId = client.Id, + Nickname = client.Nickname, + Exception = ex, + Message = "Error processing data" + }); } finally { - client.SendLock.Release(); + Array.Clear(data, 0, data.Length); } } + private void HandleNickname(Connection client, string nickname) + { + if (!string.IsNullOrWhiteSpace(nickname)) + { + client.Nickname = nickname; + _clients[client.Id] = client; + OnConnectedWithNickname?.Invoke(this, new ConnectionEventArgs + { + ClientId = client.Id, + RemoteEndPoint = client.RemoteEndPoint, + Nickname = nickname + }); + } + else + { + OnConnected?.Invoke(this, new ConnectionEventArgs + { + ClientId = client.Id, + RemoteEndPoint = client.RemoteEndPoint, + Nickname = client.Id + }); + } + } + + public async Task SendToClientAsync(string clientId, byte[] data) + { + var clientList = GetClient(clientId); + + if (clientList == null || clientList.Count == 0) + { + return false; + } + + bool any = false; + + foreach (var client in clientList) + { + if (await SendDataAsync(client, data).ConfigureAwait(false)) + { + any = true; + } + } + return any; + } + + public async Task SendToClientAsync(string clientId, string message) => await SendToClientAsync(clientId, Encoding.UTF8.GetBytes(message)); + + public async Task SendFromClientToClientAsync(string fromClientId, string toClientId, byte[] data) + { + var toClient = GetClient(toClientId); + + if (toClient == null || toClient.Count == 0) + { + return false; + } + + bool any = false; + foreach (var client in toClient) + { + if (await SendDataAsync(client, data).ConfigureAwait(false)) + { + any = true; + } + } + return any; + } + + public async Task SendFromClientToClientAsync(string fromClientId, string toClientId, string message) => await SendFromClientToClientAsync(fromClientId, toClientId, Encoding.UTF8.GetBytes(message)); + + public async Task BroadcastAsync(byte[] data) + { + if (data == null || data.Length == 0) + { + return false; + } + + bool any = false; + foreach (var client in _clients.Values) + { + if (await SendDataAsync(client, data).ConfigureAwait(false)) + { + any = true; + } + } + return any; + } + + public async Task BroadcastAsync(string message) => await BroadcastAsync(Encoding.UTF8.GetBytes(message)); + + private async Task SendDataAsync(Connection client, byte[] data) + { + if (client == null || !client.IsConnected || client.Stream == null || !client.Stream.CanWrite || data == null || data.Length == 0) + { + return false; + } + + await client.SendLock.WaitAsync().ConfigureAwait(false); + + try + { + int maxBufferSize = Math.Min(data.Length + MAX_BUFFER_HEADER_LENGTH + (_config.MessageFraming == FramingMode.LengthPrefixed ? MAX_MESSAGE_FRAMING_PREFIX_LENGTH : 0), MAX_BUFFER_MEMORY_IN_KILOBYTES * 1024); + byte[] buffer = ArrayPool.Shared.Rent(maxBufferSize); + int bytesToSend = 0; + + try + { + bytesToSend = BuildMessage(buffer, data, _config); + + if (client.IsEncrypted && client.AesEncryption != null) + { + bytesToSend = await AesKeyExchange.EncryptDataAsync(buffer, bytesToSend, client.AesEncryption).ConfigureAwait(false); + } + + if (DEBUG_DATA_SEND) + { + OnLog?.Invoke(this, $"[DEBUG] Sending raw: {BitConverter.ToString(data)}"); + OnLog?.Invoke(this, $"[DEBUG] Sending framed: {BitConverter.ToString(buffer.AsSpan(0, bytesToSend).ToArray())}"); + } + + await WriteToStreamAsync(client, buffer.AsSpan(0, bytesToSend).ToArray()).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + client.LastDataSent = DateTime.UtcNow; + client.AddBytesSent(bytesToSend); + lock (_statsLock) + { + _stats.BytesSent += bytesToSend; + _stats.MessagesSent++; + } + return true; + } + catch (IOException) + { + await SafeDisconnectAsync(client, DisconnectReason.RemoteClosed).ConfigureAwait(false); + return false; + } + catch (ObjectDisposedException) + { + await SafeDisconnectAsync(client, DisconnectReason.RemoteClosed).ConfigureAwait(false); + return false; + } + catch (Exception exception) + { + var handler = client.IsEncrypted ? OnEncryptionError : OnGeneralError; + handler?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Nickname = client.Nickname, Exception = exception, Message = "Error sending data" }); + return false; + } + finally + { + try + { + client.SendLock.Release(); + } + catch + { + // Do nothing + } + } + } + + private async Task WriteToStreamAsync(Connection connection, byte[] dataToSend) + { + if (connection.Stream == null || !connection.Stream.CanWrite) + { + return false; + } + + if (_config.Protocol == ProtocolType.TCP) + { + await connection.Stream.WriteAsync(dataToSend, 0, dataToSend.Length); + await connection.Stream.FlushAsync(); + } + else + { + await _udpListener.SendAsync(dataToSend, dataToSend.Length); + } + LastDataSent = DateTime.UtcNow; + return true; + } + + private static int BuildMessage(byte[] buffer, byte[] data, Configuration config) + { + int offset = 0; + + switch (config.MessageFraming) + { + case FramingMode.LengthPrefixed: + int length = data.Length; + if (config.UseBigEndian && BitConverter.IsLittleEndian) + { + buffer[0] = (byte)(length >> 24); + buffer[1] = (byte)(length >> 16); + buffer[2] = (byte)(length >> 8); + buffer[3] = (byte)(length); + } + else + { + buffer[0] = (byte)(length); + buffer[1] = (byte)(length >> 8); + buffer[2] = (byte)(length >> 16); + buffer[3] = (byte)(length >> 24); + } + offset += 4; + Array.Copy(data, 0, buffer, offset, data.Length); offset += data.Length; break; + case FramingMode.Delimiter: + Array.Copy(data, 0, buffer, offset, data.Length); offset += data.Length; + Array.Copy(config.Delimiter, 0, buffer, offset, config.Delimiter.Length); offset += config.Delimiter.Length; break; + case FramingMode.None: + default: + Array.Copy(data, 0, buffer, offset, data.Length); offset += data.Length; break; + } + return offset; + } + public async Task DisconnectClientAsync(string clientId, DisconnectReason reason = DisconnectReason.Unknown, Exception exception = null) { if (!_clients.TryRemove(clientId, out var client)) @@ -686,45 +1070,95 @@ namespace EonaCat.Connections return; } - await Task.Run(() => + try { + client.DisconnectionTime = DateTime.UtcNow; + try { client.CancellationToken?.Cancel(); - client.TcpClient?.Close(); - client.Stream?.Dispose(); - client.AesEncryption?.Dispose(); - client.SendLock.Dispose(); - - Volatile.Read(ref OnDisconnected)?.Invoke(this, - new ConnectionEventArgs - { - ClientId = clientId, - Nickname = client.Nickname, - RemoteEndPoint = client.RemoteEndPoint, - Reason = ConnectionEventArgs.Determine(reason, exception), - Exception = exception - }); - - InvokePlugins(p => p.OnClientDisconnected(client, reason, exception)); } - catch (Exception ex) + catch { - OnGeneralError?.Invoke(this, new ErrorEventArgs - { - ClientId = clientId, - Nickname = client.Nickname, - Exception = ex, - Message = "Error disconnecting client" - }); + // Do nothing } - }); + + _lastPingTimes.TryRemove(clientId, out _); + await SafeTaskCompletion(client.ReceiveTask).ConfigureAwait(false); + + try + { + if (client.Stream != null) + { + await client.Stream.FlushAsync().ConfigureAwait(false); + client.Stream.Dispose(); + } + client.TcpClient?.Dispose(); + client.AesEncryption?.Dispose(); + } + catch + { + // Do nothing + } + + client.SendLock?.Dispose(); + Volatile.Read(ref OnDisconnected)?.Invoke(this, new ConnectionEventArgs + { + ClientId = clientId, + Nickname = client.Nickname, + RemoteEndPoint = client.RemoteEndPoint, + Reason = ConnectionEventArgs.Determine(reason, exception), + Exception = exception + }); + } + catch (Exception ex) + { + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + ClientId = clientId, + Nickname = client.Nickname, + Exception = ex, + Message = "Error disconnecting client" + }); + } + } + + private async Task SafeDisconnectAsync(Connection client, DisconnectReason reason) + { + if (client != null) + { + try + { + await DisconnectClientAsync(client.Id, reason).ConfigureAwait(false); + } + catch + { + // Do nothing + } + } + } + + private async Task SafeTaskCompletion(Task task) + { + if (task == null) + { + return; + } + + try + { + await task.ConfigureAwait(false); + } + catch + { + // Do nothing + } + task = null; } public List GetClient(string clientId) { var result = new HashSet(); - if (Guid.TryParse(clientId, out _)) { if (_clients.TryGetValue(clientId, out var client)) @@ -734,13 +1168,9 @@ namespace EonaCat.Connections } string[] parts = clientId.Split(':'); - if (parts.Length == 2 && - IPAddress.TryParse(parts[0], out IPAddress ip) && - int.TryParse(parts[1], out int port)) + if (parts.Length == 2 && IPAddress.TryParse(parts[0], out IPAddress ip) && int.TryParse(parts[1], out int port)) { - var endPoint = new IPEndPoint(ip, port); - string clientKey = endPoint.ToString(); - + var endPoint = new IPEndPoint(ip, port); string clientKey = endPoint.ToString(); if (_clients.TryGetValue(clientKey, out var client)) { result.Add(client); @@ -749,35 +1179,100 @@ namespace EonaCat.Connections foreach (var kvp in _clients) { - if (kvp.Value.Nickname != null && - kvp.Value.Nickname.Equals(clientId, StringComparison.OrdinalIgnoreCase)) + if (kvp.Value.Nickname != null && kvp.Value.Nickname.Equals(clientId, StringComparison.OrdinalIgnoreCase)) { result.Add(kvp.Value); } } - return result.ToList(); } + private void ProcessPingPong(Connection client, ref string message) + { + if (!_config.EnableHeartbeat || string.IsNullOrEmpty(message)) + { + return; + } + + if (message.Contains(Configuration.PING_VALUE)) + { + if (_config.EnablePingPongLogs) + { + OnLog?.Invoke(this, $"[PING] Client {client.Id} PING received at {DateTime.UtcNow:O}, sending pong"); + } + + SendToClientAsync(client.Id, Configuration.PONG_VALUE).ConfigureAwait(false); + message = message.Replace(Configuration.PING_VALUE, string.Empty); + } + + if (message.Contains(Configuration.PONG_VALUE)) + { + if (_config.EnablePingPongLogs) + { + OnLog?.Invoke(this, $"[PING] Client {client.Id} PONG received for PING sent"); + } + + OnClientPingResponse?.Invoke(this, new PingEventArgs + { + Id = client.Id, + Nickname = client.Nickname, + ReceivedTime = DateTime.UtcNow, + RemoteEndPoint = client.RemoteEndPoint + }); + message = message.Replace(Configuration.PONG_VALUE, string.Empty); + } + } + public void Stop() { - _serverCancellation?.Cancel(); - _tcpListener?.Stop(); - _udpListener?.Close(); + try + { + _serverCancellation?.Cancel(); + } + catch + { + // Do nothing + } + + try + { + _pingCancellation?.Cancel(); + } + catch + { + // Do nothing + } - var disconnectTasks = _clients.Keys.ToArray() - .Select(id => DisconnectClientAsync(id, DisconnectReason.ServerShutdown)) - .ToList(); + try + { + _pongCancellation?.Cancel(); + } + catch + { + // Do nothing + } + _pingCancellation?.Dispose(); _pongCancellation?.Dispose(); _serverCancellation?.Dispose(); + _pingCancellation = null; _pongCancellation = null; _serverCancellation = null; + + var disconnectTasks = _clients.Keys.ToArray().Select(id => DisconnectClientAsync(id, DisconnectReason.ServerShutdown)).ToList(); Task.WaitAll(disconnectTasks.ToArray()); - - InvokePlugins(p => p.OnServerStopped(this)); + StopTcpServer(); StopUdpServer(); + _clients.Clear(); _lastPingTimes.Clear(); } public void Dispose() { Stop(); + OnConnected = null; + OnDisconnected = null; + OnDataReceived = null; + OnConnectedWithNickname = null; + OnSslError = null; + OnEncryptionError = null; + OnGeneralError = null; + OnClientPingResponse = null; _serverCancellation?.Dispose(); } } -} +} \ No newline at end of file diff --git a/EonaCat.Connections/Plugins/Client/ClientHttpMetricsPlugin.cs b/EonaCat.Connections/Plugins/Client/ClientHttpMetricsPlugin.cs deleted file mode 100644 index c2385c9..0000000 --- a/EonaCat.Connections/Plugins/Client/ClientHttpMetricsPlugin.cs +++ /dev/null @@ -1,112 +0,0 @@ -using EonaCat.Json; -using System.Net; - -namespace EonaCat.Connections.Plugins.Client -{ - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - - public class ClientHttpMetricsPlugin : IClientPlugin - { - public string Name => "ClientMetricsPlugin"; - - private NetworkClient _client; - private long _bytesSent; - private long _bytesReceived; - private long _messagesSent; - private long _messagesReceived; - - private readonly int _httpPort; - private HttpListener _httpListener; - private CancellationTokenSource _cts; - - public ClientHttpMetricsPlugin(int httpPort = 8080) - { - _httpPort = httpPort; - } - - public void OnClientStarted(NetworkClient client) - { - _client = client; - _cts = new CancellationTokenSource(); - StartHttpServer(_cts.Token); - } - - public void OnClientConnected(NetworkClient client) - { - Console.WriteLine($"[{Name}] Connected to server at {client.IpAddress}:{client.Port}"); - } - - public void OnClientDisconnected(NetworkClient client, DisconnectReason reason, Exception exception) - { - Console.WriteLine($"[{Name}] Disconnected: {reason} {exception?.Message}"); - } - - public void OnDataReceived(NetworkClient client, byte[] data, string stringData, bool isBinary) - { - _bytesReceived += data.Length; - _messagesReceived++; - } - - public void OnError(NetworkClient client, Exception exception, string message) - { - Console.WriteLine($"[{Name}] Error: {message} - {exception?.Message}"); - } - - public void OnClientStopped(NetworkClient client) - { - _cts.Cancel(); - _httpListener?.Stop(); - Console.WriteLine($"[{Name}] Plugin stopped."); - } - - public void IncrementSent(byte[] data) - { - _bytesSent += data.Length; - _messagesSent++; - } - - private void StartHttpServer(CancellationToken token) - { - _httpListener = new HttpListener(); - _httpListener.Prefixes.Add($"http://*:{_httpPort}/metrics/"); - _httpListener.Start(); - - Task.Run(async () => - { - while (!token.IsCancellationRequested) - { - try - { - var context = await _httpListener.GetContextAsync(); - var response = context.Response; - - var metrics = new - { - IsConnected = _client.IsConnected, - Ip = _client.IpAddress, - Port = _client.Port, - Uptime = _client.Uptime.TotalSeconds, - BytesSent = _bytesSent, - BytesReceived = _bytesReceived, - MessagesSent = _messagesSent, - MessagesReceived = _messagesReceived - }; - - var json = JsonHelper.ToJson(metrics, Formatting.Indented); - var buffer = System.Text.Encoding.UTF8.GetBytes(json); - - response.ContentType = "application/json"; - response.ContentLength64 = buffer.Length; - await response.OutputStream.WriteAsync(buffer, 0, buffer.Length, token); - response.Close(); - } - catch (Exception) - { - // ignore - } - } - }, token); - } - } -} diff --git a/EonaCat.Connections/Plugins/Server/HttpMetricsPlugin.cs b/EonaCat.Connections/Plugins/Server/HttpMetricsPlugin.cs deleted file mode 100644 index cc52176..0000000 --- a/EonaCat.Connections/Plugins/Server/HttpMetricsPlugin.cs +++ /dev/null @@ -1,106 +0,0 @@ -using EonaCat.Connections.Models; -using EonaCat.Json; -using System.Net; -using System.Text; - -namespace EonaCat.Connections.Plugins.Server -{ - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - - public class HttpMetricsPlugin : IServerPlugin - { - public string Name => "HttpMetricsPlugin"; - - private readonly int _port; - private HttpListener _httpListener; - private CancellationTokenSource _cts; - private NetworkServer _server; - - public HttpMetricsPlugin(int port = 9100) - { - _port = port; - } - - public void OnServerStarted(NetworkServer server) - { - _server = server; - _cts = new CancellationTokenSource(); - _httpListener = new HttpListener(); - _httpListener.Prefixes.Add($"http://*:{_port}/metrics/"); - - try - { - _httpListener.Start(); - Console.WriteLine($"[{Name}] Metrics endpoint running at http://localhost:{_port}/metrics/"); - } - catch (HttpListenerException ex) - { - Console.WriteLine($"[{Name}] Failed to start HTTP listener: {ex.Message}"); - return; - } - - Task.Run(async () => - { - while (!_cts.IsCancellationRequested) - { - try - { - var context = await _httpListener.GetContextAsync(); - - if (context.Request.Url.AbsolutePath == "/metrics") - { - var stats = _server.GetStats(); - - var responseObj = new - { - uptime = stats.Uptime.ToString(), - startTime = stats.StartTime, - activeConnections = stats.ActiveConnections, - totalConnections = stats.TotalConnections, - bytesSent = stats.BytesSent, - bytesReceived = stats.BytesReceived, - messagesSent = stats.MessagesSent, - messagesReceived = stats.MessagesReceived, - messagesPerSecond = stats.MessagesPerSecond - }; - - var json = JsonHelper.ToJson(responseObj, Formatting.Indented); - var buffer = Encoding.UTF8.GetBytes(json); - - context.Response.ContentType = "application/json"; - context.Response.StatusCode = 200; - await context.Response.OutputStream.WriteAsync(buffer, 0, buffer.Length); - context.Response.OutputStream.Close(); - } - else - { - context.Response.StatusCode = 404; - context.Response.Close(); - } - } - catch (ObjectDisposedException) { } - catch (HttpListenerException) { } - catch (Exception ex) - { - Console.WriteLine($"[{Name}] Error: {ex}"); - } - } - }, _cts.Token); - } - - public void OnServerStopped(NetworkServer server) - { - _cts?.Cancel(); - if (_httpListener != null && _httpListener.IsListening) - { - _httpListener.Stop(); - _httpListener.Close(); - } - } - - public void OnClientConnected(Connection client) { } - public void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception) { } - public void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary) { } - } -} diff --git a/EonaCat.Connections/Plugins/Server/IdleTimeoutPlugin.cs b/EonaCat.Connections/Plugins/Server/IdleTimeoutPlugin.cs deleted file mode 100644 index 4a899bf..0000000 --- a/EonaCat.Connections/Plugins/Server/IdleTimeoutPlugin.cs +++ /dev/null @@ -1,53 +0,0 @@ -using EonaCat.Connections.Models; - -namespace EonaCat.Connections.Plugins.Server -{ - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - - public class IdleTimeoutPlugin : IServerPlugin - { - public string Name => "IdleTimeoutPlugin"; - - private readonly TimeSpan _timeout; - private CancellationTokenSource _cts; - - public IdleTimeoutPlugin(TimeSpan timeout) - { - _timeout = timeout; - } - - public void OnServerStarted(NetworkServer server) - { - _cts = new CancellationTokenSource(); - - // Background task to check idle clients - Task.Run(async () => - { - while (!_cts.IsCancellationRequested) - { - foreach (var kvp in server.GetClients()) - { - var client = kvp.Value; - if (DateTime.UtcNow - client.LastActive > _timeout) - { - Console.WriteLine($"[{Name}] Disconnecting idle client {client.RemoteEndPoint}"); - _ = server.DisconnectClientAsync(client.Id, DisconnectReason.Timeout); - } - } - - await Task.Delay(5000, _cts.Token); // Check every 5s - } - }, _cts.Token); - } - - public void OnServerStopped(NetworkServer server) - { - _cts?.Cancel(); - } - - public void OnClientConnected(Connection client) { } - public void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception) { } - public void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary) { } - } -} diff --git a/EonaCat.Connections/Plugins/Server/MetricsPlugin.cs b/EonaCat.Connections/Plugins/Server/MetricsPlugin.cs deleted file mode 100644 index ae43d83..0000000 --- a/EonaCat.Connections/Plugins/Server/MetricsPlugin.cs +++ /dev/null @@ -1,65 +0,0 @@ -using EonaCat.Connections.Models; - -namespace EonaCat.Connections.Plugins.Server -{ - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - - public class MetricsPlugin : IServerPlugin - { - public string Name => "MetricsPlugin"; - - private readonly TimeSpan _interval; - private CancellationTokenSource _cts; - private NetworkServer _server; - - public MetricsPlugin(TimeSpan interval) - { - _interval = interval; - } - - public void OnServerStarted(NetworkServer server) - { - _server = server; - _cts = new CancellationTokenSource(); - - Task.Run(async () => - { - while (!_cts.IsCancellationRequested) - { - try - { - var stats = server.GetStats(); - - Console.WriteLine( - $"[{Name}] Uptime: {stats.Uptime:g} | " + - $"Active: {stats.ActiveConnections} | " + - $"Total: {stats.TotalConnections} | " + - $"Msgs In: {stats.MessagesReceived} | " + - $"Msgs Out: {stats.MessagesSent} | " + - $"Bytes In: {stats.BytesReceived} | " + - $"Bytes Out: {stats.BytesSent} | " + - $"Msg/s: {stats.MessagesPerSecond:F2}" - ); - - await Task.Delay(_interval, _cts.Token); - } - catch (TaskCanceledException) { } - catch (Exception ex) - { - Console.WriteLine($"[{Name}] Error logging metrics: {ex}"); - } - } - }, _cts.Token); - } - - public void OnServerStopped(NetworkServer server) - { - _cts?.Cancel(); - } - - public void OnClientConnected(Connection client) { } - public void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception) { } - public void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary) { } - } -} diff --git a/EonaCat.Connections/Plugins/Server/RateLimiterPlugin.cs b/EonaCat.Connections/Plugins/Server/RateLimiterPlugin.cs deleted file mode 100644 index 4ddcac8..0000000 --- a/EonaCat.Connections/Plugins/Server/RateLimiterPlugin.cs +++ /dev/null @@ -1,57 +0,0 @@ -using EonaCat.Connections.Models; -using System.Collections.Concurrent; - -namespace EonaCat.Connections.Plugins.Server -{ - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - - public class RateLimiterPlugin : IServerPlugin - { - public string Name => "RateLimiterPlugin"; - - private readonly int _maxMessages; - private readonly TimeSpan _interval; - private readonly ConcurrentDictionary> _messageTimestamps; - - public RateLimiterPlugin(int maxMessages, TimeSpan interval) - { - _maxMessages = maxMessages; - _interval = interval; - _messageTimestamps = new ConcurrentDictionary>(); - } - - public void OnServerStarted(NetworkServer server) { } - public void OnServerStopped(NetworkServer server) { } - - public void OnClientConnected(Connection client) - { - _messageTimestamps[client.Id] = new ConcurrentQueue(); - } - - public void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception) - { - _messageTimestamps.TryRemove(client.Id, out _); - } - - public void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary) - { - if (!_messageTimestamps.TryGetValue(client.Id, out var queue)) return; - - var now = DateTime.UtcNow; - queue.Enqueue(now); - - // Remove old timestamps - while (queue.TryPeek(out var oldest) && now - oldest > _interval) - queue.TryDequeue(out _); - - if (queue.Count > _maxMessages) - { - Console.WriteLine($"[{Name}] Client {client.RemoteEndPoint} exceeded rate limit. Disconnecting..."); - - // Force disconnect - client.TcpClient?.Close(); - } - } - } -} diff --git a/EonaCat.Connections/Processors/JsonDataProcessor.cs b/EonaCat.Connections/Processors/JsonDataProcessor.cs index f9b5286..4e0b09b 100644 --- a/EonaCat.Connections/Processors/JsonDataProcessor.cs +++ b/EonaCat.Connections/Processors/JsonDataProcessor.cs @@ -1,174 +1,243 @@ using EonaCat.Json; using EonaCat.Json.Linq; +using EonaCat.Connections.Models; using System.Collections.Concurrent; using System.Text; using System.Timers; -using Timer = System.Timers.Timer; namespace EonaCat.Connections.Processors { - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - - /// - /// Processes incoming data streams into JSON or text messages per client buffer. - /// - public class JsonDataProcessor : IDisposable + public sealed class JsonDataProcessor : IDisposable { - private const int DefaultMaxBufferSize = 20 * 1024 * 1024; // 20 MB - private const int DefaultMaxMessagesPerBatch = 200; - private static readonly TimeSpan DefaultClientBufferTimeout = TimeSpan.FromMinutes(5); + public int MaxAllowedBufferSize = 20 * 1024 * 1024; + public int MaxMessagesPerBatch = 200; + private const int MaxCleanupRemovalsPerTick = 50; - private readonly ConcurrentDictionary _buffers = new ConcurrentDictionary(); - private readonly Timer _cleanupTimer; + private readonly ConcurrentDictionary _buffers = new(); + private readonly System.Timers.Timer _cleanupTimer; + private readonly TimeSpan _clientBufferTimeout = TimeSpan.FromMinutes(5); private bool _isDisposed; - /// - /// Maximum allowed buffer size in bytes (default: 20 MB). - /// - public int MaxAllowedBufferSize { get; set; } = DefaultMaxBufferSize; + public string ClientName { get; } - /// - /// Maximum number of messages processed per batch (default: 200). - /// - public int MaxMessagesPerBatch { get; set; } = DefaultMaxMessagesPerBatch; - - /// - /// Default client name when one is not provided in . - /// - public string ClientName { get; set; } = Guid.NewGuid().ToString(); - - public Action ProcessMessage { get; set; } - public Action ProcessTextMessage { get; set; } - - public event EventHandler OnMessageError; - public event EventHandler OnError; - - private class BufferEntry + private sealed class BufferEntry { - public readonly StringBuilder Buffer = new StringBuilder(); + public readonly StringBuilder Buffer = new(); public DateTime LastUsed = DateTime.UtcNow; - public readonly object SyncRoot = new object(); + public readonly object SyncRoot = new(); + + public void Clear(bool shrink = false) + { + Buffer.Clear(); + if (shrink && Buffer.Capacity > 1024) + { + Buffer.Capacity = 1024; + } + } } + public event EventHandler>? OnProcessMessage; + + public event EventHandler? OnProcessTextMessage; + + public event EventHandler? OnMessageError; + + public event EventHandler? OnError; + public JsonDataProcessor() { - _cleanupTimer = new Timer(DefaultClientBufferTimeout.TotalMilliseconds / 5); - _cleanupTimer.AutoReset = true; + ClientName = Guid.NewGuid().ToString(); + + _cleanupTimer = new System.Timers.Timer(Math.Max(5000, _clientBufferTimeout.TotalMilliseconds / 5)) + { + AutoReset = true + }; _cleanupTimer.Elapsed += CleanupInactiveClients; _cleanupTimer.Start(); } - /// - /// Process incoming raw data. - /// - public void Process(DataReceivedEventArgs e) + public void Process(DataReceivedEventArgs e, string? currentClientName = null) { - EnsureNotDisposed(); - - if (e.IsBinary) - { - e.StringData = Encoding.UTF8.GetString(e.Data); - } - - if (string.IsNullOrWhiteSpace(e.StringData)) - { - OnError?.Invoke(this, new Exception("Received empty data.")); - return; - } - - string clientName = string.IsNullOrWhiteSpace(e.Nickname) ? ClientName : e.Nickname; - string incomingText = e.StringData.Trim(); - if (incomingText.Length == 0) + ThrowIfDisposed(); + if (e == null) { return; } + string endpoint = e.RemoteEndPoint?.ToString(); + string dataString = e.IsBinary ? Encoding.UTF8.GetString(e.Data ?? Array.Empty()) : e.StringData; + if (string.IsNullOrWhiteSpace(dataString)) + { + return; + } + + string client = e.Nickname ?? currentClientName ?? ClientName; + ProcessInternal(dataString.Trim(), client, endpoint); + } + + public void Process(string jsonString, string? currentClientName = null, string? endpoint = null) + { + ThrowIfDisposed(); + if (string.IsNullOrWhiteSpace(jsonString)) + { + return; + } + + string client = currentClientName ?? ClientName; + ProcessInternal(jsonString.Trim(), client, endpoint); + } + + private void ProcessInternal(string jsonString, string clientName, string? clientEndpoint) + { var bufferEntry = _buffers.GetOrAdd(clientName, _ => new BufferEntry()); + var pendingJson = new List(); + var pendingText = new List(); lock (bufferEntry.SyncRoot) { + // Check for buffer overflow if (bufferEntry.Buffer.Length > MaxAllowedBufferSize) { - bufferEntry.Buffer.Clear(); + OnError?.Invoke(this, new Exception($"Buffer overflow ({MaxAllowedBufferSize} bytes) for client {clientName} ({clientEndpoint}).")); + bufferEntry.Clear(shrink: true); } - bufferEntry.Buffer.Append(incomingText); + bufferEntry.Buffer.Append(jsonString); bufferEntry.LastUsed = DateTime.UtcNow; int processedCount = 0; while (processedCount < MaxMessagesPerBatch && - ExtractNextJson(bufferEntry.Buffer, out var jsonChunk)) + JsonDataProcessorHelper.TryExtractCompleteJson(bufferEntry.Buffer, out string[] json, out string[] nonJsonText)) { - ProcessDataReceived(jsonChunk, clientName); - processedCount++; + // No more messages + if ((json == null || json.Length == 0) && (nonJsonText == null || nonJsonText.Length == 0)) + { + break; + } + + if (json != null && json.Length > 0) + { + foreach (var jsonMessage in json) + { + pendingJson.Add(jsonMessage); + processedCount++; + if (processedCount >= MaxMessagesPerBatch) + { + break; + } + } + } + + if (nonJsonText != null && nonJsonText.Length > 0) + { + foreach (var textMessage in nonJsonText) + { + if (!string.IsNullOrWhiteSpace(textMessage)) + { + pendingText.Add(textMessage); + } + } + } + } + + // Cleanup buffer if needed + if (bufferEntry.Buffer.Capacity > MaxAllowedBufferSize / 2) + { + bufferEntry.Clear(shrink: true); } - // Handle leftover non-JSON text if (bufferEntry.Buffer.Length > 0 && !ContainsJsonStructure(bufferEntry.Buffer)) { - var leftover = bufferEntry.Buffer.ToString(); - bufferEntry.Buffer.Clear(); - ProcessTextMessage?.Invoke(leftover, clientName); + string leftover = bufferEntry.Buffer.ToString(); + bufferEntry.Clear(shrink: true); + if (!string.IsNullOrWhiteSpace(leftover)) + { + pendingText.Add(leftover); + } + } + } + + if (pendingText.Count > 0) + { + foreach (var textMessage in pendingText) + { + try + { + OnProcessTextMessage?.Invoke(this, new ProcessedTextMessage { Text = textMessage, ClientName = clientName, ClientEndpoint = clientEndpoint }); + } + catch (Exception exception) + { + OnError?.Invoke(this, new Exception($"ProcessTextMessage handler threw for client {clientName} ({clientEndpoint}).", exception)); + } + } + } + + if (pendingJson.Count > 0) + { + foreach (var jsonMessage in pendingJson) + { + ProcessDataReceived(jsonMessage, clientName, clientEndpoint); } } } - private void ProcessDataReceived(string data, string clientName) + private void ProcessDataReceived(string data, string clientName, string? clientEndpoint) { - EnsureNotDisposed(); - + ThrowIfDisposed(); if (string.IsNullOrWhiteSpace(data)) { return; } - if (string.IsNullOrWhiteSpace(clientName)) - { - clientName = ClientName; - } - bool looksLikeJson = data.Length > 1 && - ((data[0] == '{' && data[data.Length - 1] == '}') || - (data[0] == '[' && data[data.Length - 1] == ']') || - data[0] == '"' || // string - char.IsDigit(data[0]) || data[0] == '-' || // numbers - data.StartsWith("true") || - data.StartsWith("false") || - data.StartsWith("null")); + ((data[0] == '{' && data[data.Length - 1] == '}') || + (data[0] == '[' && data[data.Length - 1] == ']')); if (!looksLikeJson) { - ProcessTextMessage?.Invoke(data, clientName); + try + { + OnProcessTextMessage?.Invoke(this, new ProcessedTextMessage { Text = data, ClientName = clientName, ClientEndpoint = clientEndpoint }); + } + catch (Exception exception) + { + OnError?.Invoke(this, new Exception($"ProcessTextMessage handler threw for client {clientName} ({clientEndpoint}).", exception)); + } return; } try { - // Try to detect JSON-encoded exceptions - if (data.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0 || - data.IndexOf("Error", StringComparison.OrdinalIgnoreCase) >= 0) + if (data.Contains("Exception", StringComparison.OrdinalIgnoreCase) || + data.Contains("Error", StringComparison.OrdinalIgnoreCase)) { - TryHandleJsonException(data); + GetError(data); } - var messages = JsonHelper.ToObjects(data); - if (messages != null && ProcessMessage != null) + var messages = JsonHelper.ToObjects(data); + if (messages != null) { foreach (var message in messages) { - ProcessMessage(message, clientName, data); + try + { + OnProcessMessage?.Invoke(this, new ProcessedMessage { Data = message, RawData = data, ClientName = clientName, ClientEndpoint = clientEndpoint }); + } + catch (Exception exception) + { + OnError?.Invoke(this, new Exception($"ProcessMessage handler threw for client {clientName} ({clientEndpoint}).", exception)); + } } } } - catch (Exception ex) + catch (Exception exception) { - OnError?.Invoke(this, new Exception("Failed to process JSON message.", ex)); + OnError?.Invoke(this, new Exception($"Failed to process JSON message from {clientName} ({clientEndpoint}).", exception)); } } - private void TryHandleJsonException(string data) + private void GetError(string data) { try { @@ -176,142 +245,25 @@ namespace EonaCat.Connections.Processors var exceptionToken = jsonObject.SelectToken("Exception"); if (exceptionToken != null && exceptionToken.Type != JTokenType.Null) { - var exception = JsonHelper.ExtractException(data); - if (exception != null && OnMessageError != null) + var extracted = JsonHelper.ExtractException(data); + if (extracted != null) { - OnMessageError(this, new Exception(exception.Message)); + OnMessageError?.Invoke(this, new Exception(extracted.Message)); } } } catch { - // Ignore malformed exception JSON + // Do nothing } } - private static bool ExtractNextJson(StringBuilder buffer, out string json) - { - json = null; - if (buffer.Length == 0) - { - return false; - } - - int depth = 0; - bool inString = false, escape = false; - int startIndex = -1; - - for (int i = 0; i < buffer.Length; i++) - { - char c = buffer[i]; - - if (inString) - { - if (escape) - { - escape = false; - } - else if (c == '\\') - { - escape = true; - } - else if (c == '"') - { - inString = false; - } - } - else - { - switch (c) - { - case '"': - inString = true; - if (depth == 0 && startIndex == -1) - { - startIndex = i; // string-only JSON - } - - break; - - case '{': - case '[': - if (depth == 0) - { - startIndex = i; - } - - depth++; - break; - - case '}': - case ']': - depth--; - if (depth == 0 && startIndex != -1) - { - int length = i - startIndex + 1; - json = buffer.ToString(startIndex, length); - buffer.Remove(0, i + 1); - return true; - } - break; - - default: - if (depth == 0 && startIndex == -1 && - (char.IsDigit(c) || c == '-' || c == 't' || c == 'f' || c == 'n')) - { - startIndex = i; - int tokenEnd = FindPrimitiveEnd(buffer, i); - json = buffer.ToString(startIndex, tokenEnd - startIndex); - buffer.Remove(0, tokenEnd); - return true; - } - break; - } - } - } - - return false; - } - - private static int FindPrimitiveEnd(StringBuilder buffer, int startIndex) - { - // Keywords: true/false/null - if (buffer.Length >= startIndex + 4 && buffer.ToString(startIndex, 4) == "true") - { - return startIndex + 4; - } - - if (buffer.Length >= startIndex + 5 && buffer.ToString(startIndex, 5) == "false") - { - return startIndex + 5; - } - - if (buffer.Length >= startIndex + 4 && buffer.ToString(startIndex, 4) == "null") - { - return startIndex + 4; - } - - // Numbers: scan until non-number/decimal/exponent - int i = startIndex; - while (i < buffer.Length) - { - char c = buffer[i]; - if (!(char.IsDigit(c) || c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E')) - { - break; - } - - i++; - } - return i; - } - private static bool ContainsJsonStructure(StringBuilder buffer) { for (int i = 0; i < buffer.Length; i++) { char c = buffer[i]; - if (c == '{' || c == '[' || c == '"' || c == 't' || c == 'f' || c == 'n' || c == '-' || char.IsDigit(c)) + if (c == '{' || c == '[') { return true; } @@ -319,22 +271,36 @@ namespace EonaCat.Connections.Processors return false; } - private void CleanupInactiveClients(object sender, ElapsedEventArgs e) + private void CleanupInactiveClients(object? sender, ElapsedEventArgs e) { - var now = DateTime.UtcNow; + if (_isDisposed) + { + return; + } + + DateTime now = DateTime.UtcNow; + var keysToRemove = new List(capacity: 128); foreach (var kvp in _buffers) { - var bufferEntry = kvp.Value; - if (now - bufferEntry.LastUsed > DefaultClientBufferTimeout) + if (now - kvp.Value.LastUsed > _clientBufferTimeout) { - BufferEntry removed; - if (_buffers.TryRemove(kvp.Key, out removed)) + keysToRemove.Add(kvp.Key); + if (keysToRemove.Count >= MaxCleanupRemovalsPerTick) { - lock (removed.SyncRoot) - { - removed.Buffer.Clear(); - } + break; + } + } + } + + // Remove the selected keys + foreach (var key in keysToRemove) + { + if (_buffers.TryRemove(key, out var removed)) + { + lock (removed.SyncRoot) + { + removed.Clear(shrink: true); } } } @@ -347,21 +313,20 @@ namespace EonaCat.Connections.Processors return; } - BufferEntry removed; - if (_buffers.TryRemove(clientName, out removed)) + if (_buffers.TryRemove(clientName, out var removed)) { lock (removed.SyncRoot) { - removed.Buffer.Clear(); + removed.Clear(shrink: true); } } } - private void EnsureNotDisposed() + private void ThrowIfDisposed() { if (_isDisposed) { - throw new ObjectDisposedException(nameof(JsonDataProcessor)); + throw new ObjectDisposedException(nameof(JsonDataProcessor)); } } @@ -372,30 +337,33 @@ namespace EonaCat.Connections.Processors return; } - try - { - _cleanupTimer.Stop(); - _cleanupTimer.Elapsed -= CleanupInactiveClients; - _cleanupTimer.Dispose(); + _isDisposed = true; - foreach (var bufferEntry in _buffers.Values) + _cleanupTimer.Elapsed -= CleanupInactiveClients; + _cleanupTimer.Stop(); + _cleanupTimer.Dispose(); + + foreach (var entry in _buffers.Values) + { + lock (entry.SyncRoot) { - lock (bufferEntry.SyncRoot) - { - bufferEntry.Buffer.Clear(); - } + entry.Clear(shrink: true); } - _buffers.Clear(); + } - ProcessMessage = null; - ProcessTextMessage = null; - OnMessageError = null; - OnError = null; - } - finally - { - _isDisposed = true; - } + _buffers.Clear(); + OnProcessMessage = null; + OnProcessTextMessage = null; + OnMessageError = null; + OnError = null; + + GC.SuppressFinalize(this); } } -} + + internal static class StringExtensions + { + internal static bool Contains(this string? source, string toCheck, StringComparison comp) => + source?.IndexOf(toCheck, comp) >= 0; + } +} \ No newline at end of file diff --git a/EonaCat.Connections/Processors/JsonDataProcessorHelper.cs b/EonaCat.Connections/Processors/JsonDataProcessorHelper.cs new file mode 100644 index 0000000..bf8447d --- /dev/null +++ b/EonaCat.Connections/Processors/JsonDataProcessorHelper.cs @@ -0,0 +1,266 @@ +using System.Text; + +namespace EonaCat.Connections.Processors +{ + internal static class JsonDataProcessorHelper + { + public const int MAX_CAP = 1024; + private const int MAX_SEGMENTS_PER_CALL = 1024; + + internal static bool TryExtractCompleteJson(StringBuilder buffer, out string[] jsonArray, out string[] nonJsonText) + { + jsonArray = Array.Empty(); + nonJsonText = Array.Empty(); + + if (buffer is null || buffer.Length == 0) + { + return false; + } + + var jsonList = new List(capacity: 4); + var nonJsonList = new List(capacity: 2); + + int readPos = 0; + int segmentsProcessed = 0; + int bufferLen = buffer.Length; + + while (readPos < bufferLen && segmentsProcessed < MAX_SEGMENTS_PER_CALL) + { + segmentsProcessed++; + + // Skip non-JSON starting characters + if (buffer[readPos] != '{' && buffer[readPos] != '[') + { + int start = readPos; + while (readPos < bufferLen && buffer[readPos] != '{' && buffer[readPos] != '[') + { + readPos++; + } + + int len = readPos - start; + if (len > 0) + { + var segment = buffer.ToString(start, len).Trim(); + if (segment.Length > 0) + { + nonJsonList.Add(segment); + } + continue; + } + } + + // Check if we have reached the end + if (readPos >= bufferLen) + { + break; + } + + int pos = readPos; + int depth = 0; + bool inString = false; + bool escape = false; + int startIndex = pos; + bool complete = false; + + for (; pos < bufferLen; pos++) + { + char c = buffer[pos]; + + if (inString) + { + if (escape) + { + escape = false; + } + else if (c == '\\') + { + escape = true; + } + else if (c == '"') + { + inString = false; + } + } + else + { + switch (c) + { + case '"': + inString = true; + break; + + case '{': + case '[': + depth++; + break; + + case '}': + case ']': + depth--; + if (depth == 0) + { + // Completed JSON segment + pos++; + complete = true; + } + break; + } + } + + if (complete) + { + break; + } + } + + if (complete) + { + int length = pos - startIndex; + if (length <= 0) + { + // Should not happen, but just in case + break; + } + + // Extract candidate JSON segment + string candidateJson = buffer.ToString(startIndex, length); + + // Clean internal non-JSON characters + var cleaned = StripInternalNonJson(candidateJson, out var extractedInside); + if (extractedInside != null && extractedInside.Length > 0) + { + nonJsonList.AddRange(extractedInside); + } + + if (!string.IsNullOrWhiteSpace(cleaned)) + { + jsonList.Add(cleaned); + } + + // Move readPos forward + readPos = pos; + } + else + { + // Incomplete JSON segment; stop processing + break; + } + } + + // Remove processed part from buffer + if (readPos > 0) + { + buffer.Remove(0, readPos); + } + + // Cleanup buffer capacity if needed + if (buffer.Capacity > MAX_CAP && buffer.Length < 256) + { + buffer.Capacity = Math.Max(MAX_CAP, buffer.Length); + } + + jsonArray = jsonList.Count > 0 ? jsonList.ToArray() : Array.Empty(); + nonJsonText = nonJsonList.Count > 0 ? nonJsonList.ToArray() : Array.Empty(); + + return jsonArray.Length > 0; + } + + private static string StripInternalNonJson(string input, out string[] extractedTexts) + { + if (string.IsNullOrEmpty(input)) + { + extractedTexts = Array.Empty(); + return string.Empty; + } + + // Scan through the input, copying valid JSON parts to output, + List? extractedChars = null; + var sbJson = new StringBuilder(input.Length); + bool inString = false; + bool escape = false; + int depth = 0; + + for (int i = 0; i < input.Length; i++) + { + char c = input[i]; + + if (inString) + { + sbJson.Append(c); + if (escape) + { + escape = false; + } + else if (c == '\\') + { + escape = true; + } + else if (c == '"') + { + inString = false; + } + } + else + { + switch (c) + { + case '"': + inString = true; + sbJson.Append(c); + break; + + case '{': + case '[': + depth++; + sbJson.Append(c); + break; + + case '}': + case ']': + depth--; + sbJson.Append(c); + break; + + default: + // Outside JSON structures, only allow certain characters + if (depth > 0 || char.IsLetterOrDigit(c) || char.IsWhiteSpace(c) || ",:.-_".IndexOf(c) >= 0) + { + sbJson.Append(c); + } + else + { + extractedChars ??= new List(capacity: 4); + extractedChars.Add(c); + } + break; + } + } + } + + if (extractedChars != null && extractedChars.Count > 0) + { + // Convert char list to string array + var current = new string[extractedChars.Count]; + for (int i = 0; i < extractedChars.Count; i++) + { + current[i] = extractedChars[i].ToString(); + } + extractedTexts = current; + } + else + { + extractedTexts = Array.Empty(); + } + + string result = sbJson.ToString().Trim(); + + // Recompute capacity if needed + if (sbJson.Capacity > MAX_CAP) + { + sbJson.Capacity = Math.Max(MAX_CAP, sbJson.Length); + } + + return result; + } + } +} \ No newline at end of file diff --git a/EonaCat.Connections/ProtocolType.cs b/EonaCat.Connections/ProtocolType.cs deleted file mode 100644 index 217305b..0000000 --- a/EonaCat.Connections/ProtocolType.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace EonaCat.Connections -{ - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - - public enum ProtocolType - { - TCP, - UDP - } -} \ No newline at end of file