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 0000000..406f265 Binary files /dev/null and b/EonaCat.Connections/EonaCat.ico differ diff --git a/EonaCat.Connections/EonaCat.png b/EonaCat.Connections/EonaCat.png new file mode 100644 index 0000000..0595b89 Binary files /dev/null and b/EonaCat.Connections/EonaCat.png differ 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